diff --git a/shared/tests/turn.logging.test.js b/shared/tests/turn.logging.test.js index ecb3e82..82852a6 100644 --- a/shared/tests/turn.logging.test.js +++ b/shared/tests/turn.logging.test.js @@ -166,23 +166,38 @@ describe('Logging undo payloads', () => { }); }); -describe('Logging: gaps (currently unlogged mutations)', () => { - // Document current behavior. addParticipants + updateParticipant return - // null log → invisible in combat log. May be intended (bulk/no-op) or bug. +describe('Logging: addParticipants + updateParticipant', () => { let e; beforeEach(() => { e = enc([p('a', 10), p('b', 7)]); }); - test('addParticipants returns null log (bulk gap?)', () => { + test('addParticipants logs', () => { const r = addParticipants(e, [p('c', 3)]); - // Currently null. Document so decision is explicit. - expect(r.log).toBeNull(); + expectLogged(r); }); - test('updateParticipant returns null log (edit gap?)', () => { + test('updateParticipant (same slot) logs', () => { const r = updateParticipant(e, 'b', { name: 'B' }); - // Currently null. Document so decision is explicit. - expect(r.log).toBeNull(); + expectLogged(r); + }); + + test('updateParticipant (init change) logs', () => { + const r = updateParticipant(e, 'b', { initiative: 5 }); + expectLogged(r); + }); + + test('addParticipants undo restores prior list', () => { + const orig = e.participants.map(p => p.id); + const r = addParticipants(e, [p('c', 3)]); + const restored = { ...e, ...r.log.undo }; + expect(restored.participants.map(p => p.id)).toEqual(orig); + }); + + test('updateParticipant undo restores prior participant', () => { + const orig = e.participants.map(p => p.id); + const r = updateParticipant(e, 'b', { name: 'B' }); + const restored = { ...e, ...r.log.undo }; + expect(restored.participants.map(p => p.id)).toEqual(orig); }); }); diff --git a/shared/turn.js b/shared/turn.js index 5415af2..6554926 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -333,8 +333,16 @@ function addParticipant(encounter, participant) { // ADD_PARTICIPANTS — bulk add (e.g. "add all campaign characters"). function addParticipants(encounter, newParticipants) { + const undo = { participants: encounter.participants || [] }; const updatedParticipants = [...(encounter.participants || []), ...newParticipants]; - return { patch: { participants: updatedParticipants }, log: null }; + const names = newParticipants.map(p => p.name).join(', '); + return { + patch: { participants: updatedParticipants }, + log: { + message: `Added ${newParticipants.length} participant${newParticipants.length === 1 ? '' : 's'}: ${names}`, + undo, + }, + }; } // UPDATE_PARTICIPANT — edit modal save (name/initiative/hp/isNpc). @@ -346,14 +354,28 @@ function updateParticipant(encounter, participantId, updatedData) { // SLOT only if initiative changed. Other edits (name/hp/notes/isNpc) = no // position change (design doc). Re-slots on unrelated edits shuffle the // list mid-round → rotation dupes. + const undo = { participants: encounter.participants || [] }; if (!('initiative' in updatedData) || updatedData.initiative === target.initiative) { const sameSlot = existing.map(p => p.id === participantId ? merged : p); - return { patch: { participants: sameSlot, ...syncTurnOrder(sameSlot) }, log: null }; + const fields = Object.keys(updatedData).join(', '); + return { + patch: { participants: sameSlot, ...syncTurnOrder(sameSlot) }, + log: { + message: `${target.name} updated (${fields})`, + undo, + }, + }; } const without = existing.filter(p => p.id !== participantId); const idx = slotIndexForInit(without, merged.initiative); without.splice(idx, 0, merged); - return { patch: { participants: without, ...syncTurnOrder(without) }, log: null }; + return { + patch: { participants: without, ...syncTurnOrder(without) }, + log: { + message: `${target.name} updated (initiative: ${merged.initiative})`, + undo, + }, + }; } // REMOVE_PARTICIPANT — verbatim from ParticipantManager.confirmDeleteParticipant diff --git a/src/App.js b/src/App.js index 186116c..c086a09 100644 --- a/src/App.js +++ b/src/App.js @@ -940,8 +940,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { } try { - const { patch } = addParticipants(encounter, newParticipants); + const { patch, log } = addParticipants(encounter, newParticipants); await storage.updateDoc(encounterPath, patch); + if (log) { + logAction(log.message, { encounterName: encounter.name }, { + encounterPath, + updates: log.undo, + }); + } } catch (err) { alert("Failed to add all characters. Please try again."); } @@ -950,8 +956,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { const handleUpdateParticipant = async (updatedData) => { if (!db || !editingParticipant) return; try { - const { patch } = updateParticipant(encounter, editingParticipant.id, updatedData); + const { patch, log } = updateParticipant(encounter, editingParticipant.id, updatedData); await storage.updateDoc(encounterPath, patch); + if (log) { + logAction(log.message, { encounterName: encounter.name }, { + encounterPath, + updates: log.undo, + }); + } setEditingParticipant(null); } catch (err) { alert("Failed to update participant. Please try again."); @@ -967,8 +979,14 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) { const n = parseInt(value, 10); if (isNaN(n)) return; try { - const { patch } = updateParticipant(encounter, participantId, { initiative: n }); + const { patch, log } = updateParticipant(encounter, participantId, { initiative: n }); await storage.updateDoc(encounterPath, patch); + if (log) { + logAction(log.message, { encounterName: encounter.name }, { + encounterPath, + updates: log.undo, + }); + } } catch (err) { // fall through silently }