roymondchen 0724c76689 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>
2026-05-07 18:54:48 +08:00

431 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { afterEach, 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();
});