tmagic-editor/packages/editor/tests/unit/fields/headless-validation.spec.ts
roymondchen f2ee7ae7b5 feat(form): 统一字段值 effect 机制并将 nested 重命名为 innerConfig
将字段挂载时的 model 写入迁移至 registerField effect,
渲染与无渲染校验共用 applyMountValueEffects;
nested 重命名为 innerConfig 以区分配置派生与值写入。
2026-09-01 12:05:28 +08:00

339 lines
11 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 { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { NODE_CONDS_KEY } from '@tmagic/core';
import { editorFields } from '@editor/fields/headless-validation';
import { fillConfig } from '@editor/utils/props';
// 走 @form 源码别名而非 @tmagic/form编辑器包在单测里解析到的是 form 的构建产物
import {
applyMountValueEffects,
builtInFields,
clearFields,
collectValidatableFields,
createHeadlessFormState,
registerBuiltInFields,
registerFields,
} from '@form/index';
/**
* 无渲染校验只遍历 config 树,看不到复合字段在组件内部渲染出来的嵌套字段。
* 这些用例锁定嵌套配置交出来的 prop 路径——它必须与组件真实渲染出的 FormItem 路径一致,
* 否则 async-validator 会拿着错误的路径取值,校验形同虚设。
*/
const collect = (config: any[], values: any, typeMatchValid = true) => {
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
const fields = collectValidatableFields(formState, config, values, typeMatchValid);
return { props: fields.map((field) => field.prop) };
};
beforeAll(() => {
registerBuiltInFields(builtInFields);
registerFields(editorFields);
});
afterAll(() => {
clearFields();
});
describe('editorFields', () => {
test('不含 Vue 组件,供 Node 侧 registerFields', () => {
for (const [type, options] of Object.entries(editorFields)) {
expect(Object.keys(options), type).not.toContain('component');
}
});
});
describe('code-select', () => {
test('遍历内部钩子列表,路径为 <prop>.hookData.<index>.<name>', () => {
const config = [{ type: 'code-select', name: 'created', text: '钩子' }];
const values = {
created: {
hookType: 'code',
hookData: [
{ codeType: 'code', codeId: 'c1' },
{ codeType: 'dataSourceMethod', codeId: ['ds1', 'doFetch'] },
],
},
};
const { props } = collect(config, values);
expect(props).toEqual([
// typeMatchValid 会给字段自身补一条 typeMatch 规则,与渲染式一样有一个 FormItem
'created',
'created.hookData.0.codeType',
// codeType 决定第二列走代码块还是数据源方法,两者共用 codeId
'created.hookData.0.codeId',
'created.hookData.1.codeType',
'created.hookData.1.codeId',
]);
});
test('collect 只读,空值不会在收集字段时被改写', () => {
const config = [{ type: 'code-select', name: 'created' }];
const values: any = { created: [] };
collect(config, values);
expect(values.created).toEqual([]);
});
test('applyMountValueEffects 把空值改写成 { hookType, hookData }', () => {
const config = [{ type: 'code-select', name: 'created' }] as any;
const values: any = { created: [] };
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
applyMountValueEffects(formState, config, values);
expect(values.created).toEqual({ hookType: 'code', hookData: [] });
});
test('嵌套在容器里时带上父级路径', () => {
const config = [
{
type: 'panel',
name: 'hooks',
items: [{ type: 'code-select', name: 'created' }],
},
];
const values = { hooks: { created: { hookType: 'code', hookData: [{ codeType: 'code', codeId: 'c1' }] } } };
expect(collect(config, values).props).toEqual([
'hooks.created',
'hooks.created.hookData.0.codeType',
'hooks.created.hookData.0.codeId',
]);
});
});
describe('display-conds', () => {
test('展开条件列表,路径为 <prop>.<index>.cond.<index>.<name>', () => {
const config = [{ type: 'display-conds', name: 'displayConds' }];
const values = {
displayConds: [
{
cond: [
{ field: [], op: '', value: '' },
{ field: ['a'], op: 'between', range: [] },
],
},
],
};
const { props } = collect(config, values);
expect(props).toEqual([
'displayConds',
'displayConds.0.cond.0.field',
'displayConds.0.cond.0.op',
'displayConds.0.cond.0.value',
'displayConds.0.cond.1.field',
'displayConds.0.cond.1.op',
// op 为 between 时值列切到区间字段
'displayConds.0.cond.1.range',
]);
});
test('内部 group-list 复用字段自身的 name路径不会把 name 拼两次', () => {
const config = [
{
type: 'panel',
name: 'cond',
items: [{ type: 'display-conds', name: 'displayConds' }],
},
];
const values = { cond: { displayConds: [{ cond: [{ field: [], op: '' }] }] } };
expect(collect(config, values).props).toEqual([
'cond.displayConds',
'cond.displayConds.0.cond.0.field',
'cond.displayConds.0.cond.0.op',
'cond.displayConds.0.cond.0.value',
]);
});
});
describe('event-select', () => {
test('展开事件卡片与动作组,路径为 <prop>.<index>.actions.<index>.<name>', () => {
const config = [{ type: 'event-select', name: 'events' }];
const values = {
events: [
{
name: 'click',
actions: [{ actionType: 'comp', to: 'node_1', method: 'show' }],
},
],
};
const { props } = collect(config, values);
expect(props).toEqual([
'events',
'events.0.name',
'events.0.actions.0.actionType',
'events.0.actions.0.to',
'events.0.actions.0.method',
]);
});
test('动作类型为代码 / 数据源时按 display 切换到对应字段', () => {
const config = [{ type: 'event-select', name: 'events' }];
const values = {
events: [
{ name: 'click', actions: [{ actionType: 'code', codeId: '' }] },
{ name: 'click', actions: [{ actionType: 'data-source', dataSourceMethod: [] }] },
],
};
expect(collect(config, values).props).toEqual([
'events',
'events.0.name',
'events.0.actions.0.actionType',
'events.0.actions.0.codeId',
'events.1.name',
'events.1.actions.0.actionType',
'events.1.actions.0.dataSourceMethod',
]);
});
test('applyMountValueEffects 把非数组收成空数组', () => {
const config = [{ type: 'event-select', name: 'events' }] as any;
const values: any = {};
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
applyMountValueEffects(formState, config, values);
expect(values.events).toEqual([]);
});
test('旧数据格式(列表项没有 actions不含校验规则不产出字段', () => {
const config = [{ type: 'event-select', name: 'events' }];
const values = { events: [{ name: 'click', to: 'node_1', method: 'show' }] };
const { props } = collect(config, values);
// 只剩字段自身那条自动补上的 typeMatch 规则
expect(props).toEqual(['events']);
});
});
describe('style-setter', () => {
const styleField = { type: 'style-setter', name: 'style' };
test('按面板顺序遍历内部字段,路径挂在 style 下', () => {
const values = { style: { transform: {} } };
const { props } = collect([styleField], values);
expect(props).toEqual([
'style',
'style.display',
'style.width',
'style.height',
'style.overflow',
'style.opacity',
'style.position',
'style.left',
'style.top',
'style.right',
'style.bottom',
'style.zIndex',
'style.backgroundColor',
'style.backgroundImage',
'style.backgroundSize',
'style.backgroundRepeat',
'style.backgroundPosition',
'style.fontSize',
'style.lineHeight',
'style.fontWeight',
'style.color',
'style.textAlign',
'style.borderRadius',
'style.transform.rotate',
'style.transform.scale',
]);
});
test('display 为 flex 时展开主轴/辅轴/换行字段', () => {
const { props } = collect([styleField], { style: { display: 'flex' } });
expect(props).toEqual(
expect.arrayContaining(['style.flexDirection', 'style.justifyContent', 'style.alignItems', 'style.flexWrap']),
);
expect(props.indexOf('style.flexDirection')).toBeGreaterThan(props.indexOf('style.display'));
expect(props.indexOf('style.flexWrap')).toBeLessThan(props.indexOf('style.width'));
});
test('position 为 static 时不展开 left/top/right/bottom', () => {
const { props } = collect([styleField], { style: { position: 'static' } });
expect(props).toContain('style.position');
expect(props).not.toContain('style.left');
expect(props).not.toContain('style.top');
expect(props).not.toContain('style.right');
expect(props).not.toContain('style.bottom');
});
test('边框四向字段不展开Border 子组件未把 prop 传给 MContainer', () => {
const { props } = collect([styleField], { style: {} });
expect(props).not.toContain('style.borderWidth');
expect(props).not.toContain('style.borderTopWidth');
expect(props).not.toContain('style.marginTop');
});
});
describe('fillConfig 通用属性表单', () => {
test('默认注入的 tab 配置没有未知 type', () => {
const config = fillConfig([]);
const values = {
type: 'text',
id: '1',
name: '',
style: { transform: {} },
events: [],
created: { hookType: 'code', hookData: [] },
mounted: { hookType: 'code', hookData: [] },
display: { hookType: 'code', hookData: [] },
[NODE_CONDS_KEY]: [],
};
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
// 关掉独立样式面板,让 fillConfig 注入的 style tab 走 display从而覆盖 style-setter
(formState as any).services = { uiService: { get: (key: string) => key !== 'showStylePanel' } };
expect(() => collectValidatableFields(formState, config, values, true)).not.toThrow();
});
test('显示条件 tab 按 groupList 展开条件组与组内条件', () => {
const config = fillConfig([]);
const values = {
type: 'text',
id: '1',
name: '',
[NODE_CONDS_KEY]: [{ cond: [{ field: [], op: '', value: '' }] }],
};
const { props } = collect(config, values);
expect(props).toEqual(
expect.arrayContaining([
`${NODE_CONDS_KEY}.0.cond.0.field`,
`${NODE_CONDS_KEY}.0.cond.0.op`,
`${NODE_CONDS_KEY}.0.cond.0.value`,
]),
);
expect(props).not.toContain(`${NODE_CONDS_KEY}.${NODE_CONDS_KEY}`);
});
});