From 25212929ce04845421dbca08b423f307d17c7936 Mon Sep 17 00:00:00 2001 From: cschlaefke Date: Wed, 4 Nov 2026 10:17:54 +0100 Subject: [PATCH] feat(catalog): review fixes for catalog search Word limit and no-retry state for rejected input, nested /catalog route for system back, logger filter for supabase libs, more mutation-proof tests, doc fix, umlaut-aware term sorting, thousands separators. Refs #3 Co-Authored-By: Claude Sonnet 5.5 --- app/lib/core/logging/app_logger.dart | 10 +++++ app/lib/core/routing/app_router.dart | 2 +- app/lib/core/routing/app_router.g.dart | 2 +- app/lib/core/routing/app_routes.dart | 3 ++ app/lib/core/widgets/app_list_footer.dart | 6 --- .../catalog/domain/catalog_search_query.dart | 26 ++++++++++--- .../catalog/presentation/catalog_routes.dart | 7 +++- .../search/catalog_search_view.dart | 18 ++++++--- .../search/widgets/catalog_filter_panel.dart | 37 ++++++++++++++++-- app/lib/l10n/app_de.arb | 3 +- app/lib/l10n/app_en.arb | 8 ++-- app/lib/l10n/app_localizations.dart | 6 +++ app/lib/l10n/app_localizations_de.dart | 16 +++++++- app/lib/l10n/app_localizations_en.dart | 16 +++++++- app/test/core/logging/app_logger_test.dart | 15 ++++++++ .../core/widgets/app_state_message_test.dart | 26 ------------- .../catalog/catalog_test_support.dart | 1 + .../supabase_catalog_repository_test.dart | 38 ++++++++++++++++++- .../catalog/domain/catalog_domain_test.dart | 29 ++++++++++++++ .../search/catalog_search_behavior_test.dart | 29 ++++++++++++++ .../catalog_search_controller_test.dart | 25 ++++++++++++ .../search/catalog_search_screen_test.dart | 28 +++++++++++++- .../search/catalog_search_view_test.dart | 17 +++++++++ docs/design-system.md | 4 +- 24 files changed, 310 insertions(+), 62 deletions(-) diff --git a/app/lib/core/logging/app_logger.dart b/app/lib/core/logging/app_logger.dart index a10f4d6..8a8a0f2 100644 --- a/app/lib/core/logging/app_logger.dart +++ b/app/lib/core/logging/app_logger.dart @@ -48,7 +48,17 @@ class AppLogger { _subscription = null; } + /// Whether a record is written. Records of the Supabase libraries below + /// INFO are dropped: postgrest logs `'$error from request'` at FINE, and the + /// error text can echo request parameters (search terms, QUE-04). + @visibleForTesting + static bool shouldWrite(LogRecord record) => + !(record.loggerName.startsWith('supabase') && record.level < Level.INFO); + static void _write(LogRecord record) { + if (!shouldWrite(record)) { + return; + } developer.log( record.message, time: record.time, diff --git a/app/lib/core/routing/app_router.dart b/app/lib/core/routing/app_router.dart index 63baec9..df069ff 100644 --- a/app/lib/core/routing/app_router.dart +++ b/app/lib/core/routing/app_router.dart @@ -24,9 +24,9 @@ GoRouter appRouter(Ref ref) { GoRoute( path: AppRoutes.home, builder: (context, state) => const HomeScreen(), + routes: catalogRoutes, ), ...authRoutes, - ...catalogRoutes, ], ); ref.onDispose(router.dispose); diff --git a/app/lib/core/routing/app_router.g.dart b/app/lib/core/routing/app_router.g.dart index 13b150b..8998754 100644 --- a/app/lib/core/routing/app_router.g.dart +++ b/app/lib/core/routing/app_router.g.dart @@ -48,4 +48,4 @@ final class AppRouterProvider } } -String _$appRouterHash() => r'e5b9cf23cd96b28b8941552d226d592a1e06efc1'; +String _$appRouterHash() => r'cb20752ab64cf1ac56d3bfce5401e187246f5ec7'; diff --git a/app/lib/core/routing/app_routes.dart b/app/lib/core/routing/app_routes.dart index 3ce97d5..1e22bcf 100644 --- a/app/lib/core/routing/app_routes.dart +++ b/app/lib/core/routing/app_routes.dart @@ -5,6 +5,9 @@ abstract final class AppRoutes { /// Katalogsuche, öffentlich (Spec #3 § 5.5). static const catalog = '/catalog'; + /// Segment of [catalog] below [home] (nested route). + static const catalogSegment = 'catalog'; + // Auth (Spec #1 § 5.3) static const login = '/login'; static const register = '/register'; diff --git a/app/lib/core/widgets/app_list_footer.dart b/app/lib/core/widgets/app_list_footer.dart index 38bd22f..93b4381 100644 --- a/app/lib/core/widgets/app_list_footer.dart +++ b/app/lib/core/widgets/app_list_footer.dart @@ -18,7 +18,6 @@ class AppListFooter extends StatelessWidget { required this.onRetry, super.key, this.errorMessage, - this.loadMoreFocusNode, }); final bool isLoading; @@ -32,10 +31,6 @@ class AppListFooter extends StatelessWidget { final VoidCallback onLoadMore; final VoidCallback onRetry; - /// Focus node of the "load more" button (callers restore focus elsewhere - /// after loading). - final FocusNode? loadMoreFocusNode; - @override Widget build(BuildContext context) { final message = errorMessage; @@ -79,7 +74,6 @@ class AppListFooter extends StatelessWidget { } else if (hasMore) { content = OutlinedButton( key: const Key('list_footer_load_more'), - focusNode: loadMoreFocusNode, onPressed: onLoadMore, child: Text(loadMoreLabel), ); diff --git a/app/lib/features/catalog/domain/catalog_search_query.dart b/app/lib/features/catalog/domain/catalog_search_query.dart index 70d3035..13e0e9d 100644 --- a/app/lib/features/catalog/domain/catalog_search_query.dart +++ b/app/lib/features/catalog/domain/catalog_search_query.dart @@ -19,6 +19,9 @@ abstract final class CatalogSearchLimits { /// "Suitable from X years" steps. static const List ageSteps = [6, 8, 10, 12, 14, 16, 18]; + /// The backend rejects search texts with more words. + static const int maxWords = 10; + /// Most genres or mechanics that can be selected at once. static const int maxTerms = 20; @@ -77,12 +80,25 @@ abstract class CatalogSearchQuery with _$CatalogSearchQuery { const CatalogSearchQuery._(); - /// Trims [raw] and cuts it to the maximum length. + static final RegExp _word = RegExp(r'[\p{L}\p{N}]+', unicode: true); + + /// Trims [raw] and cuts it to the maximum length and to at most + /// [CatalogSearchLimits.maxWords] words. The backend counts words after its + /// normalization (punctuation separates words, one-letter words count) and + /// rejects more; this is the client-side approximation, the rest is cut off + /// silently. static String cleanText(String raw) { - final trimmed = raw.trim(); - return trimmed.length > CatalogSearchLimits.maxTextLength - ? trimmed.substring(0, CatalogSearchLimits.maxTextLength).trim() - : trimmed; + var text = raw.trim(); + if (text.length > CatalogSearchLimits.maxTextLength) { + text = text.substring(0, CatalogSearchLimits.maxTextLength).trim(); + } + var count = 0; + for (final match in _word.allMatches(text)) { + if (++count == CatalogSearchLimits.maxWords) { + return text.substring(0, match.end); + } + } + return text; } /// Whether the text is long enough to count as a search term. diff --git a/app/lib/features/catalog/presentation/catalog_routes.dart b/app/lib/features/catalog/presentation/catalog_routes.dart index 00642e3..c8c9b7f 100644 --- a/app/lib/features/catalog/presentation/catalog_routes.dart +++ b/app/lib/features/catalog/presentation/catalog_routes.dart @@ -4,9 +4,14 @@ import 'package:shuffle/features/catalog/presentation/catalog_query_params.dart' import 'package:shuffle/features/catalog/presentation/search/catalog_search_screen.dart'; /// Routes of the catalog (Spec #3 § 5.5). `/catalog` is public. +/// +/// These are sub-routes of the start page (path `catalog` below `/`), so the +/// URL stays `/catalog`, but a deep link builds the stack [home, catalog] and +/// the system back button (Android) returns to the start page instead of +/// closing the app. final List catalogRoutes = [ GoRoute( - path: AppRoutes.catalog, + path: AppRoutes.catalogSegment, builder: (context, state) => CatalogSearchScreen( initialQuery: parseCatalogQuery(state.uri.queryParameters), ), diff --git a/app/lib/features/catalog/presentation/search/catalog_search_view.dart b/app/lib/features/catalog/presentation/search/catalog_search_view.dart index b48fa86..8ebaeef 100644 --- a/app/lib/features/catalog/presentation/search/catalog_search_view.dart +++ b/app/lib/features/catalog/presentation/search/catalog_search_view.dart @@ -424,19 +424,25 @@ class _CatalogSearchViewState extends ConsumerState { ), ); case CatalogSearchStatus.error: - final offline = state.failure is CatalogNetworkFailure; + final failure = state.failure; + final offline = failure is CatalogNetworkFailure; + // Rejected input does not get better by repeating it: no retry. + final invalid = failure is CatalogInvalidInputFailure; return AppStateMessage( key: const Key('catalog_error'), icon: offline ? Icons.wifi_off : Icons.error_outline, title: offline ? l10n.catalogSearchErrorOffline + : invalid + ? l10n.catalogSearchErrorInvalid : l10n.catalogSearchErrorGeneric, actions: [ - AppStateAction( - key: const Key('catalog_error_retry'), - label: l10n.catalogSearchRetry, - onPressed: () => unawaited(_controller.retry()), - ), + if (!invalid) + AppStateAction( + key: const Key('catalog_error_retry'), + label: l10n.catalogSearchRetry, + onPressed: () => unawaited(_controller.retry()), + ), ], ); case CatalogSearchStatus.empty: diff --git a/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart b/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart index 5e02f3b..db66c1b 100644 --- a/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart +++ b/app/lib/features/catalog/presentation/search/widgets/catalog_filter_panel.dart @@ -174,10 +174,9 @@ class _TermChoice extends StatelessWidget { data: (list) { final sorted = [...list] ..sort( - (a, b) => a - .localizedName(language) - .toLowerCase() - .compareTo(b.localizedName(language).toLowerCase()), + (a, b) => + _sortKey(a.localizedName(language)) + .compareTo(_sortKey(b.localizedName(language))), ); final full = selected.length >= CatalogSearchLimits.maxTerms; return Wrap( @@ -239,3 +238,33 @@ class _TermChoice extends StatelessWidget { ); } } + +/// Sort key that puts umlauts and accents next to their base letters. +String _sortKey(String name) { + const map = { + 'ä': 'a', + 'ö': 'o', + 'ü': 'u', + 'ß': 'ss', + 'é': 'e', + 'è': 'e', + 'ê': 'e', + 'á': 'a', + 'à': 'a', + 'â': 'a', + 'í': 'i', + 'ó': 'o', + 'ô': 'o', + 'ú': 'u', + 'ç': 'c', + 'ñ': 'n', + 'æ': 'ae', + 'œ': 'oe', + }; + final buffer = StringBuffer(); + for (final rune in name.toLowerCase().runes) { + final char = String.fromCharCode(rune); + buffer.write(map[char] ?? char); + } + return buffer.toString(); +} diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 1468e3f..025862b 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -115,5 +115,6 @@ "catalogSearchEmptyCatalog": "Der Katalog ist noch leer.", "catalogSearchReportGame": "Fehlendes Spiel melden", "catalogSearchErrorOffline": "Keine Verbindung. Für die Suche brauchst du Internet.", - "catalogSearchErrorGeneric": "Die Suche ist gerade nicht möglich." + "catalogSearchErrorGeneric": "Die Suche ist gerade nicht möglich.", + "catalogSearchErrorInvalid": "Diese Suche wird nicht unterstützt. Ändere den Suchbegriff oder die Filter." } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index abc4c88..b51f889 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -169,9 +169,9 @@ "catalogSearchRetry": "Try again", "@catalogSearchRetry": {"description": "Retry button"}, "catalogSearchResultCount": "{count, plural, =0{No games} =1{1 game} other{{count} games}}", - "@catalogSearchResultCount": {"description": "Number of hits above the list", "placeholders": {"count": {"type": "int"}}}, + "@catalogSearchResultCount": {"description": "Number of hits above the list", "placeholders": {"count": {"type": "int", "format": "decimalPattern"}}}, "catalogSearchAnnouncement": "{count, plural, =0{No games found} =1{1 game found} other{{count} games found}}", - "@catalogSearchAnnouncement": {"description": "Screen reader announcement after a finished search", "placeholders": {"count": {"type": "int"}}}, + "@catalogSearchAnnouncement": {"description": "Screen reader announcement after a finished search", "placeholders": {"count": {"type": "int", "format": "decimalPattern"}}}, "catalogSearchAlsoKnownAs": "also: {title}", "@catalogSearchAlsoKnownAs": {"description": "Alternative title that matched", "placeholders": {"title": {"type": "String"}}}, "catalogSearchPlayersCount": "{count, plural, =1{1 player} other{{count} players}}", @@ -205,5 +205,7 @@ "catalogSearchErrorOffline": "No connection. You need internet for the search.", "@catalogSearchErrorOffline": {"description": "Error state: offline"}, "catalogSearchErrorGeneric": "Search is not available right now.", - "@catalogSearchErrorGeneric": {"description": "Error state: other errors"} + "@catalogSearchErrorGeneric": {"description": "Error state: other errors"}, + "catalogSearchErrorInvalid": "This search is not supported. Change the search text or the filters.", + "@catalogSearchErrorInvalid": {"description": "Error state: the backend rejected the input (no retry offered)"} } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 717eb9d..5030497 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -793,6 +793,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Search is not available right now.'** String get catalogSearchErrorGeneric; + + /// Error state: the backend rejected the input (no retry offered) + /// + /// In en, this message translates to: + /// **'This search is not supported. Change the search text or the filters.'** + String get catalogSearchErrorInvalid; } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index e5d050f..d830aa4 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -359,10 +359,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String catalogSearchResultCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count Spiele', + other: '$countString Spiele', one: '1 Spiel', zero: 'Keine Spiele', ); @@ -371,10 +375,14 @@ class AppLocalizationsDe extends AppLocalizations { @override String catalogSearchAnnouncement(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count Spiele gefunden', + other: '$countString Spiele gefunden', one: '1 Spiel gefunden', zero: 'Keine Spiele gefunden', ); @@ -457,4 +465,8 @@ class AppLocalizationsDe extends AppLocalizations { @override String get catalogSearchErrorGeneric => 'Die Suche ist gerade nicht möglich.'; + + @override + String get catalogSearchErrorInvalid => + 'Diese Suche wird nicht unterstützt. Ändere den Suchbegriff oder die Filter.'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index a6497cb..6d68ced 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -355,10 +355,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String catalogSearchResultCount(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count games', + other: '$countString games', one: '1 game', zero: 'No games', ); @@ -367,10 +371,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String catalogSearchAnnouncement(int count) { + final intl.NumberFormat countNumberFormat = + intl.NumberFormat.decimalPattern(localeName); + final String countString = countNumberFormat.format(count); + String _temp0 = intl.Intl.pluralLogic( count, locale: localeName, - other: '$count games found', + other: '$countString games found', one: '1 game found', zero: 'No games found', ); @@ -451,4 +459,8 @@ class AppLocalizationsEn extends AppLocalizations { @override String get catalogSearchErrorGeneric => 'Search is not available right now.'; + + @override + String get catalogSearchErrorInvalid => + 'This search is not supported. Change the search text or the filters.'; } diff --git a/app/test/core/logging/app_logger_test.dart b/app/test/core/logging/app_logger_test.dart index baac91c..a64a5ef 100644 --- a/app/test/core/logging/app_logger_test.dart +++ b/app/test/core/logging/app_logger_test.dart @@ -29,4 +29,19 @@ void main() { expect(records.last.level, Level.SEVERE); expect(records.last.loggerName, 'Test'); }); + + test('QUE-04: Supabase library records below INFO are not written', () { + expect( + AppLogger.shouldWrite(LogRecord(Level.FINE, 'x', 'supabase.postgrest')), + isFalse, + ); + expect( + AppLogger.shouldWrite(LogRecord(Level.WARNING, 'x', 'supabase.auth')), + isTrue, + ); + expect( + AppLogger.shouldWrite(LogRecord(Level.FINE, 'x', 'CatalogRepository')), + isTrue, + ); + }); } diff --git a/app/test/core/widgets/app_state_message_test.dart b/app/test/core/widgets/app_state_message_test.dart index ad570f1..b851488 100644 --- a/app/test/core/widgets/app_state_message_test.dart +++ b/app/test/core/widgets/app_state_message_test.dart @@ -160,31 +160,5 @@ void main() { 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/catalog_test_support.dart b/app/test/features/catalog/catalog_test_support.dart index 2922532..3bf0e62 100644 --- a/app/test/features/catalog/catalog_test_support.dart +++ b/app/test/features/catalog/catalog_test_support.dart @@ -16,6 +16,7 @@ import '../../helpers/fakes.dart'; const genreStrategy = '11111111-1111-4111-8111-111111111111'; const genreFamily = '22222222-2222-4222-8222-222222222222'; const mechanicDice = '33333333-3333-4333-8333-333333333333'; +const mechanicCards = '44444444-4444-4444-8444-444444444444'; const testGenres = [ CatalogTerm(id: genreStrategy, nameDe: 'Strategie', nameEn: 'Strategy'), 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 706e443..dcb547c 100644 --- a/app/test/features/catalog/data/supabase_catalog_repository_test.dart +++ b/app/test/features/catalog/data/supabase_catalog_repository_test.dart @@ -126,6 +126,31 @@ void main() { expect(body(requests.single), isNot(contains('p_mechanic_ids'))); }); + test( + 'KAT-01 Kriterium 4: several genre and mechanic IDs are all sent', + () async { + await repository.search( + const CatalogSearchQuery( + filters: CatalogFilters( + genreIds: {genreStrategy, genreFamily}, + mechanicIds: {mechanicDice, mechanicCards}, + ), + ), + offset: 0, + ); + + final sent = body(requests.single); + expect( + sent['p_genre_ids'], + unorderedEquals([genreStrategy, genreFamily]), + ); + expect( + sent['p_mechanic_ids'], + unorderedEquals([mechanicDice, mechanicCards]), + ); + }, + ); + test('KAT-01 Kriterium 4: mechanics are sent as p_mechanic_ids', () async { await repository.search( const CatalogSearchQuery( @@ -285,7 +310,7 @@ void main() { expect(result.errorOrNull, isA()); }); - test('QUE-04: logs the failure kind, never the search text', () async { + test('QUE-04: the repository logger never logs the search text', () async { const secret = 'geheimer-suchbegriff'; final records = []; Logger.root.level = Level.ALL; @@ -304,6 +329,17 @@ void main() { offset: 0, ); + // Any other library may echo the text in its own debug logs (postgrest + // does at FINE); AppLogger drops those, so none of them is written. + for (final record in records.where( + (r) => '${r.message} ${r.error}'.contains(secret), + )) { + expect( + AppLogger.shouldWrite(record), + isFalse, + reason: '${record.loggerName} would write the search text', + ); + } final catalog = records.where((r) => r.loggerName == 'CatalogRepository'); expect(catalog, isNotEmpty); for (final record in catalog) { diff --git a/app/test/features/catalog/domain/catalog_domain_test.dart b/app/test/features/catalog/domain/catalog_domain_test.dart index 6ad95d9..5f76b79 100644 --- a/app/test/features/catalog/domain/catalog_domain_test.dart +++ b/app/test/features/catalog/domain/catalog_domain_test.dart @@ -41,6 +41,35 @@ void main() { expect(CatalogSearchQuery.cleanText('a' * 150), hasLength(100)); }); + test( + 'Eingabegrenzen: the text is cut to 10 words (rest dropped silently)', + () { + expect( + CatalogSearchQuery.cleanText('a b c d e f g h i j k l'), + 'a b c d e f g h i j', + ); + expect( + CatalogSearchQuery.cleanText('a b c d e f g h i j'), + 'a b c d e f g h i j', + ); + expect(CatalogSearchQuery.cleanText('a b c'), 'a b c'); + }, + ); + + test('Eingabegrenzen: punctuation separates words like in the backend', () { + // Hyphen and colon split words: 12 words, not 6. + expect( + CatalogSearchQuery.cleanText('a-b c:d e-f g-h i-j k-l'), + 'a-b c:d e-f g-h i-j', + ); + expect( + CatalogSearchQuery.cleanText( + 'Æon’s End: Ünïcode Wörter ß 1 2 3 4 5 6 7', + ), + 'Æon’s End: Ünïcode Wörter ß 1 2 3 4', + ); + }); + test('KAT-01 Kriterium 4: activeFilterCount counts every filter value', () { const filters = CatalogFilters( players: 3, 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 index 05663f2..558c214 100644 --- a/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart +++ b/app/test/features/catalog/presentation/search/catalog_search_behavior_test.dart @@ -3,6 +3,7 @@ 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 'package:shuffle/features/catalog/domain/catalog_term.dart'; import '../../catalog_test_support.dart'; import 'catalog_search_test_support.dart'; @@ -142,6 +143,34 @@ void main() { expect(de, lessThan(de2)); }); + testWidgets('terms with umlauts sort next to their base letters', ( + tester, + ) async { + final repository = FakeCatalogRepository( + all: [game(1)], + genres: const [ + CatalogTerm(id: genreStrategy, nameDe: 'Zocken', nameEn: 'Z'), + CatalogTerm(id: genreFamily, nameDe: 'Ökonomie', nameEn: 'E'), + CatalogTerm( + id: '55555555-5555-4555-8555-555555555555', + nameDe: 'Party', + nameEn: 'P', + ), + ], + ); + await pumpView(tester, repository); + await openFilterSheet(tester); + + final party = tester.getTopLeft(find.text('Party')); + final oekonomie = tester.getTopLeft(find.text('Ökonomie')); + final zocken = tester.getTopLeft(find.text('Zocken')); + bool before(Offset a, Offset b) => + a.dy < b.dy || (a.dy == b.dy && a.dx < b.dx); + // Ö sorts as O: Ökonomie, Party, Zocken. + expect(before(oekonomie, party), isTrue); + expect(before(party, zocken), isTrue); + }); + 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')); 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 d3b1637..ae9ff5e 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 @@ -302,6 +302,31 @@ void main() { expect(state().loadingMore, isFalse); }); + run('KAT-01 Kriterium 5: a stale loadMore answer is not appended', ( + 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(); + unawaited(controller().loadMore()); + await tester.pump(); + + // A new search starts while the next page is still on its way ... + controller().submitText('neu'); + await tester.pump(); + // ... and the old page arrives before the new answer. + repo.complete(1, items: [game(2), game(3)], totalCount: 3); + await tester.pump(); + expect(state().items.map((g) => g.id), ['game-1']); + + repo.complete(2, items: [game(9, title: 'Neu')], totalCount: 1); + await tester.pump(); + expect(state().items.map((g) => g.title), ['Neu']); + }); + run('KAT-01 Kriterium 5: an error while loading more keeps the hits', ( tester, ) async { 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 index 5038239..e42cd99 100644 --- a/app/test/features/catalog/presentation/search/catalog_search_screen_test.dart +++ b/app/test/features/catalog/presentation/search/catalog_search_screen_test.dart @@ -82,6 +82,26 @@ void main() { expect(repository.calls.first.query.isEmpty, isTrue); }); + testWidgets('Android: system back from a deep link returns to the start ' + 'page instead of closing the app', (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'); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('catalog_search_screen')), findsOneWidget); + + // The framework's system back; `false` would mean the app closes. + final handled = await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + + expect(handled, isTrue); + expect(find.byKey(const Key('home_screen')), findsOneWidget); + }); + testWidgets('start page entry opens the search and back returns', ( tester, ) async { @@ -133,7 +153,13 @@ void main() { ), ); final router = buildTestRouter( - routes: catalogRoutes, + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const SizedBox(), + routes: catalogRoutes, + ), + ], initialLocation: initialLocation, ); addTearDown(router.dispose); 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 99cf86d..042f68d 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 @@ -362,6 +362,23 @@ void main() { expect(find.byKey(const Key('catalog_error')), findsNothing); }); + testWidgets('rejected input shows an own text and offers no retry', ( + tester, + ) async { + final repository = FakeCatalogRepository() + ..failWith = const CatalogFailure.invalidInput(); + + await pumpView(tester, repository); + + expect( + find.text( + 'Diese Suche wird nicht unterstützt. Ändere den Suchbegriff oder die Filter.', + ), + findsOneWidget, + ); + expect(find.byKey(const Key('catalog_error_retry')), findsNothing); + }); + testWidgets('Zustände Kriterium 2: other errors show the generic text', ( tester, ) async { diff --git a/docs/design-system.md b/docs/design-system.md index b9b0239..d56eb0a 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -87,13 +87,13 @@ Formular-Komponenten (Texte von außen, Tests gegen die Guidelines in Hell und D | Komponente | Zweck und API (Kurzform) | |---|---| | `AppStateMessage` | Leer-/Fehlerzustand: `icon` (dekorativ, für Screenreader ausgeblendet), `title`, optional `message`, `actions` (`AppStateAction(label, onPressed, key)`; die erste Aktion ist tonal hervorgehoben, weitere umrandet; alle 48 dp). Titel und Hinweis sind eine Live-Region. | -| `AppListFooter` | Fuß einer seitenweise geladenen Liste: `isLoading` (beschrifteter Fortschrittskreis), `hasMore` (Schaltfläche „Weitere laden“, per Tastatur und Screenreader erreichbar, optional `loadMoreFocusNode`), `errorMessage` + Wiederholen (bereits geladene Einträge bleiben). Vorrang: Fehler vor Laden vor „Weitere laden“. Automatisches Nachladen ist Sache des Aufrufers; die Schaltfläche bleibt der Weg für Tastatur und Screenreader. | +| `AppListFooter` | Fuß einer seitenweise geladenen Liste: `isLoading` (beschrifteter Fortschrittskreis), `hasMore` (Schaltfläche „Weitere laden“, per Tastatur und Screenreader erreichbar), `errorMessage` + Wiederholen (bereits geladene Einträge bleiben). Vorrang: Fehler vor Laden vor „Weitere laden“. Automatisches Nachladen ist Sache des Aufrufers; die Schaltfläche bleibt der Weg für Tastatur und Screenreader. | **Katalogsuche (`features/catalog/presentation/search/`)** – Aufbau und Verhalten, Entscheidungen der Umsetzung (Spec #3 § 5.6): - Kompakt (< 600 dp) und mittel (600–1023 dp): Suchfeld, darunter die Schaltfläche „Filter (n)“ mit Bottom Sheet (Änderungen wirken sofort, „Fertig“ schließt); ab 1024 dp feste Seitenleiste (320 dp) links, Trefferliste rechts mit höchstens 720 dp Breite. - Einfachauswahl (Spielerzahl, Dauer, Alter) als beschriftete Auswahlfelder mit „Beliebig“; Genre und Mechanik als Filter-Chips, nach lokalisiertem Namen sortiert. Fehler beim Laden der Begriffe betrifft nur diese beiden Bereiche („Erneut versuchen“). -- Trefferkachel: `GameCover` klein (Platzhalter), Titel (2 Zeilen), „auch: …“, Jahr · Verlage (zwei, sonst „+n“), Kurzangaben; ein zusammengefasstes Semantics-Label. Ohne `onTap` ist die Kachel kein Button, aber per Code fokussierbar (Fokusring 3 dp, kein Tab-Stopp). +- Trefferkachel: `GameCover` klein (Platzhalter), Titel (2 Zeilen), „auch: …“, Jahr · Verlage (zwei, sonst „+n“), Kurzangaben; ein zusammengefasstes Semantics-Label. Ohne `onTap` ist die Kachel kein Button, aber ein Tab-Stopp mit Fokusring (3 dp): Tastaturnutzer durchlaufen so die Liste, was sie scrollt und das Nachladen auslöst (die Liste wird lazy gebaut). Im Screenreader-Durchgang bewerten, ob die vielen Tab-Stopps stören. - Trefferzahl über der Liste; Ansage der Trefferzahl 600 ms nach Abschluss der letzten Suche (`SemanticsService.sendAnnouncement`), nie während des Tippens. - Nach „Weitere laden“ (bzw. „Erneut versuchen“ im Listenfuß) springt der Fokus auf die erste neu geladene Kachel. Automatisches Nachladen, wenn weniger als etwa fünf Kacheln unterhalb des sichtbaren Bereichs liegen; nach einem Fehler nur noch über die Schaltfläche (keine Wiederholungsschleife).