144 lines
6.8 KiB
JavaScript
144 lines
6.8 KiB
JavaScript
#!/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)");
|
|
});
|