修复: op 引擎 undo 逆操作 z序/越界缺陷,补 14 项核心测试
This commit is contained in:
+27
-5
@@ -40,6 +40,8 @@ export interface Op {
|
|||||||
export interface HistoryEntry {
|
export interface HistoryEntry {
|
||||||
forward: Op
|
forward: Op
|
||||||
backward: Op
|
backward: Op
|
||||||
|
/** 批次标记:同批多条(如一次导入 N 张图)undo/redo 时整组一起回滚 */
|
||||||
|
group?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 工具 ---------- */
|
/* ---------- 工具 ---------- */
|
||||||
@@ -107,8 +109,12 @@ export function applyOp(inputDeck: Deck, op: Op): Deck {
|
|||||||
case 'add_element': {
|
case 'add_element': {
|
||||||
const slideIdx = op.slideIdx as number
|
const slideIdx = op.slideIdx as number
|
||||||
const element = op.element as SlideElement
|
const element = op.element as SlideElement
|
||||||
|
const atIndex = op.atIndex as number | undefined
|
||||||
const s = deck.slides[slideIdx]
|
const s = deck.slides[slideIdx]
|
||||||
if (s) s.elements.push(element)
|
if (s) {
|
||||||
|
if (atIndex != null) s.elements.splice(atIndex, 0, element) // 指定位置插入(撤销删除时回到原 z 序)
|
||||||
|
else s.elements.push(element)
|
||||||
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,18 +250,34 @@ export function invertOp(inputDeck: Deck, op: Op): Op {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'del_element': {
|
case 'del_element': {
|
||||||
// 逆:把删掉的元素加回去
|
// 逆:把删掉的元素加回原位置(保持 z 序)
|
||||||
const slideIdx = op.slideIdx as number
|
const slideIdx = op.slideIdx as number
|
||||||
const deletedElement = op.deletedElement as SlideElement | undefined
|
const deletedElement = op.deletedElement as SlideElement | undefined
|
||||||
if (deletedElement) {
|
if (deletedElement) {
|
||||||
return { type: 'add_element', slideIdx, element: deletedElement }
|
const s = deck.slides[slideIdx]
|
||||||
|
const at = s ? s.elements.findIndex(e => e.id === deletedElement.id) : -1
|
||||||
|
// 已删状态里找不到 → 原位置 = 删除后第一个"本应在其后"的元素处;用 indexOf 拿不到,
|
||||||
|
// 所以在执行前由 invertOp 拿到 index:这里 s.elements 已无该元素,取其后继位置
|
||||||
|
const insertAt = at >= 0 ? at : (s ? s.elements.length : 0)
|
||||||
|
return { type: 'add_element', slideIdx, element: deletedElement, atIndex: insertAt }
|
||||||
}
|
}
|
||||||
return { type: 'replace_deck', deck: clone(deck) }
|
return { type: 'replace_deck', deck: clone(deck) }
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'move_element_z': {
|
case 'move_element_z': {
|
||||||
// 逆:方向反过来
|
// 逆:方向反过来。正向若越界是 no-op(applyOp break),
|
||||||
return { type: 'move_element_z', slideIdx: op.slideIdx, elementId: op.elementId, dir: -(op.dir as number) }
|
// 此时逆必须也是 no-op(空 patch 的 update_element 无副作用)——
|
||||||
|
// 否则撤销会移动一个从未移动过的元素
|
||||||
|
const slideIdx = op.slideIdx as number
|
||||||
|
const elementId = op.elementId as string
|
||||||
|
const s = deck.slides[slideIdx]
|
||||||
|
const i = s ? s.elements.findIndex(e => e.id === elementId) : -1
|
||||||
|
const j = i >= 0 ? ((op.dir as number) > 0 ? i + 1 : i - 1) : -1
|
||||||
|
const outOfRange = i < 0 || j < 0 || j >= (s ? s.elements.length : 0)
|
||||||
|
if (outOfRange) {
|
||||||
|
return { type: 'update_element', slideIdx, elementId, patch: {} }
|
||||||
|
}
|
||||||
|
return { type: 'move_element_z', slideIdx, elementId, dir: -(op.dir as number) }
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'set_theme': {
|
case 'set_theme': {
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user