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
+104
View File
@@ -0,0 +1,104 @@
"""docs/LEVELS.md must document exactly the fields the models have.
The doc is the content author's contract; the Pydantic models are its
executable form. This test parses every ``### <Model>`` section's *Field*
table (the first table whose header row starts with ``Field``, so the
HazardSpec placement table is not mistaken for a field list) and requires the
back-quoted field names to equal ``model_fields`` — no undocumented field,
no documented ghost. A planted-drift case proves the extraction can fail.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import BaseModel
from undercooked.sim.level import (
HazardSpec,
LevelDef,
OrdersSpec,
StationLimits,
TileSpec,
)
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
pytestmark = pytest.mark.unit
DOC_PATH = Path(__file__).resolve().parents[2] / "docs" / "LEVELS.md"
MODELS: tuple[type[BaseModel], ...] = (
Ingredient,
Step,
Recipe,
RecipeBook,
TileSpec,
OrdersSpec,
HazardSpec,
StationLimits,
LevelDef,
)
def _section(lines: list[str], model_name: str) -> list[str]:
"""Lines of the `### model_name` section, up to the next heading."""
heading = f"### {model_name}"
collected: list[str] = []
in_section = False
for line in lines:
if line.startswith("#"):
if in_section:
break
in_section = line.strip() == heading
continue
if in_section:
collected.append(line)
return collected
def _table_fields(section: list[str]) -> set[str]:
"""Back-quoted first-column entries of the section's `Field` table."""
start = None
for index, line in enumerate(section):
stripped = line.strip()
if stripped.startswith("|") and stripped.strip("|").split("|")[0].strip() == "Field":
start = index
break
if start is None:
return set()
fields: set[str] = set()
for line in section[start + 2 :]: # skip header + |---| separator
stripped = line.strip()
if not stripped.startswith("|"):
break
first = stripped.strip("|").split("|")[0].strip()
if len(first) > 2 and first.startswith("`") and first.endswith("`"):
fields.add(first[1:-1])
return fields
@pytest.fixture(scope="module")
def doc_lines() -> list[str]:
assert DOC_PATH.is_file(), f"{DOC_PATH} is missing"
return DOC_PATH.read_text(encoding="utf-8").splitlines()
@pytest.mark.parametrize("model", MODELS, ids=lambda model: model.__name__)
def test_every_model_has_an_exact_field_table(doc_lines: list[str], model: type[BaseModel]) -> None:
documented = _table_fields(_section(doc_lines, model.__name__))
assert documented, f"docs/LEVELS.md has no Field table for {model.__name__}"
assert documented == set(model.model_fields), (
f"docs/LEVELS.md vs {model.__name__}: "
f"undocumented={sorted(set(model.model_fields) - documented)}, "
f"not-in-model={sorted(documented - set(model.model_fields))}"
)
def test_the_comparison_catches_planted_drift(doc_lines: list[str]) -> None:
planted = [
line.replace("| `penalty` |", "| `reward` |") for line in doc_lines
]
documented = _table_fields(_section(planted, "OrdersSpec"))
assert "reward" in documented
assert documented != set(OrdersSpec.model_fields)