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

337 lines
11 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 { describe, expect, test } from 'vitest';
import type { CodeBlockStepValue, StepValue } from '@editor/type';
import {
createStackStep,
describeRevertStep,
deserializeStacks,
detectStackOpType,
detectTargetId,
detectTargetName,
getOrCreateStack,
markStackSaved,
mergeSteps,
serializeStacks,
undoFloor,
} from '@editor/utils/history';
import { UndoRedo } from '@editor/utils/undo-redo';
describe('detectStackOpType', () => {
test('old=null new=有值 → add', () => {
expect(detectStackOpType(null, {})).toBe('add');
});
test('old=有值 new=null → remove', () => {
expect(detectStackOpType({}, null)).toBe('remove');
});
test('old/new 都有值 → update', () => {
expect(detectStackOpType({}, {})).toBe('update');
});
});
describe('createStackStep', () => {
test('空 id 返回 null', () => {
expect(createStackStep('', { oldValue: null, newValue: { name: 'A' } as any })).toBeNull();
});
test('新增oldValue=null推断 opType=add', () => {
const step = createStackStep('code_1', {
oldValue: null,
newValue: { name: 'A', content: 'x' } as any,
});
expect(step?.opType).toBe('add');
expect(step?.diff?.[0]?.newSchema).toEqual({ name: 'A', content: 'x' });
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
});
test('内容 cloneDeep外部修改不影响 step', () => {
const content = { name: 'A' };
const step = createStackStep('code_1', { oldValue: null, newValue: content as any });
content.name = 'B';
expect(step?.diff?.[0]?.newSchema).toEqual({ name: 'A' });
});
test('update 可携带 changeRecords', () => {
const step = createStackStep('code_1', {
oldValue: { name: 'A' } as any,
newValue: { name: 'B' } as any,
changeRecords: [{ propPath: 'name' }],
});
expect(step?.opType).toBe('update');
expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'name' }]);
});
});
describe('describeRevertStep', () => {
test('撤回新增', () => {
expect(describeRevertStep('code_1', { newSchema: { name: 'fn' } as any }, (s) => s.name)).toBe('撤回新增 fn');
});
test('还原已删除', () => {
expect(describeRevertStep('code_1', { oldSchema: { name: 'fn' } as any }, (s) => s.name)).toBe('还原已删除的 fn');
});
test('还原修改 + propPath', () => {
expect(
describeRevertStep(
'code_1',
{
oldSchema: { name: 'fn' } as any,
newSchema: { name: 'fn' } as any,
changeRecords: [{ propPath: 'content' }],
},
(s) => s.name,
),
).toBe('还原 fn · content');
});
test('还原修改无 propPath', () => {
expect(
describeRevertStep(
'code_1',
{ oldSchema: { name: 'fn' } as any, newSchema: { name: 'fn' } as any },
(s) => s.name,
),
).toBe('还原 fn');
});
});
describe('markStackSaved', () => {
test('undoRedo 为空时不抛错', () => {
expect(() => markStackSaved(undefined)).not.toThrow();
});
test('标记当前记录为 saved并清除其它记录的 saved', () => {
const undoRedo = new UndoRedo<{ saved?: boolean }>();
undoRedo.pushElement({ saved: false });
undoRedo.pushElement({ saved: false });
markStackSaved(undoRedo);
const list = undoRedo.getElementList();
expect(list.filter((s) => s.saved)).toHaveLength(1);
expect(list[list.length - 1].saved).toBe(true);
expect(list[0].saved).toBeFalsy();
});
});
describe('mergeSteps代码块 / 数据源等按 id 分栈类型)', () => {
test('无 diff 的连续 update 不合并(无明确目标)', () => {
const list = [
{ opType: 'update', uuid: '1' },
{ opType: 'update', uuid: '2' },
] as CodeBlockStepValue[];
const groups = mergeSteps('code-block', 'code_1', list, 2);
expect(groups).toHaveLength(2);
expect(groups[0].steps).toHaveLength(1);
expect(groups[1].steps).toHaveLength(1);
expect(groups[0].opType).toBe('update');
expect(groups[1].opType).toBe('update');
});
test('同目标连续 update 合并成一组CodeBlockContent 无 id回退 step.data.id', () => {
const mkUpdate = (path: string) =>
({
opType: 'update',
data: { name: 'A', id: 'code_1' },
diff: [
{
newSchema: { name: 'A', content: 'x' },
oldSchema: { name: 'A', content: 'x' },
changeRecords: [{ propPath: path }],
},
],
}) as unknown as CodeBlockStepValue;
const groups = mergeSteps('code-block', 'code_1', [mkUpdate('content'), mkUpdate('params')], 2);
expect(groups).toHaveLength(1);
expect(groups[0].steps).toHaveLength(2);
expect(groups[0].id).toBe('code_1');
expect(groups[0].kind).toBe('code-block');
});
test('add / update 各自独立成组', () => {
const list = [
{ opType: 'add', uuid: '1' },
{ opType: 'update', uuid: '2' },
] as CodeBlockStepValue[];
const groups = mergeSteps('code-block', 'code_1', list, 2);
expect(groups).toHaveLength(2);
});
test('正确标记 applied / isCurrent', () => {
const list = [
{ opType: 'update', uuid: '1' },
{ opType: 'update', uuid: '2' },
] as CodeBlockStepValue[];
const groups = mergeSteps('code-block', 'code_1', list, 1);
expect(groups[0].applied).toBe(true);
expect(groups[0].steps[0].applied).toBe(true);
expect(groups[0].steps[0].isCurrent).toBe(true);
expect(groups[0].isCurrent).toBe(true);
expect(groups[1].applied).toBe(false);
expect(groups[1].steps[0].applied).toBe(false);
});
});
describe('detectTargetId / detectTargetName', () => {
test('单节点 update 返回 targetId 与名称', () => {
const step = {
opType: 'update',
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
} as unknown as StepValue;
expect(detectTargetId(step)).toBe('btn_1');
expect(detectTargetName(step)).toBe('按钮');
});
test('多节点 update 不参与合并', () => {
const step = {
opType: 'update',
diff: [
{ newSchema: { id: 'a' }, oldSchema: { id: 'a' } },
{ newSchema: { id: 'b' }, oldSchema: { id: 'b' } },
],
} as unknown as StepValue;
expect(detectTargetId(step)).toBeUndefined();
expect(detectTargetName(step)).toBe('2 个节点');
});
test('add 单节点返回名称', () => {
const step = {
opType: 'add',
diff: [{ newSchema: { id: 'n1', type: 'text' } }],
} as unknown as StepValue;
expect(detectTargetId(step)).toBeUndefined();
expect(detectTargetName(step)).toBe('text');
});
});
describe('mergeSteps页面', () => {
test('相邻同 targetId 的 update 合并', () => {
const mkUpdate = (path: string) =>
({
opType: 'update',
diff: [
{
newSchema: { id: 'btn_1', name: '按钮' },
oldSchema: { id: 'btn_1', name: '按钮' },
changeRecords: [{ propPath: path }],
},
],
}) as unknown as StepValue;
const list = [mkUpdate('style.color'), mkUpdate('style.fontSize')];
const groups = mergeSteps('page', 'p1', list, 2);
expect(groups).toHaveLength(1);
expect(groups[0].targetId).toBe('btn_1');
expect(groups[0].steps).toHaveLength(2);
});
test('add 始终独立成组', () => {
const list = [
{
opType: 'add',
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
},
{
opType: 'update',
diff: [{ newSchema: { id: 'n1', name: 'A' }, oldSchema: { id: 'n1', name: 'A' } }],
},
] as unknown as StepValue[];
const groups = mergeSteps('page', 'p1', list, 2);
expect(groups).toHaveLength(2);
expect(groups[0].opType).toBe('add');
expect(groups[1].opType).toBe('update');
});
test('重命名时 targetName 取最近一次', () => {
const list = [
{
opType: 'update',
diff: [{ newSchema: { id: 'n1', name: '旧名' }, oldSchema: { id: 'n1', name: '旧名' } }],
},
{
opType: 'update',
diff: [{ newSchema: { id: 'n1', name: '新名' }, oldSchema: { id: 'n1', name: '旧名' } }],
},
] as unknown as StepValue[];
const groups = mergeSteps('page', 'p1', list, 2);
expect(groups[0].targetName).toBe('新名');
});
});
describe('serializeStacks / deserializeStacks', () => {
test('序列化后还原栈内容与游标', () => {
const stacks = {
p1: new UndoRedo<{ v: number }>(),
};
stacks.p1.pushElement({ v: 1 });
stacks.p1.pushElement({ v: 2 });
const serialized = serializeStacks(stacks);
const restored = deserializeStacks(serialized);
expect(restored.p1.getLength()).toBe(2);
expect(restored.p1.getCursor()).toBe(2);
expect(restored.p1.getCurrentElement()).toEqual({ v: 2 });
});
test('还原时游标定位到最近一条 saved 记录之后', () => {
const stacks = {
p1: new UndoRedo<{ saved?: boolean; v: number }>(),
};
stacks.p1.pushElement({ v: 1 });
stacks.p1.pushElement({ v: 2, saved: true });
stacks.p1.pushElement({ v: 3 });
const restored = deserializeStacks(serializeStacks(stacks));
expect(restored.p1.getCursor()).toBe(2);
expect(restored.p1.getCurrentElement()).toEqual({ v: 2, saved: true });
});
test('空表与缺省参数', () => {
expect(serializeStacks({})).toEqual({});
expect(deserializeStacks()).toEqual({});
});
});
describe('getOrCreateStack', () => {
test('不存在时创建新栈', () => {
const stacks: Record<string, UndoRedo<number>> = {};
const stack = getOrCreateStack(stacks, 'a');
expect(stack).toBeInstanceOf(UndoRedo);
expect(stacks.a).toBe(stack);
});
test('已存在时返回原栈', () => {
const existing = new UndoRedo<number>();
existing.pushElement(1);
const stacks = { a: existing };
expect(getOrCreateStack(stacks, 'a')).toBe(existing);
expect(getOrCreateStack(stacks, 'a').getLength()).toBe(1);
});
});
describe('undoFloor', () => {
test('空栈返回 0', () => {
expect(undoFloor(new UndoRedo<StepValue>())).toBe(0);
});
test('无 initial 基线返回 0', () => {
const undoRedo = new UndoRedo<StepValue>();
undoRedo.pushElement({ opType: 'add', diff: [] } as unknown as StepValue);
expect(undoFloor(undoRedo)).toBe(0);
});
test('index 0 为 initial 时返回 1', () => {
const undoRedo = new UndoRedo<StepValue>();
undoRedo.pushElement({ opType: 'initial', diff: [] } as unknown as StepValue);
undoRedo.pushElement({ opType: 'update', diff: [] } as unknown as StepValue);
expect(undoFloor(undoRedo)).toBe(1);
});
});