test(catalog): widget, a11y, URL sync and controller tests for catalog search

KAT-01 Kriterium 1-6 and 8, QUE-05, QUE-06. Refs #3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
cschlaefke 2026-11-04 01:18:23 +01:00
parent 28404edd10
commit 3b66f4afb0
7 changed files with 942 additions and 23 deletions

View file

@ -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<void> pumpMessage(
WidgetTester tester, {
String? message,
List<AppStateAction> 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<void> 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);
});
});
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<MethodCall> platformCalls;
setUp(() => platformCalls = []);
Future<GoRouter> 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<Object?, Object?>;
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<TextField>(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');
});
});
}

View file

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