diff --git a/.github/workflows/leaderboard_repository.yaml b/.github/workflows/leaderboard_repository.yaml new file mode 100644 index 00000000..6eddf283 --- /dev/null +++ b/.github/workflows/leaderboard_repository.yaml @@ -0,0 +1,18 @@ +name: leaderboard_repository + +on: + push: + paths: + - "packages/leaderboard_repository/**" + - ".github/workflows/leaderboard_repository.yaml" + + pull_request: + paths: + - "packages/leaderboard_repository/**" + - ".github/workflows/leaderboard_repository.yaml" + +jobs: + build: + uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/flutter_package.yml@v1 + with: + working_directory: packages/leaderboard_repository diff --git a/.gitignore b/.gitignore index eeb2b0f6..9bf37325 100644 --- a/.gitignore +++ b/.gitignore @@ -126,4 +126,6 @@ app.*.map.json !.idea/dictionaries/ !.idea/runConfigurations/ +# Firebase related .firebase +web/__/firebase/init.js diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index cf6213e9..8de80730 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -8,29 +8,38 @@ // ignore_for_file: public_member_api_docs import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/l10n/l10n.dart'; import 'package:pinball/landing/landing.dart'; class App extends StatelessWidget { - const App({Key? key}) : super(key: key); + const App({Key? key, required LeaderboardRepository leaderboardRepository}) + : _leaderboardRepository = leaderboardRepository, + super(key: key); + + final LeaderboardRepository _leaderboardRepository; @override Widget build(BuildContext context) { - return MaterialApp( - title: 'I/O Pinball', - theme: ThemeData( - appBarTheme: const AppBarTheme(color: Color(0xFF13B9FF)), - colorScheme: ColorScheme.fromSwatch( - accentColor: const Color(0xFF13B9FF), + return RepositoryProvider.value( + value: _leaderboardRepository, + child: MaterialApp( + title: 'I/O Pinball', + theme: ThemeData( + appBarTheme: const AppBarTheme(color: Color(0xFF13B9FF)), + colorScheme: ColorScheme.fromSwatch( + accentColor: const Color(0xFF13B9FF), + ), ), + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: const LandingPage(), ), - localizationsDelegates: const [ - AppLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - ], - supportedLocales: AppLocalizations.supportedLocales, - home: const LandingPage(), ); } } diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 34fcc47a..c0fa9240 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -11,6 +11,7 @@ import 'dart:async'; import 'dart:developer'; import 'package:bloc/bloc.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/widgets.dart'; class AppBlocObserver extends BlocObserver { @@ -27,7 +28,10 @@ class AppBlocObserver extends BlocObserver { } } -Future bootstrap(FutureOr Function() builder) async { +Future bootstrap( + Future Function(FirebaseFirestore firestore) builder, +) async { + WidgetsFlutterBinding.ensureInitialized(); FlutterError.onError = (details) { log(details.exceptionAsString(), stackTrace: details.stack); }; @@ -35,7 +39,7 @@ Future bootstrap(FutureOr Function() builder) async { await runZonedGuarded( () async { await BlocOverrides.runZoned( - () async => runApp(await builder()), + () async => runApp(await builder(FirebaseFirestore.instance)), blocObserver: AppBlocObserver(), ); }, diff --git a/lib/main_development.dart b/lib/main_development.dart index 99927436..8673eff4 100644 --- a/lib/main_development.dart +++ b/lib/main_development.dart @@ -5,9 +5,13 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/app/app.dart'; import 'package:pinball/bootstrap.dart'; void main() { - bootstrap(() => const App()); + bootstrap((firestore) async { + final leaderboardRepository = LeaderboardRepository(firestore); + return App(leaderboardRepository: leaderboardRepository); + }); } diff --git a/lib/main_production.dart b/lib/main_production.dart index 99927436..8673eff4 100644 --- a/lib/main_production.dart +++ b/lib/main_production.dart @@ -5,9 +5,13 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/app/app.dart'; import 'package:pinball/bootstrap.dart'; void main() { - bootstrap(() => const App()); + bootstrap((firestore) async { + final leaderboardRepository = LeaderboardRepository(firestore); + return App(leaderboardRepository: leaderboardRepository); + }); } diff --git a/lib/main_staging.dart b/lib/main_staging.dart index 99927436..8673eff4 100644 --- a/lib/main_staging.dart +++ b/lib/main_staging.dart @@ -5,9 +5,13 @@ // license that can be found in the LICENSE file or at // https://opensource.org/licenses/MIT. +import 'package:leaderboard_repository/leaderboard_repository.dart'; import 'package:pinball/app/app.dart'; import 'package:pinball/bootstrap.dart'; void main() { - bootstrap(() => const App()); + bootstrap((firestore) async { + final leaderboardRepository = LeaderboardRepository(firestore); + return App(leaderboardRepository: leaderboardRepository); + }); } diff --git a/packages/leaderboard_repository/.gitignore b/packages/leaderboard_repository/.gitignore new file mode 100644 index 00000000..d6130351 --- /dev/null +++ b/packages/leaderboard_repository/.gitignore @@ -0,0 +1,39 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# VSCode related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json diff --git a/packages/leaderboard_repository/README.md b/packages/leaderboard_repository/README.md new file mode 100644 index 00000000..c3123fe0 --- /dev/null +++ b/packages/leaderboard_repository/README.md @@ -0,0 +1,11 @@ +# leaderboard_repository + +[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] +[![License: MIT][license_badge]][license_link] + +Repository to access leaderboard data in Firebase Cloud Firestore. + +[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg +[license_link]: https://opensource.org/licenses/MIT +[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg +[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis \ No newline at end of file diff --git a/packages/leaderboard_repository/analysis_options.yaml b/packages/leaderboard_repository/analysis_options.yaml new file mode 100644 index 00000000..3742fc3d --- /dev/null +++ b/packages/leaderboard_repository/analysis_options.yaml @@ -0,0 +1 @@ +include: package:very_good_analysis/analysis_options.2.4.0.yaml \ No newline at end of file diff --git a/packages/leaderboard_repository/lib/leaderboard_repository.dart b/packages/leaderboard_repository/lib/leaderboard_repository.dart new file mode 100644 index 00000000..d299569a --- /dev/null +++ b/packages/leaderboard_repository/lib/leaderboard_repository.dart @@ -0,0 +1,4 @@ +library leaderboard_repository; + +export 'src/leaderboard_repository.dart'; +export 'src/models/models.dart'; diff --git a/packages/leaderboard_repository/lib/src/leaderboard_repository.dart b/packages/leaderboard_repository/lib/src/leaderboard_repository.dart new file mode 100644 index 00000000..5a5fa42c --- /dev/null +++ b/packages/leaderboard_repository/lib/src/leaderboard_repository.dart @@ -0,0 +1,153 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; + +/// {@template leaderboard_exception} +/// Base exception for leaderboard repository failures. +/// {@endtemplate} +abstract class LeaderboardException implements Exception { + /// {@macro leaderboard_exception} + const LeaderboardException(this.error, this.stackTrace); + + /// The error that was caught. + final Object error; + + /// The Stacktrace associated with the [error]. + final StackTrace stackTrace; +} + +/// {@template leaderboard_deserialization_exception} +/// Exception thrown when leaderboard data cannot be deserialized in the +/// expected way. +/// {@endtemplate} +class LeaderboardDeserializationException extends LeaderboardException { + /// {@macro leaderboard_deserialization_exception} + const LeaderboardDeserializationException( + Object error, + StackTrace stackTrace, + ) : super( + error, + stackTrace, + ); +} + +/// {@template fetch_top_10_leaderboard_exception} +/// Exception thrown when failure occurs while fetching top 10 leaderboard. +/// {@endtemplate} +class FetchTop10LeaderboardException extends LeaderboardException { + /// {@macro fetch_top_10_leaderboard_exception} + const FetchTop10LeaderboardException( + Object error, + StackTrace stackTrace, + ) : super( + error, + stackTrace, + ); +} + +/// {@template add_leaderboard_entry_exception} +/// Exception thrown when failure occurs while adding entry to leaderboard. +/// {@endtemplate} +class AddLeaderboardEntryException extends LeaderboardException { + /// {@macro add_leaderboard_entry_exception} + const AddLeaderboardEntryException( + Object error, + StackTrace stackTrace, + ) : super( + error, + stackTrace, + ); +} + +/// {@template fetch_player_ranking_exception} +/// Exception thrown when failure occurs while fetching player ranking. +/// {@endtemplate} +class FetchPlayerRankingException extends LeaderboardException { + /// {@macro fetch_player_ranking_exception} + const FetchPlayerRankingException( + Object error, + StackTrace stackTrace, + ) : super( + error, + stackTrace, + ); +} + +/// {@template leaderboard_repository} +/// Repository to access leaderboard data in Firebase Cloud Firestore. +/// {@endtemplate} +class LeaderboardRepository { + /// {@macro leaderboard_repository} + const LeaderboardRepository( + FirebaseFirestore firebaseFirestore, + ) : _firebaseFirestore = firebaseFirestore; + + final FirebaseFirestore _firebaseFirestore; + + /// Acquires top 10 [LeaderboardEntry]s. + Future> fetchTop10Leaderboard() async { + final leaderboardEntries = []; + late List documents; + + try { + final querySnapshot = await _firebaseFirestore + .collection('leaderboard') + .orderBy('score') + .limit(10) + .get(); + documents = querySnapshot.docs; + } on Exception catch (error, stackTrace) { + throw FetchTop10LeaderboardException(error, stackTrace); + } + + for (final document in documents) { + final data = document.data() as Map?; + if (data != null) { + try { + leaderboardEntries.add(LeaderboardEntry.fromJson(data)); + } catch (error, stackTrace) { + throw LeaderboardDeserializationException(error, stackTrace); + } + } + } + + return leaderboardEntries; + } + + /// Adds player's score entry to the leaderboard and gets their + /// [LeaderboardRanking]. + Future addLeaderboardEntry(LeaderboardEntry entry) async { + late DocumentReference entryReference; + try { + entryReference = await _firebaseFirestore + .collection('leaderboard') + .add(entry.toJson()); + } on Exception catch (error, stackTrace) { + throw AddLeaderboardEntryException(error, stackTrace); + } + + try { + final querySnapshot = await _firebaseFirestore + .collection('leaderboard') + .orderBy('score') + .get(); + + // TODO(allisonryan0002): see if we can find a more performant solution. + final documents = querySnapshot.docs; + final ranking = documents.indexWhere( + (document) => document.id == entryReference.id, + ) + + 1; + + if (ranking > 0) { + return LeaderboardRanking(ranking: ranking, outOf: documents.length); + } else { + throw FetchPlayerRankingException( + 'Player score could not be found and ranking cannot be provided.', + StackTrace.current, + ); + } + } on Exception catch (error, stackTrace) { + throw FetchPlayerRankingException(error, stackTrace); + } + } +} diff --git a/packages/leaderboard_repository/lib/src/models/leaderboard_entry.dart b/packages/leaderboard_repository/lib/src/models/leaderboard_entry.dart new file mode 100644 index 00000000..86cb2464 --- /dev/null +++ b/packages/leaderboard_repository/lib/src/models/leaderboard_entry.dart @@ -0,0 +1,80 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; + +part 'leaderboard_entry.g.dart'; + +/// Google character type associated with a [LeaderboardEntry]. +enum CharacterType { + /// Dash character. + dash, + + /// Sparky character. + sparky, + + /// Android character. + android, + + /// Dino character. + dino, +} + +/// {@template leaderboard_entry} +/// A model representing a leaderboard entry containing the player's initials, +/// score, and chosen character. +/// +/// Stored in Firestore `leaderboard` collection. +/// +/// Example: +/// ```json +/// { +/// "playerInitials" : "ABC", +/// "score" : 1500, +/// "character" : "dash" +/// } +/// ``` +/// {@endtemplate} +@JsonSerializable() +class LeaderboardEntry extends Equatable { + /// {@macro leaderboard_entry} + const LeaderboardEntry({ + required this.playerInitials, + required this.score, + required this.character, + }); + + /// Factory which converts a [Map] into a [LeaderboardEntry]. + factory LeaderboardEntry.fromJson(Map json) { + return _$LeaderboardEntryFromJson(json); + } + + /// Converts the [LeaderboardEntry] to [Map]. + Map toJson() => _$LeaderboardEntryToJson(this); + + /// Player's chosen initials for [LeaderboardEntry]. + /// + /// Example: 'ABC'. + @JsonKey(name: 'playerInitials') + final String playerInitials; + + /// Score for [LeaderboardEntry]. + /// + /// Example: 1500. + @JsonKey(name: 'score') + final int score; + + /// [CharacterType] for [LeaderboardEntry]. + /// + /// Example: [CharacterType.dash]. + @JsonKey(name: 'character') + final CharacterType character; + + /// An empty [LeaderboardEntry] object. + static const empty = LeaderboardEntry( + playerInitials: '', + score: 0, + character: CharacterType.dash, + ); + + @override + List get props => [playerInitials, score, character]; +} diff --git a/packages/leaderboard_repository/lib/src/models/leaderboard_entry.g.dart b/packages/leaderboard_repository/lib/src/models/leaderboard_entry.g.dart new file mode 100644 index 00000000..fc685220 --- /dev/null +++ b/packages/leaderboard_repository/lib/src/models/leaderboard_entry.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'leaderboard_entry.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LeaderboardEntry _$LeaderboardEntryFromJson(Map json) => + LeaderboardEntry( + playerInitials: json['playerInitials'] as String, + score: json['score'] as int, + character: $enumDecode(_$CharacterTypeEnumMap, json['character']), + ); + +Map _$LeaderboardEntryToJson(LeaderboardEntry instance) => + { + 'playerInitials': instance.playerInitials, + 'score': instance.score, + 'character': _$CharacterTypeEnumMap[instance.character], + }; + +const _$CharacterTypeEnumMap = { + CharacterType.dash: 'dash', + CharacterType.sparky: 'sparky', + CharacterType.android: 'android', + CharacterType.dino: 'dino', +}; diff --git a/packages/leaderboard_repository/lib/src/models/leaderboard_ranking.dart b/packages/leaderboard_repository/lib/src/models/leaderboard_ranking.dart new file mode 100644 index 00000000..7ec90ef4 --- /dev/null +++ b/packages/leaderboard_repository/lib/src/models/leaderboard_ranking.dart @@ -0,0 +1,20 @@ +import 'package:equatable/equatable.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; + +/// {@template leaderboard_ranking} +/// Contains [ranking] for a single [LeaderboardEntry] and the number of players +/// the [ranking] is [outOf]. +/// {@endtemplate} +class LeaderboardRanking extends Equatable { + /// {@macro leaderboard_ranking} + const LeaderboardRanking({required this.ranking, required this.outOf}); + + /// Place ranking by score for a [LeaderboardEntry]. + final int ranking; + + /// Number of [LeaderboardEntry]s at the time of score entry. + final int outOf; + + @override + List get props => [ranking, outOf]; +} diff --git a/packages/leaderboard_repository/lib/src/models/models.dart b/packages/leaderboard_repository/lib/src/models/models.dart new file mode 100644 index 00000000..3dabe2bf --- /dev/null +++ b/packages/leaderboard_repository/lib/src/models/models.dart @@ -0,0 +1,2 @@ +export 'leaderboard_entry.dart'; +export 'leaderboard_ranking.dart'; diff --git a/packages/leaderboard_repository/pubspec.yaml b/packages/leaderboard_repository/pubspec.yaml new file mode 100644 index 00000000..1d3869f4 --- /dev/null +++ b/packages/leaderboard_repository/pubspec.yaml @@ -0,0 +1,23 @@ +name: leaderboard_repository +description: Repository to access leaderboard data in Firebase Cloud Firestore. +version: 1.0.0+1 +publish_to: none + +environment: + sdk: ">=2.16.0 <3.0.0" + +dependencies: + cloud_firestore: ^3.1.10 + equatable: ^2.0.3 + flutter: + sdk: flutter + json_annotation: ^4.4.0 + +dev_dependencies: + build_runner: ^2.1.8 + flutter_test: + sdk: flutter + json_serializable: ^6.1.5 + mocktail: ^0.2.0 + test: ^1.19.2 + very_good_analysis: ^2.4.0 \ No newline at end of file diff --git a/packages/leaderboard_repository/test/src/leaderboard_repository_test.dart b/packages/leaderboard_repository/test/src/leaderboard_repository_test.dart new file mode 100644 index 00000000..cd632638 --- /dev/null +++ b/packages/leaderboard_repository/test/src/leaderboard_repository_test.dart @@ -0,0 +1,226 @@ +// ignore_for_file: prefer_const_constructors, subtype_of_sealed_class + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/test.dart'; + +class MockFirebaseFirestore extends Mock implements FirebaseFirestore {} + +class MockCollectionReference extends Mock + implements CollectionReference> {} + +class MockQuery extends Mock implements Query> {} + +class MockQuerySnapshot extends Mock + implements QuerySnapshot> {} + +class MockQueryDocumentSnapshot extends Mock + implements QueryDocumentSnapshot> {} + +class MockDocumentReference extends Mock + implements DocumentReference> {} + +void main() { + group('LeaderboardRepository', () { + late FirebaseFirestore firestore; + + setUp(() { + firestore = MockFirebaseFirestore(); + }); + + test('can be instantiated', () { + expect(LeaderboardRepository(firestore), isNotNull); + }); + + group('fetchTop10Leaderboard', () { + late LeaderboardRepository leaderboardRepository; + late CollectionReference> collectionReference; + late Query> query; + late QuerySnapshot> querySnapshot; + late List>> + queryDocumentSnapshots; + + final top10Scores = [ + 2500, + 2200, + 2200, + 2000, + 1800, + 1400, + 1300, + 1000, + 600, + 300, + 100, + ]; + + final top10Leaderboard = top10Scores + .map( + (score) => LeaderboardEntry( + playerInitials: 'user$score', + score: score, + character: CharacterType.dash, + ), + ) + .toList(); + + setUp(() { + leaderboardRepository = LeaderboardRepository(firestore); + collectionReference = MockCollectionReference(); + query = MockQuery(); + querySnapshot = MockQuerySnapshot(); + queryDocumentSnapshots = top10Scores.map((score) { + final queryDocumentSnapshot = MockQueryDocumentSnapshot(); + when(queryDocumentSnapshot.data).thenReturn({ + 'character': 'dash', + 'playerInitials': 'user$score', + 'score': score + }); + return queryDocumentSnapshot; + }).toList(); + + when(() => firestore.collection('leaderboard')) + .thenAnswer((_) => collectionReference); + when(() => collectionReference.orderBy('score')) + .thenAnswer((_) => query); + when(() => query.limit(10)).thenAnswer((_) => query); + when(query.get).thenAnswer((_) async => querySnapshot); + when(() => querySnapshot.docs).thenReturn(queryDocumentSnapshots); + }); + + test( + 'returns top 10 entries when ' + 'retrieving information from firestore succeeds', () async { + final top10LeaderboardResults = + await leaderboardRepository.fetchTop10Leaderboard(); + + expect(top10LeaderboardResults, equals(top10Leaderboard)); + }); + + test( + 'throws FetchTop10LeaderboardException when Exception occurs ' + 'when trying to retrieve information from firestore', () async { + when(() => firestore.collection('leaderboard')).thenThrow(Exception()); + + expect( + () => leaderboardRepository.fetchTop10Leaderboard(), + throwsA(isA()), + ); + }); + + test( + 'throws LeaderboardDeserializationException when Exception occurs ' + 'during deserialization', () async { + final top10LeaderboardDataMalformed = { + 'playerInitials': 'ABC', + 'score': 1500, + }; + final queryDocumentSnapshot = MockQueryDocumentSnapshot(); + when(() => querySnapshot.docs).thenReturn([queryDocumentSnapshot]); + when(queryDocumentSnapshot.data) + .thenReturn(top10LeaderboardDataMalformed); + + expect( + () => leaderboardRepository.fetchTop10Leaderboard(), + throwsA(isA()), + ); + }); + }); + + group('addLeaderboardEntry', () { + late LeaderboardRepository leaderboardRepository; + late CollectionReference> collectionReference; + late DocumentReference> documentReference; + late Query> query; + late QuerySnapshot> querySnapshot; + late List>> + queryDocumentSnapshots; + + const entryScore = 1500; + final leaderboardScores = [ + 2500, + 2200, + entryScore, + 1000, + ]; + final leaderboardEntry = LeaderboardEntry( + playerInitials: 'ABC', + score: entryScore, + character: CharacterType.dash, + ); + const entryDocumentId = 'id$entryScore'; + final ranking = LeaderboardRanking(ranking: 3, outOf: 4); + + setUp(() { + leaderboardRepository = LeaderboardRepository(firestore); + collectionReference = MockCollectionReference(); + documentReference = MockDocumentReference(); + query = MockQuery(); + querySnapshot = MockQuerySnapshot(); + queryDocumentSnapshots = leaderboardScores.map((score) { + final queryDocumentSnapshot = MockQueryDocumentSnapshot(); + when(queryDocumentSnapshot.data).thenReturn({ + 'character': 'dash', + 'username': 'user$score', + 'score': score + }); + when(() => queryDocumentSnapshot.id).thenReturn('id$score'); + return queryDocumentSnapshot; + }).toList(); + + when(() => firestore.collection('leaderboard')) + .thenAnswer((_) => collectionReference); + when(() => collectionReference.add(any())) + .thenAnswer((_) async => documentReference); + when(() => collectionReference.orderBy('score')) + .thenAnswer((_) => query); + when(query.get).thenAnswer((_) async => querySnapshot); + when(() => querySnapshot.docs).thenReturn(queryDocumentSnapshots); + when(() => documentReference.id).thenReturn(entryDocumentId); + }); + + test( + 'adds leaderboard entry and returns player ranking when ' + 'firestore operations succeed', () async { + final rankingResult = + await leaderboardRepository.addLeaderboardEntry(leaderboardEntry); + + expect(rankingResult, equals(ranking)); + }); + + test( + 'throws AddLeaderboardEntryException when Exception occurs ' + 'when trying to add entry to firestore', () async { + when(() => firestore.collection('leaderboard')).thenThrow(Exception()); + + expect( + () => leaderboardRepository.addLeaderboardEntry(leaderboardEntry), + throwsA(isA()), + ); + }); + + test( + 'throws FetchPlayerRankingException when Exception occurs ' + 'when trying to retrieve information from firestore', () async { + when(() => collectionReference.orderBy('score')).thenThrow(Exception()); + + expect( + () => leaderboardRepository.addLeaderboardEntry(leaderboardEntry), + throwsA(isA()), + ); + }); + + test( + 'throws FetchPlayerRankingException when score cannot be found ' + 'in firestore leaderboard data', () async { + when(() => documentReference.id).thenReturn('nonexistentDocumentId'); + + expect( + () => leaderboardRepository.addLeaderboardEntry(leaderboardEntry), + throwsA(isA()), + ); + }); + }); + }); +} diff --git a/packages/leaderboard_repository/test/src/models/leaderboard_entry_test.dart b/packages/leaderboard_repository/test/src/models/leaderboard_entry_test.dart new file mode 100644 index 00000000..21056529 --- /dev/null +++ b/packages/leaderboard_repository/test/src/models/leaderboard_entry_test.dart @@ -0,0 +1,41 @@ +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:test/test.dart'; + +void main() { + group('LeaderboardEntry', () { + const data = { + 'playerInitials': 'ABC', + 'score': 1500, + 'character': 'dash', + }; + + const leaderboardEntry = LeaderboardEntry( + playerInitials: 'ABC', + score: 1500, + character: CharacterType.dash, + ); + + test('can be instantiated', () { + const leaderboardEntry = LeaderboardEntry.empty; + + expect(leaderboardEntry, isNotNull); + }); + + test('supports value equality.', () { + const leaderboardEntry = LeaderboardEntry.empty; + const leaderboardEntry2 = LeaderboardEntry.empty; + + expect(leaderboardEntry, equals(leaderboardEntry2)); + }); + + test('can be converted to json', () { + expect(leaderboardEntry.toJson(), equals(data)); + }); + + test('can be obtained from json', () { + final leaderboardEntryFrom = LeaderboardEntry.fromJson(data); + + expect(leaderboardEntry, equals(leaderboardEntryFrom)); + }); + }); +} diff --git a/packages/leaderboard_repository/test/src/models/leaderboard_ranking_test.dart b/packages/leaderboard_repository/test/src/models/leaderboard_ranking_test.dart new file mode 100644 index 00000000..577251e4 --- /dev/null +++ b/packages/leaderboard_repository/test/src/models/leaderboard_ranking_test.dart @@ -0,0 +1,19 @@ +import 'package:leaderboard_repository/leaderboard_repository.dart'; +import 'package:test/test.dart'; + +void main() { + group('LeaderboardRanking', () { + test('can be instantiated', () { + const leaderboardRanking = LeaderboardRanking(ranking: 1, outOf: 1); + + expect(leaderboardRanking, isNotNull); + }); + + test('supports value equality.', () { + const leaderboardRanking = LeaderboardRanking(ranking: 1, outOf: 1); + const leaderboardRanking2 = LeaderboardRanking(ranking: 1, outOf: 1); + + expect(leaderboardRanking, equals(leaderboardRanking2)); + }); + }); +} diff --git a/pubspec.lock b/pubspec.lock index 73d0fc8d..71647cae 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -78,6 +78,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.0" + cloud_firestore: + dependency: "direct main" + description: + name: cloud_firestore + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.10" + cloud_firestore_platform_interface: + dependency: transitive + description: + name: cloud_firestore_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "5.5.1" + cloud_firestore_web: + dependency: transitive + description: + name: cloud_firestore_web + url: "https://pub.dartlang.org" + source: hosted + version: "2.6.10" collection: dependency: transitive description: @@ -134,6 +155,27 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.2" + firebase_core: + dependency: transitive + description: + name: firebase_core + url: "https://pub.dartlang.org" + source: hosted + version: "1.13.1" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "4.2.5" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + url: "https://pub.dartlang.org" + source: hosted + version: "1.6.1" flame: dependency: "direct main" description: @@ -184,6 +226,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" forge2d: dependency: transitive description: @@ -246,7 +293,21 @@ packages: name: js url: "https://pub.dartlang.org" source: hosted - version: "0.6.4" + version: "0.6.3" + json_annotation: + dependency: transitive + description: + name: json_annotation + url: "https://pub.dartlang.org" + source: hosted + version: "4.4.0" + leaderboard_repository: + dependency: "direct main" + description: + path: "packages/leaderboard_repository" + relative: true + source: path + version: "1.0.0+1" logging: dependency: transitive description: @@ -338,6 +399,13 @@ packages: relative: true source: path version: "1.0.0+1" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + url: "https://pub.dartlang.org" + source: hosted + version: "2.1.2" pool: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 18905a10..81b056b3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,7 @@ environment: dependencies: bloc: ^8.0.2 + cloud_firestore: ^3.1.10 equatable: ^2.0.3 flame: ^1.1.0-releasecandidate.5 flame_bloc: ^1.2.0-releasecandidate.5 @@ -20,6 +21,8 @@ dependencies: geometry: path: packages/geometry intl: ^0.17.0 + leaderboard_repository: + path: packages/leaderboard_repository pinball_theme: path: packages/pinball_theme diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index d0fd36b9..5a6a249f 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -6,13 +6,25 @@ // https://opensource.org/licenses/MIT. 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 {} + void main() { group('App', () { + late LeaderboardRepository leaderboardRepository; + + setUp(() { + leaderboardRepository = MockLeaderboardRepository(); + }); + testWidgets('renders LandingPage', (tester) async { - await tester.pumpWidget(const App()); + await tester.pumpWidget( + App(leaderboardRepository: leaderboardRepository), + ); expect(find.byType(LandingPage), findsOneWidget); }); }); diff --git a/web/__/firebase/8.9.1/firebase-app.js b/web/__/firebase/8.9.1/firebase-app.js new file mode 100644 index 00000000..c806965c --- /dev/null +++ b/web/__/firebase/8.9.1/firebase-app.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).firebase=t()}(this,function(){"use strict";var r=function(e,t){return(r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&(e[n]=t[n])})(e,t)};var n=function(){return(n=Object.assign||function(e){for(var t,n=1,r=arguments.length;na[0]&&t[1]=e.length?void 0:e)&&e[r++],done:!e}}};throw new TypeError(t?"Object is not iterable.":"Symbol.iterator is not defined.")}function f(e,t){var n="function"==typeof Symbol&&e[Symbol.iterator];if(!n)return e;var r,i,o=n.call(e),a=[];try{for(;(void 0===t||0"})):"Error",e=this.serviceName+": "+e+" ("+o+").";return new c(o,e,i)},v);function v(e,t,n){this.service=e,this.serviceName=t,this.errors=n}var m=/\{\$([^}]+)}/g;function y(e,t){return Object.prototype.hasOwnProperty.call(e,t)}function b(e,t){t=new g(e,t);return t.subscribe.bind(t)}var g=(I.prototype.next=function(t){this.forEachObserver(function(e){e.next(t)})},I.prototype.error=function(t){this.forEachObserver(function(e){e.error(t)}),this.close(t)},I.prototype.complete=function(){this.forEachObserver(function(e){e.complete()}),this.close()},I.prototype.subscribe=function(e,t,n){var r,i=this;if(void 0===e&&void 0===t&&void 0===n)throw new Error("Missing Observer.");void 0===(r=function(e,t){if("object"!=typeof e||null===e)return!1;for(var n=0,r=t;n=(null!=o?o:e.logLevel)&&a({level:R[t].toLowerCase(),message:i,args:n,type:e.name})}}(n[e])}var H=((H={})["no-app"]="No Firebase App '{$appName}' has been created - call Firebase App.initializeApp()",H["bad-app-name"]="Illegal App name: '{$appName}",H["duplicate-app"]="Firebase App named '{$appName}' already exists",H["app-deleted"]="Firebase App named '{$appName}' already deleted",H["invalid-app-argument"]="firebase.{$appName}() takes either no argument or a Firebase App instance.",H["invalid-log-argument"]="First argument to `onLog` must be null or a function.",H),V=new d("app","Firebase",H),B="@firebase/app",M="[DEFAULT]",U=((H={})[B]="fire-core",H["@firebase/analytics"]="fire-analytics",H["@firebase/app-check"]="fire-app-check",H["@firebase/auth"]="fire-auth",H["@firebase/database"]="fire-rtdb",H["@firebase/functions"]="fire-fn",H["@firebase/installations"]="fire-iid",H["@firebase/messaging"]="fire-fcm",H["@firebase/performance"]="fire-perf",H["@firebase/remote-config"]="fire-rc",H["@firebase/storage"]="fire-gcs",H["@firebase/firestore"]="fire-fst",H["fire-js"]="fire-js",H["firebase-wrapper"]="fire-js-all",H),W=new z("@firebase/app"),G=(Object.defineProperty($.prototype,"automaticDataCollectionEnabled",{get:function(){return this.checkDestroyed_(),this.automaticDataCollectionEnabled_},set:function(e){this.checkDestroyed_(),this.automaticDataCollectionEnabled_=e},enumerable:!1,configurable:!0}),Object.defineProperty($.prototype,"name",{get:function(){return this.checkDestroyed_(),this.name_},enumerable:!1,configurable:!0}),Object.defineProperty($.prototype,"options",{get:function(){return this.checkDestroyed_(),this.options_},enumerable:!1,configurable:!0}),$.prototype.delete=function(){var t=this;return new Promise(function(e){t.checkDestroyed_(),e()}).then(function(){return t.firebase_.INTERNAL.removeApp(t.name_),Promise.all(t.container.getProviders().map(function(e){return e.delete()}))}).then(function(){t.isDeleted_=!0})},$.prototype._getService=function(e,t){void 0===t&&(t=M),this.checkDestroyed_();var n=this.container.getProvider(e);return n.isInitialized()||"EXPLICIT"!==(null===(e=n.getComponent())||void 0===e?void 0:e.instantiationMode)||n.initialize(),n.getImmediate({identifier:t})},$.prototype._removeServiceInstance=function(e,t){void 0===t&&(t=M),this.container.getProvider(e).clearInstance(t)},$.prototype._addComponent=function(t){try{this.container.addComponent(t)}catch(e){W.debug("Component "+t.name+" failed to register with FirebaseApp "+this.name,e)}},$.prototype._addOrOverwriteComponent=function(e){this.container.addOrOverwriteComponent(e)},$.prototype.toJSON=function(){return{name:this.name,automaticDataCollectionEnabled:this.automaticDataCollectionEnabled,options:this.options}},$.prototype.checkDestroyed_=function(){if(this.isDeleted_)throw V.create("app-deleted",{appName:this.name_})},$);function $(e,t,n){var r=this;this.firebase_=n,this.isDeleted_=!1,this.name_=t.name,this.automaticDataCollectionEnabled_=t.automaticDataCollectionEnabled||!1,this.options_=h(void 0,e),this.container=new S(t.name),this._addComponent(new E("app",function(){return r},"PUBLIC")),this.firebase_.INTERNAL.components.forEach(function(e){return r._addComponent(e)})}G.prototype.name&&G.prototype.options||G.prototype.delete||console.log("dc");var K="8.9.1";function Y(a){var s={},l=new Map,c={__esModule:!0,initializeApp:function(e,t){void 0===t&&(t={});"object"==typeof t&&null!==t||(t={name:t});var n=t;void 0===n.name&&(n.name=M);t=n.name;if("string"!=typeof t||!t)throw V.create("bad-app-name",{appName:String(t)});if(y(s,t))throw V.create("duplicate-app",{appName:t});n=new a(e,n,c);return s[t]=n},app:u,registerVersion:function(e,t,n){var r=null!==(i=U[e])&&void 0!==i?i:e;n&&(r+="-"+n);var i=r.match(/\s|\//),e=t.match(/\s|\//);i||e?(n=['Unable to register library "'+r+'" with version "'+t+'":'],i&&n.push('library name "'+r+'" contains illegal characters (whitespace or "/")'),i&&e&&n.push("and"),e&&n.push('version name "'+t+'" contains illegal characters (whitespace or "/")'),W.warn(n.join(" "))):o(new E(r+"-version",function(){return{library:r,version:t}},"VERSION"))},setLogLevel:T,onLog:function(e,t){if(null!==e&&"function"!=typeof e)throw V.create("invalid-log-argument");x(e,t)},apps:null,SDK_VERSION:K,INTERNAL:{registerComponent:o,removeApp:function(e){delete s[e]},components:l,useAsService:function(e,t){return"serverAuth"!==t?t:null}}};function u(e){if(!y(s,e=e||M))throw V.create("no-app",{appName:e});return s[e]}function o(n){var e,r=n.name;if(l.has(r))return W.debug("There were multiple attempts to register component "+r+"."),"PUBLIC"===n.type?c[r]:null;l.set(r,n),"PUBLIC"===n.type&&(e=function(e){if("function"!=typeof(e=void 0===e?u():e)[r])throw V.create("invalid-app-argument",{appName:r});return e[r]()},void 0!==n.serviceProps&&h(e,n.serviceProps),c[r]=e,a.prototype[r]=function(){for(var e=[],t=0;tPinball + +