From 35b5a1d23872713c6a09e4289081b9ae8601d726 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:00:08 -0400 Subject: [PATCH] test: logs + deathSave characterization (6 tests) - Logs.characterization.test.js: logAction (write + undo payload), clearLogs batch delete, undo (updateDoc encounter + mark undone), deathSave increment + isDying - mock firestore getDocs: return .ref.path on docs (batch.delete support) - mock addDoc: record full doc path not collection path All write sites characterized. 56 frontend tests green. --- src/Logs.characterization.test.js | 171 ++++++++++++++++++++++++++++ src/__mocks__/firebase/firestore.js | 4 +- 2 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 src/Logs.characterization.test.js diff --git a/src/Logs.characterization.test.js b/src/Logs.characterization.test.js new file mode 100644 index 0000000..b9ab4e6 --- /dev/null +++ b/src/Logs.characterization.test.js @@ -0,0 +1,171 @@ +// Logs + deathSave characterization. Lock paths for log writes, undo, clear, death save. + +import React from 'react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { getCalls } from './__mocks__/firebase/_mock-db'; +import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers'; + +function findLogCalls() { + return getCalls().filter(c => c.fn === 'addDoc' && c.path.includes('/logs')); +} +function lastEncCall() { + const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); + return calls[calls.length - 1]; +} + +// Navigate to /logs view. App reads pathname at mount; must re-render with path preset. +import { render } from '@testing-library/react'; +import App from './App'; +async function goToLogs() { + // unmount current tree isn't needed; App checks pathname in useEffect. + // Re-render a fresh App instance in same container. + window.history.replaceState({}, '', '/logs'); + document.body.innerHTML = ''; + render(); + await waitFor(() => screen.getByText(/Combat Log/i)); +} + +describe('Logs -> Firebase', () => { + test('logAction: addDoc to logs collection on combat start', async () => { + await setupReady('LogCamp', 'LogEnc'); + await addMonsterViaUI('Mob', 10, 2); + await startCombatViaUI(); + await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message))); + const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message)); + expect(logCall.data).toHaveProperty('message'); + expect(logCall.data).toHaveProperty('timestamp'); + expect(logCall.data.message).toMatch(/Combat started/); + }); + + test('logAction: includes undo payload', async () => { + await setupReady('UndoCamp', 'UndoEnc'); + await addMonsterViaUI('Mob', 10, 2); + await startCombatViaUI(); + await waitFor(() => findLogCalls().some(c => /Combat started/.test(c.data.message))); + const logCall = findLogCalls().find(c => /Combat started/.test(c.data.message)); + expect(logCall.data.undo).toBeTruthy(); + expect(logCall.data.undo).toHaveProperty('updates'); + }); + + test('clearLogs: writeBatch deletes all log docs', async () => { + const { renderApp } = require('./testHelpers'); + // seed a log entry via combat start + await setupReady('ClearCamp', 'ClearEnc'); + await addMonsterViaUI('Mob', 10, 2); + await startCombatViaUI(); + await waitFor(() => findLogCalls().length > 0); + + await goToLogs(); + const clearBtn = await screen.findByRole('button', { name: /Clear Log/i }); + fireEvent.click(clearBtn); + fireEvent.click(await screen.findByRole('button', { name: /Confirm/i })); + + await waitFor(() => { + const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs')); + return batchDeletes.length > 0; + }); + const batchDeletes = getCalls().filter(c => c.fn === 'batch.delete' && c.path.includes('/logs')); + expect(batchDeletes.length).toBeGreaterThan(0); + }); + + test('undo: updateDoc on encounter path + marks log undone', async () => { + // seed log via combat start + await setupReady('UndoFlowCamp', 'UndoFlowEnc'); + await addMonsterViaUI('Mob', 10, 2); + await startCombatViaUI(); + await waitFor(() => findLogCalls().length > 0); + const logId = findLogCalls()[0].path.split('/').pop(); + + await goToLogs(); + const undoBtns = await screen.findAllByRole('button', { name: /Undo/i }); + fireEvent.click(undoBtns[0]); + + await waitFor(() => { + const und = getCalls().find(c => c.fn === 'updateDoc' && c.path.endsWith(`/logs/${logId}`) && c.data.undone === true); + return und; + }); + const markUndone = getCalls().find(c => c.fn === 'updateDoc' && c.path.endsWith(`/logs/${logId}`)); + expect(markUndone.data.undone).toBe(true); + // encounter path updated with undo payload (any encounter update after undo click) + const encUndo = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); + expect(encUndo.length).toBeGreaterThan(0); + }); +}); + +describe('DeathSave -> Firebase', () => { + test('first death save: updateDoc increments deathSaves', async () => { + const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers'); + const { within } = require('@testing-library/react'); + await renderApp(); + await createCampaignViaUI('DSC2'); + await selectCampaignByName('DSC2'); + fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Hero' } }); + fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } }); + fireEvent.click(screen.getByRole('button', { name: /Add Character/i })); + await waitFor(() => { + const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1); + return c; + }); + const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id; + + await createEncounterViaUI('DSEnc2'); + await selectEncounterByName('DSEnc2'); + // switch to character type and add + const form = within(getParticipantForm()); + fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } }); + fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } }); + fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i })); + await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Hero'); + + await startCombatViaUI(); + // damage to 0 + fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } }); + fireEvent.click(screen.getByTitle('Damage')); + await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0); + + // death save buttons appear + const save1 = screen.getByTitle('Death save 1'); + fireEvent.click(save1); + await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1); + expect(lastEncCall().data.participants[0].deathSaves).toBe(1); + }); + + test('third death save: marks isDying true', async () => { + const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm } = require('./testHelpers'); + const { within } = require('@testing-library/react'); + await renderApp(); + await createCampaignViaUI('DSDie'); + await selectCampaignByName('DSDie'); + fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Martyr' } }); + fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '10' } }); + fireEvent.click(screen.getByRole('button', { name: /Add Character/i })); + await waitFor(() => { + const c = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1); + return c; + }); + const charId = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') && c.data.players?.length === 1).data.players[0].id; + + await createEncounterViaUI('DSEncDie'); + await selectEncounterByName('DSEncDie'); + const form = within(getParticipantForm()); + fireEvent.change(form.getByDisplayValue('Monster'), { target: { value: 'character' } }); + fireEvent.change(form.getByDisplayValue('-- Select from Campaign --'), { target: { value: charId } }); + fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i })); + await waitFor(() => lastEncCall()?.data?.participants?.[0]?.name === 'Martyr'); + + await startCombatViaUI(); + fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '10' } }); + fireEvent.click(screen.getByTitle('Damage')); + await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0); + + fireEvent.click(screen.getByTitle('Death save 1')); + await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 1); + fireEvent.click(screen.getByTitle('Death save 2')); + await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaves === 2); + fireEvent.click(screen.getByTitle('Death save 3')); + await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isDying === true); + expect(lastEncCall().data.participants[0].isDying).toBe(true); + expect(lastEncCall().data.participants[0].deathSaves).toBe(3); + }); +}); diff --git a/src/__mocks__/firebase/firestore.js b/src/__mocks__/firebase/firestore.js index a8ff10b..87ad372 100644 --- a/src/__mocks__/firebase/firestore.js +++ b/src/__mocks__/firebase/firestore.js @@ -35,7 +35,7 @@ export async function deleteDoc(docRef) { export async function addDoc(collRef, data) { const id = `auto_${MOCK_DB.nextId()}`; const path = `${collRef.path}/${id}`; - recordCall({ fn: 'addDoc', path: collRef.path, data: clone(data) }); + recordCall({ fn: 'addDoc', path, data: clone(data) }); MOCK_DB.set(path, clone(data)); return { id, path }; } @@ -64,7 +64,7 @@ export async function getDoc(docRef) { export async function getDocs(collRefOrQuery) { const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path; const docs = MOCK_DB.collection(collPath); - return { docs: docs.map(d => ({ id: d.id, data: () => d.data })) }; + return { docs: docs.map(d => ({ id: d.id, data: () => d.data, ref: { path: `${collPath}/${d.id}` } })) }; } // realtime — emit from mock DB, capture unsub