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, expandCountriesToKeys, hasAdjacency, 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,798 unique valid region keys', () => { expect(hasRegionData()).toBe(true); expect(validKeys().size).toBe(1798); }); 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 82 sub-regions after the #31 Arkhangel split (no ownership gap at game init)', () => { // #31 split RU-ARK into mainland + New Earth + Severnaya Zemlya. A fresh // game's RUS territory (expanded from the preset's country list) must // contain all three sub-region keys — the main one (ownership-safe, // unchanged key) plus the two new island ones. const rus = expandCountriesToKeys(['RUS']); expect(rus).toHaveLength(82); expect(rus).toContain('arkhangel-sk-lhbk'); expect(rus).toContain('new-earth-eikt'); expect(rus).toContain('severnaya-zemlya-awqp'); }); 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: [], }; // 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([]); }); 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([]); }); }); 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(1798); 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(1798); }); 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([]); }); 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); }); });