1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-07-22 22:59:41 +02:00

feat: adding the rest ofthe nutrition properties from schema.org (#4301)

This commit is contained in:
Tom Brennan 2024-10-13 09:04:29 -04:00 committed by GitHub
parent 3aea229f2d
commit 02c0fe993b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 279 additions and 57 deletions

View file

@ -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 ###

View file

@ -17,6 +17,14 @@ export function useNutritionLabels() {
label: i18n.tc("recipe.calories"), label: i18n.tc("recipe.calories"),
suffix: i18n.tc("recipe.calories-suffix"), 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: { fatContent: {
label: i18n.tc("recipe.fat-content"), label: i18n.tc("recipe.fat-content"),
suffix: i18n.tc("recipe.grams"), suffix: i18n.tc("recipe.grams"),
@ -29,6 +37,10 @@ export function useNutritionLabels() {
label: i18n.tc("recipe.protein-content"), label: i18n.tc("recipe.protein-content"),
suffix: i18n.tc("recipe.grams"), suffix: i18n.tc("recipe.grams"),
}, },
saturatedFatContent: {
label: i18n.tc("recipe.saturated-fat-content"),
suffix: i18n.tc("recipe.grams"),
},
sodiumContent: { sodiumContent: {
label: i18n.tc("recipe.sodium-content"), label: i18n.tc("recipe.sodium-content"),
suffix: i18n.tc("recipe.milligrams"), suffix: i18n.tc("recipe.milligrams"),
@ -37,8 +49,12 @@ export function useNutritionLabels() {
label: i18n.tc("recipe.sugar-content"), label: i18n.tc("recipe.sugar-content"),
suffix: i18n.tc("recipe.grams"), suffix: i18n.tc("recipe.grams"),
}, },
carbohydrateContent: { transFatContent: {
label: i18n.tc("recipe.carbohydrate-content"), 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"), suffix: i18n.tc("recipe.grams"),
}, },
}; };

View file

@ -461,6 +461,7 @@
"calories-suffix": "calories", "calories-suffix": "calories",
"carbohydrate-content": "Carbohydrate", "carbohydrate-content": "Carbohydrate",
"categories": "Categories", "categories": "Categories",
"cholesterol-content": "Cholesterol",
"comment-action": "Comment", "comment-action": "Comment",
"comment": "Comment", "comment": "Comment",
"comments": "Comments", "comments": "Comments",
@ -507,6 +508,7 @@
"recipe-updated": "Recipe updated", "recipe-updated": "Recipe updated",
"remove-from-favorites": "Remove from Favorites", "remove-from-favorites": "Remove from Favorites",
"remove-section": "Remove Section", "remove-section": "Remove Section",
"saturated-fat-content": "Saturated fat",
"save-recipe-before-use": "Save recipe before use", "save-recipe-before-use": "Save recipe before use",
"section-title": "Section Title", "section-title": "Section Title",
"servings": "Servings", "servings": "Servings",
@ -517,7 +519,9 @@
"sugar-content": "Sugar", "sugar-content": "Sugar",
"title": "Title", "title": "Title",
"total-time": "Total Time", "total-time": "Total Time",
"trans-fat-content": "Trans-fat",
"unable-to-delete-recipe": "Unable to Delete Recipe", "unable-to-delete-recipe": "Unable to Delete Recipe",
"unsaturated-fat-content": "Unsaturated fat",
"no-recipe": "No Recipe", "no-recipe": "No Recipe",
"locked-by-owner": "Locked by Owner", "locked-by-owner": "Locked by Owner",
"join-the-conversation": "Join the Conversation", "join-the-conversation": "Join the Conversation",

View file

@ -194,12 +194,16 @@ export interface MergeUnit {
} }
export interface Nutrition { export interface Nutrition {
calories?: string | null; calories?: string | null;
fatContent?: string | null;
proteinContent?: string | null;
carbohydrateContent?: string | null; carbohydrateContent?: string | null;
cholesterolContent?: string | null;
fatContent?: string | null;
fiberContent?: string | null; fiberContent?: string | null;
proteinContent?: string | null;
saturatedFatContent?: string | null;
sodiumContent?: string | null; sodiumContent?: string | null;
sugarContent?: string | null; sugarContent?: string | null;
transFatContent?: string | null;
unsaturatedFatContent?: string | null;
} }
export interface ParsedIngredient { export interface ParsedIngredient {
input?: string | null; input?: string | null;

View file

@ -9,28 +9,52 @@ class Nutrition(SqlAlchemyBase):
__tablename__ = "recipe_nutrition" __tablename__ = "recipe_nutrition"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True) id: Mapped[int] = mapped_column(sa.Integer, primary_key=True)
recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True) recipe_id: Mapped[GUID | None] = mapped_column(GUID, sa.ForeignKey("recipes.id"), index=True)
calories: Mapped[str | None] = mapped_column(sa.String) 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) fat_content: Mapped[str | None] = mapped_column(sa.String)
fiber_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) 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) sodium_content: Mapped[str | None] = mapped_column(sa.String)
sugar_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__( def __init__(
self, self,
calories=None, calories=None,
carbohydrate_content=None,
cholesterol_content=None,
fat_content=None, fat_content=None,
fiber_content=None, fiber_content=None,
protein_content=None, protein_content=None,
saturated_fat_content=None,
sodium_content=None, sodium_content=None,
sugar_content=None, sugar_content=None,
carbohydrate_content=None, trans_fat_content=None,
unsaturated_fat_content=None,
) -> None: ) -> None:
self.calories = calories self.calories = calories
self.carbohydrate_content = carbohydrate_content
self.cholesterol_content = cholesterol_content
self.fat_content = fat_content self.fat_content = fat_content
self.fiber_content = fiber_content self.fiber_content = fiber_content
self.protein_content = protein_content self.protein_content = protein_content
self.saturated_fat_content = saturated_fat_content
self.sodium_content = sodium_content self.sodium_content = sodium_content
self.sugar_content = sugar_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

View file

@ -187,7 +187,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
settings: dict | None = None, settings: dict | None = None,
**_, **_,
) -> None: ) -> None:
self.nutrition = Nutrition(**nutrition) if nutrition else Nutrition() self.nutrition = Nutrition(**(nutrition or {}))
if recipe_instructions is not None: if recipe_instructions is not None:
self.recipe_instructions = [RecipeInstruction(**step, session=session) for step in recipe_instructions] self.recipe_instructions = [RecipeInstruction(**step, session=session) for step in recipe_instructions]
@ -198,7 +198,7 @@ class RecipeModel(SqlAlchemyBase, BaseMixins):
if assets: if assets:
self.assets = [RecipeAsset(**a) for a in assets] self.assets = [RecipeAsset(**a) for a in assets]
self.settings = RecipeSettings(**settings) if settings else RecipeSettings() self.settings = RecipeSettings(**(settings or {}))
if notes: if notes:
self.notes = [Note(**n) for n in notes] self.notes = [Note(**n) for n in notes]

View file

@ -104,15 +104,7 @@ def content_with_meta(group_slug: str, recipe: Recipe) -> str:
ingredients.append(s) ingredients.append(s)
nutrition: dict[str, str | None] = {} nutrition: dict[str, str | None] = recipe.nutrition.model_dump(by_alias=True) if recipe.nutrition else {}
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
as_schema_org = { as_schema_org = {
"@context": "https://schema.org", "@context": "https://schema.org",

View file

@ -1,14 +1,24 @@
from pydantic import ConfigDict from pydantic import ConfigDict
from pydantic.alias_generators import to_camel
from mealie.schema._mealie import MealieModel from mealie.schema._mealie import MealieModel
class Nutrition(MealieModel): class Nutrition(MealieModel):
calories: str | None = None calories: str | None = None
fat_content: str | None = None
protein_content: str | None = None
carbohydrate_content: str | None = None carbohydrate_content: str | None = None
cholesterol_content: str | None = None
fat_content: str | None = None
fiber_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 sodium_content: str | None = None
sugar_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,
)

View file

@ -12,6 +12,18 @@ from mealie.services.scraper import cleaner
from ._migration_base import BaseMigrator from ._migration_base import BaseMigrator
from .utils.migration_helpers import scrape_image, split_by_line_break, split_by_semicolon 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): class MyRecipeBoxMigrator(BaseMigrator):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@ -53,22 +65,26 @@ class MyRecipeBoxMigrator(BaseMigrator):
except Exception: except Exception:
return None return None
def parse_nutrition(self, input: Any) -> dict | None: def parse_nutrition(self, input_: Any) -> dict | None:
if not input or not isinstance(input, str): if not input_ or not isinstance(input_, str):
return None return None
nutrition = {} 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: for val in vals:
try: try:
key, value = val.split(":", maxsplit=1) key, value = (x.strip() for x in val.split(":", maxsplit=1))
if not (key and value): if not (key and value):
continue continue
key = nutrition_map.get(key.lower(), key)
except ValueError: except ValueError:
continue continue
nutrition[key.strip()] = value.strip() nutrition[key] = value
return cleaner.clean_nutrition(nutrition) if nutrition else None return cleaner.clean_nutrition(nutrition) if nutrition else None

View file

@ -37,6 +37,19 @@ def get_value_as_string_or_none(dictionary: dict, key: str):
return None 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): class PlanToEatMigrator(BaseMigrator):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
@ -63,16 +76,7 @@ class PlanToEatMigrator(BaseMigrator):
def _parse_recipe_nutrition_from_row(self, row: dict) -> dict: def _parse_recipe_nutrition_from_row(self, row: dict) -> dict:
"""Parses the nutrition data from the row""" """Parses the nutrition data from the row"""
nut_dict = {normalized_k: row[k] for k, normalized_k in nutrition_map.items() if k in 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")
return cleaner.clean_nutrition(nut_dict) return cleaner.clean_nutrition(nut_dict)

View file

@ -495,7 +495,7 @@ def clean_nutrition(nutrition: dict | None) -> dict[str, str]:
list of valid keys list of valid keys
Assumptionas: 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: Returns:
dict[str, str]: If the argument is None, or not a dictionary, an empty dictionary is returned 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): if matched_digits := MATCH_DIGITS.search(val):
output_nutrition[key] = matched_digits.group(0).replace(",", ".") output_nutrition[key] = matched_digits.group(0).replace(",", ".")
if sodium := nutrition.get("sodiumContent", None): for key in ["sodiumContent", "cholesterolContent"]:
if isinstance(sodium, str) and "m" not in sodium and "g" in sodium: if val := nutrition.get(key, None):
if isinstance(val, str) and "m" not in val and "g" in val:
with contextlib.suppress(AttributeError, TypeError): with contextlib.suppress(AttributeError, TypeError):
output_nutrition["sodiumContent"] = str(float(output_nutrition["sodiumContent"]) * 1000) output_nutrition[key] = str(float(output_nutrition[key]) * 1000)
return output_nutrition return output_nutrition

View file

@ -173,8 +173,7 @@ def g2_user(session: Session, admin_token, api_client: TestClient):
pass 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() registration = utils.user_registration_factory()
response = api_client.post("/api/users/register", json=registration.model_dump(by_alias=True)) response = api_client.post("/api/users/register", json=registration.model_dump(by_alias=True))
assert response.status_code == 201 assert response.status_code == 201
@ -213,6 +212,16 @@ def unique_user(session: Session, api_client: TestClient):
pass 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") @fixture(scope="module")
def user_tuple(session: Session, admin_token, api_client: TestClient) -> Generator[list[utils.TestUser], None, None]: def user_tuple(session: Session, admin_token, api_client: TestClient) -> Generator[list[utils.TestUser], None, None]:
group_name = utils.random_string() group_name = utils.random_string()

View file

@ -1,5 +1,5 @@
import os import os
from dataclasses import dataclass from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from zipfile import ZipFile from zipfile import ZipFile
@ -8,6 +8,7 @@ import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from mealie.schema.group.group_migration import SupportedMigrations from mealie.schema.group.group_migration import SupportedMigrations
from mealie.schema.recipe.recipe import Recipe
from mealie.schema.reports.reports import ReportEntryOut from mealie.schema.reports.reports import ReportEntryOut
from tests import data as test_data from tests import data as test_data
from tests.utils import api_routes from tests.utils import api_routes
@ -19,18 +20,94 @@ from tests.utils.fixture_schemas import TestUser
class MigrationTestData: class MigrationTestData:
typ: SupportedMigrations typ: SupportedMigrations
archive: Path 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 = [ test_cases = [
MigrationTestData(typ=SupportedMigrations.nextcloud, archive=test_data.migrations_nextcloud), MigrationTestData(
MigrationTestData(typ=SupportedMigrations.paprika, archive=test_data.migrations_paprika), typ=SupportedMigrations.nextcloud,
MigrationTestData(typ=SupportedMigrations.chowdown, archive=test_data.migrations_chowdown), archive=test_data.migrations_nextcloud,
MigrationTestData(typ=SupportedMigrations.copymethat, archive=test_data.migrations_copymethat), search_slug="skillet-shepherd-s-pie",
MigrationTestData(typ=SupportedMigrations.mealie_alpha, archive=test_data.migrations_mealie), nutrition_filter={
MigrationTestData(typ=SupportedMigrations.tandoor, archive=test_data.migrations_tandoor), "transFatContent",
MigrationTestData(typ=SupportedMigrations.plantoeat, archive=test_data.migrations_plantoeat), "unsaturatedFatContent",
MigrationTestData(typ=SupportedMigrations.myrecipebox, archive=test_data.migrations_myrecipebox), },
MigrationTestData(typ=SupportedMigrations.recipekeeper, archive=test_data.migrations_recipekeeper), ),
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 = [ test_ids = [
@ -47,7 +124,8 @@ test_ids = [
@pytest.mark.parametrize("mig", test_cases, ids=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 = { payload = {
"migration_type": mig.typ.value, "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"] events = query_data["items"]
assert len(events) 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): def test_bad_mealie_alpha_data_is_ignored(api_client: TestClient, unique_user: TestUser):
with TemporaryDirectory() as tmpdir: with TemporaryDirectory() as tmpdir:

View file

@ -481,20 +481,24 @@ nutrition_test_cases = (
}, },
), ),
CleanerCase( CleanerCase(
test_id="special support for sodiumContent (g -> mg)", test_id="special support for sodiumContent/cholesterolContent (g -> mg)",
input={ input={
"cholesterolContent": "10g",
"sodiumContent": "10g", "sodiumContent": "10g",
}, },
expected={ expected={
"cholesterolContent": "10000.0",
"sodiumContent": "10000.0", "sodiumContent": "10000.0",
}, },
), ),
CleanerCase( CleanerCase(
test_id="special support for sodiumContent (mg -> mg)", test_id="special support for sodiumContent/cholesterolContent (mg -> mg)",
input={ input={
"cholesterolContent": "10000mg",
"sodiumContent": "10000mg", "sodiumContent": "10000mg",
}, },
expected={ expected={
"cholesterolContent": "10000",
"sodiumContent": "10000", "sodiumContent": "10000",
}, },
), ),

View file

@ -23,6 +23,12 @@ async def test_recipe_parser(recipe_test_data: RecipeSiteTestCase):
recipe, _ = await scraper.create_from_html(recipe_test_data.url, translator) recipe, _ = await scraper.create_from_html(recipe_test_data.url, translator)
assert recipe.slug == recipe_test_data.expected_slug assert recipe.slug == recipe_test_data.expected_slug
assert len(recipe.recipe_instructions or []) == recipe_test_data.num_steps assert len(recipe.recipe_instructions or []) == recipe_test_data.num_steps
assert len(recipe.recipe_ingredient) == recipe_test_data.num_ingredients 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 assert recipe.org_url == recipe_test_data.url

View file

@ -13,6 +13,7 @@ class RecipeSiteTestCase:
num_steps: int num_steps: int
html_file: Path html_file: Path
num_nutrition_entries: int = 0
include_tags: bool = False include_tags: bool = False
expected_tags: set[str] | None = None 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", expected_slug="taiwanese-three-cup-chicken-san-bei-ji-recipe",
num_ingredients=10, num_ingredients=10,
num_steps=3, num_steps=3,
num_nutrition_entries=11,
), ),
RecipeSiteTestCase( RecipeSiteTestCase(
url="https://www.rezeptwelt.de/backen-herzhaft-rezepte/schinken-kaese-waffeln-ohne-viel-schnickschnack/4j0bkiig-94d4d-106529-cfcd2-is97x2ml", url="https://www.rezeptwelt.de/backen-herzhaft-rezepte/schinken-kaese-waffeln-ohne-viel-schnickschnack/4j0bkiig-94d4d-106529-cfcd2-is97x2ml",