Files
Dune/tests/ordered-opportunity.test.ts
2026-09-08 07:37:52 +10:00

295 lines
9.6 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {
OrderedOpportunityError,
protectOrderedOpportunityLast,
reorderOrderedOpportunity,
validateOrderedOpportunity,
type OrderedOpportunity,
} from '../game/ordered-opportunity';
function fixture(
remaining = ['a', 'b', 'c', 'd'],
completed: string[] = [],
): OrderedOpportunity {
return {
event: 'movement-turn-4',
eligible: [...completed, ...remaining],
remaining,
completed,
current: remaining[0] ?? null,
currentStarted: false,
};
}
function freeze(state: OrderedOpportunity) {
Object.freeze(state.eligible);
Object.freeze(state.remaining);
Object.freeze(state.completed);
return Object.freeze(state);
}
function move(
state: OrderedOpportunity,
player: string,
position: 'first' | 'last',
) {
return reorderOrderedOpportunity(state, {
event: state.event,
player,
position,
});
}
function permutations(values: string[]): string[][] {
return values.length
? values.flatMap((value, index) =>
permutations(values.filter((_, at) => at !== index)).map((tail) => [
value,
...tail,
]),
)
: [[]];
}
void test('first and last reorder every unstarted position immutably and leave completed work intact', () => {
const state = freeze(fixture(['a', 'b', 'c', 'd'], ['done']));
for (const player of state.remaining) {
for (const position of ['first', 'last'] as const) {
const result = move(state, player, position);
const rest = state.remaining.filter((id) => id !== player);
assert.deepEqual(
result.remaining,
position === 'first' ? [player, ...rest] : [...rest, player],
);
assert.deepEqual(result.eligible, state.eligible);
assert.deepEqual(result.completed, ['done']);
assert.equal(result.event, state.event);
assert.equal(result.currentStarted, false);
assert.equal(result.current, result.remaining[0]);
assert.notEqual(result, state);
for (const key of ['remaining', 'completed', 'eligible'] as const)
assert.notEqual(result[key], state[key]);
}
}
assert.deepEqual(state, fixture(['a', 'b', 'c', 'd'], ['done']));
});
void test('every finite four-player ordering and completion boundary conserves participants and a started current unit', () => {
let cases = 0;
for (const order of permutations(['a', 'b', 'c', 'd'])) {
for (
let completedCount = 0;
completedCount <= order.length;
completedCount++
) {
for (const started of [false, true]) {
const remaining = order.slice(completedCount);
if (started && !remaining.length) continue;
const state = freeze({
...fixture(remaining, order.slice(0, completedCount)),
currentStarted: started,
});
validateOrderedOpportunity(state);
for (const player of remaining) {
for (const position of ['first', 'last'] as const) {
cases++;
if (
started &&
player === state.current &&
position === 'last' &&
remaining.length > 1
) {
assert.throws(
() => move(state, player, position),
/split a started opportunity/,
);
continue;
}
const result = move(state, player, position);
validateOrderedOpportunity(result);
assert.deepEqual(
[...result.remaining].sort(),
[...remaining].sort(),
);
assert.deepEqual(result.completed, state.completed);
assert.deepEqual(result.eligible, state.eligible);
assert.equal(result.currentStarted, started);
if (started) assert.equal(result.current, state.current);
const unstarted = result.remaining.slice(started ? 1 : 0);
if (!(started && player === state.current))
assert.equal(
position === 'first' ? unstarted[0] : unstarted.at(-1),
player,
);
}
}
}
}
}
assert.equal(cases, 960);
});
void test('first targets the unstarted remainder without interrupting or splitting the locked current actor', () => {
const state = freeze({ ...fixture(), currentStarted: true });
assert.deepEqual(move(state, 'd', 'first').remaining, ['a', 'd', 'b', 'c']);
assert.deepEqual(move(state, 'b', 'last').remaining, ['a', 'c', 'd', 'b']);
assert.deepEqual(move(state, 'a', 'first'), state);
assert.throws(() => move(state, 'a', 'last'), /unsupported/);
assert.throws(
() =>
protectOrderedOpportunityLast(state, { event: state.event, player: 'a' }),
/unsupported/,
);
const sole = freeze({
...fixture(['a'], ['b', 'c', 'd']),
currentStarted: true,
});
assert.deepEqual(move(sole, 'a', 'last'), sole);
});
void test('protected last survives repeated Guild deferral and earlier Guild turns without overriding competing priority', () => {
const initial = freeze(fixture(['sapho', 'a', 'guild', 'b']));
const protectedState = protectOrderedOpportunityLast(initial, {
event: initial.event,
player: 'sapho',
});
assert.deepEqual(protectedState.remaining, ['a', 'guild', 'b', 'sapho']);
assert.equal(protectedState.protectedLast, 'sapho');
let state = move(protectedState, 'guild', 'last');
assert.deepEqual(state.remaining, ['a', 'b', 'guild', 'sapho']);
for (let i = 0; i < 4; i++) state = move(state, 'guild', 'last');
assert.deepEqual(state.remaining, ['a', 'b', 'guild', 'sapho']);
state = move(state, 'guild', 'first');
assert.deepEqual(state.remaining, ['guild', 'a', 'b', 'sapho']);
assert.deepEqual(move(state, 'guild', 'last').remaining, [
'a',
'b',
'guild',
'sapho',
]);
const locked = { ...protectedState, currentStarted: true };
assert.deepEqual(move(locked, 'guild', 'last').remaining, [
'a',
'b',
'guild',
'sapho',
]);
assert.deepEqual(move(locked, 'guild', 'first').remaining, [
'a',
'guild',
'b',
'sapho',
]);
assert.throws(() => move(protectedState, 'sapho', 'first'), /protected last/);
assert.throws(
() =>
protectOrderedOpportunityLast(protectedState, {
event: state.event,
player: 'guild',
}),
/Competing/,
);
assert.deepEqual(
protectOrderedOpportunityLast(protectedState, {
event: state.event,
player: 'sapho',
}),
protectedState,
);
assert.deepEqual(initial, fixture(['sapho', 'a', 'guild', 'b']));
});
void test('stale events, completed actors and ineligible IDs reject without resurrecting or granting an opportunity', () => {
const state = freeze(fixture(['b', 'c'], ['a']));
for (const position of ['first', 'last'] as const) {
assert.throws(
() =>
reorderOrderedOpportunity(state, {
event: 'old-event',
player: 'b',
position,
}),
/expired/,
);
assert.throws(() => move(state, 'a', position), /already completed/);
assert.throws(() => move(state, 'observer', position), /not eligible/);
}
assert.throws(
() =>
protectOrderedOpportunityLast(state, { event: 'old-event', player: 'b' }),
/expired/,
);
assert.throws(
() =>
protectOrderedOpportunityLast(state, { event: state.event, player: 'a' }),
/already completed/,
);
const closed = freeze(fixture([], ['a', 'b', 'c']));
validateOrderedOpportunity(closed);
assert.throws(() => move(closed, 'a', 'first'), /already completed/);
assert.deepEqual(state, fixture(['b', 'c'], ['a']));
});
void test('malformed serialized partitions, current markers and protected positions are rejected rather than repaired', () => {
const state = fixture(['a', 'b'], ['done']);
const corrupt: unknown[] = [
null,
{},
{ ...state, event: '' },
{ ...state, eligible: ['done', 'a', 'a'] },
{ ...state, remaining: ['a', 'a'] },
{ ...state, completed: ['done', 'done'] },
{ ...state, remaining: ['a'] },
{ ...state, remaining: ['a', 'other'] },
{ ...state, completed: ['a'] },
{ ...state, remaining: ['a', ''] },
{ ...state, current: 'b' },
{ ...state, current: null },
{ ...state, currentStarted: 'yes' },
{ ...fixture([], ['done']), currentStarted: true },
{ ...state, protectedLast: 'a' },
{ ...state, protectedLast: 'done' },
{ ...state, protectedLast: 'other' },
];
for (const value of corrupt) {
assert.throws(
() => validateOrderedOpportunity(value as OrderedOpportunity),
OrderedOpportunityError,
);
assert.throws(
() =>
reorderOrderedOpportunity(value as OrderedOpportunity, {
event: state.event,
player: 'a',
position: 'first',
}),
OrderedOpportunityError,
);
}
assert.throws(
() =>
reorderOrderedOpportunity(state, {
event: state.event,
player: 'a',
position: 'middle' as 'first',
}),
/Choose first or last/,
);
});
void test('JSON snapshots preserve scope and protection, and no-op results share no participant arrays', () => {
const first = protectOrderedOpportunityLast(
fixture(['a', 'guild', 'sapho']),
{ event: 'movement-turn-4', player: 'sapho' },
);
const saved: OrderedOpportunity = JSON.parse(
JSON.stringify({ ...first, currentStarted: true }),
);
const restored = move(freeze(saved), 'guild', 'last');
assert.deepEqual(restored, saved);
assert.notEqual(restored.remaining, saved.remaining);
assert.notEqual(restored.eligible, saved.eligible);
assert.notEqual(restored.completed, saved.completed);
assert.equal(restored.event, 'movement-turn-4');
assert.equal(restored.protectedLast, 'sapho');
assert.equal(restored.currentStarted, true);
});