mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-05-30 12:18:10 +00:00
- 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 相关文档
229 lines
8.5 KiB
TypeScript
229 lines
8.5 KiB
TypeScript
/*
|
||
* 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);
|
||
});
|
||
});
|