Replace geometric sub-regions with real historical regions (v0.13.0)
CI / build + unit + e2e (pull_request) Successful in 2m34s
CI / build + unit + e2e (pull_request) Successful in 2m34s
Part of #16
This commit is contained in:
+109
-73
@@ -1,15 +1,22 @@
|
||||
/**
|
||||
* 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:
|
||||
* The dataset is now REAL named sub-national administrative regions (Natural
|
||||
* Earth admin-1: states, provinces, governorates, oblasts, …) rather than the
|
||||
* old geometric grid — one feature per named region, each carrying
|
||||
* `properties = { name, parent }` where `parent` is the country's ISO_A3 (the
|
||||
* RegionMap key the map resolves ownership through). Tiny or uncovered
|
||||
* countries collapse to a single whole-country region (`<ISO_A3>-01`).
|
||||
*
|
||||
* This test is self-contained (no import of the generator): it re-implements
|
||||
* the same planar area model the generator/analysis use, so the area-based
|
||||
* assertions measure the same quantity:
|
||||
*
|
||||
* 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).
|
||||
* with a shoelace per OUTER ring, each ring measured at its own mean latitude.
|
||||
* Outer rings only — a region's inner rings (holes) are lakes and are ignored
|
||||
* by design, the same way the coverage band already absorbed them.
|
||||
*
|
||||
* It reads each file exactly once and runs in O(total vertices).
|
||||
*/
|
||||
@@ -21,7 +28,7 @@ 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) -----------------
|
||||
// --- area model (planar approximation; matches scripts/generate-regions.mjs) --
|
||||
const KM_LON = 111.32;
|
||||
const KM_LAT = 110.574;
|
||||
const rad = (deg: number) => (deg * Math.PI) / 180;
|
||||
@@ -59,7 +66,14 @@ function featureAreaKm2(geom: Geometry): number {
|
||||
}
|
||||
return total;
|
||||
}
|
||||
// -------------------------------------------------------------------------------
|
||||
|
||||
function median(nums: number[]): number {
|
||||
if (!nums.length) return 0;
|
||||
const s = [...nums].sort((a, b) => a - b);
|
||||
const m = Math.floor(s.length / 2);
|
||||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
||||
}
|
||||
// ------------------------------------------------------------------------------
|
||||
|
||||
interface RegionFeature {
|
||||
type: 'Feature';
|
||||
@@ -73,96 +87,116 @@ interface CountryFeature {
|
||||
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>;
|
||||
) as { type: 'FeatureCollection'; features: RegionFeature[] };
|
||||
const countries = JSON.parse(
|
||||
readFileSync(path.join(root, 'public', 'countries.geo.json'), 'utf8'),
|
||||
) as FeatureCollection<CountryFeature>;
|
||||
) as { type: 'FeatureCollection'; features: CountryFeature[] };
|
||||
|
||||
// Precompute areas once (O(total vertices)).
|
||||
// Precompute once (O(total vertices)).
|
||||
const countryIds = new Set(countries.features.map((c) => c.id));
|
||||
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 sumByParent = new Map<string, number>();
|
||||
for (const f of regions.features)
|
||||
sumByParent.set(f.properties.parent, (sumByParent.get(f.properties.parent) ?? 0) + (regionArea.get(f.id) ?? 0));
|
||||
const countByParent = new Map<string, number>();
|
||||
for (const f of regions.features) {
|
||||
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', () => {
|
||||
it('is a valid GeoJSON FeatureCollection of well-formed features', () => {
|
||||
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.id, `${f.id ?? '?'}: id must be a string`).toBe('string');
|
||||
expect(f.id.length, `${f.id ?? '?'}: empty id`).toBeGreaterThan(0);
|
||||
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);
|
||||
if (g) {
|
||||
const polys = g.type === 'Polygon' ? [g.coordinates] : g.coordinates;
|
||||
for (const poly of polys)
|
||||
for (const ring of poly)
|
||||
expect(ring.length, `${f.id}: ring with ${ring.length} pts`).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('every region has a sane area: >= 200 km² (microstates exempt) and < 5,000,000 km²', () => {
|
||||
let maxArea = 0;
|
||||
let maxId = '';
|
||||
it('has globally unique feature ids (ISO 3166-2 disambiguated on within-country collision)', () => {
|
||||
const seen = new Set<string>();
|
||||
const dups = new Set<string>();
|
||||
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;
|
||||
}
|
||||
if (seen.has(f.id)) dups.add(f.id);
|
||||
seen.add(f.id);
|
||||
}
|
||||
console.log(`\n max region area: ${maxId} ≈ ${Math.round(maxArea).toLocaleString('en-US')} km²`);
|
||||
expect(
|
||||
dups.size,
|
||||
`duplicate ids: ${[...dups].slice(0, 10).join(', ')}`,
|
||||
).toBe(0);
|
||||
expect(seen.size).toBe(regions.features.length);
|
||||
});
|
||||
|
||||
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.
|
||||
it('covers every one of the 177 countries with at least one region', () => {
|
||||
const counts = countries.features.map((c) => countByParent.get(c.id) ?? 0);
|
||||
for (const c of countries.features) {
|
||||
expect(countByParent.get(c.id) ?? 0, `country ${c.id} has no regions`).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
const min = Math.min(...counts);
|
||||
const max = Math.max(...counts);
|
||||
const med = median(counts);
|
||||
const maxCountry = [...countByParent.entries()].sort((a, b) => b[1] - a[1])[0];
|
||||
const maxCountryName = countries.features.find((c) => c.id === maxCountry[0])?.properties.name ?? '';
|
||||
console.log(
|
||||
`\n per-country region count: min ${min} / median ${med} / max ${max}` +
|
||||
` (max: ${maxCountry[0]} ${maxCountryName} with ${maxCountry[1]})`,
|
||||
);
|
||||
});
|
||||
|
||||
it('has sane region areas: median >= 100 km2, and no non-Antarctica region >= 5,000,000 km2', () => {
|
||||
const areas = regions.features.map((f) => regionArea.get(f.id) ?? 0);
|
||||
const med = median(areas);
|
||||
const maxRegion = regions.features.reduce((m, f) => ((regionArea.get(f.id) ?? 0) > (regionArea.get(m.id) ?? 0) ? f : m));
|
||||
const minRegion = regions.features.reduce((m, f) => ((regionArea.get(f.id) ?? 0) < (regionArea.get(m.id) ?? 0) ? f : m));
|
||||
const tiny = areas.filter((a) => a < 1).length;
|
||||
// Antarctica (ATA) is a full-360° wrap ring, so the planar model can't
|
||||
// measure it (it reports ~21.8M km2 for a ~14M km2 landmass) — exempt it.
|
||||
for (const f of regions.features) {
|
||||
if (f.properties.parent === 'ATA') continue;
|
||||
const a = regionArea.get(f.id) ?? 0;
|
||||
expect(a < 5_000_000, `${f.id}: area ${Math.round(a).toLocaleString('en-US')} km2 is absurdly large`).toBe(true);
|
||||
}
|
||||
expect(med, `median region area ${med.toFixed(1)} km2 is too small`).toBeGreaterThanOrEqual(100);
|
||||
console.log(
|
||||
`\n region area: min ${minRegion.id} (${minRegion.properties.name}) ≈ ${(regionArea.get(minRegion.id) ?? 0).toExponential(2)} km2` +
|
||||
` / median ${med.toLocaleString('en-US')} km2` +
|
||||
` / max ${maxRegion.id} (${maxRegion.properties.name}) ≈ ${Math.round(regionArea.get(maxRegion.id) ?? 0).toLocaleString('en-US')} km2` +
|
||||
` — ${tiny} regions < 1 km2 (legit micro-regions: tiny islands, small urban districts, degenerate disputed enclaves)`,
|
||||
);
|
||||
});
|
||||
|
||||
it('sums to within [40%, 175%] of each country area (median in [70%, 130%])', () => {
|
||||
// The band is a planar-approximation sanity check. It is wider than a naive
|
||||
// [40%, 110%] because Natural Earth's admin-1 polygons are digitized
|
||||
// independently of the admin-0 country polygons (they are NOT clipped to
|
||||
// them), so for a handful of small/island nations the region union
|
||||
// over/under-fills the country: measured range is ~[52%, 151%]
|
||||
// (Brunei under-fills, Vanuatu over-fills). The band brackets that with
|
||||
// margin while still catching a genuinely missing or doubled-up region.
|
||||
const ratios: number[] = [];
|
||||
let minRatio = Infinity;
|
||||
let maxRatio = -Infinity;
|
||||
let minId = '';
|
||||
@@ -170,22 +204,24 @@ describe('regions.geo.json', () => {
|
||||
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;
|
||||
}
|
||||
const ratio = (sumByParent.get(c.id) ?? 0) / ca;
|
||||
ratios.push(ratio);
|
||||
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%]`,
|
||||
ratio >= 0.4 && ratio <= 1.75,
|
||||
`${c.id}: coverage ${(100 * ratio).toFixed(1)}% is outside [40%, 175%]`,
|
||||
).toBe(true);
|
||||
}
|
||||
const medRatio = median(ratios);
|
||||
expect(
|
||||
medRatio >= 0.7 && medRatio <= 1.3,
|
||||
`median coverage ${(100 * medRatio).toFixed(1)}% is outside [70%, 130%]`,
|
||||
).toBe(true);
|
||||
console.log(
|
||||
`\n coverage range: min ${minId} ${(100 * minRatio).toFixed(1)}% / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 45–108%)`,
|
||||
`\n coverage range: min ${minId} ${(100 * minRatio).toFixed(1)}%` +
|
||||
` / median ${(100 * medRatio).toFixed(1)}%` +
|
||||
` / max ${maxId} ${(100 * maxRatio).toFixed(1)}% (band 40–175%)`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user