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