diff --git a/.dockerignore b/.dockerignore index 4f7bee5..7610c0f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,4 @@ +TODO.md node_modules **/node_modules .git diff --git a/TODO.md b/TODO.md index 60252ca..e67569c 100644 --- a/TODO.md +++ b/TODO.md @@ -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? diff --git a/public/display-manifest.json b/public/display-manifest.json index 369bc38..7e5ed8a 100644 --- a/public/display-manifest.json +++ b/public/display-manifest.json @@ -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" } diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..bff275c Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/index.html b/public/index.html index f945035..400b585 100644 --- a/public/index.html +++ b/public/index.html @@ -19,4 +19,4 @@
- \ No newline at end of file + diff --git a/public/logo192.png-not b/public/logo192.png-not new file mode 100644 index 0000000..5a2c400 Binary files /dev/null and b/public/logo192.png-not differ diff --git a/public/logo512.png-not b/public/logo512.png-not new file mode 100644 index 0000000..806cc77 Binary files /dev/null and b/public/logo512.png-not differ diff --git a/public/manifest.json b/public/manifest.json index e93417c..0f0ecff 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -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" -} \ No newline at end of file +} diff --git a/public/player-favicon.ico b/public/player-favicon.ico new file mode 100644 index 0000000..96951e6 Binary files /dev/null and b/public/player-favicon.ico differ diff --git a/public/player-logo192.png-not b/public/player-logo192.png-not new file mode 100644 index 0000000..e474366 Binary files /dev/null and b/public/player-logo192.png-not differ diff --git a/public/player-logo512.png-not b/public/player-logo512.png-not new file mode 100644 index 0000000..d182d35 Binary files /dev/null and b/public/player-logo512.png-not differ diff --git a/shared/tests/static.eslint.test.js b/shared/tests/static.eslint.test.js index 8657f95..6b32bfc 100644 --- a/shared/tests/static.eslint.test.js +++ b/shared/tests/static.eslint.test.js @@ -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 --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) { diff --git a/src/App.js b/src/App.js index 2d682cf..e28cd5a 100644 --- a/src/App.js +++ b/src/App.js @@ -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 - @@ -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 && (
-

- Managing Encounter: {selectedEncounter.name} - +

+ Managing Encounter: {selectedEncounter.name} + + + + +

{/* Combat Controls - always left */} @@ -2770,21 +2799,43 @@ function AdminView({ userId }) { catch { return false; } }); const wakeLockRef = useRef(null); + const fullscreenWantedRef = useRef(false); - const toggleFullscreen = () => { - if (!document.fullscreenElement) { - document.documentElement.requestFullscreen(); - } else { - document.exitFullscreen(); + const toggleFullscreen = async () => { + try { + if (!document.fullscreenElement) { + fullscreenWantedRef.current = true; + await document.documentElement.requestFullscreen(); + } else { + 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} />
)} @@ -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 = () => { - if (!document.fullscreenElement) { - document.documentElement.requestFullscreen(); - } else { - document.exitFullscreen(); + const toggleFullscreen = async () => { + try { + if (!document.fullscreenElement) { + fullscreenWantedRef.current = true; + await document.documentElement.requestFullscreen(); + } else { + 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); diff --git a/src/tests/DisplayView.characterization.test.js b/src/tests/DisplayView.characterization.test.js index c367bca..144610c 100644 --- a/src/tests/DisplayView.characterization.test.js +++ b/src/tests/DisplayView.characterization.test.js @@ -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(); + 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(); diff --git a/src/tests/Participant.characterization.test.js b/src/tests/Participant.characterization.test.js index 77b6bd1..5504f6d 100644 --- a/src/tests/Participant.characterization.test.js +++ b/src/tests/Participant.characterization.test.js @@ -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(() => { diff --git a/src/tests/ParticipantActionStates.test.js b/src/tests/ParticipantActionStates.test.js new file mode 100644 index 0000000..7361841 --- /dev/null +++ b/src/tests/ParticipantActionStates.test.js @@ -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'); + }); +}); diff --git a/src/tests/ReslotAllPaths.test.js b/src/tests/ReslotAllPaths.test.js index 771b66f..f7d7159 100644 --- a/src/tests/ReslotAllPaths.test.js +++ b/src/tests/ReslotAllPaths.test.js @@ -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); diff --git a/src/tests/ScreenControls.test.js b/src/tests/ScreenControls.test.js new file mode 100644 index 0000000..4703cb2 --- /dev/null +++ b/src/tests/ScreenControls.test.js @@ -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)); + }); +});