Files
OpenFrontIO/tests/InputHandler.test.ts
T
Ivan Batsulin 29a1e8dfda feat: multi-warship selection with Shift+drag box (#3677)
Resolves #3666

## Description:

Adds RTS-style box selection for warships. Hold Shift and drag (desktop)
or long-press and drag (touch/mobile) to draw a selection rectangle —
all player-owned warships inside get selected at once. A subsequent
click/tap on water sends them all to that location.

- `SelectionBoxLayer` — pixel-dashed rectangle in world-space, player
territory color; shared between desktop and touch
- `UILayer` — same pulsing selection outline on each box-selected
warship; clears correctly when switching between single/multi selection
- `UnitLayer` — finds warships in screen rect, filters inactive ships
before sending; touch support included
- `InputHandler` — Shift+drag and touch long-press+drag both emit
selection box events; cursor becomes crosshair on Shift; discards active
ghost structure on Shift press; configurable via `shiftKey` keybind
- `Transport` — single atomic `move_multiple_warships` intent (no split
on socket drop)
- `Schemas` + `ExecutionManager` + `MoveMultipleWarshipsExecution` —
server fans out atomic intent into individual `MoveWarshipExecution` per
ship
- `DynamicUILayer` — `MoveIndicatorUI` chevron animation on target tile
for both single and multi move
- `UnitDisplay` — warship tooltip Shift hint via `translateText`
- `HelpModal` — new hotkey row: Shift + drag → select multiple warships

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## UI update
### Mouse + Keyboard


https://github.com/user-attachments/assets/3f35ab5e-1f3c-4c5d-bc4f-aabccf64dc60

### Touch


https://github.com/user-attachments/assets/0d6aec3f-44fa-4fee-b5c6-b267b9b14d79

##
## Please put your Discord username so you can be contacted if a bug or
regression is found:

fghjk_60845
2026-04-21 14:06:07 -07:00

1050 lines
29 KiB
TypeScript

import {
AutoUpgradeEvent,
ConfirmGhostStructureEvent,
InputHandler,
WarshipSelectionBoxCancelEvent,
WarshipSelectionBoxCompleteEvent,
WarshipSelectionBoxUpdateEvent,
} from "../src/client/InputHandler";
import { UIState } from "../src/client/graphics/UIState";
import { EventBus } from "../src/core/EventBus";
import { UnitType } from "../src/core/game/Game";
import { GameView } from "../src/core/game/GameView";
import { KEYBINDS_KEY, UserSettings } from "../src/core/game/UserSettings";
class MockPointerEvent {
button: number;
clientX: number;
clientY: number;
x: number;
y: number;
pointerId: number;
type: string;
pointerType: string;
preventDefault: () => void;
constructor(type: string, init: any) {
this.type = type;
this.button = init.button;
this.clientX = init.clientX;
this.clientY = init.clientY;
this.x = init.x ?? init.clientX;
this.y = init.y ?? init.clientY;
this.pointerId = init.pointerId;
this.pointerType = init.pointerType ?? "mouse";
this.preventDefault = vi.fn();
}
}
global.PointerEvent = MockPointerEvent as any;
describe("InputHandler AutoUpgrade", () => {
let inputHandler: InputHandler;
let mockGameView: GameView;
let eventBus: EventBus;
let mockCanvas: HTMLCanvasElement;
let testSettings: UserSettings;
beforeEach(() => {
testSettings = new UserSettings();
testSettings.removeCached(KEYBINDS_KEY, false);
mockGameView = { inSpawnPhase: () => false } as GameView;
mockCanvas = document.createElement("canvas");
mockCanvas.width = 800;
mockCanvas.height = 600;
eventBus = new EventBus();
inputHandler = new InputHandler(
mockGameView,
{
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
},
mockCanvas,
eventBus,
);
});
afterEach(() => {
inputHandler.destroy();
});
describe("Middle Mouse Button Handling", () => {
test("should emit AutoUpgradeEvent on middle mouse button press", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: 150,
clientY: 250,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
});
test("should emit MouseDownEvent on left mouse button press instead of AutoUpgradeEvent", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 0,
clientX: 150,
clientY: 250,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
const calls = mockEmit.mock.calls;
const lastCall = calls[calls.length - 1];
expect(lastCall[0]).not.toBeInstanceOf(AutoUpgradeEvent);
});
test("should not emit AutoUpgradeEvent on right mouse button press", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 2,
clientX: 150,
clientY: 250,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).not.toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
});
test("should handle multiple middle mouse button presses", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
const pointerEvent1 = new PointerEvent("pointerdown", {
button: 1,
clientX: 100,
clientY: 200,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent1);
const pointerEvent2 = new PointerEvent("pointerdown", {
button: 1,
clientX: 300,
clientY: 400,
pointerId: 2,
});
inputHandler["onPointerDown"](pointerEvent2);
expect(mockEmit).toHaveBeenCalledTimes(2);
expect(mockEmit).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
x: 100,
y: 200,
}),
);
expect(mockEmit).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
x: 300,
y: 400,
}),
);
});
test("should handle middle mouse button press with zero coordinates", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: 0,
clientY: 0,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: 0,
y: 0,
}),
);
});
test("should handle middle mouse button press with negative coordinates", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: -100,
clientY: -200,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: -100,
y: -200,
}),
);
});
test("should handle middle mouse button press with decimal coordinates", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: 100.5,
clientY: 200.7,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: 100.5,
y: 200.7,
}),
);
});
});
describe("Spawn Phase Handling", () => {
test("should emit MouseUpEvent and not ContextMenuEvent on left click release during spawn phase", () => {
mockGameView.inSpawnPhase = () => true;
const mockEmit = vi.spyOn(eventBus, "emit");
inputHandler["userSettings"].leftClickOpensMenu = () => true;
const pointerEvent = new PointerEvent("pointerup", {
button: 0,
clientX: 150,
clientY: 250,
});
inputHandler["lastPointerDownX"] = 149;
inputHandler["lastPointerDownY"] = 249;
inputHandler["onPointerUp"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
const emittedTypes = mockEmit.mock.calls.map(
(call) => call[0].constructor.name,
);
expect(emittedTypes).toContain("MouseUpEvent");
expect(emittedTypes).not.toContain("ContextMenuEvent");
});
test("should suppress/ignore context menu events during spawn phase", () => {
mockGameView.inSpawnPhase = () => true;
const mockEmit = vi.spyOn(eventBus, "emit");
const mouseEvent = new MouseEvent("contextmenu", {
clientX: 150,
clientY: 250,
});
const preventDefaultSpy = vi.spyOn(mouseEvent, "preventDefault");
inputHandler["onContextMenu"](mouseEvent);
expect(preventDefaultSpy).toHaveBeenCalled();
const emittedTypes = mockEmit.mock.calls.map(
(call) => call[0].constructor.name,
);
expect(emittedTypes).not.toContain("ContextMenuEvent");
});
});
describe("Pointer Event Handling", () => {
test("should handle pointer events with different pointer IDs", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
const pointerEvent1 = new PointerEvent("pointerdown", {
button: 1,
clientX: 100,
clientY: 200,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent1);
const pointerEvent2 = new PointerEvent("pointerdown", {
button: 1,
clientX: 300,
clientY: 400,
pointerId: 2,
});
inputHandler["onPointerDown"](pointerEvent2);
expect(mockEmit).toHaveBeenCalledTimes(2);
});
test("should handle pointer events with same pointer ID", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
const pointerEvent1 = new PointerEvent("pointerdown", {
button: 1,
clientX: 100,
clientY: 200,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent1);
const pointerEvent2 = new PointerEvent("pointerdown", {
button: 1,
clientX: 300,
clientY: 400,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent2);
expect(mockEmit).toHaveBeenCalledTimes(2);
});
});
describe("Edge Cases", () => {
test("should handle very large coordinates", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: Number.MAX_SAFE_INTEGER,
clientY: Number.MAX_SAFE_INTEGER,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: Number.MAX_SAFE_INTEGER,
y: Number.MAX_SAFE_INTEGER,
}),
);
});
test("should handle very small coordinates", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: Number.MIN_SAFE_INTEGER,
clientY: Number.MIN_SAFE_INTEGER,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: Number.MIN_SAFE_INTEGER,
y: Number.MIN_SAFE_INTEGER,
}),
);
});
test("should handle NaN coordinates", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: NaN,
clientY: NaN,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: NaN,
y: NaN,
}),
);
});
test("should handle Infinity coordinates", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: Infinity,
clientY: -Infinity,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockEmit).toHaveBeenCalledWith(
expect.objectContaining({
x: Infinity,
y: -Infinity,
}),
);
});
});
describe("Integration with Event Bus", () => {
test("should allow event listeners to receive AutoUpgradeEvents", () => {
const mockListener = vi.fn();
eventBus.on(AutoUpgradeEvent, mockListener);
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: 150,
clientY: 250,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockListener).toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
});
test("should allow multiple listeners for AutoUpgradeEvent", () => {
const mockListener1 = vi.fn();
const mockListener2 = vi.fn();
eventBus.on(AutoUpgradeEvent, mockListener1);
eventBus.on(AutoUpgradeEvent, mockListener2);
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: 150,
clientY: 250,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockListener1).toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
expect(mockListener2).toHaveBeenCalledWith(
expect.objectContaining({
x: 150,
y: 250,
}),
);
});
test("should not call unsubscribed listeners", () => {
const mockListener = vi.fn();
eventBus.on(AutoUpgradeEvent, mockListener);
eventBus.off(AutoUpgradeEvent, mockListener);
const pointerEvent = new PointerEvent("pointerdown", {
button: 1,
clientX: 150,
clientY: 250,
pointerId: 1,
});
inputHandler["onPointerDown"](pointerEvent);
expect(mockListener).not.toHaveBeenCalled();
});
});
describe("Keybinds JSON parsing", () => {
test("parses nested object values and flattens them to strings", () => {
const nested = {
moveUp: { key: "moveUp", value: "KeyZ" },
};
testSettings.setKeybinds(nested);
inputHandler.initialize();
expect((inputHandler as any).keybinds.moveUp).toBe("KeyZ");
});
test("accepts legacy string values", () => {
testSettings.setKeybinds({ moveUp: "KeyX" });
inputHandler.initialize();
expect((inputHandler as any).keybinds.moveUp).toBe("KeyX");
});
test("ignores non-string values and preserves defaults, removes 'Null' for unbound keys", () => {
const mixed = {
moveUp: { key: "moveUp", value: null },
moveLeft: "Null",
};
testSettings.setKeybinds(mixed);
inputHandler.initialize();
expect((inputHandler as any).keybinds.moveUp).toBe("KeyW");
// "Null" entries are removed entirely to indicate unbound keybind
expect((inputHandler as any).keybinds.moveLeft).toBeUndefined();
});
test("handles invalid JSON gracefully and warns", () => {
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
testSettings.setKeybinds("not a json");
inputHandler.initialize();
expect(spy).toHaveBeenCalled();
// default remains when parsing fails
expect((inputHandler as any).keybinds.moveUp).toBe("KeyW");
spy.mockRestore();
});
});
describe("Enter key confirm ghost structure", () => {
let uiState: UIState;
beforeEach(() => {
uiState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
});
test("emits ConfirmGhostStructureEvent on Enter when ghost structure is set", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
uiState.ghostStructure = UnitType.City;
window.dispatchEvent(new KeyboardEvent("keydown", { code: "Enter" }));
expect(mockEmit).toHaveBeenCalledWith(
expect.any(ConfirmGhostStructureEvent),
);
});
test("emits ConfirmGhostStructureEvent on NumpadEnter when ghost structure is set", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
uiState.ghostStructure = UnitType.Factory;
window.dispatchEvent(
new KeyboardEvent("keydown", { code: "NumpadEnter" }),
);
expect(mockEmit).toHaveBeenCalledWith(
expect.any(ConfirmGhostStructureEvent),
);
});
test("does not emit ConfirmGhostStructureEvent on Enter when no ghost structure", () => {
const mockEmit = vi.spyOn(eventBus, "emit");
expect(uiState.ghostStructure).toBeNull();
window.dispatchEvent(new KeyboardEvent("keydown", { code: "Enter" }));
const confirmCalls = mockEmit.mock.calls.filter(
(call) => call[0] instanceof ConfirmGhostStructureEvent,
);
expect(confirmCalls).toHaveLength(0);
});
});
describe("Numpad number keys for build keybinds", () => {
beforeEach(() => {
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
});
test("Numpad1 sets ghost structure to City when buildCity is Digit1", () => {
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }),
);
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City);
});
test("Numpad5 sets ghost structure to MissileSilo when buildMissileSilo is Digit5", () => {
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Numpad5", key: "5" }),
);
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.MissileSilo);
});
test("Numpad0 sets ghost structure to MIRV when buildMIRV is Digit0", () => {
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Numpad0", key: "0" }),
);
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.MIRV);
});
});
describe("Build keybind two-phase matching (exact code first, then digit/Numpad alias)", () => {
beforeEach(() => {
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
});
test("exact code match wins: Digit1 sets City when buildCity=Digit1 and buildFactory=Numpad1", () => {
testSettings.setKeybinds({
buildCity: "Digit1",
buildFactory: "Numpad1",
});
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Digit1", key: "1" }),
);
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City);
});
test("exact code match wins: Numpad1 sets Factory when buildCity=Digit1 and buildFactory=Numpad1", () => {
testSettings.setKeybinds({
buildCity: "Digit1",
buildFactory: "Numpad1",
});
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }),
);
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.Factory);
});
test("digit alias used when no exact match: Numpad1 sets City when only buildCity=Digit1", () => {
testSettings.setKeybinds({ buildCity: "Digit1" });
inputHandler.destroy();
const uiState: UIState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Numpad1", key: "1" }),
);
expect(inputHandler["uiState"].ghostStructure).toBe(UnitType.City);
});
});
describe("Shift+ keybind support", () => {
let uiState: UIState;
beforeEach(() => {
inputHandler.destroy();
uiState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
});
test("Shift+Digit1 sets City when buildCity is bound to Shift+Digit1", () => {
testSettings.setKeybinds({ buildCity: "Shift+Digit1" });
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Digit1", shiftKey: true }),
);
expect(uiState.ghostStructure).toBe(UnitType.City);
});
test("plain Digit1 does NOT trigger buildCity when bound to Shift+Digit1", () => {
testSettings.setKeybinds({ buildCity: "Shift+Digit1" });
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Digit1", shiftKey: false }),
);
expect(uiState.ghostStructure).toBeNull();
});
test("Shift+KeyB triggers boatAttack when bound to Shift+KeyB", () => {
testSettings.setKeybinds({ boatAttack: "Shift+KeyB" });
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
const mockEmit = vi.spyOn(eventBus, "emit");
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "KeyB", shiftKey: true }),
);
const emittedTypes = mockEmit.mock.calls.map(
(call) => call[0].constructor.name,
);
expect(emittedTypes).toContain("DoBoatAttackEvent");
});
test("plain KeyB does NOT trigger boatAttack when bound to Shift+KeyB", () => {
testSettings.setKeybinds({ boatAttack: "Shift+KeyB" });
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
const mockEmit = vi.spyOn(eventBus, "emit");
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "KeyB", shiftKey: false }),
);
const emittedTypes = mockEmit.mock.calls.map(
(call) => call[0].constructor.name,
);
expect(emittedTypes).not.toContain("DoBoatAttackEvent");
});
test("Shift+Digit1 and Digit1 can be bound to different actions without conflict", () => {
testSettings.setKeybinds({
buildCity: "Digit1",
buildFactory: "Shift+Digit1",
});
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Digit1", shiftKey: false }),
);
expect(uiState.ghostStructure).toBe(UnitType.City);
uiState.ghostStructure = null;
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Digit1", shiftKey: true }),
);
expect(uiState.ghostStructure).toBe(UnitType.Factory);
});
test("Numpad alias works with Shift+Digit keybind", () => {
testSettings.setKeybinds({ buildCity: "Shift+Digit1" });
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
window.dispatchEvent(
new KeyboardEvent("keyup", { code: "Numpad1", shiftKey: true }),
);
expect(uiState.ghostStructure).toBe(UnitType.City);
});
});
});
describe("Warship box selection (Shift+drag)", () => {
let inputHandler: InputHandler;
let eventBus: EventBus;
let mockCanvas: HTMLCanvasElement;
let uiState: UIState;
beforeEach(() => {
const mockGameView = { inSpawnPhase: () => false } as GameView;
mockCanvas = document.createElement("canvas");
eventBus = new EventBus();
uiState = {
attackRatio: 20,
ghostStructure: null,
rocketDirectionUp: true,
overlappingRailroads: [],
ghostRailPaths: [],
} as UIState;
inputHandler = new InputHandler(
mockGameView,
uiState,
mockCanvas,
eventBus,
);
inputHandler.initialize();
});
afterEach(() => {
inputHandler.destroy();
});
test("Shift keydown sets canvas cursor to crosshair", () => {
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
expect(mockCanvas.style.cursor).toBe("crosshair");
});
test("ShiftRight keydown also sets cursor to crosshair", () => {
// ShiftRight is not the default shiftKey keybind (ShiftLeft is).
// This test verifies the configured shiftKey works, not a hardcoded ShiftRight.
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
expect(mockCanvas.style.cursor).toBe("crosshair");
});
test("Shift keyup resets cursor when no selection box active", () => {
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
window.dispatchEvent(new KeyboardEvent("keyup", { code: "ShiftLeft" }));
expect(mockCanvas.style.cursor).toBe("");
});
test("Shift keydown discards active ghostStructure", () => {
uiState.ghostStructure = UnitType.Warship;
const emitSpy = vi.spyOn(eventBus, "emit");
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
expect(uiState.ghostStructure).toBeNull();
const types = emitSpy.mock.calls.map((c) => c[0].constructor.name);
expect(types).toContain("GhostStructureChangedEvent");
});
test("Shift+drag emits WarshipSelectionBoxUpdateEvent", () => {
const listener = vi.fn();
eventBus.on(WarshipSelectionBoxUpdateEvent, listener);
inputHandler["onPointerDown"](
new PointerEvent("pointerdown", {
button: 0,
clientX: 100,
clientY: 100,
pointerId: 1,
}),
);
inputHandler["activeKeys"].add("ShiftLeft");
inputHandler["onPointerMove"](
new PointerEvent("pointermove", {
button: 0,
clientX: 200,
clientY: 200,
pointerId: 1,
}),
);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({
startX: 100,
startY: 100,
endX: 200,
endY: 200,
}),
);
});
test("Shift+drag then pointerup emits WarshipSelectionBoxCompleteEvent", () => {
const listener = vi.fn();
eventBus.on(WarshipSelectionBoxCompleteEvent, listener);
inputHandler["onPointerDown"](
new PointerEvent("pointerdown", {
button: 0,
clientX: 50,
clientY: 50,
pointerId: 1,
}),
);
inputHandler["activeKeys"].add("ShiftLeft");
inputHandler["onPointerMove"](
new PointerEvent("pointermove", {
button: 0,
clientX: 200,
clientY: 200,
pointerId: 1,
}),
);
expect(inputHandler["selectionBoxActive"]).toBe(true);
inputHandler["onPointerUp"](
new PointerEvent("pointerup", {
button: 0,
clientX: 200,
clientY: 200,
pointerId: 1,
}),
);
expect(listener).toHaveBeenCalledWith(
expect.objectContaining({ startX: 50, startY: 50, endX: 200, endY: 200 }),
);
expect(inputHandler["selectionBoxActive"]).toBe(false);
});
test("Escape cancels active selection box", () => {
const listener = vi.fn();
eventBus.on(WarshipSelectionBoxCancelEvent, listener);
inputHandler["selectionBoxActive"] = true;
window.dispatchEvent(new KeyboardEvent("keydown", { code: "Escape" }));
expect(listener).toHaveBeenCalled();
expect(inputHandler["selectionBoxActive"]).toBe(false);
});
test("tiny drag (< 10px) cancels selection box instead of completing it", () => {
const cancelListener = vi.fn();
const completeListener = vi.fn();
eventBus.on(WarshipSelectionBoxCancelEvent, cancelListener);
eventBus.on(WarshipSelectionBoxCompleteEvent, completeListener);
inputHandler["onPointerDown"](
new PointerEvent("pointerdown", {
button: 0,
clientX: 100,
clientY: 100,
pointerId: 1,
}),
);
inputHandler["activeKeys"].add("ShiftLeft");
inputHandler["onPointerMove"](
new PointerEvent("pointermove", {
button: 0,
clientX: 104,
clientY: 104,
pointerId: 1,
}),
);
inputHandler["onPointerUp"](
new PointerEvent("pointerup", {
button: 0,
clientX: 104,
clientY: 104,
pointerId: 1,
}),
);
expect(cancelListener).toHaveBeenCalled();
expect(completeListener).not.toHaveBeenCalled();
});
test("window blur resets cursor", () => {
window.dispatchEvent(new KeyboardEvent("keydown", { code: "ShiftLeft" }));
expect(mockCanvas.style.cursor).toBe("crosshair");
window.dispatchEvent(new Event("blur"));
expect(mockCanvas.style.cursor).toBe("");
});
});