"""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"]