diff --git a/lib/src/model/puzzle/puzzle.dart b/lib/src/model/puzzle/puzzle.dart index 4fa9aafaf3..d62db26e5b 100644 --- a/lib/src/model/puzzle/puzzle.dart +++ b/lib/src/model/puzzle/puzzle.dart @@ -54,6 +54,8 @@ class PuzzleData with _$PuzzleData { required ISet themes, }) = _PuzzleData; + Side get sideToMove => initialPly.isEven ? Side.black : Side.white; + factory PuzzleData.fromJson(Map json) => _$PuzzleDataFromJson(json); } diff --git a/lib/src/utils/connectivity.dart b/lib/src/utils/connectivity.dart index 6d80a51cee..9f2e76829f 100644 --- a/lib/src/utils/connectivity.dart +++ b/lib/src/utils/connectivity.dart @@ -151,3 +151,61 @@ Future isOnline(Client client) { } return completer.future; } + +extension AsyncValueConnectivity on AsyncValue { + /// Switches between device's connectivity status. + /// + /// Using this method assumes the the device is offline when the status is + /// not yet available (i.e. [AsyncValue.isLoading]. + /// If you want to handle the loading state separately, use + /// [whenIsLoading] instead. + /// + /// This method is similar to [AsyncValueX.maybeWhen], but it takes two + /// functions, one for when the device is online and another for when it is + /// offline. + /// + /// Example: + /// ```dart + /// final status = ref.watch(connectivityChangesProvider); + /// final result = status.whenIs( + /// online: () => 'Online', + /// offline: () => 'Offline', + /// ); + /// ``` + R whenIs({ + required R Function() online, + required R Function() offline, + }) { + return maybeWhen( + data: (status) => status.isOnline ? online() : offline(), + orElse: offline, + ); + } + + /// Switches between device's connectivity status, but handling the loading state. + /// + /// This method is similar to [AsyncValueX.when], but it takes three + /// functions, one for when the device is online, another for when it is + /// offline, and the last for when the status is still loading. + /// + /// Example: + /// ```dart + /// final status = ref.watch(connectivityChangesProvider); + /// final result = status.whenIsLoading( + /// online: () => 'Online', + /// offline: () => 'Offline', + /// loading: () => 'Loading', + /// ); + /// ``` + R whenIsLoading({ + required R Function() online, + required R Function() offline, + required R Function() loading, + }) { + return when( + data: (status) => status.isOnline ? online() : offline(), + loading: loading, + error: (error, stack) => offline(), + ); + } +} diff --git a/lib/src/view/puzzle/puzzle_history_screen.dart b/lib/src/view/puzzle/puzzle_history_screen.dart index 9fbbab7774..d2df0b6ca2 100644 --- a/lib/src/view/puzzle/puzzle_history_screen.dart +++ b/lib/src/view/puzzle/puzzle_history_screen.dart @@ -21,6 +21,8 @@ import 'package:timeago/timeago.dart' as timeago; final _dateFormatter = DateFormat.yMMMd(); class PuzzleHistoryScreen extends StatelessWidget { + const PuzzleHistoryScreen(); + @override Widget build(BuildContext context) { return PlatformScaffold( diff --git a/lib/src/view/puzzle/puzzle_tab_screen.dart b/lib/src/view/puzzle/puzzle_tab_screen.dart index 7ebf8dbdfe..e34871785a 100644 --- a/lib/src/view/puzzle/puzzle_tab_screen.dart +++ b/lib/src/view/puzzle/puzzle_tab_screen.dart @@ -35,35 +35,19 @@ import 'streak_screen.dart'; const _kNumberOfHistoryItemsOnHandset = 8; const _kNumberOfHistoryItemsOnTablet = 16; -class PuzzleTabScreen extends ConsumerStatefulWidget { +class PuzzleTabScreen extends ConsumerWidget { const PuzzleTabScreen({super.key}); @override - ConsumerState createState() => _PuzzleTabScreenState(); -} - -class _PuzzleTabScreenState extends ConsumerState { - final _androidRefreshKey = GlobalKey(); - - @override - Widget build(BuildContext context) { - final session = ref.watch(authSessionProvider); - return PlatformWidget( - androidBuilder: (context) => _androidBuilder(context, session), - iosBuilder: (context) => _iosBuilder(context, session), + Widget build(BuildContext context, WidgetRef ref) { + return ConsumerPlatformWidget( + ref: ref, + androidBuilder: _androidBuilder, + iosBuilder: _iosBuilder, ); } - Widget _androidBuilder(BuildContext context, AuthSessionState? userSession) { - final body = Column( - children: [ - const ConnectivityBanner(), - Expanded( - child: _Body(userSession), - ), - ], - ); - + Widget _androidBuilder(BuildContext context, WidgetRef ref) { return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, _) { @@ -76,20 +60,22 @@ class _PuzzleTabScreenState extends ConsumerState { title: Text(context.l10n.puzzles), actions: const [ _DashboardButton(), + _HistoryButton(), + ], + ), + body: const Column( + children: [ + ConnectivityBanner(), + Expanded( + child: _Body(), + ), ], ), - body: userSession != null - ? RefreshIndicator( - key: _androidRefreshKey, - onRefresh: _refreshData, - child: body, - ) - : body, ), ); } - Widget _iosBuilder(BuildContext context, AuthSessionState? userSession) { + Widget _iosBuilder(BuildContext context, WidgetRef ref) { return CupertinoPageScaffold( child: CustomScrollView( controller: puzzlesScrollController, @@ -104,34 +90,24 @@ class _PuzzleTabScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ _DashboardButton(), + SizedBox(width: 6.0), + _HistoryButton(), ], ), ), - if (userSession != null) - CupertinoSliverRefreshControl( - onRefresh: _refreshData, - ), const SliverToBoxAdapter(child: ConnectivityBanner()), - SliverSafeArea( + const SliverSafeArea( top: false, - sliver: _Body(userSession), + sliver: _Body(), ), ], ), ); } - - Future _refreshData() { - return Future.wait([ - ref.refresh(puzzleRecentActivityProvider.future), - ]); - } } class _Body extends ConsumerWidget { - const _Body(this.session); - - final AuthSessionState? session; + const _Body(); @override Widget build(BuildContext context, WidgetRef ref) { @@ -140,17 +116,15 @@ class _Body extends ConsumerWidget { final isTablet = isTabletOrLarger(context); final handsetChildren = [ - connectivity.when( - data: (data) => data.isOnline - ? const _DailyPuzzle() - : const _OfflinePuzzlePreview(), - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), + connectivity.whenIs( + online: () => const DailyPuzzle(), + offline: () => const SizedBox.shrink(), ), + const SizedBox(height: 4.0), + const TacticalTrainingPreview(), if (Theme.of(context).platform == TargetPlatform.android) const SizedBox(height: 8.0), _PuzzleMenu(connectivity: connectivity), - PuzzleHistoryWidget(), ]; final tabletChildren = [ @@ -163,12 +137,9 @@ class _Body extends ConsumerWidget { mainAxisAlignment: MainAxisAlignment.start, children: [ const SizedBox(height: 8.0), - connectivity.when( - data: (data) => data.isOnline - ? const _DailyPuzzle() - : const _OfflinePuzzlePreview(), - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), + connectivity.whenIs( + online: () => const DailyPuzzle(), + offline: () => const SizedBox.shrink(), ), _PuzzleMenu(connectivity: connectivity), ], @@ -244,21 +215,6 @@ class _PuzzleMenu extends StatelessWidget { return ListSection( hasLeading: true, children: [ - _PuzzleMenuListTile( - icon: PuzzleIcons.mix, - title: context.l10n.puzzlePuzzles, - subtitle: context.l10n.puzzleDesc, - onTap: () { - pushPlatformRoute( - context, - title: context.l10n.puzzleDesc, - rootNavigator: true, - builder: (context) => const PuzzleScreen( - angle: PuzzleTheme(PuzzleThemeKey.mix), - ), - ); - }, - ), _PuzzleMenuListTile( icon: PuzzleIcons.opening, title: context.l10n.puzzlePuzzleThemes, @@ -353,7 +309,7 @@ class PuzzleHistoryWidget extends ConsumerWidget { headerTrailing: NoPaddingTextButton( onPressed: () => pushPlatformRoute( context, - builder: (context) => PuzzleHistoryScreen(), + builder: (context) => const PuzzleHistoryScreen(), ), child: Text( context.l10n.more, @@ -400,29 +356,65 @@ class _DashboardButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final session = ref.watch(authSessionProvider); - if (session != null) { - return AppBarIconButton( - icon: const Icon(Icons.history), - semanticsLabel: context.l10n.puzzlePuzzleDashboard, - onPressed: () { - ref.invalidate(puzzleDashboardProvider); - _showDashboard(context, session); - }, - ); + if (session == null) { + return const SizedBox.shrink(); + } + final onPressed = ref.watch(connectivityChangesProvider).whenIs( + online: () => () { + pushPlatformRoute( + context, + title: context.l10n.puzzlePuzzleDashboard, + builder: (_) => const PuzzleDashboardScreen(), + ); + }, + offline: () => null, + ); + + return AppBarIconButton( + icon: const Icon(Icons.assessment_outlined), + semanticsLabel: context.l10n.puzzlePuzzleDashboard, + onPressed: onPressed, + ); + } +} + +class _HistoryButton extends ConsumerWidget { + const _HistoryButton(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(authSessionProvider); + if (session == null) { + return const SizedBox.shrink(); } - return const SizedBox.shrink(); + final onPressed = ref.watch(connectivityChangesProvider).whenIs( + online: () => () { + pushPlatformRoute( + context, + title: context.l10n.puzzleHistory, + builder: (_) => const PuzzleHistoryScreen(), + ); + }, + offline: () => null, + ); + return AppBarIconButton( + icon: const Icon(Icons.history_outlined), + semanticsLabel: context.l10n.puzzleHistory, + onPressed: onPressed, + ); } +} - void _showDashboard(BuildContext context, AuthSessionState session) => - pushPlatformRoute( - context, - title: context.l10n.puzzlePuzzleDashboard, - builder: (_) => const PuzzleDashboardScreen(), - ); +TextStyle _puzzlePreviewSubtitleStyle(BuildContext context) { + return TextStyle( + fontSize: 14.0, + color: DefaultTextStyle.of(context).style.color?.withValues(alpha: 0.6), + ); } -class _DailyPuzzle extends ConsumerWidget { - const _DailyPuzzle(); +/// A widget that displays the daily puzzle. +class DailyPuzzle extends ConsumerWidget { + const DailyPuzzle(); @override Widget build(BuildContext context, WidgetRef ref) { @@ -449,6 +441,7 @@ class _DailyPuzzle extends ConsumerWidget { context.l10n .puzzlePlayedXTimes(data.puzzle.plays) .localizeNumbers(), + style: _puzzlePreviewSubtitleStyle(context), ), ], ), @@ -461,7 +454,7 @@ class _DailyPuzzle extends ConsumerWidget { ?.withValues(alpha: 0.6), ), Text( - data.puzzle.initialPly.isOdd + data.puzzle.sideToMove == Side.white ? context.l10n.whitePlays : context.l10n.blackPlays, ), @@ -480,82 +473,118 @@ class _DailyPuzzle extends ConsumerWidget { }, ); }, - loading: () => SmallBoardPreview( - orientation: Side.white, - fen: kEmptyFen, - description: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Text( - context.l10n.puzzlePuzzleOfTheDay, - style: Styles.boardPreviewTitle, - ), - const Text(''), - ], + loading: () => const Shimmer( + child: ShimmerLoading( + isLoading: true, + child: SmallBoardPreview.loading(), ), ), - error: (error, stack) => const Padding( - padding: Styles.bodySectionPadding, - child: Text('Could not load the daily puzzle.'), - ), + error: (error, _) { + return const Padding( + padding: Styles.bodySectionPadding, + child: Text('Could not load the daily puzzle.'), + ); + }, ); } } -class _OfflinePuzzlePreview extends ConsumerWidget { - const _OfflinePuzzlePreview(); +/// A widget that displays a preview of the tactical training screen. +class TacticalTrainingPreview extends ConsumerWidget { + const TacticalTrainingPreview(); @override Widget build(BuildContext context, WidgetRef ref) { final puzzle = ref.watch(nextPuzzleProvider(const PuzzleTheme(PuzzleThemeKey.mix))); - return puzzle.maybeWhen( - data: (data) { - final preview = - data != null ? PuzzlePreview.fromPuzzle(data.puzzle) : null; - return SmallBoardPreview( - orientation: preview?.orientation ?? Side.white, - fen: preview?.initialFen ?? kEmptyFen, - lastMove: preview?.initialMove, - description: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Text( - context.l10n.puzzleDesc, - style: Styles.boardPreviewTitle, - ), - Text( - context.l10n - .puzzlePlayedXTimes(data?.puzzle.puzzle.plays ?? 0) - .localizeNumbers(), + + Widget buildPuzzlePreview(Puzzle? puzzle, {bool loading = false}) { + final preview = puzzle != null ? PuzzlePreview.fromPuzzle(puzzle) : null; + return loading + ? const Shimmer( + child: ShimmerLoading( + isLoading: true, + child: SmallBoardPreview.loading(), ), - ], - ), - onTap: data != null - ? () { - pushPlatformRoute( - context, - rootNavigator: true, - builder: (context) => const PuzzleScreen( - angle: PuzzleTheme(PuzzleThemeKey.mix), + ) + : SmallBoardPreview( + orientation: preview?.orientation ?? Side.white, + fen: preview?.initialFen ?? kEmptyFen, + lastMove: preview?.initialMove, + description: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + context.l10n.puzzleDesc, + style: Styles.boardPreviewTitle, + ), + Text( + // TODO change this to a better description when + // translation tool is again available (#945) + context.l10n.puzzleThemeHealthyMixDescription, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: TextStyle( + height: 1.2, + fontSize: 12.0, + color: DefaultTextStyle.of(context) + .style + .color + ?.withValues(alpha: 0.6), + ), + ), + ], + ), + Icon( + PuzzleIcons.mix, + size: 34, + color: DefaultTextStyle.of(context) + .style + .color + ?.withValues(alpha: 0.6), + ), + if (puzzle != null) + Text( + puzzle.puzzle.sideToMove == Side.white + ? context.l10n.whitePlays + : context.l10n.blackPlays, + ) + else + const Text( + 'No puzzles available, please go online to fetch them.', ), - ).then((_) { - if (context.mounted) { - ref.invalidate( - nextPuzzleProvider( - const PuzzleTheme(PuzzleThemeKey.mix), + ], + ), + onTap: puzzle != null + ? () { + pushPlatformRoute( + context, + rootNavigator: true, + builder: (context) => const PuzzleScreen( + angle: PuzzleTheme(PuzzleThemeKey.mix), ), - ); + ).then((_) { + if (context.mounted) { + ref.invalidate( + nextPuzzleProvider( + const PuzzleTheme(PuzzleThemeKey.mix), + ), + ); + } + }); } - }); - } - : null, - ); - }, - orElse: () => const SizedBox.shrink(), + : null, + ); + } + + return puzzle.maybeWhen( + data: (data) => buildPuzzlePreview(data?.puzzle), + orElse: () => buildPuzzlePreview(null, loading: true), ); } } diff --git a/lib/src/widgets/board_preview.dart b/lib/src/widgets/board_preview.dart index ea72369549..c52b9e22b6 100644 --- a/lib/src/widgets/board_preview.dart +++ b/lib/src/widgets/board_preview.dart @@ -16,7 +16,16 @@ class SmallBoardPreview extends ConsumerStatefulWidget { this.padding, this.lastMove, this.onTap, - }); + }) : _showLoadingPlaceholder = false; + + const SmallBoardPreview.loading({ + this.padding, + }) : orientation = Side.white, + fen = kEmptyFEN, + lastMove = null, + description = const SizedBox.shrink(), + onTap = null, + _showLoadingPlaceholder = true; /// Side by which the board is oriented. final Side orientation; @@ -33,6 +42,8 @@ class SmallBoardPreview extends ConsumerStatefulWidget { final EdgeInsetsGeometry? padding; + final bool _showLoadingPlaceholder; + @override ConsumerState createState() => _SmallBoardPreviewState(); } @@ -65,23 +76,85 @@ class _SmallBoardPreviewState extends ConsumerState { height: boardSize, child: Row( children: [ - Chessboard.fixed( - size: boardSize, - fen: widget.fen, - orientation: widget.orientation, - lastMove: widget.lastMove as NormalMove?, - settings: ChessboardSettings( - enableCoordinates: false, - borderRadius: - const BorderRadius.all(Radius.circular(4.0)), - boxShadow: boardShadows, - animationDuration: const Duration(milliseconds: 150), - pieceAssets: boardPrefs.pieceSet.assets, - colorScheme: boardPrefs.boardTheme.colors, + if (widget._showLoadingPlaceholder) + Container( + width: boardSize, + height: boardSize, + decoration: const BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.all(Radius.circular(4.0)), + ), + ) + else + Chessboard.fixed( + size: boardSize, + fen: widget.fen, + orientation: widget.orientation, + lastMove: widget.lastMove as NormalMove?, + settings: ChessboardSettings( + enableCoordinates: false, + borderRadius: + const BorderRadius.all(Radius.circular(4.0)), + boxShadow: boardShadows, + animationDuration: const Duration(milliseconds: 150), + pieceAssets: boardPrefs.pieceSet.assets, + colorScheme: boardPrefs.boardTheme.colors, + ), ), - ), const SizedBox(width: 10.0), - Expanded(child: widget.description), + if (widget._showLoadingPlaceholder) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 16.0, + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.black, + borderRadius: + BorderRadius.all(Radius.circular(4.0)), + ), + ), + const SizedBox(height: 4.0), + Container( + height: 16.0, + width: MediaQuery.sizeOf(context).width / 3, + decoration: const BoxDecoration( + color: Colors.black, + borderRadius: + BorderRadius.all(Radius.circular(4.0)), + ), + ), + ], + ), + Container( + height: 44.0, + width: 44.0, + decoration: const BoxDecoration( + color: Colors.black, + borderRadius: + BorderRadius.all(Radius.circular(4.0)), + ), + ), + Container( + height: 16.0, + width: double.infinity, + decoration: const BoxDecoration( + color: Colors.black, + borderRadius: + BorderRadius.all(Radius.circular(4.0)), + ), + ), + ], + ), + ) + else + Expanded(child: widget.description), ], ), ), diff --git a/lib/src/widgets/shimmer.dart b/lib/src/widgets/shimmer.dart index 045a9dd546..825c17d88e 100644 --- a/lib/src/widgets/shimmer.dart +++ b/lib/src/widgets/shimmer.dart @@ -1,4 +1,3 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; class Shimmer extends StatefulWidget { @@ -21,26 +20,12 @@ class ShimmerState extends State with SingleTickerProviderStateMixin { late AnimationController _shimmerController; LinearGradient get _defaultGradient { - switch (Theme.of(context).platform) { - case TargetPlatform.android: - final brightness = Theme.of(context).brightness; - switch (brightness) { - case Brightness.light: - return androidLightShimmerGradient; - case Brightness.dark: - return androidDarkShimmerGradient; - } - case TargetPlatform.iOS: - final brightness = - CupertinoTheme.maybeBrightnessOf(context) ?? Brightness.light; - switch (brightness) { - case Brightness.light: - return iOSLightShimmerGradient; - case Brightness.dark: - return iOSDarkShimmerGradient; - } - default: - throw 'Unexpected platform $Theme.of(context).platform'; + final brightness = Theme.of(context).brightness; + switch (brightness) { + case Brightness.light: + return lightShimmerGradient; + case Brightness.dark: + return darkShimmerGradient; } } @@ -167,7 +152,7 @@ class _ShimmerLoadingState extends State { } } -const iOSLightShimmerGradient = LinearGradient( +const lightShimmerGradient = LinearGradient( colors: [ Color(0xFFE3E3E6), Color(0xFFECECEE), @@ -183,39 +168,7 @@ const iOSLightShimmerGradient = LinearGradient( tileMode: TileMode.clamp, ); -const iOSDarkShimmerGradient = LinearGradient( - colors: [ - Color(0xFF111111), - Color(0xFF1a1a1a), - Color(0xFF111111), - ], - stops: [ - 0.1, - 0.3, - 0.4, - ], - begin: Alignment(-1.0, -0.3), - end: Alignment(1.0, 0.3), - tileMode: TileMode.clamp, -); - -const androidLightShimmerGradient = LinearGradient( - colors: [ - Color(0xFFE6E6E6), - Color(0xFFEFEFEF), - Color(0xFFE6E6E6), - ], - stops: [ - 0.1, - 0.3, - 0.4, - ], - begin: Alignment(-1.0, -0.3), - end: Alignment(1.0, 0.3), - tileMode: TileMode.clamp, -); - -const androidDarkShimmerGradient = LinearGradient( +const darkShimmerGradient = LinearGradient( colors: [ Color(0xFF333333), Color(0xFF3c3c3c), diff --git a/test/model/auth/auth_controller_test.dart b/test/model/auth/auth_controller_test.dart index e0df8d1895..65e02b35a4 100644 --- a/test/model/auth/auth_controller_test.dart +++ b/test/model/auth/auth_controller_test.dart @@ -72,12 +72,12 @@ void main() { group('AuthController', () { test('sign in', () async { when(() => mockSessionStorage.read()) - .thenAnswer((_) => delayedAnswer(null)); + .thenAnswer((_) => Future.value(null)); when(() => mockFlutterAppAuth.authorizeAndExchangeCode(any())) - .thenAnswer((_) => delayedAnswer(signInResponse)); + .thenAnswer((_) => Future.value(signInResponse)); when( () => mockSessionStorage.write(any()), - ).thenAnswer((_) => delayedAnswer(null)); + ).thenAnswer((_) => Future.value(null)); final container = await makeContainer( overrides: [ @@ -117,10 +117,10 @@ void main() { test('sign out', () async { when(() => mockSessionStorage.read()) - .thenAnswer((_) => delayedAnswer(testUserSession)); + .thenAnswer((_) => Future.value(testUserSession)); when( () => mockSessionStorage.delete(), - ).thenAnswer((_) => delayedAnswer(null)); + ).thenAnswer((_) => Future.value(null)); final container = await makeContainer( overrides: [ diff --git a/test/model/puzzle/mock_server_responses.dart b/test/model/puzzle/mock_server_responses.dart new file mode 100644 index 0000000000..01653958f3 --- /dev/null +++ b/test/model/puzzle/mock_server_responses.dart @@ -0,0 +1,7 @@ +const mockDailyPuzzleResponse = ''' +{"game":{"id":"MNMYnEjm","perf":{"key":"classical","name":"Classical"},"rated":true,"players":[{"name":"Igor76","id":"igor76","color":"white","rating":2211},{"name":"dmitriy_duyun","id":"dmitriy_duyun","color":"black","rating":2180}],"pgn":"e4 c6 d4 d5 Nc3 g6 Nf3 Bg7 h3 dxe4 Nxe4 Nf6 Bd3 Nxe4 Bxe4 Nd7 O-O Nf6 Bd3 O-O Re1 Bf5 Bxf5 gxf5 c3 e6 Bg5 Qb6 Qc2 Rac8 Ne5 Qc7 Rad1 Nd7 Bf4 Nxe5 Bxe5 Bxe5 Rxe5 Rcd8 Qd2 Kh8 Rde1 Rg8 Qf4","clock":"20+15"},"puzzle":{"id":"0XqV2","rating":1929,"plays":93270,"solution":["f7f6","e5f5","c7g7","g2g3","e6f5"],"themes":["clearance","endgame","advantage","intermezzo","long"],"initialPly":44}} +'''; + +const mockMixBatchResponse = ''' +{"puzzles":[{"game":{"id":"PrlkCqOv","perf":{"key":"rapid","name":"Rapid"},"rated":true,"players":[{"userId":"silverjo","name":"silverjo (1777)","color":"white"},{"userId":"robyarchitetto","name":"Robyarchitetto (1742)","color":"black"}],"pgn":"e4 Nc6 Bc4 e6 a3 g6 Nf3 Bg7 c3 Nge7 d3 O-O Be3 Na5 Ba2 b6 Qd2 Bb7 Bh6 d5 e5 d4 Bxg7 Kxg7 Qf4 Bxf3 Qxf3 dxc3 Nxc3 Nac6 Qf6+ Kg8 Rd1 Nd4 O-O c5 Ne4 Nef5 Rd2 Qxf6 Nxf6+ Kg7 Re1 h5 h3 Rad8 b4 Nh4 Re3 Nhf5 Re1 a5 bxc5 bxc5 Bc4 Ra8 Rb1 Nh4 Rdb2 Nc6 Rb7 Nxe5 Bxe6 Kxf6 Bd5 Nf5 R7b6+ Kg7 Bxa8 Rxa8 R6b3 Nd4 Rb7 Nxd3 Rd1 Ne2+ Kh2 Ndf4 Rdd7 Rf8 Ra7 c4 Rxa5 c3 Rc5 Ne6 Rc4 Ra8 a4 Rb8 a5 Rb2 a6 c2","clock":"5+8"},"puzzle":{"id":"20yWT","rating":1859,"plays":551,"initialPly":93,"solution":["a6a7","b2a2","c4c2","a2a7","d7a7"],"themes":["endgame","long","advantage","advancedPawn"]}},{"game":{"id":"0lwkiJbZ","perf":{"key":"classical","name":"Classical"},"rated":true,"players":[{"userId":"nirdosh","name":"nirdosh (2035)","color":"white"},{"userId":"burn_it_down","name":"burn_it_down (2139)","color":"black"}],"pgn":"d4 Nf6 Nf3 c5 e3 g6 Bd3 Bg7 c3 Qc7 O-O O-O Nbd2 d6 Qe2 Nbd7 e4 cxd4 cxd4 e5 dxe5 dxe5 b3 Nc5 Bb2 Nh5 g3 Bh3 Rfc1 Qd6 Bc4 Rac8 Bd5 Qb8 Ng5 Bd7 Ba3 b6 Rc2 h6 Ngf3 Rfe8 Rac1 Ne6 Nc4 Bb5 Qe3 Bxc4 Bxc4 Nd4 Nxd4 exd4 Qd3 Rcd8 f4 Nf6 e5 Ng4 Qxg6 Ne3 Bxf7+ Kh8 Rc7 Qa8 Qxg7+ Kxg7 Bd5+ Kg6 Bxa8 Rxa8 Rd7 Rad8 Rc6+ Kf5 Rcd6 Rxd7 Rxd7 Ke4 Bb2 Nc2 Kf2 d3 Bc1 Nd4 h3","clock":"15+15"},"puzzle":{"id":"7H5EV","rating":1852,"plays":410,"initialPly":84,"solution":["e8c8","d7d4","e4d4"],"themes":["endgame","short","advantage"]}},{"game":{"id":"eWGRX5AI","perf":{"key":"rapid","name":"Rapid"},"rated":true,"players":[{"userId":"sacalot","name":"sacalot (2151)","color":"white"},{"userId":"landitirana","name":"landitirana (1809)","color":"black"}],"pgn":"e4 e5 Nf3 Nc6 d4 exd4 Bc4 Nf6 O-O Nxe4 Re1 d5 Bxd5 Qxd5 Nc3 Qd8 Rxe4+ Be6 Nxd4 Nxd4 Rxd4 Qf6 Ne4 Qe5 f4 Qf5 Ng3 Qa5 Bd2 Qb6 Be3 Bc5 f5 Bd5 Rxd5 Bxe3+ Kh1 O-O Rd3 Rfe8 Qf3 Qxb2 Rf1 Bd4 Nh5 Bf6 Rb3 Qd4 Rxb7 Re3 Nxf6+ gxf6 Qf2 Rae8 Rxc7 Qe5 Rc4 Re1 Rf4 Qa1 h3","clock":"10+0"},"puzzle":{"id":"1qUth","rating":1556,"plays":2661,"initialPly":60,"solution":["e1f1","f2f1","e8e1","f1e1","a1e1"],"themes":["endgame","master","advantage","fork","long","pin"]}}]} +'''; diff --git a/test/model/puzzle/puzzle_repository_test.dart b/test/model/puzzle/puzzle_repository_test.dart index 6098c0aa59..2002320c3a 100644 --- a/test/model/puzzle/puzzle_repository_test.dart +++ b/test/model/puzzle/puzzle_repository_test.dart @@ -6,18 +6,14 @@ import 'package:lichess_mobile/src/network/http.dart'; import '../../test_container.dart'; import '../../test_helpers.dart'; +import 'mock_server_responses.dart'; void main() { group('PuzzleRepository', () { test('selectBatch', () async { final mockClient = MockClient((request) { if (request.url.path == '/api/puzzle/batch/mix') { - return mockResponse( - ''' -{"puzzles":[{"game":{"id":"PrlkCqOv","perf":{"key":"rapid","name":"Rapid"},"rated":true,"players":[{"userId":"silverjo","name":"silverjo (1777)","color":"white"},{"userId":"robyarchitetto","name":"Robyarchitetto (1742)","color":"black"}],"pgn":"e4 Nc6 Bc4 e6 a3 g6 Nf3 Bg7 c3 Nge7 d3 O-O Be3 Na5 Ba2 b6 Qd2 Bb7 Bh6 d5 e5 d4 Bxg7 Kxg7 Qf4 Bxf3 Qxf3 dxc3 Nxc3 Nac6 Qf6+ Kg8 Rd1 Nd4 O-O c5 Ne4 Nef5 Rd2 Qxf6 Nxf6+ Kg7 Re1 h5 h3 Rad8 b4 Nh4 Re3 Nhf5 Re1 a5 bxc5 bxc5 Bc4 Ra8 Rb1 Nh4 Rdb2 Nc6 Rb7 Nxe5 Bxe6 Kxf6 Bd5 Nf5 R7b6+ Kg7 Bxa8 Rxa8 R6b3 Nd4 Rb7 Nxd3 Rd1 Ne2+ Kh2 Ndf4 Rdd7 Rf8 Ra7 c4 Rxa5 c3 Rc5 Ne6 Rc4 Ra8 a4 Rb8 a5 Rb2 a6 c2","clock":"5+8"},"puzzle":{"id":"20yWT","rating":1859,"plays":551,"initialPly":93,"solution":["a6a7","b2a2","c4c2","a2a7","d7a7"],"themes":["endgame","long","advantage","advancedPawn"]}},{"game":{"id":"0lwkiJbZ","perf":{"key":"classical","name":"Classical"},"rated":true,"players":[{"userId":"nirdosh","name":"nirdosh (2035)","color":"white"},{"userId":"burn_it_down","name":"burn_it_down (2139)","color":"black"}],"pgn":"d4 Nf6 Nf3 c5 e3 g6 Bd3 Bg7 c3 Qc7 O-O O-O Nbd2 d6 Qe2 Nbd7 e4 cxd4 cxd4 e5 dxe5 dxe5 b3 Nc5 Bb2 Nh5 g3 Bh3 Rfc1 Qd6 Bc4 Rac8 Bd5 Qb8 Ng5 Bd7 Ba3 b6 Rc2 h6 Ngf3 Rfe8 Rac1 Ne6 Nc4 Bb5 Qe3 Bxc4 Bxc4 Nd4 Nxd4 exd4 Qd3 Rcd8 f4 Nf6 e5 Ng4 Qxg6 Ne3 Bxf7+ Kh8 Rc7 Qa8 Qxg7+ Kxg7 Bd5+ Kg6 Bxa8 Rxa8 Rd7 Rad8 Rc6+ Kf5 Rcd6 Rxd7 Rxd7 Ke4 Bb2 Nc2 Kf2 d3 Bc1 Nd4 h3","clock":"15+15"},"puzzle":{"id":"7H5EV","rating":1852,"plays":410,"initialPly":84,"solution":["e8c8","d7d4","e4d4"],"themes":["endgame","short","advantage"]}},{"game":{"id":"eWGRX5AI","perf":{"key":"rapid","name":"Rapid"},"rated":true,"players":[{"userId":"sacalot","name":"sacalot (2151)","color":"white"},{"userId":"landitirana","name":"landitirana (1809)","color":"black"}],"pgn":"e4 e5 Nf3 Nc6 d4 exd4 Bc4 Nf6 O-O Nxe4 Re1 d5 Bxd5 Qxd5 Nc3 Qd8 Rxe4+ Be6 Nxd4 Nxd4 Rxd4 Qf6 Ne4 Qe5 f4 Qf5 Ng3 Qa5 Bd2 Qb6 Be3 Bc5 f5 Bd5 Rxd5 Bxe3+ Kh1 O-O Rd3 Rfe8 Qf3 Qxb2 Rf1 Bd4 Nh5 Bf6 Rb3 Qd4 Rxb7 Re3 Nxf6+ gxf6 Qf2 Rae8 Rxc7 Qe5 Rc4 Re1 Rf4 Qa1 h3","clock":"10+0"},"puzzle":{"id":"1qUth","rating":1556,"plays":2661,"initialPly":60,"solution":["e1f1","f2f1","e8e1","f1e1","a1e1"],"themes":["endgame","master","advantage","fork","long","pin"]}}]} -''', - 200, - ); + return mockResponse(mockMixBatchResponse, 200); } return mockResponse('', 404); }); diff --git a/test/test_container.dart b/test/test_container.dart index 0a1a42cdc1..bdc0e711fa 100644 --- a/test/test_container.dart +++ b/test/test_container.dart @@ -1,10 +1,8 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; -import 'package:intl/intl.dart'; import 'package:lichess_mobile/src/crashlytics.dart'; import 'package:lichess_mobile/src/db/database.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; @@ -13,7 +11,6 @@ import 'package:lichess_mobile/src/model/notifications/notification_service.dart import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/network/socket.dart'; import 'package:lichess_mobile/src/utils/connectivity.dart'; -import 'package:logging/logging.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import './fake_crashlytics.dart'; @@ -29,8 +26,6 @@ final testContainerMockClient = MockClient((request) async { return http.Response('', 200); }); -const shouldLog = false; - /// Returns a [ProviderContainer] with the [httpClientFactoryProvider] configured /// with the given [mockClient]. Future lichessClientContainer(MockClient mockClient) async { @@ -56,15 +51,6 @@ Future makeContainer({ await binding.preloadData(userSession); - Logger.root.onRecord.listen((record) { - if (shouldLog && record.level >= Level.FINE) { - final time = DateFormat.Hms().format(record.time); - debugPrint( - '${record.level.name} at $time [${record.loggerName}] ${record.message}${record.error != null ? '\n${record.error}' : ''}', - ); - } - }); - final container = ProviderContainer( overrides: [ connectivityPluginProvider.overrideWith((_) { diff --git a/test/test_helpers.dart b/test/test_helpers.dart index f1b109c6b0..da5507f50b 100644 --- a/test/test_helpers.dart +++ b/test/test_helpers.dart @@ -19,17 +19,14 @@ const kPlatformVariant = Matcher sameRequest(http.BaseRequest request) => _SameRequest(request); Matcher sameHeaders(Map headers) => _SameHeaders(headers); -Future delayedAnswer(T value) => - Future.delayed(const Duration(milliseconds: 5)).then((_) => value); - /// Mocks an http response with a delay of 20ms. Future mockResponse( String body, int code, { Map headers = const {}, }) => - Future.delayed(const Duration(milliseconds: 20)).then( - (_) => http.Response( + Future.value( + http.Response( body, code, headers: headers, @@ -37,23 +34,21 @@ Future mockResponse( ); Future mockStreamedResponse(String body, int code) => - Future.delayed(const Duration(milliseconds: 20)).then( - (_) => http.StreamedResponse(Stream.value(body).map(utf8.encode), code), + Future.value( + http.StreamedResponse(Stream.value(body).map(utf8.encode), code), ); Future mockHttpStreamFromIterable( Iterable events, ) async { - await Future.delayed(const Duration(milliseconds: 20)); return http.StreamedResponse( - _streamFromFutures(events.map((e) => _withDelay(utf8.encode(e)))), + _streamFromFutures(events.map((e) => Future.value(utf8.encode(e)))), 200, ); } Future mockHttpStream(Stream stream) => - Future.delayed(const Duration(milliseconds: 20)) - .then((_) => http.StreamedResponse(stream.map(utf8.encode), 200)); + Future.value(http.StreamedResponse(stream.map(utf8.encode), 200)); Future tapBackButton(WidgetTester tester) async { if (debugDefaultTargetPlatformOverride == TargetPlatform.iOS) { @@ -148,9 +143,3 @@ Stream _streamFromFutures(Iterable> futures) async* { yield result; } } - -Future _withDelay( - T value, { - Duration delay = const Duration(milliseconds: 10), -}) => - Future.delayed(delay).then((_) => value); diff --git a/test/test_provider_scope.dart b/test/test_provider_scope.dart index 253b503db1..e081e2df01 100644 --- a/test/test_provider_scope.dart +++ b/test/test_provider_scope.dart @@ -7,7 +7,6 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; -import 'package:intl/intl.dart'; import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/crashlytics.dart'; import 'package:lichess_mobile/src/db/database.dart'; @@ -20,7 +19,6 @@ import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/network/socket.dart'; import 'package:lichess_mobile/src/utils/connectivity.dart'; -import 'package:logging/logging.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:visibility_detector/visibility_detector.dart'; @@ -120,15 +118,6 @@ Future makeTestProviderScope( // TODO consider loading true fonts as well FlutterError.onError = _ignoreOverflowErrors; - Logger.root.onRecord.listen((record) { - if (record.level > Level.WARNING) { - final time = DateFormat.Hms().format(record.time); - debugPrint( - '${record.level.name} at $time [${record.loggerName}] ${record.message}${record.error != null ? '\n${record.error}' : ''}', - ); - } - }); - return ProviderScope( overrides: [ // ignore: scoped_providers_should_specify_dependencies diff --git a/test/view/puzzle/example_data.dart b/test/view/puzzle/example_data.dart new file mode 100644 index 0000000000..8a7b6a0b8c --- /dev/null +++ b/test/view/puzzle/example_data.dart @@ -0,0 +1,86 @@ +import 'package:dartchess/dartchess.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/model/common/perf.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle_batch_storage.dart'; + +final puzzle = Puzzle( + puzzle: PuzzleData( + id: const PuzzleId('6Sz3s'), + initialPly: 40, + plays: 68176, + rating: 1984, + solution: IList(const [ + 'h4h2', + 'h1h2', + 'e5f3', + 'h2h3', + 'b4h4', + ]), + themes: ISet(const [ + 'middlegame', + 'attraction', + 'long', + 'mateIn3', + 'sacrifice', + 'doubleCheck', + ]), + ), + game: const PuzzleGame( + rated: true, + id: GameId('zgBwsXLr'), + perf: Perf.blitz, + 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', + black: PuzzleGamePlayer( + side: Side.black, + name: 'CAMBIADOR', + ), + white: PuzzleGamePlayer( + side: Side.white, + name: 'arroyoM10', + ), + ), +); + +final batch = PuzzleBatch( + solved: IList(const []), + unsolved: IList([ + puzzle, + ]), +); + +final puzzle2 = Puzzle( + puzzle: PuzzleData( + id: const PuzzleId('2nNdI'), + rating: 1090, + plays: 23890, + initialPly: 88, + solution: IList(const ['g4h4', 'h8h4', 'b4h4']), + themes: ISet(const { + 'endgame', + 'short', + 'crushing', + 'fork', + 'queenRookEndgame', + }), + ), + game: const PuzzleGame( + id: GameId('w32JTzEf'), + perf: Perf.blitz, + rated: true, + white: PuzzleGamePlayer( + side: Side.white, + name: 'Li', + title: null, + ), + black: PuzzleGamePlayer( + side: Side.black, + name: 'Gabriela', + title: null, + ), + pgn: + 'e4 e5 Nf3 Nc6 Bb5 a6 Ba4 b5 Bb3 Nf6 c3 Nxe4 d4 exd4 cxd4 Qe7 O-O Qd8 Bd5 Nf6 Bb3 Bd6 Nc3 O-O Bg5 h6 Bh4 g5 Nxg5 hxg5 Bxg5 Kg7 Ne4 Be7 Bxf6+ Bxf6 Qg4+ Kh8 Qh5+ Kg8 Qg6+ Kh8 Qxf6+ Qxf6 Nxf6 Nxd4 Rfd1 Ne2+ Kh1 d6 Rd5 Kg7 Nh5+ Kh6 Rad1 Be6 R5d2 Bxb3 axb3 Kxh5 Rxe2 Rfe8 Red2 Re5 h3 Rae8 Kh2 Re2 Rd5+ Kg6 f4 Rxb2 R1d3 Ree2 Rg3+ Kf6 h4 Re4 Rg4 Rxb3 h5 Rbb4 h6 Rxf4 h7 Rxg4 h8=Q+ Ke7 Rd3', + ), +); diff --git a/test/view/puzzle/puzzle_screen_test.dart b/test/view/puzzle/puzzle_screen_test.dart index 7cc0dca6a1..41497a07ab 100644 --- a/test/view/puzzle/puzzle_screen_test.dart +++ b/test/view/puzzle/puzzle_screen_test.dart @@ -6,9 +6,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/testing.dart'; import 'package:lichess_mobile/src/model/account/account_preferences.dart'; -import 'package:lichess_mobile/src/model/common/id.dart'; -import 'package:lichess_mobile/src/model/common/perf.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_batch_storage.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_storage.dart'; @@ -21,6 +18,7 @@ import 'package:mocktail/mocktail.dart'; import '../../test_helpers.dart'; import '../../test_provider_scope.dart'; +import 'example_data.dart'; class MockPuzzleBatchStorage extends Mock implements PuzzleBatchStorage {} @@ -457,86 +455,6 @@ void main() { }); } -final puzzle = Puzzle( - puzzle: PuzzleData( - id: const PuzzleId('6Sz3s'), - initialPly: 40, - plays: 68176, - rating: 1984, - solution: IList(const [ - 'h4h2', - 'h1h2', - 'e5f3', - 'h2h3', - 'b4h4', - ]), - themes: ISet(const [ - 'middlegame', - 'attraction', - 'long', - 'mateIn3', - 'sacrifice', - 'doubleCheck', - ]), - ), - game: const PuzzleGame( - rated: true, - id: GameId('zgBwsXLr'), - perf: Perf.blitz, - 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', - black: PuzzleGamePlayer( - side: Side.black, - name: 'CAMBIADOR', - ), - white: PuzzleGamePlayer( - side: Side.white, - name: 'arroyoM10', - ), - ), -); - -final batch = PuzzleBatch( - solved: IList(const []), - unsolved: IList([ - puzzle, - ]), -); - -final puzzle2 = Puzzle( - puzzle: PuzzleData( - id: const PuzzleId('2nNdI'), - rating: 1090, - plays: 23890, - initialPly: 88, - solution: IList(const ['g4h4', 'h8h4', 'b4h4']), - themes: ISet(const { - 'endgame', - 'short', - 'crushing', - 'fork', - 'queenRookEndgame', - }), - ), - game: const PuzzleGame( - id: GameId('w32JTzEf'), - perf: Perf.blitz, - rated: true, - white: PuzzleGamePlayer( - side: Side.white, - name: 'Li', - title: null, - ), - black: PuzzleGamePlayer( - side: Side.black, - name: 'Gabriela', - title: null, - ), - pgn: - 'e4 e5 Nf3 Nc6 Bb5 a6 Ba4 b5 Bb3 Nf6 c3 Nxe4 d4 exd4 cxd4 Qe7 O-O Qd8 Bd5 Nf6 Bb3 Bd6 Nc3 O-O Bg5 h6 Bh4 g5 Nxg5 hxg5 Bxg5 Kg7 Ne4 Be7 Bxf6+ Bxf6 Qg4+ Kh8 Qh5+ Kg8 Qg6+ Kh8 Qxf6+ Qxf6 Nxf6 Nxd4 Rfd1 Ne2+ Kh1 d6 Rd5 Kg7 Nh5+ Kh6 Rad1 Be6 R5d2 Bxb3 axb3 Kxh5 Rxe2 Rfe8 Red2 Re5 h3 Rae8 Kh2 Re2 Rd5+ Kg6 f4 Rxb2 R1d3 Ree2 Rg3+ Kf6 h4 Re4 Rg4 Rxb3 h5 Rbb4 h6 Rxf4 h7 Rxg4 h8=Q+ Ke7 Rd3', - ), -); - const batchOf1 = ''' {"puzzles":[{"game":{"id":"PrlkCqOv","perf":{"key":"rapid","name":"Rapid"},"rated":true,"players":[{"name":"silverjo", "rating":1777,"color":"white"},{"name":"Robyarchitetto", "rating":1742,"color":"black"}],"pgn":"e4 Nc6 Bc4 e6 a3 g6 Nf3 Bg7 c3 Nge7 d3 O-O Be3 Na5 Ba2 b6 Qd2 Bb7 Bh6 d5 e5 d4 Bxg7 Kxg7 Qf4 Bxf3 Qxf3 dxc3 Nxc3 Nac6 Qf6+ Kg8 Rd1 Nd4 O-O c5 Ne4 Nef5 Rd2 Qxf6 Nxf6+ Kg7 Re1 h5 h3 Rad8 b4 Nh4 Re3 Nhf5 Re1 a5 bxc5 bxc5 Bc4 Ra8 Rb1 Nh4 Rdb2 Nc6 Rb7 Nxe5 Bxe6 Kxf6 Bd5 Nf5 R7b6+ Kg7 Bxa8 Rxa8 R6b3 Nd4 Rb7 Nxd3 Rd1 Ne2+ Kh2 Ndf4 Rdd7 Rf8 Ra7 c4 Rxa5 c3 Rc5 Ne6 Rc4 Ra8 a4 Rb8 a5 Rb2 a6 c2","clock":"5+8"},"puzzle":{"id":"20yWT","rating":1859,"plays":551,"initialPly":93,"solution":["a6a7","b2a2","c4c2","a2a7","d7a7"],"themes":["endgame","long","advantage","advancedPawn"]}}]} '''; diff --git a/test/view/puzzle/puzzle_tab_screen_test.dart b/test/view/puzzle/puzzle_tab_screen_test.dart new file mode 100644 index 0000000000..32149feea2 --- /dev/null +++ b/test/view/puzzle/puzzle_tab_screen_test.dart @@ -0,0 +1,187 @@ +import 'package:chessground/chessground.dart'; +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/puzzle/puzzle_angle.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle_batch_storage.dart'; +import 'package:lichess_mobile/src/model/puzzle/puzzle_theme.dart'; +import 'package:lichess_mobile/src/network/http.dart'; +import 'package:lichess_mobile/src/view/puzzle/puzzle_tab_screen.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../model/puzzle/mock_server_responses.dart'; +import '../../network/fake_http_client_factory.dart'; +import '../../test_helpers.dart'; +import '../../test_provider_scope.dart'; +import 'example_data.dart'; + +final mockClient = MockClient((request) async { + if (request.url.path == '/api/puzzle/daily') { + return mockResponse(mockDailyPuzzleResponse, 200); + } + return mockResponse('', 404); +}); + +class MockPuzzleBatchStorage extends Mock implements PuzzleBatchStorage {} + +void main() { + setUpAll(() { + registerFallbackValue( + PuzzleBatch( + solved: IList(const []), + unsolved: IList([puzzle]), + ), + ); + }); + + final mockBatchStorage = MockPuzzleBatchStorage(); + + testWidgets('meets accessibility guidelines', (WidgetTester tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + + when( + () => mockBatchStorage.fetch( + userId: null, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + ), + ).thenAnswer((_) async => batch); + + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleTabScreen(), + overrides: [ + puzzleBatchStorageProvider.overrideWith((ref) => mockBatchStorage), + ], + ); + + await tester.pumpWidget(app); + + // wait for connectivity + await tester.pump(const Duration(milliseconds: 100)); + + // wait for the puzzles to load + await tester.pump(const Duration(milliseconds: 100)); + + await meetsTapTargetGuideline(tester); + + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + + await expectLater(tester, meetsGuideline(textContrastGuideline)); + + handle.dispose(); + }); + + testWidgets('shows puzzle menu', (WidgetTester tester) async { + when( + () => mockBatchStorage.fetch( + userId: null, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + ), + ).thenAnswer((_) async => batch); + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleTabScreen(), + overrides: [ + puzzleBatchStorageProvider.overrideWith((ref) => mockBatchStorage), + httpClientFactoryProvider.overrideWith((ref) { + return FakeHttpClientFactory(() => mockClient); + }), + ], + ); + + await tester.pumpWidget(app); + + // wait for connectivity + await tester.pumpAndSettle(const Duration(milliseconds: 100)); + + expect(find.text('Puzzle Themes'), findsOneWidget); + expect(find.text('Puzzle Streak'), findsOneWidget); + expect(find.text('Puzzle Storm'), findsOneWidget); + }); + + testWidgets('shows daily puzzle', (WidgetTester tester) async { + when( + () => mockBatchStorage.fetch( + userId: null, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + ), + ).thenAnswer((_) async => batch); + + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleTabScreen(), + overrides: [ + puzzleBatchStorageProvider.overrideWith((ref) => mockBatchStorage), + httpClientFactoryProvider.overrideWith((ref) { + return FakeHttpClientFactory(() => mockClient); + }), + ], + ); + + await tester.pumpWidget(app); + + // wait for connectivity + await tester.pump(const Duration(milliseconds: 100)); + + // wait for the puzzles to load + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(DailyPuzzle), findsOneWidget); + expect( + find.widgetWithText(DailyPuzzle, 'Puzzle of the day'), + findsOneWidget, + ); + expect( + find.widgetWithText(DailyPuzzle, 'Played 93,270 times'), + findsOneWidget, + ); + expect(find.widgetWithText(DailyPuzzle, 'Black to play'), findsOneWidget); + }); + + group('tactical training preview', () { + testWidgets('shows first puzzle from unsolved batch', + (WidgetTester tester) async { + when( + () => mockBatchStorage.fetch( + userId: null, + angle: const PuzzleTheme(PuzzleThemeKey.mix), + ), + ).thenAnswer((_) async => batch); + + final app = await makeTestProviderScopeApp( + tester, + home: const PuzzleTabScreen(), + overrides: [ + puzzleBatchStorageProvider.overrideWith((ref) => mockBatchStorage), + httpClientFactoryProvider.overrideWith((ref) { + return FakeHttpClientFactory(() => mockClient); + }), + ], + ); + + await tester.pumpWidget(app); + + // wait for the puzzle to load + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(TacticalTrainingPreview), findsOneWidget); + expect( + find.widgetWithText(TacticalTrainingPreview, 'Chess tactics trainer'), + findsOneWidget, + ); + final chessboard = find + .descendant( + of: find.byType(TacticalTrainingPreview), + matching: find.byType(Chessboard), + ) + .evaluate() + .first + .widget as Chessboard; + + expect( + chessboard.fen, + equals('4k2r/Q5pp/3bp3/4n3/1r5q/8/PP2B1PP/R1B2R1K b k - 0 21'), + ); + }); + }); +}