diff --git a/packages/editor/tests/unit/components/CompareForm.spec.ts b/packages/editor/tests/unit/components/CompareForm.spec.ts new file mode 100644 index 00000000..925fbf9a --- /dev/null +++ b/packages/editor/tests/unit/components/CompareForm.spec.ts @@ -0,0 +1,230 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { defineComponent, h, nextTick, ref } from 'vue'; +import { mount } from '@vue/test-utils'; + +import { HookType } from '@tmagic/core'; + +import CompareForm from '@editor/components/CompareForm.vue'; + +const propsService = { + getPropsConfig: vi.fn(async () => [ + { + type: 'tab', + items: [{ title: '样式', items: [{ type: 'text', name: 'color', display: false }] }], + }, + { type: 'text', name: 'name' }, + ]), +}; +const dataSourceService = { + getFormConfig: vi.fn(() => [{ type: 'text', name: 'title' }]), +}; +const codeBlockService = { + getParamsColConfig: vi.fn(() => null), +}; +const editorService = { + get: vi.fn(() => ({ select: vi.fn() })), +}; + +let capturedShowDiff: ((args: any) => boolean) | undefined; +let capturedFormProps: Record = {}; + +vi.mock('@editor/hooks/use-services', () => ({ + useServices: () => ({ + propsService, + dataSourceService, + codeBlockService, + editorService, + }), +})); + +vi.mock('@editor/utils/code-block', () => ({ + getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]), +})); + +vi.mock('@tmagic/form', () => ({ + MForm: defineComponent({ + name: 'MForm', + props: ['config', 'initValues', 'lastValues', 'isCompare', 'disabled', 'labelWidth', 'extendState', 'showDiff'], + setup(props, { expose }) { + capturedShowDiff = props.showDiff as (args: any) => boolean; + capturedFormProps = props as Record; + expose({ formState: {} }); + return () => h('div', { class: 'fake-mform' }); + }, + }), +})); + +beforeEach(() => { + vi.clearAllMocks(); + capturedShowDiff = undefined; + capturedFormProps = {}; +}); + +describe('CompareForm.vue', () => { + test('node 类别按 type 加载 props 配置并展示 MForm', async () => { + const wrapper = mount(CompareForm, { + props: { + category: 'node', + type: 'text', + value: { id: 'n1', name: 'new' }, + lastValue: { id: 'n1', name: 'old' }, + }, + global: { + provide: { + codeOptions: { theme: 'vs-dark' }, + }, + }, + }); + await nextTick(); + await nextTick(); + + expect(propsService.getPropsConfig).toHaveBeenCalledWith('text', { node: { id: 'n1', name: 'new' } }); + expect(wrapper.find('.fake-mform').exists()).toBe(true); + expect(capturedFormProps.initValues).toEqual({ id: 'n1', name: 'new' }); + expect(capturedFormProps.lastValues).toEqual({ id: 'n1', name: 'old' }); + }); + + test('node 类别缺少 type 时不渲染 MForm', async () => { + const wrapper = mount(CompareForm, { + props: { + category: 'node', + value: { id: 'n1' }, + }, + }); + await nextTick(); + expect(wrapper.find('.fake-mform').exists()).toBe(false); + }); + + test('data-source 类别加载数据源表单配置', async () => { + mount(CompareForm, { + props: { + category: 'data-source', + type: 'http', + value: { id: 'ds_1', title: 'A' }, + lastValue: { id: 'ds_1', title: 'B' }, + }, + }); + await nextTick(); + await nextTick(); + expect(dataSourceService.getFormConfig).toHaveBeenCalledWith('http'); + }); + + test('code-block 类别会把 content 非字符串值 normalize 成字符串', async () => { + mount(CompareForm, { + props: { + category: 'code-block', + value: { id: 'cb_1', content: { toString: () => 'fn-body' } }, + lastValue: { id: 'cb_1', content: '' }, + }, + }); + await nextTick(); + await nextTick(); + expect(capturedFormProps.initValues.content).toBe('fn-body'); + expect(capturedFormProps.lastValues.content).toBe(''); + }); + + test('传入 height 时外层容器启用内部滚动样式', () => { + const wrapper = mount(CompareForm, { + props: { + category: 'node', + type: 'text', + value: { id: 'n1' }, + height: '400px', + }, + }); + const style = wrapper.find('.m-editor-compare-form-wrapper').attributes('style') || ''; + expect(style).toContain('height: 400px'); + expect(style).toContain('overflow: auto'); + }); + + test('自定义 loadConfig 可接管配置加载', async () => { + const loadConfig = vi.fn(async ({ defaultLoadConfig }) => { + await defaultLoadConfig(); + return [{ type: 'text', name: 'custom' }]; + }); + const wrapper = mount(CompareForm, { + props: { + category: 'node', + type: 'text', + value: { id: 'n1' }, + loadConfig, + }, + }); + await nextTick(); + await nextTick(); + await nextTick(); + expect(loadConfig).toHaveBeenCalled(); + expect((wrapper.vm as any).config).toEqual([{ type: 'text', name: 'custom' }]); + }); + + test('showDiff 对 code-select 的空形态视为相等', async () => { + mount(CompareForm, { + props: { + category: 'node', + type: 'text', + value: { id: 'n1' }, + }, + }); + await nextTick(); + await nextTick(); + expect(capturedShowDiff).toBeTypeOf('function'); + expect( + capturedShowDiff!({ + curValue: '', + lastValue: { hookType: HookType.CODE, hookData: [] }, + config: { type: 'code-select' }, + }), + ).toBe(false); + expect( + capturedShowDiff!({ + curValue: { hookType: HookType.CODE, hookData: [] }, + lastValue: '', + config: { type: 'code-select' }, + }), + ).toBe(false); + expect( + capturedShowDiff!({ + curValue: 'a', + lastValue: 'b', + config: { type: 'code-select' }, + }), + ).toBe(true); + }); + + test('reload 暴露方法会重新加载配置', async () => { + const wrapper = mount(CompareForm, { + props: { + category: 'data-source', + type: 'base', + value: { id: 'ds_1' }, + }, + }); + await nextTick(); + await nextTick(); + dataSourceService.getFormConfig.mockClear(); + await (wrapper.vm as any).reload(); + expect(dataSourceService.getFormConfig).toHaveBeenCalled(); + }); + + test('watchEffect 会把 stage / services 写入 MForm.formState', async () => { + const stage = ref({ select: vi.fn() }); + editorService.get.mockReturnValue(stage.value); + mount(CompareForm, { + props: { + category: 'node', + type: 'text', + value: { id: 'n1' }, + }, + }); + await nextTick(); + await nextTick(); + stage.value = { select: vi.fn() }; + await nextTick(); + expect(editorService.get).toHaveBeenCalledWith('stage'); + }); +}); diff --git a/packages/editor/tests/unit/layouts/history-list/BucketTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/BucketTab.spec.ts new file mode 100644 index 00000000..64a9591b --- /dev/null +++ b/packages/editor/tests/unit/layouts/history-list/BucketTab.spec.ts @@ -0,0 +1,72 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test } from 'vitest'; +import { mount } from '@vue/test-utils'; + +import BucketTab from '@editor/layouts/history-list/BucketTab.vue'; +import type { HistoryBucketConfig } from '@editor/type'; + +const buildConfig = (): HistoryBucketConfig => ({ + title: '数据源', + prefix: 'ds', + describeGroup: () => 'desc', + describeStep: () => 'sub-desc', +}); + +const buildGroup = () => ({ + applied: true, + opType: 'update' as const, + steps: [{ index: 0, applied: true, step: { mark: 's-0' } }], +}); + +describe('BucketTab.vue', () => { + test('buckets 为空时显示空态', () => { + const wrapper = mount(BucketTab, { + props: { + config: buildConfig(), + buckets: [], + expanded: {}, + }, + }); + expect(wrapper.find('.m-editor-history-list-empty').text()).toBe('暂无操作记录'); + }); + + test('buckets 非空时渲染 toolbar 与 Bucket 列表', () => { + const wrapper = mount(BucketTab, { + props: { + config: buildConfig(), + buckets: [{ id: 'ds_1', groups: [buildGroup()] }], + expanded: {}, + }, + }); + expect(wrapper.find('.m-editor-history-list-toolbar').exists()).toBe(true); + expect(wrapper.find('.m-editor-history-list-bucket-title').exists()).toBe(true); + }); + + test('点击清空按钮触发 clear 事件', async () => { + const wrapper = mount(BucketTab, { + props: { + config: buildConfig(), + buckets: [{ id: 'ds_1', groups: [buildGroup()] }], + expanded: {}, + }, + }); + await wrapper.find('.m-editor-history-list-clear').trigger('click'); + expect(wrapper.emitted('clear')).toBeTruthy(); + }); + + test('透传 Bucket 子组件事件', async () => { + const wrapper = mount(BucketTab, { + props: { + config: buildConfig(), + buckets: [{ id: 'ds_1', groups: [buildGroup()] }], + expanded: {}, + }, + }); + await wrapper.find('.m-editor-history-list-item-goto').trigger('click'); + expect(wrapper.emitted('goto')?.[0]).toEqual(['ds_1', 0]); + }); +}); diff --git a/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts index 15a292a5..f5d65b9e 100644 --- a/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/CodeBlockTab.spec.ts @@ -27,8 +27,8 @@ const toDiffStep = (s: any, opType: 'add' | 'remove' | 'update') => ({ opType, diff: [ { - ...(s.newContent != null ? { newSchema: s.newContent } : {}), - ...(s.oldContent != null ? { oldSchema: s.oldContent } : {}), + ...(s.newContent !== null && s.newContent !== undefined ? { newSchema: s.newContent } : {}), + ...(s.oldContent !== null && s.oldContent !== undefined ? { oldSchema: s.oldContent } : {}), ...(s.changeRecords ? { changeRecords: s.changeRecords } : {}), }, ], diff --git a/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts index 026cf500..a0a43a95 100644 --- a/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/DataSourceTab.spec.ts @@ -27,8 +27,8 @@ const toDiffStep = (s: any, opType: 'add' | 'remove' | 'update') => ({ opType, diff: [ { - ...(s.newSchema != null ? { newSchema: s.newSchema } : {}), - ...(s.oldSchema != null ? { oldSchema: s.oldSchema } : {}), + ...(s.newSchema !== null && s.newSchema !== undefined ? { newSchema: s.newSchema } : {}), + ...(s.oldSchema !== null && s.oldSchema !== undefined ? { oldSchema: s.oldSchema } : {}), ...(s.changeRecords ? { changeRecords: s.changeRecords } : {}), }, ], diff --git a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts index f221bff6..aaf08a6d 100644 --- a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts @@ -9,17 +9,31 @@ import { mount } from '@vue/test-utils'; import historyService from '@editor/services/history'; +const { diffDialogOpen, confirmDialogConfirm } = vi.hoisted(() => ({ + diffDialogOpen: vi.fn(), + confirmDialogConfirm: vi.fn(async () => true), +})); + const stageSelect = vi.fn(); const overlayStageSelect = vi.fn(); const editorService = { gotoPageStep: vi.fn(async () => 0), + revertPageStep: vi.fn(async () => null), getNodeById: vi.fn((id: string | number) => ({ id })), select: vi.fn(async () => {}), get: vi.fn(() => ({ select: stageSelect })), }; const stageOverlayService = { get: vi.fn(() => ({ select: overlayStageSelect })) }; -const dataSourceService = { goto: vi.fn(() => 0) }; -const codeBlockService = { goto: vi.fn(async () => 0) }; +const dataSourceService = { + goto: vi.fn(() => 0), + revert: vi.fn(async () => null), + getDataSourceById: vi.fn((id: string) => ({ id, title: 'DS' })), +}; +const codeBlockService = { + goto: vi.fn(async () => 0), + revert: vi.fn(async () => null), + getCodeContentById: vi.fn((id: string | number) => ({ id, name: 'CB' })), +}; const propsService = { getDisabledDataSource: vi.fn(() => false), getDisabledCodeBlock: vi.fn(() => false), @@ -91,7 +105,7 @@ vi.mock('@editor/layouts/history-list/HistoryDiffDialog.vue', () => ({ default: defineComponent({ name: 'FakeHistoryDiffDialog', setup(_p, { expose }) { - expose({ open: vi.fn(), close: vi.fn() }); + expose({ open: diffDialogOpen, close: vi.fn(), confirm: confirmDialogConfirm }); return () => h('div', { class: 'fake-history-diff-dialog' }); }, }), @@ -100,6 +114,8 @@ vi.mock('@editor/layouts/history-list/HistoryDiffDialog.vue', () => ({ afterEach(() => { historyService.reset(); vi.clearAllMocks(); + propsService.getDisabledDataSource.mockReturnValue(false); + propsService.getDisabledCodeBlock.mockReturnValue(false); }); const factory = async () => { @@ -348,6 +364,112 @@ describe('HistoryListPanel.vue', () => { expect(custom.text()).toBe('hello-custom'); }); + test('disabledDataSource / disabledCodeBlock 为 true 时不渲染对应 tab', async () => { + propsService.getDisabledDataSource.mockReturnValue(true); + propsService.getDisabledCodeBlock.mockReturnValue(true); + + const wrapper = await factory(); + await nextTick(); + + const empties = wrapper.findAll('.m-editor-history-list-empty'); + expect(empties).toHaveLength(1); + }); + + test('点击页面 update 记录的「查看差异」打开 diff 弹窗', async () => { + historyService.changePage({ id: 'p1' } as any); + historyService.push({ + opType: 'update', + modifiedNodeIds: new Map(), + diff: [ + { + newSchema: { id: 'btn', name: '新按钮', type: 'button' }, + oldSchema: { id: 'btn', name: '旧按钮', type: 'button' }, + changeRecords: [{ propPath: 'name' }], + }, + ], + } as any); + + const wrapper = await factory(); + await nextTick(); + + await wrapper.find('.m-editor-history-list-item-diff').trigger('click'); + expect(diffDialogOpen).toHaveBeenCalledWith( + expect.objectContaining({ + category: 'node', + targetLabel: '新按钮', + value: expect.objectContaining({ name: '新按钮' }), + }), + ); + }); + + test('点击页面 update 记录的「回滚」在确认后调用 revertPageStep', async () => { + historyService.changePage({ id: 'p1' } as any); + historyService.push({ + opType: 'update', + modifiedNodeIds: new Map(), + diff: [ + { + newSchema: { id: 'btn', name: '新按钮', type: 'button' }, + oldSchema: { id: 'btn', name: '旧按钮', type: 'button' }, + changeRecords: [{ propPath: 'name' }], + }, + ], + } as any); + + const wrapper = await factory(); + await nextTick(); + + await wrapper.find('.m-editor-history-list-item-revert').trigger('click'); + await nextTick(); + expect(confirmDialogConfirm).toHaveBeenCalled(); + expect(editorService.revertPageStep).toHaveBeenCalledWith(0); + }); + + test('回滚目标节点已删除时提示错误且不执行 revert', async () => { + historyService.changePage({ id: 'p1' } as any); + historyService.push({ + opType: 'update', + modifiedNodeIds: new Map(), + diff: [ + { + newSchema: { id: 'gone', name: '按钮', type: 'button' }, + oldSchema: { id: 'gone', name: '按钮', type: 'button' }, + changeRecords: [{ propPath: 'name' }], + }, + ], + } as any); + editorService.getNodeById.mockReturnValueOnce(null); + + const { tMagicMessage } = await import('@tmagic/design'); + const wrapper = await factory(); + await nextTick(); + + await wrapper.find('.m-editor-history-list-item-revert').trigger('click'); + await nextTick(); + expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除'); + expect(editorService.revertPageStep).not.toHaveBeenCalled(); + }); + + test('确认清空页面历史后调用 historyService.clearPage', async () => { + historyService.changePage({ id: 'p1' } as any); + historyService.push({ + opType: 'add', + diff: [{ newSchema: { id: 'n1', name: 'A' } }], + modifiedNodeIds: new Map(), + } as any); + const saveSpy = vi.spyOn(historyService, 'saveToIndexedDB').mockResolvedValue(undefined); + + const wrapper = await factory(); + await nextTick(); + + await wrapper.find('.m-editor-history-list-clear').trigger('click'); + await nextTick(); + + expect(historyService.getPageHistoryGroups()).toHaveLength(0); + expect(saveSpy).toHaveBeenCalled(); + saveSpy.mockRestore(); + }); + test('点击数据源/代码块初始项调用对应 service.goto(id, 0)', async () => { historyService.pushDataSource('ds_x', { oldSchema: null, @@ -374,4 +496,57 @@ describe('HistoryListPanel.vue', () => { await initials[1].find('.m-editor-history-list-item-goto').trigger('click'); expect(codeBlockService.goto).toHaveBeenCalledWith('code_x', 0); }); + + test('点击数据源 update 记录的「查看差异」与「回滚」', async () => { + historyService.pushDataSource('ds_1', { + oldSchema: null, + newSchema: { id: 'ds_1', title: 'DS' } as any, + }); + historyService.pushDataSource('ds_1', { + oldSchema: { id: 'ds_1', title: '旧 DS' } as any, + newSchema: { id: 'ds_1', title: '新 DS' } as any, + changeRecords: [{ propPath: 'title' }], + }); + + const wrapper = await factory(); + await nextTick(); + + const diffBtn = wrapper.find('.m-editor-history-list-item-diff'); + await diffBtn.trigger('click'); + expect(diffDialogOpen).toHaveBeenCalledWith( + expect.objectContaining({ category: 'data-source', targetLabel: '新 DS' }), + ); + + await wrapper.find('.m-editor-history-list-item-revert').trigger('click'); + await nextTick(); + expect(confirmDialogConfirm).toHaveBeenCalled(); + expect(dataSourceService.revert).toHaveBeenCalledWith('ds_1', 1); + }); + + test('确认清空数据源/代码块历史后调用 clearDataSource / clearCodeBlock', async () => { + historyService.pushDataSource('ds_1', { + oldSchema: null, + newSchema: { id: 'ds_1', title: 'DS' } as any, + }); + historyService.pushCodeBlock('code_1', { + oldContent: null, + newContent: { id: 'code_1', name: 'CB' } as any, + }); + const saveSpy = vi.spyOn(historyService, 'saveToIndexedDB').mockResolvedValue(undefined); + + const wrapper = await factory(); + await nextTick(); + + const clears = wrapper.findAll('.m-editor-history-list-clear'); + expect(clears.length).toBeGreaterThanOrEqual(2); + await clears[0].trigger('click'); + await nextTick(); + await clears[1].trigger('click'); + await nextTick(); + + expect(historyService.getDataSourceHistoryGroups()).toHaveLength(0); + expect(historyService.getCodeBlockHistoryGroups()).toHaveLength(0); + expect(saveSpy).toHaveBeenCalled(); + saveSpy.mockRestore(); + }); }); diff --git a/packages/editor/tests/unit/layouts/sidebar/Sidebar.spec.ts b/packages/editor/tests/unit/layouts/sidebar/Sidebar.spec.ts index f22d3c34..a7b6c997 100644 --- a/packages/editor/tests/unit/layouts/sidebar/Sidebar.spec.ts +++ b/packages/editor/tests/unit/layouts/sidebar/Sidebar.spec.ts @@ -9,7 +9,13 @@ import { mount } from '@vue/test-utils'; import Sidebar from '@editor/layouts/sidebar/Sidebar.vue'; -const depService = { get: vi.fn(() => false) }; +const depService = { + get: vi.fn((name: string) => { + if (name === 'collecting') return false; + if (name === 'taskLength') return 0; + return false; + }), +}; const uiState: Record = reactive({}); const uiService = { get: vi.fn((name: string) => (name === 'sideBarActiveTabName' ? uiState.sideBarActiveTabName : { left: 200 })), @@ -177,4 +183,44 @@ describe('Sidebar', () => { await items[0].trigger('dragstart'); expect(dragstartHandler).toHaveBeenCalled(); }); + + test('dragendHandler 触发', async () => { + const wrapper = mount(Sidebar, { props: baseProps() as any }); + const items = wrapper.findAll('.m-editor-sidebar-header-item'); + await items[0].trigger('dragend'); + expect(dragendHandler).toHaveBeenCalled(); + }); + + test('自定义 sidebar item 可渲染 slots 组件', async () => { + const customPanel = stub('CustomPanel'); + const wrapper = mount(Sidebar, { + props: baseProps({ + data: { + type: 'tabs', + status: '自定义', + items: [ + { + $key: 'custom', + text: '自定义', + component: customPanel, + slots: { componentList: stub('SlotComponentList') }, + }, + ], + }, + }) as any, + }); + await wrapper.find('.m-editor-sidebar-header-item').trigger('click'); + expect(wrapper.find('.CustomPanel').exists()).toBe(true); + }); + + test('dep collecting 时展示 tips 区域', () => { + depService.get.mockImplementation((name: string) => { + if (name === 'collecting') return true; + if (name === 'taskLength') return 3; + return false; + }); + const wrapper = mount(Sidebar, { props: baseProps() as any }); + expect(wrapper.find('.m-editor-sidebar-tips').exists()).toBe(true); + expect(wrapper.find('.m-editor-sidebar-tips').text()).toContain('剩余任务:3'); + }); }); diff --git a/packages/editor/tests/unit/plugin.spec.ts b/packages/editor/tests/unit/plugin.spec.ts index 375ee5fd..3c9150b8 100644 --- a/packages/editor/tests/unit/plugin.spec.ts +++ b/packages/editor/tests/unit/plugin.spec.ts @@ -83,5 +83,24 @@ describe('plugin install', () => { editorPlugin.install(app); expect(app.config.globalProperties.$TMAGIC_EDITOR).toBeDefined(); expect(typeof app.config.globalProperties.$TMAGIC_EDITOR.parseDSL).toBe('function'); + expect(typeof app.config.globalProperties.$TMAGIC_EDITOR.customCreateMonacoEditor).toBe('function'); + expect(typeof app.config.globalProperties.$TMAGIC_EDITOR.customCreateMonacoDiffEditor).toBe('function'); + }); + + test('customCreateMonacoEditor / customCreateMonacoDiffEditor 会调用 monaco API', () => { + const { app } = buildApp(); + editorPlugin.install(app); + const { customCreateMonacoEditor, customCreateMonacoDiffEditor } = app.config.globalProperties.$TMAGIC_EDITOR; + const monaco = { + editor: { + create: vi.fn(() => 'editor'), + createDiffEditor: vi.fn(() => 'diff-editor'), + }, + }; + const el = document.createElement('div'); + expect(customCreateMonacoEditor(monaco, el, { theme: 'vs' })).toBe('editor'); + expect(customCreateMonacoDiffEditor(monaco, el, { readOnly: true })).toBe('diff-editor'); + expect(monaco.editor.create).toHaveBeenCalledWith(el, { theme: 'vs' }); + expect(monaco.editor.createDiffEditor).toHaveBeenCalledWith(el, { readOnly: true }); }); }); diff --git a/packages/editor/tests/unit/services/dep.spec.ts b/packages/editor/tests/unit/services/dep.spec.ts index a782a8f4..c12b3baa 100644 --- a/packages/editor/tests/unit/services/dep.spec.ts +++ b/packages/editor/tests/unit/services/dep.spec.ts @@ -11,11 +11,17 @@ import depService from '@editor/services/dep'; vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({ default: class FakeWorker { + public static nextData: Record = {}; + public static nextError = false; public onmessage: ((e: any) => void) | null = null; public onerror: (() => void) | null = null; public postMessage() { setTimeout(() => { - this.onmessage?.({ data: {} }); + if (FakeWorker.nextError) { + this.onerror?.(new Event('error')); + return; + } + this.onmessage?.({ data: FakeWorker.nextData }); }, 0); } }, @@ -148,4 +154,74 @@ describe('Dep service', () => { expect(depService.get('collecting')).toBe(false); expect(depService.hasTarget('rs')).toBe(false); }); + + test('collect 在有 collectable target 时会收集依赖并触发 collected / ds-collected', () => { + const collected = vi.fn(); + const dsCollected = vi.fn(); + depService.on('collected', collected); + depService.on('ds-collected', dsCollected); + depService.addTarget(makeTarget('t-collect')); + depService.collect([{ id: 'n1', type: 'text' }] as any); + expect(collected).toHaveBeenCalledWith([{ id: 'n1', type: 'text' }], false); + expect(dsCollected).toHaveBeenCalled(); + depService.off('collected', collected); + depService.off('ds-collected', dsCollected); + }); + + test('collect 对 page 节点会清理 page 级旧依赖', () => { + depService.addTarget(makeTarget('page-target')); + expect(() => depService.collect([{ id: 'p1', type: 'page', items: [] }] as any, { pageId: 'p1' })).not.toThrow(); + }); + + test('collectNode 支持 page 与普通节点两条路径', () => { + const target = makeTarget('node-target'); + depService.addTarget(target); + depService.collectNode({ id: 'n1', type: 'text' } as any, target); + depService.collectNode({ id: 'p1', type: 'page', items: [] } as any, target, { pageId: 'p1' }); + expect(depService.get('collecting')).toBe(false); + }); + + test('collectByWorker worker 报错时返回空对象并完成 collected', async () => { + const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any; + fakeWorker.nextError = true; + fakeWorker.nextData = {}; + const collected = vi.fn(); + depService.on('collected', collected); + const result = await depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any); + expect(result).toEqual({}); + expect(collected).toHaveBeenCalled(); + fakeWorker.nextError = false; + depService.off('collected', collected); + }); + + test('collectByWorker 会把 worker 返回的 deps 写回 target 与 dsl', async () => { + const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any; + depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE)); + depService.addTarget(makeTarget('cond1', DepTargetType.DATA_SOURCE_COND)); + depService.addTarget(makeTarget('method1', DepTargetType.DATA_SOURCE_METHOD)); + fakeWorker.nextData = { + [DepTargetType.DATA_SOURCE]: { ds1: { fieldA: { data: {} } } }, + [DepTargetType.DATA_SOURCE_COND]: { cond1: { condA: { data: {} } } }, + [DepTargetType.DATA_SOURCE_METHOD]: { method1: { methodA: { data: {} } } }, + }; + const dsl: any = { + items: [{ id: 'n1', type: 'text' }], + id: 'app', + type: 'app', + dataSourceDeps: {}, + dataSourceCondDeps: {}, + dataSourceMethodDeps: {}, + }; + await depService.collectByWorker(dsl); + expect(dsl.dataSourceDeps.ds1).toBeDefined(); + expect(dsl.dataSourceCondDeps.cond1).toBeDefined(); + expect(dsl.dataSourceMethodDeps.method1).toBeDefined(); + fakeWorker.nextData = {}; + }); + + test('destroy 会 reset 并移除监听', () => { + depService.addTarget(makeTarget('destroy-me')); + expect(() => depService.destroy()).not.toThrow(); + expect(depService.hasTarget('destroy-me')).toBe(false); + }); }); diff --git a/packages/editor/tests/unit/services/keybinding.spec.ts b/packages/editor/tests/unit/services/keybinding.spec.ts index 77ea373b..8ac6182b 100644 --- a/packages/editor/tests/unit/services/keybinding.spec.ts +++ b/packages/editor/tests/unit/services/keybinding.spec.ts @@ -6,9 +6,32 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import keybinding from '@editor/services/keybinding'; +import { KeyBindingCommand } from '@editor/type'; + +const editorService = vi.hoisted(() => ({ + get: vi.fn(() => [{ id: 'n1', type: 'text' }]), + remove: vi.fn(), + copy: vi.fn(), + paste: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + move: vi.fn(), + selectNextNode: vi.fn(), +})); + +const uiService = vi.hoisted(() => ({ + zoom: vi.fn(), + set: vi.fn(), + calcZoom: vi.fn(async () => 1.2), +})); + +vi.mock('@editor/services/editor', () => ({ default: editorService })); +vi.mock('@editor/services/ui', () => ({ default: uiService })); afterEach(() => { keybinding.reset(); + vi.clearAllMocks(); + editorService.get.mockReturnValue([{ id: 'n1', type: 'text' }]); }); describe('keybinding service', () => { @@ -36,6 +59,10 @@ describe('keybinding service', () => { expect(() => keybinding.registerEl('global')).not.toThrow(); }); + test('registeEl 兼容别名', () => { + expect(() => keybinding.registeEl('global')).not.toThrow(); + }); + test('register 同一条目去重', () => { keybinding.register([ { @@ -78,6 +105,12 @@ describe('keybinding service', () => { expect((keybinding as any).bindingList.length).toBe(0); }); + test('destroy 调用 reset', () => { + keybinding.registerEl('global'); + keybinding.destroy(); + expect((keybinding as any).controllers.size).toBe(0); + }); + test('getKeyconKeys ctrl 在 mac 下被替换为 meta', () => { const original = (keybinding as any).ctrlKey; (keybinding as any).ctrlKey = 'meta'; @@ -90,4 +123,53 @@ describe('keybinding service', () => { const result = (keybinding as any).getKeyconKeys(['a', 'b+c']); expect(result).toHaveLength(2); }); + + test('内置 DELETE/CUT 命令在 page 节点时不执行 remove', () => { + editorService.get.mockReturnValue([{ id: 'p1', type: 'page' }]); + (keybinding as any).commands[KeyBindingCommand.DELETE_NODE](); + (keybinding as any).commands[KeyBindingCommand.CUT_NODE](); + expect(editorService.remove).not.toHaveBeenCalled(); + }); + + test('内置 COPY/CUT/PASTE/UNDO/REDO 命令', () => { + const nodes = [{ id: 'n1', type: 'text' }]; + editorService.get.mockReturnValue(nodes); + (keybinding as any).commands[KeyBindingCommand.COPY_NODE](); + (keybinding as any).commands[KeyBindingCommand.CUT_NODE](); + (keybinding as any).commands[KeyBindingCommand.PASTE_NODE](); + (keybinding as any).commands[KeyBindingCommand.UNDO](); + (keybinding as any).commands[KeyBindingCommand.REDO](); + expect(editorService.copy).toHaveBeenCalledWith(nodes); + expect(editorService.remove).toHaveBeenCalledWith(nodes, { historySource: 'shortcut' }); + expect(editorService.paste).toHaveBeenCalled(); + expect(editorService.undo).toHaveBeenCalled(); + expect(editorService.redo).toHaveBeenCalled(); + }); + + test('内置缩放与移动命令', async () => { + (keybinding as any).commands[KeyBindingCommand.ZOOM_IN](); + (keybinding as any).commands[KeyBindingCommand.ZOOM_OUT](); + (keybinding as any).commands[KeyBindingCommand.ZOOM_RESET](); + await (keybinding as any).commands[KeyBindingCommand.ZOOM_FIT](); + (keybinding as any).commands[KeyBindingCommand.MOVE_UP_1](); + (keybinding as any).commands[KeyBindingCommand.MOVE_DOWN_10](); + (keybinding as any).commands[KeyBindingCommand.MOVE_LEFT_1](); + (keybinding as any).commands[KeyBindingCommand.MOVE_RIGHT_10](); + (keybinding as any).commands[KeyBindingCommand.SWITCH_NODE](); + expect(uiService.zoom).toHaveBeenCalledWith(0.1); + expect(uiService.zoom).toHaveBeenCalledWith(-0.1); + expect(uiService.set).toHaveBeenCalledWith('zoom', 1); + expect(uiService.set).toHaveBeenCalledWith('zoom', 1.2); + expect(editorService.move).toHaveBeenCalledWith(0, -1); + expect(editorService.move).toHaveBeenCalledWith(0, 10); + expect(editorService.selectNextNode).toHaveBeenCalled(); + }); + + test('nodes 为空时 COPY/PASTE 不执行', () => { + editorService.get.mockReturnValue(null); + (keybinding as any).commands[KeyBindingCommand.COPY_NODE](); + (keybinding as any).commands[KeyBindingCommand.PASTE_NODE](); + expect(editorService.copy).not.toHaveBeenCalled(); + expect(editorService.paste).not.toHaveBeenCalled(); + }); }); diff --git a/packages/editor/tests/unit/utils/indexed-db.spec.ts b/packages/editor/tests/unit/utils/indexed-db.spec.ts new file mode 100644 index 00000000..8f6f1836 --- /dev/null +++ b/packages/editor/tests/unit/utils/indexed-db.spec.ts @@ -0,0 +1,150 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { idbDelete, idbGet, idbSet, isIndexedDBSupported, openIndexedDB } from '@editor/utils/indexed-db'; + +type StoreRecord = Map; + +interface FakeDb { + version: number; + stores: Map; +} + +const fakeDbs = new Map(); + +const createRequest = (result: T, error: DOMException | null = null) => { + const request = { + result, + error, + onsuccess: null as null | (() => void), + onerror: null as null | (() => void), + onupgradeneeded: null as null | (() => void), + }; + queueMicrotask(() => { + if (error) { + request.onerror?.(); + } else { + request.onsuccess?.(); + } + }); + return request; +}; + +const installFakeIndexedDB = () => { + fakeDbs.clear(); + const indexedDB = { + open: vi.fn((name: string, version?: number) => { + const existing = fakeDbs.get(name); + const nextVersion = version ?? existing?.version ?? 1; + const db: FakeDb = existing ?? { version: nextVersion, stores: new Map() }; + if (version !== undefined && version > (existing?.version ?? 0)) { + db.version = version; + } + fakeDbs.set(name, db); + + const idbDatabase = { + name, + version: db.version, + objectStoreNames: { + contains: (storeName: string) => db.stores.has(storeName), + }, + createObjectStore: (storeName: string) => { + if (!db.stores.has(storeName)) { + db.stores.set(storeName, new Map()); + } + return {}; + }, + close: vi.fn(), + transaction: (storeName: string, mode: IDBTransactionMode) => { + const store = db.stores.get(storeName) ?? new Map(); + if (!db.stores.has(storeName)) { + db.stores.set(storeName, store); + } + const tx = { + error: null as DOMException | null, + objectStore: () => ({ + put: (value: unknown, key: IDBValidKey) => { + if (mode === 'readonly') { + return createRequest(undefined, new DOMException('readonly')); + } + store.set(key, value); + return createRequest(undefined); + }, + get: (key: IDBValidKey) => createRequest(store.get(key)), + delete: (key: IDBValidKey) => { + if (mode === 'readonly') { + return createRequest(undefined, new DOMException('readonly')); + } + store.delete(key); + return createRequest(undefined); + }, + }), + oncomplete: null as null | (() => void), + onabort: null as null | (() => void), + onerror: null as null | (() => void), + }; + queueMicrotask(() => tx.oncomplete?.()); + return tx; + }, + }; + + const request = createRequest(idbDatabase); + request.onupgradeneeded = () => { + if (!db.stores.has('__placeholder__')) { + // no-op: upgrade hook exists for API compatibility + } + }; + queueMicrotask(() => request.onupgradeneeded?.()); + return request; + }), + }; + + vi.stubGlobal('indexedDB', indexedDB); + return indexedDB; +}; + +describe('indexed-db utils', () => { + beforeEach(() => { + installFakeIndexedDB(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test('isIndexedDBSupported 在存在 indexedDB 时返回 true', () => { + expect(isIndexedDBSupported()).toBe(true); + }); + + test('isIndexedDBSupported 在无 indexedDB 环境返回 false', () => { + vi.stubGlobal('indexedDB', undefined); + expect(isIndexedDBSupported()).toBe(false); + }); + + test('openIndexedDB 在不支持 IndexedDB 时 reject', async () => { + vi.stubGlobal('indexedDB', undefined); + await expect(openIndexedDB('db', 'store')).rejects.toThrow('当前环境不支持 IndexedDB'); + }); + + test('openIndexedDB 在 store 不存在时会升级创建', async () => { + const db = await openIndexedDB('tmagic-test', 'history'); + expect(db.objectStoreNames.contains('history')).toBe(true); + db.close(); + }); + + test('idbSet / idbGet / idbDelete 可读写删除记录', async () => { + await idbSet('tmagic-kv', 'items', 'k1', { hello: 'world' }); + await expect(idbGet('tmagic-kv', 'items', 'k1')).resolves.toEqual({ hello: 'world' }); + + await idbDelete('tmagic-kv', 'items', 'k1'); + await expect(idbGet('tmagic-kv', 'items', 'k1')).resolves.toBeUndefined(); + }); + + test('idbGet 读取不存在的 key 返回 undefined', async () => { + await expect(idbGet('tmagic-kv', 'items', 'missing')).resolves.toBeUndefined(); + }); +}); diff --git a/packages/stage/tests/unit/ActionManager.spec.ts b/packages/stage/tests/unit/ActionManager.spec.ts index aec2cd0f..2a680f89 100644 --- a/packages/stage/tests/unit/ActionManager.spec.ts +++ b/packages/stage/tests/unit/ActionManager.spec.ts @@ -1,31 +1,39 @@ /* * 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. + * Copyright (C) 2025 Tencent. */ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { MoveableOptions } from 'moveable'; import ActionManager from '../../src/ActionManager'; +import { AbleActionEventType, ContainerHighlightType } from '../../src/const'; import type { ActionManagerConfig } from '../../src/types'; -// 在 jsdom 环境下 `moveable-helper`/`moveable` 的 ESM 默认导出无法直接被 -// require/import 调用,这里仅测试 ActionManager 自身的多选状态管理,将其桩掉。 -// vi.mock 调用会被 vitest 自动提升到模块顶部,因此放在 import 之后无影响。 +const mouseAtOrigin = (): MouseEvent => new MouseEvent('click', { clientX: 0, clientY: 0 }); + vi.mock('moveable-helper', () => ({ default: { - create: () => ({ clear: vi.fn() }), + create: () => ({ + clear: vi.fn(), + onResizeStart: vi.fn(), + onResize: vi.fn(), + onDragStart: vi.fn(), + onDrag: vi.fn(), + onRotateStart: vi.fn(), + onRotate: vi.fn(), + onScaleStart: vi.fn(), + onScale: vi.fn(), + onResizeGroupStart: vi.fn(), + onResizeGroup: vi.fn(), + onDragGroupStart: vi.fn(), + onDragGroup: vi.fn(), + getFrame: vi.fn(() => ({ + get: vi.fn(() => 'transform(1)'), + toCSSObject: () => ({ transform: 'rotate(10deg)' }), + properties: { transform: { translate: { value: ['0', '0'] } } }, + })), + }), }, })); vi.mock('moveable', () => ({ @@ -40,12 +48,18 @@ vi.mock('moveable', () => ({ request() {} updateRect() {} updateTarget() {} + dragStart() {} }, })); +vi.mock('../../src/MoveableActionsAble', () => ({ + default: () => ({ name: 'actions' }), +})); const createConfig = (overrides: Partial = {}): ActionManagerConfig => { const container = globalThis.document.createElement('div'); globalThis.document.body.appendChild(container); + Object.defineProperty(container, 'clientWidth', { value: 800, configurable: true }); + Object.defineProperty(container, 'clientHeight', { value: 600, configurable: true }); return { container, getTargetElement: () => null, @@ -129,4 +143,286 @@ describe('ActionManager - alwaysMultiSelect', () => { expect((am as any).disabledMultiSelect).toBe(false); expect((am as any).isMultiSelectStatus).toBe(true); }); + + test('setGuidelines / clearGuides 代理到 dr/multiDr', () => { + am = new ActionManager(createConfig()); + expect(() => am.setGuidelines('horizontal' as any, [10])).not.toThrow(); + expect(() => am.clearGuides()).not.toThrow(); + }); + + test('select / isSelectedEl / getSelectedEl', () => { + am = new ActionManager(createConfig()); + const el = globalThis.document.createElement('div'); + el.dataset.tmagicId = 'n1'; + am.select(el); + expect(am.getSelectedEl()).toBe(el); + expect(am.isSelectedEl(el)).toBe(true); + }); + + test('highlight 选中元素不高亮', () => { + const el = globalThis.document.createElement('div'); + el.dataset.tmagicId = 'n1'; + am = new ActionManager( + createConfig({ + getTargetElement: () => el, + }), + ); + am.select(el); + am.highlight('n1'); + expect(am.getHighlightEl()).toBeUndefined(); + }); + + test('getElementFromPoint 返回第一个可选中元素', async () => { + const el = globalThis.document.createElement('div'); + el.dataset.tmagicId = 'n1'; + am = new ActionManager( + createConfig({ + getElementsFromPoint: () => [el], + canSelect: () => true, + }), + ); + const result = await am.getElementFromPoint(mouseAtOrigin()); + expect(result).toBe(el); + }); + + test('canMultiSelect 在 page 元素上返回 false 并 stop', () => { + am = new ActionManager(createConfig({ alwaysMultiSelect: true })); + const pageEl = globalThis.document.createElement('div'); + pageEl.className = 'magic-ui-page'; + let stopped = false; + expect(am.canMultiSelect(pageEl, () => (stopped = true))).toBe(false); + expect(stopped).toBe(true); + }); + + test('multiSelect 收集目标元素', () => { + const el = globalThis.document.createElement('div'); + el.dataset.tmagicId = 'n1'; + am = new ActionManager( + createConfig({ + getTargetElement: (id) => (id === 'n1' ? el : null), + }), + ); + am.multiSelect(['n1']); + expect(am.getSelectedElList()).toEqual([el]); + }); + + test('destroy 清理实例', () => { + am = new ActionManager(createConfig()); + expect(() => am?.destroy()).not.toThrow(); + am = null; + }); +}); + +describe('ActionManager - 交互与事件', () => { + let am: ActionManager | null = null; + + beforeEach(() => { + globalThis.document.body.innerHTML = ''; + vi.useFakeTimers(); + }); + + afterEach(() => { + am?.destroy(); + am = null; + vi.useRealTimers(); + }); + + test('highlight 派发 highlight 事件', () => { + const el = globalThis.document.createElement('div'); + el.dataset.tmagicId = 'h1'; + el.style.cssText = 'position:absolute;width:10px;height:10px;'; + globalThis.document.body.appendChild(el); + am = new ActionManager( + createConfig({ + getTargetElement: () => el, + }), + ); + const fn = vi.fn(); + am.on('highlight', fn); + am.highlight('h1'); + expect(fn).toHaveBeenCalledWith(el); + }); + + test('getNextElementFromPoint 跳过第一个可选中元素', async () => { + const el1 = globalThis.document.createElement('div'); + el1.dataset.tmagicId = 'n1'; + const el2 = globalThis.document.createElement('div'); + el2.dataset.tmagicId = 'n2'; + am = new ActionManager( + createConfig({ + getElementsFromPoint: () => [el1, el2], + canSelect: () => true, + }), + ); + const result = await am.getNextElementFromPoint(mouseAtOrigin()); + expect(result).toBe(el2); + }); + + test('delayedMarkContainer 延迟高亮容器', async () => { + const containerEl = globalThis.document.createElement('div'); + containerEl.dataset.tmagicId = 'container_1'; + am = new ActionManager( + createConfig({ + containerHighlightType: ContainerHighlightType.DEFAULT, + getElementsFromPoint: () => [containerEl], + isContainer: async () => true, + }), + ); + const id = am.delayedMarkContainer(mouseAtOrigin()); + expect(id).toBeDefined(); + vi.advanceTimersByTime(900); + await Promise.resolve(); + expect(containerEl.classList.contains('tmagic-stage-container-highlight')).toBe(true); + }); + + test('updateMoveableOptions / getDragStatus 可调用', () => { + am = new ActionManager(createConfig()); + expect(() => am.updateMoveableOptions()).not.toThrow(); + expect(am.getDragStatus()).toBeDefined(); + }); + + test('moveableOptions 为函数时会注入选中上下文', () => { + const el = globalThis.document.createElement('div'); + el.dataset.tmagicId = 'opt'; + const optionsFn = vi.fn(() => ({ draggable: false })); + am = new ActionManager(createConfig({ moveableOptions: optionsFn })); + am.select(el); + am.updateMoveable(el); + expect(optionsFn).toHaveBeenCalled(); + }); + + test('dblclick / mouseleave 事件透传', () => { + am = new ActionManager(createConfig()); + const dbl = vi.fn(); + const leave = vi.fn(); + am.on('dblclick', dbl); + am.on('mouseleave', leave); + am.container.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + am.container.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); + expect(dbl).toHaveBeenCalled(); + vi.advanceTimersByTime(200); + expect(leave).toHaveBeenCalled(); + }); + + test('dr 触发 remove / select-parent / rerender 事件', () => { + am = new ActionManager(createConfig()); + const removeFn = vi.fn(); + const parentFn = vi.fn(); + const rerenderFn = vi.fn(); + am.on('remove', removeFn); + am.on('select-parent', parentFn); + am.on('rerender', rerenderFn); + + const el = globalThis.document.createElement('div'); + el.dataset.tmagicId = 'rm'; + el.style.cssText = 'position:absolute;width:10px;height:10px;'; + globalThis.document.body.appendChild(el); + am.select(el); + + (am as any).dr?.emit(AbleActionEventType.REMOVE); + (am as any).dr?.emit(AbleActionEventType.SELECT_PARENT); + (am as any).dr?.emit(AbleActionEventType.RERENDER); + expect(removeFn).toHaveBeenCalled(); + expect(parentFn).toHaveBeenCalled(); + expect(rerenderFn).toHaveBeenCalled(); + }); + + test('wheel 事件清除高亮', async () => { + const el = globalThis.document.createElement('div'); + el.dataset.tmagicId = 'w1'; + el.style.cssText = 'position:absolute;width:10px;height:10px;'; + globalThis.document.body.appendChild(el); + am = new ActionManager( + createConfig({ + getTargetElement: () => el, + getElementsFromPoint: () => [el], + }), + ); + am.highlight('w1'); + am.container.dispatchEvent(new WheelEvent('wheel', { bubbles: true })); + expect(am.getHighlightEl()).toBeUndefined(); + }); + + test('addContainerHighlightClassName 支持 canDropIn 重定向', async () => { + const containerEl = globalThis.document.createElement('div'); + containerEl.dataset.tmagicId = 'outer'; + const innerEl = globalThis.document.createElement('div'); + innerEl.dataset.tmagicId = 'inner'; + am = new ActionManager( + createConfig({ + containerHighlightType: ContainerHighlightType.DEFAULT, + getElementsFromPoint: () => [containerEl], + isContainer: async () => true, + canDropIn: () => 'inner', + getTargetElement: (id) => (id === 'inner' ? innerEl : null), + }), + ); + await am.addContainerHighlightClassName(mouseAtOrigin(), []); + expect(innerEl.classList.contains('tmagic-stage-container-highlight')).toBe(true); + }); + + test('addContainerHighlightClassName canDropIn 返回 false 跳过高亮', async () => { + const containerEl = globalThis.document.createElement('div'); + containerEl.dataset.tmagicId = 'blocked'; + am = new ActionManager( + createConfig({ + getElementsFromPoint: () => [containerEl], + isContainer: async () => true, + canDropIn: () => false, + }), + ); + await am.addContainerHighlightClassName(mouseAtOrigin(), []); + expect(containerEl.classList.contains('tmagic-stage-container-highlight')).toBe(false); + }); + + test('highlight getTargetElement 异常时清除高亮', () => { + am = new ActionManager( + createConfig({ + getTargetElement: () => { + throw new Error('fail'); + }, + }), + ); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + am.highlight('bad'); + expect(am.getHighlightEl()).toBeUndefined(); + warn.mockRestore(); + }); + + test('getMoveableOption 多选时返回 multiDr 配置', () => { + const el = globalThis.document.createElement('div'); + el.dataset.tmagicId = 'm1'; + el.style.cssText = 'position:absolute;width:10px;height:10px;'; + globalThis.document.body.appendChild(el); + am = new ActionManager( + createConfig({ + moveableOptions: { draggable: true }, + getTargetElement: (id) => (id === 'm1' ? el : null), + }), + ); + am.multiSelect(['m1']); + expect(am.getMoveableOption('draggable' satisfies keyof MoveableOptions)).toBeDefined(); + }); + + test('isElCanSelect 多选状态下走 canMultiSelect', async () => { + const el1 = globalThis.document.createElement('div'); + el1.dataset.tmagicId = 'ms1'; + el1.style.cssText = 'position:absolute;width:10px;height:10px;'; + const el2 = globalThis.document.createElement('div'); + el2.dataset.tmagicId = 'ms2'; + el2.style.cssText = 'position:absolute;width:10px;height:10px;'; + globalThis.document.body.append(el1, el2); + am = new ActionManager(createConfig({ alwaysMultiSelect: true })); + am.select(el1); + const stop = vi.fn(); + expect(await am.isElCanSelect(el2, new MouseEvent('click'), stop)).toBe(true); + }); + + test('multiDr update 事件透传为 multi-update', () => { + am = new ActionManager(createConfig()); + const fn = vi.fn(); + am.on('multi-update', fn); + (am as any).multiDr?.emit('update', { data: [], parentEl: null }); + expect(fn).toHaveBeenCalled(); + }); }); diff --git a/packages/stage/tests/unit/DragResizeHelper.spec.ts b/packages/stage/tests/unit/DragResizeHelper.spec.ts new file mode 100644 index 00000000..e54e6eda --- /dev/null +++ b/packages/stage/tests/unit/DragResizeHelper.spec.ts @@ -0,0 +1,257 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { getIdFromEl, setIdToEl } from '@tmagic/core'; + +import { DRAG_EL_ID_PREFIX, Mode } from '../../src/const'; +import DragResizeHelper from '../../src/DragResizeHelper'; + +vi.mock('moveable-helper', () => ({ + default: { + create: () => ({ + clear: vi.fn(), + onResizeStart: vi.fn(), + onResize: vi.fn(), + onDragStart: vi.fn(), + onDrag: vi.fn(), + onRotateStart: vi.fn(), + onRotate: vi.fn(), + onScaleStart: vi.fn(), + onScale: vi.fn(), + onResizeGroupStart: vi.fn(), + onResizeGroup: vi.fn(), + onDragGroupStart: vi.fn(), + onDragGroup: vi.fn(), + getFrame: vi.fn(() => ({ + get: vi.fn(() => 'transform(1)'), + toCSSObject: () => ({ transform: 'rotate(10deg)' }), + properties: { transform: { translate: { value: ['0', '0'] } } }, + })), + }), + }, +})); + +Object.defineProperties(globalThis.HTMLElement.prototype, { + offsetTop: { + get() { + return parseFloat((this as HTMLElement).style.top) || 0; + }, + configurable: true, + }, + offsetLeft: { + get() { + return parseFloat((this as HTMLElement).style.left) || 0; + }, + configurable: true, + }, +}); + +afterEach(() => { + globalThis.document.body.innerHTML = ''; +}); + +describe('DragResizeHelper', () => { + const createHelper = () => { + const container = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(container); + return new DragResizeHelper({ container }); + }; + + const createTarget = (id = 'target') => { + const el = globalThis.document.createElement('div'); + el.style.cssText = 'position:absolute;left:10px;top:20px;width:100px;height:80px;'; + setIdToEl()(el, id); + globalThis.document.body.appendChild(el); + return el; + }; + + test('updateShadowEl 创建 shadow 元素', () => { + const helper = createHelper(); + const target = createTarget(); + helper.updateShadowEl(target); + expect(helper.getShadowEl()).toBeTruthy(); + helper.destroy(); + }); + + test('destroyShadowEl 仅移除单选 shadow', () => { + const helper = createHelper(); + helper.updateShadowEl(createTarget()); + helper.destroyShadowEl(); + expect(helper.getShadowEl()).toBeUndefined(); + helper.destroy(); + }); + + test('onResizeStart / onDragStart 调用 moveableHelper', () => { + const helper = createHelper(); + const target = createTarget(); + helper.updateShadowEl(target); + helper.onResizeStart({ target: helper.getShadowEl() } as any); + helper.onDragStart({ target: helper.getShadowEl() } as any); + helper.destroy(); + }); + + test('setMode 切换布局模式', () => { + const helper = createHelper(); + helper.setMode(Mode.SORTABLE); + helper.updateShadowEl(createTarget()); + helper.destroy(); + }); + + test('destroy 清理 target 与 shadow', () => { + const helper = createHelper(); + helper.updateShadowEl(createTarget()); + helper.destroy(); + expect(helper.getShadowEl()).toBeUndefined(); + }); + + test('onResize / onDrag 在 ABSOLUTE 模式更新 target 样式', () => { + const helper = createHelper(); + const target = createTarget(); + helper.updateShadowEl(target); + helper.onResizeStart({ target: helper.getShadowEl() } as any); + helper.onResize({ + width: 120, + height: 90, + drag: { beforeTranslate: [5, 6] }, + target: helper.getShadowEl(), + } as any); + helper.onDragStart({ target: helper.getShadowEl() } as any); + helper.onDrag({ beforeTranslate: [3, 4], target: helper.getShadowEl() } as any); + expect(target.style.width).toContain('px'); + helper.destroy(); + }); + + test('SORTABLE 模式 onDragStart 生成 ghost 元素', () => { + const helper = createHelper(); + helper.setMode(Mode.SORTABLE); + const target = createTarget(); + helper.updateShadowEl(target); + helper.onDragStart({ target: helper.getShadowEl() } as any); + expect(helper.getGhostEl()).toBeDefined(); + helper.onDrag({ beforeTranslate: [0, 10], target: helper.getShadowEl() } as any); + helper.destroyGhostEl(); + helper.destroy(); + }); + + test('onRotate / onScale 更新 transform', () => { + const helper = createHelper(); + const target = createTarget(); + helper.updateShadowEl(target); + helper.onRotateStart({ target: helper.getShadowEl() } as any); + helper.onRotate({ target: helper.getShadowEl() } as any); + helper.onScaleStart({ target: helper.getShadowEl() } as any); + helper.onScale({ target: helper.getShadowEl() } as any); + expect(target.style.transform).toBeTruthy(); + helper.destroy(); + }); + + test('updateGroup / onDragGroup / onResizeGroup 多选逻辑', () => { + const helper = createHelper(); + const a = createTarget('a'); + const b = createTarget('b'); + helper.updateGroup([a, b]); + expect(helper.getShadowEls()).toHaveLength(2); + + const shadowA = helper.getShadowEls()[0] as HTMLElement; + setIdToEl()(shadowA, `${DRAG_EL_ID_PREFIX}a`); + + helper.onDragGroupStart({ events: [{ target: shadowA }] } as any); + helper.onDragGroup({ + events: [{ target: shadowA, beforeTranslate: [2, 3] }], + } as any); + helper.onResizeGroupStart({ events: [{ target: shadowA, drag: { beforeTranslate: [0, 0] } }] } as any); + helper.onResizeGroup({ + events: [{ target: shadowA, drag: { beforeTranslate: [1, 2], width: 100, height: 80 } }], + } as any); + + helper.clearMultiSelectStatus(); + expect(helper.getShadowEls()).toHaveLength(0); + helper.destroy(); + }); + + test('getUpdatedElRect 返回元素矩形信息', () => { + const helper = createHelper(); + const target = createTarget('rect'); + Object.defineProperty(target, 'clientWidth', { value: 100, configurable: true }); + Object.defineProperty(target, 'clientHeight', { value: 80, configurable: true }); + helper.updateShadowEl(target); + const rect = helper.getUpdatedElRect(target, null, globalThis.document); + expect(rect).toMatchObject({ width: expect.any(Number), height: expect.any(Number) }); + helper.destroy(); + }); + + test('clear 调用 moveableHelper.clear', () => { + const helper = createHelper(); + helper.clear(); + helper.destroy(); + }); + + test('SORTABLE 模式 onResize 更新 shadow 尺寸', () => { + const helper = createHelper(); + helper.setMode(Mode.SORTABLE); + const target = createTarget('sort-resize'); + helper.updateShadowEl(target); + helper.onResizeStart({ target: helper.getShadowEl() } as any); + helper.onResize({ + width: 150, + height: 100, + drag: { beforeTranslate: [0, 0] }, + target: helper.getShadowEl(), + } as any); + expect(helper.getShadowEl()?.style.width).toBe('150px'); + helper.destroy(); + }); + + test('setTargetList 更新多选目标列表', () => { + const helper = createHelper(); + const a = createTarget('ta'); + const b = createTarget('tb'); + helper.setTargetList([a, b]); + helper.destroy(); + }); + + test('onResizeGroup 父元素也在多选列表时跳过位置更新', () => { + const helper = createHelper(); + const parent = createTarget('parent'); + const child = createTarget('child'); + parent.appendChild(child); + helper.updateGroup([parent, child]); + + const shadowParent = helper.getShadowEls().find((s) => getIdFromEl()(s)?.endsWith('parent')) as HTMLElement; + setIdToEl()(shadowParent, `${DRAG_EL_ID_PREFIX}parent`); + + helper.onResizeGroupStart({ + events: [{ target: shadowParent, drag: { beforeTranslate: [0, 0] } }], + } as any); + helper.onResizeGroup({ + events: [ + { + target: shadowParent, + drag: { beforeTranslate: [10, 10], width: 200, height: 100 }, + }, + ], + } as any); + helper.destroy(); + }); + + test('getUpdatedElRect 带 parentEl 时使用 shadow 偏移', () => { + const helper = createHelper(); + const parent = createTarget('parent-el'); + const target = createTarget('child-el'); + parent.appendChild(target); + Object.defineProperty(target, 'clientWidth', { value: 100, configurable: true }); + Object.defineProperty(target, 'clientHeight', { value: 80, configurable: true }); + helper.updateShadowEl(target); + const shadow = helper.getShadowEl() as HTMLElement; + Object.defineProperty(shadow, 'offsetLeft', { value: 20, configurable: true }); + Object.defineProperty(shadow, 'offsetTop', { value: 30, configurable: true }); + const rect = helper.getUpdatedElRect(target, parent, globalThis.document); + expect(rect.left).toBeDefined(); + expect(rect.top).toBeDefined(); + helper.destroy(); + }); +}); diff --git a/packages/stage/tests/unit/MoveableActionsAble.spec.ts b/packages/stage/tests/unit/MoveableActionsAble.spec.ts new file mode 100644 index 00000000..50a43f47 --- /dev/null +++ b/packages/stage/tests/unit/MoveableActionsAble.spec.ts @@ -0,0 +1,52 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test, vi } from 'vitest'; + +import { AbleActionEventType } from '../../src/const'; +import MoveableActionsAble from '../../src/MoveableActionsAble'; + +describe('MoveableActionsAble', () => { + test('render 创建操作按钮并触发 handler', () => { + const handler = vi.fn(); + const customClick = vi.fn(); + const able = MoveableActionsAble(handler, [ + (reactFactory) => ({ + props: { className: 'custom-btn', onClick: customClick }, + children: [reactFactory.createElement('span', {}, 'x')], + }), + ]); + + const moveable = { + getRect: () => ({ rotation: 0 }), + state: { pos2: [100, 200] }, + useCSS: (_tag: string, _css: string) => 'EditableViewer', + }; + const created: any[] = []; + const reactMock = { + createElement: (type: any, props: any, ...children: any[]) => { + const el = { type, props, children }; + created.push(el); + return el; + }, + }; + + const result = able.render(moveable as any, reactMock as any); + expect(result.type).toBe('EditableViewer'); + + const buttons = created.filter((el) => el.type === 'button'); + expect(buttons.length).toBeGreaterThanOrEqual(4); + + buttons.find((b) => b.props?.className?.includes('moveable-rerender-button'))?.props?.onClick?.(); + buttons.find((b) => b.props?.title === '选中父组件')?.props?.onClick?.(); + buttons.find((b) => b.props?.className?.includes('moveable-remove-button'))?.props?.onClick?.(); + buttons.find((b) => b.props?.className === 'custom-btn')?.props?.onClick?.(new MouseEvent('click')); + + expect(handler).toHaveBeenCalledWith(AbleActionEventType.RERENDER); + expect(handler).toHaveBeenCalledWith(AbleActionEventType.SELECT_PARENT); + expect(handler).toHaveBeenCalledWith(AbleActionEventType.REMOVE); + expect(customClick).toHaveBeenCalled(); + }); +}); diff --git a/packages/stage/tests/unit/MoveableOptionsManager.spec.ts b/packages/stage/tests/unit/MoveableOptionsManager.spec.ts new file mode 100644 index 00000000..6ae1c40e --- /dev/null +++ b/packages/stage/tests/unit/MoveableOptionsManager.spec.ts @@ -0,0 +1,108 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { setIdToEl } from '@tmagic/core'; + +import { AbleActionEventType, GuidesType, Mode } from '../../src/const'; +import MoveableOptionsManager from '../../src/MoveableOptionsManager'; + +class TestMoveableOptionsManager extends MoveableOptionsManager { + public exposeGetOptions(isMultiSelect: boolean, runtimeOptions = {}) { + return this.getOptions(isMultiSelect, runtimeOptions); + } + + public exposeSetElementGuidelines(selected: HTMLElement[]) { + return this.setElementGuidelines(selected); + } + + public triggerAction(type: AbleActionEventType) { + (this as any).actionHandler(type); + } +} + +vi.mock('../../src/MoveableActionsAble', () => ({ + default: () => ({ name: 'actions' }), +})); + +afterEach(() => { + globalThis.document.body.innerHTML = ''; +}); + +describe('MoveableOptionsManager', () => { + const createManager = ( + overrides: Partial[0]> = {}, + ) => { + const container = globalThis.document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { value: 800, configurable: true }); + Object.defineProperty(container, 'clientHeight', { value: 600, configurable: true }); + globalThis.document.body.appendChild(container); + return new TestMoveableOptionsManager({ + container, + getRootContainer: () => container, + ...overrides, + }); + }; + + test('setGuidelines / clearGuides 触发 update-moveable', () => { + const manager = createManager(); + const fn = vi.fn(); + manager.on('update-moveable', fn); + manager.setGuidelines(GuidesType.HORIZONTAL, [10]); + manager.setGuidelines(GuidesType.VERTICAL, [20]); + manager.clearGuides(); + expect(fn).toHaveBeenCalledTimes(3); + }); + + test('getOptions 合并默认、自定义与运行时参数', () => { + const manager = createManager({ + moveableOptions: { draggable: false, zoom: 2 }, + }); + const options = manager.exposeGetOptions(false, { resizable: false }); + expect(options.draggable).toBe(false); + expect(options.zoom).toBe(2); + expect(options.resizable).toBe(false); + expect(options.horizontalGuidelines).toEqual([]); + }); + + test('函数形式 customizedOptions 会被调用', () => { + const customized = vi.fn(() => ({ snapGap: false })); + const manager = createManager({ moveableOptions: customized }); + manager.exposeGetOptions(true); + expect(customized).toHaveBeenCalled(); + }); + + test('setElementGuidelines 在 ABSOLUTE 模式下创建辅助对齐元素', () => { + const manager = createManager(); + manager.mode = Mode.ABSOLUTE; + const selected = globalThis.document.createElement('div'); + setIdToEl()(selected, 'sel'); + selected.style.cssText = 'position:absolute;left:0;top:0;width:100px;height:100px;'; + + const sibling = globalThis.document.createElement('div'); + setIdToEl()(sibling, 'other'); + sibling.style.cssText = 'position:absolute;left:120px;top:0;width:80px;height:80px;'; + const parent = globalThis.document.createElement('div'); + parent.append(selected, sibling); + globalThis.document.body.appendChild(parent); + + Object.defineProperty(sibling, 'getBoundingClientRect', { + value: () => ({ width: 80, height: 80, left: 120, top: 0 }), + }); + + manager.exposeSetElementGuidelines([selected]); + const options = manager.exposeGetOptions(false); + expect(options.elementGuidelines?.length).toBeGreaterThan(0); + }); + + test('actionHandler 会 emit AbleActionEventType', () => { + const manager = createManager(); + const fn = vi.fn(); + manager.on(AbleActionEventType.REMOVE, fn); + manager.triggerAction(AbleActionEventType.REMOVE); + expect(fn).toHaveBeenCalled(); + }); +}); diff --git a/packages/stage/tests/unit/Rule.spec.ts b/packages/stage/tests/unit/Rule.spec.ts new file mode 100644 index 00000000..2660603c --- /dev/null +++ b/packages/stage/tests/unit/Rule.spec.ts @@ -0,0 +1,102 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { GuidesType } from '../../src/const'; +import Rule from '../../src/Rule'; + +const guideInstances: any[] = []; + +vi.mock('@scena/guides', () => ({ + default: class MockGuides { + public on = vi.fn(); + public off = vi.fn(); + public destroy = vi.fn(); + public resize = vi.fn(); + public setState = vi.fn(); + public scroll = vi.fn(); + public scrollGuides = vi.fn(); + constructor() { + guideInstances.push(this); + } + }, +})); + +afterEach(() => { + guideInstances.length = 0; + globalThis.document.body.innerHTML = ''; +}); + +describe('Rule', () => { + test('disabledRule 时不创建 guides', () => { + const container = globalThis.document.createElement('div'); + const rule = new Rule(container, { disabledRule: true }); + expect(rule.hGuides).toBeUndefined(); + expect(rule.vGuides).toBeUndefined(); + rule.destroy(); + }); + + test('setGuides / clearGuides 更新参考线并派发事件', () => { + const container = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(container); + const rule = new Rule(container); + const fn = vi.fn(); + rule.on('change-guides', fn); + + rule.setGuides([ + [10, 20], + [30, 40], + ]); + expect(rule.horizontalGuidelines).toEqual([10, 20]); + expect(rule.verticalGuidelines).toEqual([30, 40]); + expect(fn).toHaveBeenCalledTimes(2); + + rule.clearGuides(); + expect(rule.horizontalGuidelines).toEqual([]); + expect(rule.verticalGuidelines).toEqual([]); + rule.destroy(); + }); + + test('showGuides 切换显示状态', () => { + const container = globalThis.document.createElement('div'); + const rule = new Rule(container); + rule.showGuides(false); + expect(guideInstances[0]?.setState).toHaveBeenCalledWith({ showGuides: false }); + rule.destroy(); + }); + + test('showRule(false) 隐藏标尺,showRule(true) 重建 guides', () => { + const container = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(container); + const rule = new Rule(container); + const before = guideInstances.length; + rule.showRule(false); + rule.showRule(true); + expect(guideInstances.length).toBeGreaterThan(before); + rule.destroy(); + }); + + test('scrollRule 调用 guides 滚动', () => { + const container = globalThis.document.createElement('div'); + const rule = new Rule(container); + rule.scrollRule(120); + expect(guideInstances[0]?.scrollGuides).toHaveBeenCalled(); + rule.destroy(); + }); + + test('guides changeGuides 回调会同步 horizontal/vertical 并 emit', () => { + const container = globalThis.document.createElement('div'); + const rule = new Rule(container); + const fn = vi.fn(); + rule.on('change-guides', fn); + + const hHandler = guideInstances[0].on.mock.calls.find((c: any[]) => c[0] === 'changeGuides')?.[1]; + hHandler?.({ guides: [5, 6] }); + expect(rule.horizontalGuidelines).toEqual([5, 6]); + expect(fn).toHaveBeenCalledWith({ type: GuidesType.HORIZONTAL, guides: [5, 6] }); + rule.destroy(); + }); +}); diff --git a/packages/stage/tests/unit/StageCore.spec.ts b/packages/stage/tests/unit/StageCore.spec.ts new file mode 100644 index 00000000..63c2372d --- /dev/null +++ b/packages/stage/tests/unit/StageCore.spec.ts @@ -0,0 +1,256 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import type { MoveableOptions } from 'moveable'; + +import { setIdToEl } from '@tmagic/core'; + +import { RenderType } from '../../src/const'; +import StageCore from '../../src/StageCore'; + +const mouseAtOrigin = (): MouseEvent => new MouseEvent('click', { clientX: 0, clientY: 0 }); + +vi.mock('moveable-helper', () => ({ + default: { + create: () => ({ + clear: vi.fn(), + onResizeStart: vi.fn(), + onResize: vi.fn(), + onDragStart: vi.fn(), + onDrag: vi.fn(), + onRotateStart: vi.fn(), + onRotate: vi.fn(), + onScaleStart: vi.fn(), + onScale: vi.fn(), + getFrame: vi.fn(() => ({ get: vi.fn(() => 'transform(1)') })), + getUpdatedElRect: vi.fn(() => ({ left: 0, top: 0, width: 100, height: 100 })), + }), + }, +})); + +vi.mock('moveable', () => ({ + default: class MockMoveable { + on() { + return this; + } + destroy() {} + updateRect() {} + dragStart() {} + }, +})); + +vi.mock('@scena/guides', () => ({ + default: class MockGuides { + on = vi.fn(); + off = vi.fn(); + destroy = vi.fn(); + resize = vi.fn(); + setState = vi.fn(); + scroll = vi.fn(); + scrollGuides = vi.fn(); + }, +})); + +vi.mock('@zumer/snapdom', () => ({ + snapdom: vi.fn(async () => ({ + toPng: vi.fn(async () => 'png-data'), + })), +})); + +afterEach(() => { + globalThis.document.body.innerHTML = ''; + vi.clearAllMocks(); +}); + +describe('StageCore', () => { + const createStage = () => { + const host = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(host); + const page = globalThis.document.createElement('div'); + page.className = 'magic-ui-page'; + setIdToEl()(page, 'page_1'); + page.style.cssText = 'position:relative;width:375px;height:667px;'; + + const stage = new StageCore({ + renderType: RenderType.NATIVE, + disabledRule: true, + render: async () => page, + }); + return { host, page, stage }; + }; + + const mountRuntime = (stage: StageCore) => { + stage.renderer?.getMagicApi().onRuntimeReady({ + add: vi.fn(), + remove: vi.fn(), + update: vi.fn(), + select: vi.fn(async () => undefined), + } as any); + }; + + test('mount 挂载 renderer 与 mask 并派发 mounted', async () => { + const { host, stage } = createStage(); + const fn = vi.fn(); + stage.on('mounted', fn); + await stage.mount(host); + expect(fn).toHaveBeenCalled(); + stage.destroy(); + }); + + test('select 选中节点并触发 flash', async () => { + const { host, page, stage } = createStage(); + await stage.mount(host); + mountRuntime(stage); + const node = globalThis.document.createElement('div'); + setIdToEl()(node, 'btn_1'); + node.style.cssText = 'position:absolute;left:0;top:0;width:50px;height:30px;'; + page.appendChild(node); + + const selectFn = vi.fn(); + stage.on('select', selectFn); + await stage.select('btn_1'); + expect(stage.actionManager?.getSelectedEl()).toBe(node); + stage.destroy(); + }); + + test('multiSelect / highlight / clearHighlight', async () => { + const { host, page, stage } = createStage(); + await stage.mount(host); + mountRuntime(stage); + const n1 = globalThis.document.createElement('div'); + setIdToEl()(n1, 'n1'); + n1.style.cssText = 'position:absolute;width:10px;height:10px;'; + const n2 = globalThis.document.createElement('div'); + setIdToEl()(n2, 'n2'); + n2.style.cssText = 'position:absolute;width:10px;height:10px;'; + page.append(n1, n2); + + await stage.multiSelect(['n1', 'n2']); + expect(stage.actionManager?.getSelectedElList()).toHaveLength(2); + + stage.highlight('n1'); + expect(stage.actionManager?.getHighlightEl()).toBe(n1); + stage.clearHighlight(); + expect(stage.actionManager?.getHighlightEl()).toBeUndefined(); + stage.destroy(); + }); + + test('clearGuides / setZoom / disableMultiSelect 代理到子模块', async () => { + const { host, stage } = createStage(); + await stage.mount(host); + stage.setZoom(1.2); + stage.clearGuides(); + stage.disableMultiSelect(); + stage.enableMultiSelect(); + stage.setAlwaysMultiSelect(true); + expect(stage.actionManager).toBeTruthy(); + stage.destroy(); + }); + + test('destroy 清理所有子模块', async () => { + const { host, stage } = createStage(); + await stage.mount(host); + stage.destroy(); + expect(stage.renderer).toBeNull(); + expect(stage.mask).toBeNull(); + expect(stage.actionManager).toBeNull(); + }); + + test('update / add / remove 代理 renderer', async () => { + const { host, stage } = createStage(); + await stage.mount(host); + mountRuntime(stage); + const runtime = await stage.renderer!.getRuntime(); + await stage.add({ config: { id: 'a' } } as any); + await stage.remove({ id: 'a' } as any); + await stage.update({ config: { id: 'a' } } as any); + expect(runtime.add).toHaveBeenCalled(); + expect(runtime.remove).toHaveBeenCalled(); + expect(runtime.update).toHaveBeenCalled(); + stage.destroy(); + }); + + test('delayedMarkContainer 与 getMoveableOption 可调用', async () => { + const { host, stage } = createStage(); + await stage.mount(host); + expect(stage.delayedMarkContainer(mouseAtOrigin())).toBeUndefined(); + expect(stage.getMoveableOption('draggable' satisfies keyof MoveableOptions)).toBeUndefined(); + stage.destroy(); + }); + + test('autoScrollIntoView 选中时调用 mask.observerIntersection', async () => { + const host = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(host); + const page = globalThis.document.createElement('div'); + page.className = 'magic-ui-page'; + setIdToEl()(page, 'page_1'); + const node = globalThis.document.createElement('div'); + setIdToEl()(node, 'scroll-node'); + node.style.cssText = 'position:absolute;width:10px;height:10px;'; + page.appendChild(node); + + const stage = new StageCore({ + renderType: RenderType.NATIVE, + disabledRule: true, + autoScrollIntoView: true, + render: async () => page, + }); + await stage.mount(host); + mountRuntime(stage); + const spy = vi.spyOn(stage.mask!, 'observerIntersection'); + await stage.select('scroll-node'); + expect(spy).toHaveBeenCalledWith(node); + stage.destroy(); + }); + + test('page-el-update 触发 mask.observe 与 runtime-ready 事件', async () => { + const { host, stage } = createStage(); + const readyFn = vi.fn(); + const pageFn = vi.fn(); + stage.on('runtime-ready', readyFn); + stage.on('page-el-update', pageFn); + await stage.mount(host); + mountRuntime(stage); + const page = globalThis.document.createElement('div'); + stage.renderer!.getMagicApi().onPageElUpdate(page); + expect(pageFn).toHaveBeenCalledWith(page); + expect(stage.mask?.page).toBe(page); + stage.destroy(); + }); + + test('getElementImage / reloadIframe / getAddContainerHighlightClassNameTimeout 代理', async () => { + const { host, page, stage } = createStage(); + await stage.mount(host); + mountRuntime(stage); + const node = globalThis.document.createElement('div'); + setIdToEl()(node, 'img-el'); + page.appendChild(node); + stage.renderer!.getMagicApi().onRuntimeReady({} as any); + const img = await stage.getElementImage('img-el', 'png'); + expect(img).toBeDefined(); + expect(stage.getAddContainerHighlightClassNameTimeout(mouseAtOrigin())).toBeUndefined(); + stage.reloadIframe(''); + stage.destroy(); + }); + + test('update 选中元素变更后刷新 moveable', async () => { + vi.useFakeTimers(); + const { host, page, stage } = createStage(); + await stage.mount(host); + mountRuntime(stage); + const node = globalThis.document.createElement('div'); + setIdToEl()(node, 'upd'); + node.style.cssText = 'position:absolute;width:10px;height:10px;'; + page.appendChild(node); + await stage.select('upd'); + const spy = vi.spyOn(stage.actionManager!, 'updateMoveable'); + await stage.update({ config: { id: 'upd' } } as any); + vi.runAllTimers(); + expect(spy).toHaveBeenCalled(); + vi.useRealTimers(); + stage.destroy(); + }); +}); diff --git a/packages/stage/tests/unit/StageDragResize.spec.ts b/packages/stage/tests/unit/StageDragResize.spec.ts new file mode 100644 index 00000000..88fd9b52 --- /dev/null +++ b/packages/stage/tests/unit/StageDragResize.spec.ts @@ -0,0 +1,238 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { setIdToEl } from '@tmagic/core'; + +import { GuidesType, StageDragStatus } from '../../src/const'; +import DragResizeHelper from '../../src/DragResizeHelper'; +import StageDragResize from '../../src/StageDragResize'; + +const makeDomRect = (partial: Partial): DOMRect => ({ + x: partial.x ?? partial.left ?? 0, + y: partial.y ?? partial.top ?? 0, + width: partial.width ?? 0, + height: partial.height ?? 0, + top: partial.top ?? 0, + left: partial.left ?? 0, + right: partial.right ?? 0, + bottom: partial.bottom ?? 0, + toJSON: () => ({}), +}); + +vi.mock('moveable-helper', () => ({ + default: { + create: () => ({ + clear: vi.fn(), + onResizeStart: vi.fn(), + onResize: vi.fn(), + onDragStart: vi.fn(), + onDrag: vi.fn(), + onRotateStart: vi.fn(), + onRotate: vi.fn(), + onScaleStart: vi.fn(), + onScale: vi.fn(), + getFrame: vi.fn(() => ({ + get: vi.fn(() => 'transform(1)'), + toCSSObject: () => ({ transform: 'rotate(10deg)' }), + properties: { transform: { translate: { value: ['0', '0'] } } }, + })), + getUpdatedElRect: vi.fn(() => ({ left: 1, top: 2, width: 3, height: 4 })), + }), + }, +})); + +const moveableHandlers: Record = {}; + +vi.mock('moveable', () => ({ + default: class MockMoveable { + public target: any = null; + destroy = vi.fn(); + updateRect = vi.fn(); + dragStart = vi.fn(); + on(event: string, handler: Function) { + moveableHandlers[event] = moveableHandlers[event] || []; + moveableHandlers[event].push(handler); + return this; + } + }, +})); + +afterEach(() => { + Object.keys(moveableHandlers).forEach((k) => delete moveableHandlers[k]); + globalThis.document.body.innerHTML = ''; +}); + +describe('StageDragResize', () => { + const createInstance = () => { + const container = globalThis.document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { value: 800, configurable: true }); + Object.defineProperty(container, 'clientHeight', { value: 600, configurable: true }); + globalThis.document.body.appendChild(container); + const dragResizeHelper = new DragResizeHelper({ container }); + const dr = new StageDragResize({ + container, + getRootContainer: () => container, + getRenderDocument: () => globalThis.document, + dragResizeHelper, + markContainerEnd: vi.fn(() => null), + delayedMarkContainer: vi.fn(() => undefined), + }); + return { container, dr, dragResizeHelper }; + }; + + const createTarget = () => { + const el = globalThis.document.createElement('div'); + el.style.cssText = 'position:absolute;left:10px;top:20px;width:100px;height:80px;'; + setIdToEl()(el, 'node_1'); + globalThis.document.body.appendChild(el); + return el; + }; + + test('select null 销毁 moveable', () => { + const { dr } = createInstance(); + const target = createTarget(); + dr.select(target); + dr.select(null); + expect((dr as any).moveable).toBeUndefined(); + dr.destroy(); + }); + + test('select 初始化 moveable 并 updateMoveable', () => { + const { dr } = createInstance(); + const target = createTarget(); + dr.select(target); + expect(dr.getTarget()).toBe(target); + dr.updateMoveable(target); + dr.destroy(); + }); + + test('clearSelectStatus 清空选中框', () => { + const { dr } = createInstance(); + dr.select(createTarget()); + dr.clearSelectStatus(); + dr.destroy(); + }); + + test('setGuidelines / clearGuides 更新参考线', () => { + const { dr } = createInstance(); + const fn = vi.fn(); + dr.on('update-moveable', fn); + dr.setGuidelines(GuidesType.HORIZONTAL, [10]); + dr.clearGuides(); + expect(fn).toHaveBeenCalled(); + dr.destroy(); + }); + + test('resizeEnd 触发 update 事件', () => { + const { dr } = createInstance(); + const target = createTarget(); + dr.select(target); + const updateFn = vi.fn(); + dr.on('update', updateFn); + + moveableHandlers.resizeEnd?.[0]?.(); + expect(dr.getDragStatus()).toBe(StageDragStatus.END); + expect(updateFn).toHaveBeenCalled(); + dr.destroy(); + }); + + test('rotateEnd / scaleEnd 触发 update 事件', () => { + const { dr } = createInstance(); + const target = createTarget(); + dr.select(target); + const updateFn = vi.fn(); + dr.on('update', updateFn); + const shadow = (dr as any).dragResizeHelper.getShadowEl(); + + moveableHandlers.rotateEnd?.forEach((fn) => fn({ target: shadow })); + moveableHandlers.scaleEnd?.forEach((fn) => fn({ target: shadow })); + expect(updateFn).toHaveBeenCalled(); + dr.destroy(); + }); + + test('dragEnd 在拖动过程中触发 update', () => { + const markContainerEnd = vi.fn(() => null); + const { dr } = createInstance(); + (dr as any).markContainerEnd = markContainerEnd; + const target = createTarget(); + dr.select(target); + const updateFn = vi.fn(); + dr.on('update', updateFn); + const shadow = (dr as any).dragResizeHelper.getShadowEl(); + + moveableHandlers.dragStart?.forEach((fn) => fn({ target: shadow })); + moveableHandlers.drag?.forEach((fn) => + fn({ target: shadow, beforeTranslate: [5, 5], inputEvent: new MouseEvent('mousemove') }), + ); + moveableHandlers.dragEnd?.forEach((fn) => fn()); + expect(updateFn).toHaveBeenCalled(); + dr.destroy(); + }); + + test('dragEnd 带 parentEl 时传递 parentEl', () => { + const parent = globalThis.document.createElement('div'); + const { dr } = createInstance(); + (dr as any).markContainerEnd = vi.fn(() => parent); + const target = createTarget(); + dr.select(target); + const updateFn = vi.fn(); + dr.on('update', updateFn); + const shadow = (dr as any).dragResizeHelper.getShadowEl(); + + moveableHandlers.dragStart?.forEach((fn) => fn({ target: shadow })); + moveableHandlers.drag?.forEach((fn) => + fn({ target: shadow, beforeTranslate: [5, 5], inputEvent: new MouseEvent('mousemove') }), + ); + moveableHandlers.dragEnd?.forEach((fn) => fn()); + expect(updateFn).toHaveBeenCalledWith(expect.objectContaining({ parentEl: parent })); + dr.destroy(); + }); + + test('SORTABLE 模式 dragEnd 触发 sort 事件', () => { + const { dr, dragResizeHelper } = createInstance(); + const target = globalThis.document.createElement('div'); + target.style.cssText = 'position:static;width:100px;height:80px;'; + setIdToEl()(target, 'sort-node'); + globalThis.document.body.appendChild(target); + dr.select(target); + const sortFn = vi.fn(); + dr.on('sort', sortFn); + const shadow = dragResizeHelper.getShadowEl(); + + moveableHandlers.dragStart?.forEach((fn) => fn({ target: shadow })); + moveableHandlers.drag?.forEach((fn) => + fn({ target: shadow, beforeTranslate: [5, 5], inputEvent: new MouseEvent('mousemove') }), + ); + const ghost = dragResizeHelper.getGhostEl(); + if (ghost) { + ghost.getBoundingClientRect = () => makeDomRect({ top: 200, left: 0 }); + } + target.getBoundingClientRect = () => makeDomRect({ top: 0, left: 0 }); + moveableHandlers.dragEnd?.forEach((fn) => fn()); + expect(sortFn).toHaveBeenCalled(); + dr.destroy(); + }); + + test('init 时 overflow auto 会被设为 hidden', () => { + const { dr } = createInstance(); + const target = createTarget(); + target.style.overflow = 'auto'; + dr.select(target); + expect(target.style.overflow).toBe('hidden'); + dr.destroy(); + }); + + test('select 带 event 时调用 dragStart', () => { + const { dr } = createInstance(); + const target = createTarget(); + dr.select(target); + const event = new MouseEvent('mousedown'); + dr.select(target, event); + expect((dr as any).moveable?.dragStart).toHaveBeenCalled(); + dr.destroy(); + }); +}); diff --git a/packages/stage/tests/unit/StageHighlight.spec.ts b/packages/stage/tests/unit/StageHighlight.spec.ts new file mode 100644 index 00000000..0b906ba9 --- /dev/null +++ b/packages/stage/tests/unit/StageHighlight.spec.ts @@ -0,0 +1,92 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { setIdToEl } from '@tmagic/core'; + +import StageHighlight from '../../src/StageHighlight'; + +vi.mock('moveable', () => ({ + default: class MockMoveable { + public zoom = 0; + public updateRect = vi.fn(); + public destroy = vi.fn(); + constructor(_container: HTMLElement, options: any) { + if (options?.zoom !== undefined) this.zoom = options.zoom; + } + }, +})); + +afterEach(() => { + globalThis.document.body.innerHTML = ''; + vi.clearAllMocks(); +}); + +describe('StageHighlight', () => { + const createInstance = () => { + const container = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(container); + return new StageHighlight({ + container, + getRootContainer: () => container, + }); + }; + + test('highlight 相同元素时不重复处理', () => { + const highlight = createInstance(); + const el = globalThis.document.createElement('div'); + setIdToEl()(el, 'n1'); + el.style.cssText = 'position:absolute;width:10px;height:10px;left:0;top:0;'; + + highlight.highlight(el); + const { moveable } = highlight; + highlight.highlight(el); + expect(highlight.moveable).toBe(moveable); + highlight.destroy(); + }); + + test('highlight 创建 moveable 并更新 targetShadow', () => { + const highlight = createInstance(); + const el = globalThis.document.createElement('div'); + setIdToEl()(el, 'n2'); + el.style.cssText = 'position:absolute;width:10px;height:10px;left:0;top:0;'; + + highlight.highlight(el); + expect(highlight.target).toBe(el); + expect(highlight.moveable?.zoom).toBe(2); + highlight.destroy(); + }); + + test('clearHighlight 重置 zoom 并清空 target', () => { + const highlight = createInstance(); + const el = globalThis.document.createElement('div'); + setIdToEl()(el, 'n3'); + el.style.cssText = 'position:absolute;width:10px;height:10px;left:0;top:0;'; + + highlight.highlight(el); + highlight.clearHighlight(); + expect(highlight.moveable?.zoom).toBe(0); + expect(highlight.target).toBeUndefined(); + highlight.destroy(); + }); + + test('clearHighlight 在无 target 时不抛错', () => { + const highlight = createInstance(); + expect(() => highlight.clearHighlight()).not.toThrow(); + highlight.destroy(); + }); + + test('destroy 清理 moveable 与 targetShadow', () => { + const highlight = createInstance(); + const el = globalThis.document.createElement('div'); + setIdToEl()(el, 'n4'); + el.style.cssText = 'position:absolute;width:10px;height:10px;left:0;top:0;'; + highlight.highlight(el); + highlight.destroy(); + expect(highlight.moveable).toBeUndefined(); + expect(highlight.targetShadow).toBeUndefined(); + }); +}); diff --git a/packages/stage/tests/unit/StageMask.spec.ts b/packages/stage/tests/unit/StageMask.spec.ts new file mode 100644 index 00000000..fcd19bd8 --- /dev/null +++ b/packages/stage/tests/unit/StageMask.spec.ts @@ -0,0 +1,174 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { Mode } from '../../src/const'; +import StageMask from '../../src/StageMask'; + +const makeResizeEntry = (target: Element): ResizeObserverEntry => ({ + target, + contentRect: target.getBoundingClientRect(), + borderBoxSize: [], + contentBoxSize: [], + devicePixelContentBoxSize: [], +}); + +const makeDomRect = (partial: Partial): DOMRect => ({ + x: partial.x ?? partial.left ?? 0, + y: partial.y ?? partial.top ?? 0, + width: partial.width ?? partial.height ?? 0, + height: partial.height ?? partial.width ?? 0, + top: partial.top ?? 0, + left: partial.left ?? 0, + right: partial.right ?? 0, + bottom: partial.bottom ?? 0, + toJSON: () => ({}), +}); + +vi.mock('@scena/guides', () => ({ + default: class MockGuides { + on = vi.fn(); + off = vi.fn(); + destroy = vi.fn(); + resize = vi.fn(); + setState = vi.fn(); + scroll = vi.fn(); + scrollGuides = vi.fn(); + }, +})); + +describe('StageMask', () => { + let mask: StageMask | null = null; + + beforeEach(() => { + globalThis.document.body.innerHTML = ''; + }); + + afterEach(() => { + mask?.destroy(); + mask = null; + }); + + test('mount 将 wrapper 挂到容器', () => { + mask = new StageMask({ disabledRule: true }); + const host = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(host); + mask.mount(host); + expect(host.contains(mask.wrapper)).toBe(true); + }); + + test('setLayout 根据 fixed 父节点切换 mode', () => { + mask = new StageMask({ disabledRule: true }); + const el = globalThis.document.createElement('div'); + el.style.position = 'absolute'; + mask.setLayout(el); + expect(mask.content.dataset.mode).toBe(Mode.ABSOLUTE); + + const fixed = globalThis.document.createElement('div'); + fixed.style.position = 'fixed'; + globalThis.document.body.appendChild(fixed); + mask.setLayout(fixed); + expect(mask.content.dataset.mode).toBe(Mode.FIXED); + }); + + test('pageResize 同步宽高并触发 scroll', () => { + mask = new StageMask({ disabledRule: true }); + const page = globalThis.document.createElement('div'); + Object.defineProperty(page, 'clientWidth', { value: 400, configurable: true }); + Object.defineProperty(page, 'clientHeight', { value: 300, configurable: true }); + mask.observe(page); + + mask.pageResize([makeResizeEntry(page)]); + expect(mask.width).toBe(400); + expect(mask.height).toBe(300); + }); + + test('wheel 事件更新 scroll 并 emit scroll', () => { + mask = new StageMask({ disabledRule: true }); + const page = globalThis.document.createElement('div'); + Object.defineProperty(page, 'clientWidth', { value: 1000, configurable: true }); + Object.defineProperty(page, 'clientHeight', { value: 1000, configurable: true }); + mask.observe(page); + mask.pageResize([makeResizeEntry(page)]); + mask.wrapperWidth = 400; + mask.wrapperHeight = 300; + (mask as any).setMaxScrollLeft(); + (mask as any).setMaxScrollTop(); + + const fn = vi.fn(); + mask.on('scroll', fn); + mask.content.dispatchEvent(new WheelEvent('wheel', { deltaY: 10, deltaX: 5, bubbles: true })); + expect(fn).toHaveBeenCalled(); + expect(mask.scrollTop).not.toBe(0); + }); + + test('setGuides / clearGuides 透传 Rule 能力', () => { + mask = new StageMask({ disabledRule: true }); + const fn = vi.fn(); + mask.on('change-guides', fn); + mask.setGuides([[1], [2]]); + mask.clearGuides(); + expect(fn).toHaveBeenCalled(); + }); + + test('observe 后 observerIntersection 触发 scrollIntoView', () => { + const originalIo = globalThis.IntersectionObserver; + const MockIntersectionObserver = vi.fn(function ( + this: { + observe: ReturnType; + unobserve: ReturnType; + disconnect: ReturnType; + }, + callback: IntersectionObserverCallback, + ) { + this.observe = vi.fn((target: Element) => { + const entry: IntersectionObserverEntry = { + target, + intersectionRatio: 0, + isIntersecting: false, + boundingClientRect: makeDomRect({}), + intersectionRect: makeDomRect({}), + rootBounds: null, + time: 0, + }; + callback([entry], this as unknown as IntersectionObserver); + }); + this.unobserve = vi.fn(); + this.disconnect = vi.fn(); + }); + globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver; + + try { + mask = new StageMask({ disabledRule: true }); + const page = globalThis.document.createElement('div'); + Object.defineProperty(page, 'clientWidth', { value: 400, configurable: true }); + Object.defineProperty(page, 'clientHeight', { value: 300, configurable: true }); + Object.defineProperty(page, 'scrollWidth', { value: 400, configurable: true }); + mask.observe(page); + mask.pageResize([makeResizeEntry(page)]); + mask.wrapperWidth = 400; + mask.wrapperHeight = 300; + + const el = globalThis.document.createElement('div'); + page.appendChild(el); + el.scrollIntoView = vi.fn(); + el.getBoundingClientRect = () => makeDomRect({ left: 0, top: 0, width: 10, height: 10 }); + + mask.observerIntersection(el); + expect(el.scrollIntoView).toHaveBeenCalled(); + } finally { + globalThis.IntersectionObserver = originalIo; + } + }); + + test('destroy 清理 observer 与 page', () => { + mask = new StageMask({ disabledRule: true }); + const page = globalThis.document.createElement('div'); + mask.observe(page); + mask.destroy(); + expect(mask.page).toBeNull(); + }); +}); diff --git a/packages/stage/tests/unit/StageMultiDragResize.spec.ts b/packages/stage/tests/unit/StageMultiDragResize.spec.ts new file mode 100644 index 00000000..0c677da1 --- /dev/null +++ b/packages/stage/tests/unit/StageMultiDragResize.spec.ts @@ -0,0 +1,233 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { setIdToEl } from '@tmagic/core'; + +import { DRAG_EL_ID_PREFIX } from '../../src/const'; +import DragResizeHelper from '../../src/DragResizeHelper'; +import StageMultiDragResize from '../../src/StageMultiDragResize'; + +const moveableHandlers: Record = {}; + +vi.mock('moveable-helper', () => ({ + default: { + create: () => ({ + clear: vi.fn(), + onResizeStart: vi.fn(), + onResize: vi.fn(), + onDragStart: vi.fn(), + onDrag: vi.fn(), + onRotateStart: vi.fn(), + onRotate: vi.fn(), + onScaleStart: vi.fn(), + onScale: vi.fn(), + onResizeGroupStart: vi.fn(), + onResizeGroup: vi.fn(), + onDragGroupStart: vi.fn(), + onDragGroup: vi.fn(), + getFrame: vi.fn(() => ({ + get: vi.fn(() => 'transform(1)'), + toCSSObject: () => ({ transform: 'rotate(10deg)' }), + properties: { transform: { translate: { value: ['0', '0'] } } }, + })), + }), + }, +})); + +vi.mock('moveable', () => ({ + default: class MockMoveable { + public target: any = null; + destroy = vi.fn(); + updateRect = vi.fn(); + updateTarget = vi.fn(); + on(event: string, handler: Function) { + moveableHandlers[event] = moveableHandlers[event] || []; + moveableHandlers[event].push(handler); + return this; + } + }, +})); + +vi.mock('../../src/MoveableActionsAble', () => ({ + default: () => ({ name: 'actions' }), +})); + +Object.defineProperties(globalThis.HTMLElement.prototype, { + offsetTop: { + get() { + return parseFloat((this as HTMLElement).style.top) || 0; + }, + configurable: true, + }, + offsetLeft: { + get() { + return parseFloat((this as HTMLElement).style.left) || 0; + }, + configurable: true, + }, + clientWidth: { + get() { + return parseFloat((this as HTMLElement).style.width) || 100; + }, + configurable: true, + }, + clientHeight: { + get() { + return parseFloat((this as HTMLElement).style.height) || 80; + }, + configurable: true, + }, +}); + +afterEach(() => { + Object.keys(moveableHandlers).forEach((k) => delete moveableHandlers[k]); + globalThis.document.body.innerHTML = ''; +}); + +describe('StageMultiDragResize', () => { + const createTarget = (id: string, left = '10px', top = '20px') => { + const el = globalThis.document.createElement('div'); + el.style.cssText = `position:absolute;left:${left};top:${top};width:100px;height:80px;`; + setIdToEl()(el, id); + globalThis.document.body.appendChild(el); + return el; + }; + + const createInstance = () => { + const container = globalThis.document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { value: 800, configurable: true }); + Object.defineProperty(container, 'clientHeight', { value: 600, configurable: true }); + globalThis.document.body.appendChild(container); + const dragResizeHelper = new DragResizeHelper({ container }); + const multiDr = new StageMultiDragResize({ + container, + getRootContainer: () => container, + getRenderDocument: () => globalThis.document, + dragResizeHelper, + markContainerEnd: vi.fn(() => null), + delayedMarkContainer: vi.fn(() => undefined), + }); + return { container, multiDr, dragResizeHelper }; + }; + + test('multiSelect 空数组直接返回', () => { + const { multiDr } = createInstance(); + multiDr.multiSelect([]); + expect(multiDr.targetList).toHaveLength(0); + multiDr.destroy(); + }); + + test('multiSelect 初始化 moveable 并绑定 group 事件', () => { + const { multiDr } = createInstance(); + const els = [createTarget('a'), createTarget('b')]; + multiDr.multiSelect(els); + expect(multiDr.targetList).toHaveLength(2); + expect(multiDr.moveableForMulti).toBeDefined(); + multiDr.destroy(); + }); + + test('canSelect 流式布局不可多选', () => { + const { multiDr } = createInstance(); + const sortable = createTarget('sort'); + sortable.style.position = 'static'; + expect(multiDr.canSelect(sortable, null)).toBe(false); + multiDr.destroy(); + }); + + test('canSelect 不同定位模式不可混选', () => { + const { multiDr } = createInstance(); + const abs = createTarget('abs1'); + const fixed = createTarget('fixed1'); + fixed.style.position = 'fixed'; + globalThis.document.body.appendChild(fixed); + multiDr.multiSelect([abs]); + expect(multiDr.canSelect(fixed, null)).toBe(false); + multiDr.destroy(); + }); + + test('clearSelectStatus 清空多选状态', () => { + const { multiDr } = createInstance(); + multiDr.multiSelect([createTarget('a'), createTarget('b')]); + multiDr.clearSelectStatus(); + expect(multiDr.targetList).toHaveLength(0); + multiDr.destroy(); + }); + + test('resizeGroupEnd 触发 update 事件', () => { + const { multiDr } = createInstance(); + multiDr.multiSelect([createTarget('a'), createTarget('b')]); + const fn = vi.fn(); + multiDr.on('update', fn); + moveableHandlers.resizeGroupEnd?.forEach((handler) => handler()); + expect(fn).toHaveBeenCalled(); + multiDr.destroy(); + }); + + test('clickGroup 多选态点击子元素触发 change-to-select', () => { + const { multiDr, dragResizeHelper } = createInstance(); + multiDr.multiSelect([createTarget('a'), createTarget('b')]); + const fn = vi.fn(); + multiDr.on('change-to-select', fn); + const shadow = dragResizeHelper.getShadowEls()[0] as HTMLElement; + setIdToEl()(shadow, `${DRAG_EL_ID_PREFIX}a`); + moveableHandlers.clickGroup?.forEach((handler) => + handler({ + inputTarget: shadow, + targets: dragResizeHelper.getShadowEls(), + inputEvent: new MouseEvent('click'), + }), + ); + expect(fn).toHaveBeenCalledWith('a', expect.any(MouseEvent)); + multiDr.destroy(); + }); + + test('dragGroupEnd 触发 update 并清理 timeout', () => { + const markContainerEnd = vi.fn(() => null); + const delayedMarkContainer = vi.fn(() => globalThis.setTimeout(() => {}, 1000)); + const container = globalThis.document.createElement('div'); + Object.defineProperty(container, 'clientWidth', { value: 800, configurable: true }); + Object.defineProperty(container, 'clientHeight', { value: 600, configurable: true }); + globalThis.document.body.appendChild(container); + const dragResizeHelper = new DragResizeHelper({ container }); + const multiDr = new StageMultiDragResize({ + container, + getRootContainer: () => container, + getRenderDocument: () => globalThis.document, + dragResizeHelper, + markContainerEnd, + delayedMarkContainer, + }); + multiDr.multiSelect([createTarget('a'), createTarget('b')]); + const fn = vi.fn(); + multiDr.on('update', fn); + moveableHandlers.dragGroup?.forEach((handler) => handler({ inputEvent: new MouseEvent('mousemove'), events: [] })); + moveableHandlers.dragGroupEnd?.forEach((handler) => handler()); + expect(fn).toHaveBeenCalled(); + multiDr.destroy(); + }); + + test('updateMoveable 更新 moveable 配置', () => { + const { multiDr } = createInstance(); + multiDr.multiSelect([createTarget('a'), createTarget('b')]); + multiDr.updateMoveable(); + expect(multiDr.moveableForMulti?.updateRect).toHaveBeenCalled(); + multiDr.destroy(); + }); + + test('canSelect 单选后追加多选', () => { + const { multiDr } = createInstance(); + const abs = createTarget('abs2'); + expect(multiDr.canSelect(abs, abs)).toBe(true); + multiDr.destroy(); + }); + + test('updateMoveable 无 moveable 时直接返回', () => { + const { multiDr } = createInstance(); + expect(() => multiDr.updateMoveable()).not.toThrow(); + multiDr.destroy(); + }); +}); diff --git a/packages/stage/tests/unit/StageRender.spec.ts b/packages/stage/tests/unit/StageRender.spec.ts new file mode 100644 index 00000000..722702ee --- /dev/null +++ b/packages/stage/tests/unit/StageRender.spec.ts @@ -0,0 +1,194 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { setIdToEl } from '@tmagic/core'; + +import { RenderType } from '../../src/const'; +import StageRender from '../../src/StageRender'; + +vi.mock('@zumer/snapdom', () => ({ + snapdom: vi.fn(async () => ({ + toPng: vi.fn(async () => 'png-data'), + toRaw: vi.fn(async () => 'raw-data'), + })), +})); + +afterEach(() => { + globalThis.document.body.innerHTML = ''; + // @ts-ignore + globalThis.runtime = undefined; + vi.clearAllMocks(); +}); + +describe('StageRender - NATIVE', () => { + test('NATIVE 模式 mount 后暴露 magic API', async () => { + const host = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(host); + const page = globalThis.document.createElement('div'); + setIdToEl()(page, 'page_1'); + + const renderer = new StageRender({ + renderType: RenderType.NATIVE, + customizedRender: async () => page, + }); + + await renderer.mount(host); + expect(host.contains(renderer.nativeContainer!)).toBe(true); + expect(renderer.contentWindow?.magic).toBeDefined(); + + const runtimeReady = vi.fn(); + renderer.on('runtime-ready', runtimeReady); + renderer.getMagicApi().onRuntimeReady({ add: vi.fn(), update: vi.fn(), remove: vi.fn(), select: vi.fn() }); + expect(runtimeReady).toHaveBeenCalled(); + renderer.destroy(); + }); + + test('getTargetElement / getElementsFromPoint', async () => { + const host = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(host); + const page = globalThis.document.createElement('div'); + setIdToEl()(page, 'node_1'); + page.style.cssText = 'position:absolute;left:0;top:0;width:100px;height:100px;'; + globalThis.document.body.appendChild(page); + globalThis.document.elementsFromPoint = vi.fn(() => [page]) as any; + + const renderer = new StageRender({ + renderType: RenderType.NATIVE, + customizedRender: async () => page, + }); + await renderer.mount(host); + + expect(renderer.getTargetElement('node_1')).toBe(page); + const els = renderer.getElementsFromPoint({ clientX: 10, clientY: 10 }); + expect(els).toContain(page); + renderer.destroy(); + }); + + test('runtime 就绪后 add/remove/update/select 透传', async () => { + const renderer = new StageRender({ renderType: RenderType.NATIVE }); + const runtime = { + add: vi.fn(), + remove: vi.fn(), + update: vi.fn(), + select: vi.fn(), + }; + renderer.getMagicApi().onRuntimeReady(runtime as any); + + await renderer.add({ config: { id: 'a' } } as any); + await renderer.remove({ id: 'a' } as any); + await renderer.update({ config: { id: 'a' } } as any); + await renderer.select(['a']); + + expect(runtime.add).toHaveBeenCalled(); + expect(runtime.remove).toHaveBeenCalled(); + expect(runtime.update).toHaveBeenCalled(); + expect(runtime.select).toHaveBeenCalledWith('a'); + renderer.destroy(); + }); + + test('getElementImage 找不到元素时抛错', async () => { + const renderer = new StageRender({ renderType: RenderType.NATIVE }); + await expect(renderer.getElementImage('missing')).rejects.toThrow('Element with id'); + renderer.destroy(); + }); + + test('setZoom 与 getRuntime promise', async () => { + const renderer = new StageRender({ renderType: RenderType.NATIVE, zoom: 2 }); + renderer.setZoom(1.5); + const p = renderer.getRuntime(); + renderer.getMagicApi().onRuntimeReady({} as any); + await expect(p).resolves.toBeDefined(); + renderer.destroy(); + }); + + test('onPageElUpdate 通过 magic API 派发 page-el-update', () => { + const renderer = new StageRender({ renderType: RenderType.NATIVE }); + const fn = vi.fn(); + renderer.on('page-el-update', fn); + const page = globalThis.document.createElement('div'); + renderer.getMagicApi().onPageElUpdate(page); + expect(fn).toHaveBeenCalledWith(page); + renderer.destroy(); + }); + + test('IFRAME 模式创建 iframe 并在 mount 后 postMessage', async () => { + const host = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(host); + const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '' }); + const postMessage = vi.fn(); + Object.defineProperty(renderer, 'postTmagicRuntimeReady', { + value: vi.fn(function (this: StageRender) { + this.contentWindow = { postMessage, magic: this.getMagicApi() } as any; + postMessage(); + }), + }); + + await renderer.mount(host); + expect(host.querySelector('iframe')).toBeTruthy(); + expect(postMessage).toHaveBeenCalled(); + renderer.destroy(); + }); + + test('getElementImage 找到元素时调用 snapdom', async () => { + const page = globalThis.document.createElement('div'); + setIdToEl()(page, 'img-node'); + globalThis.document.body.appendChild(page); + const renderer = new StageRender({ renderType: RenderType.NATIVE }); + renderer.getMagicApi().onRuntimeReady({} as any); + const result = await renderer.getElementImage('img-node', 'png'); + expect(result).toBe('png-data'); + renderer.destroy(); + }); + + test('getElementImage 无效 type 抛错', async () => { + const page = globalThis.document.createElement('div'); + setIdToEl()(page, 'bad-type'); + globalThis.document.body.appendChild(page); + const renderer = new StageRender({ renderType: RenderType.NATIVE }); + renderer.getMagicApi().onRuntimeReady({} as any); + await expect(renderer.getElementImage('bad-type', 'invalid' as any)).rejects.toThrow('Invalid type'); + renderer.destroy(); + }); + + test('IFRAME getElementsFromPoint 考虑 iframe 偏移', async () => { + const host = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(host); + const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '' }); + await renderer.mount(host); + const iframe = host.querySelector('iframe')!; + const clientRect: DOMRect = { + left: 50, + top: 30, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 50, + y: 30, + toJSON: () => ({}), + }; + iframe.getClientRects = () => [clientRect]; + renderer.contentWindow = { + document: globalThis.document, + magic: renderer.getMagicApi(), + } as any; + globalThis.document.elementsFromPoint = vi.fn(() => []) as any; + renderer.getElementsFromPoint({ clientX: 100, clientY: 80 }); + expect(globalThis.document.elementsFromPoint).toHaveBeenCalled(); + renderer.destroy(); + }); + + test('reloadIframe 重新挂载 iframe', async () => { + const host = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(host); + const renderer = new StageRender({ renderType: RenderType.IFRAME, runtimeUrl: '' }); + await renderer.mount(host); + renderer.reloadIframe('/new-runtime'); + expect(host.querySelector('iframe')).toBeTruthy(); + renderer.destroy(); + }); +}); diff --git a/packages/stage/tests/unit/TargetShadow.spec.ts b/packages/stage/tests/unit/TargetShadow.spec.ts new file mode 100644 index 00000000..a7a34442 --- /dev/null +++ b/packages/stage/tests/unit/TargetShadow.spec.ts @@ -0,0 +1,87 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { setIdToEl } from '@tmagic/core'; + +import { Mode, ZIndex } from '../../src/const'; +import TargetShadow from '../../src/TargetShadow'; + +const createTarget = (id = 'n1') => { + const el = globalThis.document.createElement('div'); + el.style.cssText = 'position:absolute;left:10px;top:20px;width:50px;height:40px;'; + setIdToEl()(el, id); + return el; +}; + +afterEach(() => { + globalThis.document.body.innerHTML = ''; +}); + +describe('TargetShadow', () => { + test('update 创建并挂载 shadow 元素', () => { + const container = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(container); + const shadow = new TargetShadow({ container, zIndex: ZIndex.DRAG_EL }); + + const result = shadow.update(createTarget('a')); + expect(result).toBeTruthy(); + expect(container.children.length).toBe(1); + + shadow.destroy(); + }); + + test('updateGroup 同步多选 shadow 列表', () => { + const container = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(container); + const shadow = new TargetShadow({ container }); + + shadow.updateGroup([createTarget('a'), createTarget('b')]); + expect(shadow.els).toHaveLength(2); + + shadow.updateGroup([createTarget('c')]); + expect(shadow.els).toHaveLength(1); + + shadow.destroy(); + }); + + test('customScroll 事件会更新 fixed/absolute 模式的 transform', () => { + const container = globalThis.document.createElement('div'); + container.dataset.mode = Mode.ABSOLUTE; + globalThis.document.body.appendChild(container); + const updateDragEl = vi.fn(); + const shadow = new TargetShadow({ container, updateDragEl, idPrefix: 'test' }); + + const target = createTarget('fixed-node'); + target.style.position = 'fixed'; + shadow.update(target); + + container.dispatchEvent(new CustomEvent('customScroll', { detail: { scrollLeft: 30, scrollTop: 40 } })); + shadow.update(target); + + expect(updateDragEl).toHaveBeenCalled(); + shadow.destroy(); + }); + + test('destroyEl / destroyEls / destroy 清理节点', () => { + const container = globalThis.document.createElement('div'); + globalThis.document.body.appendChild(container); + const shadow = new TargetShadow({ container }); + + shadow.update(createTarget('a')); + shadow.updateGroup([createTarget('b')]); + shadow.destroyEl(); + expect(shadow.el).toBeUndefined(); + + shadow.updateGroup([createTarget('c')]); + shadow.destroyEls(); + expect(shadow.els).toHaveLength(0); + + shadow.update(createTarget('d')); + shadow.destroy(); + expect(container.children.length).toBe(0); + }); +}); diff --git a/packages/stage/tests/unit/logger.spec.ts b/packages/stage/tests/unit/logger.spec.ts new file mode 100644 index 00000000..22f10dc0 --- /dev/null +++ b/packages/stage/tests/unit/logger.spec.ts @@ -0,0 +1,33 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import * as logger from '../../src/logger'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('logger', () => { + test('log/info/warn/debug/error 透传到 console', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + logger.log('a'); + logger.info('b'); + logger.warn('c'); + logger.debug('d'); + logger.error('e'); + + expect(logSpy).toHaveBeenCalledWith('a'); + expect(logSpy).toHaveBeenCalledWith('b'); + expect(warnSpy).toHaveBeenCalledWith('c'); + expect(debugSpy).toHaveBeenCalledWith('d'); + expect(errorSpy).toHaveBeenCalledWith('e'); + }); +}); diff --git a/packages/table/package.json b/packages/table/package.json index 880f7dbf..2f769716 100644 --- a/packages/table/package.json +++ b/packages/table/package.json @@ -40,7 +40,8 @@ "lodash-es": "^4.17.21" }, "devDependencies": { - "@types/lodash-es": "^4.17.4" + "@types/lodash-es": "^4.17.4", + "@vue/test-utils": "^2.4.6" }, "peerDependencies": { "@tmagic/design": "workspace:*", diff --git a/packages/table/test-support/design.mock.ts b/packages/table/test-support/design.mock.ts new file mode 100644 index 00000000..e9816803 --- /dev/null +++ b/packages/table/test-support/design.mock.ts @@ -0,0 +1,102 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { vi } from 'vitest'; +import { defineComponent, h } from 'vue'; + +export const tableRefMethods = { + toggleRowSelection: vi.fn(), + toggleRowExpansion: vi.fn(), + clearSelection: vi.fn(), +}; + +export const tMagicMessage = { + success: vi.fn(), + error: vi.fn(), +}; + +export const createDesignMock = () => ({ + TMagicTable: defineComponent({ + name: 'TMagicTable', + props: { + data: { type: Array, default: () => [] }, + columns: { type: Array, default: () => [] }, + loading: Boolean, + showHeader: Boolean, + bodyHeight: [String, Number], + defaultExpandAll: Boolean, + border: Boolean, + rowKey: String, + emptyText: String, + spanMethod: Function, + }, + emits: ['sort-change', 'select', 'select-all', 'selection-change', 'cell-click', 'expand-change'], + setup(props, { expose }) { + expose(tableRefMethods); + return () => + h( + 'div', + { class: 'tmagic-table-stub' }, + (props.data as any[]).flatMap((row, $index) => + (props.columns as any[]).map((col, colIndex) => { + if (!col.cell) return null; + return h('div', { class: `cell-${colIndex}`, key: `${$index}-${colIndex}` }, [col.cell({ row, $index })]); + }), + ), + ); + }, + }), + TMagicButton: defineComponent({ + name: 'TMagicButton', + props: ['link', 'type', 'size', 'disabled', 'icon'], + emits: ['click'], + setup(props, { slots, emit }) { + return () => + h( + 'button', + { + class: ['tmagic-button-stub', props.type, props.disabled ? 'is-disabled' : ''].filter(Boolean), + disabled: props.disabled, + onClick: () => emit('click'), + }, + slots.default?.(), + ); + }, + }), + TMagicTooltip: defineComponent({ + name: 'TMagicTooltip', + props: ['placement', 'disabled', 'content'], + setup(_props, { slots }) { + return () => h('div', { class: 'tmagic-tooltip-stub' }, [slots.default?.(), slots.content?.()]); + }, + }), + TMagicTag: defineComponent({ + name: 'TMagicTag', + props: ['type'], + setup(props, { slots }) { + return () => h('span', { class: ['tmagic-tag-stub', props.type].filter(Boolean) }, slots.default?.()); + }, + }), + TMagicPopover: defineComponent({ + name: 'TMagicPopover', + props: ['placement', 'width', 'trigger', 'destroyOnClose'], + setup(_props, { slots }) { + return () => h('div', { class: 'tmagic-popover-stub' }, [slots.reference?.(), slots.default?.()]); + }, + }), + tMagicMessage, +}); + +export const createFormMock = () => ({ + MForm: defineComponent({ + name: 'MForm', + props: ['config', 'initValues', 'labelWidth'], + emits: ['change'], + setup(props) { + return () => h('form', { class: 'mform-stub' }, JSON.stringify(props.initValues ?? {})); + }, + }), + datetimeFormatter: (value: string) => `fmt:${value}`, +}); diff --git a/packages/table/tests/Table.spec.ts b/packages/table/tests/Table.spec.ts new file mode 100644 index 00000000..f3bd6901 --- /dev/null +++ b/packages/table/tests/Table.spec.ts @@ -0,0 +1,186 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test, vi } from 'vitest'; +import { defineComponent, h, nextTick } from 'vue'; +import { mount } from '@vue/test-utils'; + +import Table from '../src/Table.vue'; +import { tableRefMethods } from '../test-support/design.mock'; + +vi.mock('@tmagic/design', async () => { + const { createDesignMock } = await import('../test-support/design.mock'); + return createDesignMock(); +}); + +vi.mock('@tmagic/form', async () => { + const { createFormMock } = await import('../test-support/design.mock'); + return createFormMock(); +}); + +describe('Table.vue', () => { + const mountTable = (props: Record = {}) => + mount(Table, { + props: { + data: [{ id: 1, name: 'Alice' }], + columns: [{ prop: 'name', label: '名称' }], + ...props, + }, + }); + + test('渲染文本列内容', () => { + const wrapper = mountTable(); + expect(wrapper.text()).toContain('Alice'); + }); + + test('无 selection 列时对 data 做深拷贝', () => { + const data = [{ id: 1, name: 'Bob' }]; + const wrapper = mountTable({ data }); + const table = wrapper.findComponent({ name: 'TMagicTable' }); + expect(table.props('data')).not.toBe(data); + expect(table.props('data')).toEqual(data); + }); + + test('selection 列映射为 selection 类型', () => { + const wrapper = mountTable({ + columns: [{ selection: true }, { prop: 'name', label: '名称' }], + }); + const columns = wrapper.findComponent({ name: 'TMagicTable' }).props('columns') as any[]; + expect(columns[0].props.type).toBe('selection'); + expect(columns[1].cell).toBeDefined(); + }); + + test('border 默认 false,显式 true 时开启边框', () => { + const wrapperDefault = mountTable(); + expect(wrapperDefault.findComponent({ name: 'TMagicTable' }).props('border')).toBe(false); + const wrapperBorder = mountTable({ border: true }); + expect(wrapperBorder.findComponent({ name: 'TMagicTable' }).props('border')).toBe(true); + }); + + test('border=false 时使用传入值', () => { + const wrapper = mountTable({ border: false }); + expect(wrapper.findComponent({ name: 'TMagicTable' }).props('border')).toBe(false); + }); + + test('expose 方法代理到 TMagicTable', async () => { + const wrapper = mountTable(); + const row = { id: 1 }; + wrapper.vm.toggleRowSelection(row, true); + wrapper.vm.toggleRowExpansion(row, false); + wrapper.vm.clearSelection(); + expect(tableRefMethods.toggleRowSelection).toHaveBeenCalledWith(row, true); + expect(tableRefMethods.toggleRowExpansion).toHaveBeenCalledWith(row, false); + expect(tableRefMethods.clearSelection).toHaveBeenCalled(); + await nextTick(); + }); + + test('自定义 spanMethod 生效', () => { + const spanMethod = vi.fn(() => [2, 1] as [number, number]); + const wrapper = mountTable({ spanMethod }); + const fn = wrapper.findComponent({ name: 'TMagicTable' }).props('spanMethod') as Function; + expect(fn({ row: {}, column: {}, rowIndex: 0, columnIndex: 0 })).toEqual([2, 1]); + }); + + test('未配置 spanMethod 时返回占位函数', () => { + const wrapper = mountTable(); + const fn = wrapper.findComponent({ name: 'TMagicTable' }).props('spanMethod') as Function; + const result = fn({}); + expect(typeof result).toBe('function'); + expect(result()).toEqual({ rowspan: 0, colspan: 0 }); + }); + + test('派发 sort-change / select-all / selection-change / cell-click / expand-change', async () => { + const wrapper = mountTable({ + columns: [{ selection: true }, { prop: 'name', label: '名称' }], + }); + const table = wrapper.findComponent({ name: 'TMagicTable' }); + + table.vm.$emit('sort-change', { prop: 'name' }); + table.vm.$emit('select-all', [{ id: 1 }]); + table.vm.$emit('selection-change', [{ id: 1 }]); + table.vm.$emit('cell-click', {}, {}, {}, new Event('click')); + table.vm.$emit('expand-change', { id: 1 }, []); + + expect(wrapper.emitted('sort-change')?.[0]).toEqual([{ prop: 'name' }]); + expect(wrapper.emitted('select-all')?.[0]).toEqual([[{ id: 1 }]]); + expect(wrapper.emitted('selection-change')?.[0]).toEqual([[{ id: 1 }]]); + expect(wrapper.emitted('cell-click')).toBeTruthy(); + expect(wrapper.emitted('expand-change')).toBeTruthy(); + await nextTick(); + }); + + test('select 事件在有 selection 列时派发', async () => { + const wrapper = mountTable({ + columns: [{ selection: 'single' }, { prop: 'name', label: '名称' }], + }); + const table = wrapper.findComponent({ name: 'TMagicTable' }); + const row = { id: 1, name: 'Dave' }; + table.vm.$emit('select', [row], row); + expect(wrapper.emitted('select')?.[0]).toEqual([[row], row]); + await nextTick(); + }); + + test('无 selection 列时不派发 select', async () => { + const wrapper = mountTable(); + const table = wrapper.findComponent({ name: 'TMagicTable' }); + table.vm.$emit('select', [], {}); + expect(wrapper.emitted('select')).toBeUndefined(); + await nextTick(); + }); + + test('渲染 expand / component / actions / popover 列', () => { + const customCell = defineComponent({ + props: ['row'], + setup(p) { + return () => h('i', { class: 'custom-cell' }, p.row?.name); + }, + }); + + const wrapper = mountTable({ + data: [{ id: 1, name: 'Eve', items: [{ sub: 'x' }] }], + columns: [ + { type: 'expand', table: [{ prop: 'sub', label: '子项' }] }, + { type: 'component', component: customCell, props: (row: any) => ({ row }) }, + { + actions: [{ type: 'edit', text: '编辑' }], + }, + { + type: 'popover', + prop: 'items', + text: '详情', + popover: { tableEmbed: true, trigger: 'click' }, + table: [{ prop: 'sub', label: '子项' }], + }, + ], + }); + + expect(wrapper.find('.tmagic-table-stub').exists()).toBe(true); + expect(wrapper.find('.custom-cell').exists()).toBe(true); + expect(wrapper.find('.action-btn').exists()).toBe(true); + expect(wrapper.find('.tmagic-popover-stub').exists()).toBe(true); + }); + + test('actions 列 after-action 事件向上透传', async () => { + const wrapper = mountTable({ + columns: [ + { + actions: [ + { + type: 'edit', + text: '编辑', + action: vi.fn(), + }, + ], + }, + ], + }); + + const editBtn = wrapper.findAll('.action-btn').find((b) => b.text().includes('编辑')); + await editBtn?.trigger('click'); + const saveBtn = wrapper.findAll('.action-btn').find((b) => b.text() === '保存'); + await saveBtn?.trigger('click'); + expect(wrapper.emitted('after-action')?.[0]).toEqual([{ index: 0 }]); + }); +}); diff --git a/packages/table/tests/columns.spec.ts b/packages/table/tests/columns.spec.ts new file mode 100644 index 00000000..c57e917a --- /dev/null +++ b/packages/table/tests/columns.spec.ts @@ -0,0 +1,270 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test, vi } from 'vitest'; +import { defineComponent, h } from 'vue'; +import { mount } from '@vue/test-utils'; + +import ActionsColumn from '../src/ActionsColumn.vue'; +import ComponentColumn from '../src/ComponentColumn.vue'; +import ExpandColumn from '../src/ExpandColumn.vue'; +import PopoverColumn from '../src/PopoverColumn.vue'; +import TextColumn from '../src/TextColumn.vue'; +import { tMagicMessage } from '../test-support/design.mock'; + +vi.mock('@tmagic/design', async () => { + const { createDesignMock } = await import('../test-support/design.mock'); + return createDesignMock(); +}); + +vi.mock('@tmagic/form', async () => { + const { createFormMock } = await import('../test-support/design.mock'); + return createFormMock(); +}); + +describe('TextColumn.vue', () => { + test('index 列支持分页序号', () => { + const wrapper = mount(TextColumn, { + props: { + config: { type: 'index', pageIndex: 1, pageSize: 10 }, + row: {}, + index: 2, + }, + }); + expect(wrapper.text()).toBe('13'); + }); + + test('index 列无分页时使用 index + 1', () => { + const wrapper = mount(TextColumn, { + props: { config: { type: 'index' }, row: {}, index: 4 }, + }); + expect(wrapper.text()).toBe('5'); + }); + + test('actionLink / img / link / tip / tag 分支', () => { + const row = { + url: 'https://example.com', + img: 'https://example.com/a.png', + status: 'ok', + }; + + const link = mount(TextColumn, { + props: { + config: { action: 'actionLink', prop: 'url', handler: vi.fn() }, + row, + index: 0, + }, + }); + expect(link.find('.tmagic-button-stub').exists()).toBe(true); + + const img = mount(TextColumn, { + props: { config: { action: 'img', prop: 'img' }, row, index: 0 }, + }); + expect(img.find('img').attributes('src')).toBe(row.img); + + const anchor = mount(TextColumn, { + props: { config: { action: 'link', prop: 'url' }, row, index: 0 }, + }); + expect(anchor.find('a').attributes('href')).toBe(row.url); + + const tip = mount(TextColumn, { + props: { config: { action: 'tip', prop: 'url', buttonText: '查看' }, row, index: 0 }, + }); + expect(tip.text()).toContain('查看'); + + const tag = mount(TextColumn, { + props: { + config: { action: 'tag', prop: 'status', type: (v: string) => (v === 'ok' ? 'success' : 'info') }, + row, + index: 0, + }, + }); + expect(tag.find('.tmagic-tag-stub.success').exists()).toBe(true); + }); + + test('编辑态渲染 MForm 并响应 change', async () => { + const editState: Record = { 0: { name: 'old' } }; + const wrapper = mount(TextColumn, { + props: { + config: { type: 'text', prop: 'name', editInlineFormConfig: [{ name: 'name', type: 'text' }] }, + editState, + row: { name: 'old' }, + index: 0, + }, + }); + expect(wrapper.find('.mform-stub').exists()).toBe(true); + wrapper.vm.formChangeHandler({}, { changeRecords: [{ propPath: 'name', value: 'new' }] }); + expect(editState[0].name).toBe('new'); + }); + + test('默认分支输出 formatter 结果', () => { + const wrapper = mount(TextColumn, { + props: { config: { prop: 'name' }, row: { name: 'Tom' }, index: 0 }, + }); + expect(wrapper.text()).toContain('Tom'); + }); +}); + +describe('ActionsColumn.vue', () => { + const baseProps = () => ({ + columns: [], + config: { + actions: [ + { + type: 'edit', + text: '编辑', + display: (row: any) => row.visible !== false, + disabled: (row: any) => row.locked, + action: vi.fn(async () => ({ ret: 0 })), + cancel: vi.fn(), + }, + { + text: '删除', + handler: vi.fn(), + after: vi.fn(), + before: vi.fn(), + }, + ], + }, + row: { id: 1, visible: true, locked: false }, + index: 0, + editState: [] as any[], + }); + + test('display / disabled / formatter 辅助函数', () => { + const wrapper = mount(ActionsColumn, { props: baseProps() }); + expect(wrapper.vm.display(() => false, {})).toBe(false); + expect(wrapper.vm.display(true, {})).toBe(true); + expect(wrapper.vm.display(undefined, {})).toBe(true); + expect(wrapper.vm.disabled(() => true, {})).toBe(true); + expect(wrapper.vm.disabled(false, {})).toBe(false); + expect(wrapper.vm.formatter((row: any) => row.id, { id: 2 })).toBe(2); + expect(wrapper.vm.formatter('静态', {})).toBe('静态'); + }); + + test('编辑 / 保存 / 取消流程', async () => { + const props = baseProps(); + const wrapper = mount(ActionsColumn, { props }); + const editAction = props.config.actions[0]; + + await wrapper.vm.actionHandler(editAction, props.row, 0); + expect(props.editState[0]).toEqual(props.row); + + await wrapper.vm.save(0, props.config); + expect(tMagicMessage.success).toHaveBeenCalledWith('保存成功'); + expect(wrapper.emitted('after-action')?.[0]).toEqual([{ index: 0 }]); + + props.editState[0] = { id: 1 }; + await wrapper.vm.cancel(0, props.config); + expect(editAction.cancel).toHaveBeenCalledWith({ index: 0 }); + expect(wrapper.emitted('after-action-cancel')?.[0]).toEqual([{ index: 0 }]); + }); + + test('保存失败时提示错误', async () => { + const props = baseProps(); + props.config.actions[0].action = vi.fn(async () => ({ ret: 1, msg: '出错' })); + const wrapper = mount(ActionsColumn, { props }); + props.editState[0] = { id: 1 }; + await wrapper.vm.save(0, props.config); + expect(tMagicMessage.error).toHaveBeenCalledWith('出错'); + }); + + test('非 edit 类型 action 调用 handler', async () => { + const props = baseProps(); + const wrapper = mount(ActionsColumn, { props }); + const deleteAction = props.config.actions[1]; + await wrapper.vm.actionHandler(deleteAction, props.row, 0); + expect(deleteAction.before).toHaveBeenCalled(); + expect(deleteAction.handler).toHaveBeenCalledWith(props.row, 0); + expect(deleteAction.after).toHaveBeenCalled(); + }); +}); + +describe('ComponentColumn.vue', () => { + const innerComp = defineComponent({ + props: ['label'], + setup(p) { + return () => h('span', { class: 'inner' }, p.label); + }, + }); + + test('props / listeners 支持函数与对象', () => { + const onClick = vi.fn(); + const wrapper = mount(ComponentColumn, { + props: { + config: { + component: innerComp, + props: (row: any) => ({ label: row.name }), + listeners: (_row: any, index: number) => ({ click: () => onClick(index) }), + }, + row: { name: 'Cell' }, + index: 3, + }, + }); + expect(wrapper.find('.inner').text()).toBe('Cell'); + wrapper.vm.componentListeners({ name: 'Cell' }, 3).click(); + expect(onClick).toHaveBeenCalledWith(3); + + const wrapper2 = mount(ComponentColumn, { + props: { + config: { component: innerComp, props: { label: '静态' }, listeners: {} }, + row: {}, + index: 0, + }, + }); + expect(wrapper2.vm.componentProps({}, 0)).toEqual({ label: '静态' }); + expect(wrapper2.vm.componentListeners({}, 0)).toEqual({}); + }); +}); + +describe('ExpandColumn.vue', () => { + test('渲染嵌套表格 / 表单 / html / 组件', () => { + const titleComp = defineComponent({ + props: ['title'], + setup(p) { + return () => h('b', p.title); + }, + }); + + const wrapper = mount(ExpandColumn, { + props: { + config: { + table: [{ prop: 'a', label: 'A' }], + form: [{ name: 'a', type: 'text' }], + expandContent: (row: any) => `

${row.desc}

`, + component: titleComp, + props: (row: any) => ({ title: row.title }), + prop: 'children', + }, + row: { desc: 'detail', title: 'T', children: [{ a: 1 }] }, + }, + }); + + expect(wrapper.find('.tmagic-table-stub').exists()).toBe(true); + expect(wrapper.find('.mform-stub').exists()).toBe(true); + expect(wrapper.html()).toContain('detail'); + expect(wrapper.find('b').text()).toBe('T'); + }); +}); + +describe('PopoverColumn.vue', () => { + test('渲染 popover 与嵌套表格', () => { + const wrapper = mount(PopoverColumn, { + props: { + config: { + text: '查看', + prop: 'items', + popover: { tableEmbed: true, trigger: 'click', placement: 'top' }, + table: [{ prop: 'sub', label: '子项' }], + }, + row: { items: [{ sub: 'x' }] }, + index: 0, + }, + }); + expect(wrapper.find('.tmagic-popover-stub').exists()).toBe(true); + expect(wrapper.text()).toContain('查看'); + expect(wrapper.find('.tmagic-table-stub').exists()).toBe(true); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18d468e6..24c6fcd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -519,6 +519,9 @@ importers: '@types/lodash-es': specifier: ^4.17.4 version: 4.17.12 + '@vue/test-utils': + specifier: ^2.4.6 + version: 2.4.6 packages/tdesign-vue-next-adapter: dependencies: