refactor: single-source display lifecycle + scenario test no React
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.
This commit is contained in:
@@ -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 = {
|
module.exports = {
|
||||||
DEFAULT_MAX_HP,
|
DEFAULT_MAX_HP,
|
||||||
DEFAULT_INIT_MOD,
|
DEFAULT_INIT_MOD,
|
||||||
@@ -579,4 +600,7 @@ module.exports = {
|
|||||||
toggleCondition,
|
toggleCondition,
|
||||||
reorderParticipants,
|
reorderParticipants,
|
||||||
endEncounter,
|
endEncounter,
|
||||||
|
activateDisplay,
|
||||||
|
clearDisplay,
|
||||||
|
toggleHidePlayerHp,
|
||||||
};
|
};
|
||||||
|
|||||||
+7
-21
@@ -59,6 +59,7 @@ const {
|
|||||||
deathSave: combatDeathSave,
|
deathSave: combatDeathSave,
|
||||||
toggleCondition: combatToggleCondition,
|
toggleCondition: combatToggleCondition,
|
||||||
reorderParticipants,
|
reorderParticipants,
|
||||||
|
activateDisplay, clearDisplay, toggleHidePlayerHp: combatToggleHidePlayerHp,
|
||||||
} = shared;
|
} = shared;
|
||||||
const ROLL_DISPLAY_DURATION = 5000;
|
const ROLL_DISPLAY_DURATION = 5000;
|
||||||
|
|
||||||
@@ -1495,7 +1496,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
const handleToggleHidePlayerHp = async () => {
|
const handleToggleHidePlayerHp = async () => {
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
try {
|
try {
|
||||||
await storage.updateDoc(getPath.activeDisplay(), { hidePlayerHp: !hidePlayerHp });
|
await storage.updateDoc(getPath.activeDisplay(), combatToggleHidePlayerHp(hidePlayerHp).patch);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error toggling hidePlayerHp:", err);
|
console.error("Error toggling hidePlayerHp:", err);
|
||||||
}
|
}
|
||||||
@@ -1519,10 +1520,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
try {
|
try {
|
||||||
const { patch, log } = startEncounter(encounter);
|
const { patch, log } = startEncounter(encounter);
|
||||||
await storage.updateDoc(encounterPath, patch);
|
await storage.updateDoc(encounterPath, patch);
|
||||||
await storage.updateDoc(getPath.activeDisplay(), {
|
await storage.updateDoc(getPath.activeDisplay(), activateDisplay({ campaignId, encounterId: encounter.id }).patch);
|
||||||
activeCampaignId: campaignId,
|
|
||||||
activeEncounterId: encounter.id
|
|
||||||
});
|
|
||||||
logAction(log.message, { encounterName: encounter.name }, {
|
logAction(log.message, { encounterName: encounter.name }, {
|
||||||
encounterPath,
|
encounterPath,
|
||||||
updates: log.undo,
|
updates: log.undo,
|
||||||
@@ -1575,10 +1573,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
try {
|
try {
|
||||||
const { patch, log } = endEncounter(encounter);
|
const { patch, log } = endEncounter(encounter);
|
||||||
await storage.updateDoc(encounterPath, patch);
|
await storage.updateDoc(encounterPath, patch);
|
||||||
await storage.updateDoc(getPath.activeDisplay(), {
|
await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch);
|
||||||
activeCampaignId: null,
|
|
||||||
activeEncounterId: null
|
|
||||||
});
|
|
||||||
logAction(log.message, { encounterName: encounter.name }, {
|
logAction(log.message, { encounterName: encounter.name }, {
|
||||||
encounterPath,
|
encounterPath,
|
||||||
updates: log.undo,
|
updates: log.undo,
|
||||||
@@ -1774,10 +1769,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
|
if (activeDisplayInfo && activeDisplayInfo.activeEncounterId === encounterId) {
|
||||||
await storage.updateDoc(getPath.activeDisplay(), {
|
await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch);
|
||||||
activeCampaignId: null,
|
|
||||||
activeEncounterId: null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error deleting encounter:", err);
|
console.error("Error deleting encounter:", err);
|
||||||
@@ -1796,10 +1788,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
|
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
|
||||||
|
|
||||||
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
|
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
|
||||||
await storage.updateDoc(getPath.activeDisplay(), {
|
await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch);
|
||||||
activeCampaignId: null,
|
|
||||||
activeEncounterId: null,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
await storage.updateDoc(getPath.activeDisplay(), {
|
await storage.updateDoc(getPath.activeDisplay(), {
|
||||||
activeCampaignId: campaignId,
|
activeCampaignId: campaignId,
|
||||||
@@ -2042,10 +2031,7 @@ function AdminView({ userId }) {
|
|||||||
const activeDisplay = await storage.getDoc(getPath.activeDisplay());
|
const activeDisplay = await storage.getDoc(getPath.activeDisplay());
|
||||||
|
|
||||||
if (activeDisplay && activeDisplay.activeCampaignId === campaignId) {
|
if (activeDisplay && activeDisplay.activeCampaignId === campaignId) {
|
||||||
await storage.updateDoc(getPath.activeDisplay(), {
|
await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch);
|
||||||
activeCampaignId: null,
|
|
||||||
activeEncounterId: null
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error deleting campaign:", err);
|
console.error("Error deleting campaign:", err);
|
||||||
|
|||||||
+170
-228
@@ -1,24 +1,72 @@
|
|||||||
// Combat.scenario.test.js
|
// Combat.scenario.test.js
|
||||||
// Full combat scenario: campaign -> encounter -> participants -> 100 rounds of
|
// Full combat scenario driven through shared/turn.js directly — NO React.
|
||||||
// damage/heal/conditions/toggle-active/edit/death-save/pause/resume/add/remove.
|
// Same actions + invariants as the old UI version, but pure functions = fast.
|
||||||
// Drives the SAME UI buttons a DM clicks. Failing assertions do NOT abort the run:
|
// Purpose (verbatim from original): exercise full feature surface
|
||||||
// each phase wraps in try/catch, failures collected, final expect reports all.
|
// (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
|
// Two storage docs modeled exactly like App writes:
|
||||||
// long combat, surfacing behavioral bugs characterization tests miss.
|
// - 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';
|
const {
|
||||||
import { screen, fireEvent, waitFor, within } from '@testing-library/react';
|
makeParticipant,
|
||||||
import '@testing-library/jest-dom';
|
buildCharacterParticipant,
|
||||||
import App from '../App';
|
buildMonsterParticipant,
|
||||||
import {
|
startEncounter,
|
||||||
renderApp, createCampaignViaUI, selectCampaignByName,
|
nextTurn,
|
||||||
createEncounterViaUI, selectEncounterByName, addMonsterViaUI, setupReady,
|
togglePause,
|
||||||
fastWaitFor,
|
addParticipant,
|
||||||
} from './testHelpers';
|
addParticipants,
|
||||||
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
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 = [];
|
const RESULTS = [];
|
||||||
function record(phase, fn) {
|
function record(phase, fn) {
|
||||||
@@ -30,214 +78,110 @@ async function recordAsync(phase, fn) {
|
|||||||
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
|
catch (err) { RESULTS.push({ phase, ok: false, err: err.message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function getParticipantForm() {
|
// ---------- UI-equivalent helpers (call shared, no React) ----------
|
||||||
const heading = screen.getByText('Add Participants');
|
|
||||||
let node = heading;
|
// addChar to campaign roster (mirrors CharacterManager.addCharacter)
|
||||||
for (let i = 0; i < 6; i++) {
|
function addCharacterViaUI(name, maxHp, initMod) {
|
||||||
node = node.parentElement;
|
roster.push({ id: `char-${name}`, name, defaultMaxHp: maxHp, defaultInitMod: initMod });
|
||||||
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
|
// add monster to encounter (mirrors ParticipantManager add monster)
|
||||||
// participant list (NOT the CharacterManager roster, which also shows names).
|
function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
|
||||||
// Encounter participant rows render an 'Init:' label; roster rows do not.
|
const { participant } = buildMonsterParticipant({ name, maxHp, initMod, isNpc });
|
||||||
function getParticipantRow(name) {
|
applyEnc(addParticipant(enc, participant).patch);
|
||||||
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}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Character roster (CharacterManager). Assumes campaign selected.
|
// add a campaign character into encounter (mirrors addCharacterParticipant)
|
||||||
async function addCharacterViaUI(name, maxHp, initMod) {
|
function addCharacterParticipant(charName) {
|
||||||
fireEvent.change(document.getElementById('characterName'), { target: { value: name } });
|
const character = roster.find(c => c.name === charName);
|
||||||
fireEvent.change(document.getElementById('defaultMaxHp'), { target: { value: String(maxHp) } });
|
if (!character) throw new Error(`char not found: ${charName}`);
|
||||||
fireEvent.change(document.getElementById('defaultInitMod'), { target: { value: String(initMod) } });
|
if (enc.participants.some(p => p.type === 'character' && p.originalCharacterId === character.id)) {
|
||||||
fireEvent.click(screen.getByRole('button', { name: /^Add Character$/i }));
|
throw new Error(`${charName} already in encounter`); // mirrors App alert-guard
|
||||||
await fastWaitFor(() => {
|
}
|
||||||
const call = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/campaigns/') &&
|
const { participant } = buildCharacterParticipant(character);
|
||||||
Array.isArray(c.data.players) && c.data.players.some(p => p.name === name));
|
applyEnc(addParticipant(enc, participant).patch);
|
||||||
if (!call) throw new Error('char not persisted');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setParticipantType(type) {
|
// add all campaign chars (mirrors Add All button → addParticipants)
|
||||||
// The Type select is inside the Add Participants form.
|
function addAllCharacters() {
|
||||||
const form = getParticipantForm();
|
const toAdd = roster
|
||||||
const selects = form.getAllByRole('combobox');
|
.filter(c => !enc.participants.some(p => p.type === 'character' && p.originalCharacterId === c.id))
|
||||||
// first combobox in the participant form is Type
|
.map(c => buildCharacterParticipant(c).participant);
|
||||||
fireEvent.change(selects[0], { target: { value: type } });
|
applyEnc(addParticipants(enc, toAdd).patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addMonsterParticipant(name, maxHp, initMod, isNpc = false) {
|
// toggles
|
||||||
const form = getParticipantForm();
|
function toggleHidePlayerHpAction() {
|
||||||
setParticipantType('monster');
|
applyDisplay(toggleHidePlayerHp(display.hidePlayerHp).patch);
|
||||||
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 }));
|
function startCombat() {
|
||||||
await fastWaitFor(() => {
|
applyEnc(startEncounter(enc).patch);
|
||||||
const last = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')).slice(-1)[0];
|
applyDisplay(activateDisplay({ campaignId: CAMPAIGN_ID, encounterId: ENCOUNTER_ID }).patch);
|
||||||
if (!last || !last.data.participants?.some(p => p.name === name)) throw new Error('monster not added');
|
}
|
||||||
});
|
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');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addCharacterParticipant(charName) {
|
// per-participant actions
|
||||||
const form = getParticipantForm();
|
function applyDamage(name, amount) {
|
||||||
setParticipantType('character');
|
const p = enc.participants.find(x => x.name === name);
|
||||||
// character select is the 2nd combobox in the form after Type
|
if (!p || p.currentHp <= 0) return; // dead = skip (button hidden in UI)
|
||||||
const charSelect = form.getAllByRole('combobox')[1];
|
applyEnc(combatApplyHpChange(enc, p.id, 'damage', amount).patch);
|
||||||
// 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');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function applyHeal(name, amount) {
|
function applyHeal(name, amount) {
|
||||||
const row = getParticipantRow(name);
|
const p = enc.participants.find(x => x.name === name);
|
||||||
const healBtn = row.queryByTitle('Heal / Revive') || row.queryByTitle('Heal');
|
if (!p) throw new Error(`no heal target: ${name}`);
|
||||||
if (!healBtn) throw new Error(`${name} has no Heal button`);
|
applyEnc(combatApplyHpChange(enc, p.id, 'heal', amount).patch);
|
||||||
fireEvent.change(row.getByLabelText(`HP change for ${name}`), { target: { value: String(amount) } });
|
|
||||||
fireEvent.click(healBtn);
|
|
||||||
}
|
}
|
||||||
function toggleActive(name) {
|
function toggleActive(name) {
|
||||||
const row = getParticipantRow(name);
|
const p = enc.participants.find(x => x.name === name);
|
||||||
const btn = row.queryByTitle('Mark Active') || row.queryByTitle('Mark Inactive');
|
if (!p) throw new Error(`no active target: ${name}`);
|
||||||
if (!btn) throw new Error(`${name} has no active toggle`);
|
applyEnc(combatToggleActive(enc, p.id).patch);
|
||||||
fireEvent.click(btn);
|
|
||||||
}
|
}
|
||||||
function openConditions(name) {
|
function toggleConditionAction(name, label) {
|
||||||
const row = getParticipantRow(name);
|
const p = enc.participants.find(x => x.name === name);
|
||||||
// idempotent: only click Conditions button if panel not already open for this row.
|
if (!p) throw new Error(`no condition target: ${name}`);
|
||||||
// Re-clicking toggles it closed.
|
applyEnc(combatToggleCondition(enc, p.id, label).patch);
|
||||||
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 editParticipant(name, patch) {
|
function editParticipant(name, patch) {
|
||||||
const row = getParticipantRow(name);
|
const p = enc.participants.find(x => x.name === name);
|
||||||
fireEvent.click(row.getByTitle('Edit'));
|
if (!p) throw new Error(`no edit target: ${name}`);
|
||||||
// EditParticipantModal. Scope to the modal via its form inputs.
|
applyEnc(updateParticipant(enc, p.id, patch).patch);
|
||||||
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]) {
|
function removeParticipantByName(name) {
|
||||||
fireEvent.change(inputs[1], { target: { value: String(patch.initiative) } });
|
const p = enc.participants.find(x => x.name === name);
|
||||||
|
if (!p) throw new Error(`no remove target: ${name}`);
|
||||||
|
applyEnc(removeParticipant(enc, p.id).patch);
|
||||||
}
|
}
|
||||||
const saveBtn = modal.querySelector('button[type="submit"]') ||
|
function deathSaveAction(name, saveNum) {
|
||||||
[...modal.querySelectorAll('button')].find(b => /^Save$/i.test(b.textContent.trim()));
|
const p = enc.participants.find(x => x.name === name);
|
||||||
fireEvent.click(saveBtn);
|
if (!p) throw new Error(`no deathsave target: ${name}`);
|
||||||
}
|
applyEnc(combatDeathSave(enc, p.id, saveNum).patch);
|
||||||
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 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// aliases for combat funcs that clash with local helper names
|
||||||
|
const {
|
||||||
|
applyHpChange: combatApplyHpChange,
|
||||||
|
toggleParticipantActive: combatToggleActive,
|
||||||
|
toggleCondition: combatToggleCondition,
|
||||||
|
deathSave: combatDeathSave,
|
||||||
|
} = require('../../shared');
|
||||||
|
|
||||||
// ---------- scenario ----------
|
// ---------- scenario ----------
|
||||||
|
|
||||||
const ROUNDS = 100;
|
test('full 100-round combat scenario (shared, no React)', async () => {
|
||||||
|
|
||||||
test('full 100-round combat scenario', async () => {
|
|
||||||
await setupReady('ScenarioCamp', 'BigBoss');
|
|
||||||
|
|
||||||
// roster
|
// roster
|
||||||
await recordAsync('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
|
await recordAsync('addChar Fighter', () => addCharacterViaUI('Fighter', 30, 2));
|
||||||
await recordAsync('addChar Cleric', () => addCharacterViaUI('Cleric', 24, 1));
|
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('addCharParticipant Rogue', () => addCharacterParticipant('Rogue'));
|
||||||
await recordAsync('addAllChars', () => addAllCharacters());
|
await recordAsync('addAllChars', () => addAllCharacters());
|
||||||
|
|
||||||
// hidden hp toggle
|
// hidden hp toggle (x2 = back to default)
|
||||||
record('toggleHidePlayerHp', () => toggleHidePlayerHp());
|
record('toggleHidePlayerHp', () => toggleHidePlayerHpAction());
|
||||||
record('toggleHidePlayerHp back', () => toggleHidePlayerHp());
|
record('toggleHidePlayerHp back', () => toggleHidePlayerHpAction());
|
||||||
|
|
||||||
await recordAsync('startCombat', () => startCombat());
|
await recordAsync('startCombat', () => startCombat());
|
||||||
|
|
||||||
// 100 rounds of mixed actions
|
// 100 rounds of mixed actions
|
||||||
for (let r = 1; r <= ROUNDS; r++) {
|
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) {
|
if (r % 10 === 0) {
|
||||||
record(`round ${r} rotation-check`, () => {
|
record(`round ${r} rotation-check`, () => {
|
||||||
const enc = currentEncDoc();
|
|
||||||
if (!enc) throw new Error('no encounter doc');
|
|
||||||
const order = enc.turnOrderIds || [];
|
const order = enc.turnOrderIds || [];
|
||||||
const uniq = new Set(order);
|
const uniq = new Set(order);
|
||||||
if (uniq.size !== order.length) {
|
if (uniq.size !== order.length) {
|
||||||
@@ -279,16 +221,20 @@ test('full 100-round combat scenario', async () => {
|
|||||||
if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) {
|
if (enc.currentTurnParticipantId && !order.includes(enc.currentTurnParticipantId)) {
|
||||||
throw new Error(`currentTurn ${enc.currentTurnParticipantId} not in turnOrderIds`);
|
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
|
// damage front monster every other round
|
||||||
if (r % 2 === 0) await recordAsync(`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 % 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'));
|
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) {
|
if (r % 10 === 0) {
|
||||||
await recordAsync(`round ${r} pause`, () => pauseCombat());
|
await recordAsync(`round ${r} pause`, () => pauseCombat());
|
||||||
await recordAsync(`round ${r} addReinforcement`, () =>
|
await recordAsync(`round ${r} addReinforcement`, () =>
|
||||||
@@ -300,17 +246,17 @@ test('full 100-round combat scenario', async () => {
|
|||||||
// edit initiative on Wolf every 13
|
// edit initiative on Wolf every 13
|
||||||
if (r % 13 === 0) record(`round ${r} edit Wolf init`, () => editParticipant('Wolf', { initiative: 15 }));
|
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) {
|
if (r === 25) {
|
||||||
await recordAsync(`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} deathSave1 Rogue`, () => deathSaveAction('Rogue', 1));
|
||||||
record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
record(`round ${r} revive Rogue`, () => applyHeal('Rogue', 22));
|
||||||
}
|
}
|
||||||
if (r === 50) {
|
if (r === 50) {
|
||||||
await recordAsync(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99));
|
await recordAsync(`round ${r} drop Cleric`, () => applyDamage('Cleric', 99));
|
||||||
await recordAsync(`round ${r} deathSave Cleric x2`, async () => {
|
await recordAsync(`round ${r} deathSave Cleric x2`, async () => {
|
||||||
await deathSave('Cleric', 1);
|
deathSaveAction('Cleric', 1);
|
||||||
await deathSave('Cleric', 2);
|
deathSaveAction('Cleric', 2);
|
||||||
});
|
});
|
||||||
record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24));
|
record(`round ${r} revive Cleric`, () => applyHeal('Cleric', 24));
|
||||||
}
|
}
|
||||||
@@ -318,23 +264,12 @@ test('full 100-round combat scenario', async () => {
|
|||||||
// remove a reinforcement late
|
// remove a reinforcement late
|
||||||
if (r === 30) {
|
if (r === 30) {
|
||||||
await recordAsync(`round ${r} pause`, () => pauseCombat());
|
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(`round ${r} resume`, () => resumeCombat());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await recordAsync('endCombat', async () => {
|
await recordAsync('endCombat', () => endCombat());
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------- report ----------
|
// ---------- report ----------
|
||||||
const failed = RESULTS.filter(r => !r.ok);
|
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`);
|
console.error(`\n=== SCENARIO FAILURES (${failed.length}/${RESULTS.length}) ===\n${msg}\n`);
|
||||||
}
|
}
|
||||||
expect(failed).toEqual([]);
|
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();
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user