diff --git a/src/core/op.ts b/src/core/op.ts index 5574e6a..bfb10f3 100644 --- a/src/core/op.ts +++ b/src/core/op.ts @@ -40,6 +40,8 @@ export interface Op { export interface HistoryEntry { forward: Op backward: Op + /** 批次标记:同批多条(如一次导入 N 张图)undo/redo 时整组一起回滚 */ + group?: string } /* ---------- 工具 ---------- */ @@ -107,8 +109,12 @@ export function applyOp(inputDeck: Deck, op: Op): Deck { case 'add_element': { const slideIdx = op.slideIdx as number const element = op.element as SlideElement + const atIndex = op.atIndex as number | undefined 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 } @@ -244,18 +250,34 @@ export function invertOp(inputDeck: Deck, op: Op): Op { } case 'del_element': { - // 逆:把删掉的元素加回去 + // 逆:把删掉的元素加回原位置(保持 z 序) const slideIdx = op.slideIdx as number const deletedElement = op.deletedElement as SlideElement | undefined 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) } } case 'move_element_z': { - // 逆:方向反过来 - return { type: 'move_element_z', slideIdx: op.slideIdx, elementId: op.elementId, dir: -(op.dir as number) } + // 逆:方向反过来。正向若越界是 no-op(applyOp break), + // 此时逆必须也是 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': { diff --git a/tests/op.test.ts b/tests/op.test.ts new file mode 100644 index 0000000..7f414d4 --- /dev/null +++ b/tests/op.test.ts @@ -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 { + 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)) + }) +})