import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ resolveModel: vi.fn(), isProviderKeyRequired: vi.fn(), generateSceneOutlinesFromRequirements: vi.fn(), applyOutlineFallbacks: vi.fn(), generateSceneContent: vi.fn(), generateSceneActions: vi.fn(), createSceneWithActions: vi.fn(), persistClassroom: vi.fn(), callLLM: vi.fn(), })); vi.mock('@/lib/server/resolve-model', () => ({ resolveModel: mocks.resolveModel, })); vi.mock('@/lib/ai/providers', () => ({ isProviderKeyRequired: mocks.isProviderKeyRequired, })); vi.mock('@/lib/ai/llm', () => ({ callLLM: mocks.callLLM, })); vi.mock('@/lib/generation/outline-generator', () => ({ generateSceneOutlinesFromRequirements: mocks.generateSceneOutlinesFromRequirements, applyOutlineFallbacks: mocks.applyOutlineFallbacks, })); vi.mock('@/lib/generation/scene-generator', () => ({ generateSceneContent: mocks.generateSceneContent, generateSceneActions: mocks.generateSceneActions, createSceneWithActions: mocks.createSceneWithActions, })); vi.mock('@/lib/server/classroom-storage', () => ({ persistClassroom: mocks.persistClassroom, })); vi.mock('@/lib/logger', () => ({ createLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }), })); const outline = { id: 'outline-1', type: 'slide', title: 'Retry Basics', description: 'Explain retries', keyPoints: ['Retry transient failures'], order: 1, } as const; const slideContent = { elements: [], remark: 'Retry transient failures', }; async function generateWithProgress() { const progress: Array<{ message: string }> = []; const { generateClassroom } = await import('@/lib/server/classroom-generation'); const result = await generateClassroom( { requirement: 'Teach retry basics' }, { baseUrl: 'http://localhost', onProgress: (event) => { progress.push({ message: event.message }); }, }, ); return { result, progress }; } describe('classroom scene generation retries', () => { beforeEach(() => { for (const mock of Object.values(mocks)) { mock.mockReset(); } mocks.resolveModel.mockResolvedValue({ model: { id: 'language-model' }, modelInfo: {}, modelString: 'test:model', providerId: 'test', apiKey: '', }); mocks.isProviderKeyRequired.mockReturnValue(false); mocks.callLLM.mockResolvedValue({ text: 'ok' }); mocks.generateSceneOutlinesFromRequirements.mockResolvedValue({ success: true, data: { languageDirective: 'Use English.', outlines: [outline], }, }); mocks.applyOutlineFallbacks.mockImplementation((value) => value); mocks.generateSceneActions.mockResolvedValue([]); mocks.createSceneWithActions.mockImplementation((sceneOutline, content, actions, api) => { const sceneResult = api.scene.create({ type: sceneOutline.type, title: sceneOutline.title, order: sceneOutline.order, content: { type: 'slide', canvas: { id: 'slide-1', viewportSize: 1000, viewportRatio: 0.5625, elements: content.elements, }, }, actions, }); return sceneResult.success ? (sceneResult.data ?? null) : null; }); mocks.persistClassroom.mockImplementation(async ({ id, scenes }) => ({ id, url: `http://localhost/classroom/${id}`, scenesCount: scenes.length, createdAt: '2026-06-22T00:00:00.000Z', })); }); it('retries an empty scene content result before skipping the scene', async () => { mocks.generateSceneContent.mockResolvedValueOnce(null).mockResolvedValueOnce(slideContent); const { result, progress } = await generateWithProgress(); expect(result.scenesCount).toBe(1); expect(mocks.generateSceneContent).toHaveBeenCalledTimes(2); expect(progress.some((event) => event.message.includes('Retrying scene 1/1 content'))).toBe( true, ); }); it('forwards classroom thinking config to scene retry LLM calls', async () => { const thinkingConfig = { enabled: true, effort: 'high' }; mocks.resolveModel.mockResolvedValue({ model: { id: 'language-model' }, modelInfo: {}, modelString: 'test:model', providerId: 'test', apiKey: '', thinkingConfig, }); mocks.generateSceneContent.mockImplementation(async (_outline, aiCall) => { await aiCall('system', 'user'); return slideContent; }); await generateWithProgress(); expect(mocks.callLLM).toHaveBeenCalledWith( expect.objectContaining({ maxRetries: 0 }), 'generate-classroom-scene', undefined, thinkingConfig, ); }); it('retries retryable action generation errors', async () => { mocks.generateSceneContent.mockResolvedValue(slideContent); mocks.generateSceneActions .mockRejectedValueOnce(Object.assign(new Error('rate limited'), { statusCode: 429 })) .mockResolvedValueOnce([]); const { result, progress } = await generateWithProgress(); expect(result.scenesCount).toBe(1); expect(mocks.generateSceneActions).toHaveBeenCalledTimes(2); expect(progress.some((event) => event.message.includes('Retrying scene 1/1 actions'))).toBe( true, ); }); it('does not retry non-retryable action generation errors', async () => { const unauthorized = Object.assign(new Error('Unauthorized'), { statusCode: 401 }); mocks.generateSceneContent.mockResolvedValue(slideContent); mocks.generateSceneActions.mockRejectedValue(unauthorized); await expect(generateWithProgress()).rejects.toBe(unauthorized); expect(mocks.generateSceneActions).toHaveBeenCalledTimes(1); }); });