import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import type { SceneOutline } from '@/lib/types/generation'; const mocks = vi.hoisted(() => ({ getCurrentModelConfig: vi.fn(), settingsState: vi.fn(), audioPut: vi.fn(), audioDelete: vi.fn(), poolPut: vi.fn(), poolReplace: vi.fn(), poolRemove: vi.fn(), isTTSProviderEnabled: vi.fn(), pickNarratorAgent: vi.fn(), resolveAgentVoiceOptions: vi.fn(), listAgents: vi.fn(), })); vi.mock('@/lib/utils/model-config', () => ({ getCurrentModelConfig: mocks.getCurrentModelConfig, })); vi.mock('@/lib/store/settings', () => ({ useSettingsStore: { getState: mocks.settingsState, }, })); vi.mock('@/lib/utils/database', () => ({ db: { audioFiles: { put: mocks.audioPut, delete: mocks.audioDelete, }, }, })); vi.mock('@/lib/media/asset-pool', () => ({ putAsset: mocks.poolPut, replaceAsset: mocks.poolReplace, removeAsset: mocks.poolRemove, })); vi.mock('@/lib/audio/provider-enablement', () => ({ isTTSProviderEnabled: mocks.isTTSProviderEnabled, })); vi.mock('@/lib/audio/agent-voice', () => ({ pickNarratorAgent: mocks.pickNarratorAgent, resolveAgentVoiceOptions: mocks.resolveAgentVoiceOptions, })); vi.mock('@/lib/orchestration/registry/store', () => ({ useAgentRegistry: { getState: () => ({ listAgents: mocks.listAgents, }), }, })); const mockFetch = vi.fn() as Mock; vi.stubGlobal('fetch', mockFetch); const outline = { id: 'outline-1', type: 'slide', title: 'Retry Scene', description: 'Retry transient failures', keyPoints: ['retry'], order: 2, } as SceneOutline; const retryOptions = { maxRetries: 1, sleep: async () => undefined, random: () => 0, }; function jsonResponse(status: number, body: unknown) { return { ok: status >= 200 && status < 300, status, statusText: status === 429 ? 'Too Many Requests' : status === 401 ? 'Unauthorized' : 'OK', json: async () => body, }; } describe('browser scene generation retry wrappers', () => { beforeEach(() => { mockFetch.mockReset(); mocks.audioPut.mockReset(); mocks.audioDelete.mockReset().mockResolvedValue(undefined); mocks.poolPut.mockReset(); mocks.poolReplace.mockReset().mockResolvedValue(undefined); mocks.poolRemove.mockReset().mockResolvedValue(undefined); mocks.poolPut.mockResolvedValue('ast_audio_allocated'); mocks.getCurrentModelConfig.mockReturnValue({}); mocks.settingsState.mockReturnValue({ imageProviderId: '', imageProvidersConfig: {}, imageGenerationEnabled: false, videoProviderId: '', videoProvidersConfig: {}, videoGenerationEnabled: false, ttsProviderId: 'server-tts', ttsProvidersConfig: { 'server-tts': { apiKey: 'tts-key', modelId: 'tts-model', }, }, ttsVoice: 'narrator', ttsSpeed: 1, }); mocks.isTTSProviderEnabled.mockReturnValue(true); mocks.pickNarratorAgent.mockReturnValue(undefined); mocks.resolveAgentVoiceOptions.mockResolvedValue({}); mocks.listAgents.mockReturnValue([]); }); it('retries transient scene content HTTP failures before returning success', async () => { const { fetchSceneContent } = await import('@/lib/hooks/use-scene-generator'); mockFetch .mockResolvedValueOnce(jsonResponse(429, { error: 'rate limited' })) .mockResolvedValueOnce(jsonResponse(200, { success: true, content: { elements: [] } })); const result = await fetchSceneContent( { outline, allOutlines: [outline], stageId: 'stage-1', stageInfo: { name: 'Retry Course' }, }, undefined, retryOptions, ); expect(result).toMatchObject({ success: true, content: { elements: [] } }); expect(mockFetch).toHaveBeenCalledTimes(2); }); it('does not retry permanent scene action HTTP failures', async () => { const { fetchSceneActions } = await import('@/lib/hooks/use-scene-generator'); mockFetch.mockResolvedValue(jsonResponse(401, { error: 'unauthorized' })); const result = await fetchSceneActions( { outline, allOutlines: [outline], content: { elements: [] }, stageId: 'stage-1', }, undefined, retryOptions, ); expect(result).toMatchObject({ success: false, error: 'unauthorized' }); expect(mockFetch).toHaveBeenCalledTimes(1); }); it('preserves scene content error metadata for localized UI messages', async () => { const { fetchSceneContent } = await import('@/lib/hooks/use-scene-generator'); mockFetch.mockResolvedValue( jsonResponse(429, { success: false, errorCode: 'RATE_LIMITED', error: 'Upstream rate limit reached. Please try again shortly.', }), ); const result = await fetchSceneContent( { outline, allOutlines: [outline], stageId: 'stage-1', stageInfo: { name: 'Retry Course' }, }, undefined, { ...retryOptions, maxRetries: 0 }, ); expect(result).toMatchObject({ success: false, errorCode: 'RATE_LIMITED', statusCode: 429, }); }); it('preserves internal scene content errors for localized fallback messages', async () => { const { fetchSceneContent } = await import('@/lib/hooks/use-scene-generator'); mockFetch.mockResolvedValue( jsonResponse(500, { success: false, errorCode: 'INTERNAL_ERROR', error: 'Scene generation failed. Please try again.', }), ); const result = await fetchSceneContent( { outline, allOutlines: [outline], stageId: 'stage-1', stageInfo: { name: 'Retry Course' }, }, undefined, { ...retryOptions, maxRetries: 0 }, ); expect(result).toMatchObject({ success: false, errorCode: 'INTERNAL_ERROR', statusCode: 500, }); }); it('rethrows an aborted scene content request', async () => { const { fetchSceneContent } = await import('@/lib/hooks/use-scene-generator'); const abort = Object.assign(new Error('Aborted'), { name: 'AbortError' }); mockFetch.mockRejectedValueOnce(abort); await expect( fetchSceneContent( { outline, allOutlines: [outline], stageId: 'stage-1', stageInfo: { name: 'Retry Course' }, }, undefined, retryOptions, ), ).rejects.toBe(abort); expect(mockFetch).toHaveBeenCalledTimes(1); }); it('rethrows an aborted scene actions request', async () => { const { fetchSceneActions } = await import('@/lib/hooks/use-scene-generator'); const abort = Object.assign(new Error('Aborted'), { name: 'AbortError' }); mockFetch.mockRejectedValueOnce(abort); await expect( fetchSceneActions( { outline, allOutlines: [outline], content: { elements: [] }, stageId: 'stage-1', }, undefined, retryOptions, ), ).rejects.toBe(abort); expect(mockFetch).toHaveBeenCalledTimes(1); }); it('retries transient TTS failures before storing audio', async () => { const { generateAndStoreTTS } = await import('@/lib/hooks/use-scene-generator'); mockFetch .mockResolvedValueOnce(jsonResponse(503, { error: 'provider overloaded' })) .mockResolvedValueOnce( jsonResponse(200, { success: true, base64: btoa('audio-data'), format: 'wav', }), ); const assetId = await generateAndStoreTTS( 'tts_s2_action_1', 'Hello class', 'English', undefined, retryOptions, ); expect(assetId).toBe('ast_audio_allocated'); expect(mockFetch).toHaveBeenCalledTimes(2); expect(mocks.poolPut).toHaveBeenCalledWith( expect.any(Blob), expect.objectContaining({ contentType: 'audio/wav', mediaType: 'audio', text: 'Hello class', voice: 'narrator', }), ); expect(mocks.audioPut).toHaveBeenCalledWith( expect.objectContaining({ id: 'ast_audio_allocated', format: 'wav', }), ); }); it('does not write Dexie when pool allocation fails', async () => { const { generateAndStoreTTS } = await import('@/lib/hooks/use-scene-generator'); mockFetch.mockResolvedValue( jsonResponse(200, { success: true, base64: btoa('audio-data'), format: 'wav', }), ); mocks.poolPut.mockRejectedValueOnce(new Error('pool unavailable')); await expect(generateAndStoreTTS('request-1', 'Hello class')).rejects.toThrow( 'pool unavailable', ); expect(mocks.audioPut).not.toHaveBeenCalled(); }); it('does not report an allocated id when the compatibility write fails', async () => { const { generateAndStoreTTS } = await import('@/lib/hooks/use-scene-generator'); mockFetch.mockResolvedValue( jsonResponse(200, { success: true, base64: btoa('audio-data'), format: 'wav', }), ); mocks.audioPut.mockRejectedValueOnce(new Error('Dexie unavailable')); await expect(generateAndStoreTTS('request-1', 'Hello class')).rejects.toThrow( 'Dexie unavailable', ); expect(mocks.poolPut).toHaveBeenCalledOnce(); expect(mocks.poolRemove).toHaveBeenCalledExactlyOnceWith('ast_audio_allocated'); }); it('reclaims earlier allocations when partial scene synthesis fails', async () => { const { generateTTSForScene } = await import('@/lib/hooks/use-scene-generator'); mocks.poolPut.mockResolvedValueOnce('ast_first_audio'); mockFetch .mockResolvedValueOnce( jsonResponse(200, { success: true, base64: btoa('first-audio'), format: 'wav', }), ) .mockResolvedValueOnce(jsonResponse(401, { error: 'second speech rejected' })); const scene = { id: 'scene-1', stageId: 'stage-1', type: 'slide', title: 'Scene', order: 1, content: { type: 'slide', canvas: { id: 'slide-1', elements: [] } }, actions: [ { id: 'speech-1', type: 'speech', text: 'First line' }, { id: 'speech-2', type: 'speech', text: 'Second line' }, ], } as unknown as Parameters[0]; const result = await generateTTSForScene(scene, 'English', undefined, { ...retryOptions, maxRetries: 0, }); expect(result).toMatchObject({ success: false, failedCount: 1 }); expect(mocks.poolRemove).toHaveBeenCalledExactlyOnceWith('ast_first_audio'); expect(mocks.audioDelete).toHaveBeenCalledExactlyOnceWith('ast_first_audio'); expect(scene.actions?.every((action) => !('audioId' in action))).toBe(true); }); it('waits for parallel TTS workers before rolling back an abandoned scene', async () => { const { generateTTSForScene } = await import('@/lib/hooks/use-scene-generator'); mocks.settingsState.mockReturnValue({ ...mocks.settingsState(), parallelSceneConcurrency: 2, }); const abort = Object.assign(new Error('Aborted'), { name: 'AbortError' }); let releaseSibling!: () => void; const siblingMayFinish = new Promise((resolve) => { releaseSibling = resolve; }); mockFetch.mockRejectedValueOnce(abort).mockImplementationOnce(async () => { await siblingMayFinish; return jsonResponse(200, { success: true, base64: btoa('late-audio'), format: 'wav', }); }); mocks.poolPut.mockResolvedValueOnce('ast_late_audio'); const scene = { id: 'scene-1', stageId: 'stage-1', type: 'slide', title: 'Scene', order: 1, content: { type: 'slide', canvas: { id: 'slide-1', elements: [] } }, actions: [ { id: 'speech-1', type: 'speech', text: 'Aborted line' }, { id: 'speech-2', type: 'speech', text: 'Late line' }, ], } as unknown as Parameters[0]; const generating = generateTTSForScene(scene, 'English', undefined, retryOptions); await vi.waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2)); expect(mocks.poolRemove).not.toHaveBeenCalled(); releaseSibling(); await expect(generating).rejects.toBe(abort); expect(mocks.poolRemove).toHaveBeenCalledExactlyOnceWith('ast_late_audio'); expect(mocks.audioDelete).toHaveBeenCalledExactlyOnceWith('ast_late_audio'); expect(scene.actions?.every((action) => !('audioId' in action))).toBe(true); }); it('replaces allocated audio under the stable id and refreshes its compatibility row', async () => { const { generateAndStoreTTS } = await import('@/lib/hooks/use-scene-generator'); mockFetch.mockResolvedValue( jsonResponse(200, { success: true, base64: btoa('replacement-audio'), format: 'wav', }), ); await expect( generateAndStoreTTS( 'request-1', 'Updated class', 'English', undefined, undefined, 'ast_stable_audio', ), ).resolves.toBe('ast_stable_audio'); expect(mocks.poolPut).not.toHaveBeenCalled(); expect(mocks.poolReplace).toHaveBeenCalledExactlyOnceWith( 'ast_stable_audio', expect.any(Blob), expect.objectContaining({ mediaType: 'audio', text: 'Updated class' }), ); expect(mocks.audioPut).toHaveBeenCalledExactlyOnceWith( expect.objectContaining({ id: 'ast_stable_audio', format: 'wav' }), ); }); });