diff --git a/src/Combat.characterization.test.js b/src/Combat.characterization.test.js new file mode 100644 index 0000000..20ba7de --- /dev/null +++ b/src/Combat.characterization.test.js @@ -0,0 +1,137 @@ +// Combat characterization. Lock updateDoc/setDoc patch for combat controls. + +import React from 'react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { getCalls } from './__mocks__/firebase/_mock-db'; +import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers'; + +function findCallsEnc() { + return getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/')); +} +function lastEncCall() { + const calls = findCallsEnc(); + return calls[calls.length - 1]; +} +function findCallActiveDisplay(fn) { + return getCalls().filter(c => c.fn === fn && c.path.includes('activeDisplay/status')); +} + +async function setupWithMonsters(names = ['A', 'B', 'C']) { + await setupReady('CombatCamp', 'CombatEnc'); + for (const n of names) { + await addMonsterViaUI(n, 20, Number(n.charCodeAt(0) % 10)); + } +} + +describe('Combat -> Firebase', () => { + test('startEncounter: updateDoc sets isStarted/round/turn/current', async () => { + await setupWithMonsters(); + await startCombatViaUI(); + const call = lastEncCall(); + expect(call.data).toMatchObject({ + isStarted: true, + isPaused: false, + round: 1, + }); + expect(call.data.currentTurnParticipantId).toBeTruthy(); + expect(call.data.turnOrderIds).toHaveLength(3); + }); + + test('startEncounter: also sets activeDisplay to this encounter', async () => { + await setupWithMonsters(); + await startCombatViaUI(); + const adCalls = findCallActiveDisplay('setDoc'); + const last = adCalls[adCalls.length - 1]; + expect(last.data.activeCampaignId).toBeTruthy(); + expect(last.data.activeEncounterId).toBeTruthy(); + }); + + test('nextTurn: advances currentTurnParticipantId', async () => { + await setupWithMonsters(); + await startCombatViaUI(); + const beforeId = lastEncCall().data.currentTurnParticipantId; + + fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); + await waitFor(() => lastEncCall()?.data?.currentTurnParticipantId !== beforeId); + expect(lastEncCall().data.currentTurnParticipantId).not.toBe(beforeId); + }); + + test('nextTurn wrapping to round 1->2 increments round', async () => { + await setupWithMonsters(['A', 'B']); + await startCombatViaUI(); + + // advance through all participants to wrap + fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); // A->B (or 2nd) + await waitFor(() => lastEncCall()?.data?.currentTurnParticipantId); + fireEvent.click(screen.getByRole('button', { name: /Next Turn/i })); // wrap + await waitFor(() => lastEncCall()?.data?.round === 2); + expect(lastEncCall().data.round).toBe(2); + }); + + test('pause: updateDoc sets isPaused true', async () => { + await setupWithMonsters(); + await startCombatViaUI(); + fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i })); + await waitFor(() => lastEncCall()?.data?.isPaused === true); + expect(lastEncCall().data.isPaused).toBe(true); + }); + + test('resume: updateDoc sets isPaused false + recomputes turnOrder', async () => { + await setupWithMonsters(); + await startCombatViaUI(); + fireEvent.click(screen.getByRole('button', { name: /Pause Combat/i })); + await waitFor(() => lastEncCall()?.data?.isPaused === true); + fireEvent.click(screen.getByRole('button', { name: /Resume Combat/i })); + await waitFor(() => lastEncCall()?.data?.isPaused === false); + const call = lastEncCall(); + expect(call.data.isPaused).toBe(false); + expect(call.data.turnOrderIds).toHaveLength(3); + }); + + test('endEncounter: updateDoc resets all combat state', async () => { + await setupWithMonsters(); + await startCombatViaUI(); + fireEvent.click(screen.getByRole('button', { name: /End Combat/i })); + fireEvent.click(await screen.findByRole('button', { name: /Confirm/i })); + await waitFor(() => lastEncCall()?.data?.isStarted === false); + const call = lastEncCall(); + expect(call.data).toMatchObject({ + isStarted: false, + isPaused: false, + round: 0, + currentTurnParticipantId: null, + turnOrderIds: [], + }); + }); + + test('endEncounter: clears activeDisplay', async () => { + await setupWithMonsters(); + await startCombatViaUI(); + fireEvent.click(screen.getByRole('button', { name: /End Combat/i })); + fireEvent.click(await screen.findByRole('button', { name: /Confirm/i })); + await waitFor(() => { + const adCalls = findCallActiveDisplay('setDoc'); + const last = adCalls[adCalls.length - 1]; + return last && last.data.activeCampaignId === null; + }); + const adCalls = findCallActiveDisplay('setDoc'); + const last = adCalls[adCalls.length - 1]; + expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null }); + }); + + test('toggleHidePlayerHp: setDoc merge on activeDisplay/status', async () => { + await setupWithMonsters(); + await startCombatViaUI(); + const switchBtn = screen.getByRole('switch'); + fireEvent.click(switchBtn); + await waitFor(() => { + const adCalls = findCallActiveDisplay('setDoc'); + const last = adCalls[adCalls.length - 1]; + return last && 'hidePlayerHp' in last.data; + }); + const adCalls = findCallActiveDisplay('setDoc'); + const last = adCalls[adCalls.length - 1]; + expect(last.data).toHaveProperty('hidePlayerHp'); + }); +});