test: 补充 editor、stage、table 单元测试覆盖

This commit is contained in:
roymondchen 2026-06-11 18:45:42 +08:00
parent 89cef4e9a9
commit 9fe10e274c
28 changed files with 3561 additions and 27 deletions

View File

@ -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<string, any> = {};
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<string, any>;
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');
});
});

View File

@ -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<any> => ({
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]);
});
});

View File

@ -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 } : {}),
},
],

View File

@ -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 } : {}),
},
],

View File

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

View File

@ -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<string, any> = 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');
});
});

View File

@ -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 });
});
});

View File

@ -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<string, any> = {};
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);
});
});

View File

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

View File

@ -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<IDBValidKey, unknown>;
interface FakeDb {
version: number;
stores: Map<string, StoreRecord>;
}
const fakeDbs = new Map<string, FakeDb>();
const createRequest = <T>(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<IDBValidKey, unknown>();
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();
});
});

View File

@ -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> = {}): 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();
});
});

View File

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

View File

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

View File

@ -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<Parameters<(typeof TestMoveableOptionsManager)['prototype']['constructor']>[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();
});
});

View File

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

View File

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

View File

@ -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>): 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<string, Function[]> = {};
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();
});
});

View File

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

View File

@ -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>): 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<typeof vi.fn>;
unobserve: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
},
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();
});
});

View File

@ -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<string, Function[]> = {};
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();
});
});

View File

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

View File

@ -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);
});
});

View File

@ -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');
});
});

View File

@ -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:*",

View File

@ -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}`,
});

View File

@ -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<string, unknown> = {}) =>
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 }]);
});
});

View File

@ -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<number, any> = { 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) => `<p>${row.desc}</p>`,
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);
});
});

3
pnpm-lock.yaml generated
View File

@ -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: