1286 lines
43 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.

/* eslint-disable prettier/prettier */
/*
* 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 { describe, expect, test, vi } from 'vitest';
import type { MApp, MContainer, MNode } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type { EditorNodeInfo } from '@editor/type';
import { LayerOffset, Layout } from '@editor/type';
import * as editor from '@editor/utils/editor';
describe('util form', () => {
test('getPageList', () => {
const pageList = editor.getPageList({
id: 'app_1',
type: NodeType.ROOT,
items: [
{
id: 'page_1',
name: 'index',
type: NodeType.PAGE,
items: [],
},
],
});
expect(pageList[0].name).toBe('index');
});
test('getPageNameList', () => {
const pageList = editor.getPageNameList([
{
id: 'page_1',
name: 'index',
type: NodeType.PAGE,
items: [],
},
]);
expect(pageList[0]).toBe('index');
});
test('generatePageName', () => {
// 已有一个页面了再生成出来的name格式为page_${index}
const name = editor.generatePageName(['index', 'page_2'], NodeType.PAGE);
// 第二个页面
expect(name).toBe('page_3');
});
});
describe('getNodeIndex', () => {
test('能获取到', () => {
const index = editor.getNodeIndex(1, {
id: 2,
type: NodeType.PAGE,
items: [
{
type: 'text',
id: 1,
},
],
});
expect(index).toBe(0);
});
test('不能能获取到', () => {
// id为1不在查找数据中
const index = editor.getNodeIndex(1, {
id: 2,
type: NodeType.PAGE,
items: [
{
type: 'text',
id: 3,
},
],
});
expect(index).toBe(-1);
});
});
describe('getRelativeStyle', () => {
test('正常', () => {
const style = editor.getRelativeStyle({
color: 'red',
});
expect(style?.position).toBe('relative');
expect(style?.top).toBe(0);
expect(style?.left).toBe(0);
expect(style?.color).toBe('red');
});
});
describe('moveItemsInContainer', () => {
test('向下移动', () => {
const container = { id: 1, type: NodeType.CONTAINER, items: [{ id: 2 }, { id: 3 }, { id: 4 }] };
editor.moveItemsInContainer([0], container, 0);
expect(container.items[0].id).toBe(2);
editor.moveItemsInContainer([0], container, 1);
expect(container.items[0].id).toBe(2);
editor.moveItemsInContainer([0], container, 2);
expect(container.items[0].id).toBe(3);
expect(container.items[1].id).toBe(2);
expect(container.items[2].id).toBe(4);
});
test('向下移动到最后', () => {
const container = { id: 1, type: NodeType.CONTAINER, items: [{ id: 2 }, { id: 3 }, { id: 4 }] };
editor.moveItemsInContainer([0], container, 3);
expect(container.items[0].id).toBe(3);
expect(container.items[1].id).toBe(4);
expect(container.items[2].id).toBe(2);
});
test('向上移动', () => {
const container = { id: 1, type: NodeType.CONTAINER, items: [{ id: 2 }, { id: 3 }, { id: 4 }] };
editor.moveItemsInContainer([2], container, 3);
expect(container.items[2].id).toBe(4);
editor.moveItemsInContainer([2], container, 2);
expect(container.items[2].id).toBe(4);
editor.moveItemsInContainer([2], container, 1);
expect(container.items[0].id).toBe(2);
expect(container.items[1].id).toBe(4);
expect(container.items[2].id).toBe(3);
});
test('向上移动到最后', () => {
const container = { id: 1, type: NodeType.CONTAINER, items: [{ id: 2 }, { id: 3 }, { id: 4 }] };
editor.moveItemsInContainer([2], container, 0);
expect(container.items[0].id).toBe(4);
expect(container.items[1].id).toBe(2);
expect(container.items[2].id).toBe(3);
});
test('移动多个', () => {
const container = {
id: 1,
type: NodeType.CONTAINER,
items: [{ id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }, { id: 6 }, { id: 7 }],
};
editor.moveItemsInContainer([0, 5], container, 0);
expect(container.items[0].id).toBe(2);
expect(container.items[1].id).toBe(7);
expect(container.items[2].id).toBe(3);
});
});
describe('buildChangeRecords', () => {
test('基础类型值', () => {
const value = {
name: 'test',
age: 25,
active: true,
};
const result = editor.buildChangeRecords(value, '');
expect(result).toEqual([
{ propPath: 'name', value: 'test' },
{ propPath: 'age', value: 25 },
{ propPath: 'active', value: true },
]);
});
test('嵌套对象', () => {
const value = {
user: {
name: 'John',
profile: {
age: 30,
city: 'Beijing',
},
},
settings: {
theme: 'dark',
},
};
const result = editor.buildChangeRecords(value, '');
expect(result).toEqual([
{ propPath: 'user.name', value: 'John' },
{ propPath: 'user.profile.age', value: 30 },
{ propPath: 'user.profile.city', value: 'Beijing' },
{ propPath: 'settings.theme', value: 'dark' },
]);
});
test('带有basePath', () => {
const value = {
style: {
width: 100,
height: 200,
},
};
const result = editor.buildChangeRecords(value, 'node');
expect(result).toEqual([
{ propPath: 'node.style.width', value: 100 },
{ propPath: 'node.style.height', value: 200 },
]);
});
test('包含数组', () => {
const value = {
items: [1, 2, 3],
config: {
list: ['a', 'b'],
},
};
const result = editor.buildChangeRecords(value, '');
expect(result).toEqual([
{ propPath: 'items', value: [1, 2, 3] },
{ propPath: 'config.list', value: ['a', 'b'] },
]);
});
test('包含null值', () => {
const value = {
data: null,
info: {
value: null,
name: 'test',
},
};
const result = editor.buildChangeRecords(value, '');
expect(result).toEqual([
{ propPath: 'data', value: null },
{ propPath: 'info.value', value: null },
{ propPath: 'info.name', value: 'test' },
]);
});
test('跳过undefined值', () => {
const value = {
name: 'test',
age: undefined,
info: {
city: 'Beijing',
country: undefined,
},
};
const result = editor.buildChangeRecords(value, '');
expect(result).toEqual([
{ propPath: 'name', value: 'test' },
{ propPath: 'info.city', value: 'Beijing' },
]);
});
test('空对象', () => {
const value = {};
const result = editor.buildChangeRecords(value, '');
expect(result).toEqual([]);
});
test('深层嵌套', () => {
const value = {
level1: {
level2: {
level3: {
level4: {
value: 'deep',
},
},
},
},
};
const result = editor.buildChangeRecords(value, 'root');
expect(result).toEqual([{ propPath: 'root.level1.level2.level3.level4.value', value: 'deep' }]);
});
test('混合类型', () => {
const value = {
string: 'text',
number: 42,
boolean: false,
array: [1, 2],
object: {
nested: 'value',
},
nullValue: null,
};
const result = editor.buildChangeRecords(value, 'mixed');
expect(result).toEqual([
{ propPath: 'mixed.string', value: 'text' },
{ propPath: 'mixed.number', value: 42 },
{ propPath: 'mixed.boolean', value: false },
{ propPath: 'mixed.array', value: [1, 2] },
{ propPath: 'mixed.object.nested', value: 'value' },
{ propPath: 'mixed.nullValue', value: null },
]);
});
});
// ===== 以下为新提取的工具函数测试 =====
const mockRoot: MApp = {
id: 'app_1',
type: NodeType.ROOT,
items: [
{
id: 'page_1',
type: NodeType.PAGE,
name: 'index',
style: { position: 'relative', width: 375 },
items: [
{
id: 'node_1',
type: 'text',
style: { position: 'absolute', top: 10, left: 20, width: 100 },
},
{
id: 'node_2',
type: 'button',
style: { position: 'absolute', bottom: 50, right: 30 },
},
{
id: 'node_3',
type: 'image',
style: { position: 'relative', top: 0, left: 0 },
},
],
},
],
};
const mockGetNodeInfo = (id: string | number): EditorNodeInfo => {
const page = mockRoot.items[0];
if (`${id}` === `${mockRoot.id}`) {
return { node: mockRoot as unknown as MNode, parent: null, page: null };
}
if (`${id}` === `${page.id}`) {
return { node: page, parent: mockRoot as unknown as MContainer, page: page as any };
}
const items = (page as MContainer).items || [];
const node = items.find((n: MNode) => `${n.id}` === `${id}`);
if (node) {
return { node, parent: page as MContainer, page: page as any };
}
return { node: null, parent: null, page: null };
};
describe('resolveSelectedNode', () => {
test('传入数字ID正常返回节点信息', () => {
const result = editor.resolveSelectedNode('node_1', mockGetNodeInfo, mockRoot.id);
expect(result.node?.id).toBe('node_1');
expect(result.parent?.id).toBe('page_1');
expect(result.page?.id).toBe('page_1');
});
test('传入节点配置对象,正常返回节点信息', () => {
const config: MNode = { id: 'node_2', type: 'button' };
const result = editor.resolveSelectedNode(config, mockGetNodeInfo, mockRoot.id);
expect(result.node?.id).toBe('node_2');
});
test('传入页面ID正常返回页面信息', () => {
const result = editor.resolveSelectedNode('page_1', mockGetNodeInfo, mockRoot.id);
expect(result.node?.id).toBe('page_1');
});
test('传入空ID抛出错误', () => {
expect(() => editor.resolveSelectedNode({ id: '', type: 'text' }, mockGetNodeInfo)).toThrow('没有ID无法选中');
});
test('传入不存在的ID抛出错误', () => {
expect(() => editor.resolveSelectedNode('not_exist', mockGetNodeInfo)).toThrow('获取不到组件信息');
});
test('传入根节点ID抛出错误', () => {
expect(() => editor.resolveSelectedNode('app_1', mockGetNodeInfo, mockRoot.id)).toThrow('不能选根节点');
});
test('不传rootId时不校验根节点', () => {
const result = editor.resolveSelectedNode('app_1', mockGetNodeInfo);
expect(result.node?.id).toBe('app_1');
});
});
describe('toggleFixedPosition', () => {
const getLayoutFn = async () => Layout.ABSOLUTE;
test('非fixed变为fixed调用change2Fixed', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.position).toBe('fixed');
expect(result).not.toBe(dist);
});
test('fixed变为非fixed调用Fixed2Other', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.position).toBe('absolute');
});
test('定位未变化,不修改样式', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 30, left: 40 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.top).toBe(30);
expect(result.style?.left).toBe(40);
});
test('pop类型节点不做处理', async () => {
const src: MNode = {
id: 'node_1',
type: 'pop',
style: { position: 'absolute', top: 10, left: 20 },
name: 'pop',
};
const dist: MNode = { id: 'node_1', type: 'pop', style: { position: 'fixed', top: 10, left: 20 }, name: 'pop' };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.position).toBe('fixed');
});
test('目标节点无position属性不做处理', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute' } };
const dist: MNode = { id: 'node_1', type: 'text', style: { width: 100 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.position).toBeUndefined();
});
test('返回深拷贝,不修改原对象', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 20 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result).not.toBe(dist);
expect(dist.style?.top).toBe(20);
});
});
describe('calcMoveStyle', () => {
test('absolute定位向下移动', () => {
const style = { position: 'absolute', top: 10, left: 20 };
const result = editor.calcMoveStyle(style, 0, 5);
expect(result).toEqual({ position: 'absolute', top: 15, left: 20, bottom: '' });
});
test('absolute定位向右移动', () => {
const style = { position: 'absolute', top: 10, left: 20 };
const result = editor.calcMoveStyle(style, 5, 0);
expect(result).toEqual({ position: 'absolute', top: 10, left: 25, right: '' });
});
test('absolute定位同时向下和向右移动', () => {
const style = { position: 'absolute', top: 10, left: 20 };
const result = editor.calcMoveStyle(style, 3, 7);
expect(result).toEqual({ position: 'absolute', top: 17, left: 23, bottom: '', right: '' });
});
test('fixed定位正常移动', () => {
const style = { position: 'fixed', top: 100, left: 200 };
const result = editor.calcMoveStyle(style, -10, -20);
expect(result).toEqual({ position: 'fixed', top: 80, left: 190, bottom: '', right: '' });
});
test('使用bottom定位时向下移动减小bottom', () => {
const style = { position: 'absolute', bottom: 50, left: 20 };
const result = editor.calcMoveStyle(style, 0, 10);
expect(result?.bottom).toBe(40);
expect(result?.top).toBe('');
});
test('使用right定位时向右移动减小right', () => {
const style = { position: 'absolute', top: 10, right: 30 };
const result = editor.calcMoveStyle(style, 10, 0);
expect(result?.right).toBe(20);
expect(result?.left).toBe('');
});
test('relative定位返回null', () => {
const style = { position: 'relative', top: 0, left: 0 };
const result = editor.calcMoveStyle(style, 10, 10);
expect(result).toBeNull();
});
test('无position属性返回null', () => {
const style = { width: 100 };
const result = editor.calcMoveStyle(style, 10, 10);
expect(result).toBeNull();
});
test('空样式对象返回null', () => {
const result = editor.calcMoveStyle({}, 10, 10);
expect(result).toBeNull();
});
test('偏移量为0不修改样式', () => {
const style = { position: 'absolute', top: 10, left: 20 };
const result = editor.calcMoveStyle(style, 0, 0);
expect(result).toEqual({ position: 'absolute', top: 10, left: 20 });
});
test('不修改原对象', () => {
const style = { position: 'absolute', top: 10, left: 20 };
editor.calcMoveStyle(style, 5, 5);
expect(style.top).toBe(10);
expect(style.left).toBe(20);
});
});
describe('calcAlignCenterStyle', () => {
test('absolute布局通过配置中的width计算居中', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 0 } };
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(result?.left).toBe(137.5);
expect(result?.right).toBe('');
});
test('relative布局返回null', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100 } };
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.RELATIVE);
expect(result).toBeNull();
});
test('节点无style返回null', () => {
const node: MNode = { id: 'n1', type: 'text' };
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(result).toBeNull();
});
test('父节点无style不修改', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 10 } };
const parent = { id: 'p1', type: NodeType.PAGE, items: [] } as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(result?.left).toBe(10);
});
test('父节点width非数字不修改left', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 10 } };
const parent = {
id: 'p1',
type: NodeType.PAGE,
style: { width: '100%' },
items: [],
} as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(result?.left).toBe(10);
});
test('不修改原节点style', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 0 } };
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(node.style?.left).toBe(0);
});
});
describe('calcLayerTargetIndex', () => {
test('绝对定位向上移动1层', () => {
const result = editor.calcLayerTargetIndex(2, 1, 5, false);
expect(result).toBe(3);
});
test('绝对定位向下移动1层', () => {
const result = editor.calcLayerTargetIndex(2, -1, 5, false);
expect(result).toBe(1);
});
test('流式布局向上移动1层索引减小', () => {
const result = editor.calcLayerTargetIndex(2, 1, 5, true);
expect(result).toBe(1);
});
test('流式布局向下移动1层索引增大', () => {
const result = editor.calcLayerTargetIndex(2, -1, 5, true);
expect(result).toBe(3);
});
test('绝对定位,置顶', () => {
const result = editor.calcLayerTargetIndex(2, LayerOffset.TOP, 5, false);
expect(result).toBe(5);
});
test('绝对定位,置底', () => {
const result = editor.calcLayerTargetIndex(2, LayerOffset.BOTTOM, 5, false);
expect(result).toBe(0);
});
test('流式布局,置顶(索引最小)', () => {
const result = editor.calcLayerTargetIndex(3, LayerOffset.TOP, 5, true);
expect(result).toBe(0);
});
test('流式布局,置底(索引最大)', () => {
const result = editor.calcLayerTargetIndex(1, LayerOffset.BOTTOM, 5, true);
expect(result).toBe(5);
});
test('偏移量为0索引不变', () => {
const result = editor.calcLayerTargetIndex(2, 0, 5, false);
expect(result).toBe(2);
});
});
describe('editorNodeMergeCustomizer', () => {
test('undefined 且 source 拥有该 key 时返回空字符串', () => {
const source = { name: undefined };
const result = editor.editorNodeMergeCustomizer('old', undefined, 'name', {}, source);
expect(result).toBe('');
});
test('source 不拥有该 key 时返回 undefined使用默认合并', () => {
const result = editor.editorNodeMergeCustomizer('old', undefined, 'name', {}, {});
expect(result).toBeUndefined();
});
test('原来是数组,新值是对象,使用新值', () => {
const srcValue = { a: 1 };
const result = editor.editorNodeMergeCustomizer([1, 2], srcValue, 'key', {}, {});
expect(result).toBe(srcValue);
});
test('新值是数组,直接替换', () => {
const srcValue = [3, 4];
const result = editor.editorNodeMergeCustomizer([1, 2], srcValue, 'key', {}, {});
expect(result).toBe(srcValue);
});
test('都是普通值,返回 undefined使用默认合并', () => {
const result = editor.editorNodeMergeCustomizer('old', 'new', 'key', {}, {});
expect(result).toBeUndefined();
});
});
describe('classifyDragSources', () => {
const makeTree = (): { root: MApp; getNodeInfo: (id: any, raw?: boolean) => EditorNodeInfo } => {
const child1: MNode = { id: 'c1', type: 'text' };
const child2: MNode = { id: 'c2', type: 'text' };
const child3: MNode = { id: 'c3', type: 'text' };
const container1: MContainer = {
id: 'cont1',
type: NodeType.CONTAINER,
items: [child1, child2],
};
const container2: MContainer = {
id: 'cont2',
type: NodeType.CONTAINER,
items: [child3],
};
const page: any = {
id: 'page_1',
type: NodeType.PAGE,
items: [container1, container2],
};
const root: MApp = { id: 'app', type: NodeType.ROOT, items: [page] };
const getNodeInfo = (id: any): EditorNodeInfo => {
if (`${id}` === 'c1' || `${id}` === 'c2') {
return {
node: container1.items.find((n) => `${n.id}` === `${id}`) ?? null,
parent: container1,
page,
};
}
if (`${id}` === 'c3') {
return { node: child3, parent: container2, page };
}
if (`${id}` === 'cont1') {
return { node: container1, parent: page, page };
}
if (`${id}` === 'cont2') {
return { node: container2, parent: page, page };
}
return { node: null, parent: null, page: null };
};
return { root, getNodeInfo };
};
test('同父容器内拖拽,返回 sameParentIndices', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources([{ id: 'c1', type: 'text' }], targetParent, getNodeInfo);
expect(result.aborted).toBe(false);
expect(result.sameParentIndices).toEqual([0]);
expect(result.crossParentConfigs).toHaveLength(0);
});
test('跨容器拖拽,返回 crossParentConfigs', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources([{ id: 'c3', type: 'text' }], targetParent, getNodeInfo);
expect(result.aborted).toBe(false);
expect(result.sameParentIndices).toHaveLength(0);
expect(result.crossParentConfigs).toHaveLength(1);
expect(result.crossParentConfigs[0].config.id).toBe('c3');
});
test('混合拖拽:同容器+跨容器', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources(
[
{ id: 'c1', type: 'text' },
{ id: 'c3', type: 'text' },
],
targetParent,
getNodeInfo,
);
expect(result.aborted).toBe(false);
expect(result.sameParentIndices).toEqual([0]);
expect(result.crossParentConfigs).toHaveLength(1);
});
test('节点不存在时跳过', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources([{ id: 'nonexistent', type: 'text' }], targetParent, getNodeInfo);
expect(result.aborted).toBe(false);
expect(result.sameParentIndices).toHaveLength(0);
expect(result.crossParentConfigs).toHaveLength(0);
});
test('目标容器在节点路径上时跳过(防止循环嵌套)', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources([{ id: 'c1', type: 'text' }], targetParent, (id: any) => {
if (`${id}` === 'c1') {
return {
node: { id: 'c1', type: 'text' },
parent: targetParent,
page: { id: 'page_1', type: NodeType.PAGE, items: [] } as any,
};
}
return { node: null, parent: null, page: null };
});
expect(result.sameParentIndices).toEqual([0]);
expect(result.crossParentConfigs).toHaveLength(0);
});
});
describe('calcMoveStyle', () => {
test('非 absolute/fixed 返回 null', () => {
expect(editor.calcMoveStyle({ position: 'relative' }, 1, 1)).toBeNull();
expect(editor.calcMoveStyle(null as any, 1, 1)).toBeNull();
});
test('absolute + top/left', () => {
const result = editor.calcMoveStyle({ position: 'absolute', top: 10, left: 5 }, 3, 4)!;
expect(result.top).toBe(14);
expect(result.left).toBe(8);
});
test('absolute + bottom/right (left/top 未定义)', () => {
const result = editor.calcMoveStyle({ position: 'absolute', bottom: 10, right: 5 }, 3, 4)!;
expect(result.bottom).toBe(6);
expect(result.right).toBe(2);
});
});
describe('calcAlignCenterStyle', () => {
test('relative 布局返回 null', () => {
const result = editor.calcAlignCenterStyle(
{ id: 'a', style: { width: 100 } } as any,
{ style: { width: 200 } } as any,
Layout.RELATIVE,
);
expect(result).toBeNull();
});
test('无 doc 时按 parent.style 与 node.style 计算 left', () => {
const result = editor.calcAlignCenterStyle(
{ id: 'a', style: { width: 100, position: 'absolute' } } as any,
{ style: { width: 300 } } as any,
Layout.ABSOLUTE,
);
expect(result?.left).toBe(100);
expect(result?.right).toBe('');
});
test('node 没有 style 返回 null', () => {
const result = editor.calcAlignCenterStyle({ id: 'a' } as any, { style: { width: 100 } } as any, Layout.ABSOLUTE);
expect(result).toBeNull();
});
});
describe('calcLayerTargetIndex', () => {
test('LayerOffset.TOP / BOTTOM', () => {
expect(editor.calcLayerTargetIndex(2, LayerOffset.TOP, 5, false)).toBeGreaterThanOrEqual(0);
expect(editor.calcLayerTargetIndex(2, LayerOffset.BOTTOM, 5, false)).toBeGreaterThanOrEqual(0);
});
test('数字 offset 走偏移逻辑', () => {
const r1 = editor.calcLayerTargetIndex(2, 1, 5, true);
const r2 = editor.calcLayerTargetIndex(2, 1, 5, false);
expect(typeof r1).toBe('number');
expect(typeof r2).toBe('number');
});
});
describe('getGuideLineFromCache', () => {
test('key 为空返回 []', () => {
expect(editor.getGuideLineFromCache('')).toEqual([]);
});
test('返回缓存中的数组', () => {
globalThis.localStorage.setItem('gl_test', JSON.stringify([1, 2, 3]));
expect(editor.getGuideLineFromCache('gl_test')).toEqual([1, 2, 3]);
globalThis.localStorage.removeItem('gl_test');
});
test('JSON 解析失败时返回 []', () => {
globalThis.localStorage.setItem('gl_bad', 'not-json');
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
expect(editor.getGuideLineFromCache('gl_bad')).toEqual([]);
errSpy.mockRestore();
globalThis.localStorage.removeItem('gl_bad');
});
});
describe('change2Fixed / Fixed2Other', () => {
const root: MApp = {
id: 'app',
type: NodeType.ROOT,
items: [
{
id: 'p1',
type: NodeType.PAGE,
style: { left: 10, top: 20 },
items: [
{
id: 'btn',
type: 'text',
style: { position: 'absolute', left: 5, top: 5 },
},
],
} as any,
],
};
test('change2Fixed 累加路径上的 left/top', () => {
const node = root.items[0]!.items![0] as MNode;
const style = editor.change2Fixed(node, root);
expect(style.left).toBeGreaterThan(0);
expect(style.top).toBeGreaterThan(0);
});
test('Fixed2Other 转回 absolute', async () => {
const node = root.items[0]!.items![0] as MNode;
const style = await editor.Fixed2Other(node, root, async () => Layout.ABSOLUTE);
expect(style.position).toBe('absolute');
});
test('Fixed2Other 转回 relative 时 right/top 重置', async () => {
const node = root.items[0]!.items![0] as MNode;
const style = await editor.Fixed2Other(node, root, async () => Layout.RELATIVE);
expect(style.position).toBe('relative');
});
});
describe('补充getPageList / getPageFragmentList / generatePageNameByApp 等基础工具', () => {
test('getPageList - root 为 null/items 不是数组时返回空数组', () => {
expect(editor.getPageList(null as any)).toEqual([]);
expect(editor.getPageList(undefined as any)).toEqual([]);
expect(editor.getPageList({ id: 'x', type: NodeType.ROOT } as any)).toEqual([]);
});
test('getPageFragmentList - root 为 null/items 不是数组时返回空数组', () => {
expect(editor.getPageFragmentList(null as any)).toEqual([]);
expect(editor.getPageFragmentList({ id: 'x', type: NodeType.ROOT } as any)).toEqual([]);
});
test('getPageFragmentList - 仅返回 page-fragment 节点', () => {
const root: MApp = {
id: 'a',
type: NodeType.ROOT,
items: [
{ id: 'pf1', type: NodeType.PAGE_FRAGMENT, items: [] } as any,
{ id: 'p1', type: NodeType.PAGE, items: [] } as any,
],
};
expect(editor.getPageFragmentList(root)).toHaveLength(1);
});
test('getPageNameList - 缺少 name 时使用 index 兜底', () => {
const list = editor.getPageNameList([{ id: 'a', type: NodeType.PAGE, items: [] } as any]);
expect(list[0]).toBe('index');
});
test('generatePageName - 列表为空时返回 index', () => {
expect(editor.generatePageName([], NodeType.PAGE)).toBe('page_index');
});
test('generatePageName - 重名时累加索引', () => {
expect(editor.generatePageName(['page_1', 'page_2'], NodeType.PAGE)).toBe('page_3');
});
test('generatePageNameByApp - PAGE / PAGE_FRAGMENT 两种类型', () => {
const app: MApp = {
id: 'a',
type: NodeType.ROOT,
items: [
{ id: 'p1', type: NodeType.PAGE, name: 'page_1', items: [] } as any,
{ id: 'pf', type: NodeType.PAGE_FRAGMENT, name: 'page-fragment_1', items: [] } as any,
],
};
expect(editor.generatePageNameByApp(app, NodeType.PAGE)).toBe('page_2');
expect(editor.generatePageNameByApp(app, NodeType.PAGE_FRAGMENT)).toBe('page-fragment_2');
});
});
describe('补充getInitPositionStyle / setLayout / setChildrenLayout', () => {
test('Layout.ABSOLUTE - 已有 right 时不会被填 left=0', () => {
const style = editor.getInitPositionStyle({ right: 0 }, Layout.ABSOLUTE);
expect(style.position).toBe('absolute');
expect(style.left).toBeUndefined();
});
test('Layout.ABSOLUTE - left/right left=0', () => {
const style = editor.getInitPositionStyle({}, Layout.ABSOLUTE);
expect(style.left).toBe(0);
});
test('Layout.RELATIVE - getRelativeStyle', () => {
const style = editor.getInitPositionStyle({ color: 'red' }, Layout.RELATIVE);
expect(style.position).toBe('relative');
});
test('Layout.FIXED - style', () => {
const style = { color: 'red' };
expect(editor.getInitPositionStyle(style, Layout.FIXED)).toBe(style);
});
test('setLayout - pop ', () => {
const node = { id: 'p', type: 'pop' } as any;
expect(editor.setLayout(node, Layout.ABSOLUTE)).toBeUndefined();
});
test('setLayout - position fixed ', () => {
const node = { id: 'n', type: 'text', style: { position: 'fixed' } } as any;
expect(editor.setLayout(node, Layout.ABSOLUTE)).toBeUndefined();
});
test('setLayout - RELATIVE 使 getRelativeStyle', () => {
const node = { id: 'n', type: 'text', style: { left: 10 } } as any;
editor.setLayout(node, Layout.RELATIVE);
expect(node.style.position).toBe('relative');
expect(node.style.right).toBe('auto');
expect(node.style.bottom).toBe('auto');
});
test('setLayout - layout position absolute', () => {
const node = { id: 'n', type: 'text', style: {} } as any;
editor.setLayout(node, Layout.ABSOLUTE);
expect(node.style.position).toBe('absolute');
});
test('setLayout - style ', () => {
const node = { id: 'n', type: 'text' } as any;
editor.setLayout(node, Layout.ABSOLUTE);
});
test('setChildrenLayout - child', () => {
const container: MContainer = {
id: 'c',
type: NodeType.CONTAINER,
items: [{ id: 'a', type: 'text', style: {} } as any, { id: 'b', type: 'text', style: {} } as any],
};
const result = editor.setChildrenLayout(container, Layout.ABSOLUTE);
expect(result.items[0].style?.position).toBe('absolute');
expect(result.items[1].style?.position).toBe('absolute');
});
});
describe('fixNodeLeft / fixNodePosition / serializeConfig', () => {
test('fixNodeLeft - doc style.left', () => {
expect(editor.fixNodeLeft({ id: 'a', style: { left: 5 } } as any, { id: 'p' } as any)).toBe(5);
});
test('fixNodeLeft - left ', () => {
expect(editor.fixNodeLeft({ id: 'a', style: { left: '5%' } } as any, { id: 'p' } as any, document)).toBe('5%');
});
test('fixNodeLeft - + + ', () => {
const doc = document.implementation.createHTMLDocument();
const parent = doc.createElement('div');
parent.dataset.tmagicId = 'p';
Object.defineProperty(parent, 'offsetWidth', { value: 100 });
const child = doc.createElement('div');
child.dataset.tmagicId = 'a';
Object.defineProperty(child, 'offsetWidth', { value: 80 });
parent.appendChild(child);
doc.body.appendChild(parent);
expect(editor.fixNodeLeft({ id: 'a', style: { left: 50 } } as any, { id: 'p' } as any, doc)).toBe(20);
});
test('fixNodeLeft - left', () => {
const doc = document.implementation.createHTMLDocument();
const parent = doc.createElement('div');
parent.dataset.tmagicId = 'p2';
Object.defineProperty(parent, 'offsetWidth', { value: 200 });
const child = doc.createElement('div');
child.dataset.tmagicId = 'a2';
Object.defineProperty(child, 'offsetWidth', { value: 50 });
parent.appendChild(child);
doc.body.appendChild(parent);
expect(editor.fixNodeLeft({ id: 'a2', style: { left: 30 } } as any, { id: 'p2' } as any, doc)).toBe(30);
});
test('fixNodePosition - absolute style', () => {
const style = { position: 'relative' } as any;
expect(editor.fixNodePosition({ id: 'a', style } as any, { id: 'p', items: [] } as any, null)).toBe(style);
});
test('fixNodePosition - absolute stage top/left', () => {
const result = editor.fixNodePosition(
{ id: 'a', style: { position: 'absolute', height: 50 } } as any,
{ id: 'p', items: [], style: { height: 200 } } as any,
null,
);
expect(result).toBeDefined();
});
test('serializeConfig - key ', () => {
const out = editor.serializeConfig({ a: 1 });
expect(out).toContain('a:');
});
test('serializeConfig - key ', () => {
const out = editor.serializeConfig({ a: { b: 1 }});
expect(out).toContain('a:');
expect(out).toContain('b: 1');
});
test('serializeConfig - value中包含"x"', () => {
const out = editor.serializeConfig({ a: '"a": 1' });
expect(out).toContain('\\"a\\":');
});
test('serializeConfig - value中包含"x"', () => {
const out = editor.serializeConfig({ a: { b: '"a": 1' } });
expect(out).toContain('\\"a\\":');
expect(out).toContain('b: ');
});
test('serializeConfig - function', () => {
const out = editor.serializeConfig({
a: () => {
const b = "b";
switch (b) {
// @ts-ignore
case "a":
return 1;
}
},
});
expect(out).toContain('"a":');
});
test('serializeConfig - function', () => {
const out = editor.serializeConfig({
a: {
b: () => {
const b = "b";
switch (b) {
// @ts-ignore
case "a": return 1;
}
}
},
});
expect(out).toContain('"a":');
expect(out).toContain('b: ');
});
});
describe('isIncludeDataSource', () => {
test('updated true', () => {
const oldNode = { id: '1', type: 't', text: 'foo' } as any;
const newNode = { id: '1', type: 't', text: '${ds.bar}' } as any;
expect(editor.isIncludeDataSource(newNode, oldNode)).toBe(true);
});
test('added true', () => {
const oldNode = { id: '1', type: 't' } as any;
const newNode = { id: '1', type: 't', text: '${ds.bar}' } as any;
expect(editor.isIncludeDataSource(newNode, oldNode)).toBe(true);
});
test('NODE_CONDS_KEY true', async () => {
const { NODE_CONDS_KEY } = await import('@tmagic/core');
const oldNode = { id: '1', type: 't', [NODE_CONDS_KEY]: [] } as any;
const newNode = { id: '1', type: 't', [NODE_CONDS_KEY]: [{ field: '${ds.x}', op: '=', value: '1' }] } as any;
expect(editor.isIncludeDataSource(newNode, oldNode)).toBe(true);
});
test('NODE_CONDS_KEY ', async () => {
const { NODE_CONDS_KEY } = await import('@tmagic/core');
const oldNode = { id: '1', type: 't', [NODE_CONDS_KEY]: [{ field: 'a' }] } as any;
const newNode = { id: '1', type: 't' } as any;
// 期望函数能正常返回布尔值(覆盖 deleted 检查路径)
expect(typeof editor.isIncludeDataSource(newNode, oldNode)).toBe('boolean');
});
test(' deleted ', () => {
const oldNode = { id: '1', type: 't', extra: '${ds.x}' } as any;
const newNode = { id: '1', type: 't' } as any;
expect(typeof editor.isIncludeDataSource(newNode, oldNode)).toBe('boolean');
});
test(' false', () => {
const node = { id: '1', type: 't' } as any;
expect(editor.isIncludeDataSource(node, node)).toBe(false);
});
test('updated ', () => {
const oldNode = { id: '1', type: 't', data: { foo: { bar: 'a' } } } as any;
const newNode = { id: '1', type: 't', data: { foo: { bar: '${ds.x}' } } } as any;
expect(editor.isIncludeDataSource(newNode, oldNode)).toBe(true);
});
});
describe('collectRelatedNodes', () => {
test(' copyNodes', () => {
const source: any = { id: 'src', type: 'text', binding: '${other_node.x}' };
const other: any = { id: 'other_node', type: 'text' };
const copyNodes: MNode[] = [source];
editor.collectRelatedNodes(
copyNodes,
{ type: 'related', isTarget: () => false, initialDeps: {} } as any,
(id: any) => (id === 'other_node' ? other : null),
);
expect(Array.isArray(copyNodes)).toBe(true);
});
});
describe('calcAlignCenterStyle DOM ', () => {
test(' doc element left', () => {
const doc = document.implementation.createHTMLDocument();
const parent = doc.createElement('div');
Object.defineProperty(parent, 'clientWidth', { value: 300 });
const child = doc.createElement('div');
child.dataset.tmagicId = 'aa';
Object.defineProperty(child, 'clientWidth', { value: 100 });
Object.defineProperty(child, 'offsetParent', { value: parent });
parent.appendChild(child);
doc.body.appendChild(parent);
const result = editor.calcAlignCenterStyle(
{ id: 'aa', style: { width: 100, position: 'absolute' } } as any,
{ id: 'p', style: { width: 300 } } as any,
Layout.ABSOLUTE,
doc,
);
expect(result?.left).toBe(100);
expect(result?.right).toBe('');
});
test(' doc + Layout.FIXED doc.body parent', () => {
const doc = document.implementation.createHTMLDocument();
Object.defineProperty(doc.body, 'clientWidth', { value: 500, configurable: true });
const child = doc.createElement('div');
child.dataset.tmagicId = 'bb';
Object.defineProperty(child, 'clientWidth', { value: 100 });
doc.body.appendChild(child);
const result = editor.calcAlignCenterStyle(
{ id: 'bb', style: { width: 100, position: 'fixed' } } as any,
{ id: 'p', style: { width: 500 } } as any,
Layout.FIXED,
doc,
);
expect(result?.left).toBe(200);
});
});
describe('change2Fixed ', () => {
test(' right/ left offset 0', () => {
const root: MApp = {
id: 'app',
type: NodeType.ROOT,
items: [
{
id: 'p1',
type: NodeType.PAGE,
style: { right: 10 },
items: [{ id: 'b', type: 'text', style: { position: 'absolute', left: 5, top: 5 } }],
} as any,
],
};
const node = root.items[0]!.items![0] as MNode;
const style = editor.change2Fixed(node, root);
expect(style.left).toBe(5);
});
test(' right left', () => {
const root: MApp = {
id: 'app',
type: NodeType.ROOT,
items: [
{
id: 'p1',
type: NodeType.PAGE,
style: { left: 10, top: 20 },
items: [{ id: 'b', type: 'text', style: { position: 'absolute', right: 5 } }],
} as any,
],
};
const node = root.items[0]!.items![0] as MNode;
const style = editor.change2Fixed(node, root);
expect(style.left).toBeUndefined();
});
});
describe('classifyDragSources ', () => {
test(' getNodeIndex -1 aborted=true', () => {
const targetParent: MContainer = { id: 't', type: NodeType.CONTAINER, items: [] };
const result = editor.classifyDragSources([{ id: 'x', type: 'text' }], targetParent, ((id: any) => ({
node: { id, type: 'text' },
parent: targetParent,
page: null,
})) as any);
expect(result.aborted).toBe(true);
});
});
describe('toggleFixedPosition', () => {
test('fixed -> fixed Fixed2Other', async () => {
const root: MApp = {
id: 'app',
type: NodeType.ROOT,
items: [{ id: 'p1', type: NodeType.PAGE, items: [] } as any],
};
const result = await editor.toggleFixedPosition(
{ id: 'a', type: 'text', style: { position: 'absolute', top: 0, left: 0 } } as any,
{ id: 'a', type: 'text', style: { position: 'fixed' } } as any,
root,
async () => Layout.ABSOLUTE,
);
expect(result.style?.position).toBeDefined();
});
test(' fixed -> fixed change2Fixed', async () => {
const root: MApp = {
id: 'app',
type: NodeType.ROOT,
items: [
{
id: 'p1',
type: NodeType.PAGE,
style: { left: 0, top: 0 },
items: [{ id: 'a', type: 'text', style: { position: 'fixed', left: 0, top: 0 } }],
} as any,
],
};
const result = await editor.toggleFixedPosition(
{ id: 'a', type: 'text', style: { position: 'fixed', left: 0, top: 0 } } as any,
{ id: 'a', type: 'text', style: { position: 'absolute' } } as any,
root,
async () => Layout.ABSOLUTE,
);
expect(result.style?.position).toBe('fixed');
});
});