- shouldAcceptFix: accept when first fix, >=10 m moved, or >=5 s elapsed (whichever first) — the pure accept rule over haversineM - createGuidanceWatch(geo, onFix): high-accuracy watchPosition (maximumAge: 0), six-field Fix normalization (null accuracy/heading, source webview), rejected fixes die in the wrapper, idempotent stop() clearing the watcher id (T-06-05b's privacy-minimal trip end) - replay suite T1-T3 + R12b: truth table, recorded-options structural gate, feed-only-accepted proof, stop idempotence, module purity scan
598 lines
27 KiB
JavaScript
598 lines
27 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");
|
|
// 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 modules ————— */
|
|
|
|
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");
|
|
});
|
|
|
|
test("R12b watch.ts is pure: no console/document/maplibre/localStorage; haversineM is the distance authority", () => {
|
|
const src = stripComments(readFileSync(path.join(HERE, "..", "src", "guidance", "watch.ts"), "utf8"));
|
|
assert.doesNotMatch(src, /console\./, "no console in the watch");
|
|
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");
|
|
});
|
|
|
|
/* ————— T1..T3 — the D-15 distance throttle + the watch wrapper ————— */
|
|
|
|
const w = await import("../src/guidance/watch.ts");
|
|
const { shouldAcceptFix, createGuidanceWatch, THROTTLE_MIN_MOVE_M, THROTTLE_MIN_ELAPSED_MS } = w;
|
|
|
|
/** A fix `d` meters north of the KL origin (pure-latitude spacing reads
|
|
* exactly d meters through the same haversineM the throttle uses). The
|
|
* constant is geo.ts's own EARTH_RADIUS_M-derived meters-per-degree — a
|
|
* rounded stand-in leaves a 3e-8 relative error that flips boundaries. */
|
|
const KL = { lat: 3.1513, lng: 101.6958 };
|
|
const M_PER_DEG = (6371000 * Math.PI) / 180;
|
|
const fixAt = (m, ts) => ({
|
|
lat: KL.lat + m / M_PER_DEG,
|
|
lng: KL.lng,
|
|
accuracyM: 8,
|
|
headingDeg: null,
|
|
ts,
|
|
source: "webview",
|
|
});
|
|
|
|
test("T1 throttle constants and the full shouldAcceptFix truth table", () => {
|
|
assert.equal(THROTTLE_MIN_MOVE_M, 10);
|
|
assert.equal(THROTTLE_MIN_ELAPSED_MS, 5000);
|
|
|
|
assert.equal(shouldAcceptFix(null, fixAt(0, 1000), 0), true, "the first fix is always accepted");
|
|
assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(9, 2000), 1000), false, "9 m at 1 s -> rejected");
|
|
// 10 m + 0.1 um: the haversine round-trip costs ~5e-12 m, which would flip a
|
|
// bare 10.0 — the >= semantics stay pinned exactly by the millisecond cases
|
|
assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(10.0000001, 2000), 1000), true, "10 m moved -> accepted");
|
|
assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(11, 2000), 1000), true, "11 m at 1 s -> accepted");
|
|
assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(3, 6001), 1000), true, "3 m at 5001 ms -> accepted");
|
|
assert.equal(shouldAcceptFix(fixAt(0, 1000), fixAt(3, 5999), 1000), false, "3 m at 4999 ms -> rejected");
|
|
});
|
|
|
|
test("T2 createGuidanceWatch wires high-accuracy options, normalizes the Fix, feeds only accepted fixes", () => {
|
|
let recordedOptions = null;
|
|
let successCb = null;
|
|
const feeds = [];
|
|
const geo = {
|
|
watchPosition(success, _error, options) {
|
|
recordedOptions = options;
|
|
successCb = success;
|
|
return 42;
|
|
},
|
|
clearWatch() {},
|
|
};
|
|
const watch = createGuidanceWatch(geo, (fix) => feeds.push(fix));
|
|
|
|
assert.deepEqual(recordedOptions, { enableHighAccuracy: true, maximumAge: 0 }, "high accuracy, fresh fixes only");
|
|
|
|
successCb({
|
|
coords: { latitude: KL.lat, longitude: KL.lng, accuracy: 12, heading: null },
|
|
timestamp: 1760000000000,
|
|
});
|
|
assert.equal(feeds.length, 1, "the first fix passes");
|
|
assert.deepEqual(feeds[0], {
|
|
lat: KL.lat,
|
|
lng: KL.lng,
|
|
accuracyM: 12,
|
|
headingDeg: null,
|
|
ts: 1760000000000,
|
|
source: "webview",
|
|
});
|
|
|
|
// 3 m drift 1 s later — below both thresholds — dies in the wrapper
|
|
successCb({
|
|
coords: { latitude: KL.lat + 3 / M_PER_DEG, longitude: KL.lng, accuracy: 12, heading: 45 },
|
|
timestamp: 1760000000001,
|
|
});
|
|
assert.equal(feeds.length, 1, "the rejected fix never reaches onFix");
|
|
|
|
// 11 m jump — accepted
|
|
successCb({
|
|
coords: { latitude: KL.lat + 11 / M_PER_DEG, longitude: KL.lng, accuracy: null, heading: null },
|
|
timestamp: 1760000000002,
|
|
});
|
|
assert.equal(feeds.length, 2, "the accepted fix passes");
|
|
assert.equal(feeds[1].accuracyM, null, "missing accuracy normalizes to null");
|
|
assert.equal(feeds[1].headingDeg, null, "missing heading normalizes to null");
|
|
watch.stop();
|
|
});
|
|
|
|
test("T3 stopWatch clears the issued watcher id exactly once (the privacy-minimal stop, D-14)", () => {
|
|
const cleared = [];
|
|
const geo = {
|
|
watchPosition() {
|
|
return 77;
|
|
},
|
|
clearWatch(id) {
|
|
cleared.push(id);
|
|
},
|
|
};
|
|
const watch = createGuidanceWatch(geo, () => {});
|
|
watch.stop();
|
|
assert.deepEqual(cleared, [77], "clearWatch receives the issued id");
|
|
watch.stop();
|
|
assert.equal(cleared.length, 1, "stop is idempotent — no double clear");
|
|
});
|