mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 16:30:16 +00:00
318d1e2c44
## Description: 1) Have last localstorage calls for keybinds and attack ratio also use UserSettings cache instead, after #3481. Remaining calls to localstorage are for different things than user settings, so they are left as is. 2) Consolidate and centralize keybinds logic. And three fixes for it. - **UnitDisplay** and **UserSettingsModal**: _parsedUserKeybinds_ is introduced in **UserSettings** to centralize their logic. It is also used by _normalizedUserKeybinds_, see point below. - **UserSettingsModal** -- replaced unwanted cast `as SettingKeybind` by a typed QuerySelector. -- renamed this.keybinds to this.userKeybinds for more clarity, and distinction from defaultKeybinds. -- state private _userKeybinds_: remove type string[] since loadKeybindsFromStorage replaces a value array by its first string element, so it can not contain string[] anymore. -- _handleKeybindChange_ and _getKeyValue_: no need to check for Array.isArray anymore, see above reason. -- **Fix**: checks after calling _parsedUserKeybinds_ are improved a bit: don't delete all keybinds and print a console warning when finding just one invalid keybind and (i think i have seen people complaining about things being removed). Instead it now migrates or throws out the invalid ones but keeps the valid ones. Also works with the "Null" value expected and removed within **UserSettingsModal**._handleyKeybindChange_() and in **HelpModal**. When legacy value is an array and key is empty, don't put value as key but get first array element or empty string as key name. So that check on line 68 is true. - **HelpModal** and **InputHandler**: Also centralize/consolidate their logic more, by having __keybinds()_ from **UserSettings** perform fetching _getDefaultKeybinds_ and _normalizedUserKeybinds_. -- Functionality in _normalizedUserKeybinds_ is the same: Where HelpModal did return [k, v.value] if typeof (v as any).value === "string", this is now handled by lines 309-310 of normalizedKeybinds still the same but with less lines. Same for old HelplModal if (typeof v === "string") return [k, v], this is stil returned by line 112 of normalizedKeybinds. And return [k, undefined] when (typeof val !== "string") as was done in InputHandler, isn't needed as values that weren't strings were already filtered out right after which we still do on line 314 of normalizedKeybinds. -- **Fix** in _normalizedUserKeybinds_: added one extra thing that was a discrepancy between **HelpModal**/**InputHandler** and **UserSettingsModal** before: **UserSettingsModal** would handle array values, and normalize them by picking only the first value if it is a string. Now have _normalizedKeybinds_ do the same. Otherwise it would have thrown those values out while **UserSettingsModal** would have kept the first value. This may still help a returning player who hasn't played in the last version (i think i have seen people complaining about things being removed, but that may not have been about this). And makes the logic more consistent between **UserSettingsModal** and **HelpModal**/**InputHandler**. - **UserSettings**: -- _getDefaultKeybinds_: centralized/consolidated logic, accepts Platform.isMac parameter. In **HelpModal**, **InputHandler** and **UserSettingsModal** the same list with default keybinds was hardcoded. Now they all read from _getDefaultKeybinds_. The list of default keybinds in **HelpModal** was a little shorter, but that doesn't matter since its _render_() function has hardcoded which of the hotkeys **HelpModal** shows. Have thought about putting default keybinds in **DefaultConfig** but with all the logic handled through **UserSettings**, this seemed the better place in the current refactor. -- _removeCached_: make public, now that **InputHandler.test.ts** needs to be able to call it. We could instead make a public function like removeKeybinds() and keep removeCached() private, but went with this for now. -- _parsedUserKeybinds_: centralized/consolidated logic for **UserSettingsModal**/**UserDisplay**. Always returns an object, even an empty one if the JSON wasn't parsable. -- _normalizedKeybinds_: centralized/consolidated logic. Used by _keybinds_() which is now called by **HelpModal**/**InputHandler**. -- _keybinds_: now uses getDefaultKeybinds() and normalizedKeybinds() to get the default and user changed keybinds. -- **Fix** in _keybinds_: it now removes a key if it is Unbound by the user in **UserSettingsModal**. Instead of first loading the parsedUserKeybinds, removing "Null" keys from it, and then merging that with defaultKeybinds (so default key would overwrite an unbound key), we now merge parsedUserKeybinds with defaultKeybinds and after that remove "Null" keys from it (so that unbound key stays removed). For example if Boat Attack Up is set to "None" ("Null") by clicking Unbind, there is now no hotkey working for it anymore. Even when the default is "B". Why? This prevents the user from being confused, they have deliberately Unbound it, they don't understand why it still works (have seen bug reports and game feedback about this)? Also more importantly: they used to now be able to bind "B" to another action. Effectively making key "B" bound to two actions: the user choosen one and Boat Attack. This also makes the logic more consistent. Because building hotkeys in **UnitDisplay** already didn't work when unbound, eg. when Build Missile Silo was Unbound, the "5" key did not do anything anymore (there is a fallback in **UnitDisplay** in case the key is actually null, but it does respect "Null" as it should). -- _setKeybinds_: have it accept an object, it stringifies it itself. Callers UserSettingsModal and InputHandler.test.ts now just send either a string or an object. - **InputHandler.test.ts**: -- use **UserSettings** functions instead of localStorage for more real-world testing. -- change test "ignores non-string values and preserves defaults, removes 'Null' for unbound keys". As explained above, as a fix we no longer preserve unbound ("Null") keys within InputHandler. UserSettings.keybinds() now removes "Null" keys as explained above. - ControlPanel: use UserSettings to fetch initial attack ratio. ## 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: tryout33
729 lines
20 KiB
TypeScript
729 lines
20 KiB
TypeScript
import {
|
|
AutoUpgradeEvent,
|
|
ConfirmGhostStructureEvent,
|
|
InputHandler,
|
|
} 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);
|
|
});
|
|
});
|
|
});
|