diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 2ec410a6f6..9642d9fecd 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -1121,6 +1121,13 @@ export class ClientGameRunner { public stop() { this.soundManager.dispose(); this.graphicsListenerAbort?.abort(); + // Detach the input handler's window/canvas listeners and its EventBus + // subscription. Nothing else ever did, and the bus is created once per + // page, so a handler from a finished game kept translating keys into + // events that the next game receives, and joining another game without a + // page reload stacked a second live handler on top. Idempotent, like the + // disposals around it. + this.input.destroy(); this.disposeRenderer?.(); if (!this.isActive) return; diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 09852b58ba..a37b7d345a 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -252,6 +252,9 @@ export class InputHandler { private readonly LONG_PRESS_MS = 800; private moveInterval: NodeJS.Timeout | null = null; + /** Aborts every window/canvas listener added in + * `initializePointerAndKeyboardEvents()`. */ + private listenerAbort: AbortController | null = null; private activeKeys = new Set(); private keybinds: Record = {}; private keybindAndEvent: Array<[string, KeybindEntry]> = []; @@ -280,34 +283,64 @@ export class InputHandler { this.onKeybindsChanged, ); - // Listen for warship selection to change cursor - this.eventBus.on(UnitSelectionEvent, (e) => { - this.unitSelectionActive = - e.isSelected && (e.unit !== null || (e.units ?? []).length > 0); - if (e.isSelected && (e.units ?? []).length > 0) { - // Multi-selection active - this.multiSelectionActive = true; - this.canvas.style.cursor = "crosshair"; - } else if (e.isSelected) { - // Single warship selected — cursor crosshair, but not multi - this.multiSelectionActive = false; - this.canvas.style.cursor = "crosshair"; - } else { - // Deselected - this.multiSelectionActive = false; - if (!this.selectionBoxActive) { - this.canvas.style.cursor = ""; - } - } - }); + // Listen for warship selection to change cursor. Held in a field so + // destroy() can release it: the EventBus is created once per page in + // Main.ts and handed to every joinLobby(), so a subscription left behind + // keeps this handler -- and the GameView, uiState and overlay it closes + // over -- alive for the rest of the session, and runs against the next + // game's events. off() first so a second initialize() cannot double it. + this.eventBus.off(UnitSelectionEvent, this.onUnitSelection); + this.eventBus.on(UnitSelectionEvent, this.onUnitSelection); this.initializePointerAndKeyboardEvents(); } + private onUnitSelection = (e: UnitSelectionEvent) => { + this.unitSelectionActive = + e.isSelected && (e.unit !== null || (e.units ?? []).length > 0); + if (e.isSelected && (e.units ?? []).length > 0) { + // Multi-selection active + this.multiSelectionActive = true; + this.canvas.style.cursor = "crosshair"; + } else if (e.isSelected) { + // Single warship selected — cursor crosshair, but not multi + this.multiSelectionActive = false; + this.canvas.style.cursor = "crosshair"; + } else { + // Deselected + this.multiSelectionActive = false; + if (!this.selectionBoxActive) { + this.canvas.style.cursor = ""; + } + } + }; + private onKeybindsChanged = () => { this.buildKeybindTable(); }; + /** + * Drops every piece of in-flight pointer/drag/long-press state. Shared by + * the blur handler, the re-initialize guard and destroy(): each has to leave + * the handler with nothing latched, or a pointer that was physically down + * stays recorded as down while `pointers` is empty, and the next ordinary + * move is treated as a drag from a stale origin. Deliberately emits + * nothing -- blur re-emits the events it owes around this call. + */ + private resetPointerState() { + this.pointerDown = false; + this.pointers.clear(); + this.lastGestureScale = null; + if (this.longPressTimer !== null) { + clearTimeout(this.longPressTimer); + this.longPressTimer = null; + } + this.longPressActive = false; + this.suppressNextTap = false; + this.selectionBoxActive = false; + this.multiSelectionActive = false; + } + /** Re-read the player's keybinds and rebuild the key dispatch table. */ private buildKeybindTable() { this.keybinds = this.userSettings.keybinds(Platform.isMac); @@ -474,9 +507,27 @@ export class InputHandler { } private initializePointerAndKeyboardEvents() { - this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e)); - window.addEventListener("pointerup", (e) => this.onPointerUp(e)); - window.addEventListener("pointercancel", (e) => this.onPointerUp(e)); + // A second initialize() would otherwise orphan the first listener set and + // interval: nothing else holds the old controller, so they could never be + // removed. Production only initializes once, but this keeps that from + // being load-bearing. + this.listenerAbort?.abort(); + if (this.moveInterval !== null) { + clearInterval(this.moveInterval); + this.moveInterval = null; + } + this.resetPointerState(); + this.listenerAbort = new AbortController(); + const { signal } = this.listenerAbort; + this.canvas.addEventListener("pointerdown", (e) => this.onPointerDown(e), { + signal, + }); + window.addEventListener("pointerup", (e) => this.onPointerUp(e), { + signal, + }); + window.addEventListener("pointercancel", (e) => this.onPointerUp(e), { + signal, + }); this.canvas.addEventListener( "wheel", (e) => { @@ -484,7 +535,7 @@ export class InputHandler { this.onShiftScroll(e); e.preventDefault(); }, - { passive: false }, + { passive: false, signal }, ); // Safari trackpad pinch, which fires no ctrl+wheel event. this.canvas.addEventListener( @@ -493,7 +544,7 @@ export class InputHandler { e.preventDefault(); this.lastGestureScale = (e as WebKitGestureEvent).scale; }, - { passive: false }, + { passive: false, signal }, ); this.canvas.addEventListener( "gesturechange", @@ -501,7 +552,7 @@ export class InputHandler { e.preventDefault(); this.onGestureChange(e as WebKitGestureEvent); }, - { passive: false }, + { passive: false, signal }, ); this.canvas.addEventListener( "gestureend", @@ -509,41 +560,45 @@ export class InputHandler { e.preventDefault(); this.lastGestureScale = null; }, - { passive: false }, + { passive: false, signal }, ); - window.addEventListener("pointermove", this.onPointerMove.bind(this)); - this.canvas.addEventListener("contextmenu", (e) => this.onContextMenu(e)); - window.addEventListener("mousemove", (e) => { - if (e.movementX || e.movementY) { - this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY)); - } + window.addEventListener("pointermove", this.onPointerMove.bind(this), { + signal, }); + this.canvas.addEventListener("contextmenu", (e) => this.onContextMenu(e), { + signal, + }); + window.addEventListener( + "mousemove", + (e) => { + if (e.movementX || e.movementY) { + this.eventBus.emit(new MouseMoveEvent(e.clientX, e.clientY)); + } + }, + { signal }, + ); // Clear all tracked keys when the window loses focus so keys that had // their keyup swallowed by the browser (e.g. cmd+zoom) don't stay stuck. // Also release the hold-to-view state and any active pointer/drag state // so the alternate view and drags aren't left latched when focus returns. - window.addEventListener("blur", () => { - this.activeKeys.clear(); - if (this.alternateView) { - this.alternateView = false; - this.eventBus.emit(new AlternateViewEvent(false)); - } - this.pointerDown = false; - this.pointers.clear(); - this.lastGestureScale = null; - if (this.longPressTimer !== null) { - clearTimeout(this.longPressTimer); - this.longPressTimer = null; - } - this.longPressActive = false; - this.suppressNextTap = false; - if (this.selectionBoxActive || this.multiSelectionActive) { - this.selectionBoxActive = false; - this.multiSelectionActive = false; - this.eventBus.emit(new WarshipSelectionBoxCancelEvent()); - } - this.canvas.style.cursor = ""; - }); + window.addEventListener( + "blur", + () => { + this.activeKeys.clear(); + if (this.alternateView) { + this.alternateView = false; + this.eventBus.emit(new AlternateViewEvent(false)); + } + const hadSelection = + this.selectionBoxActive || this.multiSelectionActive; + this.resetPointerState(); + if (hadSelection) { + this.eventBus.emit(new WarshipSelectionBoxCancelEvent()); + } + this.canvas.style.cursor = ""; + }, + { signal }, + ); this.pointers.clear(); this.moveInterval = setInterval(() => { @@ -599,169 +654,177 @@ export class InputHandler { } }, 1); - window.addEventListener("keydown", (e) => { - const isTextInput = this.isTextInputTarget(e.target); - if (isTextInput && e.code !== "Escape") { - return; - } + window.addEventListener( + "keydown", + (e) => { + const isTextInput = this.isTextInputTarget(e.target); + if (isTextInput && e.code !== "Escape") { + return; + } - if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) { - e.preventDefault(); - if (!this.alternateView) { - this.alternateView = true; - this.eventBus.emit(new AlternateViewEvent(true)); + if (this.keybindMatchesEvent(e, this.keybinds.toggleView)) { + e.preventDefault(); + if (!this.alternateView) { + this.alternateView = true; + this.eventBus.emit(new AlternateViewEvent(true)); + } } - } - if ( - this.keybindMatchesEvent(e, this.keybinds.coordinateGrid) && - !e.repeat - ) { - e.preventDefault(); - this.coordinateGridEnabled = !this.coordinateGridEnabled; - this.eventBus.emit( - new ToggleCoordinateGridEvent(this.coordinateGridEnabled), - ); - } + if ( + this.keybindMatchesEvent(e, this.keybinds.coordinateGrid) && + !e.repeat + ) { + e.preventDefault(); + this.coordinateGridEnabled = !this.coordinateGridEnabled; + this.eventBus.emit( + new ToggleCoordinateGridEvent(this.coordinateGridEnabled), + ); + } - if (e.code === "Escape") { - e.preventDefault(); - let closedUI = false; + if (e.code === "Escape") { + e.preventDefault(); + let closedUI = false; - if (this.uiState.ghostStructure !== null) { - this.setGhostStructure(null); - closedUI = true; - } + if (this.uiState.ghostStructure !== null) { + this.setGhostStructure(null); + closedUI = true; + } - if (this.selectionBoxActive) { - this.selectionBoxActive = false; - this.eventBus.emit(new WarshipSelectionBoxCancelEvent()); - closedUI = true; - } + if (this.selectionBoxActive) { + this.selectionBoxActive = false; + this.eventBus.emit(new WarshipSelectionBoxCancelEvent()); + closedUI = true; + } - this.eventBus.emit(new CloseViewEvent()); + this.eventBus.emit(new CloseViewEvent()); + + if ( + !closedUI && + (this.unitSelectionActive || this.multiSelectionActive) + ) { + this.eventBus.emit(new UnitSelectionEvent(null, false)); + } + } if ( - !closedUI && - (this.unitSelectionActive || this.multiSelectionActive) + (e.code === "Enter" || e.code === "NumpadEnter") && + this.uiState.ghostStructure !== null ) { - this.eventBus.emit(new UnitSelectionEvent(null, false)); + e.preventDefault(); + this.eventBus.emit(new ConfirmGhostStructureEvent()); } - } - - if ( - (e.code === "Enter" || e.code === "NumpadEnter") && - this.uiState.ghostStructure !== null - ) { - e.preventDefault(); - this.eventBus.emit(new ConfirmGhostStructureEvent()); - } - // Don't track zoom keys when a meta/ctrl modifier is held — that means - // the browser is handling its own zoom (cmd+/cmd-) and the keyup will - // never fire, which would leave the key stuck in activeKeys forever. - // Also covers numpad zoom shortcuts (Ctrl+NumpadAdd/NumpadSubtract). - const isBrowserZoomCombo = - (e.metaKey || e.ctrlKey) && - (e.code === "Minus" || - e.code === "Equal" || - e.code === "NumpadAdd" || - e.code === "NumpadSubtract"); - - const isConfiguredKeybind = - Object.values(this.keybinds).includes(e.code) || - this.keybindAndEvent.some(([k]) => this.keybindMatchesEvent(e, k)); - - if (isConfiguredKeybind && !isBrowserZoomCombo) { - e.preventDefault(); - } + // Don't track zoom keys when a meta/ctrl modifier is held — that means + // the browser is handling its own zoom (cmd+/cmd-) and the keyup will + // never fire, which would leave the key stuck in activeKeys forever. + // Also covers numpad zoom shortcuts (Ctrl+NumpadAdd/NumpadSubtract). + const isBrowserZoomCombo = + (e.metaKey || e.ctrlKey) && + (e.code === "Minus" || + e.code === "Equal" || + e.code === "NumpadAdd" || + e.code === "NumpadSubtract"); + + const isConfiguredKeybind = + Object.values(this.keybinds).includes(e.code) || + this.keybindAndEvent.some(([k]) => this.keybindMatchesEvent(e, k)); + + if (isConfiguredKeybind && !isBrowserZoomCombo) { + e.preventDefault(); + } - if ( - !isBrowserZoomCombo && - [ - this.keybinds.moveUp, - this.keybinds.moveDown, - this.keybinds.moveLeft, - this.keybinds.moveRight, - this.keybinds.zoomOut, - this.keybinds.zoomIn, - "ArrowUp", - "ArrowLeft", - "ArrowDown", - "ArrowRight", - "Minus", - "Equal", - "NumpadAdd", - "NumpadSubtract", - this.keybinds.attackRatioDown, - this.keybinds.attackRatioUp, - this.keybinds.centerCamera, - "ControlLeft", - "ControlRight", - this.keybinds.boxSelectWarships, - this.keybinds.emojiMenuModifier, - this.keybinds.buildMenuModifier, - this.keybinds.altKey, - ].includes(e.code) - ) { - this.activeKeys.add(e.code); - } + if ( + !isBrowserZoomCombo && + [ + this.keybinds.moveUp, + this.keybinds.moveDown, + this.keybinds.moveLeft, + this.keybinds.moveRight, + this.keybinds.zoomOut, + this.keybinds.zoomIn, + "ArrowUp", + "ArrowLeft", + "ArrowDown", + "ArrowRight", + "Minus", + "Equal", + "NumpadAdd", + "NumpadSubtract", + this.keybinds.attackRatioDown, + this.keybinds.attackRatioUp, + this.keybinds.centerCamera, + "ControlLeft", + "ControlRight", + this.keybinds.boxSelectWarships, + this.keybinds.emojiMenuModifier, + this.keybinds.buildMenuModifier, + this.keybinds.altKey, + ].includes(e.code) + ) { + this.activeKeys.add(e.code); + } - // warship box selection mode. - // If a ghost structure is active, discard it first. - if (e.code === this.keybinds.boxSelectWarships) { - if (this.uiState.ghostStructure !== null) { - this.setGhostStructure(null); + // warship box selection mode. + // If a ghost structure is active, discard it first. + if (e.code === this.keybinds.boxSelectWarships) { + if (this.uiState.ghostStructure !== null) { + this.setGhostStructure(null); + } + this.canvas.style.cursor = "crosshair"; + } + }, + { signal }, + ); + window.addEventListener( + "keyup", + (e) => { + const isTextInput = this.isTextInputTarget(e.target); + if (isTextInput && !this.activeKeys.has(e.code)) { + return; } - this.canvas.style.cursor = "crosshair"; - } - }); - window.addEventListener("keyup", (e) => { - const isTextInput = this.isTextInputTarget(e.target); - if (isTextInput && !this.activeKeys.has(e.code)) { - return; - } - // When the meta (cmd) or ctrl key is released, any keys that were held - // simultaneously will have had their keyup swallowed by the browser - // (e.g. cmd+Plus for browser zoom). Clear zoom-related keys to - // prevent them staying stuck in activeKeys. - if ( - e.code === "MetaLeft" || - e.code === "MetaRight" || - e.code === "ControlLeft" || - e.code === "ControlRight" - ) { - this.activeKeys.delete("Minus"); - this.activeKeys.delete("Equal"); - this.activeKeys.delete("NumpadAdd"); - this.activeKeys.delete("NumpadSubtract"); - this.activeKeys.delete(this.keybinds.zoomIn); - this.activeKeys.delete(this.keybinds.zoomOut); - } + // When the meta (cmd) or ctrl key is released, any keys that were held + // simultaneously will have had their keyup swallowed by the browser + // (e.g. cmd+Plus for browser zoom). Clear zoom-related keys to + // prevent them staying stuck in activeKeys. + if ( + e.code === "MetaLeft" || + e.code === "MetaRight" || + e.code === "ControlLeft" || + e.code === "ControlRight" + ) { + this.activeKeys.delete("Minus"); + this.activeKeys.delete("Equal"); + this.activeKeys.delete("NumpadAdd"); + this.activeKeys.delete("NumpadSubtract"); + this.activeKeys.delete(this.keybinds.zoomIn); + this.activeKeys.delete(this.keybinds.zoomOut); + } - outerLoop: for (const item of this.keybindAndEvent) { - if (this.keybindMatchesEvent(e, item[0])) { - for (const i of item[1].conditions) { - if (!i(e)) { - continue outerLoop; + outerLoop: for (const item of this.keybindAndEvent) { + if (this.keybindMatchesEvent(e, item[0])) { + for (const i of item[1].conditions) { + if (!i(e)) { + continue outerLoop; + } } + e.preventDefault(); + item[1].handler(e); } - e.preventDefault(); - item[1].handler(e); } - } - this.activeKeys.delete(e.code); + this.activeKeys.delete(e.code); - // Reset crosshair when Shift is released (unless selection box or multi-selection still active) - if ( - e.code === this.keybinds.boxSelectWarships && - !this.selectionBoxActive && - !this.multiSelectionActive - ) { - this.canvas.style.cursor = ""; - } - }); + // Reset crosshair when Shift is released (unless selection box or multi-selection still active) + if ( + e.code === this.keybinds.boxSelectWarships && + !this.selectionBoxActive && + !this.multiSelectionActive + ) { + this.canvas.style.cursor = ""; + } + }, + { signal }, + ); } private onPointerDown(event: PointerEvent) { @@ -1226,13 +1289,23 @@ export class InputHandler { destroy() { if (this.moveInterval !== null) { clearInterval(this.moveInterval); + this.moveInterval = null; } globalThis.removeEventListener( `${USER_SETTINGS_CHANGED_EVENT}:${KEYBINDS_KEY}`, this.onKeybindsChanged, ); + this.listenerAbort?.abort(); + this.listenerAbort = null; + this.eventBus.off(UnitSelectionEvent, this.onUnitSelection); + // Includes the 800ms long-press timer a touch pointerdown arms: aborting + // the listeners does not cancel it, so without this it can still fire + // after teardown, emitting TouchLongPressStartEvent on the page-global + // bus, into the next game, and setting the cursor on a canvas the + // renderer has already removed. + this.resetPointerState(); this.activeKeys.clear(); - this.lastGestureScale = null; this.keybindAndEvent = []; + this.keybinds = {}; } } diff --git a/tests/InputHandler.test.ts b/tests/InputHandler.test.ts index 5d7c86aee3..64f55eb91d 100644 --- a/tests/InputHandler.test.ts +++ b/tests/InputHandler.test.ts @@ -1,8 +1,13 @@ import { + AlternateViewEvent, AutoUpgradeEvent, + CloseViewEvent, ConfirmGhostStructureEvent, ContextMenuEvent, + DragEvent, InputHandler, + MouseOverEvent, + TouchLongPressStartEvent, UnitSelectionEvent, WarshipSelectionBoxCancelEvent, WarshipSelectionBoxCompleteEvent, @@ -1294,3 +1299,330 @@ describe("InputHandler right-click cancels unit selection (#4692)", () => { ).toBe(true); }); }); + +describe("InputHandler teardown (OPE-411)", () => { + const makeHandler = (canvas: HTMLElement, eventBus: EventBus) => + new InputHandler( + { + inSpawnPhase: () => false, + myPlayer: () => ({ isAlive: () => true }), + } as unknown as GameView, + { + attackRatio: 20, + ghostStructure: null, + rocketDirectionUp: true, + upgradeMultiplier: 1, + }, + canvas, + eventBus, + ); + + let inputHandler: InputHandler; + let eventBus: EventBus; + let canvas: HTMLCanvasElement; + + beforeEach(() => { + new UserSettings().removeCached(KEYBINDS_KEY, false); + canvas = document.createElement("canvas"); + canvas.width = 800; + canvas.height = 600; + eventBus = new EventBus(); + inputHandler = makeHandler(canvas, eventBus); + inputHandler.initialize(); + }); + + afterEach(() => inputHandler.destroy()); + + it("emits AlternateViewEvent on Space while alive", () => { + const emit = vi.spyOn(eventBus, "emit"); + window.dispatchEvent(new KeyboardEvent("keydown", { code: "Space" })); + expect( + emit.mock.calls.some( + (c: unknown[]) => c[0] instanceof AlternateViewEvent, + ), + ).toBe(true); + }); + + it("emits CloseViewEvent on Escape while alive", () => { + const emit = vi.spyOn(eventBus, "emit"); + window.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" })); + expect( + emit.mock.calls.some((c: unknown[]) => c[0] instanceof CloseViewEvent), + ).toBe(true); + }); + + it("emits nothing on a window keydown after destroy()", () => { + inputHandler.destroy(); + const emit = vi.spyOn(eventBus, "emit"); + // Escape is the load-bearing probe: its CloseViewEvent is emitted + // unconditionally, so it still fires if the keydown listener survives + // destroy(). Space goes through this.keybinds, which destroy() also + // clears, so a Space-only probe would pass even with the abort reverted. + window.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" })); + window.dispatchEvent(new KeyboardEvent("keydown", { code: "Space" })); + window.dispatchEvent(new KeyboardEvent("keyup", { code: "Space" })); + expect(emit).not.toHaveBeenCalled(); + }); + + it("emits nothing on a canvas event after destroy()", () => { + inputHandler.destroy(); + const emit = vi.spyOn(eventBus, "emit"); + canvas.dispatchEvent( + new MouseEvent("contextmenu", { clientX: 100, clientY: 100 }), + ); + expect(emit).not.toHaveBeenCalled(); + }); + + it("clears keybinds and the keybind dispatch table on destroy()", () => { + expect(Object.keys(inputHandler["keybinds"]).length).toBeGreaterThan(0); + expect(inputHandler["keybindAndEvent"].length).toBeGreaterThan(0); + + inputHandler.destroy(); + expect(inputHandler["keybinds"]).toEqual({}); + expect(inputHandler["keybindAndEvent"]).toEqual([]); + }); + + it("is safe to destroy twice", () => { + inputHandler.destroy(); + expect(() => inputHandler.destroy()).not.toThrow(); + + const emit = vi.spyOn(eventBus, "emit"); + window.dispatchEvent(new KeyboardEvent("keydown", { code: "Space" })); + expect(emit).not.toHaveBeenCalled(); + }); + + it("destroying one handler leaves a later handler working", () => { + const secondBus = new EventBus(); + const secondCanvas = document.createElement("canvas"); + const second = makeHandler(secondCanvas, secondBus); + second.initialize(); + + try { + inputHandler.destroy(); + + const deadEmit = vi.spyOn(eventBus, "emit"); + const liveEmit = vi.spyOn(secondBus, "emit"); + window.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" })); + + expect(deadEmit).not.toHaveBeenCalled(); + expect( + liveEmit.mock.calls.some( + (c: unknown[]) => c[0] instanceof CloseViewEvent, + ), + ).toBe(true); + } finally { + // Must run even if an expectation throws, or a live window listener + // leaks into every later test in this file. + second.destroy(); + } + }); + + it("cancels a pending long-press timer on destroy()", () => { + vi.useFakeTimers(); + const bus = new EventBus(); + const handler = makeHandler(document.createElement("canvas"), bus); + try { + handler.initialize(); + touchOrMouseDown(handler, "touch"); + expect(handler["longPressTimer"]).not.toBeNull(); + + handler.destroy(); + const emit = vi.spyOn(bus, "emit"); + vi.advanceTimersByTime(2000); + + expect(emit).not.toHaveBeenCalled(); + expect(handler["longPressActive"]).toBe(false); + } finally { + handler.destroy(); + vi.useRealTimers(); + } + }); + + it("cancels a pending long-press timer on re-initialize", () => { + vi.useFakeTimers(); + const bus = new EventBus(); + const handler = makeHandler(document.createElement("canvas"), bus); + try { + handler.initialize(); + touchOrMouseDown(handler, "touch"); + + handler.initialize(); + const emit = vi.spyOn(bus, "emit"); + vi.advanceTimersByTime(2000); + + expect( + emit.mock.calls.some( + (c: unknown[]) => c[0] instanceof TouchLongPressStartEvent, + ), + ).toBe(false); + } finally { + handler.destroy(); + vi.useRealTimers(); + } + }); + + it("releases its EventBus subscription on destroy()", () => { + const unit = { id: () => 1 } as unknown as UnitView; + + // Control: while alive the subscription drives the cursor. + eventBus.emit(new UnitSelectionEvent(unit, true)); + expect(canvas.style.cursor).toBe("crosshair"); + canvas.style.cursor = ""; + + inputHandler.destroy(); + + // The EventBus is page-global, so a subscription left behind would keep + // this handler alive and run it against the next game's events. Both + // probes are discriminating: a live subscription would set the crosshair + // on the first, and clear unitSelectionActive on the second. + eventBus.emit(new UnitSelectionEvent(unit, true)); + expect(canvas.style.cursor).toBe(""); + + eventBus.emit(new UnitSelectionEvent(null, false)); + expect(inputHandler["unitSelectionActive"]).toBe(true); + }); + + const touchOrMouseDown = (handler: InputHandler, pointerType: string) => + handler["onPointerDown"]( + new PointerEvent("pointerdown", { + button: 0, + clientX: 100, + clientY: 100, + pointerId: 1, + pointerType, + }), + ); + + const movePointer = (handler: InputHandler) => + handler["onPointerMove"]( + new PointerEvent("pointermove", { + button: 0, + clientX: 400, + clientY: 400, + pointerId: 1, + pointerType: "mouse", + }), + ); + + it("drops in-flight pointer state on re-initialize", () => { + // pointers.clear() runs unconditionally on initialize, so leaving + // pointerDown latched would make the next ordinary move a drag from a + // stale origin. + touchOrMouseDown(inputHandler, "mouse"); + expect(inputHandler["pointerDown"]).toBe(true); + + inputHandler.initialize(); + + const emit = vi.spyOn(eventBus, "emit"); + movePointer(inputHandler); + + expect( + emit.mock.calls.some((c: unknown[]) => c[0] instanceof DragEvent), + ).toBe(false); + expect( + emit.mock.calls.some((c: unknown[]) => c[0] instanceof MouseOverEvent), + ).toBe(true); + }); + + // resetPointerState() is shared with the blur handler; blur owes a cancel + // event that the other two callers must not emit, so lock both halves. + it("window blur still cancels an active selection box", () => { + inputHandler["selectionBoxActive"] = true; + const emit = vi.spyOn(eventBus, "emit"); + + window.dispatchEvent(new Event("blur")); + + expect( + emit.mock.calls.some( + (c: unknown[]) => c[0] instanceof WarshipSelectionBoxCancelEvent, + ), + ).toBe(true); + expect(inputHandler["selectionBoxActive"]).toBe(false); + expect(inputHandler["pointerDown"]).toBe(false); + }); + + it("window blur emits no cancel when nothing was selected", () => { + const emit = vi.spyOn(eventBus, "emit"); + + window.dispatchEvent(new Event("blur")); + + expect( + emit.mock.calls.some( + (c: unknown[]) => c[0] instanceof WarshipSelectionBoxCancelEvent, + ), + ).toBe(false); + }); + + it("destroy() emits nothing even with a selection box active", () => { + inputHandler["selectionBoxActive"] = true; + const emit = vi.spyOn(eventBus, "emit"); + + inputHandler.destroy(); + + expect(emit).not.toHaveBeenCalled(); + }); + + it("drops in-flight pointer state on destroy()", () => { + touchOrMouseDown(inputHandler, "mouse"); + inputHandler.destroy(); + + expect(inputHandler["pointerDown"]).toBe(false); + expect(inputHandler["pointers"].size).toBe(0); + expect(inputHandler["selectionBoxActive"]).toBe(false); + expect(inputHandler["multiSelectionActive"]).toBe(false); + }); + + it("clears the pan/zoom interval on destroy()", () => { + vi.useFakeTimers(); + const handler = makeHandler( + document.createElement("canvas"), + new EventBus(), + ); + try { + handler.initialize(); + expect(vi.getTimerCount()).toBe(1); + + handler.destroy(); + expect(vi.getTimerCount()).toBe(0); + } finally { + handler.destroy(); + vi.useRealTimers(); + } + }); + + it("a second initialize() does not orphan the first listeners or interval", () => { + vi.useFakeTimers(); + const bus = new EventBus(); + const handler = makeHandler(document.createElement("canvas"), bus); + // The bus captures this field's value at initialize() time, so swapping + // it first lets us count how many times the subscription is registered. + const onUnitSelection = vi.fn(); + handler["onUnitSelection"] = onUnitSelection; + try { + handler.initialize(); + handler.initialize(); + expect(vi.getTimerCount()).toBe(1); + + bus.emit( + new UnitSelectionEvent({ id: () => 1 } as unknown as UnitView, true), + ); + expect(onUnitSelection).toHaveBeenCalledTimes(1); + + handler.destroy(); + expect(vi.getTimerCount()).toBe(0); + + onUnitSelection.mockClear(); + bus.emit( + new UnitSelectionEvent({ id: () => 1 } as unknown as UnitView, true), + ); + expect(onUnitSelection).not.toHaveBeenCalled(); + + const emit = vi.spyOn(bus, "emit"); + window.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" })); + expect(emit).not.toHaveBeenCalled(); + } finally { + handler.destroy(); + vi.useRealTimers(); + } + }); +}); diff --git a/tests/client/ClientGameRunnerActions.test.ts b/tests/client/ClientGameRunnerActions.test.ts index 4b8f52aa82..2251884487 100644 --- a/tests/client/ClientGameRunnerActions.test.ts +++ b/tests/client/ClientGameRunnerActions.test.ts @@ -108,6 +108,7 @@ function makeRunner(overrides: { playerByClientID: vi.fn(overrides.playerByClientID ?? (() => myPlayer)), euclideanDistSquared: () => overrides.boatDistSquared ?? 0, }; + const input = { initialize: vi.fn(), destroy: vi.fn() }; const runner = new ClientGameRunner( { gameID: "game1234" } as LobbyConfig, "c0000001", @@ -120,7 +121,7 @@ function makeRunner(overrides: { screenToWorldCoordinates: vi.fn(() => ({ x: 1, y: 2 })), }, } as never, - { initialize: vi.fn() } as never, + input as never, { updateCallback: vi.fn(), rejoinGame: vi.fn(), @@ -133,7 +134,7 @@ function makeRunner(overrides: { { goToPlayer: () => false } as never, ); runner.start(); - return { runner, eventBus, gameView, myPlayer }; + return { runner, eventBus, gameView, myPlayer, input }; } const flushPromises = () => new Promise((r) => setTimeout(r, 0)); @@ -234,3 +235,22 @@ describe("auto boat", () => { expect(boats).toHaveLength(0); }); }); + +describe("stop() (OPE-411)", () => { + it("calls input.destroy()", () => { + const { runner, input } = makeRunner({}); + + runner.stop(); + + expect(input.destroy).toHaveBeenCalledTimes(1); + }); + + it("tolerates a second stop()", () => { + const { runner, input } = makeRunner({}); + + runner.stop(); + runner.stop(); + + expect(input.destroy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/tests/client/ClientGameRunnerMessages.test.ts b/tests/client/ClientGameRunnerMessages.test.ts index 77549dcf26..6522b1103a 100644 --- a/tests/client/ClientGameRunnerMessages.test.ts +++ b/tests/client/ClientGameRunnerMessages.test.ts @@ -146,7 +146,7 @@ function makeStartedRunner(withStartInfo: boolean) { "c0000001", eventBus, renderer as never, - { initialize: vi.fn() } as never, + { initialize: vi.fn(), destroy: vi.fn() } as never, transport as never, worker as never, gameView as never,