"""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"