413 lines
14 KiB
TypeScript
413 lines
14 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(),
|
|
}));
|
|
const PBLGenerationErrorMock = vi.hoisted(
|
|
() =>
|
|
class PBLGenerationError extends Error {
|
|
readonly statusCode?: number;
|
|
|
|
constructor(message: string, options?: { statusCode?: number }) {
|
|
super(message);
|
|
this.name = 'PBLGenerationError';
|
|
this.statusCode = options?.statusCode;
|
|
}
|
|
},
|
|
);
|
|
|
|
vi.mock('@/lib/server/resolve-model', () => ({
|
|
resolveModel: mocks.resolveModel,
|
|
}));
|
|
|
|
vi.mock('@/lib/ai/providers', async (importOriginal) => ({
|
|
// The module graph now reaches the settings store (stage store -> settings),
|
|
// whose init reads PROVIDERS - keep the real exports and stub only the probe.
|
|
...(await importOriginal<typeof import('@/lib/ai/providers')>()),
|
|
isProviderKeyRequired: mocks.isProviderKeyRequired,
|
|
}));
|
|
|
|
vi.mock('@/lib/ai/llm', () => ({
|
|
callLLM: mocks.callLLM,
|
|
}));
|
|
|
|
vi.mock('@openmaic/generation', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('@openmaic/generation')>()),
|
|
generateSceneOutlinesFromRequirements: mocks.generateSceneOutlinesFromRequirements,
|
|
applyOutlineFallbacks: mocks.applyOutlineFallbacks,
|
|
generateSceneContent: mocks.generateSceneContent,
|
|
generateSceneActions: mocks.generateSceneActions,
|
|
PBLGenerationError: PBLGenerationErrorMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/server/scene-generation', () => ({
|
|
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(
|
|
input: { requirement: string; interactiveMode?: boolean } = {
|
|
requirement: 'Teach retry basics',
|
|
},
|
|
) {
|
|
const progress: Array<{ message: string }> = [];
|
|
const { generateClassroom } = await import('@/lib/server/classroom-generation');
|
|
const result = await generateClassroom(input, {
|
|
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 sceneContent =
|
|
sceneOutline.type === 'interactive'
|
|
? {
|
|
type: 'interactive',
|
|
html: content.html,
|
|
widgetType: content.widgetType,
|
|
widgetConfig: content.widgetConfig,
|
|
}
|
|
: {
|
|
type: 'slide',
|
|
canvas: {
|
|
id: 'slide-1',
|
|
viewportSize: 1000,
|
|
viewportRatio: 0.5625,
|
|
elements: content.elements,
|
|
},
|
|
};
|
|
const sceneResult = api.scene.create({
|
|
type: sceneOutline.type,
|
|
title: sceneOutline.title,
|
|
order: sceneOutline.order,
|
|
content: sceneContent,
|
|
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('uses the original Interactive Mode prompt and persists generated HTML', async () => {
|
|
const interactiveOutline = {
|
|
id: 'outline-interactive',
|
|
type: 'interactive' as const,
|
|
title: 'Projectile Lab',
|
|
description: 'Change launch variables and observe the trajectory',
|
|
keyPoints: ['Angle', 'Velocity'],
|
|
order: 1,
|
|
widgetType: 'simulation' as const,
|
|
widgetOutline: { concept: 'Projectile motion', keyVariables: ['angle', 'velocity'] },
|
|
};
|
|
mocks.generateSceneOutlinesFromRequirements.mockImplementation(
|
|
async (requirements, _pdfText, _pdfImages, outlineAiCall) => {
|
|
await outlineAiCall('standard package system', 'standard package user');
|
|
return {
|
|
success: true,
|
|
data: {
|
|
languageDirective: 'Use English.',
|
|
courseTitle: 'Projectile Lab',
|
|
outlines: [interactiveOutline],
|
|
},
|
|
};
|
|
},
|
|
);
|
|
mocks.generateSceneContent.mockResolvedValue({
|
|
html: '<!doctype html><html><body><button>Launch</button></body></html>',
|
|
widgetType: 'simulation',
|
|
widgetConfig: { type: 'simulation', concept: 'Projectile motion' },
|
|
});
|
|
|
|
const { result } = await generateWithProgress({
|
|
requirement: 'Build an interactive projectile-motion lesson',
|
|
interactiveMode: true,
|
|
});
|
|
|
|
const outlineCall = mocks.callLLM.mock.calls.find((call) => call[1] === 'generate-classroom');
|
|
expect(outlineCall?.[0].messages[0].content).toContain('Interactive Mode Outline Generator');
|
|
expect(outlineCall?.[0].messages[1].content).toContain('70% interactive scenes');
|
|
expect(result.stage.interactiveMode).toBe(true);
|
|
expect(result.scenes[0]?.type).toBe('interactive');
|
|
expect(result.scenes[0]?.content).toMatchObject({
|
|
type: 'interactive',
|
|
html: expect.stringContaining('<button>Launch</button>'),
|
|
});
|
|
expect(mocks.persistClassroom).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rejects Interactive Mode output without usable HTML before persistence', async () => {
|
|
mocks.generateSceneContent.mockResolvedValue(slideContent);
|
|
|
|
await expect(
|
|
generateWithProgress({
|
|
requirement: 'Build an interactive lesson',
|
|
interactiveMode: true,
|
|
}),
|
|
).rejects.toThrow('no interactive scene with generated HTML');
|
|
|
|
expect(mocks.persistClassroom).not.toHaveBeenCalled();
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('converts only PBLGenerationError to a null scene result', async () => {
|
|
const { containPBLGenerationError } = await import('@/lib/server/classroom-generation');
|
|
|
|
expect(
|
|
containPBLGenerationError(
|
|
new PBLGenerationErrorMock('both planners failed'),
|
|
'Failed PBL scene',
|
|
),
|
|
).toBeNull();
|
|
|
|
const unrelated = new Error('unrelated failure');
|
|
expect(() => containPBLGenerationError(unrelated, 'Other scene')).toThrow(unrelated);
|
|
});
|
|
|
|
it('does not retry a status-less PBL failure and completes surrounding slides', async () => {
|
|
const outlines = [
|
|
{ ...outline, id: 'outline-slide-1', title: 'Opening slide', order: 0 },
|
|
{
|
|
...outline,
|
|
id: 'outline-pbl',
|
|
type: 'pbl' as const,
|
|
title: 'Practice project',
|
|
order: 1,
|
|
pblConfig: {
|
|
projectTopic: 'Retries',
|
|
projectDescription: 'Practice resilient generation',
|
|
targetSkills: ['Retry handling'],
|
|
},
|
|
},
|
|
{ ...outline, id: 'outline-slide-2', title: 'Closing slide', order: 2 },
|
|
];
|
|
mocks.generateSceneOutlinesFromRequirements.mockResolvedValue({
|
|
success: true,
|
|
data: { languageDirective: 'Use English.', outlines },
|
|
});
|
|
mocks.generateSceneContent.mockImplementation(async (sceneOutline) => {
|
|
if (sceneOutline.type === 'pbl') {
|
|
throw new PBLGenerationErrorMock('both planners failed');
|
|
}
|
|
return slideContent;
|
|
});
|
|
|
|
const { result } = await generateWithProgress();
|
|
const pblCalls = mocks.generateSceneContent.mock.calls.filter(
|
|
([sceneOutline]) => sceneOutline.type === 'pbl',
|
|
);
|
|
|
|
expect(result.scenesCount).toBe(2);
|
|
expect(result.scenes.map((scene) => scene.title)).toEqual(['Opening slide', 'Closing slide']);
|
|
expect(pblCalls).toHaveLength(1);
|
|
});
|
|
|
|
it('does not retry a 401 PBL failure and completes surrounding slides', async () => {
|
|
const outlines = [
|
|
{ ...outline, id: 'outline-slide-1', title: 'Opening slide', order: 0 },
|
|
{
|
|
...outline,
|
|
id: 'outline-pbl',
|
|
type: 'pbl' as const,
|
|
title: 'Practice project',
|
|
order: 1,
|
|
pblConfig: {
|
|
projectTopic: 'Retries',
|
|
projectDescription: 'Practice resilient generation',
|
|
targetSkills: ['Retry handling'],
|
|
},
|
|
},
|
|
{ ...outline, id: 'outline-slide-2', title: 'Closing slide', order: 2 },
|
|
];
|
|
mocks.generateSceneOutlinesFromRequirements.mockResolvedValue({
|
|
success: true,
|
|
data: { languageDirective: 'Use English.', outlines },
|
|
});
|
|
mocks.generateSceneContent.mockImplementation(async (sceneOutline) => {
|
|
if (sceneOutline.type === 'pbl') {
|
|
throw new PBLGenerationErrorMock('provider key rejected', { statusCode: 401 });
|
|
}
|
|
return slideContent;
|
|
});
|
|
|
|
const { result } = await generateWithProgress();
|
|
const pblCalls = mocks.generateSceneContent.mock.calls.filter(
|
|
([sceneOutline]) => sceneOutline.type === 'pbl',
|
|
);
|
|
|
|
expect(result.scenesCount).toBe(2);
|
|
expect(result.scenes.map((scene) => scene.title)).toEqual(['Opening slide', 'Closing slide']);
|
|
expect(pblCalls).toHaveLength(1);
|
|
});
|
|
|
|
it('retries a 429 PBL failure before skipping it and completing surrounding slides', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const outlines = [
|
|
{ ...outline, id: 'outline-slide-1', title: 'Opening slide', order: 0 },
|
|
{
|
|
...outline,
|
|
id: 'outline-pbl',
|
|
type: 'pbl' as const,
|
|
title: 'Practice project',
|
|
order: 1,
|
|
pblConfig: {
|
|
projectTopic: 'Retries',
|
|
projectDescription: 'Practice resilient generation',
|
|
targetSkills: ['Retry handling'],
|
|
},
|
|
},
|
|
{ ...outline, id: 'outline-slide-2', title: 'Closing slide', order: 2 },
|
|
];
|
|
mocks.generateSceneOutlinesFromRequirements.mockResolvedValue({
|
|
success: true,
|
|
data: { languageDirective: 'Use English.', outlines },
|
|
});
|
|
mocks.generateSceneContent.mockImplementation(async (sceneOutline) => {
|
|
if (sceneOutline.type === 'pbl') {
|
|
throw new PBLGenerationErrorMock('provider rate limited', { statusCode: 429 });
|
|
}
|
|
return slideContent;
|
|
});
|
|
|
|
const generation = generateWithProgress();
|
|
await vi.runAllTimersAsync();
|
|
const { result } = await generation;
|
|
const pblCalls = mocks.generateSceneContent.mock.calls.filter(
|
|
([sceneOutline]) => sceneOutline.type === 'pbl',
|
|
);
|
|
|
|
expect(result.scenesCount).toBe(2);
|
|
expect(result.scenes.map((scene) => scene.title)).toEqual(['Opening slide', 'Closing slide']);
|
|
expect(pblCalls).toHaveLength(6);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|