From f43530bed0372a7b18d3ea0a364601140a7e2df4 Mon Sep 17 00:00:00 2001 From: hermes-explorigin Date: Sat, 8 Aug 2026 18:32:48 +0000 Subject: [PATCH] Add Gitea Actions CI: run tests on every push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .gitea/workflows/ci.yml: on push/PR, runs npm ci, svelte-check, offline regression tests, and a static (no-Overpass) build, then verifies output - scripts/test.mjs: offline test suite — validates manifest + GeoJSON structure and asserts fuzzy-search regressions against committed data - package.json: add 'test' and 'build:static' scripts - osm-data.mjs: tag OSM layers layerType='mixed' in the manifest --- .gitea/workflows/ci.yml | 42 +++++++++++++ README.md | 13 ++++ package.json | 2 + scripts/osm-data.mjs | 2 +- scripts/test.mjs | 135 ++++++++++++++++++++++++++++++++++++++++ static/data/_index.json | 1 + 6 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/ci.yml create mode 100644 scripts/test.mjs diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..11e5141 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +# Runs tests on every push (commit) to any branch, and on pull requests. +"on": + push: + branches: ['**'] + pull_request: + branches: ['**'] + +jobs: + test-and-build: + runs-on: host + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + # 1) Static/type checks (svelte-check). Fast, no network. + - name: Type & Svelte checks + run: npm run check + + # 2) Data-integrity + search regressions against committed data. + # Runs offline; does NOT hit the Overpass API (avoids rate limits). + - name: Run tests + run: npm test + + # 3) Build the static site using committed static/data (no live fetch). + - name: Static build + run: npm run build:static + + - name: Verify build output + run: | + test -f build/index.html + test -d build/data + echo "Build output OK" \ No newline at end of file diff --git a/README.md b/README.md index aa1e8c8..0974165 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,19 @@ python3 -m http.server 8080 Because the site uses a static adapter with prerendering, everything can be hosted on any static file host or CDN. + +## Continuous Integration + +Gitea Actions runs **tests on every commit/push** (and on pull requests) via +`.gitea/workflows/ci.yml`. It runs offline (no Overpass/Nominatim calls) so it +won't trip API rate limits: + +- `npm run check` — Svelte/TypeScript checks +- `npm test` — data-integrity + fuzzy-search regression suite against committed data +- `npm run build:static` — static site build (uses committed `static/data`) + +Requires a registered Gitea Actions runner (label `host`). + ## Repository Hosted on Gitea: https://gitea.thecookiejar.me/hermes-explorigin/navigator \ No newline at end of file diff --git a/package.json b/package.json index 3dbdb10..f5de3b2 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "data:area": "node scripts/osm-data.mjs", "build:data": "node scripts/osm-data.mjs", "build": "npm run build:data && vite build", + "build:static": "vite build", + "test": "node scripts/test.mjs", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", diff --git a/scripts/osm-data.mjs b/scripts/osm-data.mjs index da5325c..bc41817 100644 --- a/scripts/osm-data.mjs +++ b/scripts/osm-data.mjs @@ -165,7 +165,7 @@ async function buildArea(name, cfg, signal) { const outPath = resolve(OUT_DIR, `${name}.geojson`); await writeFile(outPath, JSON.stringify(fc)); console.log(` wrote ${features.length} features -> ${outPath}`); - return { name, label: cfg.label, category: 'osm', bbox: cfg.bbox, featureCount: features.length, path: `/data/${name}.geojson` }; + return { name, label: cfg.label, category: 'osm', layerType: 'mixed', bbox: cfg.bbox, featureCount: features.length, path: `/data/${name}.geojson` }; } diff --git a/scripts/test.mjs b/scripts/test.mjs new file mode 100644 index 0000000..da9855c --- /dev/null +++ b/scripts/test.mjs @@ -0,0 +1,135 @@ +/** + * 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); +}); \ No newline at end of file diff --git a/static/data/_index.json b/static/data/_index.json index c1db2ac..2437a12 100644 --- a/static/data/_index.json +++ b/static/data/_index.json @@ -3,6 +3,7 @@ "name": "tulsa", "label": "Tulsa, Oklahoma", "category": "osm", + "layerType": "mixed", "bbox": [ 35.9, -96.1,