diff --git a/packages/core/tests/Env.spec.ts b/packages/core/tests/Env.spec.ts new file mode 100644 index 00000000..93f76047 --- /dev/null +++ b/packages/core/tests/Env.spec.ts @@ -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 为 true,reset 后恢复', () => { + const fs = new FlowState(); + fs.abort(); + expect(fs.isAbort).toBe(true); + fs.reset(); + expect(fs.isAbort).toBe(false); + }); +}); diff --git a/packages/core/tests/Page.spec.ts b/packages/core/tests/Page.spec.ts new file mode 100644 index 00000000..79afd27c --- /dev/null +++ b/packages/core/tests/Page.spec.ts @@ -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); + }); +}); diff --git a/packages/core/tests/utils.spec.ts b/packages/core/tests/utils.spec.ts new file mode 100644 index 00000000..a5105fff --- /dev/null +++ b/packages/core/tests/utils.spec.ts @@ -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:'); + }); +}); diff --git a/packages/form-schema/tests/index.spec.ts b/packages/form-schema/tests/index.spec.ts new file mode 100644 index 00000000..529fcdfd --- /dev/null +++ b/packages/form-schema/tests/index.spec.ts @@ -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); + }); +}); diff --git a/packages/schema/tests/index.spec.ts b/packages/schema/tests/index.spec.ts new file mode 100644 index 00000000..6c1e8205 --- /dev/null +++ b/packages/schema/tests/index.spec.ts @@ -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'); + }); +}); diff --git a/packages/stage/tests/unit/util-extra.spec.ts b/packages/stage/tests/unit/util-extra.spec.ts new file mode 100644 index 00000000..ccf5ecaa --- /dev/null +++ b/packages/stage/tests/unit/util-extra.spec.ts @@ -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}`); + }); +}); diff --git a/packages/table/tests/utils.spec.ts b/packages/table/tests/utils.spec.ts new file mode 100644 index 00000000..a871ed5a --- /dev/null +++ b/packages/table/tests/utils.spec.ts @@ -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); + }); +}); diff --git a/packages/utils/tests/unit/extras.spec.ts b/packages/utils/tests/unit/extras.spec.ts new file mode 100644 index 00000000..134ae1b7 --- /dev/null +++ b/packages/utils/tests/unit/extras.spec.ts @@ -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(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(); +});