Files
arah2/bot/test/guidance-cron.test.mjs
T
araharah-executor 4ca7a71d15 feat(08.1-01): rebuild all fixtures and bot graph builders through the ARG3 path
- mini-graph-arg2.bin: the frozen ARG2-era bytes preserved once as the
  D-12 decode-rejection vector (never regenerated — the arg1 precedent)
- build-mini-graph.mjs: landmark stage out, CH stage in — contractGraph
  over the 25-node grid (41 shortcuts, 1.64/node, 0 budget hits) with
  fail-loud invariants: total-order rank, grid shortcut band, upward
  shortcuts, overlay-exact constituent weights (f64 replay, f32 compare),
  strong connectivity; deterministic ARG3 bytes
- graph.test.mjs: committed-fixture G4 added (ARG3 magic/version, 25
  nodes, 80 edges, rank 25, >=1 shortcut, ARG2 vector -> null); G7
  restored (full ARG3 pin)
- astar.test.mjs: A1-A6 restored — the 625-pair Dijkstra equivalence,
  oneway law, deriveSteps, depart/arrive, classifyTurn all green against
  the CH query on the committed ARG3 fixture
- snap.test.mjs: S1-S6 restored on the ARG3 fixture; S3's synthetic
  graph rebuilt as the ARG3 ch-block star
- webhook.test.mjs: landmarkBlock -> chBlock (contractGraph); corridor
  gains the fast nonstop chord (a pure line starves to zero shortcuts);
  sparse star keeps node ids 0-hub; RT9's split carrier is now two
  disconnected decodable stars (the honest A\* miss)
- guidance-cron.test.mjs: GC6b's 2-node graph becomes the lowest-id-mid
  3-node star (a 2-node graph cannot carry a valid shortcut)
- routing 87 passed / bot 126 passed; routing+bot+web typechecks green
2026-10-26 07:30:15 +08:00

674 lines
34 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 240 (launch-gate retune, 2025-10-25:
* 2-minute cron ticks x 1 list-op = 720/day, under the 1,000/day free
* ceiling — the $0 law); 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, readdirSync } 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");
const { contractGraph } = await import("@araharah/routing/src/contraction.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 at the retuned due tick (900 s total) → the 90° left carded at T-01, 2.0 KM, ETA 13:16 · 8.0 KM LEFT", async () => {
const cron = await import("../src/guidance/cron.ts");
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 + 60 });
const card = cron.projectTurnCards(env, T0 + 240, () => "Jalan Sultan Ismail");
assert.equal(card.due, true, "T0 + 240 % 240 === 0 → the window's first tick is due (the 240 s retune)");
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:16 · 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 240 (*/2-tick retune); 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, 240, "the 4-minute card cadence is an exported named constant (launch-gate retune: */2 cron + 240 s = 720 list-ops/day, under the 1,000 ceiling)");
const env = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: T0 });
const table = [[3600, true], [3659, true], [3660, false], [3779, false], [3840, true], [3899, true], [3900, false], [4079, false], [4080, true]];
for (const [nowSec, expectedDue] of table) {
const card = cron.projectTurnCards(env, nowSec);
assert.equal(card.due, expectedDue, `nowSec ${nowSec}: nowSec % 240 < 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 + 60 });
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 + 240, () => "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 + 60 });
const card = cron.projectTurnCards(env, T0 + 240); // 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 + 60 });
const card = cron.projectTurnCards(env, T0 + 240, () => 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 + 60 });
const card = cron.projectTurnCards(env, T0 + 240, () => "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 });
// A 3-node star through the same encodeGraph contract (names: [] →
// every edge carries the UNNAMED_NAME_ID sentinel). ARG3 (Phase 08.1):
// the ch block comes from the ONE contraction core, and the MID owns
// the LOWEST id — the priority tie contracts lowest-id-first, so a mid
// with id 1 would starve to zero shortcuts and an ARG3 with no
// shortcuts is not a road graph. Geometry: north leg (0->1), east leg
// (0->2) — the turn at the shared mid vertex.
const starEdges = [
{ from: 0, to: 1, seconds: meters / (30 / 3.6) },
{ from: 1, to: 0, seconds: meters / (30 / 3.6) },
{ from: 0, to: 2, seconds: meters / (30 / 3.6) },
{ from: 2, to: 0, seconds: meters / (30 / 3.6) },
];
const buf = encodeGraph({
lat: [3.15, 3.1505, 3.15],
lng: [101.69, 101.69, 101.6905],
edges: starEdges,
names: [],
maxSpeedKmh: 30,
ch: contractGraph({ nodeCount: 3, edges: starEdges }),
});
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");
});
/* ————————————————————————————————————————————————————————————————————————————
* scheduled() — the wired tick (task 2; GC-C1..GC-C7).
*
* The ExportedHandler's scheduled arm, invoked DIRECTLY offline: controller =
* { cron, scheduledTime } fake, env = Map-fake KV (list-paginated) + R2 fake +
* BOT_TOKEN + MINI_APP_URL, ctx = waitUntil-recording fake, grammY sends
* captured through createBot(env, { baseFetch }). The fake KV's put THROWS —
* any KV write inside the tick fails the suite behaviorally, on top of the
* structural scan (GC-C6).
* ———————————————————————————————————————————————————————————————————————————— */
const TEST_TOKEN = "12345:TESTTOKEN"; // the initdata/webhook test signer token
const ORIGIN = "https://araharah-bot.araharah-bot.workers.dev"; // the wrangler vars value
/** A waitUntil-recording ExecutionContext fake. */
function makeCtx() {
const pending = [];
return {
ctx: { waitUntil: (p) => pending.push(Promise.resolve(p).catch(() => {})) },
drain: () => Promise.all(pending),
};
}
/** Console-capture helper (the withConsole precedent). */
async function withConsole(fn) {
const logs = [];
const orig = console.log;
console.log = (...args) => logs.push(args.join(" "));
try {
return { value: await fn(), logs };
} finally {
console.log = orig;
}
}
/**
* A list-capable Map-fake KV: pages are consumed in order (list_complete +
* cursor respected upstream); put THROWS — the cron tick must never write.
*/
function makeListKV(map, pages) {
let pageIdx = 0;
let listCalls = 0;
return {
async get(key) {
if (this.throwOnGet !== undefined && key === this.throwOnGet) throw new Error("KV read failed");
return map.has(key) ? map.get(key) : null;
},
async put() {
throw new Error("the cron tick performed a KV write — the stateless law forbids it");
},
async delete() {},
async list() {
listCalls += 1;
return pageIdx < pages.length ? pages[pageIdx++] : { keys: [], list_complete: true };
},
get listCalls() {
return listCalls;
},
map,
};
}
/** An R2 fake over one fixed binary or absent (the webhook.test.mjs shape). */
function r2With(maybeBuf) {
let gets = 0;
return {
gets: () => gets,
async get() {
gets += 1;
if (maybeBuf === null) return null;
const buf = maybeBuf;
return { arrayBuffer: async () => buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) };
},
};
}
function makeEnv(kv, r2, overrides = {}) {
return {
BOT_TOKEN: TEST_TOKEN,
WEBHOOK_SECRET: "wk-test-secret",
chat_sessions: kv,
ROAD_GRAPH: r2,
MINI_APP_URL: ORIGIN,
...overrides,
};
}
function controllerFor(nowSec) {
return { cron: "*/1 * * * *", scheduledTime: nowSec * 1000 };
}
async function sentPhotos(calls) {
const out = [];
for (const call of calls) {
if (!call.url.includes("/sendPhoto")) continue;
const body = call.init?.body;
let params;
if (typeof body === "string") params = JSON.parse(body);
else if (body && typeof body.entries === "function") {
params = {};
for (const [k, v] of body.entries()) params[k] = v;
}
if (typeof params?.reply_markup === "string") params.reply_markup = JSON.parse(params.reply_markup);
out.push(params);
}
return out;
}
const TICK_SEC = T0 + 240; // % 240 === 0 → a due tick (the launch-gate retune)
test("GC-C1 due tick: the alive envelope's card sends to ITS OWN chat, street-bearing from the mini-graph R2 fake", async () => {
const mod = await import("../src/webhook.ts");
assert.equal(typeof mod.createExportedHandler, "function", "the handler is built by an injectable factory (offline send capture)");
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 202 });
const envB = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: TICK_SEC - 10000, chatId: 303 });
const map = new Map();
map.set(`guidance:${envA.id}`, JSON.stringify(envA));
map.set(`guidance:${envB.id}`, JSON.stringify(envB)); // dead trip — never carded
map.set(`guidance:3f2a2c3e-1b4d-4e5a-9c8d-0000000000c3`, "not json"); // malformed
const kv = makeListKV(map, [{ keys: [{ name: `guidance:${envA.id}` }, { name: `guidance:${envB.id}` }, { name: "guidance:3f2a2c3e-1b4d-4e5a-9c8d-0000000000c3" }], list_complete: true }]);
const rec = { calls: [] };
const baseFetch = async (url, init) => {
rec.calls.push({ url: String(url), init });
return { ok: true, json: async () => ({ ok: true, result: { message_id: 1 } }) };
};
const { ctx, drain } = makeCtx();
const worker = mod.createExportedHandler({ baseFetch });
const { logs } = await withConsole(async () => {
await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx);
await drain(); // INSIDE the capture window — the tick's logs fire under waitUntil
});
const photos = await sentPhotos(rec.calls);
assert.equal(photos.length, 1, "exactly the due, alive envelope sends");
assert.equal(photos[0].chat_id, 202, "chat_id comes ONLY from the envelope (T-06-02)");
assert.equal(photos[0].photo, `${ORIGIN}/glyphs/left.png`, "the committed glyph PNG rides its public static URL");
assert.equal(photos[0].parse_mode, "HTML");
assert.ok(photos[0].caption.includes("TURN LEFT ONTO JALAN MINI 1"), "street-bearing card through the shared graph memo");
assert.ok(photos[0].caption.includes(strings.en.cardHonesty));
assert.equal(photos[0].reply_markup.inline_keyboard[0][0].text, "OPEN GUIDANCE");
assert.equal(
photos[0].reply_markup.inline_keyboard[0][0].web_app.url,
`${ORIGIN}/?g=${envA.id}`,
"the OPEN deep link carries the envelope id (?g=, GUID-06 bridge)",
);
assert.ok(logs.some((l) => l.includes("[guidance-cron] skipped: malformed")), "malformed is a category-only skip");
const categoryLogs = logs.filter((l) => l.startsWith("["));
for (const line of categoryLogs) {
assert.match(line, /^\[guidance-cron\] (skipped|send failed)/, `every log line is a guidance-cron category: ${line}`);
}
});
test("GC-C2 non-due tick: zero sends (the scan still runs; the cadence gates the card)", async () => {
const mod = await import("../src/webhook.ts");
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45 });
const map = new Map([["guidance:" + envA.id, JSON.stringify(envA)]]);
const kv = makeListKV(map, [{ keys: [{ name: `guidance:${envA.id}` }], list_complete: true }]);
const rec = { calls: [] };
const baseFetch = async (url, init) => {
rec.calls.push({ url: String(url), init });
return { ok: true, json: async () => ({ ok: true, result: {} }) };
};
const { ctx, drain } = makeCtx();
const worker = mod.createExportedHandler({ baseFetch });
await withConsole(() => worker.scheduled(controllerFor(TICK_SEC + 60), makeEnv(kv, r2With(MINI_GRAPH)), ctx));
await drain();
const photos = await sentPhotos(rec.calls);
assert.equal(photos.length, 0, "non-due → no send");
});
test("GC-C3 R2-null graph: the bare-verb street-less card still sends (degradation, not silence)", async () => {
const mod = await import("../src/webhook.ts");
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45 });
const map = new Map([["guidance:" + envA.id, JSON.stringify(envA)]]);
const kv = makeListKV(map, [{ keys: [{ name: `guidance:${envA.id}` }], list_complete: true }]);
const rec = { calls: [] };
const baseFetch = async (url, init) => {
rec.calls.push({ url: String(url), init });
return { ok: true, json: async () => ({ ok: true, result: {} }) };
};
const { ctx, drain } = makeCtx();
const worker = mod.createExportedHandler({ baseFetch });
await withConsole(async () => {
await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(null)), ctx);
await drain();
});
const photos = await sentPhotos(rec.calls);
assert.equal(photos.length, 1, "the send fires even without a graph");
assert.ok(photos[0].caption.includes("— TURN LEFT"), "bare verb (graph absent)");
assert.doesNotMatch(photos[0].caption, /ONTO|JALAN MINI 1/, "no street text is guessed");
});
test("GC-C4 fail-closed: missing BOT_TOKEN → skipped: unconfigured, zero sends, zero KV touches", async () => {
const { default: worker } = await import("../src/webhook.ts");
const map = new Map();
const kv = makeListKV(map, []);
const rec = { calls: [] };
const baseFetch = async (url, init) => {
rec.calls.push({ url: String(url), init });
return { ok: true, json: async () => ({ ok: true, result: {} }) };
};
const { ctx, drain } = makeCtx();
const { logs } = await withConsole(() =>
worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(null), { BOT_TOKEN: "" }), ctx),
);
await drain();
assert.equal(kv.listCalls, 0, "the tick never touches an unconfigured namespace");
assert.equal((await sentPhotos(rec.calls)).length, 0, "no sends");
assert.ok(logs.some((l) => l.includes("[guidance-cron] skipped: unconfigured")), "the webhook fail-closed posture");
});
test("GC-C5 pagination: list_complete respected — envelopes across two pages all processed", async () => {
const mod = await import("../src/webhook.ts");
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 204 });
const envA2 = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 205 });
const envB = envelope({ points: threeLegPoints(), distanceM: 10000, durationS: 900, startedAtSec: TICK_SEC - 10000, chatId: 206 });
const map = new Map();
map.set(`guidance:${envA.id}`, JSON.stringify(envA));
map.set(`guidance:${envA2.id}`, JSON.stringify(envA2));
map.set(`guidance:${envB.id}`, JSON.stringify(envB));
const kv = makeListKV(map, [
{ keys: [{ name: `guidance:${envA.id}` }, { name: `guidance:${envA2.id}` }], list_complete: false, cursor: "page-2" },
{ keys: [{ name: `guidance:${envB.id}` }], list_complete: true },
]);
const rec = { calls: [] };
const baseFetch = async (url, init) => {
rec.calls.push({ url: String(url), init });
return { ok: true, json: async () => ({ ok: true, result: {} }) };
};
const { ctx, drain } = makeCtx();
const worker = mod.createExportedHandler({ baseFetch });
await withConsole(async () => {
await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx);
await drain();
});
const photos = await sentPhotos(rec.calls);
assert.equal(kv.listCalls, 2, "the pagination loop fetched both pages");
assert.equal(photos.length, 2, "both alive envelopes carded across pages");
assert.deepEqual(photos.map((p) => p.chat_id).sort(), [204, 205]);
});
test("GC-C6 KV-budget pin (structural): ZERO .put( in cron.ts AND webhook.ts (PATTERNS PG style)", () => {
for (const file of [CRON_SRC, path.join(here, "..", "src", "webhook.ts")]) {
const code = stripComments(readFileSync(file, "utf8"));
assert.doesNotMatch(code, /\.put\(/, `${file} — a KV write in the cron path (Pitfall 3: the 1,000-writes/day budget)`);
}
});
test("GC-C7 shared-memo wiring: loadGraph feeds the scheduled arm; no second ROAD_GRAPH reader in bot/src", () => {
const webhookCode = stripComments(readFileSync(path.join(here, "..", "src", "webhook.ts"), "utf8"));
assert.ok(webhookCode.includes("loadGraph"), "scheduled() awaits the worker-router memo");
assert.ok(webhookCode.includes("makeStreetAt"), "the graph-fed street reconstruction is wired");
assert.ok(webhookCode.includes("projectTurnCards"), "the projection is the decision authority");
const readdir = (dir) => readdirSync(dir, { withFileTypes: true });
const walk = (dir, acc = []) => {
for (const e of readdir(dir)) {
const full = dir + "/" + e.name;
if (e.isDirectory()) walk(full, acc);
else if (e.name.endsWith(".ts")) acc.push(full);
}
return acc;
};
const files = walk(here + "/../src");
assert.ok(files.length >= 8, `scanned a real tree (found ${files.length} files)`);
for (const file of files) {
const code = stripComments(readFileSync(file, "utf8"));
if (!code.includes("ROAD_GRAPH")) continue;
assert.ok(
file.endsWith("config.ts") || file.endsWith("worker-router.ts"),
`${file} mentions ROAD_GRAPH — only the Env contract (config.ts) and the one memo loader (worker-router.ts) may`,
);
}
});
test("GC-C8 one bad key cannot kill the tick: a throwing KV read is skipped, the rest still card", async () => {
const mod = await import("../src/webhook.ts");
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 207 });
const BAD_KEY = "guidance:3f2a2c3e-1b4d-4e5a-9c8d-0000000000b8";
const map = new Map();
map.set(`guidance:${envA.id}`, JSON.stringify(envA));
const kv = makeListKV(map, [{ keys: [{ name: BAD_KEY }, { name: `guidance:${envA.id}` }], list_complete: true }]);
kv.throwOnGet = BAD_KEY;
const rec = { calls: [] };
const baseFetch = async (url, init) => {
rec.calls.push({ url: String(url), init });
return { ok: true, json: async () => ({ ok: true, result: {} }) };
};
const { ctx, drain } = makeCtx();
const worker = mod.createExportedHandler({ baseFetch });
const { logs } = await withConsole(async () => {
await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx);
await drain();
});
const photos = await sentPhotos(rec.calls);
assert.equal(photos.length, 1, "the tick survived the throwing read");
assert.equal(photos[0].chat_id, 207);
assert.ok(logs.some((l) => l.includes("[guidance-cron] skipped: malformed")), "a failed read is a category-only skip");
});
test("GC-C9 a failed send logs and continues — delivery variance is documented best-effort (D-13)", async () => {
const mod = await import("../src/webhook.ts");
const envA = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 208 });
const envA2 = envelope({ points: miniStreetPoints(), distanceM: 89, durationS: 90, startedAtSec: TICK_SEC - 45, chatId: 209 });
const map = new Map();
map.set(`guidance:${envA.id}`, JSON.stringify(envA));
map.set(`guidance:${envA2.id}`, JSON.stringify(envA2));
const kv = makeListKV(map, [{ keys: [{ name: `guidance:${envA.id}` }, { name: `guidance:${envA2.id}` }], list_complete: true }]);
const rec = { calls: [] };
let sent = 0;
const baseFetch = async (url, init) => {
if (String(url).includes("/sendPhoto")) {
sent += 1;
if (sent === 1) {
// A 4xx Telegram rejection — grammY throws without retrying.
return { ok: true, json: async () => ({ ok: false, error_code: 400, description: "Bad Request: chat not found" }) };
}
}
rec.calls.push({ url: String(url), init });
return { ok: true, json: async () => ({ ok: true, result: {} }) };
};
const { ctx, drain } = makeCtx();
const worker = mod.createExportedHandler({ baseFetch });
const { logs } = await withConsole(async () => {
await worker.scheduled(controllerFor(TICK_SEC), makeEnv(kv, r2With(MINI_GRAPH)), ctx);
await drain();
});
const photos = await sentPhotos(rec.calls);
assert.equal(photos.length, 1, "the failed first send did not stop the second");
assert.equal(photos[0].chat_id, 209);
assert.ok(logs.some((l) => l.includes("[guidance-cron] send failed")), "the send failure is a category-only log");
});