Contract rewritten to match main prod truth:
- require id injection in all doc/collection results
- honor setDoc({merge:true}) (main L1624 + 4 activeDisplay sites)
- drop invented bare<->prefixed cross-lookup tests (main never does this)
- add setDoc{merge}, batch set-only/update-only contract coverage
Fix mismatches vs main:
- subscribeCollection hook now forwards queryConstraints (was dropped;
LOG_QUERY sort+limit honored again). Mock onSnapshot honors orderBy+limit.
2 new contract tests prove the chain.
- subscribeDoc/subscribeCollection adapters now forward errCb. App hooks +
DisplayView propagate subscribe errors to UI (match main onSnapshot 3rd arg).
- ws adapter signatures accept queryConstraints + errCb (interface match).
activeDisplay writes match main:
- 5 unguarded sites -> setDoc({merge:true}) (create-if-missing, not updateDoc)
- 3 guarded sites stay updateDoc
Delete memory adapter: third storage system added complexity, zero value.
firebase-mock covers fast tests, ws covers server path. Factory throws on
unknown mode now.
Delete phantom 'Phase A/B' comment in firebase.js header.
Tests updated to assert setDoc{merge} (not updateDoc) for activeDisplay writes.
83/83 green.
140 lines
5.4 KiB
JavaScript
140 lines
5.4 KiB
JavaScript
// 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} patch on activeDisplay/status', async () => {
|
|
await setupWithMonsters();
|
|
await startCombatViaUI();
|
|
// Two switches now (Hide player HP + Hide NPC/monster HP). Scope to player one.
|
|
const playerHpLabel = screen.getByText('Hide player HP');
|
|
const switchBtn = playerHpLabel.parentElement.querySelector('[role="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');
|
|
});
|
|
});
|