140 lines
6.6 KiB
TypeScript
140 lines
6.6 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|