2022-01-13 13:06:52 -09:00
|
|
|
|
import shutil
|
2021-10-09 13:08:23 -08:00
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
from fractions import Fraction
|
|
|
|
|
|
|
|
|
|
import pytest
|
2023-09-15 12:19:34 -05:00
|
|
|
|
from pydantic import UUID4
|
2021-10-09 13:08:23 -08:00
|
|
|
|
|
2023-09-15 12:19:34 -05:00
|
|
|
|
from mealie.db.db_setup import session_context
|
|
|
|
|
from mealie.repos.repository_factory import AllRepositories
|
|
|
|
|
from mealie.schema.recipe.recipe_ingredient import (
|
|
|
|
|
CreateIngredientFood,
|
|
|
|
|
CreateIngredientUnit,
|
|
|
|
|
IngredientFood,
|
|
|
|
|
IngredientUnit,
|
|
|
|
|
ParsedIngredient,
|
|
|
|
|
RecipeIngredient,
|
|
|
|
|
SaveIngredientFood,
|
|
|
|
|
SaveIngredientUnit,
|
|
|
|
|
)
|
|
|
|
|
from mealie.schema.user.user import GroupBase
|
2021-10-16 16:06:13 -08:00
|
|
|
|
from mealie.services.parser_services import RegisteredParser, get_parser
|
2021-10-09 13:08:23 -08:00
|
|
|
|
from mealie.services.parser_services.crfpp.processor import CRFIngredient, convert_list_to_crf_model
|
2023-09-15 12:19:34 -05:00
|
|
|
|
from tests.utils.factories import random_int, random_string
|
|
|
|
|
from tests.utils.fixture_schemas import TestUser
|
2021-10-09 13:08:23 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
class TestIngredient:
|
|
|
|
|
input: str
|
|
|
|
|
quantity: float
|
|
|
|
|
unit: str
|
|
|
|
|
food: str
|
|
|
|
|
comments: str
|
|
|
|
|
|
|
|
|
|
|
2021-10-16 16:06:13 -08:00
|
|
|
|
def crf_exists() -> bool:
|
|
|
|
|
return shutil.which("crf_test") is not None
|
|
|
|
|
|
|
|
|
|
|
2023-09-15 12:19:34 -05:00
|
|
|
|
def build_parsed_ing(food: str | None, unit: str | None) -> ParsedIngredient:
|
|
|
|
|
ing = RecipeIngredient(unit=None, food=None)
|
|
|
|
|
if food:
|
|
|
|
|
ing.food = CreateIngredientFood(name=food)
|
|
|
|
|
if unit:
|
|
|
|
|
ing.unit = CreateIngredientUnit(name=unit)
|
|
|
|
|
|
|
|
|
|
return ParsedIngredient(input=None, ingredient=ing)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def unique_local_group_id(database: AllRepositories) -> UUID4:
|
|
|
|
|
return str(database.groups.create(GroupBase(name=random_string())).id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def parsed_ingredient_data(
|
|
|
|
|
database: AllRepositories, unique_local_group_id: UUID4
|
|
|
|
|
) -> tuple[list[IngredientFood], list[IngredientUnit]]:
|
|
|
|
|
foods = database.ingredient_foods.create_many(
|
|
|
|
|
[
|
|
|
|
|
SaveIngredientFood(name="potatoes", group_id=unique_local_group_id),
|
|
|
|
|
SaveIngredientFood(name="onion", group_id=unique_local_group_id),
|
|
|
|
|
SaveIngredientFood(name="green onion", group_id=unique_local_group_id),
|
|
|
|
|
SaveIngredientFood(name="frozen pearl onions", group_id=unique_local_group_id),
|
|
|
|
|
SaveIngredientFood(name="bell peppers", group_id=unique_local_group_id),
|
|
|
|
|
SaveIngredientFood(name="red pepper flakes", group_id=unique_local_group_id),
|
|
|
|
|
SaveIngredientFood(name="fresh ginger", group_id=unique_local_group_id),
|
|
|
|
|
SaveIngredientFood(name="ground ginger", group_id=unique_local_group_id),
|
|
|
|
|
SaveIngredientFood(name="ñör̃m̈ãl̈ĩz̈ẽm̈ẽ", group_id=unique_local_group_id),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
foods.extend(
|
|
|
|
|
database.ingredient_foods.create_many(
|
|
|
|
|
[
|
|
|
|
|
SaveIngredientFood(name=f"{random_string()} food", group_id=unique_local_group_id)
|
|
|
|
|
for _ in range(random_int(10, 15))
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
units = database.ingredient_units.create_many(
|
|
|
|
|
[
|
|
|
|
|
SaveIngredientUnit(name="Cups", group_id=unique_local_group_id),
|
|
|
|
|
SaveIngredientUnit(name="Tablespoon", group_id=unique_local_group_id),
|
|
|
|
|
SaveIngredientUnit(name="Teaspoon", group_id=unique_local_group_id),
|
|
|
|
|
SaveIngredientUnit(name="Stalk", group_id=unique_local_group_id),
|
|
|
|
|
SaveIngredientUnit(name="My Very Long Unit Name", abbreviation="mvlun", group_id=unique_local_group_id),
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
units.extend(
|
|
|
|
|
database.ingredient_foods.create_many(
|
|
|
|
|
[
|
|
|
|
|
SaveIngredientUnit(name=f"{random_string()} unit", group_id=unique_local_group_id)
|
|
|
|
|
for _ in range(random_int(10, 15))
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return foods, units
|
|
|
|
|
|
|
|
|
|
|
2021-10-09 13:08:23 -08:00
|
|
|
|
# TODO - add more robust test cases
|
|
|
|
|
test_ingredients = [
|
|
|
|
|
TestIngredient("½ cup all-purpose flour", 0.5, "cup", "all-purpose flour", ""),
|
|
|
|
|
TestIngredient("1 ½ teaspoons ground black pepper", 1.5, "teaspoon", "black pepper", "ground"),
|
2022-06-10 21:18:31 -05:00
|
|
|
|
TestIngredient("⅔ cup unsweetened flaked coconut", 0.667, "cup", "coconut", "unsweetened flaked"),
|
|
|
|
|
TestIngredient("⅓ cup panko bread crumbs", 0.333, "cup", "panko bread crumbs", ""),
|
|
|
|
|
# Small Fraction Tests - PR #1369
|
|
|
|
|
# Reported error is was for 1/8 - new lowest expected threshold is 1/32
|
|
|
|
|
TestIngredient("1/8 cup all-purpose flour", 0.125, "cup", "all-purpose flour", ""),
|
2022-06-10 19:01:14 -08:00
|
|
|
|
TestIngredient("1/32 cup all-purpose flour", 0.031, "cup", "all-purpose flour", ""),
|
2021-10-09 13:08:23 -08:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.skipif(not crf_exists(), reason="CRF++ not installed")
|
|
|
|
|
def test_nlp_parser():
|
|
|
|
|
models: list[CRFIngredient] = convert_list_to_crf_model([x.input for x in test_ingredients])
|
|
|
|
|
|
2022-09-25 23:17:27 +00:00
|
|
|
|
# Iterate over models and test_ingredients to gather
|
2021-10-09 13:08:23 -08:00
|
|
|
|
for model, test_ingredient in zip(models, test_ingredients):
|
2022-06-10 19:01:14 -08:00
|
|
|
|
assert round(float(sum(Fraction(s) for s in model.qty.split())), 3) == pytest.approx(test_ingredient.quantity)
|
2021-10-09 13:08:23 -08:00
|
|
|
|
|
|
|
|
|
assert model.comment == test_ingredient.comments
|
|
|
|
|
assert model.name == test_ingredient.food
|
|
|
|
|
assert model.unit == test_ingredient.unit
|
2021-10-16 16:06:13 -08:00
|
|
|
|
|
|
|
|
|
|
2023-09-15 12:19:34 -05:00
|
|
|
|
def test_brute_parser(unique_user: TestUser):
|
2021-10-16 16:06:13 -08:00
|
|
|
|
# input: (quantity, unit, food, comments)
|
|
|
|
|
expectations = {
|
|
|
|
|
# Dutch
|
|
|
|
|
"1 theelepel koffie": (1, "theelepel", "koffie", ""),
|
|
|
|
|
"3 theelepels koffie": (3, "theelepels", "koffie", ""),
|
|
|
|
|
"1 eetlepel tarwe": (1, "eetlepel", "tarwe", ""),
|
|
|
|
|
"20 eetlepels bloem": (20, "eetlepels", "bloem", ""),
|
|
|
|
|
"1 mespunt kaneel": (1, "mespunt", "kaneel", ""),
|
|
|
|
|
"1 snuf(je) zout": (1, "snuf(je)", "zout", ""),
|
|
|
|
|
"2 tbsp minced cilantro, leaves and stems": (2, "tbsp", "minced cilantro", "leaves and stems"),
|
|
|
|
|
"1 large yellow onion, coarsely chopped": (1, "large", "yellow onion", "coarsely chopped"),
|
|
|
|
|
"1 1/2 tsp garam masala": (1.5, "tsp", "garam masala", ""),
|
|
|
|
|
"2 cups mango chunks, (2 large mangoes) (fresh or frozen)": (
|
|
|
|
|
2,
|
|
|
|
|
"cups",
|
|
|
|
|
"mango chunks, (2 large mangoes)",
|
|
|
|
|
"fresh or frozen",
|
|
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
|
2023-09-15 12:19:34 -05:00
|
|
|
|
with session_context() as session:
|
|
|
|
|
parser = get_parser(RegisteredParser.brute, unique_user.group_id, session)
|
|
|
|
|
|
|
|
|
|
for key, val in expectations.items():
|
|
|
|
|
parsed = parser.parse_one(key)
|
|
|
|
|
|
|
|
|
|
assert parsed.ingredient.quantity == val[0]
|
|
|
|
|
assert parsed.ingredient.unit.name == val[1]
|
|
|
|
|
assert parsed.ingredient.food.name == val[2]
|
|
|
|
|
assert parsed.ingredient.note in {val[3], None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
"input, expected_unit_name, expected_food_name, expect_unit_match, expect_food_match",
|
|
|
|
|
(
|
|
|
|
|
pytest.param(
|
|
|
|
|
build_parsed_ing(unit="cup", food="potatoes"),
|
|
|
|
|
"Cups",
|
|
|
|
|
"potatoes",
|
|
|
|
|
True,
|
|
|
|
|
True,
|
|
|
|
|
id="basic match",
|
|
|
|
|
),
|
|
|
|
|
pytest.param( # this should work in sqlite since "potato" is contained within "potatoes"
|
|
|
|
|
build_parsed_ing(unit="cup", food="potato"),
|
|
|
|
|
"Cups",
|
|
|
|
|
"potatoes",
|
|
|
|
|
True,
|
|
|
|
|
True,
|
|
|
|
|
id="basic fuzzy match",
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
build_parsed_ing(unit="tablespoon", food="onion"),
|
|
|
|
|
"Tablespoon",
|
|
|
|
|
"onion",
|
|
|
|
|
True,
|
|
|
|
|
True,
|
|
|
|
|
id="nested match 1",
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
build_parsed_ing(unit="teaspoon", food="green onion"),
|
|
|
|
|
"Teaspoon",
|
|
|
|
|
"green onion",
|
|
|
|
|
True,
|
|
|
|
|
True,
|
|
|
|
|
id="nested match 2",
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
build_parsed_ing(unit="cup", food="gren onion"),
|
|
|
|
|
"Cups",
|
|
|
|
|
"green onion",
|
|
|
|
|
True,
|
|
|
|
|
True,
|
|
|
|
|
id="nested match 3",
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
build_parsed_ing(unit="stalk", food="very unique"),
|
|
|
|
|
"Stalk",
|
|
|
|
|
"very unique",
|
|
|
|
|
True,
|
|
|
|
|
False,
|
|
|
|
|
id="no food match",
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
build_parsed_ing(unit="cup", food=None),
|
|
|
|
|
"Cups",
|
|
|
|
|
None,
|
|
|
|
|
True,
|
|
|
|
|
False,
|
|
|
|
|
id="no food input",
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
build_parsed_ing(unit="very unique", food="fresh ginger"),
|
|
|
|
|
"very unique",
|
|
|
|
|
"fresh ginger",
|
|
|
|
|
False,
|
|
|
|
|
True,
|
|
|
|
|
id="no unit match",
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
build_parsed_ing(unit=None, food="potatoes"),
|
|
|
|
|
None,
|
|
|
|
|
"potatoes",
|
|
|
|
|
False,
|
|
|
|
|
True,
|
|
|
|
|
id="no unit input",
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
build_parsed_ing(unit="very unique", food="very unique"),
|
|
|
|
|
"very unique",
|
|
|
|
|
"very unique",
|
|
|
|
|
False,
|
|
|
|
|
False,
|
|
|
|
|
id="no matches",
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
build_parsed_ing(unit=None, food=None),
|
|
|
|
|
None,
|
|
|
|
|
None,
|
|
|
|
|
False,
|
|
|
|
|
False,
|
|
|
|
|
id="no input",
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
build_parsed_ing(unit="mvlun", food="potatoes"),
|
|
|
|
|
"My Very Long Unit Name",
|
|
|
|
|
"potatoes",
|
|
|
|
|
True,
|
|
|
|
|
True,
|
|
|
|
|
id="unit abbreviation",
|
|
|
|
|
),
|
|
|
|
|
pytest.param(
|
|
|
|
|
build_parsed_ing(unit=None, food="n̅ōr̅m̄a̅l̄i̅z̄e̅m̄e̅"),
|
|
|
|
|
None,
|
|
|
|
|
"ñör̃m̈ãl̈ĩz̈ẽm̈ẽ",
|
|
|
|
|
False,
|
|
|
|
|
True,
|
|
|
|
|
id="normalization",
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
def test_parser_ingredient_match(
|
|
|
|
|
expected_food_name: str | None,
|
|
|
|
|
expected_unit_name: str | None,
|
|
|
|
|
expect_food_match: bool,
|
|
|
|
|
expect_unit_match: bool,
|
|
|
|
|
input: ParsedIngredient,
|
|
|
|
|
parsed_ingredient_data: tuple[list[IngredientFood], list[IngredientUnit]], # required so database is populated
|
|
|
|
|
unique_local_group_id: UUID4,
|
|
|
|
|
):
|
|
|
|
|
with session_context() as session:
|
|
|
|
|
parser = get_parser(RegisteredParser.brute, unique_local_group_id, session)
|
|
|
|
|
parsed_ingredient = parser.find_ingredient_match(input)
|
|
|
|
|
|
|
|
|
|
if expected_food_name:
|
|
|
|
|
assert parsed_ingredient.ingredient.food and parsed_ingredient.ingredient.food.name == expected_food_name
|
|
|
|
|
else:
|
|
|
|
|
assert parsed_ingredient.ingredient.food is None
|
|
|
|
|
|
|
|
|
|
if expect_food_match:
|
|
|
|
|
assert isinstance(parsed_ingredient.ingredient.food, IngredientFood)
|
|
|
|
|
else:
|
|
|
|
|
assert parsed_ingredient.ingredient.food is None or isinstance(
|
|
|
|
|
parsed_ingredient.ingredient.food, CreateIngredientFood
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if expected_unit_name:
|
|
|
|
|
assert parsed_ingredient.ingredient.unit and parsed_ingredient.ingredient.unit.name == expected_unit_name
|
|
|
|
|
else:
|
|
|
|
|
assert parsed_ingredient.ingredient.unit is None
|
2021-10-16 16:06:13 -08:00
|
|
|
|
|
2023-09-15 12:19:34 -05:00
|
|
|
|
if expect_unit_match:
|
|
|
|
|
assert isinstance(parsed_ingredient.ingredient.unit, IngredientUnit)
|
|
|
|
|
else:
|
|
|
|
|
assert parsed_ingredient.ingredient.unit is None or isinstance(
|
|
|
|
|
parsed_ingredient.ingredient.unit, CreateIngredientUnit
|
|
|
|
|
)
|