mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-13 23:48:43 +00:00
fix(editor,form): 合并宿主 FORM_CONTEXT 并修复 formState mForm 自引用
编辑器不再遮蔽外层 provide 的业务上下文。 formState Proxy 支持 Vue2 时代 vm.mForm 跨字段通信, 并修复 context 自引用导致的栈溢出。 Select 对 valueKey 路径使用可选链。
This commit is contained in:
parent
ad2f4e15cc
commit
b0d43dc277
@ -16,9 +16,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { computed, type ComputedRef } from 'vue';
|
||||
import { computed, type ComputedRef, inject, unref } from 'vue';
|
||||
|
||||
import type { FormContext } from '@tmagic/form';
|
||||
import { FORM_CONTEXT_KEY, type FormContext, mergeFormContexts } from '@tmagic/form';
|
||||
|
||||
import type { Services } from '@editor/type';
|
||||
|
||||
@ -28,13 +28,29 @@ import type { Services } from '@editor/type';
|
||||
* 由 `Editor.vue`(provide `FORM_CONTEXT_KEY`)、`FormPanel.vue` 与 `useCompareForm`
|
||||
* 共用。`stage` 走 computed 而非快照,保证切换画布后配置回调读到的是最新实例。
|
||||
*
|
||||
* 宿主可能在 `<MEditor>` 外层 provide 了自己的业务字段,这里必须把那一层合并进来:
|
||||
* 否则编辑器再 provide 一次会把宿主整层遮蔽掉,属性面板、对比表单里的配置回调就
|
||||
* 读不到宿主字段了。`services` / `stage` 由编辑器兜底,优先级高于宿主同名字段。
|
||||
*
|
||||
* 字段类型见 `@editor/type` 里对 `@tmagic/form-schema` 的 `FormContext` 模块增强。
|
||||
*/
|
||||
export const useEditorFormContext = (getServices: () => Services | undefined): ComputedRef<FormContext> =>
|
||||
computed(() => {
|
||||
export const useEditorFormContext = (getServices: () => Services | undefined): ComputedRef<FormContext> => {
|
||||
// inject 只能在 setup 期取值,因此本 hook 必须在 setup 中调用(`use` 前缀即此约定)。
|
||||
// 返回的 computed 可以随便传递,但 hook 本身不能延迟到事件回调或 onMounted 里再调。
|
||||
const hostContext = inject(FORM_CONTEXT_KEY, undefined);
|
||||
|
||||
return computed(() => {
|
||||
const services = getServices();
|
||||
return {
|
||||
const host = unref(hostContext);
|
||||
|
||||
// 上层(通常是 Editor.vue)已经用同一份 services 合并过了。FormPanel / useCompareForm
|
||||
// 都是它的后代,再合并一次只会多套一层 Proxy,让每次属性 miss 多一轮线性查找。
|
||||
// services 相同即可判定 stage 也相同——两边都是从同一个 editorService 读的。
|
||||
if (host && host.services === services) return host;
|
||||
|
||||
return mergeFormContexts(host, {
|
||||
services,
|
||||
stage: services?.editorService.get('stage'),
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { type ComputedRef, defineComponent, h, inject, nextTick } from 'vue';
|
||||
import { computed, type ComputedRef, defineComponent, h, inject, nextTick, provide, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { FORM_CONTEXT_KEY, type FormContext } from '@tmagic/form';
|
||||
@ -184,6 +184,55 @@ describe('Editor', () => {
|
||||
expect(context.stage).toBe(stageStub);
|
||||
});
|
||||
|
||||
test('合并宿主在外层 provide 的 FORM_CONTEXT_KEY,不遮蔽其业务字段', async () => {
|
||||
const hostComponent = defineComponent({
|
||||
setup: () => {
|
||||
provide(
|
||||
FORM_CONTEXT_KEY,
|
||||
computed(() => ({ username: 'tester', editor: { editorCore: {} } }) as unknown as FormContext),
|
||||
);
|
||||
return () => h(Editor, {} as any);
|
||||
},
|
||||
});
|
||||
|
||||
mount(hostComponent);
|
||||
await nextTick();
|
||||
|
||||
const context = injectedFormContext!.value as any;
|
||||
expect(context.username).toBe('tester');
|
||||
expect(context.editor.editorCore).toBeDefined();
|
||||
// 编辑器自己的字段优先级更高,仍然兜底
|
||||
expect(context.services.editorService).toBeDefined();
|
||||
expect(context.stage).toBe(stageStub);
|
||||
});
|
||||
|
||||
test('宿主上下文的 accessor 保持读时求值', async () => {
|
||||
const counter = ref(0);
|
||||
const hostComponent = defineComponent({
|
||||
setup: () => {
|
||||
provide(
|
||||
FORM_CONTEXT_KEY,
|
||||
computed(
|
||||
() =>
|
||||
({
|
||||
get buildVersion() {
|
||||
return counter.value;
|
||||
},
|
||||
}) as unknown as FormContext,
|
||||
),
|
||||
);
|
||||
return () => h(Editor, {} as any);
|
||||
},
|
||||
});
|
||||
|
||||
mount(hostComponent);
|
||||
await nextTick();
|
||||
|
||||
expect((injectedFormContext!.value as any).buildVersion).toBe(0);
|
||||
counter.value = 7;
|
||||
expect((injectedFormContext!.value as any).buildVersion).toBe(7);
|
||||
});
|
||||
|
||||
test('stage 走 computed 读时求值,切换画布后能读到新实例', async () => {
|
||||
const editorServiceMod = (await import('@editor/services/editor')) as any;
|
||||
mount(Editor, { props: {} as any });
|
||||
|
||||
@ -4,10 +4,11 @@
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { computed, defineComponent, h, nextTick, ref } from 'vue';
|
||||
import { mount, type VueWrapper } from '@vue/test-utils';
|
||||
|
||||
import { HookType } from '@tmagic/core';
|
||||
import { FORM_CONTEXT_KEY } from '@tmagic/form';
|
||||
|
||||
import CompareForm from '@editor/components/CompareForm.vue';
|
||||
|
||||
@ -44,7 +45,8 @@ vi.mock('@editor/utils/code-block', () => ({
|
||||
getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/form', () => ({
|
||||
vi.mock('@tmagic/form', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@tmagic/form')>()),
|
||||
MForm: defineComponent({
|
||||
name: 'MForm',
|
||||
props: ['config', 'initValues', 'lastValues', 'isCompare', 'disabled', 'labelWidth', 'context', 'showDiff'],
|
||||
@ -95,6 +97,28 @@ describe('CompareForm.vue', () => {
|
||||
expect(capturedFormProps.context).toHaveProperty('stage');
|
||||
});
|
||||
|
||||
test('宿主 provide 的上下文不被遮蔽,且 services 由编辑器兜底', async () => {
|
||||
const wrapper = mount(CompareForm, {
|
||||
props: {
|
||||
category: 'node',
|
||||
type: 'text',
|
||||
value: { id: 'n1' },
|
||||
lastValue: { id: 'n1' },
|
||||
services,
|
||||
},
|
||||
global: {
|
||||
provide: {
|
||||
codeOptions: {},
|
||||
[FORM_CONTEXT_KEY as symbol]: computed(() => ({ username: 'alice', services: 'host-services' })),
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitForFormReady(wrapper);
|
||||
|
||||
expect(capturedFormProps.context.username).toBe('alice');
|
||||
expect(capturedFormProps.context.services).toEqual(services);
|
||||
});
|
||||
|
||||
test('node 类别缺少 type 时不渲染 MForm', async () => {
|
||||
const wrapper = mount(CompareForm, {
|
||||
props: {
|
||||
|
||||
@ -35,7 +35,8 @@ vi.mock('@editor/utils/code-block', () => ({
|
||||
getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/form', () => ({
|
||||
vi.mock('@tmagic/form', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@tmagic/form')>()),
|
||||
MForm: defineComponent({
|
||||
name: 'MForm',
|
||||
props: ['config', 'initValues', 'disabled', 'labelWidth', 'context', 'size'],
|
||||
|
||||
@ -46,7 +46,8 @@ vi.mock('@editor/utils/code-block', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@tmagic/form', () => ({
|
||||
vi.mock('@tmagic/form', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@tmagic/form')>()),
|
||||
MForm: defineComponent({
|
||||
name: 'MForm',
|
||||
setup(_, { expose }) {
|
||||
|
||||
128
packages/editor/tests/unit/hooks/use-form-context.spec.ts
Normal file
128
packages/editor/tests/unit/hooks/use-form-context.spec.ts
Normal file
@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { computed, type ComputedRef, defineComponent, h, provide, ref } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { FORM_CONTEXT_KEY, type FormContext } from '@tmagic/form';
|
||||
|
||||
import { useEditorFormContext } from '@editor/hooks/use-form-context';
|
||||
import type { Services } from '@editor/type';
|
||||
|
||||
const makeServices = (stage: any = { name: 'stage' }): Services =>
|
||||
({
|
||||
editorService: { get: (key: string) => (key === 'stage' ? stage : undefined) },
|
||||
propsService: {},
|
||||
}) as unknown as Services;
|
||||
|
||||
/**
|
||||
* 在组件 setup 里调用 hook 并把结果取出来,可选地先在外层 provide 宿主上下文。
|
||||
*/
|
||||
const setup = (getServices: () => Services | undefined, hostContext?: ComputedRef<FormContext>) => {
|
||||
let context: ComputedRef<FormContext> | undefined;
|
||||
|
||||
const inner = defineComponent({
|
||||
setup() {
|
||||
context = useEditorFormContext(getServices);
|
||||
return () => h('div');
|
||||
},
|
||||
});
|
||||
|
||||
const outer = defineComponent({
|
||||
setup() {
|
||||
if (hostContext) provide(FORM_CONTEXT_KEY, hostContext);
|
||||
return () => h(inner);
|
||||
},
|
||||
});
|
||||
|
||||
mount(outer);
|
||||
|
||||
return context!;
|
||||
};
|
||||
|
||||
describe('useEditorFormContext', () => {
|
||||
test('没有宿主上下文时只提供 services 与 stage', () => {
|
||||
const stage = { name: 'stage-1' };
|
||||
const context = setup(() => makeServices(stage)) as any;
|
||||
|
||||
expect(context.value.services.editorService).toBeDefined();
|
||||
expect(context.value.stage).toBe(stage);
|
||||
});
|
||||
|
||||
test('getServices 返回 undefined 时不抛错', () => {
|
||||
const context = setup(() => undefined) as any;
|
||||
|
||||
expect(context.value.services).toBeUndefined();
|
||||
expect(context.value.stage).toBeUndefined();
|
||||
});
|
||||
|
||||
test('stage 读时求值,切换画布后拿到新实例', () => {
|
||||
const stage = ref<any>({ name: 'stage-1' });
|
||||
const context = setup(() => makeServices(stage.value)) as any;
|
||||
|
||||
expect(context.value.stage).toEqual({ name: 'stage-1' });
|
||||
stage.value = { name: 'stage-2' };
|
||||
expect(context.value.stage).toEqual({ name: 'stage-2' });
|
||||
});
|
||||
|
||||
test('合并宿主在外层 provide 的上下文,宿主字段不被遮蔽', () => {
|
||||
const host = computed(() => ({ username: 'alice', $store: { getters: {} } }) as unknown as FormContext);
|
||||
const context = setup(() => makeServices(), host) as any;
|
||||
|
||||
expect(context.value.username).toBe('alice');
|
||||
expect(context.value.$store.getters).toBeDefined();
|
||||
expect(context.value.services).toBeDefined();
|
||||
});
|
||||
|
||||
test('services / stage 由编辑器兜底,优先级高于宿主同名字段', () => {
|
||||
const stage = { name: 'editor-stage' };
|
||||
const host = computed(() => ({ stage: 'host-stage', services: 'host-services' }) as unknown as FormContext);
|
||||
const context = setup(() => makeServices(stage), host) as any;
|
||||
|
||||
expect(context.value.stage).toBe(stage);
|
||||
expect(context.value.services).not.toBe('host-services');
|
||||
});
|
||||
|
||||
/**
|
||||
* Editor.vue 已经用同一份 services 合并过,FormPanel / useCompareForm 都是它的后代。
|
||||
* 再合并一次只会多套一层 Proxy,让每次属性 miss 多一轮线性查找。
|
||||
*/
|
||||
test('上层已用同一份 services 合并过时直接复用,不再套一层 Proxy', () => {
|
||||
const services = makeServices();
|
||||
const host = computed(() => ({ services, username: 'alice' }) as unknown as FormContext);
|
||||
const context = setup(() => services, host) as any;
|
||||
|
||||
expect(context.value).toBe(host.value);
|
||||
expect(context.value.username).toBe('alice');
|
||||
});
|
||||
|
||||
test('services 不同则仍然合并,编辑器的 services 优先', () => {
|
||||
const hostServices = makeServices();
|
||||
const ownServices = makeServices({ name: 'own-stage' });
|
||||
const host = computed(() => ({ services: hostServices }) as unknown as FormContext);
|
||||
const context = setup(() => ownServices, host) as any;
|
||||
|
||||
expect(context.value).not.toBe(host.value);
|
||||
expect(context.value.services).toBe(ownServices);
|
||||
});
|
||||
|
||||
test('宿主上下文里的 accessor 保持读时求值', () => {
|
||||
const counter = ref(0);
|
||||
const host = computed(
|
||||
() =>
|
||||
({
|
||||
get buildVersion() {
|
||||
return counter.value;
|
||||
},
|
||||
}) as unknown as FormContext,
|
||||
);
|
||||
const context = setup(() => makeServices(), host) as any;
|
||||
|
||||
expect(context.value.buildVersion).toBe(0);
|
||||
counter.value = 3;
|
||||
expect(context.value.buildVersion).toBe(3);
|
||||
});
|
||||
});
|
||||
@ -4,9 +4,11 @@
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { computed, defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
|
||||
import { FORM_CONTEXT_KEY } from '@tmagic/form';
|
||||
|
||||
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
|
||||
import FormPanel from '@editor/layouts/props-panel/FormPanel.vue';
|
||||
|
||||
@ -111,6 +113,26 @@ describe('FormPanel', () => {
|
||||
expect(wrapper.findComponent({ name: 'MForm' }).exists()).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* 属性面板是宿主字段读不到时最先暴露问题的地方:编辑器自己 provide 一层 context 时
|
||||
* 不能把宿主在 <MEditor> 外层 provide 的那层遮蔽掉。
|
||||
*/
|
||||
test('宿主 provide 的上下文不被遮蔽,且 services / stage 由编辑器兜底', () => {
|
||||
const wrapper = mount(FormPanel, {
|
||||
props: { config: [], values: {} } as any,
|
||||
global: {
|
||||
provide: {
|
||||
[FORM_CONTEXT_KEY as symbol]: computed(() => ({ username: 'alice', services: 'host-services' })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const context = wrapper.findComponent({ name: 'MForm' }).props('context') as any;
|
||||
expect(context.username).toBe('alice');
|
||||
expect(context.services).not.toBe('host-services');
|
||||
expect(context.stage).toEqual({ id: 'stage' });
|
||||
});
|
||||
|
||||
test('mounted 事件 emit', async () => {
|
||||
const wrapper = mount(FormPanel, { props: { config: [], values: {} } as any });
|
||||
await nextTick();
|
||||
|
||||
@ -57,7 +57,7 @@
|
||||
<component
|
||||
v-for="option in options as SelectOption[]"
|
||||
class="tmagic-design-option"
|
||||
:key="config.valueKey ? option.value[config.valueKey] : option.value"
|
||||
:key="config.valueKey ? option.value?.[config.valueKey] : option.value"
|
||||
:is="optionComponent?.component || 'el-option'"
|
||||
v-bind="
|
||||
optionComponent?.props({
|
||||
|
||||
@ -56,6 +56,37 @@ export const mergeFormContexts = (...layers: (FormContext | undefined | null)[])
|
||||
}) as FormContext;
|
||||
};
|
||||
|
||||
/**
|
||||
* 存量下发配置沿用 Vue2 时代的写法,把回调第一个参数当组件实例用,靠 `vm.mForm.xxx`
|
||||
* 取表单状态。这类配置除了读,还会往上面挂方法做跨字段通信,例如:
|
||||
*
|
||||
* ```js
|
||||
* vm.mForm.checkPropertyLimit = async (...) => { ... } // 一个字段的 validator 里挂
|
||||
* await vm.mForm.checkPropertyLimit(...) // 另一处再取出来调用
|
||||
* ```
|
||||
*
|
||||
* 所以 `mForm` 必须能指回某个 formState:读能落到 core / context,写能经 `set` trap
|
||||
* 落到 coreState 并持久化。
|
||||
*
|
||||
* 优先级是 core > context > 合成自引用,三者都是刻意的:
|
||||
*
|
||||
* - **core**:`formState.mForm = formState` 这类直写(如 FormPreview 用自建 services 时)
|
||||
* 必须最优先。
|
||||
* - **context**:唯一的生产者是把父 formState 整体当 context 传下来的嵌套表单
|
||||
* (ComponentForm)。此时 `mForm` 命中 context 指向**父表单**,与 `extendState` 时代
|
||||
* 把父 formState 并入子状态的结果一致,跨字段通信仍落在同一份对象上。父 formState
|
||||
* 本身也是 Proxy,写入照样持久化,所以这里让 context 赢是对的,别「修正」成指向子表单。
|
||||
* - **合成自引用**:前两者都没有时才兜底,让 `vm.mForm.xxx = fn` 落到自己的 coreState。
|
||||
*
|
||||
* 由此推出一条约束:**不要往 context 里塞普通对象充当 `mForm`**。context 通常是 computed
|
||||
* 产物,依赖一变就重建,挂上去的方法会静默丢失。要么直写 core,要么什么都不放交给兜底。
|
||||
*
|
||||
* 枚举语义上合成的自引用表现得像原型链上的属性:`'mForm' in state` 为真,但不出现在
|
||||
* `Object.keys(state)` 里,`getOwnPropertyDescriptor` 也返回 undefined。这是故意的——
|
||||
* 按扩展字段打包 formState 的调用方(如发给 AI 的逻辑)会因循环引用炸掉。
|
||||
*/
|
||||
const SELF_REF_KEY = 'mForm';
|
||||
|
||||
/**
|
||||
* 将 coreState 与宿主业务上下文关联:读取时优先 core,miss 再读穿到 context。
|
||||
*
|
||||
@ -72,21 +103,33 @@ export const createFormStateProxy = (
|
||||
coreState: FormState,
|
||||
getContext: (() => FormContext) | Ref<FormContext>,
|
||||
): FormState => {
|
||||
const resolve = (): Record<string | symbol, any> =>
|
||||
(typeof getContext === 'function' ? getContext() : unref(getContext)) || EMPTY_CONTEXT;
|
||||
const resolve = (): Record<string | symbol, any> => {
|
||||
const ctx = (typeof getContext === 'function' ? getContext() : unref(getContext)) || EMPTY_CONTEXT;
|
||||
// 把 formState 自己当 context 传回来(`:context="formState"`)会让 get / has 无限递归,
|
||||
// 直接爆栈。这种自引用本就提供不了任何额外字段,断掉即可。
|
||||
return ctx === proxy ? EMPTY_CONTEXT : ctx;
|
||||
};
|
||||
|
||||
return new Proxy(coreState as object, {
|
||||
const proxy = new Proxy(coreState as object, {
|
||||
get(t, k) {
|
||||
if (typeof k === 'symbol') return Reflect.get(t, k);
|
||||
const v = (t as any)[k];
|
||||
if (v !== undefined || Reflect.has(t, k)) return v;
|
||||
return resolve()[k];
|
||||
|
||||
const ctx = resolve();
|
||||
if (k in ctx) return ctx[k];
|
||||
// 自引用不进 ownKeys:枚举 formState 的调用方(如按扩展字段打包发给 AI 的逻辑)
|
||||
// 会因为循环引用炸掉,这里只在显式读取时才合成
|
||||
return k === SELF_REF_KEY ? proxy : undefined;
|
||||
},
|
||||
set(t, k, value) {
|
||||
(t as any)[k] = value;
|
||||
return true;
|
||||
},
|
||||
has: (t, k) => Reflect.has(t, k) || (typeof k !== 'symbol' && k in resolve()),
|
||||
// `mForm` 在这里为真,但不进 ownKeys、getOwnPropertyDescriptor 也返回 undefined
|
||||
// (见 SELF_REF_KEY 注释)。因此展开 / Object.entries 不会循环引用,
|
||||
// 但对 proxy 直接做递归遍历(`for...in`、深拷贝、直接 JSON.stringify(formState))仍会。
|
||||
has: (t, k) => Reflect.has(t, k) || (typeof k !== 'symbol' && (k in resolve() || k === SELF_REF_KEY)),
|
||||
ownKeys: (t) => [
|
||||
...new Set([...Reflect.ownKeys(t), ...Reflect.ownKeys(resolve()).filter((k) => typeof k !== 'symbol')]),
|
||||
],
|
||||
@ -101,4 +144,6 @@ export const createFormStateProxy = (
|
||||
return { configurable: true, enumerable: true, writable: true, value: ctx[k] };
|
||||
},
|
||||
}) as FormState;
|
||||
|
||||
return proxy;
|
||||
};
|
||||
|
||||
311
packages/form/tests/unit/fields/Select.remote.spec.ts
Normal file
311
packages/form/tests/unit/fields/Select.remote.spec.ts
Normal file
@ -0,0 +1,311 @@
|
||||
/*
|
||||
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||
*
|
||||
* Copyright (C) 2025 Tencent.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { defineComponent, h, nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
|
||||
/**
|
||||
* 远程模式下的滚动分页依赖 `tMagicSelect.value.scrollbarWrap`,而真实 ElSelect 并不暴露它,
|
||||
* 因此这里替换掉 TMagicSelect,换成一个只暴露所需实例方法的壳组件。
|
||||
*
|
||||
* 壳组件不渲染默认插槽:el-option 需要 inject ElSelect 的上下文,脱离真实 select 会报错,
|
||||
* 而这批用例只关心脚本里的远程加载逻辑,不关心选项怎么渲染。
|
||||
*/
|
||||
const selectStub = {
|
||||
scrollbarWrap: undefined as HTMLElement | undefined,
|
||||
setQuery: vi.fn(),
|
||||
setPreviousQuery: vi.fn(),
|
||||
setSelectedLabel: vi.fn(),
|
||||
setSelected: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@tmagic/design', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@tmagic/design')>()),
|
||||
TMagicSelect: defineComponent({
|
||||
name: 'TMagicSelectStub',
|
||||
props: { modelValue: null, remoteMethod: { type: Function, default: undefined } },
|
||||
emits: ['update:modelValue', 'visible-change'],
|
||||
setup(_props, { expose }) {
|
||||
expose(selectStub);
|
||||
return () => h('div', { class: 'stub-select' });
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const MagicForm = (await import('@form/index')).default;
|
||||
const { MForm, MSelect } = await import('@form/index');
|
||||
const { setConfig } = await import('@form/utils/config');
|
||||
|
||||
let request: ReturnType<typeof vi.fn>;
|
||||
|
||||
const flushAsync = async () => {
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
const mountSelect = (config: any, initValues: any = {}) =>
|
||||
mount(MForm, {
|
||||
global: { plugins: [ElementPlus as any, [MagicForm as any, { request }]] },
|
||||
props: { config: [{ name: 's', type: 'select', text: 's', ...config }], initValues },
|
||||
});
|
||||
|
||||
const getStub = (wrapper: any) => wrapper.findComponent({ name: 'TMagicSelectStub' });
|
||||
|
||||
/**
|
||||
* 远程用例统一带上 initUrl。
|
||||
*
|
||||
* MForm 会把无初值的字段初始化成 ''(不是 undefined),而 getInitOption 在没有 initUrl 时
|
||||
* 会直接落到 getInitLocalOption 去打列表接口,把结果缓存进 localOptions——后续 getOptions
|
||||
* 就命中缓存不再发请求,远程分支全都测不到。带上 initUrl 后,空值会在 init 的空值守卫处
|
||||
* 提前返回,localOptions 保持为空,展开 / 搜索 / 触底才会真正走远程加载。
|
||||
*/
|
||||
const remoteOption = (extra: any = {}) => ({
|
||||
url: 'https://example.com/list',
|
||||
initUrl: 'https://example.com/init',
|
||||
root: 'data.list',
|
||||
totalKey: 'total',
|
||||
...extra,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const wrap = document.createElement('div');
|
||||
selectStub.scrollbarWrap = wrap;
|
||||
selectStub.setQuery.mockClear();
|
||||
selectStub.setPreviousQuery.mockClear();
|
||||
selectStub.setSelectedLabel.mockClear();
|
||||
selectStub.setSelected.mockClear();
|
||||
|
||||
request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: 'a' }] }, total: 50 }));
|
||||
setConfig({ request });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setConfig({});
|
||||
});
|
||||
|
||||
describe('Select - visibleHandler', () => {
|
||||
test('下拉收起时不触发请求', async () => {
|
||||
const wrapper = mountSelect({
|
||||
remote: true,
|
||||
option: remoteOption(),
|
||||
});
|
||||
await flushAsync();
|
||||
request.mockClear();
|
||||
|
||||
getStub(wrapper).vm.$emit('visible-change', false);
|
||||
await flushAsync();
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('非 remote 配置展开下拉时不触发请求', async () => {
|
||||
const wrapper = mountSelect({ option: remoteOption() });
|
||||
await flushAsync();
|
||||
request.mockClear();
|
||||
|
||||
getStub(wrapper).vm.$emit('visible-change', true);
|
||||
await flushAsync();
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('remote 展开且选项不足时拉取选项', async () => {
|
||||
const wrapper = mountSelect({
|
||||
remote: true,
|
||||
option: remoteOption(),
|
||||
});
|
||||
await flushAsync();
|
||||
request.mockClear();
|
||||
|
||||
getStub(wrapper).vm.$emit('visible-change', true);
|
||||
await flushAsync();
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: 'a' }]);
|
||||
});
|
||||
|
||||
test('已有搜索词时展开下拉,回填搜索状态而不重新拉取', async () => {
|
||||
const wrapper = mountSelect({
|
||||
remote: true,
|
||||
option: remoteOption(),
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
// 先通过远程搜索写入 query
|
||||
await getStub(wrapper).props('remoteMethod')('kw');
|
||||
await flushAsync();
|
||||
request.mockClear();
|
||||
|
||||
getStub(wrapper).vm.$emit('visible-change', true);
|
||||
await flushAsync();
|
||||
|
||||
expect(selectStub.setQuery).toHaveBeenCalledWith('kw');
|
||||
expect(selectStub.setPreviousQuery).toHaveBeenCalledWith('kw');
|
||||
expect(selectStub.setSelectedLabel).toHaveBeenCalledWith('kw');
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select - remoteMethod', () => {
|
||||
test('远程搜索会重置分页并按关键词请求', async () => {
|
||||
const wrapper = mountSelect({
|
||||
remote: true,
|
||||
option: remoteOption(),
|
||||
});
|
||||
await flushAsync();
|
||||
request.mockClear();
|
||||
|
||||
await getStub(wrapper).props('remoteMethod')('kw');
|
||||
await flushAsync();
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request.mock.calls[0][0].data).toMatchObject({ query: 'kw', pgIndex: 0 });
|
||||
});
|
||||
|
||||
test('多选远程搜索后刷新已选状态', async () => {
|
||||
const wrapper = mountSelect(
|
||||
{
|
||||
remote: true,
|
||||
multiple: true,
|
||||
option: remoteOption(),
|
||||
},
|
||||
{ s: [] },
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
await getStub(wrapper).props('remoteMethod')('kw');
|
||||
await flushAsync();
|
||||
|
||||
expect(selectStub.setSelected).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('已有本地选项时远程搜索不再请求', async () => {
|
||||
// 无 initUrl:初始化会走 getInitLocalOption,把结果缓存进 localOptions
|
||||
const wrapper = mountSelect(
|
||||
{
|
||||
remote: true,
|
||||
option: { url: 'https://example.com/list', root: 'data.list' },
|
||||
},
|
||||
{ s: 'a' },
|
||||
);
|
||||
await flushAsync();
|
||||
request.mockClear();
|
||||
|
||||
await getStub(wrapper).props('remoteMethod')('kw');
|
||||
await flushAsync();
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select - 滚动分页', () => {
|
||||
const scrollToBottom = async (wrapper: any) => {
|
||||
selectStub.scrollbarWrap!.dispatchEvent(new Event('scroll'));
|
||||
await flushAsync();
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
const remoteConfig = { remote: true, option: remoteOption() };
|
||||
|
||||
test('触底且仍有剩余数据时加载下一页', async () => {
|
||||
const wrapper = mountSelect(remoteConfig);
|
||||
await flushAsync();
|
||||
|
||||
// 先展开一次填充 options 与 total(total=50 > 已加载 1 条)
|
||||
getStub(wrapper).vm.$emit('visible-change', true);
|
||||
await flushAsync();
|
||||
request.mockClear();
|
||||
|
||||
await scrollToBottom(wrapper);
|
||||
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(request.mock.calls[0][0].data).toMatchObject({ pgIndex: 1 });
|
||||
});
|
||||
|
||||
test('已加载条数达到总数时触底不再请求', async () => {
|
||||
request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: 'a' }] }, total: 1 }));
|
||||
setConfig({ request });
|
||||
|
||||
const wrapper = mountSelect(remoteConfig);
|
||||
await flushAsync();
|
||||
getStub(wrapper).vm.$emit('visible-change', true);
|
||||
await flushAsync();
|
||||
request.mockClear();
|
||||
|
||||
await scrollToBottom(wrapper);
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('未触底时不加载下一页', async () => {
|
||||
const wrapper = mountSelect(remoteConfig);
|
||||
await flushAsync();
|
||||
getStub(wrapper).vm.$emit('visible-change', true);
|
||||
await flushAsync();
|
||||
request.mockClear();
|
||||
|
||||
// 距底部还有距离
|
||||
Object.defineProperty(selectStub.scrollbarWrap!, 'scrollHeight', { value: 500, configurable: true });
|
||||
Object.defineProperty(selectStub.scrollbarWrap!, 'clientHeight', { value: 100, configurable: true });
|
||||
await scrollToBottom(wrapper);
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select - 多选保留已选项', () => {
|
||||
test('新一页结果不含已选项时,已选项仍保留在选项列表里', async () => {
|
||||
// initUrl 让初始化走 init 接口,localOptions 保持为空,
|
||||
// 这样后续展开下拉才会真正调用列表接口
|
||||
request = vi.fn(async (postOptions: Record<string, any>) => {
|
||||
if (postOptions.url.includes('init')) {
|
||||
return { data: { list: [{ text: 'A', value: 'a' }] } };
|
||||
}
|
||||
return { data: { list: [{ text: 'B', value: 'b' }] }, total: 50 };
|
||||
});
|
||||
setConfig({ request });
|
||||
|
||||
const wrapper = mountSelect(
|
||||
{
|
||||
remote: true,
|
||||
multiple: true,
|
||||
option: {
|
||||
url: 'https://example.com/list',
|
||||
initUrl: 'https://example.com/init',
|
||||
initRoot: 'data.list',
|
||||
root: 'data.list',
|
||||
totalKey: 'total',
|
||||
},
|
||||
},
|
||||
{ s: ['a'] },
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: 'a' }]);
|
||||
|
||||
getStub(wrapper).vm.$emit('visible-change', true);
|
||||
await flushAsync();
|
||||
|
||||
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([
|
||||
{ text: 'A', value: 'a' },
|
||||
{ text: 'B', value: 'b' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Select - changeHandler', () => {
|
||||
test('选中值变化时向上抛 change', async () => {
|
||||
const wrapper = mountSelect({ options: [{ text: 'A', value: 'a' }] }, { s: '' });
|
||||
await flushAsync();
|
||||
|
||||
getStub(wrapper).vm.$emit('update:modelValue', 'a');
|
||||
await flushAsync();
|
||||
|
||||
expect(wrapper.findComponent(MSelect).emitted('change')?.[0]).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
@ -76,6 +76,81 @@ describe('Select', () => {
|
||||
expect(wrapper.findComponent(MSelect).exists()).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* key 必须始终取 valueKey 指向的值。曾经写成 `valueKey && option.value?.[valueKey] ? ... : option.value`,
|
||||
* 把嵌套值当布尔用,导致 `{ id: 0 }` 这类假值退化成「整个对象」当 key:
|
||||
* valueKey 被静默忽略、key 类型在数字与对象间不一致,且对象引用每次重建列表都会变,
|
||||
* Vue 只能销毁重建而非按 key 复用。对象值下拉里 id 为 0 很常见。
|
||||
*/
|
||||
test('valueKey 指向假值时,key 仍取该值而非整个对象', async () => {
|
||||
const wrapper = mountForm(
|
||||
[
|
||||
{
|
||||
name: 's',
|
||||
type: 'select',
|
||||
text: 's',
|
||||
valueKey: 'id',
|
||||
options: [
|
||||
{ text: 'A', value: { id: 0 } },
|
||||
{ text: 'B', value: { id: 1 } },
|
||||
{ text: 'C', value: { id: '' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
{ s: { id: 1 } },
|
||||
);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const keys = wrapper.findAllComponents({ name: 'ElOption' }).map((o) => (o.vm as any).$.vnode.key);
|
||||
expect(keys).toEqual([0, 1, '']);
|
||||
});
|
||||
|
||||
test('valueKey 配置下 option.value 为 null 不抛错', async () => {
|
||||
const wrapper = mountForm(
|
||||
[
|
||||
{
|
||||
name: 's',
|
||||
type: 'select',
|
||||
text: 's',
|
||||
valueKey: 'id',
|
||||
options: [
|
||||
{ text: 'A', value: { id: 1 } },
|
||||
{ text: 'B', value: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
{},
|
||||
);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const keys = wrapper.findAllComponents({ name: 'ElOption' }).map((o) => (o.vm as any).$.vnode.key);
|
||||
expect(keys).toEqual([1, null]);
|
||||
});
|
||||
|
||||
test('未配置 valueKey 时 key 取 option.value 本身', async () => {
|
||||
const wrapper = mountForm(
|
||||
[
|
||||
{
|
||||
name: 's',
|
||||
type: 'select',
|
||||
text: 's',
|
||||
options: [
|
||||
{ text: 'A', value: 0 },
|
||||
{ text: 'B', value: 'b' },
|
||||
],
|
||||
},
|
||||
],
|
||||
{},
|
||||
);
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const keys = wrapper.findAllComponents({ name: 'ElOption' }).map((o) => (o.vm as any).$.vnode.key);
|
||||
expect(keys).toEqual([0, 'b']);
|
||||
});
|
||||
|
||||
test('multiple 多选', async () => {
|
||||
const wrapper = mountForm(
|
||||
[
|
||||
@ -333,3 +408,287 @@ describe('Select - config.option model value watch', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 无 initUrl 时走 getInitLocalOption -> getOptions(列表接口)这条路径,
|
||||
* 覆盖 option 上的各类钩子与动态取值。
|
||||
*/
|
||||
describe('Select - getOptions 列表接口分支', () => {
|
||||
let request: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mountFormWithRequest = (config: any[], initValues: any = {}) =>
|
||||
mount(MForm, {
|
||||
global: { plugins: [ElementPlus as any, [MagicForm as any, { request }]] },
|
||||
props: { config, initValues },
|
||||
});
|
||||
|
||||
const flushAsync = async () => {
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
const buildConfig = (option: any, extra: any = {}) => [{ name: 's', type: 'select', text: 's', ...extra, option }];
|
||||
|
||||
beforeEach(() => {
|
||||
request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: 'a' }] }, total: 50 }));
|
||||
setConfig({ request });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setConfig({});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('url 与 body 为函数时先求值再请求,并带上分页参数', async () => {
|
||||
const url = vi.fn(async () => 'https://example.com/dyn');
|
||||
const body = vi.fn(() => ({ extra: 1 }));
|
||||
|
||||
mountFormWithRequest(buildConfig({ url, body, root: 'data.list' }), { s: 'a' });
|
||||
await flushAsync();
|
||||
|
||||
expect(url).toHaveBeenCalled();
|
||||
expect(body).toHaveBeenCalled();
|
||||
const arg = request.mock.calls[0][0];
|
||||
expect(arg.url).toBe('https://example.com/dyn');
|
||||
expect(arg.data).toMatchObject({ extra: 1, query: '', pgSize: 20, pgIndex: 0 });
|
||||
});
|
||||
|
||||
test('beforeRequest / afterRequest 钩子可改写请求与响应', async () => {
|
||||
const beforeRequest = vi.fn(async (_mForm: any, postOptions: any) => ({
|
||||
...postOptions,
|
||||
headers: { token: 't' },
|
||||
}));
|
||||
const afterRequest = vi.fn(async () => ({ data: { list: [{ text: 'AR', value: 'a' }] } }));
|
||||
|
||||
const wrapper = mountFormWithRequest(
|
||||
buildConfig({ url: 'https://example.com/list', root: 'data.list', beforeRequest, afterRequest }),
|
||||
{ s: 'a' },
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(beforeRequest).toHaveBeenCalled();
|
||||
expect(afterRequest).toHaveBeenCalled();
|
||||
expect(request.mock.calls[0][0].headers).toEqual({ token: 't' });
|
||||
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'AR', value: 'a' }]);
|
||||
});
|
||||
|
||||
test('method 为 jsonp 时补 jsonpCallback', async () => {
|
||||
mountFormWithRequest(buildConfig({ url: 'https://example.com/list', root: 'data.list', method: 'jsonp' }), {
|
||||
s: 'a',
|
||||
});
|
||||
await flushAsync();
|
||||
|
||||
expect(request.mock.calls[0][0]).toMatchObject({ method: 'jsonp', jsonpCallback: 'callback' });
|
||||
});
|
||||
|
||||
test('自定义 jsonpCallback 优先', async () => {
|
||||
mountFormWithRequest(
|
||||
buildConfig({ url: 'https://example.com/list', root: 'data.list', method: 'JSONP', jsonpCallback: 'cb' }),
|
||||
{ s: 'a' },
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(request.mock.calls[0][0].jsonpCallback).toBe('cb');
|
||||
});
|
||||
|
||||
test('option.item 自定义映射结果,totalKey 命中时记录总数', async () => {
|
||||
request = vi.fn(async () => ({ data: { list: [{ n: 'A', v: 'a' }] }, total: 50 }));
|
||||
setConfig({ request });
|
||||
|
||||
const item = vi.fn((data: any[]) => data.map((d) => ({ text: d.n, value: d.v })));
|
||||
const wrapper = mountFormWithRequest(
|
||||
buildConfig({ url: 'https://example.com/list', root: 'data.list', totalKey: 'total', item }),
|
||||
{ s: 'a' },
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(item).toHaveBeenCalled();
|
||||
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: 'a' }]);
|
||||
});
|
||||
|
||||
test('option.text / option.value 为函数时按函数取值', async () => {
|
||||
request = vi.fn(async () => ({ data: { list: [{ n: 'A', v: 'a' }] } }));
|
||||
setConfig({ request });
|
||||
|
||||
const wrapper = mountFormWithRequest(
|
||||
buildConfig({
|
||||
url: 'https://example.com/list',
|
||||
root: 'data.list',
|
||||
text: (i: any) => `T-${i.n}`,
|
||||
value: (i: any) => i.v,
|
||||
}),
|
||||
{ s: 'a' },
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'T-A', value: 'a' }]);
|
||||
});
|
||||
|
||||
test('valueKey 下用对象值比对,命中后不再重复请求', async () => {
|
||||
request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: { id: 'a' } }] } }));
|
||||
setConfig({ request });
|
||||
|
||||
const wrapper = mountFormWithRequest(
|
||||
buildConfig({ url: 'https://example.com/list', root: 'data.list' }, { valueKey: 'id' }),
|
||||
{ s: { id: 'a' } },
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: { id: 'a' } }]);
|
||||
|
||||
// 已有匹配项,hasOption 命中后不应再打接口
|
||||
const count = request.mock.calls.length;
|
||||
(wrapper.vm as any).values.s = { id: 'a' };
|
||||
await flushAsync();
|
||||
expect(request.mock.calls.length).toBe(count);
|
||||
});
|
||||
|
||||
test('group 单选:按组过滤出命中项所在的组', async () => {
|
||||
request = vi.fn(async () => ({ data: { list: [{ text: 'A', value: 'a' }] } }));
|
||||
setConfig({ request });
|
||||
|
||||
const item = vi.fn(() => [
|
||||
{ label: 'G1', options: [{ text: 'A', value: 'a' }] },
|
||||
{ label: 'G2', options: [{ text: 'B', value: 'b' }] },
|
||||
]);
|
||||
const wrapper = mountFormWithRequest(
|
||||
buildConfig({ url: 'https://example.com/list', root: 'data.list', item }, { group: true }),
|
||||
{ s: 'a' },
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
const opts = (wrapper.findComponent(MSelect).vm as any).options;
|
||||
expect(opts).toHaveLength(1);
|
||||
expect(opts[0].label).toBe('G1');
|
||||
});
|
||||
|
||||
test('group 多选:按组过滤出命中项所在的组', async () => {
|
||||
request = vi.fn(async () => ({ data: { list: [] } }));
|
||||
setConfig({ request });
|
||||
|
||||
const item = vi.fn(() => [
|
||||
{ label: 'G1', options: [{ text: 'A', value: 1 }] },
|
||||
{ label: 'G2', options: [{ text: 'B', value: 2 }] },
|
||||
]);
|
||||
const wrapper = mountFormWithRequest(
|
||||
buildConfig({ url: 'https://example.com/list', root: 'data.list', item }, { group: true, multiple: true }),
|
||||
{ s: [2] },
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
const opts = (wrapper.findComponent(MSelect).vm as any).options;
|
||||
expect(opts).toHaveLength(1);
|
||||
expect(opts[0].label).toBe('G2');
|
||||
});
|
||||
|
||||
test('本地选项已加载后再次触发不重复请求', async () => {
|
||||
const wrapper = mountFormWithRequest(buildConfig({ url: 'https://example.com/list', root: 'data.list' }), {
|
||||
s: 'a',
|
||||
});
|
||||
await flushAsync();
|
||||
const count = request.mock.calls.length;
|
||||
|
||||
// 换一个不在 options 里的值:会再次走 getInitLocalOption,
|
||||
// 但 localOptions 已有缓存,getOptions 直接返回不再打接口
|
||||
(wrapper.vm as any).values.s = 'zzz';
|
||||
await flushAsync();
|
||||
|
||||
expect(request.mock.calls.length).toBe(count);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* initUrl 存在时走 getInitOption 这条路径,覆盖 init 专用的钩子与非数组响应。
|
||||
*/
|
||||
describe('Select - getInitOption 初始化接口分支', () => {
|
||||
let request: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mountFormWithRequest = (option: any, initValues: any = {}) =>
|
||||
mount(MForm, {
|
||||
global: { plugins: [ElementPlus as any, [MagicForm as any, { request }]] },
|
||||
props: { config: [{ name: 's', type: 'select', text: 's', option }], initValues },
|
||||
});
|
||||
|
||||
const flushAsync = async () => {
|
||||
await nextTick();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await nextTick();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
request = vi.fn(async () => ({ data: { obj: { text: 'A', value: 'a' } } }));
|
||||
setConfig({ request });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setConfig({});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('initUrl / initBody 为函数,且 beforeInitRequest 可改写请求', async () => {
|
||||
const initUrl = vi.fn(async () => 'https://example.com/init-dyn');
|
||||
const initBody = vi.fn(() => ({ b: 1 }));
|
||||
const beforeInitRequest = vi.fn(async (_mForm: any, postOptions: any) => ({
|
||||
...postOptions,
|
||||
headers: { token: 't' },
|
||||
}));
|
||||
|
||||
mountFormWithRequest({ initUrl, initBody, beforeInitRequest, initRoot: 'data.obj' }, { s: 'a' });
|
||||
await flushAsync();
|
||||
|
||||
expect(initUrl).toHaveBeenCalled();
|
||||
expect(initBody).toHaveBeenCalled();
|
||||
expect(beforeInitRequest).toHaveBeenCalled();
|
||||
|
||||
const arg = request.mock.calls[0][0];
|
||||
expect(arg.url).toBe('https://example.com/init-dyn');
|
||||
expect(arg.data).toMatchObject({ id: 'a', b: 1 });
|
||||
expect(arg.headers).toEqual({ token: 't' });
|
||||
});
|
||||
|
||||
test('init 请求为 jsonp 时补 jsonpCallback', async () => {
|
||||
mountFormWithRequest({ initUrl: 'https://example.com/init', initRoot: 'data.obj', method: 'jsonp' }, { s: 'a' });
|
||||
await flushAsync();
|
||||
|
||||
expect(request.mock.calls[0][0]).toMatchObject({ method: 'jsonp', jsonpCallback: 'callback' });
|
||||
});
|
||||
|
||||
test('afterRequest 可改写 init 响应;非数组结果会被包成数组', async () => {
|
||||
const afterRequest = vi.fn(async () => ({ data: { obj: { text: 'ONE', value: 'a' } } }));
|
||||
|
||||
const wrapper = mountFormWithRequest(
|
||||
{ initUrl: 'https://example.com/init', initRoot: 'data.obj', afterRequest },
|
||||
{ s: 'a' },
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(afterRequest).toHaveBeenCalled();
|
||||
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'ONE', value: 'a' }]);
|
||||
});
|
||||
|
||||
test('init 结果走 option.item 自定义映射', async () => {
|
||||
request = vi.fn(async () => ({ data: { obj: [{ n: 'A', v: 'a' }] } }));
|
||||
setConfig({ request });
|
||||
|
||||
const item = vi.fn((data: any[]) => data.map((d) => ({ text: d.n, value: d.v })));
|
||||
const wrapper = mountFormWithRequest(
|
||||
{ initUrl: 'https://example.com/init', initRoot: 'data.obj', item },
|
||||
{ s: 'a' },
|
||||
);
|
||||
await flushAsync();
|
||||
|
||||
expect(item).toHaveBeenCalled();
|
||||
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([{ text: 'A', value: 'a' }]);
|
||||
});
|
||||
|
||||
test('init 响应取不到数据时 options 为空', async () => {
|
||||
request = vi.fn(async () => ({}));
|
||||
setConfig({ request });
|
||||
|
||||
const wrapper = mountFormWithRequest({ initUrl: 'https://example.com/init', initRoot: 'data.obj' }, { s: 'a' });
|
||||
await flushAsync();
|
||||
|
||||
expect((wrapper.findComponent(MSelect).vm as any).options).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -106,6 +106,77 @@ describe('createFormStateProxy', () => {
|
||||
expect(Object.getOwnPropertyDescriptor(formState, 'username')?.enumerable).toBe(true);
|
||||
});
|
||||
|
||||
test('mForm 自引用回 formState,读能穿到 core 与 context', () => {
|
||||
const formState = createFormStateProxy(makeCore({ keyProp: 'id' }), () => ({ username: 'alice' }));
|
||||
const vm = formState as any;
|
||||
|
||||
expect(vm.mForm).toBe(formState);
|
||||
expect(vm.mForm.keyProp).toBe('id');
|
||||
expect(vm.mForm.username).toBe('alice');
|
||||
expect('mForm' in formState).toBe(true);
|
||||
});
|
||||
|
||||
test('往 mForm 上挂的方法落到 core 并持久可读,跨回调可取回', () => {
|
||||
const core = makeCore();
|
||||
const formState = createFormStateProxy(core, () => ({ username: 'alice' }));
|
||||
const vm = formState as any;
|
||||
|
||||
// 存量配置的跨字段通信写法:一个 validator 里挂,另一处取出来调用
|
||||
const check = () => 'checked';
|
||||
vm.mForm.checkPropertyLimit = check;
|
||||
|
||||
expect((core as any).checkPropertyLimit).toBe(check);
|
||||
expect(vm.mForm.checkPropertyLimit()).toBe('checked');
|
||||
expect(vm.checkPropertyLimit).toBe(check);
|
||||
});
|
||||
|
||||
test('mForm 不进入枚举,避免循环引用', () => {
|
||||
const formState = createFormStateProxy(makeCore(), () => ({ username: 'alice' }));
|
||||
|
||||
expect(Object.keys(formState)).not.toContain('mForm');
|
||||
expect(() => JSON.stringify({ ...formState })).not.toThrow();
|
||||
});
|
||||
|
||||
test('core 或 context 显式提供 mForm 时不被自引用覆盖', () => {
|
||||
const host = { name: 'host-mForm' };
|
||||
const fromContext = createFormStateProxy(makeCore(), () => ({ mForm: host }) as any);
|
||||
expect((fromContext as any).mForm).toBe(host);
|
||||
|
||||
const own = { name: 'core-mForm' };
|
||||
const fromCore = createFormStateProxy(makeCore({ mForm: own }), () => ({ mForm: host }) as any);
|
||||
expect((fromCore as any).mForm).toEqual(own);
|
||||
});
|
||||
|
||||
/**
|
||||
* 嵌套表单(ComponentForm)把父 formState 整体当 context 传给子表单,
|
||||
* 此时 mForm 应命中 context 指向父表单——与 extendState 时代把父状态并入子状态一致,
|
||||
* 跨字段通信仍落在同一份对象上。不要「修正」成指向子表单。
|
||||
*/
|
||||
test('父 formState 作为 context 时,mForm 指向父表单而非自引用', () => {
|
||||
const parent = createFormStateProxy(makeCore({ owner: 'parent' }), () => ({}) as any);
|
||||
const child = createFormStateProxy(makeCore({ owner: 'child' }), () => parent as any);
|
||||
|
||||
expect((child as any).mForm).toBe(parent);
|
||||
// 子表单自己的 core 字段仍优先,不被父级覆盖
|
||||
expect((child as any).owner).toBe('child');
|
||||
|
||||
// 跨字段通信:一个回调往 mForm 上挂,另一个回调取回,落在同一份对象上
|
||||
(child as any).mForm.checkLimit = () => 'ok';
|
||||
expect((parent as any).checkLimit()).toBe('ok');
|
||||
});
|
||||
|
||||
test('把 formState 自己当 context 传回来不爆栈', () => {
|
||||
// 闭包只在 trap 触发时求值,构造期不会读到未初始化的 formState
|
||||
const formState: any = createFormStateProxy(makeCore({ a: 1 }), () => formState);
|
||||
|
||||
expect(formState.a).toBe(1);
|
||||
expect(formState.missing).toBeUndefined();
|
||||
expect('missing' in formState).toBe(false);
|
||||
// 自引用不提供额外字段,mForm 仍由兜底合成
|
||||
expect(formState.mForm).toBe(formState);
|
||||
expect(Object.keys(formState)).toContain('a');
|
||||
});
|
||||
|
||||
test('getContext 可以是 Ref', () => {
|
||||
const ctx = ref({ username: 'alice' });
|
||||
const formState = createFormStateProxy(makeCore(), ctx);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user