"""Unit tests for PromptExecutionEngine.""" import pytest import tempfile from pathlib import Path from markitect.prompts.templates.models import PromptTemplate from markitect.prompts.templates.analyzer import TemplateAnalyzer from markitect.prompts.resolver.resolver import PromptResolver from markitect.prompts.resolver.compiler import ContextCompiler from markitect.prompts.resolver.strategy import ( MultiSpaceResolutionStrategy, ResolutionConfig, ) from markitect.prompts.execution.engine import PromptExecutionEngine from markitect.prompts.execution.models import RunConfig, RunStatus, ExecutionStage from markitect.prompts.execution.llm_adapter import MockLLMAdapter, ErrorLLMAdapter from markitect.prompts.services.artifact_service import ArtifactService from markitect.prompts.repositories.sqlite import SQLiteArtifactRepository @pytest.fixture def temp_db(): """Create temporary database.""" with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: db_path = f.name yield db_path Path(db_path).unlink(missing_ok=True) @pytest.fixture def artifact_service(temp_db): """Create artifact service.""" repository = SQLiteArtifactRepository(temp_db) return ArtifactService(repository) @pytest.fixture def analyzer(): """Create template analyzer.""" return TemplateAnalyzer() @pytest.fixture def resolver(artifact_service): """Create resolver.""" strategy = MultiSpaceResolutionStrategy() return PromptResolver(artifact_service, strategy) @pytest.fixture def compiler(): """Create compiler.""" return ContextCompiler() @pytest.fixture def mock_llm(): """Create mock LLM adapter.""" return MockLLMAdapter(mock_response="Mock LLM output") @pytest.fixture def engine(artifact_service, analyzer, resolver, compiler, mock_llm): """Create execution engine.""" return PromptExecutionEngine( artifact_service, analyzer, resolver, compiler, mock_llm, ) class TestPromptExecutionEngine: """Tests for PromptExecutionEngine.""" def test_execute_simple_template( self, engine, artifact_service, mock_llm ): """Test executing simple template.""" # Create dependency artifact_service.create_artifact( space_id="space-1", name="intro", content="Introduction text", ) # Create template content = "# Document\n{{require:intro}}\nMore content" template = PromptTemplate.create( space_id="space-1", name="doc", content=content, ) # Execute resolution_config = ResolutionConfig(space_id="space-1") run = engine.execute(template, content, resolution_config) # Verify run assert run.status == RunStatus.SUCCESS assert run.stage == ExecutionStage.COMPLETE assert run.completed_at is not None assert "output_artifact_id" in run.metadata # Verify LLM was called assert mock_llm.call_count == 1 assert mock_llm.last_prompt is not None def test_execute_with_failed_resolution(self, engine): """Test execution with missing required dependency.""" # Create template with missing dependency content = "{{require:missing-dep}}" template = PromptTemplate.create( space_id="space-1", name="test", content=content, ) resolution_config = ResolutionConfig(space_id="space-1") run = engine.execute(template, content, resolution_config) # Should fail during resolution assert run.status == RunStatus.FAILED assert "Resolution failed" in run.error_message def test_idempotent_execution_skips_duplicate( self, engine, artifact_service ): """Test idempotent execution (FR-4.4).""" # Create dependency artifact_service.create_artifact( space_id="space-1", name="dep", content="Dependency", ) content = "{{require:dep}}" template = PromptTemplate.create( space_id="space-1", name="test", content=content, ) resolution_config = ResolutionConfig(space_id="space-1") config = RunConfig(skip_if_exists=True) # First execution run1 = engine.execute(template, content, resolution_config, config) assert run1.status == RunStatus.SUCCESS # Second execution with same inputs run2 = engine.execute(template, content, resolution_config, config) # Should be skipped assert run2.status == RunStatus.SKIPPED assert run2.id != run1.id # Different run assert run2.input_bundle_hash == run1.input_bundle_hash # Same hash assert "skipped_due_to" in run2.metadata def test_execution_without_skip_reruns( self, engine, artifact_service ): """Test execution without skip flag reruns.""" artifact_service.create_artifact( space_id="space-1", name="dep", content="Dependency", ) content = "{{require:dep}}" template = PromptTemplate.create( space_id="space-1", name="test", content=content, ) resolution_config = ResolutionConfig(space_id="space-1") config = RunConfig(skip_if_exists=False) # First execution run1 = engine.execute(template, content, resolution_config, config) assert run1.status == RunStatus.SUCCESS # Second execution run2 = engine.execute(template, content, resolution_config, config) # Should execute again (not skipped) assert run2.status == RunStatus.SUCCESS assert run2.id != run1.id def test_input_bundle_hash_changes_with_template( self, engine, artifact_service, mock_llm ): """Test hash changes when template changes.""" artifact_service.create_artifact( space_id="space-1", name="dep", content="Dependency", ) # First template content1 = "{{require:dep}} version 1" template1 = PromptTemplate.create( space_id="space-1", name="test", content=content1, ) resolution_config = ResolutionConfig(space_id="space-1") run1 = engine.execute(template1, content1, resolution_config) # Different template content content2 = "{{require:dep}} version 2" template2 = PromptTemplate.create( space_id="space-1", name="test", content=content2, ) run2 = engine.execute(template2, content2, resolution_config) # Different hashes, both execute assert run1.input_bundle_hash != run2.input_bundle_hash assert run1.status == RunStatus.SUCCESS assert run2.status == RunStatus.SUCCESS def test_input_bundle_hash_changes_with_dependencies( self, engine, artifact_service ): """Test hash changes when dependency content changes.""" # Create initial dependency dep = artifact_service.create_artifact( space_id="space-1", name="dep", content="Original content", ) content = "{{require:dep}}" template = PromptTemplate.create( space_id="space-1", name="test", content=content, ) resolution_config = ResolutionConfig(space_id="space-1") run1 = engine.execute(template, content, resolution_config) # Update dependency artifact_service.update_artifact_content(dep.id, "Modified content") run2 = engine.execute(template, content, resolution_config) # Hashes should differ assert run1.input_bundle_hash != run2.input_bundle_hash def test_execution_creates_manifest( self, engine, artifact_service ): """Test execution creates RunManifest.""" artifact_service.create_artifact( space_id="space-1", name="dep", content="Dependency", ) content = "{{require:dep}}" template = PromptTemplate.create( space_id="space-1", name="test", content=content, ) resolution_config = ResolutionConfig(space_id="space-1") run = engine.execute(template, content, resolution_config) # Check manifest in metadata assert "manifest" in run.metadata manifest_data = run.metadata["manifest"] assert manifest_data["run_id"] == run.id assert len(manifest_data["resolved_inputs"]) == 1 assert len(manifest_data["output_artifacts"]) == 1 assert "timing_metadata" in manifest_data def test_execution_with_llm_error( self, artifact_service, analyzer, resolver, compiler ): """Test execution handles LLM errors.""" # Create engine with error adapter error_llm = ErrorLLMAdapter("LLM service unavailable") engine = PromptExecutionEngine( artifact_service, analyzer, resolver, compiler, error_llm, ) artifact_service.create_artifact( space_id="space-1", name="dep", content="Dependency", ) content = "{{require:dep}}" template = PromptTemplate.create( space_id="space-1", name="test", content=content, ) resolution_config = ResolutionConfig(space_id="space-1") run = engine.execute(template, content, resolution_config) # Should fail during processing assert run.status == RunStatus.FAILED assert "LLM service unavailable" in run.error_message def test_get_run_by_hash(self, engine, artifact_service): """Test retrieving cached run by hash.""" artifact_service.create_artifact( space_id="space-1", name="dep", content="Dependency", ) content = "{{require:dep}}" template = PromptTemplate.create( space_id="space-1", name="test", content=content, ) resolution_config = ResolutionConfig(space_id="space-1") run = engine.execute(template, content, resolution_config) # Retrieve by hash cached = engine.get_run_by_hash(run.input_bundle_hash) assert cached is not None assert cached.id == run.id def test_clear_cache(self, engine, artifact_service): """Test clearing run cache.""" artifact_service.create_artifact( space_id="space-1", name="dep", content="Dependency", ) content = "{{require:dep}}" template = PromptTemplate.create( space_id="space-1", name="test", content=content, ) resolution_config = ResolutionConfig(space_id="space-1") run = engine.execute(template, content, resolution_config) # Cache should have run assert engine.get_run_by_hash(run.input_bundle_hash) is not None # Clear cache engine.clear_cache() # Should be gone assert engine.get_run_by_hash(run.input_bundle_hash) is None