mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-22 20:19:06 +08:00
406 lines
13 KiB
TypeScript
406 lines
13 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, beforeAll, describe, expect, test, vi } from 'vitest';
|
||
import { type AppContext, createApp, defineComponent, h, nextTick } from 'vue';
|
||
import MagicForm, { validateForm } from '@form/index';
|
||
import ElementPlus from 'element-plus';
|
||
|
||
let appContext: AppContext;
|
||
|
||
beforeAll(() => {
|
||
// 构造一个父级 app,把 element-plus 与 m-form 插件装上,
|
||
// 之后通过 appContext 传给 validateForm 复用全局注册
|
||
const parentApp = createApp(defineComponent({ render: () => h('div') }));
|
||
parentApp.use(ElementPlus);
|
||
parentApp.use(MagicForm);
|
||
appContext = parentApp._context;
|
||
});
|
||
|
||
afterEach(() => {
|
||
document.body.innerHTML = '';
|
||
});
|
||
|
||
// 说明:validateForm 内部会新建一个独立的 MForm 实例并复用其校验方法 `validate`(返回错误文案、不抛异常),
|
||
// 校验失败时的错误文案格式由 Form.vue 实例的 `validate` 负责,已在 Form.extra.spec.ts
|
||
// 中覆盖;此处聚焦 validateForm 独有的「命令式挂载 / 卸载 / 上下文注入 / 超时」等行为。
|
||
describe('validateForm', () => {
|
||
test('校验通过时 resolve 空字符串,并自动清理 DOM', async () => {
|
||
const error = await validateForm({
|
||
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||
initValues: { text: 'hello' },
|
||
appContext,
|
||
});
|
||
|
||
expect(error).toBe('');
|
||
expect(document.body.querySelector('.m-form')).toBeNull();
|
||
});
|
||
|
||
test('resolve 结果始终为字符串(校验入口不抛异常)', async () => {
|
||
const error = await validateForm({
|
||
config: [{ type: 'text', name: 'name', text: '名称' }],
|
||
initValues: { name: '' },
|
||
appContext,
|
||
});
|
||
|
||
expect(typeof error).toBe('string');
|
||
expect(document.body.querySelector('.m-form')).toBeNull();
|
||
});
|
||
|
||
test('支持 extendState 扩展状态', async () => {
|
||
const extendState = vi.fn(async () => ({ extra: 'value' }));
|
||
|
||
await validateForm({
|
||
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||
initValues: { text: 'foo' },
|
||
extendState,
|
||
appContext,
|
||
});
|
||
|
||
expect(extendState).toHaveBeenCalled();
|
||
});
|
||
|
||
test('tab 的 display 函数读取 extendState 注入的值时不会因竞态崩溃', async () => {
|
||
// 模拟编辑器 styleTabConfig.display 的模式:display 函数从 mForm 解构 services
|
||
// services 通过 extendState 注入。若 extendState 异步且在首次渲染前未完成,
|
||
// display 函数会读到 undefined 导致 TypeError。
|
||
const error = await validateForm({
|
||
config: [
|
||
{
|
||
type: 'tab',
|
||
items: [
|
||
{ title: '属性', items: [{ type: 'text', name: 'name', text: '名称' }] },
|
||
{
|
||
title: '样式',
|
||
display: (mForm: any) => {
|
||
const { services } = mForm || {};
|
||
return !(services?.uiService?.get('showStylePanel') ?? true);
|
||
},
|
||
items: [{ type: 'text', name: 'style', text: '样式' }],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
initValues: { name: 'test' },
|
||
extendState: () => ({
|
||
services: { uiService: { get: () => false } },
|
||
}),
|
||
appContext,
|
||
});
|
||
|
||
expect(typeof error).toBe('string');
|
||
expect(document.body.querySelector('.m-form')).toBeNull();
|
||
});
|
||
|
||
test('在嵌套 items 配置下也能正确 resolve', async () => {
|
||
const error = await validateForm({
|
||
config: [
|
||
{ type: 'text', name: 'name', text: 'name' },
|
||
{
|
||
name: 'object',
|
||
items: [{ type: 'text', name: 'nested', text: 'nested' }],
|
||
},
|
||
],
|
||
initValues: {
|
||
name: 'a',
|
||
object: { nested: 'b' },
|
||
},
|
||
appContext,
|
||
});
|
||
|
||
expect(error).toBe('');
|
||
});
|
||
|
||
test('去除 type 为 tab 的容器中各标签页的 lazy,使懒加载标签页字段也参与校验', async () => {
|
||
const config: any = [
|
||
{
|
||
type: 'tab',
|
||
items: [
|
||
{ title: '属性', items: [{ type: 'text', name: 'name', text: '名称' }] },
|
||
{ title: '样式', lazy: true, items: [{ type: 'text', name: 'style', text: '样式' }] },
|
||
],
|
||
},
|
||
];
|
||
|
||
const error = await validateForm({
|
||
config,
|
||
initValues: { name: 'a', style: 'b' },
|
||
appContext,
|
||
});
|
||
|
||
expect(error).toBe('');
|
||
// 校验只使用 config 副本,不污染调用方传入的原始配置
|
||
expect(config[0].items[1].lazy).toBe(true);
|
||
expect(document.body.querySelector('.m-form')).toBeNull();
|
||
});
|
||
|
||
test('嵌套在标签页内的 tab 容器的 lazy 同样被去除', async () => {
|
||
const config: any = [
|
||
{
|
||
type: 'tab',
|
||
items: [
|
||
{
|
||
title: '外层',
|
||
items: [
|
||
{
|
||
type: 'tab',
|
||
items: [
|
||
{ title: '内层1', items: [{ type: 'text', name: 'inner1', text: '内层1' }] },
|
||
{
|
||
title: '内层2',
|
||
lazy: true,
|
||
items: [{ type: 'text', name: 'inner2', text: '内层2' }],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
const error = await validateForm({
|
||
config,
|
||
initValues: { inner1: 'a', inner2: 'b' },
|
||
appContext,
|
||
});
|
||
|
||
expect(typeof error).toBe('string');
|
||
// 原始配置不被污染
|
||
expect(config[0].items[0].items[0].items[1].lazy).toBe(true);
|
||
expect(document.body.querySelector('.m-form')).toBeNull();
|
||
});
|
||
|
||
test('多次并发调用互不干扰,且结束后不在 body 残留节点', async () => {
|
||
const baseChildCount = document.body.children.length;
|
||
|
||
const results = await Promise.all([
|
||
validateForm({
|
||
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||
initValues: { text: 'first' },
|
||
appContext,
|
||
}),
|
||
validateForm({
|
||
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||
initValues: { text: 'second' },
|
||
appContext,
|
||
}),
|
||
]);
|
||
|
||
expect(results).toEqual(['', '']);
|
||
expect(document.body.children.length).toBe(baseChildCount);
|
||
expect(document.body.querySelector('.m-form')).toBeNull();
|
||
});
|
||
|
||
test('调用过程中临时容器会被附加到 body 上,结束后被移除', async () => {
|
||
const baseChildCount = document.body.children.length;
|
||
|
||
const pending = validateForm({
|
||
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||
initValues: { text: 'in-flight' },
|
||
appContext,
|
||
});
|
||
|
||
expect(document.body.children.length).toBe(baseChildCount + 1);
|
||
|
||
await pending;
|
||
|
||
expect(document.body.children.length).toBe(baseChildCount);
|
||
});
|
||
|
||
test('未注入 DOM 环境时(document 不可用)以错误 reject', async () => {
|
||
const originalDocument = globalThis.document;
|
||
|
||
delete (globalThis as any).document;
|
||
|
||
let caught: any = null;
|
||
try {
|
||
await validateForm({
|
||
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||
initValues: { text: 'no-dom' },
|
||
appContext,
|
||
});
|
||
} catch (e) {
|
||
caught = e;
|
||
} finally {
|
||
(globalThis as any).document = originalDocument;
|
||
}
|
||
|
||
expect(caught).toBeInstanceOf(Error);
|
||
});
|
||
|
||
test('timeout > 0 时会注册定时器,timeout <= 0 时不注册', async () => {
|
||
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||
|
||
await validateForm({
|
||
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||
initValues: { text: 'with-timeout' },
|
||
timeout: 5000,
|
||
appContext,
|
||
});
|
||
|
||
const calledWithTimeout = setTimeoutSpy.mock.calls.some(([, delay]) => delay === 5000);
|
||
expect(calledWithTimeout).toBe(true);
|
||
|
||
setTimeoutSpy.mockClear();
|
||
|
||
await validateForm({
|
||
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||
initValues: { text: 'no-timeout' },
|
||
timeout: 0,
|
||
appContext,
|
||
});
|
||
|
||
const calledWithZero = setTimeoutSpy.mock.calls.some(([, delay]) => delay === 0);
|
||
expect(calledWithZero).toBe(false);
|
||
|
||
setTimeoutSpy.mockRestore();
|
||
});
|
||
});
|
||
|
||
describe('validateForm —— debug 模式', () => {
|
||
const findButton = (text: string) =>
|
||
Array.from(document.body.querySelectorAll('button')).find(
|
||
(b) => (b.textContent || '').trim() === text,
|
||
) as HTMLButtonElement;
|
||
|
||
// 通过 Vue 渲染留下的内部指针定位 MForm 组件实例,用于 mock 其 expose 方法。
|
||
// 真实 element-plus 校验在 jsdom 下不可靠(form-item 未注册到 form),故校验失败分支以 mock 方式验证。
|
||
const findMFormInstance = (): any => {
|
||
const formEl = document.body.querySelector('.m-form') as any;
|
||
let comp: any = formEl?.__vueParentComponent;
|
||
while (comp && comp.type?.name !== 'MForm' && comp.type?.__name !== 'MForm') comp = comp.parent;
|
||
return comp;
|
||
};
|
||
|
||
const mockExposed = (comp: any, method: string, fn: any) => {
|
||
Object.defineProperty(comp.exposed, method, { value: fn, configurable: true, writable: true });
|
||
};
|
||
|
||
test('debug 模式可见渲染弹层,点击「确定」校验通过后 resolve 空字符串并清理 DOM', async () => {
|
||
const pending = validateForm({
|
||
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||
initValues: { text: 'hello' },
|
||
debug: true,
|
||
appContext,
|
||
});
|
||
|
||
// 等待弹层与表单渲染
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
// debug 模式容器未隐藏,表单可见渲染
|
||
expect(document.body.querySelector('.m-form')).not.toBeNull();
|
||
|
||
findButton('确定').click();
|
||
|
||
const error = await pending;
|
||
expect(error).toBe('');
|
||
expect(document.body.querySelector('.m-form')).toBeNull();
|
||
});
|
||
|
||
test('点击「取消」以错误 reject 并清理 DOM', async () => {
|
||
const pending = validateForm({
|
||
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||
initValues: { text: 'hello' },
|
||
debug: true,
|
||
appContext,
|
||
});
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
findButton('取消').click();
|
||
|
||
let caught: any = null;
|
||
try {
|
||
await pending;
|
||
} catch (e) {
|
||
caught = e;
|
||
}
|
||
|
||
expect(caught).toBeInstanceOf(Error);
|
||
expect(caught.message).toContain('canceled');
|
||
expect(document.body.querySelector('.m-form')).toBeNull();
|
||
});
|
||
|
||
test('校验失败时点击「确定」在弹层展示错误并保留弹层,随后取消结束', async () => {
|
||
const pending = validateForm({
|
||
config: [{ type: 'text', name: 'name', text: '名称' }],
|
||
initValues: { name: '' },
|
||
debug: true,
|
||
appContext,
|
||
});
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
// mock MForm 实例的 validate 返回非空错误文案(真实 element-plus 校验在 jsdom 下不可靠)
|
||
const comp = findMFormInstance();
|
||
expect(comp).toBeTruthy();
|
||
mockExposed(comp, 'validate', vi.fn().mockResolvedValue('名称 -> 必填'));
|
||
|
||
findButton('确定').click();
|
||
|
||
// 等待异步校验完成并展示错误(mock validate 为 resolved,需等 microtask + DOM 更新)
|
||
await vi.waitFor(
|
||
() => {
|
||
const el = Array.from(document.body.querySelectorAll('div')).find((d) =>
|
||
(d.textContent || '').includes('名称 -> 必填'),
|
||
);
|
||
expect(el).toBeTruthy();
|
||
},
|
||
{ timeout: 1000 },
|
||
);
|
||
|
||
// 弹层保留
|
||
expect(document.body.querySelector('.m-form')).not.toBeNull();
|
||
|
||
// promise 仍 pending:点击取消以 reject 结束,避免悬挂
|
||
findButton('取消').click();
|
||
|
||
let caught: any = null;
|
||
try {
|
||
await pending;
|
||
} catch (e) {
|
||
caught = e;
|
||
}
|
||
expect(caught).toBeInstanceOf(Error);
|
||
expect(caught.message).toContain('canceled');
|
||
expect(document.body.querySelector('.m-form')).toBeNull();
|
||
});
|
||
|
||
test('debug 模式不注册超时定时器(等待人工操作)', async () => {
|
||
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||
|
||
const pending = validateForm({
|
||
config: [{ type: 'text', name: 'text', text: 'text' }],
|
||
initValues: { text: 'hello' },
|
||
debug: true,
|
||
timeout: 5000,
|
||
appContext,
|
||
});
|
||
await nextTick();
|
||
await nextTick();
|
||
|
||
const calledWithTimeout = setTimeoutSpy.mock.calls.some(([, delay]) => delay === 5000);
|
||
expect(calledWithTimeout).toBe(false);
|
||
|
||
findButton('确定').click();
|
||
await pending;
|
||
setTimeoutSpy.mockRestore();
|
||
});
|
||
});
|