mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-08-08 22:08:33 +00:00
1530 lines
56 KiB
TypeScript
1530 lines
56 KiB
TypeScript
/*
|
||
* 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 触发 change:type 为 add,data 携带新增 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 触发 change:type 为 remove,data 携带被删 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 触发 change:type 为 update,data.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 触发 change:type 为 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 触发 change:type 为 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:重新插回节点时触发 change(type 为 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:重新插入节点时触发 change(type 为 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();
|
||
});
|
||
});
|