# Unit tests for VocabularyCache.infer_competences with mocked LLM client. # # **Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5** from __future__ import annotations import json import logging import pytest from teamlandkarte_mcp.matching.vocabulary import VocabularyCache # --------------------------------------------------------------------------- # Fakes / Stubs # --------------------------------------------------------------------------- class FakeDB: """Stub DBClient returning a configurable competence list.""" def __init__(self, competences: list[str] | None = None) -> None: self._competences = competences if competences is not None else [] def get_all_competence_names(self) -> list[str]: return self._competences def get_all_role_names(self) -> list[str]: return ["Software Engineer"] class FakeLLMClient: """Stub AzureOpenAIClient that returns a pre-configured response.""" def __init__(self, response: str | Exception) -> None: self._response = response async def chat_completion(self, system: str, user: str) -> str: if isinstance(self._response, Exception): raise self._response return self._response # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_valid_json_response_returns_correct_tuples() -> None: """Valid JSON response from LLM → correct list of (name, confidence) tuples.""" competences = ["Python", "TypeScript", "React", "Docker"] llm_response = json.dumps( { "competences": [ {"name": "Python", "confidence": 0.95}, {"name": "React", "confidence": 0.8}, ] } ) db = FakeDB(competences=competences) client = FakeLLMClient(response=llm_response) cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type] result = await cache.infer_competences(task_text="Build a Python backend") assert result == [("Python", 0.95), ("React", 0.8)] @pytest.mark.asyncio async def test_empty_task_text_returns_empty_list() -> None: """Empty task text → [].""" competences = ["Python", "TypeScript"] llm_response = json.dumps({"competences": [{"name": "Python", "confidence": 0.9}]}) db = FakeDB(competences=competences) client = FakeLLMClient(response=llm_response) cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type] assert await cache.infer_competences(task_text="") == [] assert await cache.infer_competences(task_text=" ") == [] @pytest.mark.asyncio async def test_empty_competence_list_in_db_returns_empty_list() -> None: """Empty competence list in DB → [].""" llm_response = json.dumps({"competences": [{"name": "Python", "confidence": 0.9}]}) db = FakeDB(competences=[]) client = FakeLLMClient(response=llm_response) cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type] result = await cache.infer_competences(task_text="Build a Python backend") assert result == [] @pytest.mark.asyncio async def test_llm_error_returns_empty_list_and_logs_warning( caplog: pytest.LogCaptureFixture, ) -> None: """LLM error (Exception) → [] + warning logged.""" competences = ["Python", "TypeScript"] db = FakeDB(competences=competences) client = FakeLLMClient(response=RuntimeError("API timeout")) cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type] with caplog.at_level(logging.WARNING): result = await cache.infer_competences(task_text="Build a Python backend") assert result == [] assert any("LLM call failed" in record.message for record in caplog.records) @pytest.mark.asyncio async def test_invalid_json_response_returns_empty_list( caplog: pytest.LogCaptureFixture, ) -> None: """Invalid JSON response → [] (json.loads raises, caught by except).""" competences = ["Python", "TypeScript"] db = FakeDB(competences=competences) client = FakeLLMClient(response="this is not valid json {{{") cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type] with caplog.at_level(logging.WARNING): result = await cache.infer_competences(task_text="Build a Python backend") assert result == [] assert any("LLM call failed" in record.message for record in caplog.records) @pytest.mark.asyncio async def test_unknown_competence_names_are_filtered_out() -> None: """Competence names not in DB → filtered out.""" competences = ["Python", "TypeScript", "React"] llm_response = json.dumps( { "competences": [ {"name": "Python", "confidence": 0.9}, {"name": "Rust", "confidence": 0.85}, # not in DB {"name": "Go", "confidence": 0.7}, # not in DB {"name": "React", "confidence": 0.6}, ] } ) db = FakeDB(competences=competences) client = FakeLLMClient(response=llm_response) cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type] result = await cache.infer_competences(task_text="Build a backend service") assert result == [("Python", 0.9), ("React", 0.6)] @pytest.mark.asyncio async def test_max_competences_limited_to_10() -> None: """Result is limited to max_competences (default 10).""" # Create 15 competences and have the LLM return all of them competences = [f"Competence_{i}" for i in range(15)] llm_response = json.dumps( { "competences": [ {"name": name, "confidence": round(0.5 + i * 0.03, 2)} for i, name in enumerate(competences) ] } ) db = FakeDB(competences=competences) client = FakeLLMClient(response=llm_response) cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type] result = await cache.infer_competences(task_text="A complex task") assert len(result) == 10 # All returned names must be in the DB list for name, confidence in result: assert name in competences assert 0.0 <= confidence <= 1.0 @pytest.mark.asyncio async def test_confidence_clamped_to_valid_range() -> None: """Confidence values outside [0.0, 1.0] are clamped.""" competences = ["Python", "TypeScript"] llm_response = json.dumps( { "competences": [ {"name": "Python", "confidence": 1.5}, # above 1.0 {"name": "TypeScript", "confidence": -0.3}, # below 0.0 ] } ) db = FakeDB(competences=competences) client = FakeLLMClient(response=llm_response) cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type] result = await cache.infer_competences(task_text="Build something") assert result == [("Python", 1.0), ("TypeScript", 0.0)]