mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -07:00
join game error dialog
This commit is contained in:
parent
db1530c9e9
commit
2aeb1542b1
20 changed files with 415 additions and 18 deletions
|
|
@ -65,6 +65,8 @@ export const disconnectedState: Partial<RootState> = {
|
|||
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 },
|
||||
|
|
|
|||
|
|
@ -48,4 +48,12 @@ export class RoomResponseImpl implements WebsocketTypes.IRoomResponse<WebsocketT
|
|||
joinedGame(roomId: number, gameId: number): void {
|
||||
RoomsDispatch.joinedGame(roomId, gameId);
|
||||
}
|
||||
|
||||
setJoinGamePending(pending: boolean): void {
|
||||
RoomsDispatch.setJoinGamePending(pending);
|
||||
}
|
||||
|
||||
setJoinGameError(code: number, message: string): void {
|
||||
RoomsDispatch.setJoinGameError(code, message);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ function buildState(
|
|||
room: ReturnType<typeof makeRoomEntry>,
|
||||
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(<GameSelector room={room as any} />, {
|
||||
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(<GameSelector room={room as any} />, {
|
||||
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(<GameSelector room={room as any} />, {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<PendingJoin | null>(null);
|
||||
const [pendingPasswordJoin, setPendingPasswordJoin] = useState<PendingPasswordJoin | null>(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}
|
||||
/>
|
||||
<PromptDialog
|
||||
isOpen={pendingJoin !== null}
|
||||
isOpen={pendingPasswordJoin !== null}
|
||||
title="Password required"
|
||||
label="Password"
|
||||
submitLabel="Join"
|
||||
onSubmit={handlePasswordSubmit}
|
||||
onCancel={() => setPendingJoin(null)}
|
||||
onCancel={() => setPendingPasswordJoin(null)}
|
||||
/>
|
||||
<AlertDialog
|
||||
isOpen={joinError !== null}
|
||||
title="Error"
|
||||
message={joinError?.message ?? ''}
|
||||
onDismiss={() => RoomsDispatch.clearJoinGameError()}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
|
|
|
|||
3
webclient/src/dialogs/AlertDialog/AlertDialog.css
Normal file
3
webclient/src/dialogs/AlertDialog/AlertDialog.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.alert-dialog__body {
|
||||
min-width: 320px;
|
||||
}
|
||||
76
webclient/src/dialogs/AlertDialog/AlertDialog.spec.tsx
Normal file
76
webclient/src/dialogs/AlertDialog/AlertDialog.spec.tsx
Normal file
|
|
@ -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(
|
||||
<AlertDialog
|
||||
isOpen
|
||||
title="Error"
|
||||
message="The game is already full."
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AlertDialog
|
||||
isOpen
|
||||
title="T"
|
||||
message="M"
|
||||
buttonLabel="Dismiss"
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fires onDismiss when the OK button is clicked', () => {
|
||||
const onDismiss = vi.fn();
|
||||
renderWithProviders(
|
||||
<AlertDialog
|
||||
isOpen
|
||||
title="T"
|
||||
message="M"
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^ok$/i }));
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fires onDismiss on Escape key', () => {
|
||||
const onDismiss = vi.fn();
|
||||
renderWithProviders(
|
||||
<AlertDialog
|
||||
isOpen
|
||||
title="T"
|
||||
message="M"
|
||||
onDismiss={onDismiss}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape', code: 'Escape' });
|
||||
expect(onDismiss).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
renderWithProviders(
|
||||
<AlertDialog
|
||||
isOpen={false}
|
||||
title="T"
|
||||
message="M"
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
80
webclient/src/dialogs/AlertDialog/AlertDialog.tsx
Normal file
80
webclient/src/dialogs/AlertDialog/AlertDialog.tsx
Normal file
|
|
@ -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 (
|
||||
<StyledDialog
|
||||
className={'AlertDialog ' + classes.root}
|
||||
open={isOpen}
|
||||
onClose={onDismiss}
|
||||
maxWidth={false}
|
||||
>
|
||||
<DialogTitle className="dialog-title">
|
||||
<div className="dialog-title__wrapper">
|
||||
<Typography variant="h2">{title}</Typography>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogContent className="dialog-content alert-dialog__body">
|
||||
<DialogContentText>{message}</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
type="button"
|
||||
variant="contained"
|
||||
color={severity === 'error' ? 'error' : 'primary'}
|
||||
onClick={onDismiss}
|
||||
autoFocus
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</StyledDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default AlertDialog;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ export function makeRoomsState(overrides: Partial<RoomsState> = {}): RoomsState
|
|||
},
|
||||
selectedGameIds: {},
|
||||
gameFilters: {},
|
||||
joinGamePending: false,
|
||||
joinGameError: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -89,6 +89,8 @@ const room = {
|
|||
removeMessages: vi.fn(),
|
||||
gameCreated: vi.fn(),
|
||||
joinedGame: vi.fn(),
|
||||
setJoinGamePending: vi.fn(),
|
||||
setJoinGameError: vi.fn(),
|
||||
};
|
||||
|
||||
const game = {
|
||||
|
|
|
|||
|
|
@ -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<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.',
|
||||
};
|
||||
|
||||
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),
|
||||
},
|
||||
});
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -120,6 +120,8 @@ export interface IRoomResponse<T extends RoomEventMap = WebSocketRoomResponseOve
|
|||
removeMessages(roomId: number, name: string, amount: number): void;
|
||||
gameCreated(roomId: number): void;
|
||||
joinedGame(roomId: number, gameId: number): void;
|
||||
setJoinGamePending(pending: boolean): void;
|
||||
setJoinGameError(code: number, message: string): void;
|
||||
}
|
||||
|
||||
export interface IGameResponse {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue