Files
ttrpg-initiative-tracker/shared/tests/turn.invariant.test.js
T
david raistrick d83d1383c0 refactor: single-source combat logic (App handlers call shared, slot model)
App.js no longer inlines combat logic. All handlers delegate to @ttrpg/shared:
startEncounter, nextTurn, togglePause, endEncounter, addParticipant(s),
updateParticipant, removeParticipant, toggleParticipantActive, applyHpChange,
deathSave, toggleCondition, reorderParticipants. ONE code path for UI.

shared/turn.js aligned to slot model (docs/INITIATIVE_ORDERING.md):
- addParticipant: splice into slot by initiative (no sort)
- updateParticipant: re-slot on initiative change (no sort)
- reorderParticipants: same-init drag only (cross-init blocked)
- applyHpChange: death flips isActive=false (matches App), revive reactivates

No renames, no API changes. Shared func signatures unchanged.

Tests updated to match unified behavior:
- dead-skip: death deactivates (was FEAT-1 stays active)
- characterization: death deactivates + revive reactivates
- reorder/invariant: cross-init drag blocked, same-init allowed

Both suites green: shared 91/91, App 77/77, 0 warnings.
2026-07-03 17:59:41 -04:00

126 lines
5.1 KiB
JavaScript

// INVARIANT test: ONE list. turnOrderIds === participants.map(id) always.
// No re-sort after startEncounter. nextTurn follows list order, skipping inactive.
// Drag (reorder) overrides initiative — cross-init drag allowed + reflected.
// Display === rotation by construction (same array).
//
// RED now: current code has two lists (sort on display, frozen turnOrderIds),
// reorder throws on cross-init. Refactor (1-list model) greens these.
'use strict';
const shared = require('@ttrpg/shared');
const {
makeParticipant,
startEncounter, nextTurn, addParticipant, removeParticipant,
toggleParticipantActive, togglePause, applyHpChange,
reorderParticipants, endEncounter,
} = shared;
function p(id, init, extra = {}) {
return makeParticipant({ id, name: id, type: 'monster',
initiative: init, maxHp: 100, currentHp: 100, ...extra });
}
function enc(ps, extra = {}) {
return { name:'t', participants:ps, isStarted:false, isPaused:false,
round:0, currentTurnParticipantId:null, turnOrderIds:[], ...extra };
}
const apply = (e, r) => r && r.patch ? { ...e, ...r.patch } : e;
// walk one full rotation from current, collect active ids in list order
function walkRotation(e) {
if (!e.isStarted || e.isPaused || !e.currentTurnParticipantId) return [];
let cur = e;
const start = cur.currentTurnParticipantId;
const seen = [];
for (let i = 0; i < (cur.turnOrderIds || []).length + 1; i++) {
const curP = (cur.participants || []).find(p => p.id === cur.currentTurnParticipantId);
if (curP && curP.isActive) seen.push(cur.currentTurnParticipantId);
const nxt = nextTurn(cur);
cur = apply(cur, nxt);
if (cur.currentTurnParticipantId === start) break;
}
return seen;
}
describe('1-list model: turnOrderIds === participants.map(id), no re-sort', () => {
test('startEncounter: list = sorted-active participants order', () => {
let e = enc([p('a',3),p('b',10),p('c',5)]);
e = apply(e, startEncounter(e));
expect(e.turnOrderIds).toEqual(['b','c','a']); // 10,5,3
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
});
test('startEncounter: inactive stays in list slot (skipped by nextTurn)', () => {
let e = enc([p('a',10),p('b',5,{isActive:false}),p('c',3)]);
e = apply(e, startEncounter(e));
// 1-list: inactive b stays in slot, nextTurn skips it
expect(e.turnOrderIds).toEqual(['a','b','c']);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
expect(e.currentTurnParticipantId).toBe('a'); // b inactive, skipped on start
});
test('addParticipant mid-encounter: inserted by init, list synced', () => {
let e = enc([p('a',10),p('c',3)]);
e = apply(e, startEncounter(e)); // [a,c]
e = apply(e, addParticipant(e, p('b',7))); // insert between a,c
expect(e.turnOrderIds).toEqual(['a','b','c']);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
});
test('addParticipant: list === participants.map(id) after add', () => {
let e = enc([p('a',10)]);
e = apply(e, startEncounter(e));
e = apply(e, addParticipant(e, p('b',5)));
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
});
test('removeParticipant: list synced, order preserved', () => {
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e));
e = apply(e, removeParticipant(e, 'b'));
expect(e.turnOrderIds).toEqual(['a','c']);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
});
test('reorder cross-init: blocked (no-op)', () => {
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); // [a,b,c]
const r = reorderParticipants(e, 'c', 'a'); // cross-init
expect(r.patch).toBeNull();
expect(e.turnOrderIds).toEqual(['a','b','c']); // unchanged
});
test('reorder same-init: rotation follows new list order', () => {
let e = enc([p('a',10),p('b',10),p('c',3)]); // a,b tie
e = apply(e, startEncounter(e)); // [a,b,c], cur=a
e = apply(e, reorderParticipants(e, 'b', 'a')); // [b,a,c], cur still a
const rot = walkRotation(e); // start a, next c (wrap), next b, back a
expect(rot).toEqual(['a','c','b']);
});
test('toggle inactive: list unchanged (stays in rotation slot)', () => {
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); // [a,b,c]
e = apply(e, toggleParticipantActive(e, 'b')); // b off
expect(e.turnOrderIds).toEqual(['a','b','c']); // b stays in slot
});
test('toggle inactive: nextTurn skips b, visits a→c', () => {
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e)); // cur=a
e = apply(e, toggleParticipantActive(e, 'b')); // b inactive
e = apply(e, nextTurn(e)); // skip b → c
expect(e.currentTurnParticipantId).toBe('c');
});
test('hp death + revive: list unchanged', () => {
let e = enc([p('a',10),p('b',7),p('c',3)]);
e = apply(e, startEncounter(e));
e = apply(e, applyHpChange(e, 'b', 'damage', 100)); // b dies
expect(e.turnOrderIds).toEqual(['a','b','c']);
e = apply(e, applyHpChange(e, 'b', 'heal', 50)); // b revive
expect(e.turnOrderIds).toEqual(['a','b','c']);
expect(e.turnOrderIds).toEqual(e.participants.map(x => x.id));
});
});