Files
History/scripts/region-geo.test.ts
2026-10-15 00:17:19 +00:00

140 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown> {
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<string, number>;
// feature id -> region key
const manifest = readJson('public/region-keys.json') as Record<string, string>;
// 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<string, string> = {};
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<string, GeoEntry>;
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<string, number> = {};
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);
});
});