Skip to content

Commit fbfba55

Browse files
committed
fix
1 parent 9a4dced commit fbfba55

12 files changed

Lines changed: 889 additions & 168 deletions

File tree

‎apps/codebattle/assets/css/style.scss‎

Lines changed: 399 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { act, render, screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import React from 'react';
4+
import { createActor } from 'xstate';
5+
6+
import RoomContext from '../widgets/components/RoomContext';
7+
import GameStateCodes from '../widgets/config/gameStateCodes';
8+
import machines from '../widgets/machines';
9+
import ReplayerControlButton from '../widgets/pages/game/ReplayerControlButton';
10+
import SignUpButton from '../widgets/pages/game/SignUpButton';
11+
12+
const { dispatchMock } = vi.hoisted(() => ({
13+
dispatchMock: vi.fn((action) => {
14+
if (typeof action === 'function') {
15+
return action(dispatchMock);
16+
}
17+
18+
return action;
19+
}),
20+
}));
21+
22+
vi.mock('react-redux', () => ({
23+
useDispatch: () => dispatchMock,
24+
}));
25+
26+
vi.mock('../widgets/middlewares/Room', () => ({
27+
downloadPlaybook: (service: ReturnType<typeof createActor>) => () => {
28+
service.send({ type: 'START_LOADING_PLAYBOOK' });
29+
service.send({ type: 'LOAD_PLAYBOOK', payload: {} });
30+
},
31+
openPlaybook: (service: ReturnType<typeof createActor>) => () => {
32+
service.send({ type: 'OPEN_REPLAYER' });
33+
},
34+
}));
35+
36+
describe('game action buttons', () => {
37+
beforeEach(() => {
38+
dispatchMock.mockClear();
39+
});
40+
41+
test('guest sign-up links to the registration page', () => {
42+
render(<SignUpButton />);
43+
44+
expect(screen.getByRole('link', { name: 'Sign up' })).toHaveAttribute('href', '/users/new');
45+
});
46+
47+
test('opens and closes history with one click', async () => {
48+
const user = userEvent.setup();
49+
const mainService = createActor(machines.game, {
50+
input: { subscriptionType: 'premium' },
51+
});
52+
mainService.start();
53+
mainService.send({
54+
type: 'LOAD_GAME',
55+
payload: { state: GameStateCodes.gameOver },
56+
});
57+
58+
render(
59+
<RoomContext.Provider value={{ mainService, taskService: mainService }}>
60+
<ReplayerControlButton />
61+
</RoomContext.Provider>,
62+
);
63+
64+
await user.click(screen.getByRole('button', { name: 'Open Record Player' }));
65+
expect(screen.getByRole('button', { name: 'Close Record Player' })).toBeInTheDocument();
66+
67+
await user.click(screen.getByRole('button', { name: 'Close Record Player' }));
68+
expect(screen.getByRole('button', { name: 'Open Record Player' })).toBeInTheDocument();
69+
70+
await user.click(screen.getByRole('button', { name: 'Open Record Player' }));
71+
expect(screen.getByRole('button', { name: 'Close Record Player' })).toBeInTheDocument();
72+
73+
act(() => mainService.stop());
74+
});
75+
});

‎apps/codebattle/assets/js/__tests__/GameRecovery.test.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import machines from '../widgets/machines';
44
import { findCurrentUserPlayingGame } from '../widgets/middlewares/Lobby';
55

66
describe('game recovery flows', () => {
7-
test('opens and closes a loaded replay on the first event', () => {
7+
test('opens, closes, and reopens a loaded replay on the first event', () => {
88
// xstate v5: seed context via `input` and drive the machine through a running actor.
99
const actor = createActor(machines.game, { input: { subscriptionType: 'premium' } });
1010
actor.start();
@@ -15,6 +15,15 @@ describe('game recovery flows', () => {
1515
actor.send({ type: 'LOAD_PLAYBOOK', payload: {} });
1616
expect(actor.getSnapshot().matches({ replayer: 'on' })).toBe(true);
1717

18+
actor.send({ type: 'SET_SPEED_MODE', speedMode: '2.5x' });
19+
expect(actor.getSnapshot().context.speedMode).toBe('2.5x');
20+
21+
actor.send({ type: 'CLOSE_REPLAYER' });
22+
expect(actor.getSnapshot().matches({ replayer: 'off' })).toBe(true);
23+
24+
actor.send({ type: 'OPEN_REPLAYER' });
25+
expect(actor.getSnapshot().matches({ replayer: 'on' })).toBe(true);
26+
1827
actor.send({ type: 'CLOSE_REPLAYER' });
1928
expect(actor.getSnapshot().matches({ replayer: 'off' })).toBe(true);
2029

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { changePresenceState } from '../widgets/middlewares/Main';
2+
3+
describe('main channel middleware', () => {
4+
test('ignores presence changes when the main channel is not initialized', () => {
5+
expect(() => changePresenceState('watching')()).not.toThrow();
6+
});
7+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { configureStore } from '@reduxjs/toolkit';
2+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import React from 'react';
5+
import { Provider } from 'react-redux';
6+
7+
import playbackModes from '../widgets/config/playbackModes';
8+
import ControlPanel from '../widgets/pages/game/ControlPanel';
9+
10+
const { copyMock } = vi.hoisted(() => ({
11+
copyMock: vi.fn().mockResolvedValue(true),
12+
}));
13+
14+
vi.mock('copy-to-clipboard', () => ({
15+
default: copyMock,
16+
}));
17+
18+
const store = configureStore({
19+
reducer: (state = {}) => state,
20+
});
21+
22+
const makeRoomState = (replayerState = 'on.paused', speedMode = '1x') => ({
23+
context: { speedMode },
24+
matches: ({ replayer }: { replayer: string }) => replayer === replayerState,
25+
});
26+
27+
describe('replay control panel', () => {
28+
beforeEach(() => {
29+
copyMock.mockClear();
30+
});
31+
32+
test('keeps clear playback controls visible while changing their values', async () => {
33+
const user = userEvent.setup();
34+
const onChangeSpeed = vi.fn();
35+
const onChangePlaybackMode = vi.fn();
36+
37+
render(
38+
<Provider store={store}>
39+
<ControlPanel
40+
roomMachineState={makeRoomState()}
41+
onPauseClick={vi.fn()}
42+
onPlayClick={vi.fn()}
43+
onChangeSpeed={onChangeSpeed}
44+
playbackMode={playbackModes.realtime}
45+
onChangePlaybackMode={onChangePlaybackMode}
46+
nextRecordId={12}
47+
currentTime={2_000}
48+
totalDuration={10_000}
49+
>
50+
<div data-testid="timeline" />
51+
</ControlPanel>
52+
</Provider>,
53+
);
54+
55+
expect(screen.getByRole('button', { name: 'Play replay' })).toBeInTheDocument();
56+
expect(screen.getByTestId('timeline')).toBeInTheDocument();
57+
expect(screen.getByLabelText('Playback time')).toHaveTextContent('00:02 / 00:10');
58+
expect(screen.queryByRole('slider', { name: 'Playback speed' })).not.toBeInTheDocument();
59+
expect(screen.queryByRole('button', { name: 'Uniform' })).not.toBeInTheDocument();
60+
61+
await user.click(screen.getByRole('button', { name: 'Replay settings' }));
62+
expect(screen.getByRole('dialog', { name: 'Replay settings' })).toBeInTheDocument();
63+
expect(screen.getByRole('button', { name: /Playback speed/ })).toBeInTheDocument();
64+
expect(screen.getByRole('button', { name: 'Uniform' })).toBeInTheDocument();
65+
66+
await user.click(screen.getByRole('button', { name: /Playback speed/ }));
67+
expect(screen.getByRole('button', { name: 'Back to replay settings' })).toBeInTheDocument();
68+
const speedSlider = screen.getByRole('slider', { name: 'Playback speed' });
69+
expect(speedSlider).toHaveAttribute('min', '0.5');
70+
expect(speedSlider).toHaveAttribute('max', '4');
71+
expect(speedSlider).toHaveAttribute('step', '0.5');
72+
73+
fireEvent.change(speedSlider, { target: { value: '2.5' } });
74+
expect(onChangeSpeed).toHaveBeenCalledWith('2.5x');
75+
76+
await user.click(screen.getByRole('button', { name: 'Set playback speed to 3×' }));
77+
expect(onChangeSpeed).toHaveBeenCalledWith('3x');
78+
79+
await user.click(screen.getByRole('button', { name: 'Increase playback speed' }));
80+
expect(onChangeSpeed).toHaveBeenCalledWith('1.5x');
81+
82+
await user.click(screen.getByRole('button', { name: 'Back to replay settings' }));
83+
await user.click(screen.getByRole('button', { name: 'Uniform' }));
84+
expect(onChangePlaybackMode).toHaveBeenCalledWith(playbackModes.standard);
85+
86+
await user.click(screen.getByRole('button', { name: 'Copy replay link at current position' }));
87+
await waitFor(() => expect(copyMock).toHaveBeenCalledWith('http://localhost/?t=12'));
88+
expect(screen.getByText('Link copied')).toBeInTheDocument();
89+
90+
await user.click(screen.getByRole('button', { name: 'Replay settings' }));
91+
expect(screen.queryByRole('dialog', { name: 'Replay settings' })).not.toBeInTheDocument();
92+
});
93+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
const speedModes = {
2+
slow: '0.5x',
23
normal: '1x',
4+
oneAndHalf: '1.5x',
35
fast: '2x',
6+
twoAndHalf: '2.5x',
7+
triple: '3x',
8+
threeAndHalf: '3.5x',
49
faster: '4x',
510
};
611

12+
export const speedModeValues = Object.values(speedModes);
13+
714
export default speedModes;

‎apps/codebattle/assets/js/widgets/machines/game.ts‎

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { assign, raise } from 'xstate';
22

33
import { channelTopics } from '../../socket';
44
import GameStateCodes from '../config/gameStateCodes';
5-
import speedModes from '../config/speedModes';
5+
import speedModes, { speedModeValues } from '../config/speedModes';
66
import subscriptionTypes from '../config/subscriptionTypes';
77
import tournamentSounds from '../config/tournamentSounds';
88
import sound from '../lib/sound';
@@ -296,6 +296,9 @@ const machine = {
296296
TOGGLE_SPEED_MODE: {
297297
actions: ['toggleSpeedMode'],
298298
},
299+
SET_SPEED_MODE: {
300+
actions: ['setSpeedMode'],
301+
},
299302
},
300303
...recordMachine,
301304
},
@@ -374,18 +377,15 @@ export const config = {
374377
// replayer actions
375378
toggleSpeedMode: assign({
376379
speedMode: ({ context }: any) => {
377-
switch (context.speedMode) {
378-
case speedModes.normal:
379-
return speedModes.fast;
380-
case speedModes.fast:
381-
return speedModes.faster;
382-
case speedModes.faster:
383-
return speedModes.normal;
384-
default:
385-
throw new Error('Unexpected speedMode [replayer machine]');
386-
}
380+
const currentIndex = speedModeValues.indexOf(context.speedMode);
381+
const nextIndex = currentIndex < 0 ? 0 : (currentIndex + 1) % speedModeValues.length;
382+
383+
return speedModeValues[nextIndex];
387384
},
388385
}),
386+
setSpeedMode: assign({
387+
speedMode: ({ event }: any) => event.speedMode,
388+
}),
389389
showPremiumSubscribeRequestModal: () => {},
390390
},
391391
};

‎apps/codebattle/assets/js/widgets/middlewares/Main.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ const initPresence = (followId?: number) => (dispatch: any) => {
166166
};
167167

168168
export const changePresenceState = (state: string) => () => {
169-
channel.push('change_presence_state', { state });
169+
channel?.push('change_presence_state', { state });
170170
};
171171

172172
export const broadcastRedirect =

0 commit comments

Comments
 (0)