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