tests: consolidate into tests/ dirs, fix import paths

Move all test files out of source dirs into per-workspace tests/:
- shared/tests/   (3 unit test files)
- server/tests/   (1 integration test)
- src/tests/      (8 characterization + scenario tests + testHelpers)

Fix all relative import paths (App, storage, __mocks__, testHelpers).
Fix jest.config testMatch globs in shared/ and server/ (rootDir +
<rootDir>/tests pattern).

Delete scripts/repro-pause-bug.js (debug scratch, superseded by
turn.pause-add.test.js).

Keep scripts/replay-combat.js + scripts/audit-rotation.js as manual
demo/exploratory tools (NOT unit tests, not deterministic).

No logic changes. All green: shared 49 + 1 validated RED, server 23,
FE 62. Scenario test unchanged (240s timeout, pre-existing slow).
This commit is contained in:
david raistrick
2026-06-29 16:02:22 -04:00
parent 33e0e52789
commit f81308a0df
16 changed files with 26 additions and 100 deletions
+142
View File
@@ -0,0 +1,142 @@
// App.characterization.test.js
// Characterize App -> Firebase calls. Lock path + payload shape per action.
// Mock SDK, render AdminView, fire action, assert recorded calls.
// Purpose: refactor (path-shape rewrite) must not change these calls.
import React from 'react';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
import { renderApp, createCampaignViaUI, selectCampaignByName } from './testHelpers';
function findCall(fn, pathSub) {
return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
}
function findCalls(fn, pathSub) {
return getCalls().filter(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
}
// ============================================================================
// CAMPAIGN GROUP
// ============================================================================
describe('Campaign -> Firebase', () => {
test('createCampaign: setDoc with campaign path + payload', async () => {
await renderApp();
const id = await createCampaignViaUI('Alpha');
const call = findCall('setDoc', '/campaigns/');
expect(call.path).toMatch(/campaigns\/.+$/);
expect(call.data).toMatchObject({
name: 'Alpha',
playerDisplayBackgroundUrl: '',
players: [],
});
expect(call.data).toHaveProperty('ownerId');
expect(call.data).toHaveProperty('createdAt');
});
test('createCampaign: path includes APP_ID namespace', async () => {
await renderApp();
await createCampaignViaUI('NS Test');
const call = findCall('setDoc', '/campaigns/');
expect(call.path).toContain('artifacts/');
expect(call.path).toContain('/public/data/');
});
test('createCampaign: optional background URL stored', async () => {
await renderApp();
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'With BG' } });
fireEvent.change(screen.getByLabelText(/Background URL/i), { target: { value: 'https://img.test/bg.png' } });
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
await waitFor(() => findCall('setDoc', '/campaigns/'));
const call = findCall('setDoc', '/campaigns/');
expect(call.data.playerDisplayBackgroundUrl).toBe('https://img.test/bg.png');
});
test('addCharacter: updateDoc on campaign doc, players array grows', async () => {
await renderApp();
const cid = await createCampaignViaUI('Roster');
await selectCampaignByName('Roster');
// CharacterManager form
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } });
fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '25' } });
fireEvent.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => findCall('updateDoc', '/campaigns/'));
const call = findCall('updateDoc', `/campaigns/${cid}`);
expect(call.data.players).toHaveLength(1);
expect(call.data.players[0]).toMatchObject({
name: 'Brog',
defaultMaxHp: 25,
defaultInitMod: 3,
});
expect(call.data.players[0]).toHaveProperty('id');
});
test('updateCharacter: updateDoc with updated players array', async () => {
await renderApp();
const cid = await createCampaignViaUI('EditRoster');
await selectCampaignByName('EditRoster');
// add one first
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Old Name' } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => findCall('updateDoc', '/campaigns/'));
// click edit
const editBtn = await screen.findByRole('button', { name: /Edit character/i });
fireEvent.click(editBtn);
await waitFor(() => screen.getByDisplayValue('Old Name'));
fireEvent.change(screen.getByDisplayValue('Old Name'), { target: { value: 'New Name' } });
// Save button is icon-only (no text); submit its form.
const form = screen.getByDisplayValue('New Name').closest('form');
fireEvent.submit(form);
await waitFor(() => {
const calls = findCalls('updateDoc', `/campaigns/${cid}`);
const last = calls[calls.length - 1];
expect(last.data.players[0].name).toBe('New Name');
});
});
test('deleteCharacter: updateDoc with character removed', async () => {
await renderApp();
const cid = await createCampaignViaUI('DeleteRoster');
await selectCampaignByName('DeleteRoster');
fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Gone' } });
fireEvent.click(screen.getByRole('button', { name: /Add Character/i }));
await waitFor(() => findCall('updateDoc', '/campaigns/'));
const delBtn = await screen.findByRole('button', { name: /Delete character/i });
fireEvent.click(delBtn);
// confirmation modal
fireEvent.click(screen.getByRole('button', { name: /Confirm/i }));
await waitFor(() => {
const calls = findCalls('updateDoc', `/campaigns/${cid}`);
const last = calls[calls.length - 1];
expect(last.data.players).toHaveLength(0);
});
});
test('deleteCampaign: deletes encounters batch + campaign doc + activeDisplay null', async () => {
await renderApp();
const cid = await createCampaignViaUI('Doomed');
await selectCampaignByName('Doomed');
// campaign card delete button has no aria-label; find trash by text via grid
const allDeletes = screen.getAllByText(/Delete/i);
// campaign card Delete is in card grid, last one rendered
fireEvent.click(allDeletes[allDeletes.length - 1]);
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => findCall('deleteDoc', `/campaigns/${cid}`));
const delCall = findCall('deleteDoc', `/campaigns/${cid}`);
expect(delCall).toBeDefined();
});
});
+137
View File
@@ -0,0 +1,137 @@
// Combat characterization. Lock updateDoc/setDoc patch for combat controls.
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 findCallsEnc() {
return getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
}
function lastEncCall() {
const calls = findCallsEnc();
return calls[calls.length - 1];
}
function findCallActiveDisplay(fn) {
return getCalls().filter(c => c.fn === fn && c.path.includes('activeDisplay/status'));
}
async function setupWithMonsters(names = ['A', 'B', 'C']) {
await setupReady('CombatCamp', 'CombatEnc');
for (const n of names) {
await addMonsterViaUI(n, 20, Number(n.charCodeAt(0) % 10));
}
}
describe('Combat -> Firebase', () => {
test('startEncounter: updateDoc sets isStarted/round/turn/current', async () => {
await setupWithMonsters();
await startCombatViaUI();
const call = lastEncCall();
expect(call.data).toMatchObject({
isStarted: true,
isPaused: false,
round: 1,
});
expect(call.data.currentTurnParticipantId).toBeTruthy();
expect(call.data.turnOrderIds).toHaveLength(3);
});
test('startEncounter: also sets activeDisplay to this encounter', async () => {
await setupWithMonsters();
await startCombatViaUI();
const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1];
expect(last.data.activeCampaignId).toBeTruthy();
expect(last.data.activeEncounterId).toBeTruthy();
});
test('nextTurn: advances currentTurnParticipantId', async () => {
await setupWithMonsters();
await startCombatViaUI();
const beforeId = lastEncCall().data.currentTurnParticipantId;
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i }));
await waitFor(() => lastEncCall()?.data?.currentTurnParticipantId !== beforeId);
expect(lastEncCall().data.currentTurnParticipantId).not.toBe(beforeId);
});
test('nextTurn wrapping to round 1->2 increments round', async () => {
await setupWithMonsters(['A', 'B']);
await startCombatViaUI();
// advance through all participants to wrap
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); // A->B (or 2nd)
await waitFor(() => lastEncCall()?.data?.currentTurnParticipantId);
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); // wrap
await waitFor(() => lastEncCall()?.data?.round === 2);
expect(lastEncCall().data.round).toBe(2);
});
test('pause: updateDoc sets isPaused true', async () => {
await setupWithMonsters();
await startCombatViaUI();
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
await waitFor(() => lastEncCall()?.data?.isPaused === true);
expect(lastEncCall().data.isPaused).toBe(true);
});
test('resume: updateDoc sets isPaused false + recomputes turnOrder', async () => {
await setupWithMonsters();
await startCombatViaUI();
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
await waitFor(() => lastEncCall()?.data?.isPaused === true);
fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i }));
await waitFor(() => lastEncCall()?.data?.isPaused === false);
const call = lastEncCall();
expect(call.data.isPaused).toBe(false);
expect(call.data.turnOrderIds).toHaveLength(3);
});
test('endEncounter: updateDoc resets all combat state', async () => {
await setupWithMonsters();
await startCombatViaUI();
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => lastEncCall()?.data?.isStarted === false);
const call = lastEncCall();
expect(call.data).toMatchObject({
isStarted: false,
isPaused: false,
round: 0,
currentTurnParticipantId: null,
turnOrderIds: [],
});
});
test('endEncounter: clears activeDisplay', async () => {
await setupWithMonsters();
await startCombatViaUI();
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => {
const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1];
return last && last.data.activeCampaignId === null;
});
const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
});
test('toggleHidePlayerHp: setDoc merge on activeDisplay/status', async () => {
await setupWithMonsters();
await startCombatViaUI();
const switchBtn = screen.getByRole('switch');
fireEvent.click(switchBtn);
await waitFor(() => {
const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1];
return last && 'hidePlayerHp' in last.data;
});
const adCalls = findCallActiveDisplay('setDoc');
const last = adCalls[adCalls.length - 1];
expect(last.data).toHaveProperty('hidePlayerHp');
});
});
+324
View File
@@ -0,0 +1,324 @@
// Combat.scenario.test.js
// Full combat scenario: campaign -> encounter -> participants -> 100 rounds of
// damage/heal/conditions/toggle-active/edit/death-save/pause/resume/add/remove.
// Drives the SAME UI buttons a DM clicks. Failing assertions do NOT abort the run:
// each phase wraps in try/catch, failures collected, final expect reports all.
//
// Purpose: exercise as much of the supported feature surface as possible in one
// long combat, surfacing behavioral bugs characterization tests miss.
import React from 'react';
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
import '@testing-library/jest-dom';
import App from '../App';
import {
renderApp, createCampaignViaUI, selectCampaignByName,
createEncounterViaUI, selectEncounterByName, addMonsterViaUI, setupReady,
} from './testHelpers';
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
// ---------- scenario helpers (UI only, same buttons as human) ----------
const RESULTS = [];
function record(phase, fn) {
try { fn(); RESULTS.push({ phase, ok: true }); }
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
}
async function recordAsync(phase, fn) {
try { await fn(); RESULTS.push({ phase, ok: true }); }
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
}
function getParticipantForm() {
const heading = screen.getByText('Add Participants');
let node = heading;
for (let i = 0; i < 6; i++) {
node = node.parentElement;
if (!node) break;
if (node.querySelector('form')) return within(node);
}
return within(heading.parentElement);
}
// Find a participant's encounter <li> row by name. Scoped to the encounter
// participant list (NOT the CharacterManager roster, which also shows names).
// Encounter participant rows render an 'Init:' label; roster rows do not.
function getParticipantRow(name) {
const lis = document.querySelectorAll('li');
for (const li of lis) {
const txt = li.textContent || '';
if (txt.includes('Init:') && txt.includes(name)) {
return within(li);
}
}
throw new Error(`encounter participant row not found: ${name}`);
}
// Character roster (CharacterManager). Assumes campaign selected.
async function addCharacterViaUI(name, maxHp, initMod) {
fireEvent.change(document.getElementById('characterName'), { target: { value: name } });
fireEvent.change(document.getElementById('defaultMaxHp'), { target: { value: String(maxHp) } });
fireEvent.change(document.getElementById('defaultInitMod'), { target: { value: String(initMod) } });
fireEvent.click(screen.getByRole('button', { name: /^Add Character$/i }));
await waitFor(() => {
const call = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') &&
Array.isArray(c.data.players) && c.data.players.some(p => p.name === name));
if (!call) throw new Error('char not persisted');
});
}
function setParticipantType(type) {
// The Type select is inside the Add Participants form.
const form = getParticipantForm();
const selects = form.getAllByRole('combobox');
// first combobox in the participant form is Type
fireEvent.change(selects[0], { target: { value: type } });
}
async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
const form = getParticipantForm();
setParticipantType('monster');
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) } });
if (isNpc) {
const npcCheck = form.getByRole('checkbox', { name: /NPC/i });
if (!npcCheck.checked) fireEvent.click(npcCheck);
}
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last || !last.data.participants?.some(p => p.name === name)) throw new Error('monster not added');
});
}
async function addCharacterParticipant(charName) {
const form = getParticipantForm();
setParticipantType('character');
// character select is the 2nd combobox in the form after Type
const charSelect = form.getAllByRole('combobox')[1];
// find option whose text includes the char name
const opt = [...charSelect.querySelectorAll('option')].find(o => o.textContent.includes(charName));
if (!opt) throw new Error(`char option not found: ${charName}`);
fireEvent.change(charSelect, { target: { value: opt.value } });
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last || !last.data.participants?.some(p => p.name === charName)) throw new Error('char not added');
});
}
async function addAllCharacters() {
fireEvent.click(screen.getByRole('button', { name: /Add All/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last) throw new Error('add all no-op');
});
}
function applyDamage(name, amount) {
const row = getParticipantRow(name);
const dmgBtn = row.queryByTitle('Damage');
if (!dmgBtn) {
// participant dead (Damage button hidden when currentHp===0). Expected game
// state over a long fight; not a bug. Skip silently.
return;
}
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
fireEvent.click(dmgBtn);
}
function applyHeal(name, amount) {
const row = getParticipantRow(name);
const healBtn = row.queryByTitle('Heal / Revive') || row.queryByTitle('Heal');
if (!healBtn) throw new Error(`${name} has no Heal button`);
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
fireEvent.click(healBtn);
}
function toggleActive(name) {
const row = getParticipantRow(name);
const btn = row.queryByTitle('Mark Active') || row.queryByTitle('Mark Inactive');
if (!btn) throw new Error(`${name} has no active toggle`);
fireEvent.click(btn);
}
function openConditions(name) {
const row = getParticipantRow(name);
const btn = row.getByTitle('Conditions');
// idempotent: ensure panel open. Click toggles; if another participant's panel
// was open it's already closed by this participant's row focus, so just click.
fireEvent.click(btn);
}
function toggleCondition(name, label) {
openConditions(name);
// panel render is async (React state). Wait for button by title.
return waitFor(() => {
const condButtons = document.querySelectorAll('button[title]');
const target = [...condButtons].find(b => b.getAttribute('title') === label);
if (!target) throw new Error(`condition button not found: ${label}`);
fireEvent.click(target);
});
}
function editParticipant(name, patch) {
const row = getParticipantRow(name);
fireEvent.click(row.getByTitle('Edit'));
// EditParticipantModal. Scope to the modal via its form inputs.
const modal = document.querySelector('.fixed.inset-0') || document.body;
const inputs = modal.querySelectorAll('input');
if (patch.name !== undefined) {
fireEvent.change(inputs[0], { target: { value: patch.name } });
}
if (patch.initiative !== undefined && inputs[1]) {
fireEvent.change(inputs[1], { target: { value: String(patch.initiative) } });
}
const saveBtn = modal.querySelector('button[type="submit"]') ||
[...modal.querySelectorAll('button')].find(b => /^Save$/i.test(b.textContent.trim()));
fireEvent.click(saveBtn);
}
function removeParticipant(name) {
fireEvent.click(getParticipantRow(name).getByTitle('Remove'));
}
async function deathSave(name, saveNum) {
const row = getParticipantRow(name);
const btn = row.getByTitle(`Death save ${saveNum}`);
fireEvent.click(btn);
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last) throw new Error('deathSave no write');
});
}
async function nextTurn() {
fireEvent.click(screen.getByRole('button', { name: /Next Turn/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last) throw new Error('nextTurn no write');
});
}
async function pauseCombat() {
fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last?.data?.isPaused) throw new Error('not paused');
});
}
async function resumeCombat() {
fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (last?.data?.isPaused) throw new Error('not resumed');
});
}
async function startCombat() {
fireEvent.click(screen.getByRole('button', { name: /Start Combat/i }));
await waitFor(() => {
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
if (!last?.data?.isStarted) throw new Error('not started');
});
}
function toggleHidePlayerHp() {
fireEvent.click(screen.getByRole('switch'));
}
function currentEncDoc() {
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
return calls[calls.length - 1]?.data;
}
// ---------- scenario ----------
const ROUNDS = 100;
test('full 100-round combat scenario', async () => {
await setupReady('ScenarioCamp', 'BigBoss');
// roster
await recordAsync('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
await recordAsync('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
await recordAsync('addChar Rogue', () => addCharacterViaUI('Rogue', 22, 3));
// monsters + npcs
await recordAsync('addMonster Goblin1', () => addMonsterParticipant('Goblin1', 8, 2));
await recordAsync('addMonster Goblin2', () => addMonsterParticipant('Goblin2', 8, 2));
await recordAsync('addMonster OrcBoss', () => addMonsterParticipant('OrcBoss', 60, 1));
await recordAsync('addMonster Wolf', () => addMonsterParticipant('Wolf', 14, 3));
await recordAsync('addNpc Merchant', () => addMonsterParticipant('Merchant', 12, 0, true));
// add chars into encounter
await recordAsync('addCharParticipant Fighter', () => addCharacterParticipant('Fighter'));
await recordAsync('addCharParticipant Cleric', () => addCharacterParticipant('Cleric'));
await recordAsync('addCharParticipant Rogue', () => addCharacterParticipant('Rogue'));
await recordAsync('addAllChars', () => addAllCharacters());
// hidden hp toggle
record('toggleHidePlayerHp', () => toggleHidePlayerHp());
record('toggleHidePlayerHp back', () => toggleHidePlayerHp());
await recordAsync('startCombat', () => startCombat());
// 100 rounds of mixed actions
for (let r = 1; r <= ROUNDS; r++) {
await recordAsync(`round ${r} nextTurn`, () => nextTurn());
// damage front monster every other round
if (r % 2 === 0) record(`round ${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
if (r % 3 === 0) record(`round ${r} heal Cleric`, () => applyHeal('Cleric', 2));
if (r % 5 === 0) record(`round ${r} condition Fighter stunned`, () => toggleCondition('Fighter', 'Stunned'));
if (r % 7 === 0) record(`round ${r} toggleActive Goblin2`, () => toggleActive('Goblin2'));
// pause/resume every 10 rounds, add a participant, resume
if (r % 10 === 0) {
await recordAsync(`round ${r} pause`, () => pauseCombat());
await recordAsync(`round ${r} addReinforcement`, () =>
addMonsterParticipant(`Reinforce${r}`, 10, 1));
await recordAsync(`round ${r} edit Rogue initiative`, () => editParticipant('Rogue', { initiative: 20 }));
await recordAsync(`round ${r} resume`, () => resumeCombat());
}
// edit initiative on Wolf every 13
if (r % 13 === 0) record(`round ${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
// damage-to-0 + death save on Rogue around round 25 and 50
if (r === 25) {
record(`round ${r} drop Rogue`, () => applyDamage('Rogue', 99));
record(`round ${r} deathSave1 Rogue`, () => deathSave('Rogue', 1));
record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22));
}
if (r === 50) {
record(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99));
record(`round ${r} deathSave Cleric x3`, async () => {
await deathSave('Cleric', 1);
await deathSave('Cleric', 2);
await deathSave('Cleric', 3);
});
record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24));
}
// remove a reinforcement late
if (r === 30) {
await recordAsync(`round ${r} pause`, () => pauseCombat());
record(`round ${r} remove Reinforce20`, () => removeParticipant('Reinforce20'));
await recordAsync(`round ${r} resume`, () => resumeCombat());
}
}
await recordAsync('endCombat', async () => {
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
// End-combat ConfirmationModal has title 'End Encounter?'. Scope Confirm to it.
const endConfirm = await screen.findByRole('heading', { name: /End Encounter/i });
const modal = endConfirm.closest('.fixed.inset-0') || document.body;
const confirmBtn = [...modal.querySelectorAll('button')].find(b => /Confirm/i.test(b.textContent.trim()));
fireEvent.click(confirmBtn);
await waitFor(() => {
const last = currentEncDoc();
if (last?.isStarted !== false) throw new Error('not ended');
});
});
// ---------- report ----------
const failed = RESULTS.filter(r => !r.ok);
if (failed.length > 0) {
const msg = failed.map(f => `FAIL [${f.phase}]: ${f.err}`).join('\n');
// eslint-disable-next-line no-console
console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
}
// eslint-disable-next-line no-console
console.log(`\n=== SCENARIO: ${RESULTS.length - failed.length}/${RESULTS.length} phases ok ===\n`);
expect(failed).toEqual([]);
}, 240000); // long timeout: 100 rounds
@@ -0,0 +1,60 @@
// DisplayView.characterization.test.js
// Lock DisplayView uses storage adapter (subscribeDoc), NOT raw SDK onSnapshot(doc(db)).
// Blind spot caught: M2 refactor missed DisplayView; raw SDK + ws stub db = crash.
// Test asserts adapter recorder shows subscribeDoc calls when player view boots.
import React from 'react';
import { render, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import App from '../App';
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
import { getAdapterCalls, resetAdapterCalls } from '../storage/firebase';
// Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to.
function seedActiveDisplay() {
const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
MOCK_DB.set(encounterPath, { name: 'Enc', participants: [], isStarted: true });
MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
}
describe('DisplayView characterization', () => {
beforeEach(() => {
window.history.replaceState({}, '', '/display');
global.alert = jest.fn();
window.open = jest.fn();
resetAdapterCalls();
});
afterEach(() => {
window.history.replaceState({}, '', '/');
});
test('DisplayView subscribes via adapter.subscribeDoc (not raw SDK)', async () => {
seedActiveDisplay();
render(<App />);
// wait for DisplayView to mount and attempt subscriptions
await waitFor(() => {
const subs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc');
expect(subs.length).toBeGreaterThanOrEqual(1);
}, { timeout: 3000 });
// must subscribe to campaign doc (for background url) and encounter doc
const docSubs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc').map(c => c.path);
expect(docSubs.some(p => p.includes('/campaigns/c1'))).toBe(true);
expect(docSubs.some(p => p.includes('/encounters/e1'))).toBe(true);
});
test('DisplayView also subscribes to activeDisplay status doc via adapter', async () => {
seedActiveDisplay();
render(<App />);
await waitFor(() => {
const subs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc' && c.path.includes('activeDisplay'));
expect(subs.length).toBeGreaterThanOrEqual(1);
}, { timeout: 3000 });
});
});
@@ -0,0 +1,122 @@
// Encounter characterization. Lock setDoc path + payload on encounter actions.
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 { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName } from './testHelpers';
function findCall(fn, pathSub) {
return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
}
function findCalls(fn, pathSub) {
return getCalls().filter(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
}
async function setupCampaignAndEncounter(campName, encName) {
await renderApp();
await createCampaignViaUI(campName);
await selectCampaignByName(campName);
await createEncounterViaUI(encName);
}
describe('Encounter -> Firebase', () => {
test('createEncounter: setDoc with encounter path + payload', async () => {
await setupCampaignAndEncounter('Camp E', 'Boss Fight');
const call = findCall('setDoc', '/encounters/');
expect(call.path).toMatch(/encounters\/.+$/);
expect(call.data).toMatchObject({
name: 'Boss Fight',
participants: [],
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false,
});
expect(call.data).toHaveProperty('createdAt');
});
test('createEncounter: path nested under campaign', async () => {
await setupCampaignAndEncounter('Camp N', 'Enc N');
const call = findCall('setDoc', '/encounters/');
expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//);
});
test('togglePlayerDisplay: setDoc merge on activeDisplay/status', async () => {
await setupCampaignAndEncounter('Camp D', 'Enc D');
await selectEncounterByName('Enc D');
// Eye button (icon-only, title attr)
const eyeBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(eyeBtn);
await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
const call = findCall('setDoc', 'activeDisplay/status');
// activeDisplay/status setDoc is called with merge option in App
expect(call.data).toMatchObject({
activeCampaignId: expect.any(String),
activeEncounterId: expect.any(String),
});
});
test('togglePlayerDisplay off: setDoc nulls active ids', async () => {
await setupCampaignAndEncounter('Camp O', 'Enc O');
await selectEncounterByName('Enc O');
// turn ON
const onBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(onBtn);
await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
// turn OFF
const offBtn = await screen.findByTitle('Deactivate for Player Display');
fireEvent.click(offBtn);
await waitFor(() => {
const calls = findCalls('setDoc', 'activeDisplay/status');
const last = calls[calls.length - 1];
return last.data.activeCampaignId === null;
});
const calls = findCalls('setDoc', 'activeDisplay/status');
const last = calls[calls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
});
test('deleteEncounter: deleteDoc on encounter path', async () => {
await setupCampaignAndEncounter('Camp X', 'Enc X');
await selectEncounterByName('Enc X');
// trash icon on encounter card
const trashBtn = screen.getAllByTitle('Delete Encounter')[0];
fireEvent.click(trashBtn);
// confirm modal
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => findCall('deleteDoc', '/encounters/'));
const del = findCall('deleteDoc', '/encounters/');
expect(del.path).toMatch(/campaigns\/[^/]+\/encounters\//);
});
test('deleteEncounter clears activeDisplay if it was active', async () => {
await setupCampaignAndEncounter('Camp A', 'Enc A');
await selectEncounterByName('Enc A');
// activate display first
const onBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(onBtn);
await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
// delete the active encounter
const trashBtn = screen.getAllByTitle('Delete Encounter')[0];
fireEvent.click(trashBtn);
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => {
const adCalls = findCalls('updateDoc', 'activeDisplay/status');
const last = adCalls[adCalls.length - 1];
return last.data.activeEncounterId === null;
});
const adCalls = findCalls('updateDoc', 'activeDisplay/status');
const last = adCalls[adCalls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
});
});
+171
View File
@@ -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);
});
});
@@ -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 <li>).
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');
});
});
+8
View File
@@ -0,0 +1,8 @@
// Runner: executes storage contract against each impl.
// TDD: contract = spec. Run against memory first. RED until memory.js built.
'use strict';
const { runStorageContract } = require('../storage/contract');
const { createMemoryStorage } = require('../storage/memory');
runStorageContract('memory', () => createMemoryStorage());
+104
View File
@@ -0,0 +1,104 @@
// test helpers: drive App UI to states. Used across characterization suites.
import React from '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({}, '', '/');
global.alert = jest.fn();
window.open = jest.fn();
const utils = render(<App />);
await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i }));
return utils;
}
// Open create-campaign modal, fill name, submit. Returns campaign id from recorded call.
export async function createCampaignViaUI(name = 'Test Campaign') {
fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i }));
await waitFor(() => screen.getByLabelText(/Campaign Name/i));
fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: name } });
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
// wait for setDoc recorded
const { getCalls } = require('../__mocks__/firebase/_mock-db');
await waitFor(() => getCalls().find(c => c.fn === 'setDoc' && c.path.includes('/campaigns/')));
const call = getCalls().find(c => c.fn === 'setDoc' && c.path.includes('/campaigns/'));
return call.path.split('/').pop(); // campaign id
}
// Click campaign card by name to select it. Returns selected campaign id.
export async function selectCampaignByName(name) {
const card = await waitFor(() => screen.getByText(name));
fireEvent.click(card);
await waitFor(() => screen.getByText(/Managing:/i));
}
// Open create-encounter modal, fill name, submit. Assumes campaign selected.
export async function createEncounterViaUI(name = 'Test Encounter') {
fireEvent.click(screen.getByRole('button', { name: /Create Encounter/i }));
await waitFor(() => screen.getByLabelText(/Encounter Name/i));
fireEvent.change(screen.getByLabelText(/Encounter Name/i), { target: { value: name } });
fireEvent.click(screen.getByRole('button', { name: /^Create$/i }));
const { getCalls } = require('../__mocks__/firebase/_mock-db');
await waitFor(() => getCalls().find(c => c.fn === 'setDoc' && c.path.includes('/encounters/')));
const call = getCalls().find(c => c.fn === 'setDoc' && c.path.includes('/encounters/'));
return call.path.split('/').pop();
}
// Click encounter card by name. Assumes campaign selected.
export async function selectEncounterByName(name) {
const card = await waitFor(() => screen.getByText(name));
fireEvent.click(card);
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 };