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
This commit is contained in:
@@ -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"]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user