test(06-05): failing guidance-surface suite GS1-GS7 — five-slot banner, list row states, gantry blink, sanctioned-innerHTML + copy gates (RED)

- Node-side ?raw loader twin so tests exercise the committed glyph strings verbatim
- banner/list behavioral cases via the minimal document fake (theme.test pattern)
This commit is contained in:
araharah-executor
2026-10-24 14:11:54 +08:00
parent 0023ab3086
commit a60443a48d
2 changed files with 499 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
/*
* glyph-raw-loader.mjs — the Node-side twin of Vite's `?raw` import suffix.
* ----------------------------------------------------------------------------
* glyphs.ts imports the committed SVGs with Vite's `?raw` (the single-source
* law). Plain Node cannot load .svg modules, so the guidance-surface suite
* registers this `load` hook (node:module register) before its dynamic
* imports: any specifier ending in `?raw` resolves to the real file on disk
* and exports its text as the module default — byte-identical to what Vite
* ships, so the tests exercise the SAME glyph strings the app renders.
* The hook only intercepts `?raw` specifiers; every other import falls
* through to the default loader (type stripping included).
*/
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
export async function load(url, context, nextLoad) {
if (url.endsWith("?raw")) {
const content = readFileSync(fileURLToPath(new URL(url)), "utf8");
return {
format: "module",
source: `export default ${JSON.stringify(content)};\n`,
shortCircuit: true,
};
}
return nextLoad(url, context);
}
+473
View File
@@ -0,0 +1,473 @@
#!/usr/bin/env node
/*
* guidance-surface.test.mjs — GS*, the Phase 6 guidance-surface gates.
* --------------------------------------------------------------------------
* The 06-UI-SPEC laws that are provable without a browser (the capture legs
* in 06-08 prove the pixels), mirroring the route-surface (RS) style:
*
* GS1 banner five-slot discipline — every state renders register + arrow
* plate + distance + street + toggle; the calm REROUTING/RESUMING/
* NO REROUTE slot swaps; never blanks mid-swap (D-04/D-06)
* GS2 banner distance formats — instrument constants {n} M / {n.n} KM,
* "—" when unknown; unit split num+label
* GS3 street fallback — streetUnnamed for an unnamed segment (D-06)
* GS4 maneuver list — labelled region + tap-out collapse, NEXT/FUTURE/
* PAST row states, real-text rows, aria-hidden glyphs (D-06)
* GS5 gantry blink — JS-timed duty cycle, one interval per element,
* stop clears (the boot-card precedent, never CSS animation)
* GS6 structural — the ONE sanctioned innerHTML site (svgHost, static
* GLYPHS constant) + the RS9-analog inline-copy ban over banner.ts
* GS7 the committed glyph set — 8 kinds, single-source raw strings
* GS8+ (tasks 3–4): guidance/confirm/trip-end cards, shell.css block scan,
* haptics + close-protection capability guards
*
* Styles: behavioral cases drive the REAL builders through a minimal
* document fake (the theme.test stub pattern — installed per case, restored
* in finally); structural cases are comment-stripped source introspection.
* The ?raw glyph imports ride web/test/glyph-raw-loader.mjs.
*/
import { strict as assert } from "node:assert";
import { register } from "node:module";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "./harness.mjs";
register(new URL("./glyph-raw-loader.mjs", import.meta.url));
const HERE = path.dirname(fileURLToPath(new URL(import.meta.url)));
const GUIDANCE_DIR = path.join(HERE, "..", "src", "guidance");
const { strings } = await import("../src/i18n/strings.ts");
const { GLYPHS, GLYPH_KINDS, svgHost } = await import("../src/guidance/glyphs.ts");
const {
buildGuidanceBanner,
buildManeuverList,
startGantryBlink,
stopGantryBlink,
MANEUVER_LIST_ID,
} = await import("../src/guidance/banner.ts");
/* ————— shared helpers ————— */
function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, " ") // block comments
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments
}
function codeText(file) {
return stripComments(readFileSync(file, "utf8"));
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Minimal element fake — exactly the surface banner.ts composes with
* (createElement/className/id/textContent/append/attributes/dataset/style/
* addEventListener + a template that parses the root <svg> tag). */
function makeEl(tag) {
const el = {
tagName: tag.toUpperCase(),
children: [],
attributes: {},
dataset: {},
style: {},
className: "",
id: "",
type: "",
textContent: "",
handlers: {},
appendChild(child) {
el.children.push(child);
return child;
},
append(...cs) {
for (const c of cs) el.appendChild(c);
},
setAttribute(name, value) {
el.attributes[name] = String(value);
},
getAttribute(name) {
return el.attributes[name];
},
addEventListener(type, cb) {
(el.handlers[type] ??= []).push(cb);
},
click() {
for (const cb of el.handlers.click ?? []) cb();
},
};
if (tag === "template") {
// template.innerHTML parses just enough for svgHost: the root <svg> tag
// with its attributes becomes content.firstElementChild.
let content = { firstElementChild: null };
Object.defineProperty(el, "innerHTML", {
set(html) {
el.rawHTML = html;
const m = /<svg\b([^>]*)>/.exec(html);
const attrs = {};
if (m) for (const a of m[1].matchAll(/([\w-]+)="([^"]*)"/g)) attrs[a[1]] = a[2];
const svg = makeEl("svg");
svg.attributes = attrs;
content = { firstElementChild: svg };
},
get() {
return el.rawHTML ?? "";
},
});
Object.defineProperty(el, "content", {
get() {
return content;
},
});
}
return el;
}
const realDocument = globalThis.document;
/** Install the minimal document fake for the duration of `body` (sync). */
function withDocument(body) {
globalThis.document = { createElement: (tag) => makeEl(tag) };
try {
return body();
} finally {
globalThis.document = realDocument;
}
}
/** Depth-first search by className (space-separated token match). */
function findByClass(root, cls) {
for (const child of root.children) {
if (typeof child.className === "string" && child.className.split(/\s+/).includes(cls)) return child;
const hit = findByClass(child, cls);
if (hit) return hit;
}
return null;
}
function findAllByClass(root, cls, acc = []) {
for (const child of root.children) {
if (typeof child.className === "string" && child.className.split(/\s+/).includes(cls)) acc.push(child);
findAllByClass(child, cls, acc);
}
return acc;
}
const BANNER_STATES = [
["on_route", "NEXT"],
["off_route", "NEXT"], // OFF_ROUTE grace: no premature signal (D-04)
["rerouting", "REROUTING"],
["resuming", "RESUMING"],
["no_reroute", "NO REROUTE"],
];
/* ————— GS1 — the five-slot banner, every state ————— */
test("GS1 banner five-slot discipline — all five slots render in EVERY state; calm states swap the street slot, never blank", () => {
withDocument(() => {
for (const [state, word] of BANNER_STATES) {
const distanceM = state === "rerouting" || state === "no_reroute" ? null : 250;
const banner = buildGuidanceBanner(
"en",
{ state, glyph: "left", distanceM, street: "Jalan Sultan Ismail", listOpen: false },
{ onToggle() {} },
);
assert.equal(banner.tagName, "BUTTON", `${state}: the whole banner is the list toggle`);
assert.equal(banner.type, "button");
assert.equal(banner.attributes["aria-expanded"], "false", `${state}: collapsed default`);
assert.equal(banner.attributes["aria-controls"], MANEUVER_LIST_ID, `${state}: aria-controls → the list panel id`);
assert.equal(banner.attributes["aria-label"], strings.en.ariaBannerToggle, `${state}: labelled toggle`);
// slot 1 — register line, composed in code from the GUIDE base key
const register = findByClass(banner, "guide-register");
assert.ok(register, `${state}: register slot present`);
assert.equal(register.textContent, `${strings.en.guideRegister} // ${word}`, `${state}: register word`);
// slot 2 — the 48×48 arrow plate carries a 32×32 committed glyph
const plate = findByClass(banner, "guide-plate");
assert.ok(plate, `${state}: arrow plate present`);
const glyphEl = plate.children.find((c) => c.tagName === "SVG");
assert.ok(glyphEl, `${state}: the plate holds the inline glyph`);
assert.equal(glyphEl.attributes.viewBox, "0 0 32 32", `${state}: committed 32×32 geometry`);
assert.equal(glyphEl.attributes.width, "32");
assert.equal(glyphEl.attributes.height, "32");
// slot 3 — distance (number + unit; "—" when unknown)
const distance = findByClass(banner, "guide-distance");
const unit = findByClass(banner, "guide-unit");
assert.ok(distance && unit, `${state}: distance slots present`);
if (distanceM === null) {
assert.equal(distance.textContent, "—", `${state}: unknown distance is the honest dash`);
} else {
assert.equal(distance.textContent, "250", `${state}: telemetry number`);
assert.equal(unit.textContent, "M", `${state}: unit label`);
}
// slot 4 — street line (verbatim, or the calm-state copy)
const street = findByClass(banner, "guide-street");
assert.ok(street, `${state}: street slot present`);
if (state === "rerouting") {
assert.equal(street.textContent, strings.en.guideReroutingLine, "REROUTING: calm copy, no blame");
} else if (state === "resuming") {
assert.equal(street.textContent, strings.en.guideResumingLine, "RESUMING: projection-labeled copy");
} else if (state === "no_reroute") {
assert.equal(street.textContent, strings.en.guideNoRerouteLine, "NO REROUTE: degradation copy");
} else {
assert.equal(street.textContent, "Jalan Sultan Ismail", `${state}: street verbatim (data, not copy)`);
}
// slot 5 — the real-word toggle
const toggle = findByClass(banner, "guide-toggle");
assert.ok(toggle, `${state}: toggle slot present`);
assert.equal(toggle.textContent, strings.en.ctaBannerList, `${state}: LIST when collapsed`);
// the machine-blink gantry rides ONLY the two calm wait states
const gantry = findByClass(banner, "guide-gantry");
if (state === "rerouting" || state === "resuming") {
assert.ok(gantry, `${state}: the gantry square is present`);
} else {
assert.equal(gantry, null, `${state}: no loading indicator outside the wait states`);
}
}
});
});
test("GS1b banner toggle word carries the open state — CLOSE when expanded, aria-expanded true", () => {
withDocument(() => {
const banner = buildGuidanceBanner(
"ms",
{ state: "on_route", glyph: "straight", distanceM: 250, street: "Jalan Ampang", listOpen: true },
{ onToggle() {} },
);
assert.equal(banner.attributes["aria-expanded"], "true");
const toggle = findByClass(banner, "guide-toggle");
assert.equal(toggle.textContent, strings.ms.ctaBannerClose, "ms CLOSE word");
assert.equal(
findByClass(banner, "guide-register").textContent,
`${strings.ms.guideRegister} // NEXT`,
"ms register still composes the instrument GUIDE base",
);
});
});
test("GS1c banner tap fires onToggle exactly once", () => {
withDocument(() => {
let toggles = 0;
const banner = buildGuidanceBanner(
"en",
{ state: "on_route", glyph: "left", distanceM: 250, street: "Jalan Ampang", listOpen: false },
{ onToggle() { toggles += 1; } },
);
banner.click();
assert.equal(toggles, 1, "the whole banner is one toggle button");
});
});
/* ————— GS2 — distance instrument formats ————— */
test("GS2 distance formats — {n} M under 1000 (integer), {n.n} KM at/over (one decimal), '—' when unknown", () => {
withDocument(() => {
const cases = [
[0, "0", "M"],
[249.6, "250", "M"],
[999, "999", "M"],
[1000, "1.0", "KM"],
[1234, "1.2", "KM"],
];
for (const [m, num, unit] of cases) {
const banner = buildGuidanceBanner(
"en",
{ state: "on_route", glyph: "straight", distanceM: m, street: "S", listOpen: false },
{ onToggle() {} },
);
assert.equal(findByClass(banner, "guide-distance").textContent, num, `${m} → ${num}`);
assert.equal(findByClass(banner, "guide-unit").textContent, unit, `${m} → ${unit}`);
}
const unknown = buildGuidanceBanner(
"en",
{ state: "rerouting", glyph: "left", distanceM: null, street: null, listOpen: false },
{ onToggle() {} },
);
assert.equal(findByClass(unknown, "guide-distance").textContent, "—");
assert.equal(findByClass(unknown, "guide-unit").textContent, "", "no unit on the unknown dash");
});
});
/* ————— GS3 — street fallback ————— */
test("GS3 unnamed segment renders the streetUnnamed fallback copy, never a blank", () => {
withDocument(() => {
for (const locale of ["en", "ms"]) {
const banner = buildGuidanceBanner(
locale,
{ state: "on_route", glyph: "depart", distanceM: 10, street: null, listOpen: false },
{ onToggle() {} },
);
assert.equal(findByClass(banner, "guide-street").textContent, strings[locale].guideStreetUnnamed);
}
});
});
/* ————— GS4 — the maneuver list ————— */
const LIST_ROWS = [
{ kind: "depart", street: "Jalan Ampang", distanceM: 0, rowState: "past" },
{ kind: "left", street: "Jalan Sultan Ismail", distanceM: 250, rowState: "next" },
{ kind: "right", street: "Jalan P. Ramlee", distanceM: 1200, rowState: "future" },
{ kind: "arrive", street: "Suria KLCC", distanceM: 1800, rowState: "future" },
];
test("GS4 maneuver list — labelled region + tap-out collapse + NEXT/FUTURE/PAST row states, real-text rows, aria-hidden glyphs", () => {
withDocument(() => {
let dismissed = 0;
const wrap = buildManeuverList("en", LIST_ROWS, { onDismiss() { dismissed += 1; } });
// the transparent tap-out layer (no dim — boot-card precedent)
const tapout = findByClass(wrap, "guide-list-tapout");
assert.ok(tapout, "the tap-out layer exists");
assert.equal(tapout.attributes["aria-hidden"], "true");
// the panel: card geometry, labelled region, the id aria-controls points at
const panel = findByClass(wrap, "guide-list");
assert.ok(panel, "the list panel exists");
assert.equal(panel.attributes.role, "region");
assert.equal(panel.attributes["aria-label"], strings.en.ariaManeuverList);
assert.equal(panel.id, MANEUVER_LIST_ID, "panel id matches the banner's aria-controls");
// rows in trip order
const rows = findAllByClass(panel, "guide-row");
assert.equal(rows.length, LIST_ROWS.length, "one row per maneuver");
rows.forEach((row, i) => {
const view = LIST_ROWS[i];
assert.equal(row.dataset.rowState, view.rowState, `row ${i} carries its state`);
const glyphWrap = findByClass(row, "guide-row-glyph");
assert.equal(glyphWrap.attributes["aria-hidden"], "true", `row ${i}: glyph is aria-hidden`);
assert.equal(glyphWrap.children.find((c) => c.tagName === "SVG").attributes.viewBox, "0 0 32 32");
assert.equal(glyphWrap.children.find((c) => c.tagName === "SVG").attributes.width, "24", "list glyphs render 24×24");
const street = findByClass(row, "guide-row-street");
assert.equal(street.textContent, view.street, `row ${i}: street verbatim (real text, no aria needed)`);
assert.equal(street.attributes.title, view.street, `row ${i}: full name rides the title`);
const dist = findByClass(row, "guide-row-dist");
const expected = view.distanceM < 1000
? `${Math.round(view.distanceM)} M`
: `${(view.distanceM / 1000).toFixed(1)} KM`;
assert.equal(dist.textContent, expected, `row ${i}: right-aligned remaining distance format`);
});
// the NEXT row carries the lamp; the others do not
assert.ok(findByClass(rows[1], "guide-lamp"), "NEXT row has the 8×8 lamp");
assert.equal(findByClass(rows[0], "guide-lamp"), null, "PAST row: no lamp");
assert.equal(findByClass(rows[2], "guide-lamp"), null, "FUTURE row: no lamp");
// tap-out collapses
assert.equal(dismissed, 0);
tapout.click();
assert.equal(dismissed, 1, "a map tap on the transparent layer dismisses the list");
});
});
test("GS4b list is locale-labelled — ms region label from the strings module", () => {
withDocument(() => {
const wrap = buildManeuverList("ms", LIST_ROWS, { onDismiss() {} });
assert.equal(findByClass(wrap, "guide-list").attributes["aria-label"], strings.ms.ariaManeuverList);
});
});
/* ————— GS5 — the machine-blink gantry ————— */
test("GS5 gantry blink — start runs the ~500ms duty cycle, restart never stacks timers, stop clears", async () => {
const gantry = makeEl("span");
startGantryBlink(gantry);
assert.equal(gantry.dataset.fill, "on", "start shows the lit state immediately");
await sleep(560);
assert.equal(gantry.dataset.fill, "off", "one flip after ~500ms — an indicator duty cycle");
startGantryBlink(gantry); // idempotent restart
assert.equal(gantry.dataset.fill, "on");
await sleep(560);
assert.equal(gantry.dataset.fill, "off", "a stacked second timer would double-flip back to on");
stopGantryBlink(gantry);
const frozen = gantry.dataset.fill;
await sleep(560);
assert.equal(gantry.dataset.fill, frozen, "stop cleared the interval — no further flips (no leak)");
});
/* ————— GS6 — structural: the sanctioned innerHTML site + the copy ban ————— */
test("GS6 innerHTML ban — zero in banner.ts; exactly one in glyphs.ts at the static GLYPHS[kind] site", () => {
const bannerSrc = codeText(path.join(GUIDANCE_DIR, "banner.ts"));
assert.equal((bannerSrc.match(/innerHTML/g) ?? []).length, 0, "banner.ts composes with createElement + textContent only");
const glyphsSrc = readFileSync(path.join(GUIDANCE_DIR, "glyphs.ts"), "utf8");
const glyphsCode = stripComments(glyphsSrc);
assert.equal((glyphsCode.match(/innerHTML/g) ?? []).length, 1, "the ONE sanctioned site");
assert.match(glyphsCode, /innerHTML\s*=\s*GLYPHS\[kind\]/, "innerHTML is set from the STATIC import value, never a runtime string");
});
test("GS6b strings-only copy — zero guidance copy inline in banner.ts (RS9-analog; instrument words + units allowed)", () => {
const src = codeText(path.join(GUIDANCE_DIR, "banner.ts"));
assert.match(src, /from "\.\.\/i18n\/strings\.ts"/, "banner.ts imports the strings module");
// every guidance COPY value (en + ms) is banned as a literal; the register
// words (NEXT/REROUTING/RESUMING/NO REROUTE/CONFIRM/END), the " // "
// separator and the unit formats are instrument constants composed in code.
const banned = [
"GUIDE //", // composed from guideRegister, never stated
"Unnamed road",
"Jalan tanpa nama",
"Finding a new route",
"Mencari laluan baharu",
"Resuming from your last",
"Sambung semula",
"Rerouting unavailable",
"Pengiraan semula",
"No road graph here",
"Tiada graf jalan",
"SENARAI",
"TUTUP",
"END TRIP",
"TAMAT PERJALANAN",
"Abandon route?",
"Tinggalkan laluan?",
"record is deleted",
"rekod perjalanan",
"YES, ABANDON",
"YA, TINGGALKAN",
"CONTINUE",
"TERUSKAN",
"Trip ended",
"Perjalanan tamat",
"Full maneuver list",
"Senarai penuh manuver",
"Next maneuver",
"Manuver seterusnya",
"Trip progress",
"Kemajuan perjalanan",
];
for (const phrase of banned) {
assert.ok(!src.includes(phrase), `banner.ts inlines guidance copy ("${phrase}") — it lives only in strings.ts`);
}
});
/* ————— GS7 — the committed glyph set ————— */
test("GS7 the 8-glyph committed set — exactly 8 kinds in order, each a non-empty raw SVG of the committed file", () => {
assert.deepEqual(
[...GLYPH_KINDS],
["depart", "straight", "left", "right", "slight-left", "slight-right", "uturn", "arrive"],
"the committed kinds in trip order",
);
assert.equal(GLYPH_KINDS.length, 8);
for (const kind of GLYPH_KINDS) {
const raw = GLYPHS[kind];
assert.ok(typeof raw === "string" && raw.length > 0, `${kind}: raw string non-empty`);
assert.ok(raw.includes('viewBox="0 0 32 32"'), `${kind}: 32×32 grid`);
assert.ok(raw.includes('stroke-width="3"'), `${kind}: the HUD stroke weight`);
assert.ok(raw.includes('stroke-linecap="square"'), `${kind}: square caps`);
assert.ok(raw.includes("currentColor"), `${kind}: color rides currentColor (token-only)`);
// the raw string IS the committed file (single source — ?raw, not a copy)
const committed = readFileSync(path.join(GUIDANCE_DIR, "glyphs", `${kind}.svg`), "utf8");
assert.equal(raw, committed.trim(), `${kind}: GLYPHS carries the committed file verbatim`);
}
// orthogonal/45° law spot-checks: no arcs anywhere (the uturn staple is the proof case)
for (const kind of GLYPH_KINDS) {
assert.ok(!/[Aa]\b/.test(GLYPHS[kind].replace(/aria-hidden="true"/g, "").replace(/<svg /g, "")), `${kind}: no arc commands — segments stay orthogonal/45°`);
}
});
test("GS7b svgHost parses a committed glyph into a sized inline-SVG element", () => {
withDocument(() => {
const svg = svgHost("uturn");
assert.equal(svg.tagName, "SVG");
assert.equal(svg.attributes.viewBox, "0 0 32 32");
});
});