Files
ttrpg-initiative-tracker/shared/tests/_helpers.js
T
david raistrick 2c997de0da Fix undo/redo forward-state restore, harden test infra, skip-guard
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.
2026-07-06 18:09:28 -04:00

150 lines
5.9 KiB
JavaScript

// Test helper: in-memory mock storage. Records all writes (addDoc/updateDoc).
// Stores docs so getDoc/getCollection return real state. undo() actually
// applies patches + flips log `undone` flag (mirrors firebase/server contract).
function createMockStorage() {
const docs = new Map(); // path -> data
const calls = []; // ordered {fn, path, data}
const applyPatch = (path, patch) => {
if (!docs.has(path)) docs.set(path, {});
const cur = docs.get(path);
// Firestore shallow-merge: arrays replaced, not merged. Patch arrays are
// the FULL new value (participants, turnOrderIds, conditions).
docs.set(path, { ...cur, ...patch });
};
return {
calls,
docs,
async getDoc(path) { return docs.has(path) ? { ...docs.get(path) } : null; },
async setDoc(path, data) {
calls.push({ fn: 'setDoc', path, data });
docs.set(path, { ...data });
},
async addDoc(path, data) {
calls.push({ fn: 'addDoc', path, data });
// Persist log entry so getCollection + undo can read it back.
const id = data.id || `log-${calls.length}`;
const stored = { ...data, id, _path: `${path}/${id}` };
docs.set(`${path}/${id}`, stored);
return id;
},
async updateDoc(path, patch) {
calls.push({ fn: 'updateDoc', path, data: patch });
applyPatch(path, patch);
},
async deleteDoc(path) {
calls.push({ fn: 'deleteDoc', path });
docs.delete(path);
},
async getCollection(path, queryConstraints = []) {
// Return docs whose _path starts with `${path}/` (Firestore collection).
const out = [];
for (const [p, d] of docs.entries()) {
if (p.startsWith(`${path}/`)) out.push({ ...d });
}
// Best-effort query support: where/orderBy/limit.
let filtered = out;
for (const qc of queryConstraints) {
if (!filtered.length) break;
const op = qc[0];
if (op === 'where') {
const [, field, , value] = qc;
filtered = filtered.filter(d => d[field] === value);
} else if (op === 'orderBy') {
const [, field, dir = 'asc'] = qc;
filtered.sort((a, b) => {
const av = a[field], bv = b[field];
if (av < bv) return dir === 'asc' ? -1 : 1;
if (av > bv) return dir === 'asc' ? 1 : -1;
return 0;
});
} else if (op === 'limit') {
filtered = filtered.slice(0, qc[1]);
}
}
return filtered;
},
async batchWrite(ops) {
for (const op of ops) {
if (op.type === 'delete') await this.deleteDoc(op.path);
else await this.updateDoc(op.path, op.data);
}
},
// Transactional undo — mirrors firebase.js / server.js contract:
// redo=false: apply undo.updates to encounterPath, flip log undone=true
// redo=true: apply undo.redo to encounterPath, flip log undone=false
async undo({ logPath, undo, redo = false }) {
calls.push({ fn: 'undo', path: logPath, data: { undo, redo } });
const patch = redo ? (undo.redo || undo.updates) : undo.updates;
applyPatch(undo.encounterPath, patch);
if (docs.has(logPath)) {
const log = docs.get(logPath);
docs.set(logPath, { ...log, undone: !redo });
}
},
// filters
updatesFor(prefix) { return calls.filter(c => c.fn === 'updateDoc' && c.path.startsWith(prefix)); },
logs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); },
getLogs() { return calls.filter(c => c.fn === 'addDoc').map(c => c.data); },
};
}
// Undo/redo harness: apply op via shared func (writes enc doc + log) then
// exercise the REAL undo mechanism (storage.undo applies patch + flips flag).
// Reads doc back so assertions reflect actual persisted state.
// applyThenUndo(ctx, encAfterOp) -> enc after undo applied
// applyThenRedo(ctx, encAfterUndo, lastLogId) -> enc after redo applied
async function undoLast(ctx, encAfterOp) {
const { storage, encPath, logPath } = ctx;
const last = storage.logs()[storage.logs().length - 1];
const { expandUndo } = require('./..');
const expanded = expandUndo(last, encAfterOp);
if (!expanded) throw new Error('expandUndo returned null');
const logEntryPath = `${logPath}/${last.id}`;
await storage.undo({ logPath: logEntryPath, undo: expanded, redo: false });
return await storage.getDoc(encPath);
}
async function redoLast(ctx, encAfterUndo, last) {
const { storage, encPath, logPath } = ctx;
const { expandUndo } = require('./..');
const expanded = expandUndo(last, encAfterUndo);
if (!expanded) throw new Error('expandUndo returned null (redo)');
const logEntryPath = `${logPath}/${last.id}`;
await storage.undo({ logPath: logEntryPath, undo: expanded, redo: true });
return await storage.getDoc(encPath);
}
// Standard ctx for tests. encPath + logPath + displayPath preset.
// Returns { storage, ctx } so tests destructure both in one call:
// const { storage, ctx } = mockCtx();
function mockCtx(storage) {
storage = storage || createMockStorage();
const ctx = {
storage,
encPath: 'encounters/e1',
logPath: 'logs',
displayPath: 'activeDisplay/status',
};
return { storage, ctx };
}
// Standard 2-participant ready encounter.
function readyEnc(opts = {}) {
return {
id: 'e1',
name: opts.name || 'TestEnc',
round: 0,
isStarted: false,
isPaused: false,
currentTurnParticipantId: null,
turnOrderIds: [],
participants: opts.participants || [
{ id: 'p1', name: 'Bob', type: 'character', initiative: 10, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 },
{ id: 'p2', name: 'Mob', type: 'monster', initiative: 5, maxHp: 10, currentHp: 10, isActive: true, conditions: [], status: 'conscious', deathSaveSuccesses: 0, deathSaveFailures: 0 },
],
};
}
module.exports = { createMockStorage, mockCtx, readyEnc, undoLast, redoLast };