mud/tests/test_fly.py
Jared Miller 875ded5762
Migrate fly to use player.location (Zone)
Removed module-level world variable and replaced all world.wrap() calls
with player.location.wrap(). Added Zone assertion for type safety,
matching the pattern in movement.py. Updated tests to remove fly.world
injection since it's no longer needed.
2026-02-11 19:33:15 -05:00

307 lines
7.7 KiB
Python

"""Tests for the fly command."""
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands import fly
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."""
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