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;
|
||||
}
|
||||
|
||||
/// 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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
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';
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ abstract final class CatalogSearchLimits {
|
|||
/// "Suitable from X years" steps.
|
||||
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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<RouteBase> catalogRoutes = [
|
||||
GoRoute(
|
||||
path: AppRoutes.catalog,
|
||||
path: AppRoutes.catalogSegment,
|
||||
builder: (context, state) => CatalogSearchScreen(
|
||||
initialQuery: parseCatalogQuery(state.uri.queryParameters),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -424,14 +424,20 @@ class _CatalogSearchViewState extends ConsumerState<CatalogSearchView> {
|
|||
),
|
||||
);
|
||||
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: [
|
||||
if (!invalid)
|
||||
AppStateAction(
|
||||
key: const Key('catalog_error_retry'),
|
||||
label: l10n.catalogSearchRetry,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)"}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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<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';
|
||||
final records = <LogRecord>[];
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const SizedBox(),
|
||||
routes: catalogRoutes,
|
||||
),
|
||||
],
|
||||
initialLocation: initialLocation,
|
||||
);
|
||||
addTearDown(router.dispose);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue