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

223 lines
8.0 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { toRuntimeSlideContent, planRegenerateApply } from '@/lib/agent/client/apply-regenerate';
import type { GeneratedSlideContent } from '@/lib/types/generation';
import type { Scene, SceneContent } from '@/lib/types/stage';
const GEN: GeneratedSlideContent = {
elements: [{ id: 'e_new', type: 'text', left: 0, top: 0, width: 1, height: 1 } as never],
background: { type: 'solid', color: '#abcdef' } as never,
remark: '',
};
function slideScene(): Pick<Scene, 'content' | 'actions'> {
return {
content: {
type: 'slide',
canvas: {
id: 'existing-canvas',
viewportSize: 1234,
viewportRatio: 0.5625,
theme: { fontName: 'KeepMe' },
elements: [{ id: 'e_old' }],
animations: [{ id: 'anim_old', elId: 'e_old', type: 'in', effect: 'fadeIn' }],
},
} as unknown as SceneContent,
actions: [{ type: 'speech', id: 'a_old' } as never],
};
}
describe('toRuntimeSlideContent', () => {
it('preserves the existing canvas (id/viewport/theme) and overrides elements + background', () => {
const existingCanvas = {
id: 'existing-canvas',
viewportSize: 1234,
theme: { fontName: 'KeepMe' },
};
const rt = toRuntimeSlideContent(GEN, existingCanvas) as unknown as {
type: string;
canvas: Record<string, unknown>;
};
expect(rt.type).toBe('slide');
expect(rt.canvas.id).toBe('existing-canvas');
expect(rt.canvas.viewportSize).toBe(1234);
expect((rt.canvas.theme as { fontName: string }).fontName).toBe('KeepMe');
expect((rt.canvas.elements as { id: string }[])[0].id).toBe('e_new');
expect((rt.canvas.background as { color: string }).color).toBe('#abcdef');
});
it('mints a default canvas when the scene has none', () => {
const rt = toRuntimeSlideContent(GEN, undefined) as unknown as {
schemaVersion?: number;
canvas: Record<string, unknown>;
};
expect(rt.canvas.viewportSize).toBe(1000);
expect(rt.canvas.viewportRatio).toBe(0.5625);
expect(rt.canvas.id).toBeTruthy();
// schemaVersion lives at the SlideContent top level (sibling of canvas),
// not inside the canvas object — matching slide-defaults / migrateSlideContent.
expect(rt.schemaVersion).toBe(1);
expect(rt.canvas.schemaVersion).toBeUndefined();
});
it('clears stale animations referencing the replaced (now-gone) element ids', () => {
// The existing canvas has animations bound to old element ids. Every regen
// element gets a brand-new id, so preserving the animations would strand
// them — they must be cleared.
const existingCanvas = {
id: 'cv',
animations: [{ id: 'anim_old', elId: 'e_old', type: 'in', effect: 'fadeIn' }],
};
const rt = toRuntimeSlideContent(GEN, existingCanvas) as unknown as {
canvas: Record<string, unknown>;
};
expect(rt.canvas.animations).toEqual([]);
});
it('keeps the existing background when the gen omits it (no wipe)', () => {
const genNoBg: GeneratedSlideContent = {
elements: [{ id: 'e_new', type: 'text', left: 0, top: 0, width: 1, height: 1 } as never],
background: undefined,
remark: '',
};
const existingCanvas = { id: 'cv', background: '#abc' };
const rt = toRuntimeSlideContent(genNoBg, existingCanvas) as unknown as {
canvas: Record<string, unknown>;
};
expect(rt.canvas.background).toBe('#abc');
});
});
describe('planRegenerateApply', () => {
it('regenerate_scene: snapshots pre-state and applies converted content + actions', () => {
const scene = slideScene();
const plan = planRegenerateApply(
{ sceneId: 's1', content: GEN, actions: [{ type: 'speech', id: 'a_new' } as never] },
scene,
);
// snapshot is the PRE-regenerate scene, for restore.
expect(plan.snapshot?.sceneId).toBe('s1');
expect((plan.snapshot?.actions[0] as { id: string }).id).toBe('a_old');
expect(
(plan.snapshot?.content as { canvas: { elements: { id: string }[] } }).canvas.elements[0].id,
).toBe('e_old');
// patch applies the new content + actions.
const patch = plan.patch as {
content: { canvas: { elements: { id: string }[]; animations: unknown[] } };
actions: { id: string }[];
};
expect(patch.content.canvas.elements[0].id).toBe('e_new');
expect(patch.actions[0].id).toBe('a_new');
// Stale animations bound to the replaced element ids are cleared.
expect(patch.content.canvas.animations).toEqual([]);
// ...while the snapshot retains the original animations for restore.
expect(
(plan.snapshot?.content as { canvas: { animations: { id: string }[] } }).canvas.animations[0]
.id,
).toBe('anim_old');
});
it('regenerate_scene with empty actions applies content but not actions (no wipe)', () => {
const plan = planRegenerateApply({ sceneId: 's1', content: GEN, actions: [] }, slideScene());
expect(plan.patch).toBeTruthy();
expect((plan.patch as Record<string, unknown>).content).toBeTruthy();
expect((plan.patch as Record<string, unknown>).actions).toBeUndefined();
});
it('regenerate_scene_actions: actions-only patch + a snapshot of the prior narration', () => {
const scene = slideScene();
const plan = planRegenerateApply(
{ sceneId: 's1', actions: [{ type: 'speech', id: 'a_new' } as never] },
scene,
);
// No content change, but a snapshot (current content + prior actions) so the
// narration regen can be reverted too.
expect(plan.patch).toEqual({ actions: [{ type: 'speech', id: 'a_new' }] });
expect(plan.snapshot).toEqual({
sceneId: 's1',
content: scene.content,
actions: scene.actions ?? [],
// narration-only → restore reverts actions only, not slide content
actionsOnly: true,
});
});
it('does nothing without a sceneId', () => {
expect(planRegenerateApply({ actions: [{ id: 'a' } as never] }, slideScene())).toEqual({
snapshot: null,
patch: null,
});
});
it('actions-only with empty actions is a no-op', () => {
expect(planRegenerateApply({ sceneId: 's1', actions: [] }, slideScene())).toEqual({
snapshot: null,
patch: null,
});
});
});
function interactiveScene(): Pick<Scene, 'content' | 'actions'> {
return {
content: {
type: 'interactive',
url: 'about:blank',
html: '<html><!-- old --></html>',
widgetType: 'simulation',
widgetConfig: {
type: 'simulation',
concept: 'energy',
description: 'Energy simulation',
variables: [],
},
} as unknown as SceneContent,
actions: [{ type: 'speech', id: 'a_old' } as never],
};
}
describe('planRegenerateApply — edit_interactive_html', () => {
it('writes the fixed html and preserves the other interactive fields', () => {
const plan = planRegenerateApply(
{ sceneId: 'w1', html: '<html><!-- fixed --></html>' },
interactiveScene(),
'edit_interactive_html',
);
const content = plan.patch?.content as unknown as {
type: string;
url: string;
html: string;
widgetType?: string;
widgetConfig?: { type: string };
};
expect(content.type).toBe('interactive');
expect(content.html).toContain('fixed');
expect(content.url).toBe('about:blank');
expect(content.widgetType).toBe('simulation');
expect(content.widgetConfig?.type).toBe('simulation');
});
it('snapshots the pre-fix content for restore', () => {
const scene = interactiveScene();
const plan = planRegenerateApply(
{ sceneId: 'w1', html: '<html><!-- fixed --></html>' },
scene,
'edit_interactive_html',
);
expect(plan.snapshot).toEqual({
sceneId: 'w1',
content: scene.content,
actions: scene.actions,
});
});
it('does nothing when the scene is not interactive', () => {
expect(
planRegenerateApply(
{ sceneId: 's1', html: '<html></html>' },
slideScene(),
'edit_interactive_html',
),
).toEqual({ snapshot: null, patch: null });
});
});