match main firebase behavior; fix contract + mismatches
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.
This commit is contained in:
@@ -41,7 +41,7 @@ describe('Combat -> Firebase', () => {
|
||||
test('startEncounter: also sets activeDisplay to this encounter', async () => {
|
||||
await setupWithMonsters();
|
||||
await startCombatViaUI();
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last.data.activeCampaignId).toBeTruthy();
|
||||
expect(last.data.activeEncounterId).toBeTruthy();
|
||||
@@ -111,16 +111,16 @@ describe('Combat -> Firebase', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||
await waitFor(() => {
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
return last && last.data.activeCampaignId === null;
|
||||
});
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||
});
|
||||
|
||||
test('toggleHidePlayerHp: updateDoc patch on activeDisplay/status', async () => {
|
||||
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.
|
||||
@@ -128,11 +128,11 @@ describe('Combat -> Firebase', () => {
|
||||
const switchBtn = playerHpLabel.parentElement.querySelector('[role="switch"]');
|
||||
fireEvent.click(switchBtn);
|
||||
await waitFor(() => {
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
return last && 'hidePlayerHp' in last.data;
|
||||
});
|
||||
const adCalls = findCallActiveDisplay('updateDoc');
|
||||
const adCalls = findCallActiveDisplay('setDoc');
|
||||
const last = adCalls[adCalls.length - 1];
|
||||
expect(last.data).toHaveProperty('hidePlayerHp');
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ describe('Encounter -> Firebase', () => {
|
||||
expect(call.path).toMatch(/campaigns\/[^/]+\/encounters\//);
|
||||
});
|
||||
|
||||
test('togglePlayerDisplay: updateDoc patch on activeDisplay/status', async () => {
|
||||
test('togglePlayerDisplay: setDoc{merge} patch on activeDisplay/status', async () => {
|
||||
await setupCampaignAndEncounter('Camp D', 'Enc D');
|
||||
await selectEncounterByName('Enc D');
|
||||
|
||||
@@ -50,33 +50,33 @@ describe('Encounter -> Firebase', () => {
|
||||
const eyeBtn = await screen.findByTitle('Activate for Player Display');
|
||||
fireEvent.click(eyeBtn);
|
||||
|
||||
await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
|
||||
const call = findCall('updateDoc', 'activeDisplay/status');
|
||||
// BUG-4 fix: updateDoc patch, not setDoc replace (was clobbering fields)
|
||||
await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
|
||||
const call = findCall('setDoc', 'activeDisplay/status');
|
||||
// main truth: setDoc{merge} patch, not replace (would clobber fields)
|
||||
expect(call.data).toMatchObject({
|
||||
activeCampaignId: expect.any(String),
|
||||
activeEncounterId: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test('togglePlayerDisplay off: updateDoc nulls active ids', async () => {
|
||||
test('togglePlayerDisplay off: setDoc{merge} nulls active ids', async () => {
|
||||
await setupCampaignAndEncounter('Camp O', 'Enc O');
|
||||
await selectEncounterByName('Enc O');
|
||||
|
||||
// turn ON
|
||||
const onBtn = await screen.findByTitle('Activate for Player Display');
|
||||
fireEvent.click(onBtn);
|
||||
await waitFor(() => findCall('updateDoc', 'activeDisplay/status'));
|
||||
await waitFor(() => findCall('setDoc', 'activeDisplay/status'));
|
||||
|
||||
// turn OFF
|
||||
const offBtn = await screen.findByTitle('Deactivate for Player Display');
|
||||
fireEvent.click(offBtn);
|
||||
await waitFor(() => {
|
||||
const calls = findCalls('updateDoc', 'activeDisplay/status');
|
||||
const calls = findCalls('setDoc', 'activeDisplay/status');
|
||||
const last = calls[calls.length - 1];
|
||||
return last.data.activeCampaignId === null;
|
||||
});
|
||||
const calls = findCalls('updateDoc', 'activeDisplay/status');
|
||||
const calls = findCalls('setDoc', 'activeDisplay/status');
|
||||
const last = calls[calls.length - 1];
|
||||
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||
});
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// BUG-4 repro: toggling hidePlayerHp must not clobber activeDisplay doc.
|
||||
// setDoc = replace (contract). {merge:true} arg ignored.
|
||||
// Toggling hide-HP writes {hidePlayerHp:X} alone → activeCampaignId + activeEncounterId → null.
|
||||
// Display reads null → "Game Session Paused". Recover requires re-activating encounter.
|
||||
// Fix: use updateDoc (patch), not setDoc.
|
||||
// setDoc = replace would clobber. setDoc({merge:true}) patches only — matches main.
|
||||
// Toggling hide-HP writes {hidePlayerHp:X} via setDoc{merge} → activeCampaignId + activeEncounterId preserved.
|
||||
// Display keeps reading them → stays active.
|
||||
// Regression: if caller swaps to plain setDoc (no merge) or updateDoc-throws-on-missing, re-breaks.
|
||||
|
||||
import React from 'react';
|
||||
import { render, waitFor, screen, fireEvent } from '@testing-library/react';
|
||||
@@ -50,14 +50,16 @@ describe('BUG-4: hide-player-HP toggle preserves activeDisplay', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const writes = getAdapterCalls().filter(
|
||||
c => c.fn === 'updateDoc' && c.path.includes('activeDisplay/status')
|
||||
c => c.fn === 'setDoc' && c.path.includes('activeDisplay/status')
|
||||
);
|
||||
expect(writes.length).toBeGreaterThan(0);
|
||||
const last = writes[writes.length - 1];
|
||||
// merge flag MUST be present — else plain setDoc clobbers other fields.
|
||||
expect(last.opts && last.opts.merge).toBe(true);
|
||||
// patch must NOT clobber activeCampaignId/activeEncounterId.
|
||||
// BUG: setDoc replace writes only {hidePlayerHp:true} → clobbers.
|
||||
// Fix: updateDoc patch — other fields untouched.
|
||||
expect(last.patch.hidePlayerHp).toBe(true);
|
||||
// Fix: setDoc{merge} patch — other fields untouched.
|
||||
expect(last.data.hidePlayerHp).toBe(true);
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,10 +82,10 @@ describe('Logs -> Firebase', () => {
|
||||
fireEvent.click(undoBtns[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
const und = getCalls().find(c => c.fn === 'updateDoc' && c.path.endsWith(`/logs/${logId}`) && c.data.undone === true);
|
||||
const und = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/logs/') && c.data.undone === true);
|
||||
return und;
|
||||
});
|
||||
const markUndone = getCalls().find(c => c.fn === 'updateDoc' && c.path.endsWith(`/logs/${logId}`));
|
||||
const markUndone = getCalls().find(c => c.fn === 'updateDoc' && c.path.includes('/logs/') && c.data.undone === true);
|
||||
expect(markUndone.data.undone).toBe(true);
|
||||
// encounter path updated with undo payload (any encounter update after undo click)
|
||||
const encUndo = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Lock: storage adapters must use ESM exports (no module.exports).
|
||||
// Regression guard: CJS in src/ crashes CRA prod build (ESM strict).
|
||||
// Bug history: ws.js + memory.js used module.exports. Dev lenient (masked),
|
||||
// Bug history: ws.js + firebase.js used module.exports. Dev lenient (masked),
|
||||
// prod bundle crashed blank page. firebase.js always ESM.
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -8,7 +8,7 @@ import path from 'path';
|
||||
const ADAPTER_DIR = path.join(__dirname, '..', 'storage');
|
||||
|
||||
describe('storage adapters use ESM (no CJS)', () => {
|
||||
const adapters = ['ws.js', 'memory.js', 'firebase.js', 'index.js'];
|
||||
const adapters = ['ws.js', 'firebase.js', 'index.js'];
|
||||
test.each(adapters)('%s has no module.exports', (file) => {
|
||||
const full = fs.readFileSync(path.join(ADAPTER_DIR, file), 'utf8');
|
||||
// strip line comments so words like 'require' in explanatory comments don't trip the guard
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
// Firebase adapter contract test.
|
||||
// Runs the SAME storage contract as memory + ws (src/storage/contract.js)
|
||||
// Runs the SAME storage contract as ws (src/storage/contract.js)
|
||||
// against createFirebaseStorage. The SDK is mocked via src/__mocks__/firebase/*,
|
||||
// so this proves the adapter translates contract calls -> SDK calls correctly
|
||||
// (path handling, merge semantics, subscribe) without hitting network.
|
||||
|
||||
import { initFirebase, createFirebaseStorage } from '../storage/firebase';
|
||||
import { runStorageContract } from '../storage/contract';
|
||||
import { resetMockDb } from '../__mocks__/firebase/_mock-db';
|
||||
import { resetMockDb, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||
import { orderBy, limit } from '../__mocks__/firebase/firestore';
|
||||
|
||||
// Firebase mock DB is shared global state. Reset before each factory so
|
||||
// contract tests are isolated.
|
||||
@@ -16,3 +17,39 @@ runStorageContract('firebase', () => {
|
||||
if (!ok) throw new Error('initFirebase failed under mocked env');
|
||||
return createFirebaseStorage();
|
||||
});
|
||||
|
||||
// Firebase-only: queryConstraints (orderBy + limit) must flow through
|
||||
// subscribeCollection. main App passes LOG_QUERY = [orderBy('timestamp','desc'),
|
||||
// limit(500)] for the combat log. Adapter forwards them to the SDK; this proves
|
||||
// the whole chain sorts + caps, not just dumps raw.
|
||||
describe('firebase adapter: queryConstraints', () => {
|
||||
let storage;
|
||||
beforeEach(() => {
|
||||
resetMockDb();
|
||||
initFirebase();
|
||||
storage = createFirebaseStorage();
|
||||
});
|
||||
|
||||
test('subscribeCollection honors orderBy desc', async () => {
|
||||
MOCK_DB.set('logs/a', { message: 'A', timestamp: 100 });
|
||||
MOCK_DB.set('logs/b', { message: 'B', timestamp: 300 });
|
||||
MOCK_DB.set('logs/c', { message: 'C', timestamp: 200 });
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
storage.subscribeCollection('logs', resolve, [orderBy('timestamp', 'desc')]);
|
||||
});
|
||||
expect(result.map(d => d.message)).toEqual(['B', 'C', 'A']);
|
||||
});
|
||||
|
||||
test('subscribeCollection honors limit', async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
MOCK_DB.set(`logs/l${i}`, { message: `L${i}`, timestamp: i });
|
||||
}
|
||||
|
||||
const result = await new Promise((resolve) => {
|
||||
storage.subscribeCollection('logs', resolve, [orderBy('timestamp', 'desc'), limit(3)]);
|
||||
});
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map(d => d.message)).toEqual(['L4', 'L3', 'L2']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,32 +33,14 @@ describe('getStorageMode', () => {
|
||||
expect(getStorageMode()).toBe('ws');
|
||||
});
|
||||
|
||||
test('returns memory when REACT_APP_STORAGE=memory', () => {
|
||||
process.env.REACT_APP_STORAGE = 'memory';
|
||||
const { getStorageMode } = require('../storage/index');
|
||||
expect(getStorageMode()).toBe('memory');
|
||||
test('throws on unknown mode', () => {
|
||||
process.env.REACT_APP_STORAGE = 'garbage';
|
||||
const { getStorage } = require('../storage/index');
|
||||
expect(() => getStorage()).toThrow(/Unknown REACT_APP_STORAGE/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStorage factory routing', () => {
|
||||
test('memory mode returns memory adapter', () => {
|
||||
process.env.REACT_APP_STORAGE = 'memory';
|
||||
const { getStorage } = require('../storage/index');
|
||||
const s = getStorage();
|
||||
expect(s).toBeTruthy();
|
||||
expect(typeof s.getDoc).toBe('function');
|
||||
expect(typeof s.setDoc).toBe('function');
|
||||
expect(typeof s.subscribeDoc).toBe('function');
|
||||
});
|
||||
|
||||
test('returns singleton on repeat call (same instance)', () => {
|
||||
process.env.REACT_APP_STORAGE = 'memory';
|
||||
const { getStorage } = require('../storage/index');
|
||||
const a = getStorage();
|
||||
const b = getStorage();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
test('ws mode returns ws adapter (init may fail without backend — catch)', () => {
|
||||
process.env.REACT_APP_STORAGE = 'ws';
|
||||
const { getStorage } = require('../storage/index');
|
||||
@@ -68,8 +50,21 @@ describe('getStorage factory routing', () => {
|
||||
expect(typeof s.getDoc).toBe('function');
|
||||
} catch (e) {
|
||||
// ws adapter without backend URL/config is allowed to throw at factory;
|
||||
// routing reached ws branch (not memory/firebase) which is the contract.
|
||||
// routing reached ws branch (not firebase) which is the contract.
|
||||
expect(e).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('returns singleton on repeat call (same instance)', () => {
|
||||
process.env.REACT_APP_STORAGE = 'ws';
|
||||
const { getStorage } = require('../storage/index');
|
||||
let a;
|
||||
try { a = getStorage(); } catch (e) { return; } // no backend ok
|
||||
if (a) {
|
||||
const b = getStorage();
|
||||
expect(a).toBe(b);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// Runner: executes storage contract against each impl.
|
||||
// TDD: contract = spec. Run against memory first. RED until memory.js built.
|
||||
'use strict';
|
||||
|
||||
const { runStorageContract } = require('../storage/contract');
|
||||
const { createMemoryStorage } = require('../storage/memory');
|
||||
|
||||
runStorageContract('memory', () => createMemoryStorage());
|
||||
Reference in New Issue
Block a user