From e83c15e20145e2cd8c5889728bfa2211dda711a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 20:08:13 +0000 Subject: [PATCH 1/7] chore: add dart format to pre-commit check and CI Adds a `format` mise task (`dart format .`) and includes it in the `check` pre-commit gate. CI gains a `dart format --set-exit-if-changed .` step before `flutter analyze` so unformatted code is a hard failure on PRs rather than a silent auto-fix. The local task reformats in place; CI uses `--set-exit-if-changed` to fail the build without modifying files. --- .github/workflows/ci.yml | 3 +++ mise.toml | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 731db8b1..894ad653 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,9 @@ jobs: google-services-json: ${{ secrets.GOOGLE_SERVICES_JSON }} generate-mocks: 'true' + - name: Check formatting + run: dart format --set-exit-if-changed . + - name: Analyze code run: flutter analyze --no-fatal-infos diff --git a/mise.toml b/mise.toml index 64a7c873..d582cae8 100644 --- a/mise.toml +++ b/mise.toml @@ -44,9 +44,13 @@ echo "Grep with: grep -n 'FAILED\|ERROR' $TEST_LOG" exit $EXIT_CODE ''' +[tasks.format] +description = "Format all Dart code in place" +run = 'dart format .' + [tasks.check] -description = "Pre-commit gate: generate → analyze + test (run before every commit)" -depends = ['analyze', 'test'] +description = "Pre-commit gate: generate → format + analyze + test (run before every commit)" +depends = ['format', 'analyze', 'test'] run = 'echo "All checks passed"' [tasks."goldens:update"] From bf164273f95d51b9a229b22fd792426f06ba169a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 20:13:49 +0000 Subject: [PATCH 2/7] style: apply dart format to entire codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulk-format all Dart files with `dart format .`. No logic changes — line wrapping, trailing whitespace, and indentation only. --- lib/app_theme.dart | 13 +- lib/constants.dart | 3 +- lib/domain/services/drink_filter_service.dart | 8 +- lib/firebase_options.dart | 3 +- lib/main.dart | 29 +- lib/models/drink.dart | 19 +- lib/models/festival.dart | 64 ++- lib/providers/beer_provider.dart | 31 +- lib/router.dart | 9 +- lib/screens/about_screen.dart | 21 +- lib/screens/brewery_screen.dart | 12 +- lib/screens/drink_detail_screen.dart | 52 ++- lib/screens/drinks_screen.dart | 246 +++++----- lib/screens/festival_info_screen.dart | 26 +- lib/screens/style_screen.dart | 6 +- lib/services/analytics_service.dart | 187 ++++---- lib/services/beer_api_service.dart | 11 +- lib/services/environment_service.dart | 20 +- lib/services/festival_service.dart | 34 +- lib/services/storage_service.dart | 6 +- lib/utils/abv_strength_helper.dart | 2 +- lib/utils/category_color_helper.dart | 2 +- lib/utils/navigation_helpers.dart | 3 +- lib/utils/string_comparison_helper.dart | 12 +- lib/utils/style_description_helper.dart | 28 +- lib/utils/url_launcher_helper.dart | 2 +- lib/widgets/drink_card.dart | 22 +- lib/widgets/drink_list_section.dart | 11 +- lib/widgets/environment_badge.dart | 11 +- lib/widgets/festival_menu_sheets.dart | 18 +- test/accessibility_test.dart | 63 ++- test/analytics_service_test.dart | 8 +- test/app_theme_test.dart | 12 +- test/beer_api_service_test.dart | 59 ++- test/beer_provider_test.dart | 426 ++++++++++-------- test/beverage_type_helper_test.dart | 3 +- test/brewery_screen_test.dart | 46 +- .../api_festival_repository_test.dart | 3 +- .../services/drink_filter_service_test.dart | 118 +++-- .../services/drink_sort_service_test.dart | 21 +- test/drink_card_test.dart | 71 +-- test/drink_detail_screen_screenshot_test.dart | 11 +- test/drink_detail_screen_test.dart | 147 +++--- test/drinks_screen_style_filter_test.dart | 49 +- test/environment_badge_test.dart | 6 +- test/info_chip_test.dart | 2 +- test/main_test.dart | 36 +- test/models_test.dart | 154 +++++-- test/provider_test.dart | 88 ++-- test/router_test.dart | 213 ++++++--- test/screens_test.dart | 18 +- test/services_test.dart | 9 +- test/storage_service_test.dart | 15 +- test/string_comparison_helper_test.dart | 62 ++- test/string_formatting_helper_test.dart | 4 +- test/style_description_helper_test.dart | 12 +- test/style_screen_screenshot_test.dart | 16 +- test/style_screen_test.dart | 24 +- test/utf8_encoding_test.dart | 30 +- test/utils/navigation_helpers_test.dart | 10 +- test/utils/widget_builders_test.dart | 30 +- test/utils_test.dart | 90 ++-- test/widgets/breadcrumb_bar_test.dart | 21 +- test/widgets/festival_menu_sheets_test.dart | 73 +-- test/widgets/overflow_menu_test.dart | 7 +- test/widgets_test.dart | 62 ++- 66 files changed, 1778 insertions(+), 1152 deletions(-) diff --git a/lib/app_theme.dart b/lib/app_theme.dart index 8be15045..2f9db69c 100644 --- a/lib/app_theme.dart +++ b/lib/app_theme.dart @@ -85,7 +85,8 @@ ThemeData buildAppTheme(Brightness brightness) { final colorScheme = ColorScheme.fromSeed( seedColor: appSeedColor, brightness: brightness, - primary: brightness == Brightness.light ? appSeedColor : const Color(0xFF8FA3E8), + primary: + brightness == Brightness.light ? appSeedColor : const Color(0xFF8FA3E8), onPrimary: Colors.white, ); final textTheme = buildAppTextTheme(colorScheme); @@ -94,14 +95,18 @@ ThemeData buildAppTheme(Brightness brightness) { textTheme: textTheme, useMaterial3: true, appBarTheme: AppBarTheme( - backgroundColor: brightness == Brightness.light ? appSeedColor : colorScheme.surface, - foregroundColor: brightness == Brightness.light ? Colors.white : colorScheme.onSurface, + backgroundColor: + brightness == Brightness.light ? appSeedColor : colorScheme.surface, + foregroundColor: + brightness == Brightness.light ? Colors.white : colorScheme.onSurface, elevation: 0, centerTitle: false, titleTextStyle: GoogleFonts.playfairDisplay( fontSize: 20, fontWeight: FontWeight.w700, - color: brightness == Brightness.light ? Colors.white : colorScheme.onSurface, + color: brightness == Brightness.light + ? Colors.white + : colorScheme.onSurface, ), ), navigationBarTheme: NavigationBarThemeData( diff --git a/lib/constants.dart b/lib/constants.dart index 3f3bdb7f..b5d3d815 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -7,4 +7,5 @@ library; /// GitHub repository URL for the Cambridge Beer Festival app /// /// Used for linking to the repository from About screen and Festival Info screen -const String kGithubUrl = 'https://github.com/richardthe3rd/cambridge-beer-festival-app'; +const String kGithubUrl = + 'https://github.com/richardthe3rd/cambridge-beer-festival-app'; diff --git a/lib/domain/services/drink_filter_service.dart b/lib/domain/services/drink_filter_service.dart index 563eb140..a5fd38d4 100644 --- a/lib/domain/services/drink_filter_service.dart +++ b/lib/domain/services/drink_filter_service.dart @@ -95,8 +95,8 @@ class DrinkFilterService { Set excludedAllergens, ) { if (excludedAllergens.isEmpty) return drinks; - return drinks.where((d) => - excludedAllergens.every((a) => (d.allergens[a] ?? 0) == 0)); + return drinks.where( + (d) => excludedAllergens.every((a) => (d.allergens[a] ?? 0) == 0)); } /// Filter drinks by search query @@ -167,8 +167,8 @@ class DrinkFilterService { } if (excludedAllergens.isNotEmpty) { - result = result.where((d) => - excludedAllergens.every((a) => (d.allergens[a] ?? 0) == 0)); + result = result.where( + (d) => excludedAllergens.every((a) => (d.allergens[a] ?? 0) == 0)); } if (searchQuery.isNotEmpty) { diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart index 426c6be0..ab264065 100644 --- a/lib/firebase_options.dart +++ b/lib/firebase_options.dart @@ -67,5 +67,4 @@ class DefaultFirebaseOptions { projectId: 'cambridge-beer-festival-app', storageBucket: 'cambridge-beer-festival-app.firebasestorage.app', ); - -} \ No newline at end of file +} diff --git a/lib/main.dart b/lib/main.dart index 3ac14e92..317548a0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -109,7 +109,8 @@ class ProviderInitializer extends StatefulWidget { State createState() => _ProviderInitializerState(); } -class _ProviderInitializerState extends State with WidgetsBindingObserver { +class _ProviderInitializerState extends State + with WidgetsBindingObserver { bool _initialized = false; @override @@ -216,8 +217,10 @@ class _ProviderInitializerState extends State with WidgetsB // If first segment is not a valid festival ID, redirect if (!provider.isValidFestivalId(firstSegment)) { // Preserve the rest of the path and query parameters - final restOfPath = segments.length > 1 ? '/${segments.sublist(1).join('/')}' : ''; - final queryString = currentUri.query.isNotEmpty ? '?${currentUri.query}' : ''; + final restOfPath = + segments.length > 1 ? '/${segments.sublist(1).join('/')}' : ''; + final queryString = + currentUri.query.isNotEmpty ? '?${currentUri.query}' : ''; router.go('/${provider.currentFestival.id}$restOfPath$queryString'); } } catch (e, stackTrace) { @@ -301,7 +304,8 @@ class _BeerFestivalHomeState extends State { // Try to use GoRouter navigation try { // Get festival ID from URL or fall back to provider - final festivalId = _festivalId ?? context.read().currentFestival.id; + final festivalId = + _festivalId ?? context.read().currentFestival.id; if (index == 0) { context.go(buildFestivalHome(festivalId)); @@ -436,8 +440,10 @@ class FavoritesScreen extends StatelessWidget { title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(provider.currentFestival.name, style: theme.textTheme.titleMedium), - Text('${favorites.length} favorites', style: theme.textTheme.bodySmall), + Text(provider.currentFestival.name, + style: theme.textTheme.titleMedium), + Text('${favorites.length} favorites', + style: theme.textTheme.bodySmall), ], ), actions: [ @@ -446,12 +452,14 @@ class FavoritesScreen extends StatelessWidget { ), body: favorites.isEmpty ? Semantics( - label: 'No favorites yet. Tap the heart icon on drinks you want to try.', + label: + 'No favorites yet. Tap the heart icon on drinks you want to try.', child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.favorite_border, size: 64, color: Colors.grey), + const Icon(Icons.favorite_border, + size: 64, color: Colors.grey), const SizedBox(height: 16), Text('No favorites yet', style: theme.textTheme.titleLarge), const SizedBox(height: 8), @@ -468,7 +476,10 @@ class FavoritesScreen extends StatelessWidget { return DrinkCard( key: ValueKey(drink.id), drink: drink, - onTap: () => navigateToRoute(context, buildDrinkDetailPath(festivalId, drink.category, drink.id)), + onTap: () => navigateToRoute( + context, + buildDrinkDetailPath( + festivalId, drink.category, drink.id)), onFavoriteTap: () => provider.toggleFavorite(drink), ); }, diff --git a/lib/models/drink.dart b/lib/models/drink.dart index eb52db83..2486e1a1 100644 --- a/lib/models/drink.dart +++ b/lib/models/drink.dart @@ -131,7 +131,9 @@ class Product { final normalized = veganValue.toLowerCase(); if (normalized == 'true' || normalized == '1' || normalized == 'yes') { parsedVegan = true; - } else if (normalized == 'false' || normalized == '0' || normalized == 'no') { + } else if (normalized == 'false' || + normalized == '0' || + normalized == 'no') { parsedVegan = false; } } @@ -171,33 +173,33 @@ class Product { AvailabilityStatus? get availabilityStatus { if (statusText == null) return null; final lower = statusText!.toLowerCase(); - + // Check for "sold out" or "run out" status first if (lower.contains('out') || lower.contains('sold')) { return AvailabilityStatus.out; } - + // Check for "not yet available" status - use more specific patterns if (lower.contains('not yet') || lower.contains('coming soon') || lower.contains('expected')) { return AvailabilityStatus.notYetAvailable; } - + // Check for available status if (lower.contains('plenty') || lower.contains('arrived') || lower.contains('available')) { return AvailabilityStatus.plenty; } - + // Check for low stock if (lower.contains('remaining') || lower.contains('nearly') || lower.contains('low')) { return AvailabilityStatus.low; } - + // Default to plenty if status text exists but doesn't match any pattern return AvailabilityStatus.plenty; } @@ -205,8 +207,9 @@ class Product { /// Returns allergen list as a formatted string String? get allergenText { if (allergens.isEmpty) return null; - final allergenList = - allergens.entries.where((e) => e.value == 1 && e.key.isNotEmpty).map((e) { + final allergenList = allergens.entries + .where((e) => e.value == 1 && e.key.isNotEmpty) + .map((e) { // Capitalize first letter return e.key[0].toUpperCase() + e.key.substring(1); }).toList(); diff --git a/lib/models/festival.dart b/lib/models/festival.dart index 45797738..4c059d37 100644 --- a/lib/models/festival.dart +++ b/lib/models/festival.dart @@ -2,10 +2,13 @@ enum FestivalStatus { /// Festival is currently running (between start and end dates) live, + /// Festival is coming up (start date is in the future) upcoming, + /// Festival was the most recent one to end mostRecent, + /// Festival has ended and is not the most recent past, } @@ -69,10 +72,11 @@ class Festival { websiteUrl: json['website_url'] as String?, hours: (json['hours'] as Map?) ?.map((key, value) => MapEntry(key, value as String)), - availableBeverageTypes: (json['available_beverage_types'] as List?) - ?.map((e) => e as String) - .toList() ?? - const ['beer'], + availableBeverageTypes: + (json['available_beverage_types'] as List?) + ?.map((e) => e as String) + .toList() ?? + const ['beer'], dataBaseUrl: json['data_base_url'] as String, isActive: json['is_active'] as bool? ?? false, charityPartnerName: json['charity_partner_name'] as String?, @@ -97,8 +101,10 @@ class Festival { 'available_beverage_types': availableBeverageTypes, 'data_base_url': dataBaseUrl, 'is_active': isActive, - if (charityPartnerName != null) 'charity_partner_name': charityPartnerName, - if (charityDonationUrl != null) 'charity_donation_url': charityDonationUrl, + if (charityPartnerName != null) + 'charity_partner_name': charityPartnerName, + if (charityDonationUrl != null) + 'charity_donation_url': charityDonationUrl, }; } @@ -175,16 +181,16 @@ class Festival { return FestivalStatus.past; } - /// Sort festivals by date: live first, then upcoming (soonest first), + /// Sort festivals by date: live first, then upcoming (soonest first), /// then past (most recent first) static List sortByDate(List festivals, [DateTime? now]) { final currentDate = now ?? DateTime.now(); final sorted = List.from(festivals); - + sorted.sort((a, b) { final statusA = a.getBasicStatus(currentDate); final statusB = b.getBasicStatus(currentDate); - + // Priority: live > upcoming > past if (statusA == FestivalStatus.live && statusB != FestivalStatus.live) { return -1; @@ -192,23 +198,26 @@ class Festival { if (statusB == FestivalStatus.live && statusA != FestivalStatus.live) { return 1; } - - if (statusA == FestivalStatus.upcoming && statusB == FestivalStatus.past) { + + if (statusA == FestivalStatus.upcoming && + statusB == FestivalStatus.past) { return -1; } - if (statusB == FestivalStatus.upcoming && statusA == FestivalStatus.past) { + if (statusB == FestivalStatus.upcoming && + statusA == FestivalStatus.past) { return 1; } - + // Within same status, sort by date - if (statusA == FestivalStatus.upcoming && statusB == FestivalStatus.upcoming) { + if (statusA == FestivalStatus.upcoming && + statusB == FestivalStatus.upcoming) { // Upcoming: soonest first. Festivals with null startDate are sorted last. if (a.startDate == null && b.startDate == null) return 0; if (a.startDate == null) return 1; if (b.startDate == null) return -1; return a.startDate!.compareTo(b.startDate!); } - + if (statusA == FestivalStatus.past && statusB == FestivalStatus.past) { // Past: most recent first. Festivals with null endDate/startDate are sorted last. final aEnd = a.endDate ?? a.startDate; @@ -218,25 +227,23 @@ class Festival { if (bEnd == null) return -1; return bEnd.compareTo(aEnd); } - + return 0; }); - + return sorted; } /// Get the status of a festival in the context of a sorted list /// The first past festival in a sorted list gets mostRecent status static FestivalStatus getStatusInContext( - Festival festival, - List sortedFestivals, - [DateTime? now] - ) { + Festival festival, List sortedFestivals, + [DateTime? now]) { final basicStatus = festival.getBasicStatus(now); if (basicStatus != FestivalStatus.past) { return basicStatus; } - + // Find the first past festival in the sorted list final currentDate = now ?? DateTime.now(); for (final f in sortedFestivals) { @@ -247,7 +254,7 @@ class Festival { break; } } - + return FestivalStatus.past; } } @@ -306,7 +313,13 @@ class DefaultFestivals { startDate: DateTime(2025, 12, 10), endDate: DateTime(2025, 12, 13), location: 'Cambridge Corn Exchange, Cambridge', - availableBeverageTypes: ['beer', 'international-beer', 'cider', 'perry', 'low-no'], + availableBeverageTypes: [ + 'beer', + 'international-beer', + 'cider', + 'perry', + 'low-no' + ], dataBaseUrl: 'https://data.cambeerfestival.app/cbfw2025', isActive: false, ); @@ -333,5 +346,6 @@ class DefaultFestivals { isActive: false, ); - static List get all => [cambridge2026, cambridge2025, cambridgeWinter2025, cambridge2024]; + static List get all => + [cambridge2026, cambridge2025, cambridgeWinter2025, cambridge2024]; } diff --git a/lib/providers/beer_provider.dart b/lib/providers/beer_provider.dart index 7a9a1d37..242439fc 100644 --- a/lib/providers/beer_provider.dart +++ b/lib/providers/beer_provider.dart @@ -61,9 +61,13 @@ class BeerProvider extends ChangeNotifier { List get drinks => _filteredDrinks; List get allDrinks => _allDrinks; List get festivals => _festivals; + /// Get festivals sorted by date (live/upcoming first, then past in reverse chronological order) List get sortedFestivals => Festival.sortByDate(_festivals); - Festival get currentFestival => _currentFestival ?? DefaultFestivals.all.firstWhere((f) => f.isActive, orElse: () => DefaultFestivals.all.first); + Festival get currentFestival => + _currentFestival ?? + DefaultFestivals.all.firstWhere((f) => f.isActive, + orElse: () => DefaultFestivals.all.first); bool get isLoading => _isLoading; bool get isFestivalsLoading => _isFestivalsLoading; bool get isInitialized => _isInitialized; @@ -74,8 +78,10 @@ class BeerProvider extends ChangeNotifier { DrinkSort get currentSort => _currentSort; String get searchQuery => _searchQuery; bool get showFavoritesOnly => _showFavoritesOnly; - bool get hideUnavailable => _visibilityFilters.contains(DrinkVisibilityFilter.availableOnly); - Set get visibilityFilters => Set.unmodifiable(_visibilityFilters); + bool get hideUnavailable => + _visibilityFilters.contains(DrinkVisibilityFilter.availableOnly); + Set get visibilityFilters => + Set.unmodifiable(_visibilityFilters); Set get excludedAllergens => Set.unmodifiable(_excludedAllergens); Set get availableAllergens { final allergens = {}; @@ -84,6 +90,7 @@ class BeerProvider extends ChangeNotifier { } return allergens; } + bool get hasFestivals => _festivals.isNotEmpty; ThemeMode get themeMode => _themeMode; DateTime? get lastDrinksRefresh => _lastDrinksRefresh; @@ -163,13 +170,15 @@ class BeerProvider extends ChangeNotifier { /// Check if drinks data is stale and should be refreshed bool get isDrinksDataStale { if (_lastDrinksRefresh == null) return true; - return DateTime.now().difference(_lastDrinksRefresh!) > _drinksStalenessThreshold; + return DateTime.now().difference(_lastDrinksRefresh!) > + _drinksStalenessThreshold; } /// Check if festivals data is stale and should be refreshed bool get isFestivalsDataStale { if (_lastFestivalsRefresh == null) return true; - return DateTime.now().difference(_lastFestivalsRefresh!) > _festivalsStalenessThreshold; + return DateTime.now().difference(_lastFestivalsRefresh!) > + _festivalsStalenessThreshold; } /// Initialize with SharedPreferences and load festivals @@ -221,7 +230,8 @@ class BeerProvider extends ChangeNotifier { } // Load excluded allergens preference - _excludedAllergens = Set.from(prefs.getStringList('excludedAllergens') ?? []); + _excludedAllergens = + Set.from(prefs.getStringList('excludedAllergens') ?? []); // Load festivals dynamically await loadFestivals(); @@ -229,7 +239,8 @@ class BeerProvider extends ChangeNotifier { // Restore previously selected festival if available final savedFestivalId = await _festivalRepository!.getSelectedFestivalId(); if (savedFestivalId != null) { - final savedFestival = _festivals.where((f) => f.id == savedFestivalId).firstOrNull; + final savedFestival = + _festivals.where((f) => f.id == savedFestivalId).firstOrNull; if (savedFestival != null) { _currentFestival = savedFestival; } @@ -273,7 +284,8 @@ class BeerProvider extends ChangeNotifier { if (_festivals.isEmpty) { await loadFestivals(); } - _currentFestival ??= DefaultFestivals.all.firstWhere((f) => f.isActive, orElse: () => DefaultFestivals.all.first); + _currentFestival ??= DefaultFestivals.all.firstWhere((f) => f.isActive, + orElse: () => DefaultFestivals.all.first); } final token = ++_drinksLoadToken; @@ -480,7 +492,8 @@ class BeerProvider extends ChangeNotifier { setVisibilityFilter(DrinkVisibilityFilter.availableOnly, value); /// Set a visibility filter on or off and persist the preference - Future setVisibilityFilter(DrinkVisibilityFilter filter, bool active) async { + Future setVisibilityFilter( + DrinkVisibilityFilter filter, bool active) async { if (active) { _visibilityFilters = Set.from(_visibilityFilters)..add(filter); } else { diff --git a/lib/router.dart b/lib/router.dart index 6912e9a8..fa0778e3 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -86,7 +86,8 @@ final GoRouter appRouter = GoRouter( context, state, onInvalidFestival: (currentId) { - final queryString = state.uri.query.isNotEmpty ? '?${state.uri.query}' : ''; + final queryString = + state.uri.query.isNotEmpty ? '?${state.uri.query}' : ''; return '/$currentId$queryString'; }, ), @@ -136,7 +137,8 @@ final GoRouter appRouter = GoRouter( redirect: (context, state) => _festivalScopeRedirect( context, state, - onInvalidFestival: (currentId) => '/$currentId/brewery/${state.pathParameters['id']}', + onInvalidFestival: (currentId) => + '/$currentId/brewery/${state.pathParameters['id']}', ), builder: (context, state) { final festivalId = state.pathParameters['festivalId']!; @@ -152,7 +154,8 @@ final GoRouter appRouter = GoRouter( redirect: (context, state) => _festivalScopeRedirect( context, state, - onInvalidFestival: (currentId) => '/$currentId/style/${state.pathParameters['name']}', + onInvalidFestival: (currentId) => + '/$currentId/style/${state.pathParameters['name']}', ), builder: (context, state) { final festivalId = state.pathParameters['festivalId']!; diff --git a/lib/screens/about_screen.dart b/lib/screens/about_screen.dart index 30884476..50ba555d 100644 --- a/lib/screens/about_screen.dart +++ b/lib/screens/about_screen.dart @@ -22,11 +22,16 @@ class _AboutScreenState extends State { static const String appName = 'Cambridge Beer Festival'; // Git version info (injected at build time via --dart-define) - static const String gitTag = String.fromEnvironment('GIT_TAG', defaultValue: ''); - static const String gitCommit = String.fromEnvironment('GIT_COMMIT', defaultValue: ''); - static const String gitBranch = String.fromEnvironment('GIT_BRANCH', defaultValue: ''); - static const String buildVersion = String.fromEnvironment('BUILD_VERSION', defaultValue: ''); - static const String buildTime = String.fromEnvironment('BUILD_TIME', defaultValue: ''); + static const String gitTag = + String.fromEnvironment('GIT_TAG', defaultValue: ''); + static const String gitCommit = + String.fromEnvironment('GIT_COMMIT', defaultValue: ''); + static const String gitBranch = + String.fromEnvironment('GIT_BRANCH', defaultValue: ''); + static const String buildVersion = + String.fromEnvironment('BUILD_VERSION', defaultValue: ''); + static const String buildTime = + String.fromEnvironment('BUILD_TIME', defaultValue: ''); @override void initState() { @@ -41,7 +46,8 @@ class _AboutScreenState extends State { // Use git build version if available, otherwise fall back to package info if (buildVersion.isNotEmpty) { appVersion = buildVersion; - buildNumber = gitCommit.isNotEmpty ? gitCommit : packageInfo.buildNumber; + buildNumber = + gitCommit.isNotEmpty ? gitCommit : packageInfo.buildNumber; } else { appVersion = packageInfo.version; buildNumber = packageInfo.buildNumber; @@ -114,7 +120,8 @@ class _AboutScreenState extends State { SelectableText( 'Version $appVersion ($buildNumber)', style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.8), + color: + theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.8), ), ), ], diff --git a/lib/screens/brewery_screen.dart b/lib/screens/brewery_screen.dart index ade4ac46..f11fd5b0 100644 --- a/lib/screens/brewery_screen.dart +++ b/lib/screens/brewery_screen.dart @@ -55,8 +55,7 @@ class _BreweryScreenState extends State { if (breweryDrinks.isEmpty) { return Scaffold( appBar: AppBar(title: const Text('Brewery Not Found')), - body: const Center( - child: Text('No drinks found from this brewery.')), + body: const Center(child: Text('No drinks found from this brewery.')), ); } @@ -77,7 +76,8 @@ class _BreweryScreenState extends State { ), // Hero info card SliverToBoxAdapter( - child: _buildHeroCard(context, producer, breweryDrinks.length, theme), + child: + _buildHeroCard(context, producer, breweryDrinks.length, theme), ), // Drinks list ...DrinkListSection.buildSlivers( @@ -105,7 +105,8 @@ class _BreweryScreenState extends State { } /// Build clean white header with brewery name and location - Widget _buildHeader(BuildContext context, Producer producer, ThemeData theme) { + Widget _buildHeader( + BuildContext context, Producer producer, ThemeData theme) { return Container( width: double.infinity, padding: const EdgeInsets.all(24.0), @@ -157,7 +158,8 @@ class _BreweryScreenState extends State { // Drink count HeroInfoRow( icon: Icons.local_bar, - text: '$drinkCount ${drinkCount == 1 ? "drink" : "drinks"} at this festival', + text: + '$drinkCount ${drinkCount == 1 ? "drink" : "drinks"} at this festival', ), ]; diff --git a/lib/screens/drink_detail_screen.dart b/lib/screens/drink_detail_screen.dart index dad77b8d..653e55db 100644 --- a/lib/screens/drink_detail_screen.dart +++ b/lib/screens/drink_detail_screen.dart @@ -109,7 +109,8 @@ class _DrinkDetailScreenState extends State { ); } - Widget _buildAppBarTitle(BuildContext context, BeerProvider provider, Drink drink) { + Widget _buildAppBarTitle( + BuildContext context, BeerProvider provider, Drink drink) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -164,18 +165,16 @@ class _DrinkDetailScreenState extends State { // Style, dispense, ABV HeroInfoRow( icon: Icons.local_drink, - text: '${drink.style ?? drink.category} · ${StringFormattingHelper.capitalizeFirst(drink.dispense)} · ${drink.abv.toStringAsFixed(1)}%', + text: + '${drink.style ?? drink.category} · ${StringFormattingHelper.capitalizeFirst(drink.dispense)} · ${drink.abv.toStringAsFixed(1)}%', ), // Availability if (drink.bar != null || isSoldOut) HeroInfoRow( icon: isSoldOut ? Icons.cancel : Icons.check_circle, - text: isSoldOut - ? 'Sold Out' - : 'Available at ${drink.bar}', - iconColor: isSoldOut - ? theme.colorScheme.error - : theme.colorScheme.primary, + text: isSoldOut ? 'Sold Out' : 'Available at ${drink.bar}', + iconColor: + isSoldOut ? theme.colorScheme.error : theme.colorScheme.primary, ), // Vegan indicator if (drink.isVegan == true) @@ -312,7 +311,8 @@ class _DrinkDetailScreenState extends State { ? Text(drink.breweryLocation) : null, trailing: const Icon(Icons.chevron_right), - onTap: () => navigateToRoute(context, buildBreweryPath(widget.festivalId, drink.producer.id)), + onTap: () => navigateToRoute(context, + buildBreweryPath(widget.festivalId, drink.producer.id)), ), ), ), @@ -321,8 +321,10 @@ class _DrinkDetailScreenState extends State { ); } - List _buildSimilarDrinksSlivers(BuildContext context, Drink drink, BeerProvider provider) { - final similarDrinksWithReasons = _getSimilarDrinksWithReasons(drink, provider.allDrinks); + List _buildSimilarDrinksSlivers( + BuildContext context, Drink drink, BeerProvider provider) { + final similarDrinksWithReasons = + _getSimilarDrinksWithReasons(drink, provider.allDrinks); return DrinkListSection.buildSliversWithSubtitles( context: context, @@ -333,7 +335,8 @@ class _DrinkDetailScreenState extends State { ); } - List<(Drink, String)> _getSimilarDrinksWithReasons(Drink drink, List allDrinks) { + List<(Drink, String)> _getSimilarDrinksWithReasons( + Drink drink, List allDrinks) { final results = <(Drink, String)>[]; for (final d in allDrinks) { @@ -355,12 +358,14 @@ class _DrinkDetailScreenState extends State { return results.take(10).toList(); } - Widget _buildBottomActionBar(BuildContext context, Drink drink, BeerProvider provider) { + Widget _buildBottomActionBar( + BuildContext context, Drink drink, BeerProvider provider) { return BottomActionBar( actions: [ // Tasted checkbox ActionButton( - icon: drink.isTasted ? Icons.check_box : Icons.check_box_outline_blank, + icon: + drink.isTasted ? Icons.check_box : Icons.check_box_outline_blank, label: 'Tasted', isActive: drink.isTasted, onPressed: () => provider.toggleTasted(drink), @@ -377,7 +382,8 @@ class _DrinkDetailScreenState extends State { onTap: () => _showRatingDialog(context, drink, provider), borderRadius: BorderRadius.circular(8.0), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + padding: + const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -395,7 +401,9 @@ class _DrinkDetailScreenState extends State { color: drink.rating != null ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.onSurfaceVariant, - fontWeight: drink.rating != null ? FontWeight.w600 : FontWeight.normal, + fontWeight: drink.rating != null + ? FontWeight.w600 + : FontWeight.normal, ), ), ], @@ -424,7 +432,8 @@ class _DrinkDetailScreenState extends State { ); } - void _showRatingDialog(BuildContext context, Drink drink, BeerProvider provider) { + void _showRatingDialog( + BuildContext context, Drink drink, BeerProvider provider) { showDialog( context: context, builder: (context) => AlertDialog( @@ -465,9 +474,12 @@ class _DrinkDetailScreenState extends State { final provider = context.read(); // Use the drink's own festivalId rather than provider.currentFestival, which // can lag on deep-link entry before the provider catches up to the route. - final festival = provider.getFestivalById(drink.festivalId) ?? provider.currentFestival; - final hashtag = festival.hashtag ?? '#${festival.id.replaceAll(_hashtagSafeRegex, '')}'; - final url = 'https://cambeerfestival.app${buildDrinkDetailPath(drink.festivalId, drink.category, drink.id)}'; + final festival = + provider.getFestivalById(drink.festivalId) ?? provider.currentFestival; + final hashtag = + festival.hashtag ?? '#${festival.id.replaceAll(_hashtagSafeRegex, '')}'; + final url = + 'https://cambeerfestival.app${buildDrinkDetailPath(drink.festivalId, drink.category, drink.id)}'; Share.share(drink.getShareMessage(hashtag, url: url)); unawaited(provider.analyticsService.logDrinkShared(drink)); } diff --git a/lib/screens/drinks_screen.dart b/lib/screens/drinks_screen.dart index 98fb20a8..415c5e38 100644 --- a/lib/screens/drinks_screen.dart +++ b/lib/screens/drinks_screen.dart @@ -101,7 +101,8 @@ class _DrinksScreenState extends State { borderRadius: BorderRadius.circular(28), borderSide: BorderSide.none, ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), onChanged: (value) => provider.setSearchQuery(value), ), @@ -149,7 +150,8 @@ class _DrinksScreenState extends State { Expanded( child: _FilterButton( label: _getSortLabel(provider.currentSort), - semanticLabel: 'Sort drinks by ${_getSortLabel(provider.currentSort)}', + semanticLabel: + 'Sort drinks by ${_getSortLabel(provider.currentSort)}', icon: Icons.sort, onPressed: () => _showSortOptions(context, provider), isActive: false, @@ -157,7 +159,8 @@ class _DrinksScreenState extends State { ), const SizedBox(width: 6), _VisibilityFilterButton( - activeCount: provider.visibilityFilters.length + provider.excludedAllergens.length, + activeCount: provider.visibilityFilters.length + + provider.excludedAllergens.length, onPressed: () => _showVisibilityFilter(context, provider), ), const SizedBox(width: 6), @@ -188,7 +191,8 @@ class _DrinksScreenState extends State { ); return Semantics( - label: 'Current festival: ${provider.currentFestival.name}, ${provider.drinks.length} drinks', + label: + 'Current festival: ${provider.currentFestival.name}, ${provider.drinks.length} drinks', child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -218,101 +222,109 @@ class _DrinksScreenState extends State { overflow: TextOverflow.ellipsis, ), ), - if (status == FestivalStatus.live) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 1, - ), - decoration: BoxDecoration( - color: isDark ? const Color(0xFF4CAF50) : const Color(0xFF2E7D32), - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'LIVE', - style: TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, + if (status == FestivalStatus.live) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, ), - ), - ), - ] else if (status == FestivalStatus.upcoming) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 1, - ), - decoration: BoxDecoration( - color: isDark ? const Color(0xFF42A5F5) : const Color(0xFF1976D2), - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'SOON', - style: TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF4CAF50) + : const Color(0xFF2E7D32), + borderRadius: BorderRadius.circular(8), ), - ), - ), - ] else if (status == FestivalStatus.mostRecent) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 1, - ), - decoration: BoxDecoration( - color: isDark ? const Color(0xFFFF9800) : const Color(0xFFEF6C00), - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'RECENT', - style: TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, + child: const Text( + 'LIVE', + style: TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), ), ), - ), - ] else if (status == FestivalStatus.past) ...[ - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 1, + ] else if (status == FestivalStatus.upcoming) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF42A5F5) + : const Color(0xFF1976D2), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'SOON', + style: TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), ), - decoration: BoxDecoration( - color: isDark ? const Color(0xFF9E9E9E) : const Color(0xFF616161), - borderRadius: BorderRadius.circular(8), + ] else if (status == FestivalStatus.mostRecent) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFFFF9800) + : const Color(0xFFEF6C00), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'RECENT', + style: TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), ), - child: const Text( - 'PAST', - style: TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, + ] else if (status == FestivalStatus.past) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF9E9E9E) + : const Color(0xFF616161), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'PAST', + style: TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), ), ), - ), + ], ], - ], - ), - ], + ), + ], + ), ), - ), - ], - ), + ], + ), ); } Widget _buildFestivalBanner(BuildContext context, BeerProvider provider) { final theme = Theme.of(context); final festival = provider.currentFestival; - + // Only show banner if festival has dates or location if (festival.formattedDates.isEmpty && festival.location == null) { return const SizedBox.shrink(); @@ -413,7 +425,8 @@ class _DrinksScreenState extends State { children: [ const Icon(Icons.error_outline, size: 64, color: Colors.red), const SizedBox(height: 16), - Text('Error loading drinks', style: Theme.of(context).textTheme.titleLarge), + Text('Error loading drinks', + style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 8), Text(provider.error!, textAlign: TextAlign.center), const SizedBox(height: 16), @@ -440,7 +453,8 @@ class _DrinksScreenState extends State { children: [ Opacity( opacity: 0.5, - child: Image.asset('assets/app_icon.png', width: 80, height: 80), + child: + Image.asset('assets/app_icon.png', width: 80, height: 80), ), const SizedBox(height: 16), Text( @@ -486,8 +500,10 @@ class _DrinksScreenState extends State { ); } - void _navigateToDetail(BuildContext context, String drinkId, String category) { - navigateToRoute(context, buildDrinkDetailPath(widget.festivalId, category, drinkId)); + void _navigateToDetail( + BuildContext context, String drinkId, String category) { + navigateToRoute( + context, buildDrinkDetailPath(widget.festivalId, category, drinkId)); } void _showCategoryFilter(BuildContext context, BeerProvider provider) { @@ -559,7 +575,8 @@ class _FilterButton extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final effectiveLabel = semanticLabel ?? label; - final semanticHint = isActive ? 'Double tap to clear filter' : 'Double tap to select filter'; + final semanticHint = + isActive ? 'Double tap to clear filter' : 'Double tap to select filter'; return Semantics( label: effectiveLabel, @@ -608,7 +625,9 @@ class _SearchButton extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final label = isActive ? 'Close search' : 'Search drinks'; - final hint = isActive ? 'Double tap to close search bar' : 'Double tap to open search bar'; + final hint = isActive + ? 'Double tap to close search bar' + : 'Double tap to open search bar'; return Semantics( label: label, @@ -619,9 +638,8 @@ class _SearchButton extends StatelessWidget { style: FilledButton.styleFrom( padding: const EdgeInsets.all(12), minimumSize: const Size(48, 48), - backgroundColor: hasQuery && !isActive - ? theme.colorScheme.primaryContainer - : null, + backgroundColor: + hasQuery && !isActive ? theme.colorScheme.primaryContainer : null, ), child: Icon( isActive ? Icons.search_off : Icons.search, @@ -741,7 +759,8 @@ class _CategoryFilterSheet extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Semantics( - label: 'Show all drinks, ${provider.allDrinks.length} total', + label: + 'Show all drinks, ${provider.allDrinks.length} total', selected: provider.selectedCategory == null, button: true, child: ListTile( @@ -754,7 +773,8 @@ class _CategoryFilterSheet extends StatelessWidget { ), ), ...categories.map((category) { - final formattedCategory = BeverageTypeHelper.formatBeverageType(category); + final formattedCategory = + BeverageTypeHelper.formatBeverageType(category); final count = counts[category] ?? 0; return Semantics( label: 'Filter by $formattedCategory, $count drinks', @@ -939,7 +959,8 @@ class _StyleFilterSheet extends StatelessWidget { curve: Curves.easeInOut, child: selectedStyles.isNotEmpty ? Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), decoration: BoxDecoration( color: theme.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8), @@ -1037,7 +1058,8 @@ class _VisibilityFilterSheet extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('View Filters', style: theme.textTheme.titleLarge), - if (active.isNotEmpty || beerProvider.excludedAllergens.isNotEmpty) + if (active.isNotEmpty || + beerProvider.excludedAllergens.isNotEmpty) Semantics( label: 'Clear all view filters', hint: 'Double tap to remove all view filters', @@ -1066,7 +1088,8 @@ class _VisibilityFilterSheet extends StatelessWidget { label: 'Available only', subtitle: 'Hide sold out & not yet arrived drinks', icon: Icons.check_circle_outline, - isChecked: active.contains(DrinkVisibilityFilter.availableOnly), + isChecked: active + .contains(DrinkVisibilityFilter.availableOnly), onChanged: (value) => beerProvider.setVisibilityFilter( DrinkVisibilityFilter.availableOnly, value ?? false, @@ -1076,7 +1099,8 @@ class _VisibilityFilterSheet extends StatelessWidget { label: 'Not tasted', subtitle: 'Hide drinks you\'ve already tasted', icon: Icons.remove_circle_outline, - isChecked: active.contains(DrinkVisibilityFilter.notTasted), + isChecked: + active.contains(DrinkVisibilityFilter.notTasted), onChanged: (value) => beerProvider.setVisibilityFilter( DrinkVisibilityFilter.notTasted, value ?? false, @@ -1086,7 +1110,8 @@ class _VisibilityFilterSheet extends StatelessWidget { label: 'Vegan only', subtitle: 'Show only drinks marked as vegan', icon: Icons.eco_outlined, - isChecked: active.contains(DrinkVisibilityFilter.veganOnly), + isChecked: + active.contains(DrinkVisibilityFilter.veganOnly), onChanged: (value) => beerProvider.setVisibilityFilter( DrinkVisibilityFilter.veganOnly, value ?? false, @@ -1101,18 +1126,27 @@ class _VisibilityFilterSheet extends StatelessWidget { ), child: Text( 'Allergen-free', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ), - for (final allergen in (beerProvider.availableAllergens.toList()..sort())) + for (final allergen + in (beerProvider.availableAllergens.toList() + ..sort())) _VisibilityFilterTile( label: _formatAllergenName(allergen), subtitle: 'Hide drinks containing $allergen', icon: Icons.no_meals_outlined, - isChecked: beerProvider.excludedAllergens.contains(allergen), - onChanged: (value) => beerProvider.setAllergenFilter( + isChecked: beerProvider.excludedAllergens + .contains(allergen), + onChanged: (value) => + beerProvider.setAllergenFilter( allergen, value ?? false, ), @@ -1169,4 +1203,4 @@ class _VisibilityFilterTile extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/screens/festival_info_screen.dart b/lib/screens/festival_info_screen.dart index ab21f7c0..7b7c0a2a 100644 --- a/lib/screens/festival_info_screen.dart +++ b/lib/screens/festival_info_screen.dart @@ -18,7 +18,7 @@ class FestivalInfoScreen extends StatelessWidget { @override Widget build(BuildContext context) { final festival = context.watch().currentFestival; - + return Scaffold( appBar: AppBar( title: const Text('Festival Info'), @@ -40,7 +40,8 @@ class FestivalInfoScreen extends StatelessWidget { _buildLocation(context, festival), if (festival.hours != null && festival.hours!.isNotEmpty) _buildHours(context, festival), - if (festival.description != null) _buildDescription(context, festival), + if (festival.description != null) + _buildDescription(context, festival), _buildActions(context, festival), const SizedBox(height: 32), ], @@ -72,13 +73,15 @@ class FestivalInfoScreen extends StatelessWidget { Icon( Icons.calendar_today, size: 18, - color: theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.7), + color: theme.colorScheme.onPrimaryContainer + .withValues(alpha: 0.7), ), const SizedBox(width: 8), SelectableText( festival.formattedDates, style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.9), + color: theme.colorScheme.onPrimaryContainer + .withValues(alpha: 0.9), ), ), ], @@ -89,7 +92,8 @@ class FestivalInfoScreen extends StatelessWidget { SelectableText( festival.hashtag!, style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.7), + color: + theme.colorScheme.onPrimaryContainer.withValues(alpha: 0.7), ), ), ], @@ -131,7 +135,8 @@ class FestivalInfoScreen extends StatelessWidget { children: festival.availableBeverageTypes.map((type) { return Chip( label: Text(BeverageTypeHelper.formatBeverageType(type)), - avatar: Icon(BeverageTypeHelper.getBeverageIcon(type), size: 18), + avatar: + Icon(BeverageTypeHelper.getBeverageIcon(type), size: 18), ); }).toList(), ), @@ -153,7 +158,8 @@ class FestivalInfoScreen extends StatelessWidget { child: ListTile( leading: const Icon(Icons.location_on), title: Text(festival.location ?? 'Location TBA'), - subtitle: festival.address != null ? Text(festival.address!) : null, + subtitle: + festival.address != null ? Text(festival.address!) : null, trailing: festival.latitude != null && festival.longitude != null ? Semantics( label: 'Open location in maps', @@ -232,7 +238,8 @@ class FestivalInfoScreen extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (festival.charityPartnerName != null && festival.charityDonationUrl != null) ...[ + if (festival.charityPartnerName != null && + festival.charityDonationUrl != null) ...[ Semantics( label: 'Donate to ${festival.charityPartnerName}', hint: 'Double tap to open donation page in browser', @@ -284,7 +291,8 @@ class FestivalInfoScreen extends StatelessWidget { void _openMaps(BuildContext context, Festival festival) async { if (festival.latitude == null || festival.longitude == null) return; - final url = 'https://www.google.com/maps/search/?api=1&query=${festival.latitude},${festival.longitude}'; + final url = + 'https://www.google.com/maps/search/?api=1&query=${festival.latitude},${festival.longitude}'; await UrlLauncherHelper.launchURL( context, url, diff --git a/lib/screens/style_screen.dart b/lib/screens/style_screen.dart index 2202ab12..642cf785 100644 --- a/lib/screens/style_screen.dart +++ b/lib/screens/style_screen.dart @@ -50,8 +50,7 @@ class _StyleScreenState extends State { if (styleDrinks.isEmpty) { return Scaffold( appBar: AppBar(title: const Text('Style Not Found')), - body: const Center( - child: Text('No drinks found with this style.')), + body: const Center(child: Text('No drinks found with this style.')), ); } @@ -150,7 +149,8 @@ class _StyleScreenState extends State { // Drink count HeroInfoRow( icon: Icons.local_bar, - text: '${styleDrinks.length} ${styleDrinks.length == 1 ? "drink" : "drinks"} at this festival', + text: + '${styleDrinks.length} ${styleDrinks.length == 1 ? "drink" : "drinks"} at this festival', ), // Average ABV if (styleDrinks.isNotEmpty) diff --git a/lib/services/analytics_service.dart b/lib/services/analytics_service.dart index f9240c17..33170df4 100644 --- a/lib/services/analytics_service.dart +++ b/lib/services/analytics_service.dart @@ -9,21 +9,24 @@ class AnalyticsService { /// Lazy initialization to avoid Firebase initialization errors in tests FirebaseAnalytics? _analytics; FirebaseAnalytics get analytics => _analytics ??= FirebaseAnalytics.instance; - + FirebaseCrashlytics? _crashlytics; - FirebaseCrashlytics get crashlytics => _crashlytics ??= FirebaseCrashlytics.instance; - + FirebaseCrashlytics get crashlytics => + _crashlytics ??= FirebaseCrashlytics.instance; + /// Check if analytics should be enabled /// Analytics are enabled only in production environments (cambeerfestival.app). - /// Analytics are disabled in staging, preview, and development environments + /// Analytics are disabled in staging, preview, and development environments /// (including localhost/127.0.0.1) to avoid mixing test data with production metrics. bool get _isAnalyticsEnabled => EnvironmentService.isProduction(); /// Helper method to execute analytics calls only when enabled - Future _logIfEnabled(Future Function() analyticsCall, {bool showDebug = false}) async { + Future _logIfEnabled(Future Function() analyticsCall, + {bool showDebug = false}) async { if (!_isAnalyticsEnabled) { if (showDebug) { - debugPrint('Analytics disabled in ${EnvironmentService.getEnvironmentName()} environment'); + debugPrint( + 'Analytics disabled in ${EnvironmentService.getEnvironmentName()} environment'); } return; } @@ -42,12 +45,12 @@ class AnalyticsService { /// Log festival selection Future logFestivalSelected(Festival festival) async { await _logIfEnabled(() => analytics.logEvent( - name: 'festival_selected', - parameters: { - 'festival_id': festival.id, - 'festival_name': festival.name, - }, - )); + name: 'festival_selected', + parameters: { + 'festival_id': festival.id, + 'festival_name': festival.name, + }, + )); } /// Log search usage @@ -58,141 +61,142 @@ class AnalyticsService { /// Log category filter usage Future logCategoryFilter(String? category) async { await _logIfEnabled(() => analytics.logEvent( - name: 'filter_category', - parameters: { - 'category': category ?? 'all', - }, - )); + name: 'filter_category', + parameters: { + 'category': category ?? 'all', + }, + )); } /// Log style filter usage Future logStyleFilter(Set styles) async { await _logIfEnabled(() => analytics.logEvent( - name: 'filter_style', - parameters: { - 'style_count': styles.length, - 'styles': styles.join(','), - }, - )); + name: 'filter_style', + parameters: { + 'style_count': styles.length, + 'styles': styles.join(','), + }, + )); } /// Log sort change Future logSortChange(String sortType) async { await _logIfEnabled(() => analytics.logEvent( - name: 'sort_changed', - parameters: { - 'sort_type': sortType, - }, - )); + name: 'sort_changed', + parameters: { + 'sort_type': sortType, + }, + )); } /// Log favorite added Future logFavoriteAdded(Drink drink) async { await _logIfEnabled(() => analytics.logEvent( - name: 'favorite_added', - parameters: { - 'drink_id': drink.id, - 'drink_name': drink.name, - 'brewery': drink.breweryName, - 'category': drink.category, - }, - )); + name: 'favorite_added', + parameters: { + 'drink_id': drink.id, + 'drink_name': drink.name, + 'brewery': drink.breweryName, + 'category': drink.category, + }, + )); } /// Log favorite removed Future logFavoriteRemoved(Drink drink) async { await _logIfEnabled(() => analytics.logEvent( - name: 'favorite_removed', - parameters: { - 'drink_id': drink.id, - 'drink_name': drink.name, - }, - )); + name: 'favorite_removed', + parameters: { + 'drink_id': drink.id, + 'drink_name': drink.name, + }, + )); } /// Log drink marked as tasted Future logTastedAdded(Drink drink) async { await _logIfEnabled(() => analytics.logEvent( - name: 'tasted_added', - parameters: { - 'drink_id': drink.id, - 'drink_name': drink.name, - 'brewery': drink.breweryName, - 'category': drink.category, - }, - )); + name: 'tasted_added', + parameters: { + 'drink_id': drink.id, + 'drink_name': drink.name, + 'brewery': drink.breweryName, + 'category': drink.category, + }, + )); } /// Log drink unmarked as tasted Future logTastedRemoved(Drink drink) async { await _logIfEnabled(() => analytics.logEvent( - name: 'tasted_removed', - parameters: { - 'drink_id': drink.id, - 'drink_name': drink.name, - }, - )); + name: 'tasted_removed', + parameters: { + 'drink_id': drink.id, + 'drink_name': drink.name, + }, + )); } /// Log drink details viewed Future logDrinkViewed(Drink drink) async { await _logIfEnabled(() => analytics.logEvent( - name: 'drink_viewed', - parameters: { - 'drink_id': drink.id, - 'drink_name': drink.name, - 'brewery': drink.breweryName, - 'category': drink.category, - 'abv': drink.abv, - }, - )); + name: 'drink_viewed', + parameters: { + 'drink_id': drink.id, + 'drink_name': drink.name, + 'brewery': drink.breweryName, + 'category': drink.category, + 'abv': drink.abv, + }, + )); } /// Log brewery details viewed Future logBreweryViewed(String breweryName) async { await _logIfEnabled(() => analytics.logEvent( - name: 'brewery_viewed', - parameters: { - 'brewery_name': breweryName, - }, - )); + name: 'brewery_viewed', + parameters: { + 'brewery_name': breweryName, + }, + )); } /// Log style details viewed Future logStyleViewed(String style) async { await _logIfEnabled(() => analytics.logEvent( - name: 'style_viewed', - parameters: { - 'style': style, - }, - )); + name: 'style_viewed', + parameters: { + 'style': style, + }, + )); } /// Log rating given Future logRatingGiven(Drink drink, int rating) async { await _logIfEnabled(() => analytics.logEvent( - name: 'rating_given', - parameters: { - 'drink_id': drink.id, - 'drink_name': drink.name, - 'rating': rating, - }, - )); + name: 'rating_given', + parameters: { + 'drink_id': drink.id, + 'drink_name': drink.name, + 'rating': rating, + }, + )); } /// Log drink shared Future logDrinkShared(Drink drink) async { await _logIfEnabled(() => analytics.logEvent( - name: 'drink_shared', - parameters: { - 'drink_id': drink.id, - 'drink_name': drink.name, - }, - )); + name: 'drink_shared', + parameters: { + 'drink_id': drink.id, + 'drink_name': drink.name, + }, + )); } /// Log error to Crashlytics (non-fatal) - Future logError(Object error, StackTrace? stackTrace, {String? reason}) async { + Future logError(Object error, StackTrace? stackTrace, + {String? reason}) async { try { await crashlytics.recordError( error, @@ -207,14 +211,15 @@ class AnalyticsService { /// Set user property (e.g., preferred theme) Future setUserProperty(String name, String? value) async { - await _logIfEnabled(() => analytics.setUserProperty(name: name, value: value)); + await _logIfEnabled( + () => analytics.setUserProperty(name: name, value: value)); } /// Set user ID for tracking across sessions Future setUserId(String? userId) async { // Analytics respects environment settings await _logIfEnabled(() => analytics.setUserId(id: userId)); - + // Crashlytics always sets user ID for debugging in all environments try { if (userId != null) { diff --git a/lib/services/beer_api_service.dart b/lib/services/beer_api_service.dart index ae83e3ef..5cfc2506 100644 --- a/lib/services/beer_api_service.dart +++ b/lib/services/beer_api_service.dart @@ -13,10 +13,10 @@ class BeerApiService { }) : _client = client ?? http.Client(); /// Fetches all drinks from a festival for a specific beverage type - Future> fetchDrinks(Festival festival, String beverageType) async { + Future> fetchDrinks( + Festival festival, String beverageType) async { final url = festival.getBeverageUrl(beverageType); - final response = await _client.get(Uri.parse(url)) - .timeout(timeout); + final response = await _client.get(Uri.parse(url)).timeout(timeout); if (response.statusCode == 200) { // Decode as UTF-8 to handle non-ASCII characters properly (é, ñ, etc.) @@ -64,9 +64,8 @@ class BeerApiService { // If we got no drinks at all and there were errors, throw with details if (allDrinks.isEmpty && errors.isNotEmpty) { - final errorDetails = errors.entries - .map((e) => '${e.key}: ${e.value}') - .join('\n'); + final errorDetails = + errors.entries.map((e) => '${e.key}: ${e.value}').join('\n'); throw BeerApiException( 'Failed to load any drinks. This may be a network or CORS issue.\n\nDetails:\n$errorDetails', ); diff --git a/lib/services/environment_service.dart b/lib/services/environment_service.dart index c854410c..4c2fdf10 100644 --- a/lib/services/environment_service.dart +++ b/lib/services/environment_service.dart @@ -3,10 +3,10 @@ import 'package:flutter/foundation.dart'; /// Service for detecting the current environment (production, staging, or preview) class EnvironmentService { /// Determine if the app is running in production environment - /// + /// /// Production environments: /// - cambeerfestival.app (production custom domain) - /// + /// /// Non-production environments: /// - staging.cambeerfestival.app (staging custom domain) /// - *.staging-cambeerfestival.pages.dev (PR previews) @@ -15,12 +15,12 @@ class EnvironmentService { if (kIsWeb) { // On web, check the window location hostname using Uri.base final hostname = Uri.base.host; - + // Production domain if (hostname == 'cambeerfestival.app') { return true; } - + // Non-production: staging, preview, and local development if (hostname == 'staging.cambeerfestival.app' || hostname.endsWith('.staging-cambeerfestival.pages.dev') || @@ -28,24 +28,24 @@ class EnvironmentService { hostname == '127.0.0.1') { return false; } - + // Default to production for unknown domains (safety fallback) return true; } - + // On mobile platforms, always treat as production // (mobile apps don't have staging environments) return true; } - + /// Get a human-readable environment name for debugging static String getEnvironmentName() { if (!kIsWeb) { return 'mobile'; } - + final hostname = Uri.base.host; - + if (hostname == 'cambeerfestival.app') { return 'production'; } else if (hostname == 'staging.cambeerfestival.app') { @@ -55,7 +55,7 @@ class EnvironmentService { } else if (hostname == 'localhost' || hostname == '127.0.0.1') { return 'development'; } - + return 'unknown'; } } diff --git a/lib/services/festival_service.dart b/lib/services/festival_service.dart index 74399e27..e0acde70 100644 --- a/lib/services/festival_service.dart +++ b/lib/services/festival_service.dart @@ -18,20 +18,19 @@ class FestivalsResponse { required this.baseUrl, }); - factory FestivalsResponse.fromJson(Map json, String baseUrl) { - final festivalsList = (json['festivals'] as List) - .map((f) { - final festivalJson = Map.from(f as Map); - // Resolve relative URLs to absolute URLs - if (festivalJson['data_base_url'] != null) { - final dataBaseUrl = festivalJson['data_base_url'] as String; - if (dataBaseUrl.startsWith('/')) { - festivalJson['data_base_url'] = baseUrl + dataBaseUrl; - } - } - return Festival.fromJson(festivalJson); - }) - .toList(); + factory FestivalsResponse.fromJson( + Map json, String baseUrl) { + final festivalsList = (json['festivals'] as List).map((f) { + final festivalJson = Map.from(f as Map); + // Resolve relative URLs to absolute URLs + if (festivalJson['data_base_url'] != null) { + final dataBaseUrl = festivalJson['data_base_url'] as String; + if (dataBaseUrl.startsWith('/')) { + festivalJson['data_base_url'] = baseUrl + dataBaseUrl; + } + } + return Festival.fromJson(festivalJson); + }).toList(); return FestivalsResponse( festivals: festivalsList, @@ -72,13 +71,14 @@ class FestivalService { /// Fetches the list of available festivals Future fetchFestivals() async { - final response = await _client.get(Uri.parse(_festivalsUrl)) - .timeout(timeout); + final response = + await _client.get(Uri.parse(_festivalsUrl)).timeout(timeout); if (response.statusCode == 200) { final data = json.decode(response.body) as Map; // Extract base URL from the festivals URL (remove /festivals.json) - final baseUrl = _festivalsUrl.replaceAll(RegExp(r'/festivals\.json$'), ''); + final baseUrl = + _festivalsUrl.replaceAll(RegExp(r'/festivals\.json$'), ''); return FestivalsResponse.fromJson(data, baseUrl); } else { throw FestivalServiceException( diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index d45bf50d..eced8735 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -3,7 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart'; /// Service for managing favorites locally class FavoritesService { static const _favoritesKey = 'favorites'; - + final SharedPreferences _prefs; FavoritesService(this._prefs); @@ -33,13 +33,13 @@ class FavoritesService { Future toggleFavorite(String festivalId, String drinkId) async { final favorites = getFavorites(festivalId); final isFavorite = favorites.contains(drinkId); - + if (isFavorite) { favorites.remove(drinkId); } else { favorites.add(drinkId); } - + await _saveFavorites(festivalId, favorites); return !isFavorite; } diff --git a/lib/utils/abv_strength_helper.dart b/lib/utils/abv_strength_helper.dart index ff803e3f..9a0374dd 100644 --- a/lib/utils/abv_strength_helper.dart +++ b/lib/utils/abv_strength_helper.dart @@ -16,7 +16,7 @@ class ABVStrengthHelper { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final brightness = theme.brightness; - + if (abv < 4.0) { // Low ABV: Blue-ish return brightness == Brightness.dark diff --git a/lib/utils/category_color_helper.dart b/lib/utils/category_color_helper.dart index 3996f35e..97e0c226 100644 --- a/lib/utils/category_color_helper.dart +++ b/lib/utils/category_color_helper.dart @@ -16,7 +16,7 @@ class CategoryColorHelper { final colorScheme = theme.colorScheme; final brightness = theme.brightness; final cat = category.toLowerCase(); - + if (cat.contains('beer')) { // Amber-like color return brightness == Brightness.dark diff --git a/lib/utils/navigation_helpers.dart b/lib/utils/navigation_helpers.dart index ee20bded..ce326972 100644 --- a/lib/utils/navigation_helpers.dart +++ b/lib/utils/navigation_helpers.dart @@ -89,7 +89,8 @@ String buildFestivalInfoPath(String festivalId) { /// buildDrinkDetailPath('cbf2025', 'beer', 'drink-123') // Returns: '/cbf2025/drink/beer/drink-123' /// buildDrinkDetailPath('cbf2025', 'foreign beer', 'drink-456') // Returns: '/cbf2025/drink/foreign%20beer/drink-456' /// ``` -String buildDrinkDetailPath(String festivalId, String category, String drinkId) { +String buildDrinkDetailPath( + String festivalId, String category, String drinkId) { assert(category.isNotEmpty, 'Category cannot be empty'); assert(drinkId.isNotEmpty, 'Drink ID cannot be empty'); final encodedCategory = Uri.encodeComponent(category); diff --git a/lib/utils/string_comparison_helper.dart b/lib/utils/string_comparison_helper.dart index db264ea8..b9e277a5 100644 --- a/lib/utils/string_comparison_helper.dart +++ b/lib/utils/string_comparison_helper.dart @@ -1,5 +1,5 @@ /// Helper class for locale-aware string comparisons -/// +/// /// Provides methods to properly sort and compare strings containing /// non-ASCII characters (e.g., "rosé", "café") in a human-friendly way. class StringComparisonHelper { @@ -7,20 +7,20 @@ class StringComparisonHelper { StringComparisonHelper._(); /// Locale-aware case-insensitive string comparison - /// + /// /// This ensures that strings with accented characters (é, ñ, ü, etc.) /// are sorted in a reasonable alphabetical order. While not perfect for /// all locales, this approach handles common European accented characters /// properly for beer/wine/cider style names. - /// + /// /// The comparison is case-insensitive, so "IPA", "Ipa", and "ipa" are /// treated as equal. - /// + /// /// Examples: - /// - "Café" comes right after "Cafe" + /// - "Café" comes right after "Cafe" /// - "Rosé" comes right after "Rose" /// - "IPA" and "ipa" are treated as equal - /// + /// /// For sorting lists: /// ```dart /// styles.sort(StringComparisonHelper.compareLocaleAware); diff --git a/lib/utils/style_description_helper.dart b/lib/utils/style_description_helper.dart index fccc519c..ca49eb36 100644 --- a/lib/utils/style_description_helper.dart +++ b/lib/utils/style_description_helper.dart @@ -8,28 +8,30 @@ import 'package:flutter/services.dart'; /// Cambridge Beer Festival style guide. class StyleDescriptionHelper { StyleDescriptionHelper._(); - + static Map? _styleDescriptions; static bool _isLoaded = false; /// Load style descriptions from the JSON asset file - /// + /// /// This is called automatically on first use, but can be called /// explicitly to preload the data. static Future _loadDescriptions() async { if (_isLoaded) return; - + try { - final jsonString = await rootBundle.loadString('assets/style_descriptions.json'); - final Map jsonData = json.decode(jsonString) as Map; - + final jsonString = + await rootBundle.loadString('assets/style_descriptions.json'); + final Map jsonData = + json.decode(jsonString) as Map; + _styleDescriptions = {}; for (final entry in jsonData.entries) { if (entry.value is String && (entry.value as String).isNotEmpty) { _styleDescriptions![entry.key.toLowerCase()] = entry.value as String; } } - + _isLoaded = true; } catch (e) { // If file doesn't exist or can't be loaded, use empty map @@ -39,23 +41,23 @@ class StyleDescriptionHelper { } /// Get the description for a beer style - /// + /// /// Returns null if no description is available for the style. /// Loads descriptions from JSON file on first call. static Future getStyleDescription(String? style) async { if (style == null) return null; - + // Load descriptions if not already loaded await _loadDescriptions(); - + // Normalize the style name for lookup (case-insensitive) final normalizedStyle = style.toLowerCase().trim(); - + return _styleDescriptions?[normalizedStyle]; } - + /// Reset the loaded descriptions cache - /// + /// /// This is primarily useful for testing to ensure test isolation. @visibleForTesting static void reset() { diff --git a/lib/utils/url_launcher_helper.dart b/lib/utils/url_launcher_helper.dart index 9358b001..89790ff8 100644 --- a/lib/utils/url_launcher_helper.dart +++ b/lib/utils/url_launcher_helper.dart @@ -23,7 +23,7 @@ class UrlLauncherHelper { String errorMessage = 'Could not open URL', }) async { final uri = Uri.parse(url); - + try { if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); diff --git a/lib/widgets/drink_card.dart b/lib/widgets/drink_card.dart index 93ce951a..e5ea48da 100644 --- a/lib/widgets/drink_card.dart +++ b/lib/widgets/drink_card.dart @@ -93,12 +93,16 @@ class DrinkCard extends StatelessWidget { ), ), Semantics( - label: drink.isFavorite ? 'Remove from favorites' : 'Add to favorites', + label: drink.isFavorite + ? 'Remove from favorites' + : 'Add to favorites', hint: 'Double tap to toggle', button: true, child: IconButton( icon: Icon( - drink.isFavorite ? Icons.favorite : Icons.favorite_border, + drink.isFavorite + ? Icons.favorite + : Icons.favorite_border, color: drink.isFavorite ? colorScheme.primary : colorScheme.onSurfaceVariant, @@ -114,8 +118,7 @@ class DrinkCard extends StatelessWidget { runSpacing: 4, children: [ _CategoryChip(category: drink.category), - if (drink.style != null) - _StyleChip(style: drink.style!), + if (drink.style != null) _StyleChip(style: drink.style!), ExcludeSemantics( child: InfoChip( label: '${drink.abv.toStringAsFixed(1)}%', @@ -124,7 +127,8 @@ class DrinkCard extends StatelessWidget { ), ExcludeSemantics( child: InfoChip( - label: StringFormattingHelper.capitalizeFirst(drink.dispense), + label: StringFormattingHelper.capitalizeFirst( + drink.dispense), icon: Icons.liquor, ), ), @@ -264,7 +268,7 @@ class _CategoryChip extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; - + // Use theme-aware colors final backgroundColor = isDark ? theme.colorScheme.primaryContainer.withValues(alpha: 0.3) @@ -272,7 +276,7 @@ class _CategoryChip extends StatelessWidget { final textColor = isDark ? theme.colorScheme.primary.withValues(alpha: 0.9) : theme.colorScheme.onPrimaryContainer; - + return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( @@ -311,7 +315,7 @@ class _StyleChip extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; - + // Use theme-aware colors - use secondary color for distinction from category final backgroundColor = isDark ? theme.colorScheme.secondaryContainer.withValues(alpha: 0.3) @@ -319,7 +323,7 @@ class _StyleChip extends StatelessWidget { final textColor = isDark ? theme.colorScheme.secondary.withValues(alpha: 0.9) : theme.colorScheme.onSecondaryContainer; - + return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( diff --git a/lib/widgets/drink_list_section.dart b/lib/widgets/drink_list_section.dart index 55db7982..d1836766 100644 --- a/lib/widgets/drink_list_section.dart +++ b/lib/widgets/drink_list_section.dart @@ -67,7 +67,8 @@ class DrinkListSection { return DrinkCard( key: ValueKey(drink.id), drink: drink, - onTap: () => navigateToRoute(context, buildDrinkDetailPath(festivalId, drink.category, drink.id)), + onTap: () => navigateToRoute(context, + buildDrinkDetailPath(festivalId, drink.category, drink.id)), onFavoriteTap: () => provider.toggleFavorite(drink), ); }, @@ -98,7 +99,8 @@ class DrinkListSection { final theme = Theme.of(context); final provider = context.read(); - final displayTitle = showCount ? '$title (${drinksWithSubtitles.length})' : title; + final displayTitle = + showCount ? '$title (${drinksWithSubtitles.length})' : title; return [ SliverToBoxAdapter( @@ -118,7 +120,8 @@ class DrinkListSection { key: ValueKey(drink.id), drink: drink, subtitle: subtitle, - onTap: () => navigateToRoute(context, buildDrinkDetailPath(festivalId, drink.category, drink.id)), + onTap: () => navigateToRoute(context, + buildDrinkDetailPath(festivalId, drink.category, drink.id)), onFavoriteTap: () => provider.toggleFavorite(drink), ); }, @@ -147,7 +150,7 @@ class _DrinkCardWithSubtitle extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/widgets/environment_badge.dart b/lib/widgets/environment_badge.dart index ccfb3ab2..0d3b9eb0 100644 --- a/lib/widgets/environment_badge.dart +++ b/lib/widgets/environment_badge.dart @@ -5,7 +5,7 @@ import '../services/environment_service.dart'; /// Only visible on non-production environments to help testers identify the environment class EnvironmentBadge extends StatelessWidget { final String? environmentName; - + const EnvironmentBadge({super.key, this.environmentName}); /// Convert environment name to title case for display @@ -18,14 +18,14 @@ class EnvironmentBadge extends StatelessWidget { Widget build(BuildContext context) { // Get environment name - use provided parameter or detect from service final envName = environmentName ?? EnvironmentService.getEnvironmentName(); - + // Only show badge if there's an environment name (non-production) // When environmentName is explicitly provided (for tests), always show // When using service detection, only show if not production if (environmentName == null && EnvironmentService.isProduction()) { return const SizedBox.shrink(); } - + // If no environment name at all, don't show badge if (envName.isEmpty) { return const SizedBox.shrink(); @@ -34,7 +34,7 @@ class EnvironmentBadge extends StatelessWidget { // Different colors for different environments Color badgeColor; Color textColor; - + // Normalize to lowercase for case-insensitive matching switch (envName.toLowerCase()) { case 'staging': @@ -59,7 +59,8 @@ class EnvironmentBadge extends StatelessWidget { left: 0, child: SafeArea( child: Semantics( - label: 'Environment badge: ${_toTitleCase(envName)}. This is a ${envName.toLowerCase()} environment, not production.', + label: + 'Environment badge: ${_toTitleCase(envName)}. This is a ${envName.toLowerCase()} environment, not production.', excludeSemantics: true, child: Container( margin: const EdgeInsets.all(8), diff --git a/lib/widgets/festival_menu_sheets.dart b/lib/widgets/festival_menu_sheets.dart index 17c3d05b..c6648973 100644 --- a/lib/widgets/festival_menu_sheets.dart +++ b/lib/widgets/festival_menu_sheets.dart @@ -185,9 +185,11 @@ class FestivalSelectorSheet extends StatelessWidget { ) else ...festivals.map((festival) { - final status = Festival.getStatusInContext(festival, festivals); + final status = + Festival.getStatusInContext(festival, festivals); final statusLabel = _getStatusLabel(status); - final isSelected = festival.id == provider.currentFestival.id; + final isSelected = + festival.id == provider.currentFestival.id; final festivalLabel = isSelected ? '${festival.name}, currently selected, $statusLabel' : '${festival.name}, $statusLabel'; @@ -412,16 +414,20 @@ class FestivalCard extends StatelessWidget { switch (status) { case FestivalStatus.live: - backgroundColor = isDark ? const Color(0xFF4CAF50) : const Color(0xFF2E7D32); + backgroundColor = + isDark ? const Color(0xFF4CAF50) : const Color(0xFF2E7D32); label = 'LIVE'; case FestivalStatus.upcoming: - backgroundColor = isDark ? const Color(0xFF42A5F5) : const Color(0xFF1976D2); + backgroundColor = + isDark ? const Color(0xFF42A5F5) : const Color(0xFF1976D2); label = 'COMING SOON'; case FestivalStatus.mostRecent: - backgroundColor = isDark ? const Color(0xFFFF9800) : const Color(0xFFEF6C00); + backgroundColor = + isDark ? const Color(0xFFFF9800) : const Color(0xFFEF6C00); label = 'MOST RECENT'; case FestivalStatus.past: - backgroundColor = isDark ? const Color(0xFF9E9E9E) : const Color(0xFF616161); + backgroundColor = + isDark ? const Color(0xFF9E9E9E) : const Color(0xFF616161); label = 'PAST'; } diff --git a/test/accessibility_test.dart b/test/accessibility_test.dart index c6e2ff8a..05db5b36 100644 --- a/test/accessibility_test.dart +++ b/test/accessibility_test.dart @@ -37,13 +37,14 @@ void main() { // Verify favorite button exists expect(find.byType(IconButton), findsWidgets, reason: 'DrinkCard should have interactive buttons'); - + // Verify Semantics widgets are present expect(find.byType(Semantics), findsWidgets, reason: 'DrinkCard should have semantic labels'); }); - testWidgets('decorative ABV chip is excluded from semantics', (tester) async { + testWidgets('decorative ABV chip is excluded from semantics', + (tester) async { final drink = Drink( product: const Product( id: '1', @@ -110,7 +111,8 @@ void main() { }); group('Accessibility - EnvironmentBadge Semantics', () { - testWidgets('environment badge renders with semantic labels', (tester) async { + testWidgets('environment badge renders with semantic labels', + (tester) async { await tester.pumpWidget( const MaterialApp( home: Scaffold( @@ -128,7 +130,7 @@ void main() { // Verify badge renders expect(find.byType(EnvironmentBadge), findsOneWidget, reason: 'Environment badge should render'); - + // Verify it has Semantics expect(find.byType(Semantics), findsWidgets, reason: 'Environment badge should have semantic labels'); @@ -153,10 +155,12 @@ void main() { ); // Find our custom Semantics wrapper - final allSemantics = tester.widgetList( - find.byType(Semantics), - ).toList(); - + final allSemantics = tester + .widgetList( + find.byType(Semantics), + ) + .toList(); + // Find the one with our specific label final ourSemantics = allSemantics.firstWhere( (s) => s.properties.label == 'Test button', @@ -166,7 +170,8 @@ void main() { reason: 'Interactive buttons must set button: true in Semantics'); }); - testWidgets('hints provide usage instructions when present', (tester) async { + testWidgets('hints provide usage instructions when present', + (tester) async { await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -184,10 +189,12 @@ void main() { ); // Find our custom Semantics wrapper - final allSemantics = tester.widgetList( - find.byType(Semantics), - ).toList(); - + final allSemantics = tester + .widgetList( + find.byType(Semantics), + ) + .toList(); + // Find the one with our specific label final ourSemantics = allSemantics.firstWhere( (s) => s.properties.label == 'Add to favorites', @@ -197,7 +204,8 @@ void main() { reason: 'Hint should match expected instruction'); }); - testWidgets('ExcludeSemantics is used for decorative elements', (tester) async { + testWidgets('ExcludeSemantics is used for decorative elements', + (tester) async { await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -223,7 +231,8 @@ void main() { }); group('Accessibility - Semantic State Communication', () { - testWidgets('filter selection state is communicated via semantics', (tester) async { + testWidgets('filter selection state is communicated via semantics', + (tester) async { const isSelected = true; await tester.pumpWidget( @@ -237,7 +246,7 @@ void main() { child: const FilterChip( label: Text('IPA'), selected: isSelected, - onSelected: null, // Non-interactive for this test + onSelected: null, // Non-interactive for this test ), ), ), @@ -245,10 +254,12 @@ void main() { ); // Find our custom Semantics wrapper (not the ones Material adds) - final allSemantics = tester.widgetList( - find.byType(Semantics), - ).toList(); - + final allSemantics = tester + .widgetList( + find.byType(Semantics), + ) + .toList(); + // Find the one with our specific label final ourSemantics = allSemantics.firstWhere( (s) => s.properties.label == 'Filter by IPA', @@ -278,10 +289,12 @@ void main() { ); // Find our custom Semantics wrapper - final allSemantics = tester.widgetList( - find.byType(Semantics), - ).toList(); - + final allSemantics = tester + .widgetList( + find.byType(Semantics), + ) + .toList(); + // Find the one with our specific label final ourSemantics = allSemantics.firstWhere( (s) => s.properties.label == 'Retry loading drinks', @@ -295,4 +308,4 @@ void main() { reason: 'Button property must be set'); }); }); -} \ No newline at end of file +} diff --git a/test/analytics_service_test.dart b/test/analytics_service_test.dart index 38bac05d..bb80d7a3 100644 --- a/test/analytics_service_test.dart +++ b/test/analytics_service_test.dart @@ -17,7 +17,7 @@ void main() { await service.logCategoryFilter('beer'); await service.logStyleFilter({'IPA', 'Stout'}); await service.logSortChange('nameAsc'); - + const festival = Festival( id: 'test', name: 'Test Festival', @@ -45,7 +45,7 @@ void main() { producer: producer, product: product, ); - + await service.logFavoriteAdded(drink); await service.logFavoriteRemoved(drink); await service.logDrinkViewed(drink); @@ -55,7 +55,7 @@ void main() { await service.logDrinkShared(drink); await service.setUserProperty('theme', 'dark'); await service.setUserId('test-user'); - + // Test completes successfully expect(true, isTrue); }); @@ -66,7 +66,7 @@ void main() { StackTrace.current, reason: 'Test reason', ); - + expect(true, isTrue); }); }); diff --git a/test/app_theme_test.dart b/test/app_theme_test.dart index 0e98ba63..e1848557 100644 --- a/test/app_theme_test.dart +++ b/test/app_theme_test.dart @@ -31,8 +31,10 @@ void main() { (WidgetTester tester) async { final lightTheme = buildAppTheme(Brightness.light); final darkTheme = buildAppTheme(Brightness.dark); - expect(darkTheme.appBarTheme.backgroundColor, isNot(equals(appSeedColor))); - expect(darkTheme.appBarTheme.backgroundColor, equals(darkTheme.colorScheme.surface)); + expect( + darkTheme.appBarTheme.backgroundColor, isNot(equals(appSeedColor))); + expect(darkTheme.appBarTheme.backgroundColor, + equals(darkTheme.colorScheme.surface)); expect(darkTheme.appBarTheme.backgroundColor, isNot(equals(lightTheme.appBarTheme.backgroundColor))); }); @@ -71,7 +73,8 @@ void main() { expect(textTheme.displayLarge!.fontSize, equals(57)); }); - testWidgets('titleLarge has correct font size', (WidgetTester tester) async { + testWidgets('titleLarge has correct font size', + (WidgetTester tester) async { final colorScheme = ColorScheme.fromSeed( seedColor: appSeedColor, brightness: Brightness.light, @@ -80,7 +83,8 @@ void main() { expect(textTheme.titleLarge!.fontSize, equals(22)); }); - testWidgets('bodyMedium has correct font size', (WidgetTester tester) async { + testWidgets('bodyMedium has correct font size', + (WidgetTester tester) async { final colorScheme = ColorScheme.fromSeed( seedColor: appSeedColor, brightness: Brightness.light, diff --git a/test/beer_api_service_test.dart b/test/beer_api_service_test.dart index 2a2bea98..92bbb776 100644 --- a/test/beer_api_service_test.dart +++ b/test/beer_api_service_test.dart @@ -80,8 +80,20 @@ void main() { 'name': 'Brewery One', 'location': 'Cambridge', 'products': [ - {'id': 'drink-1', 'name': 'Beer 1', 'category': 'beer', 'dispense': 'cask', 'abv': '4.0'}, - {'id': 'drink-2', 'name': 'Beer 2', 'category': 'beer', 'dispense': 'cask', 'abv': '5.0'}, + { + 'id': 'drink-1', + 'name': 'Beer 1', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0' + }, + { + 'id': 'drink-2', + 'name': 'Beer 2', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '5.0' + }, ], }, { @@ -89,7 +101,13 @@ void main() { 'name': 'Brewery Two', 'location': 'London', 'products': [ - {'id': 'drink-3', 'name': 'Beer 3', 'category': 'beer', 'dispense': 'keg', 'abv': '6.0'}, + { + 'id': 'drink-3', + 'name': 'Beer 3', + 'category': 'beer', + 'dispense': 'keg', + 'abv': '6.0' + }, ], }, ], @@ -204,7 +222,13 @@ void main() { 'name': 'Test Brewery', 'location': 'Cambridge', 'products': [ - {'id': 'drink-1', 'name': 'Beer', 'category': 'beer', 'dispense': 'cask', 'abv': '4.0'}, + { + 'id': 'drink-1', + 'name': 'Beer', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0' + }, ], }, ], @@ -237,7 +261,13 @@ void main() { 'name': 'Beer Brewery', 'location': 'Cambridge', 'products': [ - {'id': 'beer-1', 'name': 'Test Beer', 'category': 'beer', 'dispense': 'cask', 'abv': '4.0'}, + { + 'id': 'beer-1', + 'name': 'Test Beer', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0' + }, ], }, ], @@ -250,7 +280,13 @@ void main() { 'name': 'Cider Mill', 'location': 'Somerset', 'products': [ - {'id': 'cider-1', 'name': 'Test Cider', 'category': 'cider', 'dispense': 'bag in box', 'abv': '5.0'}, + { + 'id': 'cider-1', + 'name': 'Test Cider', + 'category': 'cider', + 'dispense': 'bag in box', + 'abv': '5.0' + }, ], }, ], @@ -285,7 +321,13 @@ void main() { 'name': 'Beer Brewery', 'location': 'Cambridge', 'products': [ - {'id': 'beer-1', 'name': 'Test Beer', 'category': 'beer', 'dispense': 'cask', 'abv': '4.0'}, + { + 'id': 'beer-1', + 'name': 'Test Beer', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0' + }, ], }, ], @@ -324,7 +366,8 @@ void main() { ); }); - test('returns empty list without error when all types return 404', () async { + test('returns empty list without error when all types return 404', + () async { service = BeerApiService(client: mockClient); const festival = Festival( diff --git a/test/beer_provider_test.dart b/test/beer_provider_test.dart index 9ddf3782..0e53ac66 100644 --- a/test/beer_provider_test.dart +++ b/test/beer_provider_test.dart @@ -101,7 +101,8 @@ void main() { baseUrl: 'https://data.cambeerfestival.app', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => []); }); @@ -113,8 +114,8 @@ void main() { test('starts with empty drinks list', () { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); expect(provider.drinks, isEmpty); @@ -124,18 +125,19 @@ void main() { test('starts with default festival when not initialized', () { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); - expect(provider.currentFestival.id, DefaultFestivals.all.firstWhere((f) => f.isActive).id); + expect(provider.currentFestival.id, + DefaultFestivals.all.firstWhere((f) => f.isActive).id); }); test('isLoading is false initially', () { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); expect(provider.isLoading, isFalse); @@ -144,8 +146,8 @@ void main() { test('error is null initially', () { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); expect(provider.error, isNull); @@ -156,8 +158,8 @@ void main() { test('loads drinks successfully', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -175,8 +177,8 @@ void main() { test('clears error on successful load', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -198,8 +200,8 @@ void main() { test('setCategory filters drinks by category', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -217,8 +219,8 @@ void main() { test('setCategory with null shows all drinks', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -237,8 +239,8 @@ void main() { test('setCategory clears style filter', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -257,8 +259,8 @@ void main() { test('availableCategories returns unique categories', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -275,8 +277,8 @@ void main() { test('categoryCountsMap returns correct counts', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -295,8 +297,8 @@ void main() { test('toggleStyle adds style to filter', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -315,8 +317,8 @@ void main() { test('toggleStyle removes style when already selected', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -335,8 +337,8 @@ void main() { test('multiple styles selected uses OR logic', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -355,8 +357,8 @@ void main() { test('clearStyles removes all style filters', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -376,8 +378,8 @@ void main() { test('availableStyles respects category filter', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -398,8 +400,8 @@ void main() { test('setSort with nameAsc sorts alphabetically', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -417,8 +419,8 @@ void main() { test('setSort with nameDesc sorts reverse alphabetically', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -436,8 +438,8 @@ void main() { test('setSort with abvHigh sorts by ABV descending', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -455,8 +457,8 @@ void main() { test('setSort with abvLow sorts by ABV ascending', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -474,8 +476,8 @@ void main() { test('setSort with brewery sorts by brewery name', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -493,8 +495,8 @@ void main() { test('setSort with style sorts by style name', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -515,8 +517,8 @@ void main() { test('setSearchQuery filters by drink name', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -534,8 +536,8 @@ void main() { test('setSearchQuery filters by brewery name', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -547,14 +549,15 @@ void main() { provider.setSearchQuery('another'); expect(provider.drinks.length, 2); - expect(provider.drinks.every((d) => d.breweryName == 'Another Brewery'), isTrue); + expect(provider.drinks.every((d) => d.breweryName == 'Another Brewery'), + isTrue); }); test('setSearchQuery filters by style', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -572,8 +575,8 @@ void main() { test('setSearchQuery filters by notes', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -591,8 +594,8 @@ void main() { test('setSearchQuery is case insensitive', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -610,8 +613,8 @@ void main() { test('setSearchQuery with empty string shows all drinks', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -632,8 +635,8 @@ void main() { test('setShowFavoritesOnly filters to favorites', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -645,7 +648,8 @@ void main() { // Mock toggleFavorite to properly toggle state final favorites = {}; - when(mockDrinkRepository.toggleFavorite(any, any)).thenAnswer((invocation) async { + when(mockDrinkRepository.toggleFavorite(any, any)) + .thenAnswer((invocation) async { final drinkId = invocation.positionalArguments[1] as String; if (favorites.contains(drinkId)) { favorites.remove(drinkId); @@ -669,8 +673,8 @@ void main() { test('favoriteDrinks getter returns only favorites', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -682,7 +686,8 @@ void main() { // Mock toggleFavorite to properly toggle state final favorites = {}; - when(mockDrinkRepository.toggleFavorite(any, any)).thenAnswer((invocation) async { + when(mockDrinkRepository.toggleFavorite(any, any)) + .thenAnswer((invocation) async { final drinkId = invocation.positionalArguments[1] as String; if (favorites.contains(drinkId)) { favorites.remove(drinkId); @@ -705,8 +710,8 @@ void main() { test('setHideUnavailable filters out sold out drinks', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -758,7 +763,7 @@ void main() { ); final sampleDrinks = [availableDrink, soldOutDrink, lowStockDrink]; - + when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => sampleDrinks); await provider.loadDrinks(); @@ -779,8 +784,8 @@ void main() { test('setHideUnavailable filters out not yet available drinks', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -818,7 +823,7 @@ void main() { ); final sampleDrinks = [availableDrink, notYetDrink]; - + when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => sampleDrinks); await provider.loadDrinks(); @@ -831,15 +836,16 @@ void main() { // Not yet available drink should be filtered out expect(provider.drinks.length, 1); - expect(provider.drinks.any((d) => d.name == 'Coming Soon Cider'), isFalse); + expect( + provider.drinks.any((d) => d.name == 'Coming Soon Cider'), isFalse); expect(provider.drinks.any((d) => d.name == 'Available Ale'), isTrue); }); test('setHideUnavailable persists preference', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -852,13 +858,15 @@ void main() { await provider.setHideUnavailable(true); expect(provider.hideUnavailable, isTrue); expect( - provider.visibilityFilters.contains(DrinkVisibilityFilter.availableOnly), + provider.visibilityFilters + .contains(DrinkVisibilityFilter.availableOnly), isTrue, ); // Verify it was persisted as the new visibilityFilters key, not the legacy key final prefs = await SharedPreferences.getInstance(); - expect(prefs.getStringList('visibilityFilters'), contains('availableOnly')); + expect(prefs.getStringList('visibilityFilters'), + contains('availableOnly')); expect(prefs.getBool('hideUnavailable'), isNull); // Disable it @@ -870,21 +878,24 @@ void main() { ); }); - test('hideUnavailable preference is loaded on initialization (legacy migration)', () async { + test( + 'hideUnavailable preference is loaded on initialization (legacy migration)', + () async { // Set legacy preference SharedPreferences.setMockInitialValues({'hideUnavailable': true}); provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); expect(provider.hideUnavailable, isTrue); }); - test('visibilityFilters preference is loaded on initialization', () async { + test('visibilityFilters preference is loaded on initialization', + () async { // Set new-style preference SharedPreferences.setMockInitialValues({ 'visibilityFilters': ['availableOnly', 'notTasted'], @@ -892,8 +903,8 @@ void main() { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -906,32 +917,40 @@ void main() { ); }); - test('setVisibilityFilter toggles individual filters independently', () async { + test('setVisibilityFilter toggles individual filters independently', + () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); - await provider.setVisibilityFilter(DrinkVisibilityFilter.veganOnly, true); - await provider.setVisibilityFilter(DrinkVisibilityFilter.notTasted, true); + await provider.setVisibilityFilter( + DrinkVisibilityFilter.veganOnly, true); + await provider.setVisibilityFilter( + DrinkVisibilityFilter.notTasted, true); - expect(provider.visibilityFilters, contains(DrinkVisibilityFilter.veganOnly)); - expect(provider.visibilityFilters, contains(DrinkVisibilityFilter.notTasted)); + expect(provider.visibilityFilters, + contains(DrinkVisibilityFilter.veganOnly)); + expect(provider.visibilityFilters, + contains(DrinkVisibilityFilter.notTasted)); expect(provider.hideUnavailable, isFalse); // Turn off vegan only, notTasted should remain - await provider.setVisibilityFilter(DrinkVisibilityFilter.veganOnly, false); - expect(provider.visibilityFilters, isNot(contains(DrinkVisibilityFilter.veganOnly))); - expect(provider.visibilityFilters, contains(DrinkVisibilityFilter.notTasted)); + await provider.setVisibilityFilter( + DrinkVisibilityFilter.veganOnly, false); + expect(provider.visibilityFilters, + isNot(contains(DrinkVisibilityFilter.veganOnly))); + expect(provider.visibilityFilters, + contains(DrinkVisibilityFilter.notTasted)); }); test('notTasted filter hides already-tasted drinks', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -943,7 +962,8 @@ void main() { expect(provider.drinks.length, sampleDrinks.length); - await provider.setVisibilityFilter(DrinkVisibilityFilter.notTasted, true); + await provider.setVisibilityFilter( + DrinkVisibilityFilter.notTasted, true); expect(provider.drinks.any((d) => d.isTasted), isFalse); }); @@ -981,8 +1001,8 @@ void main() { test('veganOnly filter shows only vegan drinks', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -1009,20 +1029,22 @@ void main() { }); final drinks = [ Drink(product: veganProduct, producer: producer, festivalId: 'test'), - Drink(product: nonVeganProduct, producer: producer, festivalId: 'test'), + Drink( + product: nonVeganProduct, producer: producer, festivalId: 'test'), ]; - when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => drinks); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => drinks); await provider.loadDrinks(); expect(provider.drinks.length, 2); - await provider.setVisibilityFilter(DrinkVisibilityFilter.veganOnly, true); + await provider.setVisibilityFilter( + DrinkVisibilityFilter.veganOnly, true); expect(provider.drinks.length, 1); expect(provider.drinks[0].isVegan, isTrue); }); - }); group('allergen filters', () { @@ -1034,47 +1056,67 @@ void main() { setUp(() async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); producer = Producer.fromJson({ - 'id': 'brewery-1', 'name': 'Test Brewery', - 'location': 'Cambridge', 'products': [], + 'id': 'brewery-1', + 'name': 'Test Brewery', + 'location': 'Cambridge', + 'products': [], }); glutenDrink = Drink( product: Product.fromJson({ - 'id': 'g1', 'name': 'Gluteny Ale', 'category': 'beer', - 'dispense': 'cask', 'abv': '4.0', 'allergens': {'gluten': 1}, + 'id': 'g1', + 'name': 'Gluteny Ale', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0', + 'allergens': {'gluten': 1}, }), - producer: producer, festivalId: 'test', + producer: producer, + festivalId: 'test', ); sulphiteDrink = Drink( product: Product.fromJson({ - 'id': 's1', 'name': 'Sulphitey Ale', 'category': 'beer', - 'dispense': 'cask', 'abv': '4.0', 'allergens': {'sulphites': 1}, + 'id': 's1', + 'name': 'Sulphitey Ale', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0', + 'allergens': {'sulphites': 1}, }), - producer: producer, festivalId: 'test', + producer: producer, + festivalId: 'test', ); cleanDrink = Drink( product: Product.fromJson({ - 'id': 'c1', 'name': 'Clean Ale', 'category': 'beer', - 'dispense': 'cask', 'abv': '4.0', 'allergens': {}, + 'id': 'c1', + 'name': 'Clean Ale', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0', + 'allergens': {}, }), - producer: producer, festivalId: 'test', + producer: producer, + festivalId: 'test', ); }); - test('availableAllergens returns all allergen keys from loaded drinks', () async { + test('availableAllergens returns all allergen keys from loaded drinks', + () async { when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => [glutenDrink, sulphiteDrink, cleanDrink]); await provider.loadDrinks(); - expect(provider.availableAllergens, containsAll(['gluten', 'sulphites'])); + expect( + provider.availableAllergens, containsAll(['gluten', 'sulphites'])); }); - test('setAllergenFilter gluten excludes drinks containing gluten', () async { + test('setAllergenFilter gluten excludes drinks containing gluten', + () async { when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => [glutenDrink, sulphiteDrink, cleanDrink]); await provider.loadDrinks(); @@ -1111,7 +1153,8 @@ void main() { expect(provider.excludedAllergens, isEmpty); }); - test('setAllergenFilter false removes allergen from exclusion set', () async { + test('setAllergenFilter false removes allergen from exclusion set', + () async { when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => [glutenDrink, cleanDrink]); await provider.loadDrinks(); @@ -1125,8 +1168,10 @@ void main() { }); test('clearVisibilityFilters removes all visibility filters', () async { - await provider.setVisibilityFilter(DrinkVisibilityFilter.availableOnly, true); - await provider.setVisibilityFilter(DrinkVisibilityFilter.notTasted, true); + await provider.setVisibilityFilter( + DrinkVisibilityFilter.availableOnly, true); + await provider.setVisibilityFilter( + DrinkVisibilityFilter.notTasted, true); expect(provider.visibilityFilters.length, 2); await provider.clearVisibilityFilters(); @@ -1137,20 +1182,23 @@ void main() { expect(prefs.getStringList('visibilityFilters'), isEmpty); }); - test('excludedAllergens persisted and restored on initialization', () async { + test('excludedAllergens persisted and restored on initialization', + () async { await provider.setAllergenFilter('gluten', true); await provider.setAllergenFilter('sulphites', true); final prefs = await SharedPreferences.getInstance(); - expect(prefs.getStringList('excludedAllergens'), containsAll(['gluten', 'sulphites'])); + expect(prefs.getStringList('excludedAllergens'), + containsAll(['gluten', 'sulphites'])); provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); - expect(provider.excludedAllergens, containsAll(['gluten', 'sulphites'])); + expect( + provider.excludedAllergens, containsAll(['gluten', 'sulphites'])); }); }); @@ -1158,8 +1206,8 @@ void main() { test('returns drink when found', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -1176,8 +1224,8 @@ void main() { test('returns null when not found', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -1195,8 +1243,8 @@ void main() { test('returns false when no festivals loaded', () { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); expect(provider.hasFestivals, isFalse); @@ -1205,8 +1253,8 @@ void main() { test('returns true when festivals are loaded', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); when(mockFestivalRepository.getFestivals()).thenAnswer( @@ -1223,7 +1271,8 @@ void main() { version: '1.0.0', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); await provider.loadFestivals(); expect(provider.hasFestivals, isTrue); @@ -1234,8 +1283,8 @@ void main() { test('applies category and style filters together', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -1254,8 +1303,8 @@ void main() { test('applies category, style, and search together', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -1276,8 +1325,8 @@ void main() { test('setFestival persists festival ID to storage', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); when(mockFestivalRepository.getFestivals()).thenAnswer( @@ -1299,13 +1348,15 @@ void main() { version: '1.0.0', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => []); // Mock setSelectedFestivalId to actually save to SharedPreferences - when(mockFestivalRepository.setSelectedFestivalId(any)).thenAnswer((invocation) async { + when(mockFestivalRepository.setSelectedFestivalId(any)) + .thenAnswer((invocation) async { final festivalId = invocation.positionalArguments[0] as String; final prefs = await SharedPreferences.getInstance(); await prefs.setString('selected_festival_id', festivalId); @@ -1313,7 +1364,8 @@ void main() { await provider.initialize(); - final festival2024 = provider.festivals.firstWhere((f) => f.id == 'cbf2024'); + final festival2024 = + provider.festivals.firstWhere((f) => f.id == 'cbf2024'); await provider.setFestival(festival2024); final prefs = await SharedPreferences.getInstance(); @@ -1330,8 +1382,8 @@ void main() { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); when(mockFestivalRepository.getFestivals()).thenAnswer( @@ -1353,11 +1405,12 @@ void main() { version: '1.0.0', ), ); - // Mock getSelectedFestivalId to read from SharedPreferences - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString('selected_festival_id'); - }); + // Mock getSelectedFestivalId to read from SharedPreferences + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString('selected_festival_id'); + }); await provider.initialize(); @@ -1373,8 +1426,8 @@ void main() { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); when(mockFestivalRepository.getFestivals()).thenAnswer( @@ -1391,7 +1444,8 @@ void main() { version: '1.0.0', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); await provider.initialize(); @@ -1401,8 +1455,8 @@ void main() { test('works correctly when no festival was previously saved', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); when(mockFestivalRepository.getFestivals()).thenAnswer( @@ -1419,7 +1473,8 @@ void main() { version: '1.0.0', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); await provider.initialize(); @@ -1428,7 +1483,8 @@ void main() { }); group('festival switch race condition', () { - test('rapid festival switches show only last-selected festival drinks', () async { + test('rapid festival switches show only last-selected festival drinks', + () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, festivalRepository: mockFestivalRepository, @@ -1462,7 +1518,8 @@ void main() { version: '1.0.0', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); final producerA = Producer.fromJson({ 'id': 'brewery-a', @@ -1528,8 +1585,8 @@ void main() { test('isDrinksDataStale returns true when no data loaded', () { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); expect(provider.isDrinksDataStale, isTrue); @@ -1538,18 +1595,19 @@ void main() { test('isFestivalsDataStale returns true when no festivals loaded', () { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); expect(provider.isFestivalsDataStale, isTrue); }); - test('isDrinksDataStale returns false immediately after loading drinks', () async { + test('isDrinksDataStale returns false immediately after loading drinks', + () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -1562,11 +1620,13 @@ void main() { expect(provider.isDrinksDataStale, isFalse); }); - test('isFestivalsDataStale returns false immediately after loading festivals', () async { + test( + 'isFestivalsDataStale returns false immediately after loading festivals', + () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); when(mockFestivalRepository.getFestivals()).thenAnswer( @@ -1583,7 +1643,8 @@ void main() { version: '1.0.0', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); await provider.loadFestivals(); @@ -1593,8 +1654,8 @@ void main() { test('refreshIfStale does nothing when already loading', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); when(mockFestivalRepository.getFestivals()).thenAnswer( @@ -1611,7 +1672,8 @@ void main() { version: '1.0.0', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => createSampleDrinks()); @@ -1644,7 +1706,8 @@ void main() { version: '1.0.0', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); // Start loading (don't await) final loadFuture = provider.loadDrinks(); @@ -1662,8 +1725,8 @@ void main() { test('refreshIfStale does not refresh when data is fresh', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); when(mockFestivalRepository.getFestivals()).thenAnswer( @@ -1680,7 +1743,8 @@ void main() { version: '1.0.0', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); await provider.initialize(); @@ -1705,8 +1769,8 @@ void main() { test('setFestival updates timestamp', () async { provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); when(mockFestivalRepository.getFestivals()).thenAnswer( @@ -1728,7 +1792,8 @@ void main() { version: '1.0.0', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => createSampleDrinks()); @@ -1740,7 +1805,8 @@ void main() { expect(provider.isDrinksDataStale, isFalse); // Change festival (which loads drinks internally) - final festival2024 = provider.festivals.firstWhere((f) => f.id == 'cbf2024'); + final festival2024 = + provider.festivals.firstWhere((f) => f.id == 'cbf2024'); await provider.setFestival(festival2024); // Data should still be fresh after festival change diff --git a/test/beverage_type_helper_test.dart b/test/beverage_type_helper_test.dart index 6a5c9d8a..40ab13aa 100644 --- a/test/beverage_type_helper_test.dart +++ b/test/beverage_type_helper_test.dart @@ -35,8 +35,7 @@ void main() { Icons.public); expect(BeverageTypeHelper.getBeverageIcon('cider'), Icons.local_drink); expect(BeverageTypeHelper.getBeverageIcon('perry'), Icons.eco); - expect( - BeverageTypeHelper.getBeverageIcon('mead'), Icons.emoji_nature); + expect(BeverageTypeHelper.getBeverageIcon('mead'), Icons.emoji_nature); expect(BeverageTypeHelper.getBeverageIcon('wine'), Icons.wine_bar); expect(BeverageTypeHelper.getBeverageIcon('low-no'), Icons.no_drinks); }); diff --git a/test/brewery_screen_test.dart b/test/brewery_screen_test.dart index 9efc99ad..7adc6915 100644 --- a/test/brewery_screen_test.dart +++ b/test/brewery_screen_test.dart @@ -43,15 +43,17 @@ void main() { dispense: 'cask', ); - final drink1 = Drink(product: product1, producer: producer1, festivalId: 'cbf2025'); - final drink2 = Drink(product: product2, producer: producer1, festivalId: 'cbf2025'); + final drink1 = + Drink(product: product1, producer: producer1, festivalId: 'cbf2025'); + final drink2 = + Drink(product: product2, producer: producer1, festivalId: 'cbf2025'); setUp(() async { SharedPreferences.setMockInitialValues({}); mockDrinkRepository = MockDrinkRepository(); mockFestivalRepository = MockFestivalRepository(); mockAnalyticsService = MockAnalyticsService(); - + // Mock fetchFestivals to return a test festival const testFestival = Festival( id: 'cbf2025', @@ -66,7 +68,8 @@ void main() { ); when(mockFestivalRepository.getFestivals()) .thenAnswer((_) async => festivalsResponse); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => []); provider = BeerProvider( @@ -140,14 +143,17 @@ void main() { routes: [ GoRoute( path: '/brewery', - builder: (context, state) => ChangeNotifierProvider.value( + builder: (context, state) => + ChangeNotifierProvider.value( value: provider, - child: const BreweryScreen(festivalId: 'cbf2025', breweryId: 'brewery1'), + child: const BreweryScreen( + festivalId: 'cbf2025', breweryId: 'brewery1'), ), ), GoRoute( path: '/cbf2025/drink/:category/:drinkId', - builder: (context, state) => const Scaffold(body: Text('Drink Detail')), + builder: (context, state) => + const Scaffold(body: Text('Drink Detail')), ), ], ); @@ -157,7 +163,8 @@ void main() { expect(find.text('Test Beer 1'), findsOneWidget); - final card = tester.widget(find.byKey(const ValueKey('drink1'))); + final card = + tester.widget(find.byKey(const ValueKey('drink1'))); card.onTap!(); await tester.pumpAndSettle(); @@ -177,7 +184,8 @@ void main() { // Mock toggleFavorite to properly toggle state final favorites = {}; - when(mockDrinkRepository.toggleFavorite(any, any)).thenAnswer((invocation) async { + when(mockDrinkRepository.toggleFavorite(any, any)) + .thenAnswer((invocation) async { final drinkId = invocation.positionalArguments[1] as String; if (favorites.contains(drinkId)) { favorites.remove(drinkId); @@ -220,9 +228,9 @@ void main() { yearFounded: null, products: [], ); - final drink = Drink(product: product1, producer: producerNoYear, festivalId: 'cbf2025'); - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + final drink = Drink( + product: product1, producer: producerNoYear, festivalId: 'cbf2025'); + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); await tester.pumpWidget(createTestWidget('brewery2')); @@ -231,8 +239,7 @@ void main() { expect(find.textContaining('Est.'), findsNothing); }); - testWidgets('handles empty location', - (WidgetTester tester) async { + testWidgets('handles empty location', (WidgetTester tester) async { const producerNoLocation = Producer( id: 'brewery3', name: 'Mystery Brewery', @@ -240,9 +247,11 @@ void main() { yearFounded: 2020, products: [], ); - final drink = Drink(product: product1, producer: producerNoLocation, festivalId: 'cbf2025'); - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + final drink = Drink( + product: product1, + producer: producerNoLocation, + festivalId: 'cbf2025'); + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); await tester.pumpWidget(createTestWidget('brewery3')); @@ -268,7 +277,8 @@ void main() { category: 'beer', dispense: 'keg', ); - final drink3 = Drink(product: product3, producer: producer2, festivalId: 'cbf2025'); + final drink3 = + Drink(product: product3, producer: producer2, festivalId: 'cbf2025'); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => [drink1, drink2, drink3]); diff --git a/test/domain/repositories/api_festival_repository_test.dart b/test/domain/repositories/api_festival_repository_test.dart index 75f443b7..2a8c80ea 100644 --- a/test/domain/repositories/api_festival_repository_test.dart +++ b/test/domain/repositories/api_festival_repository_test.dart @@ -39,8 +39,7 @@ void main() { }, 'https://example.com', ); - when(festivalService.fetchFestivals()) - .thenAnswer((_) async => response); + when(festivalService.fetchFestivals()).thenAnswer((_) async => response); final result = await repository.getFestivals(); diff --git a/test/domain/services/drink_filter_service_test.dart b/test/domain/services/drink_filter_service_test.dart index f7435c8a..9a42fcbe 100644 --- a/test/domain/services/drink_filter_service_test.dart +++ b/test/domain/services/drink_filter_service_test.dart @@ -113,7 +113,8 @@ void main() { }); test('filters drinks by multiple styles (OR logic)', () { - final result = service.filterByStyles(testDrinks, {'IPA', 'Bitter'}).toList(); + final result = + service.filterByStyles(testDrinks, {'IPA', 'Bitter'}).toList(); expect(result, hasLength(3)); expect( result.every((d) => d.style == 'IPA' || d.style == 'Bitter'), @@ -223,39 +224,57 @@ void main() { final producer = testDrinks[0].producer; glutenDrink = Drink( product: Product.fromJson({ - 'id': 'g', 'name': 'Gluteny', 'category': 'beer', - 'dispense': 'cask', 'abv': '4.0', 'allergens': {'gluten': 1}, + 'id': 'g', + 'name': 'Gluteny', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0', + 'allergens': {'gluten': 1}, }), - producer: producer, festivalId: 'test', + producer: producer, + festivalId: 'test', ); sulphiteDrink = Drink( product: Product.fromJson({ - 'id': 's', 'name': 'Sulphitey', 'category': 'beer', - 'dispense': 'cask', 'abv': '4.0', 'allergens': {'sulphites': 1}, + 'id': 's', + 'name': 'Sulphitey', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0', + 'allergens': {'sulphites': 1}, }), - producer: producer, festivalId: 'test', + producer: producer, + festivalId: 'test', ); bothDrink = Drink( product: Product.fromJson({ - 'id': 'b', 'name': 'Both', 'category': 'beer', - 'dispense': 'cask', 'abv': '4.0', + 'id': 'b', + 'name': 'Both', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0', 'allergens': {'gluten': 1, 'sulphites': 1}, }), - producer: producer, festivalId: 'test', + producer: producer, + festivalId: 'test', ); cleanDrink = Drink( product: Product.fromJson({ - 'id': 'c', 'name': 'Clean', 'category': 'beer', - 'dispense': 'cask', 'abv': '4.0', 'allergens': {}, + 'id': 'c', + 'name': 'Clean', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0', + 'allergens': {}, }), - producer: producer, festivalId: 'test', + producer: producer, + festivalId: 'test', ); }); test('excludes drinks that contain a selected allergen', () { - final result = service - .filterByExcludedAllergens([glutenDrink, cleanDrink], {'gluten'}) - .toList(); + final result = service.filterByExcludedAllergens( + [glutenDrink, cleanDrink], {'gluten'}).toList(); expect(result, hasLength(1)); expect(result[0].name, equals('Clean')); }); @@ -263,14 +282,18 @@ void main() { test('passes drinks where allergen value is 0', () { final zeroDrink = Drink( product: Product.fromJson({ - 'id': 'z', 'name': 'Zero', 'category': 'beer', - 'dispense': 'cask', 'abv': '4.0', 'allergens': {'gluten': 0}, + 'id': 'z', + 'name': 'Zero', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0', + 'allergens': {'gluten': 0}, }), - producer: testDrinks[0].producer, festivalId: 'test', + producer: testDrinks[0].producer, + festivalId: 'test', ); - final result = service - .filterByExcludedAllergens([zeroDrink, glutenDrink], {'gluten'}) - .toList(); + final result = service.filterByExcludedAllergens( + [zeroDrink, glutenDrink], {'gluten'}).toList(); expect(result, hasLength(1)); expect(result[0].name, equals('Zero')); }); @@ -278,31 +301,32 @@ void main() { test('passes drinks that lack the allergen key entirely', () { final noDrink = Drink( product: Product.fromJson({ - 'id': 'n', 'name': 'NoKey', 'category': 'beer', - 'dispense': 'cask', 'abv': '4.0', + 'id': 'n', + 'name': 'NoKey', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0', }), - producer: testDrinks[0].producer, festivalId: 'test', + producer: testDrinks[0].producer, + festivalId: 'test', ); - final result = service - .filterByExcludedAllergens([noDrink, glutenDrink], {'gluten'}) - .toList(); + final result = service.filterByExcludedAllergens( + [noDrink, glutenDrink], {'gluten'}).toList(); expect(result, hasLength(1)); expect(result[0].name, equals('NoKey')); }); test('multiple excluded allergens are ANDed', () { final drinks = [glutenDrink, sulphiteDrink, bothDrink, cleanDrink]; - final result = service - .filterByExcludedAllergens(drinks, {'gluten', 'sulphites'}) - .toList(); + final result = service.filterByExcludedAllergens( + drinks, {'gluten', 'sulphites'}).toList(); expect(result, hasLength(1)); expect(result[0].name, equals('Clean')); }); test('returns all drinks when excluded set is empty', () { final drinks = [glutenDrink, sulphiteDrink, cleanDrink]; - final result = - service.filterByExcludedAllergens(drinks, {}).toList(); + final result = service.filterByExcludedAllergens(drinks, {}).toList(); expect(result, hasLength(3)); }); }); @@ -338,13 +362,15 @@ void main() { }); test('returns empty list when no matches found', () { - final result = service.filterBySearch(testDrinks, 'nonexistent').toList(); + final result = + service.filterBySearch(testDrinks, 'nonexistent').toList(); expect(result, isEmpty); }); test('searches across multiple fields', () { final result = service.filterBySearch(testDrinks, 'sweet').toList(); - expect(result, hasLength(1)); // "Sweet Cider" has sweet in name and notes + expect( + result, hasLength(1)); // "Sweet Cider" has sweet in name and notes }); }); @@ -415,17 +441,27 @@ void main() { final producer = testDrinks[0].producer; final glutenDrink = Drink( product: Product.fromJson({ - 'id': 'gx', 'name': 'Gluteny', 'category': 'beer', - 'dispense': 'cask', 'abv': '4.0', 'allergens': {'gluten': 1}, + 'id': 'gx', + 'name': 'Gluteny', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0', + 'allergens': {'gluten': 1}, }), - producer: producer, festivalId: 'test', + producer: producer, + festivalId: 'test', ); final cleanDrink = Drink( product: Product.fromJson({ - 'id': 'cx', 'name': 'Clean', 'category': 'beer', - 'dispense': 'cask', 'abv': '4.0', 'allergens': {}, + 'id': 'cx', + 'name': 'Clean', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4.0', + 'allergens': {}, }), - producer: producer, festivalId: 'test', + producer: producer, + festivalId: 'test', ); final result = service.filterDrinks( diff --git a/test/domain/services/drink_sort_service_test.dart b/test/domain/services/drink_sort_service_test.dart index b9d44279..59b05634 100644 --- a/test/domain/services/drink_sort_service_test.dart +++ b/test/domain/services/drink_sort_service_test.dart @@ -82,7 +82,8 @@ void main() { group('sortByNameAsc', () { test('sorts drinks by name A-Z', () { - final result = service.sortDrinks(List.from(testDrinks), DrinkSort.nameAsc); + final result = + service.sortDrinks(List.from(testDrinks), DrinkSort.nameAsc); expect(result[0].name, equals('Alpha Ale')); expect(result[1].name, equals('Bravo Bitter')); expect(result[2].name, equals('Charlie Beer')); @@ -108,7 +109,8 @@ void main() { group('sortByNameDesc', () { test('sorts drinks by name Z-A', () { - final result = service.sortDrinks(List.from(testDrinks), DrinkSort.nameDesc); + final result = + service.sortDrinks(List.from(testDrinks), DrinkSort.nameDesc); expect(result[0].name, equals('Echo Lager')); expect(result[1].name, equals('Delta Strong')); expect(result[2].name, equals('Charlie Beer')); @@ -119,7 +121,8 @@ void main() { group('sortByAbvHigh', () { test('sorts drinks by ABV highest to lowest', () { - final result = service.sortDrinks(List.from(testDrinks), DrinkSort.abvHigh); + final result = + service.sortDrinks(List.from(testDrinks), DrinkSort.abvHigh); expect(result[0].abv, equals(7.2)); // Delta Strong expect(result[1].abv, equals(5.5)); // Charlie Beer expect(result[2].abv, equals(4.5)); // Echo Lager @@ -130,7 +133,8 @@ void main() { group('sortByAbvLow', () { test('sorts drinks by ABV lowest to highest', () { - final result = service.sortDrinks(List.from(testDrinks), DrinkSort.abvLow); + final result = + service.sortDrinks(List.from(testDrinks), DrinkSort.abvLow); expect(result[0].abv, equals(3.8)); // Bravo Bitter expect(result[1].abv, equals(4.2)); // Alpha Ale expect(result[2].abv, equals(4.5)); // Echo Lager @@ -141,7 +145,8 @@ void main() { group('sortByBrewery', () { test('sorts drinks by brewery name alphabetically', () { - final result = service.sortDrinks(List.from(testDrinks), DrinkSort.brewery); + final result = + service.sortDrinks(List.from(testDrinks), DrinkSort.brewery); // Alpha Brewery comes before Zeta Brewery expect(result[0].breweryName, equals('Alpha Brewery')); expect(result[1].breweryName, equals('Alpha Brewery')); @@ -153,7 +158,8 @@ void main() { group('sortByStyle', () { test('sorts drinks by style alphabetically', () { - final result = service.sortDrinks(List.from(testDrinks), DrinkSort.style); + final result = + service.sortDrinks(List.from(testDrinks), DrinkSort.style); // Empty string (no style) comes first, then Bitter, IPA, Stout expect(result[0].style, isNull); // Echo Lager expect(result[1].style, equals('Bitter')); @@ -163,7 +169,8 @@ void main() { }); test('handles drinks without style', () { - final result = service.sortDrinks(List.from(testDrinks), DrinkSort.style); + final result = + service.sortDrinks(List.from(testDrinks), DrinkSort.style); // Drinks without style should be sorted to the beginning expect(result[0].name, equals('Echo Lager')); }); diff --git a/test/drink_card_test.dart b/test/drink_card_test.dart index 658f5dc4..26d6647e 100644 --- a/test/drink_card_test.dart +++ b/test/drink_card_test.dart @@ -56,13 +56,14 @@ void main() { expect(find.text('Test IPA'), findsOneWidget); }); - testWidgets('displays brewery name and location', (WidgetTester tester) async { + testWidgets('displays brewery name and location', + (WidgetTester tester) async { await tester.pumpWidget(createTestWidget(drink: testDrink)); expect(find.text('Test Brewery • Cambridge'), findsOneWidget); }); - testWidgets('displays brewery name only when location is empty', + testWidgets('displays brewery name only when location is empty', (WidgetTester tester) async { final producerNoLocation = Producer.fromJson({ 'id': 'brewery-2', @@ -70,7 +71,7 @@ void main() { 'location': '', 'products': [], }); - + final drink = Drink( product: testProduct, producer: producerNoLocation, @@ -101,14 +102,14 @@ void main() { expect(find.text('Cask'), findsOneWidget); }); - testWidgets('displays availability status when present', + testWidgets('displays availability status when present', (WidgetTester tester) async { await tester.pumpWidget(createTestWidget(drink: testDrink)); expect(find.text('Available'), findsOneWidget); }); - testWidgets('shows favorite icon as outlined when not favorite', + testWidgets('shows favorite icon as outlined when not favorite', (WidgetTester tester) async { testDrink.isFavorite = false; await tester.pumpWidget(createTestWidget(drink: testDrink)); @@ -117,7 +118,7 @@ void main() { expect(find.byIcon(Icons.favorite), findsNothing); }); - testWidgets('shows favorite icon as filled when favorite', + testWidgets('shows favorite icon as filled when favorite', (WidgetTester tester) async { testDrink.isFavorite = true; await tester.pumpWidget(createTestWidget(drink: testDrink)); @@ -125,8 +126,7 @@ void main() { expect(find.byIcon(Icons.favorite), findsOneWidget); }); - testWidgets('calls onTap when card is tapped', - (WidgetTester tester) async { + testWidgets('calls onTap when card is tapped', (WidgetTester tester) async { bool tapped = false; await tester.pumpWidget(createTestWidget( drink: testDrink, @@ -139,7 +139,7 @@ void main() { expect(tapped, isTrue); }); - testWidgets('calls onFavoriteTap when favorite icon is tapped', + testWidgets('calls onFavoriteTap when favorite icon is tapped', (WidgetTester tester) async { bool favoriteTapped = false; await tester.pumpWidget(createTestWidget( @@ -153,7 +153,7 @@ void main() { expect(favoriteTapped, isTrue); }); - testWidgets('does not show style chip when style is null', + testWidgets('does not show style chip when style is null', (WidgetTester tester) async { final productNoStyle = Product.fromJson({ 'id': 'drink-2', @@ -162,7 +162,7 @@ void main() { 'dispense': 'cask', 'abv': '4.0', }); - + final drink = Drink( product: productNoStyle, producer: testProducer, @@ -176,7 +176,7 @@ void main() { expect(find.text('Cask'), findsOneWidget); }); - testWidgets('shows low availability chip correctly', + testWidgets('shows low availability chip correctly', (WidgetTester tester) async { final productLow = Product.fromJson({ 'id': 'drink-3', @@ -186,7 +186,7 @@ void main() { 'abv': '4.0', 'status_text': 'A little remaining', }); - + final drink = Drink( product: productLow, producer: testProducer, @@ -198,8 +198,7 @@ void main() { expect(find.text('Low'), findsOneWidget); }); - testWidgets('shows sold out chip correctly', - (WidgetTester tester) async { + testWidgets('shows sold out chip correctly', (WidgetTester tester) async { final productOut = Product.fromJson({ 'id': 'drink-4', 'name': 'Gone Beer', @@ -208,7 +207,7 @@ void main() { 'abv': '4.0', 'status_text': 'Sold out', }); - + final drink = Drink( product: productOut, producer: testProducer, @@ -220,7 +219,7 @@ void main() { expect(find.text('Sold Out'), findsOneWidget); }); - testWidgets('handles drink without availability status', + testWidgets('handles drink without availability status', (WidgetTester tester) async { final productNoStatus = Product.fromJson({ 'id': 'drink-5', @@ -229,7 +228,7 @@ void main() { 'dispense': 'cask', 'abv': '4.0', }); - + final drink = Drink( product: productNoStatus, producer: testProducer, @@ -276,7 +275,8 @@ void main() { 'dispense': 'cask', 'abv': '4.0', }); - return Drink(product: product, producer: testProducer, festivalId: 'cbf2025'); + return Drink( + product: product, producer: testProducer, festivalId: 'cbf2025'); } Color? accentBorderColor(WidgetTester tester) { @@ -294,42 +294,53 @@ void main() { } testWidgets('cider uses green accent', (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget(drink: drinkWithCategory('cider'))); + await tester + .pumpWidget(createTestWidget(drink: drinkWithCategory('cider'))); expect(accentBorderColor(tester), equals(const Color(0xFF22C55E))); }); testWidgets('perry uses lime accent', (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget(drink: drinkWithCategory('perry'))); + await tester + .pumpWidget(createTestWidget(drink: drinkWithCategory('perry'))); expect(accentBorderColor(tester), equals(const Color(0xFF84CC16))); }); testWidgets('mead uses gold accent', (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget(drink: drinkWithCategory('mead'))); + await tester + .pumpWidget(createTestWidget(drink: drinkWithCategory('mead'))); expect(accentBorderColor(tester), equals(const Color(0xFFD97706))); }); testWidgets('wine uses purple accent', (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget(drink: drinkWithCategory('wine'))); + await tester + .pumpWidget(createTestWidget(drink: drinkWithCategory('wine'))); expect(accentBorderColor(tester), equals(const Color(0xFF9333EA))); }); - testWidgets('international-beer uses red accent', (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget(drink: drinkWithCategory('international-beer'))); + testWidgets('international-beer uses red accent', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget(drink: drinkWithCategory('international-beer'))); expect(accentBorderColor(tester), equals(const Color(0xFFEF4444))); }); testWidgets('low-no uses cyan accent', (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget(drink: drinkWithCategory('low-no'))); + await tester + .pumpWidget(createTestWidget(drink: drinkWithCategory('low-no'))); expect(accentBorderColor(tester), equals(const Color(0xFF06B6D4))); }); - testWidgets('apple-juice uses apple-green accent', (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget(drink: drinkWithCategory('apple-juice'))); + testWidgets('apple-juice uses apple-green accent', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget(drink: drinkWithCategory('apple-juice'))); expect(accentBorderColor(tester), equals(const Color(0xFF65A30D))); }); - testWidgets('unknown category uses navy fallback accent', (WidgetTester tester) async { - await tester.pumpWidget(createTestWidget(drink: drinkWithCategory('unknown-type'))); + testWidgets('unknown category uses navy fallback accent', + (WidgetTester tester) async { + await tester.pumpWidget( + createTestWidget(drink: drinkWithCategory('unknown-type'))); expect(accentBorderColor(tester), equals(const Color(0xFF2B3170))); }); }); diff --git a/test/drink_detail_screen_screenshot_test.dart b/test/drink_detail_screen_screenshot_test.dart index 0b2a5a06..b2804a02 100644 --- a/test/drink_detail_screen_screenshot_test.dart +++ b/test/drink_detail_screen_screenshot_test.dart @@ -47,7 +47,8 @@ void main() { baseUrl: 'https://data.cambeerfestival.app', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); provider = BeerProvider( drinkRepository: mockDrinkRepository, festivalRepository: mockFestivalRepository, @@ -91,7 +92,8 @@ void main() { notes: 'A hoppy beer with citrus notes', ); - final drinkLongName = Drink(product: productLongName, producer: producer, festivalId: 'cbf2025'); + final drinkLongName = Drink( + product: productLongName, producer: producer, festivalId: 'cbf2025'); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => [drinkLongName]); @@ -122,7 +124,10 @@ void main() { bar: 'Main Bar', ); - final drinkMediumName = Drink(product: productMediumName, producer: producer, festivalId: 'cbf2025'); + final drinkMediumName = Drink( + product: productMediumName, + producer: producer, + festivalId: 'cbf2025'); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => [drinkMediumName]); diff --git a/test/drink_detail_screen_test.dart b/test/drink_detail_screen_test.dart index c237f9f8..e24d762a 100644 --- a/test/drink_detail_screen_test.dart +++ b/test/drink_detail_screen_test.dart @@ -46,7 +46,8 @@ void main() { allergens: {'gluten': 1, 'sulphites': 1}, ); - final drink = Drink(product: product, producer: producer, festivalId: 'cbf2025'); + final drink = + Drink(product: product, producer: producer, festivalId: 'cbf2025'); setUp(() async { SharedPreferences.setMockInitialValues({}); @@ -62,7 +63,8 @@ void main() { baseUrl: 'https://data.cambeerfestival.app', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => []); provider = BeerProvider( @@ -93,18 +95,23 @@ void main() { routes: [ GoRoute( path: '/cbf2025/drink/:category/:drinkId', - builder: (context, state) => ChangeNotifierProvider.value( + builder: (context, state) => + ChangeNotifierProvider.value( value: provider, - child: DrinkDetailScreen(festivalId: 'cbf2025', drinkId: state.pathParameters['drinkId']!), + child: DrinkDetailScreen( + festivalId: 'cbf2025', + drinkId: state.pathParameters['drinkId']!), ), ), GoRoute( path: '/cbf2025/brewery/:breweryId', - builder: (context, state) => const Scaffold(body: Text('Brewery Screen')), + builder: (context, state) => + const Scaffold(body: Text('Brewery Screen')), ), GoRoute( path: '/cbf2025/style/:style', - builder: (context, state) => const Scaffold(body: Text('Style Screen')), + builder: (context, state) => + const Scaffold(body: Text('Style Screen')), ), ], ); @@ -122,8 +129,7 @@ void main() { testWidgets('displays drink information when drink exists', (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); await tester.pumpWidget(createTestWidget('drink1')); @@ -136,8 +142,7 @@ void main() { expect(find.textContaining('Cambridge, UK'), findsWidgets); }); - testWidgets('displays drink details chips', - (WidgetTester tester) async { + testWidgets('displays drink details chips', (WidgetTester tester) async { final semanticsHandle = tester.ensureSemantics(); try { when(mockDrinkRepository.getDrinks(any)) @@ -149,7 +154,8 @@ void main() { // New layout shows combined information in HeroInfoCard expect(find.textContaining('5.0%'), findsOneWidget); - expect(find.textContaining('IPA'), findsWidgets); // Appears in HeroInfoCard and style chip + expect(find.textContaining('IPA'), + findsWidgets); // Appears in HeroInfoCard and style chip expect(find.textContaining('Cask'), findsOneWidget); expect(find.textContaining('Available at Main Bar'), findsOneWidget); expect(find.text('Vegan'), findsOneWidget); @@ -170,7 +176,10 @@ void main() { bar: 'Main Bar', statusText: 'Plenty remaining', ); - final drinkWithStatus = Drink(product: productWithStatus, producer: producer, festivalId: 'cbf2025'); + final drinkWithStatus = Drink( + product: productWithStatus, + producer: producer, + festivalId: 'cbf2025'); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => [drinkWithStatus]); await provider.loadDrinks(); @@ -192,7 +201,8 @@ void main() { dispense: 'keg', statusText: null, ); - final drinkNoStatus = Drink(product: productNoStatus, producer: producer, festivalId: 'cbf2025'); + final drinkNoStatus = Drink( + product: productNoStatus, producer: producer, festivalId: 'cbf2025'); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => [drinkNoStatus]); await provider.loadDrinks(); @@ -208,8 +218,7 @@ void main() { testWidgets('displays description when notes exist', (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); await tester.pumpWidget(createTestWidget('drink1')); @@ -218,10 +227,8 @@ void main() { expect(find.text('A hoppy beer with citrus notes'), findsOneWidget); }); - testWidgets('displays allergen information', - (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + testWidgets('displays allergen information', (WidgetTester tester) async { + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); await tester.pumpWidget(createTestWidget('drink1')); @@ -231,10 +238,8 @@ void main() { expect(find.byIcon(Icons.warning), findsOneWidget); }); - testWidgets('displays rating section', - (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + testWidgets('displays rating section', (WidgetTester tester) async { + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); await tester.pumpWidget(createTestWidget('drink1')); @@ -245,23 +250,20 @@ void main() { expect(find.widgetWithIcon(InkWell, Icons.star), findsOneWidget); }); - testWidgets('displays brewery section', - (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + testWidgets('displays brewery section', (WidgetTester tester) async { + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); await tester.pumpWidget(createTestWidget('drink1')); await tester.pumpAndSettle(); expect(find.text('Brewery'), findsOneWidget); - expect(find.byIcon(Icons.chevron_right), findsNWidgets(2)); // Style chip + brewery card + expect(find.byIcon(Icons.chevron_right), + findsNWidgets(2)); // Style chip + brewery card }); - testWidgets('has share button in app bar', - (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + testWidgets('has share button in app bar', (WidgetTester tester) async { + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); await tester.pumpWidget(createTestWidget('drink1')); @@ -270,10 +272,8 @@ void main() { expect(find.byIcon(Icons.share), findsOneWidget); }); - testWidgets('has favorite button in app bar', - (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + testWidgets('has favorite button in app bar', (WidgetTester tester) async { + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); await tester.pumpWidget(createTestWidget('drink1')); @@ -284,8 +284,7 @@ void main() { testWidgets('toggles favorite when favorite button is tapped', (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); await tester.pumpWidget(createTestWidget('drink1')); @@ -296,7 +295,8 @@ void main() { // Mock toggleFavorite to properly toggle state final favorites = {}; - when(mockDrinkRepository.toggleFavorite(any, any)).thenAnswer((invocation) async { + when(mockDrinkRepository.toggleFavorite(any, any)) + .thenAnswer((invocation) async { final drinkId = invocation.positionalArguments[1] as String; if (favorites.contains(drinkId)) { favorites.remove(drinkId); @@ -317,8 +317,7 @@ void main() { testWidgets('navigates to brewery screen when brewery card is tapped', (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); await tester.pumpWidget(createTestWidgetWithRouter('drink1')); @@ -339,8 +338,7 @@ void main() { testWidgets('navigates to style screen when style chip is tapped', (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); await tester.pumpWidget(createTestWidgetWithRouter('drink1')); @@ -366,7 +364,8 @@ void main() { dispense: 'keg', notes: null, ); - final drinkNoNotes = Drink(product: productNoNotes, producer: producer, festivalId: 'cbf2025'); + final drinkNoNotes = Drink( + product: productNoNotes, producer: producer, festivalId: 'cbf2025'); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => [drinkNoNotes]); await provider.loadDrinks(); @@ -380,10 +379,9 @@ void main() { testWidgets('displays rating value when drink has rating', (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); - + drink.rating = 4; await tester.pumpWidget(createTestWidget('drink1')); @@ -394,10 +392,9 @@ void main() { testWidgets('does not display rating value when drink has no rating', (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); - + drink.rating = null; await tester.pumpWidget(createTestWidget('drink1')); @@ -408,8 +405,7 @@ void main() { testWidgets('updates rating when set through provider', (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => [drink]); + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [drink]); await provider.loadDrinks(); await tester.pumpWidget(createTestWidget('drink1')); @@ -470,9 +466,14 @@ void main() { style: 'Lager', ); - final drink1 = Drink(product: product1, producer: producer1, festivalId: 'cbf2025'); - final drink2 = Drink(product: product2, producer: producer2, festivalId: 'cbf2025'); - final drink3 = Drink(product: product3, producer: producer1, festivalId: 'cbf2025'); // Same brewery + final drink1 = Drink( + product: product1, producer: producer1, festivalId: 'cbf2025'); + final drink2 = Drink( + product: product2, producer: producer2, festivalId: 'cbf2025'); + final drink3 = Drink( + product: product3, + producer: producer1, + festivalId: 'cbf2025'); // Same brewery when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => [drink1, drink2, drink3]); @@ -483,21 +484,22 @@ void main() { // Should show Similar Drinks section expect(find.text('Similar Drinks'), findsOneWidget); - + // Scroll down to ensure similar drinks are visible await tester.ensureVisible(find.text('Similar Drinks')); await tester.pumpAndSettle(); - + // Should show similar drinks (drink2 has same style and close ABV, drink3 has same brewery) expect(find.text('Similar IPA'), findsOneWidget); expect(find.text('Same Brewery Beer'), findsOneWidget); - + // Should show similarity reasons expect(find.text('Same style, similar strength'), findsOneWidget); expect(find.text('Same brewery'), findsOneWidget); }); - testWidgets('does not display similar drinks section when no similar drinks exist', + testWidgets( + 'does not display similar drinks section when no similar drinks exist', (WidgetTester tester) async { const producer1 = Producer( id: 'brewery1', @@ -515,7 +517,8 @@ void main() { style: 'Unique Style', ); - final drink1 = Drink(product: product1, producer: producer1, festivalId: 'cbf2025'); + final drink1 = Drink( + product: product1, producer: producer1, festivalId: 'cbf2025'); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => [drink1]); @@ -555,8 +558,10 @@ void main() { style: 'IPA', ); - final drink1 = Drink(product: product1, producer: producer1, festivalId: 'cbf2025'); - final drink2 = Drink(product: product2, producer: producer1, festivalId: 'cbf2025'); + final drink1 = Drink( + product: product1, producer: producer1, festivalId: 'cbf2025'); + final drink2 = Drink( + product: product2, producer: producer1, festivalId: 'cbf2025'); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => [drink1, drink2]); @@ -571,8 +576,8 @@ void main() { // Verify similar drink card exists expect(find.text('Similar IPA'), findsOneWidget); - - // NOTE: Navigation uses go_router's context.push() which requires GoRouter + + // NOTE: Navigation uses go_router's context.push() which requires GoRouter // in the widget tree. This is tested in E2E tests instead of unit tests. }); @@ -628,10 +633,14 @@ void main() { style: 'Bitter', ); - final drink1 = Drink(product: product1, producer: producer1, festivalId: 'cbf2025'); - final drink2 = Drink(product: product2, producer: producer2, festivalId: 'cbf2025'); - final drink3 = Drink(product: product3, producer: producer2, festivalId: 'cbf2025'); - final drink4 = Drink(product: product4, producer: producer2, festivalId: 'cbf2025'); + final drink1 = Drink( + product: product1, producer: producer1, festivalId: 'cbf2025'); + final drink2 = Drink( + product: product2, producer: producer2, festivalId: 'cbf2025'); + final drink3 = Drink( + product: product3, producer: producer2, festivalId: 'cbf2025'); + final drink4 = Drink( + product: product4, producer: producer2, festivalId: 'cbf2025'); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => [drink1, drink2, drink3, drink4]); @@ -647,7 +656,7 @@ void main() { // Should show drink with same style AND close ABV expect(find.text('Close ABV Bitter'), findsOneWidget); expect(find.text('Same style, similar strength'), findsOneWidget); - + // Should NOT show drinks that don't match both criteria expect(find.text('Different Style Beer'), findsNothing); expect(find.text('Same Style Far ABV'), findsNothing); diff --git a/test/drinks_screen_style_filter_test.dart b/test/drinks_screen_style_filter_test.dart index 4cdd453a..e38d1f86 100644 --- a/test/drinks_screen_style_filter_test.dart +++ b/test/drinks_screen_style_filter_test.dart @@ -94,7 +94,8 @@ void main() { ); when(mockFestivalRepository.getFestivals()) .thenAnswer((_) async => festivalsResponse); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => testDrinks); @@ -127,14 +128,16 @@ void main() { routes: [ GoRoute( path: '/cbf2025/drinks', - builder: (context, state) => ChangeNotifierProvider.value( + builder: (context, state) => + ChangeNotifierProvider.value( value: provider, child: const DrinksScreen(festivalId: 'cbf2025'), ), ), GoRoute( path: '/cbf2025/drink/:category/:drinkId', - builder: (context, state) => const Scaffold(body: Text('Drink Detail')), + builder: (context, state) => + const Scaffold(body: Text('Drink Detail')), ), ], ); @@ -193,7 +196,7 @@ void main() { // Verify IPA checkbox is initially unchecked final ipaCheckbox = find.widgetWithText(CheckboxListTile, 'IPA (1)'); expect(ipaCheckbox, findsOneWidget); - + CheckboxListTile checkboxWidget = tester.widget(ipaCheckbox); expect(checkboxWidget.value, false); @@ -317,7 +320,8 @@ void main() { expect(find.text('2 styles'), findsOneWidget); }); - testWidgets('clear button clears all selected styles and updates checkboxes', + testWidgets( + 'clear button clears all selected styles and updates checkboxes', (WidgetTester tester) async { await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); @@ -372,11 +376,11 @@ void main() { final firstCheckbox = tester.widget(checkboxes.at(0)); final secondCheckbox = tester.widget(checkboxes.at(1)); final thirdCheckbox = tester.widget(checkboxes.at(2)); - + expect((firstCheckbox.title as Text).data, 'Bitter (1)'); expect((secondCheckbox.title as Text).data, 'IPA (1)'); expect((thirdCheckbox.title as Text).data, 'Stout (1)'); - + // Verify Stout is selected but stays in alphabetical position expect(thirdCheckbox.value, true); }); @@ -461,10 +465,10 @@ void main() { festivalRepository: mockFestivalRepository, analyticsService: mockAnalyticsService, ); - + when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => drinksWithAccents); - + await accentProvider.initialize(); await accentProvider.loadDrinks(); @@ -492,7 +496,7 @@ void main() { final secondCheckbox = tester.widget(checkboxes.at(1)); final thirdCheckbox = tester.widget(checkboxes.at(2)); final fourthCheckbox = tester.widget(checkboxes.at(3)); - + expect((firstCheckbox.title as Text).data, 'Cafe (1)'); expect((secondCheckbox.title as Text).data, 'Café (1)'); expect((thirdCheckbox.title as Text).data, 'Rose (1)'); @@ -500,9 +504,9 @@ void main() { // Verify the accented characters display correctly (not garbled) expect((secondCheckbox.title as Text).data?.contains('é'), true, - reason: 'Café should display the é character correctly'); + reason: 'Café should display the é character correctly'); expect((fourthCheckbox.title as Text).data?.contains('é'), true, - reason: 'Rosé should display the é character correctly'); + reason: 'Rosé should display the é character correctly'); accentProvider.dispose(); }); @@ -533,12 +537,15 @@ void main() { version: '1.0.0', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); }); - testWidgets('loading state shows barrel mascot image', (WidgetTester tester) async { + testWidgets('loading state shows barrel mascot image', + (WidgetTester tester) async { final completer = Completer>(); - when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) => completer.future); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) => completer.future); final provider = BeerProvider( drinkRepository: mockDrinkRepository, @@ -563,15 +570,18 @@ void main() { final asset = img.image; return asset is AssetImage && asset.assetName == 'assets/app_icon.png'; }); - expect(hasBarrelMascot, isTrue, reason: 'Loading state should show barrel mascot'); + expect(hasBarrelMascot, isTrue, + reason: 'Loading state should show barrel mascot'); completer.complete([]); await tester.pumpAndSettle(); provider.dispose(); }); - testWidgets('empty state shows barrel mascot image', (WidgetTester tester) async { - when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => []); + testWidgets('empty state shows barrel mascot image', + (WidgetTester tester) async { + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => []); final provider = BeerProvider( drinkRepository: mockDrinkRepository, @@ -598,7 +608,8 @@ void main() { final asset = img.image; return asset is AssetImage && asset.assetName == 'assets/app_icon.png'; }); - expect(hasBarrelMascot, isTrue, reason: 'Empty state should show barrel mascot'); + expect(hasBarrelMascot, isTrue, + reason: 'Empty state should show barrel mascot'); provider.dispose(); }); diff --git a/test/environment_badge_test.dart b/test/environment_badge_test.dart index c1aec45a..6ee2b649 100644 --- a/test/environment_badge_test.dart +++ b/test/environment_badge_test.dart @@ -22,7 +22,8 @@ void main() { expect(find.text('Content'), findsOneWidget); }); - testWidgets('badge shows environment name when provided', (WidgetTester tester) async { + testWidgets('badge shows environment name when provided', + (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: Scaffold( @@ -40,7 +41,8 @@ void main() { expect(find.byIcon(Icons.science_outlined), findsOneWidget); }); - testWidgets('badge is hidden in production (no environment name)', (WidgetTester tester) async { + testWidgets('badge is hidden in production (no environment name)', + (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: Scaffold( diff --git a/test/info_chip_test.dart b/test/info_chip_test.dart index 0cc6a972..5397118a 100644 --- a/test/info_chip_test.dart +++ b/test/info_chip_test.dart @@ -51,7 +51,7 @@ void main() { ); expect(find.byType(InkWell), findsOneWidget); - + await tester.tap(find.byType(InfoChip)); expect(tapped, isTrue); }); diff --git a/test/main_test.dart b/test/main_test.dart index 7f5f6491..cb26f94d 100644 --- a/test/main_test.dart +++ b/test/main_test.dart @@ -46,7 +46,8 @@ void main() { baseUrl: 'https://example.com', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => []); @@ -70,7 +71,8 @@ void main() { expect(find.byType(BeerFestivalHome), findsOneWidget); }); - testWidgets('calls refreshIfStale when app resumes', (WidgetTester tester) async { + testWidgets('calls refreshIfStale when app resumes', + (WidgetTester tester) async { // Track if refreshIfStale is called by checking API calls var refreshCallCount = 0; @@ -112,7 +114,8 @@ void main() { expect(refreshCallCount, 0); }); - testWidgets('removes lifecycle observer on dispose', (WidgetTester tester) async { + testWidgets('removes lifecycle observer on dispose', + (WidgetTester tester) async { await tester.pumpWidget( ChangeNotifierProvider.value( value: provider, @@ -165,7 +168,8 @@ void main() { var systemNavigatorPopCalled = false; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, (methodCall) async { + .setMockMethodCallHandler(SystemChannels.platform, + (methodCall) async { if (methodCall.method == 'SystemNavigator.pop') { systemNavigatorPopCalled = true; } @@ -201,7 +205,8 @@ void main() { var systemNavigatorPopCalled = false; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(SystemChannels.platform, (methodCall) async { + .setMockMethodCallHandler(SystemChannels.platform, + (methodCall) async { if (methodCall.method == 'SystemNavigator.pop') { systemNavigatorPopCalled = true; } @@ -245,7 +250,8 @@ void main() { expect(find.text('Press back again to exit'), findsOneWidget); }); - testWidgets('initializes provider on first load', (WidgetTester tester) async { + testWidgets('initializes provider on first load', + (WidgetTester tester) async { await tester.pumpWidget( ChangeNotifierProvider.value( value: provider, @@ -267,7 +273,8 @@ void main() { verify(mockDrinkRepository.getDrinks(any)).called(1); }); - testWidgets('does not reinitialize on rebuild', (WidgetTester tester) async { + testWidgets('does not reinitialize on rebuild', + (WidgetTester tester) async { await tester.pumpWidget( ChangeNotifierProvider.value( value: provider, @@ -348,9 +355,12 @@ void main() { baseUrl: 'https://example.com', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); - when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [favoriteDrink]); - when(mockDrinkRepository.getFavorites(any)).thenAnswer((_) async => ['drink1']); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => [favoriteDrink]); + when(mockDrinkRepository.getFavorites(any)) + .thenAnswer((_) async => ['drink1']); provider = BeerProvider( drinkRepository: mockDrinkRepository, @@ -372,14 +382,16 @@ void main() { routes: [ GoRoute( path: '/favorites', - builder: (context, state) => ChangeNotifierProvider.value( + builder: (context, state) => + ChangeNotifierProvider.value( value: provider, child: const FavoritesScreen(festivalId: 'cbf2025'), ), ), GoRoute( path: '/cbf2025/drink/:category/:drinkId', - builder: (context, state) => const Scaffold(body: Text('Drink Detail')), + builder: (context, state) => + const Scaffold(body: Text('Drink Detail')), ), ], ); diff --git a/test/models_test.dart b/test/models_test.dart index 553892f4..ba04f1e8 100644 --- a/test/models_test.dart +++ b/test/models_test.dart @@ -55,7 +55,11 @@ void main() { test('availabilityStatus returns correct values', () { expect( Product.fromJson({ - 'id': '1', 'name': 'a', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '1', + 'name': 'a', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', 'status_text': 'Plenty left' }).availabilityStatus, AvailabilityStatus.plenty, @@ -63,7 +67,11 @@ void main() { expect( Product.fromJson({ - 'id': '2', 'name': 'b', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '2', + 'name': 'b', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', 'status_text': 'A little remaining' }).availabilityStatus, AvailabilityStatus.low, @@ -71,7 +79,11 @@ void main() { expect( Product.fromJson({ - 'id': '3', 'name': 'c', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '3', + 'name': 'c', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', 'status_text': 'Sold out' }).availabilityStatus, AvailabilityStatus.out, @@ -79,7 +91,11 @@ void main() { expect( Product.fromJson({ - 'id': '4', 'name': 'd', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '4', + 'name': 'd', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', 'status_text': 'Not yet available' }).availabilityStatus, AvailabilityStatus.notYetAvailable, @@ -87,7 +103,11 @@ void main() { expect( Product.fromJson({ - 'id': '5', 'name': 'e', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '5', + 'name': 'e', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', 'status_text': 'Coming soon' }).availabilityStatus, AvailabilityStatus.notYetAvailable, @@ -96,7 +116,11 @@ void main() { test('allergenText formats correctly', () { final product = Product.fromJson({ - 'id': '1', 'name': 'a', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '1', + 'name': 'a', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', 'allergens': {'gluten': 1, 'sulphites': 1}, }); @@ -495,7 +519,11 @@ void main() { group('isVegan', () { test('parses is_vegan true', () { final product = Product.fromJson({ - 'id': '1', 'name': 'a', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '1', + 'name': 'a', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', 'is_vegan': true, }); expect(product.isVegan, isTrue); @@ -503,7 +531,11 @@ void main() { test('parses is_vegan false', () { final product = Product.fromJson({ - 'id': '1', 'name': 'a', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '1', + 'name': 'a', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', 'is_vegan': false, }); expect(product.isVegan, isFalse); @@ -511,7 +543,11 @@ void main() { test('isVegan is null when not present in JSON', () { final product = Product.fromJson({ - 'id': '1', 'name': 'a', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '1', + 'name': 'a', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', }); expect(product.isVegan, isNull); }); @@ -520,7 +556,11 @@ void main() { group('isAllergenFree', () { test('returns true when allergens map is empty', () { final product = Product.fromJson({ - 'id': '1', 'name': 'a', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '1', + 'name': 'a', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', 'allergens': {}, }); expect(product.isAllergenFree, isTrue); @@ -528,14 +568,22 @@ void main() { test('returns true when no allergens field present', () { final product = Product.fromJson({ - 'id': '1', 'name': 'a', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '1', + 'name': 'a', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', }); expect(product.isAllergenFree, isTrue); }); test('returns true when all allergen values are 0', () { final product = Product.fromJson({ - 'id': '1', 'name': 'a', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '1', + 'name': 'a', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', 'allergens': {'gluten': 0, 'sulphites': 0}, }); expect(product.isAllergenFree, isTrue); @@ -543,7 +591,11 @@ void main() { test('returns false when any allergen value is 1', () { final product = Product.fromJson({ - 'id': '1', 'name': 'a', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '1', + 'name': 'a', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', 'allergens': {'gluten': 1}, }); expect(product.isAllergenFree, isFalse); @@ -551,7 +603,11 @@ void main() { test('returns false when any allergen value is 1 among others', () { final product = Product.fromJson({ - 'id': '1', 'name': 'a', 'category': 'beer', 'dispense': 'cask', 'abv': '4', + 'id': '1', + 'name': 'a', + 'category': 'beer', + 'dispense': 'cask', + 'abv': '4', 'allergens': {'gluten': 0, 'sulphites': 1}, }); expect(product.isAllergenFree, isFalse); @@ -906,7 +962,8 @@ void main() { final message = drink.getShareMessage('#cbf2025'); - expect(message, 'Drinking Test IPA from Test Brewery at #cbf2025 - 4 stars'); + expect(message, + 'Drinking Test IPA from Test Brewery at #cbf2025 - 4 stars'); }); test('uses provided hashtag', () { @@ -981,9 +1038,12 @@ void main() { dataBaseUrl: 'https://example.com/cbf2025', ); - expect(festival.getBeverageUrl('cider'), 'https://example.com/cbf2025/cider.json'); - expect(festival.getBeverageUrl('mead'), 'https://example.com/cbf2025/mead.json'); - expect(festival.getBeverageUrl('wine'), 'https://example.com/cbf2025/wine.json'); + expect(festival.getBeverageUrl('cider'), + 'https://example.com/cbf2025/cider.json'); + expect(festival.getBeverageUrl('mead'), + 'https://example.com/cbf2025/mead.json'); + expect(festival.getBeverageUrl('wine'), + 'https://example.com/cbf2025/wine.json'); }); group('formattedDates', () { @@ -1034,8 +1094,18 @@ void main() { test('formats all months correctly', () { final months = [ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' ]; for (var i = 0; i < 12; i++) { @@ -1129,7 +1199,8 @@ void main() { final festival = Festival.fromJson(json); expect(festival.charityPartnerName, 'Test Charity'); - expect(festival.charityDonationUrl, 'https://charity.example.com/donate'); + expect( + festival.charityDonationUrl, 'https://charity.example.com/donate'); }); test('handles latitude and longitude as int', () { @@ -1222,7 +1293,8 @@ void main() { final json = festival.toJson(); expect(json['charity_partner_name'], 'Test Charity'); - expect(json['charity_donation_url'], 'https://charity.example.com/donate'); + expect( + json['charity_donation_url'], 'https://charity.example.com/donate'); }); }); @@ -1283,7 +1355,8 @@ void main() { }); group('FestivalStatus', () { - test('isLive returns true when current date is between start and end', () { + test('isLive returns true when current date is between start and end', + () { final festival = Festival( id: 'test', name: 'Test Festival', @@ -1296,10 +1369,10 @@ void main() { expect(festival.isLive(DateTime(2025, 5, 20)), isTrue); expect(festival.isLive(DateTime(2025, 5, 19)), isTrue); expect(festival.isLive(DateTime(2025, 5, 24, 23, 59)), isTrue); - + // Before the festival expect(festival.isLive(DateTime(2025, 5, 18)), isFalse); - + // After the festival expect(festival.isLive(DateTime(2025, 5, 25)), isFalse); }); @@ -1343,9 +1416,12 @@ void main() { dataBaseUrl: 'https://example.com/test', ); - expect(festival.getBasicStatus(DateTime(2025, 5, 1)), FestivalStatus.upcoming); - expect(festival.getBasicStatus(DateTime(2025, 5, 20)), FestivalStatus.live); - expect(festival.getBasicStatus(DateTime(2025, 6, 1)), FestivalStatus.past); + expect(festival.getBasicStatus(DateTime(2025, 5, 1)), + FestivalStatus.upcoming); + expect(festival.getBasicStatus(DateTime(2025, 5, 20)), + FestivalStatus.live); + expect( + festival.getBasicStatus(DateTime(2025, 6, 1)), FestivalStatus.past); }); test('sortByDate orders festivals correctly', () { @@ -1356,7 +1432,7 @@ void main() { endDate: DateTime(2025, 5, 24), dataBaseUrl: 'https://example.com/live', ); - + final upcoming1 = Festival( id: 'upcoming1', name: 'Upcoming Festival 1', @@ -1364,7 +1440,7 @@ void main() { endDate: DateTime(2025, 6, 5), dataBaseUrl: 'https://example.com/upcoming1', ); - + final upcoming2 = Festival( id: 'upcoming2', name: 'Upcoming Festival 2', @@ -1372,7 +1448,7 @@ void main() { endDate: DateTime(2025, 7, 5), dataBaseUrl: 'https://example.com/upcoming2', ); - + final past1 = Festival( id: 'past1', name: 'Past Festival 1', @@ -1380,7 +1456,7 @@ void main() { endDate: DateTime(2025, 4, 5), dataBaseUrl: 'https://example.com/past1', ); - + final past2 = Festival( id: 'past2', name: 'Past Festival 2', @@ -1391,7 +1467,8 @@ void main() { // Test with date during live festival final now = DateTime(2025, 5, 20); - final sorted = Festival.sortByDate([past2, upcoming2, past1, live, upcoming1], now); + final sorted = Festival.sortByDate( + [past2, upcoming2, past1, live, upcoming1], now); expect(sorted[0].id, 'live'); // Live first expect(sorted[1].id, 'upcoming1'); // Then upcoming (soonest first) @@ -1408,7 +1485,7 @@ void main() { endDate: DateTime(2025, 4, 5), dataBaseUrl: 'https://example.com/past1', ); - + final past2 = Festival( id: 'past2', name: 'Past Festival 2', @@ -1420,8 +1497,10 @@ void main() { final now = DateTime(2025, 5, 1); final sorted = Festival.sortByDate([past2, past1], now); - expect(Festival.getStatusInContext(past1, sorted, now), FestivalStatus.mostRecent); - expect(Festival.getStatusInContext(past2, sorted, now), FestivalStatus.past); + expect(Festival.getStatusInContext(past1, sorted, now), + FestivalStatus.mostRecent); + expect(Festival.getStatusInContext(past2, sorted, now), + FestivalStatus.past); }); test('isLive treats a festival with no end date as a single day', () { @@ -1491,7 +1570,8 @@ void main() { expect(AvailabilityStatus.values, contains(AvailabilityStatus.plenty)); expect(AvailabilityStatus.values, contains(AvailabilityStatus.low)); expect(AvailabilityStatus.values, contains(AvailabilityStatus.out)); - expect(AvailabilityStatus.values, contains(AvailabilityStatus.notYetAvailable)); + expect(AvailabilityStatus.values, + contains(AvailabilityStatus.notYetAvailable)); }); }); } diff --git a/test/provider_test.dart b/test/provider_test.dart index 98f70831..8d23d16d 100644 --- a/test/provider_test.dart +++ b/test/provider_test.dart @@ -38,7 +38,8 @@ void main() { baseUrl: 'https://data.cambeerfestival.app', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); }); group('loadDrinks error messages', () { @@ -65,8 +66,8 @@ void main() { test('shows user-friendly message for 500 error', () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -86,8 +87,8 @@ void main() { test('shows user-friendly message for 502 error', () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -107,8 +108,8 @@ void main() { test('shows user-friendly message for 503 error', () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -128,8 +129,8 @@ void main() { test('shows user-friendly message for network timeout', () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -148,8 +149,8 @@ void main() { test('shows user-friendly message for no internet connection', () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -169,8 +170,8 @@ void main() { test('shows generic friendly message for unknown errors', () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -190,8 +191,8 @@ void main() { test('shows user-friendly message for 400-level errors', () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -212,8 +213,8 @@ void main() { () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -234,8 +235,8 @@ void main() { test('shows user-friendly message for festival 404 error', () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); // Mock 404 FestivalServiceException @@ -246,16 +247,16 @@ void main() { expect(provider.festivalsError, isNotNull); expect(provider.festivalsError, contains('Festival list not found')); - expect( - provider.festivalsError, isNot(contains('FestivalServiceException'))); + expect(provider.festivalsError, + isNot(contains('FestivalServiceException'))); expect(provider.festivalsError, isNot(contains('404'))); }); test('shows user-friendly message for festival 500 error', () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); // Mock 500 FestivalServiceException @@ -273,8 +274,8 @@ void main() { test('shows user-friendly message for festival 502 error', () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); // Mock 502 FestivalServiceException (Bad Gateway) @@ -292,8 +293,8 @@ void main() { test('shows user-friendly message for festival 503 error', () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); // Mock 503 FestivalServiceException (Service Unavailable) @@ -311,8 +312,8 @@ void main() { test('shows user-friendly message for festival network errors', () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); // http.ClientException is thrown on network failures across all platforms @@ -326,12 +327,13 @@ void main() { expect(provider.festivalsError, isNot(contains('ClientException'))); }); - test('shows connection message for FestivalServiceException without status', + test( + 'shows connection message for FestivalServiceException without status', () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); // Mock FestivalServiceException without status code @@ -351,8 +353,8 @@ void main() { () async { final provider = BeerProvider( drinkRepository: mockDrinkRepository, - festivalRepository: mockFestivalRepository, - analyticsService: mockAnalyticsService, + festivalRepository: mockFestivalRepository, + analyticsService: mockAnalyticsService, ); await provider.initialize(); @@ -502,12 +504,14 @@ void main() { festivalId: 'test-festival', ); - when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [testDrink]); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => [testDrink]); await provider.loadDrinks(); // Mock toggleFavorite to properly toggle state final favorites = {}; - when(mockDrinkRepository.toggleFavorite(any, any)).thenAnswer((invocation) async { + when(mockDrinkRepository.toggleFavorite(any, any)) + .thenAnswer((invocation) async { final drinkId = invocation.positionalArguments[1] as String; if (favorites.contains(drinkId)) { favorites.remove(drinkId); @@ -552,12 +556,14 @@ void main() { festivalId: 'test-festival', ); - when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [testDrink]); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => [testDrink]); await provider.loadDrinks(); // Mock toggleFavorite to properly toggle state final favorites = {}; - when(mockDrinkRepository.toggleFavorite(any, any)).thenAnswer((invocation) async { + when(mockDrinkRepository.toggleFavorite(any, any)) + .thenAnswer((invocation) async { final drinkId = invocation.positionalArguments[1] as String; if (favorites.contains(drinkId)) { favorites.remove(drinkId); @@ -604,7 +610,8 @@ void main() { festivalId: 'test-festival', ); - when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [testDrink]); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => [testDrink]); await provider.loadDrinks(); await provider.setRating(testDrink, 5); @@ -641,7 +648,8 @@ void main() { festivalId: 'test-festival', ); - when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => [testDrink]); + when(mockDrinkRepository.getDrinks(any)) + .thenAnswer((_) async => [testDrink]); await provider.loadDrinks(); await provider.setRating(testDrink, null); diff --git a/test/router_test.dart b/test/router_test.dart index 9c3849da..7d97f51c 100644 --- a/test/router_test.dart +++ b/test/router_test.dart @@ -55,7 +55,8 @@ void main() { baseUrl: 'https://example.com', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); when(mockDrinkRepository.getDrinks(any)) .thenAnswer((_) async => []); @@ -84,7 +85,8 @@ void main() { expect(find.byType(NavigationBar), findsOneWidget); }); - testWidgets('router handles festival-scoped /favorites route', (tester) async { + testWidgets('router handles festival-scoped /favorites route', + (tester) async { // Initialize provider with festivals await provider.initialize(); final festivalId = provider.currentFestival.id; @@ -129,7 +131,8 @@ void main() { baseUrl: 'https://example.com', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); await provider.initialize(); expect(provider.currentFestival.id, 'cbf2025'); @@ -161,7 +164,9 @@ void main() { expect(find.text('Cambridge 2025'), findsNothing); }); - testWidgets('router redirects root path to festival home after async initialization', (tester) async { + testWidgets( + 'router redirects root path to festival home after async initialization', + (tester) async { // DO NOT pre-initialize - this simulates the e2e scenario await tester.pumpWidget( ChangeNotifierProvider.value( @@ -176,12 +181,14 @@ void main() { await tester.pumpAndSettle(); // Should redirect from / to /cbf2025 after initialization - final currentUri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + final currentUri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(currentUri.pathSegments.isNotEmpty, true); expect(currentUri.pathSegments.first, 'cbf2025'); }); - testWidgets('router redirects invalid festival after async initialization', (tester) async { + testWidgets('router redirects invalid festival after async initialization', + (tester) async { // DO NOT pre-initialize - this simulates the e2e scenario await tester.pumpWidget( ChangeNotifierProvider.value( @@ -200,12 +207,15 @@ void main() { await tester.pumpAndSettle(); // Should redirect to current festival (cbf2025) after initialization - final currentUri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + final currentUri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(currentUri.pathSegments.first, 'cbf2025'); expect(provider.currentFestival.id, 'cbf2025'); }); - testWidgets('router redirects invalid festival with query params after async initialization', (tester) async { + testWidgets( + 'router redirects invalid festival with query params after async initialization', + (tester) async { // DO NOT pre-initialize - this simulates the e2e scenario await tester.pumpWidget( ChangeNotifierProvider.value( @@ -224,13 +234,16 @@ void main() { await tester.pumpAndSettle(); // Should redirect to cbf2025 and preserve query parameters - final currentUri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + final currentUri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(currentUri.pathSegments.first, 'cbf2025'); expect(currentUri.queryParameters['search'], 'IPA'); expect(currentUri.queryParameters['category'], 'beer'); }); - testWidgets('deep link to valid route does NOT redirect after async initialization', (tester) async { + testWidgets( + 'deep link to valid route does NOT redirect after async initialization', + (tester) async { // DO NOT pre-initialize - simulates deep link before app loads await tester.pumpWidget( ChangeNotifierProvider.value( @@ -249,7 +262,8 @@ void main() { await tester.pumpAndSettle(); // Should stay on drink detail route (valid festival ID) - final currentUri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + final currentUri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(currentUri.pathSegments.length, 4); expect(currentUri.pathSegments[0], testFestivalId); expect(currentUri.pathSegments[1], 'drink'); @@ -257,7 +271,8 @@ void main() { expect(currentUri.pathSegments[3], testDrinkId); }); - testWidgets('global /about route NOT redirected after async initialization', (tester) async { + testWidgets('global /about route NOT redirected after async initialization', + (tester) async { // Create a fresh router for this test to avoid state pollution final testRouter = GoRouter( initialLocation: '/about', // Start at /about @@ -278,8 +293,10 @@ void main() { await tester.pumpAndSettle(); // Should STAY at /about (NOT redirect to /cbf2025) - final currentUri = Uri.parse(testRouter.routerDelegate.currentConfiguration.uri.toString()); - expect(currentUri.path, '/about', reason: 'Global /about route should not be redirected'); + final currentUri = Uri.parse( + testRouter.routerDelegate.currentConfiguration.uri.toString()); + expect(currentUri.path, '/about', + reason: 'Global /about route should not be redirected'); }); // Note: Browser back/forward is handled by go_router's declarative API @@ -289,8 +306,10 @@ void main() { testWidgets('redirect handles API failure gracefully', (tester) async { // Mock API failure - when(mockFestivalRepository.getFestivals()).thenThrow(Exception('API error')); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getFestivals()) + .thenThrow(Exception('API error')); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); await tester.pumpWidget( ChangeNotifierProvider.value( @@ -300,14 +319,18 @@ void main() { ), ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); await tester.pumpAndSettle(); // Should fall back to default festival despite API failure - final currentUri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + final currentUri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(currentUri.pathSegments.isNotEmpty, true); - expect(currentUri.pathSegments.first, DefaultFestivals.all.firstWhere((f) => f.isActive).id, reason: 'Should use active hardcoded festival when API fails'); + expect(currentUri.pathSegments.first, + DefaultFestivals.all.firstWhere((f) => f.isActive).id, + reason: 'Should use active hardcoded festival when API fails'); }); testWidgets('redirect handles empty festivals list', (tester) async { @@ -315,12 +338,14 @@ void main() { when(mockFestivalRepository.getFestivals()).thenAnswer( (_) async => FestivalsResponse( festivals: const [], - defaultFestivalId: 'cbf2025', // Still provide default even with empty list + defaultFestivalId: + 'cbf2025', // Still provide default even with empty list version: '1.0.0', baseUrl: 'https://example.com', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); await tester.pumpWidget( ChangeNotifierProvider.value( @@ -334,12 +359,17 @@ void main() { await tester.pumpAndSettle(); // Should use hardcoded default festival - final currentUri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + final currentUri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(currentUri.pathSegments.isNotEmpty, true); - expect(currentUri.pathSegments.first, DefaultFestivals.all.firstWhere((f) => f.isActive).id, reason: 'Should use active hardcoded festival when registry is empty'); + expect(currentUri.pathSegments.first, + DefaultFestivals.all.firstWhere((f) => f.isActive).id, + reason: + 'Should use active hardcoded festival when registry is empty'); }); - testWidgets('multiple rapid navigations before init completes', (tester) async { + testWidgets('multiple rapid navigations before init completes', + (tester) async { await tester.pumpWidget( ChangeNotifierProvider.value( value: provider, @@ -361,9 +391,11 @@ void main() { await tester.pumpAndSettle(); // Should end up at the final destination without errors - final currentUri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + final currentUri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(currentUri.pathSegments.first, 'cbf2025'); - expect(tester.takeException(), isNull, reason: 'Should not throw exceptions during rapid navigation'); + expect(tester.takeException(), isNull, + reason: 'Should not throw exceptions during rapid navigation'); }); testWidgets('festival switch during navigation after init', (tester) async { @@ -387,7 +419,8 @@ void main() { baseUrl: 'https://example.com', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); await tester.pumpWidget( ChangeNotifierProvider.value( @@ -406,14 +439,17 @@ void main() { await tester.pumpAndSettle(); // Provider should switch to cbf2024 (via postFrameCallback) - expect(provider.currentFestival.id, 'cbf2024', reason: 'Provider should switch to festival in URL'); + expect(provider.currentFestival.id, 'cbf2024', + reason: 'Provider should switch to festival in URL'); }); testWidgets('navigation during slow initialization', (tester) async { // Create a completer to control initialization timing final completer = Completer(); - when(mockFestivalRepository.getFestivals()).thenAnswer((_) => completer.future); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getFestivals()) + .thenAnswer((_) => completer.future); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); await tester.pumpWidget( ChangeNotifierProvider.value( @@ -423,7 +459,8 @@ void main() { ), ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); // Start showing loading state await tester.pump(); @@ -449,9 +486,10 @@ void main() { await tester.pumpAndSettle(); // Should STAY at drink detail (not redirect to /cbf2025) - final currentUri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + final currentUri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(currentUri.path, '/cbf2025/drink/beer/test-drink-123', - reason: 'Should not redirect when already on valid route'); + reason: 'Should not redirect when already on valid route'); }); // KNOWN LIMITATION: Deep links with invalid festival IDs in subpaths @@ -460,7 +498,9 @@ void main() { // Reason: Matches route pattern directly, bypassing redirect logic // Fix: Requires adding festival ID validation to ALL route builders - testWidgets('router redirects invalid festival ID (pre-initialized provider)', (tester) async { + testWidgets( + 'router redirects invalid festival ID (pre-initialized provider)', + (tester) async { await provider.initialize(); final currentFestival = provider.currentFestival.id; @@ -483,7 +523,9 @@ void main() { expect(provider.currentFestival.id, currentFestival); }); - testWidgets('router preserves query parameters when redirecting invalid festival ID', (tester) async { + testWidgets( + 'router preserves query parameters when redirecting invalid festival ID', + (tester) async { await provider.initialize(); final currentFestival = provider.currentFestival.id; @@ -503,13 +545,15 @@ void main() { await tester.pumpAndSettle(); // Should redirect to current festival and preserve query params - final currentUri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + final currentUri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(currentUri.pathSegments.first, currentFestival); expect(currentUri.queryParameters['search'], 'IPA'); expect(currentUri.queryParameters['category'], 'beer'); }); // Edge cases and limitations - testWidgets('URL fragments are lost during redirect (KNOWN LIMITATION)', (tester) async { + testWidgets('URL fragments are lost during redirect (KNOWN LIMITATION)', + (tester) async { // This documents the current limitation mentioned in lib/main.dart await tester.pumpWidget( ChangeNotifierProvider.value( @@ -525,15 +569,19 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - final currentUri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + final currentUri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); // Currently fragments are lost during redirect - expect(currentUri.pathSegments.first, testFestivalId, reason: 'Should redirect to valid festival'); - expect(currentUri.fragment, isEmpty, reason: 'Fragment is lost (KNOWN LIMITATION - see lib/main.dart)'); + expect(currentUri.pathSegments.first, testFestivalId, + reason: 'Should redirect to valid festival'); + expect(currentUri.fragment, isEmpty, + reason: 'Fragment is lost (KNOWN LIMITATION - see lib/main.dart)'); // TODO: Fix this by preserving currentUri.fragment in redirect URL construction }); - testWidgets('URL-encoded festival IDs are handled correctly', (tester) async { + testWidgets('URL-encoded festival IDs are handled correctly', + (tester) async { // Ensure malformed/encoded IDs don't bypass validation await tester.pumpWidget( ChangeNotifierProvider.value( @@ -549,15 +597,18 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - final currentUri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + final currentUri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); // URL-encoded IDs should be treated as invalid and redirected expect(currentUri.pathSegments.first, testFestivalId, - reason: 'Encoded festival IDs should not match valid festival IDs'); + reason: 'Encoded festival IDs should not match valid festival IDs'); }); // Regression tests for issue #266: cold-load / browser-refresh uses wrong festival - testWidgets('cold load of non-default festival URL syncs provider (regression #266)', (tester) async { + testWidgets( + 'cold load of non-default festival URL syncs provider (regression #266)', + (tester) async { when(mockFestivalRepository.getFestivals()).thenAnswer( (_) async => FestivalsResponse( festivals: const [ @@ -577,7 +628,8 @@ void main() { baseUrl: 'https://example.com', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); // True cold load: router starts at cbf2024 before any initialization final testRouter = GoRouter( @@ -597,12 +649,15 @@ void main() { // Provider must be synced to the URL festival, not the default expect(provider.currentFestival.id, 'cbf2024', - reason: 'Cold-loaded festival URL must sync the provider (issue #266)'); + reason: + 'Cold-loaded festival URL must sync the provider (issue #266)'); expect(find.text('Cambridge 2024'), findsOneWidget); expect(find.text('Cambridge 2025'), findsNothing); }); - testWidgets('cold load of drink deep link from non-default festival syncs provider (regression #266)', (tester) async { + testWidgets( + 'cold load of drink deep link from non-default festival syncs provider (regression #266)', + (tester) async { when(mockFestivalRepository.getFestivals()).thenAnswer( (_) async => FestivalsResponse( festivals: const [ @@ -622,7 +677,8 @@ void main() { baseUrl: 'https://example.com', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); // True cold load: shared drink link from a non-default festival final testRouter = GoRouter( @@ -642,10 +698,13 @@ void main() { // Provider must switch to cbf2024 so getDrinkById searches the right festival expect(provider.currentFestival.id, 'cbf2024', - reason: 'Cold-loaded deep link must sync provider to the URL festival (issue #266)'); + reason: + 'Cold-loaded deep link must sync provider to the URL festival (issue #266)'); }); - testWidgets('invalid festival ID in detail routes redirects to current festival equivalent', (tester) async { + testWidgets( + 'invalid festival ID in detail routes redirects to current festival equivalent', + (tester) async { await provider.initialize(); await tester.pumpWidget( @@ -659,33 +718,39 @@ void main() { // Drink route appRouter.go('/$invalidFestivalId/drink/beer/$testDrinkId'); await tester.pumpAndSettle(); - var uri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + var uri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(uri.pathSegments[0], testFestivalId); expect(uri.pathSegments[1], 'drink'); expect(uri.pathSegments[2], 'beer'); expect(uri.pathSegments[3], testDrinkId, - reason: 'Invalid festival in drink route should redirect, preserving category and drink ID'); + reason: + 'Invalid festival in drink route should redirect, preserving category and drink ID'); // Brewery route appRouter.go('/$invalidFestivalId/brewery/$testBreweryId'); await tester.pumpAndSettle(); - uri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + uri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(uri.pathSegments[0], testFestivalId); expect(uri.pathSegments[1], 'brewery'); expect(uri.pathSegments[2], testBreweryId, - reason: 'Invalid festival in brewery route should redirect, preserving brewery ID'); + reason: + 'Invalid festival in brewery route should redirect, preserving brewery ID'); // Style route appRouter.go('/$invalidFestivalId/style/IPA'); await tester.pumpAndSettle(); - uri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + uri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(uri.pathSegments[0], testFestivalId); expect(uri.pathSegments[1], 'style'); // Info route appRouter.go('/$invalidFestivalId/info'); await tester.pumpAndSettle(); - uri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + uri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(uri.pathSegments[0], testFestivalId); expect(uri.pathSegments[1], 'info', reason: 'Invalid festival in info route should redirect'); @@ -693,13 +758,16 @@ void main() { // Favorites route appRouter.go('/$invalidFestivalId/favorites'); await tester.pumpAndSettle(); - uri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + uri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(uri.pathSegments[0], testFestivalId); expect(uri.pathSegments[1], 'favorites', reason: 'Invalid festival in favorites route should redirect'); }); - testWidgets('favorites route with different festival switches provider (hot navigation)', (tester) async { + testWidgets( + 'favorites route with different festival switches provider (hot navigation)', + (tester) async { when(mockFestivalRepository.getFestivals()).thenAnswer( (_) async => FestivalsResponse( festivals: const [ @@ -734,10 +802,13 @@ void main() { await tester.pumpAndSettle(); expect(provider.currentFestival.id, 'cbf2024', - reason: 'Navigating to favorites of a different festival should switch provider'); + reason: + 'Navigating to favorites of a different festival should switch provider'); }); - testWidgets('brewery, style and info routes are reachable for valid festival', (tester) async { + testWidgets( + 'brewery, style and info routes are reachable for valid festival', + (tester) async { await provider.initialize(); await tester.pumpWidget( @@ -751,26 +822,31 @@ void main() { // Brewery route appRouter.go('/$testFestivalId/brewery/$testBreweryId'); await tester.pumpAndSettle(); - var uri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + var uri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(uri.pathSegments[0], testFestivalId); expect(uri.pathSegments[1], 'brewery'); // Style route appRouter.go('/$testFestivalId/style/IPA'); await tester.pumpAndSettle(); - uri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + uri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(uri.pathSegments[0], testFestivalId); expect(uri.pathSegments[1], 'style'); // Info route appRouter.go('/$testFestivalId/info'); await tester.pumpAndSettle(); - uri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + uri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(uri.pathSegments[0], testFestivalId); expect(uri.pathSegments[1], 'info'); }); - testWidgets('navigating to drink detail updates URL with category and drink ID', (tester) async { + testWidgets( + 'navigating to drink detail updates URL with category and drink ID', + (tester) async { await provider.initialize(); await tester.pumpWidget( @@ -792,14 +868,17 @@ void main() { await tester.pumpAndSettle(); // URL must update to the full /:festivalId/drink/:category/:id deep-link format - final uri = Uri.parse(appRouter.routerDelegate.currentConfiguration.uri.toString()); + final uri = Uri.parse( + appRouter.routerDelegate.currentConfiguration.uri.toString()); expect(uri.pathSegments.length, 4, - reason: 'Drink detail URL must include festivalId, "drink", category, and drinkId'); + reason: + 'Drink detail URL must include festivalId, "drink", category, and drinkId'); expect(uri.pathSegments[0], testFestivalId); expect(uri.pathSegments[1], 'drink'); expect(uri.pathSegments[2], category); expect(uri.pathSegments[3], testDrinkId, - reason: 'URL must match deep-link format so shared/bookmarked links work'); + reason: + 'URL must match deep-link format so shared/bookmarked links work'); }); }); diff --git a/test/screens_test.dart b/test/screens_test.dart index 0d746c2d..3331fa63 100644 --- a/test/screens_test.dart +++ b/test/screens_test.dart @@ -97,16 +97,15 @@ void main() { longitude: 0.1218, location: 'Test Location', ); - + // Set up provider with test festival mockDrinkRepository = MockDrinkRepository(); mockFestivalRepository = MockFestivalRepository(); mockAnalyticsService = MockAnalyticsService(); - + // Mock fetchAllDrinks to return empty list - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => []); - + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => []); + provider = BeerProvider( drinkRepository: mockDrinkRepository, festivalRepository: mockFestivalRepository, @@ -253,7 +252,8 @@ void main() { expect(find.text('Donate to Test Charity'), findsOneWidget); }); - testWidgets('does not render donate button when charity fields are absent', + testWidgets( + 'does not render donate button when charity fields are absent', (WidgetTester tester) async { await provider.setFestival(testFestival); await tester.pumpWidget(createTestWidget()); @@ -489,12 +489,12 @@ void main() { expect(provider.themeMode, ThemeMode.light); }); - testWidgets('opens license page when tapped', - (WidgetTester tester) async { + testWidgets('opens license page when tapped', (WidgetTester tester) async { await tester.pumpWidget(createTestWidget()); await tester.pumpAndSettle(); - final licenseButton = find.widgetWithText(ListTile, 'Open Source Licenses'); + final licenseButton = + find.widgetWithText(ListTile, 'Open Source Licenses'); await tester.ensureVisible(licenseButton); await tester.pumpAndSettle(); diff --git a/test/services_test.dart b/test/services_test.dart index f73756ba..bdda21d7 100644 --- a/test/services_test.dart +++ b/test/services_test.dart @@ -104,10 +104,13 @@ void main() { 'default_festival_id': 'cbf2025', }; - final response = FestivalsResponse.fromJson(json, 'https://data.cambeerfestival.app'); + final response = + FestivalsResponse.fromJson(json, 'https://data.cambeerfestival.app'); - expect(response.festivals[0].dataBaseUrl, 'https://data.cambeerfestival.app/cbf2025'); - expect(response.festivals[1].dataBaseUrl, 'https://data.cambeerfestival.app/cbfw2025'); + expect(response.festivals[0].dataBaseUrl, + 'https://data.cambeerfestival.app/cbf2025'); + expect(response.festivals[1].dataBaseUrl, + 'https://data.cambeerfestival.app/cbfw2025'); }); test('fromJson handles missing optional fields', () { diff --git a/test/storage_service_test.dart b/test/storage_service_test.dart index e1bdc564..aa525a81 100644 --- a/test/storage_service_test.dart +++ b/test/storage_service_test.dart @@ -68,7 +68,8 @@ void main() { final prefs = await SharedPreferences.getInstance(); favoritesService = FavoritesService(prefs); - final result = await favoritesService.toggleFavorite('cbf2025', 'drink-123'); + final result = + await favoritesService.toggleFavorite('cbf2025', 'drink-123'); expect(result, isTrue); expect(favoritesService.isFavorite('cbf2025', 'drink-123'), isTrue); @@ -79,7 +80,8 @@ void main() { favoritesService = FavoritesService(prefs); await favoritesService.addFavorite('cbf2025', 'drink-123'); - final result = await favoritesService.toggleFavorite('cbf2025', 'drink-123'); + final result = + await favoritesService.toggleFavorite('cbf2025', 'drink-123'); expect(result, isFalse); expect(favoritesService.isFavorite('cbf2025', 'drink-123'), isFalse); @@ -114,7 +116,8 @@ void main() { expect(favoritesService.isFavorite('cbf2024', 'drink-123'), isFalse); }); - test('getFavorites returns separate sets for different festivals', () async { + test('getFavorites returns separate sets for different festivals', + () async { final prefs = await SharedPreferences.getInstance(); favoritesService = FavoritesService(prefs); @@ -262,7 +265,8 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - test('getSelectedFestivalId returns null when no festival is selected', () async { + test('getSelectedFestivalId returns null when no festival is selected', + () async { final prefs = await SharedPreferences.getInstance(); festivalStorageService = FestivalStorageService(prefs); @@ -303,7 +307,8 @@ void main() { expect(festivalId, isNull); }); - test('clearSelectedFestival handles no saved festival gracefully', () async { + test('clearSelectedFestival handles no saved festival gracefully', + () async { final prefs = await SharedPreferences.getInstance(); festivalStorageService = FestivalStorageService(prefs); diff --git a/test/string_comparison_helper_test.dart b/test/string_comparison_helper_test.dart index 2321be3c..834cf534 100644 --- a/test/string_comparison_helper_test.dart +++ b/test/string_comparison_helper_test.dart @@ -28,16 +28,25 @@ void main() { final cafeIndex = sorted.indexWhere((s) => s == 'Cafe'); final cafeAccentIndex = sorted.indexWhere((s) => s == 'Café'); expect(cafeIndex, lessThan(cafeAccentIndex), - reason: 'Cafe should come before Café'); + reason: 'Cafe should come before Café'); final roseIndex = sorted.indexWhere((s) => s == 'Rose'); final roseAccentIndex = sorted.indexWhere((s) => s == 'Rosé'); expect(roseIndex, lessThan(roseAccentIndex), - reason: 'Rose should come before Rosé'); + reason: 'Rose should come before Rosé'); }); test('maintains consistent alphabetical ordering', () { - final unsorted = ['Rosé', 'Rose', 'IPA', 'Bitter', 'Café', 'Cafe', 'Pilsner', 'Stout']; + final unsorted = [ + 'Rosé', + 'Rose', + 'IPA', + 'Bitter', + 'Café', + 'Cafe', + 'Pilsner', + 'Stout' + ]; final sorted = List.from(unsorted); sorted.sort(StringComparisonHelper.compareLocaleAware); @@ -59,11 +68,11 @@ void main() { test('handles various Unicode characters', () { // Test with various European characters that might appear in beer/wine names final unsorted = [ - 'Kölsch', // German ö + 'Kölsch', // German ö 'Kolsch', - 'Märzen', // German ä + 'Märzen', // German ä 'Marzen', - 'Niño', // Spanish ñ + 'Niño', // Spanish ñ 'Nino', ]; final sorted = List.from(unsorted); @@ -71,12 +80,14 @@ void main() { // Verify basic alphabetical grouping works // All K's should come before M's, M's before N's - final kCount = sorted.where((s) => s.toLowerCase().startsWith('k')).length; - final mCount = sorted.where((s) => s.toLowerCase().startsWith('m')).length; - + final kCount = + sorted.where((s) => s.toLowerCase().startsWith('k')).length; + final mCount = + sorted.where((s) => s.toLowerCase().startsWith('m')).length; + expect(kCount, 2); expect(mCount, 2); - + // Verify the K words come first expect(sorted[0].toLowerCase().startsWith('k'), true); expect(sorted[1].toLowerCase().startsWith('k'), true); @@ -90,17 +101,19 @@ void main() { // Ensure the comparison doesn't modify the strings const original = 'Rosé Cider'; const copy = 'Rosé Cider'; - + StringComparisonHelper.compareLocaleAware(original, copy); - - expect(original, 'Rosé Cider', reason: 'Original string should not be modified'); + + expect(original, 'Rosé Cider', + reason: 'Original string should not be modified'); expect(copy, 'Rosé Cider', reason: 'Copy string should not be modified'); }); test('handles empty strings', () { expect(StringComparisonHelper.compareLocaleAware('', ''), 0); expect(StringComparisonHelper.compareLocaleAware('', 'a'), lessThan(0)); - expect(StringComparisonHelper.compareLocaleAware('a', ''), greaterThan(0)); + expect( + StringComparisonHelper.compareLocaleAware('a', ''), greaterThan(0)); }); test('returns consistent ordering (transitivity)', () { @@ -114,7 +127,8 @@ void main() { final ac = StringComparisonHelper.compareLocaleAware(a, c); if (ab < 0 && bc < 0) { - expect(ac, lessThan(0), reason: 'Transitivity should hold: a < b < c => a < c'); + expect(ac, lessThan(0), + reason: 'Transitivity should hold: a < b < c => a < c'); } }); @@ -130,7 +144,7 @@ void main() { 'Bitter', 'Porter', ]; - + final sorted = List.from(styles); sorted.sort(StringComparisonHelper.compareLocaleAware); @@ -140,7 +154,7 @@ void main() { final iIndex = sorted.indexWhere((s) => s.toLowerCase().startsWith('i')); final kIndex = sorted.indexWhere((s) => s.toLowerCase().startsWith('k')); final mIndex = sorted.indexWhere((s) => s.toLowerCase().startsWith('m')); - + expect(bIndex, lessThan(iIndex), reason: 'B should come before I'); expect(iIndex, lessThan(kIndex), reason: 'I should come before K'); expect(kIndex, lessThan(mIndex), reason: 'K should come before M'); @@ -150,20 +164,20 @@ void main() { // This test verifies that the strings with accented characters // maintain their correct form after comparison final styles = ['Rosé', 'Café', 'Märzen']; - + styles.sort(StringComparisonHelper.compareLocaleAware); - + // Verify the accented characters are preserved correctly expect(styles.any((s) => s.contains('é')), true, - reason: 'Should contain é character'); + reason: 'Should contain é character'); expect(styles.any((s) => s.contains('ä')), true, - reason: 'Should contain ä character'); - + reason: 'Should contain ä character'); + // Verify they're not garbled (common mojibake patterns) expect(styles.any((s) => s.contains('é')), false, - reason: 'Should not contain mojibake é (garbled é)'); + reason: 'Should not contain mojibake é (garbled é)'); expect(styles.any((s) => s.contains('ä')), false, - reason: 'Should not contain mojibake ä (garbled ä)'); + reason: 'Should not contain mojibake ä (garbled ä)'); }); }); } diff --git a/test/string_formatting_helper_test.dart b/test/string_formatting_helper_test.dart index f785e80c..4aa344ac 100644 --- a/test/string_formatting_helper_test.dart +++ b/test/string_formatting_helper_test.dart @@ -10,8 +10,8 @@ void main() { }); test('leaves the remaining characters untouched', () { - expect(StringFormattingHelper.capitalizeFirst('bag in box'), - 'Bag in box'); + expect( + StringFormattingHelper.capitalizeFirst('bag in box'), 'Bag in box'); }); test('is a no-op for an already capitalised string', () { diff --git a/test/style_description_helper_test.dart b/test/style_description_helper_test.dart index cc656882..aee5b845 100644 --- a/test/style_description_helper_test.dart +++ b/test/style_description_helper_test.dart @@ -12,7 +12,8 @@ void main() { testWidgets('returns null for unknown style', (tester) async { await tester.pumpWidget(const MaterialApp(home: SizedBox())); - final result = await StyleDescriptionHelper.getStyleDescription('Unknown Style'); + final result = + await StyleDescriptionHelper.getStyleDescription('Unknown Style'); expect(result, isNull); }); @@ -27,12 +28,12 @@ void main() { testWidgets('handles case-insensitive lookup', (tester) async { await tester.pumpWidget(const MaterialApp(home: SizedBox())); - + // All case variations should return the same description final result1 = await StyleDescriptionHelper.getStyleDescription('IPA'); final result2 = await StyleDescriptionHelper.getStyleDescription('ipa'); final result3 = await StyleDescriptionHelper.getStyleDescription('Ipa'); - + expect(result1, isNotNull); expect(result1, equals(result2)); expect(result2, equals(result3)); @@ -41,14 +42,15 @@ void main() { testWidgets('trims whitespace from style name', (tester) async { await tester.pumpWidget(const MaterialApp(home: SizedBox())); - final result = await StyleDescriptionHelper.getStyleDescription(' IPA '); + final result = + await StyleDescriptionHelper.getStyleDescription(' IPA '); expect(result, isNotNull); expect(result, isNotEmpty); }); testWidgets('filters out empty string descriptions', (tester) async { await tester.pumpWidget(const MaterialApp(home: SizedBox())); - + // The helper should filter out empty strings from JSON final result = await StyleDescriptionHelper.getStyleDescription('ipa'); // Should be non-null and non-empty if it exists diff --git a/test/style_screen_screenshot_test.dart b/test/style_screen_screenshot_test.dart index 0e5855aa..b5dbb26b 100644 --- a/test/style_screen_screenshot_test.dart +++ b/test/style_screen_screenshot_test.dart @@ -60,9 +60,12 @@ void main() { dispense: 'cask', ); - final drink1 = Drink(product: product1, producer: producer1, festivalId: 'cbf2025'); - final drink2 = Drink(product: product2, producer: producer2, festivalId: 'cbf2025'); - final drink3 = Drink(product: product3, producer: producer1, festivalId: 'cbf2025'); + final drink1 = + Drink(product: product1, producer: producer1, festivalId: 'cbf2025'); + final drink2 = + Drink(product: product2, producer: producer2, festivalId: 'cbf2025'); + final drink3 = + Drink(product: product3, producer: producer1, festivalId: 'cbf2025'); setUp(() async { SharedPreferences.setMockInitialValues({}); @@ -78,7 +81,8 @@ void main() { baseUrl: 'https://data.cambeerfestival.app', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => []); provider = BeerProvider( @@ -120,7 +124,7 @@ void main() { await tester.pumpWidget(createTestWidget('IPA')); await tester.pumpAndSettle(); - + // Wait for FutureBuilder to load description await tester.pumpAndSettle(); @@ -156,7 +160,7 @@ void main() { ), ); await tester.pumpAndSettle(); - + // Wait for FutureBuilder to load description await tester.pumpAndSettle(); diff --git a/test/style_screen_test.dart b/test/style_screen_test.dart index 5533a527..0149bec1 100644 --- a/test/style_screen_test.dart +++ b/test/style_screen_test.dart @@ -61,9 +61,12 @@ void main() { dispense: 'cask', ); - final drink1 = Drink(product: product1, producer: producer1, festivalId: 'cbf2025'); - final drink2 = Drink(product: product2, producer: producer2, festivalId: 'cbf2025'); - final drink3 = Drink(product: product3, producer: producer1, festivalId: 'cbf2025'); + final drink1 = + Drink(product: product1, producer: producer1, festivalId: 'cbf2025'); + final drink2 = + Drink(product: product2, producer: producer2, festivalId: 'cbf2025'); + final drink3 = + Drink(product: product3, producer: producer1, festivalId: 'cbf2025'); setUp(() async { SharedPreferences.setMockInitialValues({}); @@ -79,7 +82,8 @@ void main() { baseUrl: 'https://data.cambeerfestival.app', ), ); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => []); provider = BeerProvider( @@ -173,7 +177,7 @@ void main() { // Find the drink card - this verifies the card is rendered and tappable expect(find.text('Test IPA 1'), findsOneWidget); - + // NOTE: Navigation to DrinkDetailScreen uses go_router's context.push() // which requires GoRouter in the widget tree. This is tested in E2E tests // (test-e2e/routing.spec.ts) instead of unit tests. @@ -192,7 +196,8 @@ void main() { // Mock toggleFavorite to properly toggle state final favorites = {}; - when(mockDrinkRepository.toggleFavorite(any, any)).thenAnswer((invocation) async { + when(mockDrinkRepository.toggleFavorite(any, any)) + .thenAnswer((invocation) async { final drinkId = invocation.positionalArguments[1] as String; if (favorites.contains(drinkId)) { favorites.remove(drinkId); @@ -270,7 +275,7 @@ void main() { await tester.pumpWidget(createTestWidget('IPA')); await tester.pumpAndSettle(); - + // Wait for FutureBuilder to complete await tester.pumpAndSettle(); @@ -312,7 +317,7 @@ void main() { await tester.pumpWidget(createTestWidget('Unknown Style')); await tester.pumpAndSettle(); - + // Wait for FutureBuilder to complete await tester.pumpAndSettle(); @@ -333,7 +338,7 @@ void main() { await tester.pumpWidget(createTestWidget('IPA')); await tester.pumpAndSettle(); - + // Wait for FutureBuilder to complete await tester.pumpAndSettle(); @@ -357,4 +362,3 @@ void main() { }); }); } - diff --git a/test/utf8_encoding_test.dart b/test/utf8_encoding_test.dart index 616c2641..da4e0059 100644 --- a/test/utf8_encoding_test.dart +++ b/test/utf8_encoding_test.dart @@ -79,27 +79,27 @@ void main() { // Verify the UTF-8 characters are decoded correctly (not as mojibake) final roseDrink = drinks.firstWhere((d) => d.product.id == 'cider1'); expect(roseDrink.product.name, 'Rosé Cider', - reason: 'Name should have correct é character'); + reason: 'Name should have correct é character'); expect(roseDrink.product.style, 'Rosé', - reason: 'Style should have correct é character'); - + reason: 'Style should have correct é character'); + // Verify it's NOT the mojibake version expect(roseDrink.product.name, isNot('Rosé Cider'), - reason: 'Should not be garbled as Rosé'); + reason: 'Should not be garbled as Rosé'); expect(roseDrink.product.style, isNot('Rosé'), - reason: 'Should not be garbled as Rosé'); + reason: 'Should not be garbled as Rosé'); final cafeDrink = drinks.firstWhere((d) => d.product.id == 'cider2'); expect(cafeDrink.product.name, 'Café Apple', - reason: 'Name should have correct é character'); + reason: 'Name should have correct é character'); expect(cafeDrink.product.style, 'Café', - reason: 'Style should have correct é character'); - + reason: 'Style should have correct é character'); + // Verify it's NOT the mojibake version expect(cafeDrink.product.name, isNot('Café Apple'), - reason: 'Should not be garbled as Café'); + reason: 'Should not be garbled as Café'); expect(cafeDrink.product.style, isNot('Café'), - reason: 'Should not be garbled as Café'); + reason: 'Should not be garbled as Café'); }); test('handles various European accented characters correctly', () async { @@ -159,21 +159,21 @@ void main() { expect(kolsch.product.style, 'Kölsch'); expect(kolsch.product.style?.contains('ö'), true); expect(kolsch.product.style, isNot(contains('ö')), // mojibake for ö - reason: 'Should not be garbled'); + reason: 'Should not be garbled'); // Verify German ä character final marzen = drinks.firstWhere((d) => d.product.id == 'beer2'); expect(marzen.product.style, 'Märzen'); expect(marzen.product.style?.contains('ä'), true); expect(marzen.product.style, isNot(contains('ä')), // mojibake for ä - reason: 'Should not be garbled'); + reason: 'Should not be garbled'); // Verify Spanish ñ character final nino = drinks.firstWhere((d) => d.product.id == 'beer3'); expect(nino.product.name, 'Niño Porter'); expect(nino.product.name.contains('ñ'), true); expect(nino.product.name, isNot(contains('ñ')), // mojibake for ñ - reason: 'Should not be garbled'); + reason: 'Should not be garbled'); }); test('handles response without explicit charset in Content-Type', () async { @@ -214,12 +214,12 @@ void main() { final drinks = await service.fetchDrinks(testFestival, 'wine'); expect(drinks.length, 1); - + // Verify the fix works even without explicit charset final drink = drinks.first; expect(drink.producer.name, 'Café Producer'); expect(drink.product.style, 'Rosé'); - + // Verify no mojibake expect(drink.producer.name, isNot('Café Producer')); expect(drink.product.style, isNot('Rosé')); diff --git a/test/utils/navigation_helpers_test.dart b/test/utils/navigation_helpers_test.dart index 880027b3..7366124f 100644 --- a/test/utils/navigation_helpers_test.dart +++ b/test/utils/navigation_helpers_test.dart @@ -238,11 +238,14 @@ void main() { test('encodes query parameters in drinks path', () { expect( buildDrinksPath('cbf2025', category: 'cider & perry'), - equals('/cbf2025/drinks?category=cider+%26+perry'), // + is valid for spaces in query params + equals( + '/cbf2025/drinks?category=cider+%26+perry'), // + is valid for spaces in query params ); }); - test('converts to lowercase and encodes Unicode characters in style names', () { + test( + 'converts to lowercase and encodes Unicode characters in style names', + () { expect( buildStylePath('cbf2025', 'Märzen'), equals('/cbf2025/style/m%C3%A4rzen'), @@ -361,7 +364,8 @@ void main() { }); group('canPopNavigation', () { - testWidgets('returns false when GoRouter is not available', (tester) async { + testWidgets('returns false when GoRouter is not available', + (tester) async { // In test environment with MaterialApp but without GoRouter await tester.pumpWidget( MaterialApp( diff --git a/test/utils/widget_builders_test.dart b/test/utils/widget_builders_test.dart index 2b4d4091..fb6a434b 100644 --- a/test/utils/widget_builders_test.dart +++ b/test/utils/widget_builders_test.dart @@ -43,7 +43,7 @@ void main() { group('buildHomeLeadingButton', () { testWidgets('returns widget with home icon when called', (tester) async { Widget? result; - + await tester.pumpWidget( MaterialApp( home: Builder( @@ -66,7 +66,7 @@ void main() { // In test environment without GoRouter, canPopNavigation returns false // So buildHomeLeadingButton should return a home button widget expect(result, isNotNull); - + // Verify home icon is present expect(find.byIcon(Icons.home), findsOneWidget); expect(find.byType(IconButton), findsOneWidget); @@ -103,7 +103,8 @@ void main() { ); expect(customSemantics.properties.label, equals('Go to home screen')); - expect(customSemantics.properties.hint, equals('Double tap to return to drinks list')); + expect(customSemantics.properties.hint, + equals('Double tap to return to drinks list')); expect(customSemantics.properties.button, isTrue); }); @@ -262,21 +263,26 @@ void main() { final theme = Theme.of(capturedContext); // Find the festival name text (should be the second Text widget) - final textWidgets = tester.widgetList( - find.descendant( - of: find.byType(AppBar), - matching: find.byType(Text), - ), - ).toList(); + final textWidgets = tester + .widgetList( + find.descendant( + of: find.byType(AppBar), + matching: find.byType(Text), + ), + ) + .toList(); expect(textWidgets.length, equals(2)); // First text should use titleLarge - expect(textWidgets[0].style?.fontSize, equals(theme.textTheme.titleLarge?.fontSize)); + expect(textWidgets[0].style?.fontSize, + equals(theme.textTheme.titleLarge?.fontSize)); // Second text should use bodySmall with onSurfaceVariant color - expect(textWidgets[1].style?.fontSize, equals(theme.textTheme.bodySmall?.fontSize)); - expect(textWidgets[1].style?.color, equals(theme.colorScheme.onSurfaceVariant)); + expect(textWidgets[1].style?.fontSize, + equals(theme.textTheme.bodySmall?.fontSize)); + expect(textWidgets[1].style?.color, + equals(theme.colorScheme.onSurfaceVariant)); }); }); }); diff --git a/test/utils_test.dart b/test/utils_test.dart index 38ead2a9..0da5a510 100644 --- a/test/utils_test.dart +++ b/test/utils_test.dart @@ -9,7 +9,8 @@ void main() { MaterialApp( home: Builder( builder: (context) { - final color = CategoryColorHelper.getCategoryColor(context, 'beer'); + final color = + CategoryColorHelper.getCategoryColor(context, 'beer'); expect(color, isNotNull); return Container(); }, @@ -18,13 +19,15 @@ void main() { ); }); - testWidgets('returns correct color for cider category in light theme', (tester) async { + testWidgets('returns correct color for cider category in light theme', + (tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(brightness: Brightness.light), home: Builder( builder: (context) { - final color = CategoryColorHelper.getCategoryColor(context, 'cider'); + final color = + CategoryColorHelper.getCategoryColor(context, 'cider'); expect(color, const Color(0xFF689F38)); return Container(); }, @@ -33,13 +36,15 @@ void main() { ); }); - testWidgets('returns correct color for cider category in dark theme', (tester) async { + testWidgets('returns correct color for cider category in dark theme', + (tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(brightness: Brightness.dark), home: Builder( builder: (context) { - final color = CategoryColorHelper.getCategoryColor(context, 'cider'); + final color = + CategoryColorHelper.getCategoryColor(context, 'cider'); expect(color, const Color(0xFF8BC34A).withValues(alpha: 0.8)); return Container(); }, @@ -48,13 +53,15 @@ void main() { ); }); - testWidgets('returns correct color for perry category in light theme', (tester) async { + testWidgets('returns correct color for perry category in light theme', + (tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(brightness: Brightness.light), home: Builder( builder: (context) { - final color = CategoryColorHelper.getCategoryColor(context, 'perry'); + final color = + CategoryColorHelper.getCategoryColor(context, 'perry'); expect(color, const Color(0xFFAFB42B)); return Container(); }, @@ -63,13 +70,15 @@ void main() { ); }); - testWidgets('returns correct color for perry category in dark theme', (tester) async { + testWidgets('returns correct color for perry category in dark theme', + (tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(brightness: Brightness.dark), home: Builder( builder: (context) { - final color = CategoryColorHelper.getCategoryColor(context, 'perry'); + final color = + CategoryColorHelper.getCategoryColor(context, 'perry'); expect(color, const Color(0xFFCDDC39).withValues(alpha: 0.8)); return Container(); }, @@ -78,13 +87,15 @@ void main() { ); }); - testWidgets('returns correct color for mead category in light theme', (tester) async { + testWidgets('returns correct color for mead category in light theme', + (tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(brightness: Brightness.light), home: Builder( builder: (context) { - final color = CategoryColorHelper.getCategoryColor(context, 'mead'); + final color = + CategoryColorHelper.getCategoryColor(context, 'mead'); expect(color, const Color(0xFFF9A825)); return Container(); }, @@ -93,13 +104,15 @@ void main() { ); }); - testWidgets('returns correct color for mead category in dark theme', (tester) async { + testWidgets('returns correct color for mead category in dark theme', + (tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(brightness: Brightness.dark), home: Builder( builder: (context) { - final color = CategoryColorHelper.getCategoryColor(context, 'mead'); + final color = + CategoryColorHelper.getCategoryColor(context, 'mead'); expect(color, const Color(0xFFFFEB3B).withValues(alpha: 0.8)); return Container(); }, @@ -108,13 +121,15 @@ void main() { ); }); - testWidgets('returns correct color for wine category in light theme', (tester) async { + testWidgets('returns correct color for wine category in light theme', + (tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(brightness: Brightness.light), home: Builder( builder: (context) { - final color = CategoryColorHelper.getCategoryColor(context, 'wine'); + final color = + CategoryColorHelper.getCategoryColor(context, 'wine'); expect(color, const Color(0xFF7B1FA2)); return Container(); }, @@ -123,13 +138,15 @@ void main() { ); }); - testWidgets('returns correct color for wine category in dark theme', (tester) async { + testWidgets('returns correct color for wine category in dark theme', + (tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(brightness: Brightness.dark), home: Builder( builder: (context) { - final color = CategoryColorHelper.getCategoryColor(context, 'wine'); + final color = + CategoryColorHelper.getCategoryColor(context, 'wine'); expect(color, const Color(0xFF9C27B0).withValues(alpha: 0.8)); return Container(); }, @@ -138,13 +155,15 @@ void main() { ); }); - testWidgets('returns correct color for low-no category in light theme', (tester) async { + testWidgets('returns correct color for low-no category in light theme', + (tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(brightness: Brightness.light), home: Builder( builder: (context) { - final color = CategoryColorHelper.getCategoryColor(context, 'low-no'); + final color = + CategoryColorHelper.getCategoryColor(context, 'low-no'); expect(color, isNotNull); return Container(); }, @@ -153,13 +172,15 @@ void main() { ); }); - testWidgets('returns correct color for low-no category in dark theme', (tester) async { + testWidgets('returns correct color for low-no category in dark theme', + (tester) async { await tester.pumpWidget( MaterialApp( theme: ThemeData(brightness: Brightness.dark), home: Builder( builder: (context) { - final color = CategoryColorHelper.getCategoryColor(context, 'low-no'); + final color = + CategoryColorHelper.getCategoryColor(context, 'low-no'); expect(color, isNotNull); return Container(); }, @@ -174,7 +195,8 @@ void main() { theme: ThemeData(brightness: Brightness.light), home: Builder( builder: (context) { - final color = CategoryColorHelper.getCategoryColor(context, 'unknown'); + final color = + CategoryColorHelper.getCategoryColor(context, 'unknown'); expect(color, isNotNull); expect(color, isA()); return Container(); @@ -189,9 +211,12 @@ void main() { MaterialApp( home: Builder( builder: (context) { - final colorLower = CategoryColorHelper.getCategoryColor(context, 'beer'); - final colorUpper = CategoryColorHelper.getCategoryColor(context, 'BEER'); - final colorMixed = CategoryColorHelper.getCategoryColor(context, 'BeEr'); + final colorLower = + CategoryColorHelper.getCategoryColor(context, 'beer'); + final colorUpper = + CategoryColorHelper.getCategoryColor(context, 'BEER'); + final colorMixed = + CategoryColorHelper.getCategoryColor(context, 'BeEr'); expect(colorLower, colorUpper); expect(colorUpper, colorMixed); @@ -220,7 +245,8 @@ void main() { expect(ABVStrengthHelper.getABVStrengthLabel(10.5), '(High)'); }); - testWidgets('getABVColor returns correct colors for different ABV ranges', (tester) async { + testWidgets('getABVColor returns correct colors for different ABV ranges', + (tester) async { await tester.pumpWidget( MaterialApp( home: Builder( @@ -228,15 +254,15 @@ void main() { final lowColor = ABVStrengthHelper.getABVColor(context, 3.5); final mediumColor = ABVStrengthHelper.getABVColor(context, 5.0); final highColor = ABVStrengthHelper.getABVColor(context, 8.0); - + expect(lowColor, isNotNull); expect(mediumColor, isNotNull); expect(highColor, isNotNull); - + // Colors should be different for different ranges expect(lowColor, isNot(mediumColor)); expect(mediumColor, isNot(highColor)); - + return Container(); }, ), @@ -248,7 +274,8 @@ void main() { group('BeverageTypeHelper', () { test('formatBeverageType formats dash-separated strings', () { expect(BeverageTypeHelper.formatBeverageType('beer'), 'Beer'); - expect(BeverageTypeHelper.formatBeverageType('international-beer'), 'International Beer'); + expect(BeverageTypeHelper.formatBeverageType('international-beer'), + 'International Beer'); expect(BeverageTypeHelper.formatBeverageType('low-no'), 'Low No'); }); @@ -263,7 +290,8 @@ void main() { test('getBeverageIcon returns correct icons', () { expect(BeverageTypeHelper.getBeverageIcon('beer'), Icons.sports_bar); - expect(BeverageTypeHelper.getBeverageIcon('international-beer'), Icons.public); + expect(BeverageTypeHelper.getBeverageIcon('international-beer'), + Icons.public); expect(BeverageTypeHelper.getBeverageIcon('cider'), Icons.local_drink); expect(BeverageTypeHelper.getBeverageIcon('perry'), Icons.eco); expect(BeverageTypeHelper.getBeverageIcon('mead'), Icons.emoji_nature); diff --git a/test/widgets/breadcrumb_bar_test.dart b/test/widgets/breadcrumb_bar_test.dart index 6c41d240..1d17171c 100644 --- a/test/widgets/breadcrumb_bar_test.dart +++ b/test/widgets/breadcrumb_bar_test.dart @@ -100,8 +100,7 @@ void main() { // Filter to find our custom Semantics (has our label and button property) final customSemantics = allSemantics.where((s) => - s.properties.label == 'Back to Beer' && - s.properties.button == true); + s.properties.label == 'Back to Beer' && s.properties.button == true); // Should have exactly one expect(customSemantics.length, equals(1)); @@ -219,8 +218,7 @@ void main() { // Filter to find our custom Semantics (has our label and button property) final customSemantics = allSemantics.where((s) => - s.properties.label == 'Back to Beer' && - s.properties.button == true); + s.properties.label == 'Back to Beer' && s.properties.button == true); // Should have exactly one custom Semantics widget expect(customSemantics.length, equals(1)); @@ -261,7 +259,8 @@ void main() { expect(counter, equals(2)); }); - testWidgets('calls onBackLabelTap when back label is tapped', (tester) async { + testWidgets('calls onBackLabelTap when back label is tapped', + (tester) async { var backLabelTapCount = 0; await tester.pumpWidget( @@ -281,7 +280,8 @@ void main() { expect(backLabelTapCount, equals(1)); }); - testWidgets('calls onContextLabelTap when context label is tapped', (tester) async { + testWidgets('calls onContextLabelTap when context label is tapped', + (tester) async { var contextLabelTapCount = 0; await tester.pumpWidget( @@ -302,7 +302,8 @@ void main() { expect(contextLabelTapCount, equals(1)); }); - testWidgets('both labels are clickable when both callbacks provided', (tester) async { + testWidgets('both labels are clickable when both callbacks provided', + (tester) async { var backLabelTaps = 0; var contextLabelTaps = 0; @@ -331,7 +332,8 @@ void main() { expect(contextLabelTaps, equals(1)); }); - testWidgets('text is not clickable when callbacks not provided', (tester) async { + testWidgets('text is not clickable when callbacks not provided', + (tester) async { var backTapCount = 0; await tester.pumpWidget( @@ -384,7 +386,8 @@ void main() { expect(navigationSemantics.length, equals(2)); // Check labels - final labels = navigationSemantics.map((s) => s.properties.label).toList(); + final labels = + navigationSemantics.map((s) => s.properties.label).toList(); expect(labels, contains('Navigate to Drinks')); expect(labels, contains('Navigate to Oakham Ales')); }); diff --git a/test/widgets/festival_menu_sheets_test.dart b/test/widgets/festival_menu_sheets_test.dart index b3a6be80..6938b57d 100644 --- a/test/widgets/festival_menu_sheets_test.dart +++ b/test/widgets/festival_menu_sheets_test.dart @@ -48,14 +48,14 @@ void main() { when(mockFestivalRepository.getFestivals()) .thenAnswer((_) async => FestivalsResponse( - festivals: testFestivals, - defaultFestivalId: testFestival.id, - version: '1.0.0', - baseUrl: 'https://data.cambeerfestival.app', - )); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => []); + festivals: testFestivals, + defaultFestivalId: testFestival.id, + version: '1.0.0', + baseUrl: 'https://data.cambeerfestival.app', + )); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => []); provider = BeerProvider( drinkRepository: mockDrinkRepository, @@ -82,10 +82,12 @@ void main() { await tester.pumpWidget(buildTestWidget()); expect(find.text('Browse Festivals'), findsOneWidget); - expect(find.text('Choose a festival to browse its drinks'), findsOneWidget); + expect( + find.text('Choose a festival to browse its drinks'), findsOneWidget); }); - testWidgets('displays festival cards when festivals are loaded', (tester) async { + testWidgets('displays festival cards when festivals are loaded', + (tester) async { await tester.pumpWidget(buildTestWidget()); expect(find.byType(FestivalCard), findsOneWidget); @@ -117,17 +119,20 @@ void main() { await tester.pumpWidget(buildTestWidget()); final semantics = tester.widget( - find.ancestor( - of: find.byType(FestivalCard), - matching: find.byType(Semantics), - ).first, + find + .ancestor( + of: find.byType(FestivalCard), + matching: find.byType(Semantics), + ) + .first, ); expect(semantics.properties.label, contains('Test Beer Festival 2024')); expect(semantics.properties.button, isTrue); }); - testWidgets('uses high-contrast drag handle color in light theme', (tester) async { + testWidgets('uses high-contrast drag handle color in light theme', + (tester) async { final lightTheme = buildAppTheme(Brightness.light); await tester.pumpWidget(buildTestWidget(theme: lightTheme)); @@ -246,14 +251,14 @@ void main() { when(mockFestivalRepository.getFestivals()) .thenAnswer((_) async => FestivalsResponse( - festivals: [], - defaultFestivalId: '', - version: '1.0.0', - baseUrl: 'https://data.cambeerfestival.app', - )); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => []); + festivals: [], + defaultFestivalId: '', + version: '1.0.0', + baseUrl: 'https://data.cambeerfestival.app', + )); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => []); provider = BeerProvider( drinkRepository: mockDrinkRepository, @@ -290,7 +295,8 @@ void main() { expect(find.byIcon(Icons.brightness_auto), findsOneWidget); }); - testWidgets('uses high-contrast drag handle color in light theme', (tester) async { + testWidgets('uses high-contrast drag handle color in light theme', + (tester) async { final lightTheme = buildAppTheme(Brightness.light); await tester.pumpWidget(buildTestWidget(theme: lightTheme)); @@ -318,14 +324,14 @@ void main() { when(mockFestivalRepository.getFestivals()) .thenAnswer((_) async => FestivalsResponse( - festivals: [], - defaultFestivalId: '', - version: '1.0.0', - baseUrl: 'https://data.cambeerfestival.app', - )); - when(mockFestivalRepository.getSelectedFestivalId()).thenAnswer((_) async => null); - when(mockDrinkRepository.getDrinks(any)) - .thenAnswer((_) async => []); + festivals: [], + defaultFestivalId: '', + version: '1.0.0', + baseUrl: 'https://data.cambeerfestival.app', + )); + when(mockFestivalRepository.getSelectedFestivalId()) + .thenAnswer((_) async => null); + when(mockDrinkRepository.getDrinks(any)).thenAnswer((_) async => []); provider = BeerProvider( drinkRepository: mockDrinkRepository, @@ -382,7 +388,8 @@ void main() { expect(provider.themeMode, ThemeMode.dark); }); - testWidgets('uses high-contrast drag handle color in light theme', (tester) async { + testWidgets('uses high-contrast drag handle color in light theme', + (tester) async { final lightTheme = buildAppTheme(Brightness.light); await tester.pumpWidget(buildTestWidget(theme: lightTheme)); diff --git a/test/widgets/overflow_menu_test.dart b/test/widgets/overflow_menu_test.dart index a772cca6..27cd314a 100644 --- a/test/widgets/overflow_menu_test.dart +++ b/test/widgets/overflow_menu_test.dart @@ -116,7 +116,8 @@ void main() { ); }); - testWidgets('uses high-contrast menu item colors in light theme', (tester) async { + testWidgets('uses high-contrast menu item colors in light theme', + (tester) async { await tester.pumpWidget( MaterialApp( theme: buildAppTheme(Brightness.light), @@ -133,7 +134,8 @@ void main() { await tester.tap(find.byIcon(Icons.more_vert)); await tester.pumpAndSettle(); - final expectedColor = buildAppTheme(Brightness.light).colorScheme.onSurface; + final expectedColor = + buildAppTheme(Brightness.light).colorScheme.onSurface; final festivalIcon = tester.widget(find.byIcon(Icons.festival)); expect(festivalIcon.color, expectedColor); @@ -142,7 +144,6 @@ void main() { expect(festivalText.style?.color, expectedColor); }); - testWidgets('has proper tooltip', (tester) async { await tester.pumpWidget(buildMenuWidget()); diff --git a/test/widgets_test.dart b/test/widgets_test.dart index f13461b8..cf82c196 100644 --- a/test/widgets_test.dart +++ b/test/widgets_test.dart @@ -19,7 +19,8 @@ void main() { expect(find.byIcon(Icons.star), findsNothing); }); - testWidgets('displays filled stars based on rating', (WidgetTester tester) async { + testWidgets('displays filled stars based on rating', + (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: Scaffold( @@ -33,7 +34,8 @@ void main() { expect(find.byIcon(Icons.star_border), findsNWidgets(2)); }); - testWidgets('displays all filled stars for rating of 5', (WidgetTester tester) async { + testWidgets('displays all filled stars for rating of 5', + (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: Scaffold( @@ -47,7 +49,8 @@ void main() { expect(find.byIcon(Icons.star_border), findsNothing); }); - testWidgets('displays all empty stars for null rating', (WidgetTester tester) async { + testWidgets('displays all empty stars for null rating', + (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: Scaffold( @@ -61,7 +64,8 @@ void main() { expect(find.byIcon(Icons.star), findsNothing); }); - testWidgets('calls onRatingChanged when editable star is tapped', (WidgetTester tester) async { + testWidgets('calls onRatingChanged when editable star is tapped', + (WidgetTester tester) async { int? selectedRating; await tester.pumpWidget( @@ -85,7 +89,8 @@ void main() { expect(selectedRating, 3); }); - testWidgets('tapping first star sets rating to 1', (WidgetTester tester) async { + testWidgets('tapping first star sets rating to 1', + (WidgetTester tester) async { int? selectedRating; await tester.pumpWidget( @@ -106,7 +111,8 @@ void main() { expect(selectedRating, 1); }); - testWidgets('tapping fifth star sets rating to 5', (WidgetTester tester) async { + testWidgets('tapping fifth star sets rating to 5', + (WidgetTester tester) async { int? selectedRating; await tester.pumpWidget( @@ -127,7 +133,8 @@ void main() { expect(selectedRating, 5); }); - testWidgets('does not call onRatingChanged when not editable', (WidgetTester tester) async { + testWidgets('does not call onRatingChanged when not editable', + (WidgetTester tester) async { int? selectedRating; await tester.pumpWidget( @@ -196,7 +203,8 @@ void main() { expect(iconWidget.color, Colors.blue); }); - testWidgets('uses high-contrast default inactive color in light theme', (WidgetTester tester) async { + testWidgets('uses high-contrast default inactive color in light theme', + (WidgetTester tester) async { final lightTheme = buildAppTheme(Brightness.light); await tester.pumpWidget( MaterialApp( @@ -236,7 +244,8 @@ void main() { expect(selectedRating, isNull); }); - testWidgets('tapping different star changes rating', (WidgetTester tester) async { + testWidgets('tapping different star changes rating', + (WidgetTester tester) async { int? selectedRating; await tester.pumpWidget( @@ -260,7 +269,8 @@ void main() { expect(selectedRating, 5); }); - testWidgets('tapping star when rating is null sets rating', (WidgetTester tester) async { + testWidgets('tapping star when rating is null sets rating', + (WidgetTester tester) async { int? selectedRating; await tester.pumpWidget( @@ -284,7 +294,8 @@ void main() { expect(selectedRating, 4); }); - testWidgets('tapping first star when rating is 1 clears rating', (WidgetTester tester) async { + testWidgets('tapping first star when rating is 1 clears rating', + (WidgetTester tester) async { int? selectedRating; await tester.pumpWidget( @@ -308,7 +319,8 @@ void main() { expect(selectedRating, isNull); }); - testWidgets('tapping fifth star when rating is 5 clears rating', (WidgetTester tester) async { + testWidgets('tapping fifth star when rating is 5 clears rating', + (WidgetTester tester) async { int? selectedRating; await tester.pumpWidget( @@ -332,7 +344,8 @@ void main() { expect(selectedRating, isNull); }); - testWidgets('editable rating includes clear instruction in semantic hint', (WidgetTester tester) async { + testWidgets('editable rating includes clear instruction in semantic hint', + (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: Scaffold( @@ -346,10 +359,12 @@ void main() { // Find the Semantics widget and check its properties final semantics = tester.widget( - find.ancestor( - of: find.byType(Row), - matching: find.byType(Semantics), - ).first, + find + .ancestor( + of: find.byType(Row), + matching: find.byType(Semantics), + ) + .first, ); // Verify the hint mentions clearing @@ -357,7 +372,8 @@ void main() { expect(semantics.properties.hint, contains('Tap again')); }); - testWidgets('non-editable rating does not include hint', (WidgetTester tester) async { + testWidgets('non-editable rating does not include hint', + (WidgetTester tester) async { await tester.pumpWidget( const MaterialApp( home: Scaffold( @@ -371,10 +387,12 @@ void main() { // Find the Semantics widget and check its properties final semantics = tester.widget( - find.ancestor( - of: find.byType(Row), - matching: find.byType(Semantics), - ).first, + find + .ancestor( + of: find.byType(Row), + matching: find.byType(Semantics), + ) + .first, ); // Verify there's no hint for non-editable ratings From 574a882d3db5da1b2acaa4514b080dbd14e6d18a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 20:21:46 +0000 Subject: [PATCH 3/7] docs: document dart format --no-deps usage in AGENTS.md --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 817338d2..2d962a9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,7 @@ MISE_ENV=dev ./bin/mise tasks ls # All tasks including build/serve | Task | Command | Notes | |------|---------|-------| | **Pre-commit gate** | `./bin/mise run check` | **Run before every commit** | +| **Format code** | `./bin/mise run --no-deps format` | **Run after every change** — `--no-deps` skips unnecessary `pub get` | | Generate code (mocks) | `./bin/mise run generate` | After model changes | | Analyze code | `./bin/mise run analyze` | generate → analyze | | Run tests | `./bin/mise run test` | generate → test | From ca98ff2f169031076eda1019b68129fc7dc27be0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 20:29:09 +0000 Subject: [PATCH 4/7] chore: add dart:format, prettier:format and mise:format tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splits the single `format` task into three named sub-tasks: - `dart:format` — dart format . (use --no-deps for speed after Dart changes) - `prettier:format` — Prettier for JS/TS/MJS files - `mise:format` — mise fmt for mise.toml The top-level `format` task now depends on all three. CI check formatting step expanded to cover all three. Prettier added to root package.json; .prettierignore excludes build/, android/, ios/. --- .github/workflows/ci.yml | 6 +++++- .prettierignore | 3 +++ AGENTS.md | 5 ++++- mise.toml | 26 +++++++++++++++++++++++--- package-lock.json | 17 +++++++++++++++++ package.json | 1 + 6 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 .prettierignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 894ad653..2485e9d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,11 @@ jobs: generate-mocks: 'true' - name: Check formatting - run: dart format --set-exit-if-changed . + run: | + dart format --set-exit-if-changed . + npm ci + npx prettier --check "**/*.{js,ts,mjs}" + mise fmt --check - name: Analyze code run: flutter analyze --no-fatal-infos diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..15c7c4f7 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,3 @@ +build/ +android/ +ios/ diff --git a/AGENTS.md b/AGENTS.md index 2d962a9e..67b806e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,10 @@ MISE_ENV=dev ./bin/mise tasks ls # All tasks including build/serve | Task | Command | Notes | |------|---------|-------| | **Pre-commit gate** | `./bin/mise run check` | **Run before every commit** | -| **Format code** | `./bin/mise run --no-deps format` | **Run after every change** — `--no-deps` skips unnecessary `pub get` | +| **Format all code** | `./bin/mise run format` | Runs all three formatters below | +| Format Dart | `./bin/mise run --no-deps dart:format` | **Run after every Dart change** — `--no-deps` skips unnecessary `pub get` | +| Format JS/TS | `./bin/mise run prettier:format` | After JS/TS changes | +| Format mise.toml | `./bin/mise run mise:format` | After editing mise.toml | | Generate code (mocks) | `./bin/mise run generate` | After model changes | | Analyze code | `./bin/mise run analyze` | generate → analyze | | Run tests | `./bin/mise run test` | generate → test | diff --git a/mise.toml b/mise.toml index d582cae8..a14f3ef9 100644 --- a/mise.toml +++ b/mise.toml @@ -17,7 +17,7 @@ _.path = ["./bin"] [tools] flutter = "3.38.3" -node = "22" # For http_server and Playwright e2e tests +node = "22" # For http_server and Playwright e2e tests [deps.flutter] auto = true @@ -44,10 +44,23 @@ echo "Grep with: grep -n 'FAILED\|ERROR' $TEST_LOG" exit $EXIT_CODE ''' -[tasks.format] +[tasks."dart:format"] description = "Format all Dart code in place" run = 'dart format .' +[tasks."prettier:format"] +description = "Format JS/TS/MJS files with Prettier" +run = 'npm ci && npx prettier --write "**/*.{js,ts,mjs}"' + +[tasks."mise:format"] +description = "Format mise.toml" +run = 'mise fmt' + +[tasks.format] +description = "Format all code (Dart, JS/TS, mise.toml)" +depends = ['dart:format', 'prettier:format', 'mise:format'] +run = 'echo "All formatting complete"' + [tasks.check] description = "Pre-commit gate: generate → format + analyze + test (run before every commit)" depends = ['format', 'analyze', 'test'] @@ -91,5 +104,12 @@ run = 'npm ci && node ../scripts/validate-festivals.js' [tasks."test:worker"] description = "Run Cloudflare Worker tests (Vitest + workerd)" dir = "cloudflare-worker" -sources = ['cloudflare-worker/package.json', 'cloudflare-worker/package-lock.json', 'cloudflare-worker/worker.js', 'cloudflare-worker/test/**/*.js', 'cloudflare-worker/vitest.config.js', 'data/festivals.json'] +sources = [ + 'cloudflare-worker/package.json', + 'cloudflare-worker/package-lock.json', + 'cloudflare-worker/worker.js', + 'cloudflare-worker/test/**/*.js', + 'cloudflare-worker/vitest.config.js', + 'data/festivals.json', +] run = 'npm ci && npm test' diff --git a/package-lock.json b/package-lock.json index 8ca3db55..be14cf53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "devDependencies": { "@playwright/test": "^1.48.2", "http-server": "^14.1.1", + "prettier": "^3.0.0", "tsx": "^4.19.2" } }, @@ -1035,6 +1036,22 @@ "node": ">= 10.12" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", diff --git a/package.json b/package.json index 5662e0cd..f9f448dd 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "devDependencies": { "@playwright/test": "^1.48.2", "http-server": "^14.1.1", + "prettier": "^3.0.0", "tsx": "^4.19.2" } } From 764555255eccc016347357ea0f2fb5f92b799142 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 19 May 2026 20:29:12 +0000 Subject: [PATCH 5/7] style: apply prettier to JS/TS/MJS files --- cloudflare-worker/test/beverage-types.test.js | 441 +++++++++++------- cloudflare-worker/test/cors.test.js | 395 ++++++++++------ cloudflare-worker/test/festivals.test.js | 149 +++--- cloudflare-worker/test/proxy.test.js | 331 +++++++------ cloudflare-worker/vitest.config.js | 14 +- cloudflare-worker/worker.js | 204 ++++---- .../drink/[category]/[drinkId].js | 18 +- functions/_lib/drink-preview.js | 50 +- functions/test/drink-preview.test.js | 365 +++++++++------ functions/test/handler.test.js | 149 +++--- functions/vitest.config.js | 4 +- playwright.config.ts | 20 +- scripts/check-page.mjs | 119 ++--- scripts/screenshot-batch.mjs | 120 ++--- scripts/validate-festivals.js | 41 +- test-e2e/app.spec.ts | 105 +++-- test-e2e/routing.spec.ts | 220 ++++++--- 17 files changed, 1619 insertions(+), 1126 deletions(-) diff --git a/cloudflare-worker/test/beverage-types.test.js b/cloudflare-worker/test/beverage-types.test.js index 7715fb53..f21479e7 100644 --- a/cloudflare-worker/test/beverage-types.test.js +++ b/cloudflare-worker/test/beverage-types.test.js @@ -1,27 +1,31 @@ -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; -import worker from '../worker.js'; - -const UPSTREAM = 'https://data.cambridgebeerfestival.com'; +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + env, + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import worker from "../worker.js"; + +const UPSTREAM = "https://data.cambridgebeerfestival.com"; const DIRECTORY_FETCH_INIT = { - headers: { - 'User-Agent': 'Cambridge-Beer-Festival-App-Proxy/1.0', - }, + headers: { + "User-Agent": "Cambridge-Beer-Festival-App-Proxy/1.0", + }, }; -async function fetchWorker(path, origin = 'https://cambeerfestival.app') { - const request = new Request(`https://worker.example.com${path}`, { - headers: { Origin: origin }, - }); - const ctx = createExecutionContext(); - const response = await worker.fetch(request, env, ctx); - await waitOnExecutionContext(ctx); - return response; +async function fetchWorker(path, origin = "https://cambeerfestival.app") { + const request = new Request(`https://worker.example.com${path}`, { + headers: { Origin: origin }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return response; } function makeDirectoryHtml(files) { - const links = files.map((f) => `${f}`).join('\n'); - return ` + const links = files.map((f) => `${f}`).join("\n"); + return ` Index of /cbf2025

Index of /cbf2025

@@ -32,158 +36,251 @@ ${links}
`; } -describe('available_beverage_types endpoint', () => { - let mockFetch; - - beforeEach(() => { - mockFetch = vi.fn(); - vi.stubGlobal('fetch', mockFetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('parses directory listing into beverage types', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['beer.json', 'cider.json', 'perry.json', 'mead.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data.festival_id).toBe('cbf2025'); - expect(data.available_beverage_types).toEqual(['beer', 'cider', 'mead', 'perry']); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('returns types sorted alphabetically', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['wine.json', 'beer.json', 'apple-juice.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - const data = await response.json(); - expect(data.available_beverage_types).toEqual(['apple-juice', 'beer', 'wine']); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('filters out available_beverage_types.json from results', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['beer.json', 'available_beverage_types.json', 'cider.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - const data = await response.json(); - expect(data.available_beverage_types).toEqual(['beer', 'cider']); - expect(data.available_beverage_types).not.toContain('available_beverage_types'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('returns empty array when no JSON files found', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml([]), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - const data = await response.json(); - expect(data.available_beverage_types).toEqual([]); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('returns 404 when festival not found upstream', async () => { - mockFetch.mockResolvedValueOnce(new Response('Not Found', { status: 404 })); - - const response = await fetchWorker('/nonexistent/available_beverage_types.json'); - expect(response.status).toBe(404); - - const data = await response.json(); - expect(data.error).toBe('Festival not found'); - expect(data.festival_id).toBe('nonexistent'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/nonexistent/`, DIRECTORY_FETCH_INIT); - }); - - it('returns 500 when upstream fetch fails', async () => { - mockFetch.mockRejectedValueOnce(new Error('Connection refused')); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - expect(response.status).toBe(500); - - const data = await response.json(); - expect(data.error).toBe('Failed to fetch beverage types'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('includes CORS headers on 500 error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Connection refused')); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('includes CORS headers on success', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['beer.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('includes CORS headers on 404', async () => { - mockFetch.mockResolvedValueOnce(new Response('Not Found', { status: 404 })); - - const response = await fetchWorker('/nonexistent/available_beverage_types.json'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/nonexistent/`, DIRECTORY_FETCH_INIT); - }); - - it('sets Cache-Control to 1 hour on success', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['beer.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - expect(response.headers.get('Cache-Control')).toBe('public, max-age=3600'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('includes timestamp in response', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['beer.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - const data = await response.json(); - expect(data.timestamp).toBeDefined(); - expect(new Date(data.timestamp).toISOString()).toBe(data.timestamp); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); - - it('handles hyphenated beverage type names', async () => { - mockFetch.mockResolvedValueOnce(new Response( - makeDirectoryHtml(['international-beer.json', 'low-no.json', 'apple-juice.json']), - { status: 200 }, - )); - - const response = await fetchWorker('/cbf2025/available_beverage_types.json'); - const data = await response.json(); - expect(data.available_beverage_types).toEqual([ - 'apple-juice', 'international-beer', 'low-no', - ]); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/`, DIRECTORY_FETCH_INIT); - }); +describe("available_beverage_types endpoint", () => { + let mockFetch; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("parses directory listing into beverage types", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + makeDirectoryHtml([ + "beer.json", + "cider.json", + "perry.json", + "mead.json", + ]), + { status: 200 }, + ), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data.festival_id).toBe("cbf2025"); + expect(data.available_beverage_types).toEqual([ + "beer", + "cider", + "mead", + "perry", + ]); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("returns types sorted alphabetically", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + makeDirectoryHtml(["wine.json", "beer.json", "apple-juice.json"]), + { status: 200 }, + ), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + const data = await response.json(); + expect(data.available_beverage_types).toEqual([ + "apple-juice", + "beer", + "wine", + ]); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("filters out available_beverage_types.json from results", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + makeDirectoryHtml([ + "beer.json", + "available_beverage_types.json", + "cider.json", + ]), + { status: 200 }, + ), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + const data = await response.json(); + expect(data.available_beverage_types).toEqual(["beer", "cider"]); + expect(data.available_beverage_types).not.toContain( + "available_beverage_types", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("returns empty array when no JSON files found", async () => { + mockFetch.mockResolvedValueOnce( + new Response(makeDirectoryHtml([]), { status: 200 }), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + const data = await response.json(); + expect(data.available_beverage_types).toEqual([]); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("returns 404 when festival not found upstream", async () => { + mockFetch.mockResolvedValueOnce(new Response("Not Found", { status: 404 })); + + const response = await fetchWorker( + "/nonexistent/available_beverage_types.json", + ); + expect(response.status).toBe(404); + + const data = await response.json(); + expect(data.error).toBe("Festival not found"); + expect(data.festival_id).toBe("nonexistent"); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/nonexistent/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("returns 500 when upstream fetch fails", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + expect(response.status).toBe(500); + + const data = await response.json(); + expect(data.error).toBe("Failed to fetch beverage types"); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("includes CORS headers on 500 error", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("includes CORS headers on success", async () => { + mockFetch.mockResolvedValueOnce( + new Response(makeDirectoryHtml(["beer.json"]), { status: 200 }), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("includes CORS headers on 404", async () => { + mockFetch.mockResolvedValueOnce(new Response("Not Found", { status: 404 })); + + const response = await fetchWorker( + "/nonexistent/available_beverage_types.json", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/nonexistent/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("sets Cache-Control to 1 hour on success", async () => { + mockFetch.mockResolvedValueOnce( + new Response(makeDirectoryHtml(["beer.json"]), { status: 200 }), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + expect(response.headers.get("Cache-Control")).toBe("public, max-age=3600"); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("includes timestamp in response", async () => { + mockFetch.mockResolvedValueOnce( + new Response(makeDirectoryHtml(["beer.json"]), { status: 200 }), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + const data = await response.json(); + expect(data.timestamp).toBeDefined(); + expect(new Date(data.timestamp).toISOString()).toBe(data.timestamp); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); + + it("handles hyphenated beverage type names", async () => { + mockFetch.mockResolvedValueOnce( + new Response( + makeDirectoryHtml([ + "international-beer.json", + "low-no.json", + "apple-juice.json", + ]), + { status: 200 }, + ), + ); + + const response = await fetchWorker( + "/cbf2025/available_beverage_types.json", + ); + const data = await response.json(); + expect(data.available_beverage_types).toEqual([ + "apple-juice", + "international-beer", + "low-no", + ]); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/`, + DIRECTORY_FETCH_INIT, + ); + }); }); diff --git a/cloudflare-worker/test/cors.test.js b/cloudflare-worker/test/cors.test.js index 69ea5b7b..4352f587 100644 --- a/cloudflare-worker/test/cors.test.js +++ b/cloudflare-worker/test/cors.test.js @@ -1,161 +1,252 @@ -import { describe, it, expect } from 'vitest'; -import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; -import worker from '../worker.js'; +import { describe, it, expect } from "vitest"; +import { + env, + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import worker from "../worker.js"; /** * Helper to make a request to the worker with a given origin. */ -async function fetchWithOrigin(path, origin, method = 'GET') { - const headers = {}; - if (origin) { - headers['Origin'] = origin; - } - const request = new Request(`https://worker.example.com${path}`, { - method, - headers, - }); - const ctx = createExecutionContext(); - const response = await worker.fetch(request, env, ctx); - await waitOnExecutionContext(ctx); - return response; +async function fetchWithOrigin(path, origin, method = "GET") { + const headers = {}; + if (origin) { + headers["Origin"] = origin; + } + const request = new Request(`https://worker.example.com${path}`, { + method, + headers, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return response; } -describe('CORS origin matching', () => { - it('allows production origin (cambeerfestival.app)', async () => { - const response = await fetchWithOrigin('/health', 'https://cambeerfestival.app'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - expect(response.headers.get('Access-Control-Allow-Credentials')).toBe('true'); - expect(response.headers.get('Vary')).toBe('Origin'); - }); - - it('allows staging origin', async () => { - const response = await fetchWithOrigin('/health', 'https://staging.cambeerfestival.app'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://staging.cambeerfestival.app'); - }); - - it('allows GitHub Pages origin', async () => { - const response = await fetchWithOrigin('/health', 'https://richardthe3rd.github.io'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://richardthe3rd.github.io'); - }); - - it('allows tunnel origin', async () => { - const response = await fetchWithOrigin('/health', 'https://tunnel.cambeerfestival.app'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://tunnel.cambeerfestival.app'); - }); - - it('allows localhost:8080', async () => { - const response = await fetchWithOrigin('/health', 'http://localhost:8080'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('http://localhost:8080'); - }); - - it('allows localhost:3000', async () => { - const response = await fetchWithOrigin('/health', 'http://localhost:3000'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('http://localhost:3000'); - }); - - it('allows 127.0.0.1:8080', async () => { - const response = await fetchWithOrigin('/health', 'http://127.0.0.1:8080'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('http://127.0.0.1:8080'); - }); - - it('allows Cloudflare Pages preview URLs (*.cambeerfestival.pages.dev)', async () => { - const response = await fetchWithOrigin('/health', 'https://abc123.cambeerfestival.pages.dev'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://abc123.cambeerfestival.pages.dev'); - expect(response.headers.get('Access-Control-Allow-Credentials')).toBe('true'); - expect(response.headers.get('Vary')).toBe('Origin'); - }); - - it('allows staging Pages preview URLs (*.staging-cambeerfestival.pages.dev)', async () => { - const response = await fetchWithOrigin('/health', 'https://feature-branch.staging-cambeerfestival.pages.dev'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://feature-branch.staging-cambeerfestival.pages.dev'); - }); - - it('allows Cloudflare Tunnel URLs (*.trycloudflare.com)', async () => { - const response = await fetchWithOrigin('/health', 'https://my-tunnel.trycloudflare.com'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://my-tunnel.trycloudflare.com'); - }); - - it('rejects unknown origins', async () => { - const response = await fetchWithOrigin('/health', 'https://evil.example.com'); - expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull(); - expect(response.headers.get('Access-Control-Allow-Credentials')).toBeNull(); - expect(response.headers.get('Vary')).toBeNull(); - }); - - it('handles request with no Origin header', async () => { - const response = await fetchWithOrigin('/health', null); - expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull(); - expect(response.status).toBe(200); - }); +describe("CORS origin matching", () => { + it("allows production origin (cambeerfestival.app)", async () => { + const response = await fetchWithOrigin( + "/health", + "https://cambeerfestival.app", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( + "true", + ); + expect(response.headers.get("Vary")).toBe("Origin"); + }); + + it("allows staging origin", async () => { + const response = await fetchWithOrigin( + "/health", + "https://staging.cambeerfestival.app", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://staging.cambeerfestival.app", + ); + }); + + it("allows GitHub Pages origin", async () => { + const response = await fetchWithOrigin( + "/health", + "https://richardthe3rd.github.io", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://richardthe3rd.github.io", + ); + }); + + it("allows tunnel origin", async () => { + const response = await fetchWithOrigin( + "/health", + "https://tunnel.cambeerfestival.app", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://tunnel.cambeerfestival.app", + ); + }); + + it("allows localhost:8080", async () => { + const response = await fetchWithOrigin("/health", "http://localhost:8080"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:8080", + ); + }); + + it("allows localhost:3000", async () => { + const response = await fetchWithOrigin("/health", "http://localhost:3000"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://localhost:3000", + ); + }); + + it("allows 127.0.0.1:8080", async () => { + const response = await fetchWithOrigin("/health", "http://127.0.0.1:8080"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "http://127.0.0.1:8080", + ); + }); + + it("allows Cloudflare Pages preview URLs (*.cambeerfestival.pages.dev)", async () => { + const response = await fetchWithOrigin( + "/health", + "https://abc123.cambeerfestival.pages.dev", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://abc123.cambeerfestival.pages.dev", + ); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBe( + "true", + ); + expect(response.headers.get("Vary")).toBe("Origin"); + }); + + it("allows staging Pages preview URLs (*.staging-cambeerfestival.pages.dev)", async () => { + const response = await fetchWithOrigin( + "/health", + "https://feature-branch.staging-cambeerfestival.pages.dev", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://feature-branch.staging-cambeerfestival.pages.dev", + ); + }); + + it("allows Cloudflare Tunnel URLs (*.trycloudflare.com)", async () => { + const response = await fetchWithOrigin( + "/health", + "https://my-tunnel.trycloudflare.com", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://my-tunnel.trycloudflare.com", + ); + }); + + it("rejects unknown origins", async () => { + const response = await fetchWithOrigin( + "/health", + "https://evil.example.com", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + expect(response.headers.get("Vary")).toBeNull(); + }); + + it("handles request with no Origin header", async () => { + const response = await fetchWithOrigin("/health", null); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(response.status).toBe(200); + }); }); -describe('CORS preflight (OPTIONS)', () => { - it('returns 204 with no body', async () => { - const response = await fetchWithOrigin('/', 'https://cambeerfestival.app', 'OPTIONS'); - expect(response.status).toBe(204); - const body = await response.text(); - expect(body).toBe(''); - }); - - it('includes correct methods and headers', async () => { - const response = await fetchWithOrigin('/', 'https://cambeerfestival.app', 'OPTIONS'); - expect(response.headers.get('Access-Control-Allow-Methods')).toBe('GET, OPTIONS'); - expect(response.headers.get('Access-Control-Allow-Headers')).toBe('Content-Type'); - }); - - it('returns 300s max-age for production origin', async () => { - const response = await fetchWithOrigin('/', 'https://cambeerfestival.app', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('300'); - }); - - it('returns 10s max-age for staging origin', async () => { - const response = await fetchWithOrigin('/', 'https://staging.cambeerfestival.app', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('10'); - }); - - it('returns 10s max-age for Pages preview URLs', async () => { - const response = await fetchWithOrigin('/', 'https://abc123.cambeerfestival.pages.dev', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('10'); - }); - - it('returns 10s max-age for staging Pages preview URLs', async () => { - const response = await fetchWithOrigin('/', 'https://feature.staging-cambeerfestival.pages.dev', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('10'); - }); - - it('returns 10s max-age for localhost', async () => { - const response = await fetchWithOrigin('/', 'http://localhost:8080', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('10'); - }); - - it('returns 10s max-age for 127.0.0.1', async () => { - const response = await fetchWithOrigin('/', 'http://127.0.0.1:8080', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('10'); - }); - - it('returns 10s max-age for Cloudflare Tunnel', async () => { - const response = await fetchWithOrigin('/', 'https://my-tunnel.trycloudflare.com', 'OPTIONS'); - expect(response.headers.get('Access-Control-Max-Age')).toBe('10'); - }); - - it('includes CORS origin header in preflight response', async () => { - const response = await fetchWithOrigin('/', 'https://cambeerfestival.app', 'OPTIONS'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - }); - - it('does not include CORS origin for rejected origins in preflight', async () => { - const response = await fetchWithOrigin('/', 'https://evil.example.com', 'OPTIONS'); - expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull(); - }); +describe("CORS preflight (OPTIONS)", () => { + it("returns 204 with no body", async () => { + const response = await fetchWithOrigin( + "/", + "https://cambeerfestival.app", + "OPTIONS", + ); + expect(response.status).toBe(204); + const body = await response.text(); + expect(body).toBe(""); + }); + + it("includes correct methods and headers", async () => { + const response = await fetchWithOrigin( + "/", + "https://cambeerfestival.app", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe( + "GET, OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe( + "Content-Type", + ); + }); + + it("returns 300s max-age for production origin", async () => { + const response = await fetchWithOrigin( + "/", + "https://cambeerfestival.app", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("300"); + }); + + it("returns 10s max-age for staging origin", async () => { + const response = await fetchWithOrigin( + "/", + "https://staging.cambeerfestival.app", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("10"); + }); + + it("returns 10s max-age for Pages preview URLs", async () => { + const response = await fetchWithOrigin( + "/", + "https://abc123.cambeerfestival.pages.dev", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("10"); + }); + + it("returns 10s max-age for staging Pages preview URLs", async () => { + const response = await fetchWithOrigin( + "/", + "https://feature.staging-cambeerfestival.pages.dev", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("10"); + }); + + it("returns 10s max-age for localhost", async () => { + const response = await fetchWithOrigin( + "/", + "http://localhost:8080", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("10"); + }); + + it("returns 10s max-age for 127.0.0.1", async () => { + const response = await fetchWithOrigin( + "/", + "http://127.0.0.1:8080", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("10"); + }); + + it("returns 10s max-age for Cloudflare Tunnel", async () => { + const response = await fetchWithOrigin( + "/", + "https://my-tunnel.trycloudflare.com", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Max-Age")).toBe("10"); + }); + + it("includes CORS origin header in preflight response", async () => { + const response = await fetchWithOrigin( + "/", + "https://cambeerfestival.app", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + }); + + it("does not include CORS origin for rejected origins in preflight", async () => { + const response = await fetchWithOrigin( + "/", + "https://evil.example.com", + "OPTIONS", + ); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); }); diff --git a/cloudflare-worker/test/festivals.test.js b/cloudflare-worker/test/festivals.test.js index 91c79a69..1ce755cd 100644 --- a/cloudflare-worker/test/festivals.test.js +++ b/cloudflare-worker/test/festivals.test.js @@ -1,88 +1,95 @@ -import { describe, it, expect } from 'vitest'; -import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; -import worker from '../worker.js'; +import { describe, it, expect } from "vitest"; +import { + env, + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import worker from "../worker.js"; /** * Helper to make a request to the worker. */ -async function fetchWorker(path, origin = 'https://cambeerfestival.app') { - const request = new Request(`https://worker.example.com${path}`, { - headers: { Origin: origin }, - }); - const ctx = createExecutionContext(); - const response = await worker.fetch(request, env, ctx); - await waitOnExecutionContext(ctx); - return response; +async function fetchWorker(path, origin = "https://cambeerfestival.app") { + const request = new Request(`https://worker.example.com${path}`, { + headers: { Origin: origin }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return response; } -describe('festivals.json endpoint', () => { - it('returns 200 for /festivals.json', async () => { - const response = await fetchWorker('/festivals.json'); - expect(response.status).toBe(200); - }); +describe("festivals.json endpoint", () => { + it("returns 200 for /festivals.json", async () => { + const response = await fetchWorker("/festivals.json"); + expect(response.status).toBe(200); + }); - it('returns 200 for /festivals (alias)', async () => { - const response = await fetchWorker('/festivals'); - expect(response.status).toBe(200); - }); + it("returns 200 for /festivals (alias)", async () => { + const response = await fetchWorker("/festivals"); + expect(response.status).toBe(200); + }); - it('returns valid JSON', async () => { - const response = await fetchWorker('/festivals.json'); - const data = await response.json(); - expect(data).toBeDefined(); - expect(data.festivals).toBeInstanceOf(Array); - expect(data.festivals.length).toBeGreaterThan(0); - }); + it("returns valid JSON", async () => { + const response = await fetchWorker("/festivals.json"); + const data = await response.json(); + expect(data).toBeDefined(); + expect(data.festivals).toBeInstanceOf(Array); + expect(data.festivals.length).toBeGreaterThan(0); + }); - it('contains required festival fields', async () => { - const response = await fetchWorker('/festivals.json'); - const data = await response.json(); - const festival = data.festivals[0]; + it("contains required festival fields", async () => { + const response = await fetchWorker("/festivals.json"); + const data = await response.json(); + const festival = data.festivals[0]; - expect(festival.id).toBeDefined(); - expect(festival.name).toBeDefined(); - expect(festival.start_date).toBeDefined(); - expect(festival.end_date).toBeDefined(); - expect(festival.data_base_url).toBeDefined(); - }); + expect(festival.id).toBeDefined(); + expect(festival.name).toBeDefined(); + expect(festival.start_date).toBeDefined(); + expect(festival.end_date).toBeDefined(); + expect(festival.data_base_url).toBeDefined(); + }); - it('contains default_festival_id', async () => { - const response = await fetchWorker('/festivals.json'); - const data = await response.json(); - expect(data.default_festival_id).toBeDefined(); - expect(typeof data.default_festival_id).toBe('string'); - }); + it("contains default_festival_id", async () => { + const response = await fetchWorker("/festivals.json"); + const data = await response.json(); + expect(data.default_festival_id).toBeDefined(); + expect(typeof data.default_festival_id).toBe("string"); + }); - it('default_festival_id references an existing festival', async () => { - const response = await fetchWorker('/festivals.json'); - const data = await response.json(); - const ids = data.festivals.map((f) => f.id); - expect(ids).toContain(data.default_festival_id); - }); + it("default_festival_id references an existing festival", async () => { + const response = await fetchWorker("/festivals.json"); + const data = await response.json(); + const ids = data.festivals.map((f) => f.id); + expect(ids).toContain(data.default_festival_id); + }); - it('sets Content-Type to application/json with charset', async () => { - const response = await fetchWorker('/festivals.json'); - expect(response.headers.get('Content-Type')) - .toBe('application/json; charset=utf-8'); - }); + it("sets Content-Type to application/json with charset", async () => { + const response = await fetchWorker("/festivals.json"); + expect(response.headers.get("Content-Type")).toBe( + "application/json; charset=utf-8", + ); + }); - it('sets Cache-Control to no-cache', async () => { - const response = await fetchWorker('/festivals.json'); - expect(response.headers.get('Cache-Control')) - .toBe('no-cache, must-revalidate'); - }); + it("sets Cache-Control to no-cache", async () => { + const response = await fetchWorker("/festivals.json"); + expect(response.headers.get("Cache-Control")).toBe( + "no-cache, must-revalidate", + ); + }); - it('includes CORS headers', async () => { - const response = await fetchWorker('/festivals.json'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - }); + it("includes CORS headers", async () => { + const response = await fetchWorker("/festivals.json"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + }); - it('/festivals and /festivals.json return the same data', async () => { - const response1 = await fetchWorker('/festivals.json'); - const response2 = await fetchWorker('/festivals'); - const data1 = await response1.json(); - const data2 = await response2.json(); - expect(data1).toEqual(data2); - }); + it("/festivals and /festivals.json return the same data", async () => { + const response1 = await fetchWorker("/festivals.json"); + const response2 = await fetchWorker("/festivals"); + const data1 = await response1.json(); + const data2 = await response2.json(); + expect(data1).toEqual(data2); + }); }); diff --git a/cloudflare-worker/test/proxy.test.js b/cloudflare-worker/test/proxy.test.js index 07b35d93..2f8e9247 100644 --- a/cloudflare-worker/test/proxy.test.js +++ b/cloudflare-worker/test/proxy.test.js @@ -1,152 +1,195 @@ -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; -import worker from '../worker.js'; - -const UPSTREAM = 'https://data.cambridgebeerfestival.com'; +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + env, + createExecutionContext, + waitOnExecutionContext, +} from "cloudflare:test"; +import worker from "../worker.js"; + +const UPSTREAM = "https://data.cambridgebeerfestival.com"; const PROXY_FETCH_INIT = { - method: 'GET', - headers: { - 'User-Agent': 'Cambridge-Beer-Festival-App-Proxy/1.0', - }, + method: "GET", + headers: { + "User-Agent": "Cambridge-Beer-Festival-App-Proxy/1.0", + }, }; -async function fetchWorker(path, origin = 'https://cambeerfestival.app') { - const request = new Request(`https://worker.example.com${path}`, { - headers: { Origin: origin }, - }); - const ctx = createExecutionContext(); - const response = await worker.fetch(request, env, ctx); - await waitOnExecutionContext(ctx); - return response; +async function fetchWorker(path, origin = "https://cambeerfestival.app") { + const request = new Request(`https://worker.example.com${path}`, { + headers: { Origin: origin }, + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, env, ctx); + await waitOnExecutionContext(ctx); + return response; } -describe('health check', () => { - it('returns 200 with status ok', async () => { - const response = await fetchWorker('/health'); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data).toEqual({ status: 'ok' }); - }); - - it('returns JSON content type', async () => { - const response = await fetchWorker('/health'); - expect(response.headers.get('Content-Type')) - .toBe('application/json; charset=utf-8'); - }); - - it('includes CORS headers', async () => { - const response = await fetchWorker('/health'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - }); +describe("health check", () => { + it("returns 200 with status ok", async () => { + const response = await fetchWorker("/health"); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toEqual({ status: "ok" }); + }); + + it("returns JSON content type", async () => { + const response = await fetchWorker("/health"); + expect(response.headers.get("Content-Type")).toBe( + "application/json; charset=utf-8", + ); + }); + + it("includes CORS headers", async () => { + const response = await fetchWorker("/health"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + }); }); -describe('upstream proxy', () => { - let mockFetch; - - beforeEach(() => { - mockFetch = vi.fn(); - vi.stubGlobal('fetch', mockFetch); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('proxies requests to upstream and returns response', async () => { - const upstreamBody = JSON.stringify([{ name: 'Test Brewery', products: [] }]); - mockFetch.mockResolvedValueOnce(new Response(upstreamBody, { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })); - - const response = await fetchWorker('/cbf2025/beer.json'); - expect(response.status).toBe(200); - - const data = await response.json(); - expect(data).toEqual([{ name: 'Test Brewery', products: [] }]); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/beer.json`, PROXY_FETCH_INIT); - }); - - it('adds charset=utf-8 to JSON responses missing it', async () => { - mockFetch.mockResolvedValueOnce(new Response('[]', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })); - - const response = await fetchWorker('/cbf2025/beer.json'); - expect(response.headers.get('Content-Type')) - .toBe('application/json; charset=utf-8'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/beer.json`, PROXY_FETCH_INIT); - }); - - it('preserves charset if already present in upstream response', async () => { - mockFetch.mockResolvedValueOnce(new Response('[]', { - status: 200, - headers: { 'Content-Type': 'application/json; charset=utf-8' }, - })); - - const response = await fetchWorker('/cbf2025/beer.json'); - expect(response.headers.get('Content-Type')) - .toBe('application/json; charset=utf-8'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/beer.json`, PROXY_FETCH_INIT); - }); - - it('includes CORS headers on proxied responses', async () => { - mockFetch.mockResolvedValueOnce(new Response('[]', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })); - - const response = await fetchWorker('/cbf2025/beer.json'); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - expect(response.headers.get('Vary')).toBe('Origin'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/beer.json`, PROXY_FETCH_INIT); - }); - - it('passes through upstream error status codes', async () => { - mockFetch.mockResolvedValueOnce(new Response('Not Found', { status: 404 })); - - const response = await fetchWorker('/cbf2025/nonexistent.json'); - expect(response.status).toBe(404); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/nonexistent.json`, PROXY_FETCH_INIT); - }); - - it('returns 502 when upstream fetch fails', async () => { - mockFetch.mockRejectedValueOnce(new Error('Connection refused')); - - const response = await fetchWorker('/cbf2025/beer.json'); - expect(response.status).toBe(502); - - const data = await response.json(); - expect(data.error).toBe('Proxy error'); - expect(data.message).toBeDefined(); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/beer.json`, PROXY_FETCH_INIT); - }); - - it('returns 502 with CORS headers on proxy error', async () => { - mockFetch.mockRejectedValueOnce(new Error('Connection refused')); - - const response = await fetchWorker('/cbf2025/beer.json'); - expect(response.status).toBe(502); - expect(response.headers.get('Access-Control-Allow-Origin')) - .toBe('https://cambeerfestival.app'); - expect(mockFetch).toHaveBeenCalledWith(`${UPSTREAM}/cbf2025/beer.json`, PROXY_FETCH_INIT); - }); - - it('preserves query string when proxying', async () => { - mockFetch.mockResolvedValueOnce(new Response('[]', { - status: 200, - headers: { 'Content-Type': 'application/json' }, - })); - - const response = await fetchWorker('/cbf2025/beer.json?v=2'); - expect(response.status).toBe(200); - expect(mockFetch).toHaveBeenCalledWith( - `${UPSTREAM}/cbf2025/beer.json?v=2`, - PROXY_FETCH_INIT, - ); - }); +describe("upstream proxy", () => { + let mockFetch; + + beforeEach(() => { + mockFetch = vi.fn(); + vi.stubGlobal("fetch", mockFetch); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("proxies requests to upstream and returns response", async () => { + const upstreamBody = JSON.stringify([ + { name: "Test Brewery", products: [] }, + ]); + mockFetch.mockResolvedValueOnce( + new Response(upstreamBody, { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const response = await fetchWorker("/cbf2025/beer.json"); + expect(response.status).toBe(200); + + const data = await response.json(); + expect(data).toEqual([{ name: "Test Brewery", products: [] }]); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json`, + PROXY_FETCH_INIT, + ); + }); + + it("adds charset=utf-8 to JSON responses missing it", async () => { + mockFetch.mockResolvedValueOnce( + new Response("[]", { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const response = await fetchWorker("/cbf2025/beer.json"); + expect(response.headers.get("Content-Type")).toBe( + "application/json; charset=utf-8", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json`, + PROXY_FETCH_INIT, + ); + }); + + it("preserves charset if already present in upstream response", async () => { + mockFetch.mockResolvedValueOnce( + new Response("[]", { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + }), + ); + + const response = await fetchWorker("/cbf2025/beer.json"); + expect(response.headers.get("Content-Type")).toBe( + "application/json; charset=utf-8", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json`, + PROXY_FETCH_INIT, + ); + }); + + it("includes CORS headers on proxied responses", async () => { + mockFetch.mockResolvedValueOnce( + new Response("[]", { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const response = await fetchWorker("/cbf2025/beer.json"); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + expect(response.headers.get("Vary")).toBe("Origin"); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json`, + PROXY_FETCH_INIT, + ); + }); + + it("passes through upstream error status codes", async () => { + mockFetch.mockResolvedValueOnce(new Response("Not Found", { status: 404 })); + + const response = await fetchWorker("/cbf2025/nonexistent.json"); + expect(response.status).toBe(404); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/nonexistent.json`, + PROXY_FETCH_INIT, + ); + }); + + it("returns 502 when upstream fetch fails", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const response = await fetchWorker("/cbf2025/beer.json"); + expect(response.status).toBe(502); + + const data = await response.json(); + expect(data.error).toBe("Proxy error"); + expect(data.message).toBeDefined(); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json`, + PROXY_FETCH_INIT, + ); + }); + + it("returns 502 with CORS headers on proxy error", async () => { + mockFetch.mockRejectedValueOnce(new Error("Connection refused")); + + const response = await fetchWorker("/cbf2025/beer.json"); + expect(response.status).toBe(502); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://cambeerfestival.app", + ); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json`, + PROXY_FETCH_INIT, + ); + }); + + it("preserves query string when proxying", async () => { + mockFetch.mockResolvedValueOnce( + new Response("[]", { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const response = await fetchWorker("/cbf2025/beer.json?v=2"); + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledWith( + `${UPSTREAM}/cbf2025/beer.json?v=2`, + PROXY_FETCH_INIT, + ); + }); }); diff --git a/cloudflare-worker/vitest.config.js b/cloudflare-worker/vitest.config.js index 63fb751b..adce4cc5 100644 --- a/cloudflare-worker/vitest.config.js +++ b/cloudflare-worker/vitest.config.js @@ -1,9 +1,11 @@ -import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; -import { defineConfig } from 'vitest/config'; +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; export default defineConfig({ - plugins: [cloudflareTest({ - wrangler: { configPath: './wrangler.toml' }, - })], - test: {}, + plugins: [ + cloudflareTest({ + wrangler: { configPath: "./wrangler.toml" }, + }), + ], + test: {}, }); diff --git a/cloudflare-worker/worker.js b/cloudflare-worker/worker.js index dc82a962..34ae2d4d 100644 --- a/cloudflare-worker/worker.js +++ b/cloudflare-worker/worker.js @@ -14,52 +14,52 @@ */ // Import festivals data directly - copied from data/festivals.json during build -import festivalsData from './festivals.json'; +import festivalsData from "./festivals.json"; -const UPSTREAM_URL = 'https://data.cambridgebeerfestival.com'; +const UPSTREAM_URL = "https://data.cambridgebeerfestival.com"; // Cache control for festivals.json // Use no-cache to ensure browsers revalidate on each request while still caching // This ensures updates are visible immediately while allowing conditional requests -const FESTIVALS_CACHE_CONTROL = 'no-cache, must-revalidate'; +const FESTIVALS_CACHE_CONTROL = "no-cache, must-revalidate"; // Allowed origins for CORS const ALLOWED_ORIGINS = [ - 'https://richardthe3rd.github.io', - 'https://cambeerfestival.app', - 'https://staging.cambeerfestival.app', - 'https://tunnel.cambeerfestival.app', - 'http://localhost:8080', - 'http://localhost:3000', - 'http://127.0.0.1:8080', + "https://richardthe3rd.github.io", + "https://cambeerfestival.app", + "https://staging.cambeerfestival.app", + "https://tunnel.cambeerfestival.app", + "http://localhost:8080", + "http://localhost:3000", + "http://127.0.0.1:8080", ]; export default { async fetch(request, env, ctx) { // Handle CORS preflight requests - if (request.method === 'OPTIONS') { + if (request.method === "OPTIONS") { return handleCorsPreflight(request); } const url = new URL(request.url); - + // Health check endpoint - if (url.pathname === '/health') { - return new Response(JSON.stringify({ status: 'ok' }), { - headers: { - 'Content-Type': 'application/json; charset=utf-8', + if (url.pathname === "/health") { + return new Response(JSON.stringify({ status: "ok" }), { + headers: { + "Content-Type": "application/json; charset=utf-8", ...getCorsHeaders(request), }, }); } // Serve festivals.json directly from embedded data - if (url.pathname === '/festivals.json' || url.pathname === '/festivals') { + if (url.pathname === "/festivals.json" || url.pathname === "/festivals") { return new Response(JSON.stringify(festivalsData), { status: 200, headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': FESTIVALS_CACHE_CONTROL, + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": FESTIVALS_CACHE_CONTROL, ...getCorsHeaders(request), }, }); @@ -67,19 +67,21 @@ export default { // Handle dynamic available_beverage_types.json endpoint // Pattern: /{festivalId}/available_beverage_types.json - const availableTypesMatch = url.pathname.match(/^\/([^\/]+)\/available_beverage_types\.json$/); + const availableTypesMatch = url.pathname.match( + /^\/([^\/]+)\/available_beverage_types\.json$/, + ); if (availableTypesMatch) { return handleAvailableBeverageTypes(availableTypesMatch[1], request); } // Proxy the request to the upstream API const upstreamUrl = UPSTREAM_URL + url.pathname + url.search; - + try { const response = await fetch(upstreamUrl, { method: request.method, headers: { - 'User-Agent': 'Cambridge-Beer-Festival-App-Proxy/1.0', + "User-Agent": "Cambridge-Beer-Festival-App-Proxy/1.0", }, }); @@ -89,9 +91,13 @@ export default { // Ensure JSON responses explicitly declare UTF-8 encoding // This prevents mojibake when non-ASCII characters (é, ö, ä, ñ) are present - const contentType = newHeaders.get('Content-Type'); - if (contentType && contentType.includes('application/json') && !contentType.includes('charset')) { - newHeaders.set('Content-Type', 'application/json; charset=utf-8'); + const contentType = newHeaders.get("Content-Type"); + if ( + contentType && + contentType.includes("application/json") && + !contentType.includes("charset") + ) { + newHeaders.set("Content-Type", "application/json; charset=utf-8"); } return new Response(response.body, { @@ -100,13 +106,16 @@ export default { headers: newHeaders, }); } catch (error) { - return new Response(JSON.stringify({ error: 'Proxy error', message: error.message }), { - status: 502, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - ...getCorsHeaders(request), + return new Response( + JSON.stringify({ error: "Proxy error", message: error.message }), + { + status: 502, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...getCorsHeaders(request), + }, }, - }); + ); } }, }; @@ -125,21 +134,24 @@ async function handleAvailableBeverageTypes(festivalId, request) { const upstreamUrl = `${UPSTREAM_URL}/${festivalId}/`; const response = await fetch(upstreamUrl, { headers: { - 'User-Agent': 'Cambridge-Beer-Festival-App-Proxy/1.0', + "User-Agent": "Cambridge-Beer-Festival-App-Proxy/1.0", }, }); if (!response.ok) { - return new Response(JSON.stringify({ - error: 'Festival not found', - festival_id: festivalId, - }), { - status: 404, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - ...getCorsHeaders(request), + return new Response( + JSON.stringify({ + error: "Festival not found", + festival_id: festivalId, + }), + { + status: 404, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...getCorsHeaders(request), + }, }, - }); + ); } // Parse the HTML directory listing to find .json files @@ -147,29 +159,35 @@ async function handleAvailableBeverageTypes(festivalId, request) { const beverageTypes = parseDirectoryListingForBeverageTypes(html); // Return the list of available beverage types - return new Response(JSON.stringify({ - festival_id: festivalId, - available_beverage_types: beverageTypes, - timestamp: new Date().toISOString(), - }), { - status: 200, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour - ...getCorsHeaders(request), + return new Response( + JSON.stringify({ + festival_id: festivalId, + available_beverage_types: beverageTypes, + timestamp: new Date().toISOString(), + }), + { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "public, max-age=3600", // Cache for 1 hour + ...getCorsHeaders(request), + }, }, - }); + ); } catch (error) { - return new Response(JSON.stringify({ - error: 'Failed to fetch beverage types', - message: error.message, - }), { - status: 500, - headers: { - 'Content-Type': 'application/json; charset=utf-8', - ...getCorsHeaders(request), + return new Response( + JSON.stringify({ + error: "Failed to fetch beverage types", + message: error.message, + }), + { + status: 500, + headers: { + "Content-Type": "application/json; charset=utf-8", + ...getCorsHeaders(request), + }, }, - }); + ); } } @@ -191,12 +209,12 @@ function parseDirectoryListingForBeverageTypes(html) { const filename = match[1]; // Skip the available_beverage_types.json itself to avoid recursion - if (filename === 'available_beverage_types.json') { + if (filename === "available_beverage_types.json") { continue; } // Remove .json extension to get the beverage type name - const beverageType = filename.replace(/\.json$/, ''); + const beverageType = filename.replace(/\.json$/, ""); beverageTypes.push(beverageType); } @@ -205,41 +223,43 @@ function parseDirectoryListingForBeverageTypes(html) { } function handleCorsPreflight(request) { - const origin = request.headers.get('Origin') || ''; + const origin = request.headers.get("Origin") || ""; // Set shorter CORS preflight cache for staging/preview environments // to prevent stale CORS responses after deployments - let maxAge = '300'; // 5 minutes for production - - if (origin.endsWith('.staging-cambeerfestival.pages.dev') || - origin.endsWith('.cambeerfestival.pages.dev') || - origin === 'https://staging.cambeerfestival.app' || - origin.endsWith('.trycloudflare.com') || - origin.startsWith('http://localhost') || - origin.startsWith('http://127.0.0.1')) { - maxAge = '10'; // 10 seconds for development/staging + let maxAge = "300"; // 5 minutes for production + + if ( + origin.endsWith(".staging-cambeerfestival.pages.dev") || + origin.endsWith(".cambeerfestival.pages.dev") || + origin === "https://staging.cambeerfestival.app" || + origin.endsWith(".trycloudflare.com") || + origin.startsWith("http://localhost") || + origin.startsWith("http://127.0.0.1") + ) { + maxAge = "10"; // 10 seconds for development/staging } return new Response(null, { status: 204, headers: { ...getCorsHeaders(request), - 'Access-Control-Allow-Methods': 'GET, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', - 'Access-Control-Max-Age': maxAge, + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + "Access-Control-Max-Age": maxAge, }, }); } function getCorsHeaders(request) { - const origin = request.headers.get('Origin') || ''; + const origin = request.headers.get("Origin") || ""; // Allow listed origins (exact match) if (ALLOWED_ORIGINS.includes(origin)) { return { - 'Access-Control-Allow-Origin': origin, - 'Access-Control-Allow-Credentials': 'true', - 'Vary': 'Origin', // Tell caches to key by Origin header + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + Vary: "Origin", // Tell caches to key by Origin header }; } @@ -248,22 +268,22 @@ function getCorsHeaders(request) { // Security note: This wildcard is safe because Cloudflare controls the .pages.dev // namespace. Only our cambeerfestival project can create subdomains under // cambeerfestival.pages.dev, preventing malicious domains from matching this pattern. - if (origin.endsWith('.cambeerfestival.pages.dev')) { + if (origin.endsWith(".cambeerfestival.pages.dev")) { return { - 'Access-Control-Allow-Origin': origin, - 'Access-Control-Allow-Credentials': 'true', - 'Vary': 'Origin', // Tell caches to key by Origin header + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + Vary: "Origin", // Tell caches to key by Origin header }; } // Allow Cloudflare Pages staging preview URLs (*.staging-cambeerfestival.pages.dev) // This includes branch-based staging deployments // Security note: Same as above - Cloudflare controls the .pages.dev namespace - if (origin.endsWith('.staging-cambeerfestival.pages.dev')) { + if (origin.endsWith(".staging-cambeerfestival.pages.dev")) { return { - 'Access-Control-Allow-Origin': origin, - 'Access-Control-Allow-Credentials': 'true', - 'Vary': 'Origin', // Tell caches to key by Origin header + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + Vary: "Origin", // Tell caches to key by Origin header }; } @@ -271,11 +291,11 @@ function getCorsHeaders(request) { // Used for local development with cloudflared tunnel // Security note: These are temporary development tunnels controlled by Cloudflare. // Only enable this in development/staging workers, not production. - if (origin.endsWith('.trycloudflare.com')) { + if (origin.endsWith(".trycloudflare.com")) { return { - 'Access-Control-Allow-Origin': origin, - 'Access-Control-Allow-Credentials': 'true', - 'Vary': 'Origin', // Tell caches to key by Origin header + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + Vary: "Origin", // Tell caches to key by Origin header }; } diff --git a/functions/[festivalId]/drink/[category]/[drinkId].js b/functions/[festivalId]/drink/[category]/[drinkId].js index 5de0f6c2..e968a8dc 100644 --- a/functions/[festivalId]/drink/[category]/[drinkId].js +++ b/functions/[festivalId]/drink/[category]/[drinkId].js @@ -1,12 +1,17 @@ -import { isCrawler, fetchDrinkData, findDrink, buildOgTags } from '../../../_lib/drink-preview.js'; +import { + isCrawler, + fetchDrinkData, + findDrink, + buildOgTags, +} from "../../../_lib/drink-preview.js"; -const SITE_URL = 'https://cambeerfestival.app'; +const SITE_URL = "https://cambeerfestival.app"; export async function onRequest(context) { const { request, env, params } = context; // Always serve the SPA for non-crawlers — no latency overhead. - const userAgent = request.headers.get('User-Agent') ?? ''; + const userAgent = request.headers.get("User-Agent") ?? ""; if (!isCrawler(userAgent)) { return env.ASSETS.fetch(request); } @@ -31,9 +36,12 @@ export async function onRequest(context) { // HTMLRewriter streams the response and appends OG tags inside // without buffering the body — no encoding header concerns, no string hacks. return new HTMLRewriter() - .on('head', { + .on("head", { element(element) { - element.append(buildOgTags(drink.product, drink.producer, canonicalUrl), { html: true }); + element.append( + buildOgTags(drink.product, drink.producer, canonicalUrl), + { html: true }, + ); }, }) .transform(spaResponse); diff --git a/functions/_lib/drink-preview.js b/functions/_lib/drink-preview.js index d80029c9..4ba8ed38 100644 --- a/functions/_lib/drink-preview.js +++ b/functions/_lib/drink-preview.js @@ -1,20 +1,20 @@ const CRAWLER_UA_PATTERNS = [ - 'facebookexternalhit', - 'twitterbot', - 'whatsapp', - 'slackbot', - 'linkedinbot', - 'discordbot', - 'googlebot', - 'telegrambot', + "facebookexternalhit", + "twitterbot", + "whatsapp", + "slackbot", + "linkedinbot", + "discordbot", + "googlebot", + "telegrambot", ]; -const DATA_BASE_URL = 'https://data.cambeerfestival.app'; -const OG_IMAGE_URL = 'https://cambeerfestival.app/icons/Icon-512.png'; +const DATA_BASE_URL = "https://data.cambeerfestival.app"; +const OG_IMAGE_URL = "https://cambeerfestival.app/icons/Icon-512.png"; // Product category field values don't always match their API endpoint names. const CATEGORY_TO_ENDPOINT = { - 'foreign beer': 'international-beer', + "foreign beer": "international-beer", }; export function isCrawler(userAgent) { @@ -25,7 +25,9 @@ export function isCrawler(userAgent) { export function findDrink(producers, drinkId) { for (const producer of producers) { - const product = (producer.products ?? []).find((p) => String(p.id) === drinkId); + const product = (producer.products ?? []).find( + (p) => String(p.id) === drinkId, + ); if (product) return { product, producer }; } return null; @@ -33,22 +35,26 @@ export function findDrink(producers, drinkId) { function escapeHtml(str) { return String(str) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); } function formatAbv(abv) { - const num = typeof abv === 'number' ? abv : parseFloat(abv); + const num = typeof abv === "number" ? abv : parseFloat(abv); if (isNaN(num)) return null; return `${Number.isInteger(num) ? num : num.toFixed(1)}% ABV`; } export function buildOgTags(product, producer, canonicalUrl) { const title = `${product.name} — ${producer.name}`; - const descParts = [product.style, formatAbv(product.abv), 'Cambridge Beer Festival'].filter(Boolean); - const description = descParts.join(' · '); + const descParts = [ + product.style, + formatAbv(product.abv), + "Cambridge Beer Festival", + ].filter(Boolean); + const description = descParts.join(" · "); return [ ``, @@ -60,11 +66,13 @@ export function buildOgTags(product, producer, canonicalUrl) { ``, ``, ``, - ].join('\n'); + ].join("\n"); } export async function fetchDrinkData(festivalId, category) { - const endpoint = Object.hasOwn(CATEGORY_TO_ENDPOINT, category) ? CATEGORY_TO_ENDPOINT[category] : category; + const endpoint = Object.hasOwn(CATEGORY_TO_ENDPOINT, category) + ? CATEGORY_TO_ENDPOINT[category] + : category; const url = `${DATA_BASE_URL}/${encodeURIComponent(festivalId)}/${encodeURIComponent(endpoint)}.json`; const response = await fetch(url); if (!response.ok) return null; diff --git a/functions/test/drink-preview.test.js b/functions/test/drink-preview.test.js index 1e2c69e8..da297e93 100644 --- a/functions/test/drink-preview.test.js +++ b/functions/test/drink-preview.test.js @@ -1,266 +1,336 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { isCrawler, findDrink, buildOgTags, fetchDrinkData } from '../_lib/drink-preview.js'; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { + isCrawler, + findDrink, + buildOgTags, + fetchDrinkData, +} from "../_lib/drink-preview.js"; const TEST_PRODUCERS = [ { - id: 'adnams', - name: 'Adnams', - location: 'Southwold', + id: "adnams", + name: "Adnams", + location: "Southwold", products: [ - { id: 'broadside', name: 'Broadside', category: 'beer', style: 'Strong Bitter', abv: 6.3, dispense: 'cask' }, - { id: 'ghost-ship', name: 'Ghost Ship', category: 'beer', style: 'Pale Ale', abv: 5.0, dispense: 'cask' }, + { + id: "broadside", + name: "Broadside", + category: "beer", + style: "Strong Bitter", + abv: 6.3, + dispense: "cask", + }, + { + id: "ghost-ship", + name: "Ghost Ship", + category: "beer", + style: "Pale Ale", + abv: 5.0, + dispense: "cask", + }, ], }, { - id: 'aspall', - name: 'Aspall', - location: 'Suffolk', + id: "aspall", + name: "Aspall", + location: "Suffolk", products: [ - { id: 'premier-cru', name: 'Premier Cru', category: 'cider', style: null, abv: 7.0, dispense: 'draught' }, + { + id: "premier-cru", + name: "Premier Cru", + category: "cider", + style: null, + abv: 7.0, + dispense: "draught", + }, ], }, ]; -describe('isCrawler', () => { - it('detects facebookexternalhit', () => { - expect(isCrawler('facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)')).toBe(true); +describe("isCrawler", () => { + it("detects facebookexternalhit", () => { + expect( + isCrawler( + "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)", + ), + ).toBe(true); }); - it('detects Twitterbot (case-insensitive)', () => { - expect(isCrawler('Twitterbot/1.0')).toBe(true); + it("detects Twitterbot (case-insensitive)", () => { + expect(isCrawler("Twitterbot/1.0")).toBe(true); }); - it('detects WhatsApp', () => { - expect(isCrawler('WhatsApp/2.19.81 A')).toBe(true); + it("detects WhatsApp", () => { + expect(isCrawler("WhatsApp/2.19.81 A")).toBe(true); }); - it('detects Slackbot', () => { - expect(isCrawler('Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)')).toBe(true); + it("detects Slackbot", () => { + expect( + isCrawler("Slackbot-LinkExpanding 1.0 (+https://api.slack.com/robots)"), + ).toBe(true); }); - it('detects LinkedInBot', () => { - expect(isCrawler('LinkedInBot/1.0 (compatible; Mozilla/5.0)')).toBe(true); + it("detects LinkedInBot", () => { + expect(isCrawler("LinkedInBot/1.0 (compatible; Mozilla/5.0)")).toBe(true); }); - it('detects Discordbot', () => { - expect(isCrawler('Mozilla/5.0 (compatible; Discordbot/2.0)')).toBe(true); + it("detects Discordbot", () => { + expect(isCrawler("Mozilla/5.0 (compatible; Discordbot/2.0)")).toBe(true); }); - it('detects Googlebot', () => { - expect(isCrawler('Mozilla/5.0 (compatible; Googlebot/2.1)')).toBe(true); + it("detects Googlebot", () => { + expect(isCrawler("Mozilla/5.0 (compatible; Googlebot/2.1)")).toBe(true); }); - it('detects TelegramBot', () => { - expect(isCrawler('TelegramBot (like TwitterBot)')).toBe(true); + it("detects TelegramBot", () => { + expect(isCrawler("TelegramBot (like TwitterBot)")).toBe(true); }); - it('returns false for Chrome on Android', () => { - expect(isCrawler('Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 Chrome/91.0 Mobile Safari/537.36')).toBe(false); + it("returns false for Chrome on Android", () => { + expect( + isCrawler( + "Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 Chrome/91.0 Mobile Safari/537.36", + ), + ).toBe(false); }); - it('returns false for Safari on iPhone', () => { - expect(isCrawler('Mozilla/5.0 (iPhone; CPU iPhone OS 15_0) AppleWebKit/605.1.15 Safari/604.1')).toBe(false); + it("returns false for Safari on iPhone", () => { + expect( + isCrawler( + "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0) AppleWebKit/605.1.15 Safari/604.1", + ), + ).toBe(false); }); - it('returns false for empty string', () => { - expect(isCrawler('')).toBe(false); + it("returns false for empty string", () => { + expect(isCrawler("")).toBe(false); }); - it('returns false for null', () => { + it("returns false for null", () => { expect(isCrawler(null)).toBe(false); }); }); -describe('findDrink', () => { - it('finds a drink by product ID', () => { - const result = findDrink(TEST_PRODUCERS, 'broadside'); +describe("findDrink", () => { + it("finds a drink by product ID", () => { + const result = findDrink(TEST_PRODUCERS, "broadside"); expect(result).not.toBeNull(); - expect(result.product.name).toBe('Broadside'); - expect(result.producer.name).toBe('Adnams'); + expect(result.product.name).toBe("Broadside"); + expect(result.producer.name).toBe("Adnams"); }); - it('finds a drink in a later producer', () => { - const result = findDrink(TEST_PRODUCERS, 'premier-cru'); - expect(result.producer.name).toBe('Aspall'); - expect(result.product.name).toBe('Premier Cru'); + it("finds a drink in a later producer", () => { + const result = findDrink(TEST_PRODUCERS, "premier-cru"); + expect(result.producer.name).toBe("Aspall"); + expect(result.product.name).toBe("Premier Cru"); }); - it('finds a second product from the same producer', () => { - const result = findDrink(TEST_PRODUCERS, 'ghost-ship'); - expect(result.product.name).toBe('Ghost Ship'); + it("finds a second product from the same producer", () => { + const result = findDrink(TEST_PRODUCERS, "ghost-ship"); + expect(result.product.name).toBe("Ghost Ship"); }); - it('returns null when drink ID not found', () => { - expect(findDrink(TEST_PRODUCERS, 'nonexistent-id')).toBeNull(); + it("returns null when drink ID not found", () => { + expect(findDrink(TEST_PRODUCERS, "nonexistent-id")).toBeNull(); }); - it('returns null for empty producers list', () => { - expect(findDrink([], 'broadside')).toBeNull(); + it("returns null for empty producers list", () => { + expect(findDrink([], "broadside")).toBeNull(); }); - it('handles producers with empty products array', () => { - const producers = [{ id: 'empty', name: 'Empty', products: [] }]; - expect(findDrink(producers, 'any')).toBeNull(); + it("handles producers with empty products array", () => { + const producers = [{ id: "empty", name: "Empty", products: [] }]; + expect(findDrink(producers, "any")).toBeNull(); }); - it('handles producers with missing products field', () => { - const producers = [{ id: 'noproducts', name: 'No Products' }]; - expect(findDrink(producers, 'any')).toBeNull(); + it("handles producers with missing products field", () => { + const producers = [{ id: "noproducts", name: "No Products" }]; + expect(findDrink(producers, "any")).toBeNull(); }); - it('coerces numeric product IDs to string for comparison', () => { - const producers = [{ id: 'test', name: 'Test', products: [{ id: 42, name: 'Numeric ID', abv: 4.0 }] }]; - const result = findDrink(producers, '42'); - expect(result.product.name).toBe('Numeric ID'); + it("coerces numeric product IDs to string for comparison", () => { + const producers = [ + { + id: "test", + name: "Test", + products: [{ id: 42, name: "Numeric ID", abv: 4.0 }], + }, + ]; + const result = findDrink(producers, "42"); + expect(result.product.name).toBe("Numeric ID"); }); }); -describe('buildOgTags', () => { - const product = { name: 'Broadside', style: 'Strong Bitter', abv: 6.3 }; - const producer = { name: 'Adnams' }; - const url = 'https://cambeerfestival.app/cbf2025/drink/beer/broadside'; +describe("buildOgTags", () => { + const product = { name: "Broadside", style: "Strong Bitter", abv: 6.3 }; + const producer = { name: "Adnams" }; + const url = "https://cambeerfestival.app/cbf2025/drink/beer/broadside"; - it('includes og:title with drink name and brewery', () => { + it("includes og:title with drink name and brewery", () => { const tags = buildOgTags(product, producer, url); - expect(tags).toContain('og:title'); - expect(tags).toContain('Broadside — Adnams'); + expect(tags).toContain("og:title"); + expect(tags).toContain("Broadside — Adnams"); }); - it('includes style, ABV, and festival name in description', () => { + it("includes style, ABV, and festival name in description", () => { const tags = buildOgTags(product, producer, url); - expect(tags).toContain('Strong Bitter · 6.3% ABV · Cambridge Beer Festival'); + expect(tags).toContain( + "Strong Bitter · 6.3% ABV · Cambridge Beer Festival", + ); }); - it('includes og:url set to canonical URL', () => { + it("includes og:url set to canonical URL", () => { const tags = buildOgTags(product, producer, url); expect(tags).toContain(`og:url" content="${url}"`); }); - it('includes og:image pointing to festival icon', () => { + it("includes og:image pointing to festival icon", () => { const tags = buildOgTags(product, producer, url); - expect(tags).toContain('og:image'); - expect(tags).toContain('Icon-512.png'); + expect(tags).toContain("og:image"); + expect(tags).toContain("Icon-512.png"); }); - it('includes twitter:card set to summary', () => { + it("includes twitter:card set to summary", () => { const tags = buildOgTags(product, producer, url); expect(tags).toContain('twitter:card" content="summary"'); }); - it('omits style from description when null', () => { - const noStyle = { name: 'Premier Cru', style: null, abv: 7.0 }; + it("omits style from description when null", () => { + const noStyle = { name: "Premier Cru", style: null, abv: 7.0 }; const tags = buildOgTags(noStyle, producer, url); - expect(tags).toContain('7% ABV · Cambridge Beer Festival'); - expect(tags).not.toContain('null'); + expect(tags).toContain("7% ABV · Cambridge Beer Festival"); + expect(tags).not.toContain("null"); }); - it('handles string ABV values', () => { - const strAbv = { name: 'Test', style: 'IPA', abv: '6.3' }; + it("handles string ABV values", () => { + const strAbv = { name: "Test", style: "IPA", abv: "6.3" }; const tags = buildOgTags(strAbv, producer, url); - expect(tags).toContain('6.3% ABV'); + expect(tags).toContain("6.3% ABV"); }); - it('omits ABV from description when ABV is non-numeric', () => { - const badAbv = { name: 'Test', style: 'IPA', abv: 'TBC' }; + it("omits ABV from description when ABV is non-numeric", () => { + const badAbv = { name: "Test", style: "IPA", abv: "TBC" }; const tags = buildOgTags(badAbv, producer, url); - expect(tags).not.toContain('TBC'); - expect(tags).toContain('IPA · Cambridge Beer Festival'); + expect(tags).not.toContain("TBC"); + expect(tags).toContain("IPA · Cambridge Beer Festival"); }); - it('formats whole-number ABV without decimal', () => { - const wholeAbv = { name: 'Test', style: 'IPA', abv: 5 }; + it("formats whole-number ABV without decimal", () => { + const wholeAbv = { name: "Test", style: "IPA", abv: 5 }; const tags = buildOgTags(wholeAbv, producer, url); - expect(tags).toContain('5% ABV'); - expect(tags).not.toContain('5.0%'); + expect(tags).toContain("5% ABV"); + expect(tags).not.toContain("5.0%"); }); - it('formats decimal ABV to one decimal place', () => { + it("formats decimal ABV to one decimal place", () => { const tags = buildOgTags(product, producer, url); - expect(tags).toContain('6.3% ABV'); + expect(tags).toContain("6.3% ABV"); }); - it('escapes HTML special characters in drink name', () => { - const xss = { name: '', style: null, abv: 4.0 }; + it("escapes HTML special characters in drink name", () => { + const xss = { + name: '', + style: null, + abv: 4.0, + }; const tags = buildOgTags(xss, producer, url); - expect(tags).not.toContain('