386 lines
13 KiB
Dart
386 lines
13 KiB
Dart
import 'dart:io';
|
|
|
|
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>()),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('Real-world sample fixtures', () {
|
|
/// Loads a captured page shape from `test/fixtures/recipes/`.
|
|
String fixture(String name) =>
|
|
File('test/fixtures/recipes/$name').readAsStringSync();
|
|
|
|
test('parses a BBC Good Food-style root-level Recipe payload', () {
|
|
final recipe = useCase.parseHtml(
|
|
fixture('bbc_good_food_style.html'),
|
|
sourceUrl: 'https://www.bbcgoodfood.com/recipes/classic-chicken-curry',
|
|
);
|
|
|
|
expect(recipe, isNotNull);
|
|
expect(recipe!.title, 'Classic chicken curry');
|
|
expect(recipe.description, startsWith('A simple, healthy curry'));
|
|
expect(
|
|
recipe.imageUrl,
|
|
endsWith('styles/hero/public/curry.jpg'),
|
|
);
|
|
expect(recipe.prepTimeMinutes, 15);
|
|
expect(recipe.cookTimeMinutes, 35);
|
|
expect(recipe.servings, 4);
|
|
|
|
// Published nutrition strings carry units; numeric parsing strips them.
|
|
expect(recipe.macrosPerServing.calories, 387);
|
|
expect(recipe.macrosPerServing.proteinGrams, 34);
|
|
expect(recipe.macrosPerServing.carbsGrams, 20);
|
|
expect(recipe.macrosPerServing.fatGrams, 14);
|
|
|
|
expect(recipe.ingredients, hasLength(5));
|
|
final curryPaste = recipe.ingredients[2];
|
|
expect(curryPaste.quantity, 3);
|
|
expect(curryPaste.unit, 'tbsp');
|
|
expect(curryPaste.parsedItemName, 'curry paste');
|
|
final yogurt = recipe.ingredients[3];
|
|
expect(yogurt.quantity, 100);
|
|
expect(yogurt.unit, 'g');
|
|
expect(yogurt.parsedItemName, 'yogurt');
|
|
|
|
expect(recipe.instructions, hasLength(3));
|
|
expect(recipe.instructions.first, 'Fry the onion until golden.');
|
|
});
|
|
|
|
test('skips non-recipe @graph scripts and parses the complex one', () {
|
|
final recipe = useCase.parseHtml(
|
|
fixture('allrecipes_style.html'),
|
|
sourceUrl: 'https://example.eats/recipes/turkey-meatballs',
|
|
);
|
|
|
|
expect(recipe, isNotNull);
|
|
expect(recipe!.title, 'Weeknight Turkey Meatballs');
|
|
// List-valued yield: ["6 servings"].
|
|
expect(recipe.servings, 6);
|
|
expect(recipe.prepTimeMinutes, 20);
|
|
expect(recipe.cookTimeMinutes, 25);
|
|
// First entry of the mixed string/object image array wins.
|
|
expect(
|
|
recipe.imageUrl,
|
|
'https://example.eats/img/turkey-meatballs-1000.jpg',
|
|
);
|
|
|
|
expect(recipe.macrosPerServing.calories, 321);
|
|
expect(recipe.macrosPerServing.proteinGrams, 29);
|
|
|
|
expect(recipe.ingredients, hasLength(5));
|
|
// Decimal metric weight in imperial clothing: "1.25 lb ground turkey".
|
|
expect(recipe.ingredients[0].quantity, 1.25);
|
|
expect(recipe.ingredients[0].unit, 'lb');
|
|
expect(recipe.ingredients[0].parsedItemName, 'ground turkey');
|
|
expect(recipe.ingredients[1].quantity, 0.5);
|
|
expect(recipe.ingredients[1].unit, 'cup');
|
|
|
|
// Nested HowToSections flatten into plain steps in document order.
|
|
expect(recipe.instructions, [
|
|
'Heat the oven to 400°F and line a sheet pan.',
|
|
'Mix the turkey, breadcrumbs, and egg; roll into 18 balls.',
|
|
'Bake for 18 minutes until browned.',
|
|
'Simmer the garlic and tomatoes for 10 minutes.',
|
|
]);
|
|
});
|
|
});
|
|
}
|