"""Tests for the fly command.""" import time from unittest.mock import AsyncMock, MagicMock import pytest from mudlib.commands import fly, look from mudlib.effects import active_effects from mudlib.player import Player, players from mudlib.zone import Zone @pytest.fixture def mock_writer(): writer = MagicMock() writer.write = MagicMock() writer.drain = AsyncMock() return writer @pytest.fixture def mock_reader(): return MagicMock() @pytest.fixture def test_zone(): terrain = [["." for _ in range(100)] for _ in range(100)] zone = Zone( name="testzone", width=100, height=100, toroidal=True, terrain=terrain, impassable=set(), ) return zone @pytest.fixture def player(mock_reader, mock_writer, test_zone): p = Player(name="shmup", x=50, y=50, reader=mock_reader, writer=mock_writer) p.location = test_zone test_zone._contents.append(p) return p @pytest.fixture(autouse=True) def clean_state(test_zone): """Clean global state before/after each test.""" fly.world = test_zone look.world = test_zone players.clear() active_effects.clear() yield players.clear() active_effects.clear() # --- toggle --- @pytest.mark.asyncio async def test_fly_toggles_on(player, mock_writer): """fly with no args sets flying=True.""" players[player.name] = player assert not player.flying await fly.cmd_fly(player, "") assert player.flying calls = [str(c) for c in mock_writer.write.call_args_list] assert any("fly" in c.lower() and "air" in c.lower() for c in calls) @pytest.mark.asyncio async def test_fly_toggles_off(player, mock_writer): """fly again sets flying=False.""" players[player.name] = player player.flying = True await fly.cmd_fly(player, "") assert not player.flying calls = [str(c) for c in mock_writer.write.call_args_list] assert any("land" in c.lower() for c in calls) @pytest.mark.asyncio async def test_fly_toggle_on_notifies_nearby(player, test_zone): """Others see liftoff message.""" players[player.name] = player other_writer = MagicMock() other_writer.write = MagicMock() other_writer.drain = AsyncMock() other = Player( name="bystander", x=52, y=50, reader=MagicMock(), writer=other_writer, ) other.location = test_zone test_zone._contents.append(other) players[other.name] = other await fly.cmd_fly(player, "") calls = [str(c) for c in other_writer.write.call_args_list] assert any("shmup" in c.lower() and "lifts" in c.lower() for c in calls) @pytest.mark.asyncio async def test_fly_toggle_off_notifies_nearby(player, test_zone): """Others see landing message.""" players[player.name] = player player.flying = True other_writer = MagicMock() other_writer.write = MagicMock() other_writer.drain = AsyncMock() other = Player( name="bystander", x=52, y=50, reader=MagicMock(), writer=other_writer, ) other.location = test_zone test_zone._contents.append(other) players[other.name] = other await fly.cmd_fly(player, "") calls = [str(c) for c in other_writer.write.call_args_list] assert any("shmup" in c.lower() and "lands" in c.lower() for c in calls) # --- must be flying to move --- @pytest.mark.asyncio async def test_fly_direction_without_flying_errors(player, mock_writer): """fly east when not flying gives an error.""" players[player.name] = player assert not player.flying await fly.cmd_fly(player, "east") calls = [str(c) for c in mock_writer.write.call_args_list] assert any("aren't flying" in c.lower() for c in calls) assert player.x == 50 assert player.y == 50 # --- movement while flying --- @pytest.mark.asyncio async def test_fly_east_moves_5_tiles(player): """fly east should move player 5 tiles east.""" players[player.name] = player player.flying = True await fly.cmd_fly(player, "east") assert player.x == 55 assert player.y == 50 @pytest.mark.asyncio async def test_fly_north_moves_5_tiles(player): players[player.name] = player player.flying = True await fly.cmd_fly(player, "north") assert player.x == 50 assert player.y == 45 @pytest.mark.asyncio async def test_fly_northwest_moves_5_diagonal(player): players[player.name] = player player.flying = True await fly.cmd_fly(player, "nw") assert player.x == 45 assert player.y == 45 @pytest.mark.asyncio async def test_fly_wraps_around_world(player): """Flying near an edge should wrap toroidally.""" player.x = 98 player.y = 50 player.flying = True players[player.name] = player await fly.cmd_fly(player, "east") assert player.x == 3 assert player.y == 50 # --- cloud trail --- @pytest.mark.asyncio async def test_fly_creates_cloud_trail(player): """Flying should leave ~ effects along the path.""" players[player.name] = player player.flying = True await fly.cmd_fly(player, "east") assert len(active_effects) == 5 trail_positions = [(e.x, e.y) for e in active_effects] for i in range(5): assert (50 + i, 50) in trail_positions @pytest.mark.asyncio async def test_cloud_trail_chars_are_tilde(player): players[player.name] = player player.flying = True await fly.cmd_fly(player, "east") for e in active_effects: assert e.char == "~" @pytest.mark.asyncio async def test_cloud_trail_dissolves_from_origin(player): """Origin cloud expires first, trail shrinks toward player.""" players[player.name] = player player.flying = True before = time.monotonic() await fly.cmd_fly(player, "east") # effects are in path order (step 0..4) expiries = [e.expires_at for e in active_effects] # each should expire after the previous one (origin fades first) for i in range(1, len(expiries)): assert expiries[i] > expiries[i - 1] # origin cloud ~1.5s, near-dest cloud ~1.5 + 4*0.4 = ~3.1s assert 1.3 <= expiries[0] - before <= 1.7 assert 2.9 <= expiries[-1] - before <= 3.3 @pytest.mark.asyncio async def test_fly_diagonal_trail(player): """Diagonal flight should leave trail at each intermediate step.""" players[player.name] = player player.flying = True await fly.cmd_fly(player, "se") assert len(active_effects) == 5 trail_positions = [(e.x, e.y) for e in active_effects] for i in range(5): assert (50 + i, 50 + i) in trail_positions # --- no movement without flying leaves no trail --- @pytest.mark.asyncio async def test_no_trail_when_not_flying(player): """Trying to fly a direction while grounded creates no effects.""" players[player.name] = player await fly.cmd_fly(player, "east") assert len(active_effects) == 0 # --- error cases --- @pytest.mark.asyncio async def test_fly_bad_direction_gives_error(player, mock_writer): """fly with an invalid direction should give feedback.""" players[player.name] = player player.flying = True await fly.cmd_fly(player, "sideways") calls = [str(c) for c in mock_writer.write.call_args_list] assert any("direction" in c.lower() for c in calls) assert player.x == 50 assert player.y == 50 @pytest.mark.asyncio async def test_fly_triggers_look(player, test_zone): """Flying should auto-look at the destination.""" players[player.name] = player player.flying = True await fly.cmd_fly(player, "east") # look was called (check that writer was written to) assert player.writer.write.called @pytest.mark.asyncio async def test_stays_flying_after_move(player): """Moving while flying doesn't turn off flying.""" players[player.name] = player player.flying = True await fly.cmd_fly(player, "east") assert player.flying