191 lines
6.8 KiB
TypeScript
191 lines
6.8 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
createDefaultImageElement,
|
|
createDefaultSlide,
|
|
createDefaultTextElement,
|
|
} from '@/lib/edit/slide-edit-elements';
|
|
import { commitSlideEdit, deriveSlideEditOperations } from '@/lib/edit/scene-edit-bridge';
|
|
import {
|
|
createSlideEditHistory,
|
|
redoSlideEditOperation,
|
|
undoSlideEditOperation,
|
|
} from '@/lib/edit/slide-ops';
|
|
import type { PPTTextElement } from '@openmaic/dsl';
|
|
import type { SlideContent } from '@/lib/types/stage';
|
|
|
|
// The fixture's element 0 is always the default text element; narrow so
|
|
// tests can touch text-only geometry props (PPTLineElement has no
|
|
// height/rotate, so the bare union does not expose them).
|
|
const txt = (c: SlideContent) => c.canvas.elements[0] as PPTTextElement;
|
|
|
|
/**
|
|
* The slide renderer commits geometry edits by handing the surface a whole
|
|
* post-edit SlideContent (via the scene-context bridge). `deriveSlideEditOperations`
|
|
* turns that snapshot diff back into the canonical slide-ops the kernel + PPTX
|
|
* export understand, so the surface owns a real op/undo history instead of an
|
|
* opaque content blob.
|
|
*/
|
|
function makeContent(): SlideContent {
|
|
const slide = createDefaultSlide('slide-1');
|
|
slide.elements.push(createDefaultTextElement('text-1'));
|
|
return { type: 'slide', canvas: slide };
|
|
}
|
|
|
|
function clone(content: SlideContent): SlideContent {
|
|
return structuredClone(content);
|
|
}
|
|
|
|
describe('deriveSlideEditOperations', () => {
|
|
it('returns no ops when nothing changed', () => {
|
|
const prev = makeContent();
|
|
const next = clone(prev);
|
|
expect(deriveSlideEditOperations(prev, next)).toEqual([]);
|
|
});
|
|
|
|
it('emits a single element.update with only the moved geometry keys', () => {
|
|
const prev = makeContent();
|
|
const next = clone(prev);
|
|
next.canvas.elements[0].left = 320;
|
|
next.canvas.elements[0].top = 240;
|
|
|
|
expect(deriveSlideEditOperations(prev, next)).toEqual([
|
|
{ type: 'element.update', elementId: 'text-1', patch: { left: 320, top: 240 } },
|
|
]);
|
|
});
|
|
|
|
it('captures resize (width/height) and rotate in the patch', () => {
|
|
const prev = makeContent();
|
|
const next = clone(prev);
|
|
txt(next).width = 500;
|
|
txt(next).height = 300;
|
|
txt(next).rotate = 45;
|
|
|
|
expect(deriveSlideEditOperations(prev, next)).toEqual([
|
|
{
|
|
type: 'element.update',
|
|
elementId: 'text-1',
|
|
patch: { width: 500, height: 300, rotate: 45 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('emits one element.update per changed element for a multi-element move', () => {
|
|
const prev = makeContent();
|
|
prev.canvas.elements.push(createDefaultImageElement('img-1', 'https://example.com/a.png'));
|
|
const next = clone(prev);
|
|
next.canvas.elements[0].left += 10;
|
|
next.canvas.elements[1].top += 25;
|
|
|
|
const ops = deriveSlideEditOperations(prev, next);
|
|
expect(ops).toHaveLength(2);
|
|
expect(ops).toContainEqual({
|
|
type: 'element.update',
|
|
elementId: 'text-1',
|
|
patch: { left: prev.canvas.elements[0].left + 10 },
|
|
});
|
|
expect(ops).toContainEqual({
|
|
type: 'element.update',
|
|
elementId: 'img-1',
|
|
patch: { top: prev.canvas.elements[1].top + 25 },
|
|
});
|
|
});
|
|
|
|
it('emits element.add for a new element with its insertion index', () => {
|
|
const prev = makeContent();
|
|
const next = clone(prev);
|
|
const added = createDefaultImageElement('img-9', 'https://example.com/x.png');
|
|
next.canvas.elements.push(added);
|
|
|
|
expect(deriveSlideEditOperations(prev, next)).toEqual([
|
|
{ type: 'element.add', element: added, index: 1 },
|
|
]);
|
|
});
|
|
|
|
it('emits element.delete for a removed element', () => {
|
|
const prev = makeContent();
|
|
prev.canvas.elements.push(createDefaultImageElement('img-1', 'https://example.com/a.png'));
|
|
const next = clone(prev);
|
|
next.canvas.elements = next.canvas.elements.filter((el) => el.id !== 'img-1');
|
|
|
|
expect(deriveSlideEditOperations(prev, next)).toEqual([
|
|
{ type: 'element.delete', elementId: 'img-1' },
|
|
]);
|
|
});
|
|
|
|
it('emits element.removeProps when a top-level prop is dropped', () => {
|
|
const prev = makeContent();
|
|
const next = clone(prev);
|
|
delete txt(next).lineHeight;
|
|
|
|
expect(deriveSlideEditOperations(prev, next)).toEqual([
|
|
{ type: 'element.removeProps', elementId: 'text-1', propNames: ['lineHeight'] },
|
|
]);
|
|
});
|
|
|
|
it('emits slide.update for canvas-meta changes (not elements)', () => {
|
|
const prev = makeContent();
|
|
const next = clone(prev);
|
|
next.canvas.background = { type: 'solid', color: '#ff0000' };
|
|
|
|
expect(deriveSlideEditOperations(prev, next)).toEqual([
|
|
{ type: 'slide.update', patch: { background: { type: 'solid', color: '#ff0000' } } },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('commitSlideEdit', () => {
|
|
it('records a single-element commit as one undo step', () => {
|
|
const history = createSlideEditHistory(makeContent());
|
|
const next = clone(history.present);
|
|
next.canvas.elements[0].left = 400;
|
|
|
|
const after = commitSlideEdit(history, next);
|
|
|
|
expect(after.past).toHaveLength(1);
|
|
expect(after.future).toEqual([]);
|
|
expect(after.present.canvas.elements[0].left).toBe(400);
|
|
// The undo target is the pre-commit content.
|
|
expect(undoSlideEditOperation(after).present.canvas.elements[0].left).toBe(
|
|
history.present.canvas.elements[0].left,
|
|
);
|
|
});
|
|
|
|
it('coalesces a multi-element commit into exactly one undo step', () => {
|
|
const base = makeContent();
|
|
base.canvas.elements.push(createDefaultImageElement('img-1', 'https://example.com/a.png'));
|
|
const history = createSlideEditHistory(base);
|
|
const next = clone(history.present);
|
|
next.canvas.elements[0].left += 30;
|
|
next.canvas.elements[1].top += 30;
|
|
|
|
const after = commitSlideEdit(history, next);
|
|
|
|
expect(after.past).toHaveLength(1);
|
|
const undone = undoSlideEditOperation(after);
|
|
expect(undone.present.canvas.elements[0].left).toBe(base.canvas.elements[0].left);
|
|
expect(undone.present.canvas.elements[1].top).toBe(base.canvas.elements[1].top);
|
|
});
|
|
|
|
it('is a no-op when the committed content is unchanged', () => {
|
|
const history = createSlideEditHistory(makeContent());
|
|
const after = commitSlideEdit(history, clone(history.present));
|
|
expect(after).toBe(history);
|
|
});
|
|
|
|
it('clears the redo stack on a fresh commit after undo', () => {
|
|
const history = createSlideEditHistory(makeContent());
|
|
const moved = clone(history.present);
|
|
moved.canvas.elements[0].left = 200;
|
|
const afterMove = commitSlideEdit(history, moved);
|
|
const afterUndo = undoSlideEditOperation(afterMove);
|
|
expect(redoSlideEditOperation(afterUndo).present.canvas.elements[0].left).toBe(200);
|
|
|
|
const resized = clone(afterUndo.present);
|
|
resized.canvas.elements[0].width = 999;
|
|
const afterCommit = commitSlideEdit(afterUndo, resized);
|
|
|
|
expect(afterCommit.future).toEqual([]);
|
|
expect(afterCommit.present.canvas.elements[0].width).toBe(999);
|
|
});
|
|
});
|