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

579 lines
18 KiB
TypeScript

import type { PPTElement, PPTShapeElement, PPTTextElement } from '@openmaic/dsl';
import { describe, expect, it } from 'vitest';
import { mapElementJsonPatchToEditIntents } from '@/lib/agent/tools/edit-elements-patch';
function textElement(overrides: Partial<PPTTextElement> = {}): PPTTextElement {
return {
id: 'title',
type: 'text',
left: 100,
top: 80,
width: 400,
height: 80,
rotate: 0,
content: '<p>Old title</p>',
defaultFontName: 'Inter',
defaultColor: '#111111',
lineHeight: 1.5,
...overrides,
};
}
function shapeElement(overrides: Partial<PPTShapeElement> = {}): PPTShapeElement {
return {
id: 'shape-1',
type: 'shape',
left: 50,
top: 50,
width: 200,
height: 100,
rotate: 0,
fixedRatio: false,
viewBox: [200, 100],
path: 'M 0 0 L 200 0 L 200 100 L 0 100 Z',
fill: '#ffffff',
text: {
content: '<p>Old label</p>',
defaultFontName: 'Inter',
defaultColor: '#222222',
align: 'middle',
},
...overrides,
};
}
describe('mapElementJsonPatchToEditIntents', () => {
it('maps one guarded text content and style patch into one atomic intent batch', () => {
const result = mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'replace', path: '/elements/0/content', value: '<p>New title</p>' },
{ op: 'replace', path: '/elements/0/defaultColor', value: '#2563eb' },
],
[textElement()],
);
expect(result).toEqual({
ok: true,
intents: [
{ type: 'element.update', id: 'title', props: { defaultColor: '#2563eb' } },
{
type: 'text.updateContent',
id: 'title',
content: '<p>New title</p>',
target: 'text',
},
],
targetIds: ['title'],
});
});
it('maps real shape.text paths to the existing flattened shape-text intent contract', () => {
const result = mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'shape-1' },
{ op: 'replace', path: '/elements/0/text/content', value: '<p>New label</p>' },
{ op: 'replace', path: '/elements/0/text/defaultColor', value: '#dc2626' },
{ op: 'replace', path: '/elements/0/text/align', value: 'bottom' },
],
[shapeElement()],
);
expect(result).toEqual({
ok: true,
intents: [
{
type: 'element.update',
id: 'shape-1',
props: { defaultColor: '#dc2626', vAlign: 'bottom' },
},
{
type: 'text.updateContent',
id: 'shape-1',
content: '<p>New label</p>',
target: 'shape',
},
],
targetIds: ['shape-1'],
});
});
it('enforces group cohesion for content-only and mixed content/style patches', () => {
const grouped = [
textElement({ id: 'group-a', groupId: 'group-1' }),
textElement({ id: 'group-b', groupId: 'group-1', left: 600 }),
];
const partial = mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'group-a' },
{ op: 'replace', path: '/elements/0/content', value: '<p>Changed</p>' },
],
grouped,
);
expect(partial.ok).toBe(false);
if (!partial.ok) expect(partial.reason).toMatch(/group.*missing.*group-b/i);
expect(
mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'group-a' },
{ op: 'replace', path: '/elements/0/content', value: '<p>Changed</p>' },
{ op: 'test', path: '/elements/1/id', value: 'group-b' },
{ op: 'replace', path: '/elements/1/defaultColor', value: '#2563eb' },
],
grouped,
),
).toMatchObject({ ok: true, targetIds: ['group-a', 'group-b'] });
});
it('supports nested replacement within an allowed structured style property', () => {
const result = mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'replace', path: '/elements/0/shadow/color', value: '#ff0000' },
],
[
textElement({
shadow: { h: 1, v: 2, blur: 3, color: '#000000' },
}),
],
);
expect(result).toMatchObject({
ok: true,
intents: [
{
type: 'element.update',
id: 'title',
props: { shadow: { color: '#ff0000' } },
},
],
});
});
it('patches one filter leaf without normalizing untouched legacy-unit siblings', () => {
const image = {
id: 'image-1',
type: 'image',
left: 10,
top: 20,
width: 300,
height: 200,
rotate: 0,
fixedRatio: true,
src: 'https://example.com/image.png',
filters: { blur: '2px', contrast: '90%' },
} as PPTElement;
expect(
mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'image-1' },
{ op: 'add', path: '/elements/0/filters/brightness', value: '120' },
],
[image],
),
).toEqual({
ok: true,
intents: [
{
type: 'element.update',
id: 'image-1',
props: { filters: { brightness: '120' } },
},
],
targetIds: ['image-1'],
});
});
it('preserves siblings created by a whole structured add before a nested edit', () => {
expect(
mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{
op: 'add',
path: '/elements/0/shadow',
value: { h: 1, v: 2, blur: 3, color: '#000000' },
},
{ op: 'replace', path: '/elements/0/shadow/blur', value: 4 },
],
[textElement()],
),
).toEqual({
ok: true,
intents: [
{
type: 'element.update',
id: 'title',
props: { shadow: { h: 1, v: 2, blur: 4, color: '#000000' } },
},
],
targetIds: ['title'],
});
});
it('marks a whole structured-property replace so omitted siblings are removed', () => {
const image = {
id: 'image-1',
type: 'image',
left: 10,
top: 20,
width: 300,
height: 200,
rotate: 0,
fixedRatio: true,
src: 'https://example.com/image.png',
filters: { blur: '2', contrast: '90' },
outline: { width: 1, style: 'solid', color: '#000000' },
shadow: { h: 1, v: 2, blur: 3, color: '#000000' },
} as PPTElement;
expect(
mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'image-1' },
{ op: 'replace', path: '/elements/0/filters', value: { brightness: '120' } },
{
op: 'replace',
path: '/elements/0/outline',
value: { width: 2, color: '#ff0000' },
},
{
op: 'replace',
path: '/elements/0/shadow',
value: { h: 4, v: 5, blur: 6, color: '#00ff00' },
},
],
[image],
),
).toEqual({
ok: true,
intents: [
{
type: 'element.removeProps',
id: 'image-1',
props: ['filters', 'outline', 'shadow'],
},
{
type: 'element.update',
id: 'image-1',
props: {
filters: { brightness: '120' },
outline: { width: 2, color: '#ff0000' },
shadow: { h: 4, v: 5, blur: 6, color: '#00ff00' },
},
},
],
targetIds: ['image-1'],
});
});
it('accepts identity replacements whose effect is clearing active structured styles', () => {
const image = {
id: 'image-1',
type: 'image',
left: 10,
top: 20,
width: 300,
height: 200,
rotate: 0,
fixedRatio: true,
src: 'https://example.com/image.png',
filters: { blur: '2', contrast: '90' },
outline: { width: 2, style: 'solid', color: '#ff0000' },
shadow: { h: 1, v: 2, blur: 3, color: '#000000' },
} as PPTElement;
expect(
mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'image-1' },
{ op: 'replace', path: '/elements/0/filters', value: { brightness: '100' } },
{ op: 'replace', path: '/elements/0/outline', value: { width: 0 } },
{
op: 'replace',
path: '/elements/0/shadow',
value: { h: 0, v: 0, blur: 0, color: 'transparent' },
},
],
[image],
),
).toEqual({
ok: true,
intents: [
{
type: 'element.removeProps',
id: 'image-1',
props: ['filters', 'outline', 'shadow'],
},
{
type: 'element.update',
id: 'image-1',
props: {
filters: { brightness: '100' },
outline: { width: 0 },
shadow: { h: 0, v: 0, blur: 0, color: 'transparent' },
},
},
],
targetIds: ['image-1'],
});
});
it('adds an absent optional style without allowing element insertion', () => {
const result = mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'add', path: '/elements/0/opacity', value: 0.5 },
{
op: 'add',
path: '/elements/0/shadow',
value: { h: 1, v: 2, blur: 3, color: '#000000' },
},
],
[textElement()],
);
expect(result).toMatchObject({
ok: true,
intents: [
{
type: 'element.update',
id: 'title',
props: {
opacity: 0.5,
shadow: { h: 1, v: 2, blur: 3, color: '#000000' },
},
},
],
});
expect(
mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'add', path: '/elements/-', value: textElement({ id: 'new' }) },
],
[textElement()],
),
).toEqual({ ok: false, reason: 'path /elements/- does not target an existing element' });
expect(
mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'add', path: '/elements/0/width', value: 300 },
],
[textElement()],
),
).toEqual({
ok: false,
reason: '/elements/0/width: add only supports an absent optional property',
});
});
it('requires an id test before each indexed element replacement', () => {
expect(
mapElementJsonPatchToEditIntents(
[{ op: 'replace', path: '/elements/0/defaultColor', value: '#2563eb' }],
[textElement()],
),
).toEqual({
ok: false,
reason: 'element index 0 must be guarded by a preceding id test',
});
});
it('refuses a stale or mis-indexed id test', () => {
expect(
mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'not-title' },
{ op: 'replace', path: '/elements/0/defaultColor', value: '#2563eb' },
],
[textElement()],
),
).toEqual({
ok: false,
reason: 'test failed at /elements/0/id',
});
});
it('refuses unsupported operations and paths', () => {
expect(
mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'remove', path: '/elements/0' },
],
[textElement()],
),
).toEqual({ ok: false, reason: 'operation 1 uses unsupported op "remove"' });
expect(
mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'replace', path: '/elements/0/id', value: 'renamed' },
],
[textElement()],
),
).toEqual({ ok: false, reason: 'path /elements/0/id is not editable' });
});
it('refuses locked elements before producing any intents', () => {
expect(
mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'replace', path: '/elements/0/defaultColor', value: '#2563eb' },
],
[textElement({ lock: true })],
),
).toEqual({ ok: false, reason: 'element "title" is locked' });
});
it('validates the final element schema and refuses the whole batch', () => {
const elements: PPTElement[] = [textElement()];
const result = mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'replace', path: '/elements/0/defaultColor', value: '#2563eb' },
{ op: 'replace', path: '/elements/0/content', value: 42 },
],
elements,
);
expect(result).toEqual({ ok: false, reason: 'text content must be a string' });
expect(elements[0]).toEqual(textElement());
});
it.each([
'<img src=x onerror="alert(1)">',
'<script>alert(1)</script><p>Safe</p>',
'<svg><a href="javascript:alert(1)">x</a></svg>',
'<p style="background-image:url(javascript:alert(1))">x</p>',
'<p style="background: \\75rl(https://attacker.example/pixel)">x</p>',
'<div style="position:fixed;left:0;top:0;width:100vw;height:100vh">x</div>',
])('refuses unsafe rich text HTML: %s', (content) => {
const result = mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'replace', path: '/elements/0/content', value: content },
],
[textElement()],
);
expect(result).toEqual({ ok: false, reason: 'text content contains unsafe HTML' });
});
it.each([
'<p style="text-align: center">New title</p>',
'<span style="color: #ff0000">New title&nbsp;</span>',
'<p>New title<br class="ProseMirror-trailingBreak"></p>',
'<ol start="2"><li value="3"><span data-mark="kept">New title</span></li></ol>',
'<p data-indent="2"><mark data-index="1"><span style="text-decoration-line: line-through;">New title</span></mark></p>',
'<div data-pptx-text-warp="archUp" style="position: relative;width: 100%;height: 42px;white-space: nowrap;"><span style="position: absolute;left: 10%;top: 20%;font-size: 24pt;line-height: 1;transform: translate(-50%, -50%) rotate(12deg);transform-origin: center center;white-space: nowrap;">New title</span></div>',
'<p style="margin-left: 30px;text-indent: -30px;padding-top: 2pt;"><span style="display:inline-block;width:30px;text-indent:0;padding-left:16px;padding-right:0.4em;box-sizing:border-box;">•</span>New title</p>',
])('accepts safe editor HTML despite sanitizer canonicalization: %s', (content) => {
const result = mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'replace', path: '/elements/0/content', value: content },
],
[textElement()],
);
expect(result).toMatchObject({
ok: true,
intents: [{ type: 'text.updateContent', id: 'title', content, target: 'text' }],
});
});
it('refuses a slide with duplicate ids before indexed patches can collapse by id', () => {
const result = mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'replace', path: '/elements/0/top', value: 10 },
{ op: 'test', path: '/elements/1/id', value: 'title' },
{ op: 'replace', path: '/elements/1/top', value: 30 },
],
[textElement(), textElement({ top: 20 })],
);
expect(result).toEqual({
ok: false,
reason: 'slide contains duplicate element id "title"',
});
});
it('refuses direct replacement of text too large to expose exactly', () => {
const result = mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'replace', path: '/elements/0/content', value: '<p>Short replacement</p>' },
],
[textElement({ content: `<p>${'x'.repeat(30001)}</p>` })],
);
expect(result).toEqual({
ok: false,
reason: 'text content is too large for exact JSON Patch replacement',
});
});
it.each([
{
element: textElement(),
path: '/elements/0/content',
value: `<p>${'x'.repeat(30001)}</p>`,
reason: 'new text content is too large for exact JSON Patch replacement',
},
{
element: shapeElement(),
path: '/elements/0/text/content',
value: `<p>${'\u0000'.repeat(10001)}</p>`,
reason: 'new shape text content is too large for exact JSON Patch replacement',
},
])('refuses an oversized new content value at $path', ({ element, path, value, reason }) => {
const result = mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: element.id },
{ op: 'replace', path, value },
],
[element],
);
expect(result).toEqual({ ok: false, reason });
});
it('refuses values that the legacy intent layer would silently normalize', () => {
const result = mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'replace', path: '/elements/0/width', value: 1 },
{ op: 'replace', path: '/elements/0/rotate', value: 360 },
],
[textElement()],
);
expect(result).toEqual({
ok: false,
reason:
'patch values for element "title" would be normalized; submit canonical values already within renderer bounds and units',
});
});
it('refuses empty and no-op batches', () => {
expect(mapElementJsonPatchToEditIntents([], [textElement()])).toEqual({
ok: false,
reason: 'no JSON Patch operations proposed',
});
expect(
mapElementJsonPatchToEditIntents(
[
{ op: 'test', path: '/elements/0/id', value: 'title' },
{ op: 'replace', path: '/elements/0/defaultColor', value: '#111111' },
],
[textElement()],
),
).toEqual({ ok: false, reason: 'patch does not change any element' });
});
});