diff --git a/src/components/canvas/GraphComponent/GraphComponent.test.ts b/src/components/canvas/GraphComponent/GraphComponent.test.ts new file mode 100644 index 00000000..e22f183c --- /dev/null +++ b/src/components/canvas/GraphComponent/GraphComponent.test.ts @@ -0,0 +1,129 @@ +import { Graph } from "../../../graph"; +import { GraphEventsDefinitions } from "../../../graphEvents"; +import { Component } from "../../../lib/Component"; +import { HitBox } from "../../../services/HitTest"; + +import { GraphComponent, GraphComponentContext } from "./index"; + +class TestGraphComponent extends GraphComponent { + public getEntityId(): string { + return "test-id"; + } + + public subscribeGraphEvent( + eventName: EventName, + handler: GraphEventsDefinitions[EventName], + options?: AddEventListenerOptions | boolean + ): () => void { + return this.onGraphEvent(eventName, handler, options); + } + + public subscribeRootEvent( + eventName: K, + handler: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void, + options?: AddEventListenerOptions | boolean + ): () => void { + return this.onRootEvent(eventName, handler, options); + } +} + +type TestSetup = { + component: TestGraphComponent; + graphOn: jest.Mock<() => void, Parameters>; + graphOff: jest.Mock; + rootEl: HTMLDivElement; + hitTestRemove: jest.Mock; +}; + +function createTestComponent(root?: HTMLDivElement): TestSetup { + const graphOff = jest.fn(); + const graphOn = jest.fn<() => void, Parameters>().mockReturnValue(graphOff); + + const hitTestRemove = jest.fn(); + const fakeGraph = { + on: graphOn, + hitTest: { + remove: hitTestRemove, + update: jest.fn(), + }, + // The rest of Graph API is not needed for these tests + }; + + const rootEl = root ?? document.createElement("div"); + + const parent = new Component({}, undefined); + + parent.setContext({ + graph: fakeGraph, + root: rootEl, + canvas: document.createElement("canvas"), + ctx: document.createElement("canvas").getContext("2d") as CanvasRenderingContext2D, + ownerDocument: document, + camera: { + isRectVisible: () => true, + }, + constants: {} as GraphComponentContext["constants"], + colors: {} as GraphComponentContext["colors"], + graphCanvas: document.createElement("canvas"), + layer: {} as GraphComponentContext["layer"], + affectsUsableRect: true, + } as unknown as GraphComponentContext); + + const component = new TestGraphComponent({}, parent); + + return { + component, + graphOn, + graphOff, + rootEl, + hitTestRemove, + }; +} + +describe("GraphComponent event helpers", () => { + it("subscribes to graph events via onGraphEvent and cleans up on unmount", () => { + const { component, graphOn, graphOff } = createTestComponent(); + + const handler = jest.fn(); + + component.subscribeGraphEvent("camera-change", handler); + + expect(graphOn).toHaveBeenCalledTimes(1); + expect(graphOn).toHaveBeenCalledWith("camera-change", handler, undefined); + + Component.unmount(component); + + expect(graphOff).toHaveBeenCalledTimes(1); + }); + + it("subscribes to root DOM events via onRootEvent and cleans up on unmount", () => { + const rootEl = document.createElement("div"); + const addSpy = jest.spyOn(rootEl, "addEventListener"); + const removeSpy = jest.spyOn(rootEl, "removeEventListener"); + + const { component } = createTestComponent(rootEl); + + const handler = jest.fn((event: MouseEvent) => { + // Use event to keep types happy + expect(event).toBeInstanceOf(MouseEvent); + }); + + component.subscribeRootEvent("click", handler); + + expect(addSpy).toHaveBeenCalledTimes(1); + const [eventName, addListener] = addSpy.mock.calls[0]; + expect(eventName).toBe("click"); + expect(typeof addListener).toBe("function"); + + const event = new MouseEvent("click"); + rootEl.dispatchEvent(event); + expect(handler).toHaveBeenCalledTimes(1); + + Component.unmount(component); + + expect(removeSpy).toHaveBeenCalledTimes(1); + const [removedEventName, removeListener] = removeSpy.mock.calls[0]; + expect(removedEventName).toBe("click"); + expect(typeof removeListener).toBe("function"); + }); +}); diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index 418d2806..d1cb1c6e 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -1,6 +1,7 @@ import { Signal } from "@preact/signals-core"; import { Graph } from "../../../graph"; +import { GraphEventsDefinitions } from "../../../graphEvents"; import { Component } from "../../../lib"; import { TComponentContext, TComponentProps, TComponentState } from "../../../lib/Component"; import { HitBox, HitBoxData } from "../../../services/HitTest"; @@ -164,6 +165,61 @@ export class GraphComponent< }); } + /** + * Subscribes to a graph event and automatically unsubscribes on component unmount. + * + * This is a convenience wrapper around this.context.graph.on that also registers the + * returned unsubscribe function in the internal unsubscribe list, ensuring proper cleanup. + * + * @param eventName - Graph event name to subscribe to + * @param handler - Event handler callback + * @param options - Additional AddEventListener options + * @returns Unsubscribe function + */ + protected onGraphEvent( + eventName: EventName, + handler: Cb, + options?: AddEventListenerOptions | boolean + ): () => void { + const unsubscribe = this.context.graph.on(eventName, handler, options); + this.unsubscribe.push(unsubscribe); + return unsubscribe; + } + + /** + * Subscribes to a DOM event on the graph root element and automatically unsubscribes on unmount. + * + * @param eventName - DOM event name to subscribe to + * @param handler - Event handler callback + * @param options - Additional AddEventListener options + * @returns Unsubscribe function + */ + protected onRootEvent( + eventName: K, + handler: ((this: HTMLElement, ev: HTMLElementEventMap[K]) => void) | EventListenerObject, + options?: AddEventListenerOptions | boolean + ): () => void { + const root = this.context.root; + if (!root) { + throw new Error("Attempt to add event listener to non-existent root element"); + } + + const listener = + typeof handler === "function" + ? (handler as (this: HTMLElement, ev: HTMLElementEventMap[K]) => void) + : (handler as EventListenerObject); + + root.addEventListener(eventName, listener, options); + + const unsubscribe = () => { + root.removeEventListener(eventName, listener, options); + }; + + this.unsubscribe.push(unsubscribe); + + return unsubscribe; + } + protected subscribeSignal(signal: Signal, cb: (v: T) => void) { this.unsubscribe.push(signal.subscribe(cb)); }