Files
david raistrick 0fa3928bf8 Track encounter start/end timestamps; display on card
startEncounter sets startedAt (clears endedAt on restart). endEncounter sets
endedAt. snapshotOf includes both for undo/redo fidelity. expandUndo split
start/end cases to restore old timestamps.

Display: encounter card shows Started/Ended independently (endedAt shows
even without startedAt). Undo sets null (can't delete via merge), test snap
strips only startedAt/endedAt null for compare.

Reviewed by pi gpt-5.5: stale endedAt on restart fixed, snap null-strip
narrowed. Medium-low risk.
2026-07-07 16:41:34 -04:00

214 lines
8.7 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) => {
const o = JSON.parse(JSON.stringify(e));
// normalize nullable timestamp fields: null = absent (undo clears via null)
if (o.startedAt === null) delete o.startedAt;
if (o.endedAt === null) delete o.endedAt;
return o;
};
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 excluded from undo (no log entry)', 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);
// state flips (isPaused), but no log written
expect(newEnc.isPaused).toBe(true);
expect(storage.logs()).toHaveLength(1); // start only
});
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));
});
});