From a60443a48d046f627fc3e0ff7b3c6b060e5a0347 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 14:11:54 +0800 Subject: [PATCH] =?UTF-8?q?test(06-05):=20failing=20guidance-surface=20sui?= =?UTF-8?q?te=20GS1-GS7=20=E2=80=94=20five-slot=20banner,=20list=20row=20s?= =?UTF-8?q?tates,=20gantry=20blink,=20sanctioned-innerHTML=20+=20copy=20ga?= =?UTF-8?q?tes=20(RED)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- web/test/glyph-raw-loader.mjs | 26 ++ web/test/guidance-surface.test.mjs | 473 +++++++++++++++++++++++++++++ 2 files changed, 499 insertions(+) create mode 100644 web/test/glyph-raw-loader.mjs create mode 100644 web/test/guidance-surface.test.mjs diff --git a/web/test/glyph-raw-loader.mjs b/web/test/glyph-raw-loader.mjs new file mode 100644 index 0000000..0c5cae0 --- /dev/null +++ b/web/test/glyph-raw-loader.mjs @@ -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); +} diff --git a/web/test/guidance-surface.test.mjs b/web/test/guidance-surface.test.mjs new file mode 100644 index 0000000..4a71c5e --- /dev/null +++ b/web/test/guidance-surface.test.mjs @@ -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 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 tag + // with its attributes becomes content.firstElementChild. + let content = { firstElementChild: null }; + Object.defineProperty(el, "innerHTML", { + set(html) { + el.rawHTML = html; + const m = /]*)>/.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(/ { + withDocument(() => { + const svg = svgHost("uturn"); + assert.equal(svg.tagName, "SVG"); + assert.equal(svg.attributes.viewBox, "0 0 32 32"); + }); +});