EU-Utility/tests/unit/test_plugin_manager.py

452 lines
14 KiB
Python

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