/** * scripts/regions.test.ts — validation for the committed `public/regions.geo.json`. * * Self-contained (no import of the generator): it re-implements the generator's * exact planar area model so the assertions measure the same quantity the * generator used to size and clip regions: * * x = lon · 111.32 · cos(midLat), y = lat · 110.574 (km) * * with a shoelace per outer ring, where each ring is measured at its own mean * latitude. Outer rings only — the generator never emits holes (every ring is an * independent piece: a separate island or a separate clipped cell fragment). * * It reads each file exactly once and runs in O(total vertices). */ import { readFileSync } from 'node:fs'; 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, '..'); // --- area model (must match scripts/generate-regions.mjs exactly) ----------------- const KM_LON = 111.32; const KM_LAT = 110.574; const rad = (deg: number) => (deg * Math.PI) / 180; type Ring = [number, number][]; type Geometry = | { type: 'Polygon'; coordinates: Ring[] } | { type: 'MultiPolygon'; coordinates: Ring[][] }; function ringAreaKm2(ring: Ring, midLat: number): number { const kx = KM_LON * Math.cos(rad(midLat)); let a = 0; const n = ring.length; for (let i = 0; i < n; i++) { const [lon1, lat1] = ring[i]; const [lon2, lat2] = ring[(i + 1) % n]; a += lon1 * kx * (lat2 * KM_LAT) - lon2 * kx * (lat1 * KM_LAT); } return Math.abs(a / 2); } function outerRings(geom: Geometry): Ring[] { if (geom.type === 'Polygon') return [geom.coordinates[0]]; if (geom.type === 'MultiPolygon') return geom.coordinates.map((p) => p[0]); return []; } /** Sum of a feature's outer-ring areas, each ring at its own mean latitude. */ function featureAreaKm2(geom: Geometry): number { let total = 0; for (const ring of outerRings(geom)) { let lat = 0; for (const [, la] of ring) lat += la; total += ringAreaKm2(ring, ring.length ? lat / ring.length : 0); } return total; } // ------------------------------------------------------------------------------- interface RegionFeature { type: 'Feature'; id: string; properties: { name: string; parent: string }; geometry: Geometry; } interface CountryFeature { type: 'Feature'; id: string; properties: { name: string }; geometry: Geometry; } interface FeatureCollection { type: 'FeatureCollection'; features: T[]; } // Read each file exactly once. const regions = JSON.parse( readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'), ) as FeatureCollection; const countries = JSON.parse( readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8'), ) as FeatureCollection; // Precompute areas once (O(total vertices)). const countryArea = new Map(); for (const c of countries.features) countryArea.set(c.id, featureAreaKm2(c.geometry)); const regionArea = new Map(); for (const f of regions.features) regionArea.set(f.id, featureAreaKm2(f.geometry)); const regionSumByParent = new Map(); for (const f of regions.features) { regionSumByParent.set( f.properties.parent, (regionSumByParent.get(f.properties.parent) ?? 0) + (regionArea.get(f.id) ?? 0), ); } const countByParent = new Map(); for (const f of regions.features) { countByParent.set(f.properties.parent, (countByParent.get(f.properties.parent) ?? 0) + 1); } describe('regions.geo.json', () => { it('is a valid GeoJSON FeatureCollection', () => { expect(regions.type).toBe('FeatureCollection'); expect(Array.isArray(regions.features)).toBe(true); expect(regions.features.length).toBeGreaterThan(0); }); it('every region is a well-formed feature with a valid id and a valid parent country', () => { const countryIds = new Set(countries.features.map((c) => c.id)); const idRe = /^[A-Z]{3}-\d+$/; for (const f of regions.features) { expect(f.type, `${f.id ?? '?'}: not a Feature`).toBe('Feature'); expect(idRe.test(f.id), `${f.id ?? '?'}: bad id`).toBe(true); expect(typeof f.properties?.name, `${f.id}: missing name`).toBe('string'); expect(f.properties.name.length, `${f.id}: empty name`).toBeGreaterThan(0); expect(typeof f.properties.parent, `${f.id}: missing parent`).toBe('string'); expect(countryIds.has(f.properties.parent), `${f.id}: unknown parent ${f.properties.parent}`).toBe(true); expect(f.properties.parent, `${f.id}: parent != ISO_A3 prefix`).toBe(f.id.slice(0, 3)); const g = f.geometry; expect( !!g && (g.type === 'Polygon' || g.type === 'MultiPolygon'), `${f.id}: bad geometry type ${g?.type}`, ).toBe(true); } }); it('gives every country 1..36 regions, and >=2 for any country > 50,000 km²', () => { for (const c of countries.features) { const n = countByParent.get(c.id) ?? 0; expect(n, `country ${c.id} has no regions`).toBeGreaterThanOrEqual(1); expect(n, `country ${c.id} has ${n} regions (> 36)`).toBeLessThanOrEqual(36); if ((countryArea.get(c.id) ?? 0) > 50_000) { expect(n, `large country ${c.id} should have >= 2 regions`).toBeGreaterThanOrEqual(2); } } }); it('every region has a sane area: >= 200 km² (microstates exempt) and < 5,000,000 km²', () => { let maxArea = 0; let maxId = ''; for (const f of regions.features) { const a = regionArea.get(f.id) ?? 0; const parentArea = countryArea.get(f.properties.parent) ?? 0; // A whole-country singleton of a sub-200 km² microstate is exempt. expect(a >= 200 || parentArea < 200, `${f.id}: area ${a.toFixed(1)} km² is too small`).toBe(true); expect(a < 5_000_000, `${f.id}: area ${Math.round(a).toLocaleString('en-US')} km² is absurdly large`).toBe(true); if (a > maxArea) { maxArea = a; maxId = f.id; } } console.log(`\n max region area: ${maxId} ≈ ${Math.round(maxArea).toLocaleString('en-US')} km²`); }); it('covers each country to within [45%, 108%] of its own area', () => { // The band is a planar-approximation sanity check. The measured range is // ~[64%, 105%]: Antarctica's hemispheric wedges under-fill to ~64%, and // Norway's fjord-heavy, high-latitude coast over-fills to ~105.1% under the // single-mid-latitude approximation. [45%, 108%] brackets that with margin // while still catching a genuinely missing or doubled-up region. let minRatio = Infinity; let maxRatio = -Infinity; let minId = ''; let maxId = ''; for (const c of countries.features) { const ca = countryArea.get(c.id) ?? 0; if (ca <= 0) continue; const ratio = (regionSumByParent.get(c.id) ?? 0) / ca; if (ratio < minRatio) { minRatio = ratio; minId = c.id; } if (ratio > maxRatio) { maxRatio = ratio; maxId = c.id; } expect( ratio >= 0.45 && ratio <= 1.08, `${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [45%, 108%]`, ).toBe(true); } console.log( `\n coverage range: min ${minId} ${(100 * minRatio).toFixed(1)}% / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 45–108%)`, ); }); });