Rework backend #1

Merged
robert merged 86 commits from rework-backend into main 2026-07-01 19:29:34 -04:00
3 changed files with 151 additions and 41 deletions
Showing only changes of commit b6555648ee - Show all commits
+113 -40
View File
@@ -4,48 +4,30 @@
// Purpose: refactor (path-shape rewrite) must not change these calls. // Purpose: refactor (path-shape rewrite) must not change these calls.
import React from 'react'; 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 '@testing-library/jest-dom';
import { getCalls, MOCK_DB } from './__mocks__/firebase/_mock-db'; 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) { function findCall(fn, pathSub) {
return getCalls().find(c => c.fn === fn && (pathSub ? c.path.includes(pathSub) : true)); 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. // CAMPAIGN GROUP
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(<App />);
// 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();
});
describe('Campaign -> Firebase', () => {
test('createCampaign: setDoc with campaign path + payload', async () => {
await renderApp();
const id = await createCampaignViaUI('Alpha');
const call = findCall('setDoc', '/campaigns/'); const call = findCall('setDoc', '/campaigns/');
expect(call.path).toMatch(/campaigns\/.+$/); expect(call.path).toMatch(/campaigns\/.+$/);
expect(call.data).toMatchObject({ expect(call.data).toMatchObject({
name: 'Test Campaign', name: 'Alpha',
playerDisplayBackgroundUrl: '', playerDisplayBackgroundUrl: '',
players: [], players: [],
}); });
@@ -53,17 +35,108 @@ describe('App -> Firebase characterization: createCampaign', () => {
expect(call.data).toHaveProperty('createdAt'); expect(call.data).toHaveProperty('createdAt');
}); });
test('campaign path includes APP_ID namespace', async () => { test('createCampaign: path includes APP_ID namespace', async () => {
render(<App />); await renderApp();
await waitFor(() => screen.getByText(/Create Campaign/i)); await createCampaignViaUI('NS Test');
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/'));
const call = findCall('setDoc', '/campaigns/'); const call = findCall('setDoc', '/campaigns/');
expect(call.path).toContain('artifacts/'); expect(call.path).toContain('artifacts/');
expect(call.path).toContain('/public/data/'); 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();
});
}); });
+1 -1
View File
@@ -39,7 +39,7 @@ export async function addDoc(collRef, data) {
MOCK_DB.set(path, clone(data)); MOCK_DB.set(path, clone(data));
return { id, path }; return { id, path };
} }
export async function writeBatch(db) { export function writeBatch(db) {
const ops = []; const ops = [];
return { return {
set: (r, d) => ops.push({ op: 'set', path: r.path, data: clone(d) }), set: (r, d) => ops.push({ op: 'set', path: r.path, data: clone(d) }),
+37
View File
@@ -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(<App />);
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 };