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