diff --git a/src/App.characterization.test.js b/src/App.characterization.test.js index 80c538e..edee5a2 100644 --- a/src/App.characterization.test.js +++ b/src/App.characterization.test.js @@ -4,48 +4,30 @@ // Purpose: refactor (path-shape rewrite) must not change these calls. import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { getCalls, MOCK_DB } from './__mocks__/firebase/_mock-db'; +import { renderApp, createCampaignViaUI, selectCampaignByName } from './testHelpers'; -// import AFTER mocks resolve (jest auto-uses __mocks__/firebase/* via moduleNameMapper) -import App from './App'; - -// Helper: find first setDoc call matching path substring. 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)); +} -beforeEach(() => { - // App reads window.location at mount; ensure clean. - window.history.replaceState({}, '', '/'); - window.open = jest.fn(); - global.alert = jest.fn(); -}); - -describe('App -> Firebase characterization: createCampaign', () => { - test('setDoc called with campaign path + correct payload shape', async () => { - render(); - - // Wait past auth (mock fires instantly) and campaign list load. - await waitFor(() => screen.getByText(/Create Campaign/i)); - - fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i })); - - // Modal: name input + create. - await waitFor(() => screen.getByLabelText(/Campaign Name/i)); - fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'Test Campaign' } }); - fireEvent.click(screen.getByRole('button', { name: /^Create$/i })); - - await waitFor(() => { - const call = findCall('setDoc', '/campaigns/'); - expect(call).toBeDefined(); - }); +// ============================================================================ +// CAMPAIGN GROUP +// ============================================================================ +describe('Campaign -> Firebase', () => { + test('createCampaign: setDoc with campaign path + payload', async () => { + await renderApp(); + const id = await createCampaignViaUI('Alpha'); const call = findCall('setDoc', '/campaigns/'); expect(call.path).toMatch(/campaigns\/.+$/); expect(call.data).toMatchObject({ - name: 'Test Campaign', + name: 'Alpha', playerDisplayBackgroundUrl: '', players: [], }); @@ -53,17 +35,108 @@ describe('App -> Firebase characterization: createCampaign', () => { expect(call.data).toHaveProperty('createdAt'); }); - test('campaign path includes APP_ID namespace', async () => { - render(); - await waitFor(() => screen.getByText(/Create Campaign/i)); - fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i })); - await waitFor(() => screen.getByLabelText(/Campaign Name/i)); - fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'NS Test' } }); - fireEvent.click(screen.getByRole('button', { name: /^Create$/i })); - - await waitFor(() => findCall('setDoc', '/campaigns/')); + test('createCampaign: path includes APP_ID namespace', async () => { + await renderApp(); + await createCampaignViaUI('NS Test'); const call = findCall('setDoc', '/campaigns/'); expect(call.path).toContain('artifacts/'); expect(call.path).toContain('/public/data/'); }); + + test('createCampaign: optional background URL stored', async () => { + await renderApp(); + fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i })); + await waitFor(() => screen.getByLabelText(/Campaign Name/i)); + fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: 'With BG' } }); + fireEvent.change(screen.getByLabelText(/Background URL/i), { target: { value: 'https://img.test/bg.png' } }); + fireEvent.click(screen.getByRole('button', { name: /^Create$/i })); + await waitFor(() => findCall('setDoc', '/campaigns/')); + const call = findCall('setDoc', '/campaigns/'); + expect(call.data.playerDisplayBackgroundUrl).toBe('https://img.test/bg.png'); + }); + + test('addCharacter: updateDoc on campaign doc, players array grows', async () => { + await renderApp(); + const cid = await createCampaignViaUI('Roster'); + await selectCampaignByName('Roster'); + + // CharacterManager form + fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Brog' } }); + fireEvent.change(screen.getByLabelText(/Default HP/i), { target: { value: '25' } }); + fireEvent.change(screen.getByLabelText(/Init Mod/i), { target: { value: '3' } }); + fireEvent.click(screen.getByRole('button', { name: /Add Character/i })); + + await waitFor(() => findCall('updateDoc', '/campaigns/')); + const call = findCall('updateDoc', `/campaigns/${cid}`); + expect(call.data.players).toHaveLength(1); + expect(call.data.players[0]).toMatchObject({ + name: 'Brog', + defaultMaxHp: 25, + defaultInitMod: 3, + }); + expect(call.data.players[0]).toHaveProperty('id'); + }); + + test('updateCharacter: updateDoc with updated players array', async () => { + await renderApp(); + const cid = await createCampaignViaUI('EditRoster'); + await selectCampaignByName('EditRoster'); + + // add one first + fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Old Name' } }); + fireEvent.click(screen.getByRole('button', { name: /Add Character/i })); + await waitFor(() => findCall('updateDoc', '/campaigns/')); + + // click edit + const editBtn = await screen.findByRole('button', { name: /Edit character/i }); + fireEvent.click(editBtn); + await waitFor(() => screen.getByDisplayValue('Old Name')); + fireEvent.change(screen.getByDisplayValue('Old Name'), { target: { value: 'New Name' } }); + // Save button is icon-only (no text); submit its form. + const form = screen.getByDisplayValue('New Name').closest('form'); + fireEvent.submit(form); + + await waitFor(() => { + const calls = findCalls('updateDoc', `/campaigns/${cid}`); + const last = calls[calls.length - 1]; + expect(last.data.players[0].name).toBe('New Name'); + }); + }); + + test('deleteCharacter: updateDoc with character removed', async () => { + await renderApp(); + const cid = await createCampaignViaUI('DeleteRoster'); + await selectCampaignByName('DeleteRoster'); + + fireEvent.change(screen.getByPlaceholderText('Character name'), { target: { value: 'Gone' } }); + fireEvent.click(screen.getByRole('button', { name: /Add Character/i })); + await waitFor(() => findCall('updateDoc', '/campaigns/')); + + const delBtn = await screen.findByRole('button', { name: /Delete character/i }); + fireEvent.click(delBtn); + // confirmation modal + fireEvent.click(screen.getByRole('button', { name: /Confirm/i })); + + await waitFor(() => { + const calls = findCalls('updateDoc', `/campaigns/${cid}`); + const last = calls[calls.length - 1]; + expect(last.data.players).toHaveLength(0); + }); + }); + + test('deleteCampaign: deletes encounters batch + campaign doc + activeDisplay null', async () => { + await renderApp(); + const cid = await createCampaignViaUI('Doomed'); + await selectCampaignByName('Doomed'); + + // campaign card delete button has no aria-label; find trash by text via grid + const allDeletes = screen.getAllByText(/Delete/i); + // campaign card Delete is in card grid, last one rendered + fireEvent.click(allDeletes[allDeletes.length - 1]); + fireEvent.click(await screen.findByRole('button', { name: /Confirm/i })); + + await waitFor(() => findCall('deleteDoc', `/campaigns/${cid}`)); + const delCall = findCall('deleteDoc', `/campaigns/${cid}`); + expect(delCall).toBeDefined(); + }); }); diff --git a/src/__mocks__/firebase/firestore.js b/src/__mocks__/firebase/firestore.js index cd62191..a8ff10b 100644 --- a/src/__mocks__/firebase/firestore.js +++ b/src/__mocks__/firebase/firestore.js @@ -39,7 +39,7 @@ export async function addDoc(collRef, data) { MOCK_DB.set(path, clone(data)); return { id, path }; } -export async function writeBatch(db) { +export function writeBatch(db) { const ops = []; return { set: (r, d) => ops.push({ op: 'set', path: r.path, data: clone(d) }), diff --git a/src/testHelpers.js b/src/testHelpers.js new file mode 100644 index 0000000..a84388e --- /dev/null +++ b/src/testHelpers.js @@ -0,0 +1,37 @@ +// test helpers: drive App UI to states. Used across characterization suites. +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import App from './App'; +import { MOCK_DB } from './__mocks__/firebase/_mock-db'; + +// Render app, wait for auth + campaign list. +export async function renderApp() { + window.history.replaceState({}, '', '/'); + global.alert = jest.fn(); + window.open = jest.fn(); + const utils = render(); + await waitFor(() => screen.getByRole('button', { name: /Create Campaign/i })); + return utils; +} + +// Open create-campaign modal, fill name, submit. Returns campaign id from recorded call. +export async function createCampaignViaUI(name = 'Test Campaign') { + fireEvent.click(screen.getByRole('button', { name: /Create Campaign/i })); + await waitFor(() => screen.getByLabelText(/Campaign Name/i)); + fireEvent.change(screen.getByLabelText(/Campaign Name/i), { target: { value: name } }); + fireEvent.click(screen.getByRole('button', { name: /^Create$/i })); + // wait for setDoc recorded + const { getCalls } = require('./__mocks__/firebase/_mock-db'); + await waitFor(() => getCalls().find(c => c.fn === 'setDoc' && c.path.includes('/campaigns/'))); + const call = getCalls().find(c => c.fn === 'setDoc' && c.path.includes('/campaigns/')); + return call.path.split('/').pop(); // campaign id +} + +// Click campaign card by name to select it. Returns selected campaign id. +export async function selectCampaignByName(name) { + const card = await waitFor(() => screen.getByText(name)); + fireEvent.click(card); + await waitFor(() => screen.getByText(/Managing:/i)); +} + +export { MOCK_DB };