mud/tests/test_commands.py
Jared Miller f5646589b5
Migrate look to use player.location (Zone)
- Removed world module-level variable from look.py
- look.cmd_look() now uses player.location.get_viewport() instead of world.get_viewport()
- look.cmd_look() uses zone.contents_near() to find nearby entities instead of iterating global players/mobs lists
- Wrapping calculations use zone.width/height/toroidal instead of world properties
- Added type check for player.location being a Zone instance
- Removed look.world injection from server.py
- Updated all tests to remove look.world injection
- spawn_mob() and combat commands also migrated to use Zone (player.location)
- Removed orphaned code from test_mob_ai.py and test_variant_prefix.py
2026-02-11 19:36:46 -05:00

370 lines
11 KiB
Python

"""Tests for the command system."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib import commands
from mudlib.commands import CommandDefinition, look, movement
from mudlib.effects import active_effects, add_effect
from mudlib.player import Player
from mudlib.render.ansi import RESET
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():
# Create a 100x100 zone filled with passable terrain
terrain = [["." for _ in range(100)] for _ in range(100)]
zone = Zone(
name="testzone",
width=100,
height=100,
toroidal=True,
terrain=terrain,
impassable=set(), # All terrain is passable for tests
)
return zone
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
p = Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
return p
# Test command registration
def test_register_command():
"""Test that commands can be registered."""
async def test_handler(player, args):
pass
commands.register(CommandDefinition("test", test_handler))
assert "test" in commands._registry
assert commands._registry["test"].handler is test_handler
def test_register_command_with_aliases():
"""Test that command aliases work."""
async def test_handler(player, args):
pass
commands.register(CommandDefinition("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"] is commands._registry["tc"]
assert commands._registry["testcmd"] is 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(CommandDefinition("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, test_zone):
"""Test that movement updates player position when passable."""
# Clear players registry to avoid test pollution
from mudlib.player import players
players.clear()
original_x, original_y = player.x, player.y
await movement.move_north(player, "")
assert player.x == original_x
assert player.y == original_y - 1
@pytest.mark.asyncio
async def test_movement_blocked_by_impassable_terrain(player, test_zone, mock_writer):
"""Test that movement is blocked by impassable terrain."""
# Make the target position impassable
target_y = player.y - 1
test_zone.terrain[target_y][player.x] = "^" # mountain
test_zone.impassable = {"^"}
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, test_zone):
"""Test that movement sends departure message to nearby players."""
# 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
)
other_player.location = test_zone
test_zone._contents.append(other_player)
# 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, test_zone):
"""Test that arrival messages use the opposite direction."""
# 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
)
other_player.location = test_zone
test_zone._contents.append(other_player)
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, test_zone):
"""Test that look command sends the viewport to the player."""
await look.cmd_look(player, "")
assert player.writer.write.called
@pytest.mark.asyncio
async def test_look_command_shows_player_at_center(player, test_zone):
"""Test that look command shows player @ at center."""
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, test_zone):
"""Test that look command shows other players as *."""
# Create another player in the viewport
other_player = Player(
name="OtherPlayer",
x=6,
y=5,
reader=MagicMock(),
writer=MagicMock(),
)
other_player.location = test_zone
test_zone._contents.append(other_player)
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, test_zone):
"""Test that active effects overlay on the viewport."""
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, test_zone):
"""Effects at the player's position should not hide the @ marker."""
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()
# Test mode stack
def test_mode_stack_default(player):
"""Player starts in normal mode."""
assert player.mode == "normal"
assert player.mode_stack == ["normal"]
@pytest.mark.asyncio
async def test_dispatch_blocks_wrong_mode(player, mock_writer):
"""Commands with wrong mode get rejected."""
async def combat_handler(p, args):
pass
commands.register(CommandDefinition("strike", combat_handler, mode="combat"))
await commands.dispatch(player, "strike")
assert mock_writer.write.called
written = mock_writer.write.call_args[0][0]
assert "can't" in written.lower()
@pytest.mark.asyncio
async def test_dispatch_allows_wildcard_mode(player):
"""Commands with mode='*' work from any mode."""
called = False
async def any_handler(p, args):
nonlocal called
called = True
commands.register(CommandDefinition("universal", any_handler, mode="*"))
await commands.dispatch(player, "universal")
assert called
@pytest.mark.asyncio
async def test_dispatch_allows_matching_mode(player):
"""Commands work when player mode matches command mode."""
called = False
async def combat_handler(p, args):
nonlocal called
called = True
commands.register(CommandDefinition("strike", combat_handler, mode="combat"))
player.mode_stack.append("combat")
await commands.dispatch(player, "strike")
assert called