625 lines
28 KiB
JavaScript
625 lines
28 KiB
JavaScript
#!/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,
|
||
buildGuidanceCard,
|
||
buildConfirmCard,
|
||
buildTripEndCard,
|
||
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.trim(), 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");
|
||
});
|
||
});
|
||
|
||
/* ————— GS8 — the guidance card (D-06 — the route card evolved) ————— */
|
||
|
||
const CARD_VIEW = {
|
||
destinationName: "Suria KLCC",
|
||
minutesRemaining: 18,
|
||
kmRemaining: 7.842,
|
||
etaClock: "18:42",
|
||
progressPct: 42,
|
||
showNoReroute: false,
|
||
};
|
||
|
||
test("GS8 guidance card — GUIDE register, verbatim headline, live telemetry row, 4px progressbar, ONE btn-secondary END TRIP", () => {
|
||
withDocument(() => {
|
||
let ended = 0;
|
||
const card = buildGuidanceCard("en", CARD_VIEW, { onEndTrip() { ended += 1; } });
|
||
assert.ok(card.className.includes("card"), "same card class as the route card it evolves");
|
||
const ref = findByClass(card, "card-ref");
|
||
assert.equal(ref.textContent, strings.en.guideRegister, "the ROUTE register, evolved to GUIDE");
|
||
assert.equal(findByClass(card, "text-headline-md").textContent, "Suria KLCC", "the continuity anchor holds (verbatim)");
|
||
// telemetry row: minutes + MIN + {km} KM + ETA clock — instrument formats
|
||
const telemetry = findByClass(card, "route-telemetry");
|
||
assert.ok(telemetry, "the route card's telemetry row class, evolved");
|
||
assert.deepEqual(
|
||
telemetry.children.map((c) => c.textContent),
|
||
["18", "MIN", "7.8 KM", "ETA 18:42"],
|
||
);
|
||
// progress bar: role + full aria value set + inline width
|
||
const progress = findByClass(card, "guide-progress");
|
||
assert.equal(progress.attributes.role, "progressbar");
|
||
assert.equal(progress.attributes["aria-valuemin"], "0");
|
||
assert.equal(progress.attributes["aria-valuemax"], "100");
|
||
assert.equal(progress.attributes["aria-valuenow"], "42");
|
||
assert.equal(progress.attributes["aria-label"], strings.en.ariaTripProgress);
|
||
const fill = findByClass(progress, "guide-progress-fill");
|
||
assert.equal(fill.style.width, "42%", "width set by inline style from the pct — instant, no transition");
|
||
// state line ABSENT in the normal state (less, but better)
|
||
assert.equal(findByClass(card, "text-body-sm"), null, "no state line unless degrading");
|
||
// actions: exactly ONE button, btn-secondary, END TRIP
|
||
const actionsRow = findByClass(card, "card-actions");
|
||
assert.equal(actionsRow.children.length, 1, "the guidance card has exactly one action");
|
||
const button = actionsRow.children[0];
|
||
assert.equal(button.className, "btn-secondary", "NO .btn-primary on the guidance card — the drive's forward action is driving");
|
||
assert.equal(button.textContent, strings.en.ctaEndTrip);
|
||
button.click();
|
||
assert.equal(ended, 1, "END TRIP opens the D-09 confirm surface");
|
||
assert.equal(findAllByClass(card, "btn-primary").length, 0);
|
||
});
|
||
});
|
||
|
||
test("GS8b guidance card no-reroute state line — the longer honest copy ONLY when showNoReroute (standard ink, no red)", () => {
|
||
withDocument(() => {
|
||
const card = buildGuidanceCard("en", { ...CARD_VIEW, showNoReroute: true }, { onEndTrip() {} });
|
||
const stateLine = findByClass(card, "text-body-sm");
|
||
assert.ok(stateLine, "the degradation adds the state line");
|
||
assert.equal(stateLine.textContent, strings.en.guideNoRerouteBody);
|
||
assert.equal(findByClass(card, "card-actions").children.length, 1, "still exactly one action");
|
||
// ms: same key, ms copy
|
||
const cardMs = buildGuidanceCard("ms", { ...CARD_VIEW, showNoReroute: true }, { onEndTrip() {} });
|
||
assert.equal(findByClass(cardMs, "text-body-sm").textContent, strings.ms.guideNoRerouteBody);
|
||
});
|
||
});
|
||
|
||
/* ————— GS9 — the abandon-route confirm card (D-09/PX-03) ————— */
|
||
|
||
test("GS9 confirm card — GUIDE // CONFIRM, the exact privacy copy, btn-primary YES ABANDON + btn-secondary CONTINUE, bottom-register hook", () => {
|
||
withDocument(() => {
|
||
for (const locale of ["en", "ms"]) {
|
||
let abandoned = 0;
|
||
let continued = 0;
|
||
const card = buildConfirmCard(locale, { onAbandon() { abandoned += 1; }, onContinue() { continued += 1; } });
|
||
assert.equal(findByClass(card, "card-ref").textContent, `${strings[locale].guideRegister} // CONFIRM`);
|
||
assert.equal(findByClass(card, "text-headline-md").textContent, strings[locale].guideConfirmHeadline);
|
||
const body = findByClass(card, "text-body-sm");
|
||
assert.equal(body.textContent, strings[locale].guideConfirmBody, "the privacy clause at the exact moment it executes");
|
||
const primary = findByClass(card, "btn-primary");
|
||
const secondary = findByClass(card, "btn-secondary");
|
||
assert.ok(primary && secondary, "both CTAs exist");
|
||
assert.equal(primary.textContent, strings[locale].ctaConfirmAbandon, "the working forward path is primary — deliberately NOT red");
|
||
assert.equal(secondary.textContent, strings[locale].ctaConfirmContinue);
|
||
primary.click();
|
||
secondary.click();
|
||
assert.equal(abandoned, 1);
|
||
assert.equal(continued, 1);
|
||
// the ONE .btn-primary in all guidance chrome lives on THIS card
|
||
assert.equal(findAllByClass(card, "btn-primary").length, 1);
|
||
}
|
||
// the bottom-register placement hook (thumb-reach, D-09) rides the card
|
||
withDocument(() => {
|
||
const card = buildConfirmCard("en", { onAbandon() {}, onContinue() {} });
|
||
assert.ok(card.className.includes("guide-confirm"), "the mount reads guide-confirm → bottom register");
|
||
});
|
||
});
|
||
});
|
||
|
||
/* ————— GS10 — the trip-ended card (D-14) ————— */
|
||
|
||
test("GS10 trip-end card — GUIDE // END, the exact deletion copy, NO actions", () => {
|
||
withDocument(() => {
|
||
for (const locale of ["en", "ms"]) {
|
||
const card = buildTripEndCard(locale);
|
||
assert.equal(findByClass(card, "card-ref").textContent, `${strings[locale].guideRegister} // END`);
|
||
assert.equal(findByClass(card, "text-headline-md").textContent, strings[locale].guideEndHeadline);
|
||
const body = findByClass(card, "text-body-sm");
|
||
assert.equal(body.textContent, strings[locale].guideEndBody);
|
||
assert.equal(findByClass(card, "card-actions"), null, "no actions — ONE card for arrival and abandon");
|
||
const buttons = [];
|
||
const walk = (el) => {
|
||
for (const child of el.children) {
|
||
if (child.tagName === "BUTTON") buttons.push(child);
|
||
walk(child);
|
||
}
|
||
};
|
||
walk(card);
|
||
assert.equal(buttons.length, 0, "the trip-end card carries no buttons at all");
|
||
}
|
||
});
|
||
});
|
||
|
||
/* ————— GS11 — the shell.css guidance block (token-only CSS law) ————— */
|
||
|
||
test("GS11 shell.css guidance block — zero transition/animation/box-shadow/border-radius, zero hex literals, token vars only; strip compaction is CSS-only", () => {
|
||
const cssPath = path.join(HERE, "..", "src", "shell.css");
|
||
const css = readFileSync(cssPath, "utf8");
|
||
const marker = css.indexOf("guidance cockpit");
|
||
assert.ok(marker > 0, "the guidance block exists in shell.css");
|
||
const block = css.slice(marker);
|
||
assert.ok(block.length > 500, `the block dresses the whole cockpit (found ${block.length} chars)`);
|
||
for (const banned of ["transition", "animation", "box-shadow", "border-radius"]) {
|
||
assert.ok(!block.includes(banned), `guidance block uses ${banned} — states are instant hard swaps, zero depth`);
|
||
}
|
||
const hex = /#[0-9a-fA-F]{3,8}/.exec(block);
|
||
assert.equal(hex, null, `guidance block carries a hex literal (${hex && hex[0]}) — token vars only`);
|
||
assert.ok(block.includes('[data-guidance="active"]'), "the strip compaction hook exists");
|
||
assert.ok(block.includes("--color-signal-orange"), "the accent rides the token");
|
||
// the compaction is PURE CSS — strip.ts logic untouched (Phase 3 table stands)
|
||
const stripSrc = codeText(path.join(HERE, "..", "src", "ui", "strip.ts"));
|
||
assert.ok(!stripSrc.includes("data-guidance"), "strip.ts never reads data-guidance — compaction is layout-only CSS");
|
||
assert.ok(!stripSrc.includes("guide"), "strip.ts carries no guidance furniture");
|
||
});
|
||
|
||
/* ————— GS12 — the ONE .btn-primary placement ————— */
|
||
|
||
test("GS12 banner.ts places .btn-primary exactly once — the confirm card's YES, ABANDON (comment-stripped)", () => {
|
||
const src = codeText(path.join(GUIDANCE_DIR, "banner.ts"));
|
||
assert.equal((src.match(/btn-primary/g) ?? []).length, 1, "only the confirm card builds a primary CTA");
|
||
assert.ok((src.match(/btn-secondary/g) ?? []).length >= 2, "END TRIP + CONTINUE are secondary");
|
||
});
|