import 'package:flutter_test/flutter_test.dart'; import 'package:glp1_companion/features/meal_planner/domain/usecases/scrape_recipe_from_url.dart'; /// Rich schema.org/JSON-LD fixture: `@graph` container, mixed /// `recipeInstructions` shapes (string, `HowToStep`, nested `HowToSection`), /// ISO-8601 durations, list-valued `image`, and string-quantity nutrition. const String _jsonLdHtml = ''' High-Protein Chicken Bowl - Example Kitchen '''; void main() { late ScrapeRecipeFromUrl useCase; setUp(() => useCase = ScrapeRecipeFromUrl()); group('JSON-LD recipe extraction', () { test('parses a full @graph-hosted schema.org Recipe', () { final recipe = useCase.parseHtml( _jsonLdHtml, sourceUrl: 'https://example.com/chicken-bowl', ); expect(recipe, isNotNull); expect(recipe!.id, isNotEmpty); expect(recipe.title, 'High-Protein Chicken Bowl'); expect(recipe.sourceUrl, 'https://example.com/chicken-bowl'); expect(recipe.description, 'A quick post-workout bowl.'); expect(recipe.imageUrl, 'https://example.com/chicken-1.jpg'); expect(recipe.prepTimeMinutes, 15); expect(recipe.cookTimeMinutes, 35); expect(recipe.servings, 4); }); test('extracts macros from the nutrition block', () { final recipe = useCase.parseHtml( _jsonLdHtml, sourceUrl: 'https://example.com/chicken-bowl', )!; expect(recipe.macrosPerServing.calories, 420); expect(recipe.macrosPerServing.proteinGrams, 38); expect(recipe.macrosPerServing.carbsGrams, 45); expect(recipe.macrosPerServing.fatGrams, 12); }); test('parses ingredient quantities, units, and item names', () { final recipe = useCase.parseHtml( _jsonLdHtml, sourceUrl: 'https://example.com/chicken-bowl', )!; expect(recipe.ingredients, hasLength(4)); final chicken = recipe.ingredients[0]; expect(chicken.rawText, '2 chicken breasts'); expect(chicken.quantity, 2); expect(chicken.unit, isNull); // "breasts" is not a known unit expect(chicken.parsedItemName, 'chicken breasts'); final rice = recipe.ingredients[1]; expect(rice.quantity, 1.5); // mixed number "1 1/2" expect(rice.unit, 'cups'); expect(rice.parsedItemName, 'cooked rice'); final salt = recipe.ingredients[2]; expect(salt.quantity, 0.25); expect(salt.unit, 'tsp'); expect(salt.parsedItemName, 'salt'); final oil = recipe.ingredients[3]; expect(oil.quantity, 2); expect(oil.unit, 'tbsp'); expect(oil.parsedItemName, 'olive oil'); }); test('flattens strings, HowToStep objects, and HowToSections', () { final recipe = useCase.parseHtml( _jsonLdHtml, sourceUrl: 'https://example.com/chicken-bowl', )!; expect(recipe.instructions, [ 'Season the chicken.', 'Grill for 6 minutes per side.', 'Rest, then slice and serve.', ]); }); }); group('JSON-LD navigation variants', () { test('finds a root-level Recipe object without @graph', () { const html = ''' '''; final recipe = useCase.parseHtml(html, sourceUrl: 'https://x.test/a'); expect(recipe, isNotNull); expect(recipe!.title, 'Root Recipe'); expect(recipe.macrosPerServing.calories, 300); expect(recipe.instructions, ['Mix and eat.']); // No recipeYield → default servings. expect(recipe.servings, useCase.defaultServings); }); test('finds a Recipe inside a top-level JSON array with list @type', () { const html = ''' '''; final recipe = useCase.parseHtml(html, sourceUrl: 'https://x.test/b'); expect(recipe, isNotNull); expect(recipe!.title, 'Array Recipe'); }); test('skips malformed JSON-LD and falls through to OpenGraph', () { const html = ''' '''; final recipe = useCase.parseHtml(html, sourceUrl: 'https://x.test/c'); expect(recipe, isNotNull); expect(recipe!.title, 'OG Rescued Title'); expect(recipe.description, 'A description.'); expect(recipe.imageUrl, 'https://x.test/img.png'); expect(recipe.ingredients, isEmpty); expect(recipe.macrosPerServing.isEmpty, isTrue); }); }); group('OpenGraph fallback', () { test('uses og tags when JSON-LD is absent', () { const html = ''' '''; final recipe = useCase.parseHtml(html, sourceUrl: 'https://soup.test/p'); expect(recipe, isNotNull); expect(recipe!.title, 'Simple OG Soup'); expect(recipe.imageUrl, 'https://soup.test/soup.jpg'); expect(recipe.description, 'Warm and cozy.'); expect(recipe.ingredients, isEmpty); expect(recipe.instructions, isEmpty); expect(recipe.macrosPerServing.isEmpty, isTrue); expect(recipe.prepTimeMinutes, 0); expect(recipe.cookTimeMinutes, 0); }); test('returns null when neither JSON-LD nor og:title exist', () { const html = 'Just a page' '

hello

'; expect( useCase.parseHtml(html, sourceUrl: 'https://none.test/'), isNull, ); }); }); group('execute()', () { test('downloads via the injected fetcher and parses', () async { var requestedUrl = Uri.parse('about:blank'); final scraping = ScrapeRecipeFromUrl( fetcher: (url) async { requestedUrl = url; return _jsonLdHtml; }, ); final recipe = await scraping('https://example.com/chicken-bowl'); expect(requestedUrl.toString(), 'https://example.com/chicken-bowl'); expect(recipe.title, 'High-Protein Chicken Bowl'); expect(recipe.macrosPerServing.proteinGrams, 38); }); test('rejects non-http(s) and malformed URLs', () async { final scraping = ScrapeRecipeFromUrl( fetcher: (_) async => _jsonLdHtml, ); await expectLater( scraping('ftp://example.com/file'), throwsA(isA()), ); await expectLater( scraping('not a url'), throwsA(isA()), ); }); test('throws when no recipe metadata is present', () async { final scraping = ScrapeRecipeFromUrl( fetcher: (_) async => 'Nothing here', ); await expectLater( scraping('https://example.com/blank'), throwsA( isA().having( (e) => e.message, 'message', contains('No schema.org Recipe'), ), ), ); }); test('propagates fetcher failures as RecipeScrapeException', () async { final scraping = ScrapeRecipeFromUrl( fetcher: (_) async => throw Exception('connection reset'), ); await expectLater( scraping('https://example.com/flaky'), throwsA(isA()), ); }); }); }