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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user