import { beforeEach, describe, expect, it, vi } from 'vitest'; import { applyEditorTransaction, type EditorTransaction } from '@openmaic/editor/core'; import type { EditIntent } from '@openmaic/editor/react'; import type { PPTElement } from '@openmaic/dsl'; import type { SlideContent } from '@/lib/types/stage'; const commitContent = vi.fn(); let appliedContent: SlideContent | null = null; const applyTransaction = vi.fn((transaction: EditorTransaction) => { if (!mockSession.history) throw new Error('missing test edit session'); appliedContent = applyEditorTransaction(mockSession.history.present, transaction); }); const updateScene = vi.fn(); vi.mock('@/components/edit/surfaces/slide/slide-edit-session', () => ({ useSlideEditSession: { getState: () => ({ sceneId: mockSession.sceneId, history: mockSession.history, gestureActive: mockSession.gestureActive, commitContent, applyTransaction, }), }, })); vi.mock('@/lib/store/stage', () => ({ useStageStore: { getState: () => ({ getSceneById: (id: string) => mockScenes[id] ?? null, updateScene, }), }, })); const mockSession: { sceneId: string | null; history: { present: SlideContent } | null; gestureActive: boolean; } = { sceneId: null, history: null, gestureActive: false }; const mockScenes: Record = {}; function slideWith(elements: PPTElement[]): SlideContent { return { type: 'slide', canvas: { id: 'c1', viewportSize: 1000, viewportRatio: 0.5625, elements, }, } as SlideContent; } function textEl(id: string, overrides: Partial = {}): PPTElement { return { id, type: 'text', left: 100, top: 80, width: 400, height: 60, rotate: 0, content: '

Title

', defaultColor: '#333', defaultFontName: 'Arial', ...overrides, } as PPTElement; } function getAppliedContent(): SlideContent { if (!appliedContent) throw new Error('expected an editor transaction to be applied'); return appliedContent; } describe('applyEditElementsIntents', () => { beforeEach(() => { commitContent.mockReset(); applyTransaction.mockReset(); appliedContent = null; updateScene.mockReset(); mockSession.sceneId = null; mockSession.history = null; mockSession.gestureActive = false; for (const k of Object.keys(mockScenes)) delete mockScenes[k]; }); it('detects applyable intents', async () => { const { hasEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const intents: EditIntent[] = [{ type: 'element.update', id: 'a', props: { top: 10 } }]; expect(hasEditElementsIntents({ sceneId: 's1', intents })).toBe(true); expect(hasEditElementsIntents({ sceneId: 's1', intents: null })).toBe(false); expect(hasEditElementsIntents({ sceneId: 's1', intents: [] })).toBe(false); expect(hasEditElementsIntents({ intents })).toBe(false); }); it('refuses atomically when an id is missing at apply time (no partial)', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const present = slideWith([textEl('a')]); mockSession.sceneId = 's1'; mockSession.history = { present }; const result = applyEditElementsIntents('s1', [ { type: 'element.updateMany', updates: [ { id: 'a', props: { top: 10 } }, { id: 'gone', props: { top: 10 } }, ], }, ]); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toMatch(/gone|unknown|missing/i); expect(applyTransaction).not.toHaveBeenCalled(); }); it('refuses when a target is locked at apply time', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const present = slideWith([textEl('a', { lock: true })]); mockSession.sceneId = 's1'; mockSession.history = { present }; const result = applyEditElementsIntents('s1', [ { type: 'element.update', id: 'a', props: { top: 10 } }, ]); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toMatch(/locked/i); expect(applyTransaction).not.toHaveBeenCalled(); }); it('refuses when a target element changed type after the gate ran', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const shape = { id: 'a', type: 'shape', left: 100, top: 80, width: 400, height: 60, rotate: 0, viewBox: [0, 0], path: 'M0,0', fixedRatio: false, fill: '#eee', } as PPTElement; const present = slideWith([shape]); mockSession.sceneId = 's1'; mockSession.history = { present }; const result = applyEditElementsIntents( 's1', [{ type: 'element.update', id: 'a', props: { top: 10 } }], { a: 'text' }, ); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toMatch(/changed type/i); expect(applyTransaction).not.toHaveBeenCalled(); }); it('allows apply when the gate-time element type still matches', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const present = slideWith([textEl('a')]); mockSession.sceneId = 's1'; mockSession.history = { present }; const result = applyEditElementsIntents( 's1', [{ type: 'element.update', id: 'a', props: { top: 10 } }], { a: 'text' }, ); expect(result).toEqual({ ok: true }); expect(applyTransaction).toHaveBeenCalledTimes(1); }); it('refuses when a target changed after the gate inventory was captured', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const { elementInventoryFingerprint } = await import('@/lib/agent/tools/edit-elements-gate'); const gateTime = textEl('a'); const present = slideWith([textEl('a', { top: 95 })]); mockSession.sceneId = 's1'; mockSession.history = { present }; const result = applyEditElementsIntents( 's1', [{ type: 'element.update', id: 'a', props: { top: 10 } }], { a: 'text' }, { a: elementInventoryFingerprint(gateTime) }, ); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toMatch(/changed while the edit was being prepared/i); expect(applyTransaction).not.toHaveBeenCalled(); }); it('refuses when a non-target element changed after the prompt inventory was captured', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const { elementInventorySnapshotFingerprint } = await import('@/lib/agent/tools/edit-elements-gate'); const gateTime = [textEl('a'), textEl('reference', { left: 600 })]; const present = slideWith([textEl('a'), textEl('reference', { left: 700 })]); mockSession.sceneId = 's1'; mockSession.history = { present }; const result = applyEditElementsIntents( 's1', [{ type: 'element.update', id: 'a', props: { top: 200 } }], { a: 'text' }, undefined, elementInventorySnapshotFingerprint(gateTime), ); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toMatch(/slide elements changed while the edit was being prepared/i); expect(applyTransaction).not.toHaveBeenCalled(); }); it('detects concurrent shape vAlign changes through the gate fingerprint', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const { elementInventoryFingerprint } = await import('@/lib/agent/tools/edit-elements-gate'); const shape = (align: 'top' | 'middle' | 'bottom') => ({ id: 'sh1', type: 'shape', left: 10, top: 10, width: 100, height: 80, rotate: 0, viewBox: [100, 80], path: 'M0 0', fixedRatio: false, fill: '#fff', text: { content: 'Label', defaultFontName: 'Arial', defaultColor: '#111', align, }, }) as PPTElement; mockSession.sceneId = 's1'; mockSession.history = { present: slideWith([shape('bottom')]) }; const result = applyEditElementsIntents( 's1', [{ type: 'element.update', id: 'sh1', props: { vAlign: 'middle' } }], { sh1: 'shape' }, { sh1: elementInventoryFingerprint(shape('top')) }, ); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toMatch(/changed while the edit was being prepared/i); expect(applyTransaction).not.toHaveBeenCalled(); }); it('refuses while a canvas pointer gesture has uncommitted local state', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); mockSession.sceneId = 's1'; mockSession.history = { present: slideWith([textEl('a')]) }; mockSession.gestureActive = true; const result = applyEditElementsIntents('s1', [ { type: 'element.update', id: 'a', props: { top: 10 } }, ]); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toMatch(/gesture/i); expect(commitContent).not.toHaveBeenCalled(); }); it('commits one undo entry when session is open and targets are valid', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const present = slideWith([textEl('a')]); mockSession.sceneId = 's1'; mockSession.history = { present }; const result = applyEditElementsIntents('s1', [ { type: 'element.update', id: 'a', props: { top: 10, defaultColor: '#00f' } }, ]); expect(result).toEqual({ ok: true }); expect(applyTransaction).toHaveBeenCalledTimes(1); const next = getAppliedContent(); expect(applyTransaction.mock.calls[0][0]).toMatchObject({ origin: 'agent', history: 'record' }); expect(next.canvas.elements[0]).toMatchObject({ top: 10, defaultColor: '#00f' }); expect(updateScene).not.toHaveBeenCalled(); }); it('submits a validated agent batch through the editor transaction API', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); mockSession.sceneId = 's1'; mockSession.history = { present: slideWith([textEl('a')]) }; const result = applyEditElementsIntents('s1', [ { type: 'element.update', id: 'a', props: { top: 10 } }, ]); expect(result).toEqual({ ok: true }); expect(applyTransaction).toHaveBeenCalledWith( expect.objectContaining({ origin: 'agent', history: 'record', operations: [ expect.objectContaining({ type: 'element.update', elementId: 'a', patch: { top: 10 } }), ], }), ); expect(commitContent).not.toHaveBeenCalled(); }); it('applies text content and style intents in the same undo entry', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const present = slideWith([textEl('a')]); mockSession.sceneId = 's1'; mockSession.history = { present }; const result = applyEditElementsIntents('s1', [ { type: 'element.update', id: 'a', props: { defaultColor: '#00f' } }, { type: 'text.updateContent', id: 'a', content: '

Updated title

', target: 'text', }, ]); expect(result).toEqual({ ok: true }); expect(applyTransaction).toHaveBeenCalledTimes(1); const next = getAppliedContent(); expect(next.canvas.elements[0]).toMatchObject({ defaultColor: '#00f', content: '

Updated title

', }); }); it('refuses a text content intent when its target changed type at apply time', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const shape = { id: 'a', type: 'shape', left: 100, top: 80, width: 400, height: 60, rotate: 0, viewBox: [400, 60], path: 'M0 0', fixedRatio: false, fill: '#eee', text: { content: '

Shape

', defaultFontName: 'Arial', defaultColor: '#333' }, } as PPTElement; mockSession.sceneId = 's1'; mockSession.history = { present: slideWith([shape]) }; const result = applyEditElementsIntents( 's1', [{ type: 'text.updateContent', id: 'a', content: '

Updated

', target: 'text' }], { a: 'text' }, ); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toMatch(/changed type/i); expect(applyTransaction).not.toHaveBeenCalled(); }); it('revalidates group cohesion for content-only edits', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const grouped = [ { id: 'group-a', type: 'text', left: 10, top: 10, width: 200, height: 60, rotate: 0, groupId: 'group-1', content: '

A

', defaultFontName: 'Inter', defaultColor: '#111111', lineHeight: 1.5, }, { id: 'group-b', type: 'text', left: 300, top: 10, width: 200, height: 60, rotate: 0, groupId: 'group-1', content: '

B

', defaultFontName: 'Inter', defaultColor: '#111111', lineHeight: 1.5, }, ] as PPTElement[]; mockSession.sceneId = 's1'; mockSession.history = { present: slideWith(grouped) }; const result = applyEditElementsIntents('s1', [ { type: 'text.updateContent', id: 'group-a', content: '

Changed

', target: 'text', }, ]); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toMatch(/group.*missing.*group-b/i); expect(applyTransaction).not.toHaveBeenCalled(); }); it('merges partial nested style patches without dropping existing values', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const image = { id: 'img1', type: 'image', left: 10, top: 10, width: 100, height: 80, rotate: 0, fixedRatio: true, src: 'https://example.com/image.png', filters: { blur: '2px', contrast: '90%' }, } as PPTElement; mockSession.sceneId = 's1'; mockSession.history = { present: slideWith([image]) }; const result = applyEditElementsIntents('s1', [ { type: 'element.update', id: 'img1', props: { filters: { brightness: '120%' } } as Partial, }, ]); expect(result).toEqual({ ok: true }); const next = getAppliedContent(); expect(next.canvas.elements[0]).toMatchObject({ filters: { blur: '2px', contrast: '90%', brightness: '120%' }, }); }); it('replaces a whole structured style after its removeProps marker', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const image = { id: 'img1', type: 'image', left: 10, top: 10, width: 100, height: 80, rotate: 0, fixedRatio: true, src: 'https://example.com/image.png', filters: { blur: '2px', contrast: '90%' }, } as PPTElement; mockSession.sceneId = 's1'; mockSession.history = { present: slideWith([image]) }; const result = applyEditElementsIntents('s1', [ { type: 'element.removeProps', id: 'img1', props: ['filters'] }, { type: 'element.update', id: 'img1', props: { filters: { brightness: '120' } } as Partial, }, ]); expect(result).toEqual({ ok: true }); const next = getAppliedContent(); expect(next.canvas.elements[0]).toMatchObject({ filters: { brightness: '120' }, }); expect((next.canvas.elements[0] as { filters?: unknown }).filters).toEqual({ brightness: '120', }); }); it('refuses unpaired or out-of-contract removeProps markers', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const image = { id: 'img1', type: 'image', left: 10, top: 10, width: 100, height: 80, rotate: 0, fixedRatio: true, src: 'https://example.com/image.png', filters: { blur: '2px' }, } as PPTElement; mockSession.sceneId = 's1'; mockSession.history = { present: slideWith([image]) }; expect( applyEditElementsIntents('s1', [ { type: 'element.removeProps', id: 'img1', props: ['filters'] }, ]), ).toMatchObject({ ok: false }); expect( applyEditElementsIntents('s1', [ { type: 'element.update', id: 'img1', props: { filters: { brightness: '120' } } as Partial, }, { type: 'element.removeProps', id: 'img1', props: ['filters'] }, ]), ).toMatchObject({ ok: false }); expect( applyEditElementsIntents('s1', [ { type: 'element.removeProps', id: 'img1', props: ['src'] }, { type: 'element.update', id: 'img1', props: { src: 'https://example.com/new.png' } }, ]), ).toMatchObject({ ok: false }); expect(applyTransaction).not.toHaveBeenCalled(); }); it('clears higher-priority shape paints when applying a solid fill', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const shape = { id: 'sh1', type: 'shape', left: 10, top: 10, width: 100, height: 80, rotate: 0, viewBox: [100, 80], path: 'M0,0 L100,0 L100,80 Z', fixedRatio: false, fill: '#eee', pattern: 'https://example.com/pattern.png', gradient: { type: 'linear', colors: [ { pos: 0, color: '#000' }, { pos: 100, color: '#fff' }, ], rotate: 0, }, } as PPTElement; mockSession.sceneId = 's1'; mockSession.history = { present: slideWith([shape]) }; const result = applyEditElementsIntents('s1', [ { type: 'element.update', id: 'sh1', props: { fill: '#00f' } }, ]); expect(result).toEqual({ ok: true }); const next = getAppliedContent(); expect(next.canvas.elements[0]).toMatchObject({ fill: '#00f' }); expect(next.canvas.elements[0]).not.toHaveProperty('pattern'); expect(next.canvas.elements[0]).not.toHaveProperty('gradient'); }); it('recomputes formula-backed shape geometry when resizing', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const shape = { id: 'sh1', type: 'shape', left: 10, top: 10, width: 100, height: 80, rotate: 0, viewBox: [100, 80], path: 'old path', pathFormula: 'roundRect', keypoints: [0.125], fixedRatio: false, fill: '#eee', } as PPTElement; mockSession.sceneId = 's1'; mockSession.history = { present: slideWith([shape]) }; const result = applyEditElementsIntents('s1', [ { type: 'element.update', id: 'sh1', props: { width: 200, height: 120 } }, ]); expect(result).toEqual({ ok: true }); const next = getAppliedContent(); expect(next.canvas.elements[0]).toMatchObject({ viewBox: [200, 120] }); expect((next.canvas.elements[0] as { path: string }).path).not.toBe('old path'); }); it('keeps table row height in sync when resizing its height', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const table = { id: 'tb1', type: 'table', left: 10, top: 10, width: 300, height: 120, rotate: 0, cellMinHeight: 40, rowHeights: [50, 70], colWidths: [1], data: [[{ id: 'a', text: 'A' }], [{ id: 'b', text: 'B' }]], outline: { width: 1, color: '#000', style: 'solid' }, theme: { color: '#00f', rowHeader: false, rowFooter: false, colHeader: false, colFooter: false, }, } as unknown as PPTElement; mockSession.sceneId = 's1'; mockSession.history = { present: slideWith([table]) }; const result = applyEditElementsIntents('s1', [ { type: 'element.update', id: 'tb1', props: { height: 160 } }, ]); expect(result).toEqual({ ok: true }); const next = getAppliedContent(); expect(next.canvas.elements[0]).toMatchObject({ height: 160, cellMinHeight: 60, rowHeights: [50 * (160 / 120), 70 * (160 / 120)], }); }); it('refuses when no edit session is open (no irreversible fallback write)', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); mockScenes.s1 = { content: slideWith([textEl('a')]) }; const result = applyEditElementsIntents('s1', [ { type: 'element.update', id: 'a', props: { top: 10 } }, ]); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toMatch(/session/i); expect(updateScene).not.toHaveBeenCalled(); expect(applyTransaction).not.toHaveBeenCalled(); }); it('maps shape text chrome onto shape.text instead of top-level', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const shape = { id: 'sh1', type: 'shape', left: 10, top: 10, width: 100, height: 80, rotate: 0, viewBox: [0, 0], path: 'M0,0', fixedRatio: false, fill: '#eee', text: { content: 'Label', defaultFontName: 'Arial', defaultColor: '#111', align: 'middle', }, } as PPTElement; mockSession.sceneId = 's1'; mockSession.history = { present: slideWith([shape]) }; const result = applyEditElementsIntents('s1', [ { type: 'element.update', id: 'sh1', props: { defaultColor: '#00f' } }, ]); expect(result).toEqual({ ok: true }); const next = getAppliedContent(); const el = next.canvas.elements[0] as { defaultColor?: string; text?: { defaultColor?: string }; }; expect(el.defaultColor).toBeUndefined(); expect(el.text?.defaultColor).toBe('#00f'); }); it('refuses styling text chrome on a shape with no label', async () => { const { applyEditElementsIntents } = await import('@/lib/agent/client/apply-edit-elements'); const shape = { id: 'sh1', type: 'shape', left: 10, top: 10, width: 100, height: 80, rotate: 0, viewBox: [0, 0], path: 'M0,0', fixedRatio: false, fill: '#eee', } as PPTElement; mockSession.sceneId = 's1'; mockSession.history = { present: slideWith([shape]) }; const result = applyEditElementsIntents('s1', [ { type: 'element.update', id: 'sh1', props: { defaultColor: '#00f' } }, ]); expect(result.ok).toBe(false); if (result.ok) return; expect(result.reason).toMatch(/no text label/i); expect(applyTransaction).not.toHaveBeenCalled(); }); });