From 69b6a20350a463356d0b6d6227b9575ec24e4d29 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 04:59:17 +0800 Subject: [PATCH] test(05-01): scaffold routing workspace with failing contract suite - fifth npm workspace @araharah/routing (buildless TS, search/ precedent) - root delegates routing:test / routing:typecheck / routing:corpus - C1-C6 RED: contract gates fail while src/types.ts + seed are absent --- package-lock.json | 13 ++- package.json | 6 +- routing/package.json | 12 +++ routing/test/contract.test.mjs | 163 +++++++++++++++++++++++++++++++++ routing/test/harness.mjs | 10 ++ routing/test/runner.mjs | 48 ++++++++++ routing/tsconfig.json | 17 ++++ 7 files changed, 267 insertions(+), 2 deletions(-) create mode 100644 routing/package.json create mode 100644 routing/test/contract.test.mjs create mode 100644 routing/test/harness.mjs create mode 100644 routing/test/runner.mjs create mode 100644 routing/tsconfig.json diff --git a/package-lock.json b/package-lock.json index d8eeca6..cb3baa8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "theme", "bot", "web", - "search" + "search", + "routing" ] }, "bot": { @@ -29,6 +30,10 @@ "resolved": "bot", "link": true }, + "node_modules/@araharah/routing": { + "resolved": "routing", + "link": true + }, "node_modules/@araharah/search": { "resolved": "search", "link": true @@ -3950,6 +3955,12 @@ "error-stack-parser-es": "^1.0.5" } }, + "routing": { + "name": "@araharah/routing", + "devDependencies": { + "typescript": "^5.9.3" + } + }, "search": { "name": "@araharah/search", "devDependencies": { diff --git a/package.json b/package.json index ca3d69d..ff20f20 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "theme", "bot", "web", - "search" + "search", + "routing" ], "scripts": { "build": "npm run build -w theme && npm run build -w web", @@ -26,6 +27,9 @@ "search:test": "npm test -w search", "search:typecheck": "npm run typecheck -w search", "search:corpus": "node search/scripts/gazetteer-corpus.mjs", + "routing:test": "npm test -w routing", + "routing:typecheck": "npm run typecheck -w routing", + "routing:corpus": "node routing/scripts/route-corpus.mjs", "web:dev": "npm run dev -w web", "web:build": "npm run build -w theme && npm run build -w web", "web:build:harness": "npm run build:harness -w web", diff --git a/routing/package.json b/routing/package.json new file mode 100644 index 0000000..561ba0a --- /dev/null +++ b/routing/package.json @@ -0,0 +1,12 @@ +{ + "name": "@araharah/routing", + "private": true, + "type": "module", + "scripts": { + "test": "node test/runner.mjs", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/routing/test/contract.test.mjs b/routing/test/contract.test.mjs new file mode 100644 index 0000000..6fc8062 --- /dev/null +++ b/routing/test/contract.test.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +/* + * contract.test.mjs — the RoutingSource contract gates (C1–C6). + * ------------------------------------------------------------- + * RED first (plan 05-01 task 1): every import-bearing case fails while + * routing/src/types.ts does not exist — the lazy-import pattern (the + * bot/test/initdata.test.mjs precedent) keeps each case reporting its + * own failure instead of losing the suite load. + * + * C1 interface shape — the seam module loads; a stub + * { name, route } behaves as the contract + * C2 day-one model fields — cumulativeDistanceM + steps: RouteStep[] + * + the GUID-05/06 obligation, grep-pinned + * by test, not by hope (D-07/D-11) + * C3 null contract — the RoutingSource doc comment names the + * "router pending" semantics (D-03) + * C4 no logging/storage/network — ZERO banned tokens in routing/src + * code (comments stripped first — the + * web/test/no-storage.test.mjs pattern) + * C5 zero runtime deps — no "dependencies" in routing/package.json + * (D-05: nothing in the Worker/browser path) + * C6 ODbL header present — the committed seed carries license + + * attribution + notice (T-05-06) + * + * Run: npm run routing:test (exit 0 = all green) + */ +import { strict as assert } from "node:assert"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "./harness.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); // routing/test +const routingDir = path.resolve(here, ".."); // routing/ +const srcDir = path.join(routingDir, "src"); // routing/src + +// Lazy types load — missing module => per-case ERR_MODULE_NOT_FOUND (the RED state). +let typesPromise = null; +function loadTypes() { + if (!typesPromise) { + typesPromise = import("../src/types.ts"); + } + return typesPromise; +} + +/* path-scrape + comment strip (the no-storage gate's contract) */ + +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, " ") // block comments + .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (benign over-stripping) +} + +const BANNED = [ + "console.", + "localStorage", + "sessionStorage", + "indexedDB", + "document.cookie", + ".put(", + "fetch(", + "XMLHttpRequest", + "process.env", +]; + +/* ————— C1 — the seam exists and a stub behaves as the contract ————— */ + +test("C1 interface shape — the seam module loads; a stub {name, route} behaves as the contract", async () => { + await loadTypes(); // types.ts parses under Node 24 type-stripping + // Structural: a minimal backend satisfying RoutingSource (D-01) — + // route() is a function and resolves a Promise (null = router pending). + const stub = { + name: "x", + route: async () => null, + }; + assert.equal(typeof stub.route, "function", "route() must be a function"); + const pending = stub.route({ origin: { lat: 0, lng: 0 }, destinationId: "x" }); + assert.ok(pending instanceof Promise, "route() must return a Promise"); + assert.equal(await pending, null, "the honest pending state resolves null"); +}); + +/* ————— C2 — the day-one route model fields are grep-pinned ————— */ + +test("C2 day-one model fields — D-07/D-11 type gate pins cumulativeDistanceM + steps + GUID-05/06", () => { + const text = readFileSync(path.join(srcDir, "types.ts"), "utf8"); + assert.ok( + text.includes("cumulativeDistanceM"), + "RouteGeometry.cumulativeDistanceM (snapped-progress support) is missing — D-07 violated", + ); + assert.ok( + text.includes("steps: RouteStep[]"), + "RouteOption.steps (day-one step geometry) is missing — D-07 violated", + ); + assert.ok( + text.includes("GUID-05/06 upstream obligation"), + "the GUID-05/06 upstream-obligation note is missing from the day-one fields", + ); +}); + +/* ————— C3 — the null contract is contract documentation ————— */ + +test("C3 null contract — the RoutingSource doc comment names the router-pending semantics (D-03)", () => { + const text = readFileSync(path.join(srcDir, "types.ts"), "utf8"); + const at = text.indexOf("export interface RoutingSource"); + assert.ok(at > 0, "export interface RoutingSource is missing"); + const before = text.slice(Math.max(0, at - 2000), at); + const doc = before.match(/\/\*\*[\s\S]*$/); // the last doc block before the interface + assert.ok(doc, "RoutingSource must carry a doc comment"); + assert.ok( + doc[0].includes("router pending"), + "the D-03 null contract ('router pending') must be documented on the interface", + ); +}); + +/* ————— C4 — the three zero-laws, mechanically ————— */ + +test("C4 no-logging/no-storage/no-network — zero banned tokens in routing/src code (comments stripped)", () => { + const files = readdirSync(srcDir).filter((f) => f.endsWith(".ts")); + assert.ok(files.length >= 1, "scanned a real src tree"); + for (const file of files) { + const code = stripComments(readFileSync(path.join(srcDir, file), "utf8")); + for (const banned of BANNED) { + assert.ok( + !code.includes(banned), + `${file} references ${banned} — the D-05/D-12/D-15 zero-laws are violated`, + ); + } + } +}); + +/* ————— C5 — zero runtime dependencies ————— */ + +test("C5 zero runtime dependencies — routing/package.json has no dependencies field (D-05)", () => { + const pkg = JSON.parse(readFileSync(path.join(routingDir, "package.json"), "utf8")); + assert.equal(pkg.name, "@araharah/routing"); + assert.equal(pkg.type, "module"); + assert.equal( + pkg.dependencies, + undefined, + "a runtime dependency would ride the Worker/browser bundle path — D-05 violated", + ); +}); + +/* ————— C6 — the committed seed's ODbL header (T-05-06) ————— */ + +test("C6 ODbL header present — the committed seed carries license + attribution + notice", () => { + const seed = JSON.parse( + readFileSync(path.join(srcDir, "data", "route-fixtures.json"), "utf8"), + ); + assert.equal(seed._license, "ODbL-1.0", "the ODbL license field is missing"); + assert.ok( + String(seed._attribution).includes("OpenStreetMap"), + "the attribution must credit OpenStreetMap contributors", + ); + assert.ok(String(seed._notice).includes("ODbL"), "the notice must name ODbL"); + assert.equal(seed.version, 1); + assert.ok(Array.isArray(seed.pairs), "pairs must be an array"); + assert.equal( + seed.pairs.length, + 0, + "the seed ships EMPTY — plan 05-02's build-time script populates it", + ); +}); diff --git a/routing/test/harness.mjs b/routing/test/harness.mjs new file mode 100644 index 0000000..ac20a5b --- /dev/null +++ b/routing/test/harness.mjs @@ -0,0 +1,10 @@ +/* + * harness.mjs — zero-dependency test registry. + * Same hand-rolled shape as bot/test/harness.mjs (the Phase 2 precedent): + * suites import { test } and register async cases; runner.mjs drains the registry. + */ +const tests = []; +export function test(name, fn) { + tests.push({ name, fn }); +} +export const registry = tests; diff --git a/routing/test/runner.mjs b/routing/test/runner.mjs new file mode 100644 index 0000000..eb6ba70 --- /dev/null +++ b/routing/test/runner.mjs @@ -0,0 +1,48 @@ +#!/usr/bin/env node +/* + * runner.mjs — hand-rolled zero-dependency test runner. + * + * Same shape as bot/test/runner.mjs (the Phase 2 precedent): + * - Auto-discovers *.test.mjs in this directory (sorted) and dynamic-imports them. + * - A suite that fails to LOAD is recorded as one failing case + * ("suite load failed: ") — the run never crashes mid-way. + * - Per case: PASS / FAIL (+ error detail). + * - Summary line + exit codes: exit 0 only when every case passed. + */ +import { readdirSync } from "node:fs"; +import { registry } from "./harness.mjs"; + +const dir = import.meta.dirname; +const suites = readdirSync(dir) + .filter((f) => f.endsWith(".test.mjs")) + .sort(); + +for (const file of suites) { + try { + await import(`./${file}`); + } catch (loadErr) { + registry.push({ + name: `suite load failed: ${file}`, + fn: () => { + throw loadErr; + }, + }); + } +} + +let passedCount = 0; +let failedCount = 0; +for (const { name, fn } of registry) { + try { + await fn(); + passedCount++; + console.log(`PASS ${name}`); + } catch (err) { + failedCount++; + console.log(`FAIL ${name}`); + console.error(` ${err && err.stack ? err.stack : String(err)}`); + } +} + +console.log(`[runner] ${passedCount} passed, ${failedCount} failed`); +process.exitCode = failedCount > 0 ? 1 : 0; diff --git a/routing/tsconfig.json b/routing/tsconfig.json new file mode 100644 index 0000000..bad2c37 --- /dev/null +++ b/routing/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src", "test"] +}