import { describe, expect, it, vi } from 'vitest'; import { makeEditElementsTool } from '@/lib/agent/tools/edit-elements'; import type { SceneContext } from '@/lib/agent/tools/regenerate-scene-actions'; import type { SceneContent } from '@/lib/types/stage'; import type { SceneOutline } from '@/lib/types/generation'; import type { PPTElement } from '@openmaic/dsl'; function outline(id: string, type: SceneOutline['type']): SceneOutline { return { id, type, title: 'Slide', description: 'd', keyPoints: [], order: 0, } as unknown as SceneOutline; } function slideCtx(id: string, elements: PPTElement[]): SceneContext { return { outline: outline(id, 'slide'), allOutlines: [outline(id, 'slide')], content: { type: 'slide', canvas: { id: 'c1', elements, viewportSize: 1000, viewportRatio: 0.5625 }, } as unknown as SceneContent, stageId: 'stage-1', }; } const title = { id: 'title-1', type: 'text', left: 100, top: 80, width: 400, height: 60, rotate: 0, content: '
Title
', defaultFontName: 'Arial', defaultColor: '#333333', } as unknown as PPTElement; describe('edit_elements tool', () => { it('maps direct JSON Patch to validated style and content intents without a nested LLM call', async () => { const aiCall = vi.fn(async () => 'must not be called'); const tool = makeEditElementsTool({ aiCall, getSceneContext: (id) => (id === 's1' ? slideCtx('s1', [title]) : undefined), }); const res = await tool.execute('call-1', { sceneId: 's1', reason: 'Update the title wording and color', patches: [ { op: 'test', path: '/elements/0/id', value: 'title-1' }, { op: 'replace', path: '/elements/0/content', value: 'New title
' }, { op: 'replace', path: '/elements/0/defaultColor', value: '#0000ff' }, ], }); expect((res as { isError?: boolean }).isError).toBeFalsy(); expect(res.details).toMatchObject({ sceneId: 's1', updateCount: 1, targetElementTypes: { 'title-1': 'text' }, targetElementFingerprints: { 'title-1': expect.any(String) }, inventoryFingerprint: expect.any(String), }); expect(res.details.intents).toEqual([ { type: 'element.update', id: 'title-1', props: { defaultColor: '#0000ff' }, }, { type: 'text.updateContent', id: 'title-1', content: 'New title
', target: 'text', }, ]); expect(aiCall).not.toHaveBeenCalled(); }); it('refuses an unguarded element patch', async () => { const tool = makeEditElementsTool({ aiCall: vi.fn(), getSceneContext: (id) => (id === 's1' ? slideCtx('s1', [title]) : undefined), }); const res = await tool.execute('call-1', { sceneId: 's1', reason: 'Move the title', patches: [{ op: 'replace', path: '/elements/0/top', value: 40 }], }); expect((res as { isError?: boolean }).isError).toBe(true); expect(res.details.intents).toBeNull(); expect(res.details.updateCount).toBe(0); expect((res.content[0] as { text?: string }).text).toMatch(/guarded/i); }); it('refuses non-slide scenes', async () => { const tool = makeEditElementsTool({ aiCall: vi.fn(), getSceneContext: () => ({ outline: outline('i1', 'interactive'), allOutlines: [outline('i1', 'interactive')], content: { type: 'interactive', html: '' } as unknown as SceneContent, stageId: 'stage-1', }), }); const res = await tool.execute('call-1', { sceneId: 'i1', reason: 'Make it blue', patches: [{ op: 'test', path: '/elements/0/id', value: 'x' }], }); expect((res as { isError?: boolean }).isError).toBe(true); expect(res.details.intents).toBeNull(); }); it('refuses an empty patch array', async () => { const tool = makeEditElementsTool({ aiCall: vi.fn(), getSceneContext: (id) => (id === 's1' ? slideCtx('s1', [title]) : undefined), }); const res = await tool.execute('call-1', { sceneId: 's1', reason: 'No changes', patches: [], }); expect((res as { isError?: boolean }).isError).toBe(true); }); });