From 4158a1634df679aa3d3f5d133a843526c1577607 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:50:42 -0400 Subject: [PATCH] test: participant characterization (9 tests) - Participant.characterization.test.js: addMonster (shape, initiative range, NPC), deleteParticipant, toggleActive, applyDamage, damage-to-0, heal revive, toggleCondition - testHelpers.js: getParticipantForm (scoped), addMonsterViaUI, setupReady, startCombatViaUI Locks participant write paths + payload shapes. Refactor guard. --- src/Participant.characterization.test.js | 126 +++++++++++++++++++++++ src/testHelpers.js | 50 ++++++++- 2 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 src/Participant.characterization.test.js diff --git a/src/Participant.characterization.test.js b/src/Participant.characterization.test.js new file mode 100644 index 0000000..03f4821 --- /dev/null +++ b/src/Participant.characterization.test.js @@ -0,0 +1,126 @@ +// Participant characterization. Lock updateDoc patch shape for participant ops. + +import React from 'react'; +import { screen, fireEvent, waitFor, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { getCalls } from './__mocks__/firebase/_mock-db'; +import { setupReady, addMonsterViaUI, getParticipantForm, startCombatViaUI } from './testHelpers'; + +function findCallsEnc() { + return getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); +} +function lastEncCall() { + const calls = findCallsEnc(); + return calls[calls.length - 1]; +} +// First participant list item (the participant card
  • ). +function firstParticipantItem() { + const list = screen.getByText('Victim') || + [...document.querySelectorAll('li')].find(li => li.querySelector('[title="Remove"]')); + return list.closest('li'); +} + +describe('Participant -> Firebase', () => { + test('addMonster: updateDoc appends participant with full shape', async () => { + await setupReady(); + await addMonsterViaUI('Goblin', 7, 2); + const call = lastEncCall(); + expect(call.data.participants).toHaveLength(1); + const p = call.data.participants[0]; + expect(p).toMatchObject({ + name: 'Goblin', type: 'monster', maxHp: 7, currentHp: 7, + isNpc: false, isActive: true, deathSaves: 0, isDying: false, conditions: [], + }); + expect(p).toHaveProperty('id'); + expect(p).toHaveProperty('initiative'); + }); + + test('addMonster: initiative = d20 roll (1-20) + mod', async () => { + await setupReady(); + await addMonsterViaUI('Orc', 12, 3); + const p = lastEncCall().data.participants[0]; + expect(p.initiative).toBeGreaterThanOrEqual(4); + expect(p.initiative).toBeLessThanOrEqual(23); + }); + + test('addMonster as NPC: isNpc true', async () => { + await setupReady(); + const form = within(getParticipantForm()); + fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: 'Guard' } }); + fireEvent.click(form.getByLabelText(/Is NPC/i)); + fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i })); + await waitFor(() => { + const p = lastEncCall()?.data?.participants?.[0]; + return p && p.name === 'Guard'; + }); + expect(lastEncCall().data.participants[0].isNpc).toBe(true); + }); + + test('deleteParticipant: updateDoc removes participant', async () => { + await setupReady(); + await addMonsterViaUI('Victim', 10, 0); + fireEvent.click(screen.getByTitle('Remove')); + fireEvent.click(await screen.findByRole('button', { name: /Confirm/i })); + await waitFor(() => (lastEncCall()?.data?.participants?.length === 0)); + expect(lastEncCall().data.participants).toEqual([]); + }); + + test('toggleActive: updateDoc flips isActive', async () => { + await setupReady(); + await addMonsterViaUI('Toggle', 10, 0); + fireEvent.click(screen.getByTitle('Mark Inactive')); + await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isActive === false); + expect(lastEncCall().data.participants[0].isActive).toBe(false); + }); + + test('applyDamage: updateDoc reduces currentHp, clamps 0', async () => { + await setupReady(); + await addMonsterViaUI('Hurt', 10, 0); + await startCombatViaUI(); + fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '3' } }); + fireEvent.click(screen.getByTitle('Damage')); + await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 7); + expect(lastEncCall().data.participants[0].currentHp).toBe(7); + }); + + test('damage to 0 deactivates participant', async () => { + await setupReady(); + await addMonsterViaUI('Doom', 5, 0); + await startCombatViaUI(); + fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '5' } }); + fireEvent.click(screen.getByTitle('Damage')); + await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0); + const p = lastEncCall().data.participants[0]; + expect(p.currentHp).toBe(0); + expect(p.isActive).toBe(false); + }); + + test('heal revives from 0 (reactivates, resets death saves)', async () => { + await setupReady(); + await addMonsterViaUI('Revive', 5, 0); + await startCombatViaUI(); + fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '5' } }); + fireEvent.click(screen.getByTitle('Damage')); + await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 0); + fireEvent.change(screen.getByPlaceholderText('HP'), { target: { value: '3' } }); + fireEvent.click(screen.getByTitle(/Heal/i)); + await waitFor(() => lastEncCall()?.data?.participants?.[0]?.currentHp === 3); + const p = lastEncCall().data.participants[0]; + expect(p.currentHp).toBe(3); + expect(p.isActive).toBe(true); + expect(p.deathSaves).toBe(0); + }); + + test('toggleCondition: updateDoc adds condition to array', async () => { + await setupReady(); + await addMonsterViaUI('Cond', 10, 0); + fireEvent.click(screen.getByTitle('Conditions')); + await waitFor(() => screen.getByRole('button', { name: /Blinded/i })); + fireEvent.click(screen.getByRole('button', { name: /Blinded/i })); + await waitFor(() => { + const p = lastEncCall()?.data?.participants?.[0]; + return p && p.conditions?.includes('blinded'); + }); + expect(lastEncCall().data.participants[0].conditions).toContain('blinded'); + }); +}); diff --git a/src/testHelpers.js b/src/testHelpers.js index 86a420e..0101ac0 100644 --- a/src/testHelpers.js +++ b/src/testHelpers.js @@ -1,9 +1,22 @@ // test helpers: drive App UI to states. Used across characterization suites. import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'; import App from './App'; import { MOCK_DB } from './__mocks__/firebase/_mock-db'; +// Scoped container: the "Add Participants" section (avoids label clashes with CharacterManager). +export function getParticipantForm() { + const heading = screen.getByText('Add Participants'); + // closest section/div wrapping the form + let node = heading; + for (let i = 0; i < 6; i++) { + node = node.parentElement; + if (!node) break; + if (node.querySelector('form')) return node; + } + return heading.parentElement; +} + // Render app, wait for auth + campaign list. export async function renderApp() { window.history.replaceState({}, '', '/'); @@ -53,4 +66,39 @@ export async function selectEncounterByName(name) { await waitFor(() => screen.getByText(/Managing Encounter:/i)); } +// Add a monster participant via the ParticipantManager form. Assumes encounter selected. +export async function addMonsterViaUI(name = 'Goblin', maxHp = 7, initMod = 2) { + const form = within(getParticipantForm()); + fireEvent.change(form.getByPlaceholderText('e.g., Dire Wolf'), { target: { value: name } }); + fireEvent.change(form.getByLabelText(/Init Mod/i), { target: { value: String(initMod) } }); + fireEvent.change(form.getByLabelText(/Max HP/i), { target: { value: String(maxHp) } }); + fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i })); + const { getCalls } = require('./__mocks__/firebase/_mock-db'); + await waitFor(() => { + const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); + const last = calls[calls.length - 1]; + return last && last.data.participants && last.data.participants.some(p => p.name === name); + }); +} + +// Full setup: app -> campaign -> encounter selected. +export async function setupReady(campName = 'Camp', encName = 'Enc') { + await renderApp(); + await createCampaignViaUI(campName); + await selectCampaignByName(campName); + await createEncounterViaUI(encName); + await selectEncounterByName(encName); +} + +// Start combat. Assumes encounter selected with active participants. +export async function startCombatViaUI() { + fireEvent.click(screen.getByRole('button', { name: /Start Combat/i })); + const { getCalls } = require('./__mocks__/firebase/_mock-db'); + await waitFor(() => { + const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); + const last = calls[calls.length - 1]; + return last && last.data.isStarted === true; + }); +} + export { MOCK_DB };