/** * scripts/region-geo.test.ts — validation for the committed * `public/region-geo.json` (built by `scripts/build-region-geo.mjs`). * * The manifest maps each region key to its { area (km²), population } — the * weighting source for the per-region stats model (v0.28, #46): a nation's * four 0–100 stats are derived as the area×population-weighted median of the * stats of the regions it owns. It is a load-bearing data artifact — this * test pins its contract. * * The `area` is the turf area of the region's final geometry (km², 2 dp); the * `population` is the country's Natural-Earth admin-0 `POP_EST` (from the * committed `public/ne-admin0-pop.json`) distributed across its sub-regions * proportional to area via largest-remainder, so every country's sub-regions * sum EXACTLY to the country's total (and the global total is the sum over the * 177 repo countries). This is a dependency-free test: it reads the JSON with * plain `fs`/`JSON.parse` (no `@turf`/geojson types, which would pull * `@types/geojson` into the tsc program and break `WorldMap.tsx` — see the * v0.17 note). 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 GeoEntry { area: number; population: number; } // The 177 repo countries' admin-0 population totals (the committed derived // source). Only the countries actually present in the region dataset appear. const admin0 = readJson('public/ne-admin0-pop.json') as Record; // feature id -> region key const manifest = readJson('public/region-keys.json') as Record; // feature id -> { name, parent (ISO_A3) } const regGeo = readJson('public/regions.geo.json') as { features?: Array<{ id?: string | number; properties?: { name?: string; parent?: string } }>; }; // region key -> parent ISO_A3 (via the key manifest + the geo feature parent). const keyToParent: Record = {}; for (const f of regGeo.features ?? []) { const key = manifest[String(f.id)]; const parent = f.properties?.parent; if (key && parent) keyToParent[key] = parent; } const geo = readJson('public/region-geo.json') as Record; const keys = Object.values(manifest); describe('region-geo.json', () => { it('has exactly one entry per region key (same key set as region-keys.json)', () => { expect(new Set(Object.keys(geo))).toEqual(new Set(keys)); expect(Object.keys(geo)).toHaveLength(1800); }); it('is keyed in sorted (deterministic) order', () => { const ks = Object.keys(geo); expect(ks).toEqual([...ks].sort()); }); it('every entry is a finite { area ≥ 0 with ≤ 2 dp, integer population ≥ 0 }', () => { for (const [k, v] of Object.entries(geo)) { expect(Number.isFinite(v.area), `non-finite area for ${k}`).toBe(true); expect(Number.isFinite(v.population), `non-finite population for ${k}`).toBe(true); expect(v.area >= 0, `negative area for ${k}: ${v.area}`).toBe(true); // area is recorded to at most 2 decimal places (km²) expect(Math.round(v.area * 100) / 100 === v.area, `area over 2dp for ${k}: ${v.area}`).toBe(true); expect(Number.isInteger(v.population), `non-integer population for ${k}: ${v.population}`).toBe(true); expect(v.population >= 0, `negative population for ${k}: ${v.population}`).toBe(true); } }); it('the global population total equals the sum of the 177 repo countries\' admin-0 totals', () => { // The distribution preserved each country's total exactly, so the grand // total over all sub-regions is the sum over the repo countries only. const total = Object.values(geo).reduce((s, v) => s + v.population, 0); expect(total).toBe(7654092021); }); it('preserves each country\'s population total across its sub-regions (largest-remainder)', () => { // For every parent country present in the region dataset, the sum of its // sub-regions' populations must equal that country's admin-0 POP_EST // EXACTLY — this is the invariant the largest-remainder distribution // guarantees (no population is created or lost by the split). const byParent: Record = {}; for (const [k, v] of Object.entries(geo)) { const p = keyToParent[k]; if (!p) throw new Error(`no parent for region key ${k}`); byParent[p] = (byParent[p] ?? 0) + v.population; } expect(Object.keys(byParent).length).toBe(177); // the 177 repo countries for (const [p, sum] of Object.entries(byParent)) { expect(admin0[p], `missing admin-0 population for ${p}`).toBeDefined(); expect(sum, `population drift for ${p}: sub-regions ${sum} vs admin-0 ${admin0[p]}`).toBe(admin0[p]); } }); it('spot-checks well-known regions for exact area + population', () => { const is = (k: string, area: number, population: number) => { const v = geo[k]; expect(v, `missing geo for ${k}`).toBeDefined(); expect(v!.area, `area for ${k}`).toBe(area); expect(v!.population, `population for ${k}`).toBe(population); }; is('california-kikh', 413329.21, 14184195); // US-CA is('arkhangel-sk-lhbk', 305257.7, 2579108); // RU-ARK mainland core is('pechora-qwhs', 37399.76, 315989); // RU-PEO (Arctic-coast band, #41) is('yamal-gosw', 50598.25, 427502); // RU-YAM (Arctic-coast band, #41) is('new-earth-eikt', 15721.27, 132828); // RU-ARK-NE (island group, #31) is('severnaya-zemlya-awqp', 1209.93, 10223); // RU-ARK-SZ (island group, #31) is('ain-lqtt', 22557.77, 2304126); // FR-01 (Ain) }); it('is deterministic: re-running the builder rewrites a byte-identical file', () => { const file = path.join(root, 'public', 'region-geo.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 (regions.geo.json + // region-keys.json + ne-admin0-pop.json) + pure turf + fixed 2-dp rounding // + stable (sorted) key order → identical bytes. execFileSync(process.execPath, [path.join(root, 'scripts', 'build-region-geo.mjs')], { cwd: root, stdio: 'pipe', }); const after = readFileSync(file, 'utf8'); expect(after).toBe(before); }); });