216 lines
6.4 KiB
Python
216 lines
6.4 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from app.models import RECIPE_JSON_SCHEMA, RecipeSpec
|
|
from app.quality import local_quality_warnings
|
|
from app.source_extractor import extract_from_html
|
|
from app.tandoor_service import (
|
|
_materialize_created_objects,
|
|
resolve_object,
|
|
search_variants,
|
|
)
|
|
|
|
|
|
class FakeClient:
|
|
def __init__(self):
|
|
self.objects = {
|
|
"food": [
|
|
{
|
|
"id": 10,
|
|
"name": "Kartoffel",
|
|
"plural_name": "Kartoffeln",
|
|
"properties": [{"x": 1}],
|
|
},
|
|
{"id": 11, "name": "Zwiebeln", "plural_name": "Zwiebeln"},
|
|
{"id": 12, "name": "Schinken", "plural_name": "Schinken"},
|
|
],
|
|
"unit": [
|
|
{"id": 20, "name": "g", "plural_name": "g"},
|
|
{"id": 21, "name": "TL", "plural_name": "TL"},
|
|
],
|
|
"keyword": [],
|
|
}
|
|
self.next_id = 100
|
|
self.posts = []
|
|
self.deleted = []
|
|
|
|
def list_objects(self, endpoint, force_refresh=False):
|
|
return deepcopy(self.objects.get(endpoint, []))
|
|
|
|
def get_json(self, path):
|
|
parts = path.strip("/").split("/")
|
|
endpoint = parts[-2]
|
|
object_id = int(parts[-1])
|
|
for obj in self.objects.get(endpoint, []):
|
|
if obj["id"] == object_id:
|
|
return deepcopy(obj)
|
|
raise RuntimeError("not found")
|
|
|
|
def post_json(self, path, payload):
|
|
endpoint = path.strip("/").split("/")[-1]
|
|
obj = {"id": self.next_id, **payload}
|
|
self.next_id += 1
|
|
self.objects.setdefault(endpoint, []).append(obj)
|
|
self.posts.append((endpoint, deepcopy(payload)))
|
|
return deepcopy(obj)
|
|
|
|
def forget_cache(self, endpoint):
|
|
pass
|
|
|
|
def delete(self, path):
|
|
self.deleted.append(path)
|
|
|
|
|
|
def test_json_ld_extraction():
|
|
html = """
|
|
<html><head>
|
|
<title>Test</title>
|
|
<script type="application/ld+json">
|
|
{
|
|
"@context": "https://schema.org",
|
|
"@type": "Recipe",
|
|
"name": "Testrezept",
|
|
"image": "https://example.com/image.jpg",
|
|
"recipeIngredient": ["1 TL Salz"],
|
|
"recipeInstructions": ["Mischen."]
|
|
}
|
|
</script>
|
|
</head><body><main><h1>Testrezept</h1><p>Mischen.</p></main></body></html>
|
|
"""
|
|
source = extract_from_html("https://example.com/rezept.html", html)
|
|
assert source.title == "Testrezept"
|
|
assert source.structured_recipe["@type"] == "Recipe"
|
|
assert source.image_url == "https://example.com/image.jpg"
|
|
|
|
|
|
def test_recipe_validation():
|
|
payload = json.loads((ROOT / "examples" / "crispy-chickpeas.json").read_text())
|
|
recipe = RecipeSpec.model_validate(payload)
|
|
assert recipe.name.startswith("Knusprige")
|
|
assert local_quality_warnings(recipe) == []
|
|
|
|
|
|
def test_suspicious_unit():
|
|
payload = json.loads((ROOT / "examples" / "crispy-chickpeas.json").read_text())
|
|
payload["steps"][0]["ingredients"][0]["unit_name"] = "Limette"
|
|
recipe = RecipeSpec.model_validate(payload)
|
|
warnings = local_quality_warnings(recipe)
|
|
assert any(item["code"] == "suspicious_unit" for item in warnings)
|
|
|
|
|
|
def test_schema_has_strict_objects():
|
|
assert RECIPE_JSON_SCHEMA["additionalProperties"] is False
|
|
ingredient = (
|
|
RECIPE_JSON_SCHEMA["properties"]["steps"]["items"]["properties"]
|
|
["ingredients"]["items"]
|
|
)
|
|
assert ingredient["additionalProperties"] is False
|
|
required = set(ingredient["required"])
|
|
assert {"create_food", "create_unit", "food_plural_name", "unit_plural_name"} <= required
|
|
|
|
|
|
def test_german_word_variants_and_preselection():
|
|
assert "kartoffel" in search_variants("Kartoffeln")
|
|
assert "zwiebeln" in search_variants("Zwiebel")
|
|
|
|
client = FakeClient()
|
|
potato = resolve_object(
|
|
client,
|
|
"food",
|
|
"Kartoffeln",
|
|
None,
|
|
object_type="Food",
|
|
)
|
|
assert potato["blocking"] is False
|
|
assert potato["resolved"]["id"] == 10
|
|
assert potato["selected_id"] == 10
|
|
|
|
onion = resolve_object(
|
|
client,
|
|
"food",
|
|
"Zwiebel",
|
|
None,
|
|
object_type="Food",
|
|
)
|
|
assert onion["blocking"] is False
|
|
assert onion["resolved"]["id"] == 11
|
|
|
|
|
|
def test_missing_can_be_explicitly_created():
|
|
client = FakeClient()
|
|
missing = resolve_object(
|
|
client,
|
|
"food",
|
|
"Kochschinkenwürfel",
|
|
None,
|
|
object_type="Food",
|
|
)
|
|
# Schinken ist als Teilwort-Kandidat vorhanden und wird vorausgewählt.
|
|
assert missing["blocking"] is False
|
|
assert missing["candidates"]
|
|
|
|
create = resolve_object(
|
|
client,
|
|
"food",
|
|
"Tempeh-Crunch",
|
|
None,
|
|
object_type="Food",
|
|
create_requested=True,
|
|
plural_name="Tempeh-Crunch",
|
|
)
|
|
assert create["status"] == "create"
|
|
assert create["blocking"] is False
|
|
|
|
|
|
def test_create_objects_are_materialized_for_recipe_payload():
|
|
client = FakeClient()
|
|
payload = {
|
|
"steps": [
|
|
{
|
|
"ingredients": [
|
|
{
|
|
"food": {
|
|
"name": "Tempeh-Crunch",
|
|
"plural_name": None,
|
|
"create": True,
|
|
"endpoint": "food",
|
|
},
|
|
"unit": {
|
|
"name": "Portion",
|
|
"plural_name": "Portionen",
|
|
"create": True,
|
|
"endpoint": "unit",
|
|
},
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
final, created = _materialize_created_objects(client, payload)
|
|
ingredient = final["steps"][0]["ingredients"][0]
|
|
assert ingredient["food"]["id"] == 100
|
|
assert ingredient["unit"]["id"] == 101
|
|
assert len(created) == 2
|
|
assert client.posts == [
|
|
("food", {"name": "Tempeh-Crunch"}),
|
|
("unit", {"name": "Portion", "plural_name": "Portionen"}),
|
|
]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
test_json_ld_extraction()
|
|
test_recipe_validation()
|
|
test_suspicious_unit()
|
|
test_schema_has_strict_objects()
|
|
test_german_word_variants_and_preselection()
|
|
test_missing_can_be_explicitly_created()
|
|
test_create_objects_are_materialized_for_recipe_payload()
|
|
print("Alle Selbsttests erfolgreich.")
|