Compare commits
2
Commits
c05a283cf0
...
eef11c3b6e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eef11c3b6e | ||
|
|
2c6dfdafc8 |
+33
-10
@@ -2165,7 +2165,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await startEncounter(encounter, buildCtx(encounterPath));
|
await startEncounter(encounter, buildCtx(encounterPath));
|
||||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true });
|
// Only claim player display if slot is empty or already showing this encounter.
|
||||||
|
// Don't steal display from another live encounter.
|
||||||
|
const displayEmpty = !activeDisplayData || !activeDisplayData.activeEncounterId;
|
||||||
|
const displayIsOurs = activeDisplayData && activeDisplayData.activeCampaignId === campaignId && activeDisplayData.activeEncounterId === encounter.id;
|
||||||
|
if (displayEmpty || displayIsOurs) {
|
||||||
|
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: campaignId, activeEncounterId: encounter.id }, { merge: true });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "Failed to start encounter. Please try again."); }
|
showToast(err.message || "Failed to start encounter. Please try again."); }
|
||||||
};
|
};
|
||||||
@@ -2207,7 +2213,13 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
await endEncounter(encounter, ctx);
|
await endEncounter(encounter, ctx);
|
||||||
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
|
// Only clear display if THIS encounter is the one showing.
|
||||||
|
const displayIsThis = activeDisplayData &&
|
||||||
|
activeDisplayData.activeCampaignId === campaignId &&
|
||||||
|
activeDisplayData.activeEncounterId === encounter.id;
|
||||||
|
if (displayIsThis) {
|
||||||
|
await storage.setDoc(getPath.activeDisplay(), { activeCampaignId: null, activeEncounterId: null }, { merge: true });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast("Failed to end encounter. Please try again."); }
|
showToast("Failed to end encounter. Please try again."); }
|
||||||
|
|
||||||
@@ -2337,7 +2349,7 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
|
|
||||||
const [encounters, setEncounters] = useState([]);
|
const [encounters, setEncounters] = useState([]);
|
||||||
const [selectedEncounterId, setSelectedEncounterId] = useState(() => {
|
const [selectedEncounterId, setSelectedEncounterId] = useState(() => {
|
||||||
try { return localStorage.getItem('ttrpg.selectedEncounter') || null; }
|
try { return localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null; }
|
||||||
catch { return null; }
|
catch { return null; }
|
||||||
});
|
});
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
@@ -2348,6 +2360,17 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
|
|
||||||
const selectedEncounterIdRef = useRef(selectedEncounterId);
|
const selectedEncounterIdRef = useRef(selectedEncounterId);
|
||||||
|
|
||||||
|
// Restore scoped encounter selection when campaign changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!campaignId) { setSelectedEncounterId(null); return; }
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(`ttrpg.selectedEncounter.${campaignId}`) || null;
|
||||||
|
setSelectedEncounterId(saved);
|
||||||
|
} catch {
|
||||||
|
setSelectedEncounterId(null);
|
||||||
|
}
|
||||||
|
}, [campaignId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (encountersData) setEncounters(encountersData);
|
if (encountersData) setEncounters(encountersData);
|
||||||
}, [encountersData]);
|
}, [encountersData]);
|
||||||
@@ -2394,10 +2417,10 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
selectedEncounterIdRef.current = selectedEncounterId;
|
selectedEncounterIdRef.current = selectedEncounterId;
|
||||||
try {
|
try {
|
||||||
if (selectedEncounterId) localStorage.setItem('ttrpg.selectedEncounter', selectedEncounterId);
|
if (selectedEncounterId) localStorage.setItem(`ttrpg.selectedEncounter.${campaignId}`, selectedEncounterId);
|
||||||
else localStorage.removeItem('ttrpg.selectedEncounter');
|
else localStorage.removeItem(`ttrpg.selectedEncounter.${campaignId}`);
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [selectedEncounterId]);
|
}, [selectedEncounterId, campaignId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!campaignId) {
|
if (!campaignId) {
|
||||||
@@ -2740,7 +2763,7 @@ function AdminView({ userId }) {
|
|||||||
// Save scroll on unload + visibility change + interval.
|
// Save scroll on unload + visibility change + interval.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onSave = () => {
|
const onSave = () => {
|
||||||
try { localStorage.setItem('ttrpg.scrollY', String(window.scrollY)); } catch {}
|
try { localStorage.setItem(`ttrpg.scrollY.${selectedCampaignId}`, String(window.scrollY)); } catch {}
|
||||||
};
|
};
|
||||||
window.addEventListener('beforeunload', onSave);
|
window.addEventListener('beforeunload', onSave);
|
||||||
window.addEventListener('pagehide', onSave);
|
window.addEventListener('pagehide', onSave);
|
||||||
@@ -2752,20 +2775,20 @@ function AdminView({ userId }) {
|
|||||||
document.removeEventListener('visibilitychange', onSave);
|
document.removeEventListener('visibilitychange', onSave);
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [selectedCampaignId]);
|
||||||
|
|
||||||
// Restore scroll once data loaded.
|
// Restore scroll once data loaded.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRestoredRef.current) return;
|
if (scrollRestoredRef.current) return;
|
||||||
if (campaignsWithDetails.length === 0) return;
|
if (campaignsWithDetails.length === 0) return;
|
||||||
try {
|
try {
|
||||||
const y = parseInt(localStorage.getItem('ttrpg.scrollY') || '0', 10);
|
const y = parseInt(localStorage.getItem(`ttrpg.scrollY.${selectedCampaignId}`) || '0', 10);
|
||||||
if (y > 0) {
|
if (y > 0) {
|
||||||
scrollRestoredRef.current = true;
|
scrollRestoredRef.current = true;
|
||||||
setTimeout(() => window.scrollTo(0, y), 300);
|
setTimeout(() => window.scrollTo(0, y), 300);
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
}, [campaignsWithDetails]);
|
}, [campaignsWithDetails, selectedCampaignId]);
|
||||||
|
|
||||||
// Persist selections across reload.
|
// Persist selections across reload.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// Display guard: starting encounter does NOT steal display from another live encounter.
|
||||||
|
// Ending encounter does NOT clear display if different encounter showing.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { getCalls, MOCK_DB } from '../__mocks__/firebase/_mock-db';
|
||||||
|
import { setupReady, addMonsterViaUI, startCombatViaUI } from './testHelpers';
|
||||||
|
|
||||||
|
const DISPLAY_PATH = 'artifacts/ttrpg-initiative-tracker-default/public/data/activeDisplay/status';
|
||||||
|
|
||||||
|
function findCallActiveDisplay(fn) {
|
||||||
|
return getCalls().filter(c => c.fn === fn && c.path.includes('activeDisplay/status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Display guard', () => {
|
||||||
|
test('startEncounter: claims display when slot empty', async () => {
|
||||||
|
await setupReady('GuardCamp1', 'GuardEnc1');
|
||||||
|
await addMonsterViaUI('Goblin', 10, 5);
|
||||||
|
await startCombatViaUI();
|
||||||
|
const adCalls = findCallActiveDisplay('setDoc');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
expect(last).toBeTruthy();
|
||||||
|
expect(last.data.activeEncounterId).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startEncounter: does NOT steal display from another live encounter', async () => {
|
||||||
|
// Pre-seed display as busy with a DIFFERENT encounter
|
||||||
|
MOCK_DB.set(DISPLAY_PATH, {
|
||||||
|
activeCampaignId: 'other-camp',
|
||||||
|
activeEncounterId: 'other-enc',
|
||||||
|
});
|
||||||
|
|
||||||
|
await setupReady('GuardCamp2', 'GuardEnc2');
|
||||||
|
await addMonsterViaUI('Orc', 15, 8);
|
||||||
|
await startCombatViaUI();
|
||||||
|
|
||||||
|
// Combat started on encounter doc
|
||||||
|
const encCalls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||||
|
expect(encCalls.some(c => c.data.isStarted === true)).toBe(true);
|
||||||
|
|
||||||
|
// But display setDoc should NOT have been called to claim this encounter
|
||||||
|
const adClaims = findCallActiveDisplay('setDoc').filter(
|
||||||
|
c => c.data && c.data.activeEncounterId && c.data.activeEncounterId !== 'other-enc'
|
||||||
|
);
|
||||||
|
expect(adClaims).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endEncounter: clears display when ending the displayed encounter', async () => {
|
||||||
|
await setupReady('GuardCamp3', 'GuardEnc3');
|
||||||
|
await addMonsterViaUI('Wolf', 8, 3);
|
||||||
|
await startCombatViaUI();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const adCalls = findCallActiveDisplay('setDoc');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
return last && last.data && last.data.activeCampaignId === null;
|
||||||
|
});
|
||||||
|
const adCalls = findCallActiveDisplay('setDoc');
|
||||||
|
const last = adCalls[adCalls.length - 1];
|
||||||
|
expect(last.data).toMatchObject({ activeCampaignId: null, activeEncounterId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('endEncounter: does NOT clear display when different encounter is live', async () => {
|
||||||
|
await setupReady('GuardCamp4', 'GuardEnc4');
|
||||||
|
await addMonsterViaUI('Bat', 4, 2);
|
||||||
|
await startCombatViaUI();
|
||||||
|
|
||||||
|
// Now hijack display to a different encounter (simulating eyeball toggle elsewhere)
|
||||||
|
MOCK_DB.set(DISPLAY_PATH, {
|
||||||
|
activeCampaignId: 'different-camp',
|
||||||
|
activeEncounterId: 'different-enc',
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /End Combat/i }));
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /Confirm/i }));
|
||||||
|
|
||||||
|
// Wait for encounter to end
|
||||||
|
await waitFor(() => {
|
||||||
|
const encCalls = getCalls().filter(c => c.fn === 'updateDoc' && c.path.includes('/encounters/'));
|
||||||
|
const last = encCalls[encCalls.length - 1];
|
||||||
|
return last && last.data.isStarted === false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Display should NOT have been cleared
|
||||||
|
const clearCalls = findCallActiveDisplay('setDoc').filter(
|
||||||
|
c => c.data && c.data.activeCampaignId === null
|
||||||
|
);
|
||||||
|
expect(clearCalls).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// localStorage scoping: selectedEncounter + scrollY keyed per campaign.
|
||||||
|
// Two tabs on different campaigns don't fight over selection/scroll.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { setupReady, addMonsterViaUI } from './testHelpers';
|
||||||
|
|
||||||
|
describe('localStorage campaign scoping', () => {
|
||||||
|
test('selectedEncounter key is scoped (not bare)', async () => {
|
||||||
|
await setupReady('ScopeCamp1', 'ScopeEnc1');
|
||||||
|
// bare key should not exist
|
||||||
|
expect(localStorage.getItem('ttrpg.selectedEncounter')).toBeNull();
|
||||||
|
// scoped key with suffix should exist
|
||||||
|
const scoped = Object.keys(localStorage).find(
|
||||||
|
k => k.startsWith('ttrpg.selectedEncounter.') && k !== 'ttrpg.selectedEncounter'
|
||||||
|
);
|
||||||
|
expect(scoped).toBeTruthy();
|
||||||
|
expect(localStorage.getItem(scoped)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrollY key is scoped (not bare)', async () => {
|
||||||
|
await setupReady('ScopeCamp2', 'ScopeEnc2');
|
||||||
|
await addMonsterViaUI('Goblin', 10, 5);
|
||||||
|
expect(localStorage.getItem('ttrpg.scrollY')).toBeNull();
|
||||||
|
const scoped = Object.keys(localStorage).find(
|
||||||
|
k => k.startsWith('ttrpg.scrollY.') && k !== 'ttrpg.scrollY'
|
||||||
|
);
|
||||||
|
// key may not exist until save fires — trigger by checking bare is null
|
||||||
|
// and at least scoped format is used if any scrollY key present
|
||||||
|
const scrollKeys = Object.keys(localStorage).filter(k => k.startsWith('ttrpg.scrollY'));
|
||||||
|
scrollKeys.forEach(k => expect(k).not.toBe('ttrpg.scrollY'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('global keys still exist (campaign selection, wake lock)', async () => {
|
||||||
|
await setupReady('ScopeCamp3', 'ScopeEnc3');
|
||||||
|
expect(localStorage.getItem('ttrpg.selectedCampaign')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user