test(06-01): add failing CSR graph codec suite G1-G8 (RED)

- Pinned-layout gates: round-trip, bad magic/version/truncated -> null,
  zero-copy view mutation, name pool, committed mini-graph fixture pin
- Each case lazy-imports ../src/graph.ts — ERR_MODULE_NOT_FOUND is the
  red state (fixture-source.test.mjs pattern)
This commit is contained in:
araharah-executor
2026-10-24 12:05:16 +08:00
parent 7da7c2019c
commit 53740944b8
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env node
/*
* graph.test.mjs — the CSR road-graph binary codec gates (G1–G8).
* ------------------------------------------------------------------
* RED first (plan 06-01 task 1): every decode-bearing case fails while
* routing/src/graph.ts does not exist (lazy-import pattern — each case
* reports its own ERR_MODULE_NOT_FOUND, the fixture-source.test.mjs style).
*
* The binary layout is PINNED by 06-01 (plans 04/07 depend on it):
* little-endian CSR — header (magic/version/counts/bbox/maxSpeed) ++
* f64 lat++lng ++ u32 edgeStart[n+1] ++ u32 targets ++ u8 flags ++
* f32 seconds ++ u32 nameIds ++ u32 nameOffsets[m+1] ++ u8 name pool.
* The header is padded to 64 bytes so the f64 node arrays are 8-byte
* aligned — zero-copy TypedArray views REQUIRE aligned offsets (an
* unaligned Float64Array constructor throws RangeError in every engine).
*
* G1 round-trip — encodeGraph → decodeGraph restores coords,
* targets, flags, weights, names, bbox exactly
* G2 bad magic -> null — T-06-03 fail-honest (0xdeadbeef)
* G3 bad version -> null — version !== 1 refuses
* G4 truncated/hostile -> null — byte-length ≠ header-declared size
* (incl. huge-count header on a tiny buffer,
* T-06-03b: size check BEFORE view allocation)
* G5 zero-copy views — the views' .buffer IS the input buffer;
* flipping an input byte flips the decoded view
* G6 name pool — nameAt(0) === "Jalan Mini 1";
* nameAt(UNNAMED_NAME_ID) === undefined
* G7 committed fixture — mini-graph.bin decodes: 25 nodes, 80 edges,
* 3 names, maxSpeed 60, exactly ONE oneway flag
* G8 exported constants — GRAPH_MAGIC === 0x41524731, GRAPH_VERSION === 1
*
* Run: npm run routing:test (exit 0 = all green)
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "./harness.mjs";
const here = path.dirname(fileURLToPath(import.meta.url)); // routing/test
// Lazy module load — missing module => per-case ERR_MODULE_NOT_FOUND (RED).
let graphPromise = null;
function loadGraph() {
if (!graphPromise) graphPromise = import("../src/graph.ts");
return graphPromise;
}
/** Decode helper: bytes -> exact ArrayBuffer copy (Buffer pools are larger). */
function toArrayBuffer(buf) {
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
}
/* ————— synthetic 3-node graph (the round-trip vector) ————— */
function syntheticInput() {
return {
lat: [3.15, 3.1505, 3.151],
lng: [101.69, 101.6905, 101.691],
edges: [
{ from: 0, to: 1, seconds: 4, nameId: 0 },
{ from: 1, to: 0, seconds: 4 }, // unnamed (nameId -> UNNAMED_NAME_ID)
{ from: 1, to: 2, seconds: 8, flags: 1, nameId: 1 }, // oneway-reverse marker
],
names: ["Jalan Mini 1", "Jalan Mini 2"],
maxSpeedKmh: 60,
};
}
/* ————— G1–G8 ————— */
test("G1 round-trip — encodeGraph -> decodeGraph restores coords, targets, flags, weights, names, bbox exactly", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const input = syntheticInput();
const buf = encodeGraph(input);
assert.ok(buf instanceof ArrayBuffer, "encodeGraph returns an ArrayBuffer");
const g = decodeGraph(buf);
assert.ok(g, "a well-formed buffer decodes non-null");
assert.equal(g.nodeCount, 3);
assert.equal(g.edgeCount, 3);
assert.equal(g.nameCount, 2);
assert.deepEqual([...g.lat], input.lat, "lat round-trips exactly (f64)");
assert.deepEqual([...g.lng], input.lng, "lng round-trips exactly (f64)");
// edges grouped by `from` ascending: node0 -> [e0], node1 -> [e1, e2], node2 -> []
assert.deepEqual([...g.edgeStart], [0, 1, 3, 3], "CSR row pointers");
assert.deepEqual([...g.edgeTarget], [1, 0, 2], "edge targets round-trip");
assert.deepEqual([...g.edgeFlags], [0, 0, 1], "flags round-trip (bit0 oneway-reverse)");
assert.deepEqual([...g.edgeSeconds], [4, 4, 8], "weights round-trip (f32)");
assert.deepEqual([...g.edgeNameId], [0, 0xffffffff, 1], "name ids round-trip (unnamed sentinel)");
assert.deepEqual(
g.bbox,
{ minLat: 3.15, minLng: 101.69, maxLat: 3.151, maxLng: 101.691 },
"bbox derived from the nodes at encode time",
);
assert.equal(g.maxSpeedKmh, 60);
});
test("G2 bad magic -> null — a tampered header never half-decodes (T-06-03)", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const buf = encodeGraph(syntheticInput());
const tampered = buf.slice(0);
new DataView(tampered).setUint32(0, 0xdeadbeef, true);
assert.equal(decodeGraph(tampered), null, "wrong magic -> null, never a throw");
});
test("G3 bad version -> null — version !== 1 refuses", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const buf = encodeGraph(syntheticInput());
const future = buf.slice(0);
new DataView(future).setUint32(4, 2, true);
assert.equal(decodeGraph(future), null, "wrong version -> null");
});
test("G4 truncated + hostile-size buffer -> null — exact byte-size check BEFORE any view allocation (T-06-03b)", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const buf = encodeGraph(syntheticInput());
assert.equal(
decodeGraph(buf.slice(0, buf.byteLength - 1)),
null,
"one byte short -> null (truncated)",
);
assert.equal(decodeGraph(new ArrayBuffer(0)), null, "empty buffer -> null");
assert.equal(decodeGraph(new ArrayBuffer(16)), null, "sub-header buffer -> null");
// Hostile header: valid magic/version but a huge nodeCount on a tiny buffer —
// the size mismatch rejects it before any allocation (T-06-03b).
const hostile = new ArrayBuffer(64);
const dv = new DataView(hostile);
const { GRAPH_MAGIC, GRAPH_VERSION } = await loadGraph();
dv.setUint32(0, GRAPH_MAGIC, true);
dv.setUint32(4, GRAPH_VERSION, true);
dv.setUint32(8, 1_000_000, true); // declares a million nodes…
assert.equal(decodeGraph(hostile), null, "huge-count header on a tiny buffer -> null, no allocation");
});
test("G5 zero-copy views — the views' .buffer IS the input buffer; flipping an input byte flips the view", async () => {
const { encodeGraph, decodeGraph } = await loadGraph();
const buf = encodeGraph(syntheticInput()).slice(0);
const g = decodeGraph(buf);
assert.ok(g, "decode succeeds");
assert.equal(g.lat.buffer, buf, "lat is a VIEW over the input buffer (zero-copy)");
assert.equal(g.edgeSeconds.buffer, buf, "edgeSeconds is a VIEW over the input buffer");
// The padded header ends at byte 64 — the first lat byte. Flip it.
const dv = new DataView(buf);
const before = g.lat[0];
dv.setUint8(64, dv.getUint8(64) ^ 0xff);
assert.notEqual(g.lat[0], before, "mutating the input buffer flips the decoded view");
});
test("G6 name pool — nameAt(0) resolves the first pool name; the unnamed sentinel resolves undefined", async () => {
const { encodeGraph, decodeGraph, UNNAMED_NAME_ID } = await loadGraph();
const g = decodeGraph(encodeGraph(syntheticInput()));
assert.ok(g, "decode succeeds");
assert.equal(g.nameAt(0), "Jalan Mini 1", "nameId 0 -> the first pool name");
assert.equal(g.nameAt(1), "Jalan Mini 2");
assert.equal(g.nameAt(UNNAMED_NAME_ID), undefined, "UNNAMED_NAME_ID -> undefined");
assert.equal(g.nameAt(999), undefined, "out-of-range id -> undefined");
});
test("G7 committed mini-graph fixture — decodes to 25 nodes / 80 edges / 3 names, maxSpeed 60, exactly ONE oneway flag", async () => {
const { decodeGraph } = await loadGraph();
const bin = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph.bin")));
assert.ok(bin.byteLength > 1000, `the committed fixture is substantial (${bin.byteLength} B)`);
const g = decodeGraph(bin);
assert.ok(g, "the committed fixture decodes cleanly");
assert.equal(g.nodeCount, 25, "5x5 grid");
assert.equal(g.edgeCount, 80, "20 H + 20 V segments, both directions");
assert.equal(g.nameCount, 3, "Jalan Mini 1 / Jalan Mini 2 / Jalan Persiaran Mini");
assert.equal(g.nameAt(0), "Jalan Mini 1");
assert.equal(g.nameAt(2), "Jalan Persiaran Mini");
assert.equal(g.maxSpeedKmh, 60, "the perimeter speed class is the header max");
// bbox of the hand-built grid (lat 3.1500–3.1520, lng 101.6900–101.6920)
assert.deepEqual(g.bbox, { minLat: 3.15, minLng: 101.69, maxLat: 3.152, maxLng: 101.692 });
let flagged = 0;
for (let e = 0; e < g.edgeCount; e++) if ((g.edgeFlags[e] & 1) !== 0) flagged++;
assert.equal(flagged, 1, "exactly ONE oneway-reverse edge in the whole fixture");
// both speed classes present: perimeter ~55.6 m / 16.67 ≈ 3.3 s; interior ≈ 6.7 s
let sawFast = false;
let sawSlow = false;
for (let e = 0; e < g.edgeCount; e++) {
if (g.edgeSeconds[e] < 5) sawFast = true;
if (g.edgeSeconds[e] > 5) sawSlow = true;
}
assert.ok(sawFast && sawSlow, "both speed classes (60 and 30 km/h) exist in the weights");
});
test("G8 exported constants — GRAPH_MAGIC === 0x41524731 ('ARG1'), GRAPH_VERSION === 1", async () => {
const mod = await loadGraph();
assert.equal(mod.GRAPH_MAGIC, 0x41524731);
assert.equal(mod.GRAPH_VERSION, 1);
assert.equal(mod.UNNAMED_NAME_ID, 0xffffffff);
});