修复: op 引擎 undo 逆操作 z序/越界缺陷,补 14 项核心测试

This commit is contained in:
lxy
2026-08-24 02:13:15 +08:00
parent 01f0f45007
commit 7f08f654d9
2 changed files with 141 additions and 5 deletions
+114
View File
@@ -0,0 +1,114 @@
/* =====================================================================
* 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))
})
})