roymondchen 6f0a41f2db feat(editor): 支持 update replace 整节点替换,源码编辑走 replace 模式
新增 replace 选项跳过 mergeWith 等变换,PropsPanel 源码保存时使用整节点覆盖,避免已删除字段被 merge 保留。
2026-08-04 19:13:33 +08:00

1530 lines
56 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. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
import { cloneDeep } from 'lodash-es';
import type { MApp, MContainer, MNode } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import { getNodePath } from '@tmagic/utils';
import editorService from '@editor/services/editor';
import historyService from '@editor/services/history';
import storageService from '@editor/services/storage';
import type { EditorChangeEvent } from '@editor/type';
import { COPY_STORAGE_KEY, setEditorConfig } from '@editor/utils';
setEditorConfig({
// eslint-disable-next-line no-eval
parseDSL: (dsl: string) => eval(dsl),
customCreateMonacoEditor: (monaco, codeEditorEl, options) => monaco.editor.create(codeEditorEl, options),
customCreateMonacoDiffEditor: (monaco, codeEditorEl, options) =>
monaco.editor.createDiffEditor(codeEditorEl, options),
});
// mock window.localStage
class LocalStorageMock {
public length = 0;
private store: Record<string, string> = {};
clear() {
this.store = {};
this.length = 0;
}
getItem(key: string) {
return this.store[key] || null;
}
setItem(key: string, value: string) {
this.store[key] = String(value);
this.length += 1;
}
removeItem(key: string) {
delete this.store[key];
this.length -= 1;
}
// 这里用不到这个方法只是为了mock完整localStorage
key(key: number) {
return Object.keys(this.store)[key];
}
}
globalThis.localStorage = new LocalStorageMock();
enum NodeId {
// 跟节点
ROOT_ID = 1,
// 页面节点
PAGE_ID = 2,
// 普通节点
NODE_ID = 3,
// 普通节点
NODE_ID2 = 4,
// 不存在的节点
ERROR_NODE_ID = 5,
}
// mock 页面数据,包含一个页面,两个组件
const root: MApp = {
id: NodeId.ROOT_ID,
type: NodeType.ROOT,
items: [
{
id: NodeId.PAGE_ID,
layout: 'absolute',
type: NodeType.PAGE,
style: {
width: 375,
},
items: [
{
id: NodeId.NODE_ID,
type: 'text',
style: {
width: 270,
},
},
{
id: NodeId.NODE_ID2,
type: 'text',
style: {},
},
],
},
],
};
describe('get', () => {
// 同一个设置页面数据
beforeAll(() => editorService.set('root', cloneDeep(root)));
test('get', () => {
const root = editorService.get('root');
expect(root?.id).toBe(NodeId.ROOT_ID);
});
test('get undefined', () => {
// state中不存在的key
const root = editorService.get('a' as 'root');
expect(root).toBeUndefined();
});
});
describe('multiSelect 标志位', () => {
test('disabledMultiSelect 默认值为 false', () => {
expect(editorService.get('disabledMultiSelect')).toBe(false);
});
test('alwaysMultiSelect 默认值为 false', () => {
expect(editorService.get('alwaysMultiSelect')).toBe(false);
});
test('alwaysMultiSelect 可被 set 修改并通过 get 读取', () => {
editorService.set('alwaysMultiSelect', true);
expect(editorService.get('alwaysMultiSelect')).toBe(true);
editorService.set('alwaysMultiSelect', false);
expect(editorService.get('alwaysMultiSelect')).toBe(false);
});
});
describe('getNodeInfo', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));
test('正常', () => {
const info = editorService.getNodeInfo(NodeId.NODE_ID);
expect(info?.node?.id).toBe(NodeId.NODE_ID);
expect(info?.parent?.id).toBe(NodeId.PAGE_ID);
expect(info?.page?.id).toBe(NodeId.PAGE_ID);
});
test('异常', () => {
const info = editorService.getNodeInfo(NodeId.ERROR_NODE_ID);
expect(info?.node).toBeNull();
expect(info?.parent?.id).toBeUndefined();
expect(info?.page).toBeNull();
});
});
describe('getNodeById', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));
test('正常', () => {
const node = editorService.getNodeById(NodeId.NODE_ID);
expect(node?.id).toBe(NodeId.NODE_ID);
});
test('异常', () => {
const node = editorService.getNodeById(NodeId.ERROR_NODE_ID);
expect(node).toBeNull();
});
});
describe('getParentById', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));
test('正常', () => {
const node = editorService.getParentById(NodeId.NODE_ID);
expect(node?.id).toBe(NodeId.PAGE_ID);
});
test('异常', () => {
const node = editorService.getParentById(NodeId.ERROR_NODE_ID);
expect(node?.id).toBeUndefined();
});
});
describe('getNodeInfo 当前页面优先 / 跨页面回退', () => {
// 两个页面page2 内含一个容器及其子节点,用于覆盖「优先当前页面、回退跳过当前页面」逻辑
const PAGE2_ID = 20;
const NODE_IN_PAGE2 = 21;
const CONTAINER_IN_PAGE2 = 22;
const CHILD_IN_PAGE2 = 23;
const multiPageRoot: MApp = {
id: NodeId.ROOT_ID,
type: NodeType.ROOT,
items: [
cloneDeep(root.items[0]),
{
id: PAGE2_ID,
type: NodeType.PAGE,
layout: 'absolute',
style: { width: 375 },
items: [
{ id: NODE_IN_PAGE2, type: 'text', style: {} },
{
id: CONTAINER_IN_PAGE2,
type: 'container',
style: {},
items: [{ id: CHILD_IN_PAGE2, type: 'text', style: {} }],
},
],
},
],
};
beforeAll(async () => {
editorService.set('root', cloneDeep(multiPageRoot));
// 当前停留在 page1
await editorService.select(NodeId.PAGE_ID);
});
test('id 为 root.id 时返回 root 自身parent / page 为 null', () => {
const info = editorService.getNodeInfo(NodeId.ROOT_ID);
expect(info.node?.id).toBe(NodeId.ROOT_ID);
expect(info.parent).toBeNull();
expect(info.page).toBeNull();
});
test('当前页面节点本身node 为页面、parent 为 root、page 为页面自身', () => {
const info = editorService.getNodeInfo(NodeId.PAGE_ID);
expect(info.node?.id).toBe(NodeId.PAGE_ID);
expect(info.parent?.id).toBe(NodeId.ROOT_ID);
expect(info.page?.id).toBe(NodeId.PAGE_ID);
});
test('命中当前页面内的节点(快速路径)', () => {
const info = editorService.getNodeInfo(NodeId.NODE_ID);
expect(info.node?.id).toBe(NodeId.NODE_ID);
expect(info.parent?.id).toBe(NodeId.PAGE_ID);
expect(info.page?.id).toBe(NodeId.PAGE_ID);
});
test('命中非当前页面内的深层节点回退跳过当前页面parent / page 正确', () => {
const info = editorService.getNodeInfo(CHILD_IN_PAGE2);
expect(info.node?.id).toBe(CHILD_IN_PAGE2);
expect(info.parent?.id).toBe(CONTAINER_IN_PAGE2);
expect(info.page?.id).toBe(PAGE2_ID);
});
test('非当前页面的页面节点parent 为真实 root同一引用可安全 mutate', () => {
const info = editorService.getNodeInfo(PAGE2_ID, false);
expect(info.node?.id).toBe(PAGE2_ID);
expect(info.page?.id).toBe(PAGE2_ID);
// parent 必须是真实 root 引用,而非临时副本,否则对页面增删/排序会改不到真实树
expect(info.parent).toBe(editorService.get('root'));
});
test('不存在的节点返回空 info', () => {
const info = editorService.getNodeInfo(NodeId.ERROR_NODE_ID);
expect(info.node).toBeNull();
expect(info.page).toBeNull();
});
test('未选中任何页面时仍能跨页面查找到节点', () => {
editorService.set('root', cloneDeep(multiPageRoot));
editorService.set('page', null);
const info = editorService.getNodeInfo(CHILD_IN_PAGE2);
expect(info.node?.id).toBe(CHILD_IN_PAGE2);
expect(info.parent?.id).toBe(CONTAINER_IN_PAGE2);
expect(info.page?.id).toBe(PAGE2_ID);
});
});
describe('isOnDifferentPage', () => {
test('当前未选中任何页面时返回 false', () => {
editorService.set('root', cloneDeep(root));
editorService.resetState();
const pageNode = editorService.getNodeById(NodeId.PAGE_ID)!;
expect(editorService.isOnDifferentPage(pageNode)).toBe(false);
});
test('节点在当前页面内时返回 false', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const innerNode = editorService.getNodeById(NodeId.NODE_ID)!;
expect(editorService.isOnDifferentPage(innerNode)).toBe(false);
});
test('页面节点本身就是当前页面时返回 false', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const pageNode = editorService.getNodeById(NodeId.PAGE_ID)!;
expect(editorService.isOnDifferentPage(pageNode)).toBe(false);
});
test('节点位于非当前页面时返回 true', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
await editorService.select(NodeId.PAGE_ID);
// 加一个新页面
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode, { doNotSwitchPage: true });
const newPageId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
// 当前还在第一个页面
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
const newPageNode = editorService.getNodeById(newPageId)!;
expect(editorService.isOnDifferentPage(newPageNode)).toBe(true);
});
});
describe('select', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));
test('参数是id 正常', async () => {
// 选中一个节点会对应更新parent, page
await editorService.select(NodeId.NODE_ID);
const node = editorService.get('node');
const parent = editorService.get('parent');
const page = editorService.get('page');
expect(node?.id).toBe(NodeId.NODE_ID);
expect(parent?.id).toBe(NodeId.PAGE_ID);
expect(page?.id).toBe(NodeId.PAGE_ID);
});
test('参数是config 正常', async () => {
await editorService.select({ id: NodeId.NODE_ID, type: 'text' });
const node = editorService.get('node');
const parent = editorService.get('parent');
const page = editorService.get('page');
expect(node?.id).toBe(NodeId.NODE_ID);
expect(parent?.id).toBe(NodeId.PAGE_ID);
expect(page?.id).toBe(NodeId.PAGE_ID);
});
test('参数是id undefined', () => {
expect(() => editorService.select(NodeId.ERROR_NODE_ID)).rejects.toThrowError('获取不到组件信息');
});
test('参数是config 没有id', () => {
expect(() => editorService.select({ id: '', type: 'text' })).rejects.toThrowError('没有ID无法选中');
});
});
describe('add', () => {
test('正常', async () => {
editorService.set('root', cloneDeep(root));
// 先选中容器
await editorService.select(NodeId.PAGE_ID);
const newNode = await editorService.add({
type: 'text',
});
// 添加后会选中这个节点
const node = editorService.get('node');
const parent = editorService.get('parent');
if (!Array.isArray(newNode)) {
expect(node?.id).toBe(newNode.id);
}
expect(parent?.items).toHaveLength(3);
});
test('正常, 当前不是容器', async () => {
editorService.set('root', cloneDeep(root));
// 选中不是容器的节点
await editorService.select(NodeId.NODE_ID2);
// 会加到选中节点的父节点下
const newNode = await editorService.add({
type: 'text',
});
const node = editorService.get('node');
const parent = editorService.get('parent');
if (!Array.isArray(newNode)) {
expect(node?.id).toBe(newNode.id);
}
expect(parent?.items).toHaveLength(3);
});
test('往root下添加page', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const rootNode = editorService.get('root');
const newNode = await editorService.add(
{
type: NodeType.PAGE,
},
rootNode,
);
const node = editorService.get('node');
if (!Array.isArray(newNode)) {
expect(node?.id).toBe(newNode.id);
}
expect(rootNode?.items.length).toBe(2);
});
test('往root下添加普通节点', () => {
editorService.set('root', cloneDeep(root));
// 根节点下只能加页面
const rootNode = editorService.get('root');
expect(() =>
editorService.add(
{
type: 'text',
},
rootNode,
),
).rejects.toThrowError('app下不能添加组件');
});
test('doNotSelect: true 不更新选中节点', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
const beforeNodeId = editorService.get('node')?.id;
expect(beforeNodeId).toBe(NodeId.NODE_ID);
const newNode = await editorService.add({ type: 'text' }, null, { doNotSelect: true });
// 节点已被添加到 dsl
const addedId = Array.isArray(newNode) ? newNode[0].id : newNode.id;
const parentInfo = editorService.getParentById(addedId);
expect(parentInfo?.items).toHaveLength(3);
// 但当前选中节点保持原状(未自动选中新增节点)
expect(editorService.get('node')?.id).toBe(beforeNodeId);
});
test('doNotSwitchPage: true 新增页面时保持当前页面不切换', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const beforePageId = editorService.get('page')?.id;
expect(beforePageId).toBe(NodeId.PAGE_ID);
const rootNode = editorService.get('root');
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode, { doNotSwitchPage: true });
// 新页面已加入 dsl
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
expect(editorService.getNodeById(addedId)).toBeTruthy();
expect(rootNode?.items.length).toBe(2);
// 当前 page 保持不变(没有自动切到新加的页面)
expect(editorService.get('page')?.id).toBe(beforePageId);
});
});
describe('remove', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));
test('正常', async () => {
editorService.remove({ id: NodeId.NODE_ID, type: 'text' });
const node = editorService.getNodeById(NodeId.NODE_ID);
expect(node).toBeNull();
});
test('remove page', async () => {
editorService.set('root', cloneDeep(root));
editorService.select(NodeId.PAGE_ID);
const rootNode = editorService.get('root');
// 先加一个页面
const newPage = await editorService.add(
{
type: NodeType.PAGE,
},
rootNode,
);
expect(rootNode?.items.length).toBe(2);
await editorService.remove(newPage);
expect(rootNode?.items.length).toBe(1);
});
test('undefine', async () => {
expect(() => editorService.remove({ id: NodeId.ERROR_NODE_ID, type: 'text' })).rejects.toThrow();
});
test('doNotSelect: true 不更新选中节点', async () => {
editorService.set('root', cloneDeep(root));
// 选中 NODE_ID删除另外一个 NODE_ID2
await editorService.select(NodeId.NODE_ID);
const beforeNodeId = editorService.get('node')?.id;
expect(beforeNodeId).toBe(NodeId.NODE_ID);
await editorService.remove({ id: NodeId.NODE_ID2, type: 'text' }, { doNotSelect: true });
// 节点已被删除
expect(editorService.getNodeById(NodeId.NODE_ID2)).toBeNull();
// 当前选中节点保持原状(未自动选中父节点)
expect(editorService.get('node')?.id).toBe(beforeNodeId);
});
test('被删除节点正好是当前选中节点时state 强制移除引用', async () => {
editorService.set('root', cloneDeep(root));
// 选中 NODE_ID 后再删除 NODE_ID 自身
await editorService.select(NodeId.NODE_ID);
expect(editorService.get('node')?.id).toBe(NodeId.NODE_ID);
// 即使 doNotSelect: true被删除节点正好是当前选中节点时state 也必须移除引用
await editorService.remove({ id: NodeId.NODE_ID, type: 'text' }, { doNotSelect: true });
// 节点已删除
expect(editorService.getNodeById(NodeId.NODE_ID)).toBeNull();
// state.nodes 中不再包含被删除的节点
expect(editorService.get('nodes').some((n) => n.id === NodeId.NODE_ID)).toBe(false);
});
test('doNotSwitchPage: true 删除当前页面后不自动切到其它页面', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
// 先加一个页面,确保 root 下有 2 个页面
await editorService.select(NodeId.PAGE_ID);
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode);
expect(rootNode?.items.length).toBe(2);
// 选中第一个页面,作为当前页面
await editorService.select(NodeId.PAGE_ID);
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
// 删除当前页面,并要求不切换页面
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE }, { doNotSwitchPage: true });
// 被删除页面在 dsl 中确实已不存在
expect(editorService.getNodeById(NodeId.PAGE_ID)).toBeNull();
// 当前 page 引用被清空,不会被自动切到剩余页面
expect(editorService.get('page')).toBeNull();
// 仍保留 newPage 在 dsl 中
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
expect(editorService.getNodeById(addedId)).toBeTruthy();
});
test('默认删除当前页面后会自动切到剩余首个页面', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
// 先加一个页面
await editorService.select(NodeId.PAGE_ID);
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode);
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
expect(rootNode?.items.length).toBe(2);
// 选中第一个页面作为当前页面
await editorService.select(NodeId.PAGE_ID);
// 删除当前页面,使用默认行为
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
// 被删除页面在 dsl 中已不存在
expect(editorService.getNodeById(NodeId.PAGE_ID)).toBeNull();
// 自动切到剩余首个页面
expect(editorService.get('page')?.id).toBe(addedId);
});
test('默认删除当前页面并自动切页时,不会先把 page 置为 null', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
await editorService.select(NodeId.PAGE_ID);
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode);
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
await editorService.select(NodeId.PAGE_ID);
const pageValues: Array<MNode | null> = [];
const originalSet = editorService.set.bind(editorService);
const setSpy = vi.spyOn(editorService, 'set').mockImplementation((name, value, options) => {
if (name === 'page') {
pageValues.push(value as MNode | null);
}
return originalSet(name, value, options);
});
try {
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
} finally {
setSpy.mockRestore();
}
expect(pageValues.some((page) => page === null)).toBe(false);
expect(editorService.get('page')?.id).toBe(addedId);
});
test('删除非当前页面时保持当前页选中,不强制切到首个页面', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
await editorService.select(NodeId.PAGE_ID);
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode);
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
await editorService.select(NodeId.PAGE_ID);
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
await editorService.remove({ id: addedId, type: NodeType.PAGE });
expect(editorService.getNodeById(addedId)).toBeNull();
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
});
test('删除最后一个页面后退回选中 root', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
await editorService.select(NodeId.PAGE_ID);
expect(rootNode?.items.length).toBe(1);
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
expect(rootNode?.items.length).toBe(0);
expect(editorService.get('page')).toBeNull();
// root 下已无页面,编辑器应回到「选中 root」状态而不是什么都没选中
expect(editorService.get('nodes')).toEqual([rootNode]);
expect(editorService.get('parent')).toBeNull();
});
describe('与画布的调用顺序', () => {
const createStageStub = () => {
const calls: string[] = [];
return {
calls,
stage: {
remove: vi.fn(() => {
calls.push('remove');
return Promise.resolve();
}),
select: vi.fn(() => {
calls.push('select');
return Promise.resolve();
}),
},
};
};
afterEach(() => {
editorService.set('stage', null);
});
test('删除当前页面时先把画布切到剩余页面,再通知画布删除', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
await editorService.select(NodeId.PAGE_ID);
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode);
const addedId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
await editorService.select(NodeId.PAGE_ID);
const { calls, stage } = createStageStub();
editorService.set('stage', stage as any);
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
// runtime 删除 page 时会销毁当前渲染的实例,必须切页在前,否则画布会被清空
expect(calls).toEqual(['select', 'remove']);
expect(stage.select).toHaveBeenCalledWith(addedId);
expect(editorService.get('page')?.id).toBe(addedId);
});
test('删除当前页面片时同样先切页再通知画布删除', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
await editorService.select(NodeId.PAGE_ID);
const fragment = await editorService.add({ type: NodeType.PAGE_FRAGMENT }, rootNode);
const fragmentId = Array.isArray(fragment) ? fragment[0].id : fragment.id;
// 新增页面片后当前页已切到页面片
expect(editorService.get('page')?.id).toBe(fragmentId);
const { calls, stage } = createStageStub();
editorService.set('stage', stage as any);
await editorService.remove({ id: fragmentId, type: NodeType.PAGE_FRAGMENT });
expect(calls).toEqual(['select', 'remove']);
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
});
test('删除普通节点时先通知画布删除,再选中父节点', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
const { calls, stage } = createStageStub();
editorService.set('stage', stage as any);
await editorService.remove({ id: NodeId.NODE_ID, type: 'text' });
expect(calls).toEqual(['remove', 'select']);
expect(stage.select).toHaveBeenCalledWith(NodeId.PAGE_ID);
});
test('删除最后一个页面时在 Stage 卸载前发出删除通知', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const { calls, stage } = createStageStub();
editorService.set('stage', stage as any);
await editorService.remove({ id: NodeId.PAGE_ID, type: NodeType.PAGE });
// page 置空会让 Workspace 卸载 Stage删除通知必须在这之前发出
expect(calls).toEqual(['remove']);
// selectRoot 会顺带清掉 stage 引用
expect(editorService.get('stage')).toBeNull();
});
});
});
describe('update', () => {
beforeAll(() => {
editorService.set('root', cloneDeep(root));
// dist 版 getNodeInfo 尚未返回 path测试中手动补齐以匹配 toggleFixedPosition 入参
const originalGetNodeInfo = editorService.getNodeInfo.bind(editorService);
editorService.getNodeInfo = (id, raw = true) => {
const info = originalGetNodeInfo(id, raw);
if (!Array.isArray(info.path) && info.node) {
const appRoot = editorService.get('root');
if (appRoot && `${id}` !== `${appRoot.id}`) {
const path = getNodePath(id, appRoot.items) as MNode[];
if (path.length) {
path.unshift(appRoot as unknown as MNode);
info.path = path;
} else {
info.path = [];
}
} else {
info.path = [];
}
}
return info;
};
});
test('正常', async () => {
await editorService.select(NodeId.PAGE_ID);
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'text' });
const node = editorService.getNodeById(NodeId.NODE_ID);
expect(node?.text).toBe('text');
});
test('replace=true 时整节点替换,不 merge 保留旧字段', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
// 默认 merge未传入的 style.width 会保留
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'merged' });
expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.width).toBe(270);
// replace直接用传入配置覆盖旧字段style被去掉
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'replaced' }, { replace: true });
const node = editorService.getNodeById(NodeId.NODE_ID);
expect(node?.text).toBe('replaced');
expect(node?.style).toBeUndefined();
});
test('没有id', async () => {
try {
await editorService.update({ type: 'text', text: 'text', id: '' });
} catch (e: any) {
expect(e.message).toBe('没有配置或者配置缺少id值');
}
});
test('没有type', async () => {
// 一般可能出现在外边扩展功能
try {
await editorService.update({ type: '', text: 'text', id: NodeId.NODE_ID });
} catch (e: any) {
expect(e.message).toBe('配置缺少type值');
}
});
test('id对应节点不存在', async () => {
try {
// 设置当前编辑的页面
await editorService.select(NodeId.PAGE_ID);
await editorService.update({ type: 'text', text: 'text', id: NodeId.ERROR_NODE_ID });
} catch (e: any) {
expect(e.message).toBe(`获取不到id为${NodeId.ERROR_NODE_ID}的节点`);
}
});
test('fixed与absolute切换', async () => {
// 设置当前编辑的页面
await editorService.select(NodeId.PAGE_ID);
await editorService.update({ id: NodeId.NODE_ID, type: 'text', style: { position: 'fixed' } });
const node = editorService.getNodeById(NodeId.NODE_ID);
expect(node?.style?.position).toBe('fixed');
await editorService.update({ id: NodeId.NODE_ID, type: 'text', style: { position: 'absolute' } });
const node2 = editorService.getNodeById(NodeId.NODE_ID);
expect(node2?.style?.position).toBe('absolute');
});
test('被更新节点正好是当前选中节点时state.node 始终与 dsl 同步', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'updated-text' });
// dsl 已更新
expect(editorService.getNodeById(NodeId.NODE_ID)?.text).toBe('updated-text');
// state.node 引用同步到新节点,不会持有过期数据
expect(editorService.get('node')?.text).toBe('updated-text');
});
test('更新非选中节点时,不影响当前选中列表', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
const beforeSelected = editorService.get('node');
// 更新另一个非选中节点
await editorService.update({ id: NodeId.NODE_ID2, type: 'text', text: 'other-text' });
// dsl 已更新
expect(editorService.getNodeById(NodeId.NODE_ID2)?.text).toBe('other-text');
// 原选中节点引用不被错误替换(修复 splice(-1) 误改最后一个选中项的旧 bug
expect(editorService.get('node')?.id).toBe(NodeId.NODE_ID);
expect(editorService.get('node')).toBe(beforeSelected);
});
test('更新非当前页面时,不会把编辑器切到该页', async () => {
editorService.set('root', cloneDeep(root));
const rootNode = editorService.get('root');
// 先加一个新页面
await editorService.select(NodeId.PAGE_ID);
const newPage = await editorService.add({ type: NodeType.PAGE }, rootNode);
const newPageId = Array.isArray(newPage) ? newPage[0].id : newPage.id;
// 选中第一个页面作为当前页
await editorService.select(NodeId.PAGE_ID);
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
// 更新非当前页面newPage的配置
await editorService.update({ id: newPageId, type: NodeType.PAGE, name: 'page-renamed' });
// dsl 中该页已更新
expect(editorService.getNodeById(newPageId)?.name).toBe('page-renamed');
// 当前 page 没有被切走
expect(editorService.get('page')?.id).toBe(NodeId.PAGE_ID);
});
});
describe('sort', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));
test('正常', async () => {
await editorService.select(NodeId.NODE_ID2);
let parent = editorService.get('parent');
expect(parent?.items[0].id).toBe(NodeId.NODE_ID);
await editorService.sort(NodeId.NODE_ID2, NodeId.NODE_ID);
parent = editorService.get('parent');
expect(parent?.items[0].id).toBe(NodeId.NODE_ID2);
});
test('doNotSelect: true 完成排序且不触发额外 select', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID2);
const parentBefore = editorService.get('parent');
expect(parentBefore?.items[0].id).toBe(NodeId.NODE_ID);
await editorService.sort(NodeId.NODE_ID2, NodeId.NODE_ID, { doNotSelect: true });
// dsl 顺序已更新
const parentAfter = editorService.getParentById(NodeId.NODE_ID2);
expect(parentAfter?.items[0].id).toBe(NodeId.NODE_ID2);
});
});
describe('copy', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));
test('正常', async () => {
const node = editorService.getNodeById(NodeId.NODE_ID2);
await editorService.copy(node!);
const str = storageService.getItem(COPY_STORAGE_KEY);
expect(str).toHaveLength(1);
});
});
describe('paste', () => {
test('doNotSelect: true 不更新选中节点', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
const sourceNode = editorService.getNodeById(NodeId.NODE_ID2);
await editorService.copy(sourceNode!);
const beforeNodeId = editorService.get('node')?.id;
expect(beforeNodeId).toBe(NodeId.NODE_ID);
const pasted = await editorService.paste({}, undefined, { doNotSelect: true });
// 粘贴成功
expect(pasted).toBeTruthy();
// 当前选中节点保持原状
expect(editorService.get('node')?.id).toBe(beforeNodeId);
});
test('doNotSwitchPage: true 粘贴页面时不切换当前页面', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
// 复制当前页面
const pageNode = editorService.getNodeById(NodeId.PAGE_ID);
await editorService.copy(pageNode!);
const beforePageId = editorService.get('page')?.id;
expect(beforePageId).toBe(NodeId.PAGE_ID);
const pasted = await editorService.paste({}, undefined, { doNotSwitchPage: true });
// 粘贴成功
expect(pasted).toBeTruthy();
// 当前 page 保持不变
expect(editorService.get('page')?.id).toBe(beforePageId);
});
});
describe('moveLayer', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));
test('正常', async () => {
// 设置当前编辑的组件
await editorService.select(NodeId.NODE_ID);
const parent = editorService.get('parent');
await editorService.moveLayer(1);
expect(parent?.items[0].id).toBe(NodeId.NODE_ID2);
});
});
describe('change 事件', () => {
// 取出最后一次 change 事件的回调参数
const lastChangeEvent = (fn: ReturnType<typeof vi.fn>): EditorChangeEvent =>
fn.mock.calls[fn.mock.calls.length - 1][0];
test('add 触发 changetype 为 adddata 携带新增 node 及其所属 page', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const fn = vi.fn();
editorService.on('change', fn);
const newNode = await editorService.add({ type: 'text' });
editorService.off('change', fn);
expect(fn).toHaveBeenCalledTimes(1);
const event = lastChangeEvent(fn);
expect(event.type).toBe('add');
expect(event.data).toHaveLength(1);
const addedId = Array.isArray(newNode) ? newNode[0].id : newNode.id;
expect(event.data[0].node.id).toBe(addedId);
// 新增节点归属于当前页面
expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID);
});
test('remove 触发 changetype 为 removedata 携带被删 node 及删除前所属 page', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const fn = vi.fn();
editorService.on('change', fn);
await editorService.remove({ id: NodeId.NODE_ID, type: 'text' });
editorService.off('change', fn);
const event = lastChangeEvent(fn);
expect(event.type).toBe('remove');
expect(event.data).toHaveLength(1);
expect(event.data[0].node.id).toBe(NodeId.NODE_ID);
// 删除前捕获page 仍能正确反查到
expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID);
});
test('update 触发 changetype 为 updatedata.node 携带 newNode / oldNode', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const fn = vi.fn();
editorService.on('change', fn);
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'changed' });
editorService.off('change', fn);
const event = lastChangeEvent(fn);
expect(event.type).toBe('update');
if (event.type === 'update') {
expect(event.data[0].node.newNode.id).toBe(NodeId.NODE_ID);
expect(event.data[0].node.newNode.text).toBe('changed');
expect(event.data[0].node.oldNode.id).toBe(NodeId.NODE_ID);
expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID);
}
});
test('moveLayer 触发 changetype 为 move-layer并携带 offset', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.NODE_ID);
const fn = vi.fn();
editorService.on('change', fn);
await editorService.moveLayer(1);
editorService.off('change', fn);
const event = lastChangeEvent(fn);
expect(event.type).toBe('move-layer');
if (event.type === 'move-layer') {
expect(event.offset).toBe(1);
expect(event.data[0].node.id).toBe(NodeId.NODE_ID);
expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID);
}
});
test('dragTo 触发 changetype 为 drag-to并携带 targetIndex / targetParent', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const page = editorService.get('page') as unknown as MContainer;
const fn = vi.fn();
editorService.on('change', fn);
await editorService.dragTo({ id: NodeId.NODE_ID2, type: 'text' }, page, 0);
editorService.off('change', fn);
const event = lastChangeEvent(fn);
expect(event.type).toBe('drag-to');
if (event.type === 'drag-to') {
expect(event.targetIndex).toBe(0);
expect(event.targetParent.id).toBe(NodeId.PAGE_ID);
expect(event.data[0].node.id).toBe(NodeId.NODE_ID2);
expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID);
}
});
test('change 事件携带历史来源 historySource未传时为 undefined', async () => {
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const page = editorService.get('page') as unknown as MContainer;
const fn = vi.fn();
editorService.on('change', fn);
// update
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'source' }, { historySource: 'props' });
expect(lastChangeEvent(fn).historySource).toBe('props');
// moveLayer
await editorService.select(NodeId.NODE_ID);
await editorService.moveLayer(1, { historySource: 'stage' });
expect(lastChangeEvent(fn).historySource).toBe('stage');
// dragTo
await editorService.select(NodeId.PAGE_ID);
await editorService.dragTo({ id: NodeId.NODE_ID2, type: 'text' }, page, 0, { historySource: 'stage' });
expect(lastChangeEvent(fn).historySource).toBe('stage');
// add
const newNode = (await editorService.add({ type: 'text' }, undefined, {
historySource: 'component-panel',
})) as MNode;
expect(lastChangeEvent(fn).historySource).toBe('component-panel');
// remove
await editorService.remove(newNode, { historySource: 'shortcut' });
expect(lastChangeEvent(fn).historySource).toBe('shortcut');
// 未传 historySource 时为 undefined
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'no-source' });
expect(lastChangeEvent(fn).historySource).toBeUndefined();
// doNotPushHistory 透传true 表示本次变更未写入历史记录,缺省为 false
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'no-history' }, { doNotPushHistory: true });
expect(lastChangeEvent(fn).doNotPushHistory).toBe(true);
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'with-history' });
expect(lastChangeEvent(fn).doNotPushHistory).toBe(false);
editorService.off('change', fn);
});
test('撤销/重做触发的 change 事件携带被应用 step 的 source', async () => {
historyService.reset();
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'undo-source' }, { historySource: 'props' });
const fn = vi.fn();
editorService.on('change', fn);
await editorService.undo();
expect(lastChangeEvent(fn).historySource).toBe('props');
await editorService.redo();
expect(lastChangeEvent(fn).historySource).toBe('props');
editorService.off('change', fn);
});
test('撤销 remove重新插回节点时触发 changetype 为 add携带 step source', async () => {
historyService.reset();
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
await editorService.remove({ id: NodeId.NODE_ID, type: 'text' }, { historySource: 'shortcut' });
expect(editorService.getNodeById(NodeId.NODE_ID)).toBeNull();
const fn = vi.fn();
editorService.on('change', fn);
await editorService.undo();
editorService.off('change', fn);
// 节点已按 step 记录的位置插回
expect(editorService.getNodeById(NodeId.NODE_ID)?.id).toBe(NodeId.NODE_ID);
const events = fn.mock.calls.map((call) => call[0]) as EditorChangeEvent[];
const addEvent = events.find((e) => e.type === 'add');
expect(addEvent).toBeTruthy();
expect(addEvent?.historySource).toBe('shortcut');
// 撤销/重做补发的事件不写入历史记录
expect(addEvent?.doNotPushHistory).toBe(true);
if (addEvent?.type === 'add') {
expect(addEvent.data[0].node.id).toBe(NodeId.NODE_ID);
expect(addEvent.data[0].page?.id).toBe(NodeId.PAGE_ID);
}
});
test('重做 add重新插入节点时触发 changetype 为 add携带 step source', async () => {
historyService.reset();
editorService.set('root', cloneDeep(root));
await editorService.select(NodeId.PAGE_ID);
const newNode = (await editorService.add({ type: 'text' }, undefined, {
historySource: 'component-panel',
})) as MNode;
// 撤销 add使节点从 DSL 中移除
await editorService.undo();
expect(editorService.getNodeById(newNode.id)).toBeNull();
const fn = vi.fn();
editorService.on('change', fn);
await editorService.redo();
editorService.off('change', fn);
// 节点已重新插入
expect(editorService.getNodeById(newNode.id)?.id).toBe(newNode.id);
const events = fn.mock.calls.map((call) => call[0]) as EditorChangeEvent[];
const addEvent = events.find((e) => e.type === 'add');
expect(addEvent).toBeTruthy();
expect(addEvent?.historySource).toBe('component-panel');
// 撤销/重做补发的事件不写入历史记录
expect(addEvent?.doNotPushHistory).toBe(true);
if (addEvent?.type === 'add') {
expect(addEvent.data[0].node.id).toBe(newNode.id);
}
});
});
describe('undo redo', () => {
beforeAll(() => editorService.set('root', cloneDeep(root)));
test('正常', async () => {
historyService.reset();
// 设置当前编辑的组件
await editorService.select(NodeId.NODE_ID);
const node = editorService.get('node');
if (!node) throw new Error('未选中节点');
await editorService.remove(node);
const removedNode = editorService.getNodeById(NodeId.NODE_ID);
expect(removedNode).toBeNull();
await editorService.undo();
const undoNode = editorService.getNodeById(NodeId.NODE_ID);
expect(undoNode?.id).toBe(NodeId.NODE_ID);
await editorService.redo();
const redoNode = editorService.getNodeById(NodeId.NODE_ID);
expect(redoNode?.id).toBeUndefined();
});
test('update 携带 changeRecords 时undo/redo 仅回滚/重做对应 propPath不冲掉同节点其它字段', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
// 先携带 changeRecords 改 width
await editorService.update(
{ id: NodeId.NODE_ID, type: 'text', style: { width: 500 } },
{ changeRecords: [{ propPath: 'style.width', value: 500 }] },
);
expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.width).toBe(500);
// 在 undo 之前再追加一个不入历史的字段(模拟"同节点上其它无关变更"undo 不应把它冲掉
await editorService.update(
{ id: NodeId.NODE_ID, type: 'text', style: { width: 500, height: 80 } },
{ doNotPushHistory: true },
);
expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.height).toBe(80);
await editorService.undo();
const afterUndo = editorService.getNodeById(NodeId.NODE_ID);
// width 回退height 因为不在 changeRecords 内不会被局部 patch 覆盖
expect(afterUndo?.style?.width).toBe(270);
expect(afterUndo?.style?.height).toBe(80);
await editorService.redo();
const afterRedo = editorService.getNodeById(NodeId.NODE_ID);
expect(afterRedo?.style?.width).toBe(500);
expect(afterRedo?.style?.height).toBe(80);
});
test('update 不带 changeRecords 时退化为整节点替换', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
await editorService.update({ id: NodeId.NODE_ID, type: 'text', style: { width: 600 } });
expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.width).toBe(600);
await editorService.undo();
expect(editorService.getNodeById(NodeId.NODE_ID)?.style?.width).toBe(270);
});
});
describe('*AndGetHistoryId', () => {
const lastStepUuid = () => {
const list = historyService.getStepList('page', editorService.get('page')?.id);
return list[list.length - 1]?.step.uuid;
};
test('addAndGetHistoryId 返回本次写入历史记录的 uuid且与栈顶 step 一致', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const { result, historyIds } = await editorService.addAndGetHistoryId({ type: 'text' });
expect(result).toBeTruthy();
expect(historyIds).toHaveLength(1);
expect(historyIds[0]).toBeTruthy();
expect(historyIds[0]).toBe(lastStepUuid());
});
test('addAndGetHistoryId 传 doNotPushHistory 时返回空数组', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const { historyIds } = await editorService.addAndGetHistoryId({ type: 'text' }, null, { doNotPushHistory: true });
expect(historyIds).toEqual([]);
});
test('updateAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const { result, historyIds } = await editorService.updateAndGetHistoryId({
id: NodeId.NODE_ID,
type: 'text',
text: 'x',
});
expect(result).toBeTruthy();
expect(historyIds).toHaveLength(1);
expect(historyIds[0]).toBe(lastStepUuid());
});
test('removeAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const { historyIds } = await editorService.removeAndGetHistoryId({ id: NodeId.NODE_ID, type: 'text' });
expect(historyIds).toHaveLength(1);
expect(historyIds[0]).toBe(lastStepUuid());
});
test('moveLayerAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.NODE_ID);
const { historyIds } = await editorService.moveLayerAndGetHistoryId(1);
expect(historyIds).toHaveLength(1);
expect(historyIds[0]).toBe(lastStepUuid());
});
});
describe('revertPageStepById', () => {
test('通过 uuid 回滚 add 步骤(删除被新增节点)', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const { historyIds } = await editorService.addAndGetHistoryId({ type: 'text' });
const uuid = historyIds[0];
expect(typeof uuid).toBe('string');
const addedStep = historyService
.getStepList('page', editorService.get('page')?.id)
.find((e) => e.step.uuid === uuid)!.step;
const addedId = addedStep.diff[0].newSchema!.id;
expect(editorService.getNodeById(addedId)).toBeTruthy();
const reverted = await editorService.revertPageStepById([uuid!]);
expect(reverted[0]).not.toBeNull();
// 回滚git revert 语义)会把被新增的节点删掉
expect(editorService.getNodeById(addedId)).toBeNull();
});
test('与按 index 回滚结果一致', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const { historyIds } = await editorService.addAndGetHistoryId({ type: 'text' });
const uuid = historyIds[0];
const location = historyService.findStepLocationByUuid('page', uuid!, editorService.get('page')?.id);
expect(location?.index).toBeGreaterThanOrEqual(0);
});
test('找不到 uuid 时返回 null', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
expect(await editorService.revertPageStepById(['not-exist'])).toEqual([null]);
expect(await editorService.revertPageStepById([''])).toEqual([null]);
});
test('支持传入 uuid 数组并按顺序回滚', async () => {
editorService.set('root', cloneDeep(root));
historyService.reset();
await editorService.select(NodeId.PAGE_ID);
const { historyIds: ids1 } = await editorService.addAndGetHistoryId({ type: 'text' });
const { historyIds: ids2 } = await editorService.addAndGetHistoryId({ type: 'text' });
const uuids = [ids1[0]!, ids2[0]!];
const pageId = editorService.get('page')?.id;
const addedId1 = historyService.getStepList('page', pageId).find((e) => e.step.uuid === uuids[0])!.step.diff[0]
.newSchema!.id;
const addedId2 = historyService.getStepList('page', pageId).find((e) => e.step.uuid === uuids[1])!.step.diff[0]
.newSchema!.id;
const reverted = await editorService.revertPageStepById(uuids);
expect(reverted).toHaveLength(2);
expect(reverted[0]).not.toBeNull();
expect(reverted[1]).not.toBeNull();
expect(editorService.getNodeById(addedId1)).toBeNull();
expect(editorService.getNodeById(addedId2)).toBeNull();
});
});
describe('invalidNodeIds 校验错误状态', () => {
beforeEach(() => {
editorService.set('root', cloneDeep(root));
editorService.resetInvalidNodeId();
historyService.reset();
});
test('初始状态为空 Map', () => {
expect(editorService.getInvalidNodeIds()).toBeInstanceOf(Map);
expect(editorService.getInvalidNodeIds().size).toBe(0);
});
test('setInvalidNode 记录错误并触发 invalid-node-change 事件', () => {
const handler = vi.fn();
editorService.on('invalid-node-change', handler);
editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'name 必填');
expect(handler).toHaveBeenCalledTimes(1);
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ props: 'name 必填' });
editorService.off('invalid-node-change', handler);
});
test('同一节点不同来源(props/style)合并,互不覆盖', () => {
editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误');
editorService.setInvalidNode(NodeId.NODE_ID, 'style', 'style 错误');
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({
props: 'props 错误',
style: 'style 错误',
});
expect(editorService.getInvalidNodeIds().size).toBe(1);
});
test('deleteInvalidNode 指定来源仅删除该来源,另一来源保留', () => {
editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误');
editorService.setInvalidNode(NodeId.NODE_ID, 'style', 'style 错误');
editorService.deleteInvalidNode(NodeId.NODE_ID, 'props');
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ style: 'style 错误' });
});
test('deleteInvalidNode 删除最后一个来源时整条记录移除', () => {
editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误');
editorService.deleteInvalidNode(NodeId.NODE_ID, 'props');
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined();
expect(editorService.getInvalidNodeIds().size).toBe(0);
});
test('deleteInvalidNode 不传 source 删除整节点全部来源', () => {
editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误');
editorService.setInvalidNode(NodeId.NODE_ID, 'style', 'style 错误');
editorService.deleteInvalidNode(NodeId.NODE_ID);
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined();
});
test('deleteInvalidNode 不存在的节点为空操作,不触发事件', () => {
const handler = vi.fn();
editorService.on('invalid-node-change', handler);
editorService.deleteInvalidNode(NodeId.ERROR_NODE_ID);
expect(handler).not.toHaveBeenCalled();
editorService.off('invalid-node-change', handler);
});
test('resetInvalidNodeId 清空全部并触发事件;已空时不触发', () => {
editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误');
const handler = vi.fn();
editorService.on('invalid-node-change', handler);
editorService.resetInvalidNodeId();
expect(editorService.getInvalidNodeIds().size).toBe(0);
expect(handler).toHaveBeenCalledTimes(1);
// 已经为空时再次调用不应触发事件
editorService.resetInvalidNodeId();
expect(handler).toHaveBeenCalledTimes(1);
editorService.off('invalid-node-change', handler);
});
test('resetState 清空 invalidNodeIds', () => {
editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误');
editorService.resetState();
expect(editorService.getInvalidNodeIds().size).toBe(0);
});
test('update 携带 invalidInfo(error) 时在入栈前落库error 为空时清除', async () => {
await editorService.select(NodeId.PAGE_ID);
await editorService.update(
{ id: NodeId.NODE_ID, type: 'text', text: 'a' },
{ invalidInfo: { id: NodeId.NODE_ID, source: 'props', error: '校验失败' } },
);
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ props: '校验失败' });
await editorService.update(
{ id: NodeId.NODE_ID, type: 'text', text: 'b' },
{ invalidInfo: { id: NodeId.NODE_ID, source: 'props', error: undefined } },
);
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined();
});
test('remove 删除节点及其子树的错误记录', async () => {
await editorService.select(NodeId.NODE_ID);
editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误');
const node = editorService.get('node');
if (!node) throw new Error('未选中节点');
await editorService.remove(node);
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined();
});
test('整体替换 root 后清理不存在于新 DSL 的错误记录', () => {
editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误');
// 新 DSL 不包含 NODE_ID
const newRoot: MApp = {
id: NodeId.ROOT_ID,
type: NodeType.ROOT,
items: [
{
id: NodeId.PAGE_ID,
type: NodeType.PAGE,
layout: 'absolute',
style: { width: 375 },
items: [],
},
],
};
editorService.set('root', cloneDeep(newRoot));
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined();
});
test('历史同步update 携带错误后 undo 清错、redo 恢复错', async () => {
await editorService.select(NodeId.PAGE_ID);
await editorService.update(
{ id: NodeId.NODE_ID, type: 'text', text: 'x' },
{ invalidInfo: { id: NodeId.NODE_ID, source: 'props', error: '校验失败' } },
);
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ props: '校验失败' });
// 撤销:还原到「操作前」——错误应消失
await editorService.undo();
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined();
// 重做:还原到「操作后」——错误应恢复
await editorService.redo();
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ props: '校验失败' });
});
test('历史同步:修复(清除错误)后 undo 恢复错、redo 再次清错', async () => {
await editorService.select(NodeId.PAGE_ID);
// step1产生错误
await editorService.update(
{ id: NodeId.NODE_ID, type: 'text', text: 'x' },
{ invalidInfo: { id: NodeId.NODE_ID, source: 'props', error: '校验失败' } },
);
// step2修复清除错误
await editorService.update(
{ id: NodeId.NODE_ID, type: 'text', text: 'y' },
{ invalidInfo: { id: NodeId.NODE_ID, source: 'props', error: undefined } },
);
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined();
// 撤销 step2还原到「修复前」——错误恢复
await editorService.undo();
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toEqual({ props: '校验失败' });
// 重做 step2错误再次被清除
await editorService.redo();
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined();
});
test('update 未携带 invalidInfo 时清除对应节点的全部来源错误', async () => {
await editorService.select(NodeId.PAGE_ID);
editorService.setInvalidNode(NodeId.NODE_ID, 'props', 'props 错误');
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'z' });
expect(editorService.getInvalidNodeInfo(NodeId.NODE_ID)).toBeUndefined();
});
});