tmagic-editor/packages/core/tests/EventHelper.spec.ts
2026-05-14 15:26:22 +08:00

835 lines
23 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. 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
*/
import { beforeEach, describe, expect, test, vi } from 'vitest';
import {
ActionType,
type MApp,
NODE_DISABLE_CODE_BLOCK_KEY,
NODE_DISABLE_DATA_SOURCE_KEY,
NodeType,
} from '@tmagic/schema';
import { DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX } from '@tmagic/utils';
import App from '../src/App';
import EventHelper from '../src/EventHelper';
import FlowState from '../src/FlowState';
const flushAsync = () => new Promise((r) => setTimeout(r, 0));
const createDsl = (overrides: Partial<MApp> = {}): MApp => ({
type: NodeType.ROOT,
id: 'app',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'btn_1',
type: 'button',
},
{
id: 'btn_2',
type: 'button',
},
],
},
],
...overrides,
});
describe('EventHelper 构造与销毁', () => {
test('实例化继承 EventEmitter 并保存 app 引用', () => {
const app = new App({});
const helper = new EventHelper({ app });
expect(helper).toBeInstanceOf(EventHelper);
expect(helper.app).toBe(app);
expect(helper.eventQueue).toEqual([]);
});
test('destroy 清空内部状态与监听器', () => {
const app = new App({ config: createDsl() });
const helper = app.eventHelper!;
const handler = vi.fn();
helper.on('foo', handler);
helper.destroy();
helper.emit('foo');
expect(handler).not.toHaveBeenCalled();
expect((helper as any).nodeEventList.size).toBe(0);
expect((helper as any).dataSourceEventList.size).toBe(0);
});
});
describe('EventHelper - bindNodeEvents / initEvents / removeNodeEvents', () => {
test('忽略没有 name 的事件配置', () => {
const app = new App({ config: createDsl() });
const helper = app.eventHelper!;
const node = app.getNode('btn_1')!;
node.events = [{ name: '', actions: [] } as any];
helper.bindNodeEvents(node);
expect((helper as any).nodeEventList.size).toBe(0);
});
test('为带 name 的事件创建 symbol 并写入 eventKeys', () => {
const app = new App({ config: createDsl() });
const helper = app.eventHelper!;
const node = app.getNode('btn_1')!;
node.events = [{ name: 'click', actions: [] }];
node.eventKeys.clear();
helper.bindNodeEvents(node);
expect(node.eventKeys.has(`click_${node.data.id}`)).toBe(true);
expect((helper as any).nodeEventList.size).toBe(1);
});
test('已存在的 eventKey 会被复用而不是重新创建', () => {
const app = new App({ config: createDsl() });
const helper = app.eventHelper!;
const node = app.getNode('btn_1')!;
const existingSymbol = Symbol('click_btn_1');
node.eventKeys.set(`click_${node.data.id}`, existingSymbol);
node.events = [{ name: 'click', actions: [] }];
helper.bindNodeEvents(node);
expect(node.eventKeys.get(`click_${node.data.id}`)).toBe(existingSymbol);
});
test('${nodeId}.${eventName} 形式将命名空间转换为 ${eventName}_${nodeId}', () => {
const app = new App({ config: createDsl() });
const helper = app.eventHelper!;
const node = app.getNode('btn_1')!;
node.events = [{ name: 'btn_2.click', actions: [] }];
node.eventKeys.clear();
helper.bindNodeEvents(node);
expect(node.eventKeys.has('click_btn_2')).toBe(true);
});
test('initEvents 会为 page 和 pageFragments 内的节点都绑定事件', () => {
const dsl = createDsl({
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'pf_container',
type: 'page-fragment-container',
pageFragmentId: 'pf_1',
},
{
id: 'btn_in_page',
type: 'button',
events: [{ name: 'click', actions: [] }],
},
],
},
{
type: NodeType.PAGE_FRAGMENT,
id: 'pf_1',
items: [
{
id: 'btn_in_pf',
type: 'button',
events: [{ name: 'click', actions: [] }],
},
],
},
],
} as any);
const app = new App({ config: dsl });
const helper = app.eventHelper!;
expect(app.pageFragments.size).toBe(1);
helper.initEvents();
expect((helper as any).nodeEventList.size).toBeGreaterThanOrEqual(2);
});
test('removeNodeEvents 会移除全部 node 上注册的监听', () => {
const app = new App({
config: createDsl({
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'btn_1',
type: 'button',
events: [{ name: 'click', actions: [] }],
},
],
},
],
} as any),
});
const helper = app.eventHelper!;
expect((helper as any).nodeEventList.size).toBe(1);
helper.removeNodeEvents();
expect((helper as any).nodeEventList.size).toBe(0);
});
});
describe('EventHelper - 事件队列', () => {
test('addEventToQueue / getEventQueue', () => {
const app = new App({});
const helper = new EventHelper({ app });
helper.addEventToQueue({ toId: 'x', method: 'm', fromCpt: null, args: [1] });
expect(helper.getEventQueue()).toHaveLength(1);
expect(helper.getEventQueue()[0].toId).toBe('x');
});
});
describe('EventHelper - eventHandler / actionHandler 流程', () => {
let beforeHandler: ReturnType<typeof vi.fn>;
let afterHandler: ReturnType<typeof vi.fn>;
beforeEach(() => {
beforeHandler = vi.fn();
afterHandler = vi.fn();
});
test('emit click 时执行 EventConfig.actions 并触发 before/after 钩子', async () => {
const fromInstance = { doIt: vi.fn() };
const toInstance = { doIt: vi.fn() };
const app = new App({
beforeEventHandler: beforeHandler,
afterEventHandler: afterHandler,
config: createDsl({
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'btn_1',
type: 'button',
events: [
{
name: 'click',
actions: [{ actionType: ActionType.COMP, to: 'btn_2', method: 'doIt' }],
},
],
},
{ id: 'btn_2', type: 'button' },
],
},
],
} as any),
});
const fromNode = app.getNode('btn_1')!;
const toNode = app.getNode('btn_2')!;
fromNode.setInstance(fromInstance);
toNode.setInstance(toInstance);
app.emit('click', fromNode, 'extraArg');
await flushAsync();
expect(beforeHandler).toHaveBeenCalled();
expect(afterHandler).toHaveBeenCalled();
expect(toInstance.doIt).toHaveBeenCalled();
expect(toInstance.doIt.mock.calls[0][1]).toBe('extraArg');
});
test('actions 中如果 flowState.isAbort 为 true 会中断后续 action', async () => {
const action2Spy = vi.fn();
const codeBlocks = {
abortCode: {
name: 'abortCode',
params: [],
content: ({ flowState }: any) => {
flowState.abort();
},
},
shouldNotRun: {
name: 'shouldNotRun',
params: [],
content: action2Spy,
},
};
const app = new App({
config: createDsl({
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'btn_1',
type: 'button',
events: [
{
name: 'click',
actions: [
{ actionType: ActionType.CODE, codeId: 'abortCode' } as any,
{ actionType: ActionType.CODE, codeId: 'shouldNotRun' } as any,
],
},
],
},
],
},
],
codeBlocks,
} as any),
});
app.emit('click', app.getNode('btn_1')!);
await flushAsync();
await flushAsync();
expect(action2Spy).not.toHaveBeenCalled();
});
test('CODE action 在 NODE_DISABLE_CODE_BLOCK_KEY=true 时跳过', async () => {
const codeFn = vi.fn();
const app = new App({
config: createDsl({
codeBlocks: { c: { name: 'c', params: [], content: codeFn } },
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'btn_1',
type: 'button',
[NODE_DISABLE_CODE_BLOCK_KEY]: true,
events: [
{
name: 'click',
actions: [{ actionType: ActionType.CODE, codeId: 'c' } as any],
},
],
},
],
},
],
} as any),
});
app.emit('click', app.getNode('btn_1')!);
await flushAsync();
expect(codeFn).not.toHaveBeenCalled();
});
test('DATA_SOURCE action 正常执行时通过 runDataSourceMethod 调用', async () => {
const methodFn = vi.fn().mockResolvedValue('ok');
const app = new App({
config: createDsl({
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [],
events: [],
methods: [{ name: 'fetch', params: [], content: methodFn }],
},
],
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'btn_1',
type: 'button',
events: [
{
name: 'click',
actions: [
{
actionType: ActionType.DATA_SOURCE,
dataSourceMethod: ['ds_1', 'fetch'],
params: { x: 1 },
} as any,
],
},
],
},
],
},
],
} as any),
});
app.emit('click', app.getNode('btn_1')!);
await flushAsync();
await flushAsync();
expect(methodFn).toHaveBeenCalled();
expect(methodFn.mock.calls[0][0].params).toEqual({ x: 1 });
});
test('DATA_SOURCE action 在 NODE_DISABLE_DATA_SOURCE_KEY=true 时跳过', async () => {
const methodFn = vi.fn();
const app = new App({
config: createDsl({
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [],
events: [],
methods: [{ name: 'fetch', params: [], content: methodFn }],
},
],
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'btn_1',
type: 'button',
[NODE_DISABLE_DATA_SOURCE_KEY]: true,
events: [
{
name: 'click',
actions: [
{
actionType: ActionType.DATA_SOURCE,
dataSourceMethod: ['ds_1', 'fetch'],
} as any,
],
},
],
},
],
},
],
} as any),
});
app.emit('click', app.getNode('btn_1')!);
await flushAsync();
expect(methodFn).not.toHaveBeenCalled();
});
test('actionHandler 抛错时调用 errorHandler', async () => {
const errorHandler = vi.fn();
const app = new App({
errorHandler,
config: createDsl({
codeBlocks: {
boom: {
name: 'boom',
params: [],
content: () => {
throw new Error('boom');
},
},
},
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'btn_1',
type: 'button',
events: [
{
name: 'click',
actions: [{ actionType: ActionType.CODE, codeId: 'boom' } as any],
},
],
},
],
},
],
} as any),
});
app.emit('click', app.getNode('btn_1')!);
await flushAsync();
await flushAsync();
expect(errorHandler).toHaveBeenCalled();
});
test('兼容 DeprecatedEventConfig没有 actions 字段时走 compActionHandler', async () => {
const targetInstance = { ping: vi.fn() };
const app = new App({
config: createDsl({
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'btn_1',
type: 'button',
events: [{ name: 'click', to: 'btn_2', method: 'ping' } as any],
},
{ id: 'btn_2', type: 'button' },
],
},
],
} as any),
});
const fromNode = app.getNode('btn_1')!;
app.getNode('btn_2')!.setInstance(targetInstance);
app.emit('click', fromNode);
await flushAsync();
expect(targetInstance.ping).toHaveBeenCalled();
});
test('compActionHandler 找不到目标节点时进入 eventQueue', async () => {
const app = new App({
config: createDsl({
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'btn_1',
type: 'button',
events: [
{
name: 'click',
actions: [{ actionType: ActionType.COMP, to: 'not-exist', method: 'foo' }],
},
],
},
],
},
],
} as any),
});
app.emit('click', app.getNode('btn_1')!);
await flushAsync();
expect(app.eventHelper!.getEventQueue()).toHaveLength(1);
expect(app.eventHelper!.getEventQueue()[0].toId).toBe('not-exist');
expect(app.eventHelper!.getEventQueue()[0].method).toBe('foo');
});
test('compActionHandler目标节点没有 instance 时方法入 node.eventQueue', async () => {
const app = new App({
config: createDsl({
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'btn_1',
type: 'button',
events: [
{
name: 'click',
actions: [{ actionType: ActionType.COMP, to: 'btn_2', method: 'foo' }],
},
],
},
{ id: 'btn_2', type: 'button' },
],
},
],
} as any),
});
const targetNode = app.getNode('btn_2')!;
app.emit('click', app.getNode('btn_1')!);
await flushAsync();
expect((targetNode as any).eventQueue).toHaveLength(1);
expect((targetNode as any).eventQueue[0].method).toBe('foo');
});
test('compActionHandlermethod 是数组时取 [to, method]', async () => {
const targetInstance = { hi: vi.fn() };
const app = new App({
config: createDsl({
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'btn_1',
type: 'button',
events: [
{
name: 'click',
actions: [{ actionType: ActionType.COMP, method: ['btn_2', 'hi'] } as any],
},
],
},
{ id: 'btn_2', type: 'button' },
],
},
],
} as any),
});
app.getNode('btn_2')!.setInstance(targetInstance);
app.emit('click', app.getNode('btn_1')!);
await flushAsync();
expect(targetInstance.hi).toHaveBeenCalled();
});
test('compActionHandler当前没有 page 时抛错被 errorHandler 捕获(兼容旧配置)', async () => {
const errorHandler = vi.fn();
const app = new App({
errorHandler,
config: createDsl({
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{
id: 'btn_1',
type: 'button',
events: [{ name: 'click', to: 'btn_2', method: 'foo' } as any],
},
],
},
],
} as any),
});
const node = app.getNode('btn_1')!;
app.page = undefined;
app.emit('click', node);
await flushAsync();
await flushAsync();
expect(errorHandler).toHaveBeenCalled();
const lastErr = errorHandler.mock.calls[errorHandler.mock.calls.length - 1][0];
expect(lastErr).toBeInstanceOf(Error);
});
test('compActionHandler在 pageFragments 中也能找到目标节点', async () => {
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [
{
type: NodeType.PAGE,
id: 'page_1',
items: [
{ id: 'pf_container', type: 'page-fragment-container', pageFragmentId: 'pf_1' },
{
id: 'btn_1',
type: 'button',
events: [
{
name: 'click',
actions: [{ actionType: ActionType.COMP, to: 'btn_in_pf', method: 'go' }],
},
],
},
],
},
{
type: NodeType.PAGE_FRAGMENT,
id: 'pf_1',
items: [{ id: 'btn_in_pf', type: 'button' }],
},
],
} as any,
});
const target = app.pageFragments.get('pf_container')!.getNode('btn_in_pf')!;
const inst = { go: vi.fn() };
target.setInstance(inst);
app.emit('click', app.getNode('btn_1')!);
await flushAsync();
expect(inst.go).toHaveBeenCalled();
});
});
describe('EventHelper - bindDataSourceEvents / removeDataSourceEvents', () => {
test('为数据源 schema.events 中自定义事件绑定监听', async () => {
const app = new App({
config: createDsl({
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [],
methods: [],
events: [
{
name: 'change',
actions: [{ actionType: ActionType.CODE, codeId: 'logCode' } as any],
} as any,
],
},
],
codeBlocks: {
logCode: { name: 'logCode', params: [], content: vi.fn() },
},
} as any),
});
await flushAsync();
const helper = app.eventHelper!;
expect(helper).toBeDefined();
expect((helper as any).dataSourceEventList.has('ds_1')).toBe(true);
const dsEvents: Map<string, any> = (helper as any).dataSourceEventList.get('ds_1');
expect(dsEvents.has('change')).toBe(true);
const ds = app.dataSourceManager!.get('ds_1')!;
expect(ds.listenerCount('change')).toBeGreaterThanOrEqual(1);
});
test('数据源字段变更事件 (DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX) 通过 onDataChange 注册', async () => {
const app = new App({
config: createDsl({
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ type: 'string', name: 'foo', title: 'foo', defaultValue: '', enable: true }],
methods: [],
events: [
{
name: `${DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX}.foo`,
actions: [],
} as any,
],
},
],
} as any),
});
await flushAsync();
const ds = app.dataSourceManager!.get('ds_1')!;
const onDataChangeSpy = vi.spyOn(ds, 'onDataChange');
app.eventHelper!.bindDataSourceEvents();
expect(onDataChangeSpy).toHaveBeenCalled();
});
test('event.name 为空字符串时跳过绑定', () => {
const app = new App({
config: createDsl({
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [],
methods: [],
events: [{ name: '', actions: [] } as any],
},
],
} as any),
});
const helper = app.eventHelper!;
expect(() => helper.bindDataSourceEvents()).not.toThrow();
});
test('removeDataSourceEvents当 dataSourceEventList 为空时直接返回', () => {
const app = new App({});
const helper = new EventHelper({ app });
expect(() => helper.removeDataSourceEvents([])).not.toThrow();
});
test('removeDataSourceEvents 会同时清理 onDataChange 与普通事件', async () => {
const app = new App({
config: createDsl({
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [{ type: 'string', name: 'foo', title: 'foo', defaultValue: '', enable: true }],
methods: [],
events: [
{ name: 'change', actions: [] } as any,
{ name: `${DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX}.foo`, actions: [] } as any,
],
},
],
} as any),
});
await flushAsync();
const helper = app.eventHelper!;
const ds = app.dataSourceManager!.get('ds_1')!;
const offSpy = vi.spyOn(ds, 'off');
const offDataChangeSpy = vi.spyOn(ds, 'offDataChange');
helper.removeDataSourceEvents([ds]);
expect(offSpy).toHaveBeenCalled();
expect(offDataChangeSpy).toHaveBeenCalled();
expect((helper as any).dataSourceEventList.size).toBe(0);
});
test('数据源触发自定义事件后会调用配置的 action', async () => {
const codeFn = vi.fn();
const app = new App({
config: createDsl({
codeBlocks: { c: { name: 'c', params: [], content: codeFn } },
dataSources: [
{
id: 'ds_1',
type: 'base',
fields: [],
methods: [],
events: [
{
name: 'change',
actions: [{ actionType: ActionType.CODE, codeId: 'c' } as any],
} as any,
],
},
],
} as any),
});
await flushAsync();
const ds = app.dataSourceManager!.get('ds_1')!;
ds.setData({ a: 1 });
await flushAsync();
expect(codeFn).toHaveBeenCalled();
});
});
describe('EventHelper - flowState 状态管理', () => {
test('FlowState abort/reset 行为', () => {
const fs = new FlowState();
expect(fs.isAbort).toBe(false);
fs.abort();
expect(fs.isAbort).toBe(true);
fs.reset();
expect(fs.isAbort).toBe(false);
});
});