graph.ts versioned IN PLACE (no second codec): GRAPH_MAGIC 0x41524732, GRAPH_VERSION 2, HEADER_SIZE 72 (72%8==0 keeps f64 views aligned); header gains landmarkCount u32 @60 (the old pad), landmarkQuantumS f32 @64, reserved u32 @68; the landmark suffix (u16 toTable, u16 fromTable, u32 landmarkNodes) extends layoutFor's total arithmetic — exact-size law covers the variable suffix BEFORE any view allocation (T-08-01). D-01 fail-honest: ARG1 bytes / version!=2 / landmarkCount=0 / hostile quantum / size mismatch all decode null (committed mini-graph-arg1.bin is the standing rejection vector). GraphInput.landmarks REQUIRED and loud-validated (T-08-04). RoadGraph gains the five landmark fields (Plan 08-03's ALT potentials consume them). Fixture regenerated as ARG2 through the SAME encode authority: the builder gains the tiny real landmark stage (L=2, anchors [0,18], quantum 0.1s, coverage 1.0 — identical bytes every re-run). Rule-3 call-site updates forced by the required-landmarks law (missed by the plan's files list): astar.test A3 + snap.test S3 synthetics carry inert hand landmark blocks. Fixture regen pulled forward from task 3 so this commit's own battery is green (74 passed, 0 failed).
192 lines
8.6 KiB
JavaScript
192 lines
8.6 KiB
JavaScript
#!/usr/bin/env node
|
||
/*
|
||
* snap.test.mjs — GPS-fix snapping gates (S1–S6).
|
||
* ------------------------------------------------
|
||
* RED first (plan 06-01 task 2): every case fails while
|
||
* routing/src/snap.ts does not exist (lazy-import pattern).
|
||
*
|
||
* D-05: the ROUTER snaps raw fixes to the graph (engine A owns snapping —
|
||
* one authority). The mini-graph fixture (mini-graph.bin) is the shared
|
||
* ground truth; a synthetic unnamed-edge graph covers the null-nameId half.
|
||
*
|
||
* S1 exact-node hit — a fix at a node's coordinates -> that node,
|
||
* distanceM ~ 0
|
||
* S2 mid-edge projection — a fix partway along a segment snaps to the
|
||
* CLOSER ENDPOINT (never a fabricated mid-node);
|
||
* distanceM is the haversine distance to it
|
||
* S3 nameId — the best segment's edgeNameId resolves;
|
||
* an unnamed segment resolves null
|
||
* S4 bbox margin — outside bbox + SNAP_BBOX_MARGIN_DEG -> null
|
||
* (honest miss, D-03); just outside the bbox
|
||
* but near roads still snaps
|
||
* S5 oneway ignored — a fix near the oneway segment still snaps to
|
||
* its endpoints (snapping ignores direction)
|
||
* S6 memoized grid — the coarse grid builds ONCE per RoadGraph
|
||
* (WeakMap; build-counter, kept loose) +
|
||
* structural: WeakMap and cos() in the source
|
||
*
|
||
* 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
|
||
|
||
let snapPromise = null;
|
||
function loadSnap() {
|
||
if (!snapPromise) snapPromise = import("../src/snap.ts");
|
||
return snapPromise;
|
||
}
|
||
let graphPromise = null;
|
||
function loadGraph() {
|
||
if (!graphPromise) graphPromise = import("../src/graph.ts");
|
||
return graphPromise;
|
||
}
|
||
|
||
async function loadGeo() {
|
||
return import("../src/geo.ts");
|
||
}
|
||
|
||
/** bytes -> exact ArrayBuffer copy (Buffer pools are larger). */
|
||
function toArrayBuffer(buf) {
|
||
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
|
||
}
|
||
|
||
/** The committed 5x5 mini-graph, decoded. */
|
||
async function loadMiniGraph() {
|
||
const { decodeGraph } = await loadGraph();
|
||
const bin = toArrayBuffer(readFileSync(path.join(here, "fixtures", "mini-graph.bin")));
|
||
const g = decodeGraph(bin);
|
||
assert.ok(g, "the committed mini-graph decodes");
|
||
return g;
|
||
}
|
||
|
||
/** Grid coordinates — MUST match build-mini-graph.mjs (1e-6 integer grid). */
|
||
function nodeLat(r) {
|
||
return (3150000 + r * 500) / 1e6;
|
||
}
|
||
function nodeLng(c) {
|
||
return (101690000 + c * 500) / 1e6;
|
||
}
|
||
|
||
/* ————— S1–S6 ————— */
|
||
|
||
test("S1 exact-node hit — a fix at node 12's coordinates snaps to node 12 at ~0 m", async () => {
|
||
const { snapToGraph } = await loadSnap();
|
||
const g = await loadMiniGraph();
|
||
const result = snapToGraph(g, { lat: nodeLat(2), lng: nodeLng(2) });
|
||
assert.ok(result, "an on-graph fix snaps");
|
||
assert.equal(result.node, 12, "the exact node wins");
|
||
assert.ok(result.distanceM < 0.001, `distance ~ 0 (got ${result.distanceM})`);
|
||
});
|
||
|
||
test("S2 mid-edge projection — a fix a quarter along edge 6->7 snaps to the closer endpoint 6, haversine distance", async () => {
|
||
const { snapToGraph } = await loadSnap();
|
||
const { haversineM } = await loadGeo();
|
||
const g = await loadMiniGraph();
|
||
// Point 25% of the way from node 6 (r1,c1) to node 7 (r1,c2).
|
||
const point = { lat: nodeLat(1), lng: (101690000 + 625) / 1e6 };
|
||
const result = snapToGraph(g, point);
|
||
assert.ok(result, "the mid-edge fix snaps");
|
||
assert.equal(result.node, 6, "the CLOSER endpoint wins — never a fabricated mid-node");
|
||
assert.equal(result.nameId, 0, "the best segment is 'Jalan Mini 1' (nameId 0)");
|
||
const expected = haversineM(point, { lat: nodeLat(1), lng: nodeLng(1) });
|
||
assert.ok(
|
||
Math.abs(result.distanceM - expected) < 1e-9,
|
||
`distanceM is the haversine distance to the endpoint (${result.distanceM} vs ${expected})`,
|
||
);
|
||
});
|
||
|
||
test("S3 nameId — a perimeter fix resolves 'Jalan Persiaran Mini'; an unnamed segment resolves null", async () => {
|
||
const { snapToGraph } = await loadSnap();
|
||
const { encodeGraph } = await loadGraph();
|
||
const g = await loadMiniGraph();
|
||
const atNode1 = snapToGraph(g, { lat: nodeLat(0), lng: nodeLng(1) });
|
||
assert.ok(atNode1, "perimeter fix snaps");
|
||
assert.notEqual(atNode1.nameId, null, "perimeter edges carry names");
|
||
assert.equal(g.nameAt(atNode1.nameId), "Jalan Persiaran Mini");
|
||
|
||
// Synthetic 2-node graph with ONE unnamed edge -> nameId null.
|
||
// ARG2 (plan 08-02): the landmark block is required by the one encode
|
||
// authority; values are inert steering data until ALT lands (08-03).
|
||
const bare = encodeGraph({
|
||
lat: [3.15, 3.1505],
|
||
lng: [101.69, 101.6905],
|
||
edges: [{ from: 0, to: 1, seconds: 4 }],
|
||
names: [],
|
||
maxSpeedKmh: 60,
|
||
landmarks: {
|
||
quantumS: 0.5,
|
||
nodes: [0],
|
||
to: Uint16Array.of(0, 30),
|
||
from: Uint16Array.of(0, 30),
|
||
},
|
||
});
|
||
const { decodeGraph, UNNAMED_NAME_ID } = await loadGraph();
|
||
const bg = decodeGraph(bare);
|
||
assert.ok(bg, "synthetic graph decodes");
|
||
assert.equal(bg.edgeNameId[0], UNNAMED_NAME_ID, "the synthetic edge is unnamed");
|
||
const unnamed = snapToGraph(bg, { lat: 3.15025, lng: 101.69025 });
|
||
assert.ok(unnamed, "the unnamed segment still snaps");
|
||
assert.equal(unnamed.nameId, null, "UNNAMED_NAME_ID resolves to null for consumers");
|
||
});
|
||
|
||
test("S4 bbox margin — outside bbox + SNAP_BBOX_MARGIN_DEG -> null; just outside the bbox still snaps", async () => {
|
||
const { snapToGraph, SNAP_BBOX_MARGIN_DEG } = await loadSnap();
|
||
assert.equal(SNAP_BBOX_MARGIN_DEG, 0.01, "the named margin constant is 0.01 deg");
|
||
const g = await loadMiniGraph();
|
||
// bbox maxLat = 3.152; +0.008 above it is inside the margin AND near row-4 roads.
|
||
const justOutside = snapToGraph(g, { lat: 3.1528, lng: nodeLng(1) });
|
||
assert.ok(justOutside, "a fix just outside the bbox but near roads still snaps");
|
||
assert.ok(justOutside.node >= 20 && justOutside.node <= 24, "snaps to a row-4 node (ids 20..24)");
|
||
// 0.011 deg above the bbox top -> outside bbox + margin -> honest null.
|
||
assert.equal(
|
||
snapToGraph(g, { lat: 3.163, lng: nodeLng(1) }),
|
||
null,
|
||
"beyond bbox + margin -> null (router-pending upstream, D-03)",
|
||
);
|
||
assert.equal(
|
||
snapToGraph(g, { lat: nodeLat(1), lng: 101.703 }),
|
||
null,
|
||
"beyond bbox + margin in lng -> null",
|
||
);
|
||
});
|
||
|
||
test("S5 oneway ignored — a fix near the oneway segment snaps to its endpoints like any segment", async () => {
|
||
const { snapToGraph } = await loadSnap();
|
||
const g = await loadMiniGraph();
|
||
// The fixture's single oneway-reverse edge is 3 -> 2 (segment 2-3, perimeter).
|
||
// A fix 25% along segment 2->3 snaps to node 2 — direction plays no part.
|
||
const point = { lat: nodeLat(0), lng: (101690000 + 1125) / 1e6 };
|
||
const result = snapToGraph(g, point);
|
||
assert.ok(result, "the oneway segment's fix snaps");
|
||
assert.equal(result.node, 2, "the closer endpoint of the oneway segment wins");
|
||
assert.notEqual(result.nameId, null);
|
||
assert.equal(g.nameAt(result.nameId), "Jalan Persiaran Mini");
|
||
});
|
||
|
||
test("S6 memoized grid — the coarse grid builds once per RoadGraph (WeakMap) + structural gates", async () => {
|
||
const mod = await loadSnap();
|
||
const g = await loadMiniGraph();
|
||
const before = mod.gridBuildCount();
|
||
mod.snapToGraph(g, { lat: nodeLat(2), lng: nodeLng(2) });
|
||
assert.equal(mod.gridBuildCount(), before + 1, "first snap on a graph builds the grid once");
|
||
mod.snapToGraph(g, { lat: nodeLat(0), lng: nodeLng(1) });
|
||
mod.snapToGraph(g, { lat: nodeLat(4), lng: nodeLng(4) });
|
||
assert.equal(mod.gridBuildCount(), before + 1, "subsequent snaps REUSE the memoized grid");
|
||
const g2 = await loadMiniGraph(); // a second decode -> a fresh RoadGraph instance
|
||
mod.snapToGraph(g2, { lat: nodeLat(2), lng: nodeLng(2) });
|
||
assert.equal(mod.gridBuildCount(), before + 2, "a new graph instance gets its own grid");
|
||
|
||
// Structural: the memo is a WeakMap and the projection scales lng by cos.
|
||
const src = readFileSync(path.join(here, "..", "src", "snap.ts"), "utf8");
|
||
const code = src
|
||
.replace(/\/\*[\s\S]*?\*\//g, " ")
|
||
.replace(/(^|[^:\\])\/\/[^\n]*/g, "$1");
|
||
assert.ok(code.includes("WeakMap"), "the per-graph memo is a WeakMap (no global leak)");
|
||
assert.ok(/Math\.cos\(/.test(code), "lng is cos(lat)-scaled — Pitfall 8 (never raw degrees)");
|
||
});
|