"""Unit tests for `undercooked.sim.recipe` (phase 02 task 02). The recipe book is the content contract every later phase cooks against: strict schemas (typos are load errors, not defaults), deterministic helpers, and a `min_seconds_to_complete` solver bound the validator trusts. Every failing case here is a YAML an author could plausibly write. """ from __future__ import annotations from pathlib import Path from typing import Any import pytest from pydantic import ValidationError from undercooked.sim.model import ItemForm, ItemKind, StationKind from undercooked.sim.recipe import ( HANDLING_S, ContentError, Ingredient, Recipe, RecipeBook, Step, describe_validation_error, load_recipe_book, read_yaml_mapping, ) pytestmark = pytest.mark.unit def soup_dict(**overrides: Any) -> dict[str, Any]: """A minimal valid soup recipe body; overrides replace top-level fields.""" body: dict[str, Any] = { "id": "soup", "name": "Tomato Soup", "base_score": 40, "ingredients": [ {"kind": "tomato", "form": "raw"}, {"kind": "onion", "form": "raw", "count": 2}, ], "steps": [ {"kind": "chop", "station": "chop_board", "seconds": 0.9}, {"kind": "cook", "station": "stove", "seconds": 5.0}, ], } body.update(overrides) return body def book_dict(**overrides: Any) -> dict[str, Any]: data: dict[str, Any] = {"recipes": {"soup": soup_dict()}} data.update(overrides) return data def write_book(directory: Path, text: str) -> Path: path = directory / "recipes.yaml" path.write_text(text, encoding="utf-8") return path # --------------------------------------------------------------------- Ingredient / Step def test_handling_constant_is_the_documented_bound() -> None: assert HANDLING_S == 0.6 def test_ingredient_defaults_count_to_one() -> None: ingredient = Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW) assert ingredient.count == 1 def test_ingredient_count_must_be_positive() -> None: with pytest.raises(ValidationError, match="count"): Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW, count=0) def test_ingredient_kinds_are_the_enum_not_arbitrary_strings() -> None: with pytest.raises(ValidationError): Ingredient.model_validate({"kind": "durian", "form": "raw"}) def test_step_defaults_to_zero_seconds() -> None: step = Step(kind="pick", station=StationKind.SPAWN_SHELF) assert step.seconds == 0.0 def test_step_kind_is_a_closed_vocabulary() -> None: with pytest.raises(ValidationError, match="kind"): Step.model_validate({"kind": "throw", "station": "chop_board"}) def test_step_seconds_cannot_be_negative() -> None: with pytest.raises(ValidationError, match="seconds"): Step(kind="chop", station=StationKind.CHOP_BOARD, seconds=-0.1) def test_step_station_must_be_a_known_station_kind() -> None: with pytest.raises(ValidationError, match="station"): Step.model_validate({"kind": "cook", "station": "deep_fryer"}) # --------------------------------------------------------------------------- Recipe def test_recipe_happy_path_carries_documented_defaults() -> None: recipe = Recipe.model_validate(soup_dict()) assert recipe.requires_plating is True assert recipe.tags == [] assert [ingredient.count for ingredient in recipe.ingredients] == [1, 2] def test_recipe_rejects_unknown_keys() -> None: with pytest.raises(ValidationError, match=r"extra_forbidden|Extra inputs"): Recipe.model_validate(soup_dict(garnish="parsley")) def test_recipe_requires_ingredients_and_steps() -> None: with pytest.raises(ValidationError, match="ingredients"): Recipe.model_validate(soup_dict(ingredients=[])) with pytest.raises(ValidationError, match="steps"): Recipe.model_validate(soup_dict(steps=[])) def test_recipe_score_and_ids_are_sane() -> None: with pytest.raises(ValidationError): Recipe.model_validate(soup_dict(base_score=-1)) with pytest.raises(ValidationError): Recipe.model_validate(soup_dict(id="")) # ------------------------------------------------------------------------ RecipeBook def test_book_reads_keys_ids_and_the_ingredient_catalog() -> None: book = RecipeBook.model_validate( book_dict(ingredients=["tomato", "onion", "plate"]) ) assert book.ids() == ("soup",) assert book.get("soup").base_score == 40 assert book.ingredients == [ItemKind.TOMATO, ItemKind.ONION, ItemKind.PLATE] def test_book_ids_follow_declaration_order() -> None: book = RecipeBook.model_validate( book_dict(recipes={"salad": soup_dict(id="salad"), "soup": soup_dict()}) ) assert book.ids() == ("salad", "soup") def test_book_key_must_equal_the_recipe_id() -> None: with pytest.raises(ValidationError, match="keys must equal"): RecipeBook.model_validate(book_dict(recipes={"soup": soup_dict(id="chowder")})) def test_get_unknown_recipe_names_the_known_ones() -> None: book = RecipeBook.model_validate(book_dict()) with pytest.raises(KeyError, match="unknown recipe 'ghost'"): book.get("ghost") def test_required_station_kinds_for_a_two_step_recipe() -> None: book = RecipeBook.model_validate(book_dict()) assert book.required_station_kinds("soup") == frozenset( {StationKind.CHOP_BOARD, StationKind.STOVE} ) def four_step_book() -> RecipeBook: recipe = soup_dict( steps=[ {"kind": "chop", "station": "chop_board", "seconds": 0.9}, {"kind": "cook", "station": "stove", "seconds": 4.0}, {"kind": "plate", "station": "prep", "seconds": 1.2}, {"kind": "serve", "station": "serve"}, ] ) return RecipeBook.model_validate(book_dict(recipes={"soup": recipe})) def test_required_station_kinds_for_a_four_step_recipe() -> None: assert four_step_book().required_station_kinds("soup") == frozenset( {StationKind.CHOP_BOARD, StationKind.STOVE, StationKind.PREP, StationKind.SERVE} ) def test_required_item_kinds_adds_a_plate_only_when_plating_is_required() -> None: book = RecipeBook.model_validate(book_dict()) assert book.required_item_kinds("soup") == frozenset( {ItemKind.TOMATO, ItemKind.ONION, ItemKind.PLATE} ) unplated = RecipeBook.model_validate( book_dict(recipes={"soup": soup_dict(requires_plating=False)}) ) assert unplated.required_item_kinds("soup") == frozenset({ItemKind.TOMATO, ItemKind.ONION}) def test_min_seconds_to_complete_is_steps_plus_one_handling_allowance() -> None: book = RecipeBook.model_validate(book_dict()) assert book.min_seconds_to_complete("soup") == pytest.approx(0.9 + 5.0 + HANDLING_S) def test_min_seconds_grows_with_more_steps() -> None: minutes = four_step_book().min_seconds_to_complete("soup") assert minutes == pytest.approx(0.9 + 4.0 + 1.2 + 0.0 + HANDLING_S) @pytest.mark.parametrize( "method", ["required_station_kinds", "required_item_kinds", "min_seconds_to_complete"], ) def test_solver_helpers_reject_unknown_recipes(method: str) -> None: book = RecipeBook.model_validate(book_dict()) with pytest.raises(KeyError, match="unknown recipe"): getattr(book, method)("ghost") # ------------------------------------------------------------------- load_recipe_book def test_load_recipe_book_reads_a_real_yaml_file(tmp_path: Path) -> None: path = write_book( tmp_path, """ ingredients: [tomato, onion, plate] recipes: soup: id: soup name: Tomato Soup base_score: 40 ingredients: [{kind: tomato, form: raw}, {kind: onion, form: raw, count: 2}] steps: - {kind: chop, station: chop_board, seconds: 0.9} - {kind: cook, station: stove, seconds: 5} """, ) book = load_recipe_book(path) assert book.ids() == ("soup",) assert book.ingredients == [ItemKind.TOMATO, ItemKind.ONION, ItemKind.PLATE] def test_load_recipe_book_reports_every_field_problem(tmp_path: Path) -> None: path = write_book( tmp_path, """ recipes: soup: id: soup name: Soup base_score: not-a-number ingredients: [{kind: durian, form: raw}] steps: [{kind: throw, station: chop_board}] """, ) with pytest.raises(ContentError) as excinfo: load_recipe_book(path) message = str(excinfo.value) assert "base_score" in message assert "ingredients.0.kind" in message assert "steps.0.kind" in message def test_load_recipe_book_rejects_unknown_top_level_keys(tmp_path: Path) -> None: path = write_book(tmp_path, "recipes: {}\ndesserts: {}\n") with pytest.raises(ContentError, match="desserts"): load_recipe_book(path) def test_load_recipe_book_missing_file_is_a_content_error(tmp_path: Path) -> None: with pytest.raises(ContentError, match="cannot read content file"): load_recipe_book(tmp_path / "nope.yaml") def test_load_recipe_book_invalid_yaml_is_a_content_error(tmp_path: Path) -> None: path = write_book(tmp_path, "recipes: [unclosed\n") with pytest.raises(ContentError, match="invalid YAML"): load_recipe_book(path) def test_load_recipe_book_rejects_a_list_document(tmp_path: Path) -> None: path = write_book(tmp_path, "- just\n- a\n- list\n") with pytest.raises(ContentError, match="expected a YAML mapping"): load_recipe_book(path) def test_empty_file_reads_as_an_empty_mapping_then_fails_the_schema(tmp_path: Path) -> None: path = write_book(tmp_path, "") assert read_yaml_mapping(path) == {} with pytest.raises(ContentError, match="recipes: Field required"): load_recipe_book(path) # ----------------------------------------------------------------- strictness plumbing def test_assignment_revalidates_so_late_edits_cannot_corrupt_a_model() -> None: ingredient = Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW) ingredient.count = 3 assert ingredient.count == 3 with pytest.raises(ValidationError): ingredient.count = 0 def test_describe_validation_error_flattens_loc_paths(tmp_path: Path) -> None: with pytest.raises(ValidationError) as excinfo: RecipeBook.model_validate({}) described = describe_validation_error(excinfo.value, "book.yaml") assert described.startswith("book.yaml: recipes: Field required")