/** * 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'].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`); } } // 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)`); } }); 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.expect('peoria av', 'woodward', 'poi'); // address abbreviatef->street t.expect('s peoria ave', 'woodward', 'poi'); // compass normalized 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 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); });