1
0
mirror of synced 2026-10-27 13:36:13 +08:00
Files
tmagic-editor/packages/editor/tests/unit/fields/EventSelect.spec.ts
roymondchen 94fae10ae0 feat(editor): 新增 SDK 层并将核心服务迁移至 sdk 目录
- 新增 packages/editor/src/sdk,通过 sdk.ts 对外导出
- 将 services 下的核心服务迁移至 sdk/services
- 工具函数拆分至 sdk/utils,校验逻辑迁移至 sdk/validate,新增 sdk/wiring
- services/sdk-instance.ts 负责在编辑器中实例化 SDK
- 补充 SDK 相关单元测试,新增 docs/api/editor/sdk.md 文档
2026-10-08 19:58:03 +08:00

470 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 EventSelect from '@editor/fields/EventSelect.vue';
const { editorService, dataSourceService, eventsService, codeBlockService, propsService } = vi.hoisted(() => ({
editorService: {
get: vi.fn(),
getNodeById: vi.fn(),
},
dataSourceService: {
get: vi.fn(),
getDataSourceById: vi.fn(),
getFormEvent: vi.fn(() => []),
},
eventsService: {
getEvent: vi.fn(() => [{ label: 'click', value: 'click' }]),
getMethod: vi.fn(() => [{ label: 'open', value: 'open' }]),
},
codeBlockService: {
getCodeDsl: vi.fn(() => ({ c1: {} })),
getEditStatus: vi.fn(() => true),
},
propsService: {
getDisabledCodeBlock: vi.fn(() => false),
getDisabledDataSource: vi.fn(() => false),
},
}));
vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ editorService, dataSourceService, eventsService, codeBlockService, propsService }),
}));
const mForm = {
services: { editorService, dataSourceService, eventsService, codeBlockService, propsService },
} as any;
vi.mock('@editor/utils/data-source', async () => {
const actual = await vi.importActual<any>('@editor/utils/data-source');
return { ...actual, getCascaderOptionsFromFields: vi.fn(() => []) };
});
let capturedConfig: any = null;
let capturedEventNameConfig: any = null;
vi.mock('@tmagic/form', async (importOriginal) => {
const actual = await importOriginal<any>();
return {
...actual,
defineFormItem: (cfg: any) => cfg,
MTable: defineComponent({
name: 'MTable',
props: ['model', 'config', 'name', 'size', 'disabled'],
emits: ['change'],
setup() {
return () => h('div', { class: 'fake-table' });
},
}),
MContainer: defineComponent({
name: 'MFormContainer',
props: ['config', 'model', 'lastValues', 'isCompare', 'disabled', 'size', 'prop'],
emits: ['change'],
setup(props) {
capturedEventNameConfig = props.config;
return () => h('div', { class: 'fake-form-container' });
},
}),
MGroupList: defineComponent({
name: 'MGroupList',
props: ['config', 'name', 'disabled', 'model', 'lastValues', 'isCompare', 'prop', 'size'],
emits: ['change'],
setup(props, { emit, slots }) {
capturedConfig = props.config;
return () => {
const events = props.model?.[props.name] || [];
const first = events[0];
return h(
'div',
{ class: 'fake-group-list', onClick: () => emit('change', events) },
first && slots.title
? [
slots.title({
model: first,
lastValues: props.lastValues?.[props.name]?.[0],
prop: `${props.prop}.0`,
index: 0,
title: '事件 1',
}),
]
: [],
);
};
},
}),
};
});
vi.mock('@tmagic/utils', async () => {
const actual = await vi.importActual<any>('@tmagic/utils');
return {
...actual,
DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX: 'ds_change_',
};
});
const baseProps = (extra: any = {}) => ({
config: { type: 'event-select', src: 'component' },
name: 'events',
prop: 'events',
model: { events: [] },
size: 'default',
disabled: false,
...extra,
});
const eventNameCfg = () => capturedEventNameConfig;
const actionsCfg = () => capturedConfig.items[0];
const mountEvent = (extra: any = {}) => mount(EventSelect, { props: baseProps(extra) as any });
describe('EventSelect', () => {
test('events 为空 isOldVersion=false 渲染 group-list', () => {
const wrapper = mount(EventSelect, { props: baseProps() as any });
expect(wrapper.find('.event-select-container').exists()).toBe(true);
expect(wrapper.findComponent({ name: 'MGroupList' }).exists()).toBe(true);
expect(wrapper.find('.fake-table').exists()).toBe(false);
expect(capturedConfig.type).toBe('group-list');
expect(capturedConfig.scrollLastItemIntoView).toBe(true);
expect(capturedConfig.header.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.sticky).toBe(true);
expect(capturedConfig.addButtonConfig.text).toBe('添加事件');
expect(capturedConfig.defaultAdd).toEqual({ name: '', actions: [] });
expect(capturedConfig.movable).toBe(false);
expect(capturedConfig.items[0].scrollLastItemIntoView).toBe(true);
expect(capturedConfig.items[0].header.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.sticky).toBe(true);
expect(capturedConfig.items[0].addButtonConfig.text).toBe('新增动作');
});
test('group-list change 向外 emit', async () => {
const wrapper = mount(EventSelect, { props: baseProps() as any });
await wrapper.findComponent({ name: 'MGroupList' }).vm.$emit('change', [], { modifyKey: 'foo' });
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual([]);
});
test('title 里改事件名仍抛出事件列表,而不是单条', async () => {
const events = [{ name: 'click', actions: [] }];
const wrapper = mountEvent({ model: { events } });
await wrapper.findComponent({ name: 'MFormContainer' }).vm.$emit('change', { name: 'click' }, {});
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual(events);
});
test('events 含 actions 字段时不算 oldVersion,渲染 group-list', () => {
const wrapper = mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
expect(wrapper.findComponent({ name: 'MGroupList' }).exists()).toBe(true);
});
test('events 不含 actions 字段时为 oldVersion,渲染 table', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a' }] },
}) as any,
});
expect(wrapper.find('.fake-table').exists()).toBe(true);
});
test('Table change emit', async () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a' }] },
}) as any,
});
await wrapper.findComponent({ name: 'MTable' }).vm.$emit('change', null, { modifyKey: 'foo' });
expect(wrapper.emitted('change')).toBeTruthy();
});
test('eventNameConfig type/options src=component 返回 select', () => {
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
expect(cfg.type(mForm, { formValue: { type: 'btn' } })).toBe('select');
const opts = cfg.options(mForm, { formValue: { type: 'btn' } });
expect(Array.isArray(opts)).toBe(true);
expect(opts[0]).toMatchObject({ text: 'click', value: 'click' });
});
test('eventNameConfig.rules 仅校验事件名是否在可选项中', () => {
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
const [rule] = cfg.rules;
const okCb = vi.fn();
rule.validator({ value: 'click', callback: okCb }, { formValue: { type: 'btn' } }, mForm);
expect(okCb).toHaveBeenCalledWith();
const errCb = vi.fn();
rule.validator({ value: 'unknown', callback: errCb }, { formValue: { type: 'btn' } }, mForm);
expect(errCb).toHaveBeenCalledWith('事件名(unknown)不存在');
// 空值不做枚举校验
const emptyCb = vi.fn();
rule.validator({ value: '', callback: emptyCb }, { formValue: { type: 'btn' } }, mForm);
expect(emptyCb).toHaveBeenCalledWith();
});
test('eventNameConfig.rules 自定义 options 时跳过枚举', () => {
mountEvent({
config: { type: 'event-select', src: 'component', eventNameConfig: { options: () => [{ value: 'x' }] } },
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
const [rule] = cfg.rules;
const cb = vi.fn();
rule.validator({ value: 'whatever', callback: cb }, { formValue: { type: 'btn' } }, mForm);
expect(cb).toHaveBeenCalledWith();
});
test('eventNameConfig type 当 page-fragment 且有 pageFragmentId 返回 cascader', () => {
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [] }] });
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
expect(cfg.type(mForm, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } })).toBe('cascader');
const opts = cfg.options(mForm, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } });
expect(Array.isArray(opts)).toBe(true);
});
test('eventNameConfig src=datasource 返回事件 + 数据变化字段', () => {
dataSourceService.getDataSourceById.mockReturnValue({ fields: [{ name: 'f1' }] });
mountEvent({
config: { type: 'event-select', src: 'datasource' },
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
const opts = cfg.options(mForm, { formValue: { type: 'ds', id: 'd1' } });
expect(opts).toEqual([{ label: '数据变化', value: 'ds_change_', children: [] }]);
});
test('eventNameConfig src=datasource 无 fields 时返回原始事件', () => {
dataSourceService.getDataSourceById.mockReturnValue({ fields: [] });
mountEvent({
config: { type: 'event-select', src: 'datasource' },
model: { events: [{ name: 'a', actions: [] }] },
});
const cfg = eventNameCfg();
const opts = cfg.options(mForm, { formValue: { type: 'ds', id: 'd1' } });
expect(opts).toEqual([]);
});
test('表单上下文没有 services 时安全降级,字段不抛错', () => {
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const nameCfg = eventNameCfg();
// 没有 services 时选项为空,回调不会抛错
expect(nameCfg.options(undefined, { formValue: { type: 'btn' } })).toEqual([]);
const cb = vi.fn();
nameCfg.rules[0].validator({ value: 'x', callback: cb }, { formValue: { type: 'btn' } }, undefined);
expect(cb).toHaveBeenCalledWith();
const [actionType, , compAction, codeAction, dsAction] = actionsCfg().items;
// 没有 services 时只保留组件联动
expect(actionType.options(undefined).map((o: any) => o.value)).toEqual(['comp']);
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('select');
expect(compAction.options(undefined, { model: { to: '1' } })).toEqual([]);
const cb2 = vi.fn();
compAction.rules[0].validator({ value: 'x', callback: cb2 }, { model: { to: '1' } }, undefined);
expect(cb2).toHaveBeenCalledWith();
// 没有 services 时代码块 / 数据源联动只读
expect(codeAction.notEditable(undefined)).toBe(true);
expect(dsAction.notEditable(undefined)).toBe(true);
});
test('actionTypeConfig 含 组件/代码/数据源', () => {
propsService.getDisabledCodeBlock.mockReturnValue(false);
propsService.getDisabledDataSource.mockReturnValue(false);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const actionType = actionsCfg().items[0];
const opts = typeof actionType.options === 'function' ? actionType.options(mForm) : actionType.options;
expect(opts.map((o: any) => o.value).sort()).toEqual(['code', 'comp', 'data-source'].sort());
});
test('actionTypeConfig disabledCodeBlock/disabledDataSource 时不包含选项', () => {
propsService.getDisabledCodeBlock.mockReturnValue(true);
propsService.getDisabledDataSource.mockReturnValue(true);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const actionType = actionsCfg().items[0];
const opts = typeof actionType.options === 'function' ? actionType.options(mForm) : actionType.options;
expect(opts.map((o: any) => o.value)).toEqual(['comp']);
propsService.getDisabledCodeBlock.mockReturnValue(false);
propsService.getDisabledDataSource.mockReturnValue(false);
});
test('targetCompConfig display/onChange', () => {
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const target = actionsCfg().items[1];
expect(target.display(undefined, { model: { actionType: 'comp' } })).toBe(true);
const setModel = vi.fn();
target.onChange(undefined, undefined, { setModel });
expect(setModel).toHaveBeenCalledWith('method', '');
});
test('compActionConfig 解析 type/options', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
expect(compAction.type(mForm, { model: { to: '1' } })).toBe('select');
expect(Array.isArray(compAction.options(mForm, { model: { to: '1' } }))).toBe(true);
});
test('compActionConfig type cascader 当 page-fragment-container', () => {
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
expect(compAction.type(mForm, { model: { to: '1' } })).toBe('cascader');
const opts = compAction.options(mForm, { model: { to: '1' } });
expect(Array.isArray(opts)).toBe(true);
});
test('compActionConfig options 当 node 无 type 返回空数组', () => {
editorService.getNodeById.mockReturnValue(null);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
expect(compAction.options(mForm, { model: { to: 'unknown' } })).toEqual([]);
});
test('compActionConfig.rules 仅校验动作名是否在可选项中', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
eventsService.getMethod.mockReturnValue([{ label: 'open', value: 'open' }]);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
const [rule] = compAction.rules;
const okCb = vi.fn();
rule.validator({ value: 'open', callback: okCb }, { model: { to: '1' } }, mForm);
expect(okCb).toHaveBeenCalledWith();
const errCb = vi.fn();
rule.validator({ value: 'unknown', callback: errCb }, { model: { to: '1' } }, mForm);
expect(errCb).toHaveBeenCalledWith('动作名(unknown)不存在');
const emptyCb = vi.fn();
rule.validator({ value: '', callback: emptyCb }, { model: { to: '1' } }, mForm);
expect(emptyCb).toHaveBeenCalledWith();
});
test('compActionConfig.rules 自定义 options 时跳过枚举', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn', id: '1' });
mountEvent({
config: {
type: 'event-select',
src: 'component',
compActionConfig: { options: () => [{ text: 'x', value: 'x' }] },
},
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
const [rule] = compAction.rules;
const cb = vi.fn();
rule.validator({ value: 'whatever', callback: cb }, { model: { to: '1' } }, mForm);
expect(cb).toHaveBeenCalledWith();
});
test('compActionConfig.rules 页面片 cascader 支持数组 method', () => {
editorService.getNodeById.mockReturnValue({ type: 'page-fragment-container', id: '1', pageFragmentId: 'pf1' });
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [{ id: 'c1', type: 'btn', name: 'b' }] }] });
eventsService.getMethod.mockReturnValue([{ label: 'open', value: 'open' }]);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const compAction = actionsCfg().items[2];
const [rule] = compAction.rules;
const okCb = vi.fn();
rule.validator({ value: ['c1', 'open'], callback: okCb }, { model: { to: '1' } }, mForm);
expect(okCb).toHaveBeenCalledWith();
const errCb = vi.fn();
rule.validator({ value: ['c1', 'missing'], callback: errCb }, { model: { to: '1' } }, mForm);
expect(errCb).toHaveBeenCalledWith('动作名(c1.missing)不存在');
});
test('codeActionConfig display/notEditable', () => {
codeBlockService.getEditStatus.mockReturnValue(false);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const codeAction = actionsCfg().items[3];
expect(codeAction.display(undefined, { model: { actionType: 'code' } })).toBe(true);
expect(codeAction.notEditable(mForm)).toBe(true);
codeBlockService.getEditStatus.mockReturnValue(true);
});
test('dataSourceActionConfig display/notEditable', () => {
dataSourceService.get.mockReturnValue(false);
mountEvent({
model: { events: [{ name: 'a', actions: [] }] },
});
const dsAction = actionsCfg().items[4];
expect(dsAction.display(undefined, { model: { actionType: 'data-source' } })).toBe(true);
expect(dsAction.notEditable(mForm)).toBe(true);
});
test('table 配置中 method options', () => {
editorService.getNodeById.mockReturnValue({ type: 'btn' });
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a' }] },
}) as any,
});
const tableCfg = wrapper.findComponent({ name: 'MTable' }).props('config') as any;
const methodCol = tableCfg.items.find((it: any) => it.name === 'method');
const opts = methodCol.options(mForm, { model: { to: '1' } });
expect(opts).toEqual([{ text: 'open', value: 'open' }]);
editorService.getNodeById.mockReturnValue(null);
expect(methodCol.options(mForm, { model: { to: '1' } })).toEqual([]);
});
describe('对比模式', () => {
test('isCompare 但无 lastValues 时不进入对比', () => {
const wrapper = mount(EventSelect, {
props: baseProps({ isCompare: true, model: { events: [] } }) as any,
});
expect(wrapper.findComponent({ name: 'MGroupList' }).props('isCompare')).toBe(false);
});
test('对比模式向内部透传 isCompare 与 lastValues', () => {
const lastValues = { events: [{ name: 'a', actions: [] }] };
const wrapper = mount(EventSelect, {
props: baseProps({
isCompare: true,
model: { events: [{ name: 'a', actions: [] }] },
lastValues,
}) as any,
});
const list = wrapper.findComponent({ name: 'MGroupList' });
expect(list.props('isCompare')).toBe(true);
expect(list.props('lastValues')).toEqual(lastValues);
});
});
});