Add Gitea Actions CI: run tests on every push
Some checks failed
CI / test-and-build (push) Failing after 1m18s
Some checks failed
CI / test-and-build (push) Failing after 1m18s
- .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
This commit is contained in:
parent
4e3fef6559
commit
f43530bed0
42
.gitea/workflows/ci.yml
Normal file
42
.gitea/workflows/ci.yml
Normal file
@ -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"
|
||||
13
README.md
13
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
|
||||
@ -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",
|
||||
|
||||
@ -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` };
|
||||
}
|
||||
|
||||
|
||||
|
||||
135
scripts/test.mjs
Normal file
135
scripts/test.mjs
Normal file
@ -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);
|
||||
});
|
||||
@ -3,6 +3,7 @@
|
||||
"name": "tulsa",
|
||||
"label": "Tulsa, Oklahoma",
|
||||
"category": "osm",
|
||||
"layerType": "mixed",
|
||||
"bbox": [
|
||||
35.9,
|
||||
-96.1,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user