diff --git a/web/test/guidance-surface.test.mjs b/web/test/guidance-surface.test.mjs index f7fba56..964faea 100644 --- a/web/test/guidance-surface.test.mjs +++ b/web/test/guidance-surface.test.mjs @@ -45,6 +45,9 @@ const { buildManeuverList, startGantryBlink, stopGantryBlink, + buildGuidanceCard, + buildConfirmCard, + buildTripEndCard, MANEUVER_LIST_ID, } = await import("../src/guidance/banner.ts"); @@ -471,3 +474,151 @@ test("GS7b svgHost parses a committed glyph into a sized inline-SVG element", () 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"); +});