From cf662bc1bc2fc9e1d98d16f2dd03cfd4d402a1dd Mon Sep 17 00:00:00 2001
From: Kipstz Avenger <140314732+Kipstz@users.noreply.github.com>
Date: Sat, 2 Aug 2025 00:38:16 +0200
Subject: [PATCH] Add Split radial menu into separate attack and build buttons
(#1598)
## Description:
This PR implements a new radial menu system that separates attack and
construction functionalities into distinct buttons. Previously, all
units (both attack and construction) were grouped together in a single
orange button, making the interface confusing and inefficient.
## 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
- [x] I have read and accepted the CLA agreement (only required once).
## Please put your Discord username so you can be contacted if a bug or
regression is found:
Kipstzz
---
resources/lang/en.json | 1 +
.../graphics/layers/RadialMenuElements.ts | 153 ++++--
.../graphics/RadialMenuElements.test.ts | 516 ++++++++++++++++++
3 files changed, 622 insertions(+), 48 deletions(-)
create mode 100644 tests/client/graphics/RadialMenuElements.test.ts
diff --git a/resources/lang/en.json b/resources/lang/en.json
index 169882701..bea5861c2 100644
--- a/resources/lang/en.json
+++ b/resources/lang/en.json
@@ -70,6 +70,7 @@
"radial_title": "Radial menu",
"radial_desc": "Right clicking (or touch on mobile) opens the Radial menu. Right click outside it to close it. From the menu you can:",
"radial_build": "Open the Build menu.",
+ "radial_attack": "Open the Attack menu.",
"radial_info": "Open the Info menu.",
"radial_boat": "Send a Boat (transport ship) to attack at the selected location. Only available if you have access to water.",
"radial_close": "Close the menu.",
diff --git a/src/client/graphics/layers/RadialMenuElements.ts b/src/client/graphics/layers/RadialMenuElements.ts
index 952e45a4c..5fd53fe00 100644
--- a/src/client/graphics/layers/RadialMenuElements.ts
+++ b/src/client/graphics/layers/RadialMenuElements.ts
@@ -19,6 +19,7 @@ import donateGoldIcon from "../../../../resources/images/DonateGoldIconWhite.svg
import donateTroopIcon from "../../../../resources/images/DonateTroopIconWhite.svg";
import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg";
import infoIcon from "../../../../resources/images/InfoIcon.svg";
+import swordIcon from "../../../../resources/images/SwordIconWhite.svg";
import targetIcon from "../../../../resources/images/TargetIconWhite.svg";
import traitorIcon from "../../../../resources/images/TraitorIconWhite.svg";
import { EventBus } from "../../../core/EventBus";
@@ -66,6 +67,7 @@ export const COLORS = {
breakAlly: "#c74848",
info: "#64748B",
target: "#ff0000",
+ attack: "#ff0000",
infoDetails: "#7f8c8d",
infoEmoji: "#f1c40f",
trade: "#008080",
@@ -89,6 +91,7 @@ export enum Slot {
Info = "info",
Boat = "boat",
Build = "build",
+ Attack = "attack",
Ally = "ally",
Back = "back",
}
@@ -319,6 +322,88 @@ function getAllEnabledUnits(myPlayer: boolean, config: Config): Set {
return Units;
}
+const ATTACK_UNIT_TYPES: UnitType[] = [
+ UnitType.AtomBomb,
+ UnitType.MIRV,
+ UnitType.HydrogenBomb,
+ UnitType.Warship,
+];
+
+function createMenuElements(
+ params: MenuElementParams,
+ filterType: "attack" | "build",
+ elementIdPrefix: string,
+): MenuElement[] {
+ const unitTypes: Set = getAllEnabledUnits(
+ params.selected === params.myPlayer,
+ params.game.config(),
+ );
+
+ return flattenedBuildTable
+ .filter(
+ (item) =>
+ unitTypes.has(item.unitType) &&
+ (filterType === "attack"
+ ? ATTACK_UNIT_TYPES.includes(item.unitType)
+ : !ATTACK_UNIT_TYPES.includes(item.unitType)),
+ )
+ .map((item: BuildItemDisplay) => ({
+ id: `${elementIdPrefix}_${item.unitType}`,
+ name: item.key
+ ? item.key.replace("unit_type.", "")
+ : item.unitType.toString(),
+ disabled: (params: MenuElementParams) =>
+ !params.buildMenu.canBuildOrUpgrade(item),
+ color: params.buildMenu.canBuildOrUpgrade(item)
+ ? filterType === "attack"
+ ? COLORS.attack
+ : COLORS.building
+ : undefined,
+ icon: item.icon,
+ tooltipItems: [
+ { text: translateText(item.key ?? ""), className: "title" },
+ {
+ text: translateText(item.description ?? ""),
+ className: "description",
+ },
+ {
+ text: `${renderNumber(params.buildMenu.cost(item))} ${translateText("player_panel.gold")}`,
+ className: "cost",
+ },
+ item.countable
+ ? { text: `${params.buildMenu.count(item)}x`, className: "count" }
+ : null,
+ ].filter(
+ (tooltipItem): tooltipItem is TooltipItem => tooltipItem !== null,
+ ),
+ action: (params: MenuElementParams) => {
+ const buildableUnit = params.playerActions.buildableUnits.find(
+ (bu) => bu.type === item.unitType,
+ );
+ if (buildableUnit === undefined) {
+ return;
+ }
+ if (params.buildMenu.canBuildOrUpgrade(item)) {
+ params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
+ }
+ params.closeMenu();
+ },
+ }));
+}
+
+export const attackMenuElement: MenuElement = {
+ id: Slot.Attack,
+ name: "radial_attack",
+ disabled: (params: MenuElementParams) => params.game.inSpawnPhase(),
+ icon: swordIcon,
+ color: COLORS.attack,
+
+ subMenu: (params: MenuElementParams) => {
+ if (params === undefined) return [];
+ return createMenuElements(params, "attack", "attack");
+ },
+};
+
export const buildMenuElement: MenuElement = {
id: Slot.Build,
name: "build",
@@ -328,53 +413,7 @@ export const buildMenuElement: MenuElement = {
subMenu: (params: MenuElementParams) => {
if (params === undefined) return [];
-
- const unitTypes: Set = getAllEnabledUnits(
- params.selected === params.myPlayer,
- params.game.config(),
- );
- const buildElements: MenuElement[] = flattenedBuildTable
- .filter((item) => unitTypes.has(item.unitType))
- .map((item: BuildItemDisplay) => ({
- id: `build_${item.unitType}`,
- name: item.key
- ? item.key.replace("unit_type.", "")
- : item.unitType.toString(),
- disabled: (params: MenuElementParams) =>
- !params.buildMenu.canBuildOrUpgrade(item),
- color: params.buildMenu.canBuildOrUpgrade(item)
- ? COLORS.building
- : undefined,
- icon: item.icon,
- tooltipItems: [
- { text: translateText(item.key ?? ""), className: "title" },
- {
- text: translateText(item.description ?? ""),
- className: "description",
- },
- {
- text: `${renderNumber(params.buildMenu.cost(item))} ${translateText("player_panel.gold")}`,
- className: "cost",
- },
- item.countable
- ? { text: `${params.buildMenu.count(item)}x`, className: "count" }
- : null,
- ].filter((item): item is TooltipItem => item !== null),
- action: (params: MenuElementParams) => {
- const buildableUnit = params.playerActions.buildableUnits.find(
- (bu) => bu.type === item.unitType,
- );
- if (buildableUnit === undefined) {
- return;
- }
- if (params.buildMenu.canBuildOrUpgrade(item)) {
- params.buildMenu.sendBuildOrUpgrade(buildableUnit, params.tile);
- }
- params.closeMenu();
- },
- }));
-
- return buildElements;
+ return createMenuElements(params, "build", "build");
},
};
@@ -444,6 +483,24 @@ export const rootMenuElement: MenuElement = {
if (params.selected?.isAlliedWith(params.myPlayer)) {
ally = allyBreakElement;
}
- return [infoMenuElement, boatMenuElement, ally, buildMenuElement];
+
+ const tileOwner = params.game.owner(params.tile);
+ const isOwnTerritory =
+ tileOwner.isPlayer() &&
+ (tileOwner as PlayerView).id() === params.myPlayer.id();
+
+ const menuItems: (MenuElement | null)[] = [
+ infoMenuElement,
+ boatMenuElement,
+ ally,
+ ];
+
+ if (isOwnTerritory) {
+ menuItems.push(buildMenuElement);
+ } else {
+ menuItems.push(attackMenuElement);
+ }
+
+ return menuItems.filter((item): item is MenuElement => item !== null);
},
};
diff --git a/tests/client/graphics/RadialMenuElements.test.ts b/tests/client/graphics/RadialMenuElements.test.ts
new file mode 100644
index 000000000..9ec16aad7
--- /dev/null
+++ b/tests/client/graphics/RadialMenuElements.test.ts
@@ -0,0 +1,516 @@
+/**
+ * @jest-environment jsdom
+ */
+import {
+ attackMenuElement,
+ buildMenuElement,
+ COLORS,
+ MenuElementParams,
+ rootMenuElement,
+ Slot,
+} from "../../../src/client/graphics/layers/RadialMenuElements";
+import { UnitType } from "../../../src/core/game/Game";
+import { TileRef } from "../../../src/core/game/GameMap";
+import { GameView, PlayerView } from "../../../src/core/game/GameView";
+
+jest.mock("../../../src/client/Utils", () => ({
+ translateText: jest.fn((key: string) => key),
+ renderNumber: jest.fn((num: number) => num.toString()),
+}));
+
+jest.mock("../../../src/client/graphics/layers/BuildMenu", () => {
+ const { UnitType } = jest.requireActual("../../../src/core/game/Game");
+ return {
+ flattenedBuildTable: [
+ {
+ unitType: UnitType.City,
+ key: "unit_type.city",
+ description: "unit_type.city_desc",
+ icon: "city-icon",
+ countable: true,
+ },
+ {
+ unitType: UnitType.Factory,
+ key: "unit_type.factory",
+ description: "unit_type.factory_desc",
+ icon: "factory-icon",
+ countable: true,
+ },
+ {
+ unitType: UnitType.AtomBomb,
+ key: "unit_type.atom_bomb",
+ description: "unit_type.atom_bomb_desc",
+ icon: "atom-bomb-icon",
+ countable: false,
+ },
+ {
+ unitType: UnitType.Warship,
+ key: "unit_type.warship",
+ description: "unit_type.warship_desc",
+ icon: "warship-icon",
+ countable: true,
+ },
+ {
+ unitType: UnitType.HydrogenBomb,
+ key: "unit_type.hydrogen_bomb",
+ description: "unit_type.hydrogen_bomb_desc",
+ icon: "hydrogen-bomb-icon",
+ countable: false,
+ },
+ {
+ unitType: UnitType.MIRV,
+ key: "unit_type.mirv",
+ description: "unit_type.mirv_desc",
+ icon: "mirv-icon",
+ countable: false,
+ },
+ ],
+ };
+});
+
+jest.mock("nanoid", () => ({
+ customAlphabet: jest.fn(() => jest.fn(() => "mock-id")),
+}));
+
+jest.mock("dompurify", () => ({
+ __esModule: true,
+ default: {
+ sanitize: jest.fn((str: string) => str),
+ },
+}));
+
+jest.mock("twemoji", () => ({
+ __esModule: true,
+ default: {
+ parse: jest.fn((str: string) => str),
+ },
+}));
+
+describe("RadialMenuElements", () => {
+ let mockParams: MenuElementParams;
+ let mockPlayer: PlayerView;
+ let mockGame: GameView;
+ let mockBuildMenu: any;
+ let mockPlayerActions: any;
+ let mockTile: TileRef;
+
+ beforeEach(() => {
+ mockPlayer = {
+ id: () => 1,
+ isAlliedWith: jest.fn(() => false),
+ isPlayer: jest.fn(() => true),
+ } as unknown as PlayerView;
+
+ mockGame = {
+ inSpawnPhase: jest.fn(() => false),
+ owner: jest.fn(() => mockPlayer),
+ isLand: jest.fn(() => true),
+ config: jest.fn(() => ({
+ theme: () => ({
+ territoryColor: () => ({
+ lighten: () => ({ alpha: () => ({ toRgbString: () => "#fff" }) }),
+ }),
+ }),
+ isUnitDisabled: jest.fn(() => false),
+ })),
+ } as unknown as GameView;
+
+ mockBuildMenu = {
+ canBuildOrUpgrade: jest.fn(() => true),
+ cost: jest.fn(() => 100),
+ count: jest.fn(() => 5),
+ sendBuildOrUpgrade: jest.fn(),
+ };
+
+ mockPlayerActions = {
+ buildableUnits: [
+ { type: UnitType.City, canBuild: true },
+ { type: UnitType.Factory, canBuild: true },
+ { type: UnitType.AtomBomb, canBuild: true },
+ { type: UnitType.Warship, canBuild: true },
+ { type: UnitType.HydrogenBomb, canBuild: true },
+ { type: UnitType.MIRV, canBuild: true },
+ { type: UnitType.TransportShip, canBuild: true },
+ ],
+ canAttack: true,
+ interaction: {
+ canSendAllianceRequest: true,
+ canBreakAlliance: false,
+ canDonate: true,
+ },
+ };
+
+ mockTile = {} as TileRef;
+
+ mockParams = {
+ myPlayer: mockPlayer,
+ selected: mockPlayer,
+ tile: mockTile,
+ playerActions: mockPlayerActions,
+ game: mockGame,
+ buildMenu: mockBuildMenu,
+ emojiTable: {} as any,
+ playerActionHandler: {} as any,
+ playerPanel: {} as any,
+ chatIntegration: {} as any,
+ eventBus: {} as any,
+ closeMenu: jest.fn(),
+ };
+ });
+
+ describe("attackMenuElement", () => {
+ it("should have correct basic properties", () => {
+ expect(attackMenuElement.id).toBe(Slot.Attack);
+ expect(attackMenuElement.name).toBe("radial_attack");
+ expect(attackMenuElement.icon).toBeDefined();
+ expect(attackMenuElement.color).toBe(COLORS.attack);
+ });
+
+ it("should be disabled during spawn phase", () => {
+ mockGame.inSpawnPhase = jest.fn(() => true);
+ expect(attackMenuElement.disabled(mockParams)).toBe(true);
+ });
+
+ it("should be enabled when not in spawn phase", () => {
+ mockGame.inSpawnPhase = jest.fn(() => false);
+ expect(attackMenuElement.disabled(mockParams)).toBe(false);
+ });
+
+ it("should return attack submenu with attack units only", () => {
+ const enemyPlayer = {
+ id: () => 2,
+ isPlayer: jest.fn(() => true),
+ } as unknown as PlayerView;
+ mockParams.selected = enemyPlayer;
+
+ const subMenu = attackMenuElement.subMenu!(mockParams);
+
+ expect(subMenu).toBeDefined();
+ expect(subMenu.length).toBeGreaterThan(0);
+
+ const attackUnitTypes = [
+ UnitType.AtomBomb,
+ UnitType.MIRV,
+ UnitType.HydrogenBomb,
+ UnitType.Warship,
+ ];
+ const returnedUnitTypes = subMenu.map((item) => {
+ const unitTypeStr = item.id.replace("attack_", "");
+ return Object.values(UnitType).find(
+ (type) => type.toString() === unitTypeStr,
+ );
+ });
+
+ returnedUnitTypes.forEach((unitType) => {
+ expect(attackUnitTypes).toContain(unitType);
+ });
+ });
+
+ it("should not include construction units in attack menu", () => {
+ const enemyPlayer = {
+ id: () => 2,
+ isPlayer: jest.fn(() => true),
+ } as unknown as PlayerView;
+ mockParams.selected = enemyPlayer;
+
+ const subMenu = attackMenuElement.subMenu!(mockParams);
+
+ const constructionUnitTypes = [UnitType.City, UnitType.Factory];
+ const returnedUnitTypes = subMenu.map((item) => {
+ const unitTypeStr = item.id.replace("attack_", "");
+ return Object.values(UnitType).find(
+ (type) => type.toString() === unitTypeStr,
+ );
+ });
+
+ constructionUnitTypes.forEach((unitType) => {
+ expect(returnedUnitTypes).not.toContain(unitType);
+ });
+ });
+
+ it("should handle undefined params in submenu", () => {
+ const subMenu = attackMenuElement.subMenu!(undefined as any);
+ expect(subMenu).toEqual([]);
+ });
+ });
+
+ describe("buildMenuElement", () => {
+ it("should have correct basic properties", () => {
+ expect(buildMenuElement.id).toBe(Slot.Build);
+ expect(buildMenuElement.name).toBe("build");
+ expect(buildMenuElement.icon).toBeDefined();
+ expect(buildMenuElement.color).toBe(COLORS.build);
+ });
+
+ it("should be disabled during spawn phase", () => {
+ mockGame.inSpawnPhase = jest.fn(() => true);
+ expect(buildMenuElement.disabled(mockParams)).toBe(true);
+ });
+
+ it("should be enabled when not in spawn phase", () => {
+ mockGame.inSpawnPhase = jest.fn(() => false);
+ expect(buildMenuElement.disabled(mockParams)).toBe(false);
+ });
+
+ it("should return build submenu with construction units only", () => {
+ const subMenu = buildMenuElement.subMenu!(mockParams);
+
+ expect(subMenu).toBeDefined();
+ expect(subMenu.length).toBeGreaterThan(0);
+
+ const constructionUnitTypes = [UnitType.City, UnitType.Factory];
+ const returnedUnitTypes = subMenu.map((item) => {
+ const unitTypeStr = item.id.replace("build_", "");
+ return Object.values(UnitType).find(
+ (type) => type.toString() === unitTypeStr,
+ );
+ });
+
+ returnedUnitTypes.forEach((unitType) => {
+ expect(constructionUnitTypes).toContain(unitType);
+ });
+ });
+
+ it("should not include attack units in build menu", () => {
+ const subMenu = buildMenuElement.subMenu!(mockParams);
+
+ const attackUnitTypes = [
+ UnitType.AtomBomb,
+ UnitType.MIRV,
+ UnitType.HydrogenBomb,
+ UnitType.Warship,
+ ];
+ const returnedUnitTypes = subMenu.map((item) => {
+ const unitTypeStr = item.id.replace("build_", "");
+ return Object.values(UnitType).find(
+ (type) => type.toString() === unitTypeStr,
+ );
+ });
+
+ attackUnitTypes.forEach((unitType) => {
+ expect(returnedUnitTypes).not.toContain(unitType);
+ });
+ });
+
+ it("should handle undefined params in submenu", () => {
+ const subMenu = buildMenuElement.subMenu!(undefined as any);
+ expect(subMenu).toEqual([]);
+ });
+ });
+
+ describe("rootMenuElement", () => {
+ it("should have correct basic properties", () => {
+ expect(rootMenuElement.id).toBe("root");
+ expect(rootMenuElement.name).toBe("root");
+ expect(rootMenuElement.disabled(mockParams)).toBe(false);
+ });
+
+ it("should show build menu on own territory", () => {
+ const subMenu = rootMenuElement.subMenu!(mockParams);
+ const buildMenu = subMenu.find((item) => item.id === Slot.Build);
+ const attackMenu = subMenu.find((item) => item.id === Slot.Attack);
+
+ expect(buildMenu).toBeDefined();
+ expect(attackMenu).toBeUndefined();
+ });
+
+ it("should show attack menu on enemy territory", () => {
+ const enemyPlayer = {
+ id: () => 2,
+ isPlayer: jest.fn(() => true),
+ } as unknown as PlayerView;
+ mockGame.owner = jest.fn(() => enemyPlayer);
+
+ const subMenu = rootMenuElement.subMenu!(mockParams);
+ const buildMenu = subMenu.find((item) => item.id === Slot.Build);
+ const attackMenu = subMenu.find((item) => item.id === Slot.Attack);
+
+ expect(attackMenu).toBeDefined();
+ expect(buildMenu).toBeUndefined();
+ });
+
+ it("should include info and boat menus in both cases", () => {
+ const subMenu = rootMenuElement.subMenu!(mockParams);
+ const infoMenu = subMenu.find((item) => item.id === Slot.Info);
+ const boatMenu = subMenu.find((item) => item.id === Slot.Boat);
+
+ expect(infoMenu).toBeDefined();
+ expect(boatMenu).toBeDefined();
+ });
+
+ it("should handle ally menu correctly", () => {
+ const allyPlayer = {
+ id: () => 2,
+ isAlliedWith: jest.fn(() => true),
+ isPlayer: jest.fn(() => true),
+ } as unknown as PlayerView;
+ mockParams.selected = allyPlayer;
+
+ const subMenu = rootMenuElement.subMenu!(mockParams);
+ const allyMenu = subMenu.find((item) => item.id === "ally_break");
+
+ expect(allyMenu).toBeDefined();
+ });
+ });
+
+ describe("Menu element actions", () => {
+ it("should execute build action correctly", () => {
+ const subMenu = buildMenuElement.subMenu!(mockParams);
+ const cityElement = subMenu.find((item) => item.id === "build_City");
+
+ expect(cityElement).toBeDefined();
+ expect(cityElement!.action).toBeDefined();
+
+ if (cityElement!.action) {
+ cityElement!.action(mockParams);
+ expect(mockBuildMenu.sendBuildOrUpgrade).toHaveBeenCalled();
+ expect(mockParams.closeMenu).toHaveBeenCalled();
+ }
+ });
+
+ it("should execute attack action correctly", () => {
+ const enemyPlayer = {
+ id: () => 2,
+ isPlayer: jest.fn(() => true),
+ } as unknown as PlayerView;
+ mockParams.selected = enemyPlayer;
+
+ const subMenu = attackMenuElement.subMenu!(mockParams);
+
+ const atomBombElement = subMenu.find(
+ (item) => item.id === "attack_Atom Bomb",
+ );
+
+ expect(atomBombElement).toBeDefined();
+ expect(atomBombElement!.action).toBeDefined();
+
+ if (atomBombElement!.action) {
+ atomBombElement!.action(mockParams);
+ expect(mockBuildMenu.sendBuildOrUpgrade).toHaveBeenCalled();
+ expect(mockParams.closeMenu).toHaveBeenCalled();
+ }
+ });
+
+ it("should not execute action when buildable unit is not found", () => {
+ mockPlayerActions.buildableUnits = [];
+ mockBuildMenu.canBuildOrUpgrade = jest.fn(() => false);
+
+ const subMenu = buildMenuElement.subMenu!(mockParams);
+ const cityElement = subMenu.find((item) => item.id === "build_City");
+
+ if (cityElement!.action) {
+ cityElement!.action(mockParams);
+ expect(mockBuildMenu.sendBuildOrUpgrade).not.toHaveBeenCalled();
+ expect(mockParams.closeMenu).not.toHaveBeenCalled();
+ }
+ });
+ });
+
+ describe("Menu element tooltips", () => {
+ it("should generate correct tooltip items for build elements", () => {
+ const subMenu = buildMenuElement.subMenu!(mockParams);
+ const cityElement = subMenu.find((item) => item.id === "build_City");
+
+ expect(cityElement!.tooltipItems).toBeDefined();
+ expect(cityElement!.tooltipItems!.length).toBeGreaterThan(0);
+
+ const tooltipTexts = cityElement!.tooltipItems!.map((item) => item.text);
+ expect(tooltipTexts).toContain("unit_type.city");
+ expect(tooltipTexts).toContain("unit_type.city_desc");
+ expect(tooltipTexts.some((text) => text.includes("100"))).toBe(true);
+ expect(tooltipTexts.some((text) => text.includes("5x"))).toBe(true);
+ });
+
+ it("should generate correct tooltip items for attack elements", () => {
+ const enemyPlayer = {
+ id: () => 2,
+ isPlayer: jest.fn(() => true),
+ } as unknown as PlayerView;
+ mockParams.selected = enemyPlayer;
+
+ const subMenu = attackMenuElement.subMenu!(mockParams);
+ const atomBombElement = subMenu.find(
+ (item) => item.id === "attack_Atom Bomb",
+ );
+
+ expect(atomBombElement!.tooltipItems).toBeDefined();
+ expect(atomBombElement!.tooltipItems!.length).toBeGreaterThan(0);
+
+ const tooltipTexts = atomBombElement!.tooltipItems!.map(
+ (item) => item.text,
+ );
+ expect(tooltipTexts).toContain("unit_type.atom_bomb");
+ expect(tooltipTexts).toContain("unit_type.atom_bomb_desc");
+ expect(tooltipTexts.some((text) => text.includes("100"))).toBe(true);
+ });
+ });
+
+ describe("Menu element colors", () => {
+ it("should use correct colors for build elements", () => {
+ const subMenu = buildMenuElement.subMenu!(mockParams);
+ const cityElement = subMenu.find((item) => item.id === "build_City");
+
+ expect(cityElement!.color).toBe(COLORS.building);
+ });
+
+ it("should use correct colors for attack elements", () => {
+ const enemyPlayer = {
+ id: () => 2,
+ isPlayer: jest.fn(() => true),
+ } as unknown as PlayerView;
+ mockParams.selected = enemyPlayer;
+
+ const subMenu = attackMenuElement.subMenu!(mockParams);
+ const atomBombElement = subMenu.find(
+ (item) => item.id === "attack_Atom Bomb",
+ );
+
+ expect(atomBombElement!.color).toBe(COLORS.attack);
+ });
+
+ it("should not set color when element is disabled", () => {
+ mockBuildMenu.canBuildOrUpgrade = jest.fn(() => false);
+
+ const subMenu = buildMenuElement.subMenu!(mockParams);
+ const cityElement = subMenu.find((item) => item.id === "build_City");
+
+ expect(cityElement!.color).toBeUndefined();
+ });
+ });
+
+ describe("Translation integration", () => {
+ it("should use translateText for tooltip items in build menu", () => {
+ const { translateText } = jest.requireMock("../../../src/client/Utils");
+
+ (translateText as jest.Mock).mockClear();
+
+ buildMenuElement.subMenu!(mockParams);
+
+ expect(translateText).toHaveBeenCalledWith("unit_type.city");
+ expect(translateText).toHaveBeenCalledWith("unit_type.city_desc");
+ expect(translateText).toHaveBeenCalledWith("unit_type.factory");
+ expect(translateText).toHaveBeenCalledWith("unit_type.factory_desc");
+ });
+
+ it("should use translateText for tooltip items in attack menu", () => {
+ const { translateText } = jest.requireMock("../../../src/client/Utils");
+
+ (translateText as jest.Mock).mockClear();
+
+ const enemyPlayer = {
+ id: () => 2,
+ isPlayer: jest.fn(() => true),
+ } as unknown as PlayerView;
+ mockParams.selected = enemyPlayer;
+
+ attackMenuElement.subMenu!(mockParams);
+
+ expect(translateText).toHaveBeenCalledWith("unit_type.atom_bomb");
+ expect(translateText).toHaveBeenCalledWith("unit_type.atom_bomb_desc");
+ expect(translateText).toHaveBeenCalledWith("unit_type.hydrogen_bomb");
+ expect(translateText).toHaveBeenCalledWith(
+ "unit_type.hydrogen_bomb_desc",
+ );
+ });
+ });
+});