// 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 };