roymondchen a100462a52 feat(editor): enablePropsFormValidate 改为响应式注入并接管 typeMatchValid / validateOnInit
ENABLE_PROPS_FORM_VALIDATE 原先 provide 的是布尔快照,父组件动态切换
enablePropsFormValidate 后子孙组件拿不到新值。改为与 isLargeStageContainer
一致用 computed 注入,注入键类型变为 InjectionKey<ComputedRef<boolean>>,
PropsPanel / FormPanel 侧统一按 .value 读取。

同时 FormPanel 内 MForm 的 type-match-valid / validate-on-init 由硬编码
true 改为跟随 enablePropsFormValidate,未开启时不再做 typeMatch 校验与
初始化校验。
2026-09-23 16:34:08 +08:00

280 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent.
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
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';
import Editor from '@editor/Editor.vue';
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
let injectedFormContext: ComputedRef<FormContext> | undefined;
let injectedEnablePropsFormValidate: ComputedRef<boolean> | undefined;
const { initServiceEventsMock, initServiceStateMock } = vi.hoisted(() => ({
initServiceEventsMock: vi.fn(),
initServiceStateMock: vi.fn(),
}));
vi.mock('@editor/initService', () => ({
initServiceEvents: initServiceEventsMock,
initServiceState: initServiceStateMock,
}));
vi.mock('@editor/services/codeBlock', () => ({ default: {} }));
vi.mock('@editor/services/componentList', () => ({ default: {} }));
vi.mock('@editor/services/dataSource', () => ({ default: {} }));
vi.mock('@editor/services/dep', () => ({ default: {} }));
const { stageStub } = vi.hoisted(() => ({ stageStub: { name: 'stage-instance' } }));
// 用响应式 ref 承载 stage,模拟真实 editorService 的 reactive state,
// 这样 formContext 的 computed 才会在 stage 变化时失效重算
vi.mock('@editor/services/editor', async () => {
const { shallowRef } = await import('vue');
const stage = shallowRef<any>(stageStub);
return {
default: {
get: vi.fn((key: string) => (key === 'stage' ? stage.value : undefined)),
__setStage: (value: any) => {
stage.value = value;
},
},
};
});
vi.mock('@editor/services/events', () => ({ default: {} }));
vi.mock('@editor/services/history', () => ({ default: {} }));
vi.mock('@editor/services/keybinding', () => ({
default: { register: vi.fn(), registerEl: vi.fn() },
}));
vi.mock('@editor/services/props', () => ({ default: {} }));
vi.mock('@editor/services/stageOverlay', () => ({
default: { set: vi.fn() },
}));
vi.mock('@editor/services/storage', () => ({ default: {}, Protocol: {} }));
vi.mock('@editor/services/ui', () => ({ default: {} }));
vi.mock('@editor/utils/keybinding-config', () => ({ default: {}, KeyBindingContainerKey: { STAGE: 'stage' } }));
vi.mock('@editor/layouts/Framework.vue', () => ({
default: defineComponent({
name: 'FakeFramework',
props: ['disabledPageFragment', 'pageBarSortOptions', 'pageFilterFunction'],
setup(_p, { slots }) {
return () =>
h('div', { class: 'fake-framework' }, [
slots.header?.(),
slots.nav?.({ editorService: {} }),
slots.sidebar?.({ editorService: {} }),
slots.workspace?.({ editorService: {} }),
slots['props-panel']?.(),
slots.footer?.(),
]);
},
}),
}));
vi.mock('@editor/layouts/NavMenu.vue', () => ({
default: defineComponent({
name: 'TMagicNavMenu',
props: ['data'],
setup() {
return () => h('div', { class: 'fake-nav-menu' });
},
}),
}));
vi.mock('@editor/layouts/sidebar/Sidebar.vue', () => ({
default: defineComponent({
name: 'FakeSidebar',
emits: ['layer-node-dblclick'],
setup(_p, { emit }) {
return () =>
h('div', {
class: 'fake-sidebar',
onClick: () => emit('layer-node-dblclick', new MouseEvent('dblclick'), { id: 'a' }),
});
},
}),
}));
vi.mock('@editor/layouts/workspace/Workspace.vue', () => ({
default: defineComponent({ name: 'FakeWorkspace', setup: () => () => h('div', { class: 'fake-workspace' }) }),
}));
vi.mock('@editor/layouts/props-panel/PropsPanel.vue', () => ({
default: defineComponent({
name: 'PropsPanel',
emits: ['mounted', 'unmounted', 'submit-error', 'form-error'],
setup(_p, { emit }) {
injectedFormContext = inject(FORM_CONTEXT_KEY, undefined);
injectedEnablePropsFormValidate = inject(
ENABLE_PROPS_FORM_VALIDATE,
computed(() => false),
);
return () =>
h('div', { class: 'fake-props-panel' }, [
h('button', { class: 'mounted-btn', onClick: () => emit('mounted', { proxy: true }) }),
h('button', { class: 'unmounted-btn', onClick: () => emit('unmounted') }),
h('button', { class: 'submit-err', onClick: () => emit('submit-error', new Error('e')) }),
h('button', { class: 'form-err', onClick: () => emit('form-error', new Error('e')) }),
]);
},
}),
}));
vi.mock('@editor/layouts/props-panel/FormPanel.vue', () => ({
default: defineComponent({ name: 'FormPanel', setup: () => () => h('div') }),
}));
beforeEach(() => {
vi.clearAllMocks();
injectedFormContext = undefined;
injectedEnablePropsFormValidate = undefined;
});
describe('Editor', () => {
test('挂载时初始化 services', () => {
mount(Editor, { props: {} as any });
expect(initServiceEventsMock).toHaveBeenCalled();
expect(initServiceStateMock).toHaveBeenCalled();
});
test('canDropIn 转发到 stage 含 stage-add/stage-drag 类型', async () => {
const canDropIn = vi.fn(() => true);
const stageOverlayMod = (await import('@editor/services/stageOverlay')) as any;
mount(Editor, { props: { canDropIn } as any });
await nextTick();
const stageOptions = stageOverlayMod.default.set.mock.calls.find((c: any[]) => c[0] === 'stageOptions')?.[1];
expect(stageOptions.canDropIn).toBeDefined();
stageOptions.canDropIn([], 't1');
expect(canDropIn).toHaveBeenCalledWith([], 't1', 'stage-add');
stageOptions.canDropIn(['s1'], 't1');
expect(canDropIn).toHaveBeenLastCalledWith(['s1'], 't1', 'stage-drag');
});
test('未传 canDropIn 时 stageOptions.canDropIn 为 undefined', async () => {
const stageOverlayMod = (await import('@editor/services/stageOverlay')) as any;
mount(Editor, { props: {} as any });
await nextTick();
const stageOptions = stageOverlayMod.default.set.mock.calls.find((c: any[]) => c[0] === 'stageOptions')?.[1];
expect(stageOptions.canDropIn).toBeUndefined();
});
test('PropsPanel 事件转发', async () => {
const wrapper = mount(Editor, { props: {} as any });
await wrapper.find('.mounted-btn').trigger('click');
expect(wrapper.emitted('props-panel-mounted')).toBeTruthy();
await wrapper.find('.unmounted-btn').trigger('click');
expect(wrapper.emitted('props-panel-unmounted')).toBeTruthy();
await wrapper.find('.submit-err').trigger('click');
expect(wrapper.emitted('props-submit-error')).toBeTruthy();
await wrapper.find('.form-err').trigger('click');
expect(wrapper.emitted('props-form-error')).toBeTruthy();
});
test('Sidebar layer-node-dblclick 事件转发', async () => {
const wrapper = mount(Editor, { props: {} as any });
await wrapper.find('.fake-sidebar').trigger('click');
expect(wrapper.emitted('layer-node-dblclick')).toBeTruthy();
});
test('provide FORM_CONTEXT_KEY,子孙可拿到 services 与当前 stage', async () => {
mount(Editor, { props: {} as any });
await nextTick();
expect(injectedFormContext).toBeDefined();
const context = injectedFormContext!.value as any;
expect(context.services.editorService).toBeDefined();
expect(context.services.propsService).toBeDefined();
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 });
await nextTick();
expect((injectedFormContext!.value as any).stage).toBe(stageStub);
const nextStage = { name: 'next-stage' };
editorServiceMod.default.__setStage(nextStage);
expect((injectedFormContext!.value as any).stage).toBe(nextStage);
editorServiceMod.default.__setStage(stageStub);
});
test('ENABLE_PROPS_FORM_VALIDATE 默认注入 false', async () => {
mount(Editor, { props: {} as any });
await nextTick();
expect(injectedEnablePropsFormValidate?.value).toBe(false);
});
test('ENABLE_PROPS_FORM_VALIDATE 走 computed 读时求值,prop 变化后子孙能读到最新值', async () => {
const wrapper = mount(Editor, { props: { enablePropsFormValidate: false } as any });
await nextTick();
expect(injectedEnablePropsFormValidate?.value).toBe(false);
await wrapper.setProps({ enablePropsFormValidate: true });
expect(injectedEnablePropsFormValidate?.value).toBe(true);
await wrapper.setProps({ enablePropsFormValidate: false });
expect(injectedEnablePropsFormValidate?.value).toBe(false);
});
test('expose services', () => {
const wrapper = mount(Editor, { props: {} as any });
expect((wrapper.vm as any).editorService).toBeDefined();
expect((wrapper.vm as any).propsService).toBeDefined();
});
});