Files
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

598 lines
20 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, test } from 'vitest';
import {
applySlideEditOperation,
createSlideEditHistory,
redoSlideEditOperation,
undoSlideEditOperation,
} from '@/lib/edit/slide-ops';
import type { SlideContent } from '@/lib/types/stage';
import type { PPTElement, PPTLineElement, PPTTextElement } from '@openmaic/dsl';
function textElement(overrides: Partial<PPTTextElement> = {}): PPTTextElement {
return {
id: 'title',
type: 'text',
left: 100,
top: 80,
width: 420,
height: 90,
rotate: 0,
content: '<p>Original title</p>',
defaultFontName: 'Inter',
defaultColor: '#111827',
...overrides,
};
}
function lineElement(overrides: Partial<PPTLineElement> = {}): PPTLineElement {
return {
id: 'line-1',
type: 'line',
left: 100,
top: 100,
width: 200,
start: [0, 0],
end: [200, 100],
style: 'solid',
color: '#000000',
points: ['', ''],
...overrides,
};
}
function slideContent(elements: PPTElement[] = [textElement()]): SlideContent {
return {
type: 'slide',
canvas: {
id: 'slide-1',
viewportSize: 1000,
viewportRatio: 0.5625,
theme: {
backgroundColor: '#ffffff',
themeColors: ['#2563eb'],
fontColor: '#111827',
fontName: 'Inter',
},
elements,
},
};
}
describe('applySlideEditOperation', () => {
test('updates an element without mutating the original slide content', () => {
const original = slideContent();
const updated = applySlideEditOperation(original, {
type: 'element.update',
elementId: 'title',
patch: { left: 160, top: 120, rotate: 12 },
});
expect(updated.canvas.elements[0]).toMatchObject({ left: 160, top: 120, rotate: 12 });
expect(original.canvas.elements[0]).toMatchObject({ left: 100, top: 80, rotate: 0 });
});
test('updates text content only for text elements', () => {
const original = slideContent();
const updated = applySlideEditOperation(original, {
type: 'text.updateContent',
elementId: 'title',
content: '<p>Edited title</p>',
});
expect(updated.canvas.elements[0]).toMatchObject({ content: '<p>Edited title</p>' });
});
test('deletes an element and clears its animations', () => {
const original = slideContent([
textElement({ id: 'title' }),
textElement({ id: 'subtitle', content: '<p>Subtitle</p>' }),
]);
original.canvas.animations = [
{
id: 'anim-1',
elId: 'subtitle',
effect: 'fade',
type: 'in',
duration: 600,
trigger: 'click',
},
];
const updated = applySlideEditOperation(original, {
type: 'element.delete',
elementId: 'subtitle',
});
expect(updated.canvas.elements.map((element) => element.id)).toEqual(['title']);
expect(updated.canvas.animations).toEqual([]);
});
test('reorders an element by moving it to the requested index', () => {
const original = slideContent([
textElement({ id: 'background' }),
textElement({ id: 'title' }),
textElement({ id: 'caption' }),
]);
const updated = applySlideEditOperation(original, {
type: 'element.reorder',
elementId: 'background',
index: 2,
});
expect(updated.canvas.elements.map((element) => element.id)).toEqual([
'title',
'caption',
'background',
]);
expect(original.canvas.elements.map((element) => element.id)).toEqual([
'background',
'title',
'caption',
]);
});
test('updates multiple selected elements with the same patch', () => {
const original = slideContent([textElement({ id: 'title' }), textElement({ id: 'caption' })]);
const updated = applySlideEditOperation(original, {
type: 'element.updateMany',
elementIds: ['title', 'caption'],
patch: { lock: true },
});
expect(updated.canvas.elements.map((element) => element.lock)).toEqual([true, true]);
expect(original.canvas.elements.map((element) => element.lock)).toEqual([undefined, undefined]);
});
test('duplicates selected elements with caller-provided ids and offsets', () => {
const original = slideContent([textElement({ id: 'title' })]);
const updated = applySlideEditOperation(original, {
type: 'element.duplicate',
elementIds: ['title'],
idMap: { title: 'title-copy' },
offset: { x: 24, y: 16 },
});
expect(updated.canvas.elements.map((element) => element.id)).toEqual(['title', 'title-copy']);
expect(updated.canvas.elements[1]).toMatchObject({ left: 124, top: 96 });
expect(original.canvas.elements).toHaveLength(1);
});
test('deletes multiple selected elements and clears their animations', () => {
const original = slideContent([
textElement({ id: 'title' }),
textElement({ id: 'caption' }),
textElement({ id: 'footer' }),
]);
original.canvas.animations = [
{ id: 'anim-1', elId: 'title', effect: 'fade', type: 'in', duration: 600, trigger: 'click' },
{ id: 'anim-2', elId: 'footer', effect: 'fade', type: 'in', duration: 600, trigger: 'click' },
];
const updated = applySlideEditOperation(original, {
type: 'element.deleteMany',
elementIds: ['title', 'caption'],
});
expect(updated.canvas.elements.map((element) => element.id)).toEqual(['footer']);
expect(updated.canvas.animations?.map((animation) => animation.elId)).toEqual(['footer']);
});
test('aligns selected elements horizontally to the slide canvas', () => {
const original = slideContent([
textElement({ id: 'title', left: 100, top: 80, width: 200, height: 90 }),
textElement({ id: 'caption', left: 360, top: 180, width: 100, height: 60 }),
]);
const updated = applySlideEditOperation(original, {
type: 'element.align',
elementIds: ['title', 'caption'],
command: 'horizontal',
});
expect(updated.canvas.elements.map((element) => element.left)).toEqual([320, 580]);
});
test('removes element properties from selected elements', () => {
const original = slideContent([
textElement({
id: 'title',
outline: { width: 2, color: '#111111', style: 'solid' },
}),
]);
const updated = applySlideEditOperation(original, {
type: 'element.removeProps',
elementId: 'title',
propNames: ['outline'],
});
expect('outline' in updated.canvas.elements[0]).toBe(false);
expect('outline' in original.canvas.elements[0]).toBe(true);
});
});
describe('element.add', () => {
test('appends to the end when no index is given', () => {
const original = slideContent([textElement({ id: 'a' })]);
const updated = applySlideEditOperation(original, {
type: 'element.add',
element: textElement({ id: 'b' }),
});
expect(updated.canvas.elements.map((e) => e.id)).toEqual(['a', 'b']);
});
test('inserts at the requested index', () => {
const original = slideContent([textElement({ id: 'a' }), textElement({ id: 'c' })]);
const updated = applySlideEditOperation(original, {
type: 'element.add',
element: textElement({ id: 'b' }),
index: 1,
});
expect(updated.canvas.elements.map((e) => e.id)).toEqual(['a', 'b', 'c']);
});
test('clamps out-of-range index to the end of the list', () => {
const original = slideContent([textElement({ id: 'a' })]);
const updated = applySlideEditOperation(original, {
type: 'element.add',
element: textElement({ id: 'b' }),
index: 999,
});
expect(updated.canvas.elements.map((e) => e.id)).toEqual(['a', 'b']);
});
test('clamps negative index to 0', () => {
const original = slideContent([textElement({ id: 'a' })]);
const updated = applySlideEditOperation(original, {
type: 'element.add',
element: textElement({ id: 'b' }),
index: -5,
});
expect(updated.canvas.elements.map((e) => e.id)).toEqual(['b', 'a']);
});
test('throws when the new id collides with an existing element', () => {
const original = slideContent([textElement({ id: 'title' })]);
expect(() =>
applySlideEditOperation(original, {
type: 'element.add',
element: textElement({ id: 'title', content: '<p>Dup</p>' }),
}),
).toThrow(/already exists/);
});
});
describe('slide.update contract', () => {
test('rejects element / animation collections at runtime even via type cast', () => {
const original = slideContent();
expect(() =>
applySlideEditOperation(original, {
type: 'slide.update',
// `as never` defeats the SlideMetaPatch type narrowing — the runtime
// guard is the second line of defense for misuse from JS callers or
// anywhere a cast slips through.
patch: { elements: [] } as never,
}),
).toThrow(/dedicated/);
});
test('applies meta-only patches (theme/background) successfully', () => {
const original = slideContent();
const updated = applySlideEditOperation(original, {
type: 'slide.update',
patch: { background: { type: 'solid', color: '#000000' } },
});
expect(updated.canvas.background).toEqual({ type: 'solid', color: '#000000' });
});
});
describe('element.duplicate contract', () => {
test('uses the default offset {x:20, y:20} when no offset is given', () => {
const original = slideContent([textElement({ id: 'a', left: 100, top: 50 })]);
const updated = applySlideEditOperation(original, {
type: 'element.duplicate',
elementIds: ['a'],
idMap: { a: 'a-copy' },
});
expect(updated.canvas.elements[1]).toMatchObject({ id: 'a-copy', left: 120, top: 70 });
});
test('throws when idMap is missing an entry for a selected id', () => {
const original = slideContent([textElement({ id: 'a' }), textElement({ id: 'b' })]);
expect(() =>
applySlideEditOperation(original, {
type: 'element.duplicate',
elementIds: ['a', 'b'],
idMap: { a: 'a-copy' },
}),
).toThrow(/missing entries/);
});
test('throws when a new id would collide with an existing element', () => {
const original = slideContent([textElement({ id: 'a' }), textElement({ id: 'b' })]);
expect(() =>
applySlideEditOperation(original, {
type: 'element.duplicate',
elementIds: ['a'],
idMap: { a: 'b' },
}),
).toThrow(/collide/);
});
test('deep-clones nested fields so the duplicate cannot leak mutations to the source', () => {
// A line element carries a mutable tuple (start). A shallow spread would
// share the same array between source and duplicate; a subsequent op
// that mutates the duplicate's start in place would silently mutate the
// source too. After the deep clone the two are independent.
const original = slideContent([lineElement({ id: 'l1', start: [0, 0], end: [10, 10] })]);
const updated = applySlideEditOperation(original, {
type: 'element.duplicate',
elementIds: ['l1'],
idMap: { l1: 'l1-copy' },
});
const source = updated.canvas.elements[0] as PPTLineElement;
const dup = updated.canvas.elements[1] as PPTLineElement;
expect(source.start).not.toBe(dup.start);
expect(source.end).not.toBe(dup.end);
expect(source.points).not.toBe(dup.points);
});
});
describe('element.align all directions', () => {
function twoBoxes(): SlideContent {
return slideContent([
textElement({ id: 'a', left: 100, top: 80, width: 200, height: 90 }),
textElement({ id: 'b', left: 360, top: 180, width: 100, height: 60 }),
]);
}
test('top aligns the group to the top edge of the canvas', () => {
const updated = applySlideEditOperation(twoBoxes(), {
type: 'element.align',
elementIds: ['a', 'b'],
command: 'top',
});
// group's minY is 80, so subtract 80 from each top
expect(updated.canvas.elements.map((e) => e.top)).toEqual([0, 100]);
});
test('bottom aligns the group to the bottom edge of the canvas', () => {
const updated = applySlideEditOperation(twoBoxes(), {
type: 'element.align',
elementIds: ['a', 'b'],
command: 'bottom',
});
// viewportHeight = 1000 * 0.5625 = 562.5; group's maxY = max(80+90, 180+60) = 240
// offsetY = 240 - 562.5 = -322.5; new tops = 80 - (-322.5) = 402.5, 180 - (-322.5) = 502.5
expect(updated.canvas.elements.map((e) => e.top)).toEqual([402.5, 502.5]);
});
test('left aligns the group to the left edge of the canvas', () => {
const updated = applySlideEditOperation(twoBoxes(), {
type: 'element.align',
elementIds: ['a', 'b'],
command: 'left',
});
// group's minX = 100, so subtract 100 from each left
expect(updated.canvas.elements.map((e) => e.left)).toEqual([0, 260]);
});
test('right aligns the group to the right edge of the canvas', () => {
const updated = applySlideEditOperation(twoBoxes(), {
type: 'element.align',
elementIds: ['a', 'b'],
command: 'right',
});
// viewportWidth = 1000; group's maxX = max(100+200, 360+100) = 460
// offsetX = 460 - 1000 = -540; new lefts = 100 - (-540) = 640, 360 - (-540) = 900
expect(updated.canvas.elements.map((e) => e.left)).toEqual([640, 900]);
});
test('vertical centers the group on the vertical axis', () => {
const updated = applySlideEditOperation(twoBoxes(), {
type: 'element.align',
elementIds: ['a', 'b'],
command: 'vertical',
});
// group's midY = 80 + (240 - 80) / 2 = 160; canvasMidY = 562.5 / 2 = 281.25
// offsetY = 160 - 281.25 = -121.25
expect(updated.canvas.elements.map((e) => e.top)).toEqual([201.25, 301.25]);
});
test('center centers the group on both axes', () => {
const updated = applySlideEditOperation(twoBoxes(), {
type: 'element.align',
elementIds: ['a', 'b'],
command: 'center',
});
expect(updated.canvas.elements.map((e) => e.left)).toEqual([320, 580]);
expect(updated.canvas.elements.map((e) => e.top)).toEqual([201.25, 301.25]);
});
test('uses canonical geometry for line elements (start/end, not width/height=0)', () => {
// A line visually spanning (left+0, top+0) to (left+200, top+100).
// The old local fork ignored start/end and treated the line as height 0,
// so 'bottom' would have aligned by line.top alone. The canonical helper
// uses start/end so the real extent (top..top+end[1]) drives the offset.
const original = slideContent([
lineElement({ id: 'line-1', left: 100, top: 50, start: [0, 0], end: [200, 100] }),
]);
const updated = applySlideEditOperation(original, {
type: 'element.align',
elementIds: ['line-1'],
command: 'bottom',
});
// viewportHeight = 562.5; line maxY = top + end[1] = 50 + 100 = 150
// offsetY = 150 - 562.5 = -412.5; new top = 50 - (-412.5) = 462.5
expect(updated.canvas.elements[0].top).toBe(462.5);
});
test('uses rotated bounding box for rotated elements', () => {
// A 100×100 square rotated 45° fills an axis-aligned box wider than 100,
// anchored at its center. The canonical helper accounts for rotation;
// the old local fork used the unrotated rect and would put left at 0.
const original = slideContent([
textElement({ id: 'r', left: 100, top: 100, width: 100, height: 100, rotate: 45 }),
]);
const updated = applySlideEditOperation(original, {
type: 'element.align',
elementIds: ['r'],
command: 'left',
});
// Pre-fix: unrotated minX = 100 → after align left, left ends at 0.
// Post-fix: rotated OOBB minX < 100 → after align, left ends > 0.
expect(updated.canvas.elements[0].left).toBeGreaterThan(0);
expect(updated.canvas.elements[0].left).toBeLessThan(100);
});
});
describe('no-op operations skip history push', () => {
test('element.update against a missing id returns the same content reference', () => {
const original = slideContent();
const updated = applySlideEditOperation(original, {
type: 'element.update',
elementId: 'nope',
patch: { left: 999 },
});
expect(updated).toBe(original);
});
test('element.delete against a missing id returns the same content reference', () => {
const original = slideContent();
const updated = applySlideEditOperation(original, {
type: 'element.delete',
elementId: 'nope',
});
expect(updated).toBe(original);
});
test('element.deleteMany against unmatched ids returns the same content reference', () => {
const original = slideContent();
const updated = applySlideEditOperation(original, {
type: 'element.deleteMany',
elementIds: ['nope-1', 'nope-2'],
});
expect(updated).toBe(original);
});
test('element.reorder against a missing id returns the same content reference', () => {
const original = slideContent();
const updated = applySlideEditOperation(original, {
type: 'element.reorder',
elementId: 'nope',
index: 0,
});
expect(updated).toBe(original);
});
test('element.removeProps against a missing id returns the same content reference', () => {
const original = slideContent();
const updated = applySlideEditOperation(original, {
type: 'element.removeProps',
elementId: 'nope',
propNames: ['outline'],
});
expect(updated).toBe(original);
});
test('text.updateContent against a missing id returns the same content reference', () => {
const original = slideContent();
const updated = applySlideEditOperation(original, {
type: 'text.updateContent',
elementId: 'nope',
content: '<p>X</p>',
});
expect(updated).toBe(original);
});
test('element.align with empty selection returns the same content reference', () => {
const original = slideContent();
const updated = applySlideEditOperation(original, {
type: 'element.align',
elementIds: [],
command: 'center',
});
expect(updated).toBe(original);
});
test('history is unchanged when the underlying op is a no-op', () => {
const original = slideContent();
const history = createSlideEditHistory(original);
const next = applySlideEditOperation(history, {
type: 'element.update',
elementId: 'nope',
patch: { left: 1 },
});
expect(next).toBe(history);
expect(next.past).toHaveLength(0);
});
});
describe('slide edit history', () => {
test('undoes and redoes operations using immutable snapshots', () => {
const original = slideContent();
let history = createSlideEditHistory(original);
history = applySlideEditOperation(history, {
type: 'element.update',
elementId: 'title',
patch: { left: 200 },
});
expect(history.present.canvas.elements[0].left).toBe(200);
history = undoSlideEditOperation(history);
expect(history.present.canvas.elements[0].left).toBe(100);
history = redoSlideEditOperation(history);
expect(history.present.canvas.elements[0].left).toBe(200);
});
test('clears the redo stack after a new op following undo', () => {
let history = createSlideEditHistory(slideContent());
history = applySlideEditOperation(history, {
type: 'element.update',
elementId: 'title',
patch: { left: 200 },
});
history = undoSlideEditOperation(history);
expect(history.future).toHaveLength(1);
// A new op branches the timeline and should drop the redo stack.
history = applySlideEditOperation(history, {
type: 'element.update',
elementId: 'title',
patch: { left: 300 },
});
expect(history.future).toEqual([]);
expect(history.present.canvas.elements[0].left).toBe(300);
});
test('caps past length so long edit sessions do not grow unbounded', () => {
let history = createSlideEditHistory(slideContent());
// More ops than MAX_HISTORY (50) — past should be clamped, present is current.
for (let i = 0; i < 70; i++) {
history = applySlideEditOperation(history, {
type: 'element.update',
elementId: 'title',
patch: { left: 100 + i },
});
}
expect(history.past.length).toBeLessThanOrEqual(50);
expect(history.present.canvas.elements[0].left).toBe(169);
});
});