Tests: region-key contract (map rewrite + regions loader + engine) (v0.19 P1 of #16)
- map.test.ts: rewrite the fixture for region keys; the #6 regression guard now proves the owner resolves via properties.regionKey while the repacked numeric feature.id and the sub-region regionId do not. - regions.test.ts (new): the loader sees 1,796 valid keys; FRA expands to its 42 sub-region keys (all valid, all labelled '… (FRA)'); allTerritoryKeys unions correctly; and with no data it degrades to the raw input. - engine.test.ts: buildUserPrompt includes a regions table only when provided; applyTurnResult with a validKeys set applies real keys and ignores bogus ones, and without one applies all (the legacy/unit path).
This commit is contained in:
@@ -318,6 +318,17 @@ describe('buildUserPrompt (batched round)', () => {
|
|||||||
it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => {
|
it('asks the model to keep its internal reasoning brief (concise-CoT constraint)', () => {
|
||||||
expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i);
|
expect(buildSystemPrompt(preset)).toMatch(/reasoning brief|a few sentences/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes a regions reference table only when one is provided', () => {
|
||||||
|
// with a reference table: the payload carries it (region key -> "name (parent)")
|
||||||
|
const withRef = JSON.parse(
|
||||||
|
buildUserPrompt(baseState(), [], { 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' }),
|
||||||
|
) as { regions?: Record<string, string> };
|
||||||
|
expect(withRef.regions).toEqual({ 'ain-lqtt': 'Ain (FRA)', 'wales-bbbb': 'Wales (GBR)' });
|
||||||
|
// without one: the field is absent entirely (the legacy prompt shape is unchanged)
|
||||||
|
const withoutRef = JSON.parse(buildUserPrompt(baseState(), [])) as { regions?: Record<string, string> };
|
||||||
|
expect(withoutRef.regions).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('queueRoundItem', () => {
|
describe('queueRoundItem', () => {
|
||||||
@@ -461,6 +472,41 @@ describe('applyTurnResult', () => {
|
|||||||
expect(state.pending).toHaveLength(1); // original untouched
|
expect(state.pending).toHaveLength(1); // original untouched
|
||||||
expect(state.version).toBe(1);
|
expect(state.version).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('with a validKeys set: applies real region keys, ignores hallucinated ones', () => {
|
||||||
|
const state = baseState();
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
// 'wales-bbbb' is a real key (owned by uk in the fixture); 'not-a-region-xyz'
|
||||||
|
// is not a real key at all. Both are proposed as a gain by DE.
|
||||||
|
territoryChanges: [{ nation: 'de', gain: ['wales-bbbb', 'not-a-region-xyz'], lose: [] }],
|
||||||
|
};
|
||||||
|
const next = applyTurnResult(state, result, new Set(['wales-bbbb']));
|
||||||
|
// the valid key was gained (stolen from its owner); the bogus one was ignored
|
||||||
|
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||||
|
expect(next.nations.uk.territory).not.toContain('wales-bbbb');
|
||||||
|
// the bogus key never entered any nation's territory
|
||||||
|
for (const n of Object.values(next.nations)) {
|
||||||
|
expect(n.territory).not.toContain('not-a-region-xyz');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without a validKeys set: applies every listed key (legacy/unit path)', () => {
|
||||||
|
const state = baseState();
|
||||||
|
const result: TurnResult = {
|
||||||
|
date: '1939-09-03',
|
||||||
|
events: [],
|
||||||
|
stateChanges: [],
|
||||||
|
relations: [],
|
||||||
|
territoryChanges: [{ nation: 'de', gain: ['POL', 'wales-bbbb'], lose: [] }],
|
||||||
|
};
|
||||||
|
const next = applyTurnResult(state, result); // no validKeys -> nothing filtered
|
||||||
|
expect(next.nations.de.territory).toContain('POL');
|
||||||
|
expect(next.nations.de.territory).toContain('wales-bbbb');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isMeaningful', () => {
|
describe('isMeaningful', () => {
|
||||||
|
|||||||
+40
-35
@@ -3,6 +3,10 @@ import { buildRegionMap } from './map';
|
|||||||
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
|
import { mapFeatureOwner, nationColor, regionOwner, type RegionMap } from '../shared/protocol';
|
||||||
import type { GameState } from '../shared/types';
|
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 = {
|
const state: GameState = {
|
||||||
id: 'g',
|
id: 'g',
|
||||||
presetId: 'p',
|
presetId: 'p',
|
||||||
@@ -16,7 +20,7 @@ const state: GameState = {
|
|||||||
name: 'UK',
|
name: 'UK',
|
||||||
type: 'major',
|
type: 'major',
|
||||||
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
stats: { military: 80, economy: 70, stability: 60, population: 50 },
|
||||||
territory: ['GBR', 'IRL'],
|
territory: ['albania-aaaa', 'wales-bbbb'],
|
||||||
relations: {},
|
relations: {},
|
||||||
},
|
},
|
||||||
de: {
|
de: {
|
||||||
@@ -24,7 +28,7 @@ const state: GameState = {
|
|||||||
name: 'DE',
|
name: 'DE',
|
||||||
type: 'major',
|
type: 'major',
|
||||||
stats: { military: 90, economy: 70, stability: 70, population: 90 },
|
stats: { military: 90, economy: 70, stability: 70, population: 90 },
|
||||||
territory: ['DEU'],
|
territory: ['brandenburg-cccc'],
|
||||||
relations: {},
|
relations: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -33,28 +37,28 @@ const state: GameState = {
|
|||||||
version: 1,
|
version: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
// The RegionMap built from the fixture: the authoritative ISO_A3 → owner source.
|
// The RegionMap built from the fixture: the authoritative region key → owner source.
|
||||||
const regions: RegionMap = buildRegionMap(state);
|
const regions: RegionMap = buildRegionMap(state);
|
||||||
|
|
||||||
describe('buildRegionMap', () => {
|
describe('buildRegionMap', () => {
|
||||||
it('maps each ISO_A3 region to its owner + that nation\'s stable color', () => {
|
it('maps each owned region key to its owner + that nation\'s stable color', () => {
|
||||||
const m = buildRegionMap(state);
|
const m = buildRegionMap(state);
|
||||||
expect(m.GBR).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
expect(m['albania-aaaa']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||||
expect(m.IRL).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
expect(m['wales-bbbb']).toEqual({ color: nationColor('uk'), owner: 'UK', ownerId: 'uk' });
|
||||||
expect(m.DEU).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
|
expect(m['brandenburg-cccc']).toEqual({ color: nationColor('de'), owner: 'DE', ownerId: 'de' });
|
||||||
// a region nobody owns is simply absent
|
// a region nobody owns is simply absent
|
||||||
expect(m.FRA).toBeUndefined();
|
expect(m['normandie-dddd']).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('regionOwner', () => {
|
describe('regionOwner', () => {
|
||||||
it('resolves an owned region\'s ISO_A3 to its owner record', () => {
|
it('resolves an owned region key to its owner record', () => {
|
||||||
expect(regionOwner(regions, 'GBR')).toEqual({
|
expect(regionOwner(regions, 'albania-aaaa')).toEqual({
|
||||||
color: nationColor('uk'),
|
color: nationColor('uk'),
|
||||||
owner: 'UK',
|
owner: 'UK',
|
||||||
ownerId: 'uk',
|
ownerId: 'uk',
|
||||||
});
|
});
|
||||||
expect(regionOwner(regions, 'DEU')).toEqual({
|
expect(regionOwner(regions, 'brandenburg-cccc')).toEqual({
|
||||||
color: nationColor('de'),
|
color: nationColor('de'),
|
||||||
owner: 'DE',
|
owner: 'DE',
|
||||||
ownerId: 'de',
|
ownerId: 'de',
|
||||||
@@ -62,11 +66,11 @@ describe('regionOwner', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for an unowned region (no RegionMap entry)', () => {
|
it('returns undefined for an unowned region (no RegionMap entry)', () => {
|
||||||
expect(regionOwner(regions, 'FRA')).toBeUndefined();
|
expect(regionOwner(regions, 'normandie-dddd')).toBeUndefined();
|
||||||
expect(regionOwner(regions, 'USA')).toBeUndefined();
|
expect(regionOwner(regions, 'yorkshire-eeee')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for an empty or missing code', () => {
|
it('returns undefined for an empty or missing key', () => {
|
||||||
expect(regionOwner(regions, '')).toBeUndefined();
|
expect(regionOwner(regions, '')).toBeUndefined();
|
||||||
expect(regionOwner(regions, null)).toBeUndefined();
|
expect(regionOwner(regions, null)).toBeUndefined();
|
||||||
expect(regionOwner(regions, undefined)).toBeUndefined();
|
expect(regionOwner(regions, undefined)).toBeUndefined();
|
||||||
@@ -74,28 +78,29 @@ describe('regionOwner', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #6 regression guard (sub-national map). The click/hover handlers resolve the
|
* #6 regression guard (sub-national map, region-keyed). The click/hover handlers
|
||||||
* owning nation from a rendered map feature. MapLibre repacks a GeoJSON
|
* resolve the owning nation from a rendered map feature. MapLibre repacks a
|
||||||
* source's top-level `id` into a *numeric index* when it stores the source, so a
|
* GeoJSON source's top-level `id` into a *numeric index* when it stores the
|
||||||
* feature the map hands back at interaction time has a number for `id`. The
|
* source, so a feature the map hands back at interaction time has a number for
|
||||||
* choropleth is now sub-national: a feature's id is a sub-region code
|
* `id`. The choropleth is sub-national: a feature's own sub-region id is a code
|
||||||
* (`DEU-03`) and its owner lives at the *parent country* (`DEU`), which the
|
* (`DEU-03`), and its owner now lives at its **region key** (`brandenburg-cccc`),
|
||||||
* client stamps into `properties.parent` (the RegionMap is keyed by that
|
* which the client stamps into `properties.regionKey` from `region-keys.json`
|
||||||
* country code, never the sub-region id). This pins the exact data shape and
|
* (the RegionMap is keyed by that key, never the sub-region id nor the repacked
|
||||||
* proves the resolution: `mapFeatureOwner` works via `properties.parent`, while
|
* id). This pins the exact data shape and proves the resolution:
|
||||||
* the two wrong paths (the repacked numeric `feature.id`, and the sub-region
|
* `mapFeatureOwner` works via `properties.regionKey`, while the two wrong paths
|
||||||
* `regionId`, which is not a RegionMap key) do not.
|
* (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.parent, not the sub-region id / repacked feature.id', () => {
|
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`
|
// The shape MapLibre returns for a sub-region feature after setData(): `id`
|
||||||
// is a number (repacked), and the stable identifiers live only in properties —
|
// is a number (repacked), and the stable identifiers live only in properties —
|
||||||
// `regionId` (the sub-region code) and `parent` (the ISO_A3 country).
|
// `regionId` (the sub-region code) and `regionKey` (the region key).
|
||||||
const repackedFeature = {
|
const repackedFeature = {
|
||||||
id: 0,
|
id: 0,
|
||||||
properties: { name: 'Germany R03', regionId: 'DEU-03', parent: 'DEU' },
|
properties: { name: 'Brandenburg R03', regionId: 'DEU-03', regionKey: 'brandenburg-cccc' },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('resolves the owner from properties.parent (the fix)', () => {
|
it('resolves the owner from properties.regionKey (the fix)', () => {
|
||||||
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
|
expect(mapFeatureOwner(regions, repackedFeature)).toEqual({
|
||||||
color: nationColor('de'),
|
color: nationColor('de'),
|
||||||
owner: 'DE',
|
owner: 'DE',
|
||||||
@@ -104,7 +109,7 @@ describe('#6 — sub-region owner must be resolved from properties.parent, not t
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('the sub-region regionId is NOT a RegionMap key — resolving from it fails', () => {
|
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 the parent 'DEU'.
|
// 'DEU-03' is a sub-region id; the RegionMap is keyed by region key.
|
||||||
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
|
expect(regionOwner(regions, repackedFeature.properties.regionId)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,17 +121,17 @@ describe('#6 — sub-region owner must be resolved from properties.parent, not t
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('mapFeatureOwner', () => {
|
describe('mapFeatureOwner', () => {
|
||||||
it('returns undefined for a missing feature, missing parent, or non-string parent', () => {
|
it('returns undefined for a missing feature, missing regionKey, or non-string regionKey', () => {
|
||||||
expect(mapFeatureOwner(regions, null)).toBeUndefined();
|
expect(mapFeatureOwner(regions, null)).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
|
expect(mapFeatureOwner(regions, undefined)).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, {})).toBeUndefined();
|
expect(mapFeatureOwner(regions, {})).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
|
expect(mapFeatureOwner(regions, { properties: {} })).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, { properties: { parent: 42 } })).toBeUndefined();
|
expect(mapFeatureOwner(regions, { properties: { regionKey: 42 } })).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for a parent country nobody owns', () => {
|
it('returns undefined for a region key nobody owns', () => {
|
||||||
expect(mapFeatureOwner(regions, { properties: { parent: 'FRA' } })).toBeUndefined();
|
expect(mapFeatureOwner(regions, { properties: { regionKey: 'normandie-dddd' } })).toBeUndefined();
|
||||||
expect(mapFeatureOwner(regions, { properties: { parent: 'USA' } })).toBeUndefined();
|
expect(mapFeatureOwner(regions, { properties: { regionKey: 'yorkshire-eeee' } })).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import {
|
||||||
|
allTerritoryKeys,
|
||||||
|
expandCountriesToKeys,
|
||||||
|
hasRegionData,
|
||||||
|
loadRegions,
|
||||||
|
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,796 unique valid region keys', () => {
|
||||||
|
expect(hasRegionData()).toBe(true);
|
||||||
|
expect(validKeys().size).toBe(1796);
|
||||||
|
});
|
||||||
|
|
||||||
|
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('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,
|
||||||
|
};
|
||||||
|
// 16 GBR sub-regions + 42 FRA sub-regions, no overlap between the two.
|
||||||
|
expect(allTerritoryKeys(state).size).toBe(58);
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => resetRegions());
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user