Files
ducoterra 3426264ffb 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.
2026-10-06 11:03:28 -04:00

432 lines
13 KiB
Python

"""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())