项目文件夹

文件
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 行
20 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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);
});
});