From 31d19c5912e1df71255b3d8531209fa5b2e71541 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:40:38 -0400 Subject: [PATCH] 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. --- src/App.js | 4 --- src/__mocks__/firebase/_mock-db.js | 4 +-- src/setupTests.js | 4 +++ src/tests/Combat.scenario.test.js | 55 +++++++++++++++++------------- src/tests/testHelpers.js | 3 ++ 5 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/App.js b/src/App.js index fac6895..dc4ecaa 100644 --- a/src/App.js +++ b/src/App.js @@ -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); } diff --git a/src/__mocks__/firebase/_mock-db.js b/src/__mocks__/firebase/_mock-db.js index e77be8a..328754a 100644 --- a/src/__mocks__/firebase/_mock-db.js +++ b/src/__mocks__/firebase/_mock-db.js @@ -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'); diff --git a/src/setupTests.js b/src/setupTests.js index b294100..fb7ee76 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -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) { diff --git a/src/tests/Combat.scenario.test.js b/src/tests/Combat.scenario.test.js index e9285aa..86ab336 100644 --- a/src/tests/Combat.scenario.test.js +++ b/src/tests/Combat.scenario.test.js @@ -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 diff --git a/src/tests/testHelpers.js b/src/tests/testHelpers.js index 3282aa2..9de7fdf 100644 --- a/src/tests/testHelpers.js +++ b/src/tests/testHelpers.js @@ -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');