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:
@@ -0,0 +1,81 @@
|
||||
"""`ValidationReport.render()` is deterministic, rule-naming, and bounded.
|
||||
|
||||
The CLI (task 05) prints this text verbatim and the content-gate criterion
|
||||
greps it for rule names, so the render must be stable (same report, same
|
||||
bytes — input order cannot leak in), mention every rule id it holds, stay
|
||||
free of ANSI escapes, and not explode into a wall of text for a broken level.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim.validation import LevelViolation, RuleId, ValidationReport
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _violation(rule: str, level_id: str, message: str) -> LevelViolation:
|
||||
return LevelViolation(rule=rule, level_id=level_id, message=message)
|
||||
|
||||
|
||||
def _ten_violation_report() -> ValidationReport:
|
||||
errors = tuple(
|
||||
_violation(rule, f"level_{index % 2}", f"synthetic {rule} message {index}")
|
||||
for index, rule in enumerate(RuleId.ALL[:10])
|
||||
)
|
||||
warnings = (_violation(RuleId.TARGET_IMPLAUSIBLE, "level_0", "tight but fair"),)
|
||||
return ValidationReport(violations=errors, warnings=warnings)
|
||||
|
||||
|
||||
def test_render_is_deterministic_across_runs_and_input_order() -> None:
|
||||
report = _ten_violation_report()
|
||||
assert report.render() == report.render()
|
||||
shuffled = ValidationReport(
|
||||
violations=tuple(reversed(report.violations)),
|
||||
warnings=report.warnings,
|
||||
)
|
||||
assert shuffled.render() == report.render()
|
||||
|
||||
|
||||
def test_render_names_every_rule_level_and_severity_without_ansi() -> None:
|
||||
rendered = _ten_violation_report().render()
|
||||
for violation in _ten_violation_report().violations:
|
||||
assert violation.rule in rendered
|
||||
assert violation.message in rendered
|
||||
assert "level_0" in rendered and "level_1" in rendered
|
||||
assert "ERROR" in rendered and "WARN" in rendered
|
||||
assert "\x1b" not in rendered
|
||||
|
||||
|
||||
def test_render_stays_under_40_lines_for_a_10_violation_level() -> None:
|
||||
assert len(_ten_violation_report().render().splitlines()) <= 40
|
||||
|
||||
|
||||
def test_render_orders_errors_by_rule_within_a_level() -> None:
|
||||
report = ValidationReport(
|
||||
violations=(
|
||||
_violation(RuleId.TRAVEL_TOO_SHORT, "l1", "b travels too little"),
|
||||
_violation(RuleId.GRID_SHAPE, "l1", "a shape disagrees"),
|
||||
)
|
||||
)
|
||||
error_lines = [line for line in report.render().splitlines() if "ERROR" in line]
|
||||
assert len(error_lines) == 2
|
||||
assert RuleId.GRID_SHAPE in error_lines[0]
|
||||
assert RuleId.TRAVEL_TOO_SHORT in error_lines[1]
|
||||
|
||||
|
||||
def test_clean_report_renders_one_ok_line() -> None:
|
||||
report = ValidationReport()
|
||||
assert report.ok
|
||||
assert report.render() == "content OK: no violations"
|
||||
|
||||
|
||||
def test_warnings_only_report_renders_ok_and_warn_lines() -> None:
|
||||
report = ValidationReport(
|
||||
warnings=(_violation(RuleId.TARGET_IMPLAUSIBLE, "l1", "tight but fair"),)
|
||||
)
|
||||
rendered = report.render()
|
||||
assert report.ok # warnings never fail
|
||||
assert "WARN" in rendered and "ERROR" not in rendered
|
||||
assert "0 error(s), 1 warning(s)" in rendered
|
||||
Reference in New Issue
Block a user