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,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"]
|
||||
Reference in New Issue
Block a user