115 lines
4.7 KiB
TypeScript
115 lines
4.7 KiB
TypeScript
/* =====================================================================
|
|
* 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> = {}): 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))
|
|
})
|
|
})
|