From ed0e0d0c8ec6cc0eaddf10716612fe602d59795c Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 17:03:49 +0800 Subject: [PATCH] test(06-07): failing turn-card cron suite GC1-GC8 + CS7 card-copy gate (RED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GC1-GC7: PURE projectTurnCards behavior — projection math, cadence table, dead-trip gate, 4-line caption anatomy, street-bearing DEFAULT (D-12) vs bare-verb degradation, real makeStreetAt against the committed mini-graph - GC8: the pure-module structural law (no console, routing-workspace imports, nameId street hop, zero KV writes) - CS7 in commands.test.mjs pins the 21 UI-SPEC turn-card keys in en + ms --- bot/test/commands.test.mjs | 60 ++++++ bot/test/guidance-cron.test.mjs | 327 ++++++++++++++++++++++++++++++++ 2 files changed, 387 insertions(+) create mode 100644 bot/test/guidance-cron.test.mjs diff --git a/bot/test/commands.test.mjs b/bot/test/commands.test.mjs index 89e040f..caa3801 100644 --- a/bot/test/commands.test.mjs +++ b/bot/test/commands.test.mjs @@ -202,3 +202,63 @@ test("CS6 D-04: command stack registers exactly /start /mula /help /privacy /pri "the command stack must be exactly the phase surface (D-04; no drift, no extras)", ); }); + +/* ————— CS7 — GUID-05 turn-card copy gate (06-07; D-12/D-13, UI-SPEC verbatim) ————— */ + +test("CS7 D-12/D-13: the 21 turn-card keys exist verbatim in en + ms — 8 street-bearing verbs, 8 bare degradation verbs, street fallback, ETA template, honesty line, OPEN button", () => { + const expected = { + en: { + cardActionDepart: "DEPART", + cardActionStraight: "CONTINUE ON", + cardActionLeft: "TURN LEFT ONTO", + cardActionRight: "TURN RIGHT ONTO", + cardActionSlightLeft: "KEEP LEFT ONTO", + cardActionSlightRight: "KEEP RIGHT ONTO", + cardActionUturn: "MAKE A U-TURN ONTO", + cardActionArrive: "ARRIVE AT", + cardActionBareDepart: "DEPART", + cardActionBareStraight: "CONTINUE", + cardActionBareLeft: "TURN LEFT", + cardActionBareRight: "TURN RIGHT", + cardActionBareSlightLeft: "KEEP LEFT", + cardActionBareSlightRight: "KEEP RIGHT", + cardActionBareUturn: "MAKE A U-TURN", + cardActionBareArrive: "ARRIVE", + cardStreetUnnamed: "UNNAMED ROAD", + cardEtaTemplate: "ETA {time} · {km} KM LEFT", + cardHonesty: "Best-effort projection — updates while the app is minimized.", + cardOpenButton: "OPEN GUIDANCE", + }, + ms: { + cardActionDepart: "MULA", + cardActionStraight: "TERUS KE", + cardActionLeft: "BELOK KIRI KE", + cardActionRight: "BELOK KANAN KE", + cardActionSlightLeft: "SEDAKIT KIRI KE", + cardActionSlightRight: "SEDAKIT KANAN KE", + cardActionUturn: "BUAT PUSARAN KE", + cardActionArrive: "TIBA DI", + cardActionBareDepart: "MULA", + cardActionBareStraight: "TERUS", + cardActionBareLeft: "BELOK KIRI", + cardActionBareRight: "BELOK KANAN", + cardActionBareSlightLeft: "SEDAKIT KIRI", + cardActionBareSlightRight: "SEDAKIT KANAN", + cardActionBareUturn: "BUAT PUSARAN", + cardActionBareArrive: "TIBA", + cardStreetUnnamed: "JALAN TANPA NAMA", + cardEtaTemplate: "ETA {masa} · {km} KM LAGI", + cardHonesty: "Unjuran cuba terbaik — dikemas kini semasa aplikasi diminimumkan.", + cardOpenButton: "BUKA PANDUAN", + }, + }; + for (const locale of ["en", "ms"]) { + for (const [key, value] of Object.entries(expected[locale])) { + assert.equal( + strings[locale][key], + value, + `${locale}.${key} must carry the UI-SPEC verbatim turn-card value`, + ); + } + } +}); diff --git a/bot/test/guidance-cron.test.mjs b/bot/test/guidance-cron.test.mjs new file mode 100644 index 0000000..e03cc23 --- /dev/null +++ b/bot/test/guidance-cron.test.mjs @@ -0,0 +1,327 @@ +#!/usr/bin/env node +/* + * guidance-cron.test.mjs — the minimized turn-card cron (06-07, GUID-05, D-11/D-12/D-13). + * ---------------------------------------------------------------------------------------- + * Source-under-test: src/guidance/cron.ts (PURE projection) and, from task 2, + * the scheduled() arm of src/webhook.ts through the same offline pattern as + * webhook.test.mjs (undici Request/Response, Map-fake KV, baseFetch-captured + * grammY sends — no workerd, no network). + * + * Pure-function block (task 1): + * GC1 projection math — T+300 of a 900 s / 10 km snapshot → the 90° left + * carded at register T-01, "667 M", ETA 13:15 · 6.7 KM LEFT + * GC2 cadence table — CARD_CADENCE_S 180; due on the first tick of each + * 180 s window (nowSec % 180 < 60); non-due ticks carry no card + * GC3 dead trip — elapsed ≥ duration (progress clamped ≥ distanceM) → + * due false, no card: the cron never cards a dead trip + * GC4 caption law — exactly 4 lines per the anatomy, ≤ 1024 chars, the + * honesty line verbatim, zero-padded register, NO envelope-derived + * string (destinationName included), and D-13's banned words absent + * GC5 street-bearing DEFAULT (D-12) vs street-less DEGRADATION — the + * resolver's street composes "{verb} {STREET}" (uppercased); a null / + * omitted resolver composes the bare verb — honest absence + * GC6 makeStreetAt against the COMMITTED mini-graph fixture — a vertex on + * the fixture's named way resolves that way's name via the snap's + * matched-segment nameId (never the node index); off-graph → null; + * an unnamed matched edge → cardStreetUnnamed + * GC7 geometry-overshoot fallback — progress past the drawn geometry + * (but below distanceM) → "straight" glyph, register T-00 + * GC8 structural — the module has no console at all (pure), imports + * classifyTurn + snapToGraph from @araharah/routing (no local turn + * classifier, no local snapper), the street hop is nameId-based, and + * zero KV writes exist in the whole cron path + * + * D-12 boundary: only PUBLIC coordinates enter these tests (KL landmarks / + * synthetic 1e-6-grid points near KL center) — no user coordinate ever does. + */ +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(import.meta.url)); // bot/test +const CRON_SRC = path.join(here, "..", "src", "guidance", "cron.ts"); + +const { strings } = await import("../src/i18n/strings.ts"); +const { encodePolyline6 } = await import("@araharah/routing/src/polyline.ts"); +const { deriveCumulativeDistanceM, haversineM } = await import("@araharah/routing/src/geo.ts"); +const { encodeGraph, decodeGraph } = await import("@araharah/routing/src/graph.ts"); + +/* ————— geometry helpers (1e-6-grid-exact construction, the codec's grid law) ————— */ + +const M_PER_DEG = (6_371_000 * Math.PI) / 180; // geo.ts's own sphere constant +const KL_LAT = 3.15; +const KL_LNG = 101.69; +const COS_KL = Math.cos(KL_LAT * (Math.PI / 180)); +const M_PER_DEG_LNG = M_PER_DEG * COS_KL; + +/** + * A 10 km three-leg path near KL center: east 4000 m, then a 90° LEFT + * (north 3500 m), then a 45° RIGHT onto the NE diagonal (2500 m). + * Bearings: 90 → 0 (delta −90 → left), 0 → 45 (delta +45 → slight-right). + */ +function threeLegPoints() { + const p0 = { lat: KL_LAT, lng: KL_LNG }; + const p1 = { lat: KL_LAT, lng: KL_LNG + 4000 / M_PER_DEG_LNG }; + const p2 = { lat: KL_LAT + 3500 / M_PER_DEG, lng: p1.lng }; + const p3 = { + lat: p2.lat + (2500 * Math.SQRT1_2) / M_PER_DEG, + lng: p2.lng + (2500 * Math.SQRT1_2) / M_PER_DEG_LNG, + }; + return [p0, p1, p2, p3]; +} + +/** The trip-start anchor for every synthetic envelope — also a due tick (% 180 === 0). */ +const T0 = 1792818000; // 2026-10-24T05:00:00Z; KL wall clock 13:00 + +let envelopeSeq = 0; +function envelope({ points, distanceM, durationS, startedAtSec, chatId = 101 }) { + envelopeSeq += 1; + const hex = envelopeSeq.toString(16).padStart(12, "0"); + return { + id: `3f2a2c3e-1b4d-4e5a-9c8d-${hex}`, + chat_id: chatId, + route_snapshot: { + destinationId: "suria-klcc", + destinationName: "Suria KLCC", + distanceM, + durationS, + geometry: encodePolyline6(points), + startedAtSec, + }, + created_at: startedAtSec, + }; +} + +/* ————— GC1 — the projection math (fixed nowSec, fake street resolver) ————— */ + +test("GC1 projection: T+300 of 900 → the 90° left carded at T-01, 667 M, ETA 13:15 · 6.7 KM LEFT", async () => { + const cron = await import("../src/guidance/cron.ts"); + const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); + const card = cron.projectTurnCards(env, T0 + 300, () => "Jalan Sultan Ismail"); + assert.equal(card.due, true, "T0 % 180 === 0 → the window's first tick is due"); + assert.equal(card.glyph, "left", "the first classified turn ahead (progress 3333 < along 4000) is the 90° left"); + assert.equal(card.maneuverIdx, 1, "register: depart(00) → left(01)"); + const lines = card.caption.split("\n"); + assert.equal(lines.length, 4, "the anatomy is exactly four lines"); + assert.equal(lines[0], "GUIDE // T-01", "instrument register, zero-padded index"); + assert.equal( + lines[1], + "667 M — TURN LEFT ONTO JALAN SULTAN ISMAIL", + "maneuver line: street-bearing DEFAULT — {dist} — {verb} {STREET} (D-12)", + ); + assert.equal(lines[2], "ETA 13:15 · 6.7 KM LEFT", "telemetry line from cardEtaTemplate: remaining 6667 m / 600 s"); + assert.equal(lines[3], strings.en.cardHonesty, "honesty line verbatim (D-13)"); +}); + +/* ————— GC2 — the cadence table ————— */ + +test("GC2 cadence: CARD_CADENCE_S 180; due true on the window's first ticks, false after, no card on a non-due tick", async () => { + const cron = await import("../src/guidance/cron.ts"); + assert.equal(cron.CARD_CADENCE_S, 180, "the 3-minute cadence is an exported named constant"); + const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); + const table = [[3600, true], [3659, true], [3660, false], [3779, false], [3780, true]]; + for (const [nowSec, expectedDue] of table) { + const card = cron.projectTurnCards(env, nowSec); + assert.equal(card.due, expectedDue, `nowSec ${nowSec}: nowSec % 180 < 60 → ${expectedDue}`); + if (!expectedDue) { + assert.equal(card.caption, null, "a non-due tick produces no card"); + assert.equal(card.glyph, null, "a non-due tick produces no glyph"); + } + } +}); + +/* ————— GC3 — the dead-trip gate ————— */ + +test("GC3 dead trip: elapsed ≥ duration → due false, no card — the cron never cards a dead trip", async () => { + const cron = await import("../src/guidance/cron.ts"); + const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); + for (const nowSec of [T0 + 900, T0 + 901, T0 + 5000]) { + const card = cron.projectTurnCards(env, nowSec); + assert.equal(card.due, false, `elapsed ${nowSec - T0} s ≥ duration 900 s → the trip would have ended`); + assert.equal(card.caption, null, "no card for a dead trip"); + } + // A zero-duration snapshot is dead on arrival (no projection is possible). + const zeroDur = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 0, startedAtSec: T0 }); + assert.equal(cron.projectTurnCards(zeroDur, T0 + 1).due, false, "duration 0 → dead"); +}); + +/* ————— GC4 — the caption law ————— */ + +test("GC4 caption law: ≤ 1024 chars, honesty verbatim, register zero-padded, no envelope string, D-13 banned words absent", async () => { + const cron = await import("../src/guidance/cron.ts"); + const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); + const card = cron.projectTurnCards(env, T0 + 300, () => "Jalan Sultan Ismail"); + assert.ok(card.caption.length <= 1024, "sendPhoto's caption budget is 1024 chars"); + assert.ok(card.caption.includes(strings.en.cardHonesty), "the honesty line rides verbatim"); + assert.match(card.caption, /^GUIDE \/\/ T-\d{2}\n/m, "the register is GUIDE // T-{zero-padded nn}"); + assert.ok(!card.caption.includes("Suria KLCC"), "NO envelope-derived string enters the caption (escape surface stays zero)"); + assert.doesNotMatch(card.caption, /live|real[- ]time|guaranteed/i, "D-13: the strongest claim is best-effort projection"); +}); + +/* ————— GC5 — street-bearing DEFAULT vs street-less DEGRADATION ————— */ + +test("GC5 street-less DEGRADATION: omitted resolver → the bare verb, same 4-line anatomy (honest absence, D-03)", async () => { + const cron = await import("../src/guidance/cron.ts"); + const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); + const card = cron.projectTurnCards(env, T0 + 300); // no streetAt + assert.equal(card.due, true); + assert.equal(card.glyph, "left"); + const lines = card.caption.split("\n"); + assert.equal(lines.length, 4, "the degraded form keeps the anatomy"); + assert.equal(lines[1], "667 M — TURN LEFT", "bare verb — street-less honest absence, never a guessed name"); +}); + +test("GC5b street resolver returning null → the bare verb too (a snap miss is honest absence)", async () => { + const cron = await import("../src/guidance/cron.ts"); + const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 }); + const card = cron.projectTurnCards(env, T0 + 300, () => null); + const lines = card.caption.split("\n"); + assert.equal(lines[1], "667 M — TURN LEFT", "null street → the bare-verb degradation"); +}); + +test("GC5c a 180° reversal carded as uturn — classifyTurn is the ONE turn authority", async () => { + const cron = await import("../src/guidance/cron.ts"); + const p0 = { lat: KL_LAT, lng: KL_LNG }; + const p1 = { lat: KL_LAT, lng: KL_LNG + 2000 / M_PER_DEG_LNG }; // east, bearing 90 + const p2 = { lat: KL_LAT, lng: KL_LNG }; // back west, bearing 270 → delta +180 → uturn + const env = envelope({ points: [p0, p1, p2], distanceM: 4000, durationS: 360, startedAtSec: T0 }); + const card = cron.projectTurnCards(env, T0 + 90, () => "Jalan Sultan Ismail"); + assert.equal(card.glyph, "uturn", "delta 180 ≥ TURN_UTURN_MIN_DEG 150 → uturn"); + assert.equal(card.maneuverIdx, 1); + assert.equal( + card.caption.split("\n")[1], + "1000 M — MAKE A U-TURN ONTO JALAN SULTAN ISMAIL", + ); +}); + +/* ————— GC6 — makeStreetAt against the committed mini-graph fixture ————— */ + +/** The committed 06-01 ground truth (25 nodes around KL center) via fs. */ +const MINI_GRAPH = readFileSync(new URL("../../routing/test/fixtures/mini-graph.bin", import.meta.url)); + +/** Node Buffers are views — the fake hands decodeGraph an exact copy + * (the webhook.test.mjs toArrayBuffer law). */ +function miniGraph() { + const buf = MINI_GRAPH; + return decodeGraph(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)); +} + +/** + * An L along the fixture's interior row 1 ("Jalan Mini 1", east) that turns + * north at a MID-SEGMENT vertex — strictly inside one graph segment (between + * the c=1 and c=2 grid columns, off every crossing way), so the snap is + * deterministic. + */ +function miniStreetPoints() { + return [ + { lat: 3.1505, lng: 101.69025 }, + { lat: 3.1505, lng: 101.69075 }, + { lat: 3.1508, lng: 101.69075 }, + ]; +} + +test("GC6 real makeStreetAt + mini-graph fixture: the carded turn's vertex resolves the named way (JALAN MINI 1)", async () => { + const cron = await import("../src/guidance/cron.ts"); + const graph = miniGraph(); + assert.notEqual(graph, null, "the committed fixture decodes"); + const points = miniStreetPoints(); + const along = deriveCumulativeDistanceM(points); + const distanceM = Math.round(along[along.length - 1]); + const env = envelope({ points, distanceM, durationS: 90, startedAtSec: T0 }); + const streetAt = cron.makeStreetAt(graph, points); + assert.equal(streetAt(1), "Jalan Mini 1", "the matched segment's nameId → nameAt (verbatim, title case)"); + assert.equal(streetAt(99), null, "an out-of-range vertex is an honest null"); + const card = cron.projectTurnCards(env, T0 + 45, streetAt); + assert.equal(card.glyph, "left"); + assert.equal(card.maneuverIdx, 1); + assert.ok( + card.caption.includes("TURN LEFT ONTO JALAN MINI 1"), + "street-bearing DEFAULT through the REAL graph hop (uppercased instrument voice)", + ); +}); + +test("GC6b unnamed matched edge → cardStreetUnnamed ('UNNAMED ROAD') — still street-bearing", async () => { + const cron = await import("../src/guidance/cron.ts"); + // A purpose-built unnamed way through the same encodeGraph contract + // (names: [] → every edge carries the UNNAMED_NAME_ID sentinel). + const meters = haversineM({ lat: 3.15, lng: 101.69 }, { lat: 3.1505, lng: 101.69 }); + const buf = encodeGraph({ + lat: [3.15, 3.1505], + lng: [101.69, 101.69], + edges: [ + { from: 0, to: 1, seconds: meters / (30 / 3.6) }, + { from: 1, to: 0, seconds: meters / (30 / 3.6) }, + ], + names: [], + maxSpeedKmh: 30, + }); + const graph = decodeGraph(buf); + assert.notEqual(graph, null); + const points = [ + { lat: 3.15, lng: 101.69 }, // north along the unnamed way... + { lat: 3.15025, lng: 101.69 }, // ...turning east at a MID-SEGMENT vertex + { lat: 3.15025, lng: 101.6905 }, + ]; + const along = deriveCumulativeDistanceM(points); + const env = envelope({ points, distanceM: Math.round(along[along.length - 1]), durationS: 120, startedAtSec: T0 }); + const streetAt = cron.makeStreetAt(graph, points); + assert.equal(streetAt(1), "UNNAMED ROAD", "nameId null (UNNAMED sentinel) → the instrument fallback, not a bare verb"); + const card = cron.projectTurnCards(env, T0 + 30, streetAt); + assert.equal(card.glyph, "right"); + assert.ok(card.caption.includes("TURN RIGHT ONTO UNNAMED ROAD"), "cardStreetUnnamed composes in the ONTO line"); +}); + +test("GC6c off-graph vertex → makeStreetAt null → the bare-verb card (degradation, not silence)", async () => { + const cron = await import("../src/guidance/cron.ts"); + const graph = miniGraph(); + // In the margin band but a full grid cell away from any node — an honest + // snap miss (the 3x3-neighborhood law). + const points = [{ lat: 3.145, lng: 101.691 }, { lat: 3.1452, lng: 101.691 }]; + const streetAt = cron.makeStreetAt(graph, points); + assert.equal(streetAt(1), null, "snap miss → null"); + const along = deriveCumulativeDistanceM(points); + const env = envelope({ points, distanceM: Math.round(along[along.length - 1]) * 2, durationS: 60, startedAtSec: T0 }); + const card = cron.projectTurnCards(env, T0 + 15, streetAt); + assert.equal(card.due, true, "the send still fires — degradation, not silence"); + assert.ok(card.caption.includes("\n") && /— [A-Z]/.test(card.caption), "a bare-verb line composes"); + assert.doesNotMatch(card.caption, /UNNAMED ROAD/, "no instrument fallback for a snap MISS — that is null, honestly"); +}); + +/* ————— GC7 — the geometry-overshoot fallback ————— */ + +test("GC7 overshoot: progress past the drawn geometry (below distanceM) → straight glyph, register T-00, trip-remaining distance", async () => { + const cron = await import("../src/guidance/cron.ts"); + const points = threeLegPoints(); // drawn length ≈ 10000 m + const env = envelope({ points, distanceM: 20000, durationS: 900, startedAtSec: T0 }); + const card = cron.projectTurnCards(env, T0 + 675, () => "Jalan Sultan Ismail"); // progress 15000 > geometry + assert.equal(card.due, true); + assert.equal(card.glyph, "straight", "no maneuver ahead on the drawn line → the straight fallback"); + assert.equal(card.maneuverIdx, 0, "the fallback register index is 00"); + const lines = card.caption.split("\n"); + assert.equal(lines[0], "GUIDE // T-00"); + assert.equal(lines[1], "5.0 KM — CONTINUE ON JALAN SULTAN ISMAIL", "the honest remaining trip distance is the line's number"); +}); + +/* ————— GC8 — structural laws (comment-stripped source introspection) ————— */ + +function stripComments(src) { + return src + .replace(/\/\*[\s\S]*?\*\//g, " ") // block comments + .replace(/(^|[^:\\])\/\/[^\n]*/g, "$1"); // line comments (benign over-stripping) +} + +test("GC8 structural: no console in the pure module; classifyTurn + snapToGraph imported from @araharah/routing; nameId-based street hop; zero KV writes", () => { + const code = stripComments(readFileSync(CRON_SRC, "utf8")); + assert.doesNotMatch(code, /console\./, "the pure module has NO console at all — the wiring logs (webhook.ts)"); + assert.match(code, /from ["']@araharah\/routing\/src\/index\.ts["']/, "the routing workspace is the one import authority"); + for (const name of ["classifyTurn", "snapToGraph", "decodePolyline6", "deriveCumulativeDistanceM", "haversineM"]) { + assert.ok(code.includes(name), `${name} must be IMPORTED, never re-implemented locally`); + } + assert.doesNotMatch(code, /function classifyTurn|function snapToGraph/, "no local turn classifier, no local snapper"); + assert.ok(code.includes("nameId"), "the street hop is id-based: snapToGraph → SnapResult.nameId → nameAt(nameId)"); + assert.doesNotMatch(code, /nameAt\(node/, "nameAt NEVER consumes the node index (edges carry names; nodes don't)"); + assert.doesNotMatch(code, /\.put\(/, "the cron path performs ZERO KV writes (the stateless cadence law, Pitfall 3)"); + assert.doesNotMatch(code, /destinationName/, "the envelope's destinationName never reaches the caption composer"); +});