test: kill act env warnings + remove debug console noise

Root cause: mock _notify cb fires during waitFor poll, but testing-library
asyncWrapper flips IS_REACT_ACT_ENVIRONMENT=false. Raw react act() then
warns 'not configured to support act' (146 warnings).

Fix: _notify sets globalThis.IS_REACT_ACT_ENVIRONMENT=true around act(),
restores after. setupTests sets flag globally. Both together = 0 warnings.

Also:
- Remove 4 debug console.log from App.js (encounter start/end, drag, add chars)
- Remove always-fires scenario summary console.log
- Add fastWaitFor helper (5ms poll) to testHelpers
- Swap scenario waitFor -> fastWaitFor

Tests: 77 pass / 0 fail / 0 warnings / 0 console noise.
This commit is contained in:
david raistrick
2026-07-03 15:59:14 -04:00
parent 530aaadf90
commit 31d19c5912
5 changed files with 40 additions and 30 deletions
-4
View File
@@ -928,7 +928,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
await storage.updateDoc(encounterPath, {
participants: [...participants, ...newParticipants]
});
console.log(`Added ${newParticipants.length} characters to the encounter.`);
} catch (err) {
console.error("Error adding all campaign characters:", err);
alert("Failed to add all characters. Please try again.");
@@ -1234,7 +1233,6 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters }) {
const targetItem = currentParticipants[targetIndex];
if (draggedItem.initiative !== targetItem.initiative) {
console.log("Drag-drop only allowed for participants with same initiative.");
setDraggedItemId(null);
return;
}
@@ -1720,7 +1718,6 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
});
console.log("Encounter started and set as active display.");
} catch (err) {
console.error("Error starting encounter:", err);
alert("Failed to start encounter. Please try again.");
@@ -1851,7 +1848,6 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
turnOrderIds: [...(encounter.turnOrderIds || [])],
},
});
console.log("Encounter ended and deactivated from Player Display.");
} catch (err) {
console.error("Error ending encounter:", err);
}
+2 -2
View File
@@ -39,8 +39,8 @@ export const MOCK_DB = {
},
_notify(path) {
// notify exact doc path subscribers (wrapped in act for test isolation).
// Set flag true around cb: RTL asyncWrapper (waitFor) flips it false
// during poll, making raw react act() warn 'not configured'.
// Set flag true around cb: testing-library asyncWrapper (waitFor) flips it
// false during poll, which makes raw react act() warn 'not configured'.
if (state.subscribers.has(path)) state.subscribers.get(path).forEach(cb => {
try {
const { act } = require('react');
+4
View File
@@ -23,6 +23,10 @@ console.warn = (...args) => {
};
import { resetMockDb } from './__mocks__/firebase/_mock-db';
// Tell React this is an act environment. Without it, every act() call
// emits 'current testing environment is not configured to support act'.
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
// polyfill crypto.randomUUID for jsdom (used by generateId in App.js).
if (!global.crypto) global.crypto = {};
if (!global.crypto.randomUUID) {
+31 -24
View File
@@ -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
+3
View File
@@ -4,6 +4,9 @@ import { render, screen, fireEvent, waitFor, within } from '@testing-library/rea
import App from '../App';
import { MOCK_DB } from '../__mocks__/firebase/_mock-db';
// fast waitFor: 5ms poll vs default 50ms. Cuts scenario ~8x.
export const fastWaitFor = (cb, opts) => waitFor(cb, { interval: 5, timeout: 1000, ...opts });
// Scoped container: the "Add Participants" section (avoids label clashes with CharacterManager).
export function getParticipantForm() {
const heading = screen.getByText('Add Participants');