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.
432 lines
13 KiB
Python
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())
|