From e9b902355d975a72582ec3ceb6ebfa72deebbcb2 Mon Sep 17 00:00:00 2001 From: Erick Date: Thu, 5 May 2022 12:29:50 -0300 Subject: [PATCH] feat: user initials submission (#341) * feat: initial implementation * feat: tests * fix: lint * fix: removing wrong commited images * feat: pr suggestions * feat: PR suggestions * Apply suggestions from code review Co-authored-by: Alejandro Santiago * Apply suggestions from code review * Apply suggestions from code review Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> * fix * pr suggestions * fixing comments * pr suggestions * lint Co-authored-by: Alejandro Santiago Co-authored-by: Allison Ryan <77211884+allisonryan0002@users.noreply.github.com> --- lib/game/components/backbox/backbox.dart | 88 +++++++-- .../components/backbox/bloc/backbox_bloc.dart | 56 ++++++ .../backbox/bloc/backbox_event.dart | 53 ++++++ .../backbox/bloc/backbox_state.dart | 59 ++++++ .../components/backbox/displays/displays.dart | 3 + .../initials_submission_failure_display.dart | 28 +++ .../initials_submission_success_display.dart | 28 +++ .../backbox/displays/loading_display.dart | 48 +++++ .../components/game_bloc_status_listener.dart | 4 +- lib/game/pinball_game.dart | 8 +- lib/game/view/pinball_game_page.dart | 7 +- .../models/leader_board_entry.dart | 8 +- .../game/components/backbox/backbox_test.dart | 171 ++++++++++++++++-- .../backbox/bloc/backbox_bloc_test.dart | 92 ++++++++++ .../backbox/bloc/backbox_event_test.dart | 126 +++++++++++++ .../backbox/bloc/backbox_state_test.dart | 116 ++++++++++++ ...tials_submission_failure_display_test.dart | 22 +++ ...tials_submission_success_display_test.dart | 22 +++ .../displays/loading_display_test.dart | 54 ++++++ .../game_bloc_status_listener_test.dart | 16 +- test/helpers/test_games.dart | 10 + .../models/leader_board_entry_test.dart | 42 +++++ 22 files changed, 1017 insertions(+), 44 deletions(-) create mode 100644 lib/game/components/backbox/bloc/backbox_bloc.dart create mode 100644 lib/game/components/backbox/bloc/backbox_event.dart create mode 100644 lib/game/components/backbox/bloc/backbox_state.dart create mode 100644 lib/game/components/backbox/displays/initials_submission_failure_display.dart create mode 100644 lib/game/components/backbox/displays/initials_submission_success_display.dart create mode 100644 lib/game/components/backbox/displays/loading_display.dart create mode 100644 test/game/components/backbox/bloc/backbox_bloc_test.dart create mode 100644 test/game/components/backbox/bloc/backbox_event_test.dart create mode 100644 test/game/components/backbox/bloc/backbox_state_test.dart create mode 100644 test/game/components/backbox/displays/initials_submission_failure_display_test.dart create mode 100644 test/game/components/backbox/displays/initials_submission_success_display_test.dart create mode 100644 test/game/components/backbox/displays/loading_display_test.dart create mode 100644 test/leaderboard/models/leader_board_entry_test.dart diff --git a/lib/game/components/backbox/backbox.dart b/lib/game/components/backbox/backbox.dart index 0ef85fba..30b2a1aa 100644 --- a/lib/game/components/backbox/backbox.dart +++ b/lib/game/components/backbox/backbox.dart @@ -1,38 +1,90 @@ import 'dart:async'; import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart'; import 'package:pinball/game/components/backbox/displays/displays.dart'; +import 'package:pinball/game/pinball_game.dart'; import 'package:pinball_components/pinball_components.dart'; import 'package:pinball_flame/pinball_flame.dart'; +import 'package:pinball_theme/pinball_theme.dart' hide Assets; /// {@template backbox} /// The [Backbox] of the pinball machine. /// {@endtemplate} -class Backbox extends PositionComponent with HasGameRef, ZIndex { +class Backbox extends PositionComponent with HasGameRef, ZIndex { /// {@macro backbox} - Backbox() - : super( - position: Vector2(0, -87), - anchor: Anchor.bottomCenter, - children: [ - _BackboxSpriteComponent(), - ], - ) { + Backbox({ + required LeaderboardRepository leaderboardRepository, + }) : _bloc = BackboxBloc(leaderboardRepository: leaderboardRepository); + + /// {@macro backbox} + @visibleForTesting + Backbox.test({ + required BackboxBloc bloc, + }) : _bloc = bloc; + + late final Component _display; + final BackboxBloc _bloc; + late StreamSubscription _subscription; + + @override + Future onLoad() async { + position = Vector2(0, -87); + anchor = Anchor.bottomCenter; zIndex = ZIndexes.backbox; + + await add(_BackboxSpriteComponent()); + await add(_display = Component()); + + _subscription = _bloc.stream.listen((state) { + _display.children.removeWhere((_) => true); + _build(state); + }); + } + + @override + void onRemove() { + super.onRemove(); + _subscription.cancel(); + } + + void _build(BackboxState state) { + if (state is LoadingState) { + _display.add(LoadingDisplay()); + } else if (state is InitialsFormState) { + _display.add( + InitialsInputDisplay( + score: state.score, + characterIconPath: state.character.leaderboardIcon.keyName, + onSubmit: (initials) { + _bloc.add( + PlayerInitialsSubmitted( + score: state.score, + initials: initials, + character: state.character, + ), + ); + }, + ), + ); + } else if (state is InitialsSuccessState) { + _display.add(InitialsSubmissionSuccessDisplay()); + } else if (state is InitialsFailureState) { + _display.add(InitialsSubmissionFailureDisplay()); + } } /// Puts [InitialsInputDisplay] on the [Backbox]. - Future initialsInput({ + void requestInitials({ required int score, - required String characterIconPath, - InitialsOnSubmit? onSubmit, - }) async { - removeAll(children.where((child) => child is! _BackboxSpriteComponent)); - await add( - InitialsInputDisplay( + required CharacterTheme character, + }) { + _bloc.add( + PlayerInitialsRequested( score: score, - characterIconPath: characterIconPath, - onSubmit: onSubmit, + character: character, ), ); } diff --git a/lib/game/components/backbox/bloc/backbox_bloc.dart b/lib/game/components/backbox/bloc/backbox_bloc.dart new file mode 100644 index 00000000..f315189e --- /dev/null +++ b/lib/game/components/backbox/bloc/backbox_bloc.dart @@ -0,0 +1,56 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:pinball/leaderboard/models/leader_board_entry.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +part 'backbox_event.dart'; +part 'backbox_state.dart'; + +/// {@template backbox_bloc} +/// Bloc which manages the Backbox display. +/// {@endtemplate} +class BackboxBloc extends Bloc { + /// {@macro backbox_bloc} + BackboxBloc({ + required LeaderboardRepository leaderboardRepository, + }) : _leaderboardRepository = leaderboardRepository, + super(LoadingState()) { + on(_onPlayerInitialsRequested); + on(_onPlayerInitialsSubmitted); + } + + final LeaderboardRepository _leaderboardRepository; + + void _onPlayerInitialsRequested( + PlayerInitialsRequested event, + Emitter emit, + ) { + emit( + InitialsFormState( + score: event.score, + character: event.character, + ), + ); + } + + Future _onPlayerInitialsSubmitted( + PlayerInitialsSubmitted event, + Emitter emit, + ) async { + try { + emit(LoadingState()); + await _leaderboardRepository.addLeaderboardEntry( + LeaderboardEntryData( + playerInitials: event.initials, + score: event.score, + character: event.character.toType, + ), + ); + emit(InitialsSuccessState()); + } catch (error, stackTrace) { + addError(error, stackTrace); + emit(InitialsFailureState()); + } + } +} diff --git a/lib/game/components/backbox/bloc/backbox_event.dart b/lib/game/components/backbox/bloc/backbox_event.dart new file mode 100644 index 00000000..42203cdc --- /dev/null +++ b/lib/game/components/backbox/bloc/backbox_event.dart @@ -0,0 +1,53 @@ +part of 'backbox_bloc.dart'; + +/// {@template backbox_event} +/// Base class for backbox events. +/// {@endtemplate} +abstract class BackboxEvent extends Equatable { + /// {@macro backbox_event} + const BackboxEvent(); +} + +/// {@template player_initials_requested} +/// Event that triggers the user initials display. +/// {@endtemplate} +class PlayerInitialsRequested extends BackboxEvent { + /// {@macro player_initials_requested} + const PlayerInitialsRequested({ + required this.score, + required this.character, + }); + + /// Player's score. + final int score; + + /// Player's character. + final CharacterTheme character; + + @override + List get props => [score, character]; +} + +/// {@template player_initials_submitted} +/// Event that submits the user score and initials. +/// {@endtemplate} +class PlayerInitialsSubmitted extends BackboxEvent { + /// {@macro player_initials_submitted} + const PlayerInitialsSubmitted({ + required this.score, + required this.initials, + required this.character, + }); + + /// Player's score. + final int score; + + /// Player's initials. + final String initials; + + /// Player's character. + final CharacterTheme character; + + @override + List get props => [score, initials, character]; +} diff --git a/lib/game/components/backbox/bloc/backbox_state.dart b/lib/game/components/backbox/bloc/backbox_state.dart new file mode 100644 index 00000000..e1f2c801 --- /dev/null +++ b/lib/game/components/backbox/bloc/backbox_state.dart @@ -0,0 +1,59 @@ +part of 'backbox_bloc.dart'; + +/// {@template backbox_state} +/// The base state for all [BackboxState]. +/// {@endtemplate backbox_state} +abstract class BackboxState extends Equatable { + /// {@macro backbox_state} + const BackboxState(); +} + +/// Loading state for the backbox. +class LoadingState extends BackboxState { + @override + List get props => []; +} + +/// State when the leaderboard was successfully loaded. +class LeaderboardSuccessState extends BackboxState { + @override + List get props => []; +} + +/// State when the leaderboard failed to load. +class LeaderboardFailureState extends BackboxState { + @override + List get props => []; +} + +/// {@template initials_form_state} +/// State when the user is inputting their initials. +/// {@endtemplate} +class InitialsFormState extends BackboxState { + /// {@macro initials_form_state} + const InitialsFormState({ + required this.score, + required this.character, + }) : super(); + + /// Player's score. + final int score; + + /// Player's character. + final CharacterTheme character; + + @override + List get props => [score, character]; +} + +/// State when the leaderboard was successfully loaded. +class InitialsSuccessState extends BackboxState { + @override + List get props => []; +} + +/// State when the initials submission failed. +class InitialsFailureState extends BackboxState { + @override + List get props => []; +} diff --git a/lib/game/components/backbox/displays/displays.dart b/lib/game/components/backbox/displays/displays.dart index 194212ab..a516587d 100644 --- a/lib/game/components/backbox/displays/displays.dart +++ b/lib/game/components/backbox/displays/displays.dart @@ -1 +1,4 @@ export 'initials_input_display.dart'; +export 'initials_submission_failure_display.dart'; +export 'initials_submission_success_display.dart'; +export 'loading_display.dart'; diff --git a/lib/game/components/backbox/displays/initials_submission_failure_display.dart b/lib/game/components/backbox/displays/initials_submission_failure_display.dart new file mode 100644 index 00000000..178354c2 --- /dev/null +++ b/lib/game/components/backbox/displays/initials_submission_failure_display.dart @@ -0,0 +1,28 @@ +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +final _bodyTextPaint = TextPaint( + style: const TextStyle( + fontSize: 3, + color: PinballColors.white, + fontFamily: PinballFonts.pixeloidSans, + ), +); + +/// {@template initials_submission_failure_display} +/// [Backbox] display for when a failure occurs during initials submission. +/// {@endtemplate} +class InitialsSubmissionFailureDisplay extends TextComponent + with HasGameRef { + @override + Future onLoad() async { + await super.onLoad(); + position = Vector2(0, -10); + anchor = Anchor.center; + text = 'Failure!'; + textRenderer = _bodyTextPaint; + } +} diff --git a/lib/game/components/backbox/displays/initials_submission_success_display.dart b/lib/game/components/backbox/displays/initials_submission_success_display.dart new file mode 100644 index 00000000..46c35b0e --- /dev/null +++ b/lib/game/components/backbox/displays/initials_submission_success_display.dart @@ -0,0 +1,28 @@ +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +final _bodyTextPaint = TextPaint( + style: const TextStyle( + fontSize: 3, + color: PinballColors.white, + fontFamily: PinballFonts.pixeloidSans, + ), +); + +/// {@template initials_submission_success_display} +/// [Backbox] display for initials successfully submitted. +/// {@endtemplate} +class InitialsSubmissionSuccessDisplay extends TextComponent + with HasGameRef { + @override + Future onLoad() async { + await super.onLoad(); + position = Vector2(0, -10); + anchor = Anchor.center; + text = 'Success!'; + textRenderer = _bodyTextPaint; + } +} diff --git a/lib/game/components/backbox/displays/loading_display.dart b/lib/game/components/backbox/displays/loading_display.dart new file mode 100644 index 00000000..7b1d4280 --- /dev/null +++ b/lib/game/components/backbox/displays/loading_display.dart @@ -0,0 +1,48 @@ +import 'package:flame/components.dart'; +import 'package:flutter/material.dart'; +import 'package:pinball/game/game.dart'; +import 'package:pinball_components/pinball_components.dart'; +import 'package:pinball_ui/pinball_ui.dart'; + +final _bodyTextPaint = TextPaint( + style: const TextStyle( + fontSize: 3, + color: PinballColors.white, + fontFamily: PinballFonts.pixeloidSans, + ), +); + +/// {@template loading_display} +/// Display used to show the loading animation. +/// {@endtemplate} +class LoadingDisplay extends TextComponent with HasGameRef { + /// {@template loading_display} + LoadingDisplay(); + + late final String _label; + + @override + Future onLoad() async { + await super.onLoad(); + + position = Vector2(0, -10); + anchor = Anchor.center; + text = _label = gameRef.l10n.loading; + textRenderer = _bodyTextPaint; + + await add( + TimerComponent( + period: 1, + repeat: true, + onTick: () { + final index = text.indexOf('.'); + if (index != -1 && text.substring(index).length == 3) { + text = _label; + } else { + text = '$text.'; + } + }, + ), + ); + } +} diff --git a/lib/game/components/game_bloc_status_listener.dart b/lib/game/components/game_bloc_status_listener.dart index 0012f62b..1984a523 100644 --- a/lib/game/components/game_bloc_status_listener.dart +++ b/lib/game/components/game_bloc_status_listener.dart @@ -22,9 +22,9 @@ class GameBlocStatusListener extends Component gameRef.overlays.remove(PinballGame.playButtonOverlay); break; case GameStatus.gameOver: - gameRef.descendants().whereType().first.initialsInput( + gameRef.descendants().whereType().first.requestInitials( score: state.displayScore, - characterIconPath: gameRef.characterTheme.leaderboardIcon.keyName, + character: gameRef.characterTheme, ); gameRef.firstChild()!.focusOnGameOverBackbox(); break; diff --git a/lib/game/pinball_game.dart b/lib/game/pinball_game.dart index 65a19f29..b022ea6a 100644 --- a/lib/game/pinball_game.dart +++ b/lib/game/pinball_game.dart @@ -7,6 +7,7 @@ import 'package:flame/input.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/game/behaviors/behaviors.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; @@ -23,6 +24,7 @@ class PinballGame extends PinballForge2DGame MultiTouchTapDetector { PinballGame({ required this.characterTheme, + required this.leaderboardRepository, required this.l10n, required this.player, }) : super(gravity: Vector2(0, 30)) { @@ -40,6 +42,8 @@ class PinballGame extends PinballForge2DGame final PinballPlayer player; + final LeaderboardRepository leaderboardRepository; + final AppLocalizations l10n; @override @@ -49,7 +53,7 @@ class PinballGame extends PinballForge2DGame final machine = [ BoardBackgroundSpriteComponent(), Boundaries(), - Backbox(), + Backbox(leaderboardRepository: leaderboardRepository), ]; final decals = [ GoogleWord(position: Vector2(-4.25, 1.8)), @@ -184,11 +188,13 @@ class _GameBallsController extends ComponentController class DebugPinballGame extends PinballGame with FPSCounter, PanDetector { DebugPinballGame({ required CharacterTheme characterTheme, + required LeaderboardRepository leaderboardRepository, required AppLocalizations l10n, required PinballPlayer player, }) : super( characterTheme: characterTheme, player: player, + leaderboardRepository: leaderboardRepository, l10n: l10n, ) { controller = _GameBallsController(this); diff --git a/lib/game/view/pinball_game_page.dart b/lib/game/view/pinball_game_page.dart index b56e00f4..31ba304b 100644 --- a/lib/game/view/pinball_game_page.dart +++ b/lib/game/view/pinball_game_page.dart @@ -4,6 +4,7 @@ import 'package:flame/game.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/assets_manager/assets_manager.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; @@ -37,23 +38,25 @@ class PinballGamePage extends StatelessWidget { final characterTheme = context.read().state.characterTheme; final player = context.read(); - final pinballAudio = context.read(); + final leaderboardRepository = context.read(); final game = isDebugMode ? DebugPinballGame( characterTheme: characterTheme, player: player, + leaderboardRepository: leaderboardRepository, l10n: context.l10n, ) : PinballGame( characterTheme: characterTheme, player: player, + leaderboardRepository: leaderboardRepository, l10n: context.l10n, ); final loadables = [ ...game.preLoadAssets(), - ...pinballAudio.load(), + ...player.load(), ...BonusAnimation.loadAssets(), ...SelectedCharacter.loadAssets(), ]; diff --git a/lib/leaderboard/models/leader_board_entry.dart b/lib/leaderboard/models/leader_board_entry.dart index a86975dd..db4980a1 100644 --- a/lib/leaderboard/models/leader_board_entry.dart +++ b/lib/leaderboard/models/leader_board_entry.dart @@ -1,3 +1,4 @@ +import 'package:equatable/equatable.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball_theme/pinball_theme.dart'; @@ -6,9 +7,9 @@ import 'package:pinball_theme/pinball_theme.dart'; /// player's initials, score, and chosen character. /// /// {@endtemplate} -class LeaderboardEntry { +class LeaderboardEntry extends Equatable { /// {@macro leaderboard_entry} - LeaderboardEntry({ + const LeaderboardEntry({ required this.rank, required this.playerInitials, required this.score, @@ -26,6 +27,9 @@ class LeaderboardEntry { /// [CharacterTheme] for [LeaderboardEntry]. final AssetGenImage character; + + @override + List get props => [rank, playerInitials, score, character]; } /// Converts [LeaderboardEntryData] from repository to [LeaderboardEntry]. diff --git a/test/game/components/backbox/backbox_test.dart b/test/game/components/backbox/backbox_test.dart index 341198f8..33d43aa8 100644 --- a/test/game/components/backbox/backbox_test.dart +++ b/test/game/components/backbox/backbox_test.dart @@ -1,10 +1,17 @@ -// ignore_for_file: cascade_invocations +// ignore_for_file: cascade_invocations, prefer_const_constructors +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; import 'package:flame/components.dart'; import 'package:flame_test/flame_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:pinball/game/components/backbox/displays/initials_input_display.dart'; +import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart'; +import 'package:pinball/game/components/backbox/displays/displays.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; import 'package:pinball_components/pinball_components.dart'; @@ -12,6 +19,24 @@ import 'package:pinball_theme/pinball_theme.dart' as theme; import '../../../helpers/helpers.dart'; +class _MockRawKeyUpEvent extends Mock implements RawKeyUpEvent { + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return super.toString(); + } +} + +RawKeyUpEvent _mockKeyUp(LogicalKeyboardKey key) { + final event = _MockRawKeyUpEvent(); + when(() => event.logicalKey).thenReturn(key); + return event; +} + +class _MockBackboxBloc extends Mock implements BackboxBloc {} + +class _MockLeaderboardRepository extends Mock implements LeaderboardRepository { +} + class _MockAppLocalizations extends Mock implements AppLocalizations { @override String get score => ''; @@ -33,13 +58,16 @@ class _MockAppLocalizations extends Mock implements AppLocalizations { @override String get toSubmit => ''; + + @override + String get loading => ''; } void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final characterIconPath = theme.Assets.images.dash.leaderboardIcon.keyName; + const character = theme.AndroidTheme(); final assets = [ - characterIconPath, + character.leaderboardIcon.keyName, Assets.images.backbox.marquee.keyName, Assets.images.backbox.displayDivider.keyName, ]; @@ -50,11 +78,22 @@ void main() { ), ); + late BackboxBloc bloc; + + setUp(() { + bloc = _MockBackboxBloc(); + whenListen( + bloc, + Stream.value(LoadingState()), + initialState: LoadingState(), + ); + }); + group('Backbox', () { flameTester.test( 'loads correctly', (game) async { - final backbox = Backbox(); + final backbox = Backbox.test(bloc: bloc); await game.ensureAdd(backbox); expect(game.children, contains(backbox)); @@ -68,7 +107,9 @@ void main() { game.camera ..followVector2(Vector2(0, -130)) ..zoom = 6; - await game.ensureAdd(Backbox()); + await game.ensureAdd( + Backbox.test(bloc: bloc), + ); await tester.pump(); }, verify: (game, tester) async { @@ -80,18 +121,124 @@ void main() { ); flameTester.test( - 'initialsInput adds InitialsInputDisplay', + 'requestInitials adds InitialsInputDisplay', (game) async { - final backbox = Backbox(); + final backbox = Backbox.test( + bloc: BackboxBloc( + leaderboardRepository: _MockLeaderboardRepository(), + ), + ); await game.ensureAdd(backbox); - await backbox.initialsInput( + backbox.requestInitials( score: 0, - characterIconPath: characterIconPath, - onSubmit: (_) {}, + character: character, ); await game.ready(); - expect(backbox.firstChild(), isNotNull); + expect( + backbox.descendants().whereType().length, + equals(1), + ); + }, + ); + + flameTester.test( + 'adds PlayerInitialsSubmitted when initials are submitted', + (game) async { + final bloc = _MockBackboxBloc(); + final state = InitialsFormState( + score: 10, + character: theme.AndroidTheme(), + ); + whenListen( + bloc, + Stream.value(state), + initialState: state, + ); + final backbox = Backbox.test(bloc: bloc); + await game.ensureAdd(backbox); + + game.onKeyEvent(_mockKeyUp(LogicalKeyboardKey.enter), {}); + verify( + () => bloc.add( + PlayerInitialsSubmitted( + score: 10, + initials: 'AAA', + character: theme.AndroidTheme(), + ), + ), + ).called(1); + }, + ); + + flameTester.test( + 'adds InitialsSubmissionSuccessDisplay on InitialsSuccessState', + (game) async { + whenListen( + bloc, + Stream.value(InitialsSuccessState()), + initialState: InitialsSuccessState(), + ); + final backbox = Backbox.test(bloc: bloc); + await game.ensureAdd(backbox); + + expect( + game + .descendants() + .whereType() + .length, + equals(1), + ); + }, + ); + + flameTester.test( + 'adds InitialsSubmissionFailureDisplay on InitialsFailureState', + (game) async { + whenListen( + bloc, + Stream.value(InitialsFailureState()), + initialState: InitialsFailureState(), + ); + final backbox = Backbox.test(bloc: bloc); + await game.ensureAdd(backbox); + + expect( + game + .descendants() + .whereType() + .length, + equals(1), + ); + }, + ); + + flameTester.test( + 'closes the subscription when it is removed', + (game) async { + final streamController = StreamController(); + whenListen( + bloc, + streamController.stream, + initialState: LoadingState(), + ); + + final backbox = Backbox.test(bloc: bloc); + await game.ensureAdd(backbox); + + backbox.removeFromParent(); + await game.ready(); + + streamController.add(InitialsFailureState()); + await game.ready(); + + expect( + backbox + .descendants() + .whereType() + .isEmpty, + isTrue, + ); }, ); }); diff --git a/test/game/components/backbox/bloc/backbox_bloc_test.dart b/test/game/components/backbox/bloc/backbox_bloc_test.dart new file mode 100644 index 00000000..c2fbc088 --- /dev/null +++ b/test/game/components/backbox/bloc/backbox_bloc_test.dart @@ -0,0 +1,92 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +class _MockLeaderboardRepository extends Mock implements LeaderboardRepository { +} + +void main() { + late LeaderboardRepository leaderboardRepository; + + group('BackboxBloc', () { + blocTest( + 'adds InitialsFormState on PlayerInitialsRequested', + setUp: () { + leaderboardRepository = _MockLeaderboardRepository(); + }, + build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), + act: (bloc) => bloc.add( + PlayerInitialsRequested( + score: 100, + character: AndroidTheme(), + ), + ), + expect: () => [ + InitialsFormState(score: 100, character: AndroidTheme()), + ], + ); + + group('PlayerInitialsSubmitted', () { + blocTest( + 'adds [LoadingState, InitialsSuccessState] when submission succeeds', + setUp: () { + leaderboardRepository = _MockLeaderboardRepository(); + when( + () => leaderboardRepository.addLeaderboardEntry( + LeaderboardEntryData( + playerInitials: 'AAA', + score: 10, + character: CharacterType.dash, + ), + ), + ).thenAnswer((_) async {}); + }, + build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), + act: (bloc) => bloc.add( + PlayerInitialsSubmitted( + score: 10, + initials: 'AAA', + character: DashTheme(), + ), + ), + expect: () => [ + LoadingState(), + InitialsSuccessState(), + ], + ); + + blocTest( + 'adds [LoadingState, InitialsFailureState] when submission fails', + setUp: () { + leaderboardRepository = _MockLeaderboardRepository(); + when( + () => leaderboardRepository.addLeaderboardEntry( + LeaderboardEntryData( + playerInitials: 'AAA', + score: 10, + character: CharacterType.dash, + ), + ), + ).thenThrow(Exception('Error')); + }, + build: () => BackboxBloc(leaderboardRepository: leaderboardRepository), + act: (bloc) => bloc.add( + PlayerInitialsSubmitted( + score: 10, + initials: 'AAA', + character: DashTheme(), + ), + ), + expect: () => [ + LoadingState(), + InitialsFailureState(), + ], + ); + }); + }); +} diff --git a/test/game/components/backbox/bloc/backbox_event_test.dart b/test/game/components/backbox/bloc/backbox_event_test.dart new file mode 100644 index 00000000..5fc766a9 --- /dev/null +++ b/test/game/components/backbox/bloc/backbox_event_test.dart @@ -0,0 +1,126 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +void main() { + group('BackboxEvent', () { + group('PlayerInitialsRequested', () { + test('can be instantiated', () { + expect( + PlayerInitialsRequested(score: 0, character: AndroidTheme()), + isNotNull, + ); + }); + + test('supports value comparison', () { + expect( + PlayerInitialsRequested(score: 0, character: AndroidTheme()), + equals( + PlayerInitialsRequested(score: 0, character: AndroidTheme()), + ), + ); + + expect( + PlayerInitialsRequested(score: 0, character: AndroidTheme()), + isNot( + equals( + PlayerInitialsRequested(score: 1, character: AndroidTheme()), + ), + ), + ); + + expect( + PlayerInitialsRequested(score: 0, character: AndroidTheme()), + isNot( + equals( + PlayerInitialsRequested(score: 0, character: SparkyTheme()), + ), + ), + ); + }); + }); + + group('PlayerInitialsSubmitted', () { + test('can be instantiated', () { + expect( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + isNotNull, + ); + }); + + test('supports value comparison', () { + expect( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + equals( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + ), + ); + + expect( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + isNot( + equals( + PlayerInitialsSubmitted( + score: 1, + initials: 'AAA', + character: AndroidTheme(), + ), + ), + ), + ); + + expect( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + isNot( + equals( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: SparkyTheme(), + ), + ), + ), + ); + + expect( + PlayerInitialsSubmitted( + score: 0, + initials: 'AAA', + character: AndroidTheme(), + ), + isNot( + equals( + PlayerInitialsSubmitted( + score: 0, + initials: 'BBB', + character: AndroidTheme(), + ), + ), + ), + ); + }); + }); + }); +} diff --git a/test/game/components/backbox/bloc/backbox_state_test.dart b/test/game/components/backbox/bloc/backbox_state_test.dart new file mode 100644 index 00000000..4708c9bb --- /dev/null +++ b/test/game/components/backbox/bloc/backbox_state_test.dart @@ -0,0 +1,116 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/components/backbox/bloc/backbox_bloc.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +void main() { + group('BackboxState', () { + group('LoadingState', () { + test('can be instantiated', () { + expect(LoadingState(), isNotNull); + }); + + test('supports value comparison', () { + expect(LoadingState(), equals(LoadingState())); + }); + }); + + group('LeaderboardSuccessState', () { + test('can be instantiated', () { + expect(LeaderboardSuccessState(), isNotNull); + }); + + test('supports value comparison', () { + expect(LeaderboardSuccessState(), equals(LeaderboardSuccessState())); + }); + }); + + group('LeaderboardFailureState', () { + test('can be instantiated', () { + expect(LeaderboardFailureState(), isNotNull); + }); + + test('supports value comparison', () { + expect(LeaderboardFailureState(), equals(LeaderboardFailureState())); + }); + }); + + group('InitialsFormState', () { + test('can be instantiated', () { + expect( + InitialsFormState( + score: 0, + character: AndroidTheme(), + ), + isNotNull, + ); + }); + + test('supports value comparison', () { + expect( + InitialsFormState( + score: 0, + character: AndroidTheme(), + ), + equals( + InitialsFormState( + score: 0, + character: AndroidTheme(), + ), + ), + ); + + expect( + InitialsFormState( + score: 0, + character: AndroidTheme(), + ), + isNot( + equals( + InitialsFormState( + score: 1, + character: AndroidTheme(), + ), + ), + ), + ); + + expect( + InitialsFormState( + score: 0, + character: AndroidTheme(), + ), + isNot( + equals( + InitialsFormState( + score: 0, + character: SparkyTheme(), + ), + ), + ), + ); + }); + }); + + group('InitialsSuccessState', () { + test('can be instantiated', () { + expect(InitialsSuccessState(), isNotNull); + }); + + test('supports value comparison', () { + expect(InitialsSuccessState(), equals(InitialsSuccessState())); + }); + + group('InitialsFailureState', () { + test('can be instantiated', () { + expect(InitialsFailureState(), isNotNull); + }); + + test('supports value comparison', () { + expect(InitialsFailureState(), equals(InitialsFailureState())); + }); + }); + }); + }); +} diff --git a/test/game/components/backbox/displays/initials_submission_failure_display_test.dart b/test/game/components/backbox/displays/initials_submission_failure_display_test.dart new file mode 100644 index 00000000..5989445f --- /dev/null +++ b/test/game/components/backbox/displays/initials_submission_failure_display_test.dart @@ -0,0 +1,22 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/components/backbox/displays/initials_submission_failure_display.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + group('InitialsSubmissionFailureDisplay', () { + final flameTester = FlameTester(EmptyKeyboardPinballTestGame.new); + + flameTester.test('renders correctly', (game) async { + await game.ensureAdd(InitialsSubmissionFailureDisplay()); + + final component = game.firstChild(); + expect(component, isNotNull); + expect(component?.text, equals('Failure!')); + }); + }); +} diff --git a/test/game/components/backbox/displays/initials_submission_success_display_test.dart b/test/game/components/backbox/displays/initials_submission_success_display_test.dart new file mode 100644 index 00000000..1bd1fcd9 --- /dev/null +++ b/test/game/components/backbox/displays/initials_submission_success_display_test.dart @@ -0,0 +1,22 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pinball/game/components/backbox/displays/initials_submission_success_display.dart'; + +import '../../../../helpers/helpers.dart'; + +void main() { + group('InitialsSubmissionSuccessDisplay', () { + final flameTester = FlameTester(EmptyKeyboardPinballTestGame.new); + + flameTester.test('renders correctly', (game) async { + await game.ensureAdd(InitialsSubmissionSuccessDisplay()); + + final component = game.firstChild(); + expect(component, isNotNull); + expect(component?.text, equals('Success!')); + }); + }); +} diff --git a/test/game/components/backbox/displays/loading_display_test.dart b/test/game/components/backbox/displays/loading_display_test.dart new file mode 100644 index 00000000..a09d0d68 --- /dev/null +++ b/test/game/components/backbox/displays/loading_display_test.dart @@ -0,0 +1,54 @@ +// ignore_for_file: cascade_invocations + +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:pinball/game/components/backbox/displays/loading_display.dart'; +import 'package:pinball/l10n/l10n.dart'; + +import '../../../../helpers/helpers.dart'; + +class _MockAppLocalizations extends Mock implements AppLocalizations { + @override + String get loading => 'Loading'; +} + +void main() { + group('LoadingDisplay', () { + final flameTester = FlameTester( + () => EmptyPinballTestGame( + l10n: _MockAppLocalizations(), + ), + ); + + flameTester.test('renders correctly', (game) async { + await game.ensureAdd(LoadingDisplay()); + + final component = game.firstChild(); + expect(component, isNotNull); + expect(component?.text, equals('Loading')); + }); + + flameTester.test('use ellipses as animation', (game) async { + await game.ensureAdd(LoadingDisplay()); + + final component = game.firstChild(); + expect(component?.text, equals('Loading')); + + final timer = component?.firstChild(); + + timer?.update(1.1); + expect(component?.text, equals('Loading.')); + + timer?.update(1.1); + expect(component?.text, equals('Loading..')); + + timer?.update(1.1); + expect(component?.text, equals('Loading...')); + + timer?.update(1.1); + expect(component?.text, equals('Loading')); + }); + }); +} diff --git a/test/game/components/game_bloc_status_listener_test.dart b/test/game/components/game_bloc_status_listener_test.dart index 39c81115..4bb313e6 100644 --- a/test/game/components/game_bloc_status_listener_test.dart +++ b/test/game/components/game_bloc_status_listener_test.dart @@ -20,6 +20,10 @@ class _MockPinballPlayer extends Mock implements PinballPlayer {} void main() { group('GameBlocStatusListener', () { + setUpAll(() { + registerFallbackValue(AndroidTheme()); + }); + group('listenWhen', () { test('is true when the game over state has changed', () { final state = GameState( @@ -58,10 +62,9 @@ void main() { gameFlowController.mockGameRef(game); when( - () => backbox.initialsInput( + () => backbox.requestInitials( score: any(named: 'score'), - characterIconPath: any(named: 'characterIconPath'), - onSubmit: any(named: 'onSubmit'), + character: any(named: 'character'), ), ).thenAnswer((_) async {}); when(cameraController.focusOnWaitingBackbox).thenAnswer((_) async {}); @@ -92,10 +95,9 @@ void main() { gameFlowController.onNewState(state); verify( - () => backbox.initialsInput( - score: state.displayScore, - characterIconPath: any(named: 'characterIconPath'), - onSubmit: any(named: 'onSubmit'), + () => backbox.requestInitials( + score: any(named: 'score'), + character: any(named: 'character'), ), ).called(1); verify(cameraController.focusOnGameOverBackbox).called(1); diff --git a/test/helpers/test_games.dart b/test/helpers/test_games.dart index 6bb39e2d..220693c3 100644 --- a/test/helpers/test_games.dart +++ b/test/helpers/test_games.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:flame/input.dart'; import 'package:flame_bloc/flame_bloc.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:mocktail/mocktail.dart'; import 'package:pinball/game/game.dart'; import 'package:pinball/l10n/l10n.dart'; @@ -15,6 +16,9 @@ class _MockPinballPlayer extends Mock implements PinballPlayer {} class _MockAppLocalizations extends Mock implements AppLocalizations {} +class _MockLeaderboardRepository extends Mock implements LeaderboardRepository { +} + class TestGame extends Forge2DGame with FlameBloc { TestGame() { images.prefix = ''; @@ -25,11 +29,14 @@ class PinballTestGame extends PinballGame { PinballTestGame({ List? assets, PinballPlayer? player, + LeaderboardRepository? leaderboardRepository, CharacterTheme? theme, AppLocalizations? l10n, }) : _assets = assets, super( player: player ?? _MockPinballPlayer(), + leaderboardRepository: + leaderboardRepository ?? _MockLeaderboardRepository(), characterTheme: theme ?? const DashTheme(), l10n: l10n ?? _MockAppLocalizations(), ); @@ -48,11 +55,14 @@ class DebugPinballTestGame extends DebugPinballGame { DebugPinballTestGame({ List? assets, PinballPlayer? player, + LeaderboardRepository? leaderboardRepository, CharacterTheme? theme, AppLocalizations? l10n, }) : _assets = assets, super( player: player ?? _MockPinballPlayer(), + leaderboardRepository: + leaderboardRepository ?? _MockLeaderboardRepository(), characterTheme: theme ?? const DashTheme(), l10n: l10n ?? _MockAppLocalizations(), ); diff --git a/test/leaderboard/models/leader_board_entry_test.dart b/test/leaderboard/models/leader_board_entry_test.dart new file mode 100644 index 00000000..aa0e10a7 --- /dev/null +++ b/test/leaderboard/models/leader_board_entry_test.dart @@ -0,0 +1,42 @@ +// ignore_for_file: prefer_const_constructors + +import 'package:flutter_test/flutter_test.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:pinball/leaderboard/models/leader_board_entry.dart'; +import 'package:pinball_theme/pinball_theme.dart'; + +void main() { + group('LeaderboardEntry', () { + group('toEntry', () { + test('returns the correct from a to entry data', () { + expect( + LeaderboardEntryData.empty.toEntry(1), + LeaderboardEntry( + rank: '1', + playerInitials: '', + score: 0, + character: CharacterType.dash.toTheme.leaderboardIcon, + ), + ); + }); + }); + + group('CharacterType', () { + test('toTheme returns the correct theme', () { + expect(CharacterType.dash.toTheme, equals(DashTheme())); + expect(CharacterType.sparky.toTheme, equals(SparkyTheme())); + expect(CharacterType.android.toTheme, equals(AndroidTheme())); + expect(CharacterType.dino.toTheme, equals(DinoTheme())); + }); + }); + + group('CharacterTheme', () { + test('toType returns the correct type', () { + expect(DashTheme().toType, equals(CharacterType.dash)); + expect(SparkyTheme().toType, equals(CharacterType.sparky)); + expect(AndroidTheme().toType, equals(CharacterType.android)); + expect(DinoTheme().toType, equals(CharacterType.dino)); + }); + }); + }); +}