723 lines
23 KiB
TypeScript
723 lines
23 KiB
TypeScript
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<string, { content: SlideContent }> = {};
|
|
|
|
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> = {}): PPTElement {
|
|
return {
|
|
id,
|
|
type: 'text',
|
|
left: 100,
|
|
top: 80,
|
|
width: 400,
|
|
height: 60,
|
|
rotate: 0,
|
|
content: '<p>Title</p>',
|
|
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: '<p>Updated title</p>',
|
|
target: 'text',
|
|
},
|
|
]);
|
|
|
|
expect(result).toEqual({ ok: true });
|
|
expect(applyTransaction).toHaveBeenCalledTimes(1);
|
|
const next = getAppliedContent();
|
|
expect(next.canvas.elements[0]).toMatchObject({
|
|
defaultColor: '#00f',
|
|
content: '<p>Updated title</p>',
|
|
});
|
|
});
|
|
|
|
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: '<p>Shape</p>', defaultFontName: 'Arial', defaultColor: '#333' },
|
|
} as PPTElement;
|
|
mockSession.sceneId = 's1';
|
|
mockSession.history = { present: slideWith([shape]) };
|
|
|
|
const result = applyEditElementsIntents(
|
|
's1',
|
|
[{ type: 'text.updateContent', id: 'a', content: '<p>Updated</p>', 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: '<p>A</p>',
|
|
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: '<p>B</p>',
|
|
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: '<p>Changed</p>',
|
|
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<PPTElement>,
|
|
},
|
|
]);
|
|
|
|
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<PPTElement>,
|
|
},
|
|
]);
|
|
|
|
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<PPTElement>,
|
|
},
|
|
{ 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();
|
|
});
|
|
});
|