Merge pull request 'Tablet controls, participant action states, and Android shortcut fixes' (#9) from chore/test-and-cleanup into main

Reviewed-on: #9
This commit was merged in pull request #9.
This commit is contained in:
2026-07-15 15:08:22 -04:00
18 changed files with 334 additions and 82 deletions
+1
View File
@@ -1,3 +1,4 @@
TODO.md
node_modules
**/node_modules
.git
+18 -11
View File
@@ -2,44 +2,51 @@
Backlog of bugs + long-term items. Milestones live in REWORK_PLAN.md.
## Open
x caff doent work on fron tpage or big tablet
x max/caff need to float or at least be availabe in combagt popout
x save needs bigger save button on char editor
x fullscreen and dont lock on main app dm view and the no-game-player view ...and doesnt actually prevent lock on android
also better vert tab layout - labelt friendly
x also better vert tab layout - labelt friendly
x needs AC for players dude
x and quick entry hp
hp do not carry from encounter to ecnounter!!!
x hp do not carry from encounter to ecnounter!!!
^feature to add back to campaign character after encounter?
maybe campaign toggle in charc section (choosing each end is DM overload will be missed forgotten done wront)
hp wont go over max and no temp hp support
x refresh/reload/code update causes UI to update to top unselected cambpaign have to drill all the way back down to active encounter....every time. siemtmes?
x encounters cards need clear "in progress"...
I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived
x I wonder...if a charcter-list-character should have a isNPC option vailble to them - for longer lived
npcs.....
x hp wont go over max and no temp hp support
## monsters per campaign and npcs
## or/and ....copy from encoubnter?
### dm list - keep active particpant in view (scroll)
not sure good way to do this
### npm install warnings cleanup pass
lots of updates
## monsters per campaign and npcs
## or/and ....copy from encoubnter?
+9 -11
View File
@@ -1,27 +1,25 @@
{
"id": "/display",
"short_name": "TTRPG Display",
"name": "TTRPG Initiative Tracker Player Display",
"name": "TTRPG Initiative Tracker - Player Display",
"icons": [
{
"src": "favicon.ico",
"src": "player-favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"src": "player-logo192.png",
"note": "this image intentionally missing from filesystem - keeping manfiest and having broken icon allows android to _shortcut_ which user can control name of.",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": "/display",
"scope": "/",
"display": "standalone",
"orientation": "landscape",
"scope": "/display",
"note:" "none of the display stuff actually works, even when it properly installs a pwa (which user cant control name of...)",
"display": "fullscreen",
"orientation": "portrait",
"theme_color": "#1A202C",
"background_color": "#1A202C"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+7 -7
View File
@@ -1,4 +1,5 @@
{
"id": "/",
"short_name": "TTRPG Tracker",
"name": "TTRPG Initiative Tracker",
"icons": [
@@ -9,17 +10,16 @@
},
{
"src": "logo192.png",
"note": "this image intentionally missing from filesystem - keeping manfiest and having broken icon allows android to _shortcut_ which user can control name of.",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"start_url": "/",
"scope": "/",
"note:" "none of the display stuff actually works, even when it properly installs a pwa (which user cant control name of...)",
"display": "fullscreen",
"orientation": "portrait",
"theme_color": "#2D3748",
"background_color": "#1A202C"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+18 -9
View File
@@ -1,21 +1,30 @@
// STATIC GUARD: prod source must pass eslint with zero errors/warnings.
// Scans App.js + storage adapters + shared modules. Catches unused imports,
// Scans ALL .js/.jsx in src/ + shared/ (excl tests/mocks). Catches unused imports,
// dead code, undefined vars before they hit the browser console.
// Run via: npx eslint <files> --format json -> parse -> fail on any problem.
const { execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const ROOT = path.resolve(__dirname, '..', '..');
function walkJs(dir) {
let out = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'tests' || entry.name === '__mocks__') continue;
out = out.concat(walkJs(full));
} else if (/\.(js|jsx)$/.test(entry.name)) {
out.push(full);
}
}
return out;
}
const TARGETS = [
'src/App.js',
'src/storage/contract.js',
'src/storage/firebase.js',
'src/storage/index.js',
'src/storage/server.js',
'shared/index.js',
'shared/logEvent.js',
'shared/turn.js',
...walkJs(path.join(ROOT, 'src')),
...walkJs(path.join(ROOT, 'shared')),
];
function runEslint(files) {
+118 -31
View File
@@ -8,7 +8,7 @@ import {
UserCheck, UserX, HeartCrack, HeartPulse, Zap, EyeOff, ExternalLink, AlertTriangle,
Play as PlayIcon, Pause as PauseIcon, SkipForward as SkipForwardIcon,
StopCircle as StopCircleIcon, Users2, Dices, ChevronDown, ScrollText,
Maximize2, Minimize2, Moon, Coffee, Clock, ChevronRight, X,
Maximize2, Minimize2, PanelTopOpen, PanelTopClose, Moon, Coffee, Clock, ChevronRight, X,
Undo2, Redo2, Crosshair
} from 'lucide-react';
@@ -1051,8 +1051,12 @@ function CharacterManager({ campaignId, campaignCharacters, syncCharacters }) {
/>
NPC
</label>
<button type="submit" className="p-1 text-green-400 hover:text-green-300">
<Save size={18} />
<button
type="submit"
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md bg-green-700 hover:bg-green-600 text-white font-semibold text-sm min-h-[40px]"
aria-label="Save character"
>
<Save size={18} /> Save
</button>
<button
type="button"
@@ -1763,7 +1767,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
const isGeneric = (encounter.ruleset || '5e') === 'generic';
const hasDeathSaves = !isGeneric && (p.type === 'character' || p.type === 'npc');
let bgColor = p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]';
let bgColor = !p.isActive ? 'bg-stone-800' : (p.type === 'character' ? 'bg-indigo-950' : 'bg-[#8e351c]');
if (isCurrentTurn && !encounter.isPaused) bgColor = 'bg-green-600';
const participantStatus = p.status || (isGeneric
@@ -1781,7 +1785,7 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
onDragOver={isDraggable ? handleDragOver : undefined}
onDrop={isDraggable ? (e) => handleDrop(e, p.id) : undefined}
onDragEnd={() => setDraggedItemId(null)}
className={`relative p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'opacity-35 grayscale' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} ${editingId === p.id ? 'ring-2 ring-amber-400' : ''}`}
className={`relative p-3 rounded-md flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 transition-all duration-300 ${bgColor} ${isCurrentTurn && !encounter.isPaused ? 'ring-2 ring-green-300 shadow-lg' : ''} ${!p.isActive ? 'ring-2 ring-stone-500' : (isDead ? 'opacity-90 brightness-75 saturate-125' : '')} ${isDraggable ? 'cursor-grab' : ''} ${draggedItemId === p.id ? 'opacity-50 ring-2 ring-yellow-400' : ''} ${editingId === p.id ? 'ring-2 ring-amber-400' : ''}`}
>
{editingId === p.id && (
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
@@ -2062,22 +2066,28 @@ function ParticipantManager({ encounter, encounterPath, campaignCharacters, camp
)}
<button
onClick={() => toggleParticipantActive(p.id)}
className={`p-1 rounded transition-colors ${p.isActive ? 'text-yellow-400 hover:text-yellow-300' : 'text-stone-400 hover:text-stone-300'} bg-stone-700 hover:bg-stone-600`}
title={p.isActive ? "Mark Inactive" : "Mark Active"}
className={`p-1 rounded transition-all ring-2 ${p.isActive ? 'bg-emerald-700 hover:bg-emerald-600 text-white ring-emerald-300' : 'bg-red-900 hover:bg-red-800 text-red-100 ring-red-500'}`}
title={p.isActive ? "Participant active — click to disable" : "Participant inactive — click to enable"}
aria-label={p.isActive ? "Disable participant" : "Enable participant"}
aria-pressed={p.isActive}
>
{p.isActive ? <UserCheck size={18} /> : <UserX size={18} />}
</button>
<button
onClick={() => setOpenConditionsId(openConditionsId === p.id ? null : p.id)}
className={`p-1 rounded transition-colors bg-stone-700 hover:bg-stone-600 ${openConditionsId === p.id || (p.conditions || []).length > 0 ? 'text-purple-400 hover:text-purple-300' : 'text-stone-400 hover:text-stone-300'}`}
title="Conditions"
className={`p-1 rounded transition-all ${openConditionsId === p.id ? 'bg-purple-700 text-white ring-2 ring-purple-300' : (p.conditions || []).length > 0 ? 'bg-stone-700 text-purple-300 ring-1 ring-purple-500 hover:bg-stone-600' : 'bg-stone-700 text-stone-300 hover:bg-stone-600'}`}
title={openConditionsId === p.id ? 'Conditions open — click to close' : 'Open conditions'}
aria-label={openConditionsId === p.id ? 'Close conditions' : 'Open conditions'}
aria-expanded={openConditionsId === p.id}
>
<span className="text-base leading-none"></span>
</button>
<button
onClick={() => setEditingParticipant(p)}
className="p-1 rounded transition-colors text-yellow-400 hover:text-yellow-300 bg-stone-700 hover:bg-stone-600"
title="Edit"
onClick={() => setEditingParticipant(editingParticipant?.id === p.id ? null : p)}
className={`p-1 rounded transition-all ${editingParticipant?.id === p.id ? 'bg-amber-500 text-stone-950 ring-2 ring-amber-200' : 'text-yellow-300 bg-stone-700 hover:bg-stone-600'}`}
title={editingParticipant?.id === p.id ? 'Editing — click to close' : 'Edit participant'}
aria-label={editingParticipant?.id === p.id ? 'Close participant editor' : 'Edit participant'}
aria-pressed={editingParticipant?.id === p.id}
>
<Edit3 size={18} />
</button>
@@ -2402,7 +2412,7 @@ function InitiativeControls({ campaignId, encounter, encounterPath }) {
// ENCOUNTER MANAGER COMPONENT
// ============================================================================
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef }) {
function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharacters, encounterStartedRef, encounterActiveRef, wakeLockEnabled, onToggleWakeLock, isBrowserFullscreen, onToggleBrowserFullscreen }) {
const { showToast } = useUIFeedback();
const { data: encountersData, isLoading: isLoadingEncounters } = useFirestoreCollection(
campaignId ? getPath.encounters(campaignId) : null
@@ -2708,15 +2718,34 @@ function EncounterManager({ campaignId, initialActiveEncounterId, campaignCharac
{selectedEncounter && (
<div className={`mt-3 md:mt-6 p-2 md:p-4 bg-stone-900 rounded-lg shadow-inner ${encounterFullscreen ? 'fixed inset-0 z-50 m-0 rounded-none overflow-y-auto' : ''}`}>
<h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide flex items-center justify-between">
<span>Managing Encounter: {selectedEncounter.name}</span>
<h3 className="text-xl font-semibold text-amber-300 mb-3 font-cinzel tracking-wide flex items-center justify-between gap-2">
<span className="min-w-0 truncate">Managing Encounter: {selectedEncounter.name}</span>
<span className="flex items-center gap-1 flex-shrink-0">
<button
onClick={onToggleWakeLock}
className={`p-1.5 rounded-md transition-colors ${wakeLockEnabled ? 'bg-amber-600 text-white hover:bg-amber-700' : 'bg-stone-700 text-stone-300 hover:bg-stone-600'}`}
title={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
aria-label={wakeLockEnabled ? 'Allow sleep' : 'Prevent sleep'}
>
{wakeLockEnabled ? <Coffee size={18} /> : <Moon size={18} />}
</button>
<button
onClick={onToggleBrowserFullscreen}
className="p-1.5 rounded-md text-stone-300 bg-stone-700 hover:bg-stone-600 transition-colors"
title={isBrowserFullscreen ? 'Exit browser fullscreen' : 'Enter browser fullscreen'}
aria-label={isBrowserFullscreen ? 'Exit browser fullscreen' : 'Enter browser fullscreen'}
>
{isBrowserFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>
<button
onClick={() => setEncounterFullscreen(v => !v)}
className="ml-2 p-1.5 rounded-md text-stone-300 bg-stone-700 hover:bg-stone-600 transition-colors flex-shrink-0"
title={encounterFullscreen ? 'Exit fullscreen encounter' : 'Fullscreen encounter'}
className="p-1.5 rounded-md text-stone-300 bg-stone-700 hover:bg-stone-600 transition-colors"
title={encounterFullscreen ? 'Exit encounter popout' : 'Expand encounter to full page'}
aria-label={encounterFullscreen ? 'Exit encounter popout' : 'Expand encounter to full page'}
>
{encounterFullscreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
{encounterFullscreen ? <PanelTopClose size={18} /> : <PanelTopOpen size={18} />}
</button>
</span>
</h3>
<div className="flex flex-row gap-4">
{/* Combat Controls - always left */}
@@ -2770,21 +2799,43 @@ function AdminView({ userId }) {
catch { return false; }
});
const wakeLockRef = useRef(null);
const fullscreenWantedRef = useRef(false);
const toggleFullscreen = () => {
const toggleFullscreen = async () => {
try {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
fullscreenWantedRef.current = true;
await document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
fullscreenWantedRef.current = false;
await document.exitFullscreen();
}
} catch (e) {
showToast('Fullscreen failed. Tap fullscreen again.');
}
};
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
const onFsChange = () => {
const active = !!document.fullscreenElement;
setIsFullscreen(active);
if (active) fullscreenWantedRef.current = true;
else if (document.visibilityState === 'visible') fullscreenWantedRef.current = false;
};
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
useEffect(() => {
const restoreFullscreen = async () => {
if (document.visibilityState === 'visible' && fullscreenWantedRef.current && !document.fullscreenElement) {
try { await document.documentElement.requestFullscreen(); } catch (e) { /* browser may require tap */ }
}
};
document.addEventListener('visibilitychange', restoreFullscreen);
return () => document.removeEventListener('visibilitychange', restoreFullscreen);
}, []);
useEffect(() => {
if (!wakeLockEnabled) {
wakeLockRef.current?.release();
@@ -2792,8 +2843,13 @@ function AdminView({ userId }) {
return;
}
const acquire = async () => {
try { wakeLockRef.current = await navigator.wakeLock.request('screen'); }
catch (e) {
if (document.visibilityState !== 'visible' || wakeLockRef.current?.released === false) return;
try {
if (!navigator.wakeLock?.request) throw new Error('Wake Lock API unavailable');
const sentinel = await navigator.wakeLock.request('screen');
wakeLockRef.current = sentinel;
sentinel.addEventListener?.('release', () => { wakeLockRef.current = null; });
} catch (e) {
console.error('Wake lock failed:', e);
showToast('Prevent Sleep failed. Requires HTTPS or Chrome flag: chrome://flags/#unsafely-treat-insecure-origin-as-secure');
}
@@ -3250,6 +3306,10 @@ function AdminView({ userId }) {
campaignCharacters={selectedCampaign.characters || []}
encounterStartedRef={encounterStartedRef}
encounterActiveRef={encounterActiveRef}
wakeLockEnabled={wakeLockEnabled}
onToggleWakeLock={() => setWakeLockEnabled(v => { localStorage.setItem('ttrpg.wakeLock', String(!v)); return !v; })}
isBrowserFullscreen={isFullscreen}
onToggleBrowserFullscreen={toggleFullscreen}
/>
</div>
)}
@@ -3350,6 +3410,7 @@ function DisplayView() {
});
const [displayParticipants, setDisplayParticipants] = useState([]);
const wakeLockRef = useRef(null);
const fullscreenWantedRef = useRef(false);
const currentParticipantRef = useRef(null);
// Player display transition state. Active participants render normally.
@@ -3380,19 +3441,40 @@ function DisplayView() {
}, []);
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
const onFsChange = () => {
const active = !!document.fullscreenElement;
setIsFullscreen(active);
if (active) fullscreenWantedRef.current = true;
else if (document.visibilityState === 'visible') fullscreenWantedRef.current = false;
};
document.addEventListener('fullscreenchange', onFsChange);
return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);
const toggleFullscreen = () => {
const toggleFullscreen = async () => {
try {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
fullscreenWantedRef.current = true;
await document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
fullscreenWantedRef.current = false;
await document.exitFullscreen();
}
} catch (e) {
console.error('Fullscreen failed:', e);
}
};
useEffect(() => {
const restoreFullscreen = async () => {
if (document.visibilityState === 'visible' && fullscreenWantedRef.current && !document.fullscreenElement) {
try { await document.documentElement.requestFullscreen(); } catch (e) { /* browser may require tap */ }
}
};
document.addEventListener('visibilitychange', restoreFullscreen);
return () => document.removeEventListener('visibilitychange', restoreFullscreen);
}, []);
useEffect(() => {
if (!wakeLockEnabled) {
wakeLockRef.current?.release();
@@ -3401,8 +3483,12 @@ function DisplayView() {
}
const acquire = async () => {
if (document.visibilityState !== 'visible' || wakeLockRef.current?.released === false) return;
try {
wakeLockRef.current = await navigator.wakeLock.request('screen');
if (!navigator.wakeLock?.request) throw new Error('Wake Lock API unavailable');
const sentinel = await navigator.wakeLock.request('screen');
wakeLockRef.current = sentinel;
sentinel.addEventListener?.('release', () => { wakeLockRef.current = null; });
} catch (e) {
console.error('Wake lock failed:', e);
}
@@ -3410,10 +3496,9 @@ function DisplayView() {
acquire();
// Re-acquire after tab becomes visible again (browser auto-releases on hide)
// Re-acquire after tab becomes visible again (browser auto-releases on hide).
const onVisChange = () => { if (document.visibilityState === 'visible') acquire(); };
document.addEventListener('visibilitychange', onVisChange);
// Re-acquire on fullscreen change (Android discards wakeLock on screen off)
const onFsChange = () => { if (document.fullscreenElement) acquire(); };
document.addEventListener('fullscreenchange', onFsChange);
return () => {
@@ -4057,6 +4142,8 @@ function App() {
// swap manifest so Android home-screen installs launch /display
const link = document.querySelector('link[rel="manifest"]');
if (link) link.href = `${process.env.PUBLIC_URL || ''}/display-manifest.json`;
const favicon = document.querySelector('link[rel="icon"]');
if (favicon) favicon.href = `${process.env.PUBLIC_URL || ''}/player-favicon.ico`;
}
if (window.location.pathname === '/logs') {
setIsLogsMode(true);
@@ -39,6 +39,10 @@ function participant(status) {
describe('DisplayView characterization', () => {
beforeEach(() => {
const favicon = document.createElement('link');
favicon.rel = 'icon';
favicon.href = '/favicon.ico';
document.head.appendChild(favicon);
window.history.replaceState({}, '', '/display');
global.alert = jest.fn();
window.open = jest.fn();
@@ -46,9 +50,18 @@ describe('DisplayView characterization', () => {
Element.prototype.scrollIntoView = jest.fn();
});
afterEach(() => {
document.querySelectorAll('link[rel="icon"]').forEach(link => link.remove());
window.history.replaceState({}, '', '/');
});
test('display route swaps browser favicon to player icon', async () => {
seedActiveDisplay();
render(<App />);
await waitFor(() => {
expect(document.querySelector('link[rel="icon"]')).toHaveAttribute('href', '/player-favicon.ico');
});
});
test('DisplayView subscribes via adapter.subscribeDoc (not raw SDK)', async () => {
seedActiveDisplay();
render(<App />);
@@ -70,7 +70,7 @@ describe('Participant -> Firebase', () => {
test('toggleActive: updateDoc flips isActive', async () => {
await setupReady();
await addMonsterViaUI('Toggle', 10, 0);
fireEvent.click(screen.getByTitle('Mark Inactive'));
fireEvent.click(screen.getByRole('button', { name: 'Disable participant' }));
await waitFor(() => lastEncCall()?.data?.participants?.[0]?.isActive === false);
expect(lastEncCall().data.participants[0].isActive).toBe(false);
});
@@ -116,7 +116,7 @@ describe('Participant -> Firebase', () => {
test('toggleCondition: updateDoc adds condition to array', async () => {
await setupReady();
await addMonsterViaUI('Cond', 10, 0);
fireEvent.click(screen.getByTitle('Conditions'));
fireEvent.click(screen.getByRole('button', { name: 'Open conditions' }));
await waitFor(() => screen.getByRole('button', { name: /Blinded/i }));
fireEvent.click(screen.getByRole('button', { name: /Blinded/i }));
await waitFor(() => {
+42
View File
@@ -0,0 +1,42 @@
import { screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { setupReady, addMonsterViaUI } from './testHelpers';
describe('participant action selected states', () => {
test('active toggle clearly changes to selected inactive state', async () => {
await setupReady('ActionCamp1', 'ActionEnc1');
await addMonsterViaUI('Toggle Orc', 15, 3);
const disable = screen.getByRole('button', { name: 'Disable participant' });
expect(disable).toHaveAttribute('aria-pressed', 'true');
expect(disable).toHaveClass('bg-emerald-700');
fireEvent.click(disable);
const enable = await screen.findByRole('button', { name: 'Enable participant' });
expect(enable).toHaveAttribute('aria-pressed', 'false');
expect(enable).toHaveClass('bg-red-900', 'ring-red-500');
});
test('conditions toggle highlights open state and gives close action', async () => {
await setupReady('ActionCamp2', 'ActionEnc2');
await addMonsterViaUI('Condition Orc', 15, 3);
const open = screen.getByRole('button', { name: 'Open conditions' });
expect(open).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(open);
const close = screen.getByRole('button', { name: 'Close conditions' });
expect(close).toHaveAttribute('aria-expanded', 'true');
expect(close).toHaveClass('bg-purple-700', 'ring-purple-300');
});
test('edit toggle exposes selected state while editor is open', async () => {
await setupReady('ActionCamp3', 'ActionEnc3');
await addMonsterViaUI('Edit Orc', 15, 3);
fireEvent.click(screen.getByRole('button', { name: 'Edit participant' }));
const closeEditor = screen.getByRole('button', { name: 'Close participant editor' });
expect(closeEditor).toHaveAttribute('aria-pressed', 'true');
expect(closeEditor).toHaveClass('bg-amber-500', 'ring-amber-200');
});
});
+1 -1
View File
@@ -57,7 +57,7 @@ describe('reslot on all mutation paths', () => {
expect(lastParticipantsUpdate().map(p => p.name)).toEqual(['Orc', 'Goblin']);
// open edit modal for Goblin, bump init to 8
const editBtns = screen.getAllByTitle('Edit');
const editBtns = screen.getAllByRole('button', { name: 'Edit participant' });
const goblinEdit = editBtns.find(b => b.closest('li')?.textContent.includes('Goblin'));
fireEvent.click(goblinEdit);
+95
View File
@@ -0,0 +1,95 @@
import { screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { setupReady } from './testHelpers';
describe('screen controls', () => {
let originalRequestFullscreen;
let originalExitFullscreen;
let originalFullscreenElement;
let originalVisibilityState;
let originalWakeLock;
beforeEach(() => {
localStorage.clear();
originalRequestFullscreen = document.documentElement.requestFullscreen;
originalExitFullscreen = document.exitFullscreen;
originalFullscreenElement = Object.getOwnPropertyDescriptor(document, 'fullscreenElement');
originalVisibilityState = Object.getOwnPropertyDescriptor(document, 'visibilityState');
originalWakeLock = Object.getOwnPropertyDescriptor(navigator, 'wakeLock');
});
afterEach(() => {
document.documentElement.requestFullscreen = originalRequestFullscreen;
document.exitFullscreen = originalExitFullscreen;
if (originalFullscreenElement) Object.defineProperty(document, 'fullscreenElement', originalFullscreenElement);
else Object.defineProperty(document, 'fullscreenElement', { configurable: true, value: null });
if (originalVisibilityState) Object.defineProperty(document, 'visibilityState', originalVisibilityState);
if (originalWakeLock) Object.defineProperty(navigator, 'wakeLock', originalWakeLock);
else Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: undefined });
localStorage.clear();
});
test('encounter header exposes prevent-sleep, browser-fullscreen, and popout controls', async () => {
await setupReady('ScreenCamp', 'ScreenEnc');
expect(screen.getAllByRole('button', { name: 'Prevent sleep' }).length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByRole('button', { name: 'Enter browser fullscreen' })).toHaveLength(1);
expect(screen.getByRole('button', { name: 'Expand encounter to full page' })).toBeInTheDocument();
});
test('re-acquires wake lock when tablet becomes visible after browser release', async () => {
let releaseListener;
const sentinel = {
released: false,
release: jest.fn().mockResolvedValue(undefined),
addEventListener: jest.fn((event, callback) => { if (event === 'release') releaseListener = callback; }),
};
const request = jest.fn().mockResolvedValue(sentinel);
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } });
let visibility = 'visible';
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility });
await setupReady('WakeCamp', 'WakeEnc');
fireEvent.click(screen.getAllByRole('button', { name: 'Prevent sleep' })[0]);
await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
visibility = 'hidden';
releaseListener();
visibility = 'visible';
document.dispatchEvent(new Event('visibilitychange'));
await waitFor(() => expect(request).toHaveBeenCalledTimes(2));
});
test('character editor has labeled tablet-sized save button', async () => {
await setupReady('SaveCamp', 'SaveEnc');
fireEvent.change(screen.getByLabelText('Name', { selector: '#characterName' }), { target: { value: 'Tablet Hero' } });
fireEvent.click(screen.getByRole('button', { name: 'Add Character' }));
await screen.findByText('Tablet Hero');
fireEvent.click(screen.getByRole('button', { name: 'Edit character' }));
const save = screen.getByRole('button', { name: 'Save character' });
expect(save).toHaveTextContent('Save');
expect(save).toHaveClass('min-h-[40px]');
});
test('attempts to restore fullscreen when tablet becomes visible after lock', async () => {
const requestFullscreen = jest.fn().mockResolvedValue(undefined);
document.documentElement.requestFullscreen = requestFullscreen;
document.exitFullscreen = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => null });
let visibility = 'visible';
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => visibility });
await setupReady('RestoreCamp', 'RestoreEnc');
fireEvent.click(screen.getByRole('button', { name: 'Enter browser fullscreen' }));
await waitFor(() => expect(requestFullscreen).toHaveBeenCalledTimes(1));
visibility = 'hidden';
document.dispatchEvent(new Event('fullscreenchange'));
visibility = 'visible';
document.dispatchEvent(new Event('visibilitychange'));
await waitFor(() => expect(requestFullscreen).toHaveBeenCalledTimes(2));
});
});