phase: 02_level_content_and_validation

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.
This commit is contained in:
2026-10-06 11:03:28 -04:00
parent 6fd83446ec
commit 3426264ffb
48 changed files with 8094 additions and 54 deletions
+311
View File
@@ -0,0 +1,311 @@
"""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")