diff --git a/shared/tests/turn.invariant.test.js b/shared/tests/turn.invariant.test.js new file mode 100644 index 0000000..666db9e --- /dev/null +++ b/shared/tests/turn.invariant.test.js @@ -0,0 +1,127 @@ +// INVARIANT test: three order lists must always match. +// 1. display order = sortParticipantsByInitiative(participants).map(id) +// 2. turnOrderIds = frozen rotation array +// 3. nextTurn order = walking active rotation via repeated nextTurn +// +// Divergence = bug. BUG-6 (reorder), BUG-5 (add/remove) manifest here. +// RED expected on reorder + others. Locks drift before refactor. + +'use strict'; + +const shared = require('@ttrpg/shared'); +const { + makeParticipant, buildCharacterParticipant, buildMonsterParticipant, + sortParticipantsByInitiative, + 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; + +// snapshot the 3 lists +function lists(e) { + const display = sortParticipantsByInitiative(e.participants, e.participants).map(x => x.id); + const frozen = [...(e.turnOrderIds || [])]; + // walk full rotation: from current, nextTurn until back to start, collect ids in order + const rotation = []; + if (e.isStarted && !e.isPaused && e.currentTurnParticipantId) { + let cur = e; + const start = cur.currentTurnParticipantId; + const seen = []; + for (let i = 0; i < (cur.turnOrderIds || []).length + 1; i++) { + seen.push(cur.currentTurnParticipantId); + const nxt = nextTurn(cur); + cur = apply(cur, nxt); + if (cur.currentTurnParticipantId === start) break; + } + // rotation = [start, next1, ...] cyclic. Normalize to start at frozen[0] + // so raw-array compare matches (same cycle, canonical start). + const head = frozen[0]; + const offset = seen.indexOf(head); + if (offset >= 0) { + rotation.push(...seen.slice(offset), ...seen.slice(0, offset)); + } else { + rotation.push(...seen); // head not in rotation (inactive?) — leave as-is + } + } + return { display, frozen, rotation }; +} + +describe('3-list invariant: display === turnOrderIds === nextTurn rotation', () => { + test('startEncounter: all three match', () => { + let e = enc([p('a',10),p('b',7),p('c',3)]); + e = apply(e, startEncounter(e)); + const { display, frozen, rotation } = lists(e); + expect(frozen).toEqual(display); + expect(rotation).toEqual(display); + }); + + test('tie: drag order in participants[] preserved across all 3', () => { + // a,b both init=10. participants[] order [a,b] = display tiebreak. + let e = enc([p('a',10),p('b',10),p('c',3)]); + e = apply(e, startEncounter(e)); + const { display, frozen, rotation } = lists(e); + expect(display.indexOf('a')).toBeLessThan(display.indexOf('b')); // a before b + expect(frozen).toEqual(display); + expect(rotation).toEqual(display); + }); + + test('reorder via drag: all 3 reflect new order (BUG-6 RED)', () => { + // a,b,c init 10,10,3. drag b before a. + let e = enc([p('a',10),p('b',10),p('c',3)]); + e = apply(e, startEncounter(e)); + e = apply(e, reorderParticipants(e, 'b', 'a')); // b dragged before a + const { display, frozen, rotation } = lists(e); + expect(display.indexOf('b')).toBeLessThan(display.indexOf('a')); + expect(frozen).toEqual(display); + expect(rotation).toEqual(display); + }); + + test('add mid-encounter: all 3 match (BUG-5 RED)', () => { + let e = enc([p('a',10),p('b',5)]); + e = apply(e, startEncounter(e)); + e = apply(e, addParticipant(e, p('c',7))); + const { display, frozen, rotation } = lists(e); + expect(frozen).toEqual(display); + expect(rotation).toEqual(display); + }); + + test('remove mid-encounter: all 3 match', () => { + let e = enc([p('a',10),p('b',7),p('c',3)]); + e = apply(e, startEncounter(e)); + e = apply(e, removeParticipant(e, 'b')); + const { display, frozen, rotation } = lists(e); + expect(frozen).toEqual(display); + expect(rotation).toEqual(display); + }); + + test('toggle active off+on: all 3 match', () => { + let e = enc([p('a',10),p('b',7),p('c',3)]); + e = apply(e, startEncounter(e)); + e = apply(e, nextTurn(e)); // b current + e = apply(e, toggleParticipantActive(e, 'a')); // a off + e = apply(e, toggleParticipantActive(e, 'a')); // a on + const { display, frozen, rotation } = lists(e); + expect(frozen).toEqual(display); + expect(rotation).toEqual(display); + }); + + test('hp death + revive: all 3 match', () => { + 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 + e = apply(e, applyHpChange(e, 'b', 'heal', 50)); // b revive + const { display, frozen, rotation } = lists(e); + expect(frozen).toEqual(display); + expect(rotation).toEqual(display); + }); +});