diff --git a/lib/src/model/puzzle/puzzle_controller.dart b/lib/src/model/puzzle/puzzle_controller.dart index e2eca8a0ff..327378cb8d 100644 --- a/lib/src/model/puzzle/puzzle_controller.dart +++ b/lib/src/model/puzzle/puzzle_controller.dart @@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/common/chess.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/node.dart'; import 'package:lichess_mobile/src/model/common/service/move_feedback.dart'; import 'package:lichess_mobile/src/model/common/service/sound_service.dart'; @@ -37,6 +38,7 @@ class PuzzleController extends Notifier { late Branch _gameTree; Timer? _firstMoveTimer; Timer? _viewSolutionTimer; + IList? _replayRemaining; Future get _service => ref.read(puzzleServiceFactoryProvider)(queueLength: kPuzzleLocalQueueLength); @@ -54,6 +56,8 @@ class PuzzleController extends Notifier { _updateUserRating(); } + _replayRemaining = initialContext.replayRemaining; + return _loadNewContext(initialContext); } @@ -73,6 +77,9 @@ class PuzzleController extends Notifier { final root = Root.fromPgnMoves(context.puzzle.game.pgn); _gameTree = root.nodeAt(root.mainlinePath.penultimate) as Branch; + // update puzzles that are remaining in replay + _replayRemaining = context.replayRemaining; + // play first move after 1 second _firstMoveTimer = Timer(const Duration(seconds: 1), () { _setPath(state.initialPath, firstMove: true); @@ -215,6 +222,22 @@ class PuzzleController extends Notifier { state = _loadNewContext(nextContext); } + Future _nextReplayPuzzle() async { + final remaining = _replayRemaining; + if (remaining == null || remaining.isEmpty) return null; + try { + final nextPuzzle = await _repository.fetch(remaining.first); + return PuzzleContext( + puzzle: nextPuzzle, + angle: initialContext.angle, + userId: initialContext.userId, + replayRemaining: remaining.removeAt(0), + ); + } catch (_) { + return null; + } + } + void _goToNextNode({bool isNavigating = false}) { if (state.node.children.isEmpty) return; _setPath(state.currentPath + state.node.children.first.id, isNavigating: isNavigating); @@ -260,23 +283,41 @@ class PuzzleController extends Notifier { .addAttempt(state.puzzle.puzzle.id, win: result == PuzzleResult.win); final currentPuzzle = state.puzzle.puzzle; - final service = await _service; - final next = - currentPuzzle.id == initialContext.puzzle.puzzle.id && initialContext.casual == true - ? await service.nextPuzzle(userId: initialContext.userId, angle: initialContext.angle) - : await service.solve( - userId: initialContext.userId, - angle: initialContext.angle, - puzzle: state.puzzle, - solution: PuzzleSolution( - id: state.puzzle.puzzle.id, - win: state.result == PuzzleResult.win, - rated: - initialContext.userId != null && - !state.hintShown && - ref.read(puzzlePreferencesProvider).rated, - ), - ); + final PuzzleContext? next; + if (initialContext.replayRemaining != null) { + final service = await _service; + await service.solve( + userId: initialContext.userId, + angle: initialContext.angle, + puzzle: state.puzzle, + solution: PuzzleSolution( + id: state.puzzle.puzzle.id, + win: state.result == PuzzleResult.win, + rated: + initialContext.userId != null && + !state.hintShown && + ref.read(puzzlePreferencesProvider).rated, + ), + ); + next = await _nextReplayPuzzle(); + } else { + final service = await _service; + next = currentPuzzle.id == initialContext.puzzle.puzzle.id && initialContext.casual == true + ? await service.nextPuzzle(userId: initialContext.userId, angle: initialContext.angle) + : await service.solve( + userId: initialContext.userId, + angle: initialContext.angle, + puzzle: state.puzzle, + solution: PuzzleSolution( + id: state.puzzle.puzzle.id, + win: state.result == PuzzleResult.win, + rated: + initialContext.userId != null && + !state.hintShown && + ref.read(puzzlePreferencesProvider).rated, + ), + ); + } if (!ref.mounted) return; diff --git a/lib/src/model/puzzle/puzzle_providers.dart b/lib/src/model/puzzle/puzzle_providers.dart index a67d14593b..1ebe6027d7 100644 --- a/lib/src/model/puzzle/puzzle_providers.dart +++ b/lib/src/model/puzzle/puzzle_providers.dart @@ -30,6 +30,26 @@ final nextPuzzleProvider = FutureProvider.autoDispose.family(( + Ref ref, + ({int days, String theme}) params, + ) async { + final authUser = ref.watch(authControllerProvider); + if (authUser == null) return null; + final repo = ref.read(puzzleRepositoryProvider); + final remaining = await repo.puzzleReplay(params.days, params.theme); + if (remaining.isEmpty) return null; + final puzzle = await repo.fetch(remaining.first); + return PuzzleContext( + puzzle: puzzle, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + userId: authUser.user.id, + replayRemaining: remaining.removeAt(0), + ); + }, name: 'PuzzleReplayProvider'); + /// Fetches a storm of puzzles. final stormProvider = FutureProvider.autoDispose((Ref ref) { return ref.read(puzzleRepositoryProvider).storm(); diff --git a/lib/src/model/puzzle/puzzle_repository.dart b/lib/src/model/puzzle/puzzle_repository.dart index 23de6d15e3..39355caab1 100644 --- a/lib/src/model/puzzle/puzzle_repository.dart +++ b/lib/src/model/puzzle/puzzle_repository.dart @@ -178,6 +178,13 @@ class PuzzleRepository { ); } + Future> puzzleReplay(int days, String theme) { + return client.readJson( + Uri(path: '/api/puzzle/replay/$days/$theme'), + mapper: _puzzleReplayFromJson, + ); + } + PuzzleBatchResponse _decodeBatchResponse(Map json) { final puzzles = json['puzzles']; if (puzzles is! List) { @@ -242,6 +249,14 @@ sealed class PuzzleStormResponse with _$PuzzleStormResponse { // -- +IList _puzzleReplayFromJson(Map json) { + return pick( + json, + 'replay', + 'remaining', + ).asListOrThrow((p) => PuzzleId(p.asStringOrThrow())).toIList(); +} + PuzzleHistoryEntry _puzzleActivityFromJson(Map json) => _historyPuzzleFromPick(pick(json).required()); diff --git a/lib/src/model/puzzle/puzzle_service.dart b/lib/src/model/puzzle/puzzle_service.dart index 723aa8b80c..2448110f7f 100644 --- a/lib/src/model/puzzle/puzzle_service.dart +++ b/lib/src/model/puzzle/puzzle_service.dart @@ -62,6 +62,9 @@ sealed class PuzzleContext with _$PuzzleContext { /// If true, the result won't be recorded on the server for this puzzle. bool? casual, bool? isPuzzleStreak, + + /// Remaining puzzle IDs to replay after the current one. + IList? replayRemaining, }) = _PuzzleContext; } diff --git a/lib/src/view/puzzle/dashboard_screen.dart b/lib/src/view/puzzle/dashboard_screen.dart index b6e361baca..80e9d6012e 100644 --- a/lib/src/view/puzzle/dashboard_screen.dart +++ b/lib/src/view/puzzle/dashboard_screen.dart @@ -10,6 +10,7 @@ import 'package:lichess_mobile/src/model/auth/auth_controller.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_angle.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_providers.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle_theme.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; @@ -56,13 +57,15 @@ class PuzzleDashboardWidget extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final puzzleDashboard = ref.watch(puzzleDashboardProvider(ref.watch(daysProvider).days)); + final days = ref.watch(daysProvider).days; + return puzzleDashboard.when( data: (dashboard) { if (dashboard == null) return const SizedBox.shrink(); return Column( mainAxisSize: MainAxisSize.min, children: [ - _ChartSection(dashboard: dashboard, showDaysSelector: showDaysSelector), + _ChartSection(dashboard: dashboard, showDaysSelector: showDaysSelector, days: days), _PerformanceSection(dashboard: dashboard, metric: Metric.improvementArea), _PerformanceSection(dashboard: dashboard, metric: Metric.strength), ], @@ -128,14 +131,21 @@ class PuzzleDashboardWidget extends ConsumerWidget { } class _ChartSection extends StatelessWidget { - const _ChartSection({required this.dashboard, required this.showDaysSelector}); + const _ChartSection({ + required this.dashboard, + required this.showDaysSelector, + required this.days, + }); final PuzzleDashboard dashboard; final bool showDaysSelector; + final int days; @override Widget build(BuildContext context) { final chartData = dashboard.themes.take(9).sortedBy((e) => e.theme.name).toList(); + final puzzlesToReplay = + dashboard.global.nb - dashboard.global.firstWins - dashboard.global.replayWins; return ListSection( header: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -179,6 +189,22 @@ class _ChartSection extends StatelessWidget { ), ]), if (chartData.length >= 3) PuzzleChart(chartData), + if (puzzlesToReplay > 0) + Center( + child: TextButton.icon( + onPressed: () { + Navigator.of(context, rootNavigator: true).push( + PuzzleScreen.buildRoute( + context, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + replayDays: days, + ), + ); + }, + icon: const Icon(Icons.play_arrow), + label: Text(context.l10n.puzzleNbToReplay(puzzlesToReplay)), + ), + ), ], ); } diff --git a/lib/src/view/puzzle/puzzle_screen.dart b/lib/src/view/puzzle/puzzle_screen.dart index 7728fcfa7a..a65ab85b8c 100644 --- a/lib/src/view/puzzle/puzzle_screen.dart +++ b/lib/src/view/puzzle/puzzle_screen.dart @@ -60,6 +60,7 @@ class PuzzleScreen extends ConsumerStatefulWidget { this.puzzle, this.puzzleId, this.openCasual = false, + this.replayDays, super.key, }); @@ -70,12 +71,16 @@ class PuzzleScreen extends ConsumerStatefulWidget { /// If true, the result won't be recorded on the server for the provider [puzzleId]. final bool openCasual; + /// If set, load puzzles to replay from the given number of days. + final int? replayDays; + static Route buildRoute( BuildContext context, { required PuzzleAngle angle, PuzzleId? puzzleId, Puzzle? puzzle, bool openCasual = false, + int? replayDays, }) { return buildScreenRoute( context, @@ -84,6 +89,7 @@ class PuzzleScreen extends ConsumerStatefulWidget { puzzleId: puzzleId, puzzle: puzzle, openCasual: openCasual, + replayDays: replayDays, ), ); } @@ -120,6 +126,9 @@ class _PuzzleScreenState extends ConsumerState with RouteAware { @override Widget build(BuildContext context) { + if (widget.replayDays != null) { + return _LoadReplayPuzzle(boardKey: _boardKey, days: widget.replayDays!); + } return widget.puzzle != null ? _LoadPuzzleFromPuzzle(boardKey: _boardKey, angle: widget.angle, puzzle: widget.puzzle!) : widget.puzzleId != null @@ -212,6 +221,53 @@ class _LoadNextPuzzle extends ConsumerWidget { } } +class _LoadReplayPuzzle extends ConsumerWidget { + const _LoadReplayPuzzle({required this.boardKey, required this.days}); + + final GlobalKey boardKey; + final int days; + + static const _angle = PuzzleTheme(PuzzleThemeKey.mix); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final replayPuzzle = ref.watch(puzzleReplayProvider((days: days, theme: _angle.key))); + + switch (replayPuzzle) { + case AsyncData(:final value): + if (value == null) { + return const _PuzzleScaffold( + angle: _angle, + initialPuzzleContext: null, + body: PuzzleErrorBoardWidget(errorMessage: 'No more puzzles to replay.'), + ); + } else { + return _PuzzleScaffold( + angle: _angle, + initialPuzzleContext: value, + body: _Body(boardKey: boardKey, initialPuzzleContext: value), + ); + } + case AsyncError(:final error, :final stackTrace): + debugPrint('SEVERE: [PuzzleScreen] could not load replay puzzles; $error\n$stackTrace'); + final errorMsg = error.toString().contains('404') + ? 'No puzzles to replay.' + : error.toString(); + return _PuzzleScaffold( + angle: _angle, + initialPuzzleContext: null, + body: PuzzleErrorBoardWidget(errorMessage: errorMsg), + ); + case _: + return const _PuzzleScaffold( + angle: _angle, + initialPuzzleContext: null, + body: Center(child: CircularProgressIndicator.adaptive()), + ); + } + } +} + class _LoadPuzzleFromPuzzle extends ConsumerWidget { const _LoadPuzzleFromPuzzle({required this.boardKey, required this.angle, required this.puzzle}); @@ -859,6 +915,7 @@ class _PuzzleSettingsBottomSheet extends ConsumerWidget { materialFilledCard: true, children: [ if (initialPuzzleContext.userId != null && + initialPuzzleContext.replayRemaining == null && puzzleState.mode != PuzzleMode.view && isOnline) StatefulBuilder( @@ -904,7 +961,7 @@ class _PuzzleSettingsBottomSheet extends ConsumerWidget { ref.read(puzzlePreferencesProvider.notifier).setAutoNext(value); }, ), - if (authUser != null) + if (authUser != null && initialPuzzleContext.replayRemaining == null) SwitchSettingTile( title: Text(context.l10n.rated), // ignore: avoid_bool_literals_in_conditional_expressions diff --git a/test/model/puzzle/puzzle_repository_test.dart b/test/model/puzzle/puzzle_repository_test.dart index da9a1663ef..daaa5c0355 100644 --- a/test/model/puzzle/puzzle_repository_test.dart +++ b/test/model/puzzle/puzzle_repository_test.dart @@ -1,6 +1,7 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/testing.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_repository.dart'; import 'package:lichess_mobile/src/network/http.dart'; @@ -106,6 +107,47 @@ void main() { expect(result, isA()); }); + test('puzzle replay', () async { + final mockClient = MockClient((request) { + if (request.url.path == '/api/puzzle/replay/30/mix') { + return mockResponse( + '{"replay":{"days":30,"theme":"mix","nb":3,"remaining":["EUX2q","B2dps","3ck12"]},"angle":{"key":"mix","name":"Puzzle Themes","desc":"A mix of everything."}}', + 200, + ); + } + return mockResponse('', 404); + }); + + final container = await lichessClientContainer(mockClient); + final client = container.read(lichessClientProvider); + final repo = PuzzleRepository(client); + + final remaining = await repo.puzzleReplay(30, 'mix'); + + expect(remaining.length, 3); + expect(remaining.first, const PuzzleId('EUX2q')); + }); + + test('puzzle replay empty', () async { + final mockClient = MockClient((request) { + if (request.url.path == '/api/puzzle/replay/30/mix') { + return mockResponse( + '{"replay":{"days":30,"theme":"mix","nb":0,"remaining":[]},"angle":{"key":"mix","name":"Puzzle Themes","desc":"A mix of everything."}}', + 200, + ); + } + return mockResponse('', 404); + }); + + final container = await lichessClientContainer(mockClient); + final client = container.read(lichessClientProvider); + final repo = PuzzleRepository(client); + + final remaining = await repo.puzzleReplay(30, 'mix'); + + expect(remaining, isEmpty); + }); + test('puzzle activity', () async { final mockClient = MockClient((request) { if (request.url.path == '/api/puzzle/activity') { diff --git a/test/view/puzzle/puzzle_screen_test.dart b/test/view/puzzle/puzzle_screen_test.dart index dd952a32b7..5911e25bcd 100644 --- a/test/view/puzzle/puzzle_screen_test.dart +++ b/test/view/puzzle/puzzle_screen_test.dart @@ -13,6 +13,8 @@ import 'package:lichess_mobile/src/model/puzzle/puzzle_angle.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_batch_storage.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_difficulty.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_preferences.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle_providers.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle_service.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_storage.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_theme.dart'; import 'package:lichess_mobile/src/network/http.dart'; @@ -800,6 +802,363 @@ void main() { await tester.pumpAndSettle(); }, ); + + group('Puzzle Replay', () { + testWidgets('Loads a replay puzzle', variant: kPlatformVariant, (tester) async { + final replayContext = PuzzleContext( + puzzle: puzzle2, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + userId: fakeAuthUser.user.id, + replayRemaining: IList(const [PuzzleId('6Sz3s')]), + ); + + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleScreen(angle: PuzzleTheme(PuzzleThemeKey.mix), replayDays: 30), + overrides: { + puzzleReplayProvider: puzzleReplayProvider.overrideWith((ref, params) { + return replayContext; + }), + puzzleBatchStorageProvider: puzzleBatchStorageProvider.overrideWith( + (ref) => mockBatchStorage, + ), + puzzleStorageProvider: puzzleStorageProvider.overrideWith((ref) => mockHistoryStorage), + }, + authUser: fakeAuthUser, + ); + + when(() => mockHistoryStorage.save(puzzle: any(named: 'puzzle'))).thenAnswer((_) async {}); + + await tester.pumpWidget(app); + + // wait for the puzzle to load + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.byType(Chessboard), findsOneWidget); + expect(find.text('Your turn'), findsOneWidget); + }); + + testWidgets('Shows error when no puzzles to replay', variant: kPlatformVariant, (tester) async { + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleScreen(angle: PuzzleTheme(PuzzleThemeKey.mix), replayDays: 30), + overrides: { + puzzleReplayProvider: puzzleReplayProvider.overrideWith((ref, params) { + return null; + }), + puzzleBatchStorageProvider: puzzleBatchStorageProvider.overrideWith( + (ref) => mockBatchStorage, + ), + puzzleStorageProvider: puzzleStorageProvider.overrideWith((ref) => mockHistoryStorage), + }, + authUser: fakeAuthUser, + ); + + await tester.pumpWidget(app); + + // wait for the provider to resolve + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('No more puzzles to replay.'), findsOneWidget); + }); + + testWidgets('Solves replay puzzle and loads the next one', variant: kPlatformVariant, ( + tester, + ) async { + final replayContext = PuzzleContext( + puzzle: puzzle2, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + userId: fakeAuthUser.user.id, + replayRemaining: IList(const [PuzzleId('6Sz3s')]), + ); + + final mockClient = MockClient((request) { + if (request.url.path == '/api/puzzle/6Sz3s') { + return mockResponse(puzzle1Json, 200); + } + return mockResponse('', 404); + }); + + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleScreen(angle: PuzzleTheme(PuzzleThemeKey.mix), replayDays: 30), + overrides: { + puzzleReplayProvider: puzzleReplayProvider.overrideWith((ref, params) { + return replayContext; + }), + lichessClientProvider: lichessClientProvider.overrideWith((ref) { + return LichessClient(mockClient, ref); + }), + puzzleBatchStorageProvider: puzzleBatchStorageProvider.overrideWith( + (ref) => mockBatchStorage, + ), + puzzleStorageProvider: puzzleStorageProvider.overrideWith((ref) => mockHistoryStorage), + }, + authUser: fakeAuthUser, + ); + + Future saveDBReq() => mockBatchStorage.save( + userId: fakeAuthUser.user.id, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + data: any(named: 'data'), + ); + when(saveDBReq).thenAnswer((_) async {}); + when( + () => mockBatchStorage.fetch( + userId: fakeAuthUser.user.id, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + ), + ).thenAnswer((_) async => batch); + + when(() => mockHistoryStorage.save(puzzle: any(named: 'puzzle'))).thenAnswer((_) async {}); + + await tester.pumpWidget(app); + + // wait for the puzzle to load + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.byType(Chessboard), findsOneWidget); + expect(find.text('Your turn'), findsOneWidget); + + const orientation = Side.black; + + // wait for first move to be played + await tester.pump(const Duration(milliseconds: 1500)); + + expect(find.byKey(const Key('g4-blackrook')), findsOneWidget); + + await playMove(tester, 'g4', 'h4', orientation: orientation); + + expect(find.byKey(const Key('h4-blackrook')), findsOneWidget); + expect(find.text('Best move!'), findsOneWidget); + + // wait for line reply and move animation + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('h4-whitequeen')), findsOneWidget); + + await playMove(tester, 'b4', 'h4', orientation: orientation); + + expect(find.byKey(const Key('h4-blackrook')), findsOneWidget); + expect(find.text('Success!'), findsOneWidget); + + // wait for move animation + await tester.pumpAndSettle(); + + // continue button should appear to load next replay puzzle + expect(find.byIcon(CupertinoIcons.play_arrow_solid), findsOneWidget); + + await tester.tap(find.byIcon(CupertinoIcons.play_arrow_solid)); + + // await for new puzzle load + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Success!'), findsNothing); + expect(find.text('Your turn'), findsOneWidget); + }); + + testWidgets('Continue button disabled when replay is exhausted', variant: kPlatformVariant, ( + tester, + ) async { + final replayContext = PuzzleContext( + puzzle: puzzle2, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + userId: fakeAuthUser.user.id, + replayRemaining: IList(const []), + ); + + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleScreen(angle: PuzzleTheme(PuzzleThemeKey.mix), replayDays: 30), + overrides: { + puzzleReplayProvider: puzzleReplayProvider.overrideWith((ref, params) { + return replayContext; + }), + puzzleBatchStorageProvider: puzzleBatchStorageProvider.overrideWith( + (ref) => mockBatchStorage, + ), + puzzleStorageProvider: puzzleStorageProvider.overrideWith((ref) => mockHistoryStorage), + }, + authUser: fakeAuthUser, + ); + + Future saveDBReq() => mockBatchStorage.save( + userId: fakeAuthUser.user.id, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + data: any(named: 'data'), + ); + when(saveDBReq).thenAnswer((_) async {}); + when( + () => mockBatchStorage.fetch( + userId: fakeAuthUser.user.id, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + ), + ).thenAnswer((_) async => batch); + + when(() => mockHistoryStorage.save(puzzle: any(named: 'puzzle'))).thenAnswer((_) async {}); + + await tester.pumpWidget(app); + + // wait for the puzzle to load + await tester.pump(const Duration(milliseconds: 200)); + + const orientation = Side.black; + + // wait for first move to be played + await tester.pump(const Duration(milliseconds: 1500)); + + // solve the puzzle + await playMove(tester, 'g4', 'h4', orientation: orientation); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + await playMove(tester, 'b4', 'h4', orientation: orientation); + + expect(find.text('Success!'), findsOneWidget); + await tester.pumpAndSettle(); + + // continue button should be present but disabled since no more replay puzzles + final continueBtn = find.byWidgetPredicate( + (widget) => + widget is BottomBarButton && + widget.icon == CupertinoIcons.play_arrow_solid && + !widget.enabled, + ); + expect(continueBtn, findsOneWidget); + }); + + testWidgets('Fails a replay puzzle and can continue', variant: kPlatformVariant, ( + tester, + ) async { + final replayContext = PuzzleContext( + puzzle: puzzle2, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + userId: fakeAuthUser.user.id, + replayRemaining: IList(const [PuzzleId('6Sz3s')]), + ); + + final mockClient = MockClient((request) { + if (request.url.path == '/api/puzzle/6Sz3s') { + return mockResponse(puzzle1Json, 200); + } + return mockResponse('', 404); + }); + + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleScreen(angle: PuzzleTheme(PuzzleThemeKey.mix), replayDays: 30), + overrides: { + puzzleReplayProvider: puzzleReplayProvider.overrideWith((ref, params) { + return replayContext; + }), + lichessClientProvider: lichessClientProvider.overrideWith((ref) { + return LichessClient(mockClient, ref); + }), + puzzleBatchStorageProvider: puzzleBatchStorageProvider.overrideWith( + (ref) => mockBatchStorage, + ), + puzzleStorageProvider: puzzleStorageProvider.overrideWith((ref) => mockHistoryStorage), + }, + authUser: fakeAuthUser, + ); + + Future saveDBReq() => mockBatchStorage.save( + userId: fakeAuthUser.user.id, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + data: any(named: 'data'), + ); + when(saveDBReq).thenAnswer((_) async {}); + when( + () => mockBatchStorage.fetch( + userId: fakeAuthUser.user.id, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + ), + ).thenAnswer((_) async => batch); + + when(() => mockHistoryStorage.save(puzzle: any(named: 'puzzle'))).thenAnswer((_) async {}); + + await tester.pumpWidget(app); + + // wait for the puzzle to load + await tester.pump(const Duration(milliseconds: 200)); + + const orientation = Side.black; + + // wait for first move to be played + await tester.pump(const Duration(milliseconds: 1500)); + + // play a wrong move + await playMove(tester, 'g4', 'f4', orientation: orientation); + + expect(find.text("That's not the move!"), findsOneWidget); + + // wait for move cancel and animation + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + + // solve the puzzle correctly + await playMove(tester, 'g4', 'h4', orientation: orientation); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + await playMove(tester, 'b4', 'h4', orientation: orientation); + + expect(find.text('Puzzle complete!'), findsOneWidget); + await tester.pumpAndSettle(); + + // continue button should be enabled to load next replay puzzle + expect(find.byIcon(CupertinoIcons.play_arrow_solid), findsOneWidget); + + await tester.tap(find.byIcon(CupertinoIcons.play_arrow_solid)); + await tester.pump(const Duration(milliseconds: 500)); + + // next puzzle loaded + expect(find.text('Puzzle complete!'), findsNothing); + expect(find.text('Your turn'), findsOneWidget); + }); + + testWidgets('Settings hide difficulty and rated in replay mode', variant: kPlatformVariant, ( + tester, + ) async { + final replayContext = PuzzleContext( + puzzle: puzzle2, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + userId: fakeAuthUser.user.id, + replayRemaining: IList(const [PuzzleId('6Sz3s')]), + ); + + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleScreen(angle: PuzzleTheme(PuzzleThemeKey.mix), replayDays: 30), + overrides: { + puzzleReplayProvider: puzzleReplayProvider.overrideWith((ref, params) { + return replayContext; + }), + puzzleBatchStorageProvider: puzzleBatchStorageProvider.overrideWith( + (ref) => mockBatchStorage, + ), + puzzleStorageProvider: puzzleStorageProvider.overrideWith((ref) => mockHistoryStorage), + }, + authUser: fakeAuthUser, + ); + + when(() => mockHistoryStorage.save(puzzle: any(named: 'puzzle'))).thenAnswer((_) async {}); + + await tester.pumpWidget(app); + + // wait for the puzzle to load + await tester.pump(const Duration(milliseconds: 200)); + + // open settings + await tester.tap(find.byIcon(Icons.settings)); + await tester.pumpAndSettle(); + + // the difficulty selector should not be visible in replay mode + expect(find.text('Difficulty'), findsNothing); + + // the rated switch should not be visible in replay mode + expect(find.widgetWithText(SwitchSettingTile, 'Rated'), findsNothing); + }); + }); } const batchOf1 = ''' @@ -809,3 +1168,8 @@ const batchOf1 = ''' const emptyBatch = ''' {"puzzles":[]} '''; + +/// JSON response for puzzle with id '6Sz3s' (same as [puzzle] in example_data.dart) +const puzzle1Json = ''' +{"game":{"id":"zgBwsXLr","perf":{"key":"blitz","name":"Blitz"},"rated":true,"players":[{"name":"arroyoM10","color":"white"},{"name":"CAMBIADOR","color":"black"}],"pgn":"e4 c5 Nf3 e6 c4 Nc6 d4 cxd4 Nxd4 Bc5 Nxc6 bxc6 Be2 Ne7 O-O Ng6 Nc3 Rb8 Kh1 Bb7 f4 d5 f5 Ne5 fxe6 fxe6 cxd5 cxd5 exd5 Bxd5 Qa4+ Bc6 Qf4 Bd6 Ne4 Bxe4 Qxe4 Rb4 Qe3 Qh4 Qxa7"},"puzzle":{"id":"6Sz3s","rating":1984,"plays":68176,"initialPly":40,"solution":["h4h2","h1h2","e5f3","h2h3","b4h4"],"themes":["middlegame","attraction","long","mateIn3","sacrifice","doubleCheck"]}} +''';