mud/tests/test_commands.py
Jared Miller 9844749edd
Add fly command with cloud trail effects
fly <direction> moves the player 5 tiles, ignoring terrain. Leaves
a trail of bright white ~ clouds that fade after 2 seconds. Effects
system supports arbitrary timed visual overlays on the viewport.
TinTin aliases: fn/fs/fe/fw/fne/fnw/fse/fsw.
2026-02-07 14:48:42 -05:00

319 lines
9.1 KiB
Python

"""Tests for the command system."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib import commands
from mudlib.commands import look, movement
from mudlib.effects import active_effects, add_effect
from mudlib.player import Player
from mudlib.render.ansi import RESET
@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 mock_world():
world = MagicMock()
world.width = 100
world.height = 100
world.is_passable = MagicMock(return_value=True)
world.wrap = MagicMock(side_effect=lambda x, y: (x % 100, y % 100))
# Create a 21x11 viewport filled with "."
viewport = [["." for _ in range(21)] for _ in range(11)]
world.get_viewport = MagicMock(return_value=viewport)
return world
@pytest.fixture
def player(mock_reader, mock_writer):
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
# Test command registration
def test_register_command():
"""Test that commands can be registered."""
async def test_handler(player, args):
pass
commands.register("test", test_handler)
assert "test" in commands._registry
def test_register_command_with_aliases():
"""Test that command aliases work."""
async def test_handler(player, args):
pass
commands.register("testcmd", test_handler, aliases=["tc", "t"])
assert "testcmd" in commands._registry
assert "tc" in commands._registry
assert "t" in commands._registry
assert commands._registry["testcmd"] == commands._registry["tc"]
assert commands._registry["testcmd"] == commands._registry["t"]
@pytest.mark.asyncio
async def test_dispatch_routes_to_handler(player):
"""Test that dispatch routes input to the correct handler."""
called = False
received_args = None
async def test_handler(p, args):
nonlocal called, received_args
called = True
received_args = args
commands.register("testcmd", test_handler)
await commands.dispatch(player, "testcmd arg1 arg2")
assert called
assert received_args == "arg1 arg2"
@pytest.mark.asyncio
async def test_dispatch_handles_unknown_command(player, mock_writer):
"""Test that unknown commands give feedback."""
await commands.dispatch(player, "unknowncommand")
# Should have written some kind of error message
assert mock_writer.write.called
error_msg = mock_writer.write.call_args[0][0]
assert "unknown" in error_msg.lower() or "not found" in error_msg.lower()
@pytest.mark.asyncio
async def test_dispatch_handles_empty_input(player):
"""Test that empty input doesn't crash."""
await commands.dispatch(player, "")
await commands.dispatch(player, " ")
# Test movement direction parsing
@pytest.mark.parametrize(
"direction,expected_delta",
[
("n", (0, -1)),
("north", (0, -1)),
("s", (0, 1)),
("south", (0, 1)),
("e", (1, 0)),
("east", (1, 0)),
("w", (-1, 0)),
("west", (-1, 0)),
("ne", (1, -1)),
("northeast", (1, -1)),
("nw", (-1, -1)),
("northwest", (-1, -1)),
("se", (1, 1)),
("southeast", (1, 1)),
("sw", (-1, 1)),
("southwest", (-1, 1)),
],
)
def test_direction_deltas(direction, expected_delta):
"""Test that all direction commands map to correct deltas."""
assert movement.DIRECTIONS[direction] == expected_delta
@pytest.mark.asyncio
async def test_movement_updates_position(player, mock_world):
"""Test that movement updates player position when passable."""
# Inject mock world into both movement and look modules
movement.world = mock_world
look.world = mock_world
original_x, original_y = player.x, player.y
await movement.move_north(player, "")
assert player.x == original_x
assert player.y == original_y - 1
assert mock_world.is_passable.called
@pytest.mark.asyncio
async def test_movement_blocked_by_impassable_terrain(player, mock_world, mock_writer):
"""Test that movement is blocked by impassable terrain."""
mock_world.is_passable.return_value = False
movement.world = mock_world
original_x, original_y = player.x, player.y
await movement.move_north(player, "")
# Position should not change
assert player.x == original_x
assert player.y == original_y
# Should send a message to the player
assert mock_writer.write.called
error_msg = mock_writer.write.call_args[0][0]
assert "can't" in error_msg.lower() or "cannot" in error_msg.lower()
@pytest.mark.asyncio
async def test_movement_sends_departure_message(player, mock_world):
"""Test that movement sends departure message to nearby players."""
movement.world = mock_world
look.world = mock_world
# Create another player in the area
other_writer = MagicMock()
other_writer.write = MagicMock()
other_writer.drain = AsyncMock()
other_player = Player(
name="OtherPlayer", x=5, y=4, reader=MagicMock(), writer=other_writer
)
# Register both players
from mudlib.player import players
players.clear()
players[player.name] = player
players[other_player.name] = other_player
await movement.move_north(player, "")
# Other player should have received a departure message
# (We'll check this is called, exact message format is implementation detail)
assert other_player.writer.write.called
@pytest.mark.asyncio
async def test_arrival_message_uses_opposite_direction(player, mock_world):
"""Test that arrival messages use the opposite direction."""
movement.world = mock_world
look.world = mock_world
# Create another player at the destination
other_writer = MagicMock()
other_writer.write = MagicMock()
other_writer.drain = AsyncMock()
other_player = Player(
name="OtherPlayer", x=5, y=3, reader=MagicMock(), writer=other_writer
)
from mudlib.player import players
players.clear()
players[player.name] = player
players[other_player.name] = other_player
# Player at (5, 5) moves north to (5, 4)
await movement.move_north(player, "")
# Other player at (5, 3) should see arrival "from the south"
# (Implementation will determine exact message format)
assert other_player.writer.write.called
# Test look command
@pytest.mark.asyncio
async def test_look_command_sends_viewport(player, mock_world):
"""Test that look command sends the viewport to the player."""
look.world = mock_world
await look.cmd_look(player, "")
assert mock_world.get_viewport.called
assert player.writer.write.called
@pytest.mark.asyncio
async def test_look_command_shows_player_at_center(player, mock_world):
"""Test that look command shows player @ at center."""
look.world = mock_world
await look.cmd_look(player, "")
# Check that the output contains the @ symbol for the player
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "@" in output
@pytest.mark.asyncio
async def test_look_command_shows_other_players(player, mock_world):
"""Test that look command shows other players as *."""
look.world = mock_world
# Create another player in the viewport
other_player = Player(
name="OtherPlayer",
x=6,
y=5,
reader=MagicMock(),
writer=MagicMock(),
)
from mudlib.player import players
players.clear()
players[player.name] = player
players[other_player.name] = other_player
await look.cmd_look(player, "")
# Check that the output contains * for other players
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "*" in output
@pytest.mark.asyncio
async def test_look_shows_effects_on_viewport(player, mock_world):
"""Test that active effects overlay on the viewport."""
look.world = mock_world
from mudlib.player import players
players.clear()
players[player.name] = player
active_effects.clear()
# place a cloud effect 2 tiles east of the player (viewport center is 10,5)
# player is at (5, 5), so effect at (7, 5) -> viewport (12, 5)
add_effect(7, 5, "~", "\033[1;97m", ttl=10.0)
await look.cmd_look(player, "")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
# the output should contain our cloud color applied to ~
# (bright white ~ instead of blue ~)
assert "\033[1;97m~" + RESET in output
active_effects.clear()
@pytest.mark.asyncio
async def test_effects_dont_override_player_marker(player, mock_world):
"""Effects at the player's position should not hide the @ marker."""
look.world = mock_world
from mudlib.player import players
players.clear()
players[player.name] = player
active_effects.clear()
# place an effect right at the player's position
add_effect(player.x, player.y, "~", "\033[1;97m", ttl=10.0)
await look.cmd_look(player, "")
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
# player @ should still be visible at center
assert "@" in output
active_effects.clear()