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
+81
View File
@@ -0,0 +1,81 @@
"""Unit tests for the A5 purity guard (phase 02 task 01).
Proves the shipped `sim/` package imports nothing forbidden, and — just as
important — that the guard actually catches a violation when one exists.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from undercooked.sim.purity import (
FORBIDDEN_ROOTS,
PurityError,
assert_pure_imports,
find_violations,
)
pytestmark = pytest.mark.unit
def test_forbidden_roots_are_the_plan_a5_list() -> None:
assert frozenset({"asyncio", "fastapi", "socket", "time", "uvicorn"}) == FORBIDDEN_ROOTS
def test_shipped_sim_package_is_pure() -> None:
assert find_violations() == []
assert_pure_imports() # must not raise
def test_clean_module_with_stdlib_sibling_and_relative_imports(tmp_path: Path) -> None:
(tmp_path / "ok.py").write_text(
"import math\n"
"from hashlib import sha256\n"
"from undercooked.sim.model import Vec2\n"
"from . import model\n",
encoding="utf-8",
)
assert find_violations(tmp_path) == []
def test_comments_and_strings_are_not_imports(tmp_path: Path) -> None:
(tmp_path / "prose.py").write_text(
'# we must never "import asyncio" here\nDOC = "import time is forbidden"\n',
encoding="utf-8",
)
assert find_violations(tmp_path) == []
@pytest.mark.parametrize(
("statement", "root"),
[
("import asyncio", "asyncio"),
("import time", "time"),
("from time import monotonic", "time"),
("import socket", "socket"),
("from socket import socket", "socket"),
("from fastapi import FastAPI", "fastapi"),
("import uvicorn", "uvicorn"),
("import uvicorn.protocols", "uvicorn"),
],
)
def test_find_violations_detects_each_forbidden_import(
tmp_path: Path, statement: str, root: str
) -> None:
(tmp_path / "bad.py").write_text(f"{statement}\n", encoding="utf-8")
assert find_violations(tmp_path) == [f"bad.py: imports {root}"]
def test_find_violations_scans_subpackages(tmp_path: Path) -> None:
sub = tmp_path / "deep"
sub.mkdir()
(sub / "leak.py").write_text("import asyncio\n", encoding="utf-8")
assert find_violations(tmp_path) == ["deep/leak.py: imports asyncio"]
def test_assert_pure_imports_raises_with_the_violation_list(tmp_path: Path) -> None:
(tmp_path / "bad.py").write_text("import time\nimport socket\n", encoding="utf-8")
with pytest.raises(PurityError, match=r"bad\.py: imports socket"):
assert_pure_imports(tmp_path)