Phase `02_level_content_and_validation` — final verification **green**. - **Gate:** `bash .agents/validate.sh` → ruff ✅ · pyright 0 errors ✅ · `undercooked-levels validate` OK (2 levels) ✅ · **668 passed** (incl. 10 Playwright e2e) ✅ · coverage **100%** ✅ · repo hygiene ✅ - **Criteria:** `validate` exit 0, one line per level; `list` = `01_first_kitchen`, `02_double_shift` in campaign order; live `:8000` `/healthz` 200, `/api/levels` (+detail) carries both levels with numeric `target_score`/`time_limit` and no grid/station leakage, `/api/config` correct, unknown id → 404, startup log `content loaded: 2 level(s), 4 recipe(s)`; `validate --dir tests/fixtures/levels/_bad/` → exit 1 with named `RECIPE_STATION_MISSING` / `SERVE_REACHABLE` / `TARGET_IMPLAUSIBLE`; A8 lock test proves a YAML-only level reaches loader+API+CLI. - **Defect fixed (pre-existing path, phase-02-triggered):** `undercooked` with unusable content printed the report then a raw traceback and exited `1`. `src/undercooked/__main__.py` now catches `ContentError` → one closing line, exit `2` (like a bad setting); 2 new tests in `tests/unit/test_main.py` (incl. non-content failures still propagate). - **Docs pass (full, project-wide):** README (content gate + exit `2`, config section), AGENTS.md §3, `docs/LEVELS.md` (startup refusal + exact CLI exit codes), `docs/DEPLOYMENT.md` (crash-loop diagnosis bullet); DESIGN/TESTING re-read, consistent. - **Container re-verified (no phase-01 regression):** `podman build -f Containerfile` succeeds; the image ships `levels/` and serves `/healthz` 200, `/api/levels` (2 levels), `/` 200 as non-root. - **Deviation to note:** the phase overview names `tests/fixtures/levels/bad/`; the actual broken-fixture dir is `tests/fixtures/levels/_bad/` (the `_` prefix is what keeps it out of normal discovery). Overview files are read-only to me — README/LEVELS.md document the real path. - No git index/history commands run; all changes left unstaged for the harness. ntfy notification sent.
312 lines
10 KiB
Python
312 lines
10 KiB
Python
"""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")
|