From b6555648eef25f8d8b5c1c3e90afc37fb4fc5f0c Mon Sep 17 00:00:00 2001
From: david raistrick <1108844+keen99@users.noreply.github.com>
Date: Sun, 28 Jun 2026 18:12:27 -0400
Subject: [PATCH] test: campaign characterization (7 tests)
- src/testHelpers.js: renderApp, createCampaignViaUI, selectCampaignByName
- App.characterization.test.js: createCampaign, addCharacter, updateCharacter, deleteCharacter, deleteCampaign + path namespace + bg url
- mock firestore writeBatch sync (was async, app no-await)
Locks path + payload shape per action. Refactor guard.
---
src/App.characterization.test.js | 153 ++++++++++++++++++++--------
src/__mocks__/firebase/firestore.js | 2 +-
src/testHelpers.js | 37 +++++++
3 files changed, 151 insertions(+), 41 deletions(-)
create mode 100644 src/testHelpers.js
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 };