diff --git a/routing/test/snap.test.mjs b/routing/test/snap.test.mjs new file mode 100644 index 0000000..e5e81d5 --- /dev/null +++ b/routing/test/snap.test.mjs @@ -0,0 +1,183 @@ +#!/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. + const bare = encodeGraph({ + lat: [3.15, 3.1505], + lng: [101.69, 101.6905], + edges: [{ from: 0, to: 1, seconds: 4 }], + names: [], + maxSpeedKmh: 60, + }); + 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 >= 10 && justOutside.node <= 14, "snaps to a row-4 node"); + // 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)"); +});