/** * 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); });