- FAILURE_VECTORS as data: 7 classes (empty/error/no-geometry/off-road/ location-timeout/old-client/route-timeout), each with trigger + expected surface - FM2 drives the REAL buildRoutePendingCard (text, no undefined, CTA wired) - FM3 rejecting + never-settling sources both stay off the draw path (200ms guard) - FM4 RED: a <2-point geometry currently reaches the draw path — the pending card must replace the half-drawn line (T-07-12) - FM5 pure-reducer excursion: one reroute-requested, storm guard holds, CALM slot - FM6 old-client chrome builds with zero capability calls (07-01 owns fullscreen) - FM7 location chain lands designed denied/awaiting-pin states
563 lines
24 KiB
JavaScript
563 lines
24 KiB
JavaScript
#!/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 = /<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 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");
|
||
});
|