Files
thu-maic--openmaic/tests/server/classroom-generation-retry.test.ts
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

191 lines
5.7 KiB
TypeScript

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);
});
});