mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:10:42 +00:00
ba2a947061
## Description: Based on [this suggestion on Discord](https://discord.com/channels/1284581928254701718/1447110257196138577) and feedback gathered in [this thread](https://discord.com/channels/1359946986937258015/1469598906173227184). Supersedes #3143 This PR introduces "ghost railways": when you are going to place a city or port, previews railway connections that will be made when actually building the structure. Ghost railways are skipped if the structure is going to be snapped to existing railways (as in railway snapping functionality introduced in #3156 ). ### Video https://github.com/user-attachments/assets/ff8cf325-6501-4df8-801d-c8ae3ced3d0e ### Ghost rails color revisited black with 40% opacity <img width="695" height="430" alt="image" src="https://github.com/user-attachments/assets/272efbcc-4185-426a-921c-7fae61f6c462" /> ## 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: deshack_82603
466 lines
12 KiB
TypeScript
466 lines
12 KiB
TypeScript
import { AutoUpgradeEvent, InputHandler } 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,
|
|
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("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 values and preserves defaults, but keeps 'Null' for unbound keys", () => {
|
|
const mixed = {
|
|
moveUp: { key: "moveUp", value: null },
|
|
moveLeft: "Null",
|
|
};
|
|
localStorage.setItem("settings.keybinds", JSON.stringify(mixed));
|
|
|
|
inputHandler.initialize();
|
|
|
|
expect((inputHandler as any).keybinds.moveUp).toBe("KeyW");
|
|
// "Null" is preserved to indicate unbound keybind
|
|
expect((inputHandler as any).keybinds.moveLeft).toBe("Null");
|
|
});
|
|
|
|
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();
|
|
});
|
|
});
|
|
});
|