""" Unit tests for Plugin Manager service. Tests cover: - Plugin discovery - Plugin loading/unloading - Plugin configuration management - Enable/disable functionality - Hotkey handling """ import sys import unittest import json import tempfile import shutil from pathlib import Path from unittest.mock import MagicMock, patch, mock_open # Add project root to path project_root = Path(__file__).parent.parent.parent if str(project_root) not in sys.path: sys.path.insert(0, str(project_root)) # Mock BasePlugin for tests class MockBasePlugin: """Mock BasePlugin class for testing.""" name = "TestPlugin" version = "1.0.0" description = "A test plugin" hotkey = None enabled = True def __init__(self, overlay, config=None): self.overlay = overlay self.config = config or {} def initialize(self): pass def shutdown(self): pass def get_ui(self): return None def on_hotkey(self): pass from core.plugin_manager import PluginManager class TestPluginManagerInitialization(unittest.TestCase): """Test PluginManager initialization.""" def setUp(self): """Set up test environment.""" self.temp_dir = tempfile.mkdtemp() self.overlay = MagicMock() def tearDown(self): """Clean up test environment.""" shutil.rmtree(self.temp_dir) def test_initialization_creates_empty_dicts(self): """Test that initialization creates empty plugin dicts.""" with patch.object(Path, 'exists', return_value=False): pm = PluginManager(self.overlay) self.assertEqual(pm.plugins, {}) self.assertEqual(pm.plugin_classes, {}) self.assertEqual(pm.overlay, self.overlay) def test_initialization_loads_config(self): """Test that initialization loads configuration.""" config = {"enabled": ["plugin1"], "settings": {"plugin1": {"key": "value"}}} with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: json.dump(config, f) config_path = f.name try: with patch.object(PluginManager, '_load_config', return_value=config): pm = PluginManager(self.overlay) pm.config = config self.assertEqual(pm.config["enabled"], ["plugin1"]) finally: Path(config_path).unlink() class TestPluginConfiguration(unittest.TestCase): """Test plugin configuration management.""" def setUp(self): """Set up test environment.""" self.temp_dir = tempfile.mkdtemp() self.overlay = MagicMock() self.config_path = Path(self.temp_dir) / "config" / "plugins.json" def tearDown(self): """Clean up test environment.""" shutil.rmtree(self.temp_dir) def test_load_config_default(self): """Test loading default config when file doesn't exist.""" with patch.object(PluginManager, '_load_config', return_value={"enabled": [], "settings": {}}): pm = PluginManager(self.overlay) pm.config = {"enabled": [], "settings": {}} self.assertEqual(pm.config["enabled"], []) self.assertEqual(pm.config["settings"], {}) def test_load_config_existing(self): """Test loading existing config file.""" config = {"enabled": ["plugin1", "plugin2"], "settings": {"plugin1": {"opt": 1}}} self.config_path.parent.mkdir(parents=True, exist_ok=True) with open(self.config_path, 'w') as f: json.dump(config, f) with patch('core.plugin_manager.Path') as mock_path: mock_path.return_value = self.config_path mock_path.exists.return_value = True pm = PluginManager(self.overlay) # Manually load config pm.config = json.loads(self.config_path.read_text()) self.assertEqual(pm.config["enabled"], ["plugin1", "plugin2"]) def test_is_plugin_enabled(self): """Test checking if plugin is enabled.""" pm = PluginManager(self.overlay) pm.config = {"enabled": ["plugin1"]} self.assertTrue(pm.is_plugin_enabled("plugin1")) self.assertFalse(pm.is_plugin_enabled("plugin2")) def test_is_plugin_enabled_empty_config(self): """Test checking if plugin is enabled with empty config.""" pm = PluginManager(self.overlay) pm.config = {} self.assertFalse(pm.is_plugin_enabled("plugin1")) @patch.object(PluginManager, 'save_config') def test_enable_plugin(self, mock_save): """Test enabling a plugin.""" pm = PluginManager(self.overlay) pm.config = {"enabled": []} pm.plugin_classes = {"test_plugin": MockBasePlugin} with patch.object(pm, 'load_plugin', return_value=True): result = pm.enable_plugin("test_plugin") self.assertTrue(result) self.assertIn("test_plugin", pm.config["enabled"]) mock_save.assert_called_once() @patch.object(PluginManager, 'save_config') def test_disable_plugin(self, mock_save): """Test disabling a plugin.""" pm = PluginManager(self.overlay) pm.config = {"enabled": ["test_plugin"]} pm.plugins = {} with patch.object(pm, 'unload_plugin'): result = pm.disable_plugin("test_plugin") self.assertTrue(result) self.assertNotIn("test_plugin", pm.config["enabled"]) mock_save.assert_called_once() class TestPluginDiscovery(unittest.TestCase): """Test plugin discovery.""" def setUp(self): """Set up test environment.""" self.overlay = MagicMock() def test_discover_plugins_empty_dirs(self): """Test discovering plugins when directories are empty.""" with tempfile.TemporaryDirectory() as tmpdir: pm = PluginManager(self.overlay) pm.PLUGIN_DIRS = [tmpdir] discovered = pm.discover_plugins() self.assertEqual(discovered, []) def test_discover_plugins_skips_pycache(self): """Test that discovery skips __pycache__ directories.""" with tempfile.TemporaryDirectory() as tmpdir: # Create __pycache__ directory pycache = Path(tmpdir) / "__pycache__" pycache.mkdir() pm = PluginManager(self.overlay) pm.PLUGIN_DIRS = [tmpdir] discovered = pm.discover_plugins() self.assertEqual(discovered, []) def test_discover_plugins_skips_hidden(self): """Test that discovery skips hidden directories.""" with tempfile.TemporaryDirectory() as tmpdir: # Create hidden directory hidden = Path(tmpdir) / ".hidden" hidden.mkdir() pm = PluginManager(self.overlay) pm.PLUGIN_DIRS = [tmpdir] discovered = pm.discover_plugins() self.assertEqual(discovered, []) class TestPluginLoading(unittest.TestCase): """Test plugin loading.""" def setUp(self): """Set up test environment.""" self.overlay = MagicMock() self.pm = PluginManager(self.overlay) def test_load_plugin_success(self): """Test successful plugin loading.""" self.pm.config = {"settings": {}} result = self.pm.load_plugin(MockBasePlugin) self.assertTrue(result) self.assertEqual(len(self.pm.plugins), 1) def test_load_plugin_already_loaded(self): """Test loading already loaded plugin.""" self.pm.config = {"settings": {}} # Load once self.pm.load_plugin(MockBasePlugin) # Try to load again result = self.pm.load_plugin(MockBasePlugin) self.assertTrue(result) self.assertEqual(len(self.pm.plugins), 1) # Still only one def test_load_plugin_disabled(self): """Test loading disabled plugin.""" plugin_id = f"{MockBasePlugin.__module__}.{MockBasePlugin.__name__}" self.pm.config = {"disabled": [plugin_id]} result = self.pm.load_plugin(MockBasePlugin) self.assertFalse(result) def test_load_plugin_init_failure(self): """Test loading plugin that fails initialization.""" class BadPlugin(MockBasePlugin): def initialize(self): raise Exception("Init failed") self.pm.config = {"settings": {}} result = self.pm.load_plugin(BadPlugin) self.assertFalse(result) def test_get_plugin(self): """Test getting a loaded plugin.""" self.pm.config = {"settings": {}} self.pm.load_plugin(MockBasePlugin) plugin_id = f"{MockBasePlugin.__module__}.{MockBasePlugin.__name__}" plugin = self.pm.get_plugin(plugin_id) self.assertIsNotNone(plugin) self.assertIsInstance(plugin, MockBasePlugin) def test_get_plugin_not_loaded(self): """Test getting a plugin that isn't loaded.""" plugin = self.pm.get_plugin("nonexistent") self.assertIsNone(plugin) def test_get_all_plugins(self): """Test getting all loaded plugins.""" self.pm.config = {"settings": {}} self.pm.load_plugin(MockBasePlugin) all_plugins = self.pm.get_all_plugins() self.assertEqual(len(all_plugins), 1) class TestPluginUnloading(unittest.TestCase): """Test plugin unloading.""" def setUp(self): """Set up test environment.""" self.overlay = MagicMock() self.pm = PluginManager(self.overlay) self.pm.config = {"settings": {}} self.pm.load_plugin(MockBasePlugin) self.plugin_id = f"{MockBasePlugin.__module__}.{MockBasePlugin.__name__}" def test_unload_plugin(self): """Test unloading a plugin.""" self.assertIn(self.plugin_id, self.pm.plugins) self.pm.unload_plugin(self.plugin_id) self.assertNotIn(self.plugin_id, self.pm.plugins) def test_unload_plugin_calls_shutdown(self): """Test that unloading calls plugin shutdown.""" plugin = self.pm.plugins[self.plugin_id] plugin.shutdown = MagicMock() self.pm.unload_plugin(self.plugin_id) plugin.shutdown.assert_called_once() def test_unload_nonexistent_plugin(self): """Test unloading a plugin that doesn't exist.""" # Should not raise self.pm.unload_plugin("nonexistent") def test_shutdown_all(self): """Test shutting down all plugins.""" # Load another plugin class AnotherPlugin(MockBasePlugin): name = "AnotherPlugin" self.pm.load_plugin(AnotherPlugin) self.assertEqual(len(self.pm.plugins), 2) self.pm.shutdown_all() self.assertEqual(len(self.pm.plugins), 0) class TestPluginUI(unittest.TestCase): """Test plugin UI functionality.""" def setUp(self): """Set up test environment.""" self.overlay = MagicMock() self.pm = PluginManager(self.overlay) self.pm.config = {"settings": {}} self.pm.load_plugin(MockBasePlugin) def test_get_plugin_ui(self): """Test getting plugin UI.""" plugin_id = f"{MockBasePlugin.__module__}.{MockBasePlugin.__name__}" ui = self.pm.get_plugin_ui(plugin_id) # MockBasePlugin.get_ui returns None self.assertIsNone(ui) def test_get_plugin_ui_not_loaded(self): """Test getting UI for unloaded plugin.""" ui = self.pm.get_plugin_ui("nonexistent") self.assertIsNone(ui) class TestHotkeyHandling(unittest.TestCase): """Test hotkey handling.""" def setUp(self): """Set up test environment.""" self.overlay = MagicMock() self.pm = PluginManager(self.overlay) self.pm.config = {"settings": {}} def test_trigger_hotkey_success(self): """Test triggering hotkey for a plugin.""" class HotkeyPlugin(MockBasePlugin): hotkey = "ctrl+t" on_hotkey = MagicMock() self.pm.load_plugin(HotkeyPlugin) result = self.pm.trigger_hotkey("ctrl+t") self.assertTrue(result) HotkeyPlugin.on_hotkey.assert_called_once() def test_trigger_hotkey_not_handled(self): """Test triggering hotkey that no plugin handles.""" self.pm.load_plugin(MockBasePlugin) result = self.pm.trigger_hotkey("ctrl+unknown") self.assertFalse(result) def test_trigger_hotkey_disabled_plugin(self): """Test that disabled plugins don't handle hotkeys.""" class DisabledPlugin(MockBasePlugin): hotkey = "ctrl+d" enabled = False on_hotkey = MagicMock() self.pm.load_plugin(DisabledPlugin) result = self.pm.trigger_hotkey("ctrl+d") self.assertFalse(result) DisabledPlugin.on_hotkey.assert_not_called() class TestPluginManagerSaveConfig(unittest.TestCase): """Test plugin manager configuration saving.""" def setUp(self): """Set up test environment.""" self.temp_dir = tempfile.mkdtemp() self.overlay = MagicMock() def tearDown(self): """Clean up test environment.""" shutil.rmtree(self.temp_dir) def test_save_config(self): """Test saving configuration to file.""" config_path = Path(self.temp_dir) / "config" / "plugins.json" pm = PluginManager(self.overlay) pm.config = {"enabled": ["plugin1"], "settings": {"plugin1": {"key": "value"}}} with patch('core.plugin_manager.Path') as mock_path_class: mock_path = MagicMock() mock_path.__truediv__ = MagicMock(return_value=mock_path) mock_path.parent = MagicMock() mock_path.exists.return_value = True mock_path_class.return_value = mock_path # Just verify config is serializable config_json = json.dumps(pm.config, indent=2) self.assertIn("plugin1", config_json) if __name__ == '__main__': unittest.main()