webhook corridor/sparse builders + guidance-cron unnamed way compute tiny tables through the ONE preprocessing core (@araharah/routing farthestPointAnchors + landmarkTables) and pass the landmarks block into the same single encodeGraph call — call-site updates, not a second encode function. Graphs with no degree>=2 candidate (sparse, 2-node) pin their anchor explicitly; tables still come from the integer-Dijkstra core. FULL BATTERY at this commit: routing:test 74/74, bot 126/126, both typechecks clean; the regenerated fixture is deterministic (byte- identical re-run); mini-graph-arg1.bin is byte-stable vs its committed 08-01 bytes.
671 lines
34 KiB
JavaScript
671 lines
34 KiB
JavaScript
#!/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 { LANDMARK_QUANTUM_S, landmarkTables } = await import("@araharah/routing/src/landmark.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 });
|
||
const edges = [
|
||
{ from: 0, to: 1, seconds: meters / (30 / 3.6) },
|
||
{ from: 1, to: 0, seconds: meters / (30 / 3.6) },
|
||
];
|
||
// A 2-node graph has no degree>=2 farthest-point candidate (the selection
|
||
// filter), so the anchor pins directly — the tables still come from the
|
||
// ONE integer-Dijkstra core (08-02), through the ONE encode authority.
|
||
const landmarkInput = { nodeCount: 2, edges, quantumS: LANDMARK_QUANTUM_S };
|
||
const anchors = Uint32Array.of(0);
|
||
const { to, from } = landmarkTables(landmarkInput, anchors);
|
||
const buf = encodeGraph({
|
||
lat: [3.15, 3.1505],
|
||
lng: [101.69, 101.69],
|
||
edges,
|
||
names: [],
|
||
maxSpeedKmh: 30,
|
||
landmarks: { quantumS: LANDMARK_QUANTUM_S, nodes: anchors, to, from },
|
||
});
|
||
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");
|
||
});
|
||
|