feat: add leaderboard repository (#54)

* feat: add leaderboard repository

* refactor: move leaderboard ranking to models

* refactor: username to playerInitials

* docs: typo fix

* docs: adjust repo wording

* fix: failing test
pull/56/head
Allison Ryan 4 years ago committed by GitHub
parent aba49660c2
commit c0175b0d30
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -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

@ -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

@ -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

@ -0,0 +1 @@
include: package:very_good_analysis/analysis_options.2.4.0.yaml

@ -0,0 +1,4 @@
library leaderboard_repository;
export 'src/leaderboard_repository.dart';
export 'src/models/models.dart';

@ -0,0 +1,152 @@
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<List<LeaderboardEntry>> fetchTop10Leaderboard() async {
final leaderboardEntries = <LeaderboardEntry>[];
late List<QueryDocumentSnapshot> 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<String, dynamic>?;
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<LeaderboardRanking> 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();
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);
}
}
}

@ -0,0 +1,83 @@
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<String, dynamic> json) {
return _$LeaderboardEntryFromJson(json);
}
/// Converts the [LeaderboardEntry] to [Map].
Map<String, dynamic> 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<Object?> get props => [playerInitials, score, character];
}

@ -0,0 +1,28 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'leaderboard_entry.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LeaderboardEntry _$LeaderboardEntryFromJson(Map<String, dynamic> json) =>
LeaderboardEntry(
playerInitials: json['playerInitials'] as String,
score: json['score'] as int,
character: $enumDecode(_$CharacterTypeEnumMap, json['character']),
);
Map<String, dynamic> _$LeaderboardEntryToJson(LeaderboardEntry instance) =>
<String, dynamic>{
'playerInitials': instance.playerInitials,
'score': instance.score,
'character': _$CharacterTypeEnumMap[instance.character],
};
const _$CharacterTypeEnumMap = {
CharacterType.dash: 'dash',
CharacterType.sparky: 'sparky',
CharacterType.android: 'android',
CharacterType.dino: 'dino',
};

@ -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<Object> get props => [ranking, outOf];
}

@ -0,0 +1,2 @@
export 'leaderboard_entry.dart';
export 'leaderboard_ranking.dart';

@ -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

@ -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<Map<String, dynamic>> {}
class MockQuery extends Mock implements Query<Map<String, dynamic>> {}
class MockQuerySnapshot extends Mock
implements QuerySnapshot<Map<String, dynamic>> {}
class MockQueryDocumentSnapshot extends Mock
implements QueryDocumentSnapshot<Map<String, dynamic>> {}
class MockDocumentReference extends Mock
implements DocumentReference<Map<String, dynamic>> {}
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<Map<String, dynamic>> collectionReference;
late Query<Map<String, dynamic>> query;
late QuerySnapshot<Map<String, dynamic>> querySnapshot;
late List<QueryDocumentSnapshot<Map<String, dynamic>>>
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(<String, dynamic>{
'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<FetchTop10LeaderboardException>()),
);
});
test(
'throws LeaderboardDeserializationException when Exception occurs '
'during deserialization', () async {
final top10LeaderboardDataMalformed = <String, dynamic>{
'playerInitials': 'ABC',
'score': 1500,
};
final queryDocumentSnapshot = MockQueryDocumentSnapshot();
when(() => querySnapshot.docs).thenReturn([queryDocumentSnapshot]);
when(queryDocumentSnapshot.data)
.thenReturn(top10LeaderboardDataMalformed);
expect(
() => leaderboardRepository.fetchTop10Leaderboard(),
throwsA(isA<LeaderboardDeserializationException>()),
);
});
});
group('addLeaderboardEntry', () {
late LeaderboardRepository leaderboardRepository;
late CollectionReference<Map<String, dynamic>> collectionReference;
late DocumentReference<Map<String, dynamic>> documentReference;
late Query<Map<String, dynamic>> query;
late QuerySnapshot<Map<String, dynamic>> querySnapshot;
late List<QueryDocumentSnapshot<Map<String, dynamic>>>
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(<String, dynamic>{
'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<AddLeaderboardEntryException>()),
);
});
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<FetchPlayerRankingException>()),
);
});
test(
'throws FetchPlayerRankingException when score cannot be found '
'in firestore leaderboard data', () async {
when(() => documentReference.id).thenReturn('nonexistentDocumentId');
expect(
() => leaderboardRepository.addLeaderboardEntry(leaderboardEntry),
throwsA(isA<FetchPlayerRankingException>()),
);
});
});
});
}

@ -0,0 +1,41 @@
import 'package:leaderboard_repository/leaderboard_repository.dart';
import 'package:test/test.dart';
void main() {
group('LeaderboardEntry', () {
const data = <String, dynamic>{
'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));
});
});
}

@ -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));
});
});
}

@ -78,6 +78,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
cloud_firestore:
dependency: transitive
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:

@ -20,6 +20,8 @@ dependencies:
geometry:
path: packages/geometry
intl: ^0.17.0
leaderboard_repository:
path: packages/leaderboard_repository
pinball_theme:
path: packages/pinball_theme

Loading…
Cancel
Save