#!/usr/bin/env node /* * guidance-eta.test.mjs — GUID-03: the honest ETA (D-07 EMA + deadband). * ------------------------------------------------------------------------ * The eta module consumes NUMBERS (remainingS/remainingM computed by the * caller from the route model + snapped progress) — never a Fix, never a * raw fix-to-destination distance (the D-07 law is structural: the * signature cannot lie). Cases: * * E1 constants pinned (EMA_ETA_ALPHA 0.25, deadbands 60 s / 5%) * E2 the first update seeds the EMA directly (no smoothing toward zero) * E3 ±40 s jitter around a steady value never moves the display * E4 a genuine 5-minute drop updates the display within 2 fixes and * converges (alpha 0.25: within 10% of target after ~9-12 fixes) * E5 the axes are independent: a time jump moves only the clock, a * distance jump moves only the remaining metres * E6 the distance deadband: a 1% drift holds, a 6% jump updates * E7 composeEtaClock renders the 24-hour HH:MM instrument format * E8 structural: the module is pure and Fix-free (numbers only) */ 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 { updateEta, composeEtaClock, EMA_ETA_ALPHA, DEADBAND_MIN_S, DEADBAND_REMAINING_PCT } = await import( "../src/guidance/eta.ts" ); /* ————— E1 — the constants ————— */ test("E1 eta constants are exported at their pinned D-07 values", () => { assert.equal(EMA_ETA_ALPHA, 0.25); assert.equal(DEADBAND_MIN_S, 60); assert.equal(DEADBAND_REMAINING_PCT, 0.05); }); /* ————— E2 — seeding ————— */ test("E2 the first update seeds both axes directly — no smoothing toward zero", () => { const s = updateEta(null, 600, 10000); assert.equal(s.etaS, 600); assert.equal(s.remainingM, 10000); assert.equal(s.displayedEtaS, 600, "displayed clock seeds at the raw value"); assert.equal(s.displayedRemainingM, 10000, "displayed distance seeds at the raw value"); }); /* ————— E3 — jitter holds the display ————— */ test("E3 ±40 s jitter around a steady ETA never moves the display (no whiplash)", () => { let s = updateEta(null, 600, 10000); const displayed = []; for (let i = 0; i < 60; i++) { const raw = i % 2 === 0 ? 560 : 640; // ±40 s around 600 s = updateEta(s, raw, 10000); displayed.push(s.displayedEtaS); } assert.ok(displayed.every((d) => d === 600), "the displayed clock holds through the whole jitter stream"); // the EMA itself tracks loosely around the truth (bounded oscillation) assert.ok(Math.abs(s.etaS - 600) < 30, `the smoothed value stays near 600 (got ${s.etaS})`); }); /* ————— E4 — a real drop propagates ————— */ test("E4 a genuine 5-minute drop updates within 2 fixes and converges to the target", () => { let s = updateEta(null, 600, 10000); for (let i = 0; i < 10; i++) s = updateEta(s, 600, 10000); // steady cruise assert.equal(s.displayedEtaS, 600); s = updateEta(s, 300, 10000); // motorway ramp — the honest drop assert.equal(s.displayedEtaS, 525, "the display moves after ONE fix (75 s >= the 60 s deadband)"); s = updateEta(s, 300, 10000); assert.ok(s.displayedEtaS < 600 - DEADBAND_MIN_S, "the display is on its way within 2 fixes"); // alpha 0.25 convergence: (1 - 0.25)^n < 0.1 at n = 9 — 12 fixes lands inside 10% for (let i = 0; i < 12; i++) s = updateEta(s, 300, 10000); assert.ok(Math.abs(s.etaS - 300) <= 30, `smoothed converges to the drop (got ${s.etaS})`); assert.ok(Math.abs(s.displayedEtaS - 300) <= 30, `display converges too (got ${s.displayedEtaS})`); }); /* ————— E5 — independent axes ————— */ test("E5 axes are independent: a time jump moves only the clock; a distance jump only the metres", () => { // time jump with distance pinned: only displayedEtaS moves let s = updateEta(null, 600, 10000); for (let i = 0; i < 5; i++) s = updateEta(s, 600, 10000); const before = { ...s }; for (let i = 0; i < 4; i++) s = updateEta(s, 300, 10000); // -5 min, distance constant assert.ok(s.displayedEtaS <= before.displayedEtaS - DEADBAND_MIN_S, "the clock refreshed"); assert.equal(s.displayedRemainingM, before.displayedRemainingM, "the distance display never moved"); // distance jump with time pinned: only displayedRemainingM moves s = updateEta(null, 600, 10000); for (let i = 0; i < 5; i++) s = updateEta(s, 600, 10000); const before2 = { ...s }; for (let i = 0; i < 4; i++) s = updateEta(s, 600, 12000); // +2 km, time constant assert.ok( s.displayedRemainingM >= before2.displayedRemainingM + DEADBAND_REMAINING_PCT * 12000, "the distance display refreshed past its 5% deadband", ); assert.equal(s.displayedEtaS, before2.displayedEtaS, "the clock never moved"); }); /* ————— E6 — the distance deadband ————— */ test("E6 distance deadband: a 1% drift holds the display; a real 6% jump updates it", () => { let s = updateEta(null, 600, 10000); for (let i = 0; i < 8; i++) s = updateEta(s, 600, 10100); // +1% creep assert.equal(s.displayedRemainingM, 10000, "sub-deadband drift never reaches the display"); s = updateEta(null, 600, 10000); for (let i = 0; i < 8; i++) s = updateEta(s, 600, 10600); // +6% detour assert.ok( s.displayedRemainingM > 10000, `the display follows a genuine jump (got ${s.displayedRemainingM})`, ); }); /* ————— E7 — the arrival clock ————— */ test("E7 composeEtaClock renders the 24-hour HH:MM instrument format (en/ms identical)", () => { const tenOclock = new Date(2026, 0, 1, 10, 0, 0).getTime(); assert.equal(composeEtaClock(1800, tenOclock), "10:30"); assert.equal(composeEtaClock(3660, tenOclock), "11:01"); const justBeforeMidnight = new Date(2026, 0, 1, 23, 59, 0).getTime(); assert.equal(composeEtaClock(600, justBeforeMidnight), "00:09", "rolls over midnight"); const out = composeEtaClock(59.4, new Date(2026, 5, 15, 7, 2, 0).getTime()); assert.match(out, /^\d{2}:\d{2}$/, "exactly HH:MM"); assert.equal(composeEtaClock(0, new Date(2026, 0, 1, 5, 5, 0).getTime()), "05:05", "zero-padding"); }); /* ————— E8 — structural ————— */ test("E8 eta.ts is pure and Fix-free: numbers in, no console, no storage", () => { const src = readFileSync(path.join(HERE, "..", "src", "guidance", "eta.ts"), "utf8"); const code = src.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); assert.doesNotMatch(code, /console\./, "no console in the eta module"); assert.doesNotMatch(code, /\bFix\b/, "the signature takes numbers, never a Fix (D-07 structural)"); assert.doesNotMatch(code, /localStorage|sessionStorage|fetch\(/, "no storage, no network (T-06-05)"); });