// Undo + redo roundtrip: every op that writes a log entry must restore prior // state on undo AND re-apply forward state on redo. Uses REAL mock storage // undo() (applies patch + flips undone flag), not just expandUndo output. // // Pattern per case: // before = snap(enc) // state before op // newEnc = await op(enc) // apply op (writes enc doc + log entry) // afterUndo = undoLast() // real undo via storage.undo // expect(afterUndo) === before // afterRedo = redoLast() // real redo via storage.undo({redo:true}) // expect(afterRedo) === snap(newEnc) const shared = require('@ttrpg/shared'); const { mockCtx, undoLast, redoLast } = require('./_helpers'); const { expandUndo } = shared; 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)); describe('undo + redo roundtrip', () => { test('startEncounter', async () => { const { storage, ctx } = mockCtx(); const before = enc([p('a',10),p('b',20)]); await storage.setDoc(ctx.encPath, before); const newEnc = await startEncounter(before, ctx); const last = storage.logs().at(-1); const afterUndo = await undoLast(ctx, newEnc); // participants[] order may differ (start sorts); check turn-state fields. expect(afterUndo.isStarted).toBe(before.isStarted); expect(afterUndo.isPaused).toBe(before.isPaused); expect(afterUndo.round).toBe(before.round); expect(afterUndo.currentTurnParticipantId).toBe(before.currentTurnParticipantId); expect(afterUndo.turnOrderIds).toEqual(before.turnOrderIds); const afterRedo = await redoLast(ctx, afterUndo, last); expect(afterRedo.isStarted).toBe(newEnc.isStarted); expect(afterRedo.round).toBe(newEnc.round); expect(afterRedo.currentTurnParticipantId).toBe(newEnc.currentTurnParticipantId); expect(afterRedo.turnOrderIds).toEqual(newEnc.turnOrderIds); }); test('nextTurn', async () => { const { storage, ctx } = mockCtx(); let e = enc([p('a',10),p('b',20),p('c',5)]); e = await startEncounter(e, ctx); await storage.setDoc(ctx.encPath, e); const before = snap(e); const newEnc = await nextTurn(e, ctx); const last = storage.logs().at(-1); const afterUndo = await undoLast(ctx, newEnc); expect(snap(afterUndo)).toEqual(before); const afterRedo = await redoLast(ctx, afterUndo, last); expect(snap(afterRedo)).toEqual(snap(newEnc)); }); test('togglePause', async () => { const { storage, ctx } = mockCtx(); let e = enc([p('a',10),p('b',20)]); e = await startEncounter(e, ctx); await storage.setDoc(ctx.encPath, e); const before = snap(e); const newEnc = await togglePause(e, ctx); const last = storage.logs().at(-1); const afterUndo = await undoLast(ctx, newEnc); expect(snap(afterUndo)).toEqual(before); const afterRedo = await redoLast(ctx, afterUndo, last); expect(snap(afterRedo)).toEqual(snap(newEnc)); }); test('applyHpChange damage restores HP on undo, re-applies on redo', async () => { const { storage, ctx } = mockCtx(); let e = enc([p('a',10,{maxHp:100,currentHp:100}),p('b',20)]); e = await startEncounter(e, ctx); await storage.setDoc(ctx.encPath, e); const before = snap(e); const newEnc = await applyHpChange(e, 'a', 'damage', 20, ctx); const last = storage.logs().at(-1); const afterUndo = await undoLast(ctx, newEnc); expect(snap(afterUndo)).toEqual(before); const afterRedo = await redoLast(ctx, afterUndo, last); expect(snap(afterRedo)).toEqual(snap(newEnc)); }); test('applyHpChange heal restores HP on undo, re-applies on redo', async () => { const { storage, ctx } = mockCtx(); let e = enc([p('a',10,{maxHp:100,currentHp:50})]); e = await startEncounter(e, ctx); await storage.setDoc(ctx.encPath, e); const before = snap(e); const newEnc = await applyHpChange(e, 'a', 'heal', 20, ctx); const last = storage.logs().at(-1); const afterUndo = await undoLast(ctx, newEnc); expect(snap(afterUndo)).toEqual(before); const afterRedo = await redoLast(ctx, afterUndo, last); expect(snap(afterRedo)).toEqual(snap(newEnc)); }); test('toggleCondition', async () => { const { storage, ctx } = mockCtx(); let e = enc([p('a',10),p('b',20)]); e = await startEncounter(e, ctx); await storage.setDoc(ctx.encPath, e); const before = snap(e); const newEnc = await toggleCondition(e, 'a', 'stunned', ctx); const last = storage.logs().at(-1); const afterUndo = await undoLast(ctx, newEnc); expect(snap(afterUndo)).toEqual(before); const afterRedo = await redoLast(ctx, afterUndo, last); expect(snap(afterRedo)).toEqual(snap(newEnc)); }); test('toggleParticipantActive restores participants + turn order, redo re-applies', async () => { const { storage, ctx } = mockCtx(); let e = enc([p('a',10),p('b',20),p('c',5)]); e = await startEncounter(e, ctx); await storage.setDoc(ctx.encPath, e); const before = snap(e); const newEnc = await toggleParticipantActive(e, 'b', ctx); const last = storage.logs().at(-1); const afterUndo = await undoLast(ctx, newEnc); expect(snap(afterUndo)).toEqual(before); const afterRedo = await redoLast(ctx, afterUndo, last); expect(snap(afterRedo)).toEqual(snap(newEnc)); }); test('addParticipant', async () => { const { storage, ctx } = mockCtx(); let e = enc([p('a',10),p('b',20)]); e = await startEncounter(e, ctx); await storage.setDoc(ctx.encPath, e); const before = snap(e); const np = makeParticipant({ id:'z', name:'z', type:'monster', initiative:15, maxHp:50, currentHp:50 }); const newEnc = await addParticipant(e, np, ctx); const last = storage.logs().at(-1); const afterUndo = await undoLast(ctx, newEnc); expect(snap(afterUndo)).toEqual(before); const afterRedo = await redoLast(ctx, afterUndo, last); expect(snap(afterRedo)).toEqual(snap(newEnc)); }); test('removeParticipant restores prior participants + turn order, redo re-applies', async () => { const { storage, ctx } = mockCtx(); let e = enc([p('a',10),p('b',20),p('c',5)]); e = await startEncounter(e, ctx); await storage.setDoc(ctx.encPath, e); const before = snap(e); const newEnc = await removeParticipant(e, 'b', ctx); const last = storage.logs().at(-1); const afterUndo = await undoLast(ctx, newEnc); expect(snap(afterUndo)).toEqual(before); const afterRedo = await redoLast(ctx, afterUndo, last); expect(snap(afterRedo)).toEqual(snap(newEnc)); }); test('endEncounter', async () => { const { storage, ctx } = mockCtx(); let e = enc([p('a',10),p('b',20)]); e = await startEncounter(e, ctx); await storage.setDoc(ctx.encPath, e); const before = snap(e); const newEnc = await endEncounter(e, ctx); const last = storage.logs().at(-1); const afterUndo = await undoLast(ctx, newEnc); expect(snap(afterUndo)).toEqual(before); const afterRedo = await redoLast(ctx, afterUndo, last); expect(snap(afterRedo)).toEqual(snap(newEnc)); }); test('reorderParticipants (BUG-7 fixed)', async () => { const { storage, ctx } = mockCtx(); const ps = [p('a',10),p('b',20),p('c',20)]; // b,c tie let e = enc(ps); await storage.setDoc(ctx.encPath, e); const before = snap(e); const newEnc = await reorderParticipants(e, 'c', 'b', ctx); const last = storage.logs().at(-1); const afterUndo = await undoLast(ctx, newEnc); expect(snap(afterUndo)).toEqual(before); const afterRedo = await redoLast(ctx, afterUndo, last); expect(snap(afterRedo)).toEqual(snap(newEnc)); }); test('updateParticipant', async () => { const { storage, ctx } = mockCtx(); let e = enc([p('a',10),p('b',20)]); e = await startEncounter(e, ctx); await storage.setDoc(ctx.encPath, e); const before = snap(e); const newEnc = await shared.updateParticipant(e, 'a', { initiative: 99 }, ctx); const last = storage.logs().at(-1); const afterUndo = await undoLast(ctx, newEnc); expect(snap(afterUndo)).toEqual(before); const afterRedo = await redoLast(ctx, afterUndo, last); expect(snap(afterRedo)).toEqual(snap(newEnc)); }); });