From 84a8b78acdc239d525c0dc1f428860f5cba01413 Mon Sep 17 00:00:00 2001
From: david raistrick <1108844+keen99@users.noreply.github.com>
Date: Mon, 29 Jun 2026 13:13:46 -0400
Subject: [PATCH] M2: refactor DisplayView to storage adapter (red test first)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
DisplayView missed in original M2 refactor — raw onSnapshot(doc(db,path))
survived. In ws/memory mode db is a stub sentinel, so raw SDK calls crash
('Expected first argument to collection() to be a CollectionReference...').
Reported by human testing player display after start combat.
TDD:
1. RED: DisplayView.characterization.test.js asserts adapter.subscribeDoc
called for campaign + encounter + activeDisplay paths. Adapter recorder
(getAdapterCalls/resetAdapterCalls in firebase.js) instruments subscribe
calls — catches raw-SDK bypass that firebase mock alone cannot (mock db
satisfies raw onSnapshot, hiding the bug).
2. Fix: 2 raw onSnapshot sites in DisplayView -> storage.subscribeDoc.
3. GREEN: 2 new tests pass, 116 total green.
Audit confirmed DisplayView was the ONLY remaining raw SDK site in App.js.
---
src/App.js | 32 ++++---------
src/DisplayView.characterization.test.js | 60 ++++++++++++++++++++++++
src/storage/firebase.js | 9 ++++
3 files changed, 79 insertions(+), 22 deletions(-)
create mode 100644 src/DisplayView.characterization.test.js
diff --git a/src/App.js b/src/App.js
index fe01c58..2c1ae77 100644
--- a/src/App.js
+++ b/src/App.js
@@ -2453,35 +2453,23 @@ function DisplayView() {
setIsLoadingEncounter(true);
setEncounterError(null);
- const campaignDocRef = doc(db, getPath.campaign(activeCampaignId));
- unsubscribeCampaign = onSnapshot(
- campaignDocRef,
- (campSnap) => {
- if (campSnap.exists()) {
- setCampaignBackgroundUrl(campSnap.data().playerDisplayBackgroundUrl || '');
- } else {
- setCampaignBackgroundUrl('');
- }
- },
- (err) => console.error("Error fetching campaign background:", err)
+ unsubscribeCampaign = storage.subscribeDoc(
+ getPath.campaign(activeCampaignId),
+ (camp) => {
+ setCampaignBackgroundUrl((camp && camp.playerDisplayBackgroundUrl) || '');
+ }
);
- const encounterPath = getPath.encounter(activeCampaignId, activeEncounterId);
- unsubscribeEncounter = onSnapshot(
- doc(db, encounterPath),
- (encDocSnap) => {
- if (encDocSnap.exists()) {
- setActiveEncounterData({ id: encDocSnap.id, ...encDocSnap.data() });
+ unsubscribeEncounter = storage.subscribeDoc(
+ getPath.encounter(activeCampaignId, activeEncounterId),
+ (enc) => {
+ if (enc) {
+ setActiveEncounterData({ id: activeEncounterId, ...enc });
} else {
setActiveEncounterData(null);
setEncounterError("Active encounter data not found.");
}
setIsLoadingEncounter(false);
- },
- (err) => {
- console.error("Error fetching active encounter details:", err);
- setEncounterError("Error loading active encounter data.");
- setIsLoadingEncounter(false);
}
);
} else {
diff --git a/src/DisplayView.characterization.test.js b/src/DisplayView.characterization.test.js
new file mode 100644
index 0000000..633edec
--- /dev/null
+++ b/src/DisplayView.characterization.test.js
@@ -0,0 +1,60 @@
+// DisplayView.characterization.test.js
+// Lock DisplayView uses storage adapter (subscribeDoc), NOT raw SDK onSnapshot(doc(db)).
+// Blind spot caught: M2 refactor missed DisplayView; raw SDK + ws stub db = crash.
+// Test asserts adapter recorder shows subscribeDoc calls when player view boots.
+
+import React from 'react';
+import { render, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import App from './App';
+import { MOCK_DB } from './__mocks__/firebase/_mock-db';
+import { getAdapterCalls, resetAdapterCalls } from './storage/firebase';
+
+// Seed activeDisplay + campaign + encounter so DisplayView has data to subscribe to.
+function seedActiveDisplay() {
+ const campaignPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1';
+ const encounterPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/campaigns/c1/encounters/e1';
+ const activeDisplayPath = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
+
+ MOCK_DB.set(campaignPath, { name: 'Camp', playerDisplayBackgroundUrl: '' });
+ MOCK_DB.set(encounterPath, { name: 'Enc', participants: [], isStarted: true });
+ MOCK_DB.set(activeDisplayPath, { activeCampaignId: 'c1', activeEncounterId: 'e1', hidePlayerHp: false });
+}
+
+describe('DisplayView characterization', () => {
+ beforeEach(() => {
+ window.history.replaceState({}, '', '/display');
+ global.alert = jest.fn();
+ window.open = jest.fn();
+ resetAdapterCalls();
+ });
+ afterEach(() => {
+ window.history.replaceState({}, '', '/');
+ });
+
+ test('DisplayView subscribes via adapter.subscribeDoc (not raw SDK)', async () => {
+ seedActiveDisplay();
+ render();
+
+ // wait for DisplayView to mount and attempt subscriptions
+ await waitFor(() => {
+ const subs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc');
+ expect(subs.length).toBeGreaterThanOrEqual(1);
+ }, { timeout: 3000 });
+
+ // must subscribe to campaign doc (for background url) and encounter doc
+ const docSubs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc').map(c => c.path);
+ expect(docSubs.some(p => p.includes('/campaigns/c1'))).toBe(true);
+ expect(docSubs.some(p => p.includes('/encounters/e1'))).toBe(true);
+ });
+
+ test('DisplayView also subscribes to activeDisplay status doc via adapter', async () => {
+ seedActiveDisplay();
+ render();
+
+ await waitFor(() => {
+ const subs = getAdapterCalls().filter(c => c.fn === 'subscribeDoc' && c.path.includes('activeDisplay'));
+ expect(subs.length).toBeGreaterThanOrEqual(1);
+ }, { timeout: 3000 });
+ });
+});
diff --git a/src/storage/firebase.js b/src/storage/firebase.js
index b019a5f..eafcd96 100644
--- a/src/storage/firebase.js
+++ b/src/storage/firebase.js
@@ -15,6 +15,13 @@ import {
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, serverTimestamp,
} from 'firebase/firestore';
+// Adapter call recorder (instrumentation, no behavior change).
+// Tests assert adapter.subscribeDoc called (catches raw-SDK bypass like DisplayView).
+const ADAPTER_CALLS = [];
+function recordAdapterCall(entry) { ADAPTER_CALLS.push({ ...entry, ts: Date.now() }); }
+export function getAdapterCalls() { return [...ADAPTER_CALLS]; }
+export function resetAdapterCalls() { ADAPTER_CALLS.length = 0; }
+
// Path helpers mirror App.js getPath object.
const APP_ID = process.env.REACT_APP_TRACKER_APP_ID || 'ttrpg-initiative-tracker-default';
const PUBLIC_DATA_PATH = `artifacts/${APP_ID}/public/data`;
@@ -112,12 +119,14 @@ export function createFirebaseStorage() {
// Subscribe = onSnapshot. cb fires immediately + on change. Returns unsubscribe.
subscribeDoc(path, cb) {
+ recordAdapterCall({ fn: 'subscribeDoc', path });
return onSnapshot(doc(db, path), (snap) => {
cb(snap.exists() ? { id: snap.id, ...snap.data() } : null);
}, (err) => console.error(`subscribeDoc ${path}:`, err));
},
subscribeCollection(collectionPath, cb, queryConstraints = []) {
+ recordAdapterCall({ fn: 'subscribeCollection', path: collectionPath });
const q = queryConstraints.length > 0
? query(collection(db, collectionPath), ...queryConstraints)
: collection(db, collectionPath);