Skip to content

Commit b2e6103

Browse files
authored
Fix correspondence challenge creation (#2825)
* Fix creating correspondence challenge Fixes #2815 * Add create challenge view tests
1 parent f97a433 commit b2e6103

File tree

2 files changed

+344
-4
lines changed

2 files changed

+344
-4
lines changed

lib/src/view/play/create_challenge_bottom_sheet.dart

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ class _CreateChallengeBottomSheetState extends ConsumerState<CreateChallengeBott
6969
Widget build(BuildContext context) {
7070
final accountAsync = ref.watch(accountProvider);
7171
final preferences = ref.watch(challengePreferencesProvider);
72+
final createGameService = ref.watch(createGameServiceProvider);
7273

7374
final isValidTimeControl =
7475
preferences.timeControl != ChallengeTimeControlType.clock ||
@@ -328,7 +329,6 @@ class _CreateChallengeBottomSheetState extends ConsumerState<CreateChallengeBott
328329
ChallengeTimeControlType.unlimited =>
329330
snapshot.connectionState != ConnectionState.waiting
330331
? () async {
331-
final createGameService = ref.read(createGameServiceProvider);
332332
setState(() {
333333
_pendingCorrespondenceChallenge = createGameService
334334
.newCorrespondenceChallenge(
@@ -394,9 +394,11 @@ class _CreateChallengeBottomSheetState extends ConsumerState<CreateChallengeBott
394394
);
395395
}
396396
} finally {
397-
setState(() {
398-
_pendingCorrespondenceChallenge = null;
399-
});
397+
if (mounted) {
398+
setState(() {
399+
_pendingCorrespondenceChallenge = null;
400+
});
401+
}
400402
}
401403
}
402404
: null,
Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
import 'dart:convert';
2+
3+
import 'package:flutter/material.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:http/testing.dart';
6+
import 'package:lichess_mobile/src/model/challenge/challenge.dart';
7+
import 'package:lichess_mobile/src/model/challenge/challenge_preferences.dart';
8+
import 'package:lichess_mobile/src/model/common/id.dart';
9+
import 'package:lichess_mobile/src/model/settings/preferences_storage.dart';
10+
import 'package:lichess_mobile/src/model/user/user.dart';
11+
import 'package:lichess_mobile/src/network/http.dart';
12+
import 'package:lichess_mobile/src/view/game/game_screen.dart';
13+
import 'package:lichess_mobile/src/view/play/create_challenge_bottom_sheet.dart';
14+
15+
import '../../mock_server_responses.dart';
16+
import '../../model/auth/fake_auth_storage.dart';
17+
import '../../network/fake_http_client_factory.dart';
18+
import '../../network/fake_websocket_channel.dart';
19+
import '../../test_helpers.dart';
20+
import '../../test_provider_scope.dart';
21+
22+
const _testDestUser = LightUser(id: UserId('targetuser'), name: 'TargetUser');
23+
const _correspondenceChallengeId = 'corrId123456';
24+
const _clockChallengeId = 'clockId12345';
25+
26+
void main() {
27+
group('CreateChallengeBottomSheet', () {
28+
group('clock (real-time) challenge', () {
29+
testWidgets('tapping challenge button closes the sheet and pushes GameScreen', (
30+
tester,
31+
) async {
32+
final app = await makeTestProviderScopeApp(
33+
tester,
34+
home: const _TestBottomSheetOpener(_testDestUser),
35+
overrides: {
36+
httpClientFactoryProvider: httpClientFactoryProvider.overrideWith(
37+
(ref) => FakeHttpClientFactory(() => _makeClockMockClient()),
38+
),
39+
},
40+
authUser: fakeAuthUser,
41+
);
42+
43+
await tester.pumpWidget(app);
44+
// let account provider load
45+
await tester.pump(const Duration(milliseconds: 50));
46+
47+
// open the bottom sheet
48+
await tester.tap(find.text('Open'));
49+
await tester.pumpAndSettle();
50+
51+
expect(find.byType(CreateChallengeBottomSheet), findsOneWidget);
52+
53+
// default time control is clock, so the challenge button should be enabled
54+
await tester.tap(find.text('Challenge'));
55+
await tester.pumpAndSettle();
56+
57+
// the bottom sheet should be gone
58+
expect(find.byType(CreateChallengeBottomSheet), findsNothing);
59+
60+
// GameScreen should be pushed (loading state is fine)
61+
expect(find.byType(GameScreen), findsOneWidget);
62+
}, variant: kPlatformVariant);
63+
});
64+
65+
group('correspondence/unlimited challenge', () {
66+
testWidgets(
67+
'creates correspondence challenge, closes the sheet, and shows snackbar',
68+
(tester) async {
69+
final app = await makeTestProviderScopeApp(
70+
tester,
71+
home: const _TestBottomSheetOpener(_testDestUser),
72+
overrides: {
73+
httpClientFactoryProvider: httpClientFactoryProvider.overrideWith(
74+
(ref) => FakeHttpClientFactory(() => _makeCorrespondenceMockClient()),
75+
),
76+
},
77+
authUser: fakeAuthUser,
78+
defaultPreferences: {
79+
'${PrefCategory.challenge.storageKey}.${fakeAuthUser.user.id}': jsonEncode(
80+
ChallengePrefs.defaults
81+
.copyWith(timeControl: ChallengeTimeControlType.correspondence, rated: false)
82+
.toJson(),
83+
),
84+
},
85+
);
86+
87+
await tester.pumpWidget(app);
88+
await tester.pump(const Duration(milliseconds: 50));
89+
90+
// open the bottom sheet
91+
await tester.tap(find.text('Open'));
92+
await tester.pumpAndSettle();
93+
94+
expect(find.byType(CreateChallengeBottomSheet), findsOneWidget);
95+
96+
// tap challenge button
97+
await tester.tap(find.text('Challenge'));
98+
await tester.pump(); // start the async challenge creation
99+
100+
// let the HTTP request for challenge creation complete
101+
await tester.pump(const Duration(milliseconds: 50));
102+
103+
// let the socket connect
104+
await tester.pump(kFakeWebSocketConnectionLag);
105+
106+
// let the 1-second delay in newCorrespondenceChallenge fire
107+
await tester.pump(const Duration(seconds: 1));
108+
await tester.pumpAndSettle();
109+
110+
// the bottom sheet should be closed
111+
expect(find.byType(CreateChallengeBottomSheet), findsNothing);
112+
113+
// snackbar should confirm the challenge was created
114+
expect(find.textContaining('Challenge created'), findsOneWidget);
115+
},
116+
variant: kPlatformVariant,
117+
);
118+
119+
testWidgets('shows decline reason when challenge is immediately declined', (tester) async {
120+
final app = await makeTestProviderScopeApp(
121+
tester,
122+
home: const _TestBottomSheetOpener(_testDestUser),
123+
overrides: {
124+
httpClientFactoryProvider: httpClientFactoryProvider.overrideWith(
125+
(ref) => FakeHttpClientFactory(() => _makeCorrespondenceDeclinedMockClient()),
126+
),
127+
},
128+
authUser: fakeAuthUser,
129+
defaultPreferences: {
130+
'${PrefCategory.challenge.storageKey}.${fakeAuthUser.user.id}': jsonEncode(
131+
ChallengePrefs.defaults
132+
.copyWith(timeControl: ChallengeTimeControlType.correspondence, rated: false)
133+
.toJson(),
134+
),
135+
},
136+
);
137+
138+
await tester.pumpWidget(app);
139+
await tester.pump(const Duration(milliseconds: 50));
140+
141+
// open the bottom sheet
142+
await tester.tap(find.text('Open'));
143+
await tester.pumpAndSettle();
144+
145+
expect(find.byType(CreateChallengeBottomSheet), findsOneWidget);
146+
147+
// tap challenge button
148+
await tester.tap(find.text('Challenge'));
149+
await tester.pump();
150+
151+
// let the HTTP request for challenge creation complete
152+
await tester.pump(const Duration(milliseconds: 50));
153+
154+
// let the socket connect
155+
await tester.pump(kFakeWebSocketConnectionLag);
156+
157+
// simulate a server reload event indicating the challenge was declined
158+
sendServerSocketMessages(Uri(path: '/challenge/$_correspondenceChallengeId/socket/v5'), [
159+
'{"t": "reload", "v": 1}',
160+
]);
161+
162+
// let the reload event be processed and the show request complete
163+
await tester.pump(const Duration(milliseconds: 50));
164+
165+
// let the 1-second delay in newCorrespondenceChallenge expire
166+
await tester.pump(const Duration(seconds: 1));
167+
await tester.pumpAndSettle();
168+
169+
// the bottom sheet should be closed
170+
expect(find.byType(CreateChallengeBottomSheet), findsNothing);
171+
172+
// snackbar should show the challenge declined message
173+
expect(find.text('Challenge declined.'), findsOneWidget);
174+
}, variant: kPlatformVariant);
175+
});
176+
});
177+
}
178+
179+
/// A simple wrapper widget that shows a button to open [CreateChallengeBottomSheet].
180+
class _TestBottomSheetOpener extends StatelessWidget {
181+
const _TestBottomSheetOpener(this.user);
182+
183+
final LightUser user;
184+
185+
@override
186+
Widget build(BuildContext context) {
187+
return Scaffold(
188+
body: Builder(
189+
builder: (context) {
190+
return Center(
191+
child: ElevatedButton(
192+
onPressed: () => showModalBottomSheet<void>(
193+
context: context,
194+
isScrollControlled: true,
195+
useRootNavigator: true,
196+
builder: (_) => CreateChallengeBottomSheet(user),
197+
),
198+
child: const Text('Open'),
199+
),
200+
);
201+
},
202+
),
203+
);
204+
}
205+
}
206+
207+
MockClient _makeClockMockClient() => MockClient((request) {
208+
if (request.url.path == '/api/account') {
209+
return mockResponse(mockApiAccountResponse(fakeAuthUser.user.name), 200);
210+
}
211+
// challenge creation endpoint (called by GameScreen's newRealTimeChallenge)
212+
if (request.url.path == '/api/challenge/${_testDestUser.id}') {
213+
return mockResponse(_clockChallengeResponse, 200);
214+
}
215+
return mockResponse('', 404);
216+
});
217+
218+
MockClient _makeCorrespondenceMockClient() => MockClient((request) {
219+
if (request.url.path == '/api/account') {
220+
return mockResponse(mockApiAccountResponse(fakeAuthUser.user.name), 200);
221+
}
222+
if (request.url.path == '/api/challenge/${_testDestUser.id}') {
223+
return mockResponse(_correspondenceChallengeCreatedResponse, 200);
224+
}
225+
return mockResponse('', 404);
226+
});
227+
228+
MockClient _makeCorrespondenceDeclinedMockClient() => MockClient((request) {
229+
if (request.url.path == '/api/account') {
230+
return mockResponse(mockApiAccountResponse(fakeAuthUser.user.name), 200);
231+
}
232+
if (request.url.path == '/api/challenge/${_testDestUser.id}') {
233+
return mockResponse(_correspondenceChallengeCreatedResponse, 200);
234+
}
235+
if (request.url.path == '/api/challenge/$_correspondenceChallengeId/show') {
236+
return mockResponse(_correspondenceChallengeDeclinedResponse, 200);
237+
}
238+
return mockResponse('', 404);
239+
});
240+
241+
// -- Mock server responses --
242+
243+
const _clockChallengeResponse =
244+
'''
245+
{
246+
"socketVersion": 0,
247+
"id": "$_clockChallengeId",
248+
"url": "https://lichess.org/$_clockChallengeId",
249+
"status": "created",
250+
"challenger": {
251+
"id": "testuser",
252+
"name": "testUser",
253+
"rating": 1500,
254+
"provisional": false,
255+
"online": true,
256+
"lag": 4
257+
},
258+
"destUser": {
259+
"id": "targetuser",
260+
"name": "TargetUser",
261+
"rating": 1600,
262+
"provisional": false,
263+
"online": true,
264+
"lag": 4
265+
},
266+
"variant": { "key": "standard", "name": "Standard", "short": "Std" },
267+
"rated": true,
268+
"speed": "rapid",
269+
"timeControl": { "type": "clock", "limit": 600, "increment": 0, "show": "10+0" },
270+
"color": "random",
271+
"perf": { "icon": "", "name": "Rapid" }
272+
}
273+
''';
274+
275+
const _correspondenceChallengeCreatedResponse =
276+
'''
277+
{
278+
"socketVersion": 0,
279+
"id": "$_correspondenceChallengeId",
280+
"url": "https://lichess.org/$_correspondenceChallengeId",
281+
"status": "created",
282+
"challenger": {
283+
"id": "testuser",
284+
"name": "testUser",
285+
"rating": 1500,
286+
"provisional": false,
287+
"online": true,
288+
"lag": 4
289+
},
290+
"destUser": {
291+
"id": "targetuser",
292+
"name": "TargetUser",
293+
"rating": 1600,
294+
"provisional": false,
295+
"online": true,
296+
"lag": 4
297+
},
298+
"variant": { "key": "standard", "name": "Standard", "short": "Std" },
299+
"rated": false,
300+
"speed": "correspondence",
301+
"timeControl": { "type": "correspondence", "daysPerTurn": 3 },
302+
"color": "random",
303+
"perf": { "icon": "", "name": "Correspondence" }
304+
}
305+
''';
306+
307+
const _correspondenceChallengeDeclinedResponse =
308+
'''
309+
{
310+
"socketVersion": 1,
311+
"id": "$_correspondenceChallengeId",
312+
"url": "https://lichess.org/$_correspondenceChallengeId",
313+
"status": "declined",
314+
"challenger": {
315+
"id": "testuser",
316+
"name": "testUser",
317+
"rating": 1500,
318+
"provisional": false,
319+
"online": true,
320+
"lag": 4
321+
},
322+
"destUser": {
323+
"id": "targetuser",
324+
"name": "TargetUser",
325+
"rating": 1600,
326+
"provisional": false,
327+
"online": true,
328+
"lag": 4
329+
},
330+
"variant": { "key": "standard", "name": "Standard", "short": "Std" },
331+
"rated": false,
332+
"speed": "correspondence",
333+
"timeControl": { "type": "correspondence", "daysPerTurn": 3 },
334+
"color": "random",
335+
"perf": { "icon": "", "name": "Correspondence" },
336+
"declineReasonKey": "generic"
337+
}
338+
''';

0 commit comments

Comments
 (0)