Files
markitect-main/tests/unit/prompts/test_prompt_resolver.py
tegwick 5f463e5b20 feat(prompts): implement Phase 3 - Resolver Engine (FR-3)
Implement deterministic multi-space resolution with configurable search order.

Core Features:
- ResolutionContext and ResolutionResult for tracking resolution state
- MultiSpaceResolutionStrategy implementing FR-3.1 search order:
  1. Local InformationSpace
  2. Explicitly included InformationSpaces
  3. Default InformationSpace
  4. Team/Shared InformationSpace
- PromptResolver with macro resolution logic
- ContextCompiler for assembling resolved prompts
- ResolutionConfig for configurable resolution behavior

Resolution Behavior:
- Required macros fail if not found (FR-3.2)
- Optional macros resolve to empty (FR-3.3)
- Generate macros detected for deferred execution (FR-3.4)
- Deterministic search order with duplicate removal
- Partial compilation support for debugging

Tests (31 passing):
- 14 strategy tests (search order, duplicates, priority)
- 9 resolver tests (required, optional, generate, multi-space)
- 8 compiler tests (substitution, dependencies, digests)

Implements:
- FR-3.1: Deterministic resolution order
- FR-3.2: Required macro validation
- FR-3.3: Optional macro fallback
- FR-3.4: Generate macro detection
- FR-3.5: Max generation depth configuration

Files Created:
- markitect/prompts/resolver/models.py
- markitect/prompts/resolver/strategy.py
- markitect/prompts/resolver/resolver.py
- markitect/prompts/resolver/compiler.py
- migrations/prompts/002_create_resolution_config.sql
- tests/unit/prompts/test_resolution_strategy.py
- tests/unit/prompts/test_prompt_resolver.py
- tests/unit/prompts/test_context_compiler.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 22:45:46 +01:00

249 lines
8.8 KiB
Python

"""Unit tests for PromptResolver."""
import pytest
import tempfile
from pathlib import Path
from markitect.prompts.templates.models import PromptTemplate, ContentMacro, MacroKind
from markitect.prompts.templates.analyzer import TemplateAnalyzer
from markitect.prompts.resolver.resolver import PromptResolver
from markitect.prompts.resolver.strategy import MultiSpaceResolutionStrategy, ResolutionConfig
from markitect.prompts.resolver.models import ResolutionStatus
from markitect.prompts.services.artifact_service import ArtifactService
from markitect.prompts.repositories.sqlite import SQLiteArtifactRepository
from markitect.prompts.models import Artifact, ArtifactType
@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 with temp database."""
repository = SQLiteArtifactRepository(temp_db)
return ArtifactService(repository)
@pytest.fixture
def resolver(artifact_service):
"""Create resolver with multi-space strategy."""
strategy = MultiSpaceResolutionStrategy()
return PromptResolver(artifact_service, strategy)
@pytest.fixture
def analyzer():
"""Create template analyzer."""
return TemplateAnalyzer()
class TestPromptResolver:
"""Tests for PromptResolver."""
def test_resolve_template_not_analyzed_raises_error(self, resolver):
"""Test resolving unanalyzed template raises error."""
template = PromptTemplate.create(
space_id="space-1",
name="test",
content="{{require:dep}}",
)
config = ResolutionConfig(space_id="space-1")
with pytest.raises(ValueError, match="must be analyzed"):
resolver.resolve_template(template, config)
def test_resolve_template_no_macros(self, resolver, analyzer):
"""Test resolving template with no macros."""
content = "# Simple Template\nNo macros here."
template = PromptTemplate.create(
space_id="space-1",
name="simple",
content=content,
)
analyzer.analyze(template, content)
config = ResolutionConfig(space_id="space-1")
result = resolver.resolve_template(template, config)
assert result.success is True
assert result.status == ResolutionStatus.SUCCESS
assert len(result.context.resolved_macros) == 0
assert len(result.context.unresolved_required) == 0
def test_resolve_required_macro_found(self, resolver, analyzer, artifact_service):
"""Test resolving required macro when artifact exists."""
# Create dependency artifact
artifact_service.create_artifact(
space_id="space-1",
name="glossary",
content="Glossary content here",
)
# Create template with required macro
content = "# Template\n{{require:glossary}}"
template = PromptTemplate.create(
space_id="space-1",
name="test-template",
content=content,
)
analyzer.analyze(template, content)
config = ResolutionConfig(space_id="space-1")
result = resolver.resolve_template(template, config)
assert result.success is True
assert result.status == ResolutionStatus.SUCCESS
assert len(result.context.resolved_macros) == 1
assert result.context.resolved_macros[0].resolved is True
assert result.context.resolved_macros[0].artifact.name == "glossary"
def test_resolve_required_macro_not_found_fails(self, resolver, analyzer):
"""Test resolving required macro when artifact missing (FR-3.2)."""
content = "# Template\n{{require:missing-artifact}}"
template = PromptTemplate.create(
space_id="space-1",
name="test",
content=content,
)
analyzer.analyze(template, content)
config = ResolutionConfig(space_id="space-1")
result = resolver.resolve_template(template, config)
assert result.success is False
assert result.status == ResolutionStatus.FAILED
assert len(result.context.unresolved_required) == 1
assert result.context.unresolved_required[0].target == "missing-artifact"
assert len(result.context.errors) > 0
def test_resolve_optional_macro_not_found_succeeds(self, resolver, analyzer):
"""Test resolving optional macro when missing succeeds (FR-3.3)."""
content = "# Template\n{{optional:missing-optional}}"
template = PromptTemplate.create(
space_id="space-1",
name="test",
content=content,
)
analyzer.analyze(template, content)
config = ResolutionConfig(space_id="space-1")
result = resolver.resolve_template(template, config)
assert result.success is True # Still succeeds
assert result.status == ResolutionStatus.PARTIAL # But partial
assert len(result.context.unresolved_optional) == 1
assert result.context.unresolved_optional[0].target == "missing-optional"
def test_resolve_generate_macro_deferred(self, resolver, analyzer):
"""Test generate macro is detected and deferred (FR-3.4)."""
content = "# Template\n{{generate:examples}}"
template = PromptTemplate.create(
space_id="space-1",
name="test",
content=content,
)
analyzer.analyze(template, content)
config = ResolutionConfig(space_id="space-1")
result = resolver.resolve_template(template, config)
assert result.success is True
assert result.needs_generation is True
assert len(result.context.generator_macros) == 1
assert result.context.generator_macros[0].target == "examples"
def test_resolve_multi_space_search_order(self, resolver, analyzer, artifact_service):
"""Test multi-space resolution follows search order (FR-3.1)."""
# Create same-named artifact in multiple spaces
artifact_service.create_artifact(
space_id="space-1",
name="common",
content="From space-1",
)
artifact_service.create_artifact(
space_id="space-2",
name="common",
content="From space-2",
)
content = "{{require:common}}"
template = PromptTemplate.create(
space_id="space-1",
name="test",
content=content,
)
analyzer.analyze(template, content)
# space-1 has higher priority in search order
config = ResolutionConfig(
space_id="space-1",
included_spaces=["space-2"],
)
result = resolver.resolve_template(template, config)
# Should resolve from space-1 (higher priority)
assert result.context.resolved_macros[0].space_id == "space-1"
def test_resolve_falls_back_to_included_space(self, resolver, analyzer, artifact_service):
"""Test resolution falls back to included spaces."""
# Create artifact only in included space
artifact_service.create_artifact(
space_id="space-2",
name="shared-artifact",
content="Shared content",
)
content = "{{require:shared-artifact}}"
template = PromptTemplate.create(
space_id="space-1",
name="test",
content=content,
)
analyzer.analyze(template, content)
config = ResolutionConfig(
space_id="space-1",
included_spaces=["space-2"],
)
result = resolver.resolve_template(template, config)
assert result.success is True
assert result.context.resolved_macros[0].space_id == "space-2"
def test_resolution_summary(self, resolver, analyzer, artifact_service):
"""Test getting resolution summary."""
artifact_service.create_artifact(
space_id="space-1",
name="found",
content="Content",
)
content = """
{{require:found}}
{{require:missing}}
{{optional:optional-missing}}
{{generate:gen}}
"""
template = PromptTemplate.create(
space_id="space-1",
name="test",
content=content,
)
analyzer.analyze(template, content)
config = ResolutionConfig(space_id="space-1")
result = resolver.resolve_template(template, config)
summary = resolver.get_resolution_summary(result)
assert summary["resolved_count"] == 1
assert summary["unresolved_required"] == ["missing"]
assert summary["unresolved_optional"] == ["optional-missing"]
assert summary["needs_generation"] is True
assert summary["generator_count"] == 1