roymondchen 0f42989ca3 refactor(editor): 统一历史栈结构,支持扩展历史类型
将 pageSteps/codeBlockState/dataSourceState 三套独立历史栈收敛为统一的 steps 结构
(按 stepType 分桶),并新增 registerStepType/setStepName/getStepName 支持自定义
扩展历史类型。同步重构 history 相关服务、组件、工具方法、测试与文档。
2026-06-23 20:14:41 +08:00

350 lines
13 KiB
TypeScript
Raw Permalink 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';
import { createStackStep } from '@editor/utils/history';
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id)。
// 这里用与旧便捷方法等价的小工具,按 payload 构造 step 后走新的 push便于沿用既有用例。
const pushCodeBlock = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldContent,
newValue: payload.newContent,
changeRecords: payload.changeRecords,
historyDescription: payload.historyDescription,
source: payload.source,
});
return step ? history.push('codeBlock', step as any, id) : null;
};
const pushDataSource = (id: any, payload: any) => {
const step = createStackStep(id, {
oldValue: payload.oldSchema,
newValue: payload.newSchema,
changeRecords: payload.changeRecords,
historyDescription: payload.historyDescription,
source: payload.source,
});
return step ? history.push('dataSource', step as any, id) : null;
};
afterEach(() => {
history.reset();
});
describe('history service', () => {
test('push / undo / redo 链路', () => {
const v1 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'a' } as any;
const v2 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'b' } as any;
history.push('page', v1, 'p1');
history.push('page', v2, 'p1');
const undone = history.undo('page', 'p1');
expect(undone).toBeDefined();
const redone = history.redo('page', 'p1');
expect(redone).toBeDefined();
});
test('未传 / 无效 id 时 push/undo/redo 返回 null', () => {
expect(history.push('page', {} as any, '')).toBeNull();
expect(history.undo('page', '')).toBeNull();
expect(history.redo('page', '')).toBeNull();
});
test('reset / resetState 清空页面栈', () => {
history.push('page', { data: {} } as any, 'p1');
history.reset();
expect(Object.keys(history.state.steps.page)).toHaveLength(0);
});
test('canUndo / canRedo 在 push 后更新', () => {
history.push('page', { data: {} } as any, 'p1');
history.push('page', { data: {} } as any, 'p1');
expect(history.canUndo('page', 'p1')).toBe(true);
});
test('push 指定 pageId 落到目标页栈,不影响其它页', () => {
const step = { data: { id: 'p2', name: '' }, modifiedNodeIds: new Map() } as any;
// 跨页 push把记录推到 p2目标页p1 栈应保持为空
history.push('page', step, 'p2');
expect((history.state.steps.page as any).p2).toBeDefined();
expect(history.canUndo('page', 'p2')).toBe(true);
// p1 栈没有 push 进来,仍不可撤销
expect(history.canUndo('page', 'p1')).toBe(false);
// p2 能正常 undo 该步骤
expect(history.undo('page', 'p2')).toBeDefined();
});
test('push 未带 timestamp 时自动写入入栈时间', () => {
const before = Date.now();
const step = history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
const after = Date.now();
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('push 已带 timestamp 时保留调用方指定的值', () => {
const step = history.push(
'page',
{
data: { id: 'p1', name: '' },
modifiedNodeIds: new Map(),
timestamp: 123456,
} as any,
'p1',
);
expect(step?.timestamp).toBe(123456);
});
test('push 未带 uuid 时自动生成 uuid', () => {
const step = history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
expect(typeof step?.uuid).toBe('string');
expect(step?.uuid).toBeTruthy();
});
test('push 已带 uuid 时保留调用方指定的值', () => {
const step = history.push(
'page',
{
uuid: 'my-uuid',
data: { id: 'p1', name: '' },
modifiedNodeIds: new Map(),
} as any,
'p1',
);
expect(step?.uuid).toBe('my-uuid');
});
test('push 为每条记录生成不同的 uuid', () => {
const s1 = history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
const s2 = history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
expect(s1?.uuid).toBeTruthy();
expect(s2?.uuid).toBeTruthy();
expect(s1?.uuid).not.toBe(s2?.uuid);
});
test('setMarker 在空栈时种入 initial 基线', () => {
const marker = history.setMarker('page', 'p1', { name: '首页', description: '初始' });
expect(marker?.opType).toBe('initial');
expect(history.getMarker('page', 'p1')?.uuid).toBe(marker?.uuid);
expect((history.state.steps.page as any).p1.getLength()).toBe(1);
});
test('有 initial 基线时不可撤销越过基线', () => {
history.setMarker('page', 'p1');
history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
expect(history.canUndo('page', 'p1')).toBe(true);
history.undo('page', 'p1');
expect(history.canUndo('page', 'p1')).toBe(false);
expect(history.undo('page', 'p1')).toBeNull();
expect(history.getCursor('page', 'p1')).toBe(1);
});
test('扩展类型同样支持 initial 基线(撤销不越过基线)', () => {
history.setMarker('custom', 'ext_1', {});
history.push('custom', { opType: 'update', diff: [] } as any, 'ext_1');
expect(history.canUndo('custom', 'ext_1')).toBe(true);
history.undo('custom', 'ext_1');
expect(history.canUndo('custom', 'ext_1')).toBe(false);
expect(history.undo('custom', 'ext_1')).toBeNull();
});
test('getHistoryGroups 过滤 initial 基线', () => {
history.setMarker('page', 'p1');
history.push(
'page',
{
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
modifiedNodeIds: new Map(),
} as any,
'p1',
);
const groups = history.getHistoryGroups('page', 'p1');
expect(groups).toHaveLength(1);
expect(groups[0].opType).toBe('add');
});
});
describe('history service - codeBlock', () => {
test('pushCodeBlock 入栈并触发 change 事件', () => {
const fn = vi.fn();
history.on('change', fn);
const step = pushCodeBlock('code_1', {
oldContent: null,
newContent: { name: 'A', content: 'x' } as any,
});
expect(step).not.toBeNull();
expect(step?.data?.id).toBe('code_1');
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema).toEqual({ name: 'A', content: 'x' });
expect((history.state.steps.codeBlock as any).code_1).toBeDefined();
expect(history.canUndo('codeBlock', 'code_1')).toBe(true);
expect(fn).toHaveBeenCalledWith(
expect.objectContaining({ data: { name: 'A', id: 'code_1' } }),
'codeBlock',
'code_1',
);
history.off('change', fn);
});
test('pushCodeBlock 不传 id 返回 null', () => {
expect(pushCodeBlock('', { oldContent: null, newContent: null })).toBeNull();
});
test('pushCodeBlock 自动写入入栈时间戳', () => {
const before = Date.now();
const step = pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
const after = Date.now();
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('pushCodeBlock 自动生成 uuid', () => {
const step = pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
expect(typeof step?.uuid).toBe('string');
expect(step?.uuid).toBeTruthy();
});
test('undoCodeBlock / redoCodeBlock 走对应 id 的 UndoRedo 栈', () => {
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
pushCodeBlock('code_1', {
oldContent: { name: 'A' } as any,
newContent: { name: 'B' } as any,
});
expect(history.canUndo('codeBlock', 'code_1')).toBe(true);
const undone = history.undo('codeBlock', 'code_1');
expect(undone?.diff?.[0]?.newSchema).toEqual({ name: 'B' });
expect(history.canRedo('codeBlock', 'code_1')).toBe(true);
const redone = history.redo('codeBlock', 'code_1');
expect(redone?.diff?.[0]?.newSchema).toEqual({ name: 'B' });
});
test('undoCodeBlock 对不存在 id 返回 null', () => {
expect(history.undo('codeBlock', 'not-exist')).toBeNull();
expect(history.redo('codeBlock', 'not-exist')).toBeNull();
expect(history.canUndo('codeBlock', 'not-exist')).toBe(false);
expect(history.canRedo('codeBlock', 'not-exist')).toBe(false);
});
test('不同代码块 id 的栈相互隔离', () => {
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any });
expect(history.canUndo('codeBlock', 'code_1')).toBe(true);
expect(history.canUndo('codeBlock', 'code_2')).toBe(true);
history.undo('codeBlock', 'code_1');
expect(history.canUndo('codeBlock', 'code_1')).toBe(false);
// code_2 的栈不受影响
expect(history.canUndo('codeBlock', 'code_2')).toBe(true);
});
test('reset / resetState 清空 codeBlockState', () => {
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.reset();
expect(Object.keys(history.state.steps.codeBlock)).toHaveLength(0);
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
history.resetState();
expect(Object.keys(history.state.steps.codeBlock)).toHaveLength(0);
});
});
describe('history service - dataSource', () => {
test('pushDataSource 入栈并触发 change 事件', () => {
const fn = vi.fn();
history.on('change', fn);
const step = pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
});
expect(step).not.toBeNull();
expect(step?.data?.id).toBe('ds_1');
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
expect(step?.diff?.[0]?.newSchema?.title).toBe('A');
expect((history.state.steps.dataSource as any).ds_1).toBeDefined();
expect(history.canUndo('dataSource', 'ds_1')).toBe(true);
expect(fn).toHaveBeenCalledWith(expect.objectContaining({ data: { name: 'A', id: 'ds_1' } }), 'dataSource', 'ds_1');
history.off('change', fn);
});
test('pushDataSource 不传 id 返回 null', () => {
expect(pushDataSource('', { oldSchema: null, newSchema: null })).toBeNull();
});
test('pushDataSource 自动写入入栈时间戳', () => {
const before = Date.now();
const step = pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
const after = Date.now();
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
expect(step?.timestamp).toBeLessThanOrEqual(after);
});
test('pushDataSource 自动生成 uuid', () => {
const step = pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
expect(typeof step?.uuid).toBe('string');
expect(step?.uuid).toBeTruthy();
});
test('undoDataSource / redoDataSource 走对应 id 的 UndoRedo 栈', () => {
pushDataSource('ds_1', {
oldSchema: null,
newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
});
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.undo('dataSource', 'ds_1');
expect(undone?.diff?.[0]?.newSchema?.title).toBe('B');
const redone = history.redo('dataSource', 'ds_1');
expect(redone?.diff?.[0]?.newSchema?.title).toBe('B');
});
test('undoDataSource 对不存在 id 返回 null', () => {
expect(history.undo('dataSource', 'not-exist')).toBeNull();
expect(history.redo('dataSource', 'not-exist')).toBeNull();
expect(history.canUndo('dataSource', 'not-exist')).toBe(false);
expect(history.canRedo('dataSource', 'not-exist')).toBe(false);
});
test('不同数据源 id 的栈相互隔离', () => {
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any });
history.undo('dataSource', 'ds_1');
expect(history.canUndo('dataSource', 'ds_1')).toBe(false);
expect(history.canUndo('dataSource', 'ds_2')).toBe(true);
});
test('reset / resetState 清空 dataSourceState', () => {
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.reset();
expect(Object.keys(history.state.steps.dataSource)).toHaveLength(0);
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
history.resetState();
expect(Object.keys(history.state.steps.dataSource)).toHaveLength(0);
});
});