/** * scripts/region-centroids.test.ts — validation for the committed * `public/region-centroids.json` (built by `scripts/build-region-centroids.mjs`). * * The manifest maps each region key to the anchor point a map marker (v0.27, * #44) is placed at: the centroid of the region's largest-area part, plus a * `spread` (≈ 0.1 × that part's smallest bbox dimension, in degrees) that the * engine uses to scatter multiple markers of one region around the centroid. * It is a load-bearing data artifact for the marker placement path — this test * pins its contract. * * Deliberately dependency-free: it reads the JSON with plain `fs`/`JSON.parse` * and computes bbox checks by iterating coordinates directly (no `@turf`/ * geojson types, which would pull `@types/geojson` into the tsc program and * break `WorldMap.tsx` — see the v0.17 note). Mirrors the style of * `scripts/region-adjacency.test.ts`; the determinism test re-runs the (turf * `.mjs`) builder and compares bytes, matching that the builder is idempotent. */ import { readFileSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; const here = path.dirname(fileURLToPath(import.meta.url)); const root = path.resolve(here, '..'); function readJson(rel: string): Record { return JSON.parse(readFileSync(path.join(root, rel), 'utf8')); } interface CentroidEntry { lat: number; lon: number; spread: number; } /** All [lon, lat] coordinate pairs of a Polygon / MultiPolygon geometry. */ function allCoords(geometry: { type?: string; coordinates?: unknown }): Array<[number, number]> { const c = geometry?.coordinates as | Array> | Array>> | undefined; if (!c) return []; if (geometry?.type === 'Polygon') { const rings = c as Array>; const out: Array<[number, number]> = []; for (const ring of rings) for (const p of ring) out.push(p); return out; } if (geometry?.type === 'MultiPolygon') { const polys = c as Array>>; const out: Array<[number, number]> = []; for (const poly of polys) for (const ring of poly) for (const p of ring) out.push(p); return out; } return []; } /** The bounding box [minLon, minLat, maxLon, maxLat] of a geometry. */ function bboxOf(geometry: { type?: string; coordinates?: unknown }): [number, number, number, number] { let minLon = Infinity; let minLat = Infinity; let maxLon = -Infinity; let maxLat = -Infinity; for (const [x, y] of allCoords(geometry)) { if (x < minLon) minLon = x; if (y < minLat) minLat = y; if (x > maxLon) maxLon = x; if (y > maxLat) maxLat = y; } return [minLon, minLat, maxLon, maxLat]; } describe('region-centroids.json', () => { const centroids = readJson('public/region-centroids.json') as Record; const manifest = readJson('public/region-keys.json') as Record; const geo = readJson('public/regions.geo.json') as { features?: Array<{ id?: string | number; geometry?: { type?: string; coordinates?: unknown } }>; }; const keys = Object.values(manifest); // featureId -> feature, then regionKey -> feature (via the key manifest). const featureByKey = new Map(); for (const f of geo.features ?? []) { const key = manifest[String(f.id)]; if (key) featureByKey.set(key, f); } it('has exactly one entry per region key (same key set as region-keys.json)', () => { expect(new Set(Object.keys(centroids))).toEqual(new Set(keys)); expect(Object.keys(centroids)).toHaveLength(1800); }); it('every entry is a finite { lat, lon, spread } with a non-negative spread, in world bounds', () => { for (const [k, v] of Object.entries(centroids)) { expect(Number.isFinite(v.lat), `non-finite lat for ${k}`).toBe(true); expect(Number.isFinite(v.lon), `non-finite lon for ${k}`).toBe(true); expect(Number.isFinite(v.spread), `non-finite spread for ${k}`).toBe(true); expect(v.spread >= 0, `negative spread for ${k}`).toBe(true); expect(v.lat >= -90 && v.lat <= 90, `lat out of range for ${k}: ${v.lat}`).toBe(true); expect(v.lon >= -180 && v.lon <= 180, `lon out of range for ${k}: ${v.lon}`).toBe(true); } }); it('each centroid lies inside its region feature bounding box', () => { // The recorded point is the centroid of the LARGEST part, which is a subset // of the whole feature, so it must fall inside the feature's overall bbox. let checked = 0; for (const [key, c] of Object.entries(centroids)) { const feat = featureByKey.get(key); if (!feat?.geometry) continue; const [minLon, minLat, maxLon, maxLat] = bboxOf(feat.geometry); const eps = 1e-4; // allow for the 6-dp rounding in the builder expect( c.lat >= minLat - eps && c.lat <= maxLat + eps, `lat ${c.lat} outside bbox [${minLat}, ${maxLat}] for ${key}`, ).toBe(true); expect( c.lon >= minLon - eps && c.lon <= maxLon + eps, `lon ${c.lon} outside bbox [${minLon}, ${maxLon}] for ${key}`, ).toBe(true); checked++; } expect(checked).toBe(1800); }); it('spot-checks a couple of well-known anchors land in the right place', () => { // Region keys are sub-national (states/provinces), so we spot-check a few // whose geography is unambiguous. A gross error in the centroid method // (e.g. averaging across all parts of a large multi-part region, or a // lon/lat swap) would land one of these far off. const near = (k: string, lat: number, lon: number, tol: number) => { const c = centroids[k]; expect(c, `missing centroid for ${k}`).toBeDefined(); expect(Math.abs(c!.lat - lat)).toBeLessThan(tol); expect(Math.abs(c!.lon - lon)).toBeLessThan(tol); }; near('ain-lqtt', 46.16, 4.8, 0.5); // FR-01 — eastern France near('california-kikh', 36.87, -120.67, 0.7); // US-CA — central California near('alberta-zopq', 52.09, -116.94, 1.5); // CA-AB — Alberta (large province) near('acre-ekzw', -9.49, -71.11, 0.5); // BR-AC — western Brazil }); it('is deterministic: re-running the builder rewrites a byte-identical file', () => { const file = path.join(root, 'public', 'region-centroids.json'); const before = readFileSync(file, 'utf8'); // The builder is cwd-independent (it resolves the repo root from its own // location) and idempotent: same input files + pure turf + fixed rounding // + stable insertion order → identical bytes. execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-centroids.mjs')], { cwd: root, stdio: 'pipe', }); const after = readFileSync(file, 'utf8'); expect(after).toBe(before); }); });