2026-07-06 10:31:46 -04:00
|
|
|
// Undo roundtrip: every op that writes a log entry with undo_payload must
|
|
|
|
|
// restore prior state. Apply op (writes encounter + log) → read back log's
|
|
|
|
|
// undo_payload.updates → apply undo on top of newEnc → assert deepEqual original.
|
|
|
|
|
//
|
|
|
|
|
// Rewritten for lean log shape: `undo` is id-based inverse; expandUndo(entry, enc)
|
|
|
|
|
// builds full patch from current enc doc. Apply updates → assert equals before.
|
2026-06-30 13:50:48 -04:00
|
|
|
const shared = require('@ttrpg/shared');
|
2026-07-06 10:31:46 -04:00
|
|
|
const { mockCtx } = require('./_helpers');
|
|
|
|
|
const { expandUndo } = shared;
|
2026-06-30 13:50:48 -04:00
|
|
|
const {
|
|
|
|
|
makeParticipant, startEncounter, nextTurn, togglePause,
|
|
|
|
|
addParticipant, removeParticipant, toggleParticipantActive,
|
|
|
|
|
applyHpChange, toggleCondition, 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) {
|
|
|
|
|
return { name:'t', participants:ps, isStarted:false, isPaused:false,
|
|
|
|
|
round:0, currentTurnParticipantId:null, turnOrderIds:[] };
|
|
|
|
|
}
|
|
|
|
|
const snap = (e) => JSON.parse(JSON.stringify(e));
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
// Last log entry's expanded undo patch (built from id-based undo + enc doc).
|
|
|
|
|
function lastUndo(storage, enc) {
|
|
|
|
|
const logs = storage.logs();
|
|
|
|
|
const last = logs[logs.length - 1];
|
|
|
|
|
expect(last.undo).toBeTruthy();
|
|
|
|
|
const expanded = expandUndo(last, enc);
|
|
|
|
|
expect(expanded).toBeTruthy();
|
|
|
|
|
return expanded.updates;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-30 13:50:48 -04:00
|
|
|
describe('undo roundtrip', () => {
|
2026-07-06 10:31:46 -04:00
|
|
|
test('startEncounter undo restores pre-start', async () => {
|
|
|
|
|
const { storage, ctx } = mockCtx();
|
2026-06-30 13:50:48 -04:00
|
|
|
const before = enc([p('a',10),p('b',20)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
const newEnc = await startEncounter(before, ctx);
|
|
|
|
|
expect(storage.logs()).toHaveLength(1);
|
|
|
|
|
const undo = lastUndo(storage, newEnc);
|
2026-07-01 16:00:00 -04:00
|
|
|
// undo restores isStarted/isPaused/round/current/turnOrderIds.
|
|
|
|
|
// participants[] may be reordered (1-list sort on start) — undo snapshot
|
|
|
|
|
// captures turn-state fields, not participant order.
|
2026-07-06 10:31:46 -04:00
|
|
|
const after = { ...newEnc, ...undo };
|
2026-07-01 16:00:00 -04:00
|
|
|
expect(after.isStarted).toBe(before.isStarted);
|
|
|
|
|
expect(after.isPaused).toBe(before.isPaused);
|
|
|
|
|
expect(after.round).toBe(before.round);
|
|
|
|
|
expect(after.currentTurnParticipantId).toBe(before.currentTurnParticipantId);
|
|
|
|
|
expect(after.turnOrderIds).toEqual(before.turnOrderIds);
|
2026-06-30 13:50:48 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('nextTurn undo restores prior currentTurn/round', async () => {
|
|
|
|
|
const { storage, ctx } = mockCtx();
|
2026-06-30 13:50:48 -04:00
|
|
|
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx);
|
2026-06-30 13:50:48 -04:00
|
|
|
const before = snap(e);
|
2026-07-06 10:31:46 -04:00
|
|
|
const newEnc = await nextTurn(e, ctx);
|
|
|
|
|
const undo = lastUndo(storage, newEnc);
|
|
|
|
|
const after = { ...newEnc, ...undo };
|
2026-06-30 13:50:48 -04:00
|
|
|
expect(snap(after)).toEqual(before);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('togglePause undo restores prior paused state', async () => {
|
|
|
|
|
const { storage, ctx } = mockCtx();
|
2026-06-30 13:50:48 -04:00
|
|
|
let e = enc([p('a',10),p('b',20)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx);
|
2026-06-30 13:50:48 -04:00
|
|
|
const before = snap(e);
|
2026-07-06 10:31:46 -04:00
|
|
|
const newEnc = await togglePause(e, ctx);
|
|
|
|
|
const undo = lastUndo(storage, newEnc);
|
|
|
|
|
const after = { ...newEnc, ...undo };
|
2026-06-30 13:50:48 -04:00
|
|
|
expect(snap(after)).toEqual(before);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('applyHpChange undo restores prior participants', async () => {
|
|
|
|
|
const { storage, ctx } = mockCtx();
|
2026-06-30 13:50:48 -04:00
|
|
|
let e = enc([p('a',10,{maxHp:100,currentHp:100}),p('b',20)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx);
|
2026-06-30 13:50:48 -04:00
|
|
|
const before = snap(e);
|
2026-07-06 10:31:46 -04:00
|
|
|
const newEnc = await applyHpChange(e, 'a', 'damage', 20, ctx);
|
|
|
|
|
const undo = lastUndo(storage, newEnc);
|
|
|
|
|
const after = { ...newEnc, ...undo };
|
2026-06-30 13:50:48 -04:00
|
|
|
expect(snap(after)).toEqual(before);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('toggleCondition undo restores prior participants', async () => {
|
|
|
|
|
const { storage, ctx } = mockCtx();
|
2026-06-30 13:50:48 -04:00
|
|
|
let e = enc([p('a',10),p('b',20)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx);
|
2026-06-30 13:50:48 -04:00
|
|
|
const before = snap(e);
|
2026-07-06 10:31:46 -04:00
|
|
|
const newEnc = await toggleCondition(e, 'a', 'stunned', ctx);
|
|
|
|
|
const undo = lastUndo(storage, newEnc);
|
|
|
|
|
const after = { ...newEnc, ...undo };
|
2026-06-30 13:50:48 -04:00
|
|
|
expect(snap(after)).toEqual(before);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('toggleParticipantActive undo restores prior participants + turn order', async () => {
|
|
|
|
|
const { storage, ctx } = mockCtx();
|
2026-06-30 13:50:48 -04:00
|
|
|
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx);
|
2026-06-30 13:50:48 -04:00
|
|
|
const before = snap(e);
|
2026-07-06 10:31:46 -04:00
|
|
|
const newEnc = await toggleParticipantActive(e, 'b', ctx);
|
|
|
|
|
const undo = lastUndo(storage, newEnc);
|
|
|
|
|
const after = { ...newEnc, ...undo };
|
2026-06-30 13:50:48 -04:00
|
|
|
expect(snap(after)).toEqual(before);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('addParticipant undo restores prior participants', async () => {
|
|
|
|
|
const { storage, ctx } = mockCtx();
|
2026-06-30 13:50:48 -04:00
|
|
|
let e = enc([p('a',10),p('b',20)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx);
|
2026-06-30 13:50:48 -04:00
|
|
|
const before = snap(e);
|
|
|
|
|
const np = makeParticipant({ id:'z', name:'z', type:'monster', initiative:15, maxHp:50, currentHp:50 });
|
2026-07-06 10:31:46 -04:00
|
|
|
const newEnc = await addParticipant(e, np, ctx);
|
|
|
|
|
const undo = lastUndo(storage, newEnc);
|
|
|
|
|
const after = { ...newEnc, ...undo };
|
2026-06-30 13:50:48 -04:00
|
|
|
expect(snap(after)).toEqual(before);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('removeParticipant undo restores prior participants + turn order', async () => {
|
|
|
|
|
const { storage, ctx } = mockCtx();
|
2026-06-30 13:50:48 -04:00
|
|
|
let e = enc([p('a',10),p('b',20),p('c',5)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx);
|
2026-06-30 13:50:48 -04:00
|
|
|
const before = snap(e);
|
2026-07-06 10:31:46 -04:00
|
|
|
const newEnc = await removeParticipant(e, 'b', ctx);
|
|
|
|
|
const undo = lastUndo(storage, newEnc);
|
|
|
|
|
const after = { ...newEnc, ...undo };
|
2026-06-30 13:50:48 -04:00
|
|
|
expect(snap(after)).toEqual(before);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('endEncounter undo restores prior state', async () => {
|
|
|
|
|
const { storage, ctx } = mockCtx();
|
2026-06-30 13:50:48 -04:00
|
|
|
let e = enc([p('a',10),p('b',20)]);
|
2026-07-06 10:31:46 -04:00
|
|
|
e = await startEncounter(e, ctx);
|
2026-06-30 13:50:48 -04:00
|
|
|
const before = snap(e);
|
2026-07-06 10:31:46 -04:00
|
|
|
const newEnc = await endEncounter(e, ctx);
|
|
|
|
|
const undo = lastUndo(storage, newEnc);
|
|
|
|
|
const after = { ...newEnc, ...undo };
|
2026-06-30 13:50:48 -04:00
|
|
|
expect(snap(after)).toEqual(before);
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('reorderParticipants undo restores prior order (BUG-7 fixed)', async () => {
|
|
|
|
|
// Unstarted encounter: no current-turn pointer, same-init reorder is valid.
|
|
|
|
|
const { storage, ctx } = mockCtx();
|
2026-06-30 13:50:48 -04:00
|
|
|
const ps = [p('a',10),p('b',20),p('c',20)]; // b,c tie
|
|
|
|
|
let e = enc(ps);
|
2026-07-06 10:31:46 -04:00
|
|
|
const before = snap(e);
|
|
|
|
|
const newEnc = await reorderParticipants(e, 'c', 'b', ctx);
|
|
|
|
|
expect(storage.logs()).toHaveLength(1); // logged (was a no-log bug before)
|
|
|
|
|
const undo = lastUndo(storage, newEnc);
|
|
|
|
|
const after = { ...newEnc, ...undo };
|
|
|
|
|
expect(snap(after)).toEqual(before);
|
2026-06-30 13:50:48 -04:00
|
|
|
});
|
|
|
|
|
});
|