# tests/unit/services/memory/procedural/test_matching.py """Unit tests for procedure matching.""" from datetime import UTC, datetime from uuid import uuid4 import pytest from app.services.memory.procedural.matching import ( MatchContext, MatchResult, ProcedureMatcher, get_procedure_matcher, ) from app.services.memory.types import Procedure def create_test_procedure( name: str = "deploy_api", trigger_pattern: str = "deploy.*api", success_count: int = 8, failure_count: int = 2, ) -> Procedure: """Create a test procedure for testing.""" now = datetime.now(UTC) return Procedure( id=uuid4(), project_id=None, agent_type_id=None, name=name, trigger_pattern=trigger_pattern, steps=[ {"order": 1, "action": "build"}, {"order": 2, "action": "test"}, {"order": 3, "action": "deploy"}, ], success_count=success_count, failure_count=failure_count, last_used=now, embedding=None, created_at=now, updated_at=now, ) class TestMatchResult: """Tests for MatchResult dataclass.""" def test_to_dict(self) -> None: """Test converting match result to dictionary.""" procedure = create_test_procedure() result = MatchResult( procedure=procedure, score=0.85, matched_terms=["deploy", "api"], match_type="keyword", ) data = result.to_dict() assert "procedure_id" in data assert "procedure_name" in data assert data["score"] == 0.85 assert data["matched_terms"] == ["deploy", "api"] assert data["match_type"] == "keyword" assert data["success_rate"] == 0.8 class TestMatchContext: """Tests for MatchContext dataclass.""" def test_default_values(self) -> None: """Test default values.""" context = MatchContext(query="deploy api") assert context.query == "deploy api" assert context.task_type is None assert context.project_id is None assert context.max_results == 5 assert context.min_score == 0.3 assert context.require_success_rate is None def test_with_all_values(self) -> None: """Test with all values set.""" project_id = uuid4() context = MatchContext( query="deploy api", task_type="deployment", project_id=project_id, max_results=10, min_score=0.5, require_success_rate=0.7, ) assert context.query == "deploy api" assert context.task_type == "deployment" assert context.project_id == project_id assert context.max_results == 10 assert context.min_score == 0.5 assert context.require_success_rate == 0.7 class TestProcedureMatcher: """Tests for ProcedureMatcher class.""" @pytest.fixture def matcher(self) -> ProcedureMatcher: """Create a procedure matcher.""" return ProcedureMatcher() @pytest.fixture def procedures(self) -> list[Procedure]: """Create test procedures.""" return [ create_test_procedure( name="deploy_api", trigger_pattern="deploy.*api", success_count=9, failure_count=1, ), create_test_procedure( name="deploy_frontend", trigger_pattern="deploy.*frontend", success_count=7, failure_count=3, ), create_test_procedure( name="build_project", trigger_pattern="build.*project", success_count=8, failure_count=2, ), create_test_procedure( name="run_tests", trigger_pattern="test.*run", success_count=5, failure_count=5, ), ] def test_match_exact_name( self, matcher: ProcedureMatcher, procedures: list[Procedure], ) -> None: """Test matching with exact name.""" context = MatchContext(query="deploy_api") results = matcher.match(procedures, context) assert len(results) > 0 # First result should be deploy_api assert results[0].procedure.name == "deploy_api" def test_match_partial_terms( self, matcher: ProcedureMatcher, procedures: list[Procedure], ) -> None: """Test matching with partial terms.""" context = MatchContext(query="deploy") results = matcher.match(procedures, context) assert len(results) >= 2 # Both deploy procedures should match names = [r.procedure.name for r in results] assert "deploy_api" in names assert "deploy_frontend" in names def test_match_with_task_type( self, matcher: ProcedureMatcher, procedures: list[Procedure], ) -> None: """Test matching with task type.""" context = MatchContext( query="build something", task_type="build", ) results = matcher.match(procedures, context) assert len(results) > 0 assert results[0].procedure.name == "build_project" def test_match_respects_min_score( self, matcher: ProcedureMatcher, procedures: list[Procedure], ) -> None: """Test that matching respects minimum score.""" context = MatchContext( query="completely unrelated query xyz", min_score=0.5, ) results = matcher.match(procedures, context) # Should not match anything with high min_score for result in results: assert result.score >= 0.5 def test_match_respects_success_rate_requirement( self, matcher: ProcedureMatcher, procedures: list[Procedure], ) -> None: """Test that matching respects success rate requirement.""" context = MatchContext( query="deploy", require_success_rate=0.7, ) results = matcher.match(procedures, context) for result in results: assert result.procedure.success_rate >= 0.7 def test_match_respects_max_results( self, matcher: ProcedureMatcher, procedures: list[Procedure], ) -> None: """Test that matching respects max results.""" context = MatchContext( query="deploy", max_results=1, min_score=0.0, ) results = matcher.match(procedures, context) assert len(results) <= 1 def test_match_sorts_by_score( self, matcher: ProcedureMatcher, procedures: list[Procedure], ) -> None: """Test that results are sorted by score.""" context = MatchContext(query="deploy", min_score=0.0) results = matcher.match(procedures, context) if len(results) > 1: scores = [r.score for r in results] assert scores == sorted(scores, reverse=True) def test_match_empty_procedures( self, matcher: ProcedureMatcher, ) -> None: """Test matching with empty procedures list.""" context = MatchContext(query="deploy") results = matcher.match([], context) assert results == [] class TestProcedureMatcherRankByRelevance: """Tests for rank_by_relevance method.""" @pytest.fixture def matcher(self) -> ProcedureMatcher: """Create a procedure matcher.""" return ProcedureMatcher() def test_rank_by_relevance( self, matcher: ProcedureMatcher, ) -> None: """Test ranking by relevance.""" procedures = [ create_test_procedure(name="unrelated", trigger_pattern="something else"), create_test_procedure(name="deploy_api", trigger_pattern="deploy.*api"), create_test_procedure( name="deploy_frontend", trigger_pattern="deploy.*frontend" ), ] ranked = matcher.rank_by_relevance(procedures, "deploy") # Deploy procedures should be ranked first assert ranked[0].name in ["deploy_api", "deploy_frontend"] def test_rank_by_relevance_empty( self, matcher: ProcedureMatcher, ) -> None: """Test ranking empty list.""" ranked = matcher.rank_by_relevance([], "deploy") assert ranked == [] class TestProcedureMatcherSuggestProcedures: """Tests for suggest_procedures method.""" @pytest.fixture def matcher(self) -> ProcedureMatcher: """Create a procedure matcher.""" return ProcedureMatcher() @pytest.fixture def procedures(self) -> list[Procedure]: """Create test procedures.""" return [ create_test_procedure( name="deploy_api", trigger_pattern="deploy api", success_count=9, failure_count=1, ), create_test_procedure( name="bad_deploy", trigger_pattern="deploy bad", success_count=2, failure_count=8, ), ] def test_suggest_procedures( self, matcher: ProcedureMatcher, procedures: list[Procedure], ) -> None: """Test suggesting procedures.""" suggestions = matcher.suggest_procedures( procedures, "deploy", min_success_rate=0.5, ) assert len(suggestions) > 0 # Only high success rate should be suggested for s in suggestions: assert s.procedure.success_rate >= 0.5 def test_suggest_procedures_limits_results( self, matcher: ProcedureMatcher, procedures: list[Procedure], ) -> None: """Test that suggestions are limited.""" suggestions = matcher.suggest_procedures( procedures, "deploy", max_suggestions=1, ) assert len(suggestions) <= 1 class TestGetProcedureMatcher: """Tests for singleton getter.""" def test_get_procedure_matcher_returns_instance(self) -> None: """Test that getter returns instance.""" matcher = get_procedure_matcher() assert matcher is not None assert isinstance(matcher, ProcedureMatcher) def test_get_procedure_matcher_returns_same_instance(self) -> None: """Test that getter returns same instance (singleton).""" matcher1 = get_procedure_matcher() matcher2 = get_procedure_matcher() assert matcher1 is matcher2 class TestProcedureMatcherExtractTerms: """Tests for term extraction.""" @pytest.fixture def matcher(self) -> ProcedureMatcher: """Create a procedure matcher.""" return ProcedureMatcher() def test_extract_terms_basic( self, matcher: ProcedureMatcher, ) -> None: """Test basic term extraction.""" terms = matcher._extract_terms("deploy the api") assert "deploy" in terms assert "the" in terms assert "api" in terms def test_extract_terms_removes_special_chars( self, matcher: ProcedureMatcher, ) -> None: """Test that special characters are removed.""" terms = matcher._extract_terms("deploy.api!now") assert "deploy" in terms assert "api" in terms assert "now" in terms assert "." not in terms assert "!" not in terms def test_extract_terms_filters_short( self, matcher: ProcedureMatcher, ) -> None: """Test that short terms are filtered.""" terms = matcher._extract_terms("a big api") assert "a" not in terms assert "big" in terms assert "api" in terms def test_extract_terms_lowercases( self, matcher: ProcedureMatcher, ) -> None: """Test that terms are lowercased.""" terms = matcher._extract_terms("Deploy API") assert "deploy" in terms assert "api" in terms assert "Deploy" not in terms assert "API" not in terms