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:
david raistrick
2026-07-04 12:22:19 -04:00
parent b12ac904a6
commit 79af61cb8b
17 changed files with 240 additions and 251 deletions
+23 -7
View File
@@ -205,6 +205,11 @@ function useFirestoreDocument(docPath) {
const unsubscribe = storage.subscribeDoc(docPath, (doc) => {
setData(doc);
setIsLoading(false);
}, (err) => {
console.error(`Error fetching document ${docPath}:`, err);
setError(err.message || "Failed to fetch document.");
setIsLoading(false);
setData(null);
});
return () => { if (typeof unsubscribe === 'function') unsubscribe(); };
}, [docPath]);
@@ -233,6 +238,11 @@ function useFirestoreCollection(collectionPath, queryConstraints = []) {
const unsubscribe = storage.subscribeCollection(collectionPath, (items) => {
setData(items);
setIsLoading(false);
}, queryConstraints, (err) => {
console.error(`Error fetching collection ${collectionPath}:`, err);
setError(err.message || "Failed to fetch collection.");
setIsLoading(false);
setData([]);
});
return () => { if (typeof unsubscribe === 'function') unsubscribe(); };
// queryString, not array ref
@@ -1496,7 +1506,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const handleToggleHidePlayerHp = async () => {
if (!db) return;
try {
await storage.updateDoc(getPath.activeDisplay(), combatToggleHidePlayerHp(hidePlayerHp).patch);
await storage.setDoc(getPath.activeDisplay(), combatToggleHidePlayerHp(hidePlayerHp).patch, { merge: true });
} catch (err) {
console.error("Error toggling hidePlayerHp:", err);
}
@@ -1505,7 +1515,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
const handleToggleHideNpcHp = async () => {
if (!db) return;
try {
await storage.updateDoc(getPath.activeDisplay(), { hideNpcHp: !hideNpcHp });
await storage.setDoc(getPath.activeDisplay(), { hideNpcHp: !hideNpcHp }, { merge: true });
} catch (err) {
console.error("Error toggling hideNpcHp:", err);
}
@@ -1520,7 +1530,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
try {
const { patch, log } = startEncounter(encounter);
await storage.updateDoc(encounterPath, patch);
await storage.updateDoc(getPath.activeDisplay(), activateDisplay({ campaignId, encounterId: encounter.id }).patch);
await storage.setDoc(getPath.activeDisplay(), activateDisplay({ campaignId, encounterId: encounter.id }).patch, { merge: true });
logAction(log.message, { encounterName: encounter.name }, {
encounterPath,
updates: log.undo,
@@ -1573,7 +1583,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
try {
const { patch, log } = endEncounter(encounter);
await storage.updateDoc(encounterPath, patch);
await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch);
await storage.setDoc(getPath.activeDisplay(), clearDisplay().patch, { merge: true });
logAction(log.message, { encounterName: encounter.name }, {
encounterPath,
updates: log.undo,
@@ -1788,9 +1798,9 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
const currentActiveEncounter = activeDisplayInfo?.activeEncounterId;
if (currentActiveCampaign === campaignId && currentActiveEncounter === encounterId) {
await storage.updateDoc(getPath.activeDisplay(), clearDisplay().patch);
await storage.setDoc(getPath.activeDisplay(), clearDisplay().patch, { merge: true });
} else {
await storage.updateDoc(getPath.activeDisplay(), {
await storage.setDoc(getPath.activeDisplay(), {
activeCampaignId: campaignId,
activeEncounterId: encounterId,
});
@@ -2265,7 +2275,8 @@ function DisplayView() {
getPath.campaign(activeCampaignId),
(camp) => {
setCampaignBackgroundUrl((camp && camp.playerDisplayBackgroundUrl) || '');
}
},
(err) => console.error("Error fetching campaign background:", err)
);
unsubscribeEncounter = storage.subscribeDoc(
@@ -2278,6 +2289,11 @@ function DisplayView() {
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 {