Skip to content

Commit 3fed558

Browse files
authored
Merge pull request #2329 from hexlet-codebattle/agent/fix-open-issues-batch
Fix reported gameplay and account issues
2 parents edc946e + 31070bd commit 3fed558

103 files changed

Lines changed: 2085 additions & 335 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ For frontend-only tasks in `apps/codebattle/`:
4040
- Naming: descriptive Elixir modules; `camelCase`/`PascalCase` for JS files and components.
4141

4242
## Testing Guidelines
43+
- Use `make test` as the canonical final repository test command; do not substitute a direct `mix test` run.
44+
- Run `mix credo --strict` and `mix dialyzer` as part of the final repository verification.
4345
- ExUnit tests live in `apps/*/test/`; coverage uses ExCoveralls with a 60% threshold.
4446
- Frontend tests use Jest in `apps/codebattle/`.
4547
- Name tests after the module/component under test (e.g., `user_stats_test.exs`, `UserStats.test.jsx`).
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import machines from '../widgets/machines';
2+
import { findCurrentUserPlayingGame } from '../widgets/middlewares/Lobby';
3+
4+
describe('game recovery flows', () => {
5+
test('opens and closes a loaded replay on the first event', () => {
6+
const machine = machines.game.withContext({
7+
...machines.game.context,
8+
subscriptionType: 'premium',
9+
});
10+
11+
const loading = machine.transition(machine.initialState, 'START_LOADING_PLAYBOOK');
12+
expect(loading.matches({ replayer: 'loading' })).toBe(true);
13+
14+
const opened = machine.transition(loading, { type: 'LOAD_PLAYBOOK', payload: {} });
15+
expect(opened.matches({ replayer: 'on' })).toBe(true);
16+
17+
const closed = machine.transition(opened, 'CLOSE_REPLAYER');
18+
expect(closed.matches({ replayer: 'off' })).toBe(true);
19+
});
20+
21+
test('recovers a playing game from a lobby channel snapshot', () => {
22+
const games = [
23+
{ id: 10, state: 'waiting_opponent', players: [{ id: 7 }] },
24+
{ id: 11, state: 'playing', players: [{ id: 7 }, { id: 8 }] },
25+
];
26+
27+
expect(findCurrentUserPlayingGame(games, 7)?.id).toBe(11);
28+
expect(findCurrentUserPlayingGame(games, 9)).toBeUndefined();
29+
});
30+
});

apps/codebattle/assets/js/__tests__/UserSettings.test.tsx

Lines changed: 136 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ vi.mock('@fortawesome/react-fontawesome', () => ({
1313
}));
1414

1515
vi.mock('calcite-react/Slider', () => ({ default: 'input' }));
16-
vi.mock('../widgets/components/LanguageIcon', () => ({ default: () => null }));
16+
vi.mock('../widgets/components/LanguageIcon', () => ({
17+
default: ({ lang }: { lang?: string }) => <span data-testid={`language-icon-${lang}`} />,
18+
}));
1719
vi.mock('react-bootstrap/Alert', () => ({
1820
__esModule: true,
1921
default: ({
@@ -37,7 +39,12 @@ vi.mock('../i18n', () => ({
3739
getLocale: vi.fn(() => 'en'),
3840
getSupportedLocale: vi.fn((locale) => (['en', 'ru'].includes(locale) ? locale : 'en')),
3941
default: {
40-
t: vi.fn((key) => key),
42+
t: vi.fn((key: string, params: Record<string, unknown> = {}) =>
43+
Object.entries(params).reduce(
44+
(result, [name, value]) => result.replace(`%{${name}}`, String(value)),
45+
key,
46+
),
47+
),
4148
changeLanguage: vi.fn(() => Promise.resolve()),
4249
},
4350
}));
@@ -72,6 +79,24 @@ vi.mock('@/inertia/pageProps', () => {
7279
local: 'en',
7380
current_user: { sound_settings: {} },
7481
game_id: 10,
82+
user_sessions: [
83+
{
84+
id: 'current-session',
85+
current: true,
86+
user_agent: 'Current browser',
87+
ip: '127.0.0.1',
88+
last_seen_at: '2026-07-23T12:00:00Z',
89+
created_at: '2026-07-23T11:00:00Z',
90+
},
91+
{
92+
id: 'other-session',
93+
current: false,
94+
user_agent: 'Other browser',
95+
ip: '10.0.0.2',
96+
last_seen_at: '2026-07-22T12:00:00Z',
97+
created_at: '2026-07-22T11:00:00Z',
98+
},
99+
],
75100
};
76101
return {
77102
getPageProp: (key: keyof typeof pageProps, fallback?: unknown) => pageProps[key] ?? fallback,
@@ -107,7 +132,7 @@ describe('UserSettings test cases', () => {
107132
ok: true,
108133
json: async () => ({}),
109134
});
110-
const { getByRole, getByLabelText, getByTestId, user } = setup(
135+
const { getByRole, getByLabelText, getByTestId, findByText, user } = setup(
111136
<Provider store={store}>
112137
<UserSettings />
113138
</Provider>,
@@ -118,7 +143,8 @@ describe('UserSettings test cases', () => {
118143

119144
await user.clear(nameInput);
120145
await user.type(nameInput, 'Dmitry');
121-
await user.selectOptions(codeLangSelect, 'Javascript');
146+
await user.click(codeLangSelect);
147+
await user.click(await findByText('Javascript'));
122148
await user.click(submitButton);
123149

124150
await waitFor(() => {
@@ -228,6 +254,112 @@ describe('UserSettings test cases', () => {
228254
expect(await findByRole('alert')).toHaveClass('alert-danger');
229255
});
230256

257+
test('enables saving the Silent sound option', async () => {
258+
fetchMock.mockResolvedValueOnce({
259+
ok: true,
260+
json: async () => ({ locale: 'en' }),
261+
});
262+
263+
const { getByLabelText, user } = setup(
264+
<Provider store={store}>
265+
<UserSettings />
266+
</Provider>,
267+
);
268+
269+
const submitButton = getByLabelText('SubmitForm');
270+
await user.click(getByLabelText('Silent'));
271+
272+
expect(submitButton).toBeEnabled();
273+
await user.click(submitButton);
274+
275+
await waitFor(() => {
276+
const [, requestOptions] = fetchMock.mock.calls[0];
277+
expect(JSON.parse(requestOptions.body).sound_settings.type).toBe('silent');
278+
});
279+
});
280+
281+
test('shows language icons in the settings language menu', async () => {
282+
const { getByTestId, getAllByTestId, user } = setup(
283+
<Provider store={store}>
284+
<UserSettings />
285+
</Provider>,
286+
);
287+
288+
await user.click(getByTestId('code-langSelect'));
289+
290+
expect(getAllByTestId('language-icon-js').length).toBeGreaterThan(0);
291+
expect(getAllByTestId('language-icon-python').length).toBeGreaterThan(0);
292+
});
293+
294+
test('changes a password without sending password fields to the settings endpoint', async () => {
295+
const passwordStore = configureStore({
296+
reducer,
297+
preloadedState: {
298+
user: {
299+
settings: {
300+
...preloadedState.user.settings,
301+
hasPassword: true,
302+
},
303+
},
304+
} as never,
305+
});
306+
307+
fetchMock
308+
.mockResolvedValueOnce({
309+
ok: true,
310+
json: async () => ({ locale: 'en' }),
311+
})
312+
.mockResolvedValueOnce({
313+
ok: true,
314+
json: async () => ({ status: 'ok', has_password: true }),
315+
});
316+
317+
const { getByLabelText, getByTestId, user } = setup(
318+
<Provider store={passwordStore}>
319+
<UserSettings />
320+
</Provider>,
321+
);
322+
323+
await user.type(getByTestId('currentPasswordInput'), 'old-password-secure!');
324+
await user.type(getByTestId('passwordInput'), 'new-password-secure!');
325+
await user.type(getByTestId('passwordConfirmationInput'), 'new-password-secure!');
326+
await user.click(getByLabelText('SubmitForm'));
327+
328+
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
329+
330+
const settingsBody = JSON.parse(fetchMock.mock.calls[0][1].body);
331+
expect(settingsBody).not.toHaveProperty('current_password');
332+
expect(settingsBody).not.toHaveProperty('password');
333+
expect(fetchMock.mock.calls[1][0]).toBe('/api/v1/settings/password');
334+
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({
335+
current_password: 'old-password-secure!',
336+
password: 'new-password-secure!',
337+
password_confirmation: 'new-password-secure!',
338+
});
339+
});
340+
341+
test('removes another active device', async () => {
342+
fetchMock.mockResolvedValueOnce({
343+
ok: true,
344+
json: async () => ({ status: 'ok', current: false }),
345+
});
346+
347+
const { getByLabelText, queryByText, user } = setup(
348+
<Provider store={store}>
349+
<UserSettings />
350+
</Provider>,
351+
);
352+
353+
expect(queryByText('Other browser')).toBeInTheDocument();
354+
await user.click(getByLabelText('Remove device Other browser'));
355+
356+
await waitFor(() => expect(queryByText('Other browser')).not.toBeInTheDocument());
357+
expect(fetchMock).toHaveBeenCalledWith(
358+
'/api/v1/settings/sessions/other-session',
359+
expect.objectContaining({ method: 'DELETE' }),
360+
);
361+
});
362+
231363
test('unlink button is disabled when it is the last sign-in method', () => {
232364
const customStore = configureStore({
233365
reducer,

apps/codebattle/assets/js/widgets/middlewares/Invite.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ const channel = new Channel('invites');
1111
const camelizeKeysAndDispatch = (dispatch: any, actionCreator: any) => (data: any) =>
1212
dispatch(actionCreator(camelizeKeys(data)));
1313

14-
const getRecipientName = (data: any) => data.invite.recipient.name;
15-
const getCreatorName = (data: any) => data.invite.creator.name;
14+
const getRecipientName = (data: any) => data.invite.recipient?.name || 'Anonymous';
15+
const getCreatorName = (data: any) => data.invite.creator?.name || 'Anonymous';
1616
const getOpponentName = (data: any, userId: number) => {
1717
if (userId === data.invite.creatorId) {
1818
return getRecipientName(data);
@@ -120,8 +120,10 @@ export const acceptInvite = (id: number) => (dispatch: any) =>
120120

121121
dispatch(actions.updateInvite(data));
122122
})
123-
.receive('error', ({ reason }) => {
124-
dispatch(actions.updateInvite({ id, state: 'invalid' } as any));
123+
.receive('error', ({ reason, invite }) => {
124+
dispatch(
125+
actions.updateInvite(invite ? camelizeKeys({ invite }) : ({ id, state: 'invalid' } as any)),
126+
);
125127
throw new Error(reason);
126128
});
127129

apps/codebattle/assets/js/widgets/middlewares/Lobby.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,39 @@ import Channel from './Channel';
1010

1111
const channel = new Channel();
1212

13-
export const fetchState = (currentUserId: number) => (dispatch: any) => {
13+
export const findCurrentUserPlayingGame = (games: any[] = [], currentUserId: number) =>
14+
games.find(
15+
(game: any) =>
16+
game.state === 'playing' &&
17+
some(game.players, ({ id: playerId }: { id: number }) => playerId === currentUserId),
18+
);
19+
20+
export const fetchState = (currentUserId: number, waitingGameId?: number) => (dispatch: any) => {
1421
const channelName = 'lobby';
1522
channel.setupChannel(channelName);
1623

1724
const camelizeKeysAndDispatch = (actionCreator: any) => (data: any) =>
1825
dispatch(actionCreator(camelizeKeys(data)));
1926

20-
channel.join().receive('ok', camelizeKeysAndDispatch(actions.initGameList));
27+
channel.join().receive('ok', (data: any) => {
28+
const normalizedData = camelizeKeys(data);
29+
dispatch(actions.initGameList(normalizedData));
30+
31+
const activeGame = findCurrentUserPlayingGame(normalizedData.activeGames, currentUserId);
32+
if (activeGame?.id === waitingGameId) {
33+
window.location.replace(`/games/${activeGame.id}`);
34+
}
35+
});
2136

2237
channel.onError(() => {
2338
dispatch(actions.updateLobbyChannelState(false));
2439
});
2540

2641
const handleGameUpsert = (data: any) => {
42+
const normalizedData = camelizeKeys(data);
2743
const {
2844
game: { players, id, state: gameState },
29-
} = data;
45+
} = normalizedData;
3046
const currentPlayerId = currentUserId;
3147
const isGameStarted = gameState === 'playing';
3248
const isCurrentUserInGame = some(
@@ -35,9 +51,9 @@ export const fetchState = (currentUserId: number) => (dispatch: any) => {
3551
);
3652

3753
if (isGameStarted && isCurrentUserInGame) {
38-
window.location.href = `/games/${id}`;
54+
window.location.replace(`/games/${id}`);
3955
} else {
40-
dispatch(actions.upsertGameLobby(data));
56+
dispatch(actions.upsertGameLobby(normalizedData));
4157
}
4258
};
4359

apps/codebattle/assets/js/widgets/pages/game/ActionsAfterGame.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@ function ActionsAfterGame() {
1313
const { tournamentId } = useSelector(selectors.gameStatusSelector);
1414
const gameMode = useSelector(selectors.gameModeSelector);
1515
const isOpponentInGame = useSelector(selectors.isOpponentInGameSelector);
16+
const isCurrentUserGuest = useSelector(selectors.currentUserIsGuestSelector);
1617

1718
const isRematchDisabled = !isOpponentInGame;
1819

1920
if (gameMode === GameRoomModes.training) {
21+
if (!isCurrentUserGuest) {
22+
return null;
23+
}
24+
2025
return (
2126
<>
2227
<StartTrainingButton />

apps/codebattle/assets/js/widgets/pages/lobby/LobbyWidget.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ function LobbyWidget() {
167167
);
168168

169169
useEffect(() => {
170-
const channel = lobbyMiddlewares.fetchState(currentUserId ?? 0)(dispatch);
170+
const waitingGameId = activeGame?.state === 'waiting_opponent' ? activeGame.id : undefined;
171+
const channel = lobbyMiddlewares.fetchState(currentUserId ?? 0, waitingGameId)(dispatch);
171172

172173
if (currentOpponent) {
173174
window.history.replaceState({}, document.title, getLobbyUrl());

apps/codebattle/assets/js/widgets/pages/lobby/TaskChoice.tsx

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ const taskTags = getPageProp<string[]>('task_tags', []);
2525
interface Task {
2626
id: number | null;
2727
name?: string;
28+
descriptionEn?: string;
29+
descriptionRu?: string;
2830
level?: string;
2931
origin?: string;
3032
creatorId?: number;
@@ -94,8 +96,22 @@ function TaskSelect({ value, onChange, options }: TaskSelectProps) {
9496
random: { icon: faShuffle, transform: 'down-1' },
9597
};
9698

99+
const getLocalizedDescription = (task: Task) => {
100+
const description =
101+
i18n.language === 'ru'
102+
? task.descriptionRu || task.descriptionEn
103+
: task.descriptionEn || task.descriptionRu;
104+
105+
return description
106+
?.split('\n')
107+
.map((line) => line.replace(/^#+\s*/, '').trim())
108+
.find(Boolean);
109+
};
110+
97111
const renderOptionLabel = (task: Task) => {
98112
const origin = taskOriginToIcon[task.origin ?? 'random'] ?? taskOriginToIcon.random;
113+
const description = getLocalizedDescription(task);
114+
const title = description || task.name;
99115

100116
return (
101117
<div className="d-flex align-items-center">
@@ -110,7 +126,9 @@ function TaskSelect({ value, onChange, options }: TaskSelectProps) {
110126
// eslint-disable-next-line @typescript-eslint/no-explicit-any
111127
<FontAwesomeIcon icon={origin.icon as any} transform={origin.transform} />
112128
)}
113-
<span className="text-truncate ml-1">{task.name}</span>
129+
<span className="text-truncate ml-1" lang={i18n.language}>
130+
{title}
131+
</span>
114132
</div>
115133
);
116134
};
@@ -185,9 +203,13 @@ function TaskSelect({ value, onChange, options }: TaskSelectProps) {
185203
value={value}
186204
onChange={(task) => onChange(task as Task)}
187205
options={options}
188-
getOptionLabel={(task) => renderOptionLabel(task) as unknown as string}
206+
getOptionLabel={(task) => task.name ?? ''}
207+
formatOptionLabel={renderOptionLabel}
189208
getOptionValue={(task) => String(task.id)}
190-
filterOption={createFilter({ stringify: (option) => option.data.name ?? '' })}
209+
filterOption={createFilter({
210+
stringify: (option) =>
211+
[option.data.name, getLocalizedDescription(option.data)].filter(Boolean).join(' '),
212+
})}
191213
/>
192214
);
193215
}

0 commit comments

Comments
 (0)