Files
arah2/bot/test/guidance-cron.test.mjs
T
araharah-executor ad612299ca feat(06-07): scheduled() cron tick wired — factory, loadGraph street memo, crons trigger (GREEN)
- webhook.ts: createExportedHandler(opts) factory (offline send capture via
  baseFetch); scheduled() wraps runGuidanceCronTick in ctx.waitUntil —
  fail-closed unconfigured gate, paginated guidance: KV list (list_complete
  respected), loadGraph awaited ONCE per tick (the shared R2 memo), per-key
  getGuidanceSession → projectTurnCards → sendPhoto to envelope.chat_id ONLY
  (T-06-02), every log a [guidance-cron] category, zero KV writes
- guidance-sessions.ts: GUIDANCE_KEY_PREFIX exported — the ONE key-scheme
  authority (list scans derive prefix + slice ids from it)
- wrangler.jsonc: triggers.crons */1 * * * * (60 s scan, cadence in code)
- guidance-cron.test.mjs GREEN completion: GC-C3/C5/C8/C9 wire the recording
  baseFetch through the factory (the header-documented seam — they defined
  the recorder but never passed it); GC-C7 uses readdirSync (require is
  undefined in ESM); GC-C1/C8/C9 drain inside the withConsole window (the
  tick's logs fire under waitUntil)
- guidance-envelope.test.mjs: GE7 allows the GUIDANCE_KEY_PREFIX named import
- 125/125 bot tests green; bot:typecheck clean
2026-10-24 19:49:00 +08:00

660 lines
33 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, 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");
/* ————— 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");
});
/* ————————————————————————————————————————————————————————————————————————————
* 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 + 180; // % 180 === 0 → a due tick
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");
});