- 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>
369 lines
11 KiB
Python
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"
|