// App.characterization.test.js // Characterize App -> Firebase calls. Lock path + payload shape per action. // Mock SDK, render AdminView, fire action, assert recorded calls. // Purpose: refactor (path-shape rewrite) must not change these calls. import React from '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'; 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)); } // ============================================================================ // 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: 'Alpha', playerDisplayBackgroundUrl: '', players: [], }); expect(call.data).toHaveProperty('ownerId'); expect(call.data).toHaveProperty('createdAt'); }); 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(); }); });