test: 补齐 schema/form-schema/table 包测试并扩充 utils/stage/core 用例

- schema/form-schema/table 三个此前无测试的包补齐基础用例
- utils 扩充 dom helpers、compiledCond、getDefaultValueFromFields 等边界场景
- stage 补充常量/枚举与 selected class、getTargetElStyle 等工具用例
- core 新增 utils(style2Obj/transformStyle/getTransform)、Env、Store、FlowState、Page 用例

测试文件总数 43 -> 51, 用例数 419 -> 559。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
roymondchen 2026-05-07 18:54:48 +08:00
parent c9cef3e20c
commit 0724c76689
8 changed files with 1300 additions and 0 deletions

View File

@ -0,0 +1,128 @@
/*
* 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 } from 'vitest';
import Env from '../src/Env';
import FlowState from '../src/FlowState';
import Store from '../src/Store';
describe('Env', () => {
test('空 ua 时所有标识为 false', () => {
const env = new Env('');
expect(env.isIos).toBe(false);
expect(env.isAndroid).toBe(false);
expect(env.isWeb).toBe(false);
});
test('iPhone ua 解析为 isIos / isIphone', () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148';
const env = new Env(ua);
expect(env.isIphone).toBe(true);
expect(env.isIos).toBe(true);
expect(env.isAndroid).toBe(false);
expect(env.isWeb).toBe(false);
});
test('iPad ua 解析为 isIpad / isIos', () => {
const ua = 'Mozilla/5.0 (iPad; CPU OS 16_0 like Mac OS X)';
const env = new Env(ua);
expect(env.isIpad).toBe(true);
expect(env.isIos).toBe(true);
});
test('Android phone vs Android pad', () => {
const phone = new Env('Mozilla/5.0 (Linux; Android 13; Mobile)');
expect(phone.isAndroid).toBe(true);
expect(phone.isAndroidPad).toBe(false);
const pad = new Env('Mozilla/5.0 (Linux; Android 13; Tablet)');
expect(pad.isAndroid).toBe(true);
expect(pad.isAndroidPad).toBe(true);
});
test('Mac / Windows / Wechat / 企业 QQ', () => {
const mac = new Env('Mozilla/5.0 (Macintosh; Intel Mac OS X)');
expect(mac.isMac).toBe(true);
expect(mac.isWeb).toBe(true);
const win = new Env('Mozilla/5.0 (Windows NT 10.0)');
expect(win.isWin).toBe(true);
const wechat = new Env('Mozilla/5.0 ... MicroMessenger/8.0');
expect(wechat.isWechat).toBe(true);
const qq = new Env('Mozilla/5.0 ... QQ/8.0.0');
expect(qq.isMqq).toBe(true);
const wxwork = new Env('MicroMessenger/8.0 wxwork/4.0');
expect(wxwork.isWechat).toBe(false);
});
test('OpenHarmony 时 isWeb 为 false', () => {
const env = new Env('Mozilla/5.0 OpenHarmony 4.0');
expect(env.isOpenHarmony).toBe(true);
expect(env.isWeb).toBe(false);
});
test('支持自定义扩展属性', () => {
const env = new Env('Mozilla/5.0 (Macintosh; Intel Mac OS X)', { custom: 'value', isWin: true });
expect((env as any).custom).toBe('value');
expect(env.isWin).toBe(true);
});
});
describe('Store', () => {
test('默认 initialData', () => {
const store = new Store();
expect(store.get('foo')).toBeUndefined();
});
test('set / get', () => {
const store = new Store();
store.set('foo', 'bar');
expect(store.get('foo')).toBe('bar');
});
test('使用自定义 initialData', () => {
const store = new Store({ initialData: { a: 1, b: 2 } });
expect(store.get('a')).toBe(1);
expect(store.get('b')).toBe(2);
});
test('set 覆盖 initialData 中的值', () => {
const store = new Store({ initialData: { a: 1 } });
store.set('a', 100);
expect(store.get('a')).toBe(100);
});
});
describe('FlowState', () => {
test('初始 isAbort 为 false', () => {
const fs = new FlowState();
expect(fs.isAbort).toBe(false);
});
test('abort 后 isAbort 为 truereset 后恢复', () => {
const fs = new FlowState();
fs.abort();
expect(fs.isAbort).toBe(true);
fs.reset();
expect(fs.isAbort).toBe(false);
});
});

View File

@ -0,0 +1,95 @@
/*
* 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 } from 'vitest';
import { type MApp, NodeType } from '@tmagic/schema';
import App from '../src/App';
const createDsl = (): MApp => ({
type: NodeType.ROOT,
id: 'app_1',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{ id: 'btn_1', type: 'button' },
{
id: 'container_1',
type: 'container',
items: [{ id: 'text_1', type: 'text' }],
},
],
},
{
type: NodeType.PAGE,
id: 'page_2',
items: [{ id: 'btn_2', type: 'button' }],
},
],
});
describe('Page', () => {
test('初始化时收集页面下所有节点', () => {
const app = new App({ config: createDsl() });
const { page } = app;
expect(page).toBeDefined();
expect(page?.nodes.has('page_1')).toBe(true);
expect(page?.nodes.has('btn_1')).toBe(true);
expect(page?.nodes.has('container_1')).toBe(true);
expect(page?.nodes.has('text_1')).toBe(true);
});
test('getNode 通过 id 直接获取', () => {
const app = new App({ config: createDsl() });
const node = app.page?.getNode('text_1');
expect(node?.data.id).toBe('text_1');
});
test('getNode 不存在的 id 返回 undefined', () => {
const app = new App({ config: createDsl() });
expect(app.page?.getNode('not-exist')).toBeUndefined();
});
test('setNode / deleteNode 工作正常', () => {
const app = new App({ config: createDsl() });
const page = app.page!;
const fakeNode = { destroy() {} } as any;
page.setNode('foo', fakeNode);
expect(page.nodes.has('foo')).toBe(true);
page.deleteNode('foo');
expect(page.nodes.has('foo')).toBe(false);
});
test('destroy 后节点 map 被清空', () => {
const app = new App({ config: createDsl() });
const page = app.page!;
expect(page.nodes.size).toBeGreaterThan(0);
page.destroy();
expect(page.nodes.size).toBe(0);
});
test('切换页面会构建新的 page', () => {
const app = new App({ config: createDsl() });
expect(app.page?.data.id).toBe('page_1');
app.setPage('page_2');
expect(app.page?.data.id).toBe('page_2');
expect(app.page?.nodes.has('btn_2')).toBe(true);
});
});

View File

@ -0,0 +1,146 @@
/*
* 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 } from 'vitest';
import {
COMMON_EVENT_PREFIX,
COMMON_METHOD_PREFIX,
fillBackgroundImage,
getTransform,
style2Obj,
transformStyle,
} from '../src/utils';
describe('style2Obj', () => {
test('解析 css 字符串到对象,并将 kebab-case 转 camelCase', () => {
const obj = style2Obj('background-color: red; font-size: 14px;');
expect(obj).toEqual({ backgroundColor: 'red', fontSize: '14px' });
});
test('忽略空段', () => {
const obj = style2Obj('color: red;;;font-weight: bold;');
expect(obj).toEqual({ color: 'red', fontWeight: 'bold' });
});
test('支持 value 中包含冒号 (例如 url(http://...))', () => {
const obj = style2Obj('background: url(http://example.com/a.png) no-repeat;');
expect(obj.background).toContain('http://example.com/a.png');
});
test('非字符串原样返回', () => {
const original = { color: 'red' };
expect(style2Obj(original as any)).toBe(original);
});
});
describe('fillBackgroundImage', () => {
test('裸路径会包裹 url()', () => {
expect(fillBackgroundImage('a.png')).toBe('url(a.png)');
});
test('已经是 url() 不重复包裹', () => {
expect(fillBackgroundImage('url(a.png)')).toBe('url(a.png)');
});
test('linear-gradient 不包裹', () => {
expect(fillBackgroundImage('linear-gradient(red, blue)')).toBe('linear-gradient(red, blue)');
});
test('空值原样返回', () => {
expect(fillBackgroundImage('')).toBe('');
});
});
describe('getTransform', () => {
test('browser: 字符串原样返回', () => {
expect(getTransform('rotate(90deg) scale(1.5)', 'browser')).toBe('rotate(90deg) scale(1.5)');
});
test('browser: 对象拼接成字符串', () => {
expect(getTransform({ rotate: '90', scale: '1.5' }, 'browser')).toBe('rotate(90deg) scale(1.5)');
});
test('hippy: 字符串解析成数组', () => {
expect(getTransform('rotate(90deg) scale(1.5)', 'hippy')).toEqual([{ rotate: '90deg' }, { scale: '1.5' }]);
});
test('hippy: 对象转换为数组', () => {
expect(getTransform({ rotate: '90', scale: '1.5' }, 'hippy')).toEqual([{ rotate: '90deg' }, { scale: '1.5' }]);
});
test('值为空: browser 返回空字符串, hippy 返回空数组', () => {
expect(getTransform('', 'browser')).toBe('');
expect(getTransform('', 'hippy')).toEqual([]);
});
test('对象中空值会被过滤掉', () => {
expect(getTransform({ rotate: ' ', scale: '1.5' }, 'browser')).toBe('scale(1.5)');
});
});
describe('transformStyle', () => {
test('空值返回空对象', () => {
expect(transformStyle('', 'browser')).toEqual({});
expect(transformStyle(null as any, 'browser')).toEqual({});
});
test('字符串入参先解析再转换', () => {
const result = transformStyle('width: 100; color: red;', 'browser');
expect(result.width).toBe('1rem');
expect(result.color).toBe('red');
});
test('对象入参,数值转换为 rem', () => {
expect(transformStyle({ width: 100 }, 'browser')).toEqual({ width: '1rem' });
});
test('白名单字段不会被转 rem', () => {
expect(transformStyle({ zIndex: 100, opacity: 0.5, fontWeight: 700 }, 'browser')).toEqual({
zIndex: 100,
opacity: 0.5,
fontWeight: 700,
});
});
test('hippy 模式不转 rem 而是保留原数值', () => {
expect(transformStyle({ width: 100 }, 'hippy')).toEqual({ width: 100 });
});
test('hippy: scale 单独走分支会转化为 transform 数组', () => {
expect(transformStyle({ scale: 1.5 }, 'hippy')).toEqual({ transform: [{ scale: 1.5 }] });
});
test('backgroundImage: browser 下补全 url()', () => {
expect(transformStyle({ backgroundImage: 'a.png' }, 'browser')).toEqual({
backgroundImage: 'url(a.png)',
});
});
test('transform 字段会通过 getTransform 处理', () => {
expect(transformStyle({ transform: { rotate: '90' } }, 'browser')).toEqual({
transform: 'rotate(90deg)',
});
});
});
describe('常量', () => {
test('事件 / 方法前缀', () => {
expect(COMMON_EVENT_PREFIX).toBe('magic:common:events:');
expect(COMMON_METHOD_PREFIX).toBe('magic:common:actions:');
});
});

View File

@ -0,0 +1,82 @@
/*
* 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 } from 'vitest';
import { defineFormConfig, defineFormItem } from '../src/index';
describe('defineFormConfig', () => {
test('应该原样返回配置identity 函数)', () => {
const config = [
{
name: 'title',
text: '标题',
type: 'text',
},
];
const result = defineFormConfig(config as any);
expect(result).toBe(config);
});
test('支持空数组', () => {
const result = defineFormConfig([]);
expect(result).toEqual([]);
});
test('保留嵌套结构', () => {
const config = [
{
type: 'tab',
items: [
[
{
name: 'a',
text: 'A',
type: 'text',
},
],
],
},
];
const result = defineFormConfig(config as any);
expect(result).toEqual(config);
});
});
describe('defineFormItem', () => {
test('应该原样返回 item 配置identity 函数)', () => {
const item = {
name: 'title',
text: '标题',
type: 'text',
};
const result = defineFormItem(item as any);
expect(result).toBe(item);
});
test('支持包含函数字段', () => {
const handler = () => 'hello';
const item = {
name: 'foo',
text: 'Foo',
type: 'text',
onChange: handler,
};
const result = defineFormItem(item as any) as any;
expect(result.onChange).toBe(handler);
});
});

View File

@ -0,0 +1,87 @@
/*
* 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 } from 'vitest';
import {
ActionType,
HookCodeType,
HookType,
NODE_CONDS_KEY,
NODE_CONDS_RESULT_KEY,
NODE_DISABLE_CODE_BLOCK_KEY,
NODE_DISABLE_DATA_SOURCE_KEY,
NodeType,
} from '../src/index';
describe('schema constants', () => {
test('NODE_CONDS_KEY', () => {
expect(NODE_CONDS_KEY).toBe('displayConds');
});
test('NODE_CONDS_RESULT_KEY', () => {
expect(NODE_CONDS_RESULT_KEY).toBe('displayCondsResultReverse');
});
test('NODE_DISABLE_DATA_SOURCE_KEY', () => {
expect(NODE_DISABLE_DATA_SOURCE_KEY).toBe('_tmagic_node_disabled_data_source');
});
test('NODE_DISABLE_CODE_BLOCK_KEY', () => {
expect(NODE_DISABLE_CODE_BLOCK_KEY).toBe('_tmagic_node_disabled_code_block');
});
});
describe('NodeType enum', () => {
test('字段值正确', () => {
expect(NodeType.CONTAINER).toBe('container');
expect(NodeType.PAGE).toBe('page');
expect(NodeType.ROOT).toBe('app');
expect(NodeType.PAGE_FRAGMENT).toBe('page-fragment');
});
test('枚举值唯一', () => {
const values = Object.values(NodeType);
expect(new Set(values).size).toBe(values.length);
});
});
describe('ActionType enum', () => {
test('字段值正确', () => {
expect(ActionType.COMP).toBe('comp');
expect(ActionType.CODE).toBe('code');
expect(ActionType.DATA_SOURCE).toBe('data-source');
});
test('枚举值唯一', () => {
const values = Object.values(ActionType);
expect(new Set(values).size).toBe(values.length);
});
});
describe('HookType enum', () => {
test('CODE 字段值', () => {
expect(HookType.CODE).toBe('code');
});
});
describe('HookCodeType enum', () => {
test('字段值', () => {
expect(HookCodeType.CODE).toBe('code');
expect(HookCodeType.DATA_SOURCE_METHOD).toBe('data-source-method');
});
});

View File

@ -0,0 +1,253 @@
/*
* 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 { beforeEach, describe, expect, test } from 'vitest';
import {
AbleActionEventType,
CONTAINER_HIGHLIGHT_CLASS_NAME,
ContainerHighlightType,
DEFAULT_ZOOM,
DRAG_EL_ID_PREFIX,
GHOST_EL_ID_PREFIX,
GuidesType,
HIGHLIGHT_EL_ID_PREFIX,
Mode,
MouseButton,
PAGE_CLASS,
RenderType,
SELECTED_CLASS,
SelectStatus,
StageDragStatus,
ZIndex,
} from '../../src/const';
import {
addSelectedClassName,
getBorderWidth,
getMarginValue,
getTargetElStyle,
isAbsolute,
isMoveableButton,
isRelative,
isStatic,
removeSelectedClassName,
} from '../../src/util';
describe('const 常量', () => {
test('字符串常量', () => {
expect(GHOST_EL_ID_PREFIX).toBe('ghost_el_');
expect(DRAG_EL_ID_PREFIX).toBe('drag_el_');
expect(HIGHLIGHT_EL_ID_PREFIX).toBe('highlight_el_');
expect(CONTAINER_HIGHLIGHT_CLASS_NAME).toBe('tmagic-stage-container-highlight');
expect(PAGE_CLASS).toBe('magic-ui-page');
expect(SELECTED_CLASS).toBe('tmagic-stage-selected-area');
expect(DEFAULT_ZOOM).toBe(1);
});
test('GuidesType 枚举', () => {
expect(GuidesType.HORIZONTAL).toBe('horizontal');
expect(GuidesType.VERTICAL).toBe('vertical');
});
test('ZIndex 枚举', () => {
expect(ZIndex.MASK).toBe('99999');
expect(ZIndex.SELECTED_EL).toBe('666');
expect(ZIndex.GHOST_EL).toBe('700');
expect(ZIndex.DRAG_EL).toBe('9');
expect(ZIndex.HIGHLIGHT_EL).toBe('8');
});
test('MouseButton 枚举', () => {
expect(MouseButton.LEFT).toBe(0);
expect(MouseButton.MIDDLE).toBe(1);
expect(MouseButton.RIGHT).toBe(2);
});
test('Mode 枚举', () => {
expect(Mode.ABSOLUTE).toBe('absolute');
expect(Mode.FIXED).toBe('fixed');
expect(Mode.SORTABLE).toBe('sortable');
});
test('其他枚举', () => {
expect(AbleActionEventType.SELECT_PARENT).toBe('select-parent');
expect(AbleActionEventType.REMOVE).toBe('remove');
expect(AbleActionEventType.RERENDER).toBe('rerender');
expect(ContainerHighlightType.DEFAULT).toBe('default');
expect(ContainerHighlightType.ALT).toBe('alt');
expect(RenderType.IFRAME).toBe('iframe');
expect(RenderType.NATIVE).toBe('native');
expect(SelectStatus.SELECT).toBe('select');
expect(SelectStatus.MULTI_SELECT).toBe('multiSelect');
expect(StageDragStatus.START).toBe('start');
expect(StageDragStatus.ING).toBe('ing');
expect(StageDragStatus.END).toBe('end');
});
});
describe('isAbsolute / isRelative / isStatic', () => {
test('isAbsolute', () => {
expect(isAbsolute({ position: 'absolute' })).toBe(true);
expect(isAbsolute({ position: 'relative' })).toBe(false);
expect(isAbsolute({})).toBe(false);
});
test('isRelative', () => {
expect(isRelative({ position: 'relative' })).toBe(true);
expect(isRelative({ position: 'absolute' })).toBe(false);
expect(isRelative({})).toBe(false);
});
test('isStatic', () => {
expect(isStatic({ position: 'static' })).toBe(true);
expect(isStatic({ position: 'fixed' })).toBe(false);
expect(isStatic({})).toBe(false);
});
});
describe('isMoveableButton', () => {
let doc: Document;
beforeEach(() => {
doc = globalThis.document;
doc.body.innerHTML = '';
});
test('元素自身带 moveable-button class', () => {
const el = doc.createElement('div');
el.classList.add('moveable-button');
expect(isMoveableButton(el)).toBe(true);
});
test('父元素带 moveable-button class', () => {
const parent = doc.createElement('div');
parent.classList.add('moveable-button');
const child = doc.createElement('div');
parent.appendChild(child);
expect(isMoveableButton(child)).toBe(true);
});
test('都不带时为 falsy', () => {
const el = doc.createElement('div');
expect(isMoveableButton(el)).toBeFalsy();
});
});
describe('getMarginValue / getBorderWidth', () => {
let doc: Document;
beforeEach(() => {
doc = globalThis.document;
doc.body.innerHTML = '';
});
test('null 元素返回全 0', () => {
expect(getMarginValue(null as unknown as Element)).toEqual({
marginLeft: 0,
marginTop: 0,
});
expect(getBorderWidth(null as unknown as Element)).toEqual({
borderLeftWidth: 0,
borderRightWidth: 0,
borderTopWidth: 0,
borderBottomWidth: 0,
});
});
test('正常元素返回数字jsdom 默认 0', () => {
const el = doc.createElement('div');
doc.body.appendChild(el);
const m = getMarginValue(el);
expect(typeof m.marginLeft).toBe('number');
expect(typeof m.marginTop).toBe('number');
const b = getBorderWidth(el);
expect(typeof b.borderLeftWidth).toBe('number');
expect(typeof b.borderRightWidth).toBe('number');
expect(typeof b.borderTopWidth).toBe('number');
expect(typeof b.borderBottomWidth).toBe('number');
});
});
describe('selected class 操作', () => {
let doc: Document;
beforeEach(() => {
doc = globalThis.document;
doc.body.innerHTML = '';
});
test('addSelectedClassName 给目标添加 selected, 父级添加 -parent, 祖先添加 -parents', () => {
const grand = doc.createElement('div');
const parent = doc.createElement('div');
const child = doc.createElement('div');
grand.appendChild(parent);
parent.appendChild(child);
doc.body.appendChild(grand);
addSelectedClassName(child, doc);
expect(child.classList.contains(SELECTED_CLASS)).toBe(true);
expect(parent.classList.contains(`${SELECTED_CLASS}-parent`)).toBe(true);
expect(grand.classList.contains(`${SELECTED_CLASS}-parents`)).toBe(true);
});
test('removeSelectedClassName 清除所有相关 class', () => {
const grand = doc.createElement('div');
const parent = doc.createElement('div');
const child = doc.createElement('div');
grand.appendChild(parent);
parent.appendChild(child);
doc.body.appendChild(grand);
addSelectedClassName(child, doc);
removeSelectedClassName(doc);
expect(child.classList.contains(SELECTED_CLASS)).toBe(false);
expect(parent.classList.contains(`${SELECTED_CLASS}-parent`)).toBe(false);
expect(grand.classList.contains(`${SELECTED_CLASS}-parents`)).toBe(false);
});
test('removeSelectedClassName 在没有选中元素时不抛错', () => {
expect(() => removeSelectedClassName(doc)).not.toThrow();
});
});
describe('getTargetElStyle', () => {
let doc: Document;
beforeEach(() => {
doc = globalThis.document;
doc.body.innerHTML = '';
});
test('返回包含元素尺寸的样式字符串', () => {
const el = doc.createElement('div');
Object.defineProperty(el, 'clientWidth', { value: 100 });
Object.defineProperty(el, 'clientHeight', { value: 50 });
doc.body.appendChild(el);
const style = getTargetElStyle(el as any);
expect(style).toContain('width: 100px');
expect(style).toContain('height: 50px');
expect(style).toContain('position: absolute');
});
test('传入 zIndex 时包含 z-index 声明', () => {
const el = doc.createElement('div');
doc.body.appendChild(el);
const style = getTargetElStyle(el as any, ZIndex.DRAG_EL);
expect(style).toContain(`z-index: ${ZIndex.DRAG_EL}`);
});
});

View File

@ -0,0 +1,79 @@
/*
* 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 { ColumnConfig } from '../src/schema';
import { createColumns, formatter } from '../src/utils';
describe('createColumns', () => {
test('原样返回 columns 配置', () => {
const columns: ColumnConfig[] = [
{ prop: 'name', label: '名称' },
{ prop: 'age', label: '年龄' },
];
expect(createColumns(columns)).toBe(columns);
});
test('空数组', () => {
expect(createColumns([])).toEqual([]);
});
});
describe('formatter', () => {
test('未配置 prop 时返回空字符串', () => {
const item: ColumnConfig = {};
const result = formatter(item, { name: 'tom' }, { index: 0 });
expect(result).toBe('');
});
test('未配置 formatter 时直接返回 row[prop]', () => {
const item: ColumnConfig = { prop: 'name' };
const result = formatter(item, { name: 'tom' }, { index: 0 });
expect(result).toBe('tom');
});
test('formatter 为函数时调用并返回结果', () => {
const fn = vi.fn((value: string, _row: any, _data: any) => `${value}!`);
const item: ColumnConfig = { prop: 'name', formatter: fn };
const result = formatter(item, { name: 'tom' }, { index: 1 });
expect(fn).toHaveBeenCalledWith('tom', { name: 'tom' }, { index: 1 });
expect(result).toBe('tom!');
});
test('formatter 抛错时回退到 row[prop]', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const item: ColumnConfig = {
prop: 'name',
formatter: () => {
throw new Error('boom');
},
};
const result = formatter(item, { name: 'jerry' }, { index: 0 });
expect(result).toBe('jerry');
expect(errorSpy).toHaveBeenCalled();
errorSpy.mockRestore();
});
test("formatter 为 'datetime' 字符串时会被替换成函数并执行", () => {
const item: ColumnConfig = { prop: 'createdAt', formatter: 'datetime' };
const row = { createdAt: '2024-01-01 10:30:00' };
const result = formatter(item, row, { index: 0 });
expect(typeof item.formatter).toBe('function');
expect(typeof result === 'string' || typeof result === 'undefined').toBe(true);
});
});

View File

@ -0,0 +1,430 @@
/*
* 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, beforeEach, describe, expect, test, vi } from 'vitest';
import { NodeType } from '@tmagic/schema';
import {
addParamToUrl,
calculatePercentage,
compiledCond,
convertToNumber,
emptyFn,
getDefaultValueFromFields,
getGlobalThis,
getKeys,
getKeysArray,
getNodeInfo,
IS_DSL_NODE_KEY,
isDslNode,
isNumber,
isObject,
isPageFragment,
isPercentage,
isPop,
isValueIncludeDataSource,
removeDataSourceFieldPrefix,
setValueByKeyPath,
sleep,
traverseNode,
} from '../../src';
import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX } from '../../src/const';
import {
addClassName,
calcValueByFontsize,
createDiv,
getDocument,
getElById,
getIdFromEl,
injectStyle,
removeClassName,
removeClassNameByClassName,
setDslDomRelateConfig,
setIdToEl,
} from '../../src/dom';
describe('basic helpers', () => {
test('emptyFn 永远返回 undefined', () => {
expect(emptyFn()).toBeUndefined();
expect((emptyFn as Function).call({}, 1, 2, 3)).toBeUndefined();
});
test('getGlobalThis 返回全局对象', () => {
const g = getGlobalThis();
expect(g).toBeDefined();
// 多次调用返回同一缓存
expect(getGlobalThis()).toBe(g);
});
test('sleep 在指定时间后 resolve', async () => {
const start = Date.now();
await sleep(10);
expect(Date.now() - start).toBeGreaterThanOrEqual(8);
});
test('isObject 仅对纯对象返回 true', () => {
expect(isObject({})).toBe(true);
expect(isObject({ a: 1 })).toBe(true);
expect(isObject([])).toBe(false);
expect(isObject(null)).toBe(false);
expect(isObject('s')).toBe(false);
expect(isObject(undefined)).toBe(false);
expect(isObject(new Date())).toBe(false);
});
test('isNumber 同时支持数字与可解析字符串', () => {
expect(isNumber(1)).toBe(true);
expect(isNumber(0)).toBe(true);
expect(isNumber(-1.5)).toBe(true);
expect(isNumber('1')).toBe(true);
expect(isNumber('-1.5')).toBe(true);
expect(isNumber(NaN)).toBe(false);
expect(isNumber('abc')).toBe(false);
expect(isNumber('1px')).toBe(false);
});
});
describe('node 类型判断', () => {
test('isPop: 不传则为 false', () => {
expect(isPop(null)).toBe(false);
});
test('isPageFragment 仅识别 page-fragment', () => {
expect(isPageFragment({ id: 1, type: NodeType.PAGE_FRAGMENT })).toBe(true);
expect(isPageFragment({ id: 1, type: NodeType.PAGE })).toBe(false);
expect(isPageFragment(undefined)).toBe(false);
expect(isPageFragment(null)).toBe(false);
});
test('isDslNode 默认为 true手动 false 关闭', () => {
expect(isDslNode({ type: 'text' } as any)).toBe(true);
expect(isDslNode({ type: 'text', [IS_DSL_NODE_KEY]: false } as any)).toBe(false);
expect(isDslNode({ type: 'text', [IS_DSL_NODE_KEY]: true } as any)).toBe(true);
});
});
describe('百分比与单位', () => {
test('isPercentage', () => {
expect(isPercentage('10%')).toBe(true);
expect(isPercentage('100.5%')).toBe(true);
expect(isPercentage('10')).toBe(false);
expect(isPercentage(10)).toBe(false);
});
test('calculatePercentage', () => {
expect(calculatePercentage(200, '50%')).toBe(100);
expect(calculatePercentage(100, '0%')).toBe(0);
});
test('convertToNumber: 数字直接返回', () => {
expect(convertToNumber(100)).toBe(100);
});
test('convertToNumber: 百分比按父值计算', () => {
expect(convertToNumber('50%', 200)).toBe(100);
});
test('convertToNumber: 普通字符串使用 parseFloat', () => {
expect(convertToNumber('123.45px')).toBeCloseTo(123.45);
});
});
describe('compiledCond 全部分支', () => {
test.each([
['is', 1, 1, true],
['is', 1, 2, false],
['not', 1, 2, true],
['not', 1, 1, false],
['=', 'a', 'a', true],
['!=', 'a', 'b', true],
['>', 2, 1, true],
['>=', 1, 1, true],
['<', 1, 2, true],
['<=', 1, 1, true],
['include', [1, 2], 1, true],
['include', 'abc', 'b', true],
['not_include', [1, 2], 3, true],
['unknown', 1, 1, false],
] as const)('op=%s value=%s 应该返回 %s', (op, fieldValue, inputValue, expected) => {
expect(compiledCond(op, fieldValue, inputValue)).toBe(expected);
});
test('between/not_between', () => {
expect(compiledCond('between', 5, undefined, [1, 10])).toBe(true);
expect(compiledCond('between', 11, undefined, [1, 10])).toBe(false);
expect(compiledCond('not_between', 11, undefined, [1, 10])).toBe(true);
expect(compiledCond('not_between', 5, undefined, [1, 10])).toBe(false);
expect(compiledCond('between', 5, undefined, [1])).toBe(false);
});
test('字符串字段 + undefined 输入会被规范成空字符串', () => {
expect(compiledCond('is', '', undefined)).toBe(true);
});
});
describe('getDefaultValueFromFields 边界场景', () => {
test('没有 type 也没有 defaultValue 时返回 undefined', () => {
const result = getDefaultValueFromFields([{ name: 'x' }] as any);
expect(result).toEqual({ x: undefined });
});
test('array 类型但 defaultValue 不是数组时退回 []', () => {
const result = getDefaultValueFromFields([{ name: 'x', type: 'array', defaultValue: 'not-array' }] as any);
expect(result.x).toEqual([]);
});
test('object 类型且 defaultValue 是 JSON 字符串时会被解析', () => {
const result = getDefaultValueFromFields([{ name: 'x', type: 'object', defaultValue: '{"a":1}' }] as any);
expect(result.x).toEqual({ a: 1 });
});
test('object 类型且 defaultValue 是非法 JSON 字符串时退回 {}', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
const result = getDefaultValueFromFields([{ name: 'x', type: 'object', defaultValue: 'not-json' }] as any);
expect(result.x).toEqual({});
warnSpy.mockRestore();
});
test('boolean / number 类型默认为 undefined', () => {
const result = getDefaultValueFromFields([
{ name: 'b', type: 'boolean' },
{ name: 'n', type: 'number' },
] as any);
expect(result).toEqual({ b: undefined, n: undefined });
});
});
describe('getNodeInfo', () => {
const root = {
id: 'app',
items: [
{
id: 'page_1',
type: NodeType.PAGE,
items: [{ id: 'btn_1', type: 'button' }],
},
],
} as any;
test('id 等于 root.id 时返回 root 自身', () => {
const info = getNodeInfo('app', root);
expect(info.node).toBe(root);
});
test('找到子节点时同时返回 parent 和 page', () => {
const info = getNodeInfo('btn_1', root);
expect(info.node?.id).toBe('btn_1');
expect(info.parent?.id).toBe('page_1');
expect(info.page?.id).toBe('page_1');
});
test('未找到节点时返回空 info', () => {
const info = getNodeInfo('not-exist', root);
expect(info.node).toBeNull();
expect(info.parent).toBeNull();
});
test('root 为 null 时返回空 info', () => {
const info = getNodeInfo('foo', null);
expect(info.node).toBeNull();
});
});
describe('setValueByKeyPath / getKeys', () => {
test('setValueByKeyPath 设置嵌套值', () => {
const obj: any = {};
setValueByKeyPath('a.b.c', 1, obj);
expect(obj.a.b.c).toBe(1);
});
test('getKeys 返回对象 keys', () => {
const obj: { a: number; b: number } = { a: 1, b: 2 };
expect(getKeys(obj)).toEqual(['a', 'b']);
});
});
describe('traverseNode', () => {
test('深度优先遍历,记录 parents', () => {
const tree = {
id: 1,
items: [{ id: 2, items: [{ id: 4 }] }, { id: 3 }],
};
const visited: Array<{ id: number; depth: number }> = [];
traverseNode<any>(tree, (node, parents) => {
visited.push({ id: node.id, depth: parents.length });
});
expect(visited).toEqual([
{ id: 1, depth: 0 },
{ id: 2, depth: 1 },
{ id: 4, depth: 2 },
{ id: 3, depth: 1 },
]);
});
test('evalCbAfter=true 时回调在子节点之后执行', () => {
const tree = { id: 1, items: [{ id: 2 }] };
const order: number[] = [];
traverseNode<any>(tree, (node) => order.push(node.id), [], true);
expect(order).toEqual([2, 1]);
});
});
describe('isValueIncludeDataSource & removeDataSourceFieldPrefix', () => {
test('字符串模板包含数据源', () => {
expect(isValueIncludeDataSource('hello ${ds.field}')).toBe(true);
expect(isValueIncludeDataSource('hello world')).toBe(false);
});
test('数组首项以前缀开头时识别为数据源', () => {
expect(isValueIncludeDataSource([`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}foo`, 'bar'])).toBe(true);
});
test('对象 isBindDataSource(Field) + dataSourceId 时识别为数据源', () => {
expect(isValueIncludeDataSource({ isBindDataSource: true, dataSourceId: 'ds_1' })).toBe(true);
expect(isValueIncludeDataSource({ isBindDataSourceField: true, dataSourceId: 'ds_1' })).toBe(true);
expect(isValueIncludeDataSource({ isBindDataSource: true })).toBe(false);
});
test('removeDataSourceFieldPrefix', () => {
expect(removeDataSourceFieldPrefix(`${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}foo.bar`)).toBe('foo.bar');
expect(removeDataSourceFieldPrefix(undefined)).toBe('');
expect(removeDataSourceFieldPrefix('plain')).toBe('plain');
});
});
describe('getKeysArray 数字输入', () => {
test('数字 0 应转为 ["0"]', () => {
expect(getKeysArray(0)).toEqual(['0']);
});
});
describe('addParamToUrl', () => {
beforeEach(() => {
if (typeof globalThis.window === 'undefined') {
// jsdom 环境下默认有 window这里防御性补全
(globalThis as any).window = globalThis;
}
});
test('needReload=false 时调用 history.pushState', () => {
const pushState = vi.fn();
const fakeGlobal: any = {
location: { href: 'http://example.com/a?b=1' },
history: { pushState },
};
addParamToUrl({ b: '2', c: '3' }, fakeGlobal, false);
expect(pushState).toHaveBeenCalledTimes(1);
expect(fakeGlobal.location.href).toBe('http://example.com/a?b=1');
});
test('needReload=true 时直接修改 location.href', () => {
const fakeGlobal: any = {
location: { href: 'http://example.com/' },
history: { pushState: vi.fn() },
};
addParamToUrl({ b: '1' }, fakeGlobal, true);
expect(fakeGlobal.location.href).toContain('b=1');
});
});
describe('dom helpers', () => {
let doc: Document;
beforeEach(() => {
if (typeof globalThis.document === 'undefined') return;
doc = globalThis.document;
doc.body.innerHTML = '';
});
test('addClassName / removeClassName', () => {
if (!doc) return;
const a = doc.createElement('div');
const b = doc.createElement('div');
doc.body.appendChild(a);
doc.body.appendChild(b);
addClassName(a, doc, 'active');
expect(a.classList.contains('active')).toBe(true);
addClassName(b, doc, 'active');
expect(a.classList.contains('active')).toBe(false);
expect(b.classList.contains('active')).toBe(true);
removeClassName(b, 'active');
expect(b.classList.contains('active')).toBe(false);
});
test('removeClassNameByClassName', () => {
if (!doc) return;
const a = doc.createElement('div');
a.classList.add('x');
doc.body.appendChild(a);
const removed = removeClassNameByClassName(doc, 'x');
expect(removed).toBe(a);
expect(a.classList.contains('x')).toBe(false);
expect(removeClassNameByClassName(doc, 'not-exist')).toBeNull();
});
test('injectStyle 创建 style 节点', () => {
if (!doc) return;
const styleEl = injectStyle(doc, '.a { color: red; }');
expect(styleEl.tagName.toLowerCase()).toBe('style');
expect(styleEl.innerHTML).toContain('color: red');
});
test('createDiv 设置 className 与 cssText', () => {
if (!doc) return;
const el = createDiv({ className: 'foo', cssText: 'width: 1px;' });
expect(el.className).toBe('foo');
expect(el.style.width).toBe('1px');
});
test('getDocument 返回全局 document', () => {
expect(getDocument()).toBe(globalThis.document);
});
test('calcValueByFontsize: 没有 doc 时直接返回 value', () => {
expect(calcValueByFontsize(undefined as any, 100)).toBe(100);
});
test('calcValueByFontsize: documentElement.fontSize 设置时按比例换算', () => {
if (!doc) return;
doc.documentElement.style.fontSize = '50px';
expect(calcValueByFontsize(doc, 100)).toBeCloseTo(200);
doc.documentElement.style.fontSize = '';
});
test('dslDomRelateConfig get/set/getId', () => {
if (!doc) return;
const div = doc.createElement('div');
setIdToEl()(div, 'node-1');
expect(getIdFromEl()(div)).toBe('node-1');
doc.body.appendChild(div);
expect(getElById()(doc, 'node-1')).toBe(div);
const customGetId = (el?: HTMLElement | SVGElement | null) => `custom-${el?.id ?? 'none'}`;
setDslDomRelateConfig('getIdFromEl', customGetId);
expect(getIdFromEl()(div)).toBe('custom-');
setDslDomRelateConfig('getIdFromEl', (el?: HTMLElement | SVGElement | null) => el?.dataset?.tmagicId);
});
});
afterEach(() => {
vi.restoreAllMocks();
});