Files
arah2/web/test/guidance-surface.test.mjs

726 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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");
const { fireHaptic } = await import("../src/guidance/haptics.ts");
const { engageCloseProtection, releaseCloseProtection } = await import("../src/guidance/close-protection.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");
// prose is not a call site (GEO3 pattern): strip CSS comments, then scan
const block = css
.slice(marker)
.replace(/\/\*[\s\S]*?\*\//g, " ");
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.equal((src.match(/btn-secondary/g) ?? []).length, 1, "every other action rides the shared secondary-button helper");
});
/* ————— GS13/GS14 — the capability guards (D-08 haptics, PX-03 close
protection): recording fakes prove every call is gated BEFORE it happens
and degrades to an honest no-op (T-06-06) ————— */
/** Bot-API semver compare ("7.7" vs "8.0"). */
function atLeast(actual, wanted) {
const [aMaj, aMin] = actual.split(".").map(Number);
const [wMaj, wMin] = wanted.split(".").map(Number);
return aMaj > wMaj || (aMaj === wMaj && aMin >= wMin);
}
/** A recording TelegramWebAppLike fake — every guard-relevant call lands in
* `rec`; members can be deleted to prove the no-op paths. */
function fakeWa({ version = "8.0", haptics = true, versionGate = true } = {}) {
const rec = { haptic: [], close: [], versionChecks: [] };
const wa = {
initData: "",
HapticFeedback: haptics
? {
selectionChanged() { rec.haptic.push("selection"); },
impactOccurred(style) { rec.haptic.push(`impact:${style}`); },
notificationOccurred(type) { rec.haptic.push(`notify:${type}`); },
}
: undefined,
disableVerticalSwipes() { rec.close.push("disableVerticalSwipes"); },
enableVerticalSwipes() { rec.close.push("enableVerticalSwipes"); },
enableClosingConfirmation() { rec.close.push("enableClosingConfirmation"); },
disableClosingConfirmation() { rec.close.push("disableClosingConfirmation"); },
};
if (versionGate) {
wa.isVersionAtLeast = (v) => {
rec.versionChecks.push(v);
return atLeast(version, v);
};
}
return { wa, rec };
}
test("GS13 fireHaptic routes the three cues — selectionChanged / impactOccurred('medium') / notificationOccurred('success')", () => {
const { wa, rec } = fakeWa();
fireHaptic(wa, "approach");
fireHaptic(wa, "maneuver");
fireHaptic(wa, "arrive");
assert.deepEqual(rec.haptic, ["selection", "impact:medium", "notify:success"], "the UI-SPEC haptics table, verbatim");
});
test("GS13b fireHaptic degrades silently — no HapticFeedback member and a null wa are no-ops, never throws (D-08)", () => {
const bare = fakeWa({ haptics: false });
fireHaptic(bare.wa, "approach");
fireHaptic(bare.wa, "maneuver");
fireHaptic(bare.wa, "arrive");
assert.deepEqual(bare.rec.haptic, [], "nothing recorded, nothing thrown");
fireHaptic(null, "arrive"); // plain browser — must not throw
// the no-haptic law is documented in the module header (D-08 + no blame)
const src = readFileSync(path.join(GUIDANCE_DIR, "haptics.ts"), "utf8");
assert.ok(src.includes("D-08"), "the header cites D-08");
assert.ok(src.toLowerCase().includes("reroute"), "the header carries the no-haptic-on-reroute law");
});
test("GS14 engageCloseProtection — body-swipe off at 7.7+, native confirm at 6.2+, each BEFORE its call, layered in order", () => {
const modern = fakeWa({ version: "8.0" });
engageCloseProtection(modern.wa);
assert.deepEqual(modern.rec.close, ["disableVerticalSwipes", "enableClosingConfirmation"], "both layers engage in order");
assert.deepEqual(modern.rec.versionChecks, ["7.7", "6.2"], "each gate checked before its own call");
const mid = fakeWa({ version: "7.0" });
engageCloseProtection(mid.wa);
assert.deepEqual(mid.rec.close, ["enableClosingConfirmation"], "7.0: the 7.7 layer stays a no-op");
const old = fakeWa({ version: "6.1" });
engageCloseProtection(old.wa);
assert.deepEqual(old.rec.close, [], "6.1: nothing engages — silent no-op");
const gateless = fakeWa({ versionGate: false });
engageCloseProtection(gateless.wa);
assert.deepEqual(gateless.rec.close, [], "no version gate → no calls, no throw (Pitfall 5)");
engageCloseProtection(null); // must not throw
});
test("GS14b releaseCloseProtection inverts both layers under the same gates", () => {
const modern = fakeWa({ version: "8.0" });
releaseCloseProtection(modern.wa);
assert.deepEqual(modern.rec.close, ["enableVerticalSwipes", "disableClosingConfirmation"], "trip end: both layers release");
const mid = fakeWa({ version: "6.5" });
releaseCloseProtection(mid.wa);
assert.deepEqual(mid.rec.close, ["disableClosingConfirmation"], "6.5: only the 6.2 layer releases");
const old = fakeWa({ version: "6.1" });
releaseCloseProtection(old.wa);
assert.deepEqual(old.rec.close, []);
releaseCloseProtection(null); // must not throw
});
test("GS14c the version literals are pinned — isVersionAtLeast(\"7.7\") and isVersionAtLeast(\"6.2\") in close-protection.ts", () => {
const src = readFileSync(path.join(GUIDANCE_DIR, "close-protection.ts"), "utf8");
assert.ok(src.includes('isVersionAtLeast("7.7")'), "the 7.7 body-swipe gate");
assert.ok(src.includes('isVersionAtLeast("6.2")'), "the 6.2 native-confirm gate");
assert.ok(src.includes("?.") || src.includes("?."), "optional-member chains throughout");
});