feat(catalog): review fixes for catalog search
All checks were successful
All checks were successful
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 <noreply@anthropic.com>
This commit is contained in:
parent
ae099eb3d7
commit
25212929ce
24 changed files with 310 additions and 62 deletions
|
|
@ -48,7 +48,17 @@ class AppLogger {
|
||||||
_subscription = null;
|
_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) {
|
static void _write(LogRecord record) {
|
||||||
|
if (!shouldWrite(record)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
developer.log(
|
developer.log(
|
||||||
record.message,
|
record.message,
|
||||||
time: record.time,
|
time: record.time,
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ GoRouter appRouter(Ref ref) {
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.home,
|
path: AppRoutes.home,
|
||||||
builder: (context, state) => const HomeScreen(),
|
builder: (context, state) => const HomeScreen(),
|
||||||
|
routes: catalogRoutes,
|
||||||
),
|
),
|
||||||
...authRoutes,
|
...authRoutes,
|
||||||
...catalogRoutes,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
ref.onDispose(router.dispose);
|
ref.onDispose(router.dispose);
|
||||||
|
|
|
||||||
|
|
@ -48,4 +48,4 @@ final class AppRouterProvider
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$appRouterHash() => r'e5b9cf23cd96b28b8941552d226d592a1e06efc1';
|
String _$appRouterHash() => r'cb20752ab64cf1ac56d3bfce5401e187246f5ec7';
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ abstract final class AppRoutes {
|
||||||
/// Katalogsuche, öffentlich (Spec #3 § 5.5).
|
/// Katalogsuche, öffentlich (Spec #3 § 5.5).
|
||||||
static const catalog = '/catalog';
|
static const catalog = '/catalog';
|
||||||
|
|
||||||
|
/// Segment of [catalog] below [home] (nested route).
|
||||||
|
static const catalogSegment = 'catalog';
|
||||||
|
|
||||||
// Auth (Spec #1 § 5.3)
|
// Auth (Spec #1 § 5.3)
|
||||||
static const login = '/login';
|
static const login = '/login';
|
||||||
static const register = '/register';
|
static const register = '/register';
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ class AppListFooter extends StatelessWidget {
|
||||||
required this.onRetry,
|
required this.onRetry,
|
||||||
super.key,
|
super.key,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.loadMoreFocusNode,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
|
|
@ -32,10 +31,6 @@ class AppListFooter extends StatelessWidget {
|
||||||
final VoidCallback onLoadMore;
|
final VoidCallback onLoadMore;
|
||||||
final VoidCallback onRetry;
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
/// Focus node of the "load more" button (callers restore focus elsewhere
|
|
||||||
/// after loading).
|
|
||||||
final FocusNode? loadMoreFocusNode;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final message = errorMessage;
|
final message = errorMessage;
|
||||||
|
|
@ -79,7 +74,6 @@ class AppListFooter extends StatelessWidget {
|
||||||
} else if (hasMore) {
|
} else if (hasMore) {
|
||||||
content = OutlinedButton(
|
content = OutlinedButton(
|
||||||
key: const Key('list_footer_load_more'),
|
key: const Key('list_footer_load_more'),
|
||||||
focusNode: loadMoreFocusNode,
|
|
||||||
onPressed: onLoadMore,
|
onPressed: onLoadMore,
|
||||||
child: Text(loadMoreLabel),
|
child: Text(loadMoreLabel),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ abstract final class CatalogSearchLimits {
|
||||||
/// "Suitable from X years" steps.
|
/// "Suitable from X years" steps.
|
||||||
static const List<int> ageSteps = [6, 8, 10, 12, 14, 16, 18];
|
static const List<int> 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.
|
/// Most genres or mechanics that can be selected at once.
|
||||||
static const int maxTerms = 20;
|
static const int maxTerms = 20;
|
||||||
|
|
||||||
|
|
@ -77,12 +80,25 @@ abstract class CatalogSearchQuery with _$CatalogSearchQuery {
|
||||||
|
|
||||||
const 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) {
|
static String cleanText(String raw) {
|
||||||
final trimmed = raw.trim();
|
var text = raw.trim();
|
||||||
return trimmed.length > CatalogSearchLimits.maxTextLength
|
if (text.length > CatalogSearchLimits.maxTextLength) {
|
||||||
? trimmed.substring(0, CatalogSearchLimits.maxTextLength).trim()
|
text = text.substring(0, CatalogSearchLimits.maxTextLength).trim();
|
||||||
: trimmed;
|
}
|
||||||
|
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.
|
/// Whether the text is long enough to count as a search term.
|
||||||
|
|
|
||||||
|
|
@ -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';
|
import 'package:shuffle/features/catalog/presentation/search/catalog_search_screen.dart';
|
||||||
|
|
||||||
/// Routes of the catalog (Spec #3 § 5.5). `/catalog` is public.
|
/// 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<RouteBase> catalogRoutes = [
|
final List<RouteBase> catalogRoutes = [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: AppRoutes.catalog,
|
path: AppRoutes.catalogSegment,
|
||||||
builder: (context, state) => CatalogSearchScreen(
|
builder: (context, state) => CatalogSearchScreen(
|
||||||
initialQuery: parseCatalogQuery(state.uri.queryParameters),
|
initialQuery: parseCatalogQuery(state.uri.queryParameters),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -424,19 +424,25 @@ class _CatalogSearchViewState extends ConsumerState<CatalogSearchView> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
case CatalogSearchStatus.error:
|
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(
|
return AppStateMessage(
|
||||||
key: const Key('catalog_error'),
|
key: const Key('catalog_error'),
|
||||||
icon: offline ? Icons.wifi_off : Icons.error_outline,
|
icon: offline ? Icons.wifi_off : Icons.error_outline,
|
||||||
title: offline
|
title: offline
|
||||||
? l10n.catalogSearchErrorOffline
|
? l10n.catalogSearchErrorOffline
|
||||||
|
: invalid
|
||||||
|
? l10n.catalogSearchErrorInvalid
|
||||||
: l10n.catalogSearchErrorGeneric,
|
: l10n.catalogSearchErrorGeneric,
|
||||||
actions: [
|
actions: [
|
||||||
AppStateAction(
|
if (!invalid)
|
||||||
key: const Key('catalog_error_retry'),
|
AppStateAction(
|
||||||
label: l10n.catalogSearchRetry,
|
key: const Key('catalog_error_retry'),
|
||||||
onPressed: () => unawaited(_controller.retry()),
|
label: l10n.catalogSearchRetry,
|
||||||
),
|
onPressed: () => unawaited(_controller.retry()),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
case CatalogSearchStatus.empty:
|
case CatalogSearchStatus.empty:
|
||||||
|
|
|
||||||
|
|
@ -174,10 +174,9 @@ class _TermChoice extends StatelessWidget {
|
||||||
data: (list) {
|
data: (list) {
|
||||||
final sorted = [...list]
|
final sorted = [...list]
|
||||||
..sort(
|
..sort(
|
||||||
(a, b) => a
|
(a, b) =>
|
||||||
.localizedName(language)
|
_sortKey(a.localizedName(language))
|
||||||
.toLowerCase()
|
.compareTo(_sortKey(b.localizedName(language))),
|
||||||
.compareTo(b.localizedName(language).toLowerCase()),
|
|
||||||
);
|
);
|
||||||
final full = selected.length >= CatalogSearchLimits.maxTerms;
|
final full = selected.length >= CatalogSearchLimits.maxTerms;
|
||||||
return Wrap(
|
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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -115,5 +115,6 @@
|
||||||
"catalogSearchEmptyCatalog": "Der Katalog ist noch leer.",
|
"catalogSearchEmptyCatalog": "Der Katalog ist noch leer.",
|
||||||
"catalogSearchReportGame": "Fehlendes Spiel melden",
|
"catalogSearchReportGame": "Fehlendes Spiel melden",
|
||||||
"catalogSearchErrorOffline": "Keine Verbindung. Für die Suche brauchst du Internet.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -169,9 +169,9 @@
|
||||||
"catalogSearchRetry": "Try again",
|
"catalogSearchRetry": "Try again",
|
||||||
"@catalogSearchRetry": {"description": "Retry button"},
|
"@catalogSearchRetry": {"description": "Retry button"},
|
||||||
"catalogSearchResultCount": "{count, plural, =0{No games} =1{1 game} other{{count} games}}",
|
"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": "{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": "also: {title}",
|
||||||
"@catalogSearchAlsoKnownAs": {"description": "Alternative title that matched", "placeholders": {"title": {"type": "String"}}},
|
"@catalogSearchAlsoKnownAs": {"description": "Alternative title that matched", "placeholders": {"title": {"type": "String"}}},
|
||||||
"catalogSearchPlayersCount": "{count, plural, =1{1 player} other{{count} players}}",
|
"catalogSearchPlayersCount": "{count, plural, =1{1 player} other{{count} players}}",
|
||||||
|
|
@ -205,5 +205,7 @@
|
||||||
"catalogSearchErrorOffline": "No connection. You need internet for the search.",
|
"catalogSearchErrorOffline": "No connection. You need internet for the search.",
|
||||||
"@catalogSearchErrorOffline": {"description": "Error state: offline"},
|
"@catalogSearchErrorOffline": {"description": "Error state: offline"},
|
||||||
"catalogSearchErrorGeneric": "Search is not available right now.",
|
"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)"}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -793,6 +793,12 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Search is not available right now.'**
|
/// **'Search is not available right now.'**
|
||||||
String get catalogSearchErrorGeneric;
|
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
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -359,10 +359,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String catalogSearchResultCount(int count) {
|
String catalogSearchResultCount(int count) {
|
||||||
|
final intl.NumberFormat countNumberFormat =
|
||||||
|
intl.NumberFormat.decimalPattern(localeName);
|
||||||
|
final String countString = countNumberFormat.format(count);
|
||||||
|
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: '$count Spiele',
|
other: '$countString Spiele',
|
||||||
one: '1 Spiel',
|
one: '1 Spiel',
|
||||||
zero: 'Keine Spiele',
|
zero: 'Keine Spiele',
|
||||||
);
|
);
|
||||||
|
|
@ -371,10 +375,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String catalogSearchAnnouncement(int count) {
|
String catalogSearchAnnouncement(int count) {
|
||||||
|
final intl.NumberFormat countNumberFormat =
|
||||||
|
intl.NumberFormat.decimalPattern(localeName);
|
||||||
|
final String countString = countNumberFormat.format(count);
|
||||||
|
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: '$count Spiele gefunden',
|
other: '$countString Spiele gefunden',
|
||||||
one: '1 Spiel gefunden',
|
one: '1 Spiel gefunden',
|
||||||
zero: 'Keine Spiele gefunden',
|
zero: 'Keine Spiele gefunden',
|
||||||
);
|
);
|
||||||
|
|
@ -457,4 +465,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get catalogSearchErrorGeneric => 'Die Suche ist gerade nicht möglich.';
|
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.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -355,10 +355,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String catalogSearchResultCount(int count) {
|
String catalogSearchResultCount(int count) {
|
||||||
|
final intl.NumberFormat countNumberFormat =
|
||||||
|
intl.NumberFormat.decimalPattern(localeName);
|
||||||
|
final String countString = countNumberFormat.format(count);
|
||||||
|
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: '$count games',
|
other: '$countString games',
|
||||||
one: '1 game',
|
one: '1 game',
|
||||||
zero: 'No games',
|
zero: 'No games',
|
||||||
);
|
);
|
||||||
|
|
@ -367,10 +371,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String catalogSearchAnnouncement(int count) {
|
String catalogSearchAnnouncement(int count) {
|
||||||
|
final intl.NumberFormat countNumberFormat =
|
||||||
|
intl.NumberFormat.decimalPattern(localeName);
|
||||||
|
final String countString = countNumberFormat.format(count);
|
||||||
|
|
||||||
String _temp0 = intl.Intl.pluralLogic(
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
count,
|
count,
|
||||||
locale: localeName,
|
locale: localeName,
|
||||||
other: '$count games found',
|
other: '$countString games found',
|
||||||
one: '1 game found',
|
one: '1 game found',
|
||||||
zero: 'No games found',
|
zero: 'No games found',
|
||||||
);
|
);
|
||||||
|
|
@ -451,4 +459,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get catalogSearchErrorGeneric => 'Search is not available right now.';
|
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.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,4 +29,19 @@ void main() {
|
||||||
expect(records.last.level, Level.SEVERE);
|
expect(records.last.level, Level.SEVERE);
|
||||||
expect(records.last.loggerName, 'Test');
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,31 +160,5 @@ void main() {
|
||||||
expect(find.byType(OutlinedButton), findsNothing);
|
expect(find.byType(OutlinedButton), findsNothing);
|
||||||
expect(find.byKey(const Key('list_footer_loading')), 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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import '../../helpers/fakes.dart';
|
||||||
const genreStrategy = '11111111-1111-4111-8111-111111111111';
|
const genreStrategy = '11111111-1111-4111-8111-111111111111';
|
||||||
const genreFamily = '22222222-2222-4222-8222-222222222222';
|
const genreFamily = '22222222-2222-4222-8222-222222222222';
|
||||||
const mechanicDice = '33333333-3333-4333-8333-333333333333';
|
const mechanicDice = '33333333-3333-4333-8333-333333333333';
|
||||||
|
const mechanicCards = '44444444-4444-4444-8444-444444444444';
|
||||||
|
|
||||||
const testGenres = [
|
const testGenres = [
|
||||||
CatalogTerm(id: genreStrategy, nameDe: 'Strategie', nameEn: 'Strategy'),
|
CatalogTerm(id: genreStrategy, nameDe: 'Strategie', nameEn: 'Strategy'),
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,31 @@ void main() {
|
||||||
expect(body(requests.single), isNot(contains('p_mechanic_ids')));
|
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 {
|
test('KAT-01 Kriterium 4: mechanics are sent as p_mechanic_ids', () async {
|
||||||
await repository.search(
|
await repository.search(
|
||||||
const CatalogSearchQuery(
|
const CatalogSearchQuery(
|
||||||
|
|
@ -285,7 +310,7 @@ void main() {
|
||||||
expect(result.errorOrNull, isA<CatalogUnexpectedFailure>());
|
expect(result.errorOrNull, isA<CatalogUnexpectedFailure>());
|
||||||
});
|
});
|
||||||
|
|
||||||
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';
|
const secret = 'geheimer-suchbegriff';
|
||||||
final records = <LogRecord>[];
|
final records = <LogRecord>[];
|
||||||
Logger.root.level = Level.ALL;
|
Logger.root.level = Level.ALL;
|
||||||
|
|
@ -304,6 +329,17 @@ void main() {
|
||||||
offset: 0,
|
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');
|
final catalog = records.where((r) => r.loggerName == 'CatalogRepository');
|
||||||
expect(catalog, isNotEmpty);
|
expect(catalog, isNotEmpty);
|
||||||
for (final record in catalog) {
|
for (final record in catalog) {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,35 @@ void main() {
|
||||||
expect(CatalogSearchQuery.cleanText('a' * 150), hasLength(100));
|
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', () {
|
test('KAT-01 Kriterium 4: activeFilterCount counts every filter value', () {
|
||||||
const filters = CatalogFilters(
|
const filters = CatalogFilters(
|
||||||
players: 3,
|
players: 3,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:shuffle/features/catalog/domain/catalog_failure.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_search_query.dart';
|
||||||
|
import 'package:shuffle/features/catalog/domain/catalog_term.dart';
|
||||||
|
|
||||||
import '../../catalog_test_support.dart';
|
import '../../catalog_test_support.dart';
|
||||||
import 'catalog_search_test_support.dart';
|
import 'catalog_search_test_support.dart';
|
||||||
|
|
@ -142,6 +143,34 @@ void main() {
|
||||||
expect(de, lessThan(de2));
|
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 {
|
testWidgets('QUE-05 Kriterium 4: English names and order', (tester) async {
|
||||||
final repository = FakeCatalogRepository(all: [game(1)]);
|
final repository = FakeCatalogRepository(all: [game(1)]);
|
||||||
await pumpView(tester, repository, locale: const Locale('en'));
|
await pumpView(tester, repository, locale: const Locale('en'));
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,31 @@ void main() {
|
||||||
expect(state().loadingMore, isFalse);
|
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', (
|
run('KAT-01 Kriterium 5: an error while loading more keeps the hits', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,26 @@ void main() {
|
||||||
expect(repository.calls.first.query.isEmpty, isTrue);
|
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', (
|
testWidgets('start page entry opens the search and back returns', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -133,7 +153,13 @@ void main() {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final router = buildTestRouter(
|
final router = buildTestRouter(
|
||||||
routes: catalogRoutes,
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
builder: (context, state) => const SizedBox(),
|
||||||
|
routes: catalogRoutes,
|
||||||
|
),
|
||||||
|
],
|
||||||
initialLocation: initialLocation,
|
initialLocation: initialLocation,
|
||||||
);
|
);
|
||||||
addTearDown(router.dispose);
|
addTearDown(router.dispose);
|
||||||
|
|
|
||||||
|
|
@ -362,6 +362,23 @@ void main() {
|
||||||
expect(find.byKey(const Key('catalog_error')), findsNothing);
|
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', (
|
testWidgets('Zustände Kriterium 2: other errors show the generic text', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|
|
||||||
|
|
@ -87,13 +87,13 @@ Formular-Komponenten (Texte von außen, Tests gegen die Guidelines in Hell und D
|
||||||
| Komponente | Zweck und API (Kurzform) |
|
| 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. |
|
| `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):
|
**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.
|
- 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“).
|
- 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.
|
- 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).
|
- 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).
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue