roymondchen e2c065f90d feat(editor): 代码块与数据源支持按 id 独立的历史记录
- history service 新增 pushCodeBlock/undoCodeBlock/redoCodeBlock
  /canUndoCodeBlock/canRedoCodeBlock 及数据源对称 API
- 按 id 维度各自维护独立 UndoRedo 栈,与页面/节点历史完全解耦
- type 新增 CodeBlockStepValue / DataSourceStepValue 独立类型
- HistoryState 扩展 codeBlockState / dataSourceState 字段
- codeBlockService.setCodeDslByIdSync / deleteCodeDslByIds 自动入历史
- dataSourceService.add / update / remove 自动入历史
- 入栈成功时 emit code-block-history-change / data-source-history-change
- 补充单测共 21 例,更新 history/codeBlock/dataSource 相关文档
2026-05-27 19:50:17 +08:00

229 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { afterEach, describe, expect, test, vi } from 'vitest';
import history from '@editor/services/history';
afterEach(() => {
history.reset();
});
describe('history service', () => {
test('changePage 切换页面会创建对应的 UndoRedo', () => {
history.changePage({ id: 'p1' } as any);
expect((history.state.pageSteps as any).p1).toBeDefined();
});
test('push / undo / redo 链路', () => {
history.changePage({ id: 'p1' } as any);
const v1 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'a' } as any;
const v2 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'b' } as any;
history.push(v1);
history.push(v2);
const undone = history.undo();
expect(undone).toBeDefined();
const redone = history.redo();
expect(redone).toBeDefined();
});
test('未指定 pageId 时 push/undo/redo 返回 null', () => {
history.resetPage();
expect(history.push({} as any)).toBeNull();
expect(history.undo()).toBeNull();
expect(history.redo()).toBeNull();
});
test('reset / resetPage / resetState', () => {
history.changePage({ id: 'p1' } as any);
history.push({ data: {} } as any);
history.reset();
expect(history.state.pageId).toBeUndefined();
expect(Object.keys(history.state.pageSteps)).toHaveLength(0);
});
test('canUndo / canRedo 在 push 后更新', () => {
history.changePage({ id: 'p1' } as any);
history.push({ data: {} } as any);
history.push({ data: {} } as any);
expect(history.state.canUndo).toBe(true);
});
test('changePage 接到 undefined/null 时不变更', () => {
history.changePage(null as any);
expect(history.state.pageId).toBeUndefined();
});
test('push 指定 pageId 落到目标页栈,不影响当前页', () => {
// 当前激活在 p1
history.changePage({ id: 'p1' } as any);
const step = { data: { id: 'p2', name: '' }, modifiedNodeIds: new Map() } as any;
// 跨页 push把记录推到 p2目标页p1 栈应保持为空
history.push(step, 'p2');
expect((history.state.pageSteps as any).p2).toBeDefined();
expect((history.state.pageSteps as any).p2.canUndo()).toBe(true);
// p1 栈虽然激活但没有 push 进来,仍不可撤销
expect((history.state.pageSteps as any).p1.canUndo()).toBe(false);
// 跨页 push 不应触发当前页p1的 canUndo 改变
expect(history.state.canUndo).toBe(false);
// 切到 p2 后能正常 undo 该跨页步骤
history.changePage({ id: 'p2' } as any);
expect(history.state.canUndo).toBe(true);
expect(history.undo()).toBeDefined();
});
test('push 不传 pageId 时落到当前活动页栈(向后兼容)', () => {
history.changePage({ id: 'p1' } as any);
history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
expect((history.state.pageSteps as any).p1.canUndo()).toBe(true);
expect(history.state.canUndo).toBe(true);
});
});
describe('history service - codeBlock', () => {
test('pushCodeBlock 入栈并触发 code-block-history-change 事件', () => {
const fn = vi.fn();
history.on('code-block-history-change', fn);
const step = history.pushCodeBlock('code_1', {
oldContent: null,
newContent: { name: 'A', content: 'x' } as any,
});
expect(step).not.toBeNull();
expect(step?.id).toBe('code_1');
expect(step?.oldContent).toBeNull();
expect(step?.newContent).toEqual({ name: 'A', content: 'x' });
expect((history.state.codeBlockState as any).code_1).toBeDefined();
expect(history.canUndoCodeBlock('code_1')).toBe(true);
expect(fn).toHaveBeenCalledWith('code_1', expect.objectContaining({ id: 'code_1' }));
history.off('code-block-history-change', fn);
});
test('pushCodeBlock 不传 id 返回 null', () => {
expect(history.pushCodeBlock('', { oldContent: null, newContent: null })).toBeNull();
});
test('undoCodeBlock / redoCodeBlock 走对应 id 的 UndoRedo 栈', () => {
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushCodeBlock('code_1', {
oldContent: { name: 'A' } as any,
newContent: { name: 'B' } as any,
});
expect(history.canUndoCodeBlock('code_1')).toBe(true);
const undone = history.undoCodeBlock('code_1');
expect(undone?.newContent).toEqual({ name: 'B' });
expect(history.canRedoCodeBlock('code_1')).toBe(true);
const redone = history.redoCodeBlock('code_1');
expect(redone?.newContent).toEqual({ name: 'B' });
});
test('undoCodeBlock 对不存在 id 返回 null', () => {
expect(history.undoCodeBlock('not-exist')).toBeNull();
expect(history.redoCodeBlock('not-exist')).toBeNull();
expect(history.canUndoCodeBlock('not-exist')).toBe(false);
expect(history.canRedoCodeBlock('not-exist')).toBe(false);
});
test('不同代码块 id 的栈相互隔离', () => {
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any });
expect(history.canUndoCodeBlock('code_1')).toBe(true);
expect(history.canUndoCodeBlock('code_2')).toBe(true);
history.undoCodeBlock('code_1');
expect(history.canUndoCodeBlock('code_1')).toBe(false);
// code_2 的栈不受影响
expect(history.canUndoCodeBlock('code_2')).toBe(true);
});
test('reset / resetState 清空 codeBlockState', () => {
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.reset();
expect(Object.keys(history.state.codeBlockState)).toHaveLength(0);
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.resetState();
expect(Object.keys(history.state.codeBlockState)).toHaveLength(0);
});
});
describe('history service - dataSource', () => {
test('pushDataSource 入栈并触发 data-source-history-change 事件', () => {
const fn = vi.fn();
history.on('data-source-history-change', fn);
const step = history.pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
});
expect(step).not.toBeNull();
expect(step?.id).toBe('ds_1');
expect(step?.oldSchema).toBeNull();
expect(step?.newSchema?.title).toBe('A');
expect((history.state.dataSourceState as any).ds_1).toBeDefined();
expect(history.canUndoDataSource('ds_1')).toBe(true);
expect(fn).toHaveBeenCalledWith('ds_1', expect.objectContaining({ id: 'ds_1' }));
history.off('data-source-history-change', fn);
});
test('pushDataSource 不传 id 返回 null', () => {
expect(history.pushDataSource('', { oldSchema: null, newSchema: null })).toBeNull();
});
test('undoDataSource / redoDataSource 走对应 id 的 UndoRedo 栈', () => {
history.pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
});
history.pushDataSource('ds_1', {
oldSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
newSchema: { id: 'ds_1', type: 'base', title: 'B' } as any,
});
const undone = history.undoDataSource('ds_1');
expect(undone?.newSchema?.title).toBe('B');
const redone = history.redoDataSource('ds_1');
expect(redone?.newSchema?.title).toBe('B');
});
test('undoDataSource 对不存在 id 返回 null', () => {
expect(history.undoDataSource('not-exist')).toBeNull();
expect(history.redoDataSource('not-exist')).toBeNull();
expect(history.canUndoDataSource('not-exist')).toBe(false);
expect(history.canRedoDataSource('not-exist')).toBe(false);
});
test('不同数据源 id 的栈相互隔离', () => {
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any });
history.undoDataSource('ds_1');
expect(history.canUndoDataSource('ds_1')).toBe(false);
expect(history.canUndoDataSource('ds_2')).toBe(true);
});
test('reset / resetState 清空 dataSourceState', () => {
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.reset();
expect(Object.keys(history.state.dataSourceState)).toHaveLength(0);
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.resetState();
expect(Object.keys(history.state.dataSourceState)).toHaveLength(0);
});
});