#!/usr/bin/env node /* * guidance-replay.test.mjs — GUID-02/GUID-06: THE D-04 reroute state machine, * replay-proven (D-16) + the D-15 distance throttle. * --------------------------------------------------------------------------- * The four committed drive fixtures (web/test/fixtures/drives/*.json) are * Fix-shaped JSON streams derived from the committed golden pair * central-market-to-merdeka-118 (public ODbL data; attribution travels in * each file's _notice). Each drive is folded fix-by-fix through the pure * reducer (createGuidanceSession + stepGuidance) — no DOM, no maplibre, no * device, no network — and every D-04 transition, the storm guard, the * missed-turn shortened grace, the haptic edge stream, and arrival are * asserted on the recorded frames. * * Cases: * R1 named constants pinned (the D-04/D-07/D-08/D-14/D-15 table) * R2 corridor-follow: start gate, on_route throughout, zero reroutes, * haptic approach/maneuver edges, arrival (D-16 corridor) * R3 off-route excursion: exactly ONE reroute-requested; storm guard * holds under an immediate second excursion (RESEARCH Pitfall 6) * R4 missed-turn excursion: shortened grace — the request fires well * inside OFF_ROUTE_GRACE_MS * R5 GPS jitter: never leaves on_route, zero reroute effects * R6 guidance start gate (GUIDANCE_START_ALONG_M; PX-03 binds in plan 05) * R7 reroute-failed -> honest no_reroute, then self-heal to on_route * R8 self-heal: off_route -> resuming -> on_route without a reroute * R9 storm-guard mechanics: a second request only after STORM_GUARD_MS * R10 projectAlongRoute: forward-only projection, cumulative remaining * R11 accuracy-aware threshold: max(35, 2xaccuracy) capped 100 * R12 structural: the module is pure (no console/document/maplibre/ * localStorage) and haversineM is THE distance authority * T1..T3 (task 3) the D-15 distance throttle + watchPosition wrapper */ import { strict as assert } from "node:assert"; import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "./harness.mjs"; const HERE = path.dirname(fileURLToPath(new URL(import.meta.url))); const g = await import("../src/guidance/guidance.ts"); const { createGuidanceSession, stepGuidance, projectAlongRoute, OFF_ROUTE_THRESHOLD_M, OFF_ROUTE_CONSECUTIVE_FIXES, OFF_ROUTE_GRACE_MS, STORM_GUARD_MS, MISSED_TURN_PAST_MANEUVER_M, MISSED_TURN_GRACE_MS, ARRIVAL_THRESHOLD_M, APPROACH_HAPTIC_M, GUIDANCE_START_ALONG_M, } = g; const { offRouteThresholdM } = g; const routing = await import("@araharah/routing/src/index.ts"); const { decodePolyline6, encodePolyline6, deriveCumulativeDistanceM, haversineM } = routing; const ROUTE_FIXTURES = JSON.parse( readFileSync(path.join(HERE, "..", "..", "routing", "src", "data", "route-fixtures.json"), "utf8"), ); /* ————— replay helpers ————— */ function loadDrive(name) { return JSON.parse(readFileSync(path.join(HERE, "fixtures", "drives", `${name}.json`), "utf8")); } /** GuidanceRouteModel from the SAME golden-pair route the drive was derived * from: decode -> points -> cumulative -> steps synthesized at the fixture's * recorded maneuver indices (OSRM-style step distances: a step runs from its * maneuver TO the next boundary; the maneuver point rides in step.geometry). */ function buildModel(fx) { const pair = ROUTE_FIXTURES.pairs.find((p) => p.pairId === fx.pairId); const points = decodePolyline6(pair.routes[0].geometry); const cumulative = deriveCumulativeDistanceM(points); const totalM = cumulative[cumulative.length - 1]; const steps = [{ kind: "depart", distanceM: cumulative[fx.maneuverIdx[0]] }]; fx.maneuverIdx.forEach((idx, n) => { const nextAlong = n + 1 < fx.maneuverIdx.length ? cumulative[fx.maneuverIdx[n + 1]] : totalM; steps.push({ kind: fx.kinds[n], distanceM: nextAlong - cumulative[idx], geometry: encodePolyline6(points.slice(idx, idx + 2)), }); }); steps.push({ kind: "arrive", distanceM: 0 }); return { points, cumulative, steps, destinationId: pair.destinationId, destinationName: "Merdeka 118", }; } /** A fresh engine-A-style route: origin at the requesting fix, a merge leg * along the drive's own next fixes, then the destination line onward. */ function buildRerouteModel(original, fixes, atIdx) { const proj = projectAlongRoute(original.points, original.cumulative, fixes[atIdx], 0); const startIdx = Math.min(proj.idx + 1, original.points.length - 1); const pts = [ { lat: fixes[atIdx].lat, lng: fixes[atIdx].lng }, { lat: fixes[Math.min(atIdx + 4, fixes.length - 1)].lat, lng: fixes[Math.min(atIdx + 4, fixes.length - 1)].lng }, ...original.points.slice(startIdx).map((p) => ({ lat: p.lat, lng: p.lng })), ]; const cumulative = deriveCumulativeDistanceM(pts); const totalM = cumulative[cumulative.length - 1]; return { points: pts, cumulative, steps: [ { kind: "depart", distanceM: totalM }, { kind: "arrive", distanceM: 0 }, ], destinationId: original.destinationId, destinationName: original.destinationName, }; } function totalOf(model) { return model.cumulative[model.cumulative.length - 1]; } /** Fold a drive through the machine; records {state, deviationM, alongM, * remainingM, ts, effects} per fix. handler(e, ctx) runs controller logic * between steps (it may mutate pendingEvents, folded before the next fix). */ function replayDrive(fx, handler = null) { const model = buildModel(fx); let snap = createGuidanceSession(model, fx.fixes[0], fx.fixes[0].ts); const frames = [{ state: snap.state, ts: fx.fixes[0].ts, effects: [] }]; let pendingEvents = []; for (let i = 1; i < fx.fixes.length; i++) { for (const ev of pendingEvents) { snap = stepGuidance(snap, ev, fx.fixes[i].ts).snap; } pendingEvents = []; const fix = fx.fixes[i]; const out = stepGuidance(snap, { type: "fix", fix }, fix.ts); snap = out.snap; frames.push({ state: snap.state, deviationM: snap.deviationM, alongM: snap.alongM, remainingM: snap.remainingM, ts: fix.ts, effects: out.effects, }); if (handler) handler(out.effects, { i, fix, snap, model, fixes: fx.fixes, pendingEvents }); } return { snap, frames, model }; } function effectFrames(frames, type) { return frames.flatMap((f) => f.effects.filter((e) => e.type === type).map((e) => ({ ...e, ts: f.ts }))); } /* ————— R1 — the constants table (RS4 style: exported values pinned) ————— */ test("R1 guidance constants are exported at their pinned D-04/D-07/D-08/D-14/D-15 values", () => { assert.equal(OFF_ROUTE_THRESHOLD_M, 50); assert.equal(OFF_ROUTE_CONSECUTIVE_FIXES, 3); assert.equal(OFF_ROUTE_GRACE_MS, 8000); assert.equal(STORM_GUARD_MS, 20000); assert.equal(MISSED_TURN_PAST_MANEUVER_M, 40); assert.equal(MISSED_TURN_GRACE_MS, 3000); assert.equal(ARRIVAL_THRESHOLD_M, 25); assert.equal(APPROACH_HAPTIC_M, 300); assert.equal(GUIDANCE_START_ALONG_M, 30); }); /* ————— R2 — corridor-follow (D-16) ————— */ test("R2 corridor-follow: start gate then on_route throughout, zero reroutes, haptic edges, arrival", () => { const fx = loadDrive("corridor-follow"); const { snap, frames, model } = replayDrive(fx); // start gate: first fix < 30 m along -> idle; guidance starts by ~33 m along assert.equal(frames[0].state, "idle"); assert.ok(frames.slice(1, 6).some((f) => f.state === "on_route"), "guidance starts within the first 5 fixes"); const routeFrames = frames.filter((f) => f.state !== "idle"); assert.ok(routeFrames.length > 100, "the drive is mostly guided"); assert.ok(routeFrames.every((f) => f.state === "on_route"), "state stays on_route for the whole corridor"); // ZERO reroute-requested effects on a clean corridor assert.equal(effectFrames(frames, "reroute-requested").length, 0); // haptic edges: one approach per maneuver (+ the arrival approach), // one maneuver impact per recorded turn, one arrival const approaches = effectFrames(frames, "haptic").filter((e) => e.cue === "approach"); const impacts = effectFrames(frames, "haptic").filter((e) => e.cue === "maneuver"); const arrives = effectFrames(frames, "haptic").filter((e) => e.cue === "arrive"); const arrivals = effectFrames(frames, "arrival"); assert.equal(fx.maneuverIdx.length, 5); assert.equal(impacts.length, 5, "one maneuver haptic per recorded turn"); assert.equal(approaches.length, 6, "one approach haptic per maneuver edge + arrival"); assert.equal(arrivals.length, 1, "arrival fires exactly once"); assert.equal(arrives.length, 1, "arrive haptic fires exactly once"); // the FINAL approach fires ~300 m before the final maneuver (arrival) const lastApproachFrame = frames.find((f) => f.effects.some((e) => e.type === "haptic" && e.cue === "approach" && f.remainingM <= APPROACH_HAPTIC_M && f.alongM > 1500)); assert.ok(lastApproachFrame, "the arrival approach exists late in the drive"); assert.ok( lastApproachFrame.remainingM > 250 && lastApproachFrame.remainingM <= APPROACH_HAPTIC_M, `final approach at ~300 m before arrival (got ${lastApproachFrame.remainingM.toFixed(1)} m)`, ); // arrival happens at the end-of-route threshold (D-14) const arrivalFrame = frames.find((f) => f.effects.some((e) => e.type === "arrival")); assert.ok(arrivalFrame.remainingM <= ARRIVAL_THRESHOLD_M, "arrival at snapped progress within the threshold"); assert.equal(snap.arrived, true); assert.equal(snap.state, "on_route"); }); /* ————— R3 — off-route excursion + storm guard (Pitfall 6) ————— */ test("R3 off-route excursion: exactly ONE reroute-requested; storm guard holds under the second excursion", () => { const fx = loadDrive("off-route-excursion"); const requests = []; let phase = "idle"; const { snap, frames } = replayDrive(fx, (effects, ctx) => { for (const e of effects) { if (e.type === "reroute-requested") { requests.push({ origin: e.origin, ts: ctx.fix.ts }); phase = "starting"; } } // the controller: reroute-started the next fix, reroute-applied the one after if (phase === "starting") { ctx.pendingEvents.push({ type: "reroute-started" }); phase = "routing"; } else if (phase === "routing") { ctx.pendingEvents.push({ type: "reroute-applied", model: buildRerouteModel(ctx.model, ctx.fixes, ctx.i) }); phase = "done"; } }); // exactly ONE reroute request across BOTH excursions (the storm guard holds) assert.equal(requests.length, 1, `expected 1 reroute request, got ${requests.length}`); // the request origin is the honest excursion position (off the line) assert.equal(typeof requests[0].origin.lat, "number"); assert.equal(typeof requests[0].origin.lng, "number"); const states = frames.map((f) => f.state); assert.ok(states.includes("on_route"), "starts on the route"); assert.ok(states.includes("rerouting"), "the machine tracks the in-flight reroute"); const applyIdx = states.lastIndexOf("rerouting"); assert.equal(states[applyIdx + 1], "on_route", "reroute-applied lands on_route (the new route)"); // after the apply: the SECOND excursion never re-requests and never re-enters off_route const afterApply = frames.slice(applyIdx + 1); assert.equal(effectFrames(afterApply, "reroute-requested").length, 0, "no second reroute-requested within the storm guard"); assert.ok(afterApply.every((f) => f.state !== "off_route"), "the banner stays calm under the excursion storm"); assert.equal(snap.state, "on_route", "the drive ends back on the (new) route"); }); /* ————— R4 — missed-turn shortened grace ————— */ test("R4 missed-turn: reroute fires under the shortened grace, far inside OFF_ROUTE_GRACE_MS", () => { const fx = loadDrive("missed-turn-excursion"); const requests = []; let phase = "idle"; const { snap, frames } = replayDrive(fx, (effects, ctx) => { for (const e of effects) { if (e.type === "reroute-requested") { requests.push({ ts: ctx.fix.ts }); phase = "starting"; } } if (phase === "starting") { ctx.pendingEvents.push({ type: "reroute-started" }); phase = "routing"; } else if (phase === "routing") { ctx.pendingEvents.push({ type: "reroute-applied", model: buildRerouteModel(ctx.model, ctx.fixes, ctx.i) }); phase = "done"; } }); assert.equal(requests.length, 1, "exactly one reroute request"); // the excursion window: first fix beyond the accuracy-aware threshold ... const firstOff = frames.find((f) => f.deviationM !== undefined && f.deviationM > 35); assert.ok(firstOff, "the drive goes off-threshold"); // ... to the request: well inside the STANDARD grace (shortened by the // missed-turn detector: the car is past the maneuver point) const span = requests[0].ts - firstOff.ts; assert.ok(span >= 0, "sanity"); assert.ok( span < OFF_ROUTE_GRACE_MS, `request fired ${span} ms after the first off-threshold fix — inside the standard ${OFF_ROUTE_GRACE_MS} ms grace`, ); assert.ok( span >= MISSED_TURN_GRACE_MS, `the shortened grace was still honored (>= ${MISSED_TURN_GRACE_MS} ms, got ${span} ms)`, ); // 3 consecutive off-threshold fixes preceded the request (the D-04 gate) const offRun = frames.filter((f) => f.ts <= requests[0].ts && f.deviationM > 35); assert.ok(offRun.length >= OFF_ROUTE_CONSECUTIVE_FIXES, "at least 3 consecutive off-threshold fixes before acting"); assert.equal(snap.state, "on_route", "the drive ends on the rerouted route"); }); /* ————— R5 — GPS jitter absorption ————— */ test("R5 gps-jitter: the accuracy-aware threshold + 3 fixes + grace absorb ±15 m noise — never off_route", () => { const fx = loadDrive("gps-jitter"); const { snap, frames } = replayDrive(fx); const guided = frames.filter((f) => f.state !== "idle"); assert.ok(guided.length > 100); assert.ok(guided.every((f) => f.state === "on_route"), "state NEVER leaves on_route under jitter"); assert.equal(effectFrames(frames, "reroute-requested").length, 0, "zero reroute requests"); assert.equal(effectFrames(frames, "arrival").length, 1, "the drive still arrives"); // the noise floor: the overwhelming majority of fixes ride well inside the // accuracy-aware threshold. (Transient corner artifacts — the forward-only // cursor legally rounds a 90° turn early — read larger for a fix or two // and are absorbed by the 3-consecutive + grace gate, which is the point.) const inside = guided.filter((f) => f.deviationM <= offRouteThresholdM(20)).length; assert.ok(inside / guided.length > 0.9, `>= 90% of fixes inside the honest threshold (got ${inside}/${guided.length})`); }); /* ————— R6 — guidance start gate ————— */ test("R6 start gate: a fix < 30 m along does not start guidance; >= 30 m along does", () => { const fx = loadDrive("corridor-follow"); const model = buildModel(fx); const fixes = fx.fixes; // first recorded fix sits ~4 m along — not started const snap0 = createGuidanceSession(model, fixes[0], fixes[0].ts); assert.equal(snap0.state, "idle"); assert.ok(snap0.alongM < GUIDANCE_START_ALONG_M); // a fix >= 30 m along starts guidance (PX-03 close protections bind in plan 05) const started = createGuidanceSession(model, fixes[5], fixes[5].ts); assert.ok(started.alongM >= GUIDANCE_START_ALONG_M); assert.equal(started.state, "on_route"); // and an idle session starts on the first qualifying fix const stepped = stepGuidance(snap0, { type: "fix", fix: fixes[5] }, fixes[5].ts); assert.equal(stepped.snap.state, "on_route"); assert.deepEqual(stepped.effects, []); }); /* ————— R7 — reroute-failed: the honest degradation (D-03) ————— */ test("R7 reroute-failed while rerouting -> no_reroute; a manual rejoin still recovers", () => { const fx = loadDrive("off-route-excursion"); let phase = "idle"; const { snap, frames } = replayDrive(fx, (effects, ctx) => { for (const e of effects) { if (e.type === "reroute-requested") phase = "starting"; } if (phase === "starting") { ctx.pendingEvents.push({ type: "reroute-started" }); phase = "failing"; } else if (phase === "failing") { ctx.pendingEvents.push({ type: "reroute-failed" }); phase = "done"; } }); const states = frames.map((f) => f.state); assert.ok(states.includes("rerouting"), "the reroute was in flight"); assert.ok(states.includes("no_reroute"), "the failure lands in the honest no_reroute state"); // the car rejoins the old line as the excursion decays — self-heal proceeds assert.equal(snap.state, "on_route", "a manual rejoin recovers to on_route"); assert.ok(states.includes("resuming"), "recovery walks through resuming"); assert.equal(effectFrames(frames, "reroute-requested").length, 1, "the failed attempt is still exactly one request"); }); /* ————— R8 — self-heal without any reroute ————— */ test("R8 off_route -> resuming -> on_route on a clean rejoin, no reroute events at all", () => { const fx = loadDrive("off-route-excursion"); // replay WITHOUT any controller: excursion 1 goes off_route; excursion 2's // suppression keeps the banner calm; the rejoin heals through resuming const { snap, frames } = replayDrive(fx); const states = frames.map((f) => f.state); assert.ok(states.includes("off_route"), "the excursion is detected honestly"); assert.ok(states.includes("resuming"), "the rejoin enters resuming"); const resumeIdx = states.indexOf("resuming"); assert.equal(states[resumeIdx + 1], "on_route", "resuming resolves to on_route on the next good fix"); assert.equal(effectFrames(frames, "reroute-requested").length, 1, "the un-acted request stays single"); assert.equal(snap.state, "on_route"); }); /* ————— R9 — storm-guard mechanics (unit: controlled stream) ————— */ // A synthetic 4 km straight line east of KL center — pure storm-guard probing. 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", }; } const 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: 1760000000000 + t * 1000, source: "webview", }; }; test("R9 storm guard: a re-confirmed excursion re-requests only after STORM_GUARD_MS", () => { 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. // The line runs due east, so the offset is pure LATITUDE (perpendicular) — // an along-axis offset would ride the line and never read as deviation. let snap = createGuidanceSession(model, offsetFix(base, 0, 0), 1760000000000); const requests = []; for (let t = 1; t <= 45; 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; for (const e of out.effects) { if (e.type === "reroute-requested") requests.push({ t, ts: fix.ts }); } } assert.equal(requests.length, 2, `excursion confirmed once, then once more after the guard (got ${requests.length})`); assert.equal(requests[0].t, 14, "first request: 3 consecutive fixes + 8 s grace after going off at t=6"); assert.ok( requests[1].ts - requests[0].ts >= STORM_GUARD_MS, `second request waits the full storm guard (>= ${STORM_GUARD_MS} ms)`, ); assert.equal(requests[1].t, 34, "second request lands exactly when the 20 s guard expires"); }); /* ————— R10 — projectAlongRoute ————— */ test("R10 projection: forward-only walk, snapped point, cumulative remaining", () => { const fx = loadDrive("corridor-follow"); const model = buildModel(fx); const totalM = totalOf(model); const mid = 50; const at = model.points[mid]; const fixAt = { lat: at.lat, lng: at.lng, accuracyM: 8, headingDeg: null, ts: 0, source: "webview" }; const p1 = projectAlongRoute(model.points, model.cumulative, fixAt, 0); assert.ok(haversineM(p1.snapped, at) < 1, "snapped to the route point itself"); assert.ok(Math.abs(p1.alongM - model.cumulative[mid]) < 1, "along reads the cumulative field"); assert.ok(Math.abs(p1.remainingM - (totalM - model.cumulative[mid])) < 1, "remaining is the cumulative difference (D-07)"); assert.ok(p1.idx >= mid - 1 && p1.idx <= mid, "the owning segment sits at the point"); // forward-only: a fix far BEHIND fromIdx cannot drag progress backward const behind = model.points[10]; const fixBehind = { lat: behind.lat, lng: behind.lng, accuracyM: 8, headingDeg: null, ts: 0, source: "webview" }; const p2 = projectAlongRoute(model.points, model.cumulative, fixBehind, 60); assert.ok(p2.idx >= 60, "the walk never goes backward"); assert.ok(haversineM(p2.snapped, behind) > 100, "the backward fix reads as a large deviation, not a snap"); }); /* ————— R11 — the accuracy-aware threshold ————— */ test("R11 offRouteThresholdM: max(35, 2 x accuracyM) capped at 100; null uses the nominal", () => { assert.equal(offRouteThresholdM(null), OFF_ROUTE_THRESHOLD_M, "no accuracy -> the nominal 50"); assert.equal(offRouteThresholdM(8), 35, "small accuracy floors at 35"); assert.equal(offRouteThresholdM(20), 40, "2x accuracy inside the band"); assert.equal(offRouteThresholdM(60), 100, "huge accuracy caps at 100"); assert.equal(offRouteThresholdM(25), 50, "the nominal accuracy reads the named constant"); }); /* ————— R12 — structural: THE pure module ————— */ function stripComments(src) { return src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); } test("R12 guidance.ts is pure: no console/document/maplibre/localStorage; haversineM is the distance authority", () => { const src = stripComments(readFileSync(path.join(HERE, "..", "src", "guidance", "guidance.ts"), "utf8")); assert.doesNotMatch(src, /console\./, "no console in the machine (state tokens are the controller's job)"); assert.doesNotMatch(src, /\bdocument\b/, "no DOM"); assert.doesNotMatch(src, /maplibre/, "no maplibre"); assert.doesNotMatch(src, /localStorage|sessionStorage|fetch\(/, "no storage, no network (T-06-05)"); assert.match(src, /haversineM/, "one distance authority"); });