tmagic-editor/packages/form/tests/unit/Form.extra.spec.ts
roymondchen 08011efd6d refactor(form): 使用 getter 访问 props 字段并补充单元测试
- formState 中与 props 对应的字段改用 getter,避免 props 与 formState 之间的同步中间态
- 完善 extendState 同步段的响应式追踪说明注释
- 新增 Form.extra.spec.ts 覆盖 isCompare 模式与 config 变化场景
2026-05-26 11:51:34 +08:00

516 lines
15 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, beforeEach, describe, expect, test, vi } from 'vitest';
import { nextTick, ref } from 'vue';
import MagicForm, { MForm } from '@form/index';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
const mountForm = (props: Record<string, any> = {}, options: Record<string, any> = {}) =>
mount(MForm, {
global: {
plugins: [ElementPlus as any, MagicForm as any],
},
props: {
initValues: {},
config: [],
...props,
},
...options,
});
describe('Form.vue —— 默认 props', () => {
test('未传任何 props 时使用默认值,渲染不报错', async () => {
const wrapper = mountForm();
await nextTick();
expect(wrapper.find('.m-form').exists()).toBe(true);
expect(wrapper.vm.values).toEqual({});
expect(wrapper.vm.changeRecords).toEqual([]);
});
test('height/labelWidth 透传到样式与子表单', async () => {
const wrapper = mountForm({ height: '300px', labelWidth: '120px' });
await nextTick();
const formEl = wrapper.find('.m-form').element as HTMLElement;
expect(formEl.getAttribute('style') || '').toContain('height: 300px');
});
});
describe('Form.vue —— formState getter 行为', () => {
test('formState 的 keyProp / popperClass / config / initValues / isCompare / lastValues / parentValues 始终回读最新 props', async () => {
const wrapper = mountForm({
keyProp: 'id',
popperClass: 'pop-a',
isCompare: false,
initValues: { a: 1 },
lastValues: { a: 0 },
parentValues: { x: 1 },
config: [{ text: 'a', name: 'a' }],
});
await nextTick();
const fs1: any = wrapper.vm.formState;
expect(fs1.keyProp).toBe('id');
expect(fs1.popperClass).toBe('pop-a');
expect(fs1.isCompare).toBe(false);
expect(fs1.initValues).toEqual({ a: 1 });
expect(fs1.lastValues).toEqual({ a: 0 });
expect(fs1.parentValues).toEqual({ x: 1 });
expect(Array.isArray(fs1.config)).toBe(true);
// 修改 propsformState 上的 getter 应直接反映新值(无中间态)
await wrapper.setProps({
keyProp: 'uuid',
popperClass: 'pop-b',
isCompare: true,
parentValues: { x: 2 },
});
const fs2: any = wrapper.vm.formState;
expect(fs2.keyProp).toBe('uuid');
expect(fs2.popperClass).toBe('pop-b');
expect(fs2.isCompare).toBe(true);
expect(fs2.parentValues).toEqual({ x: 2 });
});
test('values / lastValuesProcessed 在 formState 上自动解包为 ref 当前值', async () => {
const wrapper = mountForm({
isCompare: true,
initValues: { a: '1' },
lastValues: { a: '2' },
config: [{ text: 'a', type: 'text', name: 'a' }],
});
await nextTick();
await nextTick();
await nextTick();
expect((wrapper.vm.formState as any).values).toEqual({ a: '1' });
expect((wrapper.vm.formState as any).lastValuesProcessed).toEqual({ a: '2' });
});
});
describe('Form.vue —— extendState', () => {
test('extendState 抛错时被 catch不影响表单渲染', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const extendState = vi.fn(async () => {
throw new Error('boom');
});
const wrapper = mountForm({
extendState,
config: [{ text: 'text', name: 'text', type: 'text' }],
});
await nextTick();
await nextTick();
expect(extendState).toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
expect(wrapper.find('.m-form').exists()).toBe(true);
errorSpy.mockRestore();
});
test('extendState 返回的普通字段被合并到 formState', async () => {
const wrapper = mountForm({
extendState: async () => ({ extra: 'hello', count: 42 }),
});
await nextTick();
await nextTick();
await nextTick();
expect((wrapper.vm.formState as any).extra).toBe('hello');
expect((wrapper.vm.formState as any).count).toBe(42);
});
test('extendState 返回的 accessor 描述符按原样定义并支持读时求值', async () => {
let counter = 0;
const wrapper = mountForm({
extendState: () =>
Object.defineProperties(
{},
{
stage: {
enumerable: true,
get() {
counter += 1;
return `stage-${counter}`;
},
},
},
),
});
await nextTick();
await nextTick();
await nextTick();
const v1 = (wrapper.vm.formState as any).stage;
const v2 = (wrapper.vm.formState as any).stage;
expect(v1).not.toEqual(v2);
expect(v1).toMatch(/^stage-/);
expect(v2).toMatch(/^stage-/);
});
test('extendState 同步段读到的响应式数据变化时会重跑', async () => {
const username = ref('alice');
const calls: string[] = [];
const wrapper = mountForm({
// 同步读取 ref会被 watchEffect 跟踪
extendState: (_state: any) => {
calls.push(username.value);
return { username: username.value };
},
});
await nextTick();
await nextTick();
expect((wrapper.vm.formState as any).username).toBe('alice');
username.value = 'bob';
await nextTick();
await nextTick();
await nextTick();
expect((wrapper.vm.formState as any).username).toBe('bob');
// 至少跑了两次(初始 + 响应变化)
expect(calls.length).toBeGreaterThanOrEqual(2);
});
test('未传 extendState 时 watchEffect 早退,不抛错', async () => {
const wrapper = mountForm({});
await nextTick();
expect(wrapper.find('.m-form').exists()).toBe(true);
});
});
describe('Form.vue —— resetForm / changeRecords', () => {
test('resetForm 会清空 changeRecords', async () => {
const wrapper = mountForm({
config: [{ text: 'text', type: 'text', name: 'text' }],
});
await nextTick();
wrapper.find('input').setValue('hi');
await nextTick();
expect(wrapper.vm.changeRecords.length).toBeGreaterThan(0);
wrapper.vm.resetForm();
await nextTick();
expect(wrapper.vm.changeRecords).toEqual([]);
});
});
describe('Form.vue —— submitForm 实例方法', () => {
test('校验通过返回 cloneDeep 后的 values', async () => {
const wrapper = mountForm({
config: [{ text: 'text', type: 'text', name: 'text' }],
initValues: { text: 'hi' },
});
await nextTick();
const result = await wrapper.vm.submitForm();
expect(result).toEqual({ text: 'hi' });
// 默认 cloneDeep应该不是同一引用
expect(result).not.toBe(wrapper.vm.values);
});
test('native=true 直接返回原 values 引用', async () => {
const wrapper = mountForm({
config: [{ text: 'text', type: 'text', name: 'text' }],
initValues: { text: 'hi' },
});
await nextTick();
const result = await wrapper.vm.submitForm(true);
expect(result).toBe(wrapper.vm.values);
});
test('校验失败时 emit error 并抛出汇总后的错误(错误信息中包含字段 text', async () => {
const wrapper = mountForm({
config: [
{
text: '名称',
type: 'text',
name: 'name',
},
],
initValues: { name: '' },
});
await nextTick();
// 替换 useTemplateRef 暴露的 validate写入 $.exposed 才能影响内部 setup 中的 tMagicFormRef.value
const tmForm = wrapper.findComponent({ name: 'TMForm' });
expect(tmForm.exists()).toBe(true);
const invalidFields = {
name: [{ field: 'name', message: '必填' }],
};
const { exposed } = (tmForm.vm as any).$;
exposed.validate = vi.fn().mockRejectedValue(invalidFields);
let caught: Error | null = null;
try {
await wrapper.vm.submitForm();
} catch (e: any) {
caught = e;
}
expect(caught).toBeInstanceOf(Error);
expect(caught!.message).toContain('名称');
expect(caught!.message).toContain('必填');
expect(wrapper.emitted('error')).toBeTruthy();
expect(wrapper.emitted('error')![0][0]).toEqual(invalidFields);
});
test('校验返回非 truetdesign 风格)时也走错误分支', async () => {
const wrapper = mountForm({
config: [{ text: '账号', type: 'text', name: 'account' }],
initValues: { account: '' },
});
await nextTick();
const tmForm = wrapper.findComponent({ name: 'TMForm' });
const invalidFields = {
account: [{ field: 'account', message: '不能为空' }],
};
const { exposed } = (tmForm.vm as any).$;
exposed.validate = vi.fn().mockResolvedValue(invalidFields);
let caught: Error | null = null;
try {
await wrapper.vm.submitForm();
} catch (e: any) {
caught = e;
}
expect(caught).toBeInstanceOf(Error);
expect(caught!.message).toContain('账号');
expect(caught!.message).toContain('不能为空');
});
test('校验失败但 invalidFields 中字段无对应 text 时回退使用 field/prop 名', async () => {
const wrapper = mountForm({
config: [{ text: 'a', type: 'text', name: 'a' }],
initValues: { a: '' },
});
await nextTick();
const tmForm = wrapper.findComponent({ name: 'TMForm' });
const { exposed } = (tmForm.vm as any).$;
exposed.validate = vi.fn().mockRejectedValue({
unknown: [{ field: '', message: '出错' }],
});
let caught: Error | null = null;
try {
await wrapper.vm.submitForm();
} catch (e: any) {
caught = e;
}
expect(caught).toBeInstanceOf(Error);
// field 为空 -> 用 propunknown
expect(caught!.message).toContain('unknown');
expect(caught!.message).toContain('出错');
});
});
describe('Form.vue —— getTextByName', () => {
let wrapper: ReturnType<typeof mountForm>;
beforeEach(async () => {
wrapper = mountForm({
config: [
{ text: '名称', type: 'text', name: 'name' },
{
name: 'object',
items: [
{ text: '内层名称', type: 'text', name: 'inner' },
{
name: 'nested',
items: [{ text: '深层', type: 'text', name: 'deep' }],
},
],
},
// 无 name 的容器items 应能继续被搜索
{
items: [{ text: '无名容器内字段', type: 'text', name: 'plain' }],
},
// text 非字符串
{ text: { foo: 'bar' } as any, type: 'text', name: 'nonString' },
],
});
await nextTick();
});
afterEach(() => {
wrapper.unmount();
});
test('单层名匹配', () => {
expect(wrapper.vm.getTextByName('name')).toBe('名称');
});
test('点分隔多层路径匹配', () => {
expect(wrapper.vm.getTextByName('object.inner')).toBe('内层名称');
expect(wrapper.vm.getTextByName('object.nested.deep')).toBe('深层');
});
test('无 name 容器的 items 也能被搜索到', () => {
expect(wrapper.vm.getTextByName('plain')).toBe('无名容器内字段');
});
test('找不到时返回 undefined', () => {
expect(wrapper.vm.getTextByName('not.exist')).toBeUndefined();
expect(wrapper.vm.getTextByName('object.unknown')).toBeUndefined();
});
test('text 非字符串时返回 undefined', () => {
expect(wrapper.vm.getTextByName('nonString')).toBeUndefined();
});
test('参数非法时返回 undefined', () => {
expect(wrapper.vm.getTextByName('')).toBeUndefined();
// @ts-expect-error 故意传非数组
expect(wrapper.vm.getTextByName('name', null)).toBeUndefined();
});
});
describe('Form.vue —— preventSubmitDefault', () => {
test('preventSubmitDefault=true 时 submit 事件 preventDefault 被调用', async () => {
const wrapper = mountForm({
config: [{ text: 'text', type: 'text', name: 'text' }],
preventSubmitDefault: true,
});
await nextTick();
const formEl = wrapper.find('.m-form').element as HTMLFormElement;
const evt = new Event('submit', { cancelable: true, bubbles: true });
const spy = vi.spyOn(evt, 'preventDefault');
formEl.dispatchEvent(evt);
expect(spy).toHaveBeenCalled();
});
test('preventSubmitDefault=false默认时不调用 preventDefault', async () => {
const wrapper = mountForm({
config: [{ text: 'text', type: 'text', name: 'text' }],
});
await nextTick();
const formEl = wrapper.find('.m-form').element as HTMLFormElement;
const evt = new Event('submit', { cancelable: true, bubbles: true });
const spy = vi.spyOn(evt, 'preventDefault');
formEl.dispatchEvent(evt);
expect(spy).not.toHaveBeenCalled();
});
});
describe('Form.vue —— isCompare 模式', () => {
test('isCompare=true 时 lastValuesProcessed 会被初始化', async () => {
const wrapper = mountForm({
isCompare: true,
config: [{ text: 'text', type: 'text', name: 'text' }],
initValues: { text: 'a' },
lastValues: { text: 'b' },
});
await nextTick();
await nextTick();
await nextTick();
expect(wrapper.vm.values.text).toBe('a');
expect(wrapper.vm.lastValuesProcessed.text).toBe('b');
expect(wrapper.vm.initialized).toBe(true);
});
test('isCompare=false 时 lastValuesProcessed 不会被填充', async () => {
const wrapper = mountForm({
isCompare: false,
config: [{ text: 'text', type: 'text', name: 'text' }],
initValues: { text: 'a' },
lastValues: { text: 'b' },
});
await nextTick();
await nextTick();
await nextTick();
expect(wrapper.vm.values.text).toBe('a');
expect(wrapper.vm.lastValuesProcessed).toEqual({});
expect(wrapper.vm.initialized).toBe(true);
});
});
describe('Form.vue —— config 变化', () => {
test('config 引用变化会重新初始化initialized 短暂置 false 后回 true', async () => {
const wrapper = mountForm({
config: [{ text: 'a', type: 'text', name: 'a' }],
initValues: { a: '1' },
});
await nextTick();
await nextTick();
expect(wrapper.vm.initialized).toBe(true);
await wrapper.setProps({
config: [{ text: 'b', type: 'text', name: 'b' }],
initValues: { b: '2' },
});
// 第一次 microtask 后还在重建
await nextTick();
await nextTick();
await nextTick();
expect(wrapper.vm.initialized).toBe(true);
expect(wrapper.vm.values).toHaveProperty('b');
});
test('config 变化会清空 changeRecords', async () => {
const wrapper = mountForm({
config: [{ text: 'a', type: 'text', name: 'a' }],
});
await nextTick();
wrapper.find('input').setValue('xx');
await nextTick();
expect(wrapper.vm.changeRecords.length).toBeGreaterThan(0);
await wrapper.setProps({
config: [{ text: 'b', type: 'text', name: 'b' }],
initValues: {},
});
await nextTick();
await nextTick();
expect(wrapper.vm.changeRecords).toEqual([]);
});
});