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
+490
View File
@@ -0,0 +1,490 @@
"""Unit tests for `undercooked.sim.validation` (phase 02 task 03).
One failing case per rule id (asserted against `RuleId.ALL`) plus the passing
counterpart, a planted-unreachable-station exactness test, the bundle rules,
and the escalation/target math. Every fixture level is built by `make_level`
so a rule's failing case is one readable override away.
"""
from __future__ import annotations
import copy
from collections.abc import Callable
from dataclasses import replace
from typing import Any
import pytest
from undercooked.sim.content import ContentBundle
from undercooked.sim.grid import Grid
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import TileKind
from undercooked.sim.recipe import RecipeBook
from undercooked.sim.validation import (
TARGET_WARN_FRACTION,
RuleId,
escalation_axes,
theoretical_max_score,
validate_bundle,
validate_level,
validate_levels,
)
pytestmark = pytest.mark.unit
# --- fixtures ---------------------------------------------------------------------
RECIPES: dict[str, dict[str, Any]] = {
"soup": {
"id": "soup",
"name": "Tomato Soup",
"base_score": 40,
"ingredients": [{"kind": "tomato", "form": "raw"}, {"kind": "onion", "form": "raw"}],
"steps": [
{"kind": "chop", "station": "chop_board", "seconds": 0.9},
{"kind": "cook", "station": "stove", "seconds": 5.0},
{"kind": "plate", "station": "prep", "seconds": 0.5},
{"kind": "serve", "station": "serve"},
],
},
"salad": {
"id": "salad",
"name": "Garden Salad",
"base_score": 30,
"ingredients": [{"kind": "lettuce", "form": "chopped"}],
"steps": [
{"kind": "plate", "station": "prep", "seconds": 0.4},
{"kind": "serve", "station": "serve"},
],
},
}
CLEAN_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#......V..#",
"###########",
]
CLEAN_TILES: dict[str, dict[str, str]] = {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"O": {"kind": "floor", "station": "spawn_shelf", "item": "onion"},
"C": {"kind": "floor", "station": "chop_board"},
"X": {"kind": "floor", "station": "stove"},
"P": {"kind": "floor", "station": "prep"},
"V": {"kind": "floor", "station": "serve"},
}
#: The clean kitchen grown by two rows: a sealed 3-cell pocket at (7..9, 7).
POCKET_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#......V..#",
"#....######",
"#.....#..K#",
"###########",
]
#: Same pocket, empty floor inside — only the second spawn lives down there.
POCKET_FLOOR_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#......V..#",
"#....######",
"#.....#...#",
"###########",
]
#: The serve window itself sealed inside the pocket (nothing else serves).
SERVE_POCKET_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#....######",
"#.....#...#",
"#.....#..V#",
"###########",
]
NO_STOVE_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#......P..#",
"#.........#",
"#......V..#",
"###########",
]
NO_ONION_GRID = [
"###########",
"#S......C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#......V..#",
"###########",
]
TINY_GRID = ["#####", "#SV.#", "#####"]
TINY_TILES: dict[str, dict[str, str]] = {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"V": {"kind": "floor", "station": "serve"},
}
ORDERS: dict[str, Any] = {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
}
def level_data(**overrides: Any) -> dict[str, Any]:
data: dict[str, Any] = {
"id": "01_clean",
"name": "Clean Kitchen",
"order": 1,
"time_limit": 150,
"target_score": 45,
"difficulty": 1,
"grid": copy.deepcopy(CLEAN_GRID),
"tiles": copy.deepcopy(CLEAN_TILES),
"spawns": [[1, 1]],
"orders": copy.deepcopy(ORDERS),
}
data.update(overrides)
return data
def make_level(**overrides: Any) -> Level:
"""Build the clean fixture level with the given `LevelDef` overrides."""
return Level.build(LevelDef.model_validate(level_data(**overrides)))
def make_book(*recipe_ids: str) -> RecipeBook:
ids = recipe_ids or ("soup",)
return RecipeBook.model_validate(
{"recipes": {recipe_id: copy.deepcopy(RECIPES[recipe_id]) for recipe_id in ids}}
)
# --- the clean level is silent ------------------------------------------------------
def test_clean_level_reports_no_errors_or_warnings() -> None:
report = validate_level(make_level(), make_book())
assert report.ok
assert report.violations == ()
assert report.warnings == ()
assert report.render() == "content OK: no violations"
# --- failing cases, one per rule id ---------------------------------------------------
Case = Callable[[], tuple[Level, RecipeBook, Level | None]]
def _pocket_level() -> Level:
tiles = copy.deepcopy(CLEAN_TILES)
tiles["K"] = {"kind": "floor", "station": "sink"}
return make_level(grid=POCKET_GRID, tiles=tiles)
def _grid_shape_height() -> tuple[Level, RecipeBook, Level | None]:
level = make_level()
shrunk = Grid(
width=level.grid.width,
height=level.grid.height - 1,
tiles=level.grid.tiles[: level.grid.width * (level.grid.height - 1)],
)
return replace(level, grid=shrunk), make_book(), None
def _spawn_on_floor() -> tuple[Level, RecipeBook, Level | None]:
return make_level(spawns=[[0, 0]]), make_book(), None
def _spawn_count() -> tuple[Level, RecipeBook, Level | None]:
return replace(make_level(), spawns=()), make_book(), None
def _station_reachable() -> tuple[Level, RecipeBook, Level | None]:
return _pocket_level(), make_book(), None
def _serve_reachable() -> tuple[Level, RecipeBook, Level | None]:
return make_level(grid=SERVE_POCKET_GRID), make_book(), None
def _spawns_mutual() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(grid=POCKET_FLOOR_GRID, spawns=[[1, 1], [9, 7], [5, 4]])
return level, make_book(), None
def _recipe_station_missing() -> tuple[Level, RecipeBook, Level | None]:
tiles = {c: spec for c, spec in copy.deepcopy(CLEAN_TILES).items() if c != "X"}
return make_level(grid=NO_STOVE_GRID, tiles=tiles), make_book(), None
def _recipe_ingredient_missing() -> tuple[Level, RecipeBook, Level | None]:
tiles = {c: spec for c, spec in copy.deepcopy(CLEAN_TILES).items() if c != "O"}
return make_level(grid=NO_ONION_GRID, tiles=tiles), make_book(), None
def _pool_unknown() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(orders=dict(ORDERS, pool=["soup", "ghost"]))
return level, make_book(), None
def _target_too_high() -> tuple[Level, RecipeBook, Level | None]:
book = make_book()
cap = theoretical_max_score(make_level(), book)
assert cap is not None
return make_level(target_score=int(cap * 2)), book, None
def _time_limit() -> tuple[Level, RecipeBook, Level | None]:
return make_level(time_limit=10), make_book(), None
def _hazard_ref() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(hazards=[{"type": "fire", "station": "stove_9"}])
return level, make_book(), None
def _hazard_tile() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(hazards=[{"type": "spill", "x": 0, "y": 0}])
return level, make_book(), None
def _limit_zero() -> tuple[Level, RecipeBook, Level | None]:
return make_level(station_limits={"plates": 0, "pots": 1}), make_book(), None
def _difficulty_order() -> tuple[Level, RecipeBook, Level | None]:
return make_level(), make_book(), make_level()
def _travel_short() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(difficulty=3, grid=TINY_GRID, tiles=TINY_TILES)
return level, make_book(), None
FAILING: dict[str, Case] = {
RuleId.GRID_SHAPE: _grid_shape_height,
RuleId.SPAWN_ON_FLOOR: _spawn_on_floor,
RuleId.SPAWN_COUNT: _spawn_count,
RuleId.STATION_REACHABLE: _station_reachable,
RuleId.SERVE_REACHABLE: _serve_reachable,
RuleId.SPAWNS_MUTUALLY_REACHABLE: _spawns_mutual,
RuleId.RECIPE_STATION_MISSING: _recipe_station_missing,
RuleId.RECIPE_INGREDIENT_UNAVAILABLE: _recipe_ingredient_missing,
RuleId.ORDER_POOL_UNKNOWN_RECIPE: _pool_unknown,
RuleId.TARGET_IMPLAUSIBLE: _target_too_high,
RuleId.TIME_LIMIT_INVALID: _time_limit,
RuleId.HAZARD_REF_UNKNOWN: _hazard_ref,
RuleId.HAZARD_ON_UNREACHABLE_TILE: _hazard_tile,
RuleId.STATION_LIMIT_ZERO: _limit_zero,
RuleId.DIFFICULTY_ORDER: _difficulty_order,
RuleId.TRAVEL_TOO_SHORT: _travel_short,
}
def test_failing_cases_cover_every_rule_id() -> None:
"""Every rule id has a failing case here; DUPLICATE_ID is a bundle rule."""
assert set(FAILING) | {RuleId.DUPLICATE_ID} == set(RuleId.ALL)
assert len(FAILING) + 1 == len(RuleId.ALL)
@pytest.mark.parametrize("rule", sorted(FAILING))
def test_rule_fires_with_a_human_message(rule: str) -> None:
level, book, prev = FAILING[rule]()
report = validate_level(level, book, prev)
assert not report.ok
fired = [violation for violation in report.violations if violation.rule == rule]
assert fired, f"{rule} did not fire:\n{report.render()}"
def test_planted_unreachable_station_reports_exactly_station_reachable() -> None:
report = validate_level(_pocket_level(), make_book())
assert len(report.violations) == 1, report.render()
violation = report.violations[0]
assert violation.rule == RuleId.STATION_REACHABLE
assert "(9,7)" in violation.message
assert dict(violation.detail) == {"x": 9, "y": 7, "station_id": "sink_0"}
assert report.errors_for("01_clean") == (violation,)
assert report.errors_for("nope") == ()
# --- passing counterparts beyond the clean level ---------------------------------------
def test_grid_shape_also_flags_a_width_disagreement() -> None:
level = make_level()
wider = Grid(
width=level.grid.width + 1,
height=level.grid.height,
tiles=level.grid.tiles + (TileKind.WALL,) * level.grid.height,
)
report = validate_level(replace(level, grid=wider), make_book())
fired = [v.rule for v in report.violations]
assert RuleId.GRID_SHAPE in fired
def test_spawn_on_the_serve_window_travels_zero_tiles() -> None:
assert escalation_axes(make_level(spawns=[[7, 5]]))["travel_distance"] == 0.0
def test_non_raw_ingredients_are_not_shelf_checked() -> None:
level = make_level(orders=dict(ORDERS, pool=["salad"]))
report = validate_level(level, make_book("salad"))
assert report.ok, report.render()
def test_tight_target_warns_without_failing() -> None:
cap = theoretical_max_score(make_level(), make_book())
assert cap is not None
tight = make_level(target_score=int(TARGET_WARN_FRACTION * cap) + 10)
report = validate_level(tight, make_book())
assert report.ok # warnings never fail
assert [warning.rule for warning in report.warnings] == [RuleId.TARGET_IMPLAUSIBLE]
def test_two_axes_moved_satisfies_difficulty_order() -> None:
prev = make_level()
nxt = make_level(
order=2,
hazards=[{"type": "spill", "x": 5, "y": 2}],
orders=dict(ORDERS, concurrent=2),
)
report = validate_level(nxt, make_book(), prev)
assert report.ok, report.render()
def test_high_difficulty_level_with_real_travel_is_clean() -> None:
report = validate_level(make_level(difficulty=3), make_book())
assert report.ok, report.render()
def test_hazard_path_point_off_the_floor_fires() -> None:
level = make_level(
hazards=[{"type": "moving_obstacle", "path": [[1, 1], [0, 0]], "period_s": 8.0}]
)
fired = [
violation.message
for violation in validate_level(level, make_book()).violations
if violation.rule == RuleId.HAZARD_ON_UNREACHABLE_TILE
]
assert len(fired) == 1
assert "path point 1" in fired[0]
def test_hazard_outside_the_grid_fires() -> None:
level = make_level(hazards=[{"type": "spill", "x": 99, "y": 99}])
fired = [
violation.message
for violation in validate_level(level, make_book()).violations
if violation.rule == RuleId.HAZARD_ON_UNREACHABLE_TILE
]
assert len(fired) == 1
assert "outside the 11x7 grid" in fired[0] # clean fixture is 11x7
# --- bundle-level rules -------------------------------------------------------------
def test_duplicate_id_is_a_bundle_violation() -> None:
first = make_level()
second = make_level(order=2) # forgets to rename: same id again
report = validate_levels([first, second], make_book())
dupes = [violation for violation in report.violations if violation.rule == RuleId.DUPLICATE_ID]
assert len(dupes) == 1
assert dupes[0].level_id == "01_clean"
assert dupes[0].detail["count"] == 2
def test_validate_bundle_accepts_a_clean_two_level_campaign() -> None:
first = make_level()
second = make_level(
id="02_later",
order=2,
difficulty=2,
hazards=[{"type": "spill", "x": 5, "y": 2}],
orders=dict(ORDERS, concurrent=2),
)
bundle = ContentBundle(recipe_book=make_book(), levels=(first, second))
report = validate_bundle(bundle)
assert report.ok and not report.warnings, report.render()
def test_validate_bundle_refuses_a_difficulty_drop() -> None:
first = make_level(difficulty=2)
second = make_level(id="02_later", order=2, difficulty=1)
bundle = ContentBundle(recipe_book=make_book(), levels=(first, second))
report = validate_bundle(bundle)
drops = [
violation
for violation in report.violations
if violation.rule == RuleId.DIFFICULTY_ORDER and "drops" in violation.message
]
assert len(drops) == 1
assert drops[0].level_id == "02_later"
# --- the bound itself ---------------------------------------------------------------
def test_theoretical_max_score_needs_known_recipes_and_valid_time() -> None:
book = make_book()
ghost = make_level(orders=dict(ORDERS, pool=["ghost"]))
assert theoretical_max_score(ghost, book) is None
assert theoretical_max_score(make_level(time_limit=10), book) is None
def test_theoretical_max_score_counts_orders_the_time_allows() -> None:
book = make_book()
level = make_level()
cap = theoretical_max_score(level, book)
assert cap is not None
# 150 s / (7.0 s soup + 2*10 tiles / 3.5 tiles/s travel) -> 11 orders x 40 pts.
assert cap == 11 * 40
def test_escalation_axes_are_named_and_higher_is_harder() -> None:
level = make_level()
axes = escalation_axes(level)
assert set(axes) == {
"recipe_complexity",
"order_pressure",
"time_budget",
"station_scarcity",
"layout_hostility",
"hazard_density",
"travel_distance",
}
assert axes["hazard_density"] == 0.0
assert axes["travel_distance"] == 10.0
harder = make_level(
hazards=[{"type": "spill", "x": 5, "y": 2}],
orders=dict(ORDERS, concurrent=2),
)
harder_axes = escalation_axes(harder)
assert harder_axes["hazard_density"] > axes["hazard_density"]
assert harder_axes["order_pressure"] > axes["order_pressure"]