/* * 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(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(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(); });