roymondchen 2846f9eb2a fix(core): app.emit 在节点配置事件时不应短路 super.emit
去掉 eventHelper.emit 前的 return,避免节点配置 events 后 app.on 注册的监听器被吞掉,并补充回归测试。
2026-05-27 11:20:48 +08:00

500 lines
12 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.

import { describe, expect, test, vi } from 'vitest';
import { MApp, NodeType } from '@tmagic/schema';
import App from '../src/App';
import TMagicIteratorContainer from '../src/IteratorContainer';
import Node from '../src/Node';
const createAppDsl = (pageLength: number, nodeLength = 0) => {
const dsl: MApp = {
type: NodeType.ROOT,
id: 'app_1',
dataSources: [
{
id: 'ds_1',
fields: [
{
type: 'array',
name: 'array',
title: 'array',
enable: true,
fields: [
{
type: 'array',
name: 'arr',
title: 'arr',
defaultValue: [],
enable: true,
fields: [],
},
],
},
],
events: [],
methods: [],
type: 'base',
},
],
dataSourceDeps: {},
dataSourceCondDeps: {},
items: [
...new Array(pageLength)
.fill({
type: NodeType.PAGE,
items: new Array(nodeLength)
.fill({
type: 'text',
})
.map((node, index) => ({
...node,
id: `text_${index}`,
})),
})
.map((page, index) => ({
...page,
id: `page_${index}`,
})),
{
type: NodeType.PAGE_FRAGMENT,
id: 'page_fragment_1',
items: [
{
type: 'text',
id: 'text_page_fragment',
text: 'text_page_fragment',
},
],
},
],
};
return dsl;
};
describe('App', () => {
test('instance', () => {
const app = new App({});
expect(app).toBeInstanceOf(App);
});
test('page', () => {
const app = new App({
config: createAppDsl(2),
});
expect(app.getNode('page_0')?.data.id).toBe('page_0');
expect(app.page?.data.id).toBe('page_0');
app.setConfig(createAppDsl(3), 'page_1');
expect(app.page?.data.id).toBe('page_1');
app.setPage('page_2');
expect(app.page?.data.id).toBe('page_2');
});
test('node', () => {
const app = new App({
config: createAppDsl(1, 10),
});
expect(app.getNode('text_1')?.data.id).toBe('text_1');
});
test('iterator-container', () => {
const dsl = createAppDsl(1, 10);
dsl.items[0].items.push({
type: 'iterator-container',
id: 'iterator-container_1',
items: [
{
type: 'text',
id: 'text',
},
],
});
const app = new App({
config: dsl,
});
const ic = app.getNode('iterator-container_1') as unknown as TMagicIteratorContainer;
expect(ic?.data.id).toBe('iterator-container_1');
ic?.setNodes(
[
{
type: 'text',
id: 'text',
text: '1',
},
{
type: 'page-fragment-container',
id: 'page_fragment_container_1',
pageFragmentId: 'page_fragment_1',
},
{
type: 'iterator-container',
id: 'iterator-container_11',
items: [
{
type: 'text',
id: 'text',
},
],
},
],
0,
);
ic?.setNodes(
[
{
type: 'text',
id: 'text',
text: '2',
},
{
type: 'iterator-container',
id: 'iterator-container_11',
items: [
{
type: 'text',
id: 'text',
},
],
},
],
1,
);
expect(app.getNode('text', { iteratorContainerId: ['iterator-container_1'], iteratorIndex: [0] })?.data.text).toBe(
'1',
);
expect(app.getNode('text', { iteratorContainerId: ['iterator-container_1'], iteratorIndex: [1] })?.data.text).toBe(
'2',
);
expect(
app.getNode('text_page_fragment', { iteratorContainerId: ['iterator-container_1'], iteratorIndex: [0] })?.data
.text,
).toBe('text_page_fragment');
const ic1 = app.getNode('iterator-container_11', {
iteratorContainerId: ['iterator-container_1'],
iteratorIndex: [0],
}) as unknown as TMagicIteratorContainer;
ic1?.setNodes(
[
{
type: 'text',
id: 'text',
text: '111',
},
],
0,
);
ic1?.setNodes(
[
{
type: 'text',
id: 'text',
text: '222',
},
],
1,
);
const ic2 = app.getNode('iterator-container_11', {
iteratorContainerId: ['iterator-container_1'],
iteratorIndex: [1],
}) as unknown as TMagicIteratorContainer;
ic2?.setNodes(
[
{
type: 'text',
id: 'text',
text: '11',
},
],
0,
);
ic2?.setNodes(
[
{
type: 'text',
id: 'text',
text: '22',
},
],
1,
);
expect(
app.getNode('text', {
iteratorContainerId: ['iterator-container_1', 'iterator-container_11'],
iteratorIndex: [0, 0],
})?.data.text,
).toBe('111');
expect(
app.getNode('text', {
iteratorContainerId: ['iterator-container_1', 'iterator-container_11'],
iteratorIndex: [0, 1],
})?.data.text,
).toBe('222');
expect(
app.getNode('text', {
iteratorContainerId: ['iterator-container_1', 'iterator-container_11'],
iteratorIndex: [1, 0],
})?.data.text,
).toBe('11');
expect(
app.getNode('text', {
iteratorContainerId: ['iterator-container_1', 'iterator-container_11'],
iteratorIndex: [1, 1],
})?.data.text,
).toBe('22');
ic.resetNodes();
expect(ic2?.nodes.length).toBe(0);
});
});
describe('App 配置/方法/组件注册', () => {
test('platform=editor 时不创建 eventHelper', () => {
const app = new App({ platform: 'editor' });
expect(app.eventHelper).toBeUndefined();
expect(app.platform).toBe('editor');
});
test('disabledFlexible 时不创建 flexible', () => {
const app = new App({ disabledFlexible: true });
expect((app as any).flexible).toBeUndefined();
});
test('设置自定义 iteratorContainerType / pageFragmentContainerType', () => {
const app = new App({
iteratorContainerType: ['my-iter', 'custom-iter'],
pageFragmentContainerType: 'my-frag',
});
expect(app.iteratorContainerType.has('my-iter')).toBe(true);
expect(app.iteratorContainerType.has('custom-iter')).toBe(true);
expect(app.pageFragmentContainerType.has('my-frag')).toBe(true);
});
test('useMock=true 透传到 DataSourceManager', () => {
const app = new App({ useMock: true });
expect(app.useMock).toBe(true);
});
test('registerComponent / resolveComponent / unregisterComponent', () => {
const app = new App({});
const comp = { tag: 'x' };
app.registerComponent('my', comp);
expect(app.resolveComponent('my')).toBe(comp);
app.unregisterComponent('my');
expect(app.resolveComponent('my')).toBeUndefined();
});
test('registerNode 静态方法存入 nodeClassMap', () => {
class Custom extends Node {}
App.registerNode('custom-type', Custom);
expect(App.nodeClassMap.get('custom-type')).toBe(Custom);
});
test('setEnv 接受字符串/Env 实例', () => {
const app = new App({});
app.setEnv();
expect(app.env).toBeDefined();
app.setEnv('Mozilla/5.0');
expect(app.env).toBeDefined();
});
test('getPage / getNode 默认返回当前 page', () => {
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [{ type: NodeType.PAGE, id: 'p1', items: [{ id: 'btn', type: 'button' }] }],
},
});
expect(app.getPage()).toBe(app.page);
expect(app.getPage('p1')).toBe(app.page);
expect(app.getPage('not-exist')).toBeUndefined();
expect(app.getNode('btn')?.data.id).toBe('btn');
});
test('setPage 不存在时清空当前 page', () => {
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
},
});
app.setPage('not-exist');
expect(app.page).toBeUndefined();
});
test('runCode 执行代码块', async () => {
const fn = vi.fn();
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
codeBlocks: { c1: { name: 'c1', content: fn, params: [] } },
},
});
await app.runCode('c1', { p: 1 }, []);
expect(fn).toHaveBeenCalled();
});
test('runCode 抛错时进入 errorHandler', async () => {
const errorHandler = vi.fn();
const app = new App({
errorHandler,
config: {
type: NodeType.ROOT,
id: 'app',
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
codeBlocks: {
c1: {
name: 'c1',
content: () => {
throw new Error('boom');
},
params: [],
},
},
},
});
await app.runCode('c1', {}, []);
expect(errorHandler).toHaveBeenCalled();
});
test('runDataSourceMethod 调用 schema methods 中的 content', async () => {
const fn = vi.fn().mockResolvedValue('ok');
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
dataSources: [
{
type: 'base',
id: 'ds_1',
fields: [],
methods: [{ name: 'doIt', content: fn, params: [] }],
events: [],
},
],
} as any,
});
await app.runDataSourceMethod('ds_1', 'doIt', { p: 1 }, []);
expect(fn).toHaveBeenCalled();
});
test('runDataSourceMethod 不存在的数据源直接返回', async () => {
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
},
});
await expect(app.runDataSourceMethod('not', 'm', {}, [])).resolves.toBeUndefined();
await expect(app.runDataSourceMethod('', '', {}, [])).resolves.toBeUndefined();
});
test('emit 触发 node 事件时走 eventHelper', () => {
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [
{
type: NodeType.PAGE,
id: 'p1',
items: [{ id: 'btn', type: 'button', events: [{ name: 'click', actions: [] }] }],
},
],
} as any,
});
const node = app.getNode('btn')!;
const result = app.emit('click', node, 'arg1');
expect(typeof result).toBe('boolean');
});
// 回归用例:节点配置了 events 时eventHelper 派发不能短路掉 super.emit
// 即 app.on(name, cb) 注册的回调依然要被触发。
test('emit: 节点已绑定 events 时app.on 注册的监听器仍然会被调用', () => {
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [
{
type: NodeType.PAGE,
id: 'p1',
items: [{ id: 'btn', type: 'button', events: [{ name: 'click', actions: [] }] }],
},
],
} as any,
});
const node = app.getNode('btn')!;
const cb = vi.fn();
app.on('click', cb);
const result = app.emit('click', node, 'arg1');
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith(node, 'arg1');
// EventEmitter.emit 在有 listener 时返回 true
expect(result).toBe(true);
});
test('emit: 未命中节点 eventKeys 时app.on 注册的监听器正常被调用', () => {
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [
{
type: NodeType.PAGE,
id: 'p1',
items: [{ id: 'btn', type: 'button' }],
},
],
} as any,
});
const node = app.getNode('btn')!;
const cb = vi.fn();
app.on('click', cb);
app.emit('click', node, 'arg1');
expect(cb).toHaveBeenCalledTimes(1);
expect(cb).toHaveBeenCalledWith(node, 'arg1');
});
test('destroy 清理所有资源', () => {
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [{ type: NodeType.PAGE, id: 'p1', items: [{ id: 'btn', type: 'button' }] }],
},
});
app.destroy();
expect(app.page).toBeUndefined();
expect(app.dsl).toBeUndefined();
expect(app.components.size).toBe(0);
});
});