import { afterAll, afterEach, beforeEach, describe, expect, it } from 'vitest'; import { fileURLToPath } from 'node:url'; import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { allTerritoryKeys, centroidOf, expandCountriesToKeys, geoOf, hasAdjacency, hasCentroids, hasGeo, hasRegionData, loadRegions, neighboursOf, referenceForKeys, regionName, resetRegions, validKeys, } from './regions'; import type { GameState } from '../shared/types'; // The repo root: this file lives in server/, so '..' is the directory holding // public/region-keys.json and public/regions.geo.json. cwd-independent (mirrors // how server/index.ts locates its root via import.meta.url). const root = fileURLToPath(new URL('..', import.meta.url)); describe('regions (region-key loader, v0.19 #16 P1)', () => { beforeEach(() => { expect(loadRegions(root)).toBe(true); }); it('loads the full manifest: 1,800 unique valid region keys', () => { expect(hasRegionData()).toBe(true); expect(validKeys().size).toBe(1800); }); it('expands an ISO_A3 country to the region keys of all its sub-regions', () => { const fra = expandCountriesToKeys(['FRA']); // FRA has 42 sub-regions in the committed dataset. expect(fra).toHaveLength(42); // every expanded key is a real region key for (const k of fra) expect(validKeys().has(k)).toBe(true); // and the reference table labels each one with its parent country const ref = referenceForKeys(fra); expect(Object.keys(ref)).toHaveLength(42); for (const [k, label] of Object.entries(ref)) { expect(label.endsWith('(FRA)')).toBe(true); expect(regionName(k)).toBeTruthy(); } // sample known FRA keys, and their exact reference strings expect(fra).toContain('ain-lqtt'); expect(ref['ain-lqtt']).toBe('Ain (FRA)'); expect(ref['aisne-gstu']).toBe('Aisne (FRA)'); }); it('expands RUS to all 84 sub-regions after the #31 + #41 Arkhangel splits (no ownership gap at game init)', () => { // #31 split RU-ARK into the mainland + New Earth + Severnaya Zemlya; #41 // repartitioned the mainland into the core (unchanged key) + Pechora + // Yamal. A fresh game's RUS territory (expanded from the preset's // country list) must contain all five sub-region keys — the main one // (ownership-safe, unchanged key) plus the island and band ones. const rus = expandCountriesToKeys(['RUS']); expect(rus).toHaveLength(84); expect(rus).toContain('arkhangel-sk-lhbk'); expect(rus).toContain('new-earth-eikt'); expect(rus).toContain('severnaya-zemlya-awqp'); expect(rus).toContain('pechora-qwhs'); expect(rus).toContain('yamal-gosw'); }); it('referenceForKeys labels only known keys and omits unknown ones', () => { const ref = referenceForKeys(['ain-lqtt', 'not-a-real-key-xyz']); expect(ref['ain-lqtt']).toBe('Ain (FRA)'); expect(ref['not-a-real-key-xyz']).toBeUndefined(); }); it('allTerritoryKeys unions every nation\'s territory keys (no overlap)', () => { const state: GameState = { id: 'g', presetId: 'p', presetTitle: 'P', playerNationId: 'uk', turn: 1, date: '1939-09-01', nations: { uk: { id: 'uk', name: 'UK', type: 'major', stats: { military: 80, economy: 70, stability: 60, population: 50 }, territory: expandCountriesToKeys(['GBR']), relations: {}, }, fr: { id: 'fr', name: 'FR', type: 'major', stats: { military: 80, economy: 70, stability: 60, population: 50 }, territory: expandCountriesToKeys(['FRA']), relations: {}, }, }, events: [], pending: [], version: 1, unowned: [], pendingNationOps: [], markers: [], // (v0.28, #46) unused here (allTerritoryKeys is ownership-only). regionStats: {}, wars: [], // (v0.35.0, #58) unused here (allTerritoryKeys is ownership-only). trades: [], embargoes: [], }; // 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two. expect(allTerritoryKeys(state).size).toBe(58); }); it('hasAdjacency() is true: the region-adjacency manifest loaded (v0.21 #16 P2)', () => { expect(hasAdjacency()).toBe(true); }); it('neighboursOf returns the union of bordering keys (deduped, sorted, queried excluded)', () => { // known mutual pairs from the manifest (verified in the Stage 1 spot checks) const moselle = neighboursOf(['moselle-rygy']); expect(moselle).toContain('baden-wurttemberg-oyje'); // FRA/DEU border expect(neighboursOf(['baden-wurttemberg-oyje'])).toContain('moselle-rygy'); // mutual expect(neighboursOf(['acre-ekzw'])).toContain('loreto-ipkx'); // BRA/PER border expect(neighboursOf(['loreto-ipkx'])).toContain('acre-ekzw'); // mutual // union + dedupe + sorted + the queried keys themselves excluded const union = neighboursOf(['moselle-rygy', 'baden-wurttemberg-oyje']); expect(union).toEqual([...union].sort()); expect(new Set(union).size).toBe(union.length); // no duplicates expect(union).not.toContain('moselle-rygy'); expect(union).not.toContain('baden-wurttemberg-oyje'); }); it('neighboursOf returns [] for unknown keys or an empty set', () => { expect(neighboursOf(['not-a-real-key-xyz'])).toEqual([]); expect(neighboursOf([])).toEqual([]); }); it('(v0.27 #44) hasCentroids() is true and centroidOf resolves a real key to a plausible location', () => { expect(hasCentroids()).toBe(true); const ain = centroidOf('ain-lqtt'); expect(ain).toBeDefined(); expect(ain!.lat).toBeCloseTo(46.16, 1); expect(ain!.lon).toBeCloseTo(4.8, 1); expect(ain!.spread).toBeGreaterThan(0); }); it('(v0.27 #44) centroidOf returns undefined for an unknown key', () => { expect(centroidOf('not-a-real-key-xyz')).toBeUndefined(); }); it('(v0.28 #46) hasGeo() is true and geoOf resolves a real key to area + population', () => { expect(hasGeo()).toBe(true); const ain = geoOf('ain-lqtt'); expect(ain).toBeDefined(); expect(ain!.area).toBeGreaterThanOrEqual(0); // km² expect(ain!.population).toBeGreaterThanOrEqual(0); // exact spot-check values pinned from the committed manifest (Ain, FRA) expect(ain!.area).toBeCloseTo(22557.77, 1); // km² expect(ain!.population).toBe(2304126); }); it('(v0.28 #46) geoOf returns undefined for an unknown key', () => { expect(geoOf('not-a-real-key-xyz')).toBeUndefined(); }); describe('graceful degradation with no region data', () => { beforeEach(() => resetRegions()); it('expands to the raw input, omits references, and reports no data', () => { expect(hasRegionData()).toBe(false); // no manifest -> the country list passes through unchanged (old model) expect(expandCountriesToKeys(['FRA', 'GBR'])).toEqual(['FRA', 'GBR']); expect(referenceForKeys(['FRA'])).toEqual({}); expect(validKeys().size).toBe(0); expect(regionName('FRA')).toBeUndefined(); // and no adjacency: the LLM tool / frontier field switch off expect(hasAdjacency()).toBe(false); expect(neighboursOf(['moselle-rygy'])).toEqual([]); // and no centroids: the marker-placement path degrades to a no-op expect(hasCentroids()).toBe(false); expect(centroidOf('ain-lqtt')).toBeUndefined(); // and no region-geo: the per-region stats weighting degrades to uniform expect(hasGeo()).toBe(false); expect(geoOf('ain-lqtt')).toBeUndefined(); }); }); afterAll(() => resetRegions()); }); describe('loadRegions resolves the manifest per environment (dev public/ vs prod dist/)', () => { const realRoot = root; // A root that mimics the prod Docker image: no public/, the region files live // in dist/ (where Vite copies public/* at build). This is the exact layout that // broke v0.19.0 in prod (the loader only looked in public/). const base = mkdtempSync(path.join(tmpdir(), 'history-regions-')); const prodRoot = path.join(base, 'prod'); beforeEach(() => { mkdirSync(path.join(prodRoot, 'dist'), { recursive: true }); copyFileSync( path.join(realRoot, 'public', 'region-keys.json'), path.join(prodRoot, 'dist', 'region-keys.json'), ); copyFileSync( path.join(realRoot, 'public', 'regions.geo.json'), path.join(prodRoot, 'dist', 'regions.geo.json'), ); }); afterEach(() => resetRegions()); afterAll(() => { rmSync(base, { recursive: true, force: true }); }); it('loads from dist/ when public/ is absent (the prod container layout)', () => { expect(loadRegions(prodRoot)).toBe(true); expect(hasRegionData()).toBe(true); expect(validKeys().size).toBe(1800); expect(expandCountriesToKeys(['FRA'])).toHaveLength(42); }); it('still loads from public/ (the dev layout, the first candidate)', () => { expect(loadRegions(realRoot)).toBe(true); expect(validKeys().size).toBe(1800); }); it('a dist/ WITHOUT the adjacency manifest degrades: hasAdjacency() false, neighboursOf []', () => { // this fixture copies only region-keys.json + regions.geo.json (no // region-adjacency.json) — the server must boot and serve turns with the // tool/frontier simply switched off. expect(loadRegions(prodRoot)).toBe(true); expect(hasAdjacency()).toBe(false); expect(neighboursOf(['moselle-rygy'])).toEqual([]); // and no centroids (not copied into this dist/) -> marker placement degrades expect(hasCentroids()).toBe(false); expect(centroidOf('ain-lqtt')).toBeUndefined(); // and no region-geo (not copied into this dist/) -> stats weighting is uniform expect(hasGeo()).toBe(false); expect(geoOf('ain-lqtt')).toBeUndefined(); }); it('(v0.27 #44) a dist/ WITH the centroids manifest loads them (prod layout)', () => { copyFileSync( path.join(realRoot, 'public', 'region-centroids.json'), path.join(prodRoot, 'dist', 'region-centroids.json'), ); expect(loadRegions(prodRoot)).toBe(true); expect(hasCentroids()).toBe(true); const ain = centroidOf('ain-lqtt'); expect(ain).toBeDefined(); expect(ain!.lat).toBeCloseTo(46.16, 1); }); it('(v0.28 #46) a dist/ WITH the region-geo manifest loads it (prod layout)', () => { copyFileSync( path.join(realRoot, 'public', 'region-geo.json'), path.join(prodRoot, 'dist', 'region-geo.json'), ); expect(loadRegions(prodRoot)).toBe(true); expect(hasGeo()).toBe(true); const ain = geoOf('ain-lqtt'); expect(ain).toBeDefined(); expect(ain!.area).toBeCloseTo(22557.77, 1); expect(ain!.population).toBe(2304126); }); it('returns false and no data when neither public/ nor dist/ holds the files', () => { const emptyRoot = path.join(base, 'empty'); mkdirSync(emptyRoot, { recursive: true }); expect(loadRegions(emptyRoot)).toBe(false); expect(hasRegionData()).toBe(false); expect(validKeys().size).toBe(0); }); });