import { act, fireEvent, screen } from '@testing-library/react'; import { create } from '@bufbuild/protobuf'; import { renderWithProviders, makeStoreState, makeUser, connectedWithRoomsState, } from '../../../__test-utils__'; import { App, Data } from '@app/types'; import { GameTypes } from '@app/store'; import GameSelector from './GameSelector'; const { mockUseWebClient, mockNavigate } = vi.hoisted(() => ({ mockUseWebClient: vi.fn(), mockNavigate: vi.fn(), })); vi.mock('@app/hooks', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useWebClient: mockUseWebClient }; }); vi.mock('react-router-dom', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useNavigate: () => mockNavigate }; }); function makeRoomEntry(games: Data.ServerInfo_Game[] = [], gametypeMap: Record = {}) { return { info: create(Data.ServerInfo_RoomSchema, { roomId: 1, name: 'Main' }), gametypeMap, order: 0, games: Object.fromEntries(games.map((info) => [info.gameId, { info, gameType: '' }])), users: {}, }; } function makeGame(overrides: any = {}): Data.ServerInfo_Game { return create(Data.ServerInfo_GameSchema, { gameId: 1, roomId: 1, description: 'Test', maxPlayers: 4, playerCount: 1, spectatorsAllowed: true, ...overrides, }); } function makeWebClient() { return { request: { rooms: { joinRoom: vi.fn(), leaveRoom: vi.fn(), roomSay: vi.fn(), createGame: vi.fn(), joinGame: vi.fn(), }, }, } as any; } function buildState( room: ReturnType, user = makeUser(), selectedGameId?: number, roomsOverrides: Partial<{ joinGamePending: boolean; joinGameError: { code: number; message: string } | null }> = {}, ) { return makeStoreState({ ...connectedWithRoomsState, rooms: { rooms: { 1: room }, joinedRoomIds: { 1: true }, joinedGameIds: {}, messages: { 1: [] }, sortGamesBy: { field: App.GameSortField.START_TIME, order: App.SortDirection.DESC }, sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC }, selectedGameIds: selectedGameId != null ? { 1: selectedGameId } : {}, gameFilters: {}, joinGamePending: false, joinGameError: null, ...roomsOverrides, } as any, server: { ...(connectedWithRoomsState.server as any), user, } as any, }); } beforeEach(() => { mockUseWebClient.mockReset(); mockNavigate.mockReset(); }); describe('GameSelector', () => { it('renders the count header from getRoomGameCounts', () => { mockUseWebClient.mockReturnValue(makeWebClient()); const room = makeRoomEntry([makeGame({ gameId: 1 }), makeGame({ gameId: 2 })]); renderWithProviders(, { preloadedState: buildState(room) }); expect(screen.getByText('Games shown: 2 / 2')).toBeInTheDocument(); }); it('Join button is disabled until a game is selected', () => { mockUseWebClient.mockReturnValue(makeWebClient()); const room = makeRoomEntry([makeGame({ gameId: 1 })]); renderWithProviders(, { preloadedState: buildState(room) }); expect(screen.getByRole('button', { name: /^Join$/ })).toBeDisabled(); }); it('Join button enabled and dispatches joinGame when a game is selected', () => { const client = makeWebClient(); mockUseWebClient.mockReturnValue(client); const game = makeGame({ gameId: 7, withPassword: false }); const room = makeRoomEntry([game]); renderWithProviders(, { preloadedState: buildState(room, makeUser(), 7), }); const joinBtn = screen.getByRole('button', { name: /^Join$/ }); expect(joinBtn).not.toBeDisabled(); fireEvent.click(joinBtn); expect(client.request.rooms.joinGame).toHaveBeenCalledTimes(1); expect(client.request.rooms.joinGame).toHaveBeenCalledWith(1, expect.objectContaining({ gameId: 7, spectator: false, joinAsJudge: false, password: '', })); }); it('clicking Join on a password-protected game opens the password prompt before sending', () => { const client = makeWebClient(); mockUseWebClient.mockReturnValue(client); const game = makeGame({ gameId: 8, withPassword: true }); const room = makeRoomEntry([game]); renderWithProviders(, { preloadedState: buildState(room, makeUser(), 8), }); fireEvent.click(screen.getByRole('button', { name: /^Join$/ })); expect(client.request.rooms.joinGame).not.toHaveBeenCalled(); expect(screen.getByText('Password required')).toBeInTheDocument(); fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'hunter2' } }); // The dialog has its own "Join" button — find both and click the last (in dialog). const joinButtons = screen.getAllByRole('button', { name: /^Join$/ }); fireEvent.click(joinButtons[joinButtons.length - 1]); expect(client.request.rooms.joinGame).toHaveBeenCalledTimes(1); expect(client.request.rooms.joinGame).toHaveBeenCalledWith(1, expect.objectContaining({ gameId: 8, password: 'hunter2', })); }); it('Spectate button is disabled when spectators are not allowed', () => { mockUseWebClient.mockReturnValue(makeWebClient()); const game = makeGame({ gameId: 1, spectatorsAllowed: false }); const room = makeRoomEntry([game]); renderWithProviders(, { preloadedState: buildState(room, makeUser(), 1), }); expect(screen.getByRole('button', { name: /Join as Spectator/i })).toBeDisabled(); }); it('Join is disabled when the selected game is full', () => { mockUseWebClient.mockReturnValue(makeWebClient()); const game = makeGame({ gameId: 1, playerCount: 4, maxPlayers: 4 }); const room = makeRoomEntry([game]); renderWithProviders(, { preloadedState: buildState(room, makeUser(), 1), }); expect(screen.getByRole('button', { name: /^Join$/ })).toBeDisabled(); }); it('judge buttons are hidden when the user is not a judge', () => { mockUseWebClient.mockReturnValue(makeWebClient()); const room = makeRoomEntry([]); renderWithProviders(, { preloadedState: buildState(room, makeUser({ userLevel: 0 })), }); expect(screen.queryByRole('button', { name: /Join as Judge$/i })).not.toBeInTheDocument(); }); it('judge buttons are visible when the user has the IsJudge flag', () => { mockUseWebClient.mockReturnValue(makeWebClient()); const room = makeRoomEntry([]); renderWithProviders(, { preloadedState: buildState(room, makeUser({ userLevel: Data.ServerInfo_User_UserLevelFlag.IsJudge })), }); expect(screen.getByRole('button', { name: /Join as Judge$/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Join as Judge Spectator/i })).toBeInTheDocument(); }); it('renders AlertDialog with the join error message from state', () => { mockUseWebClient.mockReturnValue(makeWebClient()); const room = makeRoomEntry([]); renderWithProviders(, { preloadedState: buildState(room, makeUser(), undefined, { joinGameError: { code: 10, message: 'The game is already full.' }, }), }); expect(screen.getByText('Error')).toBeInTheDocument(); expect(screen.getByText('The game is already full.')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /^ok$/i })).toBeInTheDocument(); }); it('does not render AlertDialog when joinGameError is null (covers silent RespContextError)', () => { mockUseWebClient.mockReturnValue(makeWebClient()); const room = makeRoomEntry([]); renderWithProviders(, { preloadedState: buildState(room, makeUser(), undefined, { joinGameError: null }), }); // Only the CreateGame / FilterGames / PromptDialog / AlertDialog dialogs might exist; none // should be open, so no role="dialog" in the DOM. expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('clicking Join on a game already present in games.games navigates to /game without sending a command', () => { const client = makeWebClient(); mockUseWebClient.mockReturnValue(client); const game = makeGame({ gameId: 7, withPassword: false }); const room = makeRoomEntry([game]); const state = buildState(room, makeUser(), 7); (state as any).games = { games: { 7: { info: { gameId: 7 } } } }; renderWithProviders(, { preloadedState: state }); fireEvent.click(screen.getByRole('button', { name: /^Join$/ })); expect(client.request.rooms.joinGame).not.toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalledWith(App.RouteEnum.GAME); }); it('dispatching GAME_JOINED navigates to /game (mirrors JOIN_ROOM → /room)', async () => { mockUseWebClient.mockReturnValue(makeWebClient()); const room = makeRoomEntry([]); const { store } = renderWithProviders(, { preloadedState: buildState(room), }); mockNavigate.mockClear(); await act(async () => { store.dispatch({ type: GameTypes.GAME_JOINED, payload: { data: { gameInfo: { gameId: 42 }, hostId: 0, playerId: 0, spectator: false } }, }); }); expect(mockNavigate).toHaveBeenCalledWith(App.RouteEnum.GAME); }); it('Join button is disabled while joinGamePending is true even when a game is selected', () => { mockUseWebClient.mockReturnValue(makeWebClient()); const game = makeGame({ gameId: 7 }); const room = makeRoomEntry([game]); renderWithProviders(, { preloadedState: buildState(room, makeUser(), 7, { joinGamePending: true }), }); expect(screen.getByRole('button', { name: /^Join$/ })).toBeDisabled(); expect(screen.getByRole('button', { name: /Join as Spectator/i })).toBeDisabled(); }); it('clicking Create then submitting forwards createGame', () => { const client = makeWebClient(); mockUseWebClient.mockReturnValue(client); const room = makeRoomEntry([]); renderWithProviders(, { preloadedState: buildState(room) }); fireEvent.click(screen.getByRole('button', { name: /^Create$/ })); // The dialog Submit button is the second matching "Create" button const createButtons = screen.getAllByRole('button', { name: /^Create$/ }); fireEvent.click(createButtons[createButtons.length - 1]); expect(client.request.rooms.createGame).toHaveBeenCalledTimes(1); expect(client.request.rooms.createGame.mock.calls[0][0]).toBe(1); }); });