Files
openmaic/OpenMAIC/tests/lib/agent/tools/regenerate-scene.test.ts
2026-08-16 14:58:47 +08:00

210 lines
7.1 KiB
TypeScript

import { describe, expect, it, vi } from 'vitest';
import { makeRegenerateSceneTool } from '@/lib/agent/tools/regenerate-scene';
import type { SceneContext } from '@/lib/agent/tools/regenerate-scene-actions';
import type { SceneOutline } from '@/lib/types/generation';
import type { SceneContent } from '@/lib/types/stage';
import type { PPTElement } from '@openmaic/dsl';
function slideOutline(id: string): SceneOutline {
return { id, type: 'slide', title: 'Slide Title', description: 'd', keyPoints: ['a'], order: 0 };
}
function slideContent(extraElements: PPTElement[] = []): SceneContent {
return {
type: 'slide',
canvas: {
id: 'cv',
viewportSize: 1000,
viewportRatio: 0.5625,
elements: [
{
id: 'text_old',
type: 'text',
left: 0,
top: 0,
width: 100,
height: 40,
content: '<p>OLD-BASELINE-SENTINEL</p>',
defaultFontName: '',
defaultColor: '#000',
rotate: 0,
},
...extraElements,
],
},
} as unknown as SceneContent;
}
function slideCtx(id: string, extraElements: PPTElement[] = []): SceneContext {
return {
outline: slideOutline(id),
allOutlines: [slideOutline(id)],
content: slideContent(extraElements),
stageId: 'stage-1',
};
}
const NEW_SLIDE_JSON = JSON.stringify({
elements: [
{
id: 'text_new',
type: 'text',
left: 0,
top: 0,
width: 100,
height: 40,
content: '<p>NEW-CONTENT</p>',
defaultFontName: '',
defaultColor: '#000',
},
],
background: null,
remark: '',
});
describe('regenerate_scene tool', () => {
it('regenerates slide content (with the instruction + baseline) then actions', async () => {
const prompts: string[] = [];
const stages: string[] = [];
const aiCall = vi.fn(async (stage: string, _system: string, user: string) => {
stages.push(stage);
prompts.push(user);
// 1st call = content generation (expects slide JSON); 2nd = actions (array)
return prompts.length === 1 ? NEW_SLIDE_JSON : '[]';
});
const tool = makeRegenerateSceneTool({
aiCall,
getSceneContext: (id) => (id === 's1' ? slideCtx('s1') : undefined),
});
const res = await tool.execute('call-1', {
sceneId: 's1',
instruction: '<<REGEN-INSTRUCTION-SENTINEL>>',
});
expect((res as { isError?: boolean }).isError).toBeFalsy();
expect(res.details.sceneId).toBe('s1');
// Black box: content resolves the slide-content stage, actions the actions stage.
expect(stages).toEqual(['scene-content:slide', 'scene-actions']);
// The content-generation prompt ran in EDIT MODE with the instruction + baseline.
expect(prompts[0]).toContain('EDIT MODE');
expect(prompts[0]).toContain('<<REGEN-INSTRUCTION-SENTINEL>>');
expect(prompts[0]).toContain('OLD-BASELINE-SENTINEL');
// The regenerated content is returned for the client to apply.
expect(JSON.stringify(res.details.content)).toContain('NEW-CONTENT');
});
it('threads existing images as resources (id-ref baseline + non-empty assignedImages)', async () => {
const prompts: string[] = [];
const aiCall = vi.fn(async (_stage: string, _system: string, user: string) => {
prompts.push(user);
return prompts.length === 1 ? NEW_SLIDE_JSON : '[]';
});
const dataSrc = `data:image/png;base64,${'Z'.repeat(2000)}`;
const imageEl = {
id: 'image_old',
type: 'image',
left: 0,
top: 0,
width: 120,
height: 80,
src: dataSrc,
fixedRatio: true,
rotate: 0,
} as unknown as PPTElement;
const tool = makeRegenerateSceneTool({
aiCall,
getSceneContext: (id) => (id === 's1' ? slideCtx('s1', [imageEl]) : undefined),
});
const res = await tool.execute('call-1', { sceneId: 's1', instruction: 'tweak it' });
expect((res as { isError?: boolean }).isError).toBeFalsy();
const contentPrompt = prompts[0];
// The base64 payload never enters the prompt...
expect(contentPrompt).not.toContain('Z'.repeat(2000));
// ...the baseline references the image by its img_N id instead...
expect(contentPrompt).toContain('"src":"img_1"');
// ...and the image is offered as a resource (assignedImages → prompt).
expect(contentPrompt).toContain('img_1');
expect(contentPrompt).toContain('Existing slide image');
expect(contentPrompt).toContain('KEEP them');
});
it('refuses slides containing a video without generating anything', async () => {
const aiCall = vi.fn(async () => NEW_SLIDE_JSON);
const videoEl = {
id: 'video_1',
type: 'video',
left: 0,
top: 0,
width: 100,
height: 100,
src: 'data:video/mp4;base64,VVVV',
autoplay: false,
rotate: 0,
} as unknown as PPTElement;
const tool = makeRegenerateSceneTool({
aiCall,
getSceneContext: () => slideCtx('s1', [videoEl]),
});
const res = await tool.execute('call-1', { sceneId: 's1', instruction: 'x' });
expect((res as { isError?: boolean }).isError).toBe(true);
expect((res.content[0] as { text: string }).text).toContain('video');
expect(res.details).toEqual({ sceneId: 's1', content: null, actions: [] });
expect(aiCall).not.toHaveBeenCalled();
});
it('refuses slides with a slide-level image background without generating anything', async () => {
const aiCall = vi.fn(async () => NEW_SLIDE_JSON);
const ctx = slideCtx('s1');
(ctx.content as unknown as { canvas: { background: unknown } }).canvas.background = {
type: 'image',
image: { src: 'data:image/png;base64,IIII', size: 'cover' },
};
const tool = makeRegenerateSceneTool({
aiCall,
getSceneContext: () => ctx,
});
const res = await tool.execute('call-1', { sceneId: 's1', instruction: 'x' });
expect((res as { isError?: boolean }).isError).toBe(true);
expect((res.content[0] as { text: string }).text).toContain('image background');
expect(res.details).toEqual({ sceneId: 's1', content: null, actions: [] });
expect(aiCall).not.toHaveBeenCalled();
});
it('refuses non-slide scenes without generating anything', async () => {
const aiCall = vi.fn(async () => NEW_SLIDE_JSON);
const quizCtx: SceneContext = {
outline: { id: 'q1', type: 'quiz', title: 'Quiz', description: '', keyPoints: [], order: 0 },
allOutlines: [],
content: { type: 'quiz', questions: [] } as unknown as SceneContent,
stageId: 'stage-1',
};
const tool = makeRegenerateSceneTool({ aiCall, getSceneContext: () => quizCtx });
const res = await tool.execute('call-1', { sceneId: 'q1' });
expect((res as { isError?: boolean }).isError).toBe(true);
expect(aiCall).not.toHaveBeenCalled();
});
it('errors when the scene context is missing', async () => {
const aiCall = vi.fn(async () => NEW_SLIDE_JSON);
const tool = makeRegenerateSceneTool({ aiCall, getSceneContext: () => undefined });
const res = await tool.execute('call-1', { sceneId: 'nope' });
expect((res as { isError?: boolean }).isError).toBe(true);
expect(aiCall).not.toHaveBeenCalled();
});
});