Root cause: mutation writers stored oldValues only. Redo rebuilt from current state via derived logic — wrong order, wrong content, or no-op. Undo patched fields in place but ignored roster/array order changes. Writers now store forward arrays (shared/turn.js): - addParticipant: newTurnOrderIds, newCurrentTurnParticipantId - removeParticipant: newTurnOrderIds, newCurrentTurnParticipantId - updateParticipant: oldTurnOrderIds + newTurnOrderIds (initiative re-slot) - reorderParticipants: newParticipants + newTurnOrderIds - damage/heal/deathSave/stabilize/revive/deactivate_dead_monster: oldValues + newValues both capture full death state incl conditions expandUndo redo uses stored forward state instead of deriving: - add/remove: order via newTurnOrderIds - update: initiative change re-orders both directions - reorder: re-applies newParticipants - death ops: restore via newValues (was HP-only, dropped status/conditions) Missing expandUndo cases added: stabilize, revive, deactivate_dead_monster (were default:null -> undo no-op). Test infra (shared/tests/_helpers.js): - Mock storage undo() real now: applies patch + flips undone flag (was no-op) - addDoc persists log entries, getCollection returns them - undoLast/redoLast harness helpers exercise real mechanism - Undo tests assert against actual persisted doc, not expandUndo output turn.undo.test.js rewritten: every op tested as undo->deepEqual before, redo->deepEqual after-op (12 cases). Was undo-only, redo untested. turn.deathsave.undo.test.js added: 11 death-path roundtrips. Dead code removed: - round-trip.test.js: skipped suite testing deleted replay-from-logs.js (5 skipped tests rotting). Coverage gap acknowledged in TODO. Skip-guard added (scripts/run-tests.sh): pre-flight grep refuses to run if any .skip/xdescribe/xit found in test dirs. No CI; this is the gate.
210 lines
8.5 KiB
JavaScript
210 lines
8.5 KiB
JavaScript
// 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));
|
|
});
|
|
});
|