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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user