diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index c2ea39e6c..7f2895c70 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -32,13 +32,12 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) { - final crosswordResource = apiClient.crosswordResource; - return MultiProvider( providers: [ + Provider.value(value: apiClient.crosswordResource), Provider.value(value: apiClient.leaderboardResource), + Provider.value(value: apiClient.hintResource), Provider.value(value: user), - Provider.value(value: crosswordResource), Provider.value(value: crosswordRepository), Provider.value(value: boardInfoRepository), Provider.value(value: leaderboardRepository), diff --git a/lib/hint/bloc/hint_bloc.dart b/lib/hint/bloc/hint_bloc.dart index a2a9e7926..962c86c5a 100644 --- a/lib/hint/bloc/hint_bloc.dart +++ b/lib/hint/bloc/hint_bloc.dart @@ -1,16 +1,24 @@ +import 'package:api_client/api_client.dart'; import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:game_domain/game_domain.dart'; part 'hint_event.dart'; part 'hint_state.dart'; class HintBloc extends Bloc { - HintBloc() : super(const HintState()) { + HintBloc({ + required HintResource hintResource, + }) : _hintResource = hintResource, + super(const HintState()) { on(_onHintModeEntered); on(_onHintModeExited); on(_onHintRequested); + on(_onPreviousHintsRequested); } + final HintResource _hintResource; + void _onHintModeEntered( HintModeEntered event, Emitter emit, @@ -22,7 +30,7 @@ class HintBloc extends Bloc { HintModeExited event, Emitter emit, ) { - emit(const HintState()); + emit(state.copyWith(status: HintStatus.initial)); } Future _onHintRequested( @@ -31,9 +39,27 @@ class HintBloc extends Bloc { ) async { emit(state.copyWith(status: HintStatus.thinking)); - // Simulate a delay in retrieving the hint. - await Future.delayed(const Duration(seconds: 1), () {}); + final hint = await _hintResource.generateHint( + wordId: event.wordId, + question: event.question, + ); + final allHints = [...state.hints, hint]; + + emit( + state.copyWith( + status: HintStatus.answered, + hints: allHints, + ), + ); + } - emit(state.copyWith(status: HintStatus.answered)); + Future _onPreviousHintsRequested( + PreviousHintsRequested event, + Emitter emit, + ) async { + if (state.hints.isEmpty) { + final hints = await _hintResource.getHints(wordId: event.wordId); + emit(state.copyWith(hints: hints)); + } } } diff --git a/lib/hint/bloc/hint_event.dart b/lib/hint/bloc/hint_event.dart index 7463c8276..4be4e3c0b 100644 --- a/lib/hint/bloc/hint_event.dart +++ b/lib/hint/bloc/hint_event.dart @@ -19,10 +19,23 @@ class HintModeExited extends HintEvent { } class HintRequested extends HintEvent { - const HintRequested(this.message); + const HintRequested({ + required this.wordId, + required this.question, + }); - final String message; + final String wordId; + final String question; @override - List get props => [message]; + List get props => [wordId, question]; +} + +class PreviousHintsRequested extends HintEvent { + const PreviousHintsRequested(this.wordId); + + final String wordId; + + @override + List get props => [wordId]; } diff --git a/lib/hint/bloc/hint_state.dart b/lib/hint/bloc/hint_state.dart index 7f5494a24..6923967a0 100644 --- a/lib/hint/bloc/hint_state.dart +++ b/lib/hint/bloc/hint_state.dart @@ -22,15 +22,19 @@ enum HintStatus { class HintState extends Equatable { const HintState({ this.status = HintStatus.initial, + this.hints = const [], }); final HintStatus status; + final List hints; HintState copyWith({ HintStatus? status, + List? hints, }) { return HintState( status: status ?? this.status, + hints: hints ?? this.hints, ); } @@ -40,5 +44,5 @@ class HintState extends Equatable { status == HintStatus.invalid; @override - List get props => [status]; + List get props => [status, hints]; } diff --git a/lib/hint/extensions/extensions.dart b/lib/hint/extensions/extensions.dart new file mode 100644 index 000000000..0beafd96c --- /dev/null +++ b/lib/hint/extensions/extensions.dart @@ -0,0 +1 @@ +export 'hint_response_extension.dart'; diff --git a/lib/hint/extensions/hint_response_extension.dart b/lib/hint/extensions/hint_response_extension.dart new file mode 100644 index 000000000..49b00d995 --- /dev/null +++ b/lib/hint/extensions/hint_response_extension.dart @@ -0,0 +1,35 @@ +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:game_domain/game_domain.dart'; + +extension HintResponseExtension on HintResponse { + String get readable { + final random = Random(); + + switch (this) { + case HintResponse.yes: + return yesResponses[random.nextInt(yesResponses.length)]; + case HintResponse.no: + return noResponses[random.nextInt(noResponses.length)]; + case HintResponse.notApplicable: + return notApplicableResponses[ + random.nextInt(notApplicableResponses.length)]; + } + } +} + +@visibleForTesting +const yesResponses = [ + 'Yes!', +]; + +@visibleForTesting +const noResponses = [ + 'Nope', +]; + +@visibleForTesting +const notApplicableResponses = [ + 'Try with a "Yes or No" question', +]; diff --git a/lib/hint/hint.dart b/lib/hint/hint.dart index b551c310f..86095c9ad 100644 --- a/lib/hint/hint.dart +++ b/lib/hint/hint.dart @@ -1,2 +1,3 @@ export 'bloc/hint_bloc.dart'; +export 'extensions/extensions.dart'; export 'widgets/widgets.dart'; diff --git a/lib/hint/widgets/gemini_text_field.dart b/lib/hint/widgets/gemini_text_field.dart index f53e06cb8..ac4184b6d 100644 --- a/lib/hint/widgets/gemini_text_field.dart +++ b/lib/hint/widgets/gemini_text_field.dart @@ -2,13 +2,44 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:io_crossword/hint/hint.dart'; import 'package:io_crossword/l10n/l10n.dart'; +import 'package:io_crossword/word_selection/word_selection.dart'; import 'package:io_crossword_ui/io_crossword_ui.dart'; -class GeminiTextField extends StatelessWidget { +class GeminiTextField extends StatefulWidget { const GeminiTextField({ super.key, }); + @override + State createState() => _GeminiTextFieldState(); +} + +class _GeminiTextFieldState extends State { + late TextEditingController _controller; + late FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _controller = TextEditingController(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNode.requestFocus(); + }); + } + + void _onAskForHint(BuildContext context, String question) { + final wordId = context.read().state.word?.word.id; + + if (wordId == null) return; + if (question.isEmpty) return; + + context.read().add( + HintRequested(wordId: wordId, question: question), + ); + } + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -18,6 +49,8 @@ class GeminiTextField extends StatelessWidget { inputDecorationTheme: IoCrosswordTheme.geminiInputDecorationTheme, ), child: TextField( + focusNode: _focusNode, + controller: _controller, decoration: InputDecoration( hintText: l10n.type, prefixIcon: const Padding( @@ -28,16 +61,13 @@ class GeminiTextField extends StatelessWidget { padding: const EdgeInsets.only(right: 8), child: GeminiGradient( child: IconButton( - onPressed: () { - context - .read() - .add(const HintRequested('is it red?')); - }, + onPressed: () => _onAskForHint(context, _controller.text), icon: const Icon(Icons.send), ), ), ), ), + onSubmitted: (_) => _onAskForHint(context, _controller.text), ), ); } diff --git a/lib/hint/widgets/hint_text.dart b/lib/hint/widgets/hint_text.dart index 870a2debe..9d046764b 100644 --- a/lib/hint/widgets/hint_text.dart +++ b/lib/hint/widgets/hint_text.dart @@ -1,21 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:io_crossword/hint/hint.dart'; -import 'package:io_crossword/l10n/l10n.dart'; import 'package:io_crossword_ui/io_crossword_ui.dart'; class HintText extends StatelessWidget { - const HintText({super.key}); + const HintText({required this.text, super.key}); + + final String text; @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; - final l10n = context.l10n; - - final isHintModeActive = - context.select((HintBloc bloc) => bloc.state.isHintModeActive); - final text = - isHintModeActive ? l10n.askYesOrNoQuestion : l10n.askGeminiHint; return Row( mainAxisSize: MainAxisSize.min, diff --git a/lib/hint/widgets/hints_section.dart b/lib/hint/widgets/hints_section.dart new file mode 100644 index 000000000..505056823 --- /dev/null +++ b/lib/hint/widgets/hints_section.dart @@ -0,0 +1,118 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:game_domain/game_domain.dart'; +import 'package:io_crossword/hint/hint.dart'; +import 'package:io_crossword/l10n/l10n.dart'; +import 'package:io_crossword_ui/io_crossword_ui.dart'; + +class HintsSection extends StatelessWidget { + const HintsSection({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + + final hintState = context.watch().state; + final isHintModeActive = hintState.isHintModeActive; + final isThinking = hintState.status == HintStatus.thinking; + final allHints = hintState.hints; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: HintText( + text: + isHintModeActive ? l10n.askYesOrNoQuestion : l10n.askGeminiHint, + ), + ), + const SizedBox(height: 32), + ...allHints.mapIndexed( + (i, hint) => HintQuestionResponse( + index: i, + hint: hint, + ), + ), + if (isThinking) ...[ + const SizedBox(height: 24), + const Center(child: HintLoadingIndicator()), + ], + ], + ); + } +} + +@visibleForTesting +class HintQuestionResponse extends StatelessWidget { + @visibleForTesting + const HintQuestionResponse({ + required this.index, + required this.hint, + super.key, + }); + + final int index; + final Hint hint; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + final questionNumber = index + 1; + + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Q$questionNumber: ${hint.question}', + style: textTheme.bodySmall?.copyWith( + color: IoCrosswordColors.softGray, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + HintText(text: hint.response.readable), + const SizedBox(height: 8), + ], + ); + } +} + +@visibleForTesting +class HintLoadingIndicator extends StatefulWidget { + @visibleForTesting + const HintLoadingIndicator({super.key}); + + @override + State createState() => _HintLoadingIndicatorState(); +} + +class _HintLoadingIndicatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 3), + vsync: this, + )..repeat(); + } + + @override + Widget build(BuildContext context) { + return RotationTransition( + turns: Tween(begin: 0, end: 1).animate(_controller), + child: const GeminiIcon(size: 24), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} diff --git a/lib/hint/widgets/widgets.dart b/lib/hint/widgets/widgets.dart index 50d43c7cd..0bfd5cf3a 100644 --- a/lib/hint/widgets/widgets.dart +++ b/lib/hint/widgets/widgets.dart @@ -2,3 +2,4 @@ export 'close_hint_button.dart'; export 'gemini_hint_button.dart'; export 'gemini_text_field.dart'; export 'hint_text.dart'; +export 'hints_section.dart'; diff --git a/lib/word_selection/view/word_selection_page.dart b/lib/word_selection/view/word_selection_page.dart index 7e0693b3e..7465442ce 100644 --- a/lib/word_selection/view/word_selection_page.dart +++ b/lib/word_selection/view/word_selection_page.dart @@ -1,3 +1,4 @@ +import 'package:api_client/api_client.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:io_crossword/hint/hint.dart'; @@ -12,9 +13,14 @@ class WordSelectionPage extends StatelessWidget { context.select((WordSelectionBloc bloc) => bloc.state.word); if (selectedWord == null) return const SizedBox.shrink(); + final wordId = selectedWord.word.id; + return BlocProvider( lazy: false, - create: (context) => HintBloc(), + key: Key(wordId), + create: (context) => HintBloc( + hintResource: context.read(), + )..add(PreviousHintsRequested(wordId)), child: const WordSelectionView(), ); } diff --git a/lib/word_selection/view/word_solving_view.dart b/lib/word_selection/view/word_solving_view.dart index 2e279e7aa..d3a89b04d 100644 --- a/lib/word_selection/view/word_solving_view.dart +++ b/lib/word_selection/view/word_solving_view.dart @@ -34,16 +34,33 @@ class WordSolvingLargeView extends StatelessWidget { return Column( children: [ const WordSelectionTopBar(), - const SizedBox(height: 8), - const Spacer(), - Text( - selectedWord.word.clue, - style: IoCrosswordTextStyles.titleMD, - textAlign: TextAlign.center, - ), const SizedBox(height: 32), - const HintText(), - const Expanded(child: Center(child: WordValidatingLoadingIndicator())), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + selectedWord.word.clue, + style: IoCrosswordTextStyles.titleMD, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + Flexible( + child: BlocSelector( + selector: (state) => state.status, + builder: (context, status) { + if (status == WordSelectionStatus.validating) { + return const CircularProgressIndicator(); + } + + return const SingleChildScrollView(child: HintsSection()); + }, + ), + ), + ], + ), + ), const SizedBox(height: 8), const BottomPanel(), ], @@ -90,10 +107,18 @@ class _WordSolvingSmallViewState extends State { textAlign: TextAlign.center, ), const SizedBox(height: 32), - const HintText(), - const SizedBox( - height: 200, - child: Center(child: WordValidatingLoadingIndicator()), + Expanded( + child: BlocSelector( + selector: (state) => state.status, + builder: (context, status) { + if (status == WordSelectionStatus.validating) { + return const Center(child: CircularProgressIndicator()); + } + + return const SingleChildScrollView(child: HintsSection()); + }, + ), ), BottomPanel(controller: _controller), ], @@ -101,24 +126,6 @@ class _WordSolvingSmallViewState extends State { } } -@visibleForTesting -class WordValidatingLoadingIndicator extends StatelessWidget { - @visibleForTesting - const WordValidatingLoadingIndicator({super.key}); - - @override - Widget build(BuildContext context) { - final wordSelectionStatus = context.select( - (WordSelectionBloc bloc) => bloc.state.status, - ); - final isValidating = wordSelectionStatus == WordSelectionStatus.validating; - - if (isValidating) return const CircularProgressIndicator(); - - return const SizedBox.shrink(); - } -} - @visibleForTesting class BottomPanel extends StatelessWidget { @visibleForTesting diff --git a/packages/api_client/lib/src/api_client.dart b/packages/api_client/lib/src/api_client.dart index c41b039a2..1bf764566 100644 --- a/packages/api_client/lib/src/api_client.dart +++ b/packages/api_client/lib/src/api_client.dart @@ -59,6 +59,9 @@ class ApiClient { late final CrosswordResource crosswordResource = CrosswordResource(apiClient: this); + /// {@macro hint_resource} + late final HintResource hintResource = HintResource(apiClient: this); + Future _handleUnauthorized( Future Function() sendRequest, ) async { diff --git a/test/app/view/app_test.dart b/test/app/view/app_test.dart index 39c6a4425..1b9104630 100644 --- a/test/app/view/app_test.dart +++ b/test/app/view/app_test.dart @@ -27,6 +27,8 @@ class _MockLeaderboardResource extends Mock implements LeaderboardResource {} class _MockCrosswordResource extends Mock implements CrosswordResource {} +class _MockHintResource extends Mock implements HintResource {} + class _MockPlayerBloc extends Mock implements PlayerBloc { @override Future close() async {} @@ -54,6 +56,7 @@ void main() { .thenReturn(_MockLeaderboardResource()); when(() => apiClient.crosswordResource) .thenReturn(_MockCrosswordResource()); + when(() => apiClient.hintResource).thenReturn(_MockHintResource()); when( () => crosswordRepository.watchSectionFromPosition(0, 0), ).thenAnswer((_) => Stream.value(null)); diff --git a/test/helpers/pump_app.dart b/test/helpers/pump_app.dart index d7620f8fe..abd2e5059 100644 --- a/test/helpers/pump_app.dart +++ b/test/helpers/pump_app.dart @@ -9,6 +9,7 @@ import 'package:crossword_repository/crossword_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:game_domain/game_domain.dart'; import 'package:io_crossword/challenge/challenge.dart'; import 'package:io_crossword/crossword/crossword.dart'; import 'package:io_crossword/l10n/l10n.dart'; @@ -26,6 +27,8 @@ class _MockCrosswordResource extends Mock implements CrosswordResource {} class _MockLeaderboardResource extends Mock implements LeaderboardResource {} +class _MockHintResource extends Mock implements HintResource {} + class _MockLeaderboardRepository extends Mock implements LeaderboardRepository {} @@ -40,10 +43,11 @@ extension PumpApp on WidgetTester { IoLayoutData? layout, User? user, CrosswordRepository? crosswordRepository, - CrosswordResource? crosswordResource, BoardInfoRepository? boardInfoRepository, - LeaderboardResource? leaderboardResource, LeaderboardRepository? leaderboardRepository, + CrosswordResource? crosswordResource, + LeaderboardResource? leaderboardResource, + HintResource? hintResource, CrosswordBloc? crosswordBloc, PlayerBloc? playerBloc, ChallengeBloc? challengeBloc, @@ -85,6 +89,9 @@ extension PumpApp on WidgetTester { Provider.value( value: leaderboardRepository ?? _MockLeaderboardRepository(), ), + Provider.value( + value: hintResource ?? _MockHintResource(), + ), Provider.value( value: user ?? _MockUser(), ), @@ -148,10 +155,11 @@ extension PumpRoute on WidgetTester { Route route, { User? user, CrosswordRepository? crosswordRepository, - CrosswordResource? crosswordResource, BoardInfoRepository? boardInfoRepository, - LeaderboardResource? leaderboardResource, LeaderboardRepository? leaderboardRepository, + CrosswordResource? crosswordResource, + LeaderboardResource? leaderboardResource, + HintResource? hintResource, MockNavigator? navigator, }) async { final widget = Center( @@ -200,6 +208,9 @@ extension PumpRoute on WidgetTester { Provider.value( value: leaderboardRepository ?? _MockLeaderboardRepository(), ), + Provider.value( + value: hintResource ?? _MockHintResource(), + ), Provider.value( value: user ?? _MockUser(), ), diff --git a/test/hint/bloc/hint_bloc_test.dart b/test/hint/bloc/hint_bloc_test.dart index 0fb474ede..1eb402b9f 100644 --- a/test/hint/bloc/hint_bloc_test.dart +++ b/test/hint/bloc/hint_bloc_test.dart @@ -1,13 +1,27 @@ +// ignore_for_file: prefer_const_constructors +// ignore_for_file: prefer_const_literals_to_create_immutables + +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'; import 'package:io_crossword/hint/bloc/hint_bloc.dart'; +import 'package:mocktail/mocktail.dart'; + +class _MockHintResource extends Mock implements HintResource {} void main() { group('$HintBloc', () { + late HintResource hintResource; + + setUp(() { + hintResource = _MockHintResource(); + }); + blocTest( 'emits state with status ${HintStatus.asking} when HintModeEntered ' 'is added', - build: HintBloc.new, + build: () => HintBloc(hintResource: hintResource), act: (bloc) => bloc.add(const HintModeEntered()), expect: () => const [ HintState(status: HintStatus.asking), @@ -17,7 +31,8 @@ void main() { blocTest( 'emits state with status ${HintStatus.initial} when HintModeExited ' 'is added', - build: HintBloc.new, + seed: () => HintState(status: HintStatus.asking), + build: () => HintBloc(hintResource: hintResource), act: (bloc) => bloc.add(const HintModeExited()), expect: () => const [ HintState(), @@ -27,13 +42,84 @@ void main() { blocTest( 'emits state with status ${HintStatus.thinking} immediately and ' '${HintStatus.answered} after when HintRequested is added', - build: HintBloc.new, - act: (bloc) => bloc.add(const HintRequested('is it blue?')), + setUp: () { + when( + () => hintResource.generateHint(wordId: 'id', question: 'blue?'), + ).thenAnswer( + (_) async => Hint(question: 'blue?', response: HintResponse.no), + ); + }, + seed: () => HintState( + status: HintStatus.asking, + hints: [ + Hint(question: 'is it orange?', response: HintResponse.no), + ], + ), + build: () => HintBloc(hintResource: hintResource), + act: (bloc) => bloc.add( + HintRequested(wordId: 'id', question: 'blue?'), + ), expect: () => const [ - HintState(status: HintStatus.thinking), - HintState(status: HintStatus.answered), + HintState( + status: HintStatus.thinking, + hints: [ + Hint(question: 'is it orange?', response: HintResponse.no), + ], + ), + HintState( + status: HintStatus.answered, + hints: [ + Hint(question: 'is it orange?', response: HintResponse.no), + Hint(question: 'blue?', response: HintResponse.no), + ], + ), ], - wait: const Duration(seconds: 1), + ); + }); + + group('PreviousHintsRequested', () { + late HintResource hintResource; + + setUp(() { + hintResource = _MockHintResource(); + }); + + blocTest( + 'emits state with hints when PreviousHintsRequested is added', + setUp: () { + when(() => hintResource.getHints(wordId: 'id')).thenAnswer( + (_) async => [ + Hint(question: 'is it orange?', response: HintResponse.no), + Hint(question: 'is it blue?', response: HintResponse.yes), + ], + ); + }, + build: () => HintBloc(hintResource: hintResource), + act: (bloc) => bloc.add(PreviousHintsRequested('id')), + expect: () => const [ + HintState( + hints: [ + Hint(question: 'is it orange?', response: HintResponse.no), + Hint(question: 'is it blue?', response: HintResponse.yes), + ], + ), + ], + ); + + blocTest( + 'does not emit state when PreviousHintsRequested is added and hints ' + 'are already present', + seed: () => HintState( + hints: [ + Hint(question: 'is it orange?', response: HintResponse.no), + Hint(question: 'is it blue?', response: HintResponse.yes), + ], + ), + build: () => HintBloc(hintResource: hintResource), + act: (bloc) => bloc.add( + PreviousHintsRequested('id'), + ), + expect: () => const [], ); }); } diff --git a/test/hint/bloc/hint_event_test.dart b/test/hint/bloc/hint_event_test.dart index c71a9b7a6..b09c5ca42 100644 --- a/test/hint/bloc/hint_event_test.dart +++ b/test/hint/bloc/hint_event_test.dart @@ -19,8 +19,17 @@ void main() { group('$HintRequested', () { test('supports equality', () { expect( - HintRequested('is it orange?'), - equals(HintRequested('is it orange?')), + HintRequested(wordId: 'id', question: 'is it orange?'), + equals(HintRequested(wordId: 'id', question: 'is it orange?')), + ); + }); + }); + + group('$PreviousHintsRequested', () { + test('supports equality', () { + expect( + PreviousHintsRequested('id'), + equals(PreviousHintsRequested('id')), ); }); }); diff --git a/test/hint/bloc/hint_state_test.dart b/test/hint/bloc/hint_state_test.dart index 76593147f..a6a87e53d 100644 --- a/test/hint/bloc/hint_state_test.dart +++ b/test/hint/bloc/hint_state_test.dart @@ -1,6 +1,7 @@ // ignore_for_file: prefer_const_constructors import 'package:flutter_test/flutter_test.dart'; +import 'package:game_domain/game_domain.dart'; import 'package:io_crossword/hint/bloc/hint_bloc.dart'; void main() { @@ -25,6 +26,18 @@ void main() { equals(HintState(status: HintStatus.thinking)), ); }); + + test('returns object with updated hints when hints are passed', () { + final state = HintState(status: HintStatus.asking); + final hint = Hint( + question: 'is it orange?', + response: HintResponse.notApplicable, + ); + expect( + state.copyWith(hints: [hint, hint]), + equals(HintState(status: HintStatus.asking, hints: [hint, hint])), + ); + }); }); }); } diff --git a/test/hint/extensions/hint_response_extension_test.dart b/test/hint/extensions/hint_response_extension_test.dart new file mode 100644 index 000000000..deacd4e72 --- /dev/null +++ b/test/hint/extensions/hint_response_extension_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:game_domain/game_domain.dart'; +import 'package:io_crossword/hint/hint.dart'; + +void main() { + group('HintResponseExtension', () { + test('${HintResponse.yes} returns one of the readable responses', () { + const response = HintResponse.yes; + final readable = response.readable; + + expect(yesResponses, contains(readable)); + }); + + test('${HintResponse.no} returns one of the readable responses', () { + const response = HintResponse.no; + final readable = response.readable; + + expect(noResponses, contains(readable)); + }); + + test('${HintResponse.notApplicable} returns one of the readable responses', + () { + const response = HintResponse.notApplicable; + final readable = response.readable; + + expect(notApplicableResponses, contains(readable)); + }); + }); +} diff --git a/test/hint/widgets/gemini_text_field_test.dart b/test/hint/widgets/gemini_text_field_test.dart index d34486795..d83d3ece7 100644 --- a/test/hint/widgets/gemini_text_field_test.dart +++ b/test/hint/widgets/gemini_text_field_test.dart @@ -4,8 +4,10 @@ import 'package:bloc_test/bloc_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:game_domain/game_domain.dart'; import 'package:io_crossword/hint/hint.dart'; import 'package:io_crossword/l10n/l10n.dart'; +import 'package:io_crossword/word_selection/word_selection.dart'; import 'package:io_crossword_ui/io_crossword_ui.dart'; import 'package:mocktail/mocktail.dart'; @@ -14,10 +16,20 @@ import '../../helpers/helpers.dart'; class _MockHintBloc extends MockBloc implements HintBloc {} +class _MockWordSelectionBloc + extends MockBloc + implements WordSelectionBloc {} + +class _FakeWord extends Fake implements Word { + @override + String get id => 'id'; +} + void main() { group('$GeminiTextField', () { late AppLocalizations l10n; late HintBloc hintBloc; + late WordSelectionBloc wordSelectionBloc; setUpAll(() async { l10n = await AppLocalizations.delegate.load(Locale('en')); @@ -25,6 +37,7 @@ void main() { setUp(() { hintBloc = _MockHintBloc(); + wordSelectionBloc = _MockWordSelectionBloc(); }); testWidgets('displays type hint', (tester) async { @@ -48,16 +61,62 @@ void main() { testWidgets( 'sends HintRequested when tapping the send button', (tester) async { + when(() => wordSelectionBloc.state).thenReturn( + WordSelectionState( + status: WordSelectionStatus.solving, + word: SelectedWord(section: (0, 0), word: _FakeWord()), + ), + ); await tester.pumpApp( - BlocProvider.value( - value: hintBloc, + MultiBlocProvider( + providers: [ + BlocProvider.value(value: hintBloc), + BlocProvider.value(value: wordSelectionBloc), + ], child: GeminiTextField(), ), ); + await tester.enterText(find.byType(TextField), 'is it red?'); + await tester.pumpAndSettle(); await tester.tap(find.byIcon(Icons.send)); - verify(() => hintBloc.add(const HintRequested('is it red?'))).called(1); + verify( + () => hintBloc.add( + HintRequested(wordId: 'id', question: 'is it red?'), + ), + ).called(1); + }, + ); + + testWidgets( + 'sends HintRequested when text field is submitted', + (tester) async { + when(() => wordSelectionBloc.state).thenReturn( + WordSelectionState( + status: WordSelectionStatus.solving, + word: SelectedWord(section: (0, 0), word: _FakeWord()), + ), + ); + await tester.pumpApp( + MultiBlocProvider( + providers: [ + BlocProvider.value(value: hintBloc), + BlocProvider.value(value: wordSelectionBloc), + ], + child: GeminiTextField(), + ), + ); + + await tester.enterText(find.byType(TextField), 'is it blue?'); + await tester.pumpAndSettle(); + await tester.testTextInput.receiveAction(TextInputAction.done); + + verify( + () => hintBloc.add( + HintRequested(wordId: 'id', question: 'is it blue?'), + ), + ).called(1); }, ); }); diff --git a/test/hint/widgets/hint_text_test.dart b/test/hint/widgets/hint_text_test.dart index 5014c50ef..f0a2194fa 100644 --- a/test/hint/widgets/hint_text_test.dart +++ b/test/hint/widgets/hint_text_test.dart @@ -1,74 +1,32 @@ // ignore_for_file: prefer_const_constructors -import 'package:bloc_test/bloc_test.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:io_crossword/hint/hint.dart'; -import 'package:io_crossword/l10n/l10n.dart'; -import 'package:mocktail/mocktail.dart'; +import 'package:io_crossword_ui/io_crossword_ui.dart'; import '../../helpers/helpers.dart'; -class _MockHintBloc extends MockBloc - implements HintBloc {} - void main() { group('$HintText', () { - late AppLocalizations l10n; - late HintBloc hintBloc; - - setUpAll(() async { - l10n = await AppLocalizations.delegate.load(Locale('en')); - }); - - setUp(() { - hintBloc = _MockHintBloc(); - }); - - group('renders ask gemini a hint text', () { - for (final status in [ - HintStatus.initial, - HintStatus.answered, - ]) { - testWidgets( - 'when the status is $status', - (tester) async { - when(() => hintBloc.state).thenReturn(HintState(status: status)); - await tester.pumpApp( - BlocProvider( - create: (context) => hintBloc, - child: HintText(), - ), - ); - - expect(find.text(l10n.askGeminiHint), findsOneWidget); - }, - ); - } - }); - - group('renders ask yes or no question text', () { - for (final status in [ - HintStatus.asking, - HintStatus.thinking, - HintStatus.invalid, - ]) { - testWidgets( - 'when the status is $status', - (tester) async { - when(() => hintBloc.state).thenReturn(HintState(status: status)); - await tester.pumpApp( - BlocProvider( - create: (context) => hintBloc, - child: HintText(), - ), - ); - - expect(find.text(l10n.askYesOrNoQuestion), findsOneWidget); - }, - ); - } + group('renders', () { + testWidgets( + 'the Gemini icon', + (tester) async { + await tester.pumpApp(HintText(text: 'Ask Gemini')); + + expect(find.byIcon(IoIcons.gemini), findsOneWidget); + }, + ); + + testWidgets( + 'the provided text with the Gemini gradient', + (tester) async { + await tester.pumpApp(HintText(text: 'Ask Gemini')); + + expect(find.text('Ask Gemini'), findsOneWidget); + expect(find.byType(GeminiGradient), findsAtLeast(1)); + }, + ); }); }); } diff --git a/test/hint/widgets/hints_section_test.dart b/test/hint/widgets/hints_section_test.dart new file mode 100644 index 000000000..a0bbf4f9d --- /dev/null +++ b/test/hint/widgets/hints_section_test.dart @@ -0,0 +1,91 @@ +// ignore_for_file: prefer_const_constructors, avoid_redundant_argument_values + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:game_domain/game_domain.dart'; +import 'package:io_crossword/hint/hint.dart'; +import 'package:io_crossword/l10n/l10n.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../helpers/helpers.dart'; + +class _MockHintBloc extends MockBloc + implements HintBloc {} + +void main() { + late AppLocalizations l10n; + late HintBloc hintBloc; + + setUpAll(() async { + l10n = await AppLocalizations.delegate.load(Locale('en')); + }); + + setUp(() { + hintBloc = _MockHintBloc(); + }); + + group('$HintsSection', () { + late Widget widget; + + setUp(() { + widget = BlocProvider( + create: (context) => hintBloc, + child: HintsSection(), + ); + }); + + testWidgets( + 'renders "ask gemini a hint" when the hint mode is not active', + (tester) async { + when(() => hintBloc.state).thenReturn( + HintState(status: HintStatus.initial), + ); + await tester.pumpApp(widget); + + expect(find.text(l10n.askGeminiHint), findsOneWidget); + }, + ); + + testWidgets( + 'renders "ask yes or no question" when the hint mode is active', + (tester) async { + when(() => hintBloc.state).thenReturn( + HintState(status: HintStatus.asking), + ); + await tester.pumpApp(widget); + + expect(find.text(l10n.askYesOrNoQuestion), findsOneWidget); + }, + ); + + testWidgets( + 'renders as many $HintQuestionResponse widgets as hints are available', + (tester) async { + final hints = [ + Hint(question: 'Q1', response: HintResponse.yes), + Hint(question: 'Q2', response: HintResponse.no), + Hint(question: 'Q3', response: HintResponse.notApplicable), + Hint(question: 'Q4', response: HintResponse.no), + ]; + when(() => hintBloc.state).thenReturn(HintState(hints: hints)); + await tester.pumpApp(widget); + + expect(find.byType(HintQuestionResponse), findsNWidgets(hints.length)); + }, + ); + + testWidgets( + 'renders a hint loading indicator when the hint status is thinking', + (tester) async { + when(() => hintBloc.state).thenReturn( + HintState(status: HintStatus.thinking), + ); + await tester.pumpApp(widget); + + expect(find.byType(HintLoadingIndicator), findsOneWidget); + }, + ); + }); +} diff --git a/test/word_focused/view/word_selection_page_test.dart b/test/word_focused/view/word_selection_page_test.dart index ba21f3ff3..3d18b8057 100644 --- a/test/word_focused/view/word_selection_page_test.dart +++ b/test/word_focused/view/word_selection_page_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/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -14,6 +15,8 @@ class _MockWordSelectionBloc extends MockBloc implements WordSelectionBloc {} +class _MockHintResource extends Mock implements HintResource {} + class _FakeWord extends Fake implements Word { @override int? get solvedTimestamp => null; @@ -62,6 +65,9 @@ void main() { testWidgets( '$WordSelectionView when there is a selected word', (tester) async { + final hintResource = _MockHintResource(); + when(() => hintResource.getHints(wordId: any(named: 'wordId'))) + .thenAnswer((_) async => []); when(() => wordSelectionBloc.state).thenReturn( WordSelectionState( status: WordSelectionStatus.preSolving, @@ -74,6 +80,7 @@ void main() { create: (_) => wordSelectionBloc, child: WordSelectionPage(), ), + hintResource: hintResource, ); expect(find.byType(WordSelectionView), findsOneWidget); diff --git a/test/word_focused/view/word_solving_view_test.dart b/test/word_focused/view/word_solving_view_test.dart index 8937859b9..2ebab3950 100644 --- a/test/word_focused/view/word_solving_view_test.dart +++ b/test/word_focused/view/word_solving_view_test.dart @@ -147,18 +147,32 @@ void main() { ); testWidgets( - 'a $WordValidatingLoadingIndicator', + 'the $HintsSection when the status is not validating', (tester) async { + when(() => wordSelectionBloc.state).thenReturn( + WordSelectionState( + status: WordSelectionStatus.solving, + word: selectedWord, + ), + ); await tester.pumpApp(widget); - expect(find.byType(WordValidatingLoadingIndicator), findsOneWidget); + + expect(find.byType(HintsSection), findsOneWidget); }, ); testWidgets( - 'a $HintText', + 'a $CircularProgressIndicator when the status is validating', (tester) async { + when(() => wordSelectionBloc.state).thenReturn( + WordSelectionState( + status: WordSelectionStatus.validating, + word: selectedWord, + ), + ); await tester.pumpApp(widget); - expect(find.byType(HintText), findsOneWidget); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); }, ); @@ -219,18 +233,32 @@ void main() { ); testWidgets( - 'a $WordValidatingLoadingIndicator', + 'the $HintsSection when the status is not validating', (tester) async { + when(() => wordSelectionBloc.state).thenReturn( + WordSelectionState( + status: WordSelectionStatus.solving, + word: selectedWord, + ), + ); await tester.pumpApp(widget); - expect(find.byType(WordValidatingLoadingIndicator), findsOneWidget); + + expect(find.byType(HintsSection), findsOneWidget); }, ); testWidgets( - 'a $HintText', + 'a $CircularProgressIndicator when the status is validating', (tester) async { + when(() => wordSelectionBloc.state).thenReturn( + WordSelectionState( + status: WordSelectionStatus.validating, + word: selectedWord, + ), + ); await tester.pumpApp(widget); - expect(find.byType(HintText), findsOneWidget); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); }, ); @@ -274,49 +302,6 @@ void main() { ); }); - group('$WordValidatingLoadingIndicator', () { - late WordSelectionBloc wordSelectionBloc; - late Widget widget; - - setUp(() { - wordSelectionBloc = _MockWordSolvingBloc(); - - widget = BlocProvider.value( - value: wordSelectionBloc, - child: WordValidatingLoadingIndicator(), - ); - }); - - testWidgets( - 'renders a circular progress indicator when the status is validating', - (tester) async { - when(() => wordSelectionBloc.state).thenReturn( - WordSelectionState( - status: WordSelectionStatus.validating, - word: selectedWord, - ), - ); - await tester.pumpApp(widget); - expect(find.byType(CircularProgressIndicator), findsOneWidget); - }, - ); - - testWidgets( - 'dos not render a circular progress indicator ' - 'when the status is other than validating', - (tester) async { - when(() => wordSelectionBloc.state).thenReturn( - WordSelectionState( - status: WordSelectionStatus.solving, - word: selectedWord, - ), - ); - await tester.pumpApp(widget); - expect(find.byType(CircularProgressIndicator), findsNothing); - }, - ); - }); - group('$BottomPanel', () { late WordSelectionBloc wordSelectionBloc; late HintBloc hintBloc;