Files
openmaic/OpenMAIC/tests/hooks/use-scene-generator-retry.test.ts
2026-08-16 14:58:47 +08:00

445 lines
13 KiB
TypeScript

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<typeof generateTTSForScene>[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<void>((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<typeof generateTTSForScene>[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' }),
);
});
});