mud/tests/test_alias.py
Jared Miller 3f042de360
Add player alias system with persistence and dispatch
Implements a complete alias system allowing players to create command shortcuts.
Aliases are expanded during dispatch with a recursion guard (max 10 levels).

Changes:
- Add aliases field to Player dataclass (dict[str, str])
- Add player_aliases table to database schema
- Add save_aliases() and load_aliases() persistence functions
- Add alias/unalias commands with built-in command protection
- Integrate alias expansion into dispatch() before command resolution
- Add comprehensive test coverage for all features
2026-02-14 01:39:45 -05:00

245 lines
7.1 KiB
Python

"""Tests for player alias system."""
import os
import tempfile
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from mudlib.commands import CommandDefinition, dispatch, register
from mudlib.player import Player
from mudlib.store import (
create_account,
init_db,
load_aliases,
save_aliases,
save_player,
)
# Persistence tests
def test_save_and_load_aliases_roundtrip():
"""Save and load aliases from database."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
init_db(db_path)
aliases = {"pr": "punch right", "pl": "punch left", "l": "look"}
save_aliases("goku", aliases, db_path)
loaded = load_aliases("goku", db_path)
assert loaded == aliases
def test_load_aliases_empty():
"""Loading aliases for player with none returns empty dict."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
init_db(db_path)
loaded = load_aliases("goku", db_path)
assert loaded == {}
def test_save_aliases_overwrites_existing():
"""Saving new aliases replaces old ones."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test.db"
init_db(db_path)
save_aliases("goku", {"pr": "punch right"}, db_path)
save_aliases("goku", {"pl": "punch left", "l": "look"}, db_path)
loaded = load_aliases("goku", db_path)
assert loaded == {"pl": "punch left", "l": "look"}
assert "pr" not in loaded
# Alias command tests
@pytest.mark.asyncio
async def test_alias_list_empty(player):
"""alias with no args shows message when no aliases defined."""
from mudlib.commands.alias import cmd_alias
await cmd_alias(player, "")
player.writer.write.assert_called_with("No aliases defined.\r\n")
@pytest.mark.asyncio
async def test_alias_create(player):
"""alias <name> <expansion> creates an alias."""
from mudlib.commands.alias import cmd_alias
await cmd_alias(player, "pr punch right")
assert player.aliases["pr"] == "punch right"
player.writer.write.assert_called_with("Alias set: pr -> punch right\r\n")
@pytest.mark.asyncio
async def test_alias_list_with_aliases(player):
"""alias with no args lists all aliases."""
from mudlib.commands.alias import cmd_alias
player.aliases = {"pr": "punch right", "pl": "punch left", "l": "look"}
await cmd_alias(player, "")
output = player.writer.write.call_args[0][0]
assert "pr -> punch right" in output
assert "pl -> punch left" in output
assert "l -> look" in output
@pytest.mark.asyncio
async def test_alias_show_single(player):
"""alias <name> shows that specific alias."""
from mudlib.commands.alias import cmd_alias
player.aliases = {"pr": "punch right", "pl": "punch left"}
await cmd_alias(player, "pr")
player.writer.write.assert_called_with("pr -> punch right\r\n")
@pytest.mark.asyncio
async def test_unalias_removes_alias(player):
"""unalias <name> removes an alias."""
from mudlib.commands.alias import cmd_unalias
player.aliases = {"pr": "punch right"}
await cmd_unalias(player, "pr")
assert "pr" not in player.aliases
player.writer.write.assert_called_with("Alias removed: pr\r\n")
@pytest.mark.asyncio
async def test_unalias_no_such_alias(player):
"""unalias on non-existent alias shows error."""
from mudlib.commands.alias import cmd_unalias
await cmd_unalias(player, "pr")
player.writer.write.assert_called_with("No such alias: pr\r\n")
@pytest.mark.asyncio
async def test_alias_cannot_override_builtin(player):
"""Cannot alias over existing built-in commands."""
import mudlib.commands.look # noqa: F401 - needed to register look command
from mudlib.commands.alias import cmd_alias
await cmd_alias(player, "look punch right")
player.writer.write.assert_called_with(
"Cannot alias over built-in command: look\r\n"
)
assert "look" not in player.aliases
# Dispatch integration tests
@pytest.mark.asyncio
async def test_alias_expands_in_dispatch(player):
"""Aliases are expanded before command dispatch."""
called_with = []
async def test_handler(p, args):
called_with.append(args)
register(CommandDefinition("testcmd", test_handler))
player.aliases["tc"] = "testcmd"
await dispatch(player, "tc hello")
assert called_with == ["hello"]
@pytest.mark.asyncio
async def test_alias_with_extra_args(player):
"""Alias expansion preserves additional arguments."""
called_with = []
async def test_handler(p, args):
called_with.append(args)
register(CommandDefinition("testcmd", test_handler))
player.aliases["tc"] = "testcmd arg1"
await dispatch(player, "tc arg2 arg3")
# Expansion: "testcmd arg1" + " arg2 arg3" = "testcmd arg1 arg2 arg3"
assert called_with == ["arg1 arg2 arg3"]
@pytest.mark.asyncio
async def test_nested_alias_max_depth(player):
"""Nested aliases are limited to prevent infinite recursion."""
called = []
async def test_handler(p, args):
called.append(True)
register(CommandDefinition("final", test_handler))
# Create a chain: a -> b -> c -> ... -> final
player.aliases["a"] = "b"
player.aliases["b"] = "c"
player.aliases["c"] = "d"
player.aliases["d"] = "e"
player.aliases["e"] = "f"
player.aliases["f"] = "g"
player.aliases["g"] = "h"
player.aliases["h"] = "i"
player.aliases["i"] = "j"
player.aliases["j"] = "final"
await dispatch(player, "a")
assert len(called) == 1 # Should complete successfully
# Now create an 11-deep chain that exceeds limit
player.aliases["j"] = "k"
player.aliases["k"] = "final"
called.clear()
await dispatch(player, "a")
# Should fail and send error message
assert len(called) == 0
assert "Too many nested aliases" in player.writer.write.call_args[0][0]
@pytest.mark.asyncio
async def test_unknown_alias_falls_through(player):
"""Unknown aliases fall through to normal command resolution."""
player.aliases = {} # No aliases defined
# This should hit normal "Unknown command" path
await dispatch(player, "nonexistent")
assert "Unknown command" in player.writer.write.call_args[0][0]
# Integration test for persistence
def test_aliases_persist_on_save_player():
"""Aliases are saved when save_player is called."""
with tempfile.NamedTemporaryFile(delete=False, suffix=".db") as f:
db_path = f.name
try:
init_db(db_path)
create_account("Goku", "password")
# Create player with aliases
mock_writer = MagicMock()
player = Player(
name="Goku",
x=10,
y=20,
reader=MagicMock(),
writer=mock_writer,
)
player.aliases = {"pr": "punch right", "pl": "punch left", "l": "look"}
# Save player (should save aliases too)
save_player(player)
# Load aliases directly from database
loaded = load_aliases("Goku")
assert loaded == {"pr": "punch right", "pl": "punch left", "l": "look"}
finally:
os.unlink(db_path)