diff --git a/webclient/src/__test-utils__/storeFixtures.ts b/webclient/src/__test-utils__/storeFixtures.ts index 62538b681..e4026c9f0 100644 --- a/webclient/src/__test-utils__/storeFixtures.ts +++ b/webclient/src/__test-utils__/storeFixtures.ts @@ -65,6 +65,8 @@ export const disconnectedState: Partial = { sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC }, selectedGameIds: {}, gameFilters: {}, + joinGamePending: false, + joinGameError: null, }, games: { games: {} }, action: { type: null, payload: null, meta: null, error: false, count: 0 }, diff --git a/webclient/src/api/response/RoomResponseImpl.ts b/webclient/src/api/response/RoomResponseImpl.ts index 38a8ee7d4..409779b78 100644 --- a/webclient/src/api/response/RoomResponseImpl.ts +++ b/webclient/src/api/response/RoomResponseImpl.ts @@ -48,4 +48,12 @@ export class RoomResponseImpl implements WebsocketTypes.IRoomResponse, user = makeUser(), selectedGameId?: number, + roomsOverrides: Partial<{ joinGamePending: boolean; joinGameError: { code: number; message: string } | null }> = {}, ) { return makeStoreState({ ...connectedWithRoomsState, @@ -67,6 +68,9 @@ function buildState( 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), @@ -181,6 +185,41 @@ describe('GameSelector', () => { 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('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); diff --git a/webclient/src/containers/Room/GameSelector/GameSelector.tsx b/webclient/src/containers/Room/GameSelector/GameSelector.tsx index 784ba673c..ac898b96e 100644 --- a/webclient/src/containers/Room/GameSelector/GameSelector.tsx +++ b/webclient/src/containers/Room/GameSelector/GameSelector.tsx @@ -5,7 +5,7 @@ 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 { CreateGameDialog, FilterGamesDialog, PromptDialog } from '@app/dialogs'; +import { AlertDialog, CreateGameDialog, FilterGamesDialog, PromptDialog } from '@app/dialogs'; import OpenGames from '../OpenGames'; import GameSelectorToolbar from './GameSelectorToolbar'; @@ -16,7 +16,7 @@ interface GameSelectorProps { room: Enriched.Room; } -interface PendingJoin { +interface PendingPasswordJoin { gameId: number; asSpectator: boolean; asJudge: boolean; @@ -34,10 +34,12 @@ const GameSelector = ({ room }: GameSelectorProps) => { const isFilterActive = useAppSelector((state) => RoomsSelectors.isGameFilterActive(state, roomId)); const filters = useAppSelector((state) => RoomsSelectors.getGameFilters(state, roomId)); const isJudgeUser = useAppSelector(ServerSelectors.getIsUserJudge); + const joinPending = useAppSelector(RoomsSelectors.getJoinGamePending); + const joinError = useAppSelector(RoomsSelectors.getJoinGameError); const [createOpen, setCreateOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false); - const [pendingJoin, setPendingJoin] = useState(null); + const [pendingPasswordJoin, setPendingPasswordJoin] = useState(null); const sendJoin = useCallback( (gameId: number, asSpectator: boolean, asJudge: boolean, password: string) => { @@ -65,7 +67,7 @@ const GameSelector = ({ room }: GameSelectorProps) => { const needsPassword = info.withPassword && !(effectiveSpectator && !info.spectatorsNeedPassword); if (needsPassword) { - setPendingJoin({ gameId: info.gameId, asSpectator: effectiveSpectator, asJudge }); + setPendingPasswordJoin({ gameId: info.gameId, asSpectator: effectiveSpectator, asJudge }); return; } sendJoin(info.gameId, effectiveSpectator, asJudge, ''); @@ -80,8 +82,9 @@ const GameSelector = ({ room }: GameSelectorProps) => { [beginJoin], ); - const canJoin = Boolean(selectedGame && selectedGame.info.playerCount < selectedGame.info.maxPlayers); - const canSpectate = Boolean(selectedGame && selectedGame.info.spectatorsAllowed); + const canJoin = + Boolean(selectedGame && selectedGame.info.playerCount < selectedGame.info.maxPlayers) && !joinPending; + const canSpectate = Boolean(selectedGame && selectedGame.info.spectatorsAllowed) && !joinPending; const handleCreateSubmit = (params: App.CreateGameParams) => { webClient.request.rooms.createGame(roomId, params); @@ -94,11 +97,11 @@ const GameSelector = ({ room }: GameSelectorProps) => { }; const handlePasswordSubmit = (password: string) => { - if (!pendingJoin) { + if (!pendingPasswordJoin) { return; } - sendJoin(pendingJoin.gameId, pendingJoin.asSpectator, pendingJoin.asJudge, password); - setPendingJoin(null); + sendJoin(pendingPasswordJoin.gameId, pendingPasswordJoin.asSpectator, pendingPasswordJoin.asJudge, password); + setPendingPasswordJoin(null); }; return ( @@ -138,12 +141,18 @@ const GameSelector = ({ room }: GameSelectorProps) => { onSubmit={handleFilterSubmit} /> setPendingJoin(null)} + onCancel={() => setPendingPasswordJoin(null)} + /> + RoomsDispatch.clearJoinGameError()} /> ); diff --git a/webclient/src/dialogs/AlertDialog/AlertDialog.css b/webclient/src/dialogs/AlertDialog/AlertDialog.css new file mode 100644 index 000000000..96f2b704e --- /dev/null +++ b/webclient/src/dialogs/AlertDialog/AlertDialog.css @@ -0,0 +1,3 @@ +.alert-dialog__body { + min-width: 320px; +} diff --git a/webclient/src/dialogs/AlertDialog/AlertDialog.spec.tsx b/webclient/src/dialogs/AlertDialog/AlertDialog.spec.tsx new file mode 100644 index 000000000..d5a98f05d --- /dev/null +++ b/webclient/src/dialogs/AlertDialog/AlertDialog.spec.tsx @@ -0,0 +1,76 @@ +import { screen, fireEvent } from '@testing-library/react'; + +import { renderWithProviders } from '../../__test-utils__'; +import AlertDialog from './AlertDialog'; + +describe('AlertDialog', () => { + it('renders the title, message, and default OK button', () => { + renderWithProviders( + {}} + />, + ); + + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getByText('The game is already full.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^ok$/i })).toBeInTheDocument(); + }); + + it('uses a custom buttonLabel when provided', () => { + renderWithProviders( + {}} + />, + ); + expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument(); + }); + + it('fires onDismiss when the OK button is clicked', () => { + const onDismiss = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.click(screen.getByRole('button', { name: /^ok$/i })); + expect(onDismiss).toHaveBeenCalled(); + }); + + it('fires onDismiss on Escape key', () => { + const onDismiss = vi.fn(); + renderWithProviders( + , + ); + + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape', code: 'Escape' }); + expect(onDismiss).toHaveBeenCalled(); + }); + + it('does not render when closed', () => { + renderWithProviders( + {}} + />, + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); diff --git a/webclient/src/dialogs/AlertDialog/AlertDialog.tsx b/webclient/src/dialogs/AlertDialog/AlertDialog.tsx new file mode 100644 index 000000000..a0bdf0e0c --- /dev/null +++ b/webclient/src/dialogs/AlertDialog/AlertDialog.tsx @@ -0,0 +1,80 @@ +import { styled } from '@mui/material/styles'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogTitle from '@mui/material/DialogTitle'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; + +import './AlertDialog.css'; + +const PREFIX = 'AlertDialog'; + +const classes = { + root: `${PREFIX}-root`, +}; + +const StyledDialog = styled(Dialog)(({ theme }) => ({ + [`&.${classes.root}`]: { + '& .dialog-title__wrapper': { + borderColor: theme.palette.grey[300], + }, + }, +})); + +export type AlertDialogSeverity = 'error' | 'info'; + +export interface AlertDialogProps { + isOpen: boolean; + title: string; + message: string; + buttonLabel?: string; + severity?: AlertDialogSeverity; + onDismiss: () => void; +} + +/** + * Single-button modal alert. Mirrors desktop's QMessageBox::critical pattern + * (see cockatrice/src/interface/widgets/server/game_selector.cpp:234-260 for + * the join-game error dialogs this was originally built for). + */ +function AlertDialog({ + isOpen, + title, + message, + buttonLabel = 'OK', + severity = 'error', + onDismiss, +}: AlertDialogProps) { + return ( + + +
+ {title} +
+
+ + {message} + + + + +
+ ); +} + +export default AlertDialog; diff --git a/webclient/src/dialogs/index.ts b/webclient/src/dialogs/index.ts index 758aea6bf..181cde568 100644 --- a/webclient/src/dialogs/index.ts +++ b/webclient/src/dialogs/index.ts @@ -1,4 +1,6 @@ export { default as AccountActivationDialog } from './AccountActivationDialog/AccountActivationDialog'; +export { default as AlertDialog } from './AlertDialog/AlertDialog'; +export type { AlertDialogProps, AlertDialogSeverity } from './AlertDialog/AlertDialog'; export { default as CardImportDialog } from './CardImportDialog/CardImportDialog'; export { default as ConfirmDialog } from './ConfirmDialog/ConfirmDialog'; export { default as CreateCounterDialog } from './CreateCounterDialog/CreateCounterDialog'; diff --git a/webclient/src/store/rooms/__mocks__/rooms-fixtures.ts b/webclient/src/store/rooms/__mocks__/rooms-fixtures.ts index 1e791a830..83b47ece6 100644 --- a/webclient/src/store/rooms/__mocks__/rooms-fixtures.ts +++ b/webclient/src/store/rooms/__mocks__/rooms-fixtures.ts @@ -107,6 +107,8 @@ export function makeRoomsState(overrides: Partial = {}): RoomsState }, selectedGameIds: {}, gameFilters: {}, + joinGamePending: false, + joinGameError: null, ...overrides, }; } diff --git a/webclient/src/store/rooms/rooms.dispatch.spec.ts b/webclient/src/store/rooms/rooms.dispatch.spec.ts index 28aa6352c..3f3b3666c 100644 --- a/webclient/src/store/rooms/rooms.dispatch.spec.ts +++ b/webclient/src/store/rooms/rooms.dispatch.spec.ts @@ -109,4 +109,21 @@ describe('Dispatch', () => { Dispatch.clearGameFilters(1); expect(mockDispatch).toHaveBeenCalledWith(Actions.clearGameFilters({ roomId: 1 })); }); + + it('setJoinGamePending dispatches Actions.setJoinGamePending()', () => { + Dispatch.setJoinGamePending(true); + expect(mockDispatch).toHaveBeenCalledWith(Actions.setJoinGamePending({ pending: true })); + }); + + it('setJoinGameError dispatches Actions.setJoinGameError()', () => { + Dispatch.setJoinGameError(10, 'The game is already full.'); + expect(mockDispatch).toHaveBeenCalledWith( + Actions.setJoinGameError({ code: 10, message: 'The game is already full.' }) + ); + }); + + it('clearJoinGameError dispatches Actions.clearJoinGameError()', () => { + Dispatch.clearJoinGameError(); + expect(mockDispatch).toHaveBeenCalledWith(Actions.clearJoinGameError()); + }); }); diff --git a/webclient/src/store/rooms/rooms.dispatch.ts b/webclient/src/store/rooms/rooms.dispatch.ts index 48d7af02b..6288daaf5 100644 --- a/webclient/src/store/rooms/rooms.dispatch.ts +++ b/webclient/src/store/rooms/rooms.dispatch.ts @@ -64,4 +64,16 @@ export const Dispatch = { clearGameFilters: (roomId: number) => { store.dispatch(Actions.clearGameFilters({ roomId })); }, + + setJoinGamePending: (pending: boolean) => { + store.dispatch(Actions.setJoinGamePending({ pending })); + }, + + setJoinGameError: (code: number, message: string) => { + store.dispatch(Actions.setJoinGameError({ code, message })); + }, + + clearJoinGameError: () => { + store.dispatch(Actions.clearJoinGameError()); + }, } diff --git a/webclient/src/store/rooms/rooms.interfaces.ts b/webclient/src/store/rooms/rooms.interfaces.ts index 3b8756fe2..ed98bda16 100644 --- a/webclient/src/store/rooms/rooms.interfaces.ts +++ b/webclient/src/store/rooms/rooms.interfaces.ts @@ -9,6 +9,13 @@ export interface RoomsState { sortUsersBy: RoomsStateSortUsersBy; selectedGameIds: SelectedGameIds; gameFilters: RoomsStateGameFilters; + joinGamePending: boolean; + joinGameError: JoinGameError | null; +} + +export interface JoinGameError { + code: number; + message: string; } export interface RoomsStateRooms { diff --git a/webclient/src/store/rooms/rooms.reducer.spec.ts b/webclient/src/store/rooms/rooms.reducer.spec.ts index 27f050dae..554ce3ac6 100644 --- a/webclient/src/store/rooms/rooms.reducer.spec.ts +++ b/webclient/src/store/rooms/rooms.reducer.spec.ts @@ -401,6 +401,38 @@ describe('LEAVE_ROOM \u2014 selection and filters', () => { }); +describe('JoinGame error state', () => { + it('SET_JOIN_GAME_PENDING toggles joinGamePending', () => { + const state = makeRoomsState({ joinGamePending: false }); + const result = roomsReducer(state, Actions.setJoinGamePending({ pending: true })); + expect(result.joinGamePending).toBe(true); + }); + + it('SET_JOIN_GAME_ERROR sets the error and clears joinGamePending', () => { + const state = makeRoomsState({ joinGamePending: true }); + const result = roomsReducer(state, Actions.setJoinGameError({ code: 10, message: 'The game is already full.' })); + expect(result.joinGameError).toEqual({ code: 10, message: 'The game is already full.' }); + expect(result.joinGamePending).toBe(false); + }); + + it('CLEAR_JOIN_GAME_ERROR nulls the error', () => { + const state = makeRoomsState({ joinGameError: { code: 10, message: 'The game is already full.' } }); + const result = roomsReducer(state, Actions.clearJoinGameError()); + expect(result.joinGameError).toBeNull(); + }); + + it('CLEAR_STORE resets joinGame error state', () => { + const state = makeRoomsState({ + joinGamePending: true, + joinGameError: { code: 12, message: 'Wrong password.' }, + }); + const result = roomsReducer(state, Actions.clearStore()); + expect(result.joinGamePending).toBe(false); + expect(result.joinGameError).toBeNull(); + }); +}); + + describe('SET_GAME_FILTERS / CLEAR_GAME_FILTERS', () => { it('SET_GAME_FILTERS stores filter state for the room', () => { const state = makeRoomsState(); diff --git a/webclient/src/store/rooms/rooms.reducer.ts b/webclient/src/store/rooms/rooms.reducer.ts index b8ecb600c..38a771d31 100644 --- a/webclient/src/store/rooms/rooms.reducer.ts +++ b/webclient/src/store/rooms/rooms.reducer.ts @@ -23,6 +23,8 @@ const initialState: RoomsState = { }, selectedGameIds: {}, gameFilters: {}, + joinGamePending: false, + joinGameError: null, }; export const roomsSlice = createSlice({ @@ -205,6 +207,19 @@ export const roomsSlice = createSlice({ const { roomId } = action.payload; state.gameFilters[roomId] = { ...DEFAULT_GAME_FILTERS }; }, + + setJoinGamePending: (state, action: PayloadAction<{ pending: boolean }>) => { + state.joinGamePending = action.payload.pending; + }, + + setJoinGameError: (state, action: PayloadAction<{ code: number; message: string }>) => { + state.joinGameError = { code: action.payload.code, message: action.payload.message }; + state.joinGamePending = false; + }, + + clearJoinGameError: (state) => { + state.joinGameError = null; + }, }, }); diff --git a/webclient/src/store/rooms/rooms.selectors.ts b/webclient/src/store/rooms/rooms.selectors.ts index 0ef1c1ad9..d8d48b2fe 100644 --- a/webclient/src/store/rooms/rooms.selectors.ts +++ b/webclient/src/store/rooms/rooms.selectors.ts @@ -109,6 +109,9 @@ export const Selectors = { return !isGameFiltersAtDefaults(filters); }, + getJoinGamePending: ({ rooms }: State) => rooms.joinGamePending, + getJoinGameError: ({ rooms }: State) => rooms.joinGameError, + /** * Sorted + filter-applied view of a room's games for display. Filters * mirror desktop GamesProxyModel; buddy/ignore checks read from server. diff --git a/webclient/src/store/rooms/rooms.types.ts b/webclient/src/store/rooms/rooms.types.ts index 7fe5cf740..43c4f1a98 100644 --- a/webclient/src/store/rooms/rooms.types.ts +++ b/webclient/src/store/rooms/rooms.types.ts @@ -18,6 +18,9 @@ export const Types = { SELECT_GAME: a.selectGame.type, SET_GAME_FILTERS: a.setGameFilters.type, CLEAR_GAME_FILTERS: a.clearGameFilters.type, + SET_JOIN_GAME_PENDING: a.setJoinGamePending.type, + SET_JOIN_GAME_ERROR: a.setJoinGameError.type, + CLEAR_JOIN_GAME_ERROR: a.clearJoinGameError.type, } as const; export { MAX_ROOM_MESSAGES } from './rooms.reducer'; diff --git a/webclient/src/websocket/__mocks__/WebClient.ts b/webclient/src/websocket/__mocks__/WebClient.ts index 2485830e0..8bf6e6a71 100644 --- a/webclient/src/websocket/__mocks__/WebClient.ts +++ b/webclient/src/websocket/__mocks__/WebClient.ts @@ -89,6 +89,8 @@ const room = { removeMessages: vi.fn(), gameCreated: vi.fn(), joinedGame: vi.fn(), + setJoinGamePending: vi.fn(), + setJoinGameError: vi.fn(), }; const game = { diff --git a/webclient/src/websocket/commands/room/joinGame.ts b/webclient/src/websocket/commands/room/joinGame.ts index a19721746..d7b4b8d19 100644 --- a/webclient/src/websocket/commands/room/joinGame.ts +++ b/webclient/src/websocket/commands/room/joinGame.ts @@ -1,13 +1,47 @@ import { create } from '@bufbuild/protobuf'; import { WebClient } from '../../WebClient'; -import { Command_JoinGame_ext, Command_JoinGameSchema } from '@app/generated'; +import { Command_JoinGame_ext, Command_JoinGameSchema, Response_ResponseCode } from '@app/generated'; import type { JoinGameParams } from '@app/generated'; +// Desktop message strings from cockatrice/src/interface/widgets/server/game_selector.cpp:234-260 +// (GameSelector::checkResponse). Codes not listed here (e.g. RespContextError) intentionally +// fall through to the desktop's `default:;` — silent to the user. +const ERROR_MESSAGES: Record = { + [Response_ResponseCode.RespNotInRoom]: 'Please join the appropriate room first.', + [Response_ResponseCode.RespNameNotFound]: 'The game does not exist any more.', + [Response_ResponseCode.RespGameFull]: 'The game is already full.', + [Response_ResponseCode.RespWrongPassword]: 'Wrong password.', + [Response_ResponseCode.RespSpectatorsNotAllowed]: 'Spectators are not allowed in this game.', + [Response_ResponseCode.RespOnlyBuddies]: "This game is only open to its creator's buddies.", + [Response_ResponseCode.RespUserLevelTooLow]: 'This game is only open to registered users.', + [Response_ResponseCode.RespInIgnoreList]: 'You are being ignored by the creator of this game.', +}; + export function joinGame(roomId: number, joinGameParams: JoinGameParams): void { - WebClient.instance.protobuf.sendRoomCommand(roomId, Command_JoinGame_ext, create(Command_JoinGameSchema, joinGameParams), { - onSuccess: () => { - WebClient.instance.response.room.joinedGame(roomId, joinGameParams.gameId); + const response = WebClient.instance.response.room; + response.setJoinGamePending(true); + + const onResponseCode: { [code: number]: () => void } = { + // Match desktop default:; — acknowledge silently, no user dialog. + [Response_ResponseCode.RespContextError]: () => response.setJoinGamePending(false), + }; + for (const codeStr of Object.keys(ERROR_MESSAGES)) { + const code = Number(codeStr); + onResponseCode[code] = () => response.setJoinGameError(code, ERROR_MESSAGES[code]); + } + + WebClient.instance.protobuf.sendRoomCommand( + roomId, + Command_JoinGame_ext, + create(Command_JoinGameSchema, joinGameParams), + { + onSuccess: () => { + response.setJoinGamePending(false); + response.joinedGame(roomId, joinGameParams.gameId); + }, + onResponseCode, + onError: () => response.setJoinGamePending(false), }, - }); + ); } diff --git a/webclient/src/websocket/commands/room/roomCommands.spec.ts b/webclient/src/websocket/commands/room/roomCommands.spec.ts index b748030a6..d1e81477f 100644 --- a/webclient/src/websocket/commands/room/roomCommands.spec.ts +++ b/webclient/src/websocket/commands/room/roomCommands.spec.ts @@ -9,6 +9,7 @@ import { Command_JoinGameSchema, Command_LeaveRoom_ext, Command_RoomSay_ext, + Response_ResponseCode, } from '@app/generated'; import { createGame } from './createGame'; @@ -18,7 +19,7 @@ import { roomSay } from './roomSay'; import { create } from '@bufbuild/protobuf'; import { Mock } from 'vitest'; -const { invokeOnSuccess } = makeCallbackHelpers( +const { invokeOnSuccess, invokeResponseCode, invokeOnError } = makeCallbackHelpers( WebClient.instance.protobuf.sendRoomCommand as Mock, // sendRoomCommand(roomId, ext, value, options) — options at index 3 3 @@ -41,6 +42,11 @@ describe('createGame', () => { }); describe('joinGame', () => { + beforeEach(() => { + (WebClient.instance.response.room.joinedGame as Mock).mockClear(); + (WebClient.instance.response.room.setJoinGamePending as Mock).mockClear(); + (WebClient.instance.response.room.setJoinGameError as Mock).mockClear(); + }); it('calls sendRoomCommand with Command_JoinGame', () => { joinGame(7, create(Command_JoinGameSchema, { gameId: 42, password: '' })); @@ -49,11 +55,54 @@ describe('joinGame', () => { ); }); - it('onSuccess calls response.room.joinedGame with roomId and gameId', () => { + it('dispatches setJoinGamePending(true) before sending', () => { + joinGame(7, create(Command_JoinGameSchema, { gameId: 42 })); + expect(WebClient.instance.response.room.setJoinGamePending).toHaveBeenCalledWith(true); + }); + + it('onSuccess clears pending and calls response.room.joinedGame with roomId and gameId', () => { joinGame(7, create(Command_JoinGameSchema, { gameId: 42 })); invokeOnSuccess(); + expect(WebClient.instance.response.room.setJoinGamePending).toHaveBeenLastCalledWith(false); expect(WebClient.instance.response.room.joinedGame).toHaveBeenCalledWith(7, 42); }); + + // Desktop GameSelector::checkResponse — matching message strings from + // cockatrice/src/interface/widgets/server/game_selector.cpp:234-260. + const errorCases: Array<[number, string]> = [ + [Response_ResponseCode.RespNotInRoom, 'Please join the appropriate room first.'], + [Response_ResponseCode.RespNameNotFound, 'The game does not exist any more.'], + [Response_ResponseCode.RespGameFull, 'The game is already full.'], + [Response_ResponseCode.RespWrongPassword, 'Wrong password.'], + [Response_ResponseCode.RespSpectatorsNotAllowed, 'Spectators are not allowed in this game.'], + [Response_ResponseCode.RespOnlyBuddies, "This game is only open to its creator's buddies."], + [Response_ResponseCode.RespUserLevelTooLow, 'This game is only open to registered users.'], + [Response_ResponseCode.RespInIgnoreList, 'You are being ignored by the creator of this game.'], + ]; + + it.each(errorCases)('code %i dispatches setJoinGameError with desktop-matching message', (code, message) => { + joinGame(7, create(Command_JoinGameSchema, { gameId: 42 })); + invokeResponseCode(code); + expect(WebClient.instance.response.room.setJoinGameError).toHaveBeenCalledWith(code, message); + expect(WebClient.instance.response.room.joinedGame).not.toHaveBeenCalled(); + }); + + it('code 11 (RespContextError) is silent — clears pending, no setJoinGameError, no console.error', () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + joinGame(7, create(Command_JoinGameSchema, { gameId: 42 })); + invokeResponseCode(Response_ResponseCode.RespContextError); + expect(WebClient.instance.response.room.setJoinGameError).not.toHaveBeenCalled(); + expect(WebClient.instance.response.room.setJoinGamePending).toHaveBeenLastCalledWith(false); + expect(consoleError).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it('unknown response code goes to onError — clears pending, no setJoinGameError', () => { + joinGame(7, create(Command_JoinGameSchema, { gameId: 42 })); + invokeOnError(99); + expect(WebClient.instance.response.room.setJoinGameError).not.toHaveBeenCalled(); + expect(WebClient.instance.response.room.setJoinGamePending).toHaveBeenLastCalledWith(false); + }); }); describe('leaveRoom', () => { diff --git a/webclient/src/websocket/types/WebClientResponse.ts b/webclient/src/websocket/types/WebClientResponse.ts index 05b74dbc6..a9b57b56c 100644 --- a/webclient/src/websocket/types/WebClientResponse.ts +++ b/webclient/src/websocket/types/WebClientResponse.ts @@ -120,6 +120,8 @@ export interface IRoomResponse