Integrates the Editor class into the MUD server's shell loop, allowing players to enter and use the text editor from the game. Changes: - Add editor field to Player dataclass - Modify shell input loop to check player mode and route to editor - Add edit command to enter editor mode from normal mode - Use inp (not command.strip()) for editor to preserve indentation - Show line-numbered prompt in editor mode - Pop mode and clear editor when done=True - Add comprehensive integration tests - Fix test isolation issue in test_movement_updates_position
378 lines
11 KiB
Python
378 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
|
|
|
|
|
|
@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(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, 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
|
|
|
|
# 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
|
|
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()
|
|
|
|
|
|
# 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
|