All checks were successful
CI / test-and-build (push) Successful in 58s
buildTurnInstructions grouped steps purely by heading change, so a street that bends (same name, >30° heading shift) produced redundant 'Continue/Turn onto Pine St' entries. Now consecutive legs sharing the same street name are collapsed into a single step with summed distance. - routing.ts: track each leg's street name and absorb subsequent same-street steps' distance into the first; the arrival step uses the final bearing - scripts/test.mjs: assert a bent single-named road yields 1 step, and that two distinct streets are not merged
252 lines
12 KiB
JavaScript
252 lines
12 KiB
JavaScript
/**
|
|
* test.mjs — offline regression tests for the Navigator data build.
|
|
* ------------------------------------------------------------------
|
|
* Runs against the COMMITTED static/data (no Overpass or Nominatim calls),
|
|
* so it is safe for CI. It validates:
|
|
* 1. The manifest (_index.json) is well-formed and references existing files.
|
|
* 2. Each GeoJSON dataset is valid and has the expected structure.
|
|
* 3. The fuzzy search engine produces expected results for known queries.
|
|
*
|
|
* Usage: node scripts/test.mjs (exit 0 = pass, exit 1 = fail)
|
|
*/
|
|
|
|
import { readFileSync, statSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const STATIC = resolve(__dirname, '..', 'static', 'data');
|
|
|
|
let failures = 0;
|
|
let checks = 0;
|
|
|
|
function check(ok, label) {
|
|
checks++;
|
|
if (!ok) {
|
|
failures++;
|
|
console.error(` [FAIL] ${label}`);
|
|
} else {
|
|
console.log(` [ok] ${label}`);
|
|
}
|
|
}
|
|
|
|
function loadJson(p) {
|
|
return JSON.parse(readFileSync(p, 'utf8'));
|
|
}
|
|
|
|
// Build a searchable list from the committed data (mirrors MapView logic).
|
|
async function buildSearchables() {
|
|
const { search } = await import(resolve(__dirname, '..', 'src', 'lib', 'search.ts'));
|
|
const manifest = loadJson(resolve(STATIC, '_index.json'));
|
|
const all = [];
|
|
for (const layer of manifest) {
|
|
const fc = loadJson(resolve(STATIC, layer.path.replace(/^\/data\//, '')));
|
|
for (const f of fc.features) {
|
|
const p = f.properties || {};
|
|
let lat, lon, bounds;
|
|
if (f.geometry.type === 'Point') {
|
|
[lon, lat] = f.geometry.coordinates;
|
|
} else if (f.geometry.type === 'Polygon' || f.geometry.type === 'LineString') {
|
|
const coords = f.geometry.type === 'Polygon' ? f.geometry.coordinates[0] : f.geometry.coordinates;
|
|
const lats = coords.map((c) => c[1]);
|
|
const lons = coords.map((c) => c[0]);
|
|
lat = (Math.min(...lats) + Math.max(...lats)) / 2;
|
|
lon = (Math.min(...lons) + Math.max(...lons)) / 2;
|
|
bounds = [[Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)]];
|
|
}
|
|
const kind = layer.layerType === 'zone' ? 'zone' : layer.layerType === 'path' ? 'path' : 'poi';
|
|
all.push({
|
|
id: f.id, name: p.name ? String(p.name) : String(f.id ?? ''), label: p.name ? String(p.name) : String(f.id ?? ''), kind,
|
|
layerName: layer.name, layerLabel: layer.label, layerColor: layer.color,
|
|
description: p.description, address: p.address, bearing: p.bearing, fov: p.fov,
|
|
lat, lon, bounds, feature: f
|
|
});
|
|
}
|
|
}
|
|
return { search, all };
|
|
}
|
|
|
|
async function run() {
|
|
console.log('Navigator tests\n===============');
|
|
|
|
// 1. Manifest is valid JSON referencing existing files.
|
|
const manifest = loadJson(resolve(STATIC, '_index.json'));
|
|
check(Array.isArray(manifest) && manifest.length > 0, `manifest has ${manifest.length} layers`);
|
|
for (const layer of manifest) {
|
|
const rel = layer.path.replace(/^\/data\//, '');
|
|
const abs = resolve(STATIC, rel);
|
|
check(statSync(abs, { throwIfNoEntry: false })?.isFile(), `layer "${layer.name}" file exists (${rel})`);
|
|
check(['osm', 'custom', 'dump'].includes(layer.category), `layer "${layer.name}" has valid category`);
|
|
check(['poi', 'zone', 'path', 'mixed'].includes(layer.layerType), `layer "${layer.name}" has valid layerType "${layer.layerType}"`);
|
|
}
|
|
|
|
// 2. Each GeoJSON dataset is structurally valid.
|
|
for (const layer of manifest) {
|
|
const fc = loadJson(resolve(STATIC, layer.path.replace(/^\/data\//, '')));
|
|
check(fc.type === 'FeatureCollection', `"${layer.name}" is a FeatureCollection`);
|
|
check(Array.isArray(fc.features), `"${layer.name}" has a features array`);
|
|
for (const f of fc.features) {
|
|
check(f.type === 'Feature' && f.geometry && f.geometry.type, `"${layer.name}" feature "${f.properties?.name}" has geometry`);
|
|
}
|
|
}
|
|
|
|
|
|
// 2.4 GeoJSON geometry integrity: no malformed coords Leaflet would choke on.
|
|
for (const layer of manifest) {
|
|
const fc = loadJson(resolve(STATIC, layer.path.replace(/^\/data\//, '')));
|
|
let badLine = 0, badPoint = 0;
|
|
for (const f of fc.features) {
|
|
const g = f.geometry || {};
|
|
const c = g.coordinates;
|
|
if (g.type === 'LineString' && Array.isArray(c) && c.length && typeof c[0] === 'number') badLine++;
|
|
if (g.type === 'Point' && Array.isArray(c) && (c.length < 2 || typeof c[0] !== 'number')) badPoint++;
|
|
if (g.type === 'Polygon') {
|
|
const ring = Array.isArray(c) ? c[0] : null;
|
|
if (!ring || !ring.length || typeof ring[0] === 'number') badLine++;
|
|
}
|
|
}
|
|
check(badLine === 0, `geometry: "${layer.name}" has no malformed LineString/Polygon coords`);
|
|
check(badPoint === 0, `geometry: "${layer.name}" has no malformed Point coords`);
|
|
}
|
|
|
|
// 2.5 Routing graph validation (dump sources).
|
|
for (const layer of manifest) {
|
|
if (layer.category !== 'dump') continue;
|
|
check(!!layer.graphPath, `dump layer "${layer.name}" has graphPath`);
|
|
check(!!layer.poiPath, `dump layer "${layer.name}" has poiPath`);
|
|
const g = loadJson(resolve(STATIC, layer.graphPath.replace(/^\/data\//, '')));
|
|
check(Array.isArray(g.coords) && g.coords.length > 0, `graph "${layer.name}" has coords (${g.coords.length})`);
|
|
check(Array.isArray(g.adj) && g.adj.length === g.coords.length, `graph "${layer.name}" adj size matches coords (${g.adj.length})`);
|
|
const edgeCount = g.adj.reduce((a, x) => a + x.length, 0);
|
|
check(edgeCount > 0, `graph "${layer.name}" has edges (${Math.round(edgeCount / 2)})`);
|
|
if (g.coords.length) {
|
|
const ok = g.coords.every((c) => Array.isArray(c) && c.length === 2 && typeof c[0] === 'number');
|
|
check(ok, `graph "${layer.name}" coords are [lat,lon] pairs`);
|
|
}
|
|
}
|
|
|
|
// 3. Search engine expectations against committed data.
|
|
const { search, all } = await buildSearchables();
|
|
const build = async () => ({
|
|
search,
|
|
expect: (query, substr, kind = null) => {
|
|
const res = search(query, all);
|
|
const top = res[0]?.item?.name ?? '';
|
|
const hit = res.some((r) => r.item.name.toLowerCase().includes(substr));
|
|
check(hit, `search "${query}" finds "${substr}"` + (kind ? ` (${kind})` : ''));
|
|
return { res, top };
|
|
},
|
|
expectNone: (query) => {
|
|
const res = search(query, all);
|
|
check(res.length === 0, `search "${query}" returns no results (junk filtered)`);
|
|
},
|
|
expectAll: (query, substr) => {
|
|
const res = search(query, all);
|
|
const any = res.some((r) => String(r.item.name).toLowerCase().includes(substr));
|
|
check(any, `search "${query}" finds "${substr}" somewhere in results`);
|
|
return res;
|
|
}
|
|
});
|
|
const t = await build();
|
|
|
|
// Exact name / fuzzy / address queries (derived from seed data).
|
|
t.expect('gathering place', 'gathering place', 'poi');
|
|
t.expect('philbrick museum', 'philbrook', 'poi'); // fuzzy typo
|
|
t.expectAll('peoria av', 'peoria'); // address -> the street itself
|
|
t.expectAll('s peoria ave', 'south peoria'); // compass normalized matches roads
|
|
const cherry = t.expect('cherry street', 'cherry street', 'zone');
|
|
// Path layer should be searchable.
|
|
t.expect('claremore lake loop', 'claremore lake loop', 'path');
|
|
// Rogers County seed.
|
|
t.expect('rogers state university', 'rogers state university', 'poi');
|
|
t.expect('claremore', 'claremore');
|
|
t.expectNone('zzzzqqqq'); // gibberish yields nothing
|
|
|
|
// 4. Routing: turn-by-turn generation + partial/robustness behavior.
|
|
{
|
|
const { buildTurnInstructions } = await import(resolve(__dirname, '..', 'src', 'lib', 'routing.ts'));
|
|
// Straight north, then a 90° right turn east.
|
|
const path = [];
|
|
for (let i = 0; i <= 10; i++) path.push([36.1 + i * 0.000017, -95.9]); // north
|
|
for (let i = 1; i <= 20; i++) path.push([36.10017, -95.9 + i * 0.000017]); // east
|
|
const turns = buildTurnInstructions(path);
|
|
check(turns.length >= 3, `buildTurnInstructions emits head + turn + arrive (got ${turns.length})`);
|
|
check(turns.some((t) => t.instruction.toLowerCase().includes('head')), 'has a "Head …" first step');
|
|
check(turns.some((t) => t.instruction.toLowerCase().includes('turn right')), 'has a "Turn right" maneuver for 90° turn');
|
|
|
|
// Street names appear when a name resolver is supplied.
|
|
const namedTurns = buildTurnInstructions(path, (i) => (i < 10 ? 'Pine St' : '21st St'));
|
|
check(namedTurns[0].instruction.includes('Pine St'), 'first step includes street name ("Head north on Pine St")');
|
|
check(namedTurns.some((t) => t.instruction.toLowerCase().includes('onto 21st st')), 'turn step includes destination street ("Turn right onto 21st St")');
|
|
check(turns[turns.length - 1].instruction === 'Arrive at destination', 'last step is "Arrive at destination"');
|
|
|
|
// Same-street merge: a road that bends (>30\u00b0 heading change) but keeps
|
|
// ONE name must collapse into a single step rather than two.
|
|
{
|
|
// S-shape: north, then a 45\u00b0 bend, then north again — all "Oak Ave".
|
|
const bendPath = [];
|
|
for (let i = 0; i <= 10; i++) bendPath.push([36.1 + i * 0.000017, -95.9]); // north
|
|
for (let i = 1; i <= 10; i++) bendPath.push([36.10017 + i * 0.000017, -95.9 + i * 0.000012]); // NE bend
|
|
for (let i = 1; i <= 10; i++) bendPath.push([36.10034 + i * 0.000012, -95.89988 + i * 0.000017]); // east-ish
|
|
const bent = buildTurnInstructions(bendPath, () => 'Oak Ave');
|
|
const oakSteps = bent.filter((t) => t.instruction.toLowerCase().includes('oak ave'));
|
|
check(oakSteps.length === 1, `same-street merge: a bent "Oak Ave" collapses to 1 step (got ${oakSteps.length})`);
|
|
// Sum of all non-arrival step distances should still cover the route.
|
|
const namedCount = bent.filter((t) => t.instruction !== 'Arrive at destination').length;
|
|
check(namedCount >= 1, 'merged path still has instruction steps');
|
|
}
|
|
|
|
// Merge must NOT merge two distinct streets.
|
|
{
|
|
const mpath = [];
|
|
for (let i = 0; i <= 10; i++) mpath.push([36.1 + i * 0.000017, -95.9]); // north on A St
|
|
for (let i = 1; i <= 20; i++) mpath.push([36.10017, -95.9 + i * 0.000017]); // east on B St
|
|
const mt = buildTurnInstructions(mpath, (i) => (i < 10 ? 'A St' : 'B St'));
|
|
check(mt.some((t) => t.instruction.toLowerCase().includes('b st')), 'distinct streets are NOT merged (B St step present)');
|
|
}
|
|
|
|
const { route } = await import(resolve(__dirname, '..', 'src', 'lib', 'routing.ts'));
|
|
// Disconnected destination -> partial path.
|
|
const g2 = {
|
|
meta: { nodeCount: 3, edgeCount: 1 },
|
|
coords: [[36.1, -95.9], [36.1005, -95.9], [36.2, -95.8]],
|
|
adj: [[[1, 55]], [[0, 55]], []]
|
|
};
|
|
const rp = route(g2, [36.1, -95.9], [36.2, -95.8]);
|
|
check(rp.found === false, 'disconnected destination -> found=false');
|
|
check(rp.partial === true, 'disconnected destination -> partial=true');
|
|
check(rp.path.length >= 2, 'partial path reaches the closest reachable node');
|
|
check(!!rp.reason, 'partial route has a human-readable reason');
|
|
|
|
// Empty graph -> clear "no road network" message, no crash.
|
|
const rg = route({ meta: { nodeCount: 0, edgeCount: 0 }, coords: [], adj: [] }, [36.1, -95.9], [36.2, -95.8]);
|
|
check(rg.found === false && !!rg.reason, 'empty graph -> found=false with reason');
|
|
}
|
|
|
|
// 5. Saved Points: import merge + dedupe by id.
|
|
{
|
|
const { addSavedPoint, clearSavedPoints, importSavedPoints, exportSavedPoints } =
|
|
await import(resolve(__dirname, '..', 'src', 'lib', 'savedPoints.ts'));
|
|
clearSavedPoints();
|
|
addSavedPoint({ lat: 1, lon: 2, label: 'A' });
|
|
const existing = JSON.parse(exportSavedPoints());
|
|
const newPoint = { id: 'B', lat: 5, lon: 6, label: 'B' };
|
|
const res = importSavedPoints(JSON.stringify({ type: 'navigator.savedPoints', version: 1, points: [...existing.points, newPoint] }));
|
|
check(res.added === 1 && res.skipped === 1, `import merges + dedupes by id (added=${res.added}, skipped=${res.skipped})`);
|
|
try { importSavedPoints('not json'); check(false, 'invalid json rejected'); }
|
|
catch { check(true, 'invalid json rejected'); }
|
|
clearSavedPoints();
|
|
}
|
|
|
|
console.log(`\n${checks - failures}/${checks} checks passed`);
|
|
if (failures > 0) {
|
|
console.error(`${failures} check(s) FAILED`);
|
|
process.exit(1);
|
|
}
|
|
console.log('All checks passed.');
|
|
}
|
|
|
|
run().catch((e) => {
|
|
console.error('Test runner crashed:', e);
|
|
process.exit(1);
|
|
}); |