mud/tests/test_content_loader.py
Jared Miller d159a88ca4
Add TOML content loader for declarative command definitions
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.
2026-02-07 20:27:29 -05:00

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)