From e7b23e6c85ce8f96b4c705280b49d4f191879195 Mon Sep 17 00:00:00 2001 From: evanpelle Date: Fri, 1 Aug 2025 12:51:35 -0700 Subject: [PATCH 1/2] don't kick client for invalid message (#1673) ## Description: There is a race condition causing clients to send intents before joining: 1. User requests to join a Game 2. The Worker does authentication & authorization 3. User requests to send an intent before joining the game, so the request is sent to the worker So instead of kicking the client, just log a warning and drop the message. ## 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: evan --- src/server/Worker.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/server/Worker.ts b/src/server/Worker.ts index bad56d0a5..f4083d01e 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -325,15 +325,9 @@ export function startWorker() { // Ignore ping return; } else if (clientMsg.type !== "join") { - const error = `Invalid message before join: ${JSON.stringify(clientMsg)}`; - log.warn(error); - ws.send( - JSON.stringify({ - type: "error", - error, - } satisfies ServerErrorMessage), + log.warn( + `Invalid message before join: ${JSON.stringify(clientMsg)}`, ); - ws.close(1002, "ClientJoinMessageSchema"); return; } 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 2/2] 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 image image --- 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", + ); + }); + }); +});