roymondchen 8dae67769c feat(editor): 数据源与代码块 service 支持 undo/redo
- dataSourceService / codeBlockService 新增 undo / redo / canUndo / canRedo 方法
- undo/redo 内部复用 add / update / remove / setCodeDslByIdSync / deleteCodeDslByIds 写回,
  并强制 doNotPushHistory,借此自动驱动 initService 中的依赖收集链路
  (DepTargetType.DATA_SOURCE / DATA_SOURCE_COND / DATA_SOURCE_METHOD / CODE_BLOCK)
- 更新场景下若 step 带 changeRecords,按 propPath 局部 patch,不冲掉同节点其它无关变更;
  缺省退化为整 schema / 整内容替换
- 补充对应单测与 API 文档
2026-05-28 16:40:49 +08:00

326 lines
12 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, beforeEach, describe, expect, test, vi } from 'vitest';
import dataSource from '@editor/services/dataSource';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage';
import { setEditorConfig } from '@editor/utils/config';
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
vi.mock('@editor/services/editor', () => ({
default: {
getNodeById: vi.fn((id: string) => ({ id, dsBinding: ['ds1'] })),
},
}));
setEditorConfig({
// eslint-disable-next-line no-new-func
parseDSL: ((str: string) => new Function(`return ${str}`)()) as any,
} as any);
beforeEach(() => {
dataSource.resetState();
dataSource.set('configs', {});
dataSource.set('values', {});
dataSource.set('events', {});
dataSource.set('methods', {});
historyService.reset();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('DataSource service', () => {
test('setFormConfig / getFormConfig 取自 configs', () => {
const config = [{ type: 'text' }] as any;
dataSource.setFormConfig('http', config);
const result = dataSource.getFormConfig('http');
expect(Array.isArray(result)).toBe(true);
});
test('setFormValue / getFormValue', () => {
dataSource.setFormValue('http', { id: 'x', title: 'T' } as any);
const v = dataSource.getFormValue('http');
expect(v).toBeDefined();
});
test('setFormEvent / getFormEvent 默认空数组', () => {
expect(dataSource.getFormEvent('http')).toEqual([]);
dataSource.setFormEvent('http', [{ name: 'click' }] as any);
expect(dataSource.getFormEvent('http')).toHaveLength(1);
});
test('setFormMethod / getFormMethod 默认空数组', () => {
expect(dataSource.getFormMethod('http')).toEqual([]);
dataSource.setFormMethod('http', [{ name: 'send' }] as any);
expect(dataSource.getFormMethod('http')).toHaveLength(1);
});
test('add - 没有 id 时自动生成', () => {
const fn = vi.fn();
dataSource.on('add', fn);
const ds = dataSource.add({ title: 'a', type: 'base' } as any);
expect(ds.id?.startsWith('ds_')).toBe(true);
expect(fn).toHaveBeenCalled();
dataSource.off('add', fn);
});
test('add - 已有 id 重复时重新生成', () => {
dataSource.add({ id: 'x', title: 'a', type: 'base' } as any);
const ds = dataSource.add({ id: 'x', title: 'a2', type: 'base' } as any);
expect(ds.id).not.toBe('x');
});
test('update - 修改已有数据源并触发事件', () => {
const fn = vi.fn();
const created = dataSource.add({ title: 'a', type: 'base' } as any);
dataSource.on('update', fn);
const newConfig = { ...created, title: 'b' } as any;
dataSource.update(newConfig);
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('b');
expect(fn).toHaveBeenCalled();
dataSource.off('update', fn);
});
test('remove - 触发 remove 事件', () => {
const fn = vi.fn();
const created = dataSource.add({ title: 'a', type: 'base' } as any);
dataSource.on('remove', fn);
dataSource.remove(created.id!);
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
expect(fn).toHaveBeenCalledWith(created.id);
dataSource.off('remove', fn);
});
test('createId 以 ds_ 开头', () => {
expect(dataSource.createId().startsWith('ds_')).toBe(true);
});
test('paste - 不覆盖现有数据源', () => {
dataSource.add({ id: 'a', title: 'A', type: 'base' } as any);
storageService.setItem(
COPY_DS_STORAGE_KEY,
[
{ id: 'a', title: 'A2', type: 'base' },
{ id: 'b', title: 'B', type: 'base' },
],
{ protocol: Protocol.OBJECT },
);
dataSource.paste();
expect(dataSource.getDataSourceById('a')?.title).toBe('A');
expect(dataSource.getDataSourceById('b')?.title).toBe('B');
});
test('copyWithRelated - 没有 collectorOptions 时只写空数组', () => {
dataSource.copyWithRelated([]);
expect(storageService.getItem(COPY_DS_STORAGE_KEY)).toEqual([]);
});
test('destroy 清空 listeners', () => {
const fn = vi.fn();
dataSource.on('add', fn);
dataSource.destroy();
dataSource.emit('add', {});
expect(fn).not.toHaveBeenCalled();
});
});
describe('DataSource service - 历史记录接入', () => {
test('add - 入历史oldSchema=null', () => {
const ds = dataSource.add({ title: 'a', type: 'base' } as any);
expect(historyService.canUndoDataSource(ds.id!)).toBe(true);
const step = historyService.undoDataSource(ds.id!);
expect(step?.oldSchema).toBeNull();
expect(step?.newSchema?.title).toBe('a');
});
test('update - 入历史oldSchema 是旧值newSchema 是新值', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
// 清掉 add 推入的那条
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any);
const step = historyService.undoDataSource(created.id!);
expect(step?.oldSchema?.title).toBe('a');
expect(step?.newSchema?.title).toBe('b');
});
test('remove - 入历史newSchema=null', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
dataSource.remove(created.id!);
const step = historyService.undoDataSource(created.id!);
expect(step?.oldSchema?.title).toBe('a');
expect(step?.newSchema).toBeNull();
});
test('remove - 不存在的 id 不入历史', () => {
dataSource.remove('ghost');
expect(historyService.canUndoDataSource('ghost')).toBe(false);
});
test('update - 携带 changeRecords 时写入历史 step', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any, {
changeRecords: [{ propPath: 'title', value: 'b' }],
});
const step = historyService.undoDataSource(created.id!);
expect(step?.changeRecords).toEqual([{ propPath: 'title', value: 'b' }]);
});
test('update - 不传 changeRecords 时 step.changeRecords 为 undefined', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any);
const step = historyService.undoDataSource(created.id!);
expect(step?.changeRecords).toBeUndefined();
});
});
describe('DataSource service - undo / redo', () => {
test('undo / redo - 新增场景:撤销=移除,重做=再添加', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
// undo 后数据源应被移除
const undoStep = dataSource.undo(created.id!);
expect(undoStep).not.toBeNull();
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
// redo 后数据源应被重新添加
const redoStep = dataSource.redo(created.id!);
expect(redoStep).not.toBeNull();
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('a');
});
test('undo / redo - 删除场景:撤销=还原,重做=再删除', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
dataSource.remove(created.id!);
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
dataSource.undo(created.id!);
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('a');
dataSource.redo(created.id!);
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
});
test('undo / redo - 更新场景(无 changeRecords整 schema 替换', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any);
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('b');
dataSource.undo(created.id!);
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('a');
dataSource.redo(created.id!);
expect(dataSource.getDataSourceById(created.id!)?.title).toBe('b');
});
test('undo / redo - 更新场景(带 changeRecords按 propPath 局部 patch不冲掉同节点其它字段', () => {
const created = dataSource.add({ title: 'a', type: 'base', description: 'origin' } as any);
historyService.reset();
// 模拟 form 端只更新 title
dataSource.update({ ...created, title: 'b', description: 'origin' } as any, {
changeRecords: [{ propPath: 'title', value: 'b' }],
});
// 在两次 update 之间用户又改了同节点的另一个字段(不入历史,模拟外部同步)
dataSource.update({ ...dataSource.getDataSourceById(created.id!), description: 'changed-by-other' } as any, {
doNotPushHistory: true,
});
// undo 应只回滚 title不影响 description
dataSource.undo(created.id!);
const after = dataSource.getDataSourceById(created.id!);
expect(after?.title).toBe('a');
expect(after?.description).toBe('changed-by-other');
// redo 应只重做 title
dataSource.redo(created.id!);
const redo = dataSource.getDataSourceById(created.id!);
expect(redo?.title).toBe('b');
expect(redo?.description).toBe('changed-by-other');
});
test('undo / redo - 通过 add / update / remove 触发事件,依赖收集链路保留', () => {
const addFn = vi.fn();
const updateFn = vi.fn();
const removeFn = vi.fn();
dataSource.on('add', addFn);
dataSource.on('update', updateFn);
dataSource.on('remove', removeFn);
const created = dataSource.add({ title: 'a', type: 'base' } as any);
addFn.mockClear();
// 撤销新增 → 触发 remove 事件initService 中的 dataSourceRemoveHandler 会清依赖
dataSource.undo(created.id!);
expect(removeFn).toHaveBeenCalledWith(created.id);
// 重做新增 → 触发 add 事件initService 会重新 collectIdle
dataSource.redo(created.id!);
expect(addFn).toHaveBeenCalled();
// 推入一次 update 历史,再 undo 触发 update 事件
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any);
updateFn.mockClear();
dataSource.undo(created.id!);
expect(updateFn).toHaveBeenCalled();
dataSource.off('add', addFn);
dataSource.off('update', updateFn);
dataSource.off('remove', removeFn);
});
test('undo / redo - 写回时不会再次入历史栈', () => {
const created = dataSource.add({ title: 'a', type: 'base' } as any);
historyService.reset();
dataSource.update({ ...created, title: 'b' } as any);
// 此时栈里只有一条 update
expect(historyService.canUndoDataSource(created.id!)).toBe(true);
dataSource.undo(created.id!);
// undo 后栈应可 redo并且 undo 不应再生新栈记录
expect(historyService.canRedoDataSource(created.id!)).toBe(true);
dataSource.redo(created.id!);
expect(historyService.canRedoDataSource(created.id!)).toBe(false);
expect(historyService.canUndoDataSource(created.id!)).toBe(true);
});
test('canUndo / canRedo 委托给 historyService', () => {
expect(dataSource.canUndo('ghost')).toBe(false);
expect(dataSource.canRedo('ghost')).toBe(false);
const created = dataSource.add({ title: 'a', type: 'base' } as any);
expect(dataSource.canUndo(created.id!)).toBe(true);
expect(dataSource.canRedo(created.id!)).toBe(false);
dataSource.undo(created.id!);
expect(dataSource.canUndo(created.id!)).toBe(false);
expect(dataSource.canRedo(created.id!)).toBe(true);
});
test('undo - 栈不存在或已无可撤销时返回 null', () => {
expect(dataSource.undo('ghost')).toBeNull();
expect(dataSource.redo('ghost')).toBeNull();
});
});