/* ===================================================================== * op.test.ts — Op 引擎核心语义测试 * 保护对象:applyOp 正向应用 / invertOp 逆操作 / undo 往返一致性 * 这些是 undo/redo 正确性的地基,任何改动必须保持以下断言成立 * ===================================================================== */ import { describe, it, expect } from 'vitest' import { applyOp, invertOp, type Op } from '../src/core/op' import type { Deck, Slide, SlideElement } from '../src/core/types' /* ---------- 测试数据工厂 ---------- */ function el(id: string, over: Partial = {}): SlideElement { return { id, type: 'text', x: 10, y: 10, w: 40, h: 15, content: id, style: { fontSize: 24 }, ...over } } function slide(id: string, elements: SlideElement[] = []): Slide { return { id, background: 'bg', elements } } function deck(slides: Slide[]): Deck { return { v: 3, theme: 'indigo', slides, chatId: 'test' } as Deck } const D1 = deck([slide('s1', [el('a'), el('b')]), slide('s2', [el('c')])]) /* ---------- applyOp 正向 ---------- */ describe('applyOp', () => { it('add_element 在目标页追加元素', () => { const op: Op = { type: 'add_element', slideIdx: 0, element: el('new') } const d = applyOp(D1, op) expect(d.slides[0].elements.map(e => e.id)).toEqual(['a', 'b', 'new']) expect(d.slides[1].elements).toHaveLength(1) // 其他页不受影响 }) it('del_element 删除指定元素', () => { const op: Op = { type: 'del_element', slideIdx: 0, elementId: 'a' } const d = applyOp(D1, op) expect(d.slides[0].elements.map(e => e.id)).toEqual(['b']) }) it('update_element 只改 patch 覆盖的字段', () => { const op: Op = { type: 'update_element', slideIdx: 0, elementId: 'a', patch: { content: '改后', style: { fontSize: 32 } } } const d = applyOp(D1, op) const target = d.slides[0].elements[0] expect(target.content).toBe('改后') expect(target.style.fontSize).toBe(32) expect(target.x).toBe(10) // 未覆盖字段保持 }) it('move_slide 调整页序', () => { const op: Op = { type: 'move_slide', from: 0, to: 1 } const d = applyOp(D1, op) expect(d.slides.map(s => s.id)).toEqual(['s2', 's1']) }) it('不 mutate 输入 deck(纯函数)', () => { const snapshot = JSON.stringify(D1) applyOp(D1, { type: 'add_element', slideIdx: 0, element: el('x') }) applyOp(D1, { type: 'del_element', slideIdx: 0, elementId: 'a' }) expect(JSON.stringify(D1)).toBe(snapshot) }) }) /* ---------- invertOp + undo 往返 ---------- */ describe('invertOp 往返一致性', () => { const cases: Array<{ name: string; op: Op }> = [ { name: 'add_element', op: { type: 'add_element', slideIdx: 0, element: el('new') } }, { name: 'del_element', op: { type: 'del_element', slideIdx: 0, elementId: 'a', deletedElement: el('a') } }, { name: 'update_element', op: { type: 'update_element', slideIdx: 0, elementId: 'a', patch: { content: '改', style: { bold: true } } } }, { name: 'add_slide', op: { type: 'add_slide', atIndex: 1, slide: slide('s3') } }, { name: 'move_slide', op: { type: 'move_slide', from: 0, to: 1 } }, { name: 'set_slide_bg', op: { type: 'set_slide_bg', index: 0, bg: 'primary' } }, { name: 'move_element_z', op: { type: 'move_element_z', slideIdx: 0, elementId: 'b', dir: 1 } }, ] for (const { name, op } of cases) { it(`${name}:执行后撤销回到原状`, () => { const after = applyOp(D1, op) const back = invertOp(D1, op) const restored = applyOp(after, back) expect(JSON.stringify(restored)).toBe(JSON.stringify(D1)) }) } it('update_element 无 style patch 也往返一致', () => { const op: Op = { type: 'update_element', slideIdx: 1, elementId: 'c', patch: { x: 55, y: 60 } } const after = applyOp(D1, op) const restored = applyOp(after, invertOp(D1, op)) expect(JSON.stringify(restored)).toBe(JSON.stringify(D1)) }) }) /* ---------- 连续操作链撤销 ---------- */ describe('多步操作链撤销', () => { it('三条操作逐步回滚最终回到初始', () => { let d = D1 const trail: Array<{ forward: Op; backward: Op }> = [] const ops: Op[] = [ { type: 'add_element', slideIdx: 0, element: el('n1') }, { type: 'update_element', slideIdx: 0, elementId: 'n1', patch: { content: '更新' } }, { type: 'del_element', slideIdx: 0, elementId: 'b' }, ] for (const op of ops) { trail.push({ forward: op, backward: invertOp(d, op) }) d = applyOp(d, op) } // 逆序回滚 for (let i = trail.length - 1; i >= 0; i--) { d = applyOp(d, trail[i].backward) } expect(JSON.stringify(d)).toBe(JSON.stringify(D1)) }) })