diff --git a/app/test/core/widgets/app_state_message_test.dart b/app/test/core/widgets/app_state_message_test.dart new file mode 100644 index 0000000..ad570f1 --- /dev/null +++ b/app/test/core/widgets/app_state_message_test.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/core/widgets/app_list_footer.dart'; +import 'package:shuffle/core/widgets/app_state_message.dart'; + +import '../../helpers/pump_app.dart'; + +void main() { + group('AppStateMessage', () { + Future pumpMessage( + WidgetTester tester, { + String? message, + List actions = const [], + ThemeMode themeMode = ThemeMode.light, + double? textScale, + }) => pumpApp( + tester, + themeMode: themeMode, + textScale: textScale, + home: Scaffold( + body: AppStateMessage( + icon: Icons.search_off, + title: 'Nichts gefunden', + message: message, + actions: actions, + ), + ), + ); + + testWidgets('shows title, hint and actions', (tester) async { + var pressed = 0; + await pumpMessage( + tester, + message: 'Prüfe die Schreibweise.', + actions: [ + AppStateAction( + key: const Key('first'), + label: 'Erneut versuchen', + onPressed: () => pressed++, + ), + AppStateAction(label: 'Zurücksetzen', onPressed: () {}), + ], + ); + + expect(find.text('Nichts gefunden'), findsOneWidget); + expect(find.text('Prüfe die Schreibweise.'), findsOneWidget); + await tester.tap(find.byKey(const Key('first'))); + expect(pressed, 1); + expect(find.text('Zurücksetzen'), findsOneWidget); + }); + + testWidgets('without hint and actions it is just icon and title', ( + tester, + ) async { + await pumpMessage(tester); + + expect(find.byType(FilledButton), findsNothing); + expect(find.byType(OutlinedButton), findsNothing); + }); + + for (final mode in ThemeMode.values.where((m) => m != ThemeMode.system)) { + testWidgets('meets accessibility guidelines ($mode)', (tester) async { + final handle = tester.ensureSemantics(); + await pumpMessage( + tester, + message: 'Hinweis', + themeMode: mode, + actions: [ + AppStateAction(label: 'Erneut versuchen', onPressed: () {}), + AppStateAction(label: 'Zurücksetzen', onPressed: () {}), + ], + ); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + } + + testWidgets('text scale 2.0 does not overflow', (tester) async { + await pumpMessage( + tester, + message: 'Ein längerer Hinweis, der umbrechen muss.', + textScale: 2, + actions: [AppStateAction(label: 'Erneut versuchen', onPressed: () {})], + ); + + expect(tester.takeException(), isNull); + }); + }); + + group('AppListFooter', () { + Future pumpFooter( + WidgetTester tester, { + bool isLoading = false, + bool hasMore = false, + String? errorMessage, + VoidCallback? onLoadMore, + VoidCallback? onRetry, + }) => pumpApp( + tester, + home: Scaffold( + body: AppListFooter( + isLoading: isLoading, + hasMore: hasMore, + errorMessage: errorMessage, + loadingLabel: 'Lädt', + loadMoreLabel: 'Weitere laden', + retryLabel: 'Erneut versuchen', + onLoadMore: onLoadMore ?? () {}, + onRetry: onRetry ?? () {}, + ), + ), + settle: false, + ); + + testWidgets('shows the load-more button while more is available', ( + tester, + ) async { + var loads = 0; + await pumpFooter(tester, hasMore: true, onLoadMore: () => loads++); + + await tester.tap(find.text('Weitere laden')); + + expect(loads, 1); + }); + + testWidgets('shows a labeled indicator while loading', (tester) async { + final handle = tester.ensureSemantics(); + await pumpFooter(tester, hasMore: true, isLoading: true); + + expect(find.byKey(const Key('list_footer_loading')), findsOneWidget); + expect(find.text('Weitere laden'), findsNothing); + expect(find.bySemanticsLabel('Lädt'), findsOneWidget); + handle.dispose(); + }); + + testWidgets('shows the error with retry, ahead of everything else', ( + tester, + ) async { + var retries = 0; + await pumpFooter( + tester, + hasMore: true, + isLoading: true, + errorMessage: 'Fehler beim Laden', + onRetry: () => retries++, + ); + + expect(find.text('Fehler beim Laden'), findsOneWidget); + await tester.tap(find.byKey(const Key('list_footer_retry'))); + expect(retries, 1); + expect(find.byKey(const Key('list_footer_loading')), findsNothing); + }); + + testWidgets('shows nothing at the end of the list', (tester) async { + await pumpFooter(tester); + + expect(find.byType(OutlinedButton), findsNothing); + expect(find.byKey(const Key('list_footer_loading')), findsNothing); + }); + + testWidgets('the load-more button takes a focus node', (tester) async { + final node = FocusNode(); + addTearDown(node.dispose); + await pumpApp( + tester, + home: Scaffold( + body: AppListFooter( + isLoading: false, + hasMore: true, + loadingLabel: 'Lädt', + loadMoreLabel: 'Weitere laden', + retryLabel: 'Erneut versuchen', + onLoadMore: () {}, + onRetry: () {}, + loadMoreFocusNode: node, + ), + ), + settle: false, + ); + + node.requestFocus(); + await tester.pump(); + + expect(node.hasPrimaryFocus, isTrue); + }); + }); +} diff --git a/app/test/features/catalog/data/supabase_catalog_repository_test.dart b/app/test/features/catalog/data/supabase_catalog_repository_test.dart index 5f396f9..706e443 100644 --- a/app/test/features/catalog/data/supabase_catalog_repository_test.dart +++ b/app/test/features/catalog/data/supabase_catalog_repository_test.dart @@ -6,10 +6,10 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:logging/logging.dart'; import 'package:shuffle/core/logging/app_logger.dart'; +import 'package:shuffle/features/catalog/data/supabase_catalog_repository.dart'; import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; -import 'package:shuffle/features/catalog/data/supabase_catalog_repository.dart'; import '../catalog_test_support.dart'; diff --git a/app/test/features/catalog/presentation/search/catalog_search_a11y_test.dart b/app/test/features/catalog/presentation/search/catalog_search_a11y_test.dart new file mode 100644 index 0000000..f8dd4f8 --- /dev/null +++ b/app/test/features/catalog/presentation/search/catalog_search_a11y_test.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; + +import '../../catalog_test_support.dart'; +import 'catalog_search_view_test.dart'; + +void main() { + group('announcements', () { + testWidgets('QUE-06 Kriterium 2: the hit count is announced once', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: List.generate(75, game)); + await pumpView(tester, repository); + tester.takeAnnouncements(); + + await tester.enterText(searchInput, 'spiel'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pump(); + // Not at once: the announcement waits for a quiet moment. + expect(tester.takeAnnouncements(), isEmpty); + + await tester.pump(const Duration(seconds: 1)); + + final messages = tester.takeAnnouncements().map((a) => a.message); + expect(messages, ['75 Spiele gefunden']); + }); + + testWidgets('QUE-06 Kriterium 2: nothing is announced while typing', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: List.generate(75, game)); + await pumpView(tester, repository); + await tester.pump(const Duration(seconds: 1)); + tester.takeAnnouncements(); + + await tester.enterText(searchInput, 'sp'); + await tester.pump(const Duration(milliseconds: 100)); + await tester.enterText(searchInput, 'spi'); + await tester.pump(const Duration(milliseconds: 100)); + + expect(tester.takeAnnouncements(), isEmpty); + // Let the pending search run out before the test ends. + await tester.pump(const Duration(seconds: 2)); + }); + + testWidgets('quick filter changes give one announcement (debounced)', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + await tester.pump(const Duration(seconds: 1)); + tester.takeAnnouncements(); + await tester.tap(find.byKey(const Key('catalog_filter_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy'))); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.byKey(const Key('catalog_term_$genreFamily'))); + await tester.pump(const Duration(milliseconds: 100)); + await tester.tap(find.byKey(const Key('catalog_term_$mechanicDice'))); + await tester.pump(const Duration(seconds: 1)); + + final messages = tester.takeAnnouncements().map((a) => a.message); + expect(messages, ['1 Spiel gefunden']); + }); + + testWidgets('an empty result is announced as well', (tester) async { + final repository = FakeCatalogRepository(); + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery(text: 'zzz'), + ); + + await tester.pump(const Duration(seconds: 1)); + + final messages = tester.takeAnnouncements().map((a) => a.message); + expect(messages, contains('Keine Spiele gefunden')); + }); + + testWidgets('English announcement', (tester) async { + final repository = FakeCatalogRepository(all: List.generate(2, game)); + await pumpView(tester, repository, locale: const Locale('en')); + + await tester.pump(const Duration(seconds: 1)); + + final messages = tester.takeAnnouncements().map((a) => a.message); + expect(messages, contains('2 games found')); + }); + }); + + group('accessibility guidelines (QUE-06 Kriterium 1, 3)', () { + Future check(WidgetTester tester, {double width = 420}) async { + final handle = tester.ensureSemantics(); + final repository = FakeCatalogRepository( + all: [ + game(1, matchedAltTitle: 'Alternativtitel'), + game(2, publishers: ['A', 'B', 'C']), + ], + ); + await pumpView( + tester, + repository, + width: width, + initialQuery: const CatalogSearchQuery(text: 'spiel'), + ); + + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + } + + testWidgets('compact layout', check); + testWidgets('expanded layout with side bar', (tester) async { + await check(tester, width: 1200); + }); + + testWidgets('filter sheet', (tester) async { + final handle = tester.ensureSemantics(); + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + await tester.tap(find.byKey(const Key('catalog_filter_button'))); + await tester.pumpAndSettle(); + + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('empty and error states', (tester) async { + final handle = tester.ensureSemantics(); + final repository = FakeCatalogRepository(); + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery( + text: 'zzz', + filters: CatalogFilters(players: 2), + ), + ); + + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); + await expectLater(tester, meetsGuideline(textContrastGuideline)); + handle.dispose(); + }); + + testWidgets('search field is labeled for screen readers', (tester) async { + final handle = tester.ensureSemantics(); + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + + expect(find.bySemanticsLabel('Spiele suchen'), findsWidgets); + expect(find.bySemanticsLabel('Suchbegriff löschen'), findsNothing); + handle.dispose(); + }); + }); +} diff --git a/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart b/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart new file mode 100644 index 0000000..4fdbcb8 --- /dev/null +++ b/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart @@ -0,0 +1,362 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; +import 'package:shuffle/features/catalog/domain/catalog_search_query.dart'; + +import '../../catalog_test_support.dart'; +import 'catalog_search_view_test.dart'; + +Future openFilterSheet(WidgetTester tester) async { + await tester.tap(find.byKey(const Key('catalog_filter_button'))); + await tester.pumpAndSettle(); +} + +void main() { + group('filters in the bottom sheet (compact)', () { + testWidgets('KAT-01 Kriterium 4: the button shows the number of filters', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery( + filters: CatalogFilters(players: 2, genreIds: {genreFamily}), + ), + ); + + expect(find.text('Filter (2)'), findsOneWidget); + }); + + testWidgets('the button shows plain "Filter" without filters', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + + await pumpView(tester, repository); + + expect(find.text('Filter'), findsOneWidget); + }); + + testWidgets('KAT-01 Kriterium 4: each filter kind sets its value', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + await openFilterSheet(tester); + + Future pick(String fieldKey, String label) async { + await tester.tap(find.byKey(Key(fieldKey))); + await tester.pumpAndSettle(); + await tester.tap(find.text(label).last); + await tester.pumpAndSettle(); + } + + await pick('catalog_filter_players', '4 Spieler'); + await pick('catalog_filter_duration', 'Bis 60 Min.'); + await pick('catalog_filter_age', 'Ab 10 Jahren geeignet'); + await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('catalog_term_$mechanicDice'))); + await tester.pumpAndSettle(); + + final filters = repository.calls.last.query.filters; + expect(filters.players, 4); + expect(filters.maxMinutes, 60); + expect(filters.minAgeSuitable, 10); + expect(filters.genreIds, {genreStrategy}); + expect(filters.mechanicIds, {mechanicDice}); + // The list behind the sheet updates immediately. + expect(repository.calls.length, greaterThan(5)); + }); + + testWidgets('KAT-01 Kriterium 4: two genres are both sent (AND)', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + await openFilterSheet(tester); + + await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('catalog_term_$genreFamily'))); + await tester.pumpAndSettle(); + + expect(repository.calls.last.query.filters.genreIds, { + genreStrategy, + genreFamily, + }); + }); + + testWidgets('KAT-01 Kriterium 4: a chip can be deselected again', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + await openFilterSheet(tester); + + await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('catalog_term_$genreStrategy'))); + await tester.pumpAndSettle(); + + expect(repository.calls.last.query.filters.genreIds, isEmpty); + }); + + testWidgets('reset removes all filters and Done closes the sheet', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView( + tester, + repository, + initialQuery: const CatalogSearchQuery( + filters: CatalogFilters(players: 2, genreIds: {genreFamily}), + ), + ); + await openFilterSheet(tester); + + await tester.tap(find.byKey(const Key('catalog_filter_reset'))); + await tester.pumpAndSettle(); + expect(repository.calls.last.query.hasFilters, isFalse); + + await tester.tap(find.byKey(const Key('catalog_filter_done'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('catalog_filter_sheet')), findsNothing); + expect(find.text('Filter'), findsWidgets); + }); + + testWidgets('QUE-05 Kriterium 4: terms are named and sorted by language', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + await openFilterSheet(tester); + + // German: Familie before Strategie. + expect(find.text('Familie'), findsOneWidget); + final de = tester.getTopLeft(find.text('Familie')).dx; + final de2 = tester.getTopLeft(find.text('Strategie')).dx; + expect(de, lessThan(de2)); + }); + + testWidgets('QUE-05 Kriterium 4: English names and order', (tester) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository, locale: const Locale('en')); + await openFilterSheet(tester); + + expect(find.text('Dice rolling'), findsOneWidget); + expect(find.text('Strategy'), findsOneWidget); + final family = tester.getTopLeft(find.text('Family')).dx; + final strategy = tester.getTopLeft(find.text('Strategy')).dx; + expect(family, lessThan(strategy)); + }); + + testWidgets('a vocabulary error shows a hint with retry, rest works', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]) + ..termsFailWith = const CatalogFailure.network(); + await pumpView(tester, repository); + await openFilterSheet(tester); + + expect( + find.text('Die Auswahl konnte nicht geladen werden.'), + findsNWidgets(2), + ); + // The other filters still work. + await tester.tap(find.byKey(const Key('catalog_filter_players'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('2 Spieler').last); + await tester.pumpAndSettle(); + expect(repository.calls.last.query.filters.players, 2); + + repository.termsFailWith = null; + await tester.tap(find.byKey(const Key('catalog_terms_retry')).first); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('catalog_term_$genreStrategy')), + findsOneWidget, + ); + }); + }); + + group('keyboard', () { + testWidgets('QUE-06 Kriterium 4: Tab leads from the field to the filters', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpView(tester, repository); + + await tester.tap(searchInput); + await tester.pump(); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + final focused = FocusManager.instance.primaryFocus!.context; + expect( + find.descendant( + of: find.byKey(const Key('catalog_filter_button')), + matching: find.byElementPredicate((e) => e == focused), + ), + findsOneWidget, + ); + }); + }); + + group('layout', () { + testWidgets('Plattformen Kriterium 2: side bar from 1024 dp', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + + await pumpView(tester, repository, width: 1200); + + expect(find.byKey(const Key('catalog_filter_sidebar')), findsOneWidget); + expect(find.byKey(const Key('catalog_filter_button')), findsNothing); + // The filters work without a sheet. + await tester.tap(find.byKey(const Key('catalog_term_$genreFamily'))); + await tester.pumpAndSettle(); + expect(repository.calls.last.query.filters.genreIds, {genreFamily}); + }); + + testWidgets('below 1024 dp there is no side bar', (tester) async { + final repository = FakeCatalogRepository(all: [game(1)]); + + await pumpView(tester, repository, width: 800); + + expect(find.byKey(const Key('catalog_filter_sidebar')), findsNothing); + expect(find.byKey(const Key('catalog_filter_button')), findsOneWidget); + }); + + for (final width in [420.0, 800.0, 1200.0]) { + testWidgets('QUE-06 Kriterium 3: text scale 2.0 at $width dp', ( + tester, + ) async { + final repository = FakeCatalogRepository( + all: [ + game( + 1, + title: 'Ein sehr langer Spieltitel für den Test', + matchedAltTitle: 'Noch ein langer alternativer Titel', + ), + game(2, publishers: ['Verlag A', 'Verlag B', 'Verlag C']), + ], + ); + await pumpView(tester, repository, width: width, textScale: 2.0); + + expect(tester.takeException(), isNull); + if (width < 1024) { + await openFilterSheet(tester); + expect(tester.takeException(), isNull); + } + }); + } + }); + + group('paging', () { + testWidgets( + 'KAT-01 Kriterium 5: scrolling to the end loads the next page', + (tester) async { + final repository = FakeCatalogRepository(all: List.generate(75, game)); + await pumpView(tester, repository); + expect(repository.calls, hasLength(1)); + + await tester.drag( + find.byKey(const Key('catalog_result_list')), + const Offset(0, -3000), + ); + await tester.pumpAndSettle(); + + expect(repository.calls.length, greaterThanOrEqualTo(2)); + expect(repository.calls[1].offset, 30); + expect(repository.calls[1].query.isEmpty, isTrue); + }, + ); + + testWidgets('a first page that does not fill the screen loads on', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: List.generate(75, game)); + await pumpView(tester, repository); + repository.calls.clear(); + + // No scroll event can come when everything is visible at once. + tester.view.physicalSize = const Size(420, 9000); + await tester.pumpAndSettle(); + + expect(repository.calls, isNotEmpty); + expect(repository.calls.first.offset, 30); + }); + + testWidgets('the end of the list shows no more button', (tester) async { + final repository = FakeCatalogRepository(all: List.generate(5, game)); + + await pumpView(tester, repository); + + expect(repository.calls, hasLength(1)); + expect(find.byKey(const Key('list_footer_load_more')), findsNothing); + expect(find.byKey(const Key('list_footer_loading')), findsNothing); + }); + + testWidgets( + 'KAT-01 Kriterium 5: a failed load keeps the hits; retry moves focus ' + 'to the first new tile (QUE-06 Kriterium 4)', + (tester) async { + final repository = FakeCatalogRepository(manual: true); + await pumpView(tester, repository, settle: false); + await tester.pump(); + repository.complete(0, items: List.generate(30, game), totalCount: 90); + await tester.pump(); + + await tester.drag( + find.byKey(const Key('catalog_result_list')), + const Offset(0, -3000), + ); + await tester.pump(); + expect(repository.calls, hasLength(2)); + expect(find.byKey(const Key('list_footer_loading')), findsOneWidget); + + repository.complete(1, failure: const CatalogFailure.network()); + await tester.pumpAndSettle(); + await tester.drag( + find.byKey(const Key('catalog_result_list')), + const Offset(0, -3000), + ); + await tester.pumpAndSettle(); + + expect( + find.text('Weitere Treffer konnten nicht geladen werden.'), + findsOneWidget, + ); + expect(find.byKey(const Key('catalog_tile_game-29')), findsOneWidget); + // No automatic retry loop. + expect(repository.calls, hasLength(2)); + + await tester.tap(find.byKey(const Key('list_footer_retry'))); + await tester.pump(); + expect(repository.calls, hasLength(3)); + expect(repository.calls[2].offset, 30); + repository.complete( + 2, + items: List.generate(30, (i) => game(30 + i)), + totalCount: 90, + ); + await tester.pumpAndSettle(); + + final tile = find.byKey(const Key('catalog_tile_game-30')); + expect(tile, findsOneWidget); + expect( + find.ancestor( + of: tile, + matching: find.byWidgetPredicate( + (w) => w is Focus && (w.focusNode?.hasPrimaryFocus ?? false), + ), + ), + findsOneWidget, + ); + }, + ); + }); +} diff --git a/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart b/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart index 5d2e7f3..d3b1637 100644 --- a/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart +++ b/app/test/features/catalog/presentation/search/catalog_search_controller_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shuffle/features/catalog/domain/catalog_failure.dart'; @@ -125,7 +127,6 @@ void main() { run('KAT-01 Kriterium 1: only the latest request counts', (tester) async { final repo = FakeCatalogRepository(manual: true); useRepository(repo); - controller().init(const CatalogSearchQuery()); await tester.pump(); @@ -151,7 +152,7 @@ void main() { ) async { controller().init(const CatalogSearchQuery()); await tester.pump(); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); repository.calls.clear(); @@ -200,20 +201,20 @@ void main() { controller().init(const CatalogSearchQuery()); await tester.pump(); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); expect(state().items, hasLength(60)); expect(repository.calls.last.offset, 30); expect(state().hasMore, isTrue); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); expect(state().items, hasLength(75)); expect(state().hasMore, isFalse); // Nothing more to load. - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); expect(repository.calls, hasLength(3)); }); @@ -226,15 +227,14 @@ void main() { manual: true, ); useRepository(repo); - + controller().init(const CatalogSearchQuery()); await tester.pump(); repo.complete(0, items: repo.all.sublist(0, 30), totalCount: 75); await tester.pump(); - controller() - ..loadMore() - ..loadMore(); + unawaited(controller().loadMore()); + unawaited(controller().loadMore()); await tester.pump(); expect(repo.calls, hasLength(2)); @@ -246,13 +246,13 @@ void main() { ) async { final repo = FakeCatalogRepository(manual: true); useRepository(repo); - + controller().init(const CatalogSearchQuery()); await tester.pump(); repo.complete(0, items: [game(1), game(2)], totalCount: 4); await tester.pump(); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); repo.complete(1, items: [game(2), game(3)], totalCount: 4); await tester.pump(); @@ -265,13 +265,13 @@ void main() { ) async { final repo = FakeCatalogRepository(manual: true); useRepository(repo); - + controller().init(const CatalogSearchQuery()); await tester.pump(); repo.complete(0, items: [game(1), game(2)], totalCount: 5); await tester.pump(); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); repo.complete(1, items: const [], totalCount: 0); await tester.pump(); @@ -284,12 +284,12 @@ void main() { run('a new search discards a running loadMore', (tester) async { final repo = FakeCatalogRepository(manual: true); useRepository(repo); - + controller().init(const CatalogSearchQuery()); await tester.pump(); repo.complete(0, items: [game(1)], totalCount: 3); await tester.pump(); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); controller().submitText('neu'); @@ -309,7 +309,7 @@ void main() { await tester.pump(); repository.failWith = const CatalogFailure.network(); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); expect(state().items, hasLength(30)); @@ -318,7 +318,7 @@ void main() { expect(state().loadingMore, isFalse); repository.failWith = null; - controller().retry(); + unawaited(controller().retry()); await tester.pump(); expect(state().items, hasLength(60)); @@ -343,7 +343,7 @@ void main() { expect(state().query.filters.players, 3); repository.failWith = null; - controller().retry(); + unawaited(controller().retry()); await tester.pump(); expect(state().status, CatalogSearchStatus.data); @@ -354,7 +354,6 @@ void main() { run('Zustände Kriterium 1: no hits give the empty status', (tester) async { final repo = FakeCatalogRepository(); useRepository(repo); - controller().init(const CatalogSearchQuery(text: 'zzz')); await tester.pump(); @@ -366,7 +365,7 @@ void main() { run('a new search keeps the old hits visible while it runs', (tester) async { final repo = FakeCatalogRepository(manual: true); useRepository(repo); - + controller().init(const CatalogSearchQuery()); await tester.pump(); repo.complete(0, items: [game(1)], totalCount: 1); @@ -390,7 +389,7 @@ void main() { await tester.pump(); expect(state().searchGeneration, 1); - controller().loadMore(); + unawaited(controller().loadMore()); await tester.pump(); expect(state().searchGeneration, 1); diff --git a/app/test/features/catalog/presentation/search/catalog_search_screen_test.dart b/app/test/features/catalog/presentation/search/catalog_search_screen_test.dart new file mode 100644 index 0000000..f9398f0 --- /dev/null +++ b/app/test/features/catalog/presentation/search/catalog_search_screen_test.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shuffle/core/routing/app_router.dart'; +import 'package:shuffle/core/routing/url_replacer.dart'; +import 'package:shuffle/features/auth/domain/auth_status.dart'; +import 'package:shuffle/features/auth/domain/auth_user.dart'; +import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; +import 'package:shuffle/features/catalog/presentation/catalog_routes.dart'; +import 'package:shuffle/features/catalog/presentation/search/catalog_search_view.dart'; + +import '../../../../helpers/pump_app.dart'; +import '../../../../helpers/router_helpers.dart'; +import '../../catalog_test_support.dart'; +import 'catalog_search_view_test.dart'; + +void main() { + group('route /catalog', () { + testWidgets('Plattformen: /catalog is public (no sign-in redirect)', ( + tester, + ) async { + expect( + authRedirectFor( + const SignedOut(), + Uri.parse('/catalog?q=catan'), + privatePaths: const [], + ), + isNull, + ); + expect( + authRedirectFor( + const SignedIn( + AuthUser(id: 'u1', email: 'a@example.com', emailConfirmed: true), + ), + Uri.parse('/catalog'), + ), + isNull, + ); + }); + + testWidgets('KAT-01 Kriterium 1: the URL parameters are the start values', ( + tester, + ) async { + useWindow(tester, 420); + final repository = FakeCatalogRepository(all: [game(1)]); + final container = await pumpShuffleApp( + tester, + overrides: catalogOverrides(repository), + ); + + container + .read(appRouterProvider) + .go('/catalog?q=catan&players=3&maxMinutes=60&genre=$genreStrategy'); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget); + final query = repository.calls.first.query; + expect(query.text, 'catan'); + expect(query.filters.players, 3); + expect(query.filters.maxMinutes, 60); + expect(query.filters.genreIds, {genreStrategy}); + }); + + testWidgets('invalid URL parameters are ignored, the page still opens', ( + tester, + ) async { + useWindow(tester, 420); + final repository = FakeCatalogRepository(all: [game(1)]); + final container = await pumpShuffleApp( + tester, + overrides: catalogOverrides(repository), + ); + + container + .read(appRouterProvider) + .go('/catalog?players=99&genre=xyz&maxMinutes=abc'); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget); + expect(repository.calls.first.query.isEmpty, isTrue); + }); + + testWidgets('start page entry opens the search and back returns', ( + tester, + ) async { + useWindow(tester, 420); + final repository = FakeCatalogRepository(all: [game(1)]); + await pumpShuffleApp(tester, overrides: catalogOverrides(repository)); + + await tester.tap(find.byKey(const Key('home_catalog_search_button'))); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget); + expect(find.text('Spiel 1'), findsOneWidget); + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('home_screen')), findsOneWidget); + }); + }); + + group('URL sync (web)', () { + late List platformCalls; + + setUp(() => platformCalls = []); + + Future pumpRouted( + WidgetTester tester, + FakeCatalogRepository repository, { + required bool isWeb, + }) async { + useWindow(tester, 420); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.navigation, + (call) async { + platformCalls.add(call); + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.navigation, + null, + ), + ); + final router = buildTestRouter( + routes: catalogRoutes, + initialLocation: '/catalog?q=alt', + ); + addTearDown(router.dispose); + await pumpApp( + tester, + router: router, + overrides: [ + ...catalogOverrides(repository), + urlReplacerProvider.overrideWithValue(UrlReplacer(isWeb: isWeb)), + ], + settle: false, + ); + await tester.pumpAndSettle(); + platformCalls.clear(); + return router; + } + + testWidgets('URL is replaced after a search without rebuilding the page', ( + tester, + ) async { + final repository = FakeCatalogRepository(all: [game(1)]); + final router = await pumpRouted(tester, repository, isWeb: true); + final stateBefore = tester.state(find.byType(CatalogSearchView)); + final callsBefore = repository.calls.length; + + await tester.enterText(searchInput, 'catan'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + + // Router and browser address agree on the new search. + expect(currentUri(router).queryParameters['q'], 'catan'); + final updates = platformCalls.where( + (c) => c.method == 'routeInformationUpdated', + ); + expect(updates, isNotEmpty); + final last = updates.last.arguments as Map; + expect(last['uri'].toString(), '/catalog?q=catan'); + expect(last['replace'], isTrue); + // No history entry from the router for this change. + expect( + updates.where((c) => (c.arguments as Map)['replace'] != true), + isEmpty, + ); + // Same page state: nothing rebuilt, field and focus intact. + expect(tester.state(find.byType(CatalogSearchView)), same(stateBefore)); + expect(tester.widget(searchInput).controller!.text, 'catan'); + // Exactly one more search (no re-init after the URL change). + expect(repository.calls.length, callsBefore + 1); + }); + + testWidgets('filters are part of the written URL', (tester) async { + final repository = FakeCatalogRepository(all: [game(1)]); + final router = await pumpRouted(tester, repository, isWeb: true); + await tester.tap(find.byKey(const Key('catalog_filter_button'))); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const Key('catalog_term_$genreFamily'))); + await tester.pumpAndSettle(); + + final params = currentUri(router).queryParameters; + expect(params['q'], 'alt'); + expect(params['genre'], genreFamily); + }); + + testWidgets('outside the web nothing is written', (tester) async { + final repository = FakeCatalogRepository(all: [game(1)]); + final router = await pumpRouted(tester, repository, isWeb: false); + + await tester.enterText(searchInput, 'catan'); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + + expect( + platformCalls.where((c) => c.method == 'routeInformationUpdated'), + isEmpty, + ); + expect(currentUri(router).queryParameters['q'], 'alt'); + }); + }); +} diff --git a/app/test/features/catalog/presentation/search/catalog_search_view_test.dart b/app/test/features/catalog/presentation/search/catalog_search_view_test.dart index e022363..dbc882e 100644 --- a/app/test/features/catalog/presentation/search/catalog_search_view_test.dart +++ b/app/test/features/catalog/presentation/search/catalog_search_view_test.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/semantics.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shuffle/core/widgets/game_cover.dart'; import 'package:shuffle/features/catalog/domain/catalog_failure.dart';