- shared/protocol.ts: TurnResult.unowned (keys released this turn) + the map presentation contract (regionFillStyle / unownedTooltipLabel, hue-less grey) - shared/types.ts: required GameState.unowned (cumulative mid-game releases; [] at init, distinct from preset-unassigned regions) - server/engine.ts: applyTurnResult applies releases first (sole-owner only, never fabricating no man's land from unassigned keys), then claims against the post-release snapshot (gain on an ownerless key = re-claim); lone release is isMeaningful; prompts + user payload carry the unowned field - server/stub.ts: unchanged behavior (never releases; e2e stays stable) - server/db.ts: legacy rows load with unowned defaulted to [] - server/index.ts: create-game sets []; jump reference table includes released keys so the model can name them for re-claim - WorldMap/App: unowned prop -> visible grey + 'No man's land' tooltip - unit tests: 105 -> 127 (release/re-claim suites, presentation contract, legacy-row normalization)
214 lines
8.5 KiB
TypeScript
214 lines
8.5 KiB
TypeScript
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: [],
|
|
};
|
|
|
|
// 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');
|
|
});
|
|
});
|