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