Files
arah2/routing/test/snap.test.mjs
T
araharah-executor 32a00b5b0b feat(08-02): ARG2 codec — one version, one decode path (G-suite GREEN)
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).
2026-10-25 18:48:23 +08:00

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