#!/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"); });