diff --git a/src/App.test.tsx b/src/App.test.tsx index 4bd3d52..d25e1ec 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -2,6 +2,8 @@ import { act, render, screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; let mockState = { isPreparingPlayback: false }; +let mockActiveLoadCount = 0; +let loadingListener: ((evt: { type: 'start' | 'end'; instrument: string }) => void) | null = null; vi.mock('./stores/projectStore', () => ({ useProjectStore: (selector?: (state: typeof mockState) => unknown) => ( @@ -21,12 +23,21 @@ vi.mock('./components/ChatBox', () => ({ default: () => null })); vi.mock('./components/KGOnePanel', () => ({ default: () => null })); vi.mock('./components/EventListPanel', () => ({ default: () => null })); vi.mock('./components/settings', () => ({ SettingsPanel: () => null })); +vi.mock('./util/dialogUtil', () => ({ + showAlert: vi.fn().mockResolvedValue(undefined), +})); vi.mock('./core/audio-interface/KGToneBuffersPool', () => ({ KGToneBuffersPool: { instance: () => ({ - getActiveLoadCount: () => 0, - addLoadingListener: () => undefined, - removeLoadingListener: () => undefined, + getActiveLoadCount: () => mockActiveLoadCount, + addLoadingListener: (listener: (evt: { type: 'start' | 'end'; instrument: string }) => void) => { + loadingListener = listener; + }, + removeLoadingListener: (listener: (evt: { type: 'start' | 'end'; instrument: string }) => void) => { + if (loadingListener === listener) { + loadingListener = null; + } + }, }), }, })); @@ -47,12 +58,14 @@ vi.mock('./core/KGCore', () => ({ }, })); -import { PlaybackPreparationOverlayContainer } from './App'; +import { GlobalLoadingOverlayContainer, PlaybackPreparationOverlayContainer } from './App'; describe('PlaybackPreparationOverlayContainer', () => { beforeEach(() => { vi.useFakeTimers(); mockState = { isPreparingPlayback: false }; + mockActiveLoadCount = 0; + loadingListener = null; }); afterEach(() => { @@ -112,3 +125,29 @@ describe('PlaybackPreparationOverlayContainer', () => { expect(screen.queryByText('Preparing playback...')).not.toBeInTheDocument(); }); }); + +describe('GlobalLoadingOverlayContainer', () => { + beforeEach(() => { + vi.useFakeTimers(); + mockActiveLoadCount = 0; + loadingListener = null; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('syncs to the pool active count instead of relying on incremental listener math', () => { + mockActiveLoadCount = 2; + render(); + + expect(screen.getByText('Loading ... (2)')).toBeInTheDocument(); + + mockActiveLoadCount = 0; + act(() => { + loadingListener?.({ type: 'end', instrument: 'woodblock' }); + }); + + expect(screen.queryByText(/Loading \.\.\./)).not.toBeInTheDocument(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index c31919b..a4e4c9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -195,19 +195,20 @@ function App() { export default App; // Local component to subscribe to pool events and manage a counter -const GlobalLoadingOverlayContainer: React.FC = () => { +export const GlobalLoadingOverlayContainer: React.FC = () => { const [loadingCount, setLoadingCount] = useState(() => KGToneBuffersPool.instance().getActiveLoadCount()); const [overdue, setOverdue] = useState(false); const timeoutRef = useRef(null); useEffectReact(() => { const pool = KGToneBuffersPool.instance(); - const listener = (evt: { type: 'start' | 'end'; instrument: string }) => { - setLoadingCount(prev => { - if (evt.type === 'start') return prev + 1; - return Math.max(0, prev - 1); - }); + const syncLoadingCount = () => { + setLoadingCount(pool.getActiveLoadCount()); }; + const listener = (_evt: { type: 'start' | 'end'; instrument: string }) => { + syncLoadingCount(); + }; + syncLoadingCount(); pool.addLoadingListener(listener); return () => { pool.removeLoadingListener(listener); @@ -219,8 +220,11 @@ const GlobalLoadingOverlayContainer: React.FC = () => { // When loading starts, start a 30s timer if not already overdue/timed if (loadingCount > 0 && !overdue && timeoutRef.current === null) { timeoutRef.current = window.setTimeout(async () => { - // Only trigger if still loading - if (loadingCount > 0) { + const activeLoadCount = KGToneBuffersPool.instance().getActiveLoadCount(); + setLoadingCount(activeLoadCount); + + // Only trigger if still loading according to the source of truth + if (activeLoadCount > 0) { setOverdue(true); await showAlert( 'Loading resources is taking longer than expected and may have partially failed. If you notice any playback issues, please refresh the page to retry downloading the audio files.'