diff --git a/docs/api/editor/eventsServiceMethods.md b/docs/api/editor/eventsServiceMethods.md index e3b417ea..d70985a4 100644 --- a/docs/api/editor/eventsServiceMethods.md +++ b/docs/api/editor/eventsServiceMethods.md @@ -16,7 +16,11 @@ - **详情:** - 批量设置多个组件类型的事件列表 + 批量设置多个组件类型的事件列表。增量合并语义:只写入入参中出现的类型,已有的其他类型保持不变。 + + ::: tip 切换 runtime 版本 + 不同版本的 runtime 事件表不同,直接调用 `setEvents` 会让上一个版本独有的类型残留。是否需要清掉上一个版本的数据由业务自行决定,需要清掉时先调用 [`resetState`](#resetstate) 再写入新表。 + ::: - **示例:** @@ -47,7 +51,7 @@ eventsService.setEvents({ - **详情:** - 设置指定组件类型的事件列表 + 设置指定组件类型的事件列表,只影响该类型 - **示例:** @@ -106,7 +110,7 @@ eventsService.usePlugin({ - **详情:** - 批量设置多个组件类型的方法列表 + 批量设置多个组件类型的方法列表。语义与 `setEvents` 一致:增量合并,入参中不存在的类型保持不变。 - **示例:** @@ -138,7 +142,7 @@ eventsService.setMethods({ - **详情:** - 设置指定组件类型的方法列表 + 设置指定组件类型的方法列表,只影响该类型 - **示例:** @@ -209,14 +213,17 @@ eventsService.usePlugin({ - **详情:** - 重置事件服务状态,清空所有事件和方法配置 + 重置事件服务状态,清空所有事件和方法配置。`setEvents` / `setMethods` 是增量合并,切换 runtime 版本这类需要丢弃上一份事件表的场景,由业务在写入新表前自行调用本方法。 - **示例:** ```js import { eventsService } from '@tmagic/editor'; +// 切到另一个版本的 runtime:先清空,再写入该版本的事件表 eventsService.resetState(); +eventsService.setEvents(nextVersionEvents); +eventsService.setMethods(nextVersionMethods); ``` ## destroy diff --git a/docs/api/editor/props.md b/docs/api/editor/props.md index ea3ce73a..4ee4c93b 100644 --- a/docs/api/editor/props.md +++ b/docs/api/editor/props.md @@ -630,6 +630,10 @@ const propsValues = { 该属性最终会设置到[eventsService](./eventsServiceMethods.md)中,所以也可直接调用[eventsService.setEvents()](./eventsServiceMethods.md#setEvents)与[eventsService.setMethods()](./eventsServiceMethods.md#setMethods)方法来配置 ::: + :::warning + 该属性每次变更都是增量合并,不会清掉上一次写入的类型。切换 runtime 版本时若需要丢弃旧版本的事件表,请自行调用 [eventsService.resetState()](./eventsServiceMethods.md#resetstate) + ::: + - **默认值:** `{}` - **类型:** `Record` diff --git a/packages/editor/src/initService.ts b/packages/editor/src/initService.ts index 8a161393..38c76a84 100644 --- a/packages/editor/src/initService.ts +++ b/packages/editor/src/initService.ts @@ -254,6 +254,7 @@ export const initServiceState = ( uiService.resetState(); componentListService.resetState(); codeBlockService.resetState(); + eventsService.resetState(); keybindingService.reset(); depService.reset(); }); diff --git a/packages/editor/src/services/events.ts b/packages/editor/src/services/events.ts index e051abdc..bd03c91c 100644 --- a/packages/editor/src/services/events.ts +++ b/packages/editor/src/services/events.ts @@ -35,8 +35,8 @@ const canUsePluginMethods = { type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>; type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>; -let eventMap: Record = reactive({}); -let methodMap: Record = reactive({}); +const eventMap: Record = reactive({}); +const methodMap: Record = reactive({}); class Events extends BaseService { constructor() { @@ -75,8 +75,9 @@ class Events extends BaseService { } public resetState() { - eventMap = reactive({}); - methodMap = reactive({}); + // 原地清空而不是重新赋值,保留 reactive 代理身份,已建立的 computed / 渲染副作用才能收到更新 + Object.keys(eventMap).forEach((type) => delete eventMap[type]); + Object.keys(methodMap).forEach((type) => delete methodMap[type]); } public destroy() { diff --git a/packages/editor/tests/unit/initService.spec.ts b/packages/editor/tests/unit/initService.spec.ts index 60270801..60c1a8ba 100644 --- a/packages/editor/tests/unit/initService.spec.ts +++ b/packages/editor/tests/unit/initService.spec.ts @@ -8,6 +8,7 @@ import { defineComponent, h, nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import { DepTargetType } from '@tmagic/core'; +import { getNodes } from '@tmagic/utils'; import { initServiceEvents, initServiceState } from '@editor/initService'; import * as logger from '@editor/utils/logger'; @@ -60,6 +61,7 @@ const mkServices = () => { ...mkSvc('events'), setEvents: vi.fn(), setMethods: vi.fn(), + resetState: vi.fn(), }; const uiService: any = { ...mkSvc('ui'), @@ -210,8 +212,8 @@ describe('initServiceState', () => { eventMethodList: { typeA: { events: [{ name: 'click' }], methods: [{ name: 'm' }] } }, } as any; mount(Wrap(props, services)); - expect(services.eventsService.setEvents).toHaveBeenCalled(); - expect(services.eventsService.setMethods).toHaveBeenCalled(); + expect(services.eventsService.setEvents).toHaveBeenCalledWith({ typeA: [{ name: 'click' }] }); + expect(services.eventsService.setMethods).toHaveBeenCalledWith({ typeA: [{ name: 'm' }] }); }); test('datasourceConfigs 设置 form config', () => { @@ -284,11 +286,44 @@ describe('initServiceState', () => { expect(services.uiService.resetState).toHaveBeenCalled(); expect(services.componentListService.resetState).toHaveBeenCalled(); expect(services.codeBlockService.resetState).toHaveBeenCalled(); + expect(services.eventsService.resetState).toHaveBeenCalled(); expect(services.keybindingService.reset).toHaveBeenCalled(); expect(services.depService.reset).toHaveBeenCalled(); }); }); +const mkDataSourceManager = () => ({ + addDataSource: vi.fn(), + removeDataSource: vi.fn(), + get: vi.fn(() => ({ + setFields: vi.fn(), + setData: vi.fn(), + getDefaultData: vi.fn(() => ({})), + })), + init: vi.fn(), + compiledNode: vi.fn((node: any) => node), +}); + +/** renderer.runtime 直接就绪,getTMagicApp 走同步分支 */ +const mkReadyStage = (app: any) => { + const runtime = { + getApp: vi.fn(() => app), + updateRootConfig: vi.fn(), + updatePageId: vi.fn(), + }; + return { + renderer: { + runtime, + getRuntime: vi.fn(async () => runtime), + once: vi.fn(), + }, + select: vi.fn(), + reloadIframe: vi.fn(), + update: vi.fn(), + runtime, + }; +}; + describe('initServiceEvents', () => { let services: ReturnType; let emit: any; @@ -499,4 +534,217 @@ describe('initServiceEvents', () => { }); // 因 services 中 editor.state 不是 reactive,stage watch 不会触发,跳过该测试场景 + + test('getTMagicApp 在 runtime 未就绪时等待 runtime-ready,并复用同一个 promise', async () => { + const dataSourceManager = mkDataSourceManager(); + const app = { dsl: {}, dataSourceManager }; + let runtimeReady: any; + const renderer: any = { + runtime: null, + getRuntime: vi.fn(), + once: vi.fn((_event: string, cb: any) => { + runtimeReady = cb; + }), + }; + services.editorService.state.stage = { renderer, select: vi.fn() }; + mount(WrapEvents({} as any, emit, services)); + + services.dataSourceService.emit('add', { id: 'd1', type: 'base' }); + services.dataSourceService.emit('add', { id: 'd2', type: 'base' }); + await nextTick(); + // 两次调用只应注册一次 runtime-ready 监听 + expect(renderer.once).toHaveBeenCalledTimes(1); + + renderer.runtime = { getApp: () => app }; + runtimeReady(); + await new Promise((r) => setTimeout(r, 0)); + + expect(dataSourceManager.addDataSource).toHaveBeenCalledTimes(2); + expect(dataSourceManager.init).toHaveBeenCalled(); + }); + + test('getTMagicApp 在 stage 没有 renderer 时返回 undefined', async () => { + services.editorService.state.stage = { select: vi.fn() }; + mount(WrapEvents({} as any, emit, services)); + + services.dataSourceService.emit('add', { id: 'd1', type: 'base' }); + await new Promise((r) => setTimeout(r, 0)); + + expect(services.depService.addTarget).toHaveBeenCalled(); + }); + + test('ds-collected 事件把 dataSourceDeps 同步给 runtime app', async () => { + const app: any = { dsl: { dataSourceDeps: null }, dataSourceManager: mkDataSourceManager() }; + services.editorService.state.stage = mkReadyStage(app); + services.editorService.state.root = { id: 'r', dataSourceDeps: { d1: {} } }; + mount(WrapEvents({} as any, emit, services)); + + services.depService.emit('ds-collected'); + await new Promise((r) => setTimeout(r, 0)); + + expect(app.dsl.dataSourceDeps).toEqual({ d1: {} }); + }); + + test('rootChange items 是数组时更新画布 dsl', async () => { + const app: any = { dsl: {}, dataSourceManager: mkDataSourceManager() }; + const stage = mkReadyStage(app); + services.editorService.state.stage = stage; + services.editorService.state.page = { id: 'p1' }; + services.editorService.state.node = { id: 'n1' }; + mount(WrapEvents({} as any, emit, services)); + + services.editorService.emit('root-change', { + id: 'r', + items: [{ id: 'n1', type: 'text' }], + dataSources: [], + dataSourceDeps: { d1: {} }, + codeBlocks: {}, + }); + await new Promise((r) => setTimeout(r, 10)); + + expect(stage.runtime.updatePageId).toHaveBeenCalledWith('p1'); + expect(stage.runtime.updateRootConfig).toHaveBeenCalled(); + expect(stage.select).toHaveBeenCalledWith('n1'); + }); + + test('rootChange items 不是数组时清空依赖', async () => { + services.editorService.state.root = { id: 'r' }; + mount(WrapEvents({} as any, emit, services)); + + const value: any = { id: 'r', dataSourceDeps: { a: {} }, dataSourceCondDeps: { b: {} } }; + services.editorService.emit('root-change', value); + await new Promise((r) => setTimeout(r, 0)); + + expect(services.depService.clear).toHaveBeenCalled(); + expect(value.dataSourceDeps).toBeUndefined(); + expect(value.dataSourceCondDeps).toBeUndefined(); + }); + + test('rootChange 没有可选中节点时回落到 root 自身', async () => { + services.editorService.getNodeById.mockReturnValue(null); + mount(WrapEvents({} as any, emit, services)); + + const value: any = { id: 'r', items: [] }; + services.editorService.emit('root-change', value, { id: 'prev' }); + await new Promise((r) => setTimeout(r, 0)); + + expect(services.editorService.set).toHaveBeenCalledWith('nodes', [value]); + expect(services.editorService.set).toHaveBeenCalledWith('parent', null); + expect(services.editorService.set).toHaveBeenCalledWith('page', null); + expect(emit).toHaveBeenCalledWith('update:modelValue', value); + }); + + test('rootChange 有 items 时选中第一个节点', async () => { + services.editorService.getNodeById.mockReturnValue(null); + mount(WrapEvents({} as any, emit, services)); + + services.editorService.emit('root-change', { id: 'r', items: [{ id: 'first', type: 'page' }] }); + await new Promise((r) => setTimeout(r, 0)); + + expect(services.editorService.select).toHaveBeenCalledWith({ id: 'first', type: 'page' }); + }); + + test('update 事件:ROOT 节点、无 propPath、命中已收集依赖三种分支', async () => { + services.editorService.state.root = { id: 'r', items: [] }; + services.depService.getTargets.mockReturnValue({ + t1: { deps: { n3: { keys: ['props.text'] } } }, + }); + mount(WrapEvents({} as any, emit, services)); + + services.editorService.emit('update', [ + { newNode: { id: 'r', type: 'root' }, oldNode: { id: 'r', type: 'root' } }, + { + newNode: { id: 'n2', type: 'text' }, + oldNode: { id: 'n2', type: 'text' }, + changeRecords: [{ value: 'x' }], + }, + { + newNode: { id: 'n3', type: 'text' }, + oldNode: { id: 'n3', type: 'text' }, + changeRecords: [{ propPath: 'props.text', value: 'x' }], + }, + ]); + await new Promise((r) => setTimeout(r, 0)); + + expect(services.depService.collectIdle).toHaveBeenCalled(); + }); + + test('dataSource update 修改 mocks / methods 分别只重建对应 target', async () => { + const app: any = { dsl: {}, dataSourceManager: mkDataSourceManager() }; + services.editorService.state.stage = mkReadyStage(app); + services.editorService.state.root = { id: 'r', items: [{ id: 'a', type: 'text' }] }; + mount(WrapEvents({} as any, emit, services)); + + services.dataSourceService.emit( + 'update', + { id: 'd1', type: 'base', fields: [], mocks: [{ useInEditor: true, data: { a: 1 } }] }, + { changeRecords: [{ propPath: 'mocks' }] }, + ); + await new Promise((r) => setTimeout(r, 0)); + expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE); + + services.depService.removeTarget.mockClear(); + services.dataSourceService.emit( + 'update', + { id: 'd1', type: 'base', fields: [], methods: [] }, + { changeRecords: [{ propPath: 'methods.0.name' }] }, + ); + await new Promise((r) => setTimeout(r, 0)); + expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE_METHOD); + }); + + test('dataSource update 无依赖变更时只刷新数据', async () => { + const dataSourceManager = mkDataSourceManager(); + const app: any = { dsl: {}, dataSourceManager }; + services.editorService.state.stage = mkReadyStage(app); + services.editorService.state.root = { id: 'r', items: [], dataSources: [{ id: 'd1' }] }; + mount(WrapEvents({} as any, emit, services)); + + services.dataSourceService.emit( + 'update', + { id: 'd1', type: 'base', fields: [] }, + { changeRecords: [{ propPath: 'title' }, {}] }, + ); + await new Promise((r) => setTimeout(r, 0)); + + expect(dataSourceManager.get).toHaveBeenCalledWith('d1'); + expect(services.depService.clearIdleTasks).not.toHaveBeenCalled(); + }); + + test('dataSource remove 重新收集依赖并从 app 中移除数据源', async () => { + const dataSourceManager = mkDataSourceManager(); + const app: any = { dsl: {}, dataSourceManager }; + services.editorService.state.stage = mkReadyStage(app); + services.editorService.state.root = { + id: 'r', + items: [], + dataSources: [{ id: 'd1' }], + dataSourceDeps: { d1: { n1: {} } }, + }; + mount(WrapEvents({} as any, emit, services)); + + services.dataSourceService.emit('remove', 'd1'); + await new Promise((r) => setTimeout(r, 0)); + + expect(dataSourceManager.removeDataSource).toHaveBeenCalledWith('d1'); + expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE); + expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE_COND); + expect(services.depService.removeTarget).toHaveBeenCalledWith('d1', DepTargetType.DATA_SOURCE_METHOD); + expect(app.dsl.dataSources).toEqual([{ id: 'd1' }]); + }); + + test('dataSource remove 收集未完成时不移除数据源', async () => { + const dataSourceManager = mkDataSourceManager(); + const app: any = { dsl: {}, dataSourceManager }; + services.editorService.state.stage = mkReadyStage(app); + services.editorService.state.root = { id: 'r', items: [] }; + vi.mocked(getNodes).mockReturnValueOnce([{ id: 'n1', type: 'text' }] as any); + services.depService.collectIdle.mockResolvedValue(false); + mount(WrapEvents({} as any, emit, services)); + + services.dataSourceService.emit('remove', 'd1'); + await new Promise((r) => setTimeout(r, 0)); + + expect(dataSourceManager.removeDataSource).not.toHaveBeenCalled(); + }); }); diff --git a/packages/editor/tests/unit/services/events.spec.ts b/packages/editor/tests/unit/services/events.spec.ts index 7a9af293..6cbad396 100644 --- a/packages/editor/tests/unit/services/events.spec.ts +++ b/packages/editor/tests/unit/services/events.spec.ts @@ -16,11 +16,16 @@ * limitations under the License. */ -import { describe, expect, test } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { computed } from 'vue'; import events from '@editor/services/events'; describe('events', () => { + beforeEach(() => { + events.resetState(); + }); + test('setEvent', () => { const event = [{ label: '点击', value: 'magic:common:events:click' }]; events.setEvent('button', event); @@ -42,6 +47,37 @@ describe('events', () => { expect(events.getEvent('text')).toHaveLength(1); }); + test('setEvents 增量合并,不动入参中不存在的类型', () => { + events.setEvents({ + Image: [{ label: 'click', value: 'click' }], + Text: [{ label: 'init', value: 'init' }], + } as any); + events.setEvents({ + Image: [{ label: 'hover', value: 'hover' }], + } as any); + expect(events.getEvent('image')).toEqual([{ label: 'hover', value: 'hover' }]); + expect(events.getEvent('text')).toEqual([{ label: 'init', value: 'init' }]); + }); + + test('setEvents 空对象是空操作', () => { + events.setEvent('button', [{ label: 'click', value: 'click' }]); + events.setEvents({}); + expect(events.getEvent('button')).toEqual([{ label: 'click', value: 'click' }]); + }); + + test('setEvents 入参类型名按 toLine 归一后写入', () => { + events.setEvent('foo-bar', [{ label: 'a', value: 'a' }]); + events.setEvents({ + fooBar: [{ label: 'b', value: 'b' }], + } as any); + expect(events.getEvent('foo-bar')).toEqual([{ label: 'b', value: 'b' }]); + }); + + test('setEvents 缺省列表按空数组写入', () => { + events.setEvents({ button: undefined } as any); + expect(events.getEvent('button')).toEqual([]); + }); + test('setMethods 批量设置', () => { events.setMethods({ Image: [{ label: 'show', value: 'show' }], @@ -49,6 +85,29 @@ describe('events', () => { expect(events.getMethod('image', {})).toHaveLength(1); }); + test('setMethods 增量合并,不动入参中不存在的类型', () => { + events.setMethods({ + Image: [{ label: 'show', value: 'show' }], + Video: [{ label: 'play', value: 'play' }], + } as any); + events.setMethods({ + Video: [{ label: 'pause', value: 'pause' }], + } as any); + expect(events.getMethod('video', {})).toEqual([{ label: 'pause', value: 'pause' }]); + expect(events.getMethod('image', {})).toEqual([{ label: 'show', value: 'show' }]); + }); + + test('setMethods 缺省列表按空数组写入', () => { + events.setMethods({ video: undefined } as any); + expect(events.getMethod('video')).toEqual([]); + }); + + test('setMethods 空对象是空操作', () => { + events.setMethod('button', [{ label: 'open', value: 'open' }]); + events.setMethods({}); + expect(events.getMethod('button')).toEqual([{ label: 'open', value: 'open' }]); + }); + test('getEvent / getMethod 支持节点上下文参数', () => { const node = { id: 'n1', type: 'button' } as any; events.setEvent('button', [{ label: '点击', value: 'click' }]); @@ -64,7 +123,58 @@ describe('events', () => { test('resetState 清空所有事件 / 方法', () => { events.setEvent('foo', [{ label: 'l', value: 'v' }]); + events.setMethod('foo', [{ label: 'm', value: 'm' }]); events.resetState(); expect(events.getEvent('foo')).toEqual([]); + expect(events.getMethod('foo')).toEqual([]); + }); + + test('resetState 原地清空后仍可继续写入', () => { + events.setEvent('foo', [{ label: 'l', value: 'v' }]); + events.resetState(); + events.setEvent('bar', [{ label: 'click', value: 'click' }]); + events.setMethod('bar', [{ label: 'open', value: 'open' }]); + expect(events.getEvent('bar')).toEqual([{ label: 'click', value: 'click' }]); + expect(events.getMethod('bar')).toEqual([{ label: 'open', value: 'open' }]); + }); + + test('resetState 原地清空会使已有 computed 失效', () => { + events.setEvent('foo', [{ label: 'l', value: 'v' }]); + events.setMethod('foo', [{ label: 'm', value: 'm' }]); + const eventList = computed(() => events.getEvent('foo')); + const methodList = computed(() => events.getMethod('foo')); + expect(eventList.value).toHaveLength(1); + expect(methodList.value).toHaveLength(1); + events.resetState(); + expect(eventList.value).toEqual([]); + expect(methodList.value).toEqual([]); + }); + + test('切换 runtime 版本:宿主先 resetState 再写入新版本事件表', () => { + events.setEvents({ + button: [{ label: 'click', value: 'click' }], + video: [{ label: 'play', value: 'play' }], + } as any); + + // 是否清掉上一个版本的数据由业务决定,需要清掉时自行调用 resetState + events.resetState(); + events.setEvents({ button: [{ label: 'click', value: 'click' }] } as any); + + expect(events.getEvent('video')).toEqual([]); + expect(events.getEvent('button')).toHaveLength(1); + }); + + test('usePlugin 钩子生效,destroy 后重置状态并移除插件', () => { + const afterGetEvent = vi.fn((result: any[]) => [...result, { label: 'extra', value: 'extra' }]); + events.usePlugin({ afterGetEvent } as any); + events.setEvent('button', [{ label: 'click', value: 'click' }]); + expect(events.getEvent('button')).toHaveLength(2); + + events.destroy(); + expect(events.getEvent('button')).toEqual([]); + + events.setEvent('button', [{ label: 'click', value: 'click' }]); + expect(events.getEvent('button')).toHaveLength(1); + expect(afterGetEvent).toHaveBeenCalled(); }); });