|
|
|
@@ -14,6 +14,7 @@ import App from '../App';
|
|
|
|
|
import {
|
|
|
|
|
renderApp, createCampaignViaUI, selectCampaignByName,
|
|
|
|
|
createEncounterViaUI, selectEncounterByName, addMonsterViaUI, setupReady,
|
|
|
|
|
fastWaitFor,
|
|
|
|
|
} from './testHelpers';
|
|
|
|
|
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
|
|
|
|
|
|
|
|
@@ -46,8 +47,10 @@ function getParticipantForm() {
|
|
|
|
|
function getParticipantRow(name) {
|
|
|
|
|
const lis = document.querySelectorAll('li');
|
|
|
|
|
for (const li of lis) {
|
|
|
|
|
const txt = li.textContent || '';
|
|
|
|
|
if (txt.includes('Init:') && txt.includes(name)) {
|
|
|
|
|
// participant rows have inline initiative input with unique aria-label.
|
|
|
|
|
// Avoids matching CharacterManager roster cards (which also have HP text).
|
|
|
|
|
const initInput = li.querySelector(`input[aria-label="Initiative for ${name}"]`);
|
|
|
|
|
if (initInput) {
|
|
|
|
|
return within(li);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -60,7 +63,7 @@ async function addCharacterViaUI(name, maxHp, initMod) {
|
|
|
|
|
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(() => {
|
|
|
|
|
await fastWaitFor(() => {
|
|
|
|
|
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');
|
|
|
|
@@ -86,7 +89,7 @@ async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
|
|
|
|
|
if (!npcCheck.checked) fireEvent.click(npcCheck);
|
|
|
|
|
}
|
|
|
|
|
fireEvent.click(form.getByRole('button', { name: /Add to Encounter/i }));
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
await fastWaitFor(() => {
|
|
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
@@ -102,7 +105,7 @@ async function addCharacterParticipant(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(() => {
|
|
|
|
|
await fastWaitFor(() => {
|
|
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
@@ -110,13 +113,13 @@ async function addCharacterParticipant(charName) {
|
|
|
|
|
|
|
|
|
|
async function addAllCharacters() {
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Add All/i }));
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
await fastWaitFor(() => {
|
|
|
|
|
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) {
|
|
|
|
|
async function applyDamage(name, amount) {
|
|
|
|
|
const row = getParticipantRow(name);
|
|
|
|
|
const dmgBtn = row.queryByTitle('Damage');
|
|
|
|
|
if (!dmgBtn) {
|
|
|
|
@@ -126,6 +129,11 @@ function applyDamage(name, amount) {
|
|
|
|
|
}
|
|
|
|
|
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
|
|
|
|
|
fireEvent.click(dmgBtn);
|
|
|
|
|
await fastWaitFor(() => {
|
|
|
|
|
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
|
|
|
|
const p = last?.data?.participants?.find(x => x.name === name);
|
|
|
|
|
if (!p) throw new Error('damage no write');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
function applyHeal(name, amount) {
|
|
|
|
|
const row = getParticipantRow(name);
|
|
|
|
@@ -142,15 +150,17 @@ function toggleActive(name) {
|
|
|
|
|
}
|
|
|
|
|
function openConditions(name) {
|
|
|
|
|
const row = getParticipantRow(name);
|
|
|
|
|
// idempotent: only click Conditions button if panel not already open for this row.
|
|
|
|
|
// Re-clicking toggles it closed.
|
|
|
|
|
const panel = row.queryByText('Toggle Conditions');
|
|
|
|
|
if (panel) return;
|
|
|
|
|
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(() => {
|
|
|
|
|
return fastWaitFor(() => {
|
|
|
|
|
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}`);
|
|
|
|
@@ -180,41 +190,41 @@ async function deathSave(name, saveNum) {
|
|
|
|
|
const row = getParticipantRow(name);
|
|
|
|
|
const btn = row.getByTitle(`Death save ${saveNum}`);
|
|
|
|
|
fireEvent.click(btn);
|
|
|
|
|
await waitFor(() => {
|
|
|
|
|
await fastWaitFor(() => {
|
|
|
|
|
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(() => {
|
|
|
|
|
await fastWaitFor(() => {
|
|
|
|
|
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(() => {
|
|
|
|
|
await fastWaitFor(() => {
|
|
|
|
|
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(() => {
|
|
|
|
|
await fastWaitFor(() => {
|
|
|
|
|
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(() => {
|
|
|
|
|
await fastWaitFor(() => {
|
|
|
|
|
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'));
|
|
|
|
|
fireEvent.click(screen.getByRole('switch', { name: /hide player hp/i }));
|
|
|
|
|
}
|
|
|
|
|
function currentEncDoc() {
|
|
|
|
|
const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
|
|
|
@@ -273,7 +283,7 @@ test('full 100-round combat scenario', async () => {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// damage front monster every other round
|
|
|
|
|
if (r % 2 === 0) record(`round ${r} damage OrcBoss`, () => applyDamage('OrcBoss', 3));
|
|
|
|
|
if (r % 2 === 0) await recordAsync(`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'));
|
|
|
|
@@ -292,16 +302,15 @@ test('full 100-round combat scenario', async () => {
|
|
|
|
|
|
|
|
|
|
// damage-to-0 + death save on Rogue around round 25 and 50
|
|
|
|
|
if (r === 25) {
|
|
|
|
|
record(`round ${r} drop Rogue`, () => applyDamage('Rogue', 99));
|
|
|
|
|
await recordAsync(`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 recordAsync(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99));
|
|
|
|
|
await recordAsync(`round ${r} deathSave Cleric x2`, async () => {
|
|
|
|
|
await deathSave('Cleric', 1);
|
|
|
|
|
await deathSave('Cleric', 2);
|
|
|
|
|
await deathSave('Cleric', 3);
|
|
|
|
|
});
|
|
|
|
|
record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24));
|
|
|
|
|
}
|
|
|
|
@@ -321,7 +330,7 @@ test('full 100-round combat scenario', async () => {
|
|
|
|
|
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(() => {
|
|
|
|
|
await fastWaitFor(() => {
|
|
|
|
|
const last = currentEncDoc();
|
|
|
|
|
if (last?.isStarted !== false) throw new Error('not ended');
|
|
|
|
|
});
|
|
|
|
@@ -334,7 +343,5 @@ test('full 100-round combat scenario', async () => {
|
|
|
|
|
// 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
|
|
|
|
|