Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ For frontend-only tasks in `apps/codebattle/`:
- Naming: descriptive Elixir modules; `camelCase`/`PascalCase` for JS files and components.

## Testing Guidelines
- Use `make test` as the canonical final repository test command; do not substitute a direct `mix test` run.
- Run `mix credo --strict` and `mix dialyzer` as part of the final repository verification.
- ExUnit tests live in `apps/*/test/`; coverage uses ExCoveralls with a 60% threshold.
- Frontend tests use Jest in `apps/codebattle/`.
- Name tests after the module/component under test (e.g., `user_stats_test.exs`, `UserStats.test.jsx`).
Expand Down
30 changes: 30 additions & 0 deletions apps/codebattle/assets/js/__tests__/GameRecovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import machines from '../widgets/machines';
import { findCurrentUserPlayingGame } from '../widgets/middlewares/Lobby';

describe('game recovery flows', () => {
test('opens and closes a loaded replay on the first event', () => {
const machine = machines.game.withContext({
...machines.game.context,
subscriptionType: 'premium',
});

const loading = machine.transition(machine.initialState, 'START_LOADING_PLAYBOOK');
expect(loading.matches({ replayer: 'loading' })).toBe(true);

const opened = machine.transition(loading, { type: 'LOAD_PLAYBOOK', payload: {} });
expect(opened.matches({ replayer: 'on' })).toBe(true);

const closed = machine.transition(opened, 'CLOSE_REPLAYER');
expect(closed.matches({ replayer: 'off' })).toBe(true);
});

test('recovers a playing game from a lobby channel snapshot', () => {
const games = [
{ id: 10, state: 'waiting_opponent', players: [{ id: 7 }] },
{ id: 11, state: 'playing', players: [{ id: 7 }, { id: 8 }] },
];

expect(findCurrentUserPlayingGame(games, 7)?.id).toBe(11);
expect(findCurrentUserPlayingGame(games, 9)).toBeUndefined();
});
});
140 changes: 136 additions & 4 deletions apps/codebattle/assets/js/__tests__/UserSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ vi.mock('@fortawesome/react-fontawesome', () => ({
}));

vi.mock('calcite-react/Slider', () => ({ default: 'input' }));
vi.mock('../widgets/components/LanguageIcon', () => ({ default: () => null }));
vi.mock('../widgets/components/LanguageIcon', () => ({
default: ({ lang }: { lang?: string }) => <span data-testid={`language-icon-${lang}`} />,
}));
vi.mock('react-bootstrap/Alert', () => ({
__esModule: true,
default: ({
Expand All @@ -37,7 +39,12 @@ vi.mock('../i18n', () => ({
getLocale: vi.fn(() => 'en'),
getSupportedLocale: vi.fn((locale) => (['en', 'ru'].includes(locale) ? locale : 'en')),
default: {
t: vi.fn((key) => key),
t: vi.fn((key: string, params: Record<string, unknown> = {}) =>
Object.entries(params).reduce(
(result, [name, value]) => result.replace(`%{${name}}`, String(value)),
key,
),
),
changeLanguage: vi.fn(() => Promise.resolve()),
},
}));
Expand Down Expand Up @@ -72,6 +79,24 @@ vi.mock('@/inertia/pageProps', () => {
local: 'en',
current_user: { sound_settings: {} },
game_id: 10,
user_sessions: [
{
id: 'current-session',
current: true,
user_agent: 'Current browser',
ip: '127.0.0.1',
last_seen_at: '2026-07-23T12:00:00Z',
created_at: '2026-07-23T11:00:00Z',
},
{
id: 'other-session',
current: false,
user_agent: 'Other browser',
ip: '10.0.0.2',
last_seen_at: '2026-07-22T12:00:00Z',
created_at: '2026-07-22T11:00:00Z',
},
],
};
return {
getPageProp: (key: keyof typeof pageProps, fallback?: unknown) => pageProps[key] ?? fallback,
Expand Down Expand Up @@ -107,7 +132,7 @@ describe('UserSettings test cases', () => {
ok: true,
json: async () => ({}),
});
const { getByRole, getByLabelText, getByTestId, user } = setup(
const { getByRole, getByLabelText, getByTestId, findByText, user } = setup(
<Provider store={store}>
<UserSettings />
</Provider>,
Expand All @@ -118,7 +143,8 @@ describe('UserSettings test cases', () => {

await user.clear(nameInput);
await user.type(nameInput, 'Dmitry');
await user.selectOptions(codeLangSelect, 'Javascript');
await user.click(codeLangSelect);
await user.click(await findByText('Javascript'));
await user.click(submitButton);

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

test('enables saving the Silent sound option', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ locale: 'en' }),
});

const { getByLabelText, user } = setup(
<Provider store={store}>
<UserSettings />
</Provider>,
);

const submitButton = getByLabelText('SubmitForm');
await user.click(getByLabelText('Silent'));

expect(submitButton).toBeEnabled();
await user.click(submitButton);

await waitFor(() => {
const [, requestOptions] = fetchMock.mock.calls[0];
expect(JSON.parse(requestOptions.body).sound_settings.type).toBe('silent');
});
});

test('shows language icons in the settings language menu', async () => {
const { getByTestId, getAllByTestId, user } = setup(
<Provider store={store}>
<UserSettings />
</Provider>,
);

await user.click(getByTestId('code-langSelect'));

expect(getAllByTestId('language-icon-js').length).toBeGreaterThan(0);
expect(getAllByTestId('language-icon-python').length).toBeGreaterThan(0);
});

test('changes a password without sending password fields to the settings endpoint', async () => {
const passwordStore = configureStore({
reducer,
preloadedState: {
user: {
settings: {
...preloadedState.user.settings,
hasPassword: true,
},
},
} as never,
});

fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => ({ locale: 'en' }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ status: 'ok', has_password: true }),
});

const { getByLabelText, getByTestId, user } = setup(
<Provider store={passwordStore}>
<UserSettings />
</Provider>,
);

await user.type(getByTestId('currentPasswordInput'), 'old-password-secure!');
await user.type(getByTestId('passwordInput'), 'new-password-secure!');
await user.type(getByTestId('passwordConfirmationInput'), 'new-password-secure!');
await user.click(getByLabelText('SubmitForm'));

await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));

const settingsBody = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(settingsBody).not.toHaveProperty('current_password');
expect(settingsBody).not.toHaveProperty('password');
expect(fetchMock.mock.calls[1][0]).toBe('/api/v1/settings/password');
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({
current_password: 'old-password-secure!',
password: 'new-password-secure!',
password_confirmation: 'new-password-secure!',
});
});

test('removes another active device', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ status: 'ok', current: false }),
});

const { getByLabelText, queryByText, user } = setup(
<Provider store={store}>
<UserSettings />
</Provider>,
);

expect(queryByText('Other browser')).toBeInTheDocument();
await user.click(getByLabelText('Remove device Other browser'));

await waitFor(() => expect(queryByText('Other browser')).not.toBeInTheDocument());
expect(fetchMock).toHaveBeenCalledWith(
'/api/v1/settings/sessions/other-session',
expect.objectContaining({ method: 'DELETE' }),
);
});

test('unlink button is disabled when it is the last sign-in method', () => {
const customStore = configureStore({
reducer,
Expand Down
10 changes: 6 additions & 4 deletions apps/codebattle/assets/js/widgets/middlewares/Invite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ const channel = new Channel('invites');
const camelizeKeysAndDispatch = (dispatch: any, actionCreator: any) => (data: any) =>
dispatch(actionCreator(camelizeKeys(data)));

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

dispatch(actions.updateInvite(data));
})
.receive('error', ({ reason }) => {
dispatch(actions.updateInvite({ id, state: 'invalid' } as any));
.receive('error', ({ reason, invite }) => {
dispatch(
actions.updateInvite(invite ? camelizeKeys({ invite }) : ({ id, state: 'invalid' } as any)),
);
throw new Error(reason);
});

Expand Down
26 changes: 21 additions & 5 deletions apps/codebattle/assets/js/widgets/middlewares/Lobby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,39 @@ import Channel from './Channel';

const channel = new Channel();

export const fetchState = (currentUserId: number) => (dispatch: any) => {
export const findCurrentUserPlayingGame = (games: any[] = [], currentUserId: number) =>
games.find(
(game: any) =>
game.state === 'playing' &&
some(game.players, ({ id: playerId }: { id: number }) => playerId === currentUserId),
);

export const fetchState = (currentUserId: number, waitingGameId?: number) => (dispatch: any) => {
const channelName = 'lobby';
channel.setupChannel(channelName);

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

channel.join().receive('ok', camelizeKeysAndDispatch(actions.initGameList));
channel.join().receive('ok', (data: any) => {
const normalizedData = camelizeKeys(data);
dispatch(actions.initGameList(normalizedData));

const activeGame = findCurrentUserPlayingGame(normalizedData.activeGames, currentUserId);
if (activeGame?.id === waitingGameId) {
window.location.replace(`/games/${activeGame.id}`);
}
});

channel.onError(() => {
dispatch(actions.updateLobbyChannelState(false));
});

const handleGameUpsert = (data: any) => {
const normalizedData = camelizeKeys(data);
const {
game: { players, id, state: gameState },
} = data;
} = normalizedData;
const currentPlayerId = currentUserId;
const isGameStarted = gameState === 'playing';
const isCurrentUserInGame = some(
Expand All @@ -35,9 +51,9 @@ export const fetchState = (currentUserId: number) => (dispatch: any) => {
);

if (isGameStarted && isCurrentUserInGame) {
window.location.href = `/games/${id}`;
window.location.replace(`/games/${id}`);
} else {
dispatch(actions.upsertGameLobby(data));
dispatch(actions.upsertGameLobby(normalizedData));
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,15 @@ function ActionsAfterGame() {
const { tournamentId } = useSelector(selectors.gameStatusSelector);
const gameMode = useSelector(selectors.gameModeSelector);
const isOpponentInGame = useSelector(selectors.isOpponentInGameSelector);
const isCurrentUserGuest = useSelector(selectors.currentUserIsGuestSelector);

const isRematchDisabled = !isOpponentInGame;

if (gameMode === GameRoomModes.training) {
if (!isCurrentUserGuest) {
return null;
}

return (
<>
<StartTrainingButton />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ function LobbyWidget() {
);

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

if (currentOpponent) {
window.history.replaceState({}, document.title, getLobbyUrl());
Expand Down
28 changes: 25 additions & 3 deletions apps/codebattle/assets/js/widgets/pages/lobby/TaskChoice.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const taskTags = getPageProp<string[]>('task_tags', []);
interface Task {
id: number | null;
name?: string;
descriptionEn?: string;
descriptionRu?: string;
level?: string;
origin?: string;
creatorId?: number;
Expand Down Expand Up @@ -94,8 +96,22 @@ function TaskSelect({ value, onChange, options }: TaskSelectProps) {
random: { icon: faShuffle, transform: 'down-1' },
};

const getLocalizedDescription = (task: Task) => {
const description =
i18n.language === 'ru'
? task.descriptionRu || task.descriptionEn
: task.descriptionEn || task.descriptionRu;

return description
?.split('\n')
.map((line) => line.replace(/^#+\s*/, '').trim())
.find(Boolean);
};

const renderOptionLabel = (task: Task) => {
const origin = taskOriginToIcon[task.origin ?? 'random'] ?? taskOriginToIcon.random;
const description = getLocalizedDescription(task);
const title = description || task.name;

return (
<div className="d-flex align-items-center">
Expand All @@ -110,7 +126,9 @@ function TaskSelect({ value, onChange, options }: TaskSelectProps) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
<FontAwesomeIcon icon={origin.icon as any} transform={origin.transform} />
)}
<span className="text-truncate ml-1">{task.name}</span>
<span className="text-truncate ml-1" lang={i18n.language}>
{title}
</span>
</div>
);
};
Expand Down Expand Up @@ -185,9 +203,13 @@ function TaskSelect({ value, onChange, options }: TaskSelectProps) {
value={value}
onChange={(task) => onChange(task as Task)}
options={options}
getOptionLabel={(task) => renderOptionLabel(task) as unknown as string}
getOptionLabel={(task) => task.name ?? ''}
formatOptionLabel={renderOptionLabel}
getOptionValue={(task) => String(task.id)}
filterOption={createFilter({ stringify: (option) => option.data.name ?? '' })}
filterOption={createFilter({
stringify: (option) =>
[option.data.name, getLocalizedDescription(option.data)].filter(Boolean).join(' '),
})}
/>
);
}
Expand Down
Loading
Loading