diff --git a/web/test/failure-state-matrix.test.mjs b/web/test/failure-state-matrix.test.mjs new file mode 100644 index 0000000..118ccc9 --- /dev/null +++ b/web/test/failure-state-matrix.test.mjs @@ -0,0 +1,562 @@ +#!/usr/bin/env node +/* + * failure-state-matrix.test.mjs — FM1..FM7, the SC2 failure-state matrix + * (phase 7, 07-02; T-07-05/T-07-12). + * -------------------------------------------------------------------------- + * Every failure class renders a DESIGNED surface, never a blank screen. + * The vectors are DATA (07-RESEARCH Pattern 3): each class is a named + * entry in FAILURE_VECTORS with its trigger and expected surface token, + * and every FM case asserts one vector's designed outcome. The behaviours + * already exist (phases 3–6) — this suite PINS them; the one gap it found + * (a <2-point geometry reaching the consult draw path) is closed in + * route.ts, the pending card instead of a half-drawn line. + * + * The FM prefix follows the HS/GS/RS suite convention. Styles follow the + * house law: behavioral cases drive the REAL modules through recording + * fakes + the minimal document fake (installed per case, restored in + * finally — the homescreen.test.mjs law); the named-vector table is the + * first-class data. No real network, no real DOM, no timer > 200 ms. + * + * Cases: + * FM1 FAILURE_VECTORS — exactly 7 named entries, ids pinned in order + * FM2 empty route → the REAL pending card renders designed text + * FM3 route error AND route timeout → the same pending surface; + * nothing drawn, state never "route", 200 ms timeout guard + * FM4 no-geometry: a polyline6 decoding to < 2 points is refused + * BEFORE the draw path — pending card, never a half-drawn line + * FM5 off-road: exactly ONE reroute-requested, storm guard holds the + * immediate second excursion, REROUTING renders the CALM slot + * FM6 old client (Bot API < 8.0, methods absent): the guidance chrome + * still builds, zero capability calls attempted (chrome-level + * vector — fullscreen.ts's own suite is 07-01's) + * FM7 location timeout: the chain lands "denied" (the designed + * failure kinds exist beside "awaiting-pin"), never a blank + */ +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"; + +/* This suite sorts BEFORE guidance-surface in the runner — it registers the + * ?raw glyph hook itself before the banner/glyph imports resolve. */ +register(new URL("./glyph-raw-loader.mjs", import.meta.url)); + +const HERE = path.dirname(fileURLToPath(new URL(import.meta.url))); + +const routing = await import("@araharah/routing/src/index.ts"); +const { decodePolyline6, encodePolyline6, deriveCumulativeDistanceM } = routing; + +const { strings } = await import("../src/i18n/strings.ts"); +const { buildGuidanceBanner, buildGuidanceCard } = await import("../src/guidance/banner.ts"); +const { buildRoutePendingCard } = await import("../src/route/route-card.ts"); +const { + createGuidanceSession, + stepGuidance, + guidanceView, + OFF_ROUTE_CONSECUTIVE_FIXES, + OFF_ROUTE_GRACE_MS, + STORM_GUARD_MS, +} = await import("../src/guidance/guidance.ts"); +const { installRouteLayers } = await import("../src/route/route-layers.ts"); +const { createRouteController } = await import("../src/route/route.ts"); +const { acquireFix } = await import("../src/location/chain.ts"); + +/* ————— the matrix as DATA (RESEARCH Pattern 3) ————— */ + +const FAILURE_VECTORS = [ + { id: "empty-route", trigger: "route options=[]", expect: "pending-card" }, + { id: "route-error", trigger: "route source throws", expect: "pending-card" }, + { id: "no-geometry", trigger: "polyline decodes to <2 points", expect: "pending-card" }, + { id: "off-road", trigger: "3 fixes beyond the accuracy-aware threshold", expect: "rerouting-or-no-reroute" }, + { id: "location-timeout", trigger: "LocationManager returns null after timeout", expect: "denied-or-awaiting-card" }, + { id: "old-client", trigger: "WebApp version < 8.0 / methods absent", expect: "guidance-usable-without-fullscreen" }, + { id: "route-timeout", trigger: "route source never resolves within the budget", expect: "pending-card" }, +]; + +/* ————— shared helpers ————— */ + +/** Minimal element fake — exactly the surface the builders compose with + * (the guidance-surface pattern, copied). */ +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); + }, + replaceChildren(...cs) { + el.children = [...cs]; + }, + 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") { + let content = { firstElementChild: null }; + Object.defineProperty(el, "innerHTML", { + set(html) { + el.rawHTML = html; + const m = /]*)>/.exec(html); + const attrs = {}; + if (m) for (const a of m[1].matchAll(/([\w-]+)="([^"]*)"/g)) attrs[a[1]] = a[2]; + const svg = makeEl("svg"); + svg.attributes = attrs; + content = { firstElementChild: svg }; + }, + get() { + return el.rawHTML ?? ""; + }, + }); + Object.defineProperty(el, "content", { + get() { + return content; + }, + }); + } + return el; +} + +const realDocument = globalThis.document; + +/** Install the document fake for the duration of `body` (sync or async), + * restored in finally — the homescreen.test.mjs law. */ +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) { + if (root === null || root === undefined) return null; + if (typeof root.className === "string" && root.className.split(/\s+/).includes(cls)) return root; + for (const child of root.children ?? []) { + const hit = findByClass(child, cls); + if (hit !== null) return hit; + } + return null; +} + +/** Collected leaf text of a fake element tree (parents never accumulate). */ +function textOf(el) { + if (el === null || el === undefined) return ""; + let out = typeof el.textContent === "string" ? el.textContent : ""; + for (const child of el.children ?? []) out += textOf(child); + return out; +} + +/** Walk every element of the fake tree. */ +function walkAll(root, acc = []) { + acc.push(root); + for (const child of root.children ?? []) walkAll(child, acc); + return acc; +} + +/** Capture console.log around an async body (restored even on failure). */ +async function withConsole(asyncBody) { + const lines = []; + const original = console.log; + console.log = (...args) => lines.push(args.map(String).join(" ")); + try { + return { result: await asyncBody(), lines }; + } finally { + console.log = original; + } +} + +/** A recording MapLike fake (the route-surface shape): setRouteDrawn's + * setData swaps and fitBounds land here — the nothing-drawn assertions. */ +function fakeMap() { + const sources = new Map(); + const handlers = {}; + const recorded = { addSource: [], addLayer: [], setData: [], fitBounds: [], flyTo: [] }; + return { + recorded, + addSource(id, data) { + recorded.addSource.push({ id, data }); + sources.set(id, { + setData(d) { + recorded.setData.push({ id, data: d }); + }, + }); + }, + addLayer(layer) { + recorded.addLayer.push(layer); + }, + getSource(id) { + return sources.get(id); + }, + fitBounds(bounds, options) { + recorded.fitBounds.push({ bounds, options }); + }, + flyTo(options) { + recorded.flyTo.push(options); + }, + getBearing() { + return 42; + }, + on(event, cb) { + (handlers[event] ??= []).push(cb); + }, + emit(event, e) { + for (const cb of handlers[event] ?? []) cb(e); + }, + }; +} + +/** Full fake dep set around the REAL controller (the RS4 fakeDeps shape, + * trimmed to the consult-path surfaces the failure vectors touch). */ +function fakeDeps({ result } = {}) { + const map = fakeMap(); + installRouteLayers(map); // production installs the sources at map load — before any consult + const recorded = { shown: [], states: [], cards: [] }; + const deps = { + map, + source: { name: "fixture", route: () => Promise.resolve(result ?? null) }, + locale: "en", + buildRouteCard: (locale, destinationName, options, selectedIndex) => { + recorded.cards.push({ kind: "route", destinationName, options, selectedIndex }); + return { className: "card", textContent: destinationName }; + }, + buildPendingCard: (locale) => { + recorded.cards.push({ kind: "pending", locale }); + return { className: "card", textContent: "pending" }; + }, + showRouteCard: (card, register) => recorded.shown.push({ card, register }), + showDestinationCard: () => {}, + follow: { follow() {} }, + getLatestFix: () => null, + attachEndpoints: () => {}, + removeEndpoints: () => {}, + onStateChange: (state) => recorded.states.push(state), + __recorded: recorded, + __map: map, + }; + return deps; +} + +const DEST = { id: "suria-klcc", name: "Suria KLCC", aliases: [], lat: 3.157, lng: 101.712, category: "mall" }; +const FIX = { lat: 3.15, lng: 101.7, accuracyM: 8, headingDeg: null, ts: 1_760_000_000_000, source: "webview" }; + +/* ————— FM1 — the matrix is data, exactly seven named vectors ————— */ + +test("FM1 FAILURE_VECTORS — exactly 7 entries, ids pinned in order (the SC2 classes)", () => { + assert.equal(FAILURE_VECTORS.length, 7, `the matrix holds 7 classes (got ${FAILURE_VECTORS.length})`); + assert.deepEqual( + FAILURE_VECTORS.map((v) => v.id), + ["empty-route", "route-error", "no-geometry", "off-road", "location-timeout", "old-client", "route-timeout"], + "the named ids, in order", + ); + for (const v of FAILURE_VECTORS) { + assert.ok(typeof v.trigger === "string" && v.trigger.length > 0, `${v.id}: trigger stated`); + assert.ok(typeof v.expect === "string" && v.expect.length > 0, `${v.id}: expected surface token stated`); + assert.ok(!v.expect.includes("blank") && !v.expect.includes("crash"), `${v.id}: never a blank/crash surface`); + } +}); + +/* ————— FM2 — empty route: the REAL pending card renders designed text ————— */ + +test("FM2 empty route — the real buildRoutePendingCard renders designed text, no blank, no undefined", () => { + withDocument(() => { + let searchedAgain = 0; + const card = buildRoutePendingCard("en", { onSearchAgain: () => { searchedAgain += 1; } }); + assert.equal(card.className.includes("card"), true, "the same card anatomy as the route card"); + const text = textOf(card); + assert.ok(text.length > 0, "the card carries text — never a blank surface"); + assert.ok(!text.includes("undefined"), "no bare 'undefined' leaked into the surface"); + assert.ok(text.includes(strings.en.routePendingHeadline), "the designed headline renders"); + assert.ok(text.includes(strings.en.routePendingBody), "the designed body renders"); + assert.ok(text.includes(strings.en.ctaSearchAgain), "the SEARCH AGAIN affordance renders"); + for (const el of walkAll(card)) { + assert.ok(typeof el.className === "string" && el.className.length > 0, "no element with an empty class"); + for (const value of Object.values(el.dataset)) { + assert.ok(value !== undefined, "no undefined dataset state on any element"); + } + } + const button = findByClass(card, "btn-secondary"); + assert.ok(button, "the secondary CTA exists"); + button.click(); + assert.equal(searchedAgain, 1, "SEARCH AGAIN fires the walk-back action"); + }); +}); + +/* ————— FM3 — route error AND route timeout land on the pending surface ————— */ + +test("FM3 route error — a REJECTING source renders the pending card, nothing drawn (T-05-04)", async () => { + const deps = fakeDeps(); + deps.source = { name: "fixture", route: () => Promise.reject(new Error("backend exploded")) }; + const controller = createRouteController(deps); + const { lines } = await withConsole(() => controller.consult(FIX, DEST)); + assert.equal(controller.state, "pending", "an exception lands on the pending surface — never a blank"); + assert.equal(deps.__recorded.states.at(-1), "pending"); + assert.equal(deps.__recorded.cards.at(-1).kind, "pending", "the designed pending card"); + assert.equal(deps.__recorded.shown.at(-1).register, "center", "the pending card is a center-register surface"); + assert.equal(deps.__map.recorded.setData.length, 0, "NOTHING drawn on the route sources"); + assert.equal(deps.__map.recorded.fitBounds.length, 0, "no camera move on failure"); + assert.ok(lines.includes("ARAHARAH-ROUTE state=pending"), "the state token is logged"); +}); + +test("FM3 route timeout — a NEVER-SETTLING source draws nothing; state stays idle/pending; 200 ms guard", async () => { + const deps = fakeDeps(); + deps.source = { name: "fixture", route: () => new Promise(() => {}) }; // never settles + const controller = createRouteController(deps); + const consult = controller.consult(FIX, DEST); + const raced = await Promise.race([ + consult.then(() => "consult-settled"), + new Promise((r) => setTimeout(() => r("timeout"), 200)), + ]); + assert.equal(raced, "timeout", "the 200 ms guard resolved — the case cannot hang the runner"); + assert.notEqual(controller.state, "route", "the state never reaches route on a hanging source"); + assert.equal(deps.__map.recorded.setData.length, 0, "NOTHING drawn while the source hangs"); + assert.equal(deps.__map.recorded.fitBounds.length, 0, "no camera move while the source hangs"); + assert.equal(deps.__recorded.cards.some((c) => c.kind === "route"), false, "no route card was built"); +}); + +/* ————— FM4 — no-geometry: refused before the draw path ————— */ + +test("FM4 no-geometry — a polyline6 decoding to < 2 points is refused; the pending card renders, never a half-drawn line", async () => { + // the vector: a single-point geometry (encode → decode round-trip proves + // the codec's honest output; a real malformed string reads the same) + const single = encodePolyline6([{ lat: 3.15, lng: 101.7 }]); + const decoded = decodePolyline6(single); + assert.ok(decoded.length < 2, `the vector's decoded point count is < 2 (got ${decoded.length})`); + // control: the committed-corpus shape is drawable + const pairGeometry = decodePolyline6( + JSON.parse(readFileSync(path.join(HERE, "..", "..", "routing", "src", "data", "route-fixtures.json"), "utf8")).pairs[0].routes[0].geometry, + ); + assert.ok(pairGeometry.length >= 2, "control: a real route decodes to >= 2 points"); + + // the controller must treat such a geometry as null (the pending card), + // NOT as a drawable route + const deps = fakeDeps(); + deps.source = { + name: "fixture", + route: () => + Promise.resolve({ + destinationId: DEST.id, + origin: { lat: FIX.lat, lng: FIX.lng }, + backend: "engine-a", + options: [ + { + geometry: { points: [{ lat: 3.15, lng: 101.7 }], cumulativeDistanceM: [0] }, + summary: { distanceM: 0, durationS: 0 }, + steps: [], + }, + ], + }), + }; + const controller = createRouteController(deps); + await withConsole(() => controller.consult(FIX, DEST)); + assert.equal(controller.state, "pending", "a <2-point geometry renders the pending card, never a drawable route"); + assert.equal(deps.__recorded.cards.at(-1).kind, "pending"); + assert.equal(deps.__map.recorded.setData.length, 0, "the half-line never reaches the map sources"); + assert.equal(deps.__map.recorded.fitBounds.length, 0, "no camera move for a refused geometry"); + assert.equal(deps.__recorded.shown.at(-1).register, "center"); +}); + +/* ————— FM5 — off-road: one request, storm guard holds, calm slot ————— */ + +/** A synthetic 4 km straight line east of KL center (the R9 storm probe's + * shape — pure excursion mechanics, no fixture coupling). */ +function straightModel(lengthM, segM = 39) { + const n = Math.ceil(lengthM / segM); + const lat0 = 3.15; + const lng0 = 101.69; + const dLng = segM / (111194.93 * Math.cos(lat0 * (Math.PI / 180))); + const points = Array.from({ length: n + 1 }, (_, i) => ({ lat: lat0, lng: lng0 + i * dLng })); + const cumulative = deriveCumulativeDistanceM(points); + return { + points, + cumulative, + steps: [ + { kind: "depart", distanceM: lengthM }, + { kind: "arrive", distanceM: 0 }, + ], + destinationId: "storm-probe", + destinationName: "Storm Probe", + }; +} + +/** A fix `offM` meters north of the line point (pure-latitude offset reads + * exactly offM through the same haversineM the machine uses). */ +function offsetFix(p, offM, t) { + const M_PER_DEG = 111194.93; + return { + lat: p.lat + offM / M_PER_DEG, + lng: p.lng, + accuracyM: 10, + headingDeg: 90, + ts: 1_760_000_000_000 + t * 1000, + source: "webview", + }; +} + +test("FM5 off-road — exactly ONE reroute-requested; the storm guard holds the immediate second excursion; the machine lands rerouting with the CALM slot", async () => { + const model = straightModel(4000); + const base = model.points[0]; + // on-line fixes t=0..5 (start gate), then a permanent 80 m offset from t=6 + let snap = createGuidanceSession(model, offsetFix(base, 0, 0), 1_760_000_000_000); + const requests = []; + const allEffects = []; + for (let t = 1; t <= 19; t++) { + const fix = offsetFix(model.points[Math.min(t, model.points.length - 1)], t < 6 ? 0 : 80, t); + const out = stepGuidance(snap, { type: "fix", fix }, fix.ts); + snap = out.snap; + allEffects.push(...out.effects); + for (const e of out.effects) { + if (e.type === "reroute-requested") requests.push({ t, ts: fix.ts }); + } + } + // exactly ONE request: 3 consecutive off-threshold fixes + the 8 s grace + assert.equal(requests.length, 1, `excursion confirmed once (got ${requests.length} requests)`); + assert.ok(requests[0].t >= 6 + OFF_ROUTE_CONSECUTIVE_FIXES, "the request waited for 3 consecutive fixes"); + const elapsed = requests[0].ts - (1_760_000_000_000 + 6 * 1000); + assert.ok(elapsed >= OFF_ROUTE_GRACE_MS, `the request honored the grace (>= ${OFF_ROUTE_GRACE_MS} ms)`); + // the IMMEDIATE second excursion (t=15..19, inside the storm guard) stays + // silent — the calm law, never a reroute storm + assert.ok( + 1_760_000_000_000 + 19 * 1000 - requests[0].ts < STORM_GUARD_MS, + "the second excursion is inside the storm guard window", + ); + assert.ok( + allEffects.filter((e) => e.type === "reroute-requested").length === 1, + "the storm guard held — no second request fired", + ); + // the controller answers: reroute-started -> the machine lands rerouting + const stepped = stepGuidance(snap, { type: "reroute-started" }, 1_760_000_000_000 + 20 * 1000); + assert.equal(stepped.snap.state, "rerouting", "rerouting — not off_route hanging, not a throw"); + + // the banner's REROUTING slot is the CALM one, built by the REAL builder + await withDocument(async () => { + const view = guidanceView(stepped.snap, false); + const banner = buildGuidanceBanner( + "en", + view.banner, + { onToggle() {} }, + ); + const text = textOf(banner); + assert.ok(text.length > 0, "the banner renders designed copy — never a blank"); + const register = findByClass(banner, "guide-register"); + assert.equal(register.textContent, `${strings.en.guideRegister} // REROUTING`, "the REROUTING register word"); + const street = findByClass(banner, "guide-street"); + assert.equal(street.textContent, strings.en.guideReroutingLine, "the calm copy, no blame"); + const BLAME_RE = /you (went|drove|missed)|wrong turn|went off|missed the turn|off the route|recalculating/i; + assert.ok(!BLAME_RE.test(text), `the calm slot blames nothing (banner text: ${JSON.stringify(text.slice(0, 120))})`); + assert.ok(findByClass(banner, "guide-gantry") !== null, "the REROUTING gantry (the machine-blink wait state) renders"); + // the guidance card renders non-empty too — the full designed surface + const card = buildGuidanceCard( + "en", + { + ...view.card, + minutesRemaining: 9, + kmRemaining: 1.4, + etaClock: "18:42", + }, + { onEndTrip() {} }, + ); + assert.ok(textOf(card).length > 0, "the guidance card renders designed text"); + assert.equal(findByClass(card, "text-headline-md").textContent, "Storm Probe", "the destination headline holds"); + }); +}); + +/* ————— FM6 — old client: the chrome builds, zero capability calls ————— */ + +test("FM6 old client (Bot API 7.0, no 8.0 members) — the guidance chrome still builds; no capability call is attempted", () => { + // the old-client fake: version 7.0, isVersionAtLeast present, the 8.0 + // fullscreen/orientation members ABSENT (the SC2.e shape). A recording + // wrapper proves the chrome BUILD path calls nothing. + const calls = []; + const wa = { + initData: "", + version: "7.0", + isVersionAtLeast: (v) => { + calls.push(`isVersionAtLeast:${v}`); + const [aMaj, aMin] = v.split(".").map(Number); + return 7 > aMaj || (7 === aMaj && 0 >= aMin); + }, + // NO requestFullscreen / exitFullscreen / isFullscreen / lockOrientation members + }; + assert.equal(wa.requestFullscreen, undefined, "the old-client shape: the 8.0 method is absent"); + + withDocument(() => { + const banner = buildGuidanceBanner( + "en", + { state: "on_route", glyph: "left", distanceM: 250, street: "Jalan Sultan Ismail", listOpen: false }, + { onToggle() {} }, + ); + assert.ok(textOf(banner).length > 0, "the banner builds — non-empty element"); + assert.ok(findByClass(banner, "guide-plate") !== null, "the arrow plate mounts"); + const card = buildGuidanceCard( + "en", + { destinationName: "Suria KLCC", minutesRemaining: 9, kmRemaining: 1.4, etaClock: "18:42", progressPct: 35, showNoReroute: false }, + { onEndTrip() {} }, + ); + assert.ok(textOf(card).length > 0, "the guidance card builds — non-empty element"); + assert.equal(findByClass(card, "text-headline-md").textContent, "Suria KLCC"); + assert.equal(findByClass(banner, "guide-register").textContent, `${strings.en.guideRegister} // NEXT`); + // no capability call was attempted by the chrome build path + assert.deepEqual(calls, [], `the builders touched no WebApp capability (got ${JSON.stringify(calls)})`); + }); + + // this suite pins the CHROME's old-client survival; fullscreen.ts's own + // suite lives in 07-01 — the boundary stays (no fullscreen import here) + const self = readFileSync(path.join(HERE, "failure-state-matrix.test.mjs"), "utf8"); + assert.ok(!self.includes("telegram/" + "fullscreen"), "FM6 does not import the fullscreen module (07-01 owns it)"); +}); + +/* ————— FM7 — location timeout: denied / awaiting-pin, never a blank ————— */ + +test("FM7 location timeout — the chain lands a DESIGNED failure state; 'denied' + 'awaiting-pin' exist in the state vocabulary", async () => { + // the state vocabulary carries BOTH designed failure surfaces (types.ts) + const typesSrc = readFileSync(path.join(HERE, "..", "src", "location", "types.ts"), "utf8"); + assert.ok(typesSrc.includes('kind: "denied"'), "the LocationState union carries denied"); + assert.ok(typesSrc.includes('kind: "awaiting-pin"'), "the LocationState union carries awaiting-pin"); + assert.ok(typesSrc.includes('"timeout"'), "denied's reason set carries timeout"); + + // behavioral: LINK 1 unsupported (plain browser), LINK 2 times out (null), + // LINK 3 has no token -> the chain returns a NON-NULL state object whose + // kind is a designed failure kind + const noFix = () => Promise.resolve(null); + const outcome = await acquireFix( + { wa: null, getToken: () => null, readWebview: noFix, pollPin: noFix }, + null, + ); + assert.notEqual(outcome.state, null, "the failure branch returns a NON-NULL state object"); + assert.equal(outcome.fix, null); + assert.equal(outcome.state.kind, "denied", "the designed denied state"); + assert.equal(outcome.state.reason, "no-permission"); + + // the timeout reason: the pin poll expires -> denied/timeout (LINK 3) + const withToken = await acquireFix( + { wa: null, getToken: () => "tok", readWebview: noFix, pollPin: noFix }, + null, + ); + assert.equal(withToken.state.kind, "denied", "the designed denied state"); + assert.equal(withToken.state.reason, "timeout", "the timeout reason is carried, not swallowed"); +});