diff --git a/alembic/versions/2024-10-01-14.17.00_602927e1013e_add_the_rest_of_the_schema_org_.py b/alembic/versions/2024-10-01-14.17.00_602927e1013e_add_the_rest_of_the_schema_org_.py new file mode 100644 index 000000000..dbeea910f --- /dev/null +++ b/alembic/versions/2024-10-01-14.17.00_602927e1013e_add_the_rest_of_the_schema_org_.py @@ -0,0 +1,39 @@ +"""'add the rest of the schema.org nutrition properties' + +Revision ID: 602927e1013e +Revises: 1fe4bd37ccc8 +Create Date: 2024-10-01 14:17:00.611398 + +""" + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision = "602927e1013e" +down_revision: str | None = "1fe4bd37ccc8" +branch_labels: str | tuple[str, ...] | None = None +depends_on: str | tuple[str, ...] | None = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("recipe_nutrition", schema=None) as batch_op: + batch_op.add_column(sa.Column("cholesterol_content", sa.String(), nullable=True)) + batch_op.add_column(sa.Column("saturated_fat_content", sa.String(), nullable=True)) + batch_op.add_column(sa.Column("trans_fat_content", sa.String(), nullable=True)) + batch_op.add_column(sa.Column("unsaturated_fat_content", sa.String(), nullable=True)) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("recipe_nutrition", schema=None) as batch_op: + batch_op.drop_column("unsaturated_fat_content") + batch_op.drop_column("trans_fat_content") + batch_op.drop_column("saturated_fat_content") + batch_op.drop_column("cholesterol_content") + + # ### end Alembic commands ### diff --git a/frontend/composables/recipes/use-recipe-nutrition.ts b/frontend/composables/recipes/use-recipe-nutrition.ts index a1e53b750..e249d5d61 100644 --- a/frontend/composables/recipes/use-recipe-nutrition.ts +++ b/frontend/composables/recipes/use-recipe-nutrition.ts @@ -17,6 +17,14 @@ export function useNutritionLabels() { label: i18n.tc("recipe.calories"), suffix: i18n.tc("recipe.calories-suffix"), }, + carbohydrateContent: { + label: i18n.tc("recipe.carbohydrate-content"), + suffix: i18n.tc("recipe.grams"), + }, + cholesterolContent: { + label: i18n.tc("recipe.cholesterol-content"), + suffix: i18n.tc("recipe.milligrams"), + }, fatContent: { label: i18n.tc("recipe.fat-content"), suffix: i18n.tc("recipe.grams"), @@ -29,6 +37,10 @@ export function useNutritionLabels() { label: i18n.tc("recipe.protein-content"), suffix: i18n.tc("recipe.grams"), }, + saturatedFatContent: { + label: i18n.tc("recipe.saturated-fat-content"), + suffix: i18n.tc("recipe.grams"), + }, sodiumContent: { label: i18n.tc("recipe.sodium-content"), suffix: i18n.tc("recipe.milligrams"), @@ -37,8 +49,12 @@ export function useNutritionLabels() { label: i18n.tc("recipe.sugar-content"), suffix: i18n.tc("recipe.grams"), }, - carbohydrateContent: { - label: i18n.tc("recipe.carbohydrate-content"), + transFatContent: { + label: i18n.tc("recipe.trans-fat-content"), + suffix: i18n.tc("recipe.grams"), + }, + unsaturatedFatContent: { + label: i18n.tc("recipe.unsaturated-fat-content"), suffix: i18n.tc("recipe.grams"), }, }; diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index c53e551c5..a58580d8b 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -461,6 +461,7 @@ "calories-suffix": "calories", "carbohydrate-content": "Carbohydrate", "categories": "Categories", + "cholesterol-content": "Cholesterol", "comment-action": "Comment", "comment": "Comment", "comments": "Comments", @@ -507,6 +508,7 @@ "recipe-updated": "Recipe updated", "remove-from-favorites": "Remove from Favorites", "remove-section": "Remove Section", + "saturated-fat-content": "Saturated fat", "save-recipe-before-use": "Save recipe before use", "section-title": "Section Title", "servings": "Servings", @@ -517,7 +519,9 @@ "sugar-content": "Sugar", "title": "Title", "total-time": "Total Time", + "trans-fat-content": "Trans-fat", "unable-to-delete-recipe": "Unable to Delete Recipe", + "unsaturated-fat-content": "Unsaturated fat", "no-recipe": "No Recipe", "locked-by-owner": "Locked by Owner", "join-the-conversation": "Join the Conversation", diff --git a/frontend/lib/api/types/recipe.ts b/frontend/lib/api/types/recipe.ts index 3125b6bce..8973fd70f 100644 --- a/frontend/lib/api/types/recipe.ts +++ b/frontend/lib/api/types/recipe.ts @@ -194,12 +194,16 @@ export interface MergeUnit { } export interface Nutrition { calories?: string | null; - fatContent?: string | null; - proteinContent?: string | null; carbohydrateContent?: string | null; + cholesterolContent?: string | null; + fatContent?: string | null; fiberContent?: string | null; + proteinContent?: string | null; + saturatedFatContent?: string | null; sodiumContent?: string | null; sugarContent?: string | null; + transFatContent?: string | null; + unsaturatedFatContent?: string | null; } export interface ParsedIngredient { input?: string | null; @@ -486,7 +490,7 @@ export interface ScrapeRecipeTest { url: string; useOpenAI?: boolean; } -export interface SlugResponse {} +export interface SlugResponse { } export interface TagIn { name: string; } diff --git a/mealie/db/models/recipe/nutrition.py b/mealie/db/models/recipe/nutrition.py index 1202a4a70..619748389 100644 --- a/mealie/db/models/recipe/nutrition.py +++ b/mealie/db/models/recipe/nutrition.py @@ -9,28 +9,52 @@ class Nutrition(SqlAlchemyBase): __tablename__ = "recipe_nutrition" id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True) + calories: Mapped[str | None] = mapped_column(sa.String) + carbohydrate_content: Mapped[str | None] = mapped_column(sa.String) + cholesterol_content: Mapped[str | None] = mapped_column(sa.String) fat_content: Mapped[str | None] = mapped_column(sa.String) fiber_content: Mapped[str | None] = mapped_column(sa.String) protein_content: Mapped[str | None] = mapped_column(sa.String) - carbohydrate_content: Mapped[str | None] = mapped_column(sa.String) + saturated_fat_content: Mapped[str | None] = mapped_column(sa.String) + + # `serving_size` is not a scaling factor, but a per-serving volume or mass + # according to schema.org. E.g., "2 L", "500 g", "5 cups", etc. + # + # Ignoring for now because it's too difficult to work around variable units + # in translation for the frontend. Also, it causes cognitive dissonance wrt + # "servings" (i.e., "serves 2" etc.), which is an unrelated concept that + # might cause confusion. + # + # serving_size: Mapped[str | None] = mapped_column(sa.String) + sodium_content: Mapped[str | None] = mapped_column(sa.String) sugar_content: Mapped[str | None] = mapped_column(sa.String) + trans_fat_content: Mapped[str | None] = mapped_column(sa.String) + unsaturated_fat_content: Mapped[str | None] = mapped_column(sa.String) def __init__( self, calories=None, + carbohydrate_content=None, + cholesterol_content=None, fat_content=None, fiber_content=None, protein_content=None, + saturated_fat_content=None, sodium_content=None, sugar_content=None, - carbohydrate_content=None, + trans_fat_content=None, + unsaturated_fat_content=None, ) -> None: self.calories = calories + self.carbohydrate_content = carbohydrate_content + self.cholesterol_content = cholesterol_content self.fat_content = fat_content self.fiber_content = fiber_content self.protein_content = protein_content + self.saturated_fat_content = saturated_fat_content self.sodium_content = sodium_content self.sugar_content = sugar_content - self.carbohydrate_content = carbohydrate_content + self.trans_fat_content = trans_fat_content + self.unsaturated_fat_content = unsaturated_fat_content diff --git a/mealie/db/models/recipe/recipe.py b/mealie/db/models/recipe/recipe.py index 9266ec899..fc9c142bb 100644 --- a/mealie/db/models/recipe/recipe.py +++ b/mealie/db/models/recipe/recipe.py @@ -187,7 +187,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): settings: dict | None = None, **_, ) -> None: - self.nutrition = Nutrition(**nutrition) if nutrition else Nutrition() + self.nutrition = Nutrition(**(nutrition or {})) if recipe_instructions is not None: self.recipe_instructions = [RecipeInstruction(**step, session=session) for step in recipe_instructions] @@ -198,7 +198,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins): if assets: self.assets = [RecipeAsset(**a) for a in assets] - self.settings = RecipeSettings(**settings) if settings else RecipeSettings() + self.settings = RecipeSettings(**(settings or {})) if notes: self.notes = [Note(**n) for n in notes] diff --git a/mealie/routes/spa/__init__.py b/mealie/routes/spa/__init__.py index 77af4747d..d4c21e4c0 100644 --- a/mealie/routes/spa/__init__.py +++ b/mealie/routes/spa/__init__.py @@ -104,15 +104,7 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str: ingredients.append(s) - nutrition: dict[str, str | None] = {} - if recipe.nutrition: - nutrition["calories"] = recipe.nutrition.calories - nutrition["fatContent"] = recipe.nutrition.fat_content - nutrition["fiberContent"] = recipe.nutrition.fiber_content - nutrition["proteinContent"] = recipe.nutrition.protein_content - nutrition["carbohydrateContent"] = recipe.nutrition.carbohydrate_content - nutrition["sodiumContent"] = recipe.nutrition.sodium_content - nutrition["sugarContent"] = recipe.nutrition.sugar_content + nutrition: dict[str, str | None] = recipe.nutrition.model_dump(by_alias=True) if recipe.nutrition else {} as_schema_org = { "@context": "https://schema.org", diff --git a/mealie/schema/recipe/recipe_nutrition.py b/mealie/schema/recipe/recipe_nutrition.py index 167b30285..920dfca77 100644 --- a/mealie/schema/recipe/recipe_nutrition.py +++ b/mealie/schema/recipe/recipe_nutrition.py @@ -1,14 +1,24 @@ from pydantic import ConfigDict +from pydantic.alias_generators import to_camel from mealie.schema._mealie import MealieModel class Nutrition(MealieModel): calories: str | None = None - fat_content: str | None = None - protein_content: str | None = None carbohydrate_content: str | None = None + cholesterol_content: str | None = None + fat_content: str | None = None fiber_content: str | None = None + protein_content: str | None = None + saturated_fat_content: str | None = None sodium_content: str | None = None sugar_content: str | None = None - model_config = ConfigDict(from_attributes=True, coerce_numbers_to_str=True) + trans_fat_content: str | None = None + unsaturated_fat_content: str | None = None + + model_config = ConfigDict( + from_attributes=True, + coerce_numbers_to_str=True, + alias_generator=to_camel, + ) diff --git a/mealie/services/migrations/myrecipebox.py b/mealie/services/migrations/myrecipebox.py index 3003cc259..0211768a2 100644 --- a/mealie/services/migrations/myrecipebox.py +++ b/mealie/services/migrations/myrecipebox.py @@ -12,6 +12,18 @@ from mealie.services.scraper import cleaner from ._migration_base import BaseMigrator from .utils.migration_helpers import scrape_image, split_by_line_break, split_by_semicolon +nutrition_map = { + "carbohydrate": "carbohydrateContent", + "protein": "proteinContent", + "fat": "fatContent", + "saturatedfat": "saturatedFatContent", + "transfat": "transFatContent", + "sodium": "sodiumContent", + "fiber": "fiberContent", + "sugar": "sugarContent", + "unsaturatedfat": "unsaturatedFatContent", +} + class MyRecipeBoxMigrator(BaseMigrator): def __init__(self, **kwargs): @@ -53,22 +65,26 @@ class MyRecipeBoxMigrator(BaseMigrator): except Exception: return None - def parse_nutrition(self, input: Any) -> dict | None: - if not input or not isinstance(input, str): + def parse_nutrition(self, input_: Any) -> dict | None: + if not input_ or not isinstance(input_, str): return None nutrition = {} - vals = [x.strip() for x in input.split(",") if x] + vals = (x.strip() for x in input_.split("\n") if x) for val in vals: try: - key, value = val.split(":", maxsplit=1) + key, value = (x.strip() for x in val.split(":", maxsplit=1)) + if not (key and value): continue + + key = nutrition_map.get(key.lower(), key) + except ValueError: continue - nutrition[key.strip()] = value.strip() + nutrition[key] = value return cleaner.clean_nutrition(nutrition) if nutrition else None diff --git a/mealie/services/migrations/plantoeat.py b/mealie/services/migrations/plantoeat.py index 7dc1efc76..5b2771645 100644 --- a/mealie/services/migrations/plantoeat.py +++ b/mealie/services/migrations/plantoeat.py @@ -37,6 +37,19 @@ def get_value_as_string_or_none(dictionary: dict, key: str): return None +nutrition_map = { + "Calories": "calories", + "Fat": "fatContent", + "Saturated Fat": "saturatedFatContent", + "Cholesterol": "cholesterolContent", + "Sodium": "sodiumContent", + "Sugar": "sugarContent", + "Carbohydrate": "carbohydrateContent", + "Fiber": "fiberContent", + "Protein": "proteinContent", +} + + class PlanToEatMigrator(BaseMigrator): def __init__(self, **kwargs): super().__init__(**kwargs) @@ -63,16 +76,7 @@ class PlanToEatMigrator(BaseMigrator): def _parse_recipe_nutrition_from_row(self, row: dict) -> dict: """Parses the nutrition data from the row""" - - nut_dict: dict = {} - - nut_dict["calories"] = get_value_as_string_or_none(row, "Calories") - nut_dict["fatContent"] = get_value_as_string_or_none(row, "Fat") - nut_dict["proteinContent"] = get_value_as_string_or_none(row, "Protein") - nut_dict["carbohydrateContent"] = get_value_as_string_or_none(row, "Carbohydrate") - nut_dict["fiberContent"] = get_value_as_string_or_none(row, "Fiber") - nut_dict["sodiumContent"] = get_value_as_string_or_none(row, "Sodium") - nut_dict["sugarContent"] = get_value_as_string_or_none(row, "Sugar") + nut_dict = {normalized_k: row[k] for k, normalized_k in nutrition_map.items() if k in row} return cleaner.clean_nutrition(nut_dict) diff --git a/mealie/services/scraper/cleaner.py b/mealie/services/scraper/cleaner.py index add1d060b..d685c54d8 100644 --- a/mealie/services/scraper/cleaner.py +++ b/mealie/services/scraper/cleaner.py @@ -495,7 +495,7 @@ def clean_nutrition(nutrition: dict | None) -> dict[str, str]: list of valid keys Assumptionas: - - All units are supplied in grams, expect sodium which maybe be in milligrams + - All units are supplied in grams, expect sodium and cholesterol which maybe be in milligrams Returns: dict[str, str]: If the argument is None, or not a dictionary, an empty dictionary is returned @@ -509,9 +509,10 @@ def clean_nutrition(nutrition: dict | None) -> dict[str, str]: if matched_digits := MATCH_DIGITS.search(val): output_nutrition[key] = matched_digits.group(0).replace(",", ".") - if sodium := nutrition.get("sodiumContent", None): - if isinstance(sodium, str) and "m" not in sodium and "g" in sodium: - with contextlib.suppress(AttributeError, TypeError): - output_nutrition["sodiumContent"] = str(float(output_nutrition["sodiumContent"]) * 1000) + for key in ["sodiumContent", "cholesterolContent"]: + if val := nutrition.get(key, None): + if isinstance(val, str) and "m" not in val and "g" in val: + with contextlib.suppress(AttributeError, TypeError): + output_nutrition[key] = str(float(output_nutrition[key]) * 1000) return output_nutrition diff --git a/tests/fixtures/fixture_users.py b/tests/fixtures/fixture_users.py index 927a3a790..86cae118e 100644 --- a/tests/fixtures/fixture_users.py +++ b/tests/fixtures/fixture_users.py @@ -173,8 +173,7 @@ def g2_user(session: Session, admin_token, api_client: TestClient): pass -@fixture(scope="module") -def unique_user(session: Session, api_client: TestClient): +def _unique_user(session: Session, api_client: TestClient): registration = utils.user_registration_factory() response = api_client.post("/api/users/register", json=registration.model_dump(by_alias=True)) assert response.status_code == 201 @@ -213,6 +212,16 @@ def unique_user(session: Session, api_client: TestClient): pass +@fixture(scope="function") +def unique_user_fn_scoped(session: Session, api_client: TestClient): + yield from _unique_user(session, api_client) + + +@fixture(scope="module") +def unique_user(session: Session, api_client: TestClient): + yield from _unique_user(session, api_client) + + @fixture(scope="module") def user_tuple(session: Session, admin_token, api_client: TestClient) -> Generator[list[utils.TestUser], None, None]: group_name = utils.random_string() diff --git a/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py b/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py index 4038ecf22..8236f07bf 100644 --- a/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py +++ b/tests/integration_tests/recipe_migration_tests/test_recipe_migrations.py @@ -1,5 +1,5 @@ import os -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from tempfile import TemporaryDirectory from zipfile import ZipFile @@ -8,6 +8,7 @@ import pytest from fastapi.testclient import TestClient from mealie.schema.group.group_migration import SupportedMigrations +from mealie.schema.recipe.recipe import Recipe from mealie.schema.reports.reports import ReportEntryOut from tests import data as test_data from tests.utils import api_routes @@ -19,18 +20,94 @@ from tests.utils.fixture_schemas import TestUser class MigrationTestData: typ: SupportedMigrations archive: Path + search_slug: str + + nutrition_filter: set[str] = field(default_factory=set) + nutrition_entries: set[str] = field( + default_factory=lambda: { + "calories", + "carbohydrateContent", + "cholesterolContent", + "fatContent", + "fiberContent", + "proteinContent", + "saturatedFatContent", + "sodiumContent", + "sugarContent", + "transFatContent", + "unsaturatedFatContent", + } + ) test_cases = [ - MigrationTestData(typ=SupportedMigrations.nextcloud, archive=test_data.migrations_nextcloud), - MigrationTestData(typ=SupportedMigrations.paprika, archive=test_data.migrations_paprika), - MigrationTestData(typ=SupportedMigrations.chowdown, archive=test_data.migrations_chowdown), - MigrationTestData(typ=SupportedMigrations.copymethat, archive=test_data.migrations_copymethat), - MigrationTestData(typ=SupportedMigrations.mealie_alpha, archive=test_data.migrations_mealie), - MigrationTestData(typ=SupportedMigrations.tandoor, archive=test_data.migrations_tandoor), - MigrationTestData(typ=SupportedMigrations.plantoeat, archive=test_data.migrations_plantoeat), - MigrationTestData(typ=SupportedMigrations.myrecipebox, archive=test_data.migrations_myrecipebox), - MigrationTestData(typ=SupportedMigrations.recipekeeper, archive=test_data.migrations_recipekeeper), + MigrationTestData( + typ=SupportedMigrations.nextcloud, + archive=test_data.migrations_nextcloud, + search_slug="skillet-shepherd-s-pie", + nutrition_filter={ + "transFatContent", + "unsaturatedFatContent", + }, + ), + MigrationTestData( + typ=SupportedMigrations.paprika, + archive=test_data.migrations_paprika, + search_slug="zucchini-kartoffel-frittata", + nutrition_entries=set(), + ), + MigrationTestData( + typ=SupportedMigrations.chowdown, + archive=test_data.migrations_chowdown, + search_slug="roasted-okra", + nutrition_entries=set(), + ), + MigrationTestData( + typ=SupportedMigrations.copymethat, + archive=test_data.migrations_copymethat, + search_slug="spam-zoodles", + nutrition_entries=set(), + ), + MigrationTestData( + typ=SupportedMigrations.mealie_alpha, + archive=test_data.migrations_mealie, + search_slug="old-fashioned-beef-stew", + nutrition_filter={ + "cholesterolContent", + "saturatedFatContent", + "transFatContent", + "unsaturatedFatContent", + }, + ), + MigrationTestData( + typ=SupportedMigrations.tandoor, + archive=test_data.migrations_tandoor, + search_slug="texas-red-chili", + nutrition_entries=set(), + ), + MigrationTestData( + typ=SupportedMigrations.plantoeat, + archive=test_data.migrations_plantoeat, + search_slug="test-recipe", + nutrition_filter={ + "unsaturatedFatContent", + "transFatContent", + }, + ), + MigrationTestData( + typ=SupportedMigrations.myrecipebox, + archive=test_data.migrations_myrecipebox, + search_slug="beef-cheese-piroshki", + nutrition_filter={ + "cholesterolContent", + }, + ), + MigrationTestData( + typ=SupportedMigrations.recipekeeper, + archive=test_data.migrations_recipekeeper, + search_slug="zucchini-bread", + nutrition_entries=set(), + ), ] test_ids = [ @@ -47,7 +124,8 @@ test_ids = [ @pytest.mark.parametrize("mig", test_cases, ids=test_ids) -def test_recipe_migration(api_client: TestClient, unique_user: TestUser, mig: MigrationTestData) -> None: +def test_recipe_migration(api_client: TestClient, unique_user_fn_scoped: TestUser, mig: MigrationTestData) -> None: + unique_user = unique_user_fn_scoped payload = { "migration_type": mig.typ.value, } @@ -91,6 +169,19 @@ def test_recipe_migration(api_client: TestClient, unique_user: TestUser, mig: Mi events = query_data["items"] assert len(events) + # Validate recipe content + response = api_client.get(api_routes.recipes_slug(mig.search_slug), headers=unique_user.token) + recipe = Recipe(**assert_deserialize(response)) + + if mig.nutrition_entries: + assert recipe.nutrition is not None + nutrition = recipe.nutrition.model_dump(by_alias=True) + + for k in mig.nutrition_entries.difference(mig.nutrition_filter): + assert k in nutrition and nutrition[k] is not None + + # TODO: validate other types of content + def test_bad_mealie_alpha_data_is_ignored(api_client: TestClient, unique_user: TestUser): with TemporaryDirectory() as tmpdir: diff --git a/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py b/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py index 5fedb073b..1c308ac91 100644 --- a/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py +++ b/tests/unit_tests/services_tests/scraper_tests/test_cleaner_parts.py @@ -481,20 +481,24 @@ nutrition_test_cases = ( }, ), CleanerCase( - test_id="special support for sodiumContent (g -> mg)", + test_id="special support for sodiumContent/cholesterolContent (g -> mg)", input={ + "cholesterolContent": "10g", "sodiumContent": "10g", }, expected={ + "cholesterolContent": "10000.0", "sodiumContent": "10000.0", }, ), CleanerCase( - test_id="special support for sodiumContent (mg -> mg)", + test_id="special support for sodiumContent/cholesterolContent (mg -> mg)", input={ + "cholesterolContent": "10000mg", "sodiumContent": "10000mg", }, expected={ + "cholesterolContent": "10000", "sodiumContent": "10000", }, ), diff --git a/tests/unit_tests/test_recipe_parser.py b/tests/unit_tests/test_recipe_parser.py index 7d98b1323..c26623447 100644 --- a/tests/unit_tests/test_recipe_parser.py +++ b/tests/unit_tests/test_recipe_parser.py @@ -23,6 +23,12 @@ async def test_recipe_parser(recipe_test_data: RecipeSiteTestCase): recipe, _ = await scraper.create_from_html(recipe_test_data.url, translator) assert recipe.slug == recipe_test_data.expected_slug + assert len(recipe.recipe_instructions or []) == recipe_test_data.num_steps + assert len(recipe.recipe_ingredient) == recipe_test_data.num_ingredients + + actual = recipe.nutrition.model_dump() if recipe.nutrition else {} + assert recipe_test_data.num_nutrition_entries == len(actual.items()) + assert recipe.org_url == recipe_test_data.url diff --git a/tests/utils/recipe_data.py b/tests/utils/recipe_data.py index 3b9d6f309..7db0c2f5f 100644 --- a/tests/utils/recipe_data.py +++ b/tests/utils/recipe_data.py @@ -13,6 +13,7 @@ class RecipeSiteTestCase: num_steps: int html_file: Path + num_nutrition_entries: int = 0 include_tags: bool = False expected_tags: set[str] | None = None @@ -26,6 +27,7 @@ def get_recipe_test_cases(): expected_slug="taiwanese-three-cup-chicken-san-bei-ji-recipe", num_ingredients=10, num_steps=3, + num_nutrition_entries=11, ), RecipeSiteTestCase( url="https://www.rezeptwelt.de/backen-herzhaft-rezepte/schinken-kaese-waffeln-ohne-viel-schnickschnack/4j0bkiig-94d4d-106529-cfcd2-is97x2ml",