mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-11-07 18:40:43 +00:00
- MForm 新增 formKey,数据实体切换时只重挂叶子字段,保留 tab 等容器状态;异步 initValue 仅采用最后一次结果 - 代码块 / 数据源方法参数配置改为按 model 派生,避免 setup 期快照残留旧节点配置 - 补充相关单测与 form-props 文档
1109 lines
35 KiB
TypeScript
1109 lines
35 KiB
TypeScript
/*
|
||
* 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 { computed, defineComponent, h, nextTick, provide, ref } from 'vue';
|
||
import { mount } from '@vue/test-utils';
|
||
import ElementPlus from 'element-plus';
|
||
|
||
import MagicForm, { FORM_CONTEXT_KEY, MForm } from '@form/index';
|
||
|
||
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');
|
||
});
|
||
|
||
test('labelPosition 与 labelWidth 一样,未配置时回落到表单级,配置了则用自身', async () => {
|
||
const wrapper = mountForm({
|
||
labelPosition: 'left',
|
||
labelWidth: '120px',
|
||
config: [
|
||
{ name: 'a', type: 'text', text: 'a' },
|
||
{ name: 'b', type: 'text', text: 'b', labelPosition: 'top' },
|
||
],
|
||
initValues: { a: '1', b: '2' },
|
||
});
|
||
await nextTick();
|
||
|
||
const containers = wrapper.findAllComponents({ name: 'MFormContainer' });
|
||
const byName = Object.fromEntries(containers.map((c) => [c.props('config')?.name, c]));
|
||
|
||
expect(byName.a.props('labelWidth')).toBe('120px');
|
||
expect(byName.a.props('labelPosition')).toBe('left');
|
||
expect(byName.b.props('labelPosition')).toBe('top');
|
||
});
|
||
});
|
||
|
||
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);
|
||
|
||
// 修改 props,formState 上的 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('formState.popperClass 在传入 theme 时自动追加 m-theme 修饰类', async () => {
|
||
const wrapper = mountForm({ popperClass: 'pop-x', theme: 'magic-admin' });
|
||
await nextTick();
|
||
|
||
const fs: any = wrapper.vm.formState;
|
||
expect(fs.popperClass).toBe('pop-x m-theme--magic-admin');
|
||
|
||
// 仅 theme 没有用户 popperClass 时不带前导空格
|
||
await wrapper.setProps({ popperClass: undefined });
|
||
expect(wrapper.vm.formState.popperClass).toBe('m-theme--magic-admin');
|
||
|
||
// 取消 theme 后回退为原始 popperClass
|
||
await wrapper.setProps({ theme: undefined, popperClass: 'pop-x' });
|
||
expect(wrapper.vm.formState.popperClass).toBe('pop-x');
|
||
});
|
||
|
||
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 —— context', () => {
|
||
test('context 字段可通过 formState 读穿,核心字段不被覆盖', async () => {
|
||
const wrapper = mountForm({
|
||
keyProp: 'id',
|
||
context: { username: 'alice', keyProp: 'hacked' },
|
||
});
|
||
|
||
await nextTick();
|
||
|
||
expect((wrapper.vm.formState as any).username).toBe('alice');
|
||
expect((wrapper.vm.formState as any).keyProp).toBe('id');
|
||
});
|
||
|
||
test('context 里的 accessor 保持读时求值', async () => {
|
||
let counter = 0;
|
||
const wrapper = mountForm({
|
||
context: Object.defineProperties(
|
||
{},
|
||
{
|
||
stage: {
|
||
enumerable: true,
|
||
get() {
|
||
counter += 1;
|
||
return `stage-${counter}`;
|
||
},
|
||
},
|
||
},
|
||
),
|
||
});
|
||
|
||
await nextTick();
|
||
|
||
// 每次读都重新求值,而不是挂载时快照一次
|
||
const first = (wrapper.vm.formState as any).stage;
|
||
const second = (wrapper.vm.formState as any).stage;
|
||
expect(first).toMatch(/^stage-\d+$/);
|
||
expect(second).not.toBe(first);
|
||
});
|
||
|
||
test('Object.entries(formState) 能枚举到 context 字段', async () => {
|
||
const wrapper = mountForm({ context: { username: 'alice', env: 'prod' } });
|
||
|
||
await nextTick();
|
||
|
||
const keys = Object.keys(wrapper.vm.formState as any);
|
||
expect(keys).toContain('username');
|
||
expect(keys).toContain('env');
|
||
expect(keys).toContain('values');
|
||
});
|
||
|
||
test('context 变化后 formState 读到新值,且不残留旧 key', async () => {
|
||
const context = ref<Record<string, any>>({ username: 'alice', stale: 1 });
|
||
const wrapper = mountForm({ context: context.value });
|
||
|
||
await nextTick();
|
||
expect((wrapper.vm.formState as any).username).toBe('alice');
|
||
|
||
await wrapper.setProps({ context: { username: 'bob' } });
|
||
await nextTick();
|
||
|
||
expect((wrapper.vm.formState as any).username).toBe('bob');
|
||
expect((wrapper.vm.formState as any).stale).toBeUndefined();
|
||
});
|
||
|
||
test('defaultValue 首轮就能读到 context 注入的字段', async () => {
|
||
const seen: any[] = [];
|
||
const wrapper = mountForm({
|
||
context: { username: 'alice' },
|
||
config: [
|
||
{
|
||
text: 'u',
|
||
name: 'u',
|
||
type: 'text',
|
||
defaultValue: (mForm: any) => {
|
||
seen.push(mForm?.username);
|
||
return mForm?.username;
|
||
},
|
||
},
|
||
],
|
||
});
|
||
|
||
await nextTick();
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
expect(seen[0]).toBe('alice');
|
||
expect(wrapper.vm.values.u).toBe('alice');
|
||
});
|
||
|
||
test('formState 直写扩展字段优先于 context', async () => {
|
||
const wrapper = mountForm({
|
||
context: { stage: 'from-context' },
|
||
});
|
||
|
||
await nextTick();
|
||
(wrapper.vm.formState as any).stage = 'assigned';
|
||
await nextTick();
|
||
|
||
expect((wrapper.vm.formState as any).stage).toBe('assigned');
|
||
});
|
||
|
||
test('嵌套 MForm 通过 FORM_CONTEXT_KEY 继承祖先 context', async () => {
|
||
const parentComponent = defineComponent({
|
||
setup() {
|
||
provide(
|
||
FORM_CONTEXT_KEY,
|
||
computed(() => ({ username: 'ancestor', env: 'prod' })),
|
||
);
|
||
return () => h(MForm, { initValues: {}, config: [] });
|
||
},
|
||
});
|
||
|
||
const wrapper = mount(parentComponent, {
|
||
global: {
|
||
plugins: [ElementPlus as any, MagicForm as any],
|
||
},
|
||
});
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
const formState = wrapper.findComponent(MForm).vm.formState as any;
|
||
expect(formState.username).toBe('ancestor');
|
||
expect(formState.env).toBe('prod');
|
||
expect(Object.keys(formState)).toEqual(expect.arrayContaining(['username', 'env', 'values']));
|
||
expect(Object.getOwnPropertyDescriptor(formState, 'username')?.enumerable).toBe(true);
|
||
});
|
||
|
||
test('子表单 props.context 覆盖祖先同名字段,未覆盖的仍可读', async () => {
|
||
const parentComponent = defineComponent({
|
||
setup() {
|
||
provide(
|
||
FORM_CONTEXT_KEY,
|
||
computed(() => ({ username: 'ancestor', env: 'prod' })),
|
||
);
|
||
return () =>
|
||
h(MForm, {
|
||
initValues: {},
|
||
config: [],
|
||
context: { username: 'child' },
|
||
});
|
||
},
|
||
});
|
||
|
||
const wrapper = mount(parentComponent, {
|
||
global: {
|
||
plugins: [ElementPlus as any, MagicForm as any],
|
||
},
|
||
});
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
const formState = wrapper.findComponent(MForm).vm.formState as any;
|
||
expect(formState.username).toBe('child');
|
||
expect(formState.env).toBe('prod');
|
||
});
|
||
|
||
test('未传 context 也未有祖先注入时,读扩展字段为 undefined 且不抛错', async () => {
|
||
const wrapper = mountForm({});
|
||
await nextTick();
|
||
|
||
expect(wrapper.find('.m-form').exists()).toBe(true);
|
||
expect((wrapper.vm.formState as any).whatever).toBeUndefined();
|
||
});
|
||
});
|
||
|
||
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('校验返回非 true(tdesign 风格)时也走错误分支', 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 为空 -> 用 prop(unknown)
|
||
expect(caught!.message).toContain('unknown');
|
||
expect(caught!.message).toContain('出错');
|
||
});
|
||
});
|
||
|
||
describe('Form.vue —— validate 校验实例方法(返回错误文案,不抛异常)', () => {
|
||
test('校验通过返回空字符串,且不触发 error 事件', async () => {
|
||
const wrapper = mountForm({
|
||
config: [{ text: 'text', type: 'text', name: 'text' }],
|
||
initValues: { text: 'hi' },
|
||
});
|
||
await nextTick();
|
||
|
||
const result = await wrapper.vm.validate();
|
||
expect(result).toBe('');
|
||
expect(wrapper.emitted('error')).toBeFalsy();
|
||
});
|
||
|
||
test('校验失败返回汇总后的错误文案(含字段 text),且不触发 error 事件、不抛异常', async () => {
|
||
const wrapper = mountForm({
|
||
config: [{ text: '名称', type: 'text', name: 'name' }],
|
||
initValues: { name: '' },
|
||
});
|
||
await nextTick();
|
||
|
||
const tmForm = wrapper.findComponent({ name: 'TMForm' });
|
||
const { exposed } = (tmForm.vm as any).$;
|
||
exposed.validate = vi.fn().mockRejectedValue({
|
||
name: [{ field: 'name', message: '必填' }],
|
||
});
|
||
|
||
const result = await wrapper.vm.validate();
|
||
expect(result).toBe('名称 -> 必填');
|
||
// 校验失败仅通过返回值给出错误文案,不触发 error 事件
|
||
expect(wrapper.emitted('error')).toBeFalsy();
|
||
});
|
||
|
||
test('校验返回非 true(tdesign 风格)时也返回错误文案', async () => {
|
||
const wrapper = mountForm({
|
||
config: [{ text: '账号', type: 'text', name: 'account' }],
|
||
initValues: { account: '' },
|
||
});
|
||
await nextTick();
|
||
|
||
const tmForm = wrapper.findComponent({ name: 'TMForm' });
|
||
const { exposed } = (tmForm.vm as any).$;
|
||
exposed.validate = vi.fn().mockResolvedValue({
|
||
account: [{ field: 'account', message: '不能为空' }],
|
||
});
|
||
|
||
const result = await wrapper.vm.validate();
|
||
expect(result).toContain('账号 -> 不能为空');
|
||
});
|
||
});
|
||
|
||
describe('Form.vue —— useFieldTextInError', () => {
|
||
const mountAndMockValidate = async (
|
||
props: Record<string, any>,
|
||
invalidFields: Record<string, { field: string; message: string }[]>,
|
||
) => {
|
||
const wrapper = mountForm(props);
|
||
await nextTick();
|
||
|
||
const tmForm = wrapper.findComponent({ name: 'TMForm' });
|
||
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;
|
||
}
|
||
|
||
return { wrapper, caught };
|
||
};
|
||
|
||
test('默认(useFieldTextInError 未传)时错误信息使用 config 中的 text', async () => {
|
||
const { caught } = await mountAndMockValidate(
|
||
{
|
||
config: [{ text: '名称', type: 'text', name: 'name' }],
|
||
initValues: { name: '' },
|
||
},
|
||
{ name: [{ field: 'name', message: '必填' }] },
|
||
);
|
||
|
||
expect(caught!.message).toContain('名称');
|
||
expect(caught!.message).not.toContain('name -> ');
|
||
});
|
||
|
||
test('useFieldTextInError=true 时错误信息使用 config 中的 text', async () => {
|
||
const { caught } = await mountAndMockValidate(
|
||
{
|
||
config: [{ text: '名称', type: 'text', name: 'name' }],
|
||
initValues: { name: '' },
|
||
useFieldTextInError: true,
|
||
},
|
||
{ name: [{ field: 'name', message: '必填' }] },
|
||
);
|
||
|
||
expect(caught!.message).toContain('名称 -> 必填');
|
||
});
|
||
|
||
test('useFieldTextInError=false 时跳过查找,直接使用字段 name', async () => {
|
||
const { caught } = await mountAndMockValidate(
|
||
{
|
||
config: [{ text: '名称', type: 'text', name: 'name' }],
|
||
initValues: { name: '' },
|
||
useFieldTextInError: false,
|
||
},
|
||
{ name: [{ field: 'name', message: '必填' }] },
|
||
);
|
||
|
||
expect(caught!.message).toContain('name -> 必填');
|
||
expect(caught!.message).not.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([]);
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 字段的值初始化写入(`date` 归一化、`display` 的 `initValue` 等)统一由 `applyMountValueEffects`
|
||
* 在表单值初始化完成后执行一次,字段组件自身不再在 setup 里改写 model。
|
||
*/
|
||
describe('Form.vue —— 字段值初始化统一执行', () => {
|
||
test('字段未渲染出来时值同样被规整', async () => {
|
||
const wrapper = mountForm({
|
||
config: [
|
||
{
|
||
type: 'fieldset',
|
||
name: 'wrap',
|
||
expand: true,
|
||
checkbox: { name: 'value', trueValue: 1, falseValue: 0 },
|
||
items: [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }],
|
||
},
|
||
],
|
||
initValues: { wrap: { value: 0, start: '2021/07/17 15:37:00' } },
|
||
});
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
// 勾选框未勾选,内部字段没有渲染
|
||
expect(wrapper.findComponent({ name: 'MFormDate' }).exists()).toBe(false);
|
||
expect(wrapper.vm.values.wrap.start).toBe('2021-07-17');
|
||
});
|
||
|
||
test('initValues 变化后重新初始化,值仍被规整', async () => {
|
||
const config = [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }];
|
||
const wrapper = mountForm({ config, initValues: { start: '2021/07/17 15:37:00' } });
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
expect(wrapper.vm.values.start).toBe('2021-07-17');
|
||
|
||
await wrapper.setProps({ initValues: { start: '2022/08/18 15:37:00' } });
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
expect(wrapper.vm.values.start).toBe('2022-08-18');
|
||
});
|
||
|
||
test('对比模式下待对比的那份值同样被规整', async () => {
|
||
const wrapper = mountForm({
|
||
isCompare: true,
|
||
config: [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }],
|
||
initValues: { start: '2021/07/17 15:37:00' },
|
||
lastValues: { start: '2021/07/17 09:00:00' },
|
||
});
|
||
await nextTick();
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
expect(wrapper.vm.values.start).toBe('2021-07-17');
|
||
// 两份值都归一化后才不会比出「只是格式不同」的假差异
|
||
expect(wrapper.vm.lastValuesProcessed.start).toBe('2021-07-17');
|
||
});
|
||
|
||
test('group-list 新增行的值被规整', async () => {
|
||
const wrapper = mountForm({
|
||
config: [
|
||
{
|
||
type: 'group-list',
|
||
name: 'list',
|
||
items: [
|
||
{
|
||
type: 'date',
|
||
name: 'start',
|
||
text: '开始',
|
||
valueFormat: 'YYYY-MM-DD',
|
||
defaultValue: '2021/07/17 15:37:00',
|
||
},
|
||
],
|
||
},
|
||
],
|
||
initValues: { list: [] },
|
||
});
|
||
await nextTick();
|
||
|
||
const addButton = wrapper.findAll('button').find((btn) => btn.text().includes('新增'));
|
||
await addButton?.trigger('click');
|
||
await nextTick();
|
||
|
||
expect(wrapper.vm.values.list[0].start).toBe('2021-07-17');
|
||
});
|
||
|
||
test('group-list 走 enum 新增时值也被规整', async () => {
|
||
const wrapper = mountForm({
|
||
config: [
|
||
{
|
||
type: 'group-list',
|
||
name: 'list',
|
||
enum: [{ id: 1, start: '2021/07/17 15:37:00' }],
|
||
items: [{ type: 'date', name: 'start', text: '开始', valueFormat: 'YYYY-MM-DD' }],
|
||
},
|
||
],
|
||
initValues: { list: [] },
|
||
});
|
||
await nextTick();
|
||
|
||
const addButton = wrapper.findAll('button').find((btn) => btn.text().includes('新增'));
|
||
await addButton?.trigger('click');
|
||
await nextTick();
|
||
|
||
expect(wrapper.vm.values.list[0].start).toBe('2021-07-17');
|
||
});
|
||
});
|
||
|
||
describe('Form.vue —— 配置变化是否触发重挂', () => {
|
||
const makeConfig = () => [
|
||
{
|
||
type: 'text',
|
||
name: 'title',
|
||
text: '标题',
|
||
// 宿主每次重新生成配置时都是新闭包
|
||
display: () => true,
|
||
onChange: (_mForm: any, v: any) => v,
|
||
},
|
||
];
|
||
|
||
test('结构不变、只有闭包换了新实例时不卸载重挂', async () => {
|
||
const wrapper = mountForm({ config: makeConfig(), initValues: { title: 'a' } });
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
const before = wrapper.find('input').element;
|
||
expect(before).toBeTruthy();
|
||
|
||
await wrapper.setProps({ config: makeConfig(), initValues: { title: 'b' } });
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
// 同一个 DOM 节点还在,说明表单没有被销毁重建,滚动位置/焦点才不会丢
|
||
expect(wrapper.find('input').element).toBe(before);
|
||
expect(wrapper.vm.initialized).toBe(true);
|
||
});
|
||
|
||
test('结构真的变了(换字段)时仍然重挂', async () => {
|
||
const wrapper = mountForm({ config: makeConfig(), initValues: { title: 'a' } });
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
const before = wrapper.find('input').element;
|
||
|
||
await wrapper.setProps({
|
||
config: [{ type: 'text', name: 'subtitle', text: '副标题' }],
|
||
initValues: { subtitle: 'b' },
|
||
});
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
expect(wrapper.find('input').element).not.toBe(before);
|
||
});
|
||
});
|
||
|
||
describe('Form.vue —— 数据实体切换(formKey)', () => {
|
||
// 模拟「在 setup 期从 model 派生配置」的字段(如事件动作里的代码块 / 数据源方法参数)
|
||
const snapshotField = defineComponent({
|
||
name: 'SnapshotField',
|
||
props: ['model', 'name', 'config'],
|
||
setup(props: any) {
|
||
const snapshot = ref(props.model?.[props.name]);
|
||
return () => h('span', { class: 'snapshot-field' }, `${snapshot.value}`);
|
||
},
|
||
});
|
||
|
||
const makeConfig = () => [
|
||
{
|
||
type: 'tab',
|
||
items: [
|
||
{ title: '属性', items: [{ name: 'id', type: 'text', text: 'ID' }] },
|
||
{
|
||
title: '事件',
|
||
items: [
|
||
{
|
||
type: 'group-list',
|
||
name: 'events',
|
||
expandAll: true,
|
||
items: [{ type: 'entity-snapshot', name: 'label', text: '事件' }],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
const mountEntityForm = (props: Record<string, any>) =>
|
||
mount(MForm, {
|
||
global: {
|
||
plugins: [
|
||
ElementPlus as any,
|
||
[MagicForm as any, { fields: { 'entity-snapshot': { component: snapshotField } } }] as any,
|
||
],
|
||
},
|
||
props: { config: makeConfig(), ...props },
|
||
});
|
||
|
||
const flush = async () => {
|
||
await nextTick();
|
||
await Promise.resolve();
|
||
await nextTick();
|
||
};
|
||
|
||
const nodeA = { id: 'A', type: 'button', events: [{ label: 'A-click' }] };
|
||
const nodeB = { id: 'B', type: 'button', events: [{ label: 'B-click' }] };
|
||
|
||
test('同类型节点切换时叶子字段重挂,不再残留上一个节点的配置', async () => {
|
||
const wrapper = mountEntityForm({ formKey: 'A', initValues: nodeA });
|
||
await flush();
|
||
expect(wrapper.find('.snapshot-field').text()).toBe('A-click');
|
||
|
||
await wrapper.setProps({ formKey: 'B', initValues: nodeB });
|
||
await flush();
|
||
|
||
expect(wrapper.vm.values.events[0].label).toBe('B-click');
|
||
expect(wrapper.find('.snapshot-field').text()).toBe('B-click');
|
||
expect((wrapper.vm.formState as any).fieldVersion).toBe(1);
|
||
});
|
||
|
||
test('同一实体的值更新不重挂字段,输入焦点/DOM 保持不变', async () => {
|
||
const wrapper = mountEntityForm({ formKey: 'A', initValues: nodeA });
|
||
await flush();
|
||
const before = wrapper.find('.snapshot-field').element;
|
||
|
||
await wrapper.setProps({ initValues: { ...nodeA, events: [{ label: 'A-click-2' }] } });
|
||
await flush();
|
||
|
||
expect(wrapper.find('.snapshot-field').element).toBe(before);
|
||
expect((wrapper.vm.formState as any).fieldVersion).toBe(0);
|
||
expect(wrapper.vm.values.events[0].label).toBe('A-click-2');
|
||
});
|
||
|
||
test('切换实体保留 tab 容器实例,激活 tab / 滚动状态不重置', async () => {
|
||
const wrapper = mountEntityForm({ formKey: 'A', initValues: nodeA });
|
||
await flush();
|
||
const tabsUid = (wrapper.findComponent({ name: 'MFormTabs' }).vm as any).$.uid;
|
||
|
||
await wrapper.setProps({ formKey: 'B', initValues: nodeB });
|
||
await flush();
|
||
|
||
expect((wrapper.findComponent({ name: 'MFormTabs' }).vm as any).$.uid).toBe(tabsUid);
|
||
expect(wrapper.find('.snapshot-field').text()).toBe('B-click');
|
||
});
|
||
|
||
test('快速切换实体时只采用最后一次 initValue 的结果', async () => {
|
||
const pending: ((v: any) => void)[] = [];
|
||
const config: any = [
|
||
{
|
||
type: 'tab',
|
||
onInitValue: (_mForm: any, { formValue }: any) =>
|
||
new Promise((resolve) => pending.push((v) => resolve({ ...formValue, ...v }))),
|
||
items: [{ title: '属性', items: [{ name: 'id', type: 'text', text: 'ID' }] }],
|
||
},
|
||
];
|
||
const wrapper = mount(MForm, {
|
||
global: { plugins: [ElementPlus as any, MagicForm as any] },
|
||
props: { config, formKey: 'A', initValues: nodeA },
|
||
});
|
||
await nextTick();
|
||
expect(pending.length).toBe(1);
|
||
|
||
await wrapper.setProps({ formKey: 'B', initValues: nodeB });
|
||
await nextTick();
|
||
expect(pending.length).toBe(2);
|
||
|
||
// 故意让旧实体的异步初始化后完成
|
||
pending[1]({ id: 'B' });
|
||
await flush();
|
||
pending[0]({ id: 'A' });
|
||
await flush();
|
||
|
||
expect(wrapper.vm.values.id).toBe('B');
|
||
expect(wrapper.vm.initialized).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('Container.vue —— 值处理与对比分支', () => {
|
||
const emitField = defineComponent({
|
||
name: 'EmitField',
|
||
props: ['model', 'name', 'config'],
|
||
emits: ['change'],
|
||
setup(_props: any, { emit }: any) {
|
||
return () =>
|
||
h('button', { class: 'emit-field', onClick: () => emit('change', ' hello ', { changeRecords: [] }) });
|
||
},
|
||
});
|
||
|
||
const mountEmitForm = (config: any[], props: Record<string, any> = {}) =>
|
||
mount(MForm, {
|
||
global: {
|
||
plugins: [
|
||
ElementPlus as any,
|
||
[MagicForm as any, { fields: { 'emit-field': { component: emitField } } }] as any,
|
||
],
|
||
},
|
||
props: { config, initValues: { title: 'old' }, ...props },
|
||
});
|
||
|
||
const flush = async () => {
|
||
await nextTick();
|
||
await Promise.resolve();
|
||
await nextTick();
|
||
};
|
||
|
||
test('filter / trim / onChange 回调链按最新表单值处理字段改动', async () => {
|
||
const getFormValueInFilter = vi.fn();
|
||
const getFormValueInChange = vi.fn();
|
||
const wrapper = mountEmitForm([
|
||
{
|
||
type: 'emit-field',
|
||
name: 'title',
|
||
text: '标题',
|
||
filter: (_mForm: any, value: any, ctx: any) => {
|
||
getFormValueInFilter(ctx.getFormValue('title'));
|
||
return value;
|
||
},
|
||
trim: true,
|
||
onChange: (_mForm: any, value: any, ctx: any) => {
|
||
getFormValueInChange(ctx.getFormValue('title'));
|
||
// 读取 model proxy 的字段,验证其按 props.model 读穿
|
||
void ctx.model.title;
|
||
return value;
|
||
},
|
||
},
|
||
]);
|
||
await flush();
|
||
|
||
await wrapper.find('.emit-field').trigger('click');
|
||
await flush();
|
||
|
||
expect(getFormValueInFilter).toHaveBeenCalledWith('old');
|
||
expect(getFormValueInChange).toHaveBeenCalledWith('old');
|
||
expect(wrapper.vm.values.title).toBe('hello');
|
||
});
|
||
|
||
test('onChange 抛错时不影响表单继续可用', async () => {
|
||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||
const wrapper = mountEmitForm([
|
||
{
|
||
type: 'emit-field',
|
||
name: 'title',
|
||
onChange: () => {
|
||
throw new Error('boom');
|
||
},
|
||
},
|
||
]);
|
||
await flush();
|
||
|
||
await wrapper.find('.emit-field').trigger('click');
|
||
await flush();
|
||
|
||
expect(errorSpy).toHaveBeenCalled();
|
||
expect(wrapper.vm.initialized).toBe(true);
|
||
errorSpy.mockRestore();
|
||
});
|
||
|
||
test('对比模式支持自定义 showDiff 与 selfDiffFieldTypes', async () => {
|
||
const showDiff = vi.fn(() => true);
|
||
const selfDiffTypes = vi.fn((defaults: string[]) => [...defaults]);
|
||
const wrapper = mountForm({
|
||
config: [{ type: 'text', name: 'title', text: '标题' }],
|
||
initValues: { title: 'new' },
|
||
lastValues: { title: 'old' },
|
||
isCompare: true,
|
||
showDiff,
|
||
selfDiffFieldTypes: selfDiffTypes,
|
||
});
|
||
await flush();
|
||
|
||
expect(showDiff).toHaveBeenCalled();
|
||
expect(selfDiffTypes).toHaveBeenCalled();
|
||
expect(wrapper.findComponent({ name: 'MFormContainer' }).exists()).toBe(true);
|
||
|
||
await wrapper.setProps({ selfDiffFieldTypes: ['text'] });
|
||
await flush();
|
||
expect(wrapper.findComponent({ name: 'MFormContainer' }).exists()).toBe(true);
|
||
});
|
||
});
|