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.
This commit is contained in:
@@ -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(<App />);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ export async function deleteDoc(docRef) {
|
|||||||
export async function addDoc(collRef, data) {
|
export async function addDoc(collRef, data) {
|
||||||
const id = `auto_${MOCK_DB.nextId()}`;
|
const id = `auto_${MOCK_DB.nextId()}`;
|
||||||
const path = `${collRef.path}/${id}`;
|
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));
|
MOCK_DB.set(path, clone(data));
|
||||||
return { id, path };
|
return { id, path };
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ export async function getDoc(docRef) {
|
|||||||
export async function getDocs(collRefOrQuery) {
|
export async function getDocs(collRefOrQuery) {
|
||||||
const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path;
|
const collPath = collRefOrQuery.ref ? collRefOrQuery.ref.path : collRefOrQuery.path;
|
||||||
const docs = MOCK_DB.collection(collPath);
|
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
|
// realtime — emit from mock DB, capture unsub
|
||||||
|
|||||||
Reference in New Issue
Block a user