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