mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
40eea22007
Resolves #2719 - [X] Ran `npm run format` - [X] Ran `npm run lint:fix` - [X] Ran `npm test` - [X] Manually tested fixes ## Description: ### What problem(s) was I solving? On Mac, `Ctrl+Click` is commonly used as a substitute for right-click to open context menus. However, in OpenFront, `Ctrl+Click` was triggering both the context menu, AND causing an attack. This effectively prevented Mac users from being able to ally with bots/nations, as trying to ally would cause you to attack them. ### What changes did I make? - `Ctrl+Click` on Mac no longer triggers an attack, allowing players to properly interact with the alliance system ### How I implemented it 1. Added an `isMac()` method to `InputHandler` which encapsulates the "is mac" logic, and lets it be mocked during tests 2. In the `onPointerUp` handler, added a check: if on Mac and `ControlLeft` is held, emit a `ContextMenuEvent`, and then return (instead of continuing to also create a `MouseUpEvent`) 3. Extracted magic numbers for mouse button checks into descriptive helper methods (`isMiddleMouseButton`, `isNonLeftMouseButton`) for improved code clarity 4. Added clarifying comments throughout the pointer event handlers Last, alphabetized the `.gitignore` file and organized it into "Folders" and "Files" sections to make it easier to read. ### How to verify it #### Manual Testing - [X] On a Mac, hold `Ctrl` and left-click on another nation - verify the context menu opens (not an attack) - [X] On a Mac, right-click should still open the context menu as expected - [X] On Windows/Linux, `Ctrl+Click` continue to work as before (modifier key for build menu if configured) - [X] Regular left-click still triggers attacks/interactions as expected #### Automated Testing - [x] New unit test added: `Mac Ctrl+Click Context Menu` - verifies that `Ctrl+Click` on Mac emits `ContextMenuEvent` instead of `MouseUpEvent` ### Description for the changelog Fixed `Ctrl+Click` on Mac to properly open the context menu instead of triggering an attack, restoring the ability for Mac users to form alliances with other nations. ## 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 ## Please put your Discord username so you can be contacted if a bug or regression is found: Terekhov
503 lines
13 KiB
TypeScript
503 lines
13 KiB
TypeScript
import {
|
|
AutoUpgradeEvent,
|
|
ContextMenuEvent,
|
|
InputHandler,
|
|
MouseUpEvent,
|
|
} from "../src/client/InputHandler";
|
|
import { EventBus } from "../src/core/EventBus";
|
|
|
|
class MockPointerEvent {
|
|
button: number;
|
|
clientX: number;
|
|
clientY: number;
|
|
pointerId: number;
|
|
type: string;
|
|
preventDefault: () => void;
|
|
|
|
constructor(type: string, init: any) {
|
|
this.type = type;
|
|
this.button = init.button;
|
|
this.clientX = init.clientX;
|
|
this.clientY = init.clientY;
|
|
this.pointerId = init.pointerId;
|
|
this.preventDefault = vi.fn();
|
|
}
|
|
}
|
|
|
|
global.PointerEvent = MockPointerEvent as any;
|
|
|
|
describe("InputHandler AutoUpgrade", () => {
|
|
let inputHandler: InputHandler;
|
|
let eventBus: EventBus;
|
|
let mockCanvas: HTMLCanvasElement;
|
|
|
|
beforeEach(() => {
|
|
mockCanvas = document.createElement("canvas");
|
|
mockCanvas.width = 800;
|
|
mockCanvas.height = 600;
|
|
|
|
eventBus = new EventBus();
|
|
|
|
inputHandler = new InputHandler(
|
|
{ attackRatio: 20, ghostStructure: null, rocketDirectionUp: true },
|
|
mockCanvas,
|
|
eventBus,
|
|
);
|
|
});
|
|
|
|
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("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", () => {
|
|
beforeEach(() => {
|
|
localStorage.removeItem("settings.keybinds");
|
|
});
|
|
|
|
test("parses nested object values and flattens them to strings", () => {
|
|
const nested = {
|
|
moveUp: { key: "moveUp", value: "KeyZ" },
|
|
};
|
|
localStorage.setItem("settings.keybinds", JSON.stringify(nested));
|
|
|
|
inputHandler.initialize();
|
|
|
|
expect((inputHandler as any).keybinds.moveUp).toBe("KeyZ");
|
|
});
|
|
|
|
test("accepts legacy string values", () => {
|
|
localStorage.setItem(
|
|
"settings.keybinds",
|
|
JSON.stringify({ moveUp: "KeyX" }),
|
|
);
|
|
|
|
inputHandler.initialize();
|
|
|
|
expect((inputHandler as any).keybinds.moveUp).toBe("KeyX");
|
|
});
|
|
|
|
test("ignores non-string and 'Null' values and preserves defaults", () => {
|
|
const mixed = {
|
|
moveUp: { key: "moveUp", value: null },
|
|
moveLeft: "Null",
|
|
};
|
|
localStorage.setItem("settings.keybinds", JSON.stringify(mixed));
|
|
|
|
inputHandler.initialize();
|
|
|
|
// defaults from InputHandler should remain
|
|
expect((inputHandler as any).keybinds.moveUp).toBe("KeyW");
|
|
expect((inputHandler as any).keybinds.moveLeft).toBe("KeyA");
|
|
});
|
|
|
|
test("handles invalid JSON gracefully and warns", () => {
|
|
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
localStorage.setItem("settings.keybinds", "not a json");
|
|
|
|
inputHandler.initialize();
|
|
|
|
expect(spy).toHaveBeenCalled();
|
|
// default remains when parsing fails
|
|
expect((inputHandler as any).keybinds.moveUp).toBe("KeyW");
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe("Mac Ctrl+Click Context Menu", () => {
|
|
test("should create context menu with Ctrl+Click on Mac, but not attack", () => {
|
|
// Mock isMac() to return true
|
|
vi.spyOn(inputHandler as any, "isMac").mockReturnValue(true);
|
|
const mockEmit = vi.spyOn(eventBus, "emit");
|
|
|
|
// Simulate ControlLeft being held
|
|
inputHandler["activeKeys"].add("ControlLeft");
|
|
|
|
// Simulate a pointer down first (to set pointerDown state)
|
|
const pointerDownEvent = new PointerEvent("pointerdown", {
|
|
button: 0,
|
|
clientX: 100,
|
|
clientY: 200,
|
|
pointerId: 1,
|
|
});
|
|
inputHandler["onPointerDown"](pointerDownEvent);
|
|
mockEmit.mockClear();
|
|
|
|
// Now trigger pointer up
|
|
const pointerUpEvent = new PointerEvent("pointerup", {
|
|
button: 0,
|
|
clientX: 100,
|
|
clientY: 200,
|
|
pointerId: 1,
|
|
});
|
|
inputHandler["onPointerUp"](pointerUpEvent);
|
|
|
|
// Verify ContextMenuEvent was emitted with correct coordinates
|
|
expect(mockEmit).toHaveBeenCalledTimes(1);
|
|
// If MouseUp is fired, that would cause an attack - which we do not want.
|
|
expect(mockEmit).not.toHaveBeenCalledWith(expect.any(MouseUpEvent));
|
|
expect(mockEmit).toHaveBeenCalledWith(expect.any(ContextMenuEvent));
|
|
expect(mockEmit).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
x: 100,
|
|
y: 200,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
});
|