"""Unit tests for `undercooked.sim.level` (phase 02 task 02). The level schema is what makes a YAML file *well-formed* — rectangular grid, complete legend both directions, spawns on the map, exactly one serve, ordered spawn intervals, placed hazards — and `build_grid`/`Level.build` are the deterministic geometry those rules guarantee. Each validator branch gets a failing document; the happy path asserts the documented defaults and station id conventions from `docs/LEVELS.md`. """ from __future__ import annotations from typing import Any import pytest from pydantic import ValidationError from undercooked.sim.grid import MAX_GRID, Grid from undercooked.sim.level import ( HazardSpec, Level, LevelDef, OrdersSpec, StationLimits, TileSpec, build_grid, build_station_items, build_stations, ) from undercooked.sim.model import HazardKind, ItemKind, StationKind, TileKind pytestmark = pytest.mark.unit TILES: dict[str, Any] = { ".": {"kind": "floor"}, "#": {"kind": "wall"}, "S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"}, "C": {"kind": "floor", "station": "chop_board"}, "V": {"kind": "floor", "station": "serve"}, } GRID = ["#####", "#S..#", "#.C.#", "#..V#", "#####"] def level_dict(**overrides: Any) -> dict[str, Any]: """A minimal valid level body; overrides replace top-level fields.""" body: dict[str, Any] = { "id": "01_first_kitchen", "name": "First Kitchen", "order": 1, "time_limit": 150, "target_score": 45, "grid": list(GRID), "tiles": {key: dict(value) for key, value in TILES.items()}, "spawns": [[1, 1]], "orders": { "pool": ["soup"], "concurrent": 1, "spawn_interval": [16, 20], "deadline": 60, }, } body.update(overrides) return body def make_level(**overrides: Any) -> LevelDef: return LevelDef.model_validate(level_dict(**overrides)) # ------------------------------------------------------------------------- TileSpec def test_plain_tiles_default_to_no_station_no_item() -> None: spec = TileSpec.model_validate({"kind": "floor"}) assert spec.kind is TileKind.FLOOR assert spec.station is None assert spec.item is None assert spec.id_prefix is None def test_spawn_shelf_must_name_the_item_it_spawns() -> None: with pytest.raises(ValidationError, match="must name the item"): TileSpec.model_validate({"kind": "floor", "station": "spawn_shelf"}) def test_only_a_spawn_shelf_may_carry_an_item() -> None: with pytest.raises(ValidationError, match="only a spawn_shelf"): TileSpec.model_validate({"kind": "floor", "station": "chop_board", "item": "tomato"}) def test_item_without_any_station_is_meaningless() -> None: with pytest.raises(ValidationError, match="only a spawn_shelf"): TileSpec.model_validate({"kind": "floor", "item": "tomato"}) def test_id_prefix_belongs_to_a_station_tile() -> None: with pytest.raises(ValidationError, match="id_prefix belongs to a station"): TileSpec.model_validate({"kind": "wall", "id_prefix": "north"}) def test_id_prefix_must_not_be_empty() -> None: with pytest.raises(ValidationError): TileSpec.model_validate({"kind": "floor", "station": "stove", "id_prefix": ""}) def test_tilespec_rejects_unknown_keys_and_values() -> None: with pytest.raises(ValidationError): TileSpec.model_validate({"kind": "floor", "glow": True}) with pytest.raises(ValidationError): TileSpec.model_validate({"kind": "swamp"}) # ------------------------------------------------------------------------ OrdersSpec def test_orders_defaults() -> None: orders = OrdersSpec.model_validate( {"pool": ["soup"], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 42} ) assert orders.penalty == 10 assert orders.spawn_interval == (7.0, 11.0) def test_spawn_interval_min_must_not_exceed_max() -> None: with pytest.raises(ValidationError, match="greater than its end"): OrdersSpec.model_validate( {"pool": ["soup"], "concurrent": 1, "spawn_interval": [12, 8], "deadline": 42} ) def test_spawn_interval_must_start_above_zero() -> None: with pytest.raises(ValidationError, match="above 0"): OrdersSpec.model_validate( {"pool": ["soup"], "concurrent": 1, "spawn_interval": [0, 8], "deadline": 42} ) def test_orders_pool_needs_recipes_and_sane_numbers() -> None: with pytest.raises(ValidationError, match="pool"): OrdersSpec.model_validate( {"pool": [], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 42} ) with pytest.raises(ValidationError, match="concurrent"): OrdersSpec.model_validate( {"pool": ["soup"], "concurrent": 0, "spawn_interval": [7, 11], "deadline": 42} ) with pytest.raises(ValidationError, match="deadline"): OrdersSpec.model_validate( {"pool": ["soup"], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 0} ) with pytest.raises(ValidationError, match="penalty"): OrdersSpec.model_validate( {"pool": ["soup"], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 42, "penalty": -1} ) # ------------------------------------------------------------------------ HazardSpec def hazard_dict(**overrides: Any) -> dict[str, Any]: body: dict[str, Any] = {"type": "spill", "x": 3, "y": 4} body.update(overrides) return body def test_hazard_defaults() -> None: hazard = HazardSpec.model_validate(hazard_dict()) assert hazard.type is HazardKind.SPILL assert hazard.chance_per_s is None assert hazard.respawn_s is None assert hazard.path is None assert hazard.intensity == 1.0 def test_fire_may_attach_to_a_station() -> None: hazard = HazardSpec.model_validate({"type": "fire", "station": "stove_2"}) assert hazard.station == "stove_2" def test_fire_needs_a_station_or_coordinates() -> None: with pytest.raises(ValidationError, match="fire hazard needs a station or x/y"): HazardSpec.model_validate({"type": "fire"}) def test_spill_needs_a_location_too() -> None: with pytest.raises(ValidationError, match="spill hazard needs a station or x/y"): HazardSpec.model_validate({"type": "spill"}) def test_station_and_coordinates_are_exclusive() -> None: with pytest.raises(ValidationError, match="not both"): HazardSpec.model_validate({"type": "fire", "station": "stove_0", "x": 2, "y": 3}) def test_coordinates_come_as_a_pair() -> None: with pytest.raises(ValidationError, match="given together"): HazardSpec.model_validate({"type": "spill", "x": 2}) with pytest.raises(ValidationError, match="given together"): HazardSpec.model_validate({"type": "spill", "y": 3}) def test_moving_obstacle_needs_a_route_and_a_period() -> None: with pytest.raises(ValidationError, match="path of at least 2 points"): HazardSpec.model_validate({"type": "moving_obstacle", "period_s": 9}) with pytest.raises(ValidationError, match="path of at least 2 points"): HazardSpec.model_validate( {"type": "moving_obstacle", "path": [[4, 3]], "period_s": 9} ) with pytest.raises(ValidationError, match="needs period_s"): HazardSpec.model_validate({"type": "moving_obstacle", "path": [[4, 3], [10, 3]]}) def test_moving_obstacle_happy_path_needs_no_placement() -> None: hazard = HazardSpec.model_validate( {"type": "moving_obstacle", "path": [[4, 3], [10, 3]], "period_s": 9} ) assert hazard.path == [(4, 3), (10, 3)] assert hazard.period_s == 9.0 def test_rush_is_global_but_needs_positive_intensity() -> None: assert HazardSpec.model_validate({"type": "rush"}).intensity == 1.0 with pytest.raises(ValidationError, match="positive intensity"): HazardSpec.model_validate({"type": "rush", "intensity": 0}) def test_hazard_numeric_bounds() -> None: with pytest.raises(ValidationError, match="chance_per_s"): HazardSpec.model_validate(hazard_dict(chance_per_s=-0.01)) with pytest.raises(ValidationError, match="respawn_s"): HazardSpec.model_validate(hazard_dict(respawn_s=0)) # -------------------------------------------------------------------------- LevelDef def test_level_defaults_match_the_documented_values() -> None: level = make_level() assert level.hazards == [] assert level.station_limits.plates == 99 assert level.station_limits.pots == 99 assert level.difficulty == 1 assert level.blurb == "" assert level.escalation_notes == "" def test_ragged_grid_is_refused() -> None: with pytest.raises(ValidationError, match="ragged grid: row 4 is 4 chars, want 5"): make_level(grid=["#####", "#S..#", "#.C.#", "#..V#", "####"]) def test_empty_rows_are_refused() -> None: with pytest.raises(ValidationError, match="rows must not be empty"): make_level(grid=["", ""], tiles={".": {"kind": "floor"}, "V": {"kind": "floor", "station": "serve"}}, spawns=[]) def test_grid_larger_than_the_engine_supports_is_refused() -> None: wide = "#" * (MAX_GRID + 1) with pytest.raises(ValidationError, match=f"at most {MAX_GRID}x{MAX_GRID}"): make_level(grid=[wide, wide]) def test_grid_character_without_a_legend_entry_is_refused() -> None: with pytest.raises(ValidationError, match="grid character 'Z' has no tiles"): make_level(grid=["#####", "#Z..#", "#.C.#", "#..V#", "#####"]) def test_unused_legend_character_is_refused() -> None: with pytest.raises(ValidationError, match="legend character 'X' is never used"): make_level(tiles={**TILES, "X": {"kind": "wall"}}) def test_level_needs_at_least_one_spawn() -> None: with pytest.raises(ValidationError, match="at least one spawn"): make_level(spawns=[]) def test_spawns_must_be_inside_the_grid() -> None: with pytest.raises(ValidationError, match=r"spawn \(9,9\) is outside the 5x5 grid"): make_level(spawns=[[9, 9]]) with pytest.raises(ValidationError, match=r"spawn \(-1,2\) is outside"): make_level(spawns=[[-1, 2]]) def test_level_needs_exactly_one_serve_legend() -> None: no_serve = {key: dict(value) for key, value in TILES.items() if key != "V"} with pytest.raises(ValidationError, match="found 0"): make_level(grid=["#####", "#S..#", "#.C.#", "#...#", "#####"], tiles=no_serve) with pytest.raises(ValidationError, match="found 2"): make_level( grid=["#####", "#S..#", "#.C.#", "#V.U#", "#####"], tiles={**TILES, "U": {"kind": "floor", "station": "serve"}}, ) def test_level_numbers_are_validated() -> None: with pytest.raises(ValidationError, match="difficulty"): make_level(difficulty=11) with pytest.raises(ValidationError, match="difficulty"): make_level(difficulty=0) with pytest.raises(ValidationError, match="order"): make_level(order=0) with pytest.raises(ValidationError, match="time_limit"): make_level(time_limit=0) with pytest.raises(ValidationError, match="target_score"): make_level(target_score=0) def test_level_rejects_unknown_keys() -> None: with pytest.raises(ValidationError): make_level(weather="rain") def test_level_requires_a_grid_and_a_legend() -> None: with pytest.raises(ValidationError, match="grid"): make_level(grid=[]) with pytest.raises(ValidationError, match="tiles"): make_level(tiles={}) def test_hazards_and_station_limits_round_trip_on_the_level() -> None: level = make_level( hazards=[ {"type": "fire", "station": "stove_2", "chance_per_s": 0.02}, {"type": "moving_obstacle", "path": [[4, 3], [10, 3]], "period_s": 9}, ], station_limits={"plates": 3, "pots": 2}, difficulty=7, blurb="A kitchen.", escalation_notes="tighter", ) assert [hazard.type for hazard in level.hazards] == [ HazardKind.FIRE, HazardKind.MOVING_OBSTACLE, ] assert level.station_limits == StationLimits(plates=3, pots=2) assert level.difficulty == 7 # ------------------------------------------------------------------- building geometry def test_build_grid_maps_the_legend_and_marks_station_cells() -> None: level = make_level() grid = build_grid(level) assert isinstance(grid, Grid) assert (grid.width, grid.height) == (5, 5) assert grid.tile_at(0, 0) is TileKind.WALL assert grid.tile_at(1, 1) is TileKind.FLOOR assert dict(grid.station_at) == {6: "spawn_shelf_0", 12: "chop_board_0", 18: "serve_0"} def test_station_ids_count_per_kind_in_reading_order() -> None: stations = build_stations(make_level()) assert [(station.id, station.kind) for station in stations] == [ ("spawn_shelf_0", StationKind.SPAWN_SHELF), ("chop_board_0", StationKind.CHOP_BOARD), ("serve_0", StationKind.SERVE), ] def test_two_legend_chars_of_one_station_kind_share_one_counter() -> None: level = make_level( grid=["######", "#T...#", "#..t.#", "#...V#", "######"], tiles={ ".": {"kind": "floor"}, "#": {"kind": "wall"}, "T": {"kind": "floor", "station": "stove"}, "t": {"kind": "floor", "station": "stove", "id_prefix": "burner"}, "V": {"kind": "floor", "station": "serve"}, }, ) stations = build_stations(level) assert [station.id for station in stations] == ["stove_0", "burner_1", "serve_0"] def test_station_positions_are_float_tile_coordinates() -> None: chop = next(station for station in build_stations(make_level()) if station.kind is StationKind.CHOP_BOARD) assert (chop.x, chop.y) == (2.0, 2.0) def test_a_station_on_a_non_floor_tile_still_builds_and_is_task_03_problem() -> None: level = make_level( grid=["#####", "#S..#", "#.C.#", "#.=V#", "#####"], tiles={**TILES, "=": {"kind": "counter", "station": "stove"}}, ) grid = build_grid(level) assert grid.tile_at(2, 3) is TileKind.COUNTER assert grid.station_at[17] == "stove_0" def test_station_items_cover_only_spawn_shelves() -> None: level = make_level( grid=["######", "#Ss..#", "#.C..#", "#...V#", "######"], tiles={ ".": {"kind": "floor"}, "#": {"kind": "wall"}, "S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"}, "s": {"kind": "floor", "station": "spawn_shelf", "item": "onion"}, "C": {"kind": "floor", "station": "chop_board"}, "V": {"kind": "floor", "station": "serve"}, }, spawns=[[1, 1], [2, 1]], ) assert build_station_items(level) == { "spawn_shelf_0": ItemKind.TOMATO, "spawn_shelf_1": ItemKind.ONION, } def test_level_build_pairs_the_spec_with_grid_stations_and_spawns() -> None: spec = make_level() level = Level.build(spec) assert level.spec is spec assert level.grid == build_grid(spec) assert level.stations == build_stations(spec) assert level.spawns == ((1, 1),) assert level.station_items == {"spawn_shelf_0": ItemKind.TOMATO} def test_level_delegates_the_authoring_fields_to_its_spec() -> None: level = Level.build( make_level(difficulty=3, blurb="Cook!", escalation_notes="harder", station_limits={"plates": 4}) ) assert level.id == "01_first_kitchen" assert level.name == "First Kitchen" assert level.order == 1 assert level.difficulty == 3 assert level.time_limit == 150 assert level.target_score == 45 assert level.orders.pool == ["soup"] assert level.hazards == [] assert level.station_limits.plates == 4 assert level.blurb == "Cook!" assert level.escalation_notes == "harder"