diff --git a/api/packages/crossword_repository/lib/crossword_repository.dart b/api/packages/crossword_repository/lib/crossword_repository.dart index e7eaf31da..caabbdd6d 100644 --- a/api/packages/crossword_repository/lib/crossword_repository.dart +++ b/api/packages/crossword_repository/lib/crossword_repository.dart @@ -2,3 +2,4 @@ library; export 'src/crossword_repository.dart'; +export 'src/crossword_repository_exception.dart'; diff --git a/api/packages/crossword_repository/lib/src/crossword_repository.dart b/api/packages/crossword_repository/lib/src/crossword_repository.dart index f3bda5939..172710ac3 100644 --- a/api/packages/crossword_repository/lib/src/crossword_repository.dart +++ b/api/packages/crossword_repository/lib/src/crossword_repository.dart @@ -1,5 +1,6 @@ import 'package:clock/clock.dart'; import 'package:collection/collection.dart'; +import 'package:crossword_repository/crossword_repository.dart'; import 'package:db_client/db_client.dart'; import 'package:game_domain/game_domain.dart'; @@ -15,6 +16,8 @@ class CrosswordRepository { final DbClient _dbClient; static const _sectionsCollection = 'boardChunks'; + static const _answersCollection = 'answers'; + static const _boardInfoCollection = 'boardInfo'; /// Fetches all sections from the board. Future> listAllSections() async { @@ -59,33 +62,50 @@ class CrosswordRepository { ); } + /// Fetches a word answer by its id. + Future findAnswerById(String id) async { + final result = await _dbClient.getById(_answersCollection, 'id$id'); + + if (result != null) { + return result.data['answer'] as String; + } + + return null; + } + /// Tries solving a word. /// Returns true if succeeds and updates the word's solvedTimestamp /// attribute. Future answerWord( int sectionX, int sectionY, - int wordX, - int wordY, + String wordId, Mascots mascot, - String answer, + String userAnswer, ) async { final section = await findSectionByPosition(sectionX, sectionY); if (section == null) { - return false; + throw CrosswordRepositoryException( + 'Section not found for position ($sectionX, $sectionY)', + StackTrace.current, + ); } - final word = section.words.firstWhereOrNull( - (element) => element.position.x == wordX && element.position.y == wordY, - ); + final word = section.words.firstWhereOrNull((e) => e.id == wordId); if (word == null) { - return false; + throw CrosswordRepositoryException( + 'Word with id $wordId not found for section ($sectionX, $sectionY)', + StackTrace.current, + ); } - if (answer == word.answer) { + final correctAnswer = await findAnswerById(wordId); + + if (userAnswer.toLowerCase() == correctAnswer?.toLowerCase()) { final solvedWord = word.copyWith( + answer: correctAnswer, solvedTimestamp: clock.now().millisecondsSinceEpoch, mascot: mascot, ); @@ -97,4 +117,28 @@ class CrosswordRepository { } return false; } + + /// Adds one to the solved words count in the crossword. + Future updateSolvedWordsCount() async { + final snapshot = await _dbClient.find( + _boardInfoCollection, + { + 'type': 'solved_words_count', + }, + ); + + final document = snapshot.first; + final solvedWordsCount = document.data['value'] as int; + final newValue = solvedWordsCount + 1; + + await _dbClient.update( + _boardInfoCollection, + DbEntityRecord( + id: document.id, + data: { + 'value': newValue, + }, + ), + ); + } } diff --git a/api/packages/crossword_repository/lib/src/crossword_repository_exception.dart b/api/packages/crossword_repository/lib/src/crossword_repository_exception.dart new file mode 100644 index 000000000..3de21b7e9 --- /dev/null +++ b/api/packages/crossword_repository/lib/src/crossword_repository_exception.dart @@ -0,0 +1,23 @@ +import 'package:crossword_repository/crossword_repository.dart'; + +/// {@template crossword_repository_exception} +/// Exception thrown when an error occurs in the [CrosswordRepository]. +/// {@endtemplate} +class CrosswordRepositoryException implements Exception { + /// {@macro crossword_repository_exception} + CrosswordRepositoryException(this.cause, this.stackTrace); + + /// Error cause. + final dynamic cause; + + /// The stack trace of the error. + final StackTrace stackTrace; + + @override + String toString() { + return ''' +cause: $cause +stackTrace: $stackTrace +'''; + } +} diff --git a/api/packages/crossword_repository/test/src/crossword_repository_exception_test.dart b/api/packages/crossword_repository/test/src/crossword_repository_exception_test.dart new file mode 100644 index 000000000..bc3828f44 --- /dev/null +++ b/api/packages/crossword_repository/test/src/crossword_repository_exception_test.dart @@ -0,0 +1,21 @@ +import 'package:crossword_repository/crossword_repository.dart'; +import 'package:test/test.dart'; + +void main() { + group('CrosswordRepositoryException', () { + test('can be converted to a string', () { + final exception = CrosswordRepositoryException( + 'something is broken', + StackTrace.fromString('it happened here'), + ); + + expect( + exception.toString(), + equals(''' +cause: something is broken +stackTrace: it happened here +'''), + ); + }); + }); +} diff --git a/api/packages/crossword_repository/test/src/crossword_repository_test.dart b/api/packages/crossword_repository/test/src/crossword_repository_test.dart index 63948d3cb..356016537 100644 --- a/api/packages/crossword_repository/test/src/crossword_repository_test.dart +++ b/api/packages/crossword_repository/test/src/crossword_repository_test.dart @@ -1,4 +1,6 @@ // ignore_for_file: prefer_const_constructors +// ignore_for_file: prefer_const_literals_to_create_immutables + import 'package:clock/clock.dart'; import 'package:crossword_repository/crossword_repository.dart'; import 'package:db_client/db_client.dart'; @@ -15,6 +17,7 @@ void main() { late DbClient dbClient; const sectionsCollection = 'boardChunks'; + const answersCollection = 'answers'; setUpAll(() { registerFallbackValue(_MockDbEntityRecord()); @@ -160,10 +163,7 @@ void main() { 'position': {'x': 1, 'y': 1}, 'size': 300, 'words': [ - { - 'id': '1', - ...word.toJson(), - }, + {'id': '1', ...word.toJson()}, ], 'borderWords': const [], }, @@ -171,27 +171,33 @@ void main() { when( () => dbClient.find( sectionsCollection, - { - 'position.x': 1, - 'position.y': 1, - }, + {'position.x': 1, 'position.y': 1}, ), ).thenAnswer((_) async => [record]); + when( () => dbClient.update( sectionsCollection, any(that: isA()), ), ).thenAnswer((_) async {}); + + final answersRecord = _MockDbEntityRecord(); + when(() => answersRecord.id).thenReturn('id1'); + when(() => answersRecord.data).thenReturn({'answer': 'flutter'}); + when( + () => dbClient.getById(answersCollection, 'id1'), + ).thenAnswer((_) async => answersRecord); + repository = CrosswordRepository(dbClient: dbClient); }); - test('answerWord returns true if answer is correct', () async { + test('returns true if answer is correct', () async { final time = DateTime.now(); final clock = Clock.fixed(time); await withClock(clock, () async { final valid = - await repository.answerWord(1, 1, 1, 1, Mascots.dino, 'flutter'); + await repository.answerWord(1, 1, '1', Mascots.dino, 'flutter'); expect(valid, isTrue); final captured = verify( () => dbClient.update( @@ -221,32 +227,76 @@ void main() { }); }); - test('answerWord returns false if answer is incorrect', () async { + test('returns false if answer is incorrect', () async { final valid = - await repository.answerWord(1, 1, 1, 1, Mascots.dino, 'android'); + await repository.answerWord(1, 1, '1', Mascots.dino, 'android'); expect(valid, isFalse); }); - test('answerWord returns false if section does not exist', () async { + test( + 'throws $CrosswordRepositoryException if section does not exist', + () async { + when( + () => dbClient.find( + sectionsCollection, + {'position.x': 0, 'position.y': 0}, + ), + ).thenAnswer((_) async => []); + + expect( + () => repository.answerWord(0, 0, '1', Mascots.dino, 'flutter'), + throwsA(isA()), + ); + }, + ); + + test( + 'throws $CrosswordRepositoryException if word is not in section', + () async { + expect( + () => repository.answerWord(1, 1, 'fake', Mascots.dino, 'flutter'), + throwsA(isA()), + ); + }, + ); + }); + + group('updateSolvedWordsCount', () { + late CrosswordRepository repository; + + setUp(() { + repository = CrosswordRepository(dbClient: dbClient); + when( - () => dbClient.find( + () => dbClient.update( sectionsCollection, - { - 'position.x': 0, - 'position.y': 0, - }, + any(), ), - ).thenAnswer((_) async => []); - - final valid = - await repository.answerWord(0, 0, 1, 1, Mascots.dino, 'flutter'); - expect(valid, isFalse); + ).thenAnswer((_) async {}); }); - test('answerWord returns false if word is not in section', () async { - final valid = - await repository.answerWord(1, 1, -1, -1, Mascots.dino, 'flutter'); - expect(valid, isFalse); + test('updates the document in the database', () async { + final record = _MockDbEntityRecord(); + when(() => record.id).thenReturn('id'); + when(() => record.data).thenReturn({'value': 80}); + when( + () => dbClient.find('boardInfo', {'type': 'solved_words_count'}), + ).thenAnswer((_) async => [record]); + when( + () => dbClient.update('boardInfo', any()), + ).thenAnswer((_) async {}); + + await repository.updateSolvedWordsCount(); + + verify( + () => dbClient.update( + 'boardInfo', + DbEntityRecord( + id: 'id', + data: {'value': 81}, + ), + ), + ).called(1); }); }); }); diff --git a/api/packages/leaderboard_repository/lib/src/leaderboard_repository.dart b/api/packages/leaderboard_repository/lib/src/leaderboard_repository.dart index 39f40089d..c11cf8516 100644 --- a/api/packages/leaderboard_repository/lib/src/leaderboard_repository.dart +++ b/api/packages/leaderboard_repository/lib/src/leaderboard_repository.dart @@ -58,11 +58,11 @@ class LeaderboardRepository { } /// Updates the score for the provided user when it solves one word. - Future updateScore(String userId) async { + Future updateScore(String userId) async { final playerData = await _dbClient.getById(_playersCollection, userId); if (playerData == null) { - return; + return 0; } final player = Player.fromJson({ @@ -70,20 +70,23 @@ class LeaderboardRepository { ...playerData.data, }); - final updatedPlayerData = increaseScore(player); + final points = getPointsForCorrectAnswer(player); + final updatedPlayerData = increaseScore(player, points); - return _dbClient.set( + await _dbClient.set( _playersCollection, DbEntityRecord( id: updatedPlayerData.id, data: updatedPlayerData.toJson(), ), ); + + return points; } - /// Increases the score for the provided score card. + /// Calculates the points for the provided player when it solves a word. @visibleForTesting - Player increaseScore(Player player) { + int getPointsForCorrectAnswer(Player player) { final streak = player.streak; // Streak multiplier would be 1 for the first answer, 2 for the second, @@ -94,8 +97,14 @@ class LeaderboardRepository { const pointsPerWord = 10; final points = streakMultiplier * pointsPerWord; + return points.round(); + } + + /// Increases the score for the provided player. + @visibleForTesting + Player increaseScore(Player player, int points) { final updatedPlayerData = player.copyWith( - score: player.score + points.round(), + score: player.score + points, streak: player.streak + 1, ); @@ -104,17 +113,12 @@ class LeaderboardRepository { /// Resets the streak for the provided user. Future resetStreak(String userId) async { - final playerData = await _dbClient.getById(_playersCollection, userId); + final player = await getPlayer(userId); - if (playerData == null) { + if (player == null) { return; } - final player = Player.fromJson({ - 'id': userId, - ...playerData.data, - }); - final updatedPlayerData = player.copyWith(streak: 0); return _dbClient.set( @@ -125,4 +129,18 @@ class LeaderboardRepository { ), ); } + + /// Retrieves the player for the provided user. + Future getPlayer(String userId) async { + final playerData = await _dbClient.getById(_playersCollection, userId); + + if (playerData == null) { + return null; + } + + return Player.fromJson({ + 'id': userId, + ...playerData.data, + }); + } } diff --git a/api/packages/leaderboard_repository/test/src/leaderboard_repository_test.dart b/api/packages/leaderboard_repository/test/src/leaderboard_repository_test.dart index 941eb3399..7b93500e6 100644 --- a/api/packages/leaderboard_repository/test/src/leaderboard_repository_test.dart +++ b/api/packages/leaderboard_repository/test/src/leaderboard_repository_test.dart @@ -113,6 +113,22 @@ void main() { }); }); + group('getPointsForCorrectAnswer', () { + test('calculates the points correctly', () async { + final points = leaderboardRepository.getPointsForCorrectAnswer( + Player( + id: 'userId', + score: 10, + streak: 1, + mascot: Mascots.dash, + initials: 'ABC', + ), + ); + + expect(points, equals(20)); + }); + }); + group('increaseScore', () { test('updates the score correctly', () async { final newScoreCard = leaderboardRepository.increaseScore( @@ -123,9 +139,10 @@ void main() { mascot: Mascots.dash, initials: 'ABC', ), + 30, ); - expect(newScoreCard.score, equals(40)); + expect(newScoreCard.score, equals(50)); expect(newScoreCard.streak, equals(2)); }); }); @@ -165,5 +182,48 @@ void main() { ).called(1); }); }); + + group('getPlayer', () { + test('retrieves the player correctly', () async { + when( + () => dbClient.getById('players', 'userId'), + ).thenAnswer((_) async { + return DbEntityRecord( + id: 'userId', + data: { + 'score': 20, + 'streak': 3, + 'mascot': 'dash', + 'initials': 'ABC', + }, + ); + }); + + final player = await leaderboardRepository.getPlayer('userId'); + + expect( + player, + equals( + Player( + id: 'userId', + score: 20, + streak: 3, + mascot: Mascots.dash, + initials: 'ABC', + ), + ), + ); + }); + + test('returns null when the player does not exist', () async { + when( + () => dbClient.getById('players', 'userId'), + ).thenAnswer((_) async => null); + + final player = await leaderboardRepository.getPlayer('userId'); + + expect(player, isNull); + }); + }); }); } diff --git a/api/routes/game/answer.dart b/api/routes/game/answer.dart index 3e5e809e7..d3596debd 100644 --- a/api/routes/game/answer.dart +++ b/api/routes/game/answer.dart @@ -1,10 +1,8 @@ import 'dart:io'; import 'package:api/extensions/path_param_to_position.dart'; -import 'package:collection/collection.dart'; import 'package:crossword_repository/crossword_repository.dart'; import 'package:dart_frog/dart_frog.dart'; -import 'package:game_domain/game_domain.dart'; import 'package:jwt_middleware/jwt_middleware.dart'; import 'package:leaderboard_repository/leaderboard_repository.dart'; @@ -23,16 +21,10 @@ Future _onPost(RequestContext context) async { final json = await context.request.json() as Map; final sectionId = json['sectionId'] as String?; - final wordPosition = json['wordPosition'] as String?; - final mascotName = json['mascot'] as String?; + final wordId = json['wordId'] as String?; final answer = json['answer'] as String?; - final mascot = Mascots.values.firstWhereOrNull((e) => e.name == mascotName); - - if (sectionId == null || - wordPosition == null || - mascot == null || - answer == null) { + if (sectionId == null || wordId == null || answer == null) { return Response(statusCode: HttpStatus.badRequest); } @@ -44,26 +36,32 @@ Future _onPost(RequestContext context) async { return Response(statusCode: HttpStatus.badRequest); } - final posWord = wordPosition.parseToPosition(); - final wordX = posWord?.$1; - final wordY = posWord?.$2; + final player = await leaderboardRepository.getPlayer(user.id); - if (wordX == null || wordY == null) { + if (player == null) { return Response(statusCode: HttpStatus.badRequest); } - final valid = await crosswordRepository.answerWord( - sectionX, - sectionY, - wordX, - wordY, - mascot, - answer, - ); + try { + final valid = await crosswordRepository.answerWord( + sectionX, + sectionY, + wordId, + player.mascot, + answer, + ); - if (valid) { - await leaderboardRepository.updateScore(user.id); - } + var points = 0; + if (valid) { + await crosswordRepository.updateSolvedWordsCount(); + points = await leaderboardRepository.updateScore(user.id); + } - return Response.json(body: {'valid': valid}); + return Response.json(body: {'points': points}); + } catch (e) { + return Response( + body: e.toString(), + statusCode: HttpStatus.internalServerError, + ); + } } diff --git a/api/test/routes/game/answer_test.dart b/api/test/routes/game/answer_test.dart index b8eaf2d29..2e7892eb3 100644 --- a/api/test/routes/game/answer_test.dart +++ b/api/test/routes/game/answer_test.dart @@ -70,18 +70,27 @@ void main() { 'returns Response with valid to true and updates score ' 'if answer is correct', () async { + when(() => leaderboardRepository.getPlayer('userId')).thenAnswer( + (_) async => Player( + id: 'userId', + mascot: Mascots.dash, + initials: 'ABC', + ), + ); + when( + () => crosswordRepository.updateSolvedWordsCount(), + ).thenAnswer((_) async {}); when( () => - crosswordRepository.answerWord(1, 1, 1, 1, Mascots.dash, 'sun'), + crosswordRepository.answerWord(1, 1, 'id', Mascots.dash, 'sun'), ).thenAnswer((_) async => true); when( () => leaderboardRepository.updateScore(user.id), - ).thenAnswer((_) async {}); + ).thenAnswer((_) async => 10); when(() => request.json()).thenAnswer( (_) async => { 'sectionId': '1,1', - 'wordPosition': '1,1', - 'mascot': 'dash', + 'wordId': 'id', 'answer': 'sun', }, ); @@ -89,24 +98,30 @@ void main() { final response = await route.onRequest(requestContext); expect(response.statusCode, HttpStatus.ok); - expect(await response.json(), equals({'valid': true})); + expect(await response.json(), equals({'points': 10})); verify(() => leaderboardRepository.updateScore(user.id)).called(1); }, ); test( - 'returns Response with valid to false and does not update score ' + 'returns Response with 0 points and does not update score ' 'if answer is incorrect', () async { + when(() => leaderboardRepository.getPlayer('userId')).thenAnswer( + (_) async => Player( + id: 'userId', + mascot: Mascots.dash, + initials: 'ABC', + ), + ); when( () => - crosswordRepository.answerWord(1, 1, 1, 1, Mascots.dash, 'sun'), + crosswordRepository.answerWord(1, 1, 'id', Mascots.dash, 'sun'), ).thenAnswer((_) async => false); when(() => request.json()).thenAnswer( (_) async => { 'sectionId': '1,1', - 'wordPosition': '1,1', - 'mascot': 'dash', + 'wordId': 'id', 'answer': 'sun', }, ); @@ -114,7 +129,7 @@ void main() { final response = await route.onRequest(requestContext); expect(response.statusCode, HttpStatus.ok); - expect(await response.json(), equals({'valid': false})); + expect(await response.json(), equals({'points': 0})); verifyNever(() => leaderboardRepository.updateScore(user.id)); }, ); @@ -125,9 +140,8 @@ void main() { when(() => request.json()).thenAnswer( (_) async => { 'sectionId': '00', - 'wordPosition': '1,1', - 'mascot': 'dash', - 'answer': 'android', + 'wordId': 'id', + 'answer': 'sun', }, ); @@ -137,16 +151,18 @@ void main() { ); test( - 'returns Response with status BadRequest if word position is invalid', + 'returns Response with status BadRequest if player does not exist', () async { when(() => request.json()).thenAnswer( (_) async => { 'sectionId': '1,1', - 'wordPosition': '12', - 'mascot': 'dash', - 'answer': 'android', + 'wordId': 'id', + 'answer': 'sun', }, ); + when(() => leaderboardRepository.getPlayer('userId')).thenAnswer( + (_) async => null, + ); final response = await route.onRequest(requestContext); expect(response.statusCode, HttpStatus.badRequest); @@ -158,8 +174,7 @@ void main() { () async { when(() => request.json()).thenAnswer( (_) async => { - 'wordPosition': '12', - 'mascot': 'dash', + 'wordId': 'id', 'answer': 'android', }, ); @@ -170,13 +185,11 @@ void main() { ); test( - 'returns Response with status BadRequest if wordPosition ' - 'is not provided', + 'returns Response with status BadRequest if wordId is not provided', () async { when(() => request.json()).thenAnswer( (_) async => { 'sectionId': '1,1', - 'mascot': 'dash', 'answer': 'android', }, ); @@ -187,13 +200,12 @@ void main() { ); test( - 'returns Response with status BadRequest if mascot is not provided', + 'returns Response with status BadRequest if answer is not provided', () async { when(() => request.json()).thenAnswer( (_) async => { 'sectionId': '1,1', - 'wordPosition': '1,1', - 'answer': 'android', + 'wordId': 'id', }, ); @@ -203,18 +215,29 @@ void main() { ); test( - 'returns Response with status BadRequest if answer is not provided', + 'returns Response with status internalServerError if answerWord throws', () async { when(() => request.json()).thenAnswer( (_) async => { 'sectionId': '1,1', - 'wordPosition': '1,1', - 'mascot': 'dash', + 'wordId': 'id', + 'answer': 'sun', }, ); + when(() => leaderboardRepository.getPlayer('userId')).thenAnswer( + (_) async => Player( + id: 'userId', + mascot: Mascots.dash, + initials: 'ABC', + ), + ); + when( + () => + crosswordRepository.answerWord(1, 1, 'id', Mascots.dash, 'sun'), + ).thenThrow(Exception('Oops')); final response = await route.onRequest(requestContext); - expect(response.statusCode, HttpStatus.badRequest); + expect(response.statusCode, HttpStatus.internalServerError); }, ); }); diff --git a/lib/crossword/view/crossword_page.dart b/lib/crossword/view/crossword_page.dart index 57e1b19a3..004c3eeca 100644 --- a/lib/crossword/view/crossword_page.dart +++ b/lib/crossword/view/crossword_page.dart @@ -1,3 +1,4 @@ +import 'package:api_client/api_client.dart'; import 'package:flame/game.dart' hide Route; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -26,7 +27,9 @@ class CrosswordPage extends StatelessWidget { ..add(const BoardLoadingInformationRequested()); return BlocProvider( - create: (_) => WordSelectionBloc(), + create: (_) => WordSelectionBloc( + crosswordResource: context.read(), + ), lazy: false, child: const CrosswordView(), ); diff --git a/lib/word_selection/bloc/word_selection_bloc.dart b/lib/word_selection/bloc/word_selection_bloc.dart index 59a8e671d..e42d268f0 100644 --- a/lib/word_selection/bloc/word_selection_bloc.dart +++ b/lib/word_selection/bloc/word_selection_bloc.dart @@ -1,3 +1,4 @@ +import 'package:api_client/api_client.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; import 'package:game_domain/game_domain.dart'; @@ -6,13 +7,18 @@ part 'word_selection_event.dart'; part 'word_selection_state.dart'; class WordSelectionBloc extends Bloc { - WordSelectionBloc() : super(const WordSelectionState.initial()) { + WordSelectionBloc({ + required CrosswordResource crosswordResource, + }) : _crosswordResource = crosswordResource, + super(const WordSelectionState.initial()) { on(_onWordSelected); on(_onWordUnselected); on(_onWordSolveRequested); on(_onWordAttemptRequested); } + final CrosswordResource _crosswordResource; + void _onWordSelected( WordSelected event, Emitter emit, @@ -65,14 +71,13 @@ class WordSelectionBloc extends Bloc { state.copyWith(status: WordSelectionStatus.validating), ); - final isCorrect = await Future.delayed( - const Duration(seconds: 1), - // TODO(alesstiago): Replace with a call to the backend that validates - // the answer. - // https://very-good-ventures-team.monday.com/boards/6004820050/pulses/6444661142 - () => event.answer.toLowerCase() == 'correct', + final points = await _crosswordResource.answerWord( + section: state.word!.section, + word: state.word!.word, + answer: event.answer, ); + final isCorrect = points > 0; if (isCorrect) { emit( state.copyWith( @@ -80,7 +85,7 @@ class WordSelectionBloc extends Bloc { word: state.word!.copyWith( word: state.word!.word.copyWith(answer: event.answer), ), - wordPoints: 10, + wordPoints: points, ), ); } else { diff --git a/packages/api_client/lib/src/resources/crossword_resource.dart b/packages/api_client/lib/src/resources/crossword_resource.dart index 001375f63..64fe97dbf 100644 --- a/packages/api_client/lib/src/resources/crossword_resource.dart +++ b/packages/api_client/lib/src/resources/crossword_resource.dart @@ -18,19 +18,17 @@ class CrosswordResource { /// Post /game/answer /// /// Returns a [bool]. - Future answerWord({ - required BoardSection section, + Future answerWord({ + required (int, int) section, required Word word, required String answer, - required Mascots mascot, }) async { final response = await _apiClient.post( '/game/answer', body: jsonEncode({ - 'sectionId': '${section.position.x},${section.position.y}', - 'wordPosition': '${word.position.x},${word.position.y}', + 'sectionId': '${section.$1},${section.$2}', + 'wordId': word.id, 'answer': answer, - 'mascot': mascot.name, }), ); @@ -45,8 +43,8 @@ class CrosswordResource { try { final body = jsonDecode(response.body) as Map; - final isValidAnswer = body['valid'] as bool; - return isValidAnswer; + final points = body['points'] as int; + return points; } catch (error, stackTrace) { throw ApiClientError( 'POST /game/answer' diff --git a/packages/api_client/test/src/resources/crossword_resource_test.dart b/packages/api_client/test/src/resources/crossword_resource_test.dart index 33681976c..5f264b792 100644 --- a/packages/api_client/test/src/resources/crossword_resource_test.dart +++ b/packages/api_client/test/src/resources/crossword_resource_test.dart @@ -13,14 +13,9 @@ class _MockApiClient extends Mock implements ApiClient {} class _MockResponse extends Mock implements http.Response {} -class _FakeBoardSection extends Fake implements BoardSection { - @override - Point get position => Point(0, 0); -} - class _FakeWord extends Fake implements Word { @override - Point get position => Point(0, 0); + String get id => 'wordId'; } void main() { @@ -45,58 +40,52 @@ void main() { test('calls correct api endpoint', () async { when(() => response.statusCode).thenReturn(HttpStatus.ok); - when(() => response.body).thenReturn( - jsonEncode({'valid': true}), - ); + when(() => response.body).thenReturn(jsonEncode({'points': 10})); await resource.answerWord( - section: _FakeBoardSection(), + section: (1, 1), word: _FakeWord(), answer: 'correctAnswer', - mascot: Mascots.android, ); verify( () => apiClient.post( '/game/answer', body: jsonEncode({ - 'sectionId': '0,0', - 'wordPosition': '0,0', + 'sectionId': '1,1', + 'wordId': 'wordId', 'answer': 'correctAnswer', - 'mascot': 'android', }), ), ).called(1); }); - test('returns true when succeeds with correct answer', () async { + test('returns the points when succeeds with correct answer', () async { when(() => response.statusCode).thenReturn(HttpStatus.ok); when(() => response.body).thenReturn( - jsonEncode({'valid': true}), + jsonEncode({'points': 10}), ); final result = await resource.answerWord( - section: _FakeBoardSection(), + section: (1, 1), word: _FakeWord(), answer: 'correctAnswer', - mascot: Mascots.android, ); - expect(result, isTrue); + expect(result, 10); }); - test('returns false when succeeds with incorrect answer', () async { + test('returns 0 points when succeeds with incorrect answer', () async { when(() => response.statusCode).thenReturn(HttpStatus.ok); when(() => response.body).thenReturn( - jsonEncode({'valid': false}), + jsonEncode({'points': 0}), ); final result = await resource.answerWord( - section: _FakeBoardSection(), + section: (1, 1), word: _FakeWord(), answer: 'incorrectAnswer', - mascot: Mascots.android, ); - expect(result, isFalse); + expect(result, 0); }); test('throws ApiClientError when request fails', () async { @@ -106,10 +95,9 @@ void main() { await expectLater( resource.answerWord( - section: _FakeBoardSection(), + section: (1, 1), word: _FakeWord(), answer: 'incorrectAnswer', - mascot: Mascots.android, ), throwsA( isA().having( @@ -122,16 +110,16 @@ void main() { ), ); }); + test('throws ApiClientError when response is invalid', () async { when(() => response.statusCode).thenReturn(HttpStatus.ok); when(() => response.body).thenReturn('Oops'); await expectLater( resource.answerWord( - section: _FakeBoardSection(), + section: (1, 1), word: _FakeWord(), answer: 'incorrectAnswer', - mascot: Mascots.android, ), throwsA( isA().having( diff --git a/packages/board_generator/lib/create_sections.dart b/packages/board_generator/lib/create_sections.dart index f7012eaa5..6a096aa49 100644 --- a/packages/board_generator/lib/create_sections.dart +++ b/packages/board_generator/lib/create_sections.dart @@ -46,7 +46,7 @@ void main(List args) async { final answers = {}; for (final (i, row) in rows.indexed) { - final id = '${i + 1}'; + final id = 'id${i + 1}'; final answer = row[2] as String; answers[id] = answer; words.add( diff --git a/packages/board_generator/lib/src/crossword_repository.dart b/packages/board_generator/lib/src/crossword_repository.dart index 28372fbe5..1c12b5c92 100644 --- a/packages/board_generator/lib/src/crossword_repository.dart +++ b/packages/board_generator/lib/src/crossword_repository.dart @@ -14,20 +14,18 @@ class CrosswordRepository { /// Adds a map of word id: answer to the database. Future addAnswers(Map answers) async { - final answersCollection = firestore.collection('answers'); + const size = 1000; + final maps = answers.entries.slices(size); + + await Future.wait(maps.map(_addAnswers)); + } - // Firestore has a limit that prevents having a document with more than - // 20000 answers. - const size = 20000; - for (final subset in answers.entries.slices(size)) { - final map = subset.fold>( - {}, - (previousValue, element) { - previousValue[element.key] = element.value; - return previousValue; - }, - ); - await answersCollection.add(map); + Future _addAnswers(List> map) async { + final answersCollection = firestore.collection('answers'); + for (final entry in map) { + await answersCollection.doc(entry.key).set({ + 'answer': entry.value, + }); } } diff --git a/test/word_focused/bloc/word_selection_bloc_test.dart b/test/word_focused/bloc/word_selection_bloc_test.dart index ac117428d..ee38c5ff4 100644 --- a/test/word_focused/bloc/word_selection_bloc_test.dart +++ b/test/word_focused/bloc/word_selection_bloc_test.dart @@ -1,5 +1,6 @@ // ignore_for_file: prefer_const_constructors +import 'package:api_client/api_client.dart'; import 'package:bloc_test/bloc_test.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:game_domain/game_domain.dart'; @@ -9,11 +10,15 @@ import 'package:mocktail/mocktail.dart'; class _MockWord extends Mock implements Word {} +class _MockCrosswordResource extends Mock implements CrosswordResource {} + void main() { group('$WordSelectionBloc', () { + late CrosswordResource crosswordResource; late SelectedWord selectedWord; setUp(() { + crosswordResource = _MockCrosswordResource(); selectedWord = SelectedWord( section: (0, 0), word: _MockWord(), @@ -21,14 +26,16 @@ void main() { }); test('initial state is WordSelectionState.initial', () { - final bloc = WordSelectionBloc(); + final bloc = WordSelectionBloc( + crosswordResource: crosswordResource, + ); expect(bloc.state, equals(WordSelectionState.initial())); }); group('$WordSelected', () { blocTest( 'emits preSolving status', - build: WordSelectionBloc.new, + build: () => WordSelectionBloc(crosswordResource: crosswordResource), act: (bloc) => bloc.add(WordSelected(selectedWord: selectedWord)), expect: () => [ WordSelectionState( @@ -42,7 +49,7 @@ void main() { group('$WordUnselected', () { blocTest( 'emits initial state', - build: WordSelectionBloc.new, + build: () => WordSelectionBloc(crosswordResource: crosswordResource), seed: () => WordSelectionState( status: WordSelectionStatus.preSolving, word: selectedWord, @@ -55,7 +62,7 @@ void main() { group('$WordSolveRequested', () { blocTest( 'does nothing if there is no word identifier', - build: WordSelectionBloc.new, + build: () => WordSelectionBloc(crosswordResource: crosswordResource), act: (bloc) => bloc.add( WordSolveRequested(), ), @@ -64,7 +71,7 @@ void main() { blocTest( 'emits solving status when there is a word identifier', - build: WordSelectionBloc.new, + build: () => WordSelectionBloc(crosswordResource: crosswordResource), seed: () => WordSelectionState( status: WordSelectionStatus.preSolving, word: selectedWord, @@ -86,14 +93,14 @@ void main() { blocTest( 'does nothing if not solving', - build: WordSelectionBloc.new, + build: () => WordSelectionBloc(crosswordResource: crosswordResource), act: (bloc) => bloc.add(WordSolveAttempted(answer: 'answer')), expect: () => [], ); blocTest( 'does nothing if already solved', - build: WordSelectionBloc.new, + build: () => WordSelectionBloc(crosswordResource: crosswordResource), seed: () => WordSelectionState( status: WordSelectionStatus.solved, word: selectedWord, @@ -104,17 +111,23 @@ void main() { blocTest( 'validates a valid answer', - build: WordSelectionBloc.new, + build: () => WordSelectionBloc(crosswordResource: crosswordResource), setUp: () { answerWord = _MockWord(); when(() => selectedWord.word.copyWith(answer: 'correct')) .thenReturn(answerWord); + when( + () => crosswordResource.answerWord( + section: selectedWord.section, + word: selectedWord.word, + answer: 'correct', + ), + ).thenAnswer((_) async => 10); }, seed: () => WordSelectionState( status: WordSelectionStatus.solving, word: selectedWord, ), - wait: Duration(seconds: 2), act: (bloc) => bloc.add(WordSolveAttempted(answer: 'correct')), expect: () => [ WordSelectionState( @@ -134,12 +147,20 @@ void main() { blocTest( 'invalidates an invalid answer', - build: WordSelectionBloc.new, + build: () => WordSelectionBloc(crosswordResource: crosswordResource), + setUp: () { + when( + () => crosswordResource.answerWord( + section: selectedWord.section, + word: selectedWord.word, + answer: 'incorrect', + ), + ).thenAnswer((_) async => 0); + }, seed: () => WordSelectionState( status: WordSelectionStatus.solving, word: selectedWord, ), - wait: Duration(seconds: 2), act: (bloc) => bloc.add(WordSolveAttempted(answer: 'incorrect')), expect: () => [ WordSelectionState(