""" Tests for Issue #19: Plugin Architecture and Extensions System This module provides comprehensive tests for the MarkiTect plugin system including plugin discovery, loading, management, and CLI integration. """ import pytest import json import tempfile import os from pathlib import Path from unittest.mock import Mock, patch from markitect.plugins import ( PluginManager, BasePlugin, ProcessorPlugin, FormatterPlugin, PluginType, PluginMetadata, plugin_registry, register_plugin ) from markitect.plugins.manager import PluginManager from markitect.plugins.registry import PluginRegistry class TestPluginArchitecture: """Test suite for plugin architecture components.""" def setup_method(self): """Set up test environment.""" # Clear plugin registry for clean tests plugin_registry.cleanup_all() plugin_registry._plugins.clear() plugin_registry._instances.clear() plugin_registry._plugins_by_type.clear() def teardown_method(self): """Clean up after tests.""" plugin_registry.cleanup_all() plugin_registry._plugins.clear() plugin_registry._instances.clear() plugin_registry._plugins_by_type.clear() class TestPluginBase: """Test base plugin functionality.""" def test_plugin_metadata_creation(self): """Test PluginMetadata creation and properties.""" metadata = PluginMetadata( name="test_plugin", version="1.0.0", description="Test plugin", author="Test Author", plugin_type=PluginType.PROCESSOR, dependencies=["dep1", "dep2"], markitect_version=">=0.1.0" ) assert metadata.name == "test_plugin" assert metadata.version == "1.0.0" assert metadata.description == "Test plugin" assert metadata.author == "Test Author" assert metadata.plugin_type == PluginType.PROCESSOR assert metadata.dependencies == ["dep1", "dep2"] assert metadata.markitect_version == ">=0.1.0" def test_base_plugin_initialization(self): """Test BasePlugin initialization.""" class TestPlugin(BasePlugin): @property def metadata(self): return PluginMetadata( name="test", version="1.0.0", description="Test", plugin_type=PluginType.EXTENSION ) config = {"option1": "value1", "option2": "value2"} plugin = TestPlugin(config) assert plugin.config == config assert not plugin.is_initialized def test_plugin_initialization_lifecycle(self): """Test plugin initialization and cleanup lifecycle.""" class TestPlugin(BasePlugin): def __init__(self, config=None): super().__init__(config) self.initialized = False self.cleaned_up = False @property def metadata(self): return PluginMetadata( name="test", version="1.0.0", description="Test", plugin_type=PluginType.EXTENSION ) def _initialize(self): self.initialized = True def cleanup(self): self.cleaned_up = True plugin = TestPlugin() assert not plugin.initialized assert not plugin.is_initialized # Test initialization result = plugin.initialize() assert result is True assert plugin.initialized assert plugin.is_initialized # Test cleanup plugin.cleanup() assert plugin.cleaned_up def test_plugin_initialization_failure(self): """Test plugin initialization failure handling.""" class FailingPlugin(BasePlugin): @property def metadata(self): return PluginMetadata( name="failing", version="1.0.0", description="Failing plugin", plugin_type=PluginType.EXTENSION ) def _initialize(self): raise Exception("Initialization failed") plugin = FailingPlugin() result = plugin.initialize() assert result is False assert not plugin.is_initialized class TestProcessorPlugin: """Test processor plugin functionality.""" def test_processor_plugin_interface(self): """Test processor plugin interface implementation.""" class TestProcessor(ProcessorPlugin): @property def metadata(self): return PluginMetadata( name="test_processor", version="1.0.0", description="Test processor", plugin_type=PluginType.PROCESSOR ) def process(self, content: str, **kwargs) -> str: return content.upper() processor = TestProcessor() result = processor.process("hello world") assert result == "HELLO WORLD" # Test default can_process implementation assert processor.can_process("any content") def test_processor_plugin_with_options(self): """Test processor plugin with processing options.""" class ConfigurableProcessor(ProcessorPlugin): @property def metadata(self): return PluginMetadata( name="configurable_processor", version="1.0.0", description="Configurable processor", plugin_type=PluginType.PROCESSOR ) def process(self, content: str, **kwargs) -> str: if kwargs.get('uppercase', False): content = content.upper() if kwargs.get('reverse', False): content = content[::-1] return content processor = ConfigurableProcessor() # Test with no options result = processor.process("hello") assert result == "hello" # Test with uppercase option result = processor.process("hello", uppercase=True) assert result == "HELLO" # Test with both options result = processor.process("hello", uppercase=True, reverse=True) assert result == "OLLEH" class TestFormatterPlugin: """Test formatter plugin functionality.""" def test_formatter_plugin_interface(self): """Test formatter plugin interface implementation.""" class TestFormatter(FormatterPlugin): @property def metadata(self): return PluginMetadata( name="test_formatter", version="1.0.0", description="Test formatter", plugin_type=PluginType.FORMATTER ) def format(self, data, **kwargs) -> str: return json.dumps(data, indent=kwargs.get('indent', 2)) def get_file_extension(self) -> str: return '.json' formatter = TestFormatter() data = {"key": "value", "number": 42} result = formatter.format(data) parsed = json.loads(result) assert parsed == data extension = formatter.get_file_extension() assert extension == '.json' class TestPluginRegistry: """Test plugin registry functionality.""" def setup_method(self): """Set up test environment.""" self.registry = PluginRegistry() def test_plugin_registration(self): """Test plugin registration.""" class TestPlugin(BasePlugin): @property def metadata(self): return PluginMetadata( name="test", version="1.0.0", description="Test", plugin_type=PluginType.EXTENSION ) # Test registration name = self.registry.register(TestPlugin) assert name == "TestPlugin" assert "TestPlugin" in self.registry._plugins # Test registration with custom name custom_name = self.registry.register(TestPlugin, "custom_name") assert custom_name == "custom_name" assert "custom_name" in self.registry._plugins def test_plugin_registration_duplicate_name(self): """Test plugin registration with duplicate name.""" class TestPlugin(BasePlugin): @property def metadata(self): return PluginMetadata( name="test", version="1.0.0", description="Test", plugin_type=PluginType.EXTENSION ) self.registry.register(TestPlugin, "test_name") # Should raise error for duplicate name with pytest.raises(ValueError, match="already registered"): self.registry.register(TestPlugin, "test_name") def test_plugin_retrieval(self): """Test plugin retrieval from registry.""" class TestPlugin(BasePlugin): @property def metadata(self): return PluginMetadata( name="test", version="1.0.0", description="Test", plugin_type=PluginType.EXTENSION ) self.registry.register(TestPlugin, "test_plugin") # Test successful retrieval plugin = self.registry.get_plugin("test_plugin") assert plugin is not None assert isinstance(plugin, TestPlugin) # Test non-existent plugin plugin = self.registry.get_plugin("non_existent") assert plugin is None def test_plugin_unregistration(self): """Test plugin unregistration.""" class TestPlugin(BasePlugin): @property def metadata(self): return PluginMetadata( name="test", version="1.0.0", description="Test", plugin_type=PluginType.EXTENSION ) self.registry.register(TestPlugin, "test_plugin") plugin = self.registry.get_plugin("test_plugin") assert plugin is not None # Test unregistration result = self.registry.unregister("test_plugin") assert result is True # Plugin should no longer be available plugin = self.registry.get_plugin("test_plugin") assert plugin is None # Test unregistering non-existent plugin result = self.registry.unregister("non_existent") assert result is False def test_plugins_by_type(self): """Test retrieving plugins by type.""" class ProcessorPlugin1(ProcessorPlugin): @property def metadata(self): return PluginMetadata( name="processor1", version="1.0.0", description="Processor 1", plugin_type=PluginType.PROCESSOR ) def process(self, content, **kwargs): return content class FormatterPlugin1(FormatterPlugin): @property def metadata(self): return PluginMetadata( name="formatter1", version="1.0.0", description="Formatter 1", plugin_type=PluginType.FORMATTER ) def format(self, data, **kwargs): return str(data) def get_file_extension(self): return '.txt' self.registry.register(ProcessorPlugin1, "processor1") self.registry.register(FormatterPlugin1, "formatter1") # Test getting processors processors = self.registry.get_plugins_by_type(PluginType.PROCESSOR) assert "processor1" in processors assert "formatter1" not in processors # Test getting formatters formatters = self.registry.get_plugins_by_type(PluginType.FORMATTER) assert "formatter1" in formatters assert "processor1" not in formatters def test_list_plugins(self): """Test listing all plugins with metadata.""" class TestPlugin(BasePlugin): @property def metadata(self): return PluginMetadata( name="test", version="1.0.0", description="Test plugin", author="Test Author", plugin_type=PluginType.EXTENSION ) self.registry.register(TestPlugin, "test_plugin") plugins = self.registry.list_plugins() assert "test_plugin" in plugins plugin_info = plugins["test_plugin"] assert plugin_info["name"] == "test" assert plugin_info["version"] == "1.0.0" assert plugin_info["description"] == "Test plugin" assert plugin_info["author"] == "Test Author" assert plugin_info["type"] == "extension" class TestPluginManager: """Test plugin manager functionality.""" def setup_method(self): """Set up test environment.""" # Clear plugin registry plugin_registry.cleanup_all() plugin_registry._plugins.clear() plugin_registry._instances.clear() plugin_registry._plugins_by_type.clear() def test_plugin_manager_initialization(self): """Test plugin manager initialization.""" manager = PluginManager() assert manager.config is not None assert isinstance(manager.plugin_directories, list) def test_plugin_manager_with_config(self): """Test plugin manager with custom configuration.""" with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as f: f.write(""" plugin_directories: - "custom_plugins" auto_discover: false plugins: test_plugin: enabled: true """) config_path = f.name try: manager = PluginManager(config_path) assert "custom_plugins" in manager.config.get('plugin_directories', []) assert manager.config.get('auto_discover') is False assert 'test_plugin' in manager.config.get('plugins', {}) finally: os.unlink(config_path) def test_plugin_discovery_empty(self): """Test plugin discovery with no plugins.""" manager = PluginManager() discovered = manager.discover_plugins() # Should be a dictionary (empty or with built-ins) assert isinstance(discovered, dict) @patch('importlib.import_module') def test_load_plugin_success(self, mock_import): """Test successful plugin loading.""" class TestPlugin(BasePlugin): @property def metadata(self): return PluginMetadata( name="test", version="1.0.0", description="Test", plugin_type=PluginType.EXTENSION ) # Mock module with plugin mock_module = Mock() mock_module.TestPlugin = TestPlugin mock_import.return_value = mock_module manager = PluginManager() # Manually add to discovered plugins manager._discovered_plugins = { "test_plugin": { "module_name": "test_module", "class_name": "TestPlugin" } } plugin = manager.load_plugin("test_plugin") assert plugin is not None assert isinstance(plugin, TestPlugin) def test_load_plugin_not_found(self): """Test loading non-existent plugin.""" manager = PluginManager() plugin = manager.load_plugin("non_existent_plugin") assert plugin is None def test_get_plugins_by_type(self): """Test getting plugins by type.""" class TestProcessor(ProcessorPlugin): @property def metadata(self): return PluginMetadata( name="test_processor", version="1.0.0", description="Test processor", plugin_type=PluginType.PROCESSOR ) def process(self, content, **kwargs): return content # Register plugin directly plugin_registry.register(TestProcessor, "test_processor") manager = PluginManager() processors = manager.get_plugins_by_type(PluginType.PROCESSOR) # Should have at least our test processor assert len(processors) >= 1 assert any(isinstance(p, TestProcessor) for p in processors) class TestPluginDecorator: """Test plugin registration decorator.""" def setup_method(self): """Set up test environment.""" # Clear plugin registry plugin_registry.cleanup_all() plugin_registry._plugins.clear() plugin_registry._instances.clear() plugin_registry._plugins_by_type.clear() def test_register_plugin_decorator(self): """Test @register_plugin decorator.""" @register_plugin("decorated_plugin") class DecoratedPlugin(BasePlugin): @property def metadata(self): return PluginMetadata( name="decorated", version="1.0.0", description="Decorated plugin", plugin_type=PluginType.EXTENSION ) # Plugin should be automatically registered assert "decorated_plugin" in plugin_registry._plugins # Should be able to retrieve it plugin = plugin_registry.get_plugin("decorated_plugin") assert plugin is not None assert isinstance(plugin, DecoratedPlugin) def test_register_plugin_decorator_no_name(self): """Test @register_plugin decorator without name.""" @register_plugin() class AutoNamedPlugin(BasePlugin): @property def metadata(self): return PluginMetadata( name="auto_named", version="1.0.0", description="Auto named plugin", plugin_type=PluginType.EXTENSION ) # Should use class name assert "AutoNamedPlugin" in plugin_registry._plugins class TestBuiltinPlugins: """Test built-in plugins.""" def test_json_formatter_plugin(self): """Test built-in JSON formatter plugin.""" from markitect.plugins.builtin.formatters import JsonFormatter formatter = JsonFormatter() assert formatter.metadata.plugin_type == PluginType.FORMATTER data = {"key": "value", "number": 42} result = formatter.format(data) parsed = json.loads(result) assert parsed == data assert formatter.get_file_extension() == '.json' def test_table_formatter_plugin(self): """Test built-in table formatter plugin.""" from markitect.plugins.builtin.formatters import TableFormatter formatter = TableFormatter() assert formatter.metadata.plugin_type == PluginType.FORMATTER # Test with list of dictionaries data = [ {"name": "John", "age": 30}, {"name": "Jane", "age": 25} ] result = formatter.format(data) assert "John" in result assert "Jane" in result assert "name" in result assert "age" in result assert formatter.get_file_extension() == '.txt' def test_markdown_processor_plugin(self): """Test built-in markdown processor plugin.""" from markitect.plugins.builtin.processors import MarkdownProcessor processor = MarkdownProcessor() assert processor.metadata.plugin_type == PluginType.PROCESSOR # Test basic processing content = "# Header\n\nSome content\n" result = processor.process(content) assert isinstance(result, str) # Test can_process assert processor.can_process("# Markdown header") assert processor.can_process("Some **bold** text") class TestPluginCLIIntegration: """Test plugin CLI command integration.""" def setup_method(self): """Set up test environment.""" # Clear plugin registry plugin_registry.cleanup_all() plugin_registry._plugins.clear() plugin_registry._instances.clear() plugin_registry._plugins_by_type.clear() def test_plugin_list_command_import(self): """Test that plugin CLI commands can be imported.""" # This tests that the CLI commands are properly integrated from markitect.cli import plugin_list, plugin_load, plugin_info assert callable(plugin_list) assert callable(plugin_load) assert callable(plugin_info) def test_plugin_type_enum_import(self): """Test that PluginType enum is accessible for CLI.""" from markitect.plugins.base import PluginType # Test all plugin types are available assert PluginType.PROCESSOR assert PluginType.FORMATTER assert PluginType.VALIDATOR assert PluginType.EXPORTER assert PluginType.GENERATOR assert PluginType.IMPORTER assert PluginType.TRANSFORMER assert PluginType.EXTENSION assert PluginType.BACKEND assert PluginType.COMMAND # Test values are strings assert isinstance(PluginType.PROCESSOR.value, str) class TestPluginErrorHandling: """Test plugin error handling and edge cases.""" def test_plugin_with_invalid_metadata(self): """Test plugin with invalid metadata.""" class BadMetadataPlugin(BasePlugin): @property def metadata(self): # Missing required fields return None plugin = BadMetadataPlugin() # Should handle gracefully try: plugin_registry.register(BadMetadataPlugin, "bad_plugin") # Should not crash, might register as extension type except Exception: # Exception is acceptable for invalid metadata pass def test_plugin_initialization_with_bad_config(self): """Test plugin initialization with invalid configuration.""" class ConfigValidatingPlugin(BasePlugin): @property def metadata(self): return PluginMetadata( name="config_validator", version="1.0.0", description="Config validating plugin", plugin_type=PluginType.EXTENSION ) def validate_config(self): errors = [] if 'required_field' not in self.config: errors.append("Missing required_field") return errors # Test with invalid config plugin = ConfigValidatingPlugin({"wrong_field": "value"}) errors = plugin.validate_config() assert len(errors) > 0 assert "required_field" in errors[0] # Test with valid config plugin = ConfigValidatingPlugin({"required_field": "value"}) errors = plugin.validate_config() assert len(errors) == 0 def test_plugin_manager_with_invalid_config_file(self): """Test plugin manager with invalid configuration file.""" with tempfile.NamedTemporaryFile(mode='w', suffix='.yml', delete=False) as f: f.write("invalid: yaml: content: [") # Invalid YAML config_path = f.name try: # Should not crash, should use defaults manager = PluginManager(config_path) assert manager.config is not None # Should fall back to defaults assert 'plugin_directories' in manager.config finally: os.unlink(config_path) class TestPluginIntegration: """Integration tests for the plugin system.""" def setup_method(self): """Set up test environment.""" # Clear plugin registry plugin_registry.cleanup_all() plugin_registry._plugins.clear() plugin_registry._instances.clear() plugin_registry._plugins_by_type.clear() def test_end_to_end_plugin_workflow(self): """Test complete plugin workflow from registration to usage.""" # 1. Create a plugin @register_plugin("workflow_processor") class WorkflowProcessor(ProcessorPlugin): @property def metadata(self): return PluginMetadata( name="workflow_processor", version="1.0.0", description="End-to-end workflow processor", plugin_type=PluginType.PROCESSOR ) def process(self, content, **kwargs): prefix = kwargs.get('prefix', self.config.get('prefix', '')) return f"{prefix}{content}" # 2. Verify registration assert "workflow_processor" in plugin_registry._plugins # 3. Create manager and load plugin manager = PluginManager() plugin = manager.load_plugin("workflow_processor", {"prefix": ">> "}) # 4. Use plugin assert plugin is not None result = plugin.process("Hello World") assert result == ">> Hello World" # 5. Verify plugin is in registry assert plugin_registry.is_loaded("workflow_processor") # 6. Get plugin by type processors = manager.get_plugins_by_type(PluginType.PROCESSOR) assert any(isinstance(p, WorkflowProcessor) for p in processors) # 7. Unload plugin success = manager.unload_plugin("workflow_processor") assert success is True assert not plugin_registry.is_loaded("workflow_processor") def test_multiple_plugins_interaction(self): """Test interaction between multiple plugins.""" # Register multiple plugins @register_plugin("upper_processor") class UpperProcessor(ProcessorPlugin): @property def metadata(self): return PluginMetadata( name="upper_processor", version="1.0.0", description="Uppercase processor", plugin_type=PluginType.PROCESSOR ) def process(self, content, **kwargs): return content.upper() @register_plugin("json_test_formatter") class JsonTestFormatter(FormatterPlugin): @property def metadata(self): return PluginMetadata( name="json_test_formatter", version="1.0.0", description="JSON test formatter", plugin_type=PluginType.FORMATTER ) def format(self, data, **kwargs): return json.dumps(data) def get_file_extension(self): return '.json' manager = PluginManager() # Load both plugins processor = manager.load_plugin("upper_processor") formatter = manager.load_plugin("json_test_formatter") assert processor is not None assert formatter is not None # Use them together processed = processor.process("hello world") formatted = formatter.format({"result": processed}) data = json.loads(formatted) assert data["result"] == "HELLO WORLD" # Verify both are loaded assert plugin_registry.is_loaded("upper_processor") assert plugin_registry.is_loaded("json_test_formatter") if __name__ == '__main__': pytest.main([__file__])