mirror of
https://github.com/lichess-org/mobile.git
synced 2026-05-26 13:50:52 +00:00
2105 lines
81 KiB
Dart
2105 lines
81 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:chessground/chessground.dart';
|
|
import 'package:dartchess/dartchess.dart';
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart' show ProviderScope;
|
|
import 'package:flutter_riverpod/misc.dart' show Override, ProviderOrFamily;
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:http/testing.dart';
|
|
import 'package:lichess_mobile/src/constants.dart';
|
|
import 'package:lichess_mobile/src/model/account/account_preferences.dart' hide Challenge;
|
|
import 'package:lichess_mobile/src/model/auth/auth_controller.dart';
|
|
import 'package:lichess_mobile/src/model/challenge/challenge.dart';
|
|
import 'package:lichess_mobile/src/model/common/chess.dart';
|
|
import 'package:lichess_mobile/src/model/common/game.dart';
|
|
import 'package:lichess_mobile/src/model/common/id.dart';
|
|
import 'package:lichess_mobile/src/model/common/service/sound_service.dart';
|
|
import 'package:lichess_mobile/src/model/common/socket.dart';
|
|
import 'package:lichess_mobile/src/model/common/speed.dart';
|
|
import 'package:lichess_mobile/src/model/game/game.dart';
|
|
import 'package:lichess_mobile/src/model/game/game_controller.dart';
|
|
import 'package:lichess_mobile/src/model/game/game_socket_events.dart';
|
|
import 'package:lichess_mobile/src/model/game/game_status.dart';
|
|
import 'package:lichess_mobile/src/model/lobby/create_game_service.dart';
|
|
import 'package:lichess_mobile/src/model/lobby/game_seek.dart';
|
|
import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
|
|
import 'package:lichess_mobile/src/model/settings/preferences_storage.dart';
|
|
import 'package:lichess_mobile/src/model/user/user.dart';
|
|
import 'package:lichess_mobile/src/network/http.dart';
|
|
import 'package:lichess_mobile/src/network/socket.dart';
|
|
import 'package:lichess_mobile/src/styles/lichess_icons.dart';
|
|
import 'package:lichess_mobile/src/view/chat/chat_screen.dart';
|
|
import 'package:lichess_mobile/src/view/game/correspondence_clock_widget.dart';
|
|
import 'package:lichess_mobile/src/view/game/game_body.dart';
|
|
import 'package:lichess_mobile/src/view/game/game_player.dart';
|
|
import 'package:lichess_mobile/src/view/game/game_screen.dart';
|
|
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
|
|
import 'package:lichess_mobile/src/widgets/bottom_bar.dart';
|
|
import 'package:lichess_mobile/src/widgets/clock.dart';
|
|
import 'package:lichess_mobile/src/widgets/game_layout.dart';
|
|
import 'package:lichess_mobile/src/widgets/pockets.dart';
|
|
import 'package:mocktail/mocktail.dart';
|
|
import 'package:qr_flutter/qr_flutter.dart';
|
|
import 'package:wakelock_plus_platform_interface/messages.g.dart';
|
|
|
|
import '../../model/game/game_socket_example_data.dart';
|
|
import '../../network/fake_websocket_channel.dart';
|
|
import '../../test_helpers.dart';
|
|
import '../../test_provider_scope.dart';
|
|
|
|
final client = MockClient((request) {
|
|
if (request.url.path == '/api/board/seek') {
|
|
return mockResponse('ok', 200);
|
|
} else if (request.url.path == '/game/export/CCW6EEru') {
|
|
return mockResponse('''
|
|
{"id":"CCW6EEru","rated":true,"source":"lobby","variant":"standard","speed":"bullet","perf":"bullet","createdAt":1706185945680,"lastMoveAt":1706186170504,"status":"resign","players":{"white":{"user":{"name":"veloce","id":"veloce"},"rating":1789,"ratingDiff":9},"black":{"user":{"name":"chabrot","id":"chabrot"},"rating":1810,"ratingDiff":-9}},"winner":"white","opening":{"eco":"C52","name":"Italian Game: Evans Gambit, Main Line","ply":10},"moves":"e4 e5 Nf3 Nc6 Bc4 Bc5 b4 Bxb4 c3 Ba5 d4 Bb6 Ba3 Nf6 Qb3 d6 Bxf7+ Kf8 O-O Qe7 Nxe5 Nxe5 dxe5 Be6 Bxe6 Nxe4 Re1 Nc5 Bxc5 Bxc5 Qxb7 Re8 Bh3 dxe5 Qf3+ Kg8 Nd2 Rf8 Qd5+ Rf7 Be6 Qxe6 Qxe6","clocks":[12003,12003,11883,11811,11683,11379,11307,11163,11043,11043,10899,10707,10155,10483,10019,9995,9635,9923,8963,8603,7915,8283,7763,7459,7379,6083,6587,5819,6363,5651,6075,5507,5675,4803,5059,4515,4547,3555,3971,3411,3235,3123,3120,2742],"clock":{"initial":120,"increment":1,"totalTime":160}}
|
|
''', 200);
|
|
}
|
|
return mockResponse('', 404);
|
|
});
|
|
|
|
class MockSoundService extends Mock implements SoundService {}
|
|
|
|
class MockCreateGameService extends Mock implements CreateGameService {}
|
|
|
|
void main() {
|
|
const testGameFullId = GameFullId('qVChCOTcHSeW');
|
|
final testGameSocketUri = GameController.socketUri(testGameFullId);
|
|
|
|
setUpAll(() {
|
|
registerFallbackValue(Variant.standard);
|
|
registerFallbackValue(Sound.error);
|
|
registerFallbackValue(
|
|
const GameSeek(clock: (Duration(minutes: 3), Duration(seconds: 2)), rated: false),
|
|
);
|
|
registerFallbackValue(
|
|
const ChallengeRequest(
|
|
variant: Variant.standard,
|
|
timeControl: ChallengeTimeControlType.clock,
|
|
rated: false,
|
|
sideChoice: SideChoice.random,
|
|
),
|
|
);
|
|
registerFallbackValue(
|
|
const Challenge(
|
|
id: ChallengeId('challeng'),
|
|
status: ChallengeStatus.created,
|
|
variant: Variant.standard,
|
|
speed: Speed.blitz,
|
|
timeControl: ChallengeTimeControlType.clock,
|
|
rated: false,
|
|
sideChoice: SideChoice.random,
|
|
),
|
|
);
|
|
});
|
|
|
|
group('Loading', () {
|
|
testWidgets('a game directly with initialGameId', (WidgetTester tester) async {
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: const GameScreen(source: ExistingGameSource(testGameFullId)),
|
|
overrides: {
|
|
lichessClientProvider: lichessClientProvider.overrideWith(
|
|
(ref) => LichessClient(client, ref),
|
|
),
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
|
|
// while loading, displays an empty board
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(getBoardPieces(tester), isEmpty);
|
|
|
|
final initialBoardPosition = tester.getTopLeft(find.byType(Chessboard));
|
|
|
|
// now the game controller is loading and screen doesn't have changed yet
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(getBoardPieces(tester), isEmpty);
|
|
expect(
|
|
tester.getTopLeft(find.byType(Chessboard)),
|
|
initialBoardPosition,
|
|
reason: 'board position should not change',
|
|
);
|
|
|
|
await tester.pump(kFakeWebSocketConnectionLag);
|
|
|
|
sendServerSocketMessages(testGameSocketUri, [
|
|
makeFullEvent(
|
|
const GameId('qVChCOTc'),
|
|
'',
|
|
whiteUserName: 'Peter',
|
|
blackUserName: 'Steven',
|
|
),
|
|
]);
|
|
// wait for socket message handling
|
|
await tester.pump();
|
|
|
|
expect(getBoardPieces(tester).length, 32);
|
|
expect(find.text('Peter'), findsOneWidget);
|
|
expect(find.text('Steven'), findsOneWidget);
|
|
expect(
|
|
tester.getTopLeft(find.byType(Chessboard)),
|
|
initialBoardPosition,
|
|
reason: 'board position should not change',
|
|
);
|
|
});
|
|
|
|
testWidgets('a game from the pool with a seek', (WidgetTester tester) async {
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: const GameScreen(
|
|
source: LobbySource(
|
|
GameSeek(clock: (Duration(minutes: 3), Duration(seconds: 2)), rated: true),
|
|
),
|
|
),
|
|
overrides: {
|
|
lichessClientProvider: lichessClientProvider.overrideWith(
|
|
(ref) => LichessClient(client, ref),
|
|
),
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(getBoardPieces(tester), isEmpty);
|
|
expect(find.text('Waiting for opponent to join...'), findsOneWidget);
|
|
expect(find.text('3+2'), findsOneWidget);
|
|
expect(find.widgetWithText(BottomBarButton, 'Cancel'), findsOneWidget);
|
|
|
|
final initialBoardPosition = tester.getTopLeft(find.byType(Chessboard));
|
|
|
|
// waiting for the game
|
|
await tester.pump(const Duration(seconds: 2));
|
|
|
|
// when a seek is accepted, server lobby sends a 'redirect' message with game id
|
|
sendServerSocketMessages(Uri(path: '/lobby/socket/v5'), [
|
|
'{"t": "redirect", "d": {"id": "qVChCOTcHSeW" }, "v": 1}',
|
|
]);
|
|
// wait for socket message handling
|
|
await tester.pump(const Duration(milliseconds: 1));
|
|
|
|
// now the game controller is loading
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(getBoardPieces(tester), isEmpty);
|
|
expect(find.text('Waiting for opponent to join...'), findsNothing);
|
|
expect(find.text('3+2'), findsNothing);
|
|
expect(find.widgetWithText(BottomBarButton, 'Cancel'), findsNothing);
|
|
expect(
|
|
tester.getTopLeft(find.byType(Chessboard)),
|
|
initialBoardPosition,
|
|
reason: 'board position should not change',
|
|
);
|
|
|
|
// wait for game socket to connect
|
|
await tester.pump(kFakeWebSocketConnectionLag);
|
|
|
|
sendServerSocketMessages(GameController.socketUri(testGameFullId), [
|
|
makeFullEvent(
|
|
const GameId('qVChCOTc'),
|
|
'',
|
|
whiteUserName: 'Peter',
|
|
blackUserName: 'Steven',
|
|
),
|
|
]);
|
|
// wait for socket message handling
|
|
await tester.pump();
|
|
|
|
expect(getBoardPieces(tester).length, 32);
|
|
expect(find.text('Peter'), findsOneWidget);
|
|
expect(find.text('Steven'), findsOneWidget);
|
|
expect(find.text('Waiting for opponent to join...'), findsNothing);
|
|
expect(find.text('3+2'), findsNothing);
|
|
expect(
|
|
tester.getTopLeft(find.byType(Chessboard)),
|
|
initialBoardPosition,
|
|
reason: 'board position should not change',
|
|
);
|
|
});
|
|
|
|
for (final authUser in [
|
|
null,
|
|
AuthUser(
|
|
user: LightUser(id: UserId.fromUserName('John'), name: 'John'),
|
|
token: 'test-token',
|
|
),
|
|
]) {
|
|
testWidgets('displays game link for open challenge, logged in: ${authUser != null}', (
|
|
WidgetTester tester,
|
|
) async {
|
|
const challengeRequest = ChallengeRequest(
|
|
destUser: null,
|
|
variant: Variant.standard,
|
|
timeControl: ChallengeTimeControlType.clock,
|
|
rated: true,
|
|
sideChoice: SideChoice.white,
|
|
);
|
|
final challenge = Challenge(
|
|
sideChoice: challengeRequest.sideChoice,
|
|
id: const ChallengeId('challengeId'),
|
|
variant: challengeRequest.variant,
|
|
timeControl: challengeRequest.timeControl,
|
|
rated: challengeRequest.rated,
|
|
speed: Speed.blitz,
|
|
status: ChallengeStatus.created,
|
|
);
|
|
|
|
final createGameService = MockCreateGameService();
|
|
when(
|
|
() => createGameService.newOpenOrRealTimeChallenge(challengeRequest),
|
|
).thenAnswer((_) async => challenge);
|
|
when(
|
|
() => createGameService.waitForChallengeResponse(challenge),
|
|
).thenAnswer((_) => Completer<ChallengeResponse>().future);
|
|
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: const GameScreen(source: UserChallengeSource(challengeRequest)),
|
|
authUser: authUser,
|
|
overrides: {
|
|
createGameServiceProvider: createGameServiceProvider.overrideWith(
|
|
(_) => createGameService,
|
|
),
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(getBoardPieces(tester), isEmpty);
|
|
expect(find.text('To invite someone to play, give this URL'), findsOneWidget);
|
|
expect(find.text('Or let your opponent scan this QR code'), findsOneWidget);
|
|
expect(find.byType(QrImageView), findsOneWidget);
|
|
expect(find.textContaining('https://$kLichessHost/${challenge.id.value}'), findsOneWidget);
|
|
expect(
|
|
find.text('Or invite a Lichess user'),
|
|
authUser == null ? findsNothing : findsOneWidget,
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('Reconnecting title', () {
|
|
testWidgets('shows Reconnecting when socket has no ping response during a real-time game', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final noPongFactory = ListenableFakeWebSocketChannelFactory((route) {
|
|
final channel = createDefaultFakeWebSocketChannel(route);
|
|
channel.shouldSendPong = false;
|
|
return channel;
|
|
});
|
|
|
|
await createTestGame(tester, socketFactory: noPongFactory);
|
|
// Wait for _isRealTimePlayableGameProvider to resolve so monitorSocket becomes true.
|
|
await tester.pump();
|
|
|
|
// averageLag stays at Duration.zero (no pong ever received), so rating == 0.
|
|
expect(find.text('Reconnecting'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('shows normal game title when socket ping is established', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await createTestGame(tester);
|
|
// Wait for _isRealTimePlayableGameProvider to resolve.
|
|
await tester.pump();
|
|
|
|
// createTestGame pumps 10ms, during which the immediate pong is received
|
|
// (connectionLag = 5ms), so averageLag > 0 and rating > 0.
|
|
expect(find.text('Reconnecting'), findsNothing);
|
|
});
|
|
});
|
|
|
|
group('AppBar title', () {
|
|
testWidgets('active real-time game shows time control and mode', (WidgetTester tester) async {
|
|
await createTestGame(tester);
|
|
// Wait for _isRealTimePlayableGameProvider to resolve.
|
|
await tester.pump();
|
|
|
|
expect(find.text('3+2 • Casual'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('lobby loading shows seek time control and mode', (WidgetTester tester) async {
|
|
const seek = GameSeek(clock: (Duration(minutes: 3), Duration(seconds: 2)), rated: true);
|
|
final createGameService = MockCreateGameService();
|
|
when(
|
|
() => createGameService.newLobbyGame(any()),
|
|
).thenAnswer((_) => Completer<GameSeekResponse>().future);
|
|
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: const GameScreen(source: LobbySource(seek)),
|
|
overrides: {
|
|
createGameServiceProvider: createGameServiceProvider.overrideWith(
|
|
(_) => createGameService,
|
|
),
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
await tester.pump(kFakeWebSocketConnectionLag);
|
|
await tester.pump();
|
|
|
|
expect(find.text('3+2 • Rated'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('seek cancelled shows seek time control and mode', (WidgetTester tester) async {
|
|
const seek = GameSeek(clock: (Duration(minutes: 3), Duration(seconds: 2)), rated: true);
|
|
final createGameService = MockCreateGameService();
|
|
when(
|
|
() => createGameService.newLobbyGame(any()),
|
|
).thenAnswer((_) async => const GameSeekCancelled());
|
|
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: const GameScreen(source: LobbySource(seek)),
|
|
overrides: {
|
|
createGameServiceProvider: createGameServiceProvider.overrideWith(
|
|
(_) => createGameService,
|
|
),
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
await tester.pump();
|
|
|
|
expect(find.text('3+2 • Rated'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('challenge loading with destUser shows challenge time control and mode', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final challengeRequest = ChallengeRequest(
|
|
destUser: LightUser(id: UserId.fromUserName('bob'), name: 'Bob'),
|
|
variant: Variant.standard,
|
|
timeControl: ChallengeTimeControlType.clock,
|
|
clock: (time: const Duration(minutes: 3), increment: const Duration(seconds: 2)),
|
|
rated: true,
|
|
sideChoice: .random,
|
|
);
|
|
final createGameService = MockCreateGameService();
|
|
when(
|
|
() => createGameService.newOpenOrRealTimeChallenge(any()),
|
|
).thenAnswer((_) => Completer<Challenge>().future);
|
|
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: GameScreen(source: UserChallengeSource(challengeRequest)),
|
|
overrides: {
|
|
createGameServiceProvider: createGameServiceProvider.overrideWith(
|
|
(_) => createGameService,
|
|
),
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
await tester.pump(kFakeWebSocketConnectionLag);
|
|
await tester.pump();
|
|
|
|
expect(find.text('3+2 • Rated'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('challenge cancelled shows challenge time control and mode', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final challengeRequest = ChallengeRequest(
|
|
destUser: LightUser(id: UserId.fromUserName('bob'), name: 'Bob'),
|
|
variant: Variant.standard,
|
|
timeControl: ChallengeTimeControlType.clock,
|
|
clock: (time: const Duration(minutes: 3), increment: const Duration(seconds: 2)),
|
|
rated: true,
|
|
sideChoice: .random,
|
|
);
|
|
final challenge = Challenge(
|
|
id: const ChallengeId('challeng'),
|
|
status: ChallengeStatus.canceled,
|
|
variant: Variant.standard,
|
|
speed: Speed.blitz,
|
|
timeControl: ChallengeTimeControlType.clock,
|
|
clock: (time: const Duration(minutes: 3), increment: const Duration(seconds: 2)),
|
|
rated: true,
|
|
sideChoice: .random,
|
|
destUser: (
|
|
user: LightUser(id: UserId.fromUserName('bob'), name: 'Bob'),
|
|
rating: null,
|
|
provisionalRating: null,
|
|
lagRating: null,
|
|
),
|
|
);
|
|
final createGameService = MockCreateGameService();
|
|
when(
|
|
() => createGameService.newOpenOrRealTimeChallenge(any()),
|
|
).thenAnswer((_) async => challenge);
|
|
when(
|
|
() => createGameService.waitForChallengeResponse(any()),
|
|
).thenAnswer((_) async => const ChallengeResponseCancelled());
|
|
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: GameScreen(source: UserChallengeSource(challengeRequest)),
|
|
overrides: {
|
|
createGameServiceProvider: createGameServiceProvider.overrideWith(
|
|
(_) => createGameService,
|
|
),
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('3+2 • Rated'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('challenge declined shows challenge time control and mode', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final challengeRequest = ChallengeRequest(
|
|
destUser: LightUser(id: UserId.fromUserName('bob'), name: 'Bob'),
|
|
variant: Variant.standard,
|
|
timeControl: ChallengeTimeControlType.clock,
|
|
clock: (time: const Duration(minutes: 3), increment: const Duration(seconds: 2)),
|
|
rated: true,
|
|
sideChoice: .random,
|
|
);
|
|
final challenge = Challenge(
|
|
id: const ChallengeId('challeng'),
|
|
status: ChallengeStatus.declined,
|
|
variant: Variant.standard,
|
|
speed: Speed.blitz,
|
|
timeControl: ChallengeTimeControlType.clock,
|
|
clock: (time: const Duration(minutes: 3), increment: const Duration(seconds: 2)),
|
|
rated: true,
|
|
sideChoice: .random,
|
|
destUser: (
|
|
user: LightUser(id: UserId.fromUserName('bob'), name: 'Bob'),
|
|
rating: null,
|
|
provisionalRating: null,
|
|
lagRating: null,
|
|
),
|
|
);
|
|
final createGameService = MockCreateGameService();
|
|
when(
|
|
() => createGameService.newOpenOrRealTimeChallenge(any()),
|
|
).thenAnswer((_) async => challenge);
|
|
when(() => createGameService.waitForChallengeResponse(any())).thenAnswer(
|
|
(_) async => ChallengeResponseDeclined(challenge: challenge, declineReason: null),
|
|
);
|
|
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: GameScreen(source: UserChallengeSource(challengeRequest)),
|
|
overrides: {
|
|
createGameServiceProvider: createGameServiceProvider.overrideWith(
|
|
(_) => createGameService,
|
|
),
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('3+2 • Rated'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('open challenge shows challenge time control and mode', (
|
|
WidgetTester tester,
|
|
) async {
|
|
const challengeRequest = ChallengeRequest(
|
|
variant: Variant.standard,
|
|
timeControl: ChallengeTimeControlType.clock,
|
|
clock: (time: Duration(minutes: 3), increment: Duration(seconds: 2)),
|
|
rated: true,
|
|
sideChoice: .random,
|
|
);
|
|
const challenge = Challenge(
|
|
id: ChallengeId('challeng'),
|
|
status: ChallengeStatus.created,
|
|
variant: Variant.standard,
|
|
speed: Speed.blitz,
|
|
timeControl: ChallengeTimeControlType.clock,
|
|
clock: (time: Duration(minutes: 3), increment: Duration(seconds: 2)),
|
|
rated: true,
|
|
sideChoice: .random,
|
|
);
|
|
final createGameService = MockCreateGameService();
|
|
when(
|
|
() => createGameService.newOpenOrRealTimeChallenge(any()),
|
|
).thenAnswer((_) async => challenge);
|
|
when(
|
|
() => createGameService.waitForChallengeResponse(any()),
|
|
).thenAnswer((_) => Completer<ChallengeResponse>().future);
|
|
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: const GameScreen(source: UserChallengeSource(challengeRequest)),
|
|
overrides: {
|
|
createGameServiceProvider: createGameServiceProvider.overrideWith(
|
|
(_) => createGameService,
|
|
),
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
await tester.pump(); // challenge created, state = OpenChallengeCreatedState
|
|
await tester.pump(kFakeWebSocketConnectionLag); // wait for socket pong
|
|
await tester.pump();
|
|
|
|
expect(find.text('3+2 • Rated'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('finished game shows time control and mode', (WidgetTester tester) async {
|
|
await loadFinishedTestGame(tester);
|
|
// Pump 500ms to let the game-over popup timer fire and resolve _gameMetaProvider.
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
|
|
expect(find.text('2+1 • Rated'), findsOneWidget);
|
|
});
|
|
});
|
|
|
|
group('Plays sound for', () {
|
|
testWidgets('move', (WidgetTester tester) async {
|
|
final mockSoundService = MockSoundService();
|
|
when(() => mockSoundService.play(any())).thenAnswer((_) async {});
|
|
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 e5',
|
|
overrides: {
|
|
soundServiceProvider: soundServiceProvider.overrideWith((_) => mockSoundService),
|
|
},
|
|
);
|
|
|
|
await playMove(tester, 'd2', 'd4');
|
|
await tester.pumpAndSettle();
|
|
|
|
verify(() => mockSoundService.play(Sound.move));
|
|
});
|
|
|
|
testWidgets('captures', (WidgetTester tester) async {
|
|
final mockSoundService = MockSoundService();
|
|
when(() => mockSoundService.playCaptureSound(any())).thenAnswer((_) async {});
|
|
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 d5',
|
|
overrides: {
|
|
soundServiceProvider: soundServiceProvider.overrideWith((_) => mockSoundService),
|
|
},
|
|
);
|
|
|
|
await playMove(tester, 'e4', 'd5');
|
|
|
|
verify(() => mockSoundService.playCaptureSound(Variant.standard));
|
|
});
|
|
});
|
|
|
|
group('Game actions', () {
|
|
testWidgets('promotion with move confirmation closes promotion picker after piece selection', (
|
|
WidgetTester tester,
|
|
) async {
|
|
// White pawn on e7 ready to promote (king on g8 avoids pawn attack on d8/f8)
|
|
await createTestGame(
|
|
tester,
|
|
variant: Variant.fromPosition,
|
|
initialFen: '6k1/4P3/8/8/8/8/8/4K3 w - - 0 1',
|
|
serverPrefs: const ServerGamePrefs(
|
|
showRatings: true,
|
|
enablePremove: true,
|
|
autoQueen: AutoQueen.never,
|
|
confirmResign: true,
|
|
submitMove: true,
|
|
zenMode: Zen.no,
|
|
),
|
|
);
|
|
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
|
|
await playMove(tester, 'e7', 'e8');
|
|
|
|
final container = ProviderScope.containerOf(tester.element(find.byType(GameScreen)));
|
|
final ctrlProvider = gameControllerProvider(const GameFullId('qVChCOTcHSeW'));
|
|
|
|
expect(container.read(ctrlProvider).requireValue.moveToConfirm, isNull);
|
|
|
|
final boardRect = tester.getRect(find.byType(Chessboard));
|
|
await tester.tapAt(squareOffset(Square.fromName('e8'), boardRect));
|
|
await tester.pump();
|
|
|
|
expect(container.read(ctrlProvider).requireValue.moveToConfirm, isNotNull);
|
|
});
|
|
|
|
testWidgets('move confirmation', (WidgetTester tester) async {
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 e5',
|
|
clock: const (
|
|
running: true,
|
|
initial: Duration(minutes: 1),
|
|
increment: Duration.zero,
|
|
white: Duration(seconds: 58),
|
|
black: Duration(seconds: 54),
|
|
emerg: Duration(seconds: 10),
|
|
),
|
|
serverPrefs: const ServerGamePrefs(
|
|
showRatings: true,
|
|
enablePremove: true,
|
|
autoQueen: AutoQueen.always,
|
|
confirmResign: true,
|
|
submitMove: true,
|
|
zenMode: Zen.no,
|
|
),
|
|
);
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(getBoardPieces(tester).length, 32);
|
|
|
|
await playMove(tester, 'g1', 'f3');
|
|
|
|
// see confirmation dialog
|
|
expect(find.text('Confirm move'), findsOneWidget);
|
|
// move is shown on board
|
|
expect(boardHasPiece(tester, Square.f3, Piece.whiteKnight), isTrue);
|
|
// move is not yet played so it doesn't appear in the move list
|
|
expect(find.text('Nf3'), findsNothing);
|
|
|
|
// confirm the move
|
|
await tester.tap(find.byIcon(CupertinoIcons.checkmark_rectangle_fill));
|
|
await tester.pump();
|
|
|
|
// move still shown on board
|
|
expect(boardHasPiece(tester, Square.f3, Piece.whiteKnight), isTrue);
|
|
// move appears in move list
|
|
expect(find.text('Nf3'), findsOneWidget);
|
|
});
|
|
|
|
group('Premoves', () {
|
|
testWidgets('premove is applied after opponent move', (WidgetTester tester) async {
|
|
const gameFullId = GameFullId('qVChCOTcHSeW');
|
|
final gameSocketUri = GameController.socketUri(gameFullId);
|
|
|
|
// After e4 it's black's turn, white can premove
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4',
|
|
clock: const (
|
|
running: true,
|
|
initial: Duration(minutes: 1),
|
|
increment: Duration.zero,
|
|
white: Duration(seconds: 58),
|
|
black: Duration(seconds: 58),
|
|
emerg: Duration(seconds: 10),
|
|
),
|
|
serverPrefs: const ServerGamePrefs(
|
|
showRatings: true,
|
|
enablePremove: true,
|
|
autoQueen: .always,
|
|
confirmResign: true,
|
|
submitMove: false,
|
|
zenMode: .no,
|
|
),
|
|
);
|
|
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
|
|
// white premoves d2-d4
|
|
await playMove(tester, 'd2', 'd4');
|
|
|
|
// premove indicator should be visible
|
|
expect(boardHasPremove(tester, const NormalMove(from: Square.d2, to: Square.d4)), isTrue);
|
|
|
|
// opponent plays e7-e5 (ply 2)
|
|
sendServerSocketMessages(gameSocketUri, [
|
|
'{"t": "move", "v": 1, "d": {"ply": 2, "uci": "e7e5", "san": "e5", "clock": {"white": 58, "black": 56}}}',
|
|
]);
|
|
await tester.pump();
|
|
|
|
// let the premove microtask run
|
|
await tester.pump(const Duration(milliseconds: 1));
|
|
// let the board rebuild from userMove
|
|
await tester.pump();
|
|
|
|
// premove should have been played
|
|
expect(boardHasPremove(tester, const NormalMove(from: Square.d2, to: Square.d4)), isFalse);
|
|
expect(boardHasPiece(tester, Square.d4, Piece.whitePawn), isTrue);
|
|
});
|
|
|
|
testWidgets('illegal premove is cancelled after opponent move with move confirmation', (
|
|
WidgetTester tester,
|
|
) async {
|
|
const gameFullId = GameFullId('qVChCOTcHSeW');
|
|
final gameSocketUri = GameController.socketUri(gameFullId);
|
|
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 e5',
|
|
clock: const (
|
|
running: true,
|
|
initial: Duration(minutes: 1),
|
|
increment: Duration.zero,
|
|
white: Duration(seconds: 58),
|
|
black: Duration(seconds: 54),
|
|
emerg: Duration(seconds: 10),
|
|
),
|
|
serverPrefs: const ServerGamePrefs(
|
|
showRatings: true,
|
|
enablePremove: true,
|
|
autoQueen: .always,
|
|
confirmResign: true,
|
|
submitMove: true,
|
|
zenMode: .no,
|
|
),
|
|
);
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(getBoardPieces(tester).length, 32);
|
|
|
|
// white plays d4 with confirmation
|
|
await playMove(tester, 'd2', 'd4');
|
|
expect(find.text('Confirm move'), findsOneWidget);
|
|
expect(boardHasPiece(tester, Square.d4, Piece.whitePawn), isTrue);
|
|
|
|
// white premoves d4-d5 (push the d-pawn, anticipating d5 stays free)
|
|
await playMove(tester, 'd4', 'd5');
|
|
await tester.pump();
|
|
|
|
// premove indicators should be visible
|
|
expect(boardHasPremove(tester, const NormalMove(from: Square.d4, to: Square.d5)), isTrue);
|
|
|
|
// confirm the move
|
|
await tester.tap(find.byIcon(CupertinoIcons.checkmark_rectangle_fill));
|
|
await tester.pump();
|
|
|
|
// premove indicators should still be visible after confirmation
|
|
expect(boardHasPremove(tester, const NormalMove(from: Square.d4, to: Square.d5)), isTrue);
|
|
|
|
// server acknowledges white's d4 move (ply 3)
|
|
sendServerSocketMessages(gameSocketUri, [
|
|
'{"t": "move", "v": 1, "d": {"ply": 3, "uci": "d2d4", "san": "d4", "clock": {"white": 57, "black": 54}}}',
|
|
]);
|
|
await tester.pump();
|
|
|
|
// opponent plays d7-d5 (ply 4), blocking the premove
|
|
sendServerSocketMessages(gameSocketUri, [
|
|
'{"t": "move", "v": 2, "d": {"ply": 4, "uci": "d7d5", "san": "d5", "clock": {"white": 57, "black": 52}}}',
|
|
]);
|
|
await tester.pump();
|
|
|
|
// let the premove microtask run
|
|
await tester.pump(const Duration(milliseconds: 1));
|
|
|
|
// premove should be cancelled since d4-d5 is now illegal (d5 is occupied)
|
|
expect(boardHasPremove(tester, const NormalMove(from: Square.d4, to: Square.d5)), isFalse);
|
|
|
|
// d5 should have black's pawn (opponent's move was applied)
|
|
expect(boardHasPiece(tester, Square.d5, Piece.blackPawn), isTrue);
|
|
// d4 should still have white's pawn
|
|
expect(boardHasPiece(tester, Square.d4, Piece.whitePawn), isTrue);
|
|
});
|
|
|
|
testWidgets('can premove drop moves in Crazyhouse', (WidgetTester tester) async {
|
|
const gameFullId = GameFullId('qVChCOTcHSeW');
|
|
final gameSocketUri = GameController.socketUri(gameFullId);
|
|
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 d5 exd5',
|
|
variant: Variant.crazyhouse,
|
|
clock: const (
|
|
running: true,
|
|
initial: Duration(minutes: 1),
|
|
increment: Duration.zero,
|
|
white: Duration(seconds: 58),
|
|
black: Duration(seconds: 54),
|
|
emerg: Duration(seconds: 10),
|
|
),
|
|
serverPrefs: const ServerGamePrefs(
|
|
showRatings: true,
|
|
enablePremove: true,
|
|
autoQueen: .always,
|
|
confirmResign: true,
|
|
submitMove: false,
|
|
zenMode: .no,
|
|
),
|
|
);
|
|
|
|
await playDropMove(tester, Side.white, Role.pawn, 'a4');
|
|
|
|
// premove indicator should be visible
|
|
expect(boardHasPremove(tester, const DropMove(to: Square.a4, role: Role.pawn)), isTrue);
|
|
|
|
// opponent plays Qxd5
|
|
sendServerSocketMessages(gameSocketUri, [
|
|
'{"t": "move", "v": 1, "d": {"ply": 4, "uci": "d8d5", "san": "Qxd5", "clock": {"white": 57, "black": 52}}}',
|
|
]);
|
|
await tester.pump();
|
|
|
|
// let the premove microtask run
|
|
await tester.pump(const Duration(milliseconds: 1));
|
|
// let the board rebuild from userMove
|
|
await tester.pump();
|
|
|
|
// premove should have been played
|
|
expect(boardHasPremove(tester, const DropMove(to: Square.a4, role: Role.pawn)), isFalse);
|
|
expect(boardHasPiece(tester, Square.a4, Piece.whitePawn), isTrue);
|
|
});
|
|
});
|
|
|
|
testWidgets('takeback', (WidgetTester tester) async {
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 e5 Nf3',
|
|
clock: const (
|
|
running: true,
|
|
initial: Duration(minutes: 1),
|
|
increment: Duration.zero,
|
|
white: Duration(seconds: 58),
|
|
black: Duration(seconds: 54),
|
|
emerg: Duration(seconds: 10),
|
|
),
|
|
);
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(getBoardPieces(tester).length, 32);
|
|
|
|
// black plays
|
|
sendServerSocketMessages(testGameSocketUri, [
|
|
'{"t": "move", "v": 1, "d": {"ply": 4, "uci": "b8c6", "san": "Nc6", "clock": {"white": 58, "black": 52}}}',
|
|
]);
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
expect(boardHasPiece(tester, Square.c6, Piece.blackKnight), isTrue);
|
|
expect(
|
|
tester.widgetList<Clock>(find.byType(Clock)).last.active,
|
|
true,
|
|
reason: 'white clock is active',
|
|
);
|
|
// white clock ticking
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(findClockWithTime(Side.white, '0:56'), findsOneWidget);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(findClockWithTime(Side.white, '0:55'), findsOneWidget);
|
|
|
|
// black asks for takeback
|
|
sendServerSocketMessages(testGameSocketUri, [
|
|
'{"t":"takebackOffers","v":2,"d":{"black":true}}',
|
|
]);
|
|
await tester.pump(const Duration(milliseconds: 1));
|
|
|
|
// see takeback button
|
|
expect(find.byIcon(CupertinoIcons.arrowshape_turn_up_left), findsOneWidget);
|
|
await tester.tap(find.byIcon(CupertinoIcons.arrowshape_turn_up_left));
|
|
// wait for the popup to show (cannot use pumpAndSettle because of clocks)
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
await tester.tap(find.text('Accept'));
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
// server acknowledges the takeback and ask client to reload
|
|
sendServerSocketMessages(testGameSocketUri, ['{"v": 3}', '{"t":"reload","v":4,"d":null}']);
|
|
// wait for client to reconnect
|
|
await tester.pump(const Duration(milliseconds: 1));
|
|
// socket will reconnect, wait for connection
|
|
await tester.pump(kFakeWebSocketConnectionLag);
|
|
// server sends 'full' event immediately after reconnect
|
|
sendServerSocketMessages(testGameSocketUri, [
|
|
makeFullEvent(
|
|
const GameId('qVChCOTc'),
|
|
'e4 e5 Nf3',
|
|
whiteUserName: 'Peter',
|
|
blackUserName: 'Steven',
|
|
youAre: Side.white,
|
|
socketVersion: 5,
|
|
clock: (
|
|
running: true,
|
|
initial: const Duration(minutes: 1),
|
|
increment: Duration.zero,
|
|
white: const Duration(seconds: 55),
|
|
black: const Duration(seconds: 53),
|
|
emerg: const Duration(seconds: 10),
|
|
),
|
|
),
|
|
]);
|
|
await tester.pump(const Duration(milliseconds: 1));
|
|
|
|
// black move is cancelled
|
|
expect(boardHasPiece(tester, Square.c6, Piece.blackKnight), isFalse);
|
|
expect(boardHasPiece(tester, Square.b8, Piece.blackKnight), isTrue);
|
|
expect(tester.widget<Clock>(findClock(Side.black)).active, true);
|
|
expect(tester.widget<Clock>(findClock(Side.white)).active, false);
|
|
// black clock is ticking
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(findClockWithTime(Side.black, '0:52'), findsOneWidget);
|
|
expect(findClockWithTime(Side.white, '0:55'), findsOneWidget);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(findClockWithTime(Side.black, '0:51'), findsOneWidget);
|
|
expect(findClockWithTime(Side.white, '0:55'), findsOneWidget);
|
|
});
|
|
});
|
|
|
|
group('Castling', () {
|
|
const String castlingSetupPgn = 'e4 e5 Nf3 Nf6 Bc4 Bc5 d3 d6 Bd2 Bd7 Nc3 Nc6 Qe2 Qe7';
|
|
|
|
for (final castlingMethod in CastlingMethod.values) {
|
|
testWidgets('respect castling preference ($castlingMethod)', (tester) async {
|
|
await createTestGame(
|
|
pgn: castlingSetupPgn,
|
|
defaultPreferences: {
|
|
PrefCategory.board.storageKey: jsonEncode(
|
|
BoardPrefs.defaults.copyWith(castlingMethod: castlingMethod).toJson(),
|
|
),
|
|
},
|
|
tester,
|
|
);
|
|
|
|
expect(boardHasPiece(tester, Square.e1, Piece.whiteKing), isTrue);
|
|
|
|
final boardRect = tester.getRect(find.byType(Chessboard));
|
|
await tester.tapAt(squareOffset(Square.e1, boardRect));
|
|
await tester.pump();
|
|
|
|
final validMoves = getBoardValidMoves(tester);
|
|
switch (castlingMethod) {
|
|
case CastlingMethod.kingOverRook:
|
|
// kingOverRook acts as either kingTwoSquares or kingOverRook
|
|
expect(validMoves.contains(Square.f1), isTrue);
|
|
expect(validMoves.contains(Square.g1), isTrue);
|
|
expect(validMoves.contains(Square.h1), isTrue);
|
|
expect(validMoves.contains(Square.c1), isTrue);
|
|
expect(validMoves.contains(Square.d1), isTrue);
|
|
expect(validMoves.contains(Square.a1), isTrue);
|
|
case CastlingMethod.kingTwoSquares:
|
|
expect(validMoves.contains(Square.f1), isTrue);
|
|
expect(validMoves.contains(Square.g1), isTrue);
|
|
expect(validMoves.contains(Square.h1), isFalse);
|
|
expect(validMoves.contains(Square.c1), isTrue);
|
|
expect(validMoves.contains(Square.d1), isTrue);
|
|
expect(validMoves.contains(Square.a1), isFalse);
|
|
}
|
|
});
|
|
}
|
|
|
|
for (final castlingMethod in CastlingMethod.values) {
|
|
testWidgets('chess960: $castlingMethod', (tester) async {
|
|
await createTestGame(
|
|
pgn: castlingSetupPgn,
|
|
variant: Variant.chess960,
|
|
initialFen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
|
|
defaultPreferences: {
|
|
PrefCategory.board.storageKey: jsonEncode(
|
|
BoardPrefs.defaults.copyWith(castlingMethod: castlingMethod).toJson(),
|
|
),
|
|
},
|
|
tester,
|
|
);
|
|
|
|
final boardRect = tester.getRect(find.byType(Chessboard));
|
|
await tester.tapAt(squareOffset(Square.e1, boardRect));
|
|
|
|
await tester.pump();
|
|
|
|
// in chess960, castling is only king over rook, no matter the preference
|
|
final validMoves = getBoardValidMoves(tester);
|
|
expect(validMoves.contains(Square.f1), isTrue);
|
|
expect(validMoves.contains(Square.g1), isFalse);
|
|
expect(validMoves.contains(Square.h1), isTrue);
|
|
expect(validMoves.contains(Square.c1), isFalse);
|
|
expect(validMoves.contains(Square.d1), isTrue);
|
|
expect(validMoves.contains(Square.a1), isTrue);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('Tournament Game', () {
|
|
final tournamentGameEvent = makeFullEvent(
|
|
const GameId('qVChCOTc'),
|
|
'',
|
|
whiteUserName: 'Peter',
|
|
blackUserName: 'Steven',
|
|
youAre: Side.white,
|
|
tournament: TournamentMeta(
|
|
id: const TournamentId('id'),
|
|
name: 'Test Tournament',
|
|
clock: (timeLeft: const Duration(minutes: 10), at: DateTime.now()),
|
|
berserkable: true,
|
|
ranks: (white: 42, black: 24),
|
|
),
|
|
);
|
|
testWidgets('displays tournament info', (WidgetTester tester) async {
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: const GameScreen(source: ExistingGameSource(GameFullId('qVChCOTcHSeW'))),
|
|
overrides: {
|
|
lichessClientProvider: lichessClientProvider.overrideWith(
|
|
(ref) => LichessClient(client, ref),
|
|
),
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
// Wait for game screen to load
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
|
|
sendServerSocketMessages(GameController.socketUri(testGameFullId), [tournamentGameEvent]);
|
|
// wait for socket message handling
|
|
await tester.pump();
|
|
|
|
// Should display tournament info
|
|
expect(find.text('Peter'), findsOneWidget);
|
|
expect(find.text('Steven'), findsOneWidget);
|
|
expect(find.text('Test Tournament'), findsOneWidget);
|
|
expect(find.textContaining('#42'), findsOneWidget);
|
|
expect(find.textContaining('#24'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('supports berserking', (WidgetTester tester) async {
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: const GameScreen(source: ExistingGameSource(GameFullId('qVChCOTcHSeW'))),
|
|
overrides: {
|
|
lichessClientProvider: lichessClientProvider.overrideWith(
|
|
(ref) => LichessClient(client, ref),
|
|
),
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
// Wait for game screen to load
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
|
|
sendServerSocketMessages(GameController.socketUri(testGameFullId), [tournamentGameEvent]);
|
|
// wait for socket message handling
|
|
await tester.pump();
|
|
|
|
// Nobody has berserked yet.
|
|
// The widget we're finding is our own berserk button.
|
|
expect(find.byIcon(LichessIcons.body_cut), findsOneWidget);
|
|
|
|
// we berserk
|
|
await tester.tap(find.byIcon(LichessIcons.body_cut));
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
|
|
// No server response yet, so should not yet show the berserk icon next to our name.
|
|
expect(find.byIcon(LichessIcons.body_cut), findsOneWidget);
|
|
|
|
sendServerSocketMessages(GameController.socketUri(testGameFullId), [
|
|
'''{"t": "berserk", "d": "white"}''',
|
|
]);
|
|
// wait for socket message handling
|
|
await tester.pump();
|
|
|
|
// We have berserked, which caused the berserk icon appear next to our name.
|
|
// Also, the berserk button is still there (but disabled).
|
|
expect(find.byIcon(LichessIcons.body_cut), findsNWidgets(2));
|
|
|
|
// opponent berserks
|
|
sendServerSocketMessages(GameController.socketUri(testGameFullId), [
|
|
'''{"t": "berserk", "d": "black"}''',
|
|
]);
|
|
// wait for socket message handling
|
|
await tester.pump();
|
|
|
|
expect(find.byIcon(LichessIcons.body_cut), findsNWidgets(3));
|
|
});
|
|
});
|
|
|
|
group('Clock', () {
|
|
testWidgets('loads on game start', (WidgetTester tester) async {
|
|
await createTestGame(tester);
|
|
expect(findClockWithTime(Side.white, '3:00'), findsOneWidget);
|
|
expect(findClockWithTime(Side.black, '3:00'), findsOneWidget);
|
|
expect(
|
|
tester
|
|
.widgetList<Clock>(find.byType(Clock))
|
|
.where((widget) => widget.active == false)
|
|
.length,
|
|
2,
|
|
reason: 'clocks are not active yet',
|
|
);
|
|
});
|
|
|
|
testWidgets('ticks after the first full move', (WidgetTester tester) async {
|
|
await createTestGame(tester);
|
|
expect(findClockWithTime(Side.white, '3:00'), findsOneWidget);
|
|
expect(findClockWithTime(Side.black, '3:00'), findsOneWidget);
|
|
await playMove(tester, 'e2', 'e4');
|
|
// at that point clock is not yet started
|
|
expect(
|
|
tester
|
|
.widgetList<Clock>(find.byType(Clock))
|
|
.where((widget) => widget.active == false)
|
|
.length,
|
|
2,
|
|
reason: 'clocks are not active yet',
|
|
);
|
|
sendServerSocketMessages(testGameSocketUri, [
|
|
'{"t": "move", "v": 1, "d": {"ply": 1, "uci": "e2e4", "san": "e4", "clock": {"white": 180, "black": 180}}}',
|
|
'{"t": "move", "v": 2, "d": {"ply": 2, "uci": "e7e5", "san": "e5", "clock": {"white": 180, "black": 180}}}',
|
|
]);
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
expect(tester.widget<Clock>(findClock(Side.white)).active, true);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(findClockWithTime(Side.white, '2:59'), findsOneWidget);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(findClockWithTime(Side.white, '2:58'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('ticks immediately when resuming game', (WidgetTester tester) async {
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 e5 Nf3',
|
|
clock: const (
|
|
running: true,
|
|
initial: Duration(minutes: 3),
|
|
increment: Duration(seconds: 2),
|
|
white: Duration(minutes: 2, seconds: 58),
|
|
black: Duration(minutes: 2, seconds: 54),
|
|
emerg: Duration(seconds: 30),
|
|
),
|
|
);
|
|
expect(tester.widget<Clock>(findClock(Side.black)).active, true);
|
|
expect(findClockWithTime(Side.white, '2:58'), findsOneWidget);
|
|
expect(findClockWithTime(Side.black, '2:54'), findsOneWidget);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(findClockWithTime(Side.black, '2:53'), findsOneWidget);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(findClockWithTime(Side.black, '2:52'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('switch timer side after a move', (WidgetTester tester) async {
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 e5',
|
|
clock: const (
|
|
running: true,
|
|
initial: Duration(minutes: 3),
|
|
increment: Duration(seconds: 2),
|
|
white: Duration(minutes: 2, seconds: 58),
|
|
black: Duration(minutes: 3),
|
|
emerg: Duration(seconds: 30),
|
|
),
|
|
);
|
|
expect(tester.widget<Clock>(findClock(Side.white)).active, true);
|
|
// simulates think time of 3s
|
|
await tester.pump(const Duration(seconds: 3));
|
|
await playMove(tester, 'g1', 'f3');
|
|
expect(findClockWithTime(Side.white, '2:55'), findsOneWidget);
|
|
expect(
|
|
tester.widget<Clock>(findClock(Side.white)).active,
|
|
false,
|
|
reason: 'white clock is stopped while waiting for server ack',
|
|
);
|
|
expect(
|
|
tester.widget<Clock>(findClock(Side.black)).active,
|
|
true,
|
|
reason: 'black clock is now active but not yet ticking',
|
|
);
|
|
expect(findClockWithTime(Side.black, '3:00'), findsOneWidget);
|
|
// simulates a long lag just to show the clock is not running yet
|
|
await tester.pump(const Duration(milliseconds: 200));
|
|
expect(findClockWithTime(Side.black, '3:00'), findsOneWidget);
|
|
// server ack having the white clock updated with the increment
|
|
sendServerSocketMessages(testGameSocketUri, [
|
|
'{"t": "move", "v": 1, "d": {"ply": 3, "uci": "g1f3", "san": "Nf3", "clock": {"white": 177, "black": 180}}}',
|
|
]);
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
// we see now the white clock has got its increment
|
|
expect(findClockWithTime(Side.white, '2:57'), findsOneWidget);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
// black clock is ticking
|
|
expect(findClockWithTime(Side.black, '2:59'), findsOneWidget);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(findClockWithTime(Side.white, '2:57'), findsOneWidget);
|
|
expect(findClockWithTime(Side.black, '2:58'), findsOneWidget);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(findClockWithTime(Side.white, '2:57'), findsOneWidget);
|
|
expect(findClockWithTime(Side.black, '2:57'), findsOneWidget);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(findClockWithTime(Side.white, '2:57'), findsOneWidget);
|
|
expect(findClockWithTime(Side.black, '2:56'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('compensates opponent lag', (WidgetTester tester) async {
|
|
int socketVersion = 0;
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 e5 Nf3 Nc6',
|
|
clock: const (
|
|
running: true,
|
|
initial: Duration(minutes: 1),
|
|
increment: Duration.zero,
|
|
white: Duration(seconds: 58),
|
|
black: Duration(seconds: 54),
|
|
emerg: Duration(seconds: 10),
|
|
),
|
|
socketVersion: socketVersion,
|
|
);
|
|
await tester.pump(const Duration(seconds: 3));
|
|
await playMoveWithServerAck(
|
|
testGameFullId,
|
|
tester,
|
|
'f1',
|
|
'c4',
|
|
ply: 5,
|
|
san: 'Bc4',
|
|
clockAck: (
|
|
white: const Duration(seconds: 55),
|
|
black: const Duration(seconds: 54),
|
|
lag: const Duration(milliseconds: 250),
|
|
),
|
|
socketVersion: ++socketVersion,
|
|
);
|
|
// black clock is active
|
|
expect(tester.widget<Clock>(findClock(Side.black)).active, true);
|
|
expect(findClockWithTime(Side.black, '0:54'), findsOneWidget);
|
|
await tester.pump(const Duration(milliseconds: 250));
|
|
// lag is 250ms, so clock will only start after that delay
|
|
expect(findClockWithTime(Side.black, '0:54'), findsOneWidget);
|
|
await tester.pump(const Duration(milliseconds: 100));
|
|
expect(findClockWithTime(Side.black, '0:53'), findsOneWidget);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(findClockWithTime(Side.black, '0:52'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('onEmergency', (WidgetTester tester) async {
|
|
final mockSoundService = MockSoundService();
|
|
when(() => mockSoundService.play(Sound.lowTime)).thenAnswer((_) async {});
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 e5',
|
|
clock: const (
|
|
running: true,
|
|
initial: Duration(minutes: 3),
|
|
increment: Duration(seconds: 2),
|
|
white: Duration(seconds: 40),
|
|
black: Duration(minutes: 3),
|
|
emerg: Duration(seconds: 30),
|
|
),
|
|
overrides: {
|
|
soundServiceProvider: soundServiceProvider.overrideWith((_) => mockSoundService),
|
|
},
|
|
);
|
|
expect(
|
|
tester.widget<Clock>(findClockWithTime(Side.white, '0:40')).emergencyThreshold,
|
|
const Duration(seconds: 30),
|
|
);
|
|
await tester.pump(const Duration(seconds: 10));
|
|
expect(findClockWithTime(Side.white, '0:30'), findsOneWidget);
|
|
verify(() => mockSoundService.play(Sound.lowTime)).called(1);
|
|
});
|
|
|
|
testWidgets('flags', (WidgetTester tester) async {
|
|
final socketFactory = ListenableFakeWebSocketChannelFactory(
|
|
createDefaultFakeWebSocketChannel,
|
|
);
|
|
await createTestGame(
|
|
tester,
|
|
socketFactory: socketFactory,
|
|
pgn: 'e4 e5 Nf3',
|
|
clock: const (
|
|
running: true,
|
|
initial: Duration(minutes: 3),
|
|
increment: Duration(seconds: 2),
|
|
white: Duration(minutes: 2, seconds: 58),
|
|
black: Duration(minutes: 2, seconds: 54),
|
|
emerg: Duration(seconds: 30),
|
|
),
|
|
);
|
|
expect(tester.widget<Clock>(findClock(Side.black)).active, true);
|
|
|
|
expect(findClockWithTime(Side.white, '2:58'), findsOneWidget);
|
|
expect(findClockWithTime(Side.black, '2:54'), findsOneWidget);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
expect(findClockWithTime(Side.black, '2:53'), findsOneWidget);
|
|
await tester.pump(const Duration(minutes: 2, seconds: 53));
|
|
expect(findClockWithTime(Side.white, '2:58'), findsOneWidget);
|
|
expect(findClockWithTime(Side.black, '0:00.0'), findsOneWidget);
|
|
|
|
expect(
|
|
tester.widgetList<Clock>(find.byType(Clock)).first.active,
|
|
true,
|
|
reason:
|
|
'black clock is still active after flag (as long as we have not received server ack)',
|
|
);
|
|
|
|
// flag messages are throttled with 500ms delay
|
|
// we'll simulate an anormally long server response of 1s to check 2
|
|
// flag messages are sent
|
|
expectLater(
|
|
socketFactory.outgoingMessages(testGameSocketUri),
|
|
emitsInOrder(['{"t":"flag","d":"black"}', '{"t":"flag","d":"black"}']),
|
|
);
|
|
await tester.pump(const Duration(seconds: 1));
|
|
sendServerSocketMessages(testGameSocketUri, [
|
|
'{"t":"endData","d":{"status":"outoftime","winner":"white","clock":{"wc":17800,"bc":0}}}',
|
|
]);
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
|
|
expect(
|
|
tester
|
|
.widgetList<Clock>(find.byType(Clock))
|
|
.where((widget) => widget.active == false)
|
|
.length,
|
|
2,
|
|
reason: 'both clocks are now inactive',
|
|
);
|
|
expect(findClockWithTime(Side.white, '2:58'), findsOneWidget);
|
|
expect(findClockWithTime(Side.black, '0:00.00'), findsOneWidget);
|
|
|
|
// wait for the dong
|
|
await tester.pump(const Duration(seconds: 500));
|
|
});
|
|
});
|
|
|
|
group('Correspondence Clock', () {
|
|
// Retrieves the CorrespondenceClock widget that contains the given displayed time.
|
|
CorrespondenceClock correspondenceClockWithTime(WidgetTester tester, String time) {
|
|
return tester.widget<CorrespondenceClock>(
|
|
find.ancestor(
|
|
of: find.text(time, findRichText: true),
|
|
matching: find.byType(CorrespondenceClock),
|
|
),
|
|
);
|
|
}
|
|
|
|
testWidgets('shows correspondence clocks, not regular clocks', (tester) async {
|
|
await createTestGame(
|
|
tester,
|
|
clock: null,
|
|
pgn: 'e4 e5',
|
|
correspondenceClock: (
|
|
white: const Duration(hours: 20, minutes: 5),
|
|
black: const Duration(days: 1),
|
|
daysPerTurn: 1,
|
|
),
|
|
);
|
|
|
|
expect(find.byType(Clock), findsNothing);
|
|
expect(find.byType(CorrespondenceClock), findsNWidgets(2));
|
|
expect(find.text('One day', findRichText: true), findsOneWidget);
|
|
expect(find.text('20:05', findRichText: true), findsOneWidget);
|
|
});
|
|
|
|
testWidgets("active clock is white's when it is white's turn", (tester) async {
|
|
// pgn 'e4 e5': fullmoves = 2, white to move → white active
|
|
await createTestGame(
|
|
tester,
|
|
clock: null,
|
|
pgn: 'e4 e5',
|
|
correspondenceClock: (
|
|
white: const Duration(hours: 20, minutes: 5),
|
|
black: const Duration(days: 1),
|
|
daysPerTurn: 1,
|
|
),
|
|
);
|
|
|
|
expect(correspondenceClockWithTime(tester, '20:05').active, isTrue);
|
|
expect(correspondenceClockWithTime(tester, 'One day').active, isFalse);
|
|
|
|
await tester.pump(const Duration(minutes: 2));
|
|
|
|
expect(find.text('20:05', findRichText: true), findsNothing);
|
|
expect(find.text('20:03', findRichText: true), findsOneWidget);
|
|
expect(find.text('One day', findRichText: true), findsOneWidget);
|
|
});
|
|
|
|
testWidgets("active clock is black's when it is black's turn", (tester) async {
|
|
// pgn 'e4 e5 Nf3': black to move → black active
|
|
await createTestGame(
|
|
tester,
|
|
clock: null,
|
|
pgn: 'e4 e5 Nf3',
|
|
correspondenceClock: (
|
|
white: const Duration(days: 1),
|
|
black: const Duration(hours: 19, minutes: 59),
|
|
daysPerTurn: 1,
|
|
),
|
|
);
|
|
|
|
expect(correspondenceClockWithTime(tester, '19:59').active, isTrue);
|
|
expect(correspondenceClockWithTime(tester, 'One day').active, isFalse);
|
|
|
|
await tester.pump(const Duration(minutes: 5));
|
|
|
|
expect(find.text('19:59', findRichText: true), findsNothing);
|
|
expect(find.text('19:54', findRichText: true), findsOneWidget);
|
|
expect(find.text('One day', findRichText: true), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('clock values and active side update after opponent move', (tester) async {
|
|
await createTestGame(
|
|
tester,
|
|
clock: null,
|
|
pgn: 'e4 e5 Nf3', // black to move → black active
|
|
correspondenceClock: (
|
|
white: const Duration(days: 1),
|
|
black: const Duration(hours: 10, minutes: 3),
|
|
daysPerTurn: 1,
|
|
),
|
|
);
|
|
|
|
expect(correspondenceClockWithTime(tester, '10:03').active, isTrue);
|
|
|
|
await tester.pump(const Duration(minutes: 3));
|
|
expect(find.text('10:00', findRichText: true), findsOneWidget);
|
|
|
|
// server sends black's move with updated clock values
|
|
sendServerSocketMessages(testGameSocketUri, [
|
|
'{"t": "move", "v": 1, "d": {"ply": 4, "uci": "g8f6", "san": "Nf6", "clock": {"white": 86400, "black": 86400}}}',
|
|
]);
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
|
|
// both clocks reset to server's authoritative values
|
|
expect(find.text('One day', findRichText: true), findsNWidgets(2));
|
|
|
|
await tester.pump(const Duration(seconds: 2));
|
|
|
|
// white is now active
|
|
expect(correspondenceClockWithTime(tester, '23:59').active, isTrue);
|
|
expect(correspondenceClockWithTime(tester, 'One day').active, isFalse);
|
|
});
|
|
});
|
|
|
|
group('Opening analysis', () {
|
|
testWidgets('is not possible for an unfinished real time game', (WidgetTester tester) async {
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 e5 Nf3 Nc6 Bc4 Nf6 Ng5 d5 exd5 Na5 Bb5+ c6 dxc6 bxc6 Qf3 Rb8 Bd3',
|
|
socketVersion: 0,
|
|
);
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
await tester.tap(find.byIcon(Icons.menu));
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
expect(find.byType(Dialog), findsOneWidget);
|
|
expect(find.text('Analysis board'), findsNothing);
|
|
});
|
|
|
|
testWidgets('for an unfinished correspondence game', (WidgetTester tester) async {
|
|
const gameFullId = GameFullId('qVChCOTcHSeW');
|
|
final fullEventString = makeFullEvent(
|
|
gameFullId.gameId,
|
|
'e4 e5 Nf3 Nc6 Bc4 Nf6 Ng5 d5 exd5 Na5 Bb5+ c6 dxc6 bxc6 Qf3 Rb8 Bd3',
|
|
whiteUserName: 'Peter',
|
|
blackUserName: 'Steven',
|
|
socketVersion: 0,
|
|
clock: null,
|
|
correspondenceClock: (
|
|
daysPerTurn: 3,
|
|
white: const Duration(days: 3),
|
|
black: const Duration(days: 2, hours: 22, minutes: 49, seconds: 59),
|
|
),
|
|
);
|
|
|
|
// AnalysisScreen uses this to get the game data
|
|
final mockClient = MockClient((request) {
|
|
if (request.url.path == '/$gameFullId/forecasts') {
|
|
return mockResponse(
|
|
jsonEncode(
|
|
SocketEvent.fromJson(jsonDecode(fullEventString) as Map<String, dynamic>).data,
|
|
),
|
|
200,
|
|
);
|
|
}
|
|
return mockResponse('', 404);
|
|
});
|
|
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: const GameScreen(source: ExistingGameSource(gameFullId)),
|
|
overrides: {
|
|
lichessClientProvider: lichessClientProvider.overrideWith(
|
|
(ref) => LichessClient(mockClient, ref),
|
|
),
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
|
|
sendServerSocketMessages(GameController.socketUri(gameFullId), [fullEventString]);
|
|
await tester.pump();
|
|
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(boardHasPiece(tester, Square.d3, Piece.whiteBishop), isTrue);
|
|
expect(getBoardLastMove(tester)?.hasSquare(Square.b5), isTrue);
|
|
expect(getBoardLastMove(tester)?.hasSquare(Square.d3), isTrue);
|
|
await tester.tap(find.byIcon(Icons.menu));
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
expect(find.byType(Dialog), findsOneWidget);
|
|
await tester.tap(find.text('Analysis board'));
|
|
await tester.pumpAndSettle(); // wait for analysis screen to open
|
|
expect(
|
|
find.widgetWithText(AppBar, 'Analysis board'),
|
|
findsOneWidget,
|
|
); // analysis screen is now open
|
|
expect(boardHasPiece(tester, Square.f3, Piece.whiteQueen), isTrue);
|
|
expect(boardHasPiece(tester, Square.d3, Piece.whiteBishop), isTrue);
|
|
expect(getBoardLastMove(tester)?.hasSquare(Square.b5), isTrue);
|
|
expect(getBoardLastMove(tester)?.hasSquare(Square.d3), isTrue);
|
|
expect(find.bySemanticsLabel(RegExp('Moves played')), findsOneWidget);
|
|
// computer analysis is not available when game is not finished
|
|
expect(find.bySemanticsLabel(RegExp('Computer analysis')), findsNothing);
|
|
});
|
|
|
|
testWidgets('for a finished game', (WidgetTester tester) async {
|
|
await loadFinishedTestGame(tester);
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(boardHasPiece(tester, Square.e6, Piece.whiteQueen), isTrue);
|
|
expect(getBoardLastMove(tester)?.hasSquare(Square.d5), isTrue);
|
|
expect(getBoardLastMove(tester)?.hasSquare(Square.e6), isTrue);
|
|
await tester.pump(const Duration(milliseconds: 500)); // wait for popup
|
|
await tester.tap(find.text('Analysis board'));
|
|
await tester.pumpAndSettle(); // wait for analysis screen to open
|
|
expect(
|
|
find.descendant(of: find.byType(AppBar), matching: find.textContaining('Rated')),
|
|
findsOneWidget,
|
|
); // analysis screen is now open
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(boardHasPiece(tester, Square.e6, Piece.whiteQueen), isTrue);
|
|
expect(getBoardLastMove(tester)?.hasSquare(Square.d5), isTrue);
|
|
expect(getBoardLastMove(tester)?.hasSquare(Square.e6), isTrue);
|
|
expect(find.bySemanticsLabel(RegExp('Moves played')), findsOneWidget);
|
|
expect(
|
|
find.bySemanticsLabel(RegExp('Computer analysis')),
|
|
findsOneWidget,
|
|
); // computer analysis is available
|
|
});
|
|
});
|
|
|
|
group('Chat', () {
|
|
group('Enabled', () {
|
|
testWidgets('onNewMessage', (WidgetTester tester) async {
|
|
final mockSoundService = MockSoundService();
|
|
when(
|
|
() => mockSoundService.play(Sound.confirmation, volume: any(named: 'volume')),
|
|
).thenAnswer((_) async {});
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 e5',
|
|
overrides: {
|
|
soundServiceProvider: soundServiceProvider.overrideWith((_) => mockSoundService),
|
|
},
|
|
);
|
|
sendServerSocketMessages(testGameSocketUri, [
|
|
'{"t":"message","d":{"u":"Steven","t":"Hello!"}}',
|
|
]);
|
|
await tester.pump();
|
|
verify(
|
|
() => mockSoundService.play(Sound.confirmation, volume: any(named: 'volume')),
|
|
).called(1);
|
|
});
|
|
|
|
testWidgets('chat messages do not disappear when game state changes', (
|
|
WidgetTester tester,
|
|
) async {
|
|
await createTestGame(tester, pgn: 'e4 e5');
|
|
sendServerSocketMessages(testGameSocketUri, [
|
|
'{"t":"message","d":{"u":"Steven","t":"Hello!"}}',
|
|
]);
|
|
await tester.pump();
|
|
|
|
// Play a move to update the GameController's state.
|
|
// There used to be a bug where this would make chat messages disappear.
|
|
await playMove(tester, 'g1', 'f3');
|
|
|
|
await tester.tap(find.byType(ChatBottomBarButton));
|
|
await tester.pumpAndSettle(); // wait for chat to open
|
|
|
|
expect(find.text('Hello!'), findsOneWidget);
|
|
});
|
|
});
|
|
|
|
group('Disabled', () {
|
|
testWidgets('onNewMessage', (WidgetTester tester) async {
|
|
final mockSoundService = MockSoundService();
|
|
when(() => mockSoundService.play(Sound.confirmation)).thenAnswer((_) async {});
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 e5',
|
|
defaultPreferences: {PrefCategory.game.storageKey: '{"enableChat": false}'},
|
|
overrides: {
|
|
soundServiceProvider: soundServiceProvider.overrideWith((_) => mockSoundService),
|
|
},
|
|
);
|
|
sendServerSocketMessages(testGameSocketUri, [
|
|
'{"t":"message","d":{"u":"Steven","t":"Hello!"}}',
|
|
]);
|
|
await tester.pump();
|
|
verifyNever(() => mockSoundService.play(Sound.confirmation));
|
|
});
|
|
});
|
|
});
|
|
|
|
group('Crazyhouse', () {
|
|
testWidgets('displays pockets and handles player drop moves', (tester) async {
|
|
final socketFactory = ListenableFakeWebSocketChannelFactory(
|
|
createDefaultFakeWebSocketChannel,
|
|
);
|
|
// After 1.e4 d5 2.exd5 Qxd5, white has a pawn in pocket and it's white's turn
|
|
await createTestGame(
|
|
tester,
|
|
variant: Variant.crazyhouse,
|
|
pgn: 'e4 d5 exd5 Qxd5',
|
|
youAre: Side.white,
|
|
socketFactory: socketFactory,
|
|
);
|
|
|
|
final dropExpectation = expectLater(
|
|
socketFactory.outgoingMessages(testGameSocketUri),
|
|
emitsThrough('{"t":"drop","d":{"role":"pawn","pos":"c4","s":"0","a":1}}'),
|
|
);
|
|
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(find.byType(PocketsMenu), findsNWidgets(2));
|
|
|
|
// White drops a pawn to c4
|
|
await playDropMove(tester, Side.white, Role.pawn, 'c4');
|
|
await tester.pumpAndSettle();
|
|
|
|
// Pawn should appear on c4 (transient move before server ack)
|
|
expect(boardHasPiece(tester, Square.c4, Piece.whitePawn), isTrue);
|
|
|
|
await dropExpectation;
|
|
});
|
|
|
|
testWidgets('pocket count display updates after a player drop move', (tester) async {
|
|
// Regression test: the pocket counts are rendered by the GameLayout from
|
|
// the board params. With the high-performance board, a move no longer
|
|
// rebuilds the layout shell, so the displayed pocket count could go stale
|
|
// (the dropped pawn would still show in the pocket after being played).
|
|
|
|
// After 1.e4 d5 2.exd5 Qxd5, white has a pawn in pocket and it's white's turn.
|
|
await createTestGame(
|
|
tester,
|
|
variant: Variant.crazyhouse,
|
|
pgn: 'e4 d5 exd5 Qxd5',
|
|
youAre: Side.white,
|
|
);
|
|
|
|
final whitePawnPocket = find.byKey(const ValueKey('pocket-whitepawn'));
|
|
expect(whitePawnPocket, findsOneWidget);
|
|
|
|
// The white pawn pocket initially shows a count badge of 1.
|
|
expect(find.descendant(of: whitePawnPocket, matching: find.text('1')), findsOneWidget);
|
|
|
|
// White drops the pawn to c4.
|
|
await playDropMove(tester, Side.white, Role.pawn, 'c4');
|
|
await tester.pumpAndSettle();
|
|
|
|
// The pawn is on the board and the pocket count badge is gone (count 0).
|
|
expect(boardHasPiece(tester, Square.c4, Piece.whitePawn), isTrue);
|
|
expect(find.descendant(of: whitePawnPocket, matching: find.text('1')), findsNothing);
|
|
});
|
|
|
|
testWidgets("Cannot interact with the opponent's pockets", (tester) async {
|
|
// After 1.e4 d5 2.exd5 Qxd5 Nf3, white and black both have a pawn in pocket and it's black's turn
|
|
await createTestGame(
|
|
tester,
|
|
variant: Variant.crazyhouse,
|
|
pgn: 'e4 d5 exd5 Qxd5 Nf3',
|
|
youAre: Side.white,
|
|
);
|
|
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(find.byType(PocketsMenu), findsNWidgets(2));
|
|
|
|
// Regression test: it used to be possible to interact with the opponent's pockets and play a DropMove for them
|
|
await playDropMove(tester, Side.black, Role.pawn, 'd6');
|
|
await tester.pumpAndSettle();
|
|
|
|
// Move should not be played since it's not our turn and the opponent's pockets should not be interactable
|
|
expect(boardHasPiece(tester, Square.d6, Piece.blackPawn), isFalse);
|
|
});
|
|
|
|
testWidgets('correctly handles opponent drop move received from server', (tester) async {
|
|
const gameFullId = GameFullId('qVChCOTcHSeW');
|
|
final gameSocketUri = GameController.socketUri(gameFullId);
|
|
|
|
// After 1.e4 d5 2.exd5 Qxd5, white has a pawn in pocket and it's white's turn
|
|
await createTestGame(
|
|
tester,
|
|
variant: Variant.crazyhouse,
|
|
pgn: 'e4 d5 exd5 Qxd5',
|
|
youAre: Side.black,
|
|
);
|
|
|
|
expect(find.byType(Chessboard), findsOneWidget);
|
|
expect(find.byType(PocketsMenu), findsNWidgets(2));
|
|
|
|
// Server sends white's drop move P@c4 (ply 5 after the 4 pgn moves)
|
|
sendServerSocketMessages(gameSocketUri, [
|
|
'{"t": "drop", "v": 1, "d": {"role": "pawn", "ply": 5, "uci": "P@c4", "san": "P@c4", "clock": {"white": 176, "black": 180}}}',
|
|
]);
|
|
await tester.pump();
|
|
|
|
// White pawn should appear on c4
|
|
expect(boardHasPiece(tester, Square.c4, Piece.whitePawn), isTrue);
|
|
});
|
|
});
|
|
|
|
group('Widget rebuilds', () {
|
|
// These tests guard the GameScreen performance optimization: a move must
|
|
// update the board through the ChessboardController WITHOUT rebuilding the
|
|
// expensive ancestors (GameScreen/GameBody, the GameLayout shell, the board).
|
|
// Only the leaf widgets that depend on the move (e.g. the player tables) may
|
|
// rebuild. The rebuild probe is widget-instance identity: if an ancestor does
|
|
// not rebuild, the child widget instance found in the tree is unchanged.
|
|
|
|
testWidgets('a local move does not rebuild GameBody, GameLayout or the board', (tester) async {
|
|
await createTestGame(tester, pgn: 'e4 e5'); // white (us) to move
|
|
// Flush the one-time load rebuilds (e.g. the real-time-playable future resolving).
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
final gameBodyBefore = tester.widget<GameBody>(find.byType(GameBody));
|
|
final gameLayoutBefore = tester.widget<GameLayout>(find.byType(GameLayout));
|
|
final boardBefore = tester.widget<Chessboard>(find.byType(Chessboard));
|
|
final playerBefore = tester.widgetList<GamePlayer>(find.byType(GamePlayer)).first;
|
|
final bottomBarBefore = tester.widget<BottomBar>(find.byType(BottomBar));
|
|
|
|
await playMove(tester, 'g1', 'f3');
|
|
await tester.pump();
|
|
|
|
// The move reached the board (controller-driven repaint)…
|
|
expect(boardHasPiece(tester, Square.f3, Piece.whiteKnight), isTrue);
|
|
|
|
// …but the expensive ancestors were not rebuilt.
|
|
expect(
|
|
identical(tester.widget<GameBody>(find.byType(GameBody)), gameBodyBefore),
|
|
isTrue,
|
|
reason: 'GameScreen must not rebuild GameBody on a move',
|
|
);
|
|
expect(
|
|
identical(tester.widget<GameLayout>(find.byType(GameLayout)), gameLayoutBefore),
|
|
isTrue,
|
|
reason: 'the GameLayout shell must not rebuild on a move',
|
|
);
|
|
expect(
|
|
identical(tester.widget<Chessboard>(find.byType(Chessboard)), boardBefore),
|
|
isTrue,
|
|
reason: 'the board must not rebuild on a move (the controller drives the repaint)',
|
|
);
|
|
|
|
// The player table (material diff) is a contained, necessary rebuild.
|
|
expect(
|
|
identical(tester.widgetList<GamePlayer>(find.byType(GamePlayer)).first, playerBefore),
|
|
isFalse,
|
|
reason: 'the player table should rebuild on a move',
|
|
);
|
|
|
|
// The bottom bar (minus the isolated prev/next nav buttons) watches only
|
|
// discrete flags that don't change on a plain move, so it must not rebuild.
|
|
expect(
|
|
identical(tester.widget<BottomBar>(find.byType(BottomBar)), bottomBarBefore),
|
|
isTrue,
|
|
reason: 'the bottom bar must not rebuild on a move',
|
|
);
|
|
});
|
|
|
|
testWidgets('an opponent move does not rebuild GameBody, GameLayout or the board', (
|
|
tester,
|
|
) async {
|
|
await createTestGame(tester, pgn: 'e4 e5 Nf3'); // black (opponent) to move
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
|
|
final gameBodyBefore = tester.widget<GameBody>(find.byType(GameBody));
|
|
final gameLayoutBefore = tester.widget<GameLayout>(find.byType(GameLayout));
|
|
final boardBefore = tester.widget<Chessboard>(find.byType(Chessboard));
|
|
final bottomBarBefore = tester.widget<BottomBar>(find.byType(BottomBar));
|
|
|
|
// Opponent (black) plays Nf6, received from the server.
|
|
sendServerSocketMessages(testGameSocketUri, [
|
|
'{"t": "move", "v": 1, "d": {"ply": 4, "uci": "g8f6", "san": "Nf6", "clock": {"white": 180, "black": 180}}}',
|
|
]);
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
|
|
expect(boardHasPiece(tester, Square.f6, Piece.blackKnight), isTrue);
|
|
|
|
expect(
|
|
identical(tester.widget<GameBody>(find.byType(GameBody)), gameBodyBefore),
|
|
isTrue,
|
|
reason: 'GameScreen must not rebuild GameBody on an opponent move',
|
|
);
|
|
expect(
|
|
identical(tester.widget<GameLayout>(find.byType(GameLayout)), gameLayoutBefore),
|
|
isTrue,
|
|
reason: 'the GameLayout shell must not rebuild on an opponent move',
|
|
);
|
|
expect(
|
|
identical(tester.widget<Chessboard>(find.byType(Chessboard)), boardBefore),
|
|
isTrue,
|
|
reason: 'the board must not rebuild on an opponent move',
|
|
);
|
|
expect(
|
|
identical(tester.widget<BottomBar>(find.byType(BottomBar)), bottomBarBefore),
|
|
isTrue,
|
|
reason: 'the bottom bar must not rebuild on an opponent move',
|
|
);
|
|
});
|
|
});
|
|
|
|
group('Wakelock', () {
|
|
for (final gameStatus in GameStatus.values) {
|
|
final gameIsFinished = gameStatus != GameStatus.started && gameStatus != GameStatus.created;
|
|
testWidgets(
|
|
'${gameIsFinished ? 'disables' : 'does not disable'} when game status is ${gameStatus.name}',
|
|
(tester) async {
|
|
final List<ToggleMessage> messages = <ToggleMessage>[];
|
|
const pigeonCodec = _PigeonCodec();
|
|
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMessageHandler(
|
|
'dev.flutter.pigeon.wakelock_plus_platform_interface.WakelockPlusApi.toggle',
|
|
(ByteData? data) async {
|
|
final decodedMessages = (pigeonCodec.decodeMessage(data) as List)
|
|
.cast<ToggleMessage>();
|
|
messages.add(decodedMessages.single);
|
|
return data;
|
|
},
|
|
);
|
|
|
|
addTearDown(() {
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMessageHandler(
|
|
'dev.flutter.pigeon.wakelock_plus_platform_interface.WakelockPlusApi.toggle',
|
|
null,
|
|
);
|
|
});
|
|
|
|
await createTestGame(
|
|
tester,
|
|
pgn: 'e4 e5',
|
|
defaultPreferences: {PrefCategory.game.storageKey: '{"enableChat": false}'},
|
|
);
|
|
|
|
sendServerSocketMessages(testGameSocketUri, [
|
|
'{"t":"endData","d":{"status":"${gameStatus.name}","winner":"white","clock":{"wc":17800,"bc":0}}}',
|
|
]);
|
|
|
|
await tester.pump();
|
|
|
|
await tester.pump(const Duration(seconds: 500));
|
|
expect(messages.last.enable, gameIsFinished ? isFalse : isTrue);
|
|
},
|
|
);
|
|
}
|
|
});
|
|
|
|
group('Claim win', () {
|
|
testWidgets('shows the countdown when the opponent leaves and claims victory', (
|
|
WidgetTester tester,
|
|
) async {
|
|
final socketFactory = ListenableFakeWebSocketChannelFactory(
|
|
createDefaultFakeWebSocketChannel,
|
|
);
|
|
await createTestGame(
|
|
tester,
|
|
socketFactory: socketFactory,
|
|
// fullmoves >= 2 so the game is resignable (not abortable), and it is the
|
|
// opponent's turn so the claim-win countdown is allowed to show.
|
|
pgn: 'e4 e5 Nf3',
|
|
clock: const (
|
|
running: true,
|
|
initial: Duration(minutes: 3),
|
|
increment: Duration(seconds: 2),
|
|
white: Duration(minutes: 2, seconds: 58),
|
|
black: Duration(minutes: 2, seconds: 54),
|
|
emerg: Duration(seconds: 30),
|
|
),
|
|
);
|
|
|
|
// No countdown until the opponent leaves.
|
|
expect(find.byType(CountdownClockBuilder), findsNothing);
|
|
|
|
// 'goneIn' announces the opponent left and starts the claim-win countdown.
|
|
sendServerSocketMessages(testGameSocketUri, ['{"t": "goneIn", "v": 1, "d": 30}']);
|
|
await tester.pump();
|
|
expect(find.textContaining('claim victory in 30 seconds'), findsOneWidget);
|
|
|
|
final countdownButton = find.ancestor(
|
|
of: find.textContaining('claim victory in 30 seconds'),
|
|
matching: find.byType(InkWell),
|
|
);
|
|
|
|
// Victory is not claimable until the 'gone' threshold is reached, so the
|
|
// countdown is not tappable yet (InkWell.onTap is null).
|
|
await tester.tap(countdownButton, warnIfMissed: false);
|
|
await tester.pump();
|
|
expect(find.text('Claim victory'), findsNothing);
|
|
|
|
// 'gone' confirms the opponent has been gone long enough to claim.
|
|
sendServerSocketMessages(testGameSocketUri, ['{"t": "gone", "v": 2, "d": true}']);
|
|
await tester.pump();
|
|
|
|
// Tapping the countdown now opens the claim-win dialog. (warnIfMissed: the
|
|
// tap reaches the InkWell's gesture listener, but layered widgets mean the
|
|
// InkWell RenderBox isn't the topmost hit-test object at its center.)
|
|
await tester.tap(countdownButton, warnIfMissed: false);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
expect(find.text('Claim victory'), findsOneWidget);
|
|
expect(find.text('Call draw'), findsOneWidget);
|
|
|
|
// Claiming victory sends the force-resign message and closes the dialog.
|
|
expectLater(socketFactory.outgoingMessages(testGameSocketUri), emits('{"t":"resign-force"}'));
|
|
await tester.tap(find.text('Claim victory'));
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 500));
|
|
expect(find.text('Claim victory'), findsNothing);
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Finds the clock on the specified [side].
|
|
Finder findClock(Side side, {bool skipOffstage = true}) {
|
|
return find.byKey(ValueKey('${side.name}-clock'), skipOffstage: skipOffstage);
|
|
}
|
|
|
|
/// Finds the clock with the given [text] on the specified [side].
|
|
Finder findClockWithTime(Side side, String text, {bool skipOffstage = true}) {
|
|
return find.ancestor(
|
|
of: find.text(text, findRichText: true, skipOffstage: skipOffstage),
|
|
matching: find.byKey(ValueKey('${side.name}-clock'), skipOffstage: skipOffstage),
|
|
);
|
|
}
|
|
|
|
/// Simulates playing a move and getting the ack from the server after [elapsedTime].
|
|
Future<void> playMoveWithServerAck(
|
|
GameFullId gameFullId,
|
|
WidgetTester tester,
|
|
String from,
|
|
String to, {
|
|
required String san,
|
|
required ({Duration white, Duration black, Duration? lag}) clockAck,
|
|
required int socketVersion,
|
|
required int ply,
|
|
Duration elapsedTime = const Duration(milliseconds: 10),
|
|
Side orientation = Side.white,
|
|
}) async {
|
|
await playMove(tester, from, to, orientation: orientation);
|
|
final uci = '$from$to';
|
|
final lagStr = clockAck.lag != null
|
|
? ', "lag": ${(clockAck.lag!.inMilliseconds / 10).round()}'
|
|
: '';
|
|
await tester.pump(elapsedTime - const Duration(milliseconds: 1));
|
|
sendServerSocketMessages(GameController.socketUri(gameFullId), [
|
|
'{"t": "move", "v": $socketVersion, "d": {"ply": $ply, "uci": "$uci", "san": "$san", "clock": {"white": ${(clockAck.white.inMilliseconds / 1000).toStringAsFixed(2)}, "black": ${(clockAck.black.inMilliseconds / 1000).toStringAsFixed(2)}$lagStr}}}',
|
|
]);
|
|
await tester.pump();
|
|
}
|
|
|
|
/// Convenient function to start a new test game
|
|
Future<void> createTestGame(
|
|
WidgetTester tester, {
|
|
Variant variant = Variant.standard,
|
|
String? initialFen,
|
|
Side? youAre = Side.white,
|
|
String? pgn,
|
|
int socketVersion = 0,
|
|
FullEventTestClock? clock = const (
|
|
running: false,
|
|
initial: Duration(minutes: 3),
|
|
increment: Duration(seconds: 2),
|
|
white: Duration(minutes: 3),
|
|
black: Duration(minutes: 3),
|
|
emerg: Duration(seconds: 30),
|
|
),
|
|
FullEventTestCorrespondenceClock? correspondenceClock,
|
|
Map<String, Object>? defaultPreferences,
|
|
Map<ProviderOrFamily, Override>? overrides,
|
|
TournamentMeta? tournament,
|
|
ServerGamePrefs? serverPrefs,
|
|
|
|
/// An optional listenable fake web socket channel factory to use in place of the default one if
|
|
/// we need to listen to the sent messages.
|
|
ListenableFakeWebSocketChannelFactory? socketFactory,
|
|
}) async {
|
|
const gameFullId = GameFullId('qVChCOTcHSeW');
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: const GameScreen(source: ExistingGameSource(gameFullId)),
|
|
defaultPreferences: defaultPreferences,
|
|
overrides: {
|
|
lichessClientProvider: lichessClientProvider.overrideWith(
|
|
(ref) => LichessClient(client, ref),
|
|
),
|
|
if (socketFactory != null)
|
|
webSocketChannelFactoryProvider: webSocketChannelFactoryProvider.overrideWith((ref) {
|
|
ref.onDispose(socketFactory.dispose);
|
|
return socketFactory;
|
|
}),
|
|
...?overrides,
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
|
|
sendServerSocketMessages(GameController.socketUri(gameFullId), [
|
|
makeFullEvent(
|
|
variant: variant,
|
|
const GameId('qVChCOTc'),
|
|
pgn ?? '',
|
|
initialFen: initialFen,
|
|
whiteUserName: 'Peter',
|
|
blackUserName: 'Steven',
|
|
youAre: youAre,
|
|
socketVersion: socketVersion,
|
|
clock: clock,
|
|
correspondenceClock: correspondenceClock,
|
|
tournament: tournament,
|
|
serverPrefs: serverPrefs,
|
|
),
|
|
]);
|
|
await tester.pump();
|
|
}
|
|
|
|
Future<void> loadFinishedTestGame(
|
|
WidgetTester tester, {
|
|
String serverFullEvent = _finishedGameFullEvent,
|
|
Map<ProviderOrFamily, Override>? overrides,
|
|
}) async {
|
|
final json = jsonDecode(serverFullEvent) as Map<String, dynamic>;
|
|
final gameId = GameFullEvent.fromJson(json['d'] as Map<String, dynamic>).game.id;
|
|
final gameFullId = GameFullId('${gameId.value}test');
|
|
final app = await makeTestProviderScopeApp(
|
|
tester,
|
|
home: GameScreen(source: ExistingGameSource(gameFullId)),
|
|
overrides: {
|
|
lichessClientProvider: lichessClientProvider.overrideWith(
|
|
(ref) => LichessClient(client, ref),
|
|
),
|
|
...?overrides,
|
|
},
|
|
);
|
|
await tester.pumpWidget(app);
|
|
await tester.pump(const Duration(milliseconds: 10));
|
|
// wait for socket
|
|
await tester.pump(kFakeWebSocketConnectionLag);
|
|
|
|
sendServerSocketMessages(GameController.socketUri(gameFullId), [serverFullEvent]);
|
|
await tester.pump();
|
|
}
|
|
|
|
const _finishedGameFullEvent = '''
|
|
{"t":"full","d":{"game":{"id":"CCW6EEru","variant":{"key":"standard","name":"Standard","short":"Std"},"speed":"bullet","perf":"bullet","rated":true,"fen":"6kr/p1p2rpp/4Q3/2b1p3/8/2P5/P2N1PPP/R3R1K1 b - - 0 22","turns":43,"source":"lobby","status":{"id":31,"name":"resign"},"createdAt":1706185945680,"winner":"white","pgn":"e4 e5 Nf3 Nc6 Bc4 Bc5 b4 Bxb4 c3 Ba5 d4 Bb6 Ba3 Nf6 Qb3 d6 Bxf7+ Kf8 O-O Qe7 Nxe5 Nxe5 dxe5 Be6 Bxe6 Nxe4 Re1 Nc5 Bxc5 Bxc5 Qxb7 Re8 Bh3 dxe5 Qf3+ Kg8 Nd2 Rf8 Qd5+ Rf7 Be6 Qxe6 Qxe6"},"white":{"user":{"name":"veloce","id":"veloce"},"rating":1789,"ratingDiff":9},"black":{"user":{"name":"chabrot","id":"chabrot"},"rating":1810,"ratingDiff":-9},"socket":0,"clock":{"running":false,"initial":120,"increment":1,"white":31.2,"black":27.42,"emerg":15,"moretime":15},"takebackable":true,"youAre":"white","prefs":{"autoQueen":2,"zen":2,"confirmResign":true,"enablePremove":true},"chat":{"lines":[]}}}
|
|
''';
|
|
|
|
/// Necessary to mock wakelock_plus method calls
|
|
/// See: https://github.com/fluttercommunity/wakelock_plus/blob/0c74e5bbc6aefac57b6c96bb7ef987705ed559ec/wakelock_plus_platform_interface/lib/messages.g.dart#L127-L156
|
|
class _PigeonCodec extends StandardMessageCodec {
|
|
const _PigeonCodec();
|
|
@override
|
|
void writeValue(WriteBuffer buffer, Object? value) {
|
|
if (value is int) {
|
|
buffer.putUint8(4);
|
|
buffer.putInt64(value);
|
|
} else if (value is ToggleMessage) {
|
|
buffer.putUint8(129);
|
|
writeValue(buffer, value.encode());
|
|
} else if (value is IsEnabledMessage) {
|
|
buffer.putUint8(130);
|
|
writeValue(buffer, value.encode());
|
|
} else {
|
|
super.writeValue(buffer, value);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Object? readValueOfType(int type, ReadBuffer buffer) {
|
|
switch (type) {
|
|
case 129:
|
|
return ToggleMessage.decode(readValue(buffer)!);
|
|
case 130:
|
|
return IsEnabledMessage.decode(readValue(buffer)!);
|
|
default:
|
|
return super.readValueOfType(type, buffer);
|
|
}
|
|
}
|
|
}
|