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,207 @@
|
||||
"""Unit tests for `undercooked.sim.grid` (phase 02 task 01).
|
||||
|
||||
Geometry, walkability, BFS around walls, and every `GridError` the ASCII
|
||||
parser must refuse: ragged rows, unknown legend chars, empty/oversized maps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim.grid import MAX_GRID, Grid, GridError
|
||||
from undercooked.sim.model import TileKind, Vec2
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
LEGEND = {
|
||||
".": TileKind.FLOOR,
|
||||
"#": TileKind.WALL,
|
||||
"C": TileKind.COUNTER,
|
||||
"o": TileKind.VOID,
|
||||
}
|
||||
|
||||
|
||||
def make_grid(rows: list[str]) -> Grid:
|
||||
return Grid.from_rows(rows, LEGEND)
|
||||
|
||||
|
||||
# --- Grid.from_rows --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_from_rows_lays_tiles_out_row_major() -> None:
|
||||
grid = make_grid(["..#", "#.o", "C#."])
|
||||
assert (grid.width, grid.height) == (3, 3)
|
||||
assert grid.tiles == (
|
||||
TileKind.FLOOR,
|
||||
TileKind.FLOOR,
|
||||
TileKind.WALL,
|
||||
TileKind.WALL,
|
||||
TileKind.FLOOR,
|
||||
TileKind.VOID,
|
||||
TileKind.COUNTER,
|
||||
TileKind.WALL,
|
||||
TileKind.FLOOR,
|
||||
)
|
||||
assert dict(grid.station_at) == {}
|
||||
|
||||
|
||||
def test_from_rows_rejects_no_rows() -> None:
|
||||
with pytest.raises(GridError, match="no rows"):
|
||||
make_grid([])
|
||||
|
||||
|
||||
def test_from_rows_rejects_ragged_rows() -> None:
|
||||
with pytest.raises(GridError, match="ragged grid: row 1"):
|
||||
make_grid(["...", ".."])
|
||||
|
||||
|
||||
def test_from_rows_rejects_unknown_legend_char() -> None:
|
||||
with pytest.raises(GridError, match=r"unknown legend char '@' at \(2,0\)"):
|
||||
make_grid(["..@"])
|
||||
|
||||
|
||||
def test_from_rows_rejects_empty_row() -> None:
|
||||
with pytest.raises(GridError, match=r"1\.\.64"):
|
||||
make_grid([""])
|
||||
|
||||
|
||||
def test_from_rows_rejects_oversized_map() -> None:
|
||||
with pytest.raises(GridError, match=r"1\.\.64"):
|
||||
make_grid(["." * (MAX_GRID + 1)])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("width", "height", "tiles"),
|
||||
[
|
||||
(0, 1, ()),
|
||||
(1, 0, ()),
|
||||
(MAX_GRID + 1, 1, (TileKind.FLOOR,) * (MAX_GRID + 1)),
|
||||
(2, 2, (TileKind.FLOOR,) * 3), # length mismatch
|
||||
],
|
||||
)
|
||||
def test_direct_construction_validates_dimensions(
|
||||
width: int, height: int, tiles: tuple[TileKind, ...]
|
||||
) -> None:
|
||||
with pytest.raises(GridError):
|
||||
Grid(width=width, height=height, tiles=tiles)
|
||||
|
||||
|
||||
# --- geometry ---------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def grid() -> Grid:
|
||||
return make_grid(["..#", "#.o", "C#."])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("x", "y", "expected"),
|
||||
[(0, 0, True), (2, 2, True), (-1, 0, False), (0, -1, False), (3, 0, False), (0, 3, False)],
|
||||
)
|
||||
def test_in_bounds(grid: Grid, x: int, y: int, expected: bool) -> None:
|
||||
assert grid.in_bounds(x, y) is expected
|
||||
|
||||
|
||||
def test_tile_at_in_and_out_of_bounds(grid: Grid) -> None:
|
||||
assert grid.tile_at(0, 0) is TileKind.FLOOR
|
||||
assert grid.tile_at(2, 0) is TileKind.WALL
|
||||
assert grid.tile_at(1, 1) is TileKind.FLOOR
|
||||
assert grid.tile_at(-1, 0) is None
|
||||
assert grid.tile_at(3, 2) is None
|
||||
|
||||
|
||||
def test_is_walkable(grid: Grid) -> None:
|
||||
assert grid.is_walkable(0, 0) is True
|
||||
assert grid.is_walkable(1, 2) is False # wall
|
||||
assert grid.is_walkable(2, 1) is False # void
|
||||
assert grid.is_walkable(0, 2) is False # counter
|
||||
assert grid.is_walkable(9, 9) is False # off-map
|
||||
|
||||
|
||||
def test_walkable_cells_is_row_major(grid: Grid) -> None:
|
||||
assert grid.walkable_cells() == (0, 1, 4, 8)
|
||||
|
||||
|
||||
def test_blocking_mask_covers_every_cell(grid: Grid) -> None:
|
||||
assert grid.blocking_mask() == (
|
||||
False,
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
True,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
# --- neighbours ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_neighbours_four_way_order_is_north_west_east_south() -> None:
|
||||
grid = make_grid(["...", "...", "..."])
|
||||
assert grid.neighbours(4) == (1, 3, 5, 7)
|
||||
|
||||
|
||||
def test_neighbours_eight_way_is_complete_around_a_center() -> None:
|
||||
grid = make_grid(["...", "...", "..."])
|
||||
assert grid.neighbours(4, four_way=False) == (1, 0, 2, 3, 5, 6, 8, 7)
|
||||
|
||||
|
||||
def test_neighbours_are_clipped_to_the_map() -> None:
|
||||
grid = make_grid(["..", ".."])
|
||||
assert grid.neighbours(0) == (1, 2)
|
||||
assert set(grid.neighbours(0, four_way=False)) == {1, 2, 3}
|
||||
|
||||
|
||||
def test_neighbours_reject_out_of_range_cells() -> None:
|
||||
grid = make_grid([".."])
|
||||
with pytest.raises(GridError, match="out of range"):
|
||||
grid.neighbours(2)
|
||||
|
||||
|
||||
# --- bfs_reachable ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bfs_finds_the_whole_open_region() -> None:
|
||||
grid = make_grid(["...", "..."])
|
||||
assert grid.bfs_reachable(0) == frozenset(range(6))
|
||||
|
||||
|
||||
def test_bfs_walks_around_a_wall_column() -> None:
|
||||
grid = make_grid([".##.", "....", ".##."])
|
||||
# every floor cell is connected through the middle row
|
||||
assert grid.bfs_reachable(0) == frozenset({0, 3, 4, 5, 6, 7, 8, 11})
|
||||
|
||||
|
||||
def test_bfs_cannot_cross_a_full_wall() -> None:
|
||||
grid = make_grid(["...###", "...###"])
|
||||
assert grid.bfs_reachable(0) == frozenset({0, 1, 2, 6, 7, 8})
|
||||
|
||||
|
||||
def test_bfs_honours_extra_blocked_cells() -> None:
|
||||
grid = make_grid(["..."])
|
||||
assert grid.bfs_reachable(0) == frozenset({0, 1, 2})
|
||||
assert grid.bfs_reachable(0, extra_blocked=frozenset({1})) == frozenset({0})
|
||||
assert grid.bfs_reachable(0, extra_blocked=frozenset({0})) == frozenset()
|
||||
|
||||
|
||||
def test_bfs_from_a_non_walkable_start_reaches_nothing() -> None:
|
||||
grid = make_grid([".#"])
|
||||
assert grid.bfs_reachable(1) == frozenset()
|
||||
|
||||
|
||||
def test_bfs_rejects_an_out_of_range_start() -> None:
|
||||
grid = make_grid([".."])
|
||||
with pytest.raises(GridError, match="out of range"):
|
||||
grid.bfs_reachable(2)
|
||||
|
||||
|
||||
# --- manhattan --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_manhattan_distance() -> None:
|
||||
assert Grid.manhattan(Vec2(1.0, 1.0), Vec2(4.0, 5.0)) == 7.0
|
||||
assert Grid.manhattan(Vec2(-2.5, 0.25), Vec2(0.5, 0.25)) == 3.0
|
||||
assert Grid.manhattan(Vec2(2.0, 2.0), Vec2(2.0, 2.0)) == 0.0
|
||||
@@ -0,0 +1,431 @@
|
||||
"""Unit tests for the pure simulation data model (phase 02 task 01, PLAN §5).
|
||||
|
||||
Covers the enum vocabulary, `Vec2` geometry, the frozen entities and their
|
||||
value-semantics helpers, and the `state_hash` contract the replay tests
|
||||
(A21) will rely on: scored changes change the hash, cosmetic ones never do.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim.grid import Grid
|
||||
from undercooked.sim.model import (
|
||||
OPPOSITE,
|
||||
WALKABLE,
|
||||
Event,
|
||||
EventKind,
|
||||
Facing,
|
||||
Hazard,
|
||||
HazardKind,
|
||||
Item,
|
||||
ItemForm,
|
||||
ItemKind,
|
||||
KitchenState,
|
||||
Order,
|
||||
Player,
|
||||
PlayerState,
|
||||
RoundResult,
|
||||
Station,
|
||||
StationKind,
|
||||
StationState,
|
||||
TileKind,
|
||||
Vec2,
|
||||
_canonical,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state() -> KitchenState:
|
||||
grid = Grid(width=3, height=2, tiles=(TileKind.FLOOR,) * 6)
|
||||
return KitchenState(level_id="01_test", seed=42, grid=grid, target_score=40, time_left=90.0)
|
||||
|
||||
|
||||
# --- Facing -------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("facing", "delta"),
|
||||
[
|
||||
(Facing.UP, Vec2(0.0, -1.0)),
|
||||
(Facing.RIGHT, Vec2(1.0, 0.0)),
|
||||
(Facing.DOWN, Vec2(0.0, 1.0)),
|
||||
(Facing.LEFT, Vec2(-1.0, 0.0)),
|
||||
],
|
||||
)
|
||||
def test_facing_delta_is_a_unit_step(facing: Facing, delta: Vec2) -> None:
|
||||
assert facing.delta() == delta
|
||||
|
||||
|
||||
def test_facing_wire_values() -> None:
|
||||
assert [f.value for f in Facing] == ["up", "right", "down", "left"]
|
||||
|
||||
|
||||
def test_opposite_pairs_and_is_involutive() -> None:
|
||||
assert len(OPPOSITE) == 4
|
||||
for facing in Facing:
|
||||
assert OPPOSITE[OPPOSITE[facing]] is facing
|
||||
|
||||
|
||||
# --- Vec2 ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_vec2_defaults_to_zero() -> None:
|
||||
assert Vec2() == Vec2(0.0, 0.0)
|
||||
|
||||
|
||||
def test_vec2_add_and_sub() -> None:
|
||||
a, b = Vec2(2.0, 3.0), Vec2(0.5, -1.0)
|
||||
assert a + b == Vec2(2.5, 2.0)
|
||||
assert a - b == Vec2(1.5, 4.0)
|
||||
|
||||
|
||||
def test_vec2_scale() -> None:
|
||||
assert Vec2(1.5, -2.0).scale(2.0) == Vec2(3.0, -4.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("vec", "expected"),
|
||||
[(Vec2(3.0, 4.0), 5.0), (Vec2(0.0, 0.0), 0.0), (Vec2(-1.0, 0.0), 1.0)],
|
||||
)
|
||||
def test_vec2_length(vec: Vec2, expected: float) -> None:
|
||||
assert vec.length == pytest.approx(expected)
|
||||
|
||||
|
||||
def test_vec2_normalized() -> None:
|
||||
unit = Vec2(3.0, 4.0).normalized()
|
||||
assert unit.x == pytest.approx(0.6)
|
||||
assert unit.y == pytest.approx(0.8)
|
||||
assert unit.length == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_vec2_zero_normalizes_to_zero_without_division_error() -> None:
|
||||
assert Vec2(0.0, 0.0).normalized() == Vec2(0.0, 0.0)
|
||||
|
||||
|
||||
def test_vec2_quantized() -> None:
|
||||
assert Vec2(1.23456, -2.98765).quantized() == Vec2(1.23, -2.99)
|
||||
assert Vec2(1.23456, -2.98765).quantized(digits=1) == Vec2(1.2, -3.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("vec", "key"),
|
||||
[
|
||||
(Vec2(0.0, 0.0), (0, 0)),
|
||||
(Vec2(1.5, 2.25), (96, 144)),
|
||||
(Vec2(0.02, 0.01), (1, 1)),
|
||||
(Vec2(-0.03, -0.007), (-2, 0)),
|
||||
],
|
||||
)
|
||||
def test_vec2_key_is_64th_tile_resolution(vec: Vec2, key: tuple[int, int]) -> None:
|
||||
assert key == vec.KEY
|
||||
|
||||
|
||||
# --- TileKind / WALKABLE -------------------------------------------------------
|
||||
|
||||
|
||||
def test_only_floor_is_walkable() -> None:
|
||||
assert frozenset({TileKind.FLOOR}) == WALKABLE
|
||||
assert {t.value for t in TileKind} == {"floor", "counter", "wall", "void"}
|
||||
|
||||
|
||||
# --- Item ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_item_defaults() -> None:
|
||||
item = Item(kind=ItemKind.TOMATO, form=ItemForm.RAW)
|
||||
assert (item.quality, item.ttl, item.dish_id) == (1.0, 0.0, None)
|
||||
|
||||
|
||||
def test_item_is_trash_only_when_burnt() -> None:
|
||||
for form in ItemForm:
|
||||
item = Item(kind=ItemKind.PATTY, form=form)
|
||||
assert item.is_trash() is (form is ItemForm.BURNT)
|
||||
|
||||
|
||||
def test_item_with_form_returns_a_new_instance() -> None:
|
||||
raw = Item(kind=ItemKind.ONION, form=ItemForm.RAW, quality=0.5)
|
||||
chopped = raw.with_form(ItemForm.CHOPPED)
|
||||
assert chopped is not raw
|
||||
assert chopped.form is ItemForm.CHOPPED
|
||||
assert chopped.kind is ItemKind.ONION and chopped.quality == 0.5
|
||||
assert raw.form is ItemForm.RAW # value semantics: the original never changes
|
||||
|
||||
|
||||
# --- Station / Player / Order / Hazard ------------------------------------------
|
||||
|
||||
|
||||
def test_station_defaults() -> None:
|
||||
station = Station(id="stove_1", kind=StationKind.STOVE, x=3.0, y=2.0)
|
||||
assert (station.contents, station.state) == ((), StationState.IDLE)
|
||||
assert (station.progress, station.duration, station.cooldown) == (0.0, 0.0, 0.0)
|
||||
assert station.worker_id is None and station.dirty is False
|
||||
|
||||
|
||||
def test_station_kinds_and_states_match_the_plan_vocabulary() -> None:
|
||||
assert {k.value for k in StationKind} == {
|
||||
"spawn_shelf",
|
||||
"chop_board",
|
||||
"stove",
|
||||
"prep",
|
||||
"serve",
|
||||
"sink",
|
||||
"trash",
|
||||
}
|
||||
assert {s.value for s in StationState} == {
|
||||
"idle",
|
||||
"working",
|
||||
"ready",
|
||||
"busy",
|
||||
"dirty",
|
||||
"blocked",
|
||||
}
|
||||
|
||||
|
||||
def test_player_defaults() -> None:
|
||||
player = Player(id="tok")
|
||||
assert player.pos == Vec2(0.0, 0.0) and player.vel == Vec2(0.0, 0.0)
|
||||
assert player.facing is Facing.DOWN and player.carrying is None
|
||||
assert player.state is PlayerState.IDLE
|
||||
assert (player.stun, player.score, player.dishes_served) == (0.0, 0, 0)
|
||||
assert player.connected is True and player.seat == 0
|
||||
|
||||
|
||||
def test_player_state_vocabulary() -> None:
|
||||
assert {s.value for s in PlayerState} == {"idle", "moving", "using", "stunned", "disconnected"}
|
||||
|
||||
|
||||
def test_order_defaults() -> None:
|
||||
order = Order(id=17, recipe_id="soup")
|
||||
assert (order.time_left, order.deadline, order.value) == (0.0, 0.0, 0)
|
||||
|
||||
|
||||
def test_hazard_defaults() -> None:
|
||||
hazard = Hazard(id="hz1", kind=HazardKind.FIRE)
|
||||
assert hazard.pos == Vec2(0.0, 0.0)
|
||||
assert hazard.life == 0.0 and hazard.path == () and hazard.period_s == 0.0
|
||||
assert hazard.station_id is None and hazard.chance_per_s == 0.0
|
||||
|
||||
|
||||
def test_hazard_and_result_vocabularies() -> None:
|
||||
assert {k.value for k in HazardKind} == {"fire", "spill", "moving_obstacle", "rush"}
|
||||
assert {r.value for r in RoundResult} == {"won", "lost", "aborted"}
|
||||
|
||||
|
||||
def test_item_kind_and_form_vocabularies() -> None:
|
||||
assert {k.value for k in ItemKind} == {
|
||||
"tomato",
|
||||
"onion",
|
||||
"lettuce",
|
||||
"dough",
|
||||
"patty",
|
||||
"rice",
|
||||
"fish",
|
||||
"plate",
|
||||
}
|
||||
assert {f.value for f in ItemForm} == {
|
||||
"raw",
|
||||
"chopped",
|
||||
"cooking",
|
||||
"cooked",
|
||||
"burnt",
|
||||
"plated",
|
||||
"dirty",
|
||||
}
|
||||
|
||||
|
||||
# --- Event / EventKind -----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kind", "value"),
|
||||
[
|
||||
(EventKind.CHOPPED, "chopped"),
|
||||
(EventKind.SERVED, "served"),
|
||||
(EventKind.BURNT, "burnt"),
|
||||
(EventKind.ORDER_MISSED, "order_missed"),
|
||||
(EventKind.FIRE_STARTED, "fire_started"),
|
||||
(EventKind.DENIED, "denied"),
|
||||
(EventKind.SCORE, "score"),
|
||||
(EventKind.STUNNED, "stunned"),
|
||||
(EventKind.WASHED, "washed"),
|
||||
(EventKind.TRASHED, "trashed"),
|
||||
(EventKind.TICK_SLOW, "tick_slow"),
|
||||
],
|
||||
)
|
||||
def test_event_kind_constants(kind: EventKind, value: str) -> None:
|
||||
assert kind == value
|
||||
|
||||
|
||||
def test_event_defaults() -> None:
|
||||
event = Event(kind=EventKind.SERVED)
|
||||
assert event.kind == "served"
|
||||
assert (event.x, event.y) == (0.0, 0.0)
|
||||
assert dict(event.payload) == {}
|
||||
|
||||
|
||||
def test_event_carries_a_payload() -> None:
|
||||
event = Event(kind=EventKind.SCORE, x=9.0, y=1.0, payload={"points": 40})
|
||||
assert event.payload["points"] == 40
|
||||
|
||||
|
||||
# --- KitchenState.state_hash -------------------------------------------------------
|
||||
|
||||
|
||||
def test_state_hash_is_stable_hex(state: KitchenState) -> None:
|
||||
first = state.state_hash()
|
||||
assert first == state.state_hash()
|
||||
assert len(first) == 64
|
||||
assert all(c in "0123456789abcdef" for c in first)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
[
|
||||
lambda s: replace(s, score=s.score + 1),
|
||||
lambda s: replace(s, target_score=s.target_score + 10),
|
||||
lambda s: replace(s, tick=s.tick + 1),
|
||||
lambda s: replace(s, time_left=s.time_left + 0.01),
|
||||
lambda s: replace(s, streak=s.streak + 1),
|
||||
lambda s: replace(s, result=RoundResult.WON),
|
||||
lambda s: replace(s, seed=s.seed + 1),
|
||||
lambda s: replace(s, level_id="02_other"),
|
||||
lambda s: replace(s, rng_state=s.rng_state ^ 1),
|
||||
lambda s: replace(s, counters={"served": 1}),
|
||||
lambda s: replace(s, players=(Player(id="p1"),)),
|
||||
lambda s: replace(s, stations=(Station(id="s1", kind=StationKind.STOVE),)),
|
||||
lambda s: replace(s, orders=(Order(id=1, recipe_id="soup"),)),
|
||||
lambda s: replace(s, hazards=(Hazard(id="h1", kind=HazardKind.FIRE),)),
|
||||
lambda s: replace(
|
||||
s, grid=Grid(width=3, height=2, tiles=(TileKind.FLOOR,) * 5 + (TileKind.WALL,))
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"score",
|
||||
"target_score",
|
||||
"tick",
|
||||
"time_left",
|
||||
"streak",
|
||||
"result",
|
||||
"seed",
|
||||
"level_id",
|
||||
"rng_state",
|
||||
"counters",
|
||||
"players",
|
||||
"stations",
|
||||
"orders",
|
||||
"hazards",
|
||||
"grid",
|
||||
],
|
||||
)
|
||||
def test_state_hash_changes_for_scored_fields(
|
||||
state: KitchenState, mutation: Any
|
||||
) -> None:
|
||||
assert mutation(state).state_hash() != state.state_hash()
|
||||
|
||||
|
||||
def test_state_hash_ignores_counter_insertion_order() -> None:
|
||||
grid = Grid(width=1, height=1, tiles=(TileKind.FLOOR,))
|
||||
left = KitchenState("01_a", 1, grid, counters={"served": 2, "missed": 1})
|
||||
right = KitchenState("01_a", 1, grid, counters={"missed": 1, "served": 2})
|
||||
assert left.state_hash() == right.state_hash()
|
||||
|
||||
|
||||
def test_state_hash_ignores_station_at_insertion_order() -> None:
|
||||
tiles = (TileKind.FLOOR,) * 6
|
||||
grid_a = Grid(width=3, height=2, tiles=tiles, station_at={0: "shelf", 5: "serve"})
|
||||
grid_b = Grid(width=3, height=2, tiles=tiles, station_at={5: "serve", 0: "shelf"})
|
||||
assert (
|
||||
KitchenState("01_a", 1, grid_a).state_hash()
|
||||
== KitchenState("01_a", 1, grid_b).state_hash()
|
||||
)
|
||||
|
||||
|
||||
def test_state_hash_ignores_float_repr_noise(state: KitchenState) -> None:
|
||||
# cosmetic float noise (< 1e-6) is below the canonical quantization...
|
||||
assert replace(state, time_left=90.0 + 1e-9).state_hash() == state.state_hash()
|
||||
assert replace(state, time_left=-0.0).state_hash() == replace(
|
||||
state, time_left=0.0
|
||||
).state_hash()
|
||||
# ...but a real difference above it always changes the hash
|
||||
assert replace(state, time_left=90.000001).state_hash() != state.state_hash()
|
||||
|
||||
|
||||
# --- KitchenState.with_* helpers -----------------------------------------------------
|
||||
|
||||
|
||||
def test_with_tick_and_time_left_return_new_instances(state: KitchenState) -> None:
|
||||
advanced = state.with_tick(7).with_time_left(83.5)
|
||||
assert advanced.tick == 7 and advanced.time_left == 83.5
|
||||
assert advanced is not state
|
||||
assert state.tick == 0 and state.time_left == 90.0
|
||||
|
||||
|
||||
def test_with_score_optionally_sets_streak(state: KitchenState) -> None:
|
||||
scored = state.with_score(40)
|
||||
assert scored.score == 40 and scored.streak == state.streak
|
||||
with_streak = state.with_score(40, streak=3)
|
||||
assert with_streak.score == 40 and with_streak.streak == 3
|
||||
assert state.score == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("result", [RoundResult.WON, RoundResult.LOST, None])
|
||||
def test_with_result(state: KitchenState, result: RoundResult | None) -> None:
|
||||
updated = state.with_result(result)
|
||||
assert updated.result is result
|
||||
assert state.result is None
|
||||
|
||||
|
||||
def test_with_entity_collections(state: KitchenState) -> None:
|
||||
stations = (Station(id="s1", kind=StationKind.CHOP_BOARD),)
|
||||
players = (Player(id="p1", seat=0),)
|
||||
orders = (Order(id=1, recipe_id="soup"),)
|
||||
hazards = (Hazard(id="h1", kind=HazardKind.SPILL),)
|
||||
updated = (
|
||||
state.with_stations(stations)
|
||||
.with_players(players)
|
||||
.with_orders(orders)
|
||||
.with_hazards(hazards)
|
||||
)
|
||||
assert updated.stations == stations
|
||||
assert updated.players == players
|
||||
assert updated.orders == orders
|
||||
assert updated.hazards == hazards
|
||||
assert state.stations == () and state.players == ()
|
||||
assert state.orders == () and state.hazards == ()
|
||||
|
||||
|
||||
def test_with_counters_merges_without_mutating(state: KitchenState) -> None:
|
||||
merged = state.with_counters(served=1).with_counters(served=2, missed=1)
|
||||
assert dict(merged.counters) == {"served": 2, "missed": 1}
|
||||
assert dict(state.counters) == {}
|
||||
|
||||
|
||||
# --- _canonical ------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_canonical_scalars_are_typed_tokens() -> None:
|
||||
assert _canonical(None) == "n:"
|
||||
assert _canonical(True) == "b:1"
|
||||
assert _canonical(1) == "i:1" # bool is checked before int
|
||||
assert _canonical("served") == "s:served"
|
||||
assert _canonical(Facing.UP) == "s:up" # StrEnums canonicalise by value
|
||||
assert _canonical(2.5) == "f:2.500000"
|
||||
|
||||
|
||||
def test_canonical_sorts_mapping_keys() -> None:
|
||||
assert _canonical({"b": 1, "a": 2}) == _canonical({"a": 2, "b": 1})
|
||||
|
||||
|
||||
def test_canonical_renders_dataclasses_field_name_sorted() -> None:
|
||||
assert _canonical(Vec2(1.0, 2.0)) == "d:Vec2(x=f:1.000000,y=f:2.000000)"
|
||||
|
||||
|
||||
def test_canonical_rejects_unknown_types_so_fields_cannot_hide() -> None:
|
||||
with pytest.raises(TypeError, match="cannot canonicalise"):
|
||||
_canonical(object())
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Unit tests for the A5 purity guard (phase 02 task 01).
|
||||
|
||||
Proves the shipped `sim/` package imports nothing forbidden, and — just as
|
||||
important — that the guard actually catches a violation when one exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim.purity import (
|
||||
FORBIDDEN_ROOTS,
|
||||
PurityError,
|
||||
assert_pure_imports,
|
||||
find_violations,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def test_forbidden_roots_are_the_plan_a5_list() -> None:
|
||||
assert frozenset({"asyncio", "fastapi", "socket", "time", "uvicorn"}) == FORBIDDEN_ROOTS
|
||||
|
||||
|
||||
def test_shipped_sim_package_is_pure() -> None:
|
||||
assert find_violations() == []
|
||||
assert_pure_imports() # must not raise
|
||||
|
||||
|
||||
def test_clean_module_with_stdlib_sibling_and_relative_imports(tmp_path: Path) -> None:
|
||||
(tmp_path / "ok.py").write_text(
|
||||
"import math\n"
|
||||
"from hashlib import sha256\n"
|
||||
"from undercooked.sim.model import Vec2\n"
|
||||
"from . import model\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert find_violations(tmp_path) == []
|
||||
|
||||
|
||||
def test_comments_and_strings_are_not_imports(tmp_path: Path) -> None:
|
||||
(tmp_path / "prose.py").write_text(
|
||||
'# we must never "import asyncio" here\nDOC = "import time is forbidden"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert find_violations(tmp_path) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("statement", "root"),
|
||||
[
|
||||
("import asyncio", "asyncio"),
|
||||
("import time", "time"),
|
||||
("from time import monotonic", "time"),
|
||||
("import socket", "socket"),
|
||||
("from socket import socket", "socket"),
|
||||
("from fastapi import FastAPI", "fastapi"),
|
||||
("import uvicorn", "uvicorn"),
|
||||
("import uvicorn.protocols", "uvicorn"),
|
||||
],
|
||||
)
|
||||
def test_find_violations_detects_each_forbidden_import(
|
||||
tmp_path: Path, statement: str, root: str
|
||||
) -> None:
|
||||
(tmp_path / "bad.py").write_text(f"{statement}\n", encoding="utf-8")
|
||||
assert find_violations(tmp_path) == [f"bad.py: imports {root}"]
|
||||
|
||||
|
||||
def test_find_violations_scans_subpackages(tmp_path: Path) -> None:
|
||||
sub = tmp_path / "deep"
|
||||
sub.mkdir()
|
||||
(sub / "leak.py").write_text("import asyncio\n", encoding="utf-8")
|
||||
assert find_violations(tmp_path) == ["deep/leak.py: imports asyncio"]
|
||||
|
||||
|
||||
def test_assert_pure_imports_raises_with_the_violation_list(tmp_path: Path) -> None:
|
||||
(tmp_path / "bad.py").write_text("import time\nimport socket\n", encoding="utf-8")
|
||||
with pytest.raises(PurityError, match=r"bad\.py: imports socket"):
|
||||
assert_pure_imports(tmp_path)
|
||||
@@ -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"]
|
||||
@@ -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