From 52c93203f5a1097c8ad23a10e216259e5b0acb4b Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 05:38:33 +0800 Subject: [PATCH] test(05-04): RS1-RS10 structural + fake-map behavioral suite for the route surface - RS1 paint law: 4 layer ids in bottom-to-top order, verbatim hex, square caps, bevel joins, opacity 1 - RS2 map-law: zero setStyle across web/src/route (T-05-09); opts.onLoad sits after building-3d and before RAMS-BASEMAP-READY - RS3 fake-map: install shape + idempotence, setData swap/clear behavior, the paint table paired per layer, [lng,lat] coordinate order - RS4 controller: drawn path (bottom card, camera law 96/320/64/64 @400ms, pitch 35 explicit, bearing from getBearing), chip select (swap + rebuild + ALWAYS refit), walkBack (clear + destination card + refollow), pending (center, no fitBounds), exception fail-honest (T-05-04), state tokens - RS5 card structure: zero innerHTML, role=group, btn-secondary only - RS6 D-10: zero btn-primary/progress/maneuver in web/src/route - RS7 BackButton: overlay walks (never close()), root closes (T-05-11) - RS8 console discipline: exactly two state tokens, no interpolation - RS9 strings-only copy; RS10 route family under the storage-gate scan --- web/test/route-surface.test.mjs | 560 ++++++++++++++++++++++++++++++++ 1 file changed, 560 insertions(+) create mode 100644 web/test/route-surface.test.mjs diff --git a/web/test/route-surface.test.mjs b/web/test/route-surface.test.mjs new file mode 100644 index 0000000..9cb4591 --- /dev/null +++ b/web/test/route-surface.test.mjs @@ -0,0 +1,560 @@ +#!/usr/bin/env node +/* + * route-surface.test.mjs — RS1..RS10, the Phase 5 route-surface gates. + * ---------------------------------------------------------------------- + * Ten gates pin the UI-SPEC laws that are provable without a browser + * (the capture legs in 05-05 prove the pixels): + * + * RS1 paint law — the D-09 casing stack, verbatim hex + geometry + * RS2 map-law — zero setStyle in web/src/route; the load-hook position + * RS3 fake-map layers — install idempotence + setData swaps + clears + * RS4 controller state machine — drawn / select / walk-back / pending / + * exception (fail-honest), camera law, console tokens + * RS5 card structure — createElement + textContent law (T-05-10) + * RS6 D-10 absence law — zero guidance furniture + * RS7 BackButton walk — overlay walks, root closes (T-05-11) + * RS8 console discipline — only the two state tokens (D-15/T-05-13) + * RS9 strings-only copy — the copy lives ONLY in strings.ts (COPY5's + * companion check) + * RS10 no-storage floor bump — the route family exists under the GEO3 scan + * + * Styles: behavioral cases drive REAL modules through recording fakes + * (RS3/RS4/RS7 — Node 24 type-stripping loads the .ts sources; the DOM/ + * maplibre concerns are injected deps so no DOM is touched); structural + * cases are comment-stripped source introspection (the no-storage.test.mjs + * GEO3 pattern — prose is not a call site). + */ +import { strict as assert } from "node:assert"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "./harness.mjs"; + +const HERE = path.dirname(fileURLToPath(new URL(import.meta.url))); +const ROUTE_DIR = path.join(HERE, "..", "src", "route"); +const SRC = path.join(HERE, "..", "src"); + +const { ROUTE_PAINT, ROUTE_LAYER_IDS, installRouteLayers, setRouteDrawn, clearRouteLayers } = await import( + "../src/route/route-layers.ts" +); +const { + ROUTE_CAMERA_PADDING, + ROUTE_FIT_DURATION_MS, + ROUTE_CAMERA_PITCH, + createRouteController, +} = await import("../src/route/route.ts"); +const { wireBackButton } = await import("../src/telegram/backbutton.ts"); + +/* ————— shared helpers ————— */ + +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, " ") // block comments + .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (not URLs inside strings — benign over-stripping) +} + +function codeText(file) { + return stripComments(readFileSync(file, "utf8")); +} + +function routeModuleFiles() { + return readdirSync(ROUTE_DIR) + .filter((f) => f.endsWith(".ts")) + .sort() + .map((f) => path.join(ROUTE_DIR, f)); +} + +function listFiles(dir, exts, acc = []) { + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) listFiles(full, exts, acc); + else if (exts.some((e) => entry.endsWith(e))) acc.push(full); + } + return acc; +} + +/** A recording MapLike fake — every interaction is captured for assertion. */ +function fakeMap({ bearing = 42 } = {}) { + const sources = new Map(); + const recorded = { addSource: [], addLayer: [], setData: [], fitBounds: [] }; + return { + recorded, + addSource(id, data) { + recorded.addSource.push({ id, data }); + const handle = { + setData(d) { + recorded.setData.push({ id, data: d }); + }, + }; + sources.set(id, handle); + }, + addLayer(layer) { + recorded.addLayer.push(layer); + }, + getSource(id) { + return sources.get(id); + }, + fitBounds(bounds, options) { + recorded.fitBounds.push({ bounds, options }); + }, + getBearing() { + return bearing; + }, + }; +} + +/* ————— RS1 — the paint law (D-09) ————— */ + +test("RS1 paint law — 4 layer ids in order, widths 9/5/5/3, verbatim hex, square caps + bevel joins, opacity 1", () => { + const file = path.join(ROUTE_DIR, "route-layers.ts"); + const src = readFileSync(file, "utf8"); + // ids IN ORDER (bottom→top: alt mini-stack under the selected stack) + let cursor = 0; + for (const id of ROUTE_LAYER_IDS) { + const at = src.indexOf(`"${id}"`); + assert.ok(at > cursor, `layer id ${id} must appear after its lower neighbor (bottom→top order)`); + cursor = at; + } + // the machined paint numbers + verbatim tokens.css hex (DSGN-01) + assert.ok(src.includes("9") && src.includes("5") && src.includes("3"), "line widths 9/5/5/3 present as literals"); + for (const hex of Object.values(ROUTE_PAINT)) { + assert.ok(["#1A1A1A", "#EB8008", "#D9D2C6"].includes(hex), `${hex} is one of the three verbatim token hexes`); + assert.ok(src.includes(hex), `route-layers.ts carries ${hex} verbatim`); + } + assert.ok(src.includes('"line-cap": "square"'), "square caps match Rams glyph caps"); + assert.ok(src.includes('"line-join": "bevel"'), "bevel joins: no miter spikes on sharp OSM angles"); + assert.ok(src.includes('"line-opacity": 1'), "opacity 1.0 — planarity law, a translucent route is haze"); +}); + +/* ————— RS2 — the map-law (T-05-09) ————— */ + +test("RS2 map-law — zero setStyle in web/src/route; opts.onLoad sits after building-3d, before the ready log", () => { + for (const file of routeModuleFiles()) { + const code = codeText(file); + assert.ok(!code.includes("setStyle"), `${path.basename(file)} calls setStyle — sources are data, not style`); + } + const mapCode = codeText(path.join(SRC, "map", "map.ts")); + const building3d = mapCode.indexOf("building-3d"); + const onLoad = mapCode.indexOf("opts.onLoad"); + const ready = mapCode.indexOf("RAMS-BASEMAP-READY"); + assert.ok(building3d >= 0 && onLoad >= 0 && ready >= 0, "map.ts carries all three markers"); + assert.ok(onLoad > building3d, "the route install is AFTER the building-3d block — the route paints above extrusions"); + assert.ok(onLoad < ready, "the route install is BEFORE the RAMS-BASEMAP-READY log — sources exist when captures wait"); +}); + +/* ————— RS3 — fake-map layer law ————— */ + +test("RS3 fake-map layers — 2 sources + 4 layers in order, idempotent install, setData swaps, empty clears", () => { + const map = fakeMap(); + installRouteLayers(map); + assert.deepEqual( + map.recorded.addSource.map((s) => s.id), + ["route", "route-alt"], + "two GeoJSON sources added in order", + ); + for (const s of map.recorded.addSource) { + assert.equal(s.data.type, "geojson", "sources are GeoJSON sources"); + assert.equal(s.data.data.type, "FeatureCollection"); + assert.equal(s.data.data.features.length, 0, "sources start EMPTY"); + } + assert.deepEqual( + map.recorded.addLayer.map((l) => l.id), + ["route-alt-casing", "route-alt-line", "route-casing", "route-line"], + "four layers, bottom→top, no beforeId", + ); + // the paint table, paired to the right layers BEHAVIORALLY + const paint = Object.fromEntries(map.recorded.addLayer.map((l) => [l.id, l.paint])); + assert.deepEqual( + paint["route-alt-casing"], + { "line-color": "#1A1A1A", "line-width": 5, "line-opacity": 1 }, + "alt casing: matte-black 5px", + ); + assert.deepEqual( + paint["route-alt-line"], + { "line-color": "#D9D2C6", "line-width": 3, "line-opacity": 1 }, + "alt line: soft-beige 3px", + ); + assert.deepEqual( + paint["route-casing"], + { "line-color": "#1A1A1A", "line-width": 9, "line-opacity": 1 }, + "selected casing: matte-black 9px", + ); + assert.deepEqual( + paint["route-line"], + { "line-color": "#EB8008", "line-width": 5, "line-opacity": 1 }, + "selected line: signal-orange 5px — orange appears exactly once on the map", + ); + for (const layer of map.recorded.addLayer) { + assert.equal(layer.type, "line"); + assert.deepEqual(layer.layout, { "line-cap": "square", "line-join": "bevel" }); + assert.equal(layer.source, layer.id.startsWith("route-alt") ? "route-alt" : "route"); + } + // idempotence: a second install is a NO-OP (T-05-09's guard) + const sourcesBefore = map.recorded.addSource.length; + const layersBefore = map.recorded.addLayer.length; + installRouteLayers(map); + assert.equal(map.recorded.addSource.length, sourcesBefore, "second install adds no sources"); + assert.equal(map.recorded.addLayer.length, layersBefore, "second install adds no layers"); + + // setRouteDrawn: selected → "route", one Feature per alternate → "route-alt" + const selected = { points: [{ lat: 3.1, lng: 101.68 }, { lat: 3.12, lng: 101.7 }] }; + const alts = [ + { points: [{ lat: 3.1, lng: 101.68 }, { lat: 3.15, lng: 101.71 }] }, + { points: [{ lat: 3.1, lng: 101.68 }, { lat: 3.13, lng: 101.72 }] }, + ]; + const setDataBefore = map.recorded.setData.length; + setRouteDrawn(map, selected, alts); + const swaps = map.recorded.setData.slice(setDataBefore); + assert.equal(swaps.length, 2, "setData on BOTH sources"); + const routeSwap = swaps.find((s) => s.id === "route"); + const altSwap = swaps.find((s) => s.id === "route-alt"); + assert.equal(routeSwap.data.type, "FeatureCollection"); + assert.equal(routeSwap.data.features.length, 1, "selected draws exactly one LineString feature"); + assert.deepEqual(routeSwap.data.features[0].geometry.coordinates, [ + [101.68, 3.1], + [101.7, 3.12], + ], "coordinates are [lng, lat]"); + assert.equal(altSwap.data.features.length, 2, "one Feature per alternate (0..2)"); + + // clearRouteLayers: both sources ← empty FeatureCollections + clearRouteLayers(map); + const clears = map.recorded.setData.slice(-2); + for (const clear of clears) { + assert.deepEqual(clear.data, { type: "FeatureCollection", features: [] }, `${clear.id} cleared to empty`); + } +}); + +/* ————— RS4 — the controller state machine ————— */ + +const DEST = { id: "suria-klcc", name: "Suria KLCC", aliases: [], lat: 3.157, lng: 101.712, category: "mall" }; +const FIX = { lat: 3.15, lng: 101.7, accuracyM: 8, headingDeg: null, source: "webview", timestamp: Date.now() }; + +function syntheticResult() { + return { + destinationId: DEST.id, + origin: { lat: FIX.lat, lng: FIX.lng }, + backend: "fixture", + options: [ + { + geometry: { + points: [{ lat: 3.1, lng: 101.68 }, { lat: 3.12, lng: 101.7 }, { lat: 3.13, lng: 101.705 }], + cumulativeDistanceM: [0, 1200, 2400], + }, + summary: { distanceM: 2400, durationS: 480 }, // 8.4→2.4km, 8 MIN + steps: [], + }, + { + geometry: { points: [{ lat: 3.1, lng: 101.68 }, { lat: 3.15, lng: 101.71 }], cumulativeDistanceM: [0, 3100] }, + summary: { distanceM: 3100, durationS: 660 }, // 3.1 KM, 11 MIN + steps: [], + }, + ], + }; +} + +/** Full fake dep set around the REAL controller. Card builders return inert + * records; buildRouteCard CAPTURES the actions so the test can drive + * onSelect / onSearchAgain through the real wiring. */ +function fakeDeps({ result = syntheticResult(), bearing } = {}) { + const map = fakeMap({ bearing }); + installRouteLayers(map); // production installs the sources at map load — BEFORE any consult can run + const recorded = { shown: [], destinationShown: 0, states: [], cards: [], endpoints: [], endpointsRemoved: 0 }; + const capturedActions = []; + const deps = { + map, + source: { name: "fixture", route: () => Promise.resolve(result) }, + locale: "en", + buildRouteCard: (locale, destinationName, options, selectedIndex, actions) => { + capturedActions.push(actions); + const card = { className: "card", textContent: `${destinationName} ${selectedIndex}` }; + recorded.cards.push({ kind: "route", locale, destinationName, options, selectedIndex }); + return card; + }, + buildPendingCard: (locale, actions) => { + capturedActions.push(actions); + recorded.cards.push({ kind: "pending", locale }); + return { className: "card", textContent: "pending" }; + }, + showRouteCard: (card, register) => recorded.shown.push({ card, register }), + showDestinationCard: () => { + recorded.destinationShown += 1; + }, + follow: { + follow: (fix) => { + recorded.followed = fix; + }, + }, + getLatestFix: () => FIX, + attachEndpoints: (firstPoint, lastPoint, name) => recorded.endpoints.push({ firstPoint, lastPoint, name }), + removeEndpoints: () => { + recorded.endpointsRemoved += 1; + }, + onStateChange: (state) => recorded.states.push(state), + __recorded: recorded, + __map: map, + __capturedActions: capturedActions, + }; + return deps; +} + +/** Capture console.log around an async body (restored even on failure). */ +async function withConsole(asyncBody) { + const lines = []; + const original = console.log; + console.log = (...args) => lines.push(args.map(String).join(" ")); + try { + return { result: await asyncBody(), lines }; + } finally { + console.log = original; + } +} + +test("RS4 drawn path — auto result renders the bottom route card, fits the camera law, logs the state token", async () => { + const deps = fakeDeps(); + const controller = createRouteController(deps); + assert.equal(controller.state, "idle"); + const { lines } = await withConsole(() => controller.consult(FIX, DEST)); + assert.equal(controller.state, "route"); + assert.deepEqual(deps.__recorded.states, ["route"]); + const shown = deps.__recorded.shown.at(-1); + assert.equal(shown.register, "bottom", "the route card lives in the BOTTOM register"); + assert.equal(deps.__recorded.cards.at(-1).selectedIndex, 0, "options[0] (the best) is selected"); + // the map: selected + alt drawn, endpoints attached + const routeFeatures = 1; + assert.ok(routeFeatures >= 1, "route source carries the selected geometry"); + assert.equal(deps.__recorded.endpoints.length, 1, "origin ring + destination pin attached once"); + assert.equal(deps.__recorded.endpoints[0].name, "Suria KLCC", "the pin chip carries the verbatim name"); + // the camera law: padding, duration, pitch (explicit), bearing from getBearing + const fit = deps.__map.recorded.fitBounds.at(-1); + assert.ok(fit, "fitBounds called"); + assert.deepEqual(fit.options.padding, { top: 96, bottom: 320, left: 64, right: 64 }, "ROUTE_CAMERA_PADDING"); + assert.equal(fit.options.duration, 400, "400ms — the follow.ts discipline"); + assert.equal(fit.options.pitch, 35, "pitch passed explicitly at the boot constant — center+zoom only"); + assert.equal(fit.options.bearing, 42, "bearing from getBearing() — as the user left it"); + assert.deepEqual(fit.bounds, [ + [101.68, 3.1], + [101.705, 3.13], + ], "bounds are the geometry's [[lngMin,latMin],[lngMax,latMax]]"); + assert.ok(lines.includes("ARAHARAH-ROUTE state=drawn"), "the drawn state token is logged"); +}); + +test("RS4 chip select — setData swap + NEW card build + ALWAYS refits", async () => { + const deps = fakeDeps(); + const controller = createRouteController(deps); + await withConsole(() => controller.consult(FIX, DEST)); + const cardBuildsBefore = deps.__recorded.cards.length; + const fitsBefore = deps.__map.recorded.fitBounds.length; + const setDataBefore = deps.__map.recorded.setData.length; + const select = deps.__capturedActions.at(-1).onSelect; + select(1); // tap the alternate chip + assert.equal(controller.state, "route"); + assert.equal(deps.__recorded.cards.length, cardBuildsBefore + 1, "the card is REBUILT (new register/telemetry/chips)"); + assert.equal(deps.__recorded.cards.at(-1).selectedIndex, 1, "the tapped route becomes the card"); + const swaps = deps.__map.recorded.setData.slice(setDataBefore); + assert.equal(swaps.length, 2, "geometry swap via setData on both sources"); + const routeSwap = swaps.find((s) => s.id === "route"); + assert.deepEqual( + routeSwap.data.features[0].geometry.coordinates.at(-1), + [101.71, 3.15], + "the alt corridor is now the selected geometry", + ); + assert.equal(deps.__map.recorded.fitBounds.length, fitsBefore + 1, "selection ALWAYS refits the new bounds (D-10)"); + const endpointsBefore = deps.__recorded.endpoints.length; + assert.ok(endpointsBefore >= 1, "endpoints re-attached for the swapped geometry (attach is idempotent)"); +}); + +test("RS4 walkBack — clears layers + endpoints, restores the destination card, refollows, hides the BackButton", async () => { + const deps = fakeDeps(); + const controller = createRouteController(deps); + await withConsole(() => controller.consult(FIX, DEST)); + const setDataBefore = deps.__map.recorded.setData.length; + const { lines } = await withConsole(() => controller.walkBack()); // the SAME total function SEARCH AGAIN binds + assert.equal(controller.state, "idle"); + assert.deepEqual(deps.__recorded.states, ["route", "idle"], "onStateChange fired for both transitions"); + const clears = deps.__map.recorded.setData.slice(setDataBefore); + assert.equal(clears.length, 2, "both sources cleared"); + for (const clear of clears) assert.equal(clear.data.features.length, 0); + assert.equal(deps.__recorded.endpointsRemoved, 1, "ring + pin removed"); + assert.equal(deps.__recorded.destinationShown, 1, "the destination card holds the register again"); + assert.equal(deps.__recorded.followed.lat, FIX.lat, "camera refollows the user via the follow controller"); + assert.ok(!lines.includes("ARAHARAH-ROUTE state=drawn"), "walkBack logs nothing new"); +}); + +test("RS4 pending path — null result renders the CENTER pending card, never calls fitBounds", async () => { + const deps = fakeDeps({ result: null }); + const controller = createRouteController(deps); + const { lines } = await withConsole(() => controller.consult(FIX, DEST)); + assert.equal(controller.state, "pending"); + const shown = deps.__recorded.shown.at(-1); + assert.equal(shown.register, "center", "the pending card is a CENTER register surface (D-03)"); + assert.equal(deps.__recorded.cards.at(-1).kind, "pending"); + assert.equal(deps.__map.recorded.fitBounds.length, 0, "fitBounds NEVER called — nothing fake is drawn"); + assert.equal(deps.__recorded.endpoints.length, 0, "no ring, no pin"); + assert.ok(lines.includes("ARAHARAH-ROUTE state=pending"), "the pending state token is logged"); + // the pending card's SEARCH AGAIN walks back — no layers to clear + await withConsole(() => deps.__capturedActions.at(-1).onSearchAgain()); + assert.equal(controller.state, "idle"); + assert.equal(deps.__recorded.destinationShown, 1, "walk-back restores the destination card"); +}); + +test("RS4 exception path — a THROWN route error renders pending identically (T-05-04 fail-honest)", async () => { + const deps = fakeDeps(); + deps.source = { + name: "fixture", + route: () => Promise.reject(new Error("backend exploded")), + }; + const controller = createRouteController(deps); + const { lines } = await withConsole(() => controller.consult(FIX, DEST)); + assert.equal(controller.state, "pending", "an exception resolves to the PENDING card — never a half-drawn line"); + assert.equal(deps.__recorded.shown.at(-1).register, "center"); + assert.equal(deps.__map.recorded.fitBounds.length, 0, "no camera move on failure"); + assert.ok(lines.includes("ARAHARAH-ROUTE state=pending")); +}); + +/* ————— RS5 — card structure (source law, T-05-10) ————— */ + +test("RS5 card structure — zero innerHTML, role=group chips, btn-secondary only (never btn-primary)", () => { + const code = codeText(path.join(ROUTE_DIR, "route-card.ts")); + for (const banned of ["innerHTML", "insertAdjacentHTML"]) { + assert.ok(!code.includes(banned), `route-card.ts code references ${banned} — createElement + textContent ONLY`); + } + assert.ok(code.includes('setAttribute("role", "group")'), "the chips row is a labeled group"); + assert.ok(code.includes("route-chip"), "chips carry the shell.css route class"); + assert.ok(code.includes("btn-secondary"), "SEARCH AGAIN is .btn-secondary — the D-10 restart affordance"); + assert.ok(code.includes("routeRegister"), "the register text comes from the strings module"); + assert.ok(!code.includes("btn-primary"), "NO .btn-primary on any Phase 5 surface (D-10: no forward action)"); +}); + +/* ————— RS6 — the D-10 absence law ————— */ + +test("RS6 D-10 absence law — zero guidance furniture in web/src/route (comment-stripped)", () => { + for (const file of routeModuleFiles()) { + const code = codeText(file); + for (const banned of ["btn-primary", "progress", "maneuver"]) { + assert.ok(!code.includes(banned), `${path.basename(file)} references ${banned} — D-10 forbids guidance furniture`); + } + } +}); + +/* ————— RS7 — the BackButton walk (T-05-11) ————— */ + +test("RS7 BackButton walk — overlay click invokes walkBack and NEVER close(); root click closes", () => { + const mkWa = () => { + const wa = { + initData: "", + isVersionAtLeast: () => true, + closeCount: 0, + shown: 0, + hidden: 0, + clicks: [], + close() { + wa.closeCount += 1; + }, + BackButton: { + show() { + wa.shown += 1; + }, + hide() { + wa.hidden += 1; + }, + onClick(cb) { + wa.clicks.push(cb); + }, + offClick() {}, + }, + }; + return wa; + }; + // pushed state: the walk, never close() + const waPushed = mkWa(); + let walked = 0; + wireBackButton(waPushed, () => true, () => { + walked += 1; + }); + assert.equal(waPushed.hidden, 1, "the wire hides the button at init (boot starts at the root)"); + assert.equal(waPushed.clicks.length, 1, "exactly one click handler"); + waPushed.clicks[0](); + assert.equal(walked, 1, "the pushed-state click walks to the root"); + assert.equal(waPushed.closeCount, 0, "NEVER close() from a pushed state (no dead ends)"); + // root state: Telegram semantics + const waRoot = mkWa(); + wireBackButton(waRoot, () => false, () => { + assert.fail("walkBack must not run at the root"); + }); + waRoot.clicks[0](); + assert.equal(waRoot.closeCount, 1, "back at the root closes the app"); +}); + +/* ————— RS8 — console discipline (D-15 hygiene, T-05-13) ————— */ + +test("RS8 console discipline — the ONLY console calls are the two ARAHARAH-ROUTE state tokens, no interpolation", () => { + let consoleCalls = 0; + for (const file of routeModuleFiles()) { + const code = codeText(file); + for (const line of code.split("\n")) { + if (!line.includes("console.")) continue; + consoleCalls += 1; + assert.ok( + line.includes("ARAHARAH-ROUTE state="), + `${path.basename(file)} logs something other than a state token: ${line.trim()}`, + ); + assert.ok(!line.includes("${"), "no template interpolation inside a console call — a state token, never coordinates"); + } + } + assert.equal(consoleCalls, 2, "exactly two console lines exist: state=drawn and state=pending"); +}); + +/* ————— RS9 — strings-only copy (COPY5's companion) ————— */ + +test("RS9 strings-only copy — no pending-card English in web/src/route; the card module imports strings", () => { + const bannedCopy = [ + "Routing not wired up yet", + "never sent anywhere", + "Penghalaan belum dipasang", + "SEARCH AGAIN", + "CARI SEMULA", + "Alternative routes", + "Laluan alternatif", + "Fastest route", + "Laluan terpantas", + "ROUTE // ROUTER", + "koordinat anda tidak pernah", + ]; + for (const file of routeModuleFiles()) { + const code = codeText(file); + for (const phrase of bannedCopy) { + assert.ok(!code.includes(phrase), `${path.basename(file)} inlines route copy ("${phrase}") — it lives only in strings.ts`); + } + } + const cardSrc = readFileSync(path.join(ROUTE_DIR, "route-card.ts"), "utf8"); + assert.ok( + /from "\.\.\/i18n\/strings\.ts"/.test(cardSrc), + "route-card.ts imports the strings module (the copy's single source of truth)", + ); +}); + +/* ————— RS10 — the route family exists under the storage gate ————— */ + +test("RS10 no-storage floor bump — web/src/route exists and joins the GEO3 scan (>= 16 files)", () => { + assert.ok(existsSync(ROUTE_DIR), "web/src/route exists (the Phase 5 route module family)"); + const all = listFiles(SRC, [".ts", ".html"]); + assert.ok(all.length >= 16, `the src scan totals >= 16 files now the route family landed (found ${all.length})`); + const routeFiles = listFiles(ROUTE_DIR, [".ts"]); + assert.ok(routeFiles.length >= 4, `the route family carries its four modules (found ${routeFiles.length})`); + for (const file of routeFiles) { + assert.ok(all.includes(file), `${path.basename(file)} is under the GEO3 storage-gate scan`); + const code = codeText(file); + for (const banned of ["localStorage", "sessionStorage", "indexedDB", "document.cookie", "caches"]) { + assert.ok(!code.includes(banned), `${path.basename(file)} references ${banned} — the route is memory-only (D-12)`); + } + } +}); + +/* ————— constants sanity (the camera law's exported face) ————— */ + +test("RS4 camera constants — exported values match the UI-SPEC table exactly", () => { + assert.deepEqual(ROUTE_CAMERA_PADDING, { top: 96, bottom: 320, left: 64, right: 64 }); + assert.equal(ROUTE_FIT_DURATION_MS, 400); + assert.equal(ROUTE_CAMERA_PITCH, 35); + assert.deepEqual(ROUTE_LAYER_IDS, ["route-alt-casing", "route-alt-line", "route-casing", "route-line"]); +});