glp-companion/test/features/meal_planner/recipe_scraper_test.dart

303 lines
9.7 KiB
Dart

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 = '''
<!DOCTYPE html>
<html>
<head>
<title>High-Protein Chicken Bowl - Example Kitchen</title>
<meta property="og:title" content="OG Fallback Title" />
<meta property="og:image" content="https://example.com/og.jpg" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{ "@type": "WebSite", "name": "Example Kitchen" },
{
"@type": "Recipe",
"name": "High-Protein Chicken Bowl",
"description": "A quick post-workout bowl.",
"image": [
"https://example.com/chicken-1.jpg",
{ "@type": "ImageObject", "url": "https://example.com/chicken-2.jpg" }
],
"prepTime": "PT15M",
"cookTime": "PT35M",
"recipeYield": "4 servings",
"recipeIngredient": [
"2 chicken breasts",
"1 1/2 cups cooked rice",
"1/4 tsp salt",
"2 tbsp olive oil"
],
"recipeInstructions": [
"Season the chicken.",
{ "@type": "HowToStep", "text": "Grill for 6 minutes per side." },
{
"@type": "HowToSection",
"name": "Finish",
"itemListElement": [
{ "@type": "HowToStep", "text": "Rest, then slice and serve." }
]
}
],
"nutrition": {
"@type": "NutritionInformation",
"calories": "420 kcal",
"proteinContent": "38 g",
"carbohydrateContent": "45 g",
"fatContent": "12 g"
}
}
]
}
</script>
</head>
<body></body>
</html>
''';
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 = '''
<html><head>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Recipe",
"name": "Root Recipe",
"recipeIngredient": ["1 cup oats"],
"recipeInstructions": "Mix and eat.",
"nutrition": { "calories": 300 }
}
</script>
</head><body></body></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 = '''
<html><head>
<script type="application/ld+json">
[
{ "@type": "WebSite", "name": "Other" },
{
"@type": ["Recipe", "CreativeWork"],
"name": "Array Recipe",
"recipeIngredient": []
}
]
</script>
</head><body></body></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 = '''
<html><head>
<script type="application/ld+json">{ not valid json</script>
<meta property="og:title" content="OG Rescued Title" />
<meta property="og:description" content="A description." />
<meta property="og:image" content="https://x.test/img.png" />
</head><body></body></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 = '''
<html><head>
<meta property="og:title" content="Simple OG Soup" />
<meta property="og:image" content="https://soup.test/soup.jpg" />
<meta property="og:description" content="Warm and cozy." />
</head><body></body></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 = '<html><head><title>Just a page</title></head>'
'<body><p>hello</p></body></html>';
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<RecipeScrapeException>()),
);
await expectLater(
scraping('not a url'),
throwsA(isA<RecipeScrapeException>()),
);
});
test('throws when no recipe metadata is present', () async {
final scraping = ScrapeRecipeFromUrl(
fetcher: (_) async =>
'<html><head><title>Nothing here</title></head></html>',
);
await expectLater(
scraping('https://example.com/blank'),
throwsA(
isA<RecipeScrapeException>().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<RecipeScrapeException>()),
);
});
});
}