From f12f7f46f3efe888cf90504dee28d7cb8fc90c2f Mon Sep 17 00:00:00 2001 From: Sandip Date: Wed, 31 Jul 2024 01:23:47 +0530 Subject: [PATCH] feat: save and resume unfinished puzzle --- android/gradlew | 0 ios/Podfile.lock | 7 + lib/home/bloc/home_bloc.dart | 46 +++- lib/home/bloc/home_event.dart | 11 + lib/home/bloc/home_state.dart | 6 + lib/home/view/home_page.dart | 14 +- lib/l10n/arb/app_en.arb | 6 +- lib/puzzle/bloc/puzzle_bloc.dart | 16 +- lib/puzzle/bloc/puzzle_event.dart | 9 + lib/puzzle/view/puzzle_page.dart | 45 ++-- lib/timer/bloc/timer_bloc.dart | 16 +- lib/timer/bloc/timer_event.dart | 7 +- lib/timer/bloc/timer_state.dart | 6 +- test/app/view/app_test.dart | 8 + test/helpers/mocks.dart | 5 + test/home/bloc/home_bloc_test.dart | 279 ++++++++++++++++-------- test/home/bloc/home_event_test.dart | 32 +++ test/home/bloc/home_state_test.dart | 12 + test/home/view/home_page_test.dart | 51 ++++- test/puzzle/bloc/puzzle_bloc_test.dart | 50 ++++- test/puzzle/bloc/puzzle_event_test.dart | 16 ++ test/puzzle/view/puzzle_page_test.dart | 87 +++++++- test/timer/bloc/timer_bloc_test.dart | 82 +++++-- test/timer/bloc/timer_event_test.dart | 4 +- test/timer/bloc/timer_state_test.dart | 7 +- 25 files changed, 676 insertions(+), 146 deletions(-) mode change 100644 => 100755 android/gradlew diff --git a/android/gradlew b/android/gradlew old mode 100644 new mode 100755 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index b6b90e4..dfe38e7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,20 +3,27 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS DEPENDENCIES: - Flutter (from `Flutter`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) EXTERNAL SOURCES: Flutter: :path: Flutter path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 diff --git a/lib/home/bloc/home_bloc.dart b/lib/home/bloc/home_bloc.dart index f8ff20f..820eec5 100644 --- a/lib/home/bloc/home_bloc.dart +++ b/lib/home/bloc/home_bloc.dart @@ -18,6 +18,8 @@ class HomeBloc extends Bloc { _puzzleRepository = puzzleRepository, super(const HomeState()) { on(_onSudokuCreationRequested); + on(_onSubscriptionRequested); + on(_onUnfinishedPuzzleResumed); } final SudokuAPI _apiClient; @@ -39,7 +41,7 @@ class HomeBloc extends Bloc { final sudoku = await _apiClient.createSudoku( difficulty: event.difficulty, ); - _puzzleRepository.storePuzzle( + _puzzleRepository.savePuzzleToCache( puzzle: Puzzle(sudoku: sudoku, difficulty: event.difficulty), ); emit( @@ -70,4 +72,46 @@ class HomeBloc extends Bloc { ); } } + + FutureOr _onSubscriptionRequested( + UnfinishedPuzzleSubscriptionRequested event, + Emitter emit, + ) async { + await emit.forEach( + _puzzleRepository.getPuzzleFromLocalMemory(), + onData: (puzzle) { + return state.copyWith( + unfinishedPuzzle: () => puzzle, + ); + }, + onError: (_, __) => state.copyWith( + unfinishedPuzzle: () => null, + ), + ); + } + + void _onUnfinishedPuzzleResumed( + UnfinishedPuzzleResumed event, + Emitter emit, + ) { + final unfinishedPuzzle = state.unfinishedPuzzle; + if (unfinishedPuzzle == null) return; + + emit( + state.copyWith( + sudokuCreationStatus: () => SudokuCreationStatus.inProgress, + ), + ); + // Save the unfinished puzzle to the local cache, to be picked up by + // PuzzleBloc, and at the same time, remove the unfinished saved puzzle + // from local storage. + _puzzleRepository + ..savePuzzleToCache(puzzle: unfinishedPuzzle) + ..clearPuzzleInLocalMemory(); + emit( + state.copyWith( + sudokuCreationStatus: () => SudokuCreationStatus.completed, + ), + ); + } } diff --git a/lib/home/bloc/home_event.dart b/lib/home/bloc/home_event.dart index e3453a3..167a9ea 100644 --- a/lib/home/bloc/home_event.dart +++ b/lib/home/bloc/home_event.dart @@ -2,6 +2,9 @@ part of 'home_bloc.dart'; sealed class HomeEvent extends Equatable { const HomeEvent(); + + @override + List get props => []; } final class SudokuCreationRequested extends HomeEvent { @@ -12,3 +15,11 @@ final class SudokuCreationRequested extends HomeEvent { @override List get props => [difficulty]; } + +final class UnfinishedPuzzleSubscriptionRequested extends HomeEvent { + const UnfinishedPuzzleSubscriptionRequested(); +} + +final class UnfinishedPuzzleResumed extends HomeEvent { + const UnfinishedPuzzleResumed(); +} diff --git a/lib/home/bloc/home_state.dart b/lib/home/bloc/home_state.dart index 6d8c224..2701a5d 100644 --- a/lib/home/bloc/home_state.dart +++ b/lib/home/bloc/home_state.dart @@ -10,23 +10,27 @@ class HomeState extends Equatable { this.difficulty, this.sudokuCreationStatus = SudokuCreationStatus.initial, this.sudokuCreationError, + this.unfinishedPuzzle, }); final Difficulty? difficulty; final SudokuCreationStatus sudokuCreationStatus; final SudokuCreationErrorType? sudokuCreationError; + final Puzzle? unfinishedPuzzle; @override List get props => [ difficulty, sudokuCreationStatus, sudokuCreationError, + unfinishedPuzzle, ]; HomeState copyWith({ Difficulty? Function()? difficulty, SudokuCreationStatus Function()? sudokuCreationStatus, SudokuCreationErrorType? Function()? sudokuCreationError, + Puzzle? Function()? unfinishedPuzzle, }) { return HomeState( difficulty: difficulty != null ? difficulty() : this.difficulty, @@ -36,6 +40,8 @@ class HomeState extends Equatable { sudokuCreationError: sudokuCreationError != null ? sudokuCreationError() : this.sudokuCreationError, + unfinishedPuzzle: + unfinishedPuzzle != null ? unfinishedPuzzle() : this.unfinishedPuzzle, ); } } diff --git a/lib/home/view/home_page.dart b/lib/home/view/home_page.dart index a890c91..1a826c6 100644 --- a/lib/home/view/home_page.dart +++ b/lib/home/view/home_page.dart @@ -27,7 +27,7 @@ class HomePage extends StatelessWidget { create: (context) => HomeBloc( apiClient: context.read(), puzzleRepository: context.read(), - ), + )..add(const UnfinishedPuzzleSubscriptionRequested()), child: const HomeView(), ); } @@ -278,6 +278,10 @@ class HighlightedSection extends StatelessWidget { Widget build(BuildContext context) { final l10n = context.l10n; + final unfinishedPuzzle = context.select( + (HomeBloc bloc) => bloc.state.unfinishedPuzzle, + ); + final dailyChallengeWidget = HighlightedSectionItem( key: const Key('daily_challenge_widget'), elevatedButtonkey: const Key('daily_challenge_widget_elevated_button'), @@ -293,9 +297,13 @@ class HighlightedSection extends StatelessWidget { elevatedButtonkey: const Key('resume_puzzle_widget_elevated_button'), iconAsset: Assets.unfinishedPuzzleIcon, title: l10n.resumeSudokuTitle, - subtitle: l10n.resumeSudokuSubtitle, + subtitle: unfinishedPuzzle != null + ? l10n.resumeSudokuSubtitle + : l10n.resumeSudokuNoPuzzleSubtitle, buttonText: 'Resume', - onButtonPressed: null, + onButtonPressed: unfinishedPuzzle != null + ? () => context.read().add(const UnfinishedPuzzleResumed()) + : null, ); return ResponsiveLayoutBuilder( diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index d27711c..03abfa0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -24,10 +24,14 @@ "@resumeSudokuTitle": { "description": "Title shown in the Resume Sudoku card of the Home Page" }, - "resumeSudokuSubtitle": "All clear! Start a new game.", + "resumeSudokuSubtitle": "Complete your last unsolved puzzle.", "@resumeSudokuSubtitle": { "description": "Subtitle shown in the Resume Sudoku card of the Home Page" }, + "resumeSudokuNoPuzzleSubtitle": "All clear! Start a new game.", + "@resumeSudokuNoPuzzleSubtitle": { + "description": "Subtitle shown when no unfinished puzzle in the Resume Sudoku card of the Home Page" + }, "createNewGameHeader": "New Game", "@createNewGameHeader": { "description": "Header text shown in the Create New Game section of the Home Page" diff --git a/lib/puzzle/bloc/puzzle_bloc.dart b/lib/puzzle/bloc/puzzle_bloc.dart index e7d5483..8158e35 100644 --- a/lib/puzzle/bloc/puzzle_bloc.dart +++ b/lib/puzzle/bloc/puzzle_bloc.dart @@ -23,6 +23,7 @@ class PuzzleBloc extends Bloc { on(_onSudokuInputErased); on(_onSudokuHintRequested); on(_onHintInteractioCompleted); + on(_onUnfinishedPuzzleSaveRequested); } final PuzzleRepository _puzzleRepository; @@ -33,7 +34,7 @@ class PuzzleBloc extends Bloc { PuzzleInitialized event, Emitter emit, ) { - final puzzle = _puzzleRepository.getPuzzle()!; + final puzzle = _puzzleRepository.fetchPuzzleFromCache()!; emit(state.copyWith(puzzle: () => puzzle)); } @@ -195,4 +196,17 @@ class PuzzleBloc extends Bloc { ), ); } + + void _onUnfinishedPuzzleSaveRequested( + UnfinishedPuzzleSaveRequested event, + Emitter emit, + ) { + final puzzle = state.puzzle.copyWith( + totalSecondsElapsed: event.elapsedSeconds, + ); + emit(state.copyWith(puzzle: () => puzzle)); + unawaited( + _puzzleRepository.storePuzzleInLocalMemory(puzzle: puzzle), + ); + } } diff --git a/lib/puzzle/bloc/puzzle_event.dart b/lib/puzzle/bloc/puzzle_event.dart index d5cab48..b293ca5 100644 --- a/lib/puzzle/bloc/puzzle_event.dart +++ b/lib/puzzle/bloc/puzzle_event.dart @@ -40,3 +40,12 @@ final class SudokuHintRequested extends PuzzleEvent { final class HintInteractioCompleted extends PuzzleEvent { const HintInteractioCompleted(); } + +final class UnfinishedPuzzleSaveRequested extends PuzzleEvent { + const UnfinishedPuzzleSaveRequested(this.elapsedSeconds); + + final int elapsedSeconds; + + @override + List get props => [elapsedSeconds]; +} diff --git a/lib/puzzle/view/puzzle_page.dart b/lib/puzzle/view/puzzle_page.dart index d5daab3..cfa25dd 100644 --- a/lib/puzzle/view/puzzle_page.dart +++ b/lib/puzzle/view/puzzle_page.dart @@ -27,9 +27,12 @@ class PuzzlePage extends StatelessWidget { )..add(const PuzzleInitialized()), ), BlocProvider( - create: (context) => TimerBloc( - ticker: const Ticker(), - )..add(const TimerStarted()), + create: (context) { + final puzzle = context.read().state.puzzle; + return TimerBloc( + ticker: const Ticker(), + )..add(TimerStarted(puzzle.totalSecondsElapsed)); + }, ), ], child: const PuzzleView(), @@ -68,18 +71,30 @@ class PuzzleView extends StatelessWidget { ); } }, - child: Scaffold( - extendBodyBehindAppBar: true, - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - scrolledUnderElevation: 0, - systemOverlayStyle: theme.brightness == Brightness.light - ? SystemUiOverlayStyle.dark - : SystemUiOverlayStyle.light, - ), - body: const SudokuBackground( - child: PuzzleViewLayout(), + child: PopScope( + onPopInvoked: (_) { + final puzzleStatus = context.read().state.puzzleStatus; + if (puzzleStatus == PuzzleStatus.incomplete) { + context.read().add( + UnfinishedPuzzleSaveRequested( + context.read().state.secondsElapsed, + ), + ); + } + }, + child: Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + systemOverlayStyle: theme.brightness == Brightness.light + ? SystemUiOverlayStyle.dark + : SystemUiOverlayStyle.light, + ), + body: const SudokuBackground( + child: PuzzleViewLayout(), + ), ), ), ); diff --git a/lib/timer/bloc/timer_bloc.dart b/lib/timer/bloc/timer_bloc.dart index b657918..f576671 100644 --- a/lib/timer/bloc/timer_bloc.dart +++ b/lib/timer/bloc/timer_bloc.dart @@ -29,11 +29,19 @@ class TimerBloc extends Bloc { } void _onTimerStarted(TimerStarted event, Emitter emit) { + emit( + state.copyWith( + initialValue: event.initialValue, + secondsElapsed: event.initialValue, + isRunning: true, + ), + ); _tickerSubscription?.cancel(); - _tickerSubscription = _ticker - .tick() - .listen((secondsElapsed) => add(TimerTicked(secondsElapsed))); - emit(state.copyWith(isRunning: true)); + _tickerSubscription = _ticker.tick().listen( + (secondsElapsed) => add( + TimerTicked(state.initialValue + secondsElapsed), + ), + ); } void _onTimerTicked(TimerTicked event, Emitter emit) { diff --git a/lib/timer/bloc/timer_event.dart b/lib/timer/bloc/timer_event.dart index fb8f3cc..4166fd0 100644 --- a/lib/timer/bloc/timer_event.dart +++ b/lib/timer/bloc/timer_event.dart @@ -8,7 +8,12 @@ sealed class TimerEvent extends Equatable { } final class TimerStarted extends TimerEvent { - const TimerStarted(); + const TimerStarted(this.initialValue); + + final int initialValue; + + @override + List get props => [initialValue]; } final class TimerTicked extends TimerEvent { diff --git a/lib/timer/bloc/timer_state.dart b/lib/timer/bloc/timer_state.dart index 9e54ea5..8023ae0 100644 --- a/lib/timer/bloc/timer_state.dart +++ b/lib/timer/bloc/timer_state.dart @@ -4,21 +4,25 @@ part of 'timer_bloc.dart'; class TimerState extends Equatable { const TimerState({ this.isRunning = false, + this.initialValue = 0, this.secondsElapsed = 0, }); final bool isRunning; + final int initialValue; final int secondsElapsed; @override - List get props => [isRunning, secondsElapsed]; + List get props => [isRunning, initialValue, secondsElapsed]; TimerState copyWith({ bool? isRunning, + int? initialValue, int? secondsElapsed, }) { return TimerState( isRunning: isRunning ?? this.isRunning, + initialValue: initialValue ?? this.initialValue, secondsElapsed: secondsElapsed ?? this.secondsElapsed, ); } diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index 0ed0b4d..6b110ed 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -1,19 +1,27 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:sudoku/api/api.dart'; import 'package:sudoku/app/app.dart'; import 'package:sudoku/home/home.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; import 'package:sudoku/repository/repository.dart'; import '../../helpers/helpers.dart'; void main() { group('App', () { + late Puzzle puzzle; late SudokuAPI apiClient; late PuzzleRepository puzzleRepository; setUp(() { + puzzle = MockPuzzle(); apiClient = MockSudokuAPI(); puzzleRepository = MockPuzzleRepository(); + + when(() => puzzleRepository.getPuzzleFromLocalMemory()).thenAnswer( + (_) => Stream.value(puzzle), + ); }); testWidgets('renders HomePage', (tester) async { diff --git a/test/helpers/mocks.dart b/test/helpers/mocks.dart index 6994c1f..47d2e83 100644 --- a/test/helpers/mocks.dart +++ b/test/helpers/mocks.dart @@ -8,6 +8,7 @@ import 'package:sudoku/home/home.dart'; import 'package:sudoku/models/models.dart'; import 'package:sudoku/puzzle/puzzle.dart'; import 'package:sudoku/repository/repository.dart'; +import 'package:sudoku/storage/storage.dart'; import 'package:sudoku/timer/timer.dart'; class MockSudoku extends Mock implements Sudoku {} @@ -25,6 +26,8 @@ class MockSudokuAPI extends Mock implements SudokuAPI {} class MockHomeBloc extends MockBloc implements HomeBloc {} +class MockHomeState extends Mock implements HomeState {} + class MockDio extends Mock implements Dio {} class MockCacheClient extends Mock implements CacheClient {} @@ -43,3 +46,5 @@ class MockSharedPreferences extends Mock implements SharedPreferences {} class MockHint extends Mock implements Hint {} class MockTimerState extends Mock implements TimerState {} + +class MockStorageAPI extends Mock implements StorageAPI {} diff --git a/test/home/bloc/home_bloc_test.dart b/test/home/bloc/home_bloc_test.dart index b885909..cc18326 100644 --- a/test/home/bloc/home_bloc_test.dart +++ b/test/home/bloc/home_bloc_test.dart @@ -13,6 +13,7 @@ import '../../helpers/helpers.dart'; void main() { group('HomeBloc', () { + late Puzzle puzzle; late SudokuAPI apiClient; late PuzzleRepository puzzleRepository; @@ -27,10 +28,20 @@ void main() { ); setUp(() { + puzzle = MockPuzzle(); apiClient = MockSudokuAPI(); puzzleRepository = MockPuzzleRepository(); + when(() => apiClient.createSudoku(difficulty: any(named: 'difficulty'))) - .thenAnswer((_) => Future.value(sudoku)); + .thenAnswer( + (_) => Future.value(sudoku), + ); + when(() => puzzleRepository.getPuzzleFromLocalMemory()).thenAnswer( + (_) => Stream.value(puzzle), + ); + when(() => puzzleRepository.clearPuzzleInLocalMemory()).thenAnswer( + (_) async {}, + ); }); setUpAll(() { @@ -48,99 +59,185 @@ void main() { expect(buildBloc, returnsNormally); }); - blocTest( - 'emits state with in progress and completed [SudokuCreationStatus] ' - 'along with defined difficulty', - build: buildBloc, - act: (bloc) => bloc.add(SudokuCreationRequested(Difficulty.medium)), - expect: () => [ - HomeState( - difficulty: Difficulty.medium, - sudokuCreationStatus: SudokuCreationStatus.inProgress, - sudokuCreationError: null, - ), - HomeState( - difficulty: Difficulty.medium, - sudokuCreationStatus: SudokuCreationStatus.completed, - sudokuCreationError: null, - ), - ], - verify: (_) { - verify( - () => apiClient.createSudoku(difficulty: Difficulty.medium), - ).called(1); - verify( - () => puzzleRepository.storePuzzle( - puzzle: Puzzle(sudoku: sudoku, difficulty: Difficulty.medium), + test('has an initial state', () { + expect(buildBloc().state, equals(HomeState())); + }); + + group('SudokuCreationRequested', () { + blocTest( + 'emits state with in progress and completed [SudokuCreationStatus] ' + 'along with defined difficulty', + build: buildBloc, + act: (bloc) => bloc.add(SudokuCreationRequested(Difficulty.medium)), + expect: () => [ + HomeState( + difficulty: Difficulty.medium, + sudokuCreationStatus: SudokuCreationStatus.inProgress, + sudokuCreationError: null, ), - ).called(1); - }, - ); + HomeState( + difficulty: Difficulty.medium, + sudokuCreationStatus: SudokuCreationStatus.completed, + sudokuCreationError: null, + ), + ], + verify: (_) { + verify( + () => apiClient.createSudoku(difficulty: Difficulty.medium), + ).called(1); + verify( + () => puzzleRepository.savePuzzleToCache( + puzzle: Puzzle(sudoku: sudoku, difficulty: Difficulty.medium), + ), + ).called(1); + }, + ); - blocTest( - 'emits failed [SudokuCreationStatus] along with error type ' - 'when api exception is thrown', - build: buildBloc, - setUp: () => when( - () => apiClient.createSudoku(difficulty: any(named: 'difficulty')), - ).thenThrow(SudokuAPIClientException()), - act: (bloc) => bloc.add(SudokuCreationRequested(Difficulty.medium)), - expect: () => [ - HomeState( - difficulty: Difficulty.medium, - sudokuCreationStatus: SudokuCreationStatus.inProgress, - sudokuCreationError: null, - ), - HomeState( - difficulty: Difficulty.medium, - sudokuCreationStatus: SudokuCreationStatus.failed, - sudokuCreationError: SudokuCreationErrorType.apiClient, - ), - ], - ); + blocTest( + 'emits failed [SudokuCreationStatus] along with error type ' + 'when api exception is thrown', + build: buildBloc, + setUp: () => when( + () => apiClient.createSudoku(difficulty: any(named: 'difficulty')), + ).thenThrow(SudokuAPIClientException()), + act: (bloc) => bloc.add(SudokuCreationRequested(Difficulty.medium)), + expect: () => [ + HomeState( + difficulty: Difficulty.medium, + sudokuCreationStatus: SudokuCreationStatus.inProgress, + sudokuCreationError: null, + ), + HomeState( + difficulty: Difficulty.medium, + sudokuCreationStatus: SudokuCreationStatus.failed, + sudokuCreationError: SudokuCreationErrorType.apiClient, + ), + ], + ); - blocTest( - 'emits failed [SudokuCreationStatus] along with error type ' - 'when api returns invalid raw data', - build: buildBloc, - setUp: () => when( - () => apiClient.createSudoku(difficulty: any(named: 'difficulty')), - ).thenThrow(SudokuInvalidRawDataException()), - act: (bloc) => bloc.add(SudokuCreationRequested(Difficulty.medium)), - expect: () => [ - HomeState( - difficulty: Difficulty.medium, - sudokuCreationStatus: SudokuCreationStatus.inProgress, - sudokuCreationError: null, - ), - HomeState( - difficulty: Difficulty.medium, - sudokuCreationStatus: SudokuCreationStatus.failed, - sudokuCreationError: SudokuCreationErrorType.invalidRawData, - ), - ], - ); + blocTest( + 'emits failed [SudokuCreationStatus] along with error type ' + 'when api returns invalid raw data', + build: buildBloc, + setUp: () => when( + () => apiClient.createSudoku(difficulty: any(named: 'difficulty')), + ).thenThrow(SudokuInvalidRawDataException()), + act: (bloc) => bloc.add(SudokuCreationRequested(Difficulty.medium)), + expect: () => [ + HomeState( + difficulty: Difficulty.medium, + sudokuCreationStatus: SudokuCreationStatus.inProgress, + sudokuCreationError: null, + ), + HomeState( + difficulty: Difficulty.medium, + sudokuCreationStatus: SudokuCreationStatus.failed, + sudokuCreationError: SudokuCreationErrorType.invalidRawData, + ), + ], + ); - blocTest( - 'emits failed [SudokuCreationStatus] along with unexpected error type ' - 'when api method returns exception', - build: buildBloc, - setUp: () => when( - () => apiClient.createSudoku(difficulty: any(named: 'difficulty')), - ).thenThrow(Exception()), - act: (bloc) => bloc.add(SudokuCreationRequested(Difficulty.medium)), - expect: () => [ - HomeState( - difficulty: Difficulty.medium, - sudokuCreationStatus: SudokuCreationStatus.inProgress, - sudokuCreationError: null, - ), - HomeState( - difficulty: Difficulty.medium, - sudokuCreationStatus: SudokuCreationStatus.failed, - sudokuCreationError: SudokuCreationErrorType.unexpected, - ), - ], - ); + blocTest( + 'emits failed [SudokuCreationStatus] along with unexpected error type ' + 'when api method returns exception', + build: buildBloc, + setUp: () => when( + () => apiClient.createSudoku(difficulty: any(named: 'difficulty')), + ).thenThrow(Exception()), + act: (bloc) => bloc.add(SudokuCreationRequested(Difficulty.medium)), + expect: () => [ + HomeState( + difficulty: Difficulty.medium, + sudokuCreationStatus: SudokuCreationStatus.inProgress, + sudokuCreationError: null, + ), + HomeState( + difficulty: Difficulty.medium, + sudokuCreationStatus: SudokuCreationStatus.failed, + sudokuCreationError: SudokuCreationErrorType.unexpected, + ), + ], + ); + }); + + group('UnfinishedPuzzleSubscriptionRequested', () { + blocTest( + 'starts listening to getPuzzleFromLocalMemory from PuzzleRepository', + build: buildBloc, + act: (bloc) => bloc.add(UnfinishedPuzzleSubscriptionRequested()), + verify: (_) { + verify(() => puzzleRepository.getPuzzleFromLocalMemory()).called(1); + }, + ); + + blocTest( + 'emits state with updated [unfinishedPuzzle] when repository ' + 'getPuzzleFromLocalMemory emits a new puzzle', + build: buildBloc, + act: (bloc) => bloc.add(UnfinishedPuzzleSubscriptionRequested()), + expect: () => [ + HomeState(unfinishedPuzzle: puzzle), + ], + ); + + blocTest( + 'emits state with null [unfinishedPuzzle] when repository ' + 'getPuzzleFromLocalMemory emits an error', + build: buildBloc, + setUp: () { + when(() => puzzleRepository.getPuzzleFromLocalMemory()) + .thenAnswer((_) => Stream.error(Exception())); + }, + act: (bloc) => bloc.add(UnfinishedPuzzleSubscriptionRequested()), + expect: () => [ + HomeState(unfinishedPuzzle: null), + ], + ); + }); + + group('UnfinishedPuzzleResumed', () { + blocTest( + 'calls the savePuzzleToCache and clearPuzzleInLocalMemory ' + 'from PuzzleRepository', + build: buildBloc, + act: (bloc) => bloc.add(UnfinishedPuzzleResumed()), + seed: () => HomeState(unfinishedPuzzle: puzzle), + verify: (_) { + verify( + () => puzzleRepository.savePuzzleToCache(puzzle: puzzle), + ).called(1); + verify(() => puzzleRepository.clearPuzzleInLocalMemory()).called(1); + }, + ); + + blocTest( + 'does not emit state changes when unfinishedPuzzle is null', + build: buildBloc, + act: (bloc) => bloc.add(UnfinishedPuzzleResumed()), + seed: () => HomeState(unfinishedPuzzle: null), + verify: (_) { + verifyNever(() => puzzleRepository.savePuzzleToCache(puzzle: puzzle)); + verifyNever(() => puzzleRepository.clearPuzzleInLocalMemory()); + }, + expect: () => [], + ); + + blocTest( + 'emits the loading status and then the completed puzzleStatus', + build: buildBloc, + act: (bloc) => bloc.add(UnfinishedPuzzleResumed()), + seed: () => HomeState(unfinishedPuzzle: puzzle), + expect: () => [ + HomeState( + sudokuCreationStatus: SudokuCreationStatus.inProgress, + unfinishedPuzzle: puzzle, + ), + HomeState( + sudokuCreationStatus: SudokuCreationStatus.completed, + unfinishedPuzzle: puzzle, + ), + ], + ); + }); }); } diff --git a/test/home/bloc/home_event_test.dart b/test/home/bloc/home_event_test.dart index a1d815a..7c16a8d 100644 --- a/test/home/bloc/home_event_test.dart +++ b/test/home/bloc/home_event_test.dart @@ -21,5 +21,37 @@ void main() { ); }); }); + + group('UnfinishedPuzzleSubscriptionRequested', () { + test('supports value equality', () { + expect( + UnfinishedPuzzleSubscriptionRequested(), + equals(UnfinishedPuzzleSubscriptionRequested()), + ); + }); + + test('props are correct', () { + expect( + UnfinishedPuzzleSubscriptionRequested().props, + equals([]), + ); + }); + }); + + group('UnfinishedPuzzleResumed', () { + test('supports value equality', () { + expect( + UnfinishedPuzzleResumed(), + equals(UnfinishedPuzzleResumed()), + ); + }); + + test('props are correct', () { + expect( + UnfinishedPuzzleResumed().props, + equals([]), + ); + }); + }); }); } diff --git a/test/home/bloc/home_state_test.dart b/test/home/bloc/home_state_test.dart index 85d8f9f..08c32d0 100644 --- a/test/home/bloc/home_state_test.dart +++ b/test/home/bloc/home_state_test.dart @@ -3,6 +3,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:sudoku/home/home.dart'; import 'package:sudoku/models/models.dart'; +import 'package:sudoku/puzzle/puzzle.dart'; + +import '../../helpers/helpers.dart'; void main() { group('HomeState', () { @@ -10,12 +13,14 @@ void main() { Difficulty? difficulty, SudokuCreationStatus? sudokuCreationStatus, SudokuCreationErrorType? sudokuCreationError, + Puzzle? unfinishedPuzzle, }) { return HomeState( difficulty: difficulty, sudokuCreationStatus: sudokuCreationStatus ?? SudokuCreationStatus.initial, sudokuCreationError: sudokuCreationError, + unfinishedPuzzle: unfinishedPuzzle, ); } @@ -31,6 +36,7 @@ void main() { null, SudokuCreationStatus.initial, null, + null, ], ), ); @@ -47,23 +53,27 @@ void main() { difficulty: null, sudokuCreationStatus: null, sudokuCreationError: null, + unfinishedPuzzle: null, ), equals(createSubject()), ); }); test('returns the updated copy of this for every non-null parameter', () { + final puzzle = MockPuzzle(); expect( createSubject().copyWith( difficulty: () => Difficulty.expert, sudokuCreationStatus: () => SudokuCreationStatus.inProgress, sudokuCreationError: () => SudokuCreationErrorType.unexpected, + unfinishedPuzzle: () => puzzle, ), equals( createSubject( difficulty: Difficulty.expert, sudokuCreationStatus: SudokuCreationStatus.inProgress, sudokuCreationError: SudokuCreationErrorType.unexpected, + unfinishedPuzzle: puzzle, ), ), ); @@ -75,11 +85,13 @@ void main() { createSubject().copyWith( difficulty: () => null, sudokuCreationError: () => null, + unfinishedPuzzle: () => null, ), equals( createSubject( difficulty: null, sudokuCreationError: null, + unfinishedPuzzle: null, ), ), ); diff --git a/test/home/view/home_page_test.dart b/test/home/view/home_page_test.dart index 860c504..681a12a 100644 --- a/test/home/view/home_page_test.dart +++ b/test/home/view/home_page_test.dart @@ -7,6 +7,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:sudoku/home/home.dart'; import 'package:sudoku/models/models.dart'; import 'package:sudoku/puzzle/puzzle.dart'; +import 'package:sudoku/repository/repository.dart'; import 'package:sudoku/widgets/widgets.dart'; import '../../helpers/helpers.dart'; @@ -27,29 +28,49 @@ void main() { const failureDialogKey = Key('sudoku_failure_dialog'); late HomeBloc homeBloc; + late HomeState homeState; + late PuzzleBloc puzzleBloc; + late PuzzleState puzzleState; + late Puzzle puzzle; + late PuzzleRepository puzzleRepository; setUp(() { homeBloc = MockHomeBloc(); - when(() => homeBloc.state).thenReturn(HomeState()); + puzzleRepository = MockPuzzleRepository(); + puzzle = MockPuzzle(); + homeState = MockHomeState(); + + when(() => homeBloc.state).thenReturn(homeState); + when(puzzleRepository.fetchPuzzleFromCache).thenReturn(puzzle); + when(() => puzzleRepository.getPuzzleFromLocalMemory()).thenAnswer( + (_) => Stream.value(puzzle), + ); + + puzzleBloc = MockPuzzleBloc(); + puzzleState = MockPuzzleState(); + + when(() => puzzle.totalSecondsElapsed).thenReturn(12); + when(() => puzzleState.puzzle).thenReturn(puzzle); + when(() => puzzleBloc.state).thenReturn(puzzleState); }); testWidgets('renders on a large layout', (tester) async { tester.setLargeDisplaySize(); - await tester.pumpApp(HomePage()); + await tester.pumpApp(HomePage(), puzzleRepository: puzzleRepository); expect(find.byType(HomeView), findsOneWidget); }); testWidgets('renders on a medium layout', (tester) async { tester.setMediumDisplaySize(); - await tester.pumpApp(HomePage()); + await tester.pumpApp(HomePage(), puzzleRepository: puzzleRepository); expect(find.byType(HomeView), findsOneWidget); }); testWidgets('renders on a small layout', (tester) async { tester.setSmallDisplaySize(); - await tester.pumpApp(HomePage()); + await tester.pumpApp(HomePage(), puzzleRepository: puzzleRepository); expect(find.byType(HomeView), findsOneWidget); }); @@ -122,14 +143,10 @@ void main() { testWidgets( 'routes to [PuzzlePage] when [SudokuCreationStatus] is completed', (tester) async { - final puzzle = MockPuzzle(); when(() => puzzle.sudoku).thenReturn(sudoku3x3); when(() => puzzle.difficulty).thenReturn(Difficulty.medium); when(() => puzzle.remainingMistakes).thenReturn(3); - final puzzleRepository = MockPuzzleRepository(); - when(puzzleRepository.getPuzzle).thenReturn(puzzle); - whenListen( homeBloc, Stream.fromIterable( @@ -149,6 +166,7 @@ void main() { await tester.pumpApp( HomeView(), homeBloc: homeBloc, + puzzleBloc: puzzleBloc, puzzleRepository: puzzleRepository, ); await tester.pumpAndSettle(); @@ -213,6 +231,23 @@ void main() { findsOneWidget, ); }); + + testWidgets( + 'adds [UnfinishedPuzzleResumed] when unfinishedPuzzle is not null', + (tester) async { + when(() => homeState.unfinishedPuzzle).thenReturn(puzzle); + when(() => homeBloc.state).thenReturn(homeState); + + await tester.pumpApp(HomeView(), homeBloc: homeBloc); + final finder = find.byKey(resumePuzzleElevatedButtonKey); + + await tester.tap(finder); + await tester.pumpAndSettle(); + + expect(finder, findsOneWidget); + verify(() => homeBloc.add(UnfinishedPuzzleResumed())).called(1); + }, + ); }); group('Create Game', () { diff --git a/test/puzzle/bloc/puzzle_bloc_test.dart b/test/puzzle/bloc/puzzle_bloc_test.dart index 62becbd..f5b4fe3 100644 --- a/test/puzzle/bloc/puzzle_bloc_test.dart +++ b/test/puzzle/bloc/puzzle_bloc_test.dart @@ -14,6 +14,8 @@ class _FakeBlock extends Fake implements Block {} class _FakeSudoku extends Fake implements Sudoku {} +class _FakePuzzle extends Fake implements Puzzle {} + void main() { group('PuzzleBloc', () { late Block block; @@ -35,12 +37,16 @@ void main() { when(() => sudoku.blocksToHighlight(any())).thenReturn([block]); when(() => puzzle.sudoku).thenReturn(sudoku); - when(() => repository.getPuzzle()).thenReturn(puzzle); + when(() => repository.fetchPuzzleFromCache()).thenReturn(puzzle); + when( + () => repository.storePuzzleInLocalMemory(puzzle: any(named: 'puzzle')), + ).thenAnswer((_) async {}); }); setUpAll(() { registerFallbackValue(_FakeBlock()); registerFallbackValue(_FakeSudoku()); + registerFallbackValue(_FakePuzzle()); }); PuzzleBloc buildBloc() { @@ -67,7 +73,7 @@ void main() { PuzzleState(puzzle: puzzle), ], verify: (_) { - verify(() => repository.getPuzzle()).called(1); + verify(() => repository.fetchPuzzleFromCache()).called(1); }, ); }); @@ -405,5 +411,45 @@ void main() { ], ); }); + + group('UnfinishedPuzzleSaveRequested', () { + blocTest( + 'calls the storePuzzleInLocalMemory method from PuzzleRepository', + build: buildBloc, + act: (bloc) => bloc.add(UnfinishedPuzzleSaveRequested(12)), + seed: () => PuzzleState( + puzzle: Puzzle(sudoku: sudoku3x3, difficulty: Difficulty.easy), + ), + verify: (_) { + verify( + () => repository.storePuzzleInLocalMemory( + puzzle: Puzzle( + sudoku: sudoku3x3, + difficulty: Difficulty.easy, + totalSecondsElapsed: 12, + ), + ), + ).called(1); + }, + ); + + blocTest( + 'emits new state with updated totalSecondsElapsed', + build: buildBloc, + act: (bloc) => bloc.add(UnfinishedPuzzleSaveRequested(12)), + seed: () => PuzzleState( + puzzle: Puzzle(sudoku: sudoku3x3, difficulty: Difficulty.easy), + ), + expect: () => [ + PuzzleState( + puzzle: Puzzle( + sudoku: sudoku3x3, + difficulty: Difficulty.easy, + totalSecondsElapsed: 12, + ), + ), + ], + ); + }); }); } diff --git a/test/puzzle/bloc/puzzle_event_test.dart b/test/puzzle/bloc/puzzle_event_test.dart index 11d700b..b07a2b5 100644 --- a/test/puzzle/bloc/puzzle_event_test.dart +++ b/test/puzzle/bloc/puzzle_event_test.dart @@ -93,5 +93,21 @@ void main() { expect(HintInteractioCompleted().props, equals([])); }); }); + + group('UnfinishedPuzzleSaveRequested', () { + test('supports value equality', () { + expect( + UnfinishedPuzzleSaveRequested(12), + equals(UnfinishedPuzzleSaveRequested(12)), + ); + }); + + test('props are correct', () { + expect( + UnfinishedPuzzleSaveRequested(12).props, + equals([12]), + ); + }); + }); }); } diff --git a/test/puzzle/view/puzzle_page_test.dart b/test/puzzle/view/puzzle_page_test.dart index f3608e3..7887c98 100644 --- a/test/puzzle/view/puzzle_page_test.dart +++ b/test/puzzle/view/puzzle_page_test.dart @@ -3,6 +3,7 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockingjay/mockingjay.dart'; import 'package:mocktail/mocktail.dart'; import 'package:sudoku/models/models.dart'; import 'package:sudoku/puzzle/puzzle.dart'; @@ -31,7 +32,7 @@ void main() { when(() => puzzle.difficulty).thenReturn(Difficulty.medium); when(() => puzzle.remainingMistakes).thenReturn(3); - when(() => puzzleRepository.getPuzzle()).thenReturn(puzzle); + when(() => puzzleRepository.fetchPuzzleFromCache()).thenReturn(puzzle); when(() => timerState.secondsElapsed).thenReturn(167); when(() => timerState.isRunning).thenReturn(true); @@ -132,6 +133,90 @@ void main() { ); }); + group('PuzzleView', () { + late Sudoku sudoku; + late Puzzle puzzle; + late PuzzleState puzzleState; + late PuzzleBloc puzzleBloc; + + late TimerBloc timerBloc; + late TimerState timerState; + + setUp(() { + sudoku = MockSudoku(); + puzzle = MockPuzzle(); + puzzleState = MockPuzzleState(); + puzzleBloc = MockPuzzleBloc(); + + timerState = MockTimerState(); + timerBloc = MockTimerBloc(); + + when(() => sudoku.getDimesion()).thenReturn(3); + when(() => sudoku.blocks).thenReturn([]); + + when(() => puzzle.sudoku).thenReturn(sudoku); + when(() => puzzle.difficulty).thenReturn(Difficulty.medium); + when(() => puzzle.remainingMistakes).thenReturn(2); + + when(() => puzzleState.puzzle).thenReturn(puzzle); + when(() => puzzleState.puzzleStatus).thenReturn(PuzzleStatus.failed); + when(() => puzzleBloc.state).thenReturn(puzzleState); + + when(() => timerState.secondsElapsed).thenReturn(167); + when(() => timerState.isRunning).thenReturn(true); + when(() => timerBloc.state).thenReturn(timerState); + }); + + testWidgets('renders with a [PopScope] widget', (tester) async { + await tester.pumpApp( + PuzzleView(), + puzzleBloc: puzzleBloc, + timerBloc: timerBloc, + ); + final finder = find.byType(PopScope); + expect(finder, findsOneWidget); + }); + + testWidgets( + 'does not add [UnfinishedPuzzleSaveRequested] when puzzle ' + 'status is not incomplete', + (tester) async { + await tester.pumpApp( + PuzzleView(), + puzzleBloc: puzzleBloc, + timerBloc: timerBloc, + ); + + final finder = find.byType(PopScope); + Navigator.pop(tester.element(finder)); + + verifyNever(() => puzzleBloc.add(UnfinishedPuzzleSaveRequested(167))); + }, + ); + + testWidgets( + 'adds [UnfinishedPuzzleSaveRequested] when puzzle status is incomplete', + (tester) async { + when(() => puzzleState.puzzleStatus).thenReturn( + PuzzleStatus.incomplete, + ); + + await tester.pumpApp( + PuzzleView(), + puzzleBloc: puzzleBloc, + timerBloc: timerBloc, + ); + + final finder = find.byType(PopScope); + Navigator.pop(tester.element(finder)); + + verify( + () => puzzleBloc.add(UnfinishedPuzzleSaveRequested(167)), + ).called(1); + }, + ); + }); + group('PageHeader', () { late Puzzle puzzle; late PuzzleBloc puzzleBloc; diff --git a/test/timer/bloc/timer_bloc_test.dart b/test/timer/bloc/timer_bloc_test.dart index cae6a84..a41260d 100644 --- a/test/timer/bloc/timer_bloc_test.dart +++ b/test/timer/bloc/timer_bloc_test.dart @@ -27,7 +27,7 @@ void main() { group('TimerStarted', () { test('emits 3 sequential timer states', () async { - final bloc = TimerBloc(ticker: ticker)..add(TimerStarted()); + final bloc = TimerBloc(ticker: ticker)..add(TimerStarted(0)); await bloc.stream.first; streamController @@ -57,17 +57,29 @@ void main() { group('TimerStopped', () { test('does not emit after timer is stopped', () async { - final bloc = TimerBloc(ticker: ticker)..add(TimerStarted()); + final bloc = TimerBloc(ticker: ticker)..add(TimerStarted(0)); expect( await bloc.stream.first, - equals(TimerState(isRunning: true, secondsElapsed: 0)), + equals( + TimerState( + isRunning: true, + initialValue: 0, + secondsElapsed: 0, + ), + ), ); streamController.add(1); expect( await bloc.stream.first, - equals(TimerState(isRunning: true, secondsElapsed: 1)), + equals( + TimerState( + isRunning: true, + initialValue: 0, + secondsElapsed: 1, + ), + ), ); bloc.add(TimerStopped()); @@ -75,18 +87,30 @@ void main() { expect( await bloc.stream.first, - equals(TimerState(isRunning: false, secondsElapsed: 1)), + equals( + TimerState( + isRunning: false, + initialValue: 0, + secondsElapsed: 1, + ), + ), ); }); }); group('TimerResumed', () { test('resumes the timer from where it was stopped', () async { - final bloc = TimerBloc(ticker: ticker)..add(TimerStarted()); + final bloc = TimerBloc(ticker: ticker)..add(TimerStarted(0)); expect( await bloc.stream.first, - equals(TimerState(isRunning: true, secondsElapsed: 0)), + equals( + TimerState( + isRunning: true, + initialValue: 0, + secondsElapsed: 0, + ), + ), ); streamController @@ -96,8 +120,16 @@ void main() { await expectLater( bloc.stream, emitsInOrder([ - TimerState(isRunning: true, secondsElapsed: 1), - TimerState(isRunning: true, secondsElapsed: 2), + TimerState( + isRunning: true, + initialValue: 0, + secondsElapsed: 1, + ), + TimerState( + isRunning: true, + initialValue: 0, + secondsElapsed: 2, + ), ]), ); @@ -106,7 +138,13 @@ void main() { expect( await bloc.stream.first, - equals(TimerState(isRunning: false, secondsElapsed: 2)), + equals( + TimerState( + isRunning: false, + initialValue: 0, + secondsElapsed: 2, + ), + ), ); bloc.add(TimerResumed()); @@ -118,10 +156,26 @@ void main() { await expectLater( bloc.stream, emitsInOrder([ - TimerState(isRunning: true, secondsElapsed: 2), - TimerState(isRunning: true, secondsElapsed: 3), - TimerState(isRunning: true, secondsElapsed: 4), - TimerState(isRunning: true, secondsElapsed: 5), + TimerState( + isRunning: true, + initialValue: 0, + secondsElapsed: 2, + ), + TimerState( + isRunning: true, + initialValue: 0, + secondsElapsed: 3, + ), + TimerState( + isRunning: true, + initialValue: 0, + secondsElapsed: 4, + ), + TimerState( + isRunning: true, + initialValue: 0, + secondsElapsed: 5, + ), ]), ); }); diff --git a/test/timer/bloc/timer_event_test.dart b/test/timer/bloc/timer_event_test.dart index e0aa4ba..d0c999f 100644 --- a/test/timer/bloc/timer_event_test.dart +++ b/test/timer/bloc/timer_event_test.dart @@ -7,11 +7,11 @@ void main() { group('TimerEvent', () { group('TimerStarted', () { test('supports value equality', () { - expect(TimerStarted(), equals(TimerStarted())); + expect(TimerStarted(12), equals(TimerStarted(12))); }); test('props are correct', () { - expect(TimerStarted().props, equals([])); + expect(TimerStarted(12).props, equals([12])); }); }); diff --git a/test/timer/bloc/timer_state_test.dart b/test/timer/bloc/timer_state_test.dart index 9d9e471..b79deec 100644 --- a/test/timer/bloc/timer_state_test.dart +++ b/test/timer/bloc/timer_state_test.dart @@ -7,10 +7,12 @@ void main() { group('TimerState', () { TimerState createSubject({ bool isRunning = true, + int initialValue = 0, int secondsElapsed = 10, }) { return TimerState( isRunning: isRunning, + initialValue: initialValue, secondsElapsed: secondsElapsed, ); } @@ -26,7 +28,7 @@ void main() { test('props are correct', () { expect( createSubject().props, - equals([true, 10]), + equals([true, 0, 10]), ); }); @@ -42,6 +44,7 @@ void main() { expect( createSubject().copyWith( isRunning: null, + initialValue: null, secondsElapsed: null, ), equals(createSubject()), @@ -52,11 +55,13 @@ void main() { expect( createSubject().copyWith( isRunning: false, + initialValue: 2, secondsElapsed: 5, ), equals( createSubject( isRunning: false, + initialValue: 2, secondsElapsed: 5, ), ),