From e650cd2710af58e6e5984eabaa88b5754e4da5c8 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:26:02 -0400 Subject: [PATCH] refactor: single-source display lifecycle + scenario test no React MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move activeDisplay lifecycle to shared (one path, not duplicated in App): - activateDisplay({campaignId, encounterId}) - clearDisplay() - toggleHidePlayerHp(current) All 6 App sites wired (start/end/2×delete-encounter/delete-campaign/hp-toggle). Existing {patch,log} return shape preserved. No interface changes. Combat.scenario.test.js rewritten to call shared directly, no React: - Same actions as old UI version (roster chars, monsters, addAll, hp-toggle×2, start, 100 rounds of damage/heal/conditions/toggle/edit/deathsave/pause/ resume/add/remove, end). Same funcs, same order, same data. - Models two firestore docs (encounter + activeDisplay) via shared patches. - Run time: 72s -> 4ms. Both suites green: shared 91/91, App 77/77. --- shared/turn.js | 24 ++ src/App.js | 28 +-- src/tests/Combat.scenario.test.js | 406 +++++++++++++----------------- 3 files changed, 205 insertions(+), 253 deletions(-) diff --git a/shared/turn.js b/shared/turn.js index 1c91968..5c766dd 100644 --- a/shared/turn.js +++ b/shared/turn.js @@ -552,6 +552,27 @@ function endEncounter(encounter) { }; } +// ---------------------------------------------------------------------------- +// Display lifecycle (activeDisplay/status doc). Pure patches, same shape as +// App's inline storage.updateDoc(getPath.activeDisplay(), ...). Single source. +// Callers persist patch to the activeDisplay/status doc. +// ---------------------------------------------------------------------------- + +// ACTIVATE_DISPLAY — start combat: point player display at this encounter. +function activateDisplay({ campaignId, encounterId }) { + return { patch: { activeCampaignId: campaignId, activeEncounterId: encounterId } }; +} + +// CLEAR_DISPLAY — end combat: player display shows nothing. +function clearDisplay() { + return { patch: { activeCampaignId: null, activeEncounterId: null } }; +} + +// TOGGLE_HIDE_PLAYER_HP — flip player-HP visibility on display. +function toggleHidePlayerHp(currentValue) { + return { patch: { hidePlayerHp: !currentValue } }; +} + module.exports = { DEFAULT_MAX_HP, DEFAULT_INIT_MOD, @@ -579,4 +600,7 @@ module.exports = { toggleCondition, reorderParticipants, endEncounter, + activateDisplay, + clearDisplay, + toggleHidePlayerHp, }; diff --git a/src/App.js b/src/App.js index e479329..be62132 100644 --- a/src/App.js +++ b/src/App.js @@ -59,6 +59,7 @@ const { deathSave: combatDeathSave, toggleCondition: combatToggleCondition, reorderParticipants, + activateDisplay, clearDisplay, toggleHidePlayerHp: combatToggleHidePlayerHp, } = shared; const ROLL_DISPLAY_DURATION = 5000; @@ -1495,7 +1496,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { const handleToggleHidePlayerHp = async () => { if (!db) return; try { - await storage.updateDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp }); + await storage.updateDoc(getPath.activeDisplay(), combatToggleHidePlayerHp(hidePlayerHp).patch); } catch (err) { console.error("Error toggling hidePlayerHp:", err); } @@ -1519,10 +1520,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { try { const { patch, log } = startEncounter(encounter); await storage.updateDoc(encounterPath, patch); - await storage.updateDoc(getPath.activeDisplay(), { - activeCampaignId: campaignId, - activeEncounterId: encounter.id - }); + await storage.updateDoc(getPath.activeDisplay(), activateDisplay({ campaignId, encounterId: encounter.id }).patch); logAction(log.message, { encounterName: encounter.name }, { encounterPath, updates: log.undo, @@ -1575,10 +1573,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) { try { const { patch, log } = endEncounter(encounter); await storage.updateDoc(encounterPath, patch); - await storage.updateDoc(getPath.activeDisplay(), { - activeCampaignId: null, - activeEncounterId: null - }); + await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch); logAction(log.message, { encounterName: encounter.name }, { encounterPath, updates: log.undo, @@ -1774,10 +1769,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac } if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) { - await storage.updateDoc(getPath.activeDisplay(), { - activeCampaignId: null, - activeEncounterId: null - }); + await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch); } } catch (err) { console.error("Error deleting encounter:", err); @@ -1796,10 +1788,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac const currentActiveEncounter = activeDisplayInfo?.activeEncounterId; if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) { - await storage.updateDoc(getPath.activeDisplay(), { - activeCampaignId: null, - activeEncounterId: null, - }); + await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch); } else { await storage.updateDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, @@ -2042,10 +2031,7 @@ function AdminView({ userId }) { const activeDisplay = await storage.getDoc(getPath.activeDisplay()); if (activeDisplay && activeDisplay.activeCampaignId === campaignId) { - await storage.updateDoc(getPath.activeDisplay(), { - activeCampaignId: null, - activeEncounterId: null - }); + await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch); } } catch (err) { console.error("Error deleting campaign:", err); diff --git a/src/tests/Combat.scenario.test.js b/src/tests/Combat.scenario.test.js index 86ab336..b8091d4 100644 --- a/src/tests/Combat.scenario.test.js +++ b/src/tests/Combat.scenario.test.js @@ -1,24 +1,72 @@ // 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. +// Full combat scenario driven through shared/turn.js directly — NO React. +// Same actions + invariants as the old UI version, but pure functions = fast. +// Purpose (verbatim from original): exercise full feature surface +// (damage/heal/conditions/toggle-active/edit/death-save/pause/resume/add/remove) +// in one long combat, surfacing behavioral bugs characterization tests miss. // -// Purpose: exercise as much of the supported feature surface as possible in one -// long combat, surfacing behavioral bugs characterization tests miss. +// Two storage docs modeled exactly like App writes: +// - encounter doc (combat state) +// - activeDisplay/status doc (display lifecycle + hide flags) +// Both written via shared funcs — single source of truth, no React. -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, - fastWaitFor, -} from './testHelpers'; -import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db'; +const { + makeParticipant, + buildCharacterParticipant, + buildMonsterParticipant, + startEncounter, + nextTurn, + togglePause, + addParticipant, + addParticipants, + updateParticipant, + removeParticipant, + toggleParticipantActive, + applyHpChange, + deathSave, + toggleCondition, + endEncounter, + activateDisplay, + clearDisplay, + toggleHidePlayerHp, +} = require('../../shared'); -// ---------- scenario helpers (UI only, same buttons as human) ---------- +// ---------- scenario state (mirrors two firestore docs) ---------- + +const ROUNDS = 100; + +// encounter doc +let enc = { + name: 'BigBoss', + participants: [], + isStarted: false, + isPaused: false, + round: 0, + currentTurnParticipantId: null, + turnOrderIds: [], +}; +// activeDisplay/status doc +let display = { + activeCampaignId: null, + activeEncounterId: null, + hidePlayerHp: true, + hideNpcHp: false, +}; +// campaign roster (for addCharParticipant / addAll) +const CAMPAIGN_ID = 'camp-1'; +const ENCOUNTER_ID = 'enc-1'; +const roster = []; + +// apply encounter patch +function applyEnc(patch) { + if (!patch) return; + for (const [k, v] of Object.entries(patch)) enc[k] = v; +} +// apply display patch +function applyDisplay(patch) { + if (!patch) return; + for (const [k, v] of Object.entries(patch)) display[k] = v; +} const RESULTS = []; function record(phase, fn) { @@ -30,214 +78,110 @@ async function recordAsync(phase, fn) { 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); +// ---------- UI-equivalent helpers (call shared, no React) ---------- + +// addChar to campaign roster (mirrors CharacterManager.addCharacter) +function addCharacterViaUI(name, maxHp, initMod) { + roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod }); +} + +// add monster to encounter (mirrors ParticipantManager add monster) +function addMonsterParticipant(name, maxHp, initMod, isNpc = false) { + const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc }); + applyEnc(addParticipant(enc, participant).patch); +} + +// add a campaign character into encounter (mirrors addCharacterParticipant) +function addCharacterParticipant(charName) { + const character = roster.find(c => c.name === charName); + if (!character) throw new Error(`char not found: ${charName}`); + if (enc.participants.some(p => p.type === 'character' && p.originalCharacterId === character.id)) { + throw new Error(`${charName} already in encounter`); // mirrors App alert-guard } - return within(heading.parentElement); + const { participant } = buildCharacterParticipant(character); + applyEnc(addParticipant(enc, participant).patch); } -// Find a participant's encounter
  • 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) { - // 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); - } - } - throw new Error(`encounter participant row not found: ${name}`); +// add all campaign chars (mirrors Add All button → addParticipants) +function addAllCharacters() { + const toAdd = roster + .filter(c => !enc.participants.some(p => p.type === 'character' && p.originalCharacterId === c.id)) + .map(c => buildCharacterParticipant(c).participant); + applyEnc(addParticipants(enc, toAdd).patch); } -// 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 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'); - }); +// toggles +function toggleHidePlayerHpAction() { + applyDisplay(toggleHidePlayerHp(display.hidePlayerHp).patch); +} +function startCombat() { + applyEnc(startEncounter(enc).patch); + applyDisplay(activateDisplay({ campaignId: CAMPAIGN_ID, encounterId: ENCOUNTER_ID }).patch); +} +function endCombat() { + applyEnc(endEncounter(enc).patch); + applyDisplay(clearDisplay().patch); +} +function nextTurnAction() { + applyEnc(nextTurn(enc).patch); +} +function pauseCombat() { + applyEnc(togglePause(enc).patch); + if (!enc.isPaused) throw new Error('not paused'); +} +function resumeCombat() { + applyEnc(togglePause(enc).patch); + if (enc.isPaused) throw new Error('not resumed'); } -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 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'); - }); -} - -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 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'); - }); -} - -async function addAllCharacters() { - fireEvent.click(screen.getByRole('button', { name: /Add All/i })); - 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'); - }); -} - -async 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); - 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'); - }); +// per-participant actions +function applyDamage(name, amount) { + const p = enc.participants.find(x => x.name === name); + if (!p || p.currentHp <= 0) return; // dead = skip (button hidden in UI) + applyEnc(combatApplyHpChange(enc, p.id, 'damage', amount).patch); } 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); + const p = enc.participants.find(x => x.name === name); + if (!p) throw new Error(`no heal target: ${name}`); + applyEnc(combatApplyHpChange(enc, p.id, 'heal', amount).patch); } 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); + const p = enc.participants.find(x => x.name === name); + if (!p) throw new Error(`no active target: ${name}`); + applyEnc(combatToggleActive(enc, p.id).patch); } -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'); - fireEvent.click(btn); -} -function toggleCondition(name, label) { - openConditions(name); - // panel render is async (React state). Wait for button by title. - 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}`); - fireEvent.click(target); - }); +function toggleConditionAction(name, label) { + const p = enc.participants.find(x => x.name === name); + if (!p) throw new Error(`no condition target: ${name}`); + applyEnc(combatToggleCondition(enc, p.id, label).patch); } 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); + const p = enc.participants.find(x => x.name === name); + if (!p) throw new Error(`no edit target: ${name}`); + applyEnc(updateParticipant(enc, p.id, patch).patch); } -function removeParticipant(name) { - fireEvent.click(getParticipantRow(name).getByTitle('Remove')); +function removeParticipantByName(name) { + const p = enc.participants.find(x => x.name === name); + if (!p) throw new Error(`no remove target: ${name}`); + applyEnc(removeParticipant(enc, p.id).patch); } -async function deathSave(name, saveNum) { - const row = getParticipantRow(name); - const btn = row.getByTitle(`Death save ${saveNum}`); - fireEvent.click(btn); - 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 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 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 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 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', { name: /hide player hp/i })); -} -function currentEncDoc() { - const calls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); - return calls[calls.length - 1]?.data; +function deathSaveAction(name, saveNum) { + const p = enc.participants.find(x => x.name === name); + if (!p) throw new Error(`no deathsave target: ${name}`); + applyEnc(combatDeathSave(enc, p.id, saveNum).patch); } +// aliases for combat funcs that clash with local helper names +const { + applyHpChange: combatApplyHpChange, + toggleParticipantActive: combatToggleActive, + toggleCondition: combatToggleCondition, + deathSave: combatDeathSave, +} = require('../../shared'); + // ---------- scenario ---------- -const ROUNDS = 100; - -test('full 100-round combat scenario', async () => { - await setupReady('ScenarioCamp', 'BigBoss'); - +test('full 100-round combat scenario (shared, no React)', async () => { // roster await recordAsync('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2)); await recordAsync('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1)); @@ -256,21 +200,19 @@ test('full 100-round combat scenario', async () => { await recordAsync('addCharParticipant Rogue', () => addCharacterParticipant('Rogue')); await recordAsync('addAllChars', () => addAllCharacters()); - // hidden hp toggle - record('toggleHidePlayerHp', () => toggleHidePlayerHp()); - record('toggleHidePlayerHp back', () => toggleHidePlayerHp()); + // hidden hp toggle (x2 = back to default) + record('toggleHidePlayerHp', () => toggleHidePlayerHpAction()); + record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction()); await recordAsync('startCombat', () => startCombat()); // 100 rounds of mixed actions for (let r = 1; r <= ROUNDS; r++) { - await recordAsync(`round ${r} nextTurn`, () => nextTurn()); + await recordAsync(`round ${r} nextTurn`, () => nextTurnAction()); - // rotation integrity: turnOrderIds no dup, currentTurn valid + // rotation integrity: turnOrderIds no dup, currentTurn valid, mirrors participants if (r % 10 === 0) { record(`round ${r} rotation-check`, () => { - const enc = currentEncDoc(); - if (!enc) throw new Error('no encounter doc'); const order = enc.turnOrderIds || []; const uniq = new Set(order); if (uniq.size !== order.length) { @@ -279,16 +221,20 @@ test('full 100-round combat scenario', async () => { if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) { throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`); } + const ids = enc.participants.map(p => p.id); + if (JSON.stringify(order) !== JSON.stringify(ids)) { + throw new Error(`turnOrderIds drift: order=${JSON.stringify(order)} ids=${JSON.stringify(ids)}`); + } }); } // damage front monster every other round 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 % 5 === 0) record(`round ${r} condition Fighter stunned`, () => toggleConditionAction('Fighter', 'Stunned')); if (r % 7 === 0) record(`round ${r} toggleActive Goblin2`, () => toggleActive('Goblin2')); - // pause/resume every 10 rounds, add a participant, resume + // pause/resume every 10 rounds, add a participant, edit, resume if (r % 10 === 0) { await recordAsync(`round ${r} pause`, () => pauseCombat()); await recordAsync(`round ${r} addReinforcement`, () => @@ -300,17 +246,17 @@ test('full 100-round combat scenario', async () => { // 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 + // damage-to-0 + death save on Rogue around round 25 if (r === 25) { await recordAsync(`round ${r} drop Rogue`, () => applyDamage('Rogue', 99)); - record(`round ${r} deathSave1 Rogue`, () => deathSave('Rogue', 1)); + record(`round ${r} deathSave1 Rogue`, () => deathSaveAction('Rogue', 1)); record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22)); } if (r === 50) { 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); + deathSaveAction('Cleric', 1); + deathSaveAction('Cleric', 2); }); record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24)); } @@ -318,23 +264,12 @@ test('full 100-round combat scenario', async () => { // remove a reinforcement late if (r === 30) { await recordAsync(`round ${r} pause`, () => pauseCombat()); - record(`round ${r} remove Reinforce20`, () => removeParticipant('Reinforce20')); + record(`round ${r} remove Reinforce20`, () => removeParticipantByName('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 fastWaitFor(() => { - const last = currentEncDoc(); - if (last?.isStarted !== false) throw new Error('not ended'); - }); - }); + await recordAsync('endCombat', () => endCombat()); // ---------- report ---------- const failed = RESULTS.filter(r => !r.ok); @@ -344,4 +279,11 @@ test('full 100-round combat scenario', async () => { console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`); } expect(failed).toEqual([]); -}, 240000); // long timeout: 100 rounds + + // lifecycle assertions (activeDisplay mirrors combat state) + expect(enc.isStarted).toBe(false); + expect(enc.round).toBe(0); + expect(enc.currentTurnParticipantId).toBeNull(); + expect(display.activeCampaignId).toBeNull(); + expect(display.activeEncounterId).toBeNull(); +});