Files
derp/tests/test_plugin.py
user 77f9a364e6 feat: add hot-reload, shorthand commands, and plugin help
- Plugin registry: add unload_plugin(), reload_plugin(), path tracking
- Bot: add load_plugin(), reload_plugin(), unload_plugin() public API
- Core plugin: add !load, !reload, !unload, !plugins commands
- Command dispatch: support unambiguous prefix matching (!h -> !help)
- Help: support !help <plugin> to show plugin description and commands
- Tests: 17 new tests covering hot-reload, prefix matching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 01:15:59 +01:00

369 lines
11 KiB
Python

"""Tests for the plugin system."""
import textwrap
from pathlib import Path
from derp.bot import _AMBIGUOUS, Bot
from derp.plugin import PluginRegistry, command, event
class TestDecorators:
"""Test that decorators mark functions correctly."""
def test_command_decorator(self):
@command("test", help="a test command")
async def handler(bot, msg):
pass
assert handler._derp_command == "test"
assert handler._derp_help == "a test command"
def test_event_decorator(self):
@event("join")
async def handler(bot, msg):
pass
assert handler._derp_event == "JOIN"
def test_event_decorator_case(self):
@event("PRIVMSG")
async def handler(bot, msg):
pass
assert handler._derp_event == "PRIVMSG"
class TestRegistry:
"""Test the plugin registry."""
def test_register_command(self):
registry = PluginRegistry()
async def handler(bot, msg):
pass
registry.register_command("test", handler, help="test help")
assert "test" in registry.commands
assert registry.commands["test"].help == "test help"
def test_register_event(self):
registry = PluginRegistry()
async def handler(bot, msg):
pass
registry.register_event("PRIVMSG", handler)
assert "PRIVMSG" in registry.events
assert len(registry.events["PRIVMSG"]) == 1
def test_multiple_event_handlers(self):
registry = PluginRegistry()
async def handler_a(bot, msg):
pass
async def handler_b(bot, msg):
pass
registry.register_event("JOIN", handler_a)
registry.register_event("JOIN", handler_b)
assert len(registry.events["JOIN"]) == 2
def test_load_plugin_file(self, tmp_path: Path):
plugin_code = textwrap.dedent("""\
from derp.plugin import command, event
@command("greet", help="Say hello")
async def cmd_greet(bot, msg):
pass
@event("JOIN")
async def on_join(bot, msg):
pass
""")
plugin_file = tmp_path / "greet.py"
plugin_file.write_text(plugin_code)
registry = PluginRegistry()
registry.load_plugin(plugin_file)
assert "greet" in registry.commands
assert "JOIN" in registry.events
def test_load_directory(self, tmp_path: Path):
for name in ("a.py", "b.py"):
(tmp_path / name).write_text(textwrap.dedent(f"""\
from derp.plugin import command
@command("{name[0]}", help="{name}")
async def cmd(bot, msg):
pass
"""))
registry = PluginRegistry()
registry.load_directory(tmp_path)
assert "a" in registry.commands
assert "b" in registry.commands
def test_skip_underscore_files(self, tmp_path: Path):
(tmp_path / "__init__.py").write_text("")
(tmp_path / "_private.py").write_text("x = 1\n")
(tmp_path / "valid.py").write_text(textwrap.dedent("""\
from derp.plugin import command
@command("valid")
async def cmd(bot, msg):
pass
"""))
registry = PluginRegistry()
registry.load_directory(tmp_path)
assert "valid" in registry.commands
assert len(registry.commands) == 1
def test_load_missing_directory(self, tmp_path: Path):
registry = PluginRegistry()
registry.load_directory(tmp_path / "nonexistent")
assert len(registry.commands) == 0
def test_load_plugin_returns_count(self, tmp_path: Path):
plugin_file = tmp_path / "counting.py"
plugin_file.write_text(textwrap.dedent("""\
from derp.plugin import command, event
@command("one", help="first")
async def cmd_one(bot, msg):
pass
@command("two", help="second")
async def cmd_two(bot, msg):
pass
@event("JOIN")
async def on_join(bot, msg):
pass
"""))
registry = PluginRegistry()
count = registry.load_plugin(plugin_file)
assert count == 3
def test_load_plugin_stores_path(self, tmp_path: Path):
plugin_file = tmp_path / "pathed.py"
plugin_file.write_text(textwrap.dedent("""\
from derp.plugin import command
@command("pathed")
async def cmd(bot, msg):
pass
"""))
registry = PluginRegistry()
registry.load_plugin(plugin_file)
assert "pathed" in registry._paths
assert registry._paths["pathed"] == plugin_file.resolve()
class TestHotReload:
"""Test hot-load, unload, and reload of plugins."""
def _write_plugin(self, tmp_path: Path, name: str, code: str) -> Path:
"""Write a plugin file and return its path."""
path = tmp_path / f"{name}.py"
path.write_text(textwrap.dedent(code))
return path
def test_unload_removes_commands(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "greet", """\
from derp.plugin import command
@command("hello")
async def cmd(bot, msg):
pass
""")
registry.load_plugin(tmp_path / "greet.py")
assert "hello" in registry.commands
result = registry.unload_plugin("greet")
assert result is True
assert "hello" not in registry.commands
assert "greet" not in registry._modules
assert "greet" not in registry._paths
def test_unload_removes_events(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "evplug", """\
from derp.plugin import event
@event("JOIN")
async def on_join(bot, msg):
pass
""")
registry.load_plugin(tmp_path / "evplug.py")
assert "JOIN" in registry.events
assert len(registry.events["JOIN"]) == 1
registry.unload_plugin("evplug")
assert "JOIN" not in registry.events
def test_unload_unknown_returns_false(self):
registry = PluginRegistry()
assert registry.unload_plugin("nonexistent") is False
def test_unload_core_refused(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "core", """\
from derp.plugin import command
@command("ping")
async def cmd(bot, msg):
pass
""")
registry.load_plugin(tmp_path / "core.py")
assert "ping" in registry.commands
result = registry.unload_plugin("core")
assert result is False
assert "ping" in registry.commands
def test_reload_plugin(self, tmp_path: Path):
registry = PluginRegistry()
path = self._write_plugin(tmp_path, "mutable", """\
from derp.plugin import command
@command("old")
async def cmd(bot, msg):
pass
""")
registry.load_plugin(path)
assert "old" in registry.commands
# Rewrite the file with a different command
path.write_text(textwrap.dedent("""\
from derp.plugin import command
@command("new")
async def cmd(bot, msg):
pass
"""))
ok, reason = registry.reload_plugin("mutable")
assert ok is True
assert "old" not in registry.commands
assert "new" in registry.commands
def test_reload_unknown_returns_failure(self):
registry = PluginRegistry()
ok, reason = registry.reload_plugin("ghost")
assert ok is False
assert "not loaded" in reason
def test_reload_core_allowed(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "core", """\
from derp.plugin import command
@command("ping")
async def cmd(bot, msg):
pass
""")
registry.load_plugin(tmp_path / "core.py")
ok, _ = registry.reload_plugin("core")
assert ok is True
assert "ping" in registry.commands
def test_unload_preserves_other_plugins(self, tmp_path: Path):
registry = PluginRegistry()
self._write_plugin(tmp_path, "keep", """\
from derp.plugin import command, event
@command("keeper")
async def cmd(bot, msg):
pass
@event("JOIN")
async def on_join(bot, msg):
pass
""")
self._write_plugin(tmp_path, "drop", """\
from derp.plugin import command, event
@command("dropper")
async def cmd(bot, msg):
pass
@event("JOIN")
async def on_join(bot, msg):
pass
""")
registry.load_directory(tmp_path)
assert "keeper" in registry.commands
assert "dropper" in registry.commands
assert len(registry.events["JOIN"]) == 2
registry.unload_plugin("drop")
assert "keeper" in registry.commands
assert "dropper" not in registry.commands
assert len(registry.events["JOIN"]) == 1
class TestPrefixMatch:
"""Test shorthand / prefix matching for commands."""
@staticmethod
def _make_bot(commands: list[str]) -> Bot:
"""Create a minimal Bot with the given command names registered."""
config = {
"server": {"host": "localhost", "port": 6667, "tls": False,
"nick": "test", "user": "test", "realname": "test"},
"bot": {"prefix": "!", "channels": [], "plugins_dir": "plugins"},
}
registry = PluginRegistry()
for name in commands:
async def _noop(bot, msg):
pass
registry.register_command(name, _noop, plugin="test")
return Bot(config, registry)
def test_exact_match(self):
bot = self._make_bot(["ping", "pong", "plugins"])
handler = bot._resolve_command("ping")
assert handler is not None
assert handler.name == "ping"
def test_unique_prefix(self):
bot = self._make_bot(["ping", "help", "version"])
handler = bot._resolve_command("h")
assert handler is not None
assert handler.name == "help"
def test_unique_prefix_partial(self):
bot = self._make_bot(["ping", "plugins", "help"])
handler = bot._resolve_command("pi")
assert handler is not None
assert handler.name == "ping"
def test_ambiguous_prefix(self):
bot = self._make_bot(["ping", "plugins", "help"])
result = bot._resolve_command("p")
assert result is _AMBIGUOUS
def test_no_match(self):
bot = self._make_bot(["ping", "help"])
assert bot._resolve_command("z") is None
def test_exact_match_beats_prefix(self):
bot = self._make_bot(["help", "helper"])
handler = bot._resolve_command("help")
assert handler is not None
assert handler.name == "help"
def test_single_char_unique(self):
bot = self._make_bot(["version", "help", "ping"])
handler = bot._resolve_command("v")
assert handler is not None
assert handler.name == "version"