From 6625c9bc48e3cdbb39f87e53c1865e35f19381df Mon Sep 17 00:00:00 2001 From: dswbx Date: Wed, 15 Jan 2025 17:21:28 +0100 Subject: [PATCH] Refactor event system to support returnable events Added support for validating and managing return values in events. Implemented `validate` and `clone` methods in the event base class for event mutation and return handling. Additionally, enhanced error handling, introduced "once" listeners, and improved async execution management in the `EventManager`. --- app/__test__/core/EventManager.spec.ts | 135 +++++++++++++++++++--- app/src/core/events/Event.ts | 24 +++- app/src/core/events/EventListener.ts | 7 +- app/src/core/events/EventManager.ts | 92 ++++++++++++--- app/src/core/events/index.ts | 4 +- tmp/event_manager_returning_test.patch | 150 ------------------------- 6 files changed, 227 insertions(+), 185 deletions(-) delete mode 100644 tmp/event_manager_returning_test.patch diff --git a/app/__test__/core/EventManager.spec.ts b/app/__test__/core/EventManager.spec.ts index 3327449..4f11d19 100644 --- a/app/__test__/core/EventManager.spec.ts +++ b/app/__test__/core/EventManager.spec.ts @@ -1,8 +1,18 @@ -import { describe, expect, test } from "bun:test"; -import { Event, EventManager, NoParamEvent } from "../../src/core/events"; +import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; +import { + Event, + EventManager, + InvalidEventReturn, + type ListenerHandler, + NoParamEvent +} from "../../src/core/events"; +import { disableConsoleLog, enableConsoleLog } from "../helper"; + +beforeAll(disableConsoleLog); +afterAll(enableConsoleLog); class SpecialEvent extends Event<{ foo: string }> { - static slug = "special-event"; + static override slug = "special-event"; isBar() { return this.params.foo === "bar"; @@ -10,37 +20,136 @@ class SpecialEvent extends Event<{ foo: string }> { } class InformationalEvent extends NoParamEvent { - static slug = "informational-event"; + static override slug = "informational-event"; +} + +class ReturnEvent extends Event<{ foo: string }, string> { + static override slug = "return-event"; + + override validate(value: string) { + if (typeof value !== "string") { + throw new InvalidEventReturn("string", typeof value); + } + + return this.clone({ + foo: [this.params.foo, value].join("-") + }); + } } describe("EventManager", async () => { - test("test", async () => { + test("executes", async () => { + const call = mock(() => null); + const delayed = mock(() => null); + const emgr = new EventManager(); emgr.registerEvents([SpecialEvent, InformationalEvent]); + expect(emgr.eventExists("special-event")).toBe(true); + expect(emgr.eventExists("informational-event")).toBe(true); + expect(emgr.eventExists("unknown-event")).toBe(false); + emgr.onEvent( SpecialEvent, async (event, name) => { - console.log("Event: ", name, event.params.foo, event.isBar()); - console.log("wait..."); - - await new Promise((resolve) => setTimeout(resolve, 100)); - console.log("done waiting"); + expect(name).toBe("special-event"); + expect(event.isBar()).toBe(true); + call(); + await new Promise((resolve) => setTimeout(resolve, 50)); + delayed(); }, "sync" ); emgr.onEvent(InformationalEvent, async (event, name) => { - console.log("Event: ", name, event.params); + call(); + expect(name).toBe("informational-event"); }); await emgr.emit(new SpecialEvent({ foo: "bar" })); - console.log("done"); + await emgr.emit(new InformationalEvent()); // expect construct signatures to not cause ts errors new SpecialEvent({ foo: "bar" }); new InformationalEvent(); - expect(true).toBe(true); + expect(call).toHaveBeenCalledTimes(2); + expect(delayed).toHaveBeenCalled(); + }); + + test("custom async executor", async () => { + const call = mock(() => null); + const asyncExecutor = (p: Promise[]) => { + call(); + return Promise.all(p); + }; + const emgr = new EventManager( + { InformationalEvent }, + { + asyncExecutor + } + ); + + emgr.onEvent(InformationalEvent, async () => {}); + await emgr.emit(new InformationalEvent()); + expect(call).toHaveBeenCalled(); + }); + + test("piping", async () => { + const onInvalidReturn = mock(() => null); + const asyncEventCallback = mock(() => null); + const emgr = new EventManager( + { ReturnEvent, InformationalEvent }, + { + onInvalidReturn + } + ); + + // @ts-expect-error InformationalEvent has no return value + emgr.onEvent(InformationalEvent, async () => { + asyncEventCallback(); + return 1; + }); + + emgr.onEvent(ReturnEvent, async () => "1", "sync"); + emgr.onEvent(ReturnEvent, async () => "0", "sync"); + + // @ts-expect-error must be string + emgr.onEvent(ReturnEvent, async () => 0, "sync"); + + // return is not required + emgr.onEvent(ReturnEvent, async () => {}, "sync"); + + // was "async", will not return + const e1 = await emgr.emit(new InformationalEvent()); + expect(e1.returned).toBe(false); + + const e2 = await emgr.emit(new ReturnEvent({ foo: "bar" })); + expect(e2.returned).toBe(true); + expect(e2.params.foo).toBe("bar-1-0"); + expect(onInvalidReturn).toHaveBeenCalled(); + expect(asyncEventCallback).toHaveBeenCalled(); + }); + + test("once", async () => { + const call = mock(() => null); + const emgr = new EventManager({ InformationalEvent }); + + emgr.onEventOnce( + InformationalEvent, + async (event, slug) => { + expect(event).toBeInstanceOf(InformationalEvent); + expect(slug).toBe("informational-event"); + call(); + }, + "sync" + ); + + expect(emgr.getListeners().length).toBe(1); + await emgr.emit(new InformationalEvent()); + expect(emgr.getListeners().length).toBe(0); + await emgr.emit(new InformationalEvent()); + expect(emgr.getListeners().length).toBe(0); + expect(call).toHaveBeenCalledTimes(1); }); }); diff --git a/app/src/core/events/Event.ts b/app/src/core/events/Event.ts index 247c7a5..734c7c2 100644 --- a/app/src/core/events/Event.ts +++ b/app/src/core/events/Event.ts @@ -1,17 +1,31 @@ -export abstract class Event { +export abstract class Event { + _returning!: Returning; + /** * Unique event slug * Must be static, because registering events is done by class */ static slug: string = "untitled-event"; params: Params; + returned: boolean = false; + + validate(value: Returning): Event | void {} + + protected clone = Event>( + this: This, + params: Params + ): This { + const cloned = new (this.constructor as any)(params); + cloned.returned = true; + return cloned as This; + } constructor(params: Params) { this.params = params; } } -// @todo: current workaround: potentially there is none and that's the way +// @todo: current workaround: potentially there is "none" and that's the way export class NoParamEvent extends Event { static override slug: string = "noparam-event"; @@ -19,3 +33,9 @@ export class NoParamEvent extends Event { super(null); } } + +export class InvalidEventReturn extends Error { + constructor(expected: string, given: string) { + super(`Expected "${expected}", got "${given}"`); + } +} diff --git a/app/src/core/events/EventListener.ts b/app/src/core/events/EventListener.ts index 951fce8..fc677ed 100644 --- a/app/src/core/events/EventListener.ts +++ b/app/src/core/events/EventListener.ts @@ -4,15 +4,16 @@ import type { EventClass } from "./EventManager"; export const ListenerModes = ["sync", "async"] as const; export type ListenerMode = (typeof ListenerModes)[number]; -export type ListenerHandler = ( +export type ListenerHandler> = ( event: E, - slug: string, -) => Promise | void; + slug: string +) => E extends Event ? R | Promise : never; export class EventListener { mode: ListenerMode = "async"; event: EventClass; handler: ListenerHandler; + once: boolean = false; constructor(event: EventClass, handler: ListenerHandler, mode: ListenerMode = "async") { this.event = event; diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts index 9233666..6e48224 100644 --- a/app/src/core/events/EventManager.ts +++ b/app/src/core/events/EventManager.ts @@ -1,4 +1,4 @@ -import type { Event } from "./Event"; +import { type Event, InvalidEventReturn } from "./Event"; import { EventListener, type ListenerHandler, type ListenerMode } from "./EventListener"; export interface EmitsEvents { @@ -6,7 +6,7 @@ export interface EmitsEvents { } export type EventClass = { - new (params: any): Event; + new (params: any): Event; slug: string; }; @@ -17,16 +17,20 @@ export class EventManager< protected listeners: EventListener[] = []; enabled: boolean = true; - constructor(events?: RegisteredEvents, listeners?: EventListener[]) { + constructor( + events?: RegisteredEvents, + private options?: { + listeners?: EventListener[]; + onError?: (event: Event, e: unknown) => void; + onInvalidReturn?: (event: Event, e: InvalidEventReturn) => void; + asyncExecutor?: typeof Promise.all; + } + ) { if (events) { this.registerEvents(events); } - if (listeners) { - for (const listener of listeners) { - this.addListener(listener); - } - } + options?.listeners?.forEach((l) => this.addListener(l)); } enable() { @@ -128,6 +132,18 @@ export class EventManager< this.addListener(listener as any); } + onEventOnce>( + event: ActualEvent, + handler: ListenerHandler, + mode: ListenerMode = "async" + ) { + this.throwIfEventNotRegistered(event); + + const listener = new EventListener(event, handler, mode); + listener.once = true; + this.addListener(listener as any); + } + on( slug: string, handler: ListenerHandler>, @@ -145,27 +161,73 @@ export class EventManager< this.events.forEach((event) => this.onEvent(event, handler, mode)); } - async emit(event: Event) { + protected executeAsyncs(promises: (() => Promise)[]) { + const executor = this.options?.asyncExecutor ?? ((e) => Promise.all(e)); + executor(promises.map((p) => p())).then(() => void 0); + } + + async emit>(event: Actual): Promise { // @ts-expect-error slug is static const slug = event.constructor.slug; if (!this.enabled) { console.log("EventManager disabled, not emitting", slug); - return; + return event; } if (!this.eventExists(event)) { throw new Error(`Event "${slug}" not registered`); } - const listeners = this.listeners.filter((listener) => listener.event.slug === slug); - //console.log("---!-- emitting", slug, listeners.length); + const syncs: EventListener[] = []; + const asyncs: (() => Promise)[] = []; + + this.listeners = this.listeners.filter((listener) => { + // if no match, keep and ignore + if (listener.event.slug !== slug) return true; - for (const listener of listeners) { if (listener.mode === "sync") { - await listener.handler(event, listener.event.slug); + syncs.push(listener); } else { - listener.handler(event, listener.event.slug); + asyncs.push(async () => await listener.handler(event, listener.event.slug)); + } + // Remove if `once` is true, otherwise keep + return !listener.once; + }); + + // execute asyncs + this.executeAsyncs(asyncs); + + // execute syncs + let _event: Actual = event; + for (const listener of syncs) { + try { + const return_value = (await listener.handler(_event, listener.event.slug)) as any; + + if (typeof return_value !== "undefined") { + const newEvent = _event.validate(return_value); + // @ts-expect-error slug is static + if (newEvent && newEvent.constructor.slug === slug) { + if (!newEvent.returned) { + throw new Error( + // @ts-expect-error slug is static + `Returned event ${newEvent.constructor.slug} must be marked as returned.` + ); + } + _event = newEvent as Actual; + } + } + } catch (e) { + if (e instanceof InvalidEventReturn) { + this.options?.onInvalidReturn?.(_event, e); + console.warn(`Invalid return of event listener for "${slug}": ${e.message}`); + } else if (this.options?.onError) { + this.options.onError(_event, e); + } else { + throw e; + } } } + + return _event; } } diff --git a/app/src/core/events/index.ts b/app/src/core/events/index.ts index b823edf..1edb065 100644 --- a/app/src/core/events/index.ts +++ b/app/src/core/events/index.ts @@ -1,8 +1,8 @@ -export { Event, NoParamEvent } from "./Event"; +export { Event, NoParamEvent, InvalidEventReturn } from "./Event"; export { EventListener, ListenerModes, type ListenerMode, - type ListenerHandler, + type ListenerHandler } from "./EventListener"; export { EventManager, type EmitsEvents, type EventClass } from "./EventManager"; diff --git a/tmp/event_manager_returning_test.patch b/tmp/event_manager_returning_test.patch deleted file mode 100644 index 1e194ef..0000000 --- a/tmp/event_manager_returning_test.patch +++ /dev/null @@ -1,150 +0,0 @@ -Subject: [PATCH] event manager returning test ---- -Index: app/__test__/core/EventManager.spec.ts -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/app/__test__/core/EventManager.spec.ts b/app/__test__/core/EventManager.spec.ts ---- a/app/__test__/core/EventManager.spec.ts (revision f06777256f332766de4bc76c23183725c8c7d310) -+++ b/app/__test__/core/EventManager.spec.ts (date 1731498680965) -@@ -1,8 +1,8 @@ - import { describe, expect, test } from "bun:test"; --import { Event, EventManager, NoParamEvent } from "../../src/core/events"; -+import { Event, EventManager, type ListenerHandler, NoParamEvent } from "../../src/core/events"; - - class SpecialEvent extends Event<{ foo: string }> { -- static slug = "special-event"; -+ static override slug = "special-event"; - - isBar() { - return this.params.foo === "bar"; -@@ -10,7 +10,19 @@ - } - - class InformationalEvent extends NoParamEvent { -- static slug = "informational-event"; -+ static override slug = "informational-event"; -+} -+ -+class ReturnEvent extends Event<{ foo: string }, number> { -+ static override slug = "return-event"; -+ static override returning = true; -+ -+ override setValidatedReturn(value: number) { -+ if (typeof value !== "number") { -+ throw new Error("Invalid return value"); -+ } -+ this.params.foo = value.toString(); -+ } - } - - describe("EventManager", async () => { -@@ -43,4 +55,22 @@ - - expect(true).toBe(true); - }); -+ -+ test.only("piping", async () => { -+ const emgr = new EventManager(); -+ emgr.registerEvents([ReturnEvent, InformationalEvent]); -+ -+ type T = ListenerHandler; -+ -+ // @ts-expect-error InformationalEvent has no return value -+ emgr.onEvent(InformationalEvent, async (event, name) => { -+ console.log("Event: ", name, event.params); -+ return 1; -+ }); -+ -+ emgr.onEvent(ReturnEvent, async (event, name) => { -+ console.log("Event: ", name, event.params); -+ return 1; -+ }); -+ }); - }); -Index: app/src/core/events/EventManager.ts -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/app/src/core/events/EventManager.ts b/app/src/core/events/EventManager.ts ---- a/app/src/core/events/EventManager.ts (revision f06777256f332766de4bc76c23183725c8c7d310) -+++ b/app/src/core/events/EventManager.ts (date 1731498680971) -@@ -6,7 +6,7 @@ - } - - export type EventClass = { -- new (params: any): Event; -+ new (params: any): Event; - slug: string; - }; - -@@ -137,6 +137,9 @@ - throw new Error(`Event "${slug}" not registered`); - } - -+ // @ts-expect-error returning is static -+ const returning = Boolean(event.constructor.returning); -+ - const listeners = this.listeners.filter((listener) => listener.event.slug === slug); - //console.log("---!-- emitting", slug, listeners.length); - -Index: app/src/core/events/EventListener.ts -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/app/src/core/events/EventListener.ts b/app/src/core/events/EventListener.ts ---- a/app/src/core/events/EventListener.ts (revision f06777256f332766de4bc76c23183725c8c7d310) -+++ b/app/src/core/events/EventListener.ts (date 1731498680968) -@@ -4,10 +4,10 @@ - export const ListenerModes = ["sync", "async"] as const; - export type ListenerMode = (typeof ListenerModes)[number]; - --export type ListenerHandler = ( -+export type ListenerHandler> = ( - event: E, -- slug: string, --) => Promise | void; -+ slug: string -+) => E extends Event ? R | Promise : never; - - export class EventListener { - mode: ListenerMode = "async"; -Index: app/src/core/events/Event.ts -IDEA additional info: -Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP -<+>UTF-8 -=================================================================== -diff --git a/app/src/core/events/Event.ts b/app/src/core/events/Event.ts ---- a/app/src/core/events/Event.ts (revision f06777256f332766de4bc76c23183725c8c7d310) -+++ b/app/src/core/events/Event.ts (date 1731498680973) -@@ -1,17 +1,25 @@ --export abstract class Event { -+export abstract class Event { - /** - * Unique event slug - * Must be static, because registering events is done by class - */ - static slug: string = "untitled-event"; - params: Params; -+ _returning!: Returning; -+ static returning: boolean = false; -+ -+ setValidatedReturn(value: Returning): void { -+ if (typeof value !== "undefined") { -+ throw new Error("Invalid event return value"); -+ } -+ } - - constructor(params: Params) { - this.params = params; - } - } - --// @todo: current workaround: potentially there is none and that's the way -+// @todo: current workaround: potentially there is "none" and that's the way - export class NoParamEvent extends Event { - static override slug: string = "noparam-event"; -