From 53740944b82b0786eefbc8b99d128e2ae0f6ff6f Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Sat, 24 Oct 2026 12:05:16 +0800 Subject: [PATCH] test(06-01): add failing CSR graph codec suite G1-G8 (RED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- routing/test/graph.test.mjs | 191 ++++++++++++++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 routing/test/graph.test.mjs diff --git a/routing/test/graph.test.mjs b/routing/test/graph.test.mjs new file mode 100644 index 0000000..40c0333 --- /dev/null +++ b/routing/test/graph.test.mjs @@ -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); +});