Files
arah2/routing/test/graph.test.mjs
T
araharah-executor 53740944b8 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)
2026-10-24 12:05:16 +08:00

192 lines
9.3 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
/*
* 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);
});