roymondchen cbc4b25072 feat(editor): 字段对比模式逐项展示差异并补充历史记录面板文档
- CodeSelect/CodeSelectCol/EventSelect/DataSource 等复合字段在对比模式下
  按索引对齐前后值,逐项展示新增/删除/修改高亮,并隐藏写操作按钮
- form 容器/列表/表格支持对比模式只读展示
- 新增「历史记录面板」指南文档,完善表单对比文档及 menu props 说明
- 补充相关单元测试

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 15:51:47 +08:00

421 lines
16 KiB
TypeScript
Raw 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 = {
get: vi.fn(),
getNodeById: vi.fn(),
};
const dataSourceService = {
get: vi.fn(),
getDataSourceById: vi.fn(),
getFormEvent: vi.fn(() => []),
};
const eventsService = {
getEvent: vi.fn(() => [{ label: 'click', value: 'click' }]),
getMethod: vi.fn(() => [{ label: 'open', value: 'open' }]),
};
const codeBlockService = {
getCodeDsl: vi.fn(() => ({ c1: {} })),
getEditStatus: vi.fn(() => true),
};
const propsService = {
getDisabledCodeBlock: vi.fn(() => false),
getDisabledDataSource: vi.fn(() => false),
};
vi.mock('@editor/hooks/use-services', () => ({
useServices: () => ({ editorService, dataSourceService, eventsService, codeBlockService, propsService }),
}));
vi.mock('@editor/utils', async () => {
const actual = await vi.importActual<any>('@editor/utils');
return { ...actual, getCascaderOptionsFromFields: vi.fn(() => []) };
});
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' });
},
}),
MPanel: defineComponent({
name: 'MPanel',
props: ['model', 'config', 'prop', 'disabled', 'size', 'labelWidth', 'lastValues', 'isCompare'],
emits: ['change'],
setup(_p, { slots }) {
return () => h('div', { class: 'fake-panel' }, slots.header?.());
},
}),
MContainer: defineComponent({
name: 'MFormContainer',
props: ['model', 'config', 'prop', 'disabled', 'size'],
emits: ['change'],
setup() {
return () => h('div', { class: 'fake-container' });
},
}),
};
});
vi.mock('@tmagic/design', () => ({
TMagicButton: defineComponent({
name: 'TMagicButton',
props: ['type', 'size', 'disabled', 'icon', 'link'],
emits: ['click'],
setup(_p, { emit, slots }) {
return () => h('button', { onClick: () => emit('click') }, slots.default?.());
},
}),
}));
vi.mock('@tmagic/utils', async () => {
const actual = await vi.importActual<any>('@tmagic/utils');
return {
...actual,
DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX: 'ds_change_',
traverseNode: (node: any, fn: any) => {
fn(node);
node.items?.forEach((c: any) => fn(c));
},
};
});
const baseProps = (extra: any = {}) => ({
config: { type: 'event-select', src: 'component' },
name: 'events',
prop: 'events',
model: { events: [] },
size: 'default',
disabled: false,
...extra,
});
describe('EventSelect', () => {
test('events 为空 isOldVersion=false 显示新版按钮', () => {
const wrapper = mount(EventSelect, { props: baseProps() as any });
expect(wrapper.find('.create-button').exists()).toBe(true);
expect(wrapper.find('.fake-table').exists()).toBe(false);
});
test('addEvent emit 事件并携带 modifyKey', async () => {
const wrapper = mount(EventSelect, { props: baseProps() as any });
await wrapper.find('.create-button').trigger('click');
const evts = wrapper.emitted('change');
expect((evts?.[0]?.[0] as any).name).toBe('');
});
test('removeEvent 删除指定 index', async () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const buttons = wrapper.findAll('button');
const lastBtn = buttons[buttons.length - 1];
await lastBtn.trigger('click');
const evts = wrapper.emitted('change');
expect(evts).toBeTruthy();
});
test('events 含 actions 字段时不算 oldVersion渲染 panel', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
expect(wrapper.findAll('.fake-panel').length).toBe(1);
});
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('Panel header MFormContainer change emit', async () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
await wrapper.findComponent({ name: 'MFormContainer' }).vm.$emit('change', null, { modifyKey: 'name' });
expect(wrapper.emitted('change')).toBeTruthy();
});
test('addEvent 在 model[name] 为空时初始化', async () => {
const m: any = { events: [] };
const wrapper = mount(EventSelect, { props: baseProps({ model: m }) as any });
await wrapper.find('.create-button').trigger('click');
const evts = wrapper.emitted('change');
expect(evts).toBeTruthy();
expect((evts?.[0]?.[0] as any).actions).toEqual([]);
});
test('eventNameConfig type/options src=component 返回 select', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
expect(cfg.type(undefined, { formValue: { type: 'btn' } })).toBe('select');
const opts = cfg.options(undefined, { formValue: { type: 'btn' } });
expect(Array.isArray(opts)).toBe(true);
expect(opts[0]).toMatchObject({ text: 'click', value: 'click' });
});
test('eventNameConfig type 当 page-fragment 且有 pageFragmentId 返回 cascader', () => {
editorService.get.mockReturnValue({ items: [{ id: 'pf1', items: [] }] });
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
expect(cfg.type(undefined, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } })).toBe(
'cascader',
);
const opts = cfg.options(undefined, { formValue: { type: 'page-fragment-container', pageFragmentId: 'pf1' } });
expect(Array.isArray(opts)).toBe(true);
});
test('eventNameConfig src=datasource 返回事件 + 数据变化字段', () => {
dataSourceService.getDataSourceById.mockReturnValue({ fields: [{ name: 'f1' }] });
const wrapper = mount(EventSelect, {
props: baseProps({
config: { type: 'event-select', src: 'datasource' },
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } });
expect(opts).toEqual([{ label: '数据变化', value: 'ds_change_', children: [] }]);
});
test('eventNameConfig src=datasource 无 fields 时返回原始事件', () => {
dataSourceService.getDataSourceById.mockReturnValue({ fields: [] });
const wrapper = mount(EventSelect, {
props: baseProps({
config: { type: 'event-select', src: 'datasource' },
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const cfg = wrapper.findComponent({ name: 'MFormContainer' }).props('config') as any;
const opts = cfg.options(undefined, { formValue: { type: 'ds', id: 'd1' } });
expect(opts).toEqual([]);
});
test('actionTypeConfig 含 组件/代码/数据源', () => {
propsService.getDisabledCodeBlock.mockReturnValue(false);
propsService.getDisabledDataSource.mockReturnValue(false);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const groupItems = panelCfg.items[0].items;
const actionType = groupItems[0];
const opts = 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);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const actionType = panelCfg.items[0].items[0];
const opts = actionType.options();
expect(opts.map((o: any) => o.value)).toEqual(['comp']);
propsService.getDisabledCodeBlock.mockReturnValue(false);
propsService.getDisabledDataSource.mockReturnValue(false);
});
test('targetCompConfig display/onChange', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const target = panelCfg.items[0].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' });
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const compAction = panelCfg.items[0].items[2];
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('select');
expect(Array.isArray(compAction.options(undefined, { 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' }] }] });
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const compAction = panelCfg.items[0].items[2];
expect(compAction.type(undefined, { model: { to: '1' } })).toBe('cascader');
const opts = compAction.options(undefined, { model: { to: '1' } });
expect(Array.isArray(opts)).toBe(true);
});
test('compActionConfig options 当 node 无 type 返回空数组', () => {
editorService.getNodeById.mockReturnValue(null);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const compAction = panelCfg.items[0].items[2];
expect(compAction.options(undefined, { model: { to: 'unknown' } })).toEqual([]);
});
test('codeActionConfig display/notEditable', () => {
codeBlockService.getEditStatus.mockReturnValue(false);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const codeAction = panelCfg.items[0].items[3];
expect(codeAction.display(undefined, { model: { actionType: 'code' } })).toBe(true);
expect(codeAction.notEditable()).toBe(true);
codeBlockService.getEditStatus.mockReturnValue(true);
});
test('dataSourceActionConfig display/notEditable', () => {
dataSourceService.get.mockReturnValue(false);
const wrapper = mount(EventSelect, {
props: baseProps({
model: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
const panelCfg = wrapper.findComponent({ name: 'MPanel' }).props('config') as any;
const dsAction = panelCfg.items[0].items[4];
expect(dsAction.display(undefined, { model: { actionType: 'data-source' } })).toBe(true);
expect(dsAction.notEditable()).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(undefined, { model: { to: '1' } });
expect(opts).toEqual([{ text: 'open', value: 'open' }]);
editorService.getNodeById.mockReturnValue(null);
expect(methodCol.options(undefined, { model: { to: '1' } })).toEqual([]);
});
describe('对比模式', () => {
test('isCompare 但无 lastValues 时不进入对比,仍显示添加按钮', () => {
const wrapper = mount(EventSelect, {
props: baseProps({ isCompare: true, model: { events: [] } }) as any,
});
expect(wrapper.find('.create-button').exists()).toBe(true);
});
test('对比模式隐藏「添加事件」与删除按钮', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
isCompare: true,
model: { events: [{ name: 'a', actions: [] }] },
lastValues: { events: [{ name: 'a', actions: [] }] },
}) as any,
});
expect(wrapper.find('.create-button').exists()).toBe(false);
// 对比模式 panel header 内不渲染删除按钮(仅 MFormContainer 占位)
expect(wrapper.findAll('button').length).toBe(0);
});
test('对比模式按索引对齐当前值与历史值,取最大长度渲染', () => {
const wrapper = mount(EventSelect, {
props: baseProps({
isCompare: true,
model: { events: [{ name: 'a', actions: [] }] },
lastValues: {
events: [
{ name: 'a', actions: [] },
{ name: 'b', actions: [] },
],
},
}) as any,
});
// 当前 1 项 + 历史 2 项 → 取 max=2渲染 2 个 panel含被删除的事件
expect(wrapper.findAll('.fake-panel').length).toBe(2);
const panels = wrapper.findAllComponents({ name: 'MPanel' });
expect(panels[0].props('isCompare')).toBe(true);
// 缺失一侧用空对象兜底
expect(panels[1].props('model')).toEqual({});
expect(panels[1].props('lastValues')).toEqual({ name: 'b', actions: [] });
});
});
test('removeEvent 通过 panel header 删除按钮调用', async () => {
const m: any = {
events: [
{ name: 'a', actions: [] },
{ name: 'b', actions: [] },
],
};
const wrapper = mount(EventSelect, { props: baseProps({ model: m }) as any });
const buttons = wrapper.findAll('button');
const deleteBtn = buttons[buttons.length - 1];
await deleteBtn.trigger('click');
expect(m.events.length).toBe(1);
});
});