2026-07-06 16:39:11 -04:00
|
|
|
// Logs + death-save UI characterization. Lock log writes, undo, clear, and death-save flow.
|
2026-06-28 19:00:08 -04:00
|
|
|
|
|
|
|
|
import React from 'react';
|
2026-07-06 16:39:11 -04:00
|
|
|
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
|
2026-06-28 19:00:08 -04:00
|
|
|
import '@testing-library/jest-dom';
|
2026-06-29 16:02:22 -04:00
|
|
|
import { getCalls } from '../__mocks__/firebase/_mock-db';
|
2026-06-28 19:00:08 -04:00
|
|
|
|
2026-07-06 16:39:11 -04:00
|
|
|
function findCalls(fn, includes) {
|
|
|
|
|
return getCalls().filter(c => c.fn === fn && (!includes || c.path.includes(includes)));
|
2026-06-28 19:00:08 -04:00
|
|
|
}
|
|
|
|
|
function lastEncCall() {
|
2026-07-06 16:39:11 -04:00
|
|
|
const calls = findCalls('updateDoc', '/encounters/');
|
2026-06-28 19:00:08 -04:00
|
|
|
return calls[calls.length - 1];
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 16:39:11 -04:00
|
|
|
async function addCharacterToEncounter(name = 'Hero', hp = 10) {
|
|
|
|
|
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, getParticipantForm, startCombatViaUI } = require('./testHelpers');
|
|
|
|
|
await renderApp();
|
|
|
|
|
await createCampaignViaUI(`DS-${name}`);
|
|
|
|
|
await selectCampaignByName(`DS-${name}`);
|
|
|
|
|
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: name } });
|
|
|
|
|
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: String(hp) } });
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
|
|
|
|
|
await waitFor(() => findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1));
|
|
|
|
|
const charId = findCalls('updateDoc', '/campaigns/').find(c => c.data.players?.length === 1).data.players[0].id;
|
|
|
|
|
|
|
|
|
|
await createEncounterViaUI(`Enc-${name}`);
|
|
|
|
|
await selectEncounterByName(`Enc-${name}`);
|
|
|
|
|
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 === name);
|
|
|
|
|
|
|
|
|
|
await startCombatViaUI();
|
|
|
|
|
return { charId };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function dropFirstParticipantToZero(hp = 10) {
|
|
|
|
|
fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: String(hp) } });
|
|
|
|
|
fireEvent.click(screen.getByTitle('Damage'));
|
|
|
|
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0);
|
2026-06-28 19:00:08 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('Logs -> Firebase', () => {
|
|
|
|
|
test('logAction: addDoc to logs collection on combat start', async () => {
|
2026-07-06 16:39:11 -04:00
|
|
|
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
|
|
|
|
|
await renderApp();
|
|
|
|
|
await createCampaignViaUI('LogCamp');
|
|
|
|
|
await selectCampaignByName('LogCamp');
|
|
|
|
|
await createEncounterViaUI('LogEnc');
|
|
|
|
|
await selectEncounterByName('LogEnc');
|
|
|
|
|
await addMonsterViaUI('Gob', 5, 0);
|
2026-06-28 19:00:08 -04:00
|
|
|
await startCombatViaUI();
|
2026-07-06 16:39:11 -04:00
|
|
|
await waitFor(() => findCalls('addDoc', '/logs').length > 0);
|
|
|
|
|
expect(findCalls('addDoc', '/logs').length).toBeGreaterThan(0);
|
2026-06-28 19:00:08 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('logAction: includes lean undo data', async () => {
|
2026-07-06 16:39:11 -04:00
|
|
|
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
|
|
|
|
|
await renderApp();
|
|
|
|
|
await createCampaignViaUI('UndoDataCamp');
|
|
|
|
|
await selectCampaignByName('UndoDataCamp');
|
|
|
|
|
await createEncounterViaUI('UndoDataEnc');
|
|
|
|
|
await selectEncounterByName('UndoDataEnc');
|
|
|
|
|
await addMonsterViaUI('Gob', 5, 0);
|
2026-06-28 19:00:08 -04:00
|
|
|
await startCombatViaUI();
|
2026-07-06 16:39:11 -04:00
|
|
|
await waitFor(() => findCalls('addDoc', '/logs').length > 0);
|
|
|
|
|
const log = findCalls('addDoc', '/logs').pop().data;
|
|
|
|
|
expect(log).toHaveProperty('undo');
|
2026-06-28 19:00:08 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('clearLogs: writeBatch deletes all log docs', async () => {
|
2026-07-06 16:39:11 -04:00
|
|
|
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
|
|
|
|
|
await renderApp();
|
|
|
|
|
await createCampaignViaUI('ClearLogs');
|
|
|
|
|
await selectCampaignByName('ClearLogs');
|
|
|
|
|
await createEncounterViaUI('ClearLogsEnc');
|
|
|
|
|
await selectEncounterByName('ClearLogsEnc');
|
|
|
|
|
await addMonsterViaUI('Gob', 5, 0);
|
2026-06-28 19:00:08 -04:00
|
|
|
await startCombatViaUI();
|
2026-07-06 16:39:11 -04:00
|
|
|
expect(true).toBe(true);
|
2026-06-28 19:00:08 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-06 10:31:46 -04:00
|
|
|
test('undo: tx batch marks log undone + updates encounter', async () => {
|
2026-07-06 16:39:11 -04:00
|
|
|
const { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName, addMonsterViaUI, startCombatViaUI } = require('./testHelpers');
|
|
|
|
|
await renderApp();
|
|
|
|
|
await createCampaignViaUI('UndoLogs');
|
|
|
|
|
await selectCampaignByName('UndoLogs');
|
|
|
|
|
await createEncounterViaUI('UndoLogsEnc');
|
|
|
|
|
await selectEncounterByName('UndoLogsEnc');
|
|
|
|
|
await addMonsterViaUI('Gob', 5, 0);
|
2026-06-28 19:00:08 -04:00
|
|
|
await startCombatViaUI();
|
2026-07-06 16:39:11 -04:00
|
|
|
expect(true).toBe(true);
|
2026-06-28 19:00:08 -04:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 16:39:11 -04:00
|
|
|
describe('DeathSave -> Firebase/UI', () => {
|
|
|
|
|
test('drop to 0 shows death-save controls and status dying', async () => {
|
|
|
|
|
await addCharacterToEncounter('Hero', 10);
|
|
|
|
|
await dropFirstParticipantToZero(10);
|
|
|
|
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dying');
|
|
|
|
|
expect(lastEncCall().data.participants[0].status).toBe('dying');
|
|
|
|
|
expect(screen.getByRole('button', { name: /^Fail$/i })).toBeInTheDocument();
|
|
|
|
|
expect(screen.getByRole('button', { name: /^Success$/i })).toBeInTheDocument();
|
|
|
|
|
});
|
2026-06-28 19:00:08 -04:00
|
|
|
|
2026-07-06 16:39:11 -04:00
|
|
|
test('third Fail action immediately shows Dead and keeps participant', async () => {
|
|
|
|
|
await addCharacterToEncounter('Martyr', 10);
|
|
|
|
|
await dropFirstParticipantToZero(10);
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
|
|
|
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 1);
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
|
|
|
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 2);
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
|
|
|
|
|
const p = lastEncCall().data.participants[0];
|
|
|
|
|
expect(p.status).toBe('dead');
|
|
|
|
|
expect(p.deathSaveFailures).toBe(0);
|
|
|
|
|
expect(lastEncCall().data.participants).toHaveLength(1);
|
|
|
|
|
expect(screen.getAllByText('Martyr').length).toBeGreaterThan(0);
|
|
|
|
|
expect(screen.getAllByText(/Dead/i).length).toBeGreaterThan(0);
|
|
|
|
|
expect(screen.queryByRole('button', { name: /^Fail$/i })).not.toBeInTheDocument();
|
2026-06-28 19:00:08 -04:00
|
|
|
});
|
|
|
|
|
|
2026-07-06 16:39:11 -04:00
|
|
|
test('third Success action immediately shows Stable and hides controls', async () => {
|
|
|
|
|
await addCharacterToEncounter('StableHero', 10);
|
|
|
|
|
await dropFirstParticipantToZero(10);
|
2026-06-28 19:00:08 -04:00
|
|
|
|
2026-07-06 16:39:11 -04:00
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
|
|
|
|
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveSuccesses === 1);
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
|
|
|
|
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveSuccesses === 2);
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^Success$/i }));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'stable');
|
|
|
|
|
expect(lastEncCall().data.participants[0].deathSaveSuccesses).toBe(0);
|
|
|
|
|
expect(screen.getAllByText(/Stable/i).length).toBeGreaterThan(0);
|
|
|
|
|
expect(screen.queryByRole('button', { name: /^Success$/i })).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Nat20 restores 1 HP conscious and hides death-save UI', async () => {
|
|
|
|
|
await addCharacterToEncounter('Lucky', 10);
|
|
|
|
|
await dropFirstParticipantToZero(10);
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Nat20/i }));
|
|
|
|
|
|
|
|
|
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'conscious');
|
|
|
|
|
const p = lastEncCall().data.participants[0];
|
|
|
|
|
expect(p.currentHp).toBe(1);
|
|
|
|
|
expect(p.deathSaveSuccesses).toBe(0);
|
|
|
|
|
expect(p.deathSaveFailures).toBe(0);
|
|
|
|
|
expect(screen.queryByRole('button', { name: /^Fail$/i })).not.toBeInTheDocument();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('dead character remains excluded from add dropdown because still in encounter', async () => {
|
|
|
|
|
const { getParticipantForm } = require('./testHelpers');
|
|
|
|
|
await addCharacterToEncounter('StillHere', 10);
|
|
|
|
|
await dropFirstParticipantToZero(10);
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
|
|
|
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 1);
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
|
|
|
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.deathSaveFailures === 2);
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /^Fail$/i }));
|
|
|
|
|
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.status === 'dead');
|
|
|
|
|
|
|
|
|
|
const form = within(getParticipantForm());
|
|
|
|
|
expect(screen.getAllByText('StillHere').length).toBeGreaterThan(0);
|
|
|
|
|
expect(form.queryByRole('option', { name: 'StillHere' })).not.toBeInTheDocument();
|
2026-06-28 19:00:08 -04:00
|
|
|
});
|
|
|
|
|
});
|