Scan content/commands/ for .toml files at startup and register them as commands alongside Python-defined ones. Two flavors: handler-based (points to a Python callable via module:function) and message-based (auto-generates a handler from inline text). Includes example MOTD command, type validation, error logging, and full test coverage.
314 lines
8.4 KiB
Python
314 lines
8.4 KiB
Python
"""Tests for content loading from TOML files."""
|
|
|
|
import logging
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from mudlib.content import load_command, load_commands
|
|
from mudlib.player import Player
|
|
|
|
|
|
@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 player(mock_reader, mock_writer):
|
|
return Player(name="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
|
|
|
|
|
|
def test_load_command_with_handler_reference():
|
|
"""Test loading a command with a Python handler reference."""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
f.write("""
|
|
name = "shout"
|
|
aliases = ["yell"]
|
|
help = "shout a message to nearby players"
|
|
mode = "normal"
|
|
handler = "mudlib.commands.look:cmd_look"
|
|
""")
|
|
f.flush()
|
|
tmp_path = Path(f.name)
|
|
|
|
try:
|
|
defn = load_command(tmp_path)
|
|
assert defn.name == "shout"
|
|
assert defn.aliases == ["yell"]
|
|
assert defn.help == "shout a message to nearby players"
|
|
assert defn.mode == "normal"
|
|
assert callable(defn.handler)
|
|
finally:
|
|
tmp_path.unlink()
|
|
|
|
|
|
def test_load_command_with_message():
|
|
"""Test loading a command that just sends a message."""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
f.write("""
|
|
name = "rules"
|
|
aliases = ["info"]
|
|
help = "display the server rules"
|
|
mode = "*"
|
|
message = \"\"\"
|
|
Welcome to the MUD!
|
|
1. Be respectful
|
|
2. No cheating
|
|
\"\"\"
|
|
""")
|
|
f.flush()
|
|
tmp_path = Path(f.name)
|
|
|
|
try:
|
|
defn = load_command(tmp_path)
|
|
assert defn.name == "rules"
|
|
assert defn.aliases == ["info"]
|
|
assert defn.help == "display the server rules"
|
|
assert defn.mode == "*"
|
|
assert callable(defn.handler)
|
|
finally:
|
|
tmp_path.unlink()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_command_sends_text(player, mock_writer):
|
|
"""Test that a message command actually sends the text to the player."""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
f.write("""
|
|
name = "motd"
|
|
message = "Welcome to the server!\\nEnjoy your stay."
|
|
""")
|
|
f.flush()
|
|
tmp_path = Path(f.name)
|
|
|
|
try:
|
|
defn = load_command(tmp_path)
|
|
await defn.handler(player, "")
|
|
|
|
assert mock_writer.write.called
|
|
written_text = mock_writer.write.call_args[0][0]
|
|
assert "Welcome to the server!" in written_text
|
|
assert "Enjoy your stay." in written_text
|
|
finally:
|
|
tmp_path.unlink()
|
|
|
|
|
|
def test_load_commands_from_directory():
|
|
"""Test loading multiple command definitions from a directory."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
|
|
# Create two command files
|
|
(tmp_path / "cmd1.toml").write_text("""
|
|
name = "cmd1"
|
|
message = "Command 1"
|
|
""")
|
|
(tmp_path / "cmd2.toml").write_text("""
|
|
name = "cmd2"
|
|
message = "Command 2"
|
|
""")
|
|
|
|
# Create a non-toml file that should be ignored
|
|
(tmp_path / "readme.txt").write_text("This is not a command")
|
|
|
|
commands = load_commands(tmp_path)
|
|
|
|
assert len(commands) == 2
|
|
names = {cmd.name for cmd in commands}
|
|
assert names == {"cmd1", "cmd2"}
|
|
|
|
|
|
def test_load_command_missing_name():
|
|
"""Test that missing name field raises an error."""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
f.write("""
|
|
message = "Hello"
|
|
""")
|
|
f.flush()
|
|
tmp_path = Path(f.name)
|
|
|
|
try:
|
|
with pytest.raises(KeyError):
|
|
load_command(tmp_path)
|
|
finally:
|
|
tmp_path.unlink()
|
|
|
|
|
|
def test_load_command_missing_handler_and_message():
|
|
"""Test that a command without handler or message raises an error."""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
f.write("""
|
|
name = "broken"
|
|
""")
|
|
f.flush()
|
|
tmp_path = Path(f.name)
|
|
|
|
try:
|
|
match_msg = "must specify either 'handler' or 'message'"
|
|
with pytest.raises(ValueError, match=match_msg):
|
|
load_command(tmp_path)
|
|
finally:
|
|
tmp_path.unlink()
|
|
|
|
|
|
def test_load_command_bad_handler_reference():
|
|
"""Test that an invalid handler reference raises an error."""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
f.write("""
|
|
name = "broken"
|
|
handler = "nonexistent.module:function"
|
|
""")
|
|
f.flush()
|
|
tmp_path = Path(f.name)
|
|
|
|
try:
|
|
with pytest.raises((ImportError, ModuleNotFoundError)):
|
|
load_command(tmp_path)
|
|
finally:
|
|
tmp_path.unlink()
|
|
|
|
|
|
def test_load_command_defaults():
|
|
"""Test that optional fields have sensible defaults."""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
f.write("""
|
|
name = "simple"
|
|
message = "Hello"
|
|
""")
|
|
f.flush()
|
|
tmp_path = Path(f.name)
|
|
|
|
try:
|
|
defn = load_command(tmp_path)
|
|
assert defn.name == "simple"
|
|
assert defn.aliases == []
|
|
assert defn.help == ""
|
|
assert defn.mode == "normal"
|
|
finally:
|
|
tmp_path.unlink()
|
|
|
|
|
|
def test_load_command_both_handler_and_message():
|
|
"""Test that specifying both handler and message raises an error."""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
f.write("""
|
|
name = "broken"
|
|
handler = "mudlib.commands.look:cmd_look"
|
|
message = "Hello"
|
|
""")
|
|
f.flush()
|
|
tmp_path = Path(f.name)
|
|
|
|
try:
|
|
match_msg = "cannot specify both 'handler' and 'message'"
|
|
with pytest.raises(ValueError, match=match_msg):
|
|
load_command(tmp_path)
|
|
finally:
|
|
tmp_path.unlink()
|
|
|
|
|
|
def test_load_command_handler_wrong_type():
|
|
"""Test that non-string handler field raises TypeError."""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
f.write("""
|
|
name = "broken"
|
|
handler = 123
|
|
""")
|
|
f.flush()
|
|
tmp_path = Path(f.name)
|
|
|
|
try:
|
|
match_msg = "'handler' must be a string"
|
|
with pytest.raises(TypeError, match=match_msg):
|
|
load_command(tmp_path)
|
|
finally:
|
|
tmp_path.unlink()
|
|
|
|
|
|
def test_load_command_message_wrong_type():
|
|
"""Test that non-string message field raises TypeError."""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
f.write("""
|
|
name = "broken"
|
|
message = 456
|
|
""")
|
|
f.flush()
|
|
tmp_path = Path(f.name)
|
|
|
|
try:
|
|
match_msg = "'message' must be a string"
|
|
with pytest.raises(TypeError, match=match_msg):
|
|
load_command(tmp_path)
|
|
finally:
|
|
tmp_path.unlink()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_message_whitespace_handling(player, mock_writer):
|
|
"""Test message stripping removes outer whitespace, keeps internal formatting."""
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
|
|
f.write("""
|
|
name = "formatted"
|
|
message = \"\"\"
|
|
|
|
First line
|
|
Indented line
|
|
Last line
|
|
|
|
\"\"\"
|
|
""")
|
|
f.flush()
|
|
tmp_path = Path(f.name)
|
|
|
|
try:
|
|
defn = load_command(tmp_path)
|
|
await defn.handler(player, "")
|
|
|
|
assert mock_writer.write.called
|
|
written_text = mock_writer.write.call_args[0][0]
|
|
# Outer whitespace should be stripped
|
|
assert not written_text.startswith("\n")
|
|
# But internal formatting should be preserved
|
|
assert "First line\n Indented line\nLast line" in written_text
|
|
finally:
|
|
tmp_path.unlink()
|
|
|
|
|
|
def test_load_commands_logs_broken_files(caplog):
|
|
"""Test that load_commands logs warnings for broken TOML files."""
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
tmp_path = Path(tmpdir)
|
|
|
|
# Create a valid command file
|
|
(tmp_path / "valid.toml").write_text("""
|
|
name = "valid"
|
|
message = "OK"
|
|
""")
|
|
|
|
# Create a broken command file (missing required field)
|
|
(tmp_path / "broken.toml").write_text("""
|
|
name = "broken"
|
|
""")
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
commands = load_commands(tmp_path)
|
|
|
|
# Should load the valid one only
|
|
assert len(commands) == 1
|
|
assert commands[0].name == "valid"
|
|
|
|
# Should log warning about the broken file
|
|
warnings = [r.message for r in caplog.records]
|
|
assert any("failed to load command from" in msg for msg in warnings)
|
|
assert any("broken.toml" in msg for msg in warnings)
|