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.
150 lines
5.9 KiB
JavaScript
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 };
|