Files
arah2/bot/test/guidance-cron.test.mjs
T
araharah-executor f07021e271 feat(06-07): PURE projectTurnCards + makeStreetAt + the 21 turn-card copy keys (GREEN)
- cron.ts: linear progress projection (clamped), stateless 180 s cadence
  (due = nowSec % 180 < 60), dead-trip gate, classifyTurn-authority next-turn
  detection over consecutive-leg bearings, 4-line HTML caption anatomy
- D-12 street by DEFAULT: makeStreetAt hops snapToGraph → matched segment's
  nameId → nameAt (never the node index); snap miss → bare-verb degradation;
  matched-unnamed → cardStreetUnnamed
- strings.ts: cardAction* (8) + cardActionBare* (8) + cardStreetUnnamed +
  cardEtaTemplate + cardHonesty + cardOpenButton, en + ms verbatim
- GC1-GC8 + CS7 green: 116 passed, 0 failed; bot:typecheck exit 0
2026-10-24 17:07:58 +08:00

328 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: elapsed 180 s of 900 → the 90° left carded at T-01, 2.0 KM, ETA 13:15 · 8.0 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 + 180, () => "Jalan Sultan Ismail");
assert.equal(card.due, true, "T0 + 180 % 180 === 0 → the window's first tick is due");
assert.equal(card.glyph, "left", "the first classified turn ahead (progress 2000 < 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],
"<b>2.0 KM — TURN LEFT ONTO JALAN SULTAN ISMAIL</b>",
"maneuver line: street-bearing DEFAULT — {dist} — {verb} {STREET} (D-12)",
);
assert.equal(lines[2], "ETA 13:15 · 8.0 KM LEFT", "telemetry line from cardEtaTemplate: remaining 8000 m / 720 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 + 180, () => "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 + 180); // 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], "<b>2.0 KM — TURN LEFT</b>", "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 + 180, () => null);
const lines = card.caption.split("\n");
assert.equal(lines[1], "<b>2.0 KM — TURN LEFT</b>", "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 + 2500 / 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: 5000, durationS: 400, startedAtSec: T0 });
const card = cron.projectTurnCards(env, T0 + 180, () => "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],
"<b>250 M — MAKE A U-TURN ONTO JALAN SULTAN ISMAIL</b>",
);
});
/* ————— 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<b>") && /— [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 + 720, () => "Jalan Sultan Ismail"); // progress 16000 > drawn
assert.equal(card.due, true, "T0 + 720 % 180 === 0 → due");
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], "<b>4.0 KM — CONTINUE ON JALAN SULTAN ISMAIL</b>", "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");
});