diff --git a/webclient/src/containers/Room/GameSelector/GameSelector.spec.tsx b/webclient/src/containers/Room/GameSelector/GameSelector.spec.tsx index ec6f199c4..1d4cb41f8 100644 --- a/webclient/src/containers/Room/GameSelector/GameSelector.spec.tsx +++ b/webclient/src/containers/Room/GameSelector/GameSelector.spec.tsx @@ -7,13 +7,21 @@ import { connectedWithRoomsState, } from '../../../__test-utils__'; import { App, Data } from '@app/types'; +import { GameTypes } from '@app/store'; import GameSelector from './GameSelector'; -const { mockUseWebClient } = vi.hoisted(() => ({ mockUseWebClient: vi.fn() })); +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 { @@ -81,6 +89,7 @@ function buildState( beforeEach(() => { mockUseWebClient.mockReset(); + mockNavigate.mockReset(); }); describe('GameSelector', () => { @@ -209,6 +218,38 @@ describe('GameSelector', () => { 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(); + store.dispatch({ + type: GameTypes.GAME_JOINED, + payload: { data: { gameInfo: { gameId: 42 }, hostId: 0, playerId: 0, spectator: false } }, + }); + + await Promise.resolve(); + 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 }); diff --git a/webclient/src/containers/Room/GameSelector/GameSelector.tsx b/webclient/src/containers/Room/GameSelector/GameSelector.tsx index ac898b96e..39fd26c00 100644 --- a/webclient/src/containers/Room/GameSelector/GameSelector.tsx +++ b/webclient/src/containers/Room/GameSelector/GameSelector.tsx @@ -1,10 +1,18 @@ import React, { useCallback, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; -import { RoomsDispatch, RoomsSelectors, ServerSelectors, useAppSelector } from '@app/store'; -import { useWebClient } from '@app/hooks'; -import type { App, Enriched } from '@app/types'; +import { + GameSelectors, + GameTypes, + RoomsDispatch, + RoomsSelectors, + ServerSelectors, + useAppSelector, +} from '@app/store'; +import { useReduxEffect, useWebClient } from '@app/hooks'; +import { App, type Enriched } from '@app/types'; import { AlertDialog, CreateGameDialog, FilterGamesDialog, PromptDialog } from '@app/dialogs'; import OpenGames from '../OpenGames'; @@ -25,6 +33,7 @@ interface PendingPasswordJoin { const GameSelector = ({ room }: GameSelectorProps) => { const roomId = room.info.roomId; const webClient = useWebClient(); + const navigate = useNavigate(); const selectedGameId = useAppSelector((state) => RoomsSelectors.getSelectedGameId(state, roomId)); const selectedGame = useAppSelector((state) => @@ -36,6 +45,13 @@ const GameSelector = ({ room }: GameSelectorProps) => { const isJudgeUser = useAppSelector(ServerSelectors.getIsUserJudge); const joinPending = useAppSelector(RoomsSelectors.getJoinGamePending); const joinError = useAppSelector(RoomsSelectors.getJoinGameError); + const activeGameIds = useAppSelector(GameSelectors.getActiveGameIds); + + // Mirrors Server.tsx's JOIN_ROOM → navigate(ROOM) pattern: when Event_GameJoined + // lands, we're actually in the game — route to /game. + useReduxEffect(() => { + navigate(App.RouteEnum.GAME); + }, GameTypes.GAME_JOINED, [navigate]); const [createOpen, setCreateOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false); @@ -43,6 +59,14 @@ const GameSelector = ({ room }: GameSelectorProps) => { const sendJoin = useCallback( (gameId: number, asSpectator: boolean, asJudge: boolean, password: string) => { + // Mirrors Rooms.tsx short-circuit: if we already have a live game entry + // (Event_GameJoined has populated games.games[gameId]), skip the duplicate + // JoinGame — the server would reject it with RespContextError — and go + // straight to the game view. + if (activeGameIds.includes(gameId)) { + navigate(App.RouteEnum.GAME); + return; + } const params: App.JoinGameParams = { gameId, password, @@ -52,7 +76,7 @@ const GameSelector = ({ room }: GameSelectorProps) => { }; webClient.request.rooms.joinGame(roomId, params); }, - [roomId, webClient], + [activeGameIds, navigate, roomId, webClient], ); const beginJoin = useCallback(