fix(editor,form): 合并宿主 FORM_CONTEXT 并修复 formState mForm 自引用

编辑器不再遮蔽外层 provide 的业务上下文。
formState Proxy 支持 Vue2 时代 vm.mForm 跨字段通信,
并修复 context 自引用导致的栈溢出。
Select 对 valueKey 路径使用可选链。
This commit is contained in:
roymondchen 2026-09-02 16:44:01 +08:00
parent ad2f4e15cc
commit b0d43dc277
12 changed files with 1045 additions and 18 deletions

View File

@ -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'),
};
});
});
};

View File

@ -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 });

View File

@ -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: {

View File

@ -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'],

View File

@ -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 }) {

View 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);
});
});

View File

@ -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();

View File

@ -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({

View File

@ -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 宿 coremiss 穿 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;
};

View 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 与 totaltotal=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']);
});
});

View File

@ -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([]);
});
});

View File

@ -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);