Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -142,5 +142,6 @@ app.*.symbols

# Keep manually created test mocks (build_runner has version compatibility issues)
!test/*.mocks.dart
!test/**/*.mocks.dart
screenshots/
test/failures/
86 changes: 86 additions & 0 deletions test/abv_strength_helper_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import 'package:cambridge_beer_festival/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('ABVStrengthHelper', () {
group('getABVStrengthLabel', () {
test('returns (Low) below 4.0%', () {
expect(ABVStrengthHelper.getABVStrengthLabel(0.0), '(Low)');
expect(ABVStrengthHelper.getABVStrengthLabel(3.9), '(Low)');
});

test('returns (Medium) from 4.0% up to but not including 7.0%', () {
expect(ABVStrengthHelper.getABVStrengthLabel(4.0), '(Medium)');
expect(ABVStrengthHelper.getABVStrengthLabel(6.9), '(Medium)');
});

test('returns (High) at 7.0% and above', () {
expect(ABVStrengthHelper.getABVStrengthLabel(7.0), '(High)');
expect(ABVStrengthHelper.getABVStrengthLabel(12.0), '(High)');
});
});

group('getABVColor', () {
/// Pumps a [Builder] under [brightness] and captures the colour the
/// helper returns for [abv] alongside the active colour scheme.
Future<(Color result, ColorScheme scheme)> resolve(
WidgetTester tester,
Brightness brightness,
double abv,
) async {
late Color result;
late ColorScheme scheme;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(brightness: brightness),
home: Builder(
builder: (context) {
scheme = Theme.of(context).colorScheme;
result = ABVStrengthHelper.getABVColor(context, abv);
return const SizedBox();
},
),
),
);
await tester.pumpAndSettle();
return (result, scheme);
}

testWidgets('low ABV uses the primary colour (light theme)',
(tester) async {
final (result, scheme) = await resolve(tester, Brightness.light, 3.0);
expect(result, scheme.primary);
});

testWidgets('low ABV dims the primary colour (dark theme)',
(tester) async {
final (result, scheme) = await resolve(tester, Brightness.dark, 3.0);
expect(result, scheme.primary.withValues(alpha: 0.7));
});

testWidgets('medium ABV uses the secondary colour (light theme)',
(tester) async {
final (result, scheme) = await resolve(tester, Brightness.light, 5.0);
expect(result, scheme.secondary);
});

testWidgets('medium ABV dims the secondary colour (dark theme)',
(tester) async {
final (result, scheme) = await resolve(tester, Brightness.dark, 5.0);
expect(result, scheme.secondary.withValues(alpha: 0.8));
});

testWidgets('high ABV uses deep orange (light theme)', (tester) async {
final (result, _) = await resolve(tester, Brightness.light, 8.0);
expect(result, const Color(0xFFE64A19));
});

testWidgets('high ABV uses translucent deep orange (dark theme)',
(tester) async {
final (result, _) = await resolve(tester, Brightness.dark, 8.0);
expect(result, const Color(0xFFFF5722).withValues(alpha: 0.85));
});
});
});
}
111 changes: 111 additions & 0 deletions test/beer_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1717,5 +1717,116 @@ void main() {
expect(provider.isDrinksDataStale, isFalse);
});
});

group('tasted, refresh and favourite filtering', () {
test('toggleTasted updates the drink and logs the change', () async {
provider = BeerProvider(
drinkRepository: mockDrinkRepository,
festivalRepository: mockFestivalRepository,
analyticsService: mockAnalyticsService,
);
await provider.initialize();
when(mockDrinkRepository.getDrinks(any))
.thenAnswer((_) async => createSampleDrinks());
await provider.loadDrinks();
final drink = provider.allDrinks.first;

when(mockDrinkRepository.toggleTasted(any, any))
.thenAnswer((_) async => true);
await provider.toggleTasted(drink);
expect(drink.isTasted, isTrue);
verify(mockAnalyticsService.logTastedAdded(drink)).called(1);

when(mockDrinkRepository.toggleTasted(any, any))
.thenAnswer((_) async => false);
await provider.toggleTasted(drink);
expect(drink.isTasted, isFalse);
verify(mockAnalyticsService.logTastedRemoved(drink)).called(1);
});

test('refreshIfStale reloads festivals and drinks when both are stale',
() async {
provider = BeerProvider(
drinkRepository: mockDrinkRepository,
festivalRepository: mockFestivalRepository,
analyticsService: mockAnalyticsService,
);
when(mockDrinkRepository.getDrinks(any))
.thenAnswer((_) async => createSampleDrinks());

// Without initialize()/loadDrinks() both refresh timestamps are null,
// so both data sets report as stale.
expect(provider.isFestivalsDataStale, isTrue);
expect(provider.isDrinksDataStale, isTrue);

await provider.refreshIfStale();

verify(mockFestivalRepository.getFestivals()).called(1);
verify(mockDrinkRepository.getDrinks(any)).called(1);
expect(provider.allDrinks, isNotEmpty);
});

test('toggleFavorite re-applies filters when showing favourites only',
() async {
provider = BeerProvider(
drinkRepository: mockDrinkRepository,
festivalRepository: mockFestivalRepository,
analyticsService: mockAnalyticsService,
);
await provider.initialize();
when(mockDrinkRepository.getDrinks(any))
.thenAnswer((_) async => createSampleDrinks());
await provider.loadDrinks();

provider.setShowFavoritesOnly(true);
expect(provider.showFavoritesOnly, isTrue);
expect(provider.drinks, isEmpty);

final drink = provider.allDrinks.first;
when(mockDrinkRepository.toggleFavorite(any, any))
.thenAnswer((_) async => true);
await provider.toggleFavorite(drink);

// The favourites-only list is refreshed in place by toggleFavorite.
expect(provider.drinks, contains(drink));
});

test('styleCountsMap is scoped to the selected category', () async {
provider = BeerProvider(
drinkRepository: mockDrinkRepository,
festivalRepository: mockFestivalRepository,
analyticsService: mockAnalyticsService,
);
await provider.initialize();
when(mockDrinkRepository.getDrinks(any))
.thenAnswer((_) async => createSampleDrinks());
await provider.loadDrinks();

expect(
provider.styleCountsMap.keys,
containsAll(['IPA', 'Bitter', 'Dry', 'Sweet']),
);

provider.setCategory('cider');
expect(provider.styleCountsMap.keys, containsAll(['Dry', 'Sweet']));
expect(provider.styleCountsMap.containsKey('IPA'), isFalse);
});

test('lastDrinksRefresh is set only after a successful load', () async {
provider = BeerProvider(
drinkRepository: mockDrinkRepository,
festivalRepository: mockFestivalRepository,
analyticsService: mockAnalyticsService,
);
await provider.initialize();
expect(provider.lastDrinksRefresh, isNull);

when(mockDrinkRepository.getDrinks(any))
.thenAnswer((_) async => createSampleDrinks());
await provider.loadDrinks();

expect(provider.lastDrinksRefresh, isNotNull);
});
});
});
}
53 changes: 53 additions & 0 deletions test/beverage_type_helper_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import 'package:cambridge_beer_festival/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('BeverageTypeHelper', () {
group('formatBeverageType', () {
test('title-cases a single word', () {
expect(BeverageTypeHelper.formatBeverageType('beer'), 'Beer');
});

test('title-cases each dash-separated segment', () {
expect(
BeverageTypeHelper.formatBeverageType('international-beer'),
'International Beer',
);
expect(BeverageTypeHelper.formatBeverageType('low-no'), 'Low No');
});

test('ignores empty segments from leading/trailing/double dashes', () {
expect(BeverageTypeHelper.formatBeverageType('--beer--'), 'Beer');
expect(BeverageTypeHelper.formatBeverageType('cider--perry'),
'Cider Perry');
});

test('returns an empty string for empty input', () {
expect(BeverageTypeHelper.formatBeverageType(''), '');
});
});

group('getBeverageIcon', () {
test('maps each known beverage type to a distinct icon', () {
expect(BeverageTypeHelper.getBeverageIcon('beer'), Icons.sports_bar);
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);
expect(BeverageTypeHelper.getBeverageIcon('wine'), Icons.wine_bar);
expect(BeverageTypeHelper.getBeverageIcon('low-no'), Icons.no_drinks);
});

test('falls back to a generic icon for unknown types', () {
expect(
BeverageTypeHelper.getBeverageIcon('apple-juice'),
Icons.local_drink,
);
expect(BeverageTypeHelper.getBeverageIcon(''), Icons.local_drink);
});
});
});
}
85 changes: 85 additions & 0 deletions test/category_color_helper_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import 'package:cambridge_beer_festival/utils/utils.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
group('CategoryColorHelper', () {
/// Pumps a [Builder] under [brightness] and captures the colour the helper
/// returns for [category] alongside the active colour scheme.
Future<(Color result, ColorScheme scheme)> resolve(
WidgetTester tester,
Brightness brightness,
String category,
) async {
late Color result;
late ColorScheme scheme;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(brightness: brightness),
home: Builder(
builder: (context) {
scheme = Theme.of(context).colorScheme;
result = CategoryColorHelper.getCategoryColor(context, category);
return const SizedBox();
},
),
),
);
await tester.pumpAndSettle();
return (result, scheme);
}

testWidgets('beer categories use the secondary colour', (tester) async {
final (result, scheme) = await resolve(tester, Brightness.light, 'beer');
expect(result, scheme.secondary);
});

testWidgets('international-beer still matches the beer branch',
(tester) async {
final (result, scheme) =
await resolve(tester, Brightness.light, 'international-beer');
expect(result, scheme.secondary);
});

testWidgets('cider has a dedicated colour per theme', (tester) async {
final (light, _) = await resolve(tester, Brightness.light, 'cider');
expect(light, const Color(0xFF689F38));

final (dark, _) = await resolve(tester, Brightness.dark, 'cider');
expect(dark, const Color(0xFF8BC34A).withValues(alpha: 0.8));
});

testWidgets('perry has a dedicated colour', (tester) async {
final (result, _) = await resolve(tester, Brightness.light, 'perry');
expect(result, const Color(0xFFAFB42B));
});

testWidgets('mead has a dedicated colour', (tester) async {
final (result, _) = await resolve(tester, Brightness.light, 'mead');
expect(result, const Color(0xFFF9A825));
});

testWidgets('wine has a dedicated colour', (tester) async {
final (result, _) = await resolve(tester, Brightness.light, 'wine');
expect(result, const Color(0xFF7B1FA2));
});

testWidgets('low-no categories use the primary colour', (tester) async {
final (result, scheme) =
await resolve(tester, Brightness.light, 'low-no');
expect(result, scheme.primary);
});

testWidgets('matching is case-insensitive', (tester) async {
final (result, scheme) = await resolve(tester, Brightness.light, 'BEER');
expect(result, scheme.secondary);
});

testWidgets('unknown categories fall back to the outline colour',
(tester) async {
final (result, scheme) =
await resolve(tester, Brightness.light, 'spirits');
expect(result, scheme.outline);
});
});
}
Loading
Loading