From 65852940ccf849586d6181480aa607c2957d5556 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 12:47:13 +0800 Subject: [PATCH] =?UTF-8?q?test(06-02):=20add=20failing=20honest-ETA=20sui?= =?UTF-8?q?te=20E1-E8=20=E2=80=94=20EMA=20+=20deadband=20+=20arrival=20clo?= =?UTF-8?q?ck=20(RED)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/test/guidance-eta.test.mjs | 143 +++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 web/test/guidance-eta.test.mjs diff --git a/web/test/guidance-eta.test.mjs b/web/test/guidance-eta.test.mjs new file mode 100644 index 0000000..d77d995 --- /dev/null +++ b/web/test/guidance-eta.test.mjs @@ -0,0 +1,143 @@ +#!/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)"); +});