diff --git a/cloudflare-worker/worker.js b/cloudflare-worker/worker.js index 537120c8..40841bb3 100644 --- a/cloudflare-worker/worker.js +++ b/cloudflare-worker/worker.js @@ -42,7 +42,7 @@ export default { if (url.pathname === '/health') { return new Response(JSON.stringify({ status: 'ok' }), { headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json; charset=utf-8', ...getCorsHeaders(request), }, }); @@ -53,7 +53,7 @@ export default { return new Response(JSON.stringify(festivalsData), { status: 200, headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': FESTIVALS_CACHE_CONTROL, ...getCorsHeaders(request), }, @@ -81,6 +81,13 @@ export default { // Clone the response and add CORS headers const newHeaders = new Headers(response.headers); setCorsHeaders(newHeaders, request); + + // 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'); + } return new Response(response.body, { status: response.status, @@ -91,7 +98,7 @@ export default { return new Response(JSON.stringify({ error: 'Proxy error', message: error.message }), { status: 502, headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json; charset=utf-8', ...getCorsHeaders(request), }, }); @@ -124,7 +131,7 @@ async function handleAvailableBeverageTypes(festivalId, request) { }), { status: 404, headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json; charset=utf-8', ...getCorsHeaders(request), }, }); @@ -142,7 +149,7 @@ async function handleAvailableBeverageTypes(festivalId, request) { }), { status: 200, headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'public, max-age=3600', // Cache for 1 hour ...getCorsHeaders(request), }, @@ -154,7 +161,7 @@ async function handleAvailableBeverageTypes(festivalId, request) { }), { status: 500, headers: { - 'Content-Type': 'application/json', + 'Content-Type': 'application/json; charset=utf-8', ...getCorsHeaders(request), }, }); diff --git a/lib/screens/drinks_screen.dart b/lib/screens/drinks_screen.dart index c41f75c6..156543bc 100644 --- a/lib/screens/drinks_screen.dart +++ b/lib/screens/drinks_screen.dart @@ -845,9 +845,10 @@ class _StyleFilterSheet extends StatelessWidget { final styleCounts = beerProvider.styleCountsMap; final selectedStyles = beerProvider.selectedStyles; - // Sort styles alphabetically (don't move selected to top to avoid jumping) + // Sort styles alphabetically using locale-aware comparison + // This ensures non-ASCII characters (é, ñ, etc.) sort correctly final sortedStyles = List.from(styles); - sortedStyles.sort((a, b) => a.compareTo(b)); + sortedStyles.sort(StringComparisonHelper.compareLocaleAware); return Container( padding: const EdgeInsets.all(16), diff --git a/lib/services/beer_api_service.dart b/lib/services/beer_api_service.dart index e177ff09..967b1ab6 100644 --- a/lib/services/beer_api_service.dart +++ b/lib/services/beer_api_service.dart @@ -19,7 +19,11 @@ class BeerApiService { .timeout(timeout); if (response.statusCode == 200) { - final data = json.decode(response.body) as Map; + // Decode as UTF-8 to handle non-ASCII characters properly (é, ñ, etc.) + // Using response.body defaults to Latin-1 if no charset in Content-Type, + // which causes "Rosé" to display as "Rosé" (mojibake) + final jsonString = utf8.decode(response.bodyBytes); + final data = json.decode(jsonString) as Map; return _parseDrinks(data, festival.id); } else if (response.statusCode == 404) { // Beverage type not available for this festival diff --git a/lib/utils/string_comparison_helper.dart b/lib/utils/string_comparison_helper.dart new file mode 100644 index 00000000..db264ea8 --- /dev/null +++ b/lib/utils/string_comparison_helper.dart @@ -0,0 +1,34 @@ +/// 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 { + // Private constructor to prevent instantiation + 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" + /// - "Rosé" comes right after "Rose" + /// - "IPA" and "ipa" are treated as equal + /// + /// For sorting lists: + /// ```dart + /// styles.sort(StringComparisonHelper.compareLocaleAware); + /// ``` + static int compareLocaleAware(String a, String b) { + // Use case-insensitive comparison + // This handles accented characters reasonably well for European languages + // by comparing the lowercase versions + return a.toLowerCase().compareTo(b.toLowerCase()); + } +} diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index d691ba7f..d84b1e0e 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -1,6 +1,7 @@ export 'abv_strength_helper.dart'; export 'beverage_type_helper.dart'; export 'category_color_helper.dart'; +export 'string_comparison_helper.dart'; export 'string_formatting_helper.dart'; export 'style_description_helper.dart'; export 'url_launcher_helper.dart'; diff --git a/test/drinks_screen_style_filter_test.dart b/test/drinks_screen_style_filter_test.dart index c1438896..15435fb4 100644 --- a/test/drinks_screen_style_filter_test.dart +++ b/test/drinks_screen_style_filter_test.dart @@ -342,5 +342,131 @@ void main() { // Verify Stout is selected but stays in alphabetical position expect(thirdCheckbox.value, true); }); + + testWidgets('styles with non-ASCII characters sort correctly', + (WidgetTester tester) async { + // Override the test drinks to include non-ASCII characters + final drinksWithAccents = [ + Drink( + product: const Product( + id: 'drink1', + name: 'Rose Cider', + abv: 5.0, + category: 'cider', + dispense: 'keg', + style: 'Rose', + ), + producer: const Producer( + id: 'cidery1', + name: 'Test Cidery', + location: 'France', + products: [], + ), + festivalId: 'cbf2025', + ), + Drink( + product: const Product( + id: 'drink2', + name: 'Rosé Cider', + abv: 5.2, + category: 'cider', + dispense: 'keg', + style: 'Rosé', + ), + producer: const Producer( + id: 'cidery1', + name: 'Test Cidery', + location: 'France', + products: [], + ), + festivalId: 'cbf2025', + ), + Drink( + product: const Product( + id: 'drink3', + name: 'Cafe Stout', + abv: 6.0, + category: 'beer', + dispense: 'cask', + style: 'Cafe', + ), + producer: const Producer( + id: 'brewery1', + name: 'Test Brewery', + location: 'UK', + products: [], + ), + festivalId: 'cbf2025', + ), + Drink( + product: const Product( + id: 'drink4', + name: 'Café Stout', + abv: 6.2, + category: 'beer', + dispense: 'cask', + style: 'Café', + ), + producer: const Producer( + id: 'brewery1', + name: 'Test Brewery', + location: 'UK', + products: [], + ), + festivalId: 'cbf2025', + ), + ]; + + // Create new provider with accented test data + final accentProvider = BeerProvider( + apiService: mockApiService, + festivalService: mockFestivalService, + analyticsService: mockAnalyticsService, + ); + + when(mockApiService.fetchAllDrinks(any)) + .thenAnswer((_) async => drinksWithAccents); + + await accentProvider.initialize(); + await accentProvider.loadDrinks(); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: accentProvider, + child: const MaterialApp( + home: DrinksScreen(), + ), + ), + ); + await tester.pumpAndSettle(); + + // Open style filter + await tester.tap(find.text('Style')); + await tester.pumpAndSettle(); + + // Find all CheckboxListTiles + final checkboxes = find.byType(CheckboxListTile); + expect(checkboxes, findsNWidgets(4)); + + // Verify locale-aware alphabetical order: + // Cafe, Café, Rose, Rosé + final firstCheckbox = tester.widget(checkboxes.at(0)); + 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)'); + expect((fourthCheckbox.title as Text).data, 'Rosé (1)'); + + // Verify the accented characters display correctly (not garbled) + expect((secondCheckbox.title as Text).data?.contains('é'), true, + reason: 'Café should display the é character correctly'); + expect((fourthCheckbox.title as Text).data?.contains('é'), true, + reason: 'Rosé should display the é character correctly'); + + accentProvider.dispose(); + }); }); } diff --git a/test/string_comparison_helper_test.dart b/test/string_comparison_helper_test.dart new file mode 100644 index 00000000..2321be3c --- /dev/null +++ b/test/string_comparison_helper_test.dart @@ -0,0 +1,169 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:cambridge_beer_festival/utils/utils.dart'; + +void main() { + group('StringComparisonHelper', () { + test('sorts case-insensitively', () { + final unsorted = ['ipa', 'IPA', 'bitter', 'BITTER', 'Stout', 'STOUT']; + final sorted = List.from(unsorted); + sorted.sort(StringComparisonHelper.compareLocaleAware); + + // All case variations of the same word should be grouped together + expect(sorted[0].toLowerCase(), 'bitter'); + expect(sorted[1].toLowerCase(), 'bitter'); + expect(sorted[2].toLowerCase(), 'ipa'); + expect(sorted[3].toLowerCase(), 'ipa'); + expect(sorted[4].toLowerCase(), 'stout'); + expect(sorted[5].toLowerCase(), 'stout'); + }); + + test('sorts accented characters after their base characters', () { + // With case-insensitive comparison, accented versions should come + // after their non-accented counterparts in most cases + final unsorted = ['Rosé', 'Rose', 'Café', 'Cafe']; + final sorted = List.from(unsorted); + sorted.sort(StringComparisonHelper.compareLocaleAware); + + // Verify Cafe comes before Café, and Rose comes before Rosé + final cafeIndex = sorted.indexWhere((s) => s == 'Cafe'); + final cafeAccentIndex = sorted.indexWhere((s) => s == 'Café'); + expect(cafeIndex, lessThan(cafeAccentIndex), + 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é'); + }); + + test('maintains consistent alphabetical ordering', () { + final unsorted = ['Rosé', 'Rose', 'IPA', 'Bitter', 'Café', 'Cafe', 'Pilsner', 'Stout']; + final sorted = List.from(unsorted); + sorted.sort(StringComparisonHelper.compareLocaleAware); + + // Verify basic alphabetical order (B < C < I < P < R < S) + final bIndex = sorted.indexWhere((s) => s.toLowerCase().startsWith('b')); + final cIndex = sorted.indexWhere((s) => s.toLowerCase().startsWith('c')); + final iIndex = sorted.indexWhere((s) => s.toLowerCase().startsWith('i')); + final pIndex = sorted.indexWhere((s) => s.toLowerCase().startsWith('p')); + final rIndex = sorted.indexWhere((s) => s.toLowerCase().startsWith('r')); + final sIndex = sorted.indexWhere((s) => s.toLowerCase().startsWith('s')); + + expect(bIndex, lessThan(cIndex)); + expect(cIndex, lessThan(iIndex)); + expect(iIndex, lessThan(pIndex)); + expect(pIndex, lessThan(rIndex)); + expect(rIndex, lessThan(sIndex)); + }); + + test('handles various Unicode characters', () { + // Test with various European characters that might appear in beer/wine names + final unsorted = [ + 'Kölsch', // German ö + 'Kolsch', + 'Märzen', // German ä + 'Marzen', + 'Niño', // Spanish ñ + 'Nino', + ]; + final sorted = List.from(unsorted); + sorted.sort(StringComparisonHelper.compareLocaleAware); + + // 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; + + 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); + expect(sorted[2].toLowerCase().startsWith('m'), true); + expect(sorted[3].toLowerCase().startsWith('m'), true); + expect(sorted[4].toLowerCase().startsWith('n'), true); + expect(sorted[5].toLowerCase().startsWith('n'), true); + }); + + test('preserves original strings (no normalization)', () { + // 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(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)); + }); + + test('returns consistent ordering (transitivity)', () { + // Verify transitivity: if a < b and b < c, then a < c + const a = 'Cafe'; + const b = 'Café'; + const c = 'IPA'; + + final ab = StringComparisonHelper.compareLocaleAware(a, b); + final bc = StringComparisonHelper.compareLocaleAware(b, c); + final ac = StringComparisonHelper.compareLocaleAware(a, c); + + if (ab < 0 && bc < 0) { + expect(ac, lessThan(0), reason: 'Transitivity should hold: a < b < c => a < c'); + } + }); + + test('actual beer style names with accents', () { + // Real-world test case with actual beer/wine style names that might have accents + final styles = [ + 'Saison', + 'Märzen', + 'Kölsch', + 'Rosé Cider', + 'Bière de Garde', + 'IPA', + 'Bitter', + 'Porter', + ]; + + final sorted = List.from(styles); + sorted.sort(StringComparisonHelper.compareLocaleAware); + + // Verify it's in a reasonable alphabetical order + // B comes before I, I before K, K before M, etc. + final bIndex = sorted.indexWhere((s) => s.toLowerCase().startsWith('b')); + 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'); + }); + + test('accented characters display correctly (not garbled)', () { + // 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'); + expect(styles.any((s) => s.contains('ä')), true, + 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 é)'); + expect(styles.any((s) => s.contains('ä')), false, + reason: 'Should not contain mojibake ä (garbled ä)'); + }); + }); +} diff --git a/test/utf8_encoding_test.dart b/test/utf8_encoding_test.dart new file mode 100644 index 00000000..616c2641 --- /dev/null +++ b/test/utf8_encoding_test.dart @@ -0,0 +1,228 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:cambridge_beer_festival/services/services.dart'; +import 'package:cambridge_beer_festival/models/models.dart'; + +import 'utf8_encoding_test.mocks.dart'; + +@GenerateMocks([http.Client]) +void main() { + group('BeerApiService UTF-8 Encoding', () { + late MockClient mockClient; + late BeerApiService service; + const testFestival = Festival( + id: 'test2025', + name: 'Test Festival', + dataBaseUrl: 'https://example.com/test2025', + ); + + setUp(() { + mockClient = MockClient(); + service = BeerApiService(client: mockClient); + }); + + tearDown(() { + service.dispose(); + }); + + test('correctly decodes UTF-8 characters like é in Rosé', () async { + // Create a JSON response with UTF-8 characters + final jsonData = { + 'producers': [ + { + 'id': 'cidery1', + 'name': 'Test Cidery', + 'location': 'France', + 'products': [ + { + 'id': 'cider1', + 'name': 'Rosé Cider', + 'abv': 5.5, + 'category': 'cider', + 'dispense': 'keg', + 'style': 'Rosé', + }, + { + 'id': 'cider2', + 'name': 'Café Apple', + 'abv': 6.0, + 'category': 'cider', + 'dispense': 'keg', + 'style': 'Café', + }, + ], + }, + ], + }; + + // Encode as UTF-8 bytes (simulating real API response) + final utf8Bytes = utf8.encode(json.encode(jsonData)); + + // Mock the HTTP response + when(mockClient.get(any)).thenAnswer((_) async { + return http.Response.bytes( + utf8Bytes, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + // Fetch the drinks + final drinks = await service.fetchDrinks(testFestival, 'cider'); + + // Verify we got 2 drinks + expect(drinks.length, 2); + + // 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'); + expect(roseDrink.product.style, 'Rosé', + 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é'); + expect(roseDrink.product.style, isNot('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'); + expect(cafeDrink.product.style, 'Café', + 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é'); + expect(cafeDrink.product.style, isNot('Café'), + reason: 'Should not be garbled as Café'); + }); + + test('handles various European accented characters correctly', () async { + // Test with German, Spanish, and French characters + final jsonData = { + 'producers': [ + { + 'id': 'brewery1', + 'name': 'Test Brewery', + 'location': 'Germany', + 'products': [ + { + 'id': 'beer1', + 'name': 'Kölsch Beer', + 'abv': 4.8, + 'category': 'beer', + 'dispense': 'keg', + 'style': 'Kölsch', + }, + { + 'id': 'beer2', + 'name': 'Märzen Lager', + 'abv': 5.5, + 'category': 'beer', + 'dispense': 'keg', + 'style': 'Märzen', + }, + { + 'id': 'beer3', + 'name': 'Niño Porter', + 'abv': 5.0, + 'category': 'beer', + 'dispense': 'cask', + 'style': 'Porter', + }, + ], + }, + ], + }; + + final utf8Bytes = utf8.encode(json.encode(jsonData)); + + when(mockClient.get(any)).thenAnswer((_) async { + return http.Response.bytes( + utf8Bytes, + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final drinks = await service.fetchDrinks(testFestival, 'beer'); + + expect(drinks.length, 3); + + // Verify German ö character + final kolsch = drinks.firstWhere((d) => d.product.id == 'beer1'); + 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'); + + // 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'); + + // 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'); + }); + + test('handles response without explicit charset in Content-Type', () async { + // Many APIs don't specify charset=utf-8 in Content-Type header + // Our fix should handle this correctly by using bodyBytes + final jsonData = { + 'producers': [ + { + 'id': 'producer1', + 'name': 'Café Producer', + 'location': 'France', + 'products': [ + { + 'id': 'product1', + 'name': 'Rosé Wine', + 'abv': 12.5, + 'category': 'wine', + 'dispense': 'bottle', + 'style': 'Rosé', + }, + ], + }, + ], + }; + + final utf8Bytes = utf8.encode(json.encode(jsonData)); + + // Return response WITHOUT charset in Content-Type + // This is the problematic case that causes mojibake with response.body + when(mockClient.get(any)).thenAnswer((_) async { + return http.Response.bytes( + utf8Bytes, + 200, + headers: {'content-type': 'application/json'}, // No charset=utf-8 + ); + }); + + 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é')); + }); + }); +}