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

213 lines
8.2 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, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import codeBlockService from '@editor/services/codeBlock';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage';
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
import { setEditorConfig } from '@editor/utils/config';
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
vi.mock('@editor/services/editor', () => ({
default: {
getNodeById: vi.fn((id: string) => ({ id, code: 'code1' })),
},
}));
beforeAll(() => {
setEditorConfig({
// eslint-disable-next-line no-new-func
parseDSL: ((str: string) => new Function(`return ${str}`)()) as any,
} as any);
});
beforeEach(() => {
codeBlockService.resetState();
historyService.reset();
globalThis.localStorage.clear();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('CodeBlockService - 基础', () => {
test('setCodeDsl / getCodeDsl 保存并触发事件', async () => {
const fn = vi.fn();
codeBlockService.on('code-dsl-change', fn);
await codeBlockService.setCodeDsl({ a: { name: 'a', content: 'x' } } as any);
expect(codeBlockService.getCodeDsl()).toEqual({ a: { name: 'a', content: 'x' } });
expect(fn).toHaveBeenCalled();
codeBlockService.off('code-dsl-change', fn);
});
test('getCodeContentById - 没有 dsl 或 id 返回 null', () => {
expect(codeBlockService.getCodeContentById('')).toBeNull();
expect(codeBlockService.getCodeContentById('any')).toBeNull();
});
test('getCodeContentById - 取得现有内容', async () => {
await codeBlockService.setCodeDsl({ x: { name: 'X' } } as any);
expect(codeBlockService.getCodeContentById('x')).toEqual({ name: 'X' });
expect(codeBlockService.getCodeContentById('y')).toBeNull();
});
test('setCodeDslByIdSync - 没有 dsl 时抛错', () => {
expect(() => codeBlockService.setCodeDslByIdSync('id', {} as any)).toThrow('dsl中没有codeBlocks');
});
test('setCodeDslByIdSync - 已存在且 force=false 时跳过', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'NEW' } as any, false);
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
});
test('setCodeDslByIdSync - content 为字符串时被 parseDSL 转换', async () => {
await codeBlockService.setCodeDsl({} as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'A', content: '() => 42' } as any);
const item = codeBlockService.getCodeContentById('a') as any;
expect(typeof item.content).toBe('function');
expect(item.content()).toBe(42);
});
test('setCodeDslByIdSync - 触发 addOrUpdate 事件', async () => {
await codeBlockService.setCodeDsl({} as any);
const fn = vi.fn();
codeBlockService.on('addOrUpdate', fn);
codeBlockService.setCodeDslByIdSync('id', { name: 'a' } as any);
expect(fn).toHaveBeenCalledWith('id', expect.any(Object));
codeBlockService.off('addOrUpdate', fn);
});
test('getCodeDslByIds 仅返回指定 ids', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'a' }, b: { name: 'b' } } as any);
expect(codeBlockService.getCodeDslByIds(['a'])).toEqual({ a: { name: 'a' } });
});
test('编辑状态 / combineIds / undeletableList', async () => {
expect(codeBlockService.getEditStatus()).toBe(true);
await codeBlockService.setEditStatus(false);
expect(codeBlockService.getEditStatus()).toBe(false);
await codeBlockService.setCombineIds(['a', 'b']);
expect(codeBlockService.getCombineIds()).toEqual(['a', 'b']);
await codeBlockService.setUndeleteableList(['x']);
expect(codeBlockService.getUndeletableList()).toEqual(['x']);
});
test('代码草稿 set/get/remove', () => {
codeBlockService.setCodeDraft('id', 'draft');
expect(globalThis.localStorage.getItem(`${CODE_DRAFT_STORAGE_KEY}_id`)).toBe('draft');
expect(codeBlockService.getCodeDraft('id')).toBe('draft');
codeBlockService.removeCodeDraft('id');
expect(codeBlockService.getCodeDraft('id')).toBeNull();
});
test('deleteCodeDslByIds 删除并触发 remove 事件', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'a' }, b: { name: 'b' } } as any);
const fn = vi.fn();
codeBlockService.on('remove', fn);
await codeBlockService.deleteCodeDslByIds(['a', 'b']);
expect(codeBlockService.getCodeDsl()).toEqual({});
expect(fn).toHaveBeenCalledTimes(2);
codeBlockService.off('remove', fn);
});
test('deleteCodeDslByIds - dsl 为空时直接返回', async () => {
await expect(codeBlockService.deleteCodeDslByIds(['x'])).resolves.toBeUndefined();
});
test('paramsColConfig set/get', () => {
const cfg = { type: 'row', items: [] } as any;
codeBlockService.setParamsColConfig(cfg);
expect(codeBlockService.getParamsColConfig()).toEqual(cfg);
});
test('getUniqueId 在重复时再次取', async () => {
let count = 0;
const random = vi.spyOn(Math, 'random').mockImplementation(() => {
count += 1;
return count === 1 ? 0.111111111 : 0.222222222;
});
await codeBlockService.setCodeDsl({ code_1111: { name: 'x' } } as any);
const id = await codeBlockService.getUniqueId();
expect(id.startsWith('code_')).toBe(true);
expect(id).not.toBe('code_1111');
random.mockRestore();
});
test('paste - 不覆盖现有 id', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
storageService.setItem(
COPY_CODE_STORAGE_KEY,
{ a: { name: 'OTHER' }, b: { name: 'B' } },
{ protocol: Protocol.OBJECT },
);
codeBlockService.paste();
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
expect(codeBlockService.getCodeContentById('b')?.name).toBe('B');
});
test('copyWithRelated - 没有 collectorOptions 时仅写空对象', () => {
codeBlockService.copyWithRelated([]);
const stored = storageService.getItem(COPY_CODE_STORAGE_KEY, { protocol: Protocol.OBJECT });
expect(stored).toEqual({});
});
test('destroy 清空 listeners 与 plugin', () => {
const fn = vi.fn();
codeBlockService.on('addOrUpdate', fn);
codeBlockService.destroy();
codeBlockService.emit('addOrUpdate', 'a');
expect(fn).not.toHaveBeenCalled();
});
});
describe('CodeBlockService - 历史记录接入', () => {
test('setCodeDslByIdSync - 新增时入历史oldContent=null', async () => {
await codeBlockService.setCodeDsl({} as any);
codeBlockService.setCodeDslByIdSync('new_code', { name: 'A' } as any);
expect(historyService.canUndoCodeBlock('new_code')).toBe(true);
const step = historyService.undoCodeBlock('new_code');
expect(step?.oldContent).toBeNull();
expect(step?.newContent).toEqual(expect.objectContaining({ name: 'A' }));
});
test('setCodeDslByIdSync - 更新时入历史oldContent / newContent 都非空)', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
const step = historyService.undoCodeBlock('a');
expect(step?.oldContent).toEqual({ name: 'A' });
expect(step?.newContent).toEqual(expect.objectContaining({ name: 'A2' }));
});
test('setCodeDslByIdSync - force=false 已存在时不入历史', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any, false);
expect(historyService.canUndoCodeBlock('a')).toBe(false);
});
test('deleteCodeDslByIds - 删除已存在的代码块入历史newContent=null', async () => {
await codeBlockService.setCodeDsl({ a: { name: 'A' }, b: { name: 'B' } } as any);
await codeBlockService.deleteCodeDslByIds(['a']);
const step = historyService.undoCodeBlock('a');
expect(step?.oldContent).toEqual({ name: 'A' });
expect(step?.newContent).toBeNull();
});
test('deleteCodeDslByIds - 删除不存在的 id 不入历史', async () => {
await codeBlockService.setCodeDsl({} as any);
await codeBlockService.deleteCodeDslByIds(['ghost']);
expect(historyService.canUndoCodeBlock('ghost')).toBe(false);
});
});