Files
arah2/web/test/guidance-replay.test.mjs
T
araharah-executor b879e56bb1 test(06-02): add failing D-16 drive replay suite R1-R12 + four committed drive fixtures (RED)
- guidance-replay.test.mjs: corridor/excursion/missed-turn/jitter replays,
  storm guard, shortened grace, haptic edges, start gate, projection
- fixtures derived from golden pair central-market-to-merdeka-118
  (ODbL attribution in each _notice; Fix-shaped streams per D-16)
2026-10-24 12:35:32 +08:00

484 lines
22 KiB
JavaScript

#!/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");
// deviations stay inside the honest threshold the whole drive
assert.ok(guided.every((f) => f.deviationM <= offRouteThresholdM(20)), "deviation inside the accuracy-aware threshold");
});
/* ————— 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,
lng: p.lng + offM / (M_PER_DEG * Math.cos(p.lat * (Math.PI / 180))),
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
let snap = createGuidanceSession(model, offsetFix(base, 0, 0), 1760000000000);
const requests = [];
for (let t = 1; t <= 60; 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");
});