Files
OpenFrontIO/tests/client/graphics/layers/PlayerPanelKick.test.ts
T
Mitchell Zinck de3794313d feat: Kick player in game (#2969)
If this PR fixes an issue, link it below. If not, delete these two
lines.
Resolves #2686 

## Description:
- Implemented feature for lobby creator to kick players in game.
- Added new moderation option for lobby creator, with a kick player
option if they aren't the creator, a bot, and exist in game.
- Includes a confirm kick option, and keeps track of kicked players so
that the kick option changes to "Already Kicked" if the kicked player
panel is opened again on the kicked player.

Screenshot order:
1) Open player panel
2) Click on moderation
3) Click on kick player and confirm kick
4) Player is kicked, open same player panel again and observe change in
kick status
5) Receiving player kick message

<img width="1470" height="776" alt="Screenshot 2026-01-20 at 12 33
55 PM"
src="https://github.com/user-attachments/assets/7c47b5a2-a0f8-4e92-833c-7b9732f751a8"
/>
<img width="1470" height="776" alt="Screenshot 2026-01-20 at 11 58
58 AM"
src="https://github.com/user-attachments/assets/3aa026af-9a42-4512-91b8-916f146849a6"
/>
<img width="1470" height="776" alt="Screenshot 2026-01-20 at 12 31
46 PM"
src="https://github.com/user-attachments/assets/5e1d271b-bf32-4335-8eb1-bcdf84aba8ce"
/>
<img width="1470" height="776" alt="Screenshot 2026-01-20 at 11 57
58 AM"
src="https://github.com/user-attachments/assets/7cbd5ea6-bcb6-4a35-a003-ea0add936925"
/>
<img width="1470" height="776" alt="Screenshot 2026-01-20 at 11 57
39 AM"
src="https://github.com/user-attachments/assets/4309b3e3-2fe6-48dd-8e0c-55036e567461"
/>



## 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:

mitchfz
2026-01-24 20:55:58 -08:00

171 lines
5.5 KiB
TypeScript

vi.mock("lit", () => ({
html: (strings: TemplateStringsArray, ...values: unknown[]) => ({
strings,
values,
}),
LitElement: class extends EventTarget {
requestUpdate() {}
},
}));
vi.mock("lit/decorators.js", () => ({
customElement: () => (clazz: unknown) => clazz,
state: () => () => {},
property: () => () => {},
query: () => () => {},
}));
vi.mock("../../../../src/client/Utils", () => ({
translateText: vi.fn((key: string) => key),
renderDuration: vi.fn(),
renderNumber: vi.fn(),
renderTroops: vi.fn(),
}));
vi.mock("../../../../src/client/components/ui/ActionButton", () => ({
actionButton: vi.fn((props: unknown) => props),
}));
import { actionButton } from "../../../../src/client/components/ui/ActionButton";
import { PlayerModerationModal } from "../../../../src/client/graphics/layers/PlayerModerationModal";
import { PlayerPanel } from "../../../../src/client/graphics/layers/PlayerPanel";
import { SendKickPlayerIntentEvent } from "../../../../src/client/Transport";
import { PlayerType } from "../../../../src/core/game/Game";
import { PlayerView } from "../../../../src/core/game/GameView";
describe("PlayerPanel - kick player moderation", () => {
let panel: PlayerPanel;
const originalConfirm = globalThis.confirm;
beforeEach(() => {
panel = new PlayerPanel();
(panel as any).requestUpdate = vi.fn();
(panel as any).isVisible = true;
});
afterEach(() => {
vi.clearAllMocks();
globalThis.confirm = originalConfirm;
});
test("renders moderation action only when allowed or already kicked", () => {
const my = { isLobbyCreator: () => true } as unknown as PlayerView;
const other = {
id: () => 2,
name: () => "Other",
type: () => PlayerType.Human,
clientID: () => "client-2",
} as unknown as PlayerView;
(actionButton as unknown as ReturnType<typeof vi.fn>).mockClear();
(panel as any).renderModeration(my, other);
expect(actionButton).toHaveBeenCalledTimes(1);
expect(
(actionButton as unknown as ReturnType<typeof vi.fn>).mock.calls[0][0],
).toMatchObject({
label: "player_panel.moderation",
title: "player_panel.moderation",
type: "red",
});
(actionButton as unknown as ReturnType<typeof vi.fn>).mockClear();
(panel as any).kickedPlayerIDs.add("2");
(panel as any).renderModeration(my, other);
expect(actionButton).toHaveBeenCalledTimes(1);
const notCreator = { isLobbyCreator: () => false } as unknown as PlayerView;
(actionButton as unknown as ReturnType<typeof vi.fn>).mockClear();
(panel as any).kickedPlayerIDs.clear();
(panel as any).renderModeration(notCreator, other);
expect(actionButton).not.toHaveBeenCalled();
});
test("opens moderation modal and hides after a kick", () => {
const other = {
id: () => 2,
name: () => "Other",
type: () => PlayerType.Human,
clientID: () => "client-2",
} as unknown as PlayerView;
(panel as any).openModeration({ stopPropagation: vi.fn() }, other);
expect((panel as any).moderationTarget).toBe(other);
expect((panel as any).suppressNextHide).toBe(true);
(panel as any).handleModerationKicked(
new CustomEvent("kicked", { detail: { playerId: "2" } }),
);
expect((panel as any).kickedPlayerIDs.has("2")).toBe(true);
expect((panel as any).moderationTarget).toBe(null);
expect((panel as any).isVisible).toBe(false);
});
});
describe("PlayerModerationModal - kick confirmation", () => {
const originalConfirm = globalThis.confirm;
afterEach(() => {
vi.clearAllMocks();
globalThis.confirm = originalConfirm;
});
test("emits SendKickPlayerIntentEvent and dispatches kicked when confirmed", () => {
(globalThis as any).confirm = vi.fn(() => true);
const modal = new PlayerModerationModal();
const eventBus = { emit: vi.fn() };
const my = { isLobbyCreator: () => true } as unknown as PlayerView;
const other = {
id: () => 2,
name: () => "Other",
type: () => PlayerType.Human,
clientID: () => "client-2",
} as unknown as PlayerView;
modal.eventBus = eventBus as any;
modal.myPlayer = my;
modal.target = other;
const kickedListener = vi.fn();
modal.addEventListener("kicked", kickedListener as any);
(modal as any).handleKickClick({ stopPropagation: vi.fn() });
expect(eventBus.emit).toHaveBeenCalledTimes(1);
const event = eventBus.emit.mock.calls[0][0] as SendKickPlayerIntentEvent;
expect(event).toBeInstanceOf(SendKickPlayerIntentEvent);
expect(event.target).toBe("client-2");
expect(kickedListener).toHaveBeenCalledTimes(1);
const kickedEvent = kickedListener.mock.calls[0][0] as CustomEvent;
expect(kickedEvent.detail).toEqual({ playerId: "2" });
});
test("does not emit when confirmation is cancelled", () => {
(globalThis as any).confirm = vi.fn(() => false);
const modal = new PlayerModerationModal();
const eventBus = { emit: vi.fn() };
const my = { isLobbyCreator: () => true } as unknown as PlayerView;
const other = {
id: () => 2,
name: () => "Other",
type: () => PlayerType.Human,
clientID: () => "client-2",
} as unknown as PlayerView;
modal.eventBus = eventBus as any;
modal.myPlayer = my;
modal.target = other;
const kickedListener = vi.fn();
modal.addEventListener("kicked", kickedListener as any);
(modal as any).handleKickClick({ stopPropagation: vi.fn() });
expect(eventBus.emit).not.toHaveBeenCalled();
expect(kickedListener).not.toHaveBeenCalled();
});
});