Files
ttrpg-initiative-tracker/src/tests/Encounter.characterization.test.js
T
david raistrick c0998da0a7 fix(BUG-4): updateDoc patch on activeDisplay (not setDoc replace)
All 5 storage.setDoc(activeDisplay, {...}, {merge:true}) →
storage.updateDoc(activeDisplay, {...}).

setDoc merge:true worked in prod (firebase honors merge) but ws adapter
+ mock ignore opts arg entirely → clobbers doc. updateDoc uses PATCH
across all adapters (firebase real updateDoc, ws PATCH endpoint, mock
merge). Consistent, no clobber.

Sites fixed:
- hidePlayerHp toggle
- startEncounter (set active ids)
- endEncounter (null active ids)
- deactivate active display
- activate new display

TDD: HideHpToggle.test RED first (assert updateDoc patch, impl still
setDoc → 0 calls found). GREEN after switch.

Char tests updated: Encounter.characterization (2) + Combat.characterization
(2) assert updateDoc on activeDisplay, not setDoc.

BUG-4: prod was already fixed (merge:true), test was RED due to mock
ignoring opts. Now all 3 adapters consistent via updateDoc.
2026-07-01 23:32:23 -04:00

123 lines
4.7 KiB
JavaScript

// Encounter characterization. Lock setDoc path + payload on encounter actions.
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 { renderApp, createCampaignViaUI, selectCampaignByName, createEncounterViaUI, selectEncounterByName } from './testHelpers';
function findCall(fn, pathSub) {
return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
}
function findCalls(fn, pathSub) {
return getCalls().filter(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true));
}
async function setupCampaignAndEncounter(campName, encName) {
await renderApp();
await createCampaignViaUI(campName);
await selectCampaignByName(campName);
await createEncounterViaUI(encName);
}
describe('Encounter -> Firebase', () => {
test('createEncounter: setDoc with encounter path + payload', async () => {
await setupCampaignAndEncounter('Camp E', 'Boss Fight');
const call = findCall('setDoc', '/encounters/');
expect(call.path).toMatch(/encounters\/.+$/);
expect(call.data).toMatchObject({
name: 'Boss Fight',
participants: [],
round: 0,
currentTurnParticipantId: null,
isStarted: false,
isPaused: false,
});
expect(call.data).toHaveProperty('createdAt');
});
test('createEncounter: path nested under campaign', async () => {
await setupCampaignAndEncounter('Camp N', 'Enc N');
const call = findCall('setDoc', '/encounters/');
expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//);
});
test('togglePlayerDisplay: updateDoc patch on activeDisplay/status', async () => {
await setupCampaignAndEncounter('Camp D', 'Enc D');
await selectEncounterByName('Enc D');
// Eye button (icon-only, title attr)
const eyeBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(eyeBtn);
await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
const call = findCall('updateDoc', 'activeDisplay/status');
// BUG-4 fix: updateDoc patch, not setDoc replace (was clobbering fields)
expect(call.data).toMatchObject({
activeCampaignId: expect.any(String),
activeEncounterId: expect.any(String),
});
});
test('togglePlayerDisplay off: updateDoc nulls active ids', async () => {
await setupCampaignAndEncounter('Camp O', 'Enc O');
await selectEncounterByName('Enc O');
// turn ON
const onBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(onBtn);
await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
// turn OFF
const offBtn = await screen.findByTitle('Deactivate for Player Display');
fireEvent.click(offBtn);
await waitFor(() => {
const calls = findCalls('updateDoc', 'activeDisplay/status');
const last = calls[calls.length - 1];
return last.data.activeCampaignId === null;
});
const calls = findCalls('updateDoc', 'activeDisplay/status');
const last = calls[calls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
});
test('deleteEncounter: deleteDoc on encounter path', async () => {
await setupCampaignAndEncounter('Camp X', 'Enc X');
await selectEncounterByName('Enc X');
// trash icon on encounter card
const trashBtn = screen.getAllByTitle('Delete Encounter')[0];
fireEvent.click(trashBtn);
// confirm modal
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => findCall('deleteDoc', '/encounters/'));
const del = findCall('deleteDoc', '/encounters/');
expect(del.path).toMatch(/campaigns\/[^/]+\/encounters\//);
});
test('deleteEncounter clears activeDisplay if it was active', async () => {
await setupCampaignAndEncounter('Camp A', 'Enc A');
await selectEncounterByName('Enc A');
// activate display first
const onBtn = await screen.findByTitle('Activate for Player Display');
fireEvent.click(onBtn);
await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
// delete the active encounter
const trashBtn = screen.getAllByTitle('Delete Encounter')[0];
fireEvent.click(trashBtn);
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
await waitFor(() => {
const adCalls = findCalls('updateDoc', 'activeDisplay/status');
const last = adCalls[adCalls.length - 1];
return last.data.activeEncounterId === null;
});
const adCalls = findCalls('updateDoc', 'activeDisplay/status');
const last = adCalls[adCalls.length - 1];
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
});
});