mud/tests/test_verbs.py
Jared Miller fcfa13c785
Add verb infrastructure on Object
Verbs let any Object have interactive handlers players can trigger.
Uses @verb decorator to mark methods that auto-register on instantiation.

- Object._verbs dict stores verb name to async handler mapping
- Object.register_verb(), get_verb(), has_verb() API
- @verb decorator marks methods with _verb_name attribute
- __post_init__ scans for decorated methods and registers them
- find_object() helper searches inventory then ground by name/alias
- Bound methods stored in _verbs (self already bound)
- Works on Object and all subclasses (Thing, Entity, etc)
- 18 tests covering registration, lookup, decoration, inheritance
2026-02-11 21:47:33 -05:00

243 lines
6.7 KiB
Python

"""Tests for the verb system on Object."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.object import Object
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.verbs import find_object, verb
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():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(name="testzone", width=10, height=10, terrain=terrain)
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
return Player(
name="TestPlayer",
x=5,
y=5,
reader=mock_reader,
writer=mock_writer,
location=test_zone,
)
def test_object_starts_with_empty_verbs():
"""Object starts with empty _verbs dict."""
obj = Object(name="rock")
assert obj._verbs == {}
def test_register_verb():
"""register_verb adds a verb to the _verbs dict."""
async def test_handler(obj, player, args):
pass
obj = Object(name="fountain")
obj.register_verb("drink", test_handler)
assert "drink" in obj._verbs
assert obj._verbs["drink"] is test_handler
def test_get_verb_returns_handler():
"""get_verb returns the registered handler."""
async def test_handler(obj, player, args):
pass
obj = Object(name="fountain")
obj.register_verb("drink", test_handler)
assert obj.get_verb("drink") is test_handler
def test_get_verb_returns_none_for_unknown():
"""get_verb returns None for unknown verb."""
obj = Object(name="rock")
assert obj.get_verb("nonexistent") is None
def test_has_verb_returns_true_when_present():
"""has_verb returns True when verb is registered."""
async def test_handler(obj, player, args):
pass
obj = Object(name="fountain")
obj.register_verb("drink", test_handler)
assert obj.has_verb("drink") is True
def test_has_verb_returns_false_when_absent():
"""has_verb returns False when verb is not registered."""
obj = Object(name="rock")
assert obj.has_verb("drink") is False
def test_verb_decorator_marks_method():
"""@verb decorator marks method with _verb_name attribute."""
class TestObject(Object):
@verb("test")
async def test_verb(self, player, args):
pass
assert hasattr(TestObject.test_verb, "_verb_name")
assert TestObject.test_verb._verb_name == "test"
def test_verb_decorator_auto_registers_on_instantiation():
"""Class with @verb methods auto-registers verbs on instantiation."""
class Fountain(Thing):
@verb("drink")
async def drink(self, player, args):
pass
fountain = Fountain(name="fountain")
assert fountain.has_verb("drink")
assert fountain.get_verb("drink") is not None
def test_multiple_verbs_on_same_class():
"""Multiple verbs on same class all register."""
class Fountain(Thing):
@verb("drink")
async def drink(self, player, args):
pass
@verb("splash")
async def splash(self, player, args):
pass
fountain = Fountain(name="fountain")
assert fountain.has_verb("drink")
assert fountain.has_verb("splash")
@pytest.mark.asyncio
async def test_verb_handler_can_be_called(player):
"""Verb handler can be called as an async function."""
called = False
received_args = None
class Fountain(Thing):
@verb("drink")
async def drink(self, player, args):
nonlocal called, received_args
called = True
received_args = args
fountain = Fountain(name="fountain")
handler = fountain.get_verb("drink")
assert handler is not None
# handler is a bound method, so we don't pass self/fountain again
await handler(player, "from the left side")
assert called
assert received_args == "from the left side"
def test_verb_registered_on_parent_class_propagates_to_subclass():
"""Verb registered on parent class propagates to subclass instances."""
class BaseFountain(Thing):
@verb("drink")
async def drink(self, player, args):
pass
class FancyFountain(BaseFountain):
@verb("splash")
async def splash(self, player, args):
pass
fancy = FancyFountain(name="fancy fountain")
assert fancy.has_verb("drink")
assert fancy.has_verb("splash")
def test_find_object_by_name_in_inventory(player):
"""find_object finds object by name in inventory."""
sword = Thing(name="sword", location=player)
found = find_object("sword", player)
assert found is sword
def test_find_object_by_alias_in_inventory(player):
"""find_object finds object by alias in inventory."""
can = Thing(name="pepsi can", aliases=["can", "pepsi"], location=player)
found = find_object("can", player)
assert found is can
def test_find_object_by_name_on_ground(player, test_zone):
"""find_object finds object by name on ground."""
rock = Thing(name="rock", location=test_zone, x=5, y=5)
found = find_object("rock", player)
assert found is rock
def test_find_object_by_alias_on_ground(player, test_zone):
"""find_object finds object by alias on ground."""
can = Thing(
name="pepsi can", aliases=["can", "pepsi"], location=test_zone, x=5, y=5
)
found = find_object("pepsi", player)
assert found is can
def test_find_object_prefers_inventory_over_ground(player, test_zone):
"""find_object prefers inventory over ground when matching object in both."""
Thing(name="sword", location=test_zone, x=5, y=5)
inv_sword = Thing(name="sword", location=player)
found = find_object("sword", player)
assert found is inv_sword
def test_find_object_returns_none_when_nothing_matches(player):
"""find_object returns None when nothing matches."""
found = find_object("nonexistent", player)
assert found is None
def test_find_object_searches_all_objects_not_just_things(player, test_zone):
"""find_object searches all Objects, not just Things."""
class CustomObject(Object):
def __init__(self, name, aliases=None, **kwargs):
super().__init__(name=name, **kwargs)
self.aliases = aliases or []
custom = CustomObject(name="widget", aliases=["gadget"], location=player)
found = find_object("widget", player)
assert found is custom
found_by_alias = find_object("gadget", player)
assert found_by_alias is custom
def test_find_object_skips_self(player, test_zone):
"""find_object should not return the querying player."""
player.move_to(test_zone, x=0, y=0)
result = find_object(player.name, player)
assert result is None