192 lines
7.3 KiB
TypeScript
192 lines
7.3 KiB
TypeScript
/**
|
||
* 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<T> {
|
||
type: 'FeatureCollection';
|
||
features: T[];
|
||
}
|
||
|
||
// Read each file exactly once.
|
||
const regions = JSON.parse(
|
||
readFileSync(path.join(root, 'public', 'regions.geo.json'), 'utf8'),
|
||
) as FeatureCollection<RegionFeature>;
|
||
const countries = JSON.parse(
|
||
readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8'),
|
||
) as FeatureCollection<CountryFeature>;
|
||
|
||
// Precompute areas once (O(total vertices)).
|
||
const countryArea = new Map<string, number>();
|
||
for (const c of countries.features) countryArea.set(c.id, featureAreaKm2(c.geometry));
|
||
const regionArea = new Map<string, number>();
|
||
for (const f of regions.features) regionArea.set(f.id, featureAreaKm2(f.geometry));
|
||
const regionSumByParent = new Map<string, number>();
|
||
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<string, number>();
|
||
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%)`,
|
||
);
|
||
});
|
||
});
|