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

567 lines
18 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. 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.
*/
import { afterEach, beforeAll, describe, expect, test, vi } from 'vitest';
import { effect as dateEffect } from '@form/fields/Date/effect';
import { effect as dateTimeEffect } from '@form/fields/DateTime/effect';
import {
applyMountValueEffects,
builtInFields,
clearFields,
collectValidatableFields,
createHeadlessFormState,
type FieldMountValueEffectContext,
isFieldInnerConfigError,
registerBuiltInFields,
registerField,
} from '@form/index';
const apply = (config: any, values: any) => {
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
applyMountValueEffects(formState, config, values);
return values;
};
beforeAll(() => {
registerBuiltInFields(builtInFields);
});
afterEach(() => {
clearFields();
});
describe('applyMountValueEffects —— 内置字段的值初始化', () => {
test('display 的 initValue 会写入表单值', () => {
const values = apply([{ type: 'display', name: 'status', text: '状态', initValue: 'ready' }], {});
expect(values.status).toBe('ready');
});
test('date 按 valueFormat 归一化datetime 的非法值归一化为空字符串', () => {
const values = apply(
[
{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' },
{ type: 'datetime', name: 'end', text: '结束' },
{ type: 'datetime', name: 'meet', text: '会议', valueFormat: 'YYYY-MM-DD HH:mm:ss' },
],
{ start: '2021/07/17 15:37:00', end: new Date('nonsense'), meet: '2021/07/17 15:37:00' },
);
expect(values.start).toBe('2021-07-17');
expect(values.end).toBe('');
expect(values.meet).toBe('2021-07-17 15:37:00');
});
test('date / datetime 在 model 缺失时不抛错', () => {
const ctx = { config: { name: 'd' }, model: undefined, prop: 'd', values: {}, mForm: undefined } as any;
expect(() => dateEffect(ctx)).not.toThrow();
expect(() => dateTimeEffect(ctx)).not.toThrow();
});
test('number-range 的非数组值与 checkbox-group 的空值都被修正为空数组', () => {
const values = apply(
[
{ type: 'number-range', name: 'range', text: '区间' },
{ type: 'checkbox-group', name: 'tags', text: '标签' },
],
{ range: 'not-an-array' },
);
expect(values.range).toEqual([]);
expect(values.tags).toEqual([]);
});
test('重复执行结果不变(幂等)', () => {
const config = [
{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' },
{ type: 'display', name: 'status', text: '状态', initValue: 'ready' },
];
const values = apply(config, { start: '2021/07/17 15:37:00' });
const once = { ...values };
apply(config, values);
expect(values).toEqual(once);
});
test('config 不是数组时直接返回,不抛错', () => {
expect(() => applyMountValueEffects(undefined, {} as any, {})).not.toThrow();
});
test('mForm 缺省时也能执行', () => {
const values: any = { start: '2021/07/17 15:37:00' };
applyMountValueEffects(undefined, [{ type: 'date', name: 'start', text: '开始' }] as any, values);
expect(values.start).toBe('2021/07/17');
});
});
describe('applyMountValueEffects —— 不受 display 影响', () => {
test('display 为 false 的字段同样被规整', () => {
const values = apply([{ type: 'date', name: 'start', text: '开始', display: false }], {
start: '2021/07/17 15:37:00',
});
expect(values.start).toBe('2021/07/17');
});
test('display 为函数且返回 false 的字段同样被规整', () => {
const values = apply([{ type: 'date', name: 'start', text: '开始', display: () => false }], {
start: '2021/07/17 15:37:00',
});
expect(values.start).toBe('2021/07/17');
});
test('fieldset 勾选框未勾选时,内部字段同样被规整', () => {
const values = apply(
[
{
type: 'fieldset',
name: 'wrap',
expand: true,
checkbox: { name: 'value', trueValue: 1, falseValue: 0 },
items: [{ type: 'date', name: 'start', text: '开始' }],
},
],
{ wrap: { value: 0, start: '2021/07/17 15:37:00' } },
);
expect(values.wrap.start).toBe('2021/07/17');
});
test('display 为假的 tab 页内字段同样被规整', () => {
const values = apply(
[
{
type: 'tab',
items: [
{
title: '隐藏页',
display: false,
items: [{ type: 'date', name: 'start', text: '开始' }],
},
],
},
],
{ start: '2021/07/17 15:37:00' },
);
expect(values.start).toBe('2021/07/17');
});
test('display 为假的表格列,行内字段同样被规整', () => {
const values = apply(
[
{
type: 'table',
name: 'list',
items: [{ type: 'date', name: 'start', label: '开始', display: false }],
},
],
{ list: [{ start: '2021/07/17 15:37:00' }] },
);
expect(values.list[0].start).toBe('2021/07/17');
});
});
describe('applyMountValueEffects —— 登记字段', () => {
test('effect 收到完整 prop 路径与本次处理的表单值根对象', () => {
const seen: Array<Pick<FieldMountValueEffectContext, 'prop'> & { isRoot: boolean; model: any }> = [];
registerField('my-probe', {
effect: ({ prop, values, model }) => {
seen.push({ prop, isRoot: values === rootValues, model });
},
});
const rootValues: any = { wrap: { inner: 'v' } };
apply([{ name: 'wrap', items: [{ type: 'my-probe', name: 'inner', text: '内部' }] }], rootValues);
expect(seen).toHaveLength(1);
expect(seen[0].prop).toBe('wrap.inner');
expect(seen[0].isRoot).toBe(true);
expect(seen[0].model).toBe(rootValues.wrap);
});
test('业务登记的 effect 可覆盖内置字段', () => {
registerField('display', {
effect: ({ config, model }) => {
model[(config as any).name] = 'overridden';
},
});
const values = apply([{ type: 'display', name: 'status', text: '状态', initValue: 'ready' }], {});
expect(values.status).toBe('overridden');
});
test('复合字段同时登记 effect 与 innerConfig 时,先执行本字段 effect 再下钻', () => {
registerField('my-composite', {
effect: ({ config, model }) => {
const { name } = config as any;
if (model && !model[name]) {
model[name] = { start: '2021/07/17 15:37:00' };
}
},
innerConfig: ({ config, model }) => ({
config: { type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' },
model: model[(config as any).name],
}),
});
const values = apply([{ type: 'my-composite', name: 'wrap', text: '包裹' }], {});
expect(values.wrap.start).toBe('2021-07-17');
});
test('innerConfig 登记的复合字段,其内部叶子字段的 effect 也会执行', () => {
registerField('my-composite', {
innerConfig: ({ config, model }) => ({
config: { type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' },
model: model[(config as any).name],
}),
});
const values = apply([{ type: 'my-composite', name: 'wrap', text: '包裹' }], {
wrap: { start: '2021/07/17 15:37:00' },
});
expect(values.wrap.start).toBe('2021-07-17');
});
test('dynamic-field 的 defaultValue 按 prop 写入传入的值根对象', () => {
const values = apply(
[
{
name: 'wrap',
items: [
{
type: 'dynamic-field',
name: 'dynamic',
dynamicKey: 'kind',
returnFields: () => [{ name: 'extra', label: '附加', defaultValue: 'fallback' }],
},
],
},
],
{ wrap: { kind: 'a' } },
);
expect(values.wrap.dynamic.extra).toBe('fallback');
});
test('returnFields 异步时不写入,交由组件挂载后的 watch 处理', () => {
const values = apply(
[
{
type: 'dynamic-field',
name: 'dynamic',
dynamicKey: 'kind',
returnFields: async () => [{ name: 'extra', label: '附加', defaultValue: 'fallback' }],
},
],
{ kind: 'a' },
);
expect(values.dynamic).toBeUndefined();
});
});
/**
* table / group-list / dynamic tab 会把同一份 items 重复展开,遍历前会先预判这份 items
* 是否可能触发 effect。是否真的跳过靠「walkNode 是否求值过函数型 text」观测
* 真正遍历到该节点才会调用它,被跳过则一次都不会调用。
*/
describe('applyMountValueEffects —— 跳过不含 effect 的重复展开', () => {
const probe = () => vi.fn(() => '探针');
test('列里没有 effect 的表格不逐行展开', () => {
const text = probe();
apply(
[
{ type: 'table', name: 'list', items: [{ type: 'text', name: 'k', text }] },
{ type: 'date', name: 'day', text: '日期', valueFormat: 'YYYY-MM-DD' },
],
{ list: Array.from({ length: 5 }, (_, i) => ({ k: `k${i}` })), day: '2021/07/17 15:37:00' },
);
expect(text).not.toHaveBeenCalled();
});
test('列里有 effect 的表格照常逐行展开', () => {
const text = probe();
const values = apply(
[
{
type: 'table',
name: 'list',
items: [
{ type: 'text', name: 'k', text },
{ type: 'date', name: 'd', text: '日期', valueFormat: 'YYYY-MM-DD' },
],
},
],
{ list: [{ k: 'k0', d: '2021/07/17 15:37:00' }, { k: 'k1' }] },
);
expect(text).toHaveBeenCalled();
expect(values.list[0].d).toBe('2021-07-17');
});
test('group-list 形态同样按列配置预判', () => {
const text = probe();
apply([{ type: 'group-list', name: 'list', items: [{ type: 'text', name: 'k', text }] }], {
list: [{ k: 'k0' }, { k: 'k1' }],
});
expect(text).not.toHaveBeenCalled();
});
test('dynamic tab 的标签页内没有 effect 时不逐页展开', () => {
const text = probe();
apply([{ type: 'tab', name: 'panes', dynamic: true, items: [{ type: 'text', name: 'k', text }] }], {
panes: [{ k: 'k0' }, { k: 'k1' }],
});
expect(text).not.toHaveBeenCalled();
});
test('dynamic tab 的标签页内有 effect 时照常逐页展开', () => {
const values = apply(
[
{
type: 'tab',
name: 'panes',
dynamic: true,
items: [{ type: 'date', name: 'd', text: '日期', valueFormat: 'YYYY-MM-DD' }],
},
],
{ panes: [{ d: '2021/07/17 15:37:00' }, { d: '2021/07/18 15:37:00' }] },
);
expect(values.panes.map((pane: any) => pane.d)).toEqual(['2021-07-17', '2021-07-18']);
});
test('空列、hidden 列、container 型列都不会让预判误判为可能有 effect', () => {
const text = probe();
const values = apply(
[
{
type: 'table',
name: 'list',
items: [
null,
// hidden 只收集规则,不往下分派,内部字段本就不参与值初始化
{ type: 'hidden', name: 'h', items: [{ type: 'date', name: 'd', valueFormat: 'YYYY-MM-DD' }] },
{ type: 'container', name: 'c', items: [{ type: 'text', name: 'k', text }] },
],
},
],
{ list: [{ h: {}, d: '2021/07/17 15:37:00', c: { k: 'k0' } }] },
);
expect(text).not.toHaveBeenCalled();
expect(values.list[0].d).toBe('2021/07/17 15:37:00');
});
test('container 型列里的 effect 字段不会被跳过', () => {
const values = apply(
[
{
type: 'table',
name: 'list',
items: [{ type: 'container', items: [{ type: 'date', name: 'd', valueFormat: 'YYYY-MM-DD' }] }],
},
],
{ list: [{ d: '2021/07/17 15:37:00' }] },
);
expect(values.list[0].d).toBe('2021-07-17');
});
test('列的函数型 type 静态看不出来,不会被跳过', () => {
const values = apply(
[{ type: 'table', name: 'list', items: [{ type: () => 'date', name: 'd', valueFormat: 'YYYY-MM-DD' }] }],
{ list: [{ d: '2021/07/17 15:37:00' }] },
);
expect(values.list[0].d).toBe('2021-07-17');
});
test('列里嵌套多层后才出现的 effect 字段不会被跳过', () => {
const values = apply(
[
{
type: 'table',
name: 'list',
items: [
{
items: [{ items: [{ type: 'date', name: 'd', text: '日期', valueFormat: 'YYYY-MM-DD' }] }],
},
],
},
],
{ list: [{ d: '2021/07/17 15:37:00' }] },
);
expect(values.list[0].d).toBe('2021-07-17');
});
test('itemsFunction 按行生成的列不会被跳过', () => {
const values = apply(
[
{
type: 'table',
name: 'list',
items: [{ itemsFunction: () => [{ type: 'date', name: 'd', text: '日期', valueFormat: 'YYYY-MM-DD' }] }],
},
],
{ list: [{ k: 'k0', d: '2021/07/17 15:37:00' }] },
);
expect(values.list[0].d).toBe('2021-07-17');
});
test('另一形态的 tableItems 不参与值初始化,与遍历范围一致', () => {
const values = apply(
[
{
type: 'group-list',
name: 'list',
items: [{ type: 'text', name: 'k', text: 'k' }],
tableItems: [{ type: 'date', name: 'd', text: '日期', valueFormat: 'YYYY-MM-DD' }],
},
],
{ list: [{ k: 'k0', d: '2021/07/17 15:37:00' }] },
);
expect(values.list[0].d).toBe('2021/07/17 15:37:00');
});
test('列是 innerConfig 复合字段时不会被跳过:内部配置运行期才产生', () => {
const innerConfig = vi.fn(() => null);
registerField('my-composite', { innerConfig });
apply([{ type: 'table', name: 'list', items: [{ type: 'my-composite', name: 'wrap' }] }], {
list: [{ wrap: {} }],
});
expect(innerConfig).toHaveBeenCalled();
});
test('列是业务登记的容器时不会被跳过:遍历路径未知', () => {
const walk = vi.fn();
registerField('my-box', { walk });
apply([{ type: 'table', name: 'list', items: [{ type: 'my-box', name: 'box', items: [] }] }], {
list: [{ box: {} }],
});
expect(walk).toHaveBeenCalled();
});
});
describe('applyMountValueEffects —— 单个字段出错不影响整体', () => {
test('effect 抛错时记录并继续处理后续字段', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
registerField('my-boom', {
effect: () => {
throw new Error('boom');
},
});
const values = apply(
[
{ type: 'my-boom', name: 'bad', text: '坏字段' },
{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' },
],
{ start: '2021/07/17 15:37:00' },
);
expect(values.start).toBe('2021-07-17');
expect(spy.mock.calls[0][0]).toContain('[MForm] mount value effect for "my-boom" at "bad" failed:');
spy.mockRestore();
});
test('innerConfig 抛错时记录并继续,不像校验那样抛出', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
registerField('my-composite', {
innerConfig: () => {
throw new Error('boom');
},
});
const config = [
{ type: 'my-composite', name: 'wrap', text: '包裹' },
{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' },
];
const values = apply(config, { start: '2021/07/17 15:37:00' });
expect(values.start).toBe('2021-07-17');
expect(isFieldInnerConfigError(spy.mock.calls[0][0])).toBe(true);
spy.mockRestore();
});
test('collect 模式下 innerConfig 抛错仍然抛出 FieldInnerConfigError', () => {
registerField('my-composite', {
innerConfig: () => {
throw new Error('boom');
},
});
const config = [{ type: 'my-composite', name: 'wrap', text: '包裹' }] as any;
const formState = createHeadlessFormState({ config, initValues: {} });
expect(() => collectValidatableFields(formState, config, {})).toThrow(/FIELD_INNER_CONFIG|innerConfig/);
});
});
describe('collectValidatableFields —— 只读', () => {
test('收集字段不再改写表单值,值初始化由 applyMountValueEffects 负责', () => {
const config = [{ type: 'display', name: 'status', text: '状态', initValue: 'ready' }] as any;
const values: any = {};
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
collectValidatableFields(formState, config, values);
expect(values.status).toBeUndefined();
});
test('innerConfig 回调在 collect 时也不改写本字段的值', () => {
registerField('my-composite', {
effect: ({ config, model }) => {
model[(config as any).name] = { start: 'from-effect' };
},
innerConfig: ({ config, model }) => ({
config: { type: 'text', name: 'start', text: '开始' },
model: model[(config as any).name],
}),
});
const config = [{ type: 'my-composite', name: 'wrap', text: '包裹' }] as any;
const values: any = { wrap: {} };
const formState = createHeadlessFormState({ config, initValues: values });
formState.values = values;
collectValidatableFields(formState, config, values);
expect(values.wrap).toEqual({});
});
});