mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-05-08 17:48:47 +00:00
- 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>
431 lines
14 KiB
TypeScript
431 lines
14 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, 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();
|
||
});
|