import { vi, type Mock } from "vitest"; import { attackMenuElement, buildMenuElement, COLORS, MenuElementParams, rootMenuElement, Slot, } from "../../../src/client/hud/layers/RadialMenuElements"; import { GameView, PlayerView } from "../../../src/client/view"; import { UnitType } from "../../../src/core/game/Game"; import { TileRef } from "../../../src/core/game/GameMap"; vi.mock("../../../src/client/Utils", () => ({ translateText: vi.fn((key: string) => key), renderNumber: vi.fn((num: number) => num.toString()), })); vi.mock("../../../src/client/hud/layers/BuildMenu", async () => { const { UnitType } = await vi.importActual< typeof import("../../../src/core/game/Game") >("../../../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, }, ], }; }); vi.mock("nanoid", () => ({ customAlphabet: vi.fn(() => vi.fn(() => "mock-id")), })); vi.mock("dompurify", () => ({ __esModule: true, default: { sanitize: vi.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: vi.fn(() => false), isPlayer: vi.fn(() => true), isTraitor: vi.fn(() => false), isDisconnected: vi.fn(() => false), } as unknown as PlayerView; mockGame = { inSpawnPhase: vi.fn(() => false), owner: vi.fn(() => mockPlayer), isLand: vi.fn(() => true), config: vi.fn(() => ({ theme: () => ({ territoryColor: () => ({ lighten: () => ({ alpha: () => ({ toRgbString: () => "#fff" }) }), }), }), isUnitDisabled: vi.fn(() => false), })), } as unknown as GameView; mockBuildMenu = { canBuildOrUpgrade: vi.fn(() => true), cost: vi.fn(() => 100), count: vi.fn(() => 5), sendBuildOrUpgrade: vi.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, canDonateTroops: true, canDonateGold: 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: vi.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 = vi.fn(() => true); expect(attackMenuElement.disabled(mockParams)).toBe(true); }); it("should be enabled when not in spawn phase", () => { mockGame.inSpawnPhase = vi.fn(() => false); expect(attackMenuElement.disabled(mockParams)).toBe(false); }); it("should return attack submenu with attack units only", () => { const enemyPlayer = { id: () => 2, isPlayer: vi.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: vi.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 = vi.fn(() => true); expect(buildMenuElement.disabled(mockParams)).toBe(true); }); it("should be enabled when not in spawn phase", () => { mockGame.inSpawnPhase = vi.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 and delete 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); const deleteMenu = subMenu.find((item) => item.id === Slot.Delete); expect(buildMenu).toBeDefined(); expect(attackMenu).toBeUndefined(); expect(deleteMenu).toBeDefined(); }); it("should show attack and boat menu on enemy territory", () => { const enemyPlayer = { id: () => 2, isPlayer: vi.fn(() => true), } as unknown as PlayerView; mockGame.owner = vi.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); const boatMenu = subMenu.find((item) => item.id === Slot.Boat); expect(attackMenu).toBeDefined(); expect(buildMenu).toBeUndefined(); expect(boatMenu).toBeDefined(); }); it("should include info menu in both cases", () => { const subMenu = rootMenuElement.subMenu!(mockParams); const infoMenu = subMenu.find((item) => item.id === Slot.Info); expect(infoMenu).toBeDefined(); }); it("should handle ally menu correctly", () => { const allyPlayer = { id: () => 2, isAlliedWith: vi.fn(() => true), isPlayer: vi.fn(() => true), isTraitor: vi.fn(() => false), isDisconnected: vi.fn(() => false), } as unknown as PlayerView; mockParams.selected = allyPlayer; mockGame.owner = vi.fn(() => allyPlayer); const subMenu = rootMenuElement.subMenu!(mockParams); const allyMenu = subMenu.find((item) => item.id === "ally_break"); expect(allyMenu).toBeDefined(); }); it("should show extend element when inAllianceExtensionWindow is true", () => { const allyPlayer = { id: () => 2, isAlliedWith: vi.fn(() => true), isPlayer: vi.fn(() => true), } as unknown as PlayerView; mockParams.selected = allyPlayer; mockGame.owner = vi.fn(() => allyPlayer); mockPlayerActions.interaction = { ...mockPlayerActions.interaction, canBreakAlliance: true, allianceInfo: { expiresAt: 100, inExtensionWindow: true, myPlayerAgreedToExtend: true, otherAgreedToExtend: false, canExtend: false, }, }; const subMenu = rootMenuElement.subMenu!(mockParams); const extendMenu = subMenu.find((item) => item.id === "ally_extend"); expect(extendMenu).toBeDefined(); }); it("should not show extend element when inAllianceExtensionWindow is false", () => { const allyPlayer = { id: () => 2, isAlliedWith: vi.fn(() => true), isPlayer: vi.fn(() => true), } as unknown as PlayerView; mockParams.selected = allyPlayer; mockGame.owner = vi.fn(() => allyPlayer); mockPlayerActions.interaction = { ...mockPlayerActions.interaction, canBreakAlliance: true, allianceInfo: { expiresAt: 100, inExtensionWindow: false, myPlayerAgreedToExtend: false, otherAgreedToExtend: false, canExtend: false, }, }; const subMenu = rootMenuElement.subMenu!(mockParams); const extendMenu = subMenu.find((item) => item.id === "ally_extend"); expect(extendMenu).toBeUndefined(); }); it("should show extend element as disabled when canExtend is false", () => { const allyPlayer = { id: () => 2, isAlliedWith: vi.fn(() => true), isPlayer: vi.fn(() => true), } as unknown as PlayerView; mockParams.selected = allyPlayer; mockGame.owner = vi.fn(() => allyPlayer); mockPlayerActions.interaction = { ...mockPlayerActions.interaction, canBreakAlliance: true, allianceInfo: { expiresAt: 100, inExtensionWindow: true, myPlayerAgreedToExtend: true, otherAgreedToExtend: false, canExtend: false, }, }; const subMenu = rootMenuElement.subMenu!(mockParams); const extendMenu = subMenu.find((item) => item.id === "ally_extend"); expect(extendMenu).toBeDefined(); expect(extendMenu!.disabled(mockParams)).toBe(true); }); it("should show extend element as enabled when canExtend is true", () => { const allyPlayer = { id: () => 2, isAlliedWith: vi.fn(() => true), isPlayer: vi.fn(() => true), } as unknown as PlayerView; mockParams.selected = allyPlayer; mockGame.owner = vi.fn(() => allyPlayer); mockPlayerActions.interaction = { ...mockPlayerActions.interaction, canBreakAlliance: true, allianceInfo: { expiresAt: 100, inExtensionWindow: true, myPlayerAgreedToExtend: false, otherAgreedToExtend: false, canExtend: true, }, }; const subMenu = rootMenuElement.subMenu!(mockParams); const extendMenu = subMenu.find((item) => item.id === "ally_extend"); expect(extendMenu).toBeDefined(); expect(extendMenu!.disabled(mockParams)).toBe(false); }); }); 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: vi.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 = vi.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: vi.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 as (params: MenuElementParams) => string)( mockParams, ), ).toBe(COLORS.building); }); it("should use correct colors for attack elements", () => { const enemyPlayer = { id: () => 2, isPlayer: vi.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 as (params: MenuElementParams) => string)( mockParams, ), ).toBe(COLORS.attack); }); it("should use disabled color when element is disabled", () => { mockBuildMenu.canBuildOrUpgrade = vi.fn(() => false); const subMenu = buildMenuElement.subMenu!(mockParams); const cityElement = subMenu.find((item) => item.id === "build_City"); expect( (cityElement!.color as (params: MenuElementParams) => string)( mockParams, ), ).toBe(COLORS.building); }); }); describe("Translation integration", () => { it("should use translateText for tooltip items in build menu", async () => { const { translateText } = await vi.importMock< typeof import("../../../src/client/Utils") >("../../../src/client/Utils"); (translateText as 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", async () => { const { translateText } = await vi.importMock< typeof import("../../../src/client/Utils") >("../../../src/client/Utils"); (translateText as Mock).mockClear(); const enemyPlayer = { id: () => 2, isPlayer: vi.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", ); }); }); });