navigator/scripts/test.mjs
hermes-explorigin 81a3da388e
All checks were successful
CI / test-and-build (push) Successful in 47s
Fix layer toggles: malformed GeoJSON geometry + fragile layer loading
Root cause: osm-data.mjs emitted ways with 'out center' (no geometry) as a
flat [lon,lat] labeled 'LineString'. Leaflet reads those as two invalid
points and threw 'Invalid LatLng', which aborted the entire layer load loop
in onMount -- so no layers rendered and the checkboxes all showed unchecked.
Toggling then appeared to do nothing.

Fixes:
- osm-data.mjs: coordsFor/geometryFor now emit a proper Point for any flat
  [lon,lat] (from nodes / 'out center' ways) instead of a malformed LineString
- MapView.svelte: isolate each layer's load + each feature's searchable build
  in try/catch so one bad feature/layer can't break all layers; skip invalid
  bounds in fitBounds
- osm-data.mjs: preserve manifest entries it doesn't generate (e.g. dump
  layers from osm-dump.mjs) so data/osm-dump scripts don't clobber each other
- scripts/test.mjs: add geometry-integrity checks (no malformed coords)

Verified in a real browser: all 6 layers render, each toggles off/on
independently (33361->0 overlays), and the Directions panel loads.
2026-08-09 21:35:47 +00:00

176 lines
7.4 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
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);
});