mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-06-14 02:18:16 +08:00
test: 补充 editor、stage、table 单元测试覆盖
This commit is contained in:
parent
89cef4e9a9
commit
9fe10e274c
230
packages/editor/tests/unit/components/CompareForm.spec.ts
Normal file
230
packages/editor/tests/unit/components/CompareForm.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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]);
|
||||
});
|
||||
});
|
||||
@ -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 } : {}),
|
||||
},
|
||||
],
|
||||
|
||||
@ -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 } : {}),
|
||||
},
|
||||
],
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
150
packages/editor/tests/unit/utils/indexed-db.spec.ts
Normal file
150
packages/editor/tests/unit/utils/indexed-db.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
257
packages/stage/tests/unit/DragResizeHelper.spec.ts
Normal file
257
packages/stage/tests/unit/DragResizeHelper.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
52
packages/stage/tests/unit/MoveableActionsAble.spec.ts
Normal file
52
packages/stage/tests/unit/MoveableActionsAble.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
108
packages/stage/tests/unit/MoveableOptionsManager.spec.ts
Normal file
108
packages/stage/tests/unit/MoveableOptionsManager.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
102
packages/stage/tests/unit/Rule.spec.ts
Normal file
102
packages/stage/tests/unit/Rule.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
256
packages/stage/tests/unit/StageCore.spec.ts
Normal file
256
packages/stage/tests/unit/StageCore.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
238
packages/stage/tests/unit/StageDragResize.spec.ts
Normal file
238
packages/stage/tests/unit/StageDragResize.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
92
packages/stage/tests/unit/StageHighlight.spec.ts
Normal file
92
packages/stage/tests/unit/StageHighlight.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
174
packages/stage/tests/unit/StageMask.spec.ts
Normal file
174
packages/stage/tests/unit/StageMask.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
233
packages/stage/tests/unit/StageMultiDragResize.spec.ts
Normal file
233
packages/stage/tests/unit/StageMultiDragResize.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
194
packages/stage/tests/unit/StageRender.spec.ts
Normal file
194
packages/stage/tests/unit/StageRender.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
87
packages/stage/tests/unit/TargetShadow.spec.ts
Normal file
87
packages/stage/tests/unit/TargetShadow.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
33
packages/stage/tests/unit/logger.spec.ts
Normal file
33
packages/stage/tests/unit/logger.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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:*",
|
||||
|
||||
102
packages/table/test-support/design.mock.ts
Normal file
102
packages/table/test-support/design.mock.ts
Normal 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}`,
|
||||
});
|
||||
186
packages/table/tests/Table.spec.ts
Normal file
186
packages/table/tests/Table.spec.ts
Normal 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 }]);
|
||||
});
|
||||
});
|
||||
270
packages/table/tests/columns.spec.ts
Normal file
270
packages/table/tests/columns.spec.ts
Normal 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
3
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user