Merge branch 'main' into refactor/google-letters

pull/93/head
Allison Ryan 4 years ago committed by GitHub
commit e82bcb1c26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -29,6 +29,12 @@
"type": "dart",
"program": "lib/main_production.dart",
"args": ["--flavor", "production", "--target", "lib/main_production.dart"]
},
{
"name": "Launch component sandbox",
"request": "launch",
"type": "dart",
"program": "packages/pinball_components/sandbox/lib/main.dart"
}
]
}

@ -14,6 +14,8 @@ const _attachedErrorMessage = "Can't add to attached Blueprints";
/// the [FlameGame] level.
abstract class Blueprint<T extends FlameGame> {
final List<Component> _components = [];
final List<Blueprint> _blueprints = [];
bool _isAttached = false;
/// Called before the the [Component]s managed
@ -25,7 +27,10 @@ abstract class Blueprint<T extends FlameGame> {
@mustCallSuper
Future<void> attach(T game) async {
build(game);
await game.addAll(_components);
await Future.wait([
game.addAll(_components),
..._blueprints.map(game.addFromBlueprint).toList(),
]);
_isAttached = true;
}
@ -41,8 +46,23 @@ abstract class Blueprint<T extends FlameGame> {
_components.add(component);
}
/// Adds a list of [Blueprint]s to this blueprint.
void addAllBlueprints(List<Blueprint> blueprints) {
assert(!_isAttached, _attachedErrorMessage);
_blueprints.addAll(blueprints);
}
/// Adds a single [Blueprint] to this blueprint.
void addBlueprint(Blueprint blueprint) {
assert(!_isAttached, _attachedErrorMessage);
_blueprints.add(blueprint);
}
/// Returns a copy of the components built by this blueprint
List<Component> get components => List.unmodifiable(_components);
/// Returns a copy of the children blueprints
List<Blueprint> get blueprints => List.unmodifiable(_blueprints);
}
/// A [Blueprint] that provides additional

@ -1,11 +1,9 @@
import 'package:flame/components.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template board}
/// The main flat surface of the [PinballGame], where the [Flipper]s,
/// [RoundBumper]s, [Kicker]s are arranged.
/// {entemplate}
/// The main flat surface of the [PinballGame].
/// {endtemplate}
class Board extends Component {
/// {@macro board}
Board();
@ -21,7 +19,7 @@ class Board extends Component {
spacing: 2,
);
final dashForest = _FlutterForest(
final flutterForest = FlutterForest(
position: Vector2(
PinballGame.boardBounds.center.dx + 20,
PinballGame.boardBounds.center.dy + 48,
@ -30,44 +28,7 @@ class Board extends Component {
await addAll([
bottomGroup,
dashForest,
]);
}
}
/// {@template flutter_forest}
/// Area positioned at the top right of the [Board] where the [Ball]
/// can bounce off [RoundBumper]s.
/// {@endtemplate}
class _FlutterForest extends Component {
/// {@macro flutter_forest}
_FlutterForest({
required this.position,
});
final Vector2 position;
@override
Future<void> onLoad() async {
// TODO(alestiago): adjust positioning once sprites are added.
// TODO(alestiago): Use [NestBumper] instead of [RoundBumper] once provided.
final smallLeftNest = RoundBumper(
radius: 1,
points: 10,
)..initialPosition = position + Vector2(-4.8, 2.8);
final smallRightNest = RoundBumper(
radius: 1,
points: 10,
)..initialPosition = position + Vector2(0.5, -5.5);
final bigNest = RoundBumper(
radius: 2,
points: 20,
)..initialPosition = position;
await addAll([
smallLeftNest,
smallRightNest,
bigNest,
flutterForest,
]);
}
}

@ -21,13 +21,9 @@ class BonusWord extends Component with BlocComponent<GameBloc, GameState> {
@override
bool listenWhen(GameState? previousState, GameState newState) {
if ((previousState?.bonusHistory.length ?? 0) <
return (previousState?.bonusHistory.length ?? 0) <
newState.bonusHistory.length &&
newState.bonusHistory.last == GameBonus.word) {
return true;
}
return false;
newState.bonusHistory.last == GameBonus.word;
}
@override

@ -4,6 +4,7 @@ export 'board.dart';
export 'board_side.dart';
export 'bonus_word.dart';
export 'flipper.dart';
export 'flutter_forest.dart';
export 'jetpack_ramp.dart';
export 'joint_anchor.dart';
export 'kicker.dart';
@ -11,7 +12,6 @@ export 'launcher_ramp.dart';
export 'pathway.dart';
export 'plunger.dart';
export 'ramp_opening.dart';
export 'round_bumper.dart';
export 'score_points.dart';
export 'spaceship.dart';
export 'wall.dart';

@ -0,0 +1,131 @@
// ignore_for_file: avoid_renaming_method_parameters
import 'package:flame/components.dart';
import 'package:flame_bloc/flame_bloc.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/material.dart';
import 'package:pinball/flame/blueprint.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template flutter_forest}
/// Area positioned at the top right of the [Board] where the [Ball]
/// can bounce off [DashNestBumper]s.
///
/// When all [DashNestBumper]s are hit at least once, the [GameBonus.dashNest]
/// is awarded, and the [BigDashNestBumper] releases a new [Ball].
/// {@endtemplate}
// TODO(alestiago): Make a [Blueprint] once nesting [Blueprint] is implemented.
class FlutterForest extends Component
with HasGameRef<PinballGame>, BlocComponent<GameBloc, GameState> {
/// {@macro flutter_forest}
FlutterForest({
required this.position,
});
/// The position of the [FlutterForest] on the [Board].
final Vector2 position;
@override
bool listenWhen(GameState? previousState, GameState newState) {
return (previousState?.bonusHistory.length ?? 0) <
newState.bonusHistory.length &&
newState.bonusHistory.last == GameBonus.dashNest;
}
@override
void onNewState(GameState state) {
super.onNewState(state);
gameRef.addFromBlueprint(BallBlueprint(position: position));
}
@override
Future<void> onLoad() async {
gameRef.addContactCallback(DashNestBumperBallContactCallback());
// TODO(alestiago): adjust positioning once sprites are added.
final smallLeftNest = SmallDashNestBumper(id: 'small_left_nest')
..initialPosition = position + Vector2(-4.8, 2.8);
final smallRightNest = SmallDashNestBumper(id: 'small_right_nest')
..initialPosition = position + Vector2(0.5, -5.5);
final bigNest = BigDashNestBumper(id: 'big_nest')
..initialPosition = position;
await addAll([
smallLeftNest,
smallRightNest,
bigNest,
]);
}
}
/// {@template dash_nest_bumper}
/// Bumper located in the [FlutterForest].
/// {@endtemplate}
@visibleForTesting
abstract class DashNestBumper extends BodyComponent<PinballGame>
with ScorePoints, InitialPosition {
/// {@macro dash_nest_bumper}
DashNestBumper({required this.id});
/// Unique identifier for this [DashNestBumper].
///
/// Used to identify [DashNestBumper]s in [GameState.activatedDashNests].
final String id;
}
/// Listens when a [Ball] bounces bounces against a [DashNestBumper].
@visibleForTesting
class DashNestBumperBallContactCallback
extends ContactCallback<DashNestBumper, Ball> {
@override
void begin(DashNestBumper dashNestBumper, Ball ball, Contact _) {
dashNestBumper.gameRef.read<GameBloc>().add(
DashNestActivated(dashNestBumper.id),
);
}
}
/// {@macro dash_nest_bumper}
@visibleForTesting
class BigDashNestBumper extends DashNestBumper {
/// {@macro dash_nest_bumper}
BigDashNestBumper({required String id}) : super(id: id);
@override
int get points => 20;
@override
Body createBody() {
final shape = CircleShape()..radius = 2.5;
final fixtureDef = FixtureDef(shape);
final bodyDef = BodyDef()
..position = initialPosition
..userData = this;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}
/// {@macro dash_nest_bumper}
@visibleForTesting
class SmallDashNestBumper extends DashNestBumper {
/// {@macro dash_nest_bumper}
SmallDashNestBumper({required String id}) : super(id: id);
@override
int get points => 10;
@override
Body createBody() {
final shape = CircleShape()..radius = 1;
final fixtureDef = FixtureDef(shape);
final bodyDef = BodyDef()
..position = initialPosition
..userData = this;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}

@ -1,35 +0,0 @@
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
/// {@template round_bumper}
/// Circular body that repels a [Ball] on contact, increasing the score.
/// {@endtemplate}
class RoundBumper extends BodyComponent with ScorePoints, InitialPosition {
/// {@macro round_bumper}
RoundBumper({
required double radius,
required int points,
}) : _radius = radius,
_points = points;
/// The radius of the [RoundBumper].
final double _radius;
/// Points awarded from hitting this [RoundBumper].
final int _points;
@override
int get points => _points;
@override
Body createBody() {
final shape = CircleShape()..radius = _radius;
final fixtureDef = FixtureDef(shape)..restitution = 1;
final bodyDef = BodyDef()..position = initialPosition;
return world.createBody(bodyDef)..createFixture(fixtureDef);
}
}

@ -70,7 +70,7 @@ class _PinballGameViewState extends State<PinballGameView> {
showDialog<void>(
context: context,
builder: (_) {
return const GameOverDialog();
return GameOverDialog(theme: widget.theme.characterTheme);
},
);
}

@ -1,21 +1,40 @@
import 'package:flutter/material.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
import 'package:pinball_theme/pinball_theme.dart';
/// {@template game_over_dialog}
/// [Dialog] displayed when the [PinballGame] is over.
/// {@endtemplate}
class GameOverDialog extends StatelessWidget {
/// {@macro game_over_dialog}
const GameOverDialog({Key? key}) : super(key: key);
const GameOverDialog({Key? key, required this.theme}) : super(key: key);
/// Current [CharacterTheme] to customize dialog
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
return const Dialog(
final l10n = context.l10n;
return Dialog(
child: SizedBox(
width: 200,
height: 200,
child: Center(
child: Text('Game Over'),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(l10n.gameOver),
TextButton(
onPressed: () => Navigator.of(context).push<void>(
LeaderboardPage.route(theme: theme),
),
child: Text(l10n.leaderboard),
),
],
),
),
),
);

@ -23,5 +23,33 @@
"characterSelectionTitle": "Choose your character!",
"@characterSelectionTitle": {
"description": "Title text displayed on the character selection page"
},
"gameOver": "Game Over",
"@gameOver": {
"description": "Text displayed on the ending dialog when game finishes"
},
"leaderboard": "Leaderboard",
"@leaderboard": {
"description": "Text displayed on the ending dialog leaderboard button"
},
"rank": "Rank",
"@rank": {
"description": "Text displayed on the leaderboard page header rank column"
},
"character": "Character",
"@character": {
"description": "Text displayed on the leaderboard page header character column"
},
"username": "Username",
"@username": {
"description": "Text displayed on the leaderboard page header userName column"
},
"score": "Score",
"@score": {
"description": "Text displayed on the leaderboard page header score column"
},
"retry": "Retry",
"@retry": {
"description": "Text displayed on the retry button leaders board page"
}
}

@ -1,2 +1,3 @@
export 'bloc/leaderboard_bloc.dart';
export 'models/leader_board_entry.dart';
export 'view/leaderboard_page.dart';

@ -0,0 +1,306 @@
// ignore_for_file: public_member_api_docs
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
import 'package:pinball/theme/theme.dart';
import 'package:pinball_theme/pinball_theme.dart';
class LeaderboardPage extends StatelessWidget {
const LeaderboardPage({Key? key, required this.theme}) : super(key: key);
final CharacterTheme theme;
static Route route({required CharacterTheme theme}) {
return MaterialPageRoute<void>(
builder: (_) => LeaderboardPage(theme: theme),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => LeaderboardBloc(
context.read<LeaderboardRepository>(),
)..add(const Top10Fetched()),
child: LeaderboardView(theme: theme),
);
}
}
class LeaderboardView extends StatelessWidget {
const LeaderboardView({Key? key, required this.theme}) : super(key: key);
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
body: Center(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 80),
Text(
l10n.leaderboard,
style: Theme.of(context).textTheme.headline3,
),
const SizedBox(height: 80),
BlocBuilder<LeaderboardBloc, LeaderboardState>(
builder: (context, state) {
switch (state.status) {
case LeaderboardStatus.loading:
return _LeaderboardLoading(theme: theme);
case LeaderboardStatus.success:
return _LeaderboardRanking(
ranking: state.leaderboard,
theme: theme,
);
case LeaderboardStatus.error:
return _LeaderboardError(theme: theme);
}
},
),
const SizedBox(height: 20),
TextButton(
onPressed: () => Navigator.of(context).push<void>(
CharacterSelectionPage.route(),
),
child: Text(l10n.retry),
),
],
),
),
),
);
}
}
class _LeaderboardLoading extends StatelessWidget {
const _LeaderboardLoading({Key? key, required this.theme}) : super(key: key);
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
return const Center(
child: CircularProgressIndicator(),
);
}
}
class _LeaderboardError extends StatelessWidget {
const _LeaderboardError({Key? key, required this.theme}) : super(key: key);
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: Text(
'There was en error loading data!',
style:
Theme.of(context).textTheme.headline6?.copyWith(color: Colors.red),
),
);
}
}
class _LeaderboardRanking extends StatelessWidget {
const _LeaderboardRanking({
Key? key,
required this.ranking,
required this.theme,
}) : super(key: key);
final List<LeaderboardEntry> ranking;
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_LeaderboardHeaders(theme: theme),
_LeaderboardList(
ranking: ranking,
theme: theme,
),
],
),
);
}
}
class _LeaderboardHeaders extends StatelessWidget {
const _LeaderboardHeaders({Key? key, required this.theme}) : super(key: key);
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_LeaderboardHeaderItem(title: l10n.rank, theme: theme),
_LeaderboardHeaderItem(title: l10n.character, theme: theme),
_LeaderboardHeaderItem(title: l10n.username, theme: theme),
_LeaderboardHeaderItem(title: l10n.score, theme: theme),
],
);
}
}
class _LeaderboardHeaderItem extends StatelessWidget {
const _LeaderboardHeaderItem({
Key? key,
required this.title,
required this.theme,
}) : super(key: key);
final CharacterTheme theme;
final String title;
@override
Widget build(BuildContext context) {
return Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.ballColor,
),
child: Text(
title,
style: Theme.of(context).textTheme.headline5,
),
),
);
}
}
class _LeaderboardList extends StatelessWidget {
const _LeaderboardList({
Key? key,
required this.ranking,
required this.theme,
}) : super(key: key);
final List<LeaderboardEntry> ranking;
final CharacterTheme theme;
@override
Widget build(BuildContext context) {
return ListView.builder(
shrinkWrap: true,
itemBuilder: (_, index) => _LeaderBoardCompetitor(
entry: ranking[index],
theme: theme,
),
itemCount: ranking.length,
);
}
}
class _LeaderBoardCompetitor extends StatelessWidget {
const _LeaderBoardCompetitor({
Key? key,
required this.entry,
required this.theme,
}) : super(key: key);
final CharacterTheme theme;
final LeaderboardEntry entry;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_LeaderboardCompetitorField(
text: entry.rank,
theme: theme,
),
_LeaderboardCompetitorCharacter(
characterAsset: entry.character,
theme: theme,
),
_LeaderboardCompetitorField(
text: entry.playerInitials,
theme: theme,
),
_LeaderboardCompetitorField(
text: entry.score.toString(),
theme: theme,
),
],
);
}
}
class _LeaderboardCompetitorField extends StatelessWidget {
const _LeaderboardCompetitorField({
Key? key,
required this.text,
required this.theme,
}) : super(key: key);
final CharacterTheme theme;
final String text;
@override
Widget build(BuildContext context) {
return Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: theme.ballColor,
width: 2,
),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(text),
),
),
);
}
}
class _LeaderboardCompetitorCharacter extends StatelessWidget {
const _LeaderboardCompetitorCharacter({
Key? key,
required this.characterAsset,
required this.theme,
}) : super(key: key);
final CharacterTheme theme;
final AssetGenImage characterAsset;
@override
Widget build(BuildContext context) {
return Expanded(
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: theme.ballColor,
width: 2,
),
),
child: SizedBox(
height: 30,
child: characterAsset.image(),
),
),
);
}
}

@ -7,11 +7,10 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/app/app.dart';
import 'package:pinball/landing/landing.dart';
class MockLeaderboardRepository extends Mock implements LeaderboardRepository {}
import '../../helpers/mocks.dart';
void main() {
group('App', () {

@ -14,6 +14,28 @@ class MyBlueprint extends Blueprint {
}
}
class MyOtherBlueprint extends Blueprint {
@override
void build(_) {
add(Component());
}
}
class YetMyOtherBlueprint extends Blueprint {
@override
void build(_) {
add(Component());
}
}
class MyComposedBlueprint extends Blueprint {
@override
void build(_) {
addBlueprint(MyBlueprint());
addAllBlueprints([MyOtherBlueprint(), YetMyOtherBlueprint()]);
}
}
class MyForge2dBlueprint extends Forge2DBlueprint {
@override
void build(_) {
@ -24,12 +46,23 @@ class MyForge2dBlueprint extends Forge2DBlueprint {
void main() {
group('Blueprint', () {
setUpAll(() {
registerFallbackValue(MyBlueprint());
registerFallbackValue(Component());
});
test('components can be added to it', () {
final blueprint = MyBlueprint()..build(MockPinballGame());
expect(blueprint.components.length, equals(3));
});
test('blueprints can be added to it', () {
final blueprint = MyComposedBlueprint()..build(MockPinballGame());
expect(blueprint.blueprints.length, equals(3));
});
test('adds the components to a game on attach', () {
final mockGame = MockPinballGame();
when(() => mockGame.addAll(any())).thenAnswer((_) async {});
@ -38,6 +71,14 @@ void main() {
verify(() => mockGame.addAll(any())).called(1);
});
test('adds components from a child Blueprint the to a game on attach', () {
final mockGame = MockPinballGame();
when(() => mockGame.addAll(any())).thenAnswer((_) async {});
MyComposedBlueprint().attach(mockGame);
verify(() => mockGame.addAll(any())).called(4);
});
test(
'throws assertion error when adding to an already attached blueprint',
() async {

@ -4,6 +4,7 @@ import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import '../../helpers/helpers.dart';

@ -75,15 +75,15 @@ void main() {
);
flameTester.test(
'has three RoundBumpers',
'has one FlutterForest',
(game) async {
// TODO(alestiago): change to [NestBumpers] once provided.
final board = Board();
await game.ready();
await game.ensureAdd(board);
final roundBumpers = board.descendants().whereType<RoundBumper>();
expect(roundBumpers.length, equals(3));
final flutterForest = board.descendants().whereType<FlutterForest>();
expect(flutterForest.length, equals(1));
},
);
});

@ -194,6 +194,7 @@ void main() {
group('bonus letter activation', () {
final gameBloc = MockGameBloc();
final tester = flameBlocTester(gameBloc: () => gameBloc);
BonusLetter _getBonusLetter(PinballGame game) {
return game.children
@ -212,8 +213,6 @@ void main() {
);
});
final tester = flameBlocTester(gameBloc: () => gameBloc);
tester.widgetTest(
'adds BonusLetterActivated to GameBloc when not activated',
(game, tester) async {

@ -0,0 +1,125 @@
// ignore_for_file: cascade_invocations
import 'package:bloc_test/bloc_test.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball_components/pinball_components.dart';
import '../../helpers/helpers.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final flameTester = FlameTester(PinballGameTest.create);
group('FlutterForest', () {
flameTester.test(
'loads correctly',
(game) async {
await game.ready();
final flutterForest = FlutterForest(position: Vector2(0, 0));
await game.ensureAdd(flutterForest);
expect(game.contains(flutterForest), isTrue);
},
);
flameTester.test(
'onNewState adds a new ball',
(game) async {
final flutterForest = FlutterForest(position: Vector2(0, 0));
await game.ready();
await game.ensureAdd(flutterForest);
final previousBalls = game.descendants().whereType<Ball>().length;
flutterForest.onNewState(MockGameState());
await game.ready();
expect(
game.descendants().whereType<Ball>().length,
greaterThan(previousBalls),
);
},
);
group('listenWhen', () {
final gameBloc = MockGameBloc();
final tester = flameBlocTester(gameBloc: () => gameBloc);
setUp(() {
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState.initial(),
);
});
tester.widgetTest(
'listens when a Bonus.dashNest is added',
(game, tester) async {
await game.ready();
final flutterForest =
game.descendants().whereType<FlutterForest>().first;
const state = GameState(
score: 0,
balls: 3,
activatedBonusLetters: [],
activatedDashNests: {},
bonusHistory: [GameBonus.dashNest],
);
expect(
flutterForest.listenWhen(const GameState.initial(), state),
isTrue,
);
},
);
});
});
group('DashNestBumperBallContactCallback', () {
final gameBloc = MockGameBloc();
final tester = flameBlocTester(gameBloc: () => gameBloc);
setUp(() {
whenListen(
gameBloc,
const Stream<GameState>.empty(),
initialState: const GameState.initial(),
);
});
tester.widgetTest(
'adds a DashNestActivated event with DashNestBumper.id',
(game, tester) async {
final contactCallback = DashNestBumperBallContactCallback();
const id = '0';
final dashNestBumper = MockDashNestBumper();
when(() => dashNestBumper.id).thenReturn(id);
when(() => dashNestBumper.gameRef).thenReturn(game);
contactCallback.begin(dashNestBumper, MockBall(), MockContact());
verify(() => gameBloc.add(DashNestActivated(dashNestBumper.id)))
.called(1);
},
);
});
group('BigDashNestBumper', () {
test('has points', () {
final dashNestBumper = BigDashNestBumper(id: '');
expect(dashNestBumper.points, greaterThan(0));
});
});
group('SmallDashNestBumper', () {
test('has points', () {
final dashNestBumper = SmallDashNestBumper(id: '');
expect(dashNestBumper.points, greaterThan(0));
});
});
}

@ -1,102 +0,0 @@
// ignore_for_file: cascade_invocations
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pinball/game/game.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('RoundBumper', () {
final flameTester = FlameTester(Forge2DGame.new);
const radius = 1.0;
const points = 1;
flameTester.test(
'loads correctly',
(game) async {
await game.ready();
final roundBumper = RoundBumper(
radius: radius,
points: points,
);
await game.ensureAdd(roundBumper);
expect(game.contains(roundBumper), isTrue);
},
);
flameTester.test(
'has points',
(game) async {
final roundBumper = RoundBumper(
radius: radius,
points: points,
);
await game.ensureAdd(roundBumper);
expect(roundBumper.points, equals(points));
},
);
group('body', () {
flameTester.test(
'is static',
(game) async {
final roundBumper = RoundBumper(
radius: radius,
points: points,
);
await game.ensureAdd(roundBumper);
expect(roundBumper.body.bodyType, equals(BodyType.static));
},
);
});
group('fixture', () {
flameTester.test(
'exists',
(game) async {
final roundBumper = RoundBumper(
radius: radius,
points: points,
);
await game.ensureAdd(roundBumper);
expect(roundBumper.body.fixtures[0], isA<Fixture>());
},
);
flameTester.test(
'has restitution',
(game) async {
final roundBumper = RoundBumper(
radius: radius,
points: points,
);
await game.ensureAdd(roundBumper);
final fixture = roundBumper.body.fixtures[0];
expect(fixture.restitution, greaterThan(0));
},
);
flameTester.test(
'shape is circular',
(game) async {
final roundBumper = RoundBumper(
radius: radius,
points: points,
);
await game.ensureAdd(roundBumper);
final fixture = roundBumper.body.fixtures[0];
expect(fixture.shape.shapeType, equals(ShapeType.circle));
expect(fixture.shape.radius, equals(1));
},
);
});
});
}

@ -104,10 +104,7 @@ void main() {
);
await tester.pump();
expect(
find.text('Game Over'),
findsOneWidget,
);
expect(find.byType(GameOverDialog), findsOneWidget);
},
);

@ -0,0 +1,44 @@
// ignore_for_file: prefer_const_constructors
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockingjay/mockingjay.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball_theme/pinball_theme.dart';
import '../../../helpers/helpers.dart';
void main() {
group('GameOverDialog', () {
testWidgets('renders correctly', (tester) async {
final l10n = await AppLocalizations.delegate.load(Locale('en'));
await tester.pumpApp(
const GameOverDialog(
theme: DashTheme(),
),
);
expect(find.text(l10n.gameOver), findsOneWidget);
expect(find.text(l10n.leaderboard), findsOneWidget);
});
testWidgets('tapping on leaderboard button navigates to LeaderBoardPage',
(tester) async {
final l10n = await AppLocalizations.delegate.load(Locale('en'));
final navigator = MockNavigator();
when(() => navigator.push<void>(any())).thenAnswer((_) async {});
await tester.pumpApp(
const GameOverDialog(
theme: DashTheme(),
),
navigator: navigator,
);
await tester.tap(find.widgetWithText(TextButton, l10n.leaderboard));
verify(() => navigator.push<void>(any())).called(1);
});
});
}

@ -8,4 +8,5 @@ export 'builders.dart';
export 'extensions.dart';
export 'key_testers.dart';
export 'mocks.dart';
export 'navigator.dart';
export 'pump_app.dart';

@ -1,3 +1,4 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:flame/components.dart';
import 'package:flame/input.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
@ -6,6 +7,7 @@ import 'package:flutter/services.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:mocktail/mocktail.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
import 'package:pinball/theme/theme.dart';
import 'package:pinball_components/pinball_components.dart';
@ -37,6 +39,9 @@ class MockGameState extends Mock implements GameState {}
class MockThemeCubit extends Mock implements ThemeCubit {}
class MockLeaderboardBloc extends MockBloc<LeaderboardEvent, LeaderboardState>
implements LeaderboardBloc {}
class MockLeaderboardRepository extends Mock implements LeaderboardRepository {}
class MockRawKeyDownEvent extends Mock implements RawKeyDownEvent {
@ -68,3 +73,5 @@ class MockSpaceshipEntrance extends Mock implements SpaceshipEntrance {}
class MockSpaceshipHole extends Mock implements SpaceshipHole {}
class MockComponentSet extends Mock implements ComponentSet {}
class MockDashNestBumper extends Mock implements DashNestBumper {}

@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'helpers.dart';
Future<void> expectNavigatesToRoute<Type>(
WidgetTester tester,
Route route, {
bool hasFlameGameInside = false,
}) async {
// ignore: avoid_dynamic_calls
await tester.pumpApp(
Scaffold(
body: Builder(
builder: (context) {
return ElevatedButton(
onPressed: () {
Navigator.of(context).push<void>(route);
},
child: const Text('Tap me'),
);
},
),
),
);
await tester.tap(find.text('Tap me'));
if (hasFlameGameInside) {
// We can't use pumpAndSettle here because the page renders a Flame game
// which is an infinity animation, so it will timeout
await tester.pump(); // Runs the button action
await tester.pump(); // Runs the navigation
} else {
await tester.pumpAndSettle();
}
expect(find.byType(Type), findsOneWidget);
}

@ -9,6 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:mockingjay/mockingjay.dart';
import 'package:pinball/game/game.dart';
import 'package:pinball/l10n/l10n.dart';
@ -22,26 +23,30 @@ extension PumpApp on WidgetTester {
MockNavigator? navigator,
GameBloc? gameBloc,
ThemeCubit? themeCubit,
LeaderboardRepository? leaderboardRepository,
}) {
return pumpWidget(
MultiBlocProvider(
providers: [
BlocProvider.value(
value: themeCubit ?? MockThemeCubit(),
),
BlocProvider.value(
value: gameBloc ?? MockGameBloc(),
),
],
child: MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
RepositoryProvider.value(
value: leaderboardRepository ?? MockLeaderboardRepository(),
child: MultiBlocProvider(
providers: [
BlocProvider.value(
value: themeCubit ?? MockThemeCubit(),
),
BlocProvider.value(
value: gameBloc ?? MockGameBloc(),
),
],
supportedLocales: AppLocalizations.supportedLocales,
home: navigator != null
? MockNavigatorProvider(navigator: navigator, child: widget)
: widget,
child: MaterialApp(
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
],
supportedLocales: AppLocalizations.supportedLocales,
home: navigator != null
? MockNavigatorProvider(navigator: navigator, child: widget)
: widget,
),
),
),
);

@ -0,0 +1,150 @@
// ignore_for_file: prefer_const_constructors
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:mockingjay/mockingjay.dart';
import 'package:pinball/l10n/l10n.dart';
import 'package:pinball/leaderboard/leaderboard.dart';
import 'package:pinball_theme/pinball_theme.dart';
import '../../helpers/helpers.dart';
void main() {
group('LeaderboardPage', () {
testWidgets('renders LeaderboardView', (tester) async {
await tester.pumpApp(
LeaderboardPage(
theme: DashTheme(),
),
);
expect(find.byType(LeaderboardView), findsOneWidget);
});
testWidgets('route returns a valid navigation route', (tester) async {
await expectNavigatesToRoute<LeaderboardPage>(
tester,
LeaderboardPage.route(
theme: DashTheme(),
),
);
});
});
group('LeaderboardView', () {
late LeaderboardBloc leaderboardBloc;
setUp(() {
leaderboardBloc = MockLeaderboardBloc();
});
testWidgets('renders correctly', (tester) async {
final l10n = await AppLocalizations.delegate.load(Locale('en'));
when(() => leaderboardBloc.state).thenReturn(LeaderboardState.initial());
await tester.pumpApp(
BlocProvider.value(
value: leaderboardBloc,
child: LeaderboardView(
theme: DashTheme(),
),
),
);
expect(find.text(l10n.leaderboard), findsOneWidget);
expect(find.text(l10n.retry), findsOneWidget);
});
testWidgets('renders loading view when bloc emits [loading]',
(tester) async {
when(() => leaderboardBloc.state).thenReturn(LeaderboardState.initial());
await tester.pumpApp(
BlocProvider.value(
value: leaderboardBloc,
child: LeaderboardView(
theme: DashTheme(),
),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('There was en error loading data!'), findsNothing);
expect(find.byType(ListView), findsNothing);
});
testWidgets('renders error view when bloc emits [error]', (tester) async {
when(() => leaderboardBloc.state).thenReturn(
LeaderboardState.initial().copyWith(status: LeaderboardStatus.error),
);
await tester.pumpApp(
BlocProvider.value(
value: leaderboardBloc,
child: LeaderboardView(
theme: DashTheme(),
),
),
);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('There was en error loading data!'), findsOneWidget);
expect(find.byType(ListView), findsNothing);
});
testWidgets('renders success view when bloc emits [success]',
(tester) async {
final l10n = await AppLocalizations.delegate.load(Locale('en'));
when(() => leaderboardBloc.state).thenReturn(
LeaderboardState(
status: LeaderboardStatus.success,
ranking: LeaderboardRanking(ranking: 0, outOf: 0),
leaderboard: [
LeaderboardEntry(
rank: '1',
playerInitials: 'ABC',
score: 10000,
character: DashTheme().characterAsset,
),
],
),
);
await tester.pumpApp(
BlocProvider.value(
value: leaderboardBloc,
child: LeaderboardView(
theme: DashTheme(),
),
),
);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('There was en error loading data!'), findsNothing);
expect(find.text(l10n.rank), findsOneWidget);
expect(find.text(l10n.character), findsOneWidget);
expect(find.text(l10n.username), findsOneWidget);
expect(find.text(l10n.score), findsOneWidget);
expect(find.byType(ListView), findsOneWidget);
});
testWidgets('navigates to CharacterSelectionPage when retry is tapped',
(tester) async {
final navigator = MockNavigator();
when(() => navigator.push<void>(any())).thenAnswer((_) async {});
await tester.pumpApp(
LeaderboardPage(
theme: DashTheme(),
),
navigator: navigator,
);
await tester.ensureVisible(find.byType(TextButton));
await tester.tap(find.byType(TextButton));
verify(() => navigator.push<void>(any())).called(1);
});
});
}
Loading…
Cancel
Save