mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-15 08:28:41 +00:00
编辑器不再遮蔽外层 provide 的业务上下文。 formState Proxy 支持 Vue2 时代 vm.mForm 跨字段通信, 并修复 context 自引用导致的栈溢出。 Select 对 valueKey 路径使用可选链。
253 lines
9.6 KiB
TypeScript
253 lines
9.6 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 { describe, expect, test } from 'vitest';
|
||
import { reactive, ref } from 'vue';
|
||
|
||
import type { FormState } from '@form/index';
|
||
import { createFormStateProxy, mergeFormContexts } from '@form/utils/formStateProxy';
|
||
|
||
const makeCore = (extra: Record<string, any> = {}): FormState =>
|
||
reactive({
|
||
config: [],
|
||
initValues: {},
|
||
lastValues: {},
|
||
isCompare: false,
|
||
values: {},
|
||
lastValuesProcessed: {},
|
||
$emit: () => undefined,
|
||
keyProp: '__key',
|
||
setField: () => undefined,
|
||
getField: () => undefined,
|
||
deleteField: () => undefined,
|
||
$messageBox: {} as any,
|
||
$message: {} as any,
|
||
...extra,
|
||
}) as FormState;
|
||
|
||
describe('createFormStateProxy', () => {
|
||
test('核心字段优先于 context', () => {
|
||
const formState = createFormStateProxy(makeCore({ keyProp: 'id' }), () => ({ keyProp: 'hacked', extra: 1 }));
|
||
|
||
expect(formState.keyProp).toBe('id');
|
||
expect((formState as any).extra).toBe(1);
|
||
});
|
||
|
||
test('Object.entries 能枚举到扩展字段', () => {
|
||
const formState = createFormStateProxy(makeCore(), () => ({ username: 'alice' }));
|
||
const keys = Object.keys(formState);
|
||
|
||
expect(keys).toContain('username');
|
||
expect(keys).toContain('values');
|
||
expect(Object.entries(formState).some(([k, v]) => k === 'username' && v === 'alice')).toBe(true);
|
||
});
|
||
|
||
test('set 写入 core,读取优先于 context', () => {
|
||
const core = makeCore();
|
||
const formState = createFormStateProxy(core, () => ({ stage: 'from-context' }));
|
||
|
||
(formState as any).stage = 'assigned';
|
||
expect((formState as any).stage).toBe('assigned');
|
||
expect((core as any).stage).toBe('assigned');
|
||
});
|
||
|
||
test('has 在 core 或 context 命中时为 true', () => {
|
||
const formState = createFormStateProxy(makeCore(), () => ({ username: 'alice' }));
|
||
expect('values' in formState).toBe(true);
|
||
expect('username' in formState).toBe(true);
|
||
expect('missing' in formState).toBe(false);
|
||
});
|
||
|
||
test('symbol key 只走 core,四个 trap 语义一致', () => {
|
||
const key = Symbol('vue-raw');
|
||
const core = makeCore();
|
||
(core as any)[key] = 'from-core';
|
||
const onlyInContext = Symbol('ctx-only');
|
||
const formState = createFormStateProxy(core, () => ({ [key]: 'from-context', [onlyInContext]: 1 }));
|
||
|
||
expect((formState as any)[key]).toBe('from-core');
|
||
// context 上的 symbol 不应被 has / ownKeys / getOwnPropertyDescriptor 报告为存在,
|
||
// 否则会与 get 返回 undefined 自相矛盾
|
||
expect(onlyInContext in formState).toBe(false);
|
||
expect(Object.getOwnPropertySymbols(formState)).not.toContain(onlyInContext);
|
||
expect(Object.getOwnPropertyDescriptor(formState, onlyInContext)).toBeUndefined();
|
||
expect((formState as any)[onlyInContext]).toBeUndefined();
|
||
});
|
||
|
||
test('core 上存在但值为 undefined 的字段不读穿 context', () => {
|
||
const formState = createFormStateProxy(makeCore({ parentValues: undefined }), () => ({ parentValues: { a: 1 } }));
|
||
expect(formState.parentValues).toBeUndefined();
|
||
});
|
||
|
||
test('getContext 返回 undefined 时读扩展字段不抛错', () => {
|
||
const formState = createFormStateProxy(makeCore(), () => undefined as any);
|
||
expect((formState as any).username).toBeUndefined();
|
||
expect('username' in formState).toBe(false);
|
||
});
|
||
|
||
test('getOwnPropertyDescriptor 对 core/context 都不存在的 key 返回 undefined', () => {
|
||
const formState = createFormStateProxy(makeCore(), () => ({ username: 'alice' }));
|
||
expect(Object.getOwnPropertyDescriptor(formState, 'nope')).toBeUndefined();
|
||
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);
|
||
expect((formState as any).username).toBe('alice');
|
||
ctx.value = { username: 'bob' };
|
||
expect((formState as any).username).toBe('bob');
|
||
});
|
||
});
|
||
|
||
describe('mergeFormContexts', () => {
|
||
test('靠后的层优先', () => {
|
||
const merged = mergeFormContexts({ a: 1, b: 1 }, { b: 2, c: 2 }, { c: 3 }) as any;
|
||
|
||
expect(merged.a).toBe(1);
|
||
expect(merged.b).toBe(2);
|
||
expect(merged.c).toBe(3);
|
||
});
|
||
|
||
test('accessor 保持读时求值,不在合并时被展开', () => {
|
||
let count = 0;
|
||
const layer = Object.defineProperties(
|
||
{},
|
||
{
|
||
stage: {
|
||
enumerable: true,
|
||
get: () => {
|
||
count += 1;
|
||
return count;
|
||
},
|
||
},
|
||
},
|
||
);
|
||
|
||
const merged = mergeFormContexts(layer) as any;
|
||
expect(count).toBe(0);
|
||
|
||
const multi = mergeFormContexts({ other: 1 }, layer) as any;
|
||
expect(count).toBe(0);
|
||
expect(multi.stage).toBe(1);
|
||
expect(multi.stage).toBe(2);
|
||
expect(merged.stage).toBe(3);
|
||
});
|
||
|
||
test('枚举语义覆盖所有层,且不重复', () => {
|
||
const merged = mergeFormContexts({ a: 1, b: 1 }, { b: 2, c: 2 });
|
||
|
||
expect(Object.keys(merged).sort()).toEqual(['a', 'b', 'c']);
|
||
expect(Object.entries(merged).sort()).toEqual([
|
||
['a', 1],
|
||
['b', 2],
|
||
['c', 2],
|
||
]);
|
||
});
|
||
|
||
test('忽略 undefined / null 层;零层与单层不额外包 Proxy', () => {
|
||
const only = { a: 1 };
|
||
|
||
expect(mergeFormContexts(undefined, null)).toEqual({});
|
||
expect(mergeFormContexts(undefined, only, null)).toBe(only);
|
||
// 空层数组每次返回同一个共享空对象
|
||
expect(mergeFormContexts()).toBe(mergeFormContexts(undefined));
|
||
});
|
||
|
||
test('has 与 getOwnPropertyDescriptor 与取值一致', () => {
|
||
const merged = mergeFormContexts({ a: 1 }, { b: 2 });
|
||
|
||
expect('a' in merged).toBe(true);
|
||
expect('b' in merged).toBe(true);
|
||
expect('missing' in merged).toBe(false);
|
||
expect(Object.getOwnPropertyDescriptor(merged, 'b')?.value).toBe(2);
|
||
expect(Object.getOwnPropertyDescriptor(merged, 'missing')).toBeUndefined();
|
||
});
|
||
});
|