import { describe, expect, it } from 'vitest'; import { buildRegionMap } from './map'; import { mapFeatureOwner, nationColor, NO_MANS_LAND_FILL, NO_MANS_LAND_STROKE, OWNED_STROKE, regionFillStyle, regionOwner, UNOWNED_FILL, UNOWNED_STROKE, unownedTooltipLabel, type RegionMap, } from '../shared/protocol'; import type { GameState } from '../shared/types'; // Region-key fixtures (v0.19, #16 P1): ownership and the RegionMap are now keyed // by region key (one per owned sub-region), not by a parent-country ISO_A3. The // keys below are deliberately fake (obviously not real manifest entries) so this // test stays self-contained and format-agnostic. 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: ['albania-aaaa', 'wales-bbbb'], relations: {}, }, de: { id: 'de', name: 'DE', type: 'major', stats: { military: 90, economy: 70, stability: 70, population: 90 }, territory: ['brandenburg-cccc'], relations: {}, }, }, events: [], pending: [], version: 1, unowned: [], pendingNationOps: [], }; // The RegionMap built from the fixture: the authoritative region key → owner source. const regions: RegionMap = buildRegionMap(state); describe('buildRegionMap', () => { it('maps each owned region key to its owner + that nation\'s stable color', () => { const m = buildRegionMap(state); expect(m['albania-aaaa']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' }); expect(m['wales-bbbb']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' }); expect(m['brandenburg-cccc']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' }); // a region nobody owns is simply absent expect(m['normandie-dddd']).toBeUndefined(); }); it('(v0.22 #16 P3) a released (no-man\'s-land) key — in GameState.unowned, owned by no nation — is absent: absence IS unowned', () => { const s: GameState = JSON.parse(JSON.stringify(state)); // simulate a mid-game release: the key left every territory and entered unowned s.nations.uk.territory = s.nations.uk.territory.filter((k) => k !== 'albania-aaaa'); s.unowned = ['albania-aaaa']; const m = buildRegionMap(s); expect(m['albania-aaaa']).toBeUndefined(); // the map cannot tell which kind of unowned expect(s.unowned).toEqual(['albania-aaaa']); // ...the client's GameState.unowned decides that }); }); describe('regionOwner', () => { it('resolves an owned region key to its owner record', () => { expect(regionOwner(regions, 'albania-aaaa')).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk', }); expect(regionOwner(regions, 'brandenburg-cccc')).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de', }); }); it('returns undefined for an unowned region (no RegionMap entry)', () => { expect(regionOwner(regions, 'normandie-dddd')).toBeUndefined(); expect(regionOwner(regions, 'yorkshire-eeee')).toBeUndefined(); }); it('returns undefined for an empty or missing key', () => { expect(regionOwner(regions, '')).toBeUndefined(); expect(regionOwner(regions, null)).toBeUndefined(); expect(regionOwner(regions, undefined)).toBeUndefined(); }); }); /** * #6 regression guard (sub-national map, region-keyed). The click/hover handlers * resolve the owning nation from a rendered map feature. MapLibre repacks a * GeoJSON source's top-level `id` into a *numeric index* when it stores the * source, so a feature the map hands back at interaction time has a number for * `id`. The choropleth is sub-national: a feature's own sub-region id is a code * (`DEU-03`), and its owner now lives at its **region key** (`brandenburg-cccc`), * which the client stamps into `properties.regionKey` from `region-keys.json` * (the RegionMap is keyed by that key, never the sub-region id nor the repacked * id). This pins the exact data shape and proves the resolution: * `mapFeatureOwner` works via `properties.regionKey`, while the two wrong paths * (the repacked numeric `feature.id`, and the sub-region `regionId`, which is * not a RegionMap key) do not. */ describe('#6 — sub-region owner must be resolved from properties.regionKey, not the sub-region id / repacked feature.id', () => { // The shape MapLibre returns for a sub-region feature after setData(): `id` // is a number (repacked), and the stable identifiers live only in properties — // `regionId` (the sub-region code) and `regionKey` (the region key). const repackedFeature = { id: 0, properties: { name: 'Brandenburg R03', regionId: 'DEU-03', regionKey: 'brandenburg-cccc' }, }; it('resolves the owner from properties.regionKey (the fix)', () => { expect(mapFeatureOwner(regions, repackedFeature)).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de', }); }); it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => { // 'DEU-03' is a sub-region id; the RegionMap is keyed by region key. expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined(); }); it('the repacked numeric feature.id does NOT resolve — the bug', () => { // String(0) === '0' is not a valid key, so the pre-fix lookup failed and // reported "No owner" even for owned regions. expect(regionOwner(regions, String(repackedFeature.id))).toBeUndefined(); }); }); describe('mapFeatureOwner', () => { it('returns undefined for a missing feature, missing regionKey, or non-string regionKey', () => { expect(mapFeatureOwner(regions, null)).toBeUndefined(); expect(mapFeatureOwner(regions, undefined)).toBeUndefined(); expect(mapFeatureOwner(regions, {})).toBeUndefined(); expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined(); expect(mapFeatureOwner(regions, { properties: { regionKey: 42 } })).toBeUndefined(); }); it('returns undefined for a region key nobody owns', () => { expect(mapFeatureOwner(regions, { properties: { regionKey: 'normandie-dddd' } })).toBeUndefined(); expect(mapFeatureOwner(regions, { properties: { regionKey: 'yorkshire-eeee' } })).toBeUndefined(); }); }); describe('nationColor', () => { it('is deterministic per id and distinct across different ids', () => { expect(nationColor('uk')).toBe(nationColor('uk')); expect(nationColor('uk')).not.toBe(nationColor('de')); // always a valid HSL string expect(nationColor('uk')).toMatch(/^hsl\(\d+ 58% 46%\)$/); }); }); // (v0.22, #16 P3) The no-man's-land presentation contract lives in // shared/protocol (shared/*.test.ts is not in the vitest glob, so these // tests live here): the distinct visible grey, the faint preset-unassigned // look, the per-kind borders, and the tooltip sub-line. describe('no man\'s land presentation contract (v0.22 #16 P3)', () => { it('regionFillStyle: an owned region uses its nation color + the owned border', () => { expect(regionFillStyle(regions['albania-aaaa'], false)).toEqual({ fill: nationColor('uk'), stroke: OWNED_STROKE, }); expect(regionFillStyle(regions['brandenburg-cccc'], true)).toEqual({ fill: nationColor('de'), stroke: OWNED_STROKE, }); // the no-man's-land flag never affects an owned region }); it('regionFillStyle: an unowned region that IS no man\'s land gets the distinct visible grey', () => { expect(regionFillStyle(undefined, true)).toEqual({ fill: NO_MANS_LAND_FILL, stroke: NO_MANS_LAND_STROKE, }); }); it('regionFillStyle: an unowned region that was never released keeps the faint look', () => { expect(regionFillStyle(undefined, false)).toEqual({ fill: UNOWNED_FILL, stroke: UNOWNED_STROKE, }); }); it('the no-man\'s-land grey can never collide with a nation color (hue-less vs HSL)', () => { expect(NO_MANS_LAND_FILL).not.toMatch(/^hsl\(/); expect(NO_MANS_LAND_FILL).not.toBe(nationColor('uk')); expect(NO_MANS_LAND_FILL).not.toBe(nationColor('de')); // and it must be visibly different from the faint preset-unassigned fill expect(NO_MANS_LAND_FILL).not.toBe(UNOWNED_FILL); expect(NO_MANS_LAND_STROKE).not.toBe(UNOWNED_STROKE); }); it('unownedTooltipLabel: the tooltip sub-line names the two kinds of unowned', () => { expect(unownedTooltipLabel(true)).toBe("No man's land"); expect(unownedTooltipLabel(false)).toBe('No owner'); }); });