"""Unit tests for LLM batch-matching scenarios. Covers: - Empty input returns immediately without LLM calls - All errors: no exception, complete error list - Default max_concurrency value is 5 - Single error does not affect other candidates Anforderungen: 4.2, 4.3, 4.4, 5.3 """ from __future__ import annotations import asyncio import json from teamlandkarte_mcp.azure.openai_client import AzureAPIError from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher from teamlandkarte_mcp.matching.profiles import TaskProfile from teamlandkarte_mcp.models import Capacity _CATEGORIES = ("Top", "Good", "Partial", "Low", "Irrelevant") # --------------------------------------------------------------------------- # Test doubles # --------------------------------------------------------------------------- class _StubDb: def batch_get_capacity_descriptions(self, capacity_ids): return {str(i): None for i in capacity_ids} def batch_get_capacity_certificates(self, capacity_ids): return {str(i): [] for i in capacity_ids} def batch_get_capacity_references(self, capacity_ids): return {str(i): [] for i in capacity_ids} class _StaticLlm: """Always returns the same response or raises.""" def __init__(self, response=None, exc=None): self._response = response self._exc = exc self.call_count = 0 async def chat_completion(self, system, user): self.call_count += 1 if self._exc: raise self._exc return self._response class _FailOneLlm: """Fails on a specific call index, succeeds on others.""" def __init__(self, fail_index: int): self._fail_index = fail_index self._call_index = 0 async def chat_completion(self, system, user): idx = self._call_index self._call_index += 1 if idx == self._fail_index: raise AzureAPIError("simulated failure") return json.dumps({"category": "Top", "rationale": "ok"}) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _make_capacities(n: int) -> list[Capacity]: return [ Capacity( id=i + 1, owner_name=f"owner{i + 1}", role_name="Engineer", role_level=None, begin_date=None, end_date=None, competences=[], ) for i in range(n) ] def _task_profile() -> TaskProfile: return TaskProfile(id="t1", title="Task", description="Desc", skills=[]) # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- def test_empty_input_returns_immediately_without_llm_calls(): """Empty capacities list returns empty result, no LLM calls made.""" llm = _StaticLlm(response=json.dumps({"category": "Top", "rationale": "x"})) matcher = LlmFulltextMatcher(db=_StubDb(), client=llm) result = asyncio.run( matcher.match_capacities(task_profile=_task_profile(), capacities=[]) ) # All 5 categories present but empty assert set(result.by_category.keys()) == set(_CATEGORIES) for cat in _CATEGORIES: assert result.by_category[cat] == [] # No errors assert result.errors == [] # No LLM calls were made assert llm.call_count == 0 def test_all_errors_no_exception_complete_error_list(): """When all LLM calls fail, no exception is thrown and errors list is complete.""" n = 5 llm = _StaticLlm(exc=AzureAPIError("total failure")) matcher = LlmFulltextMatcher(db=_StubDb(), client=llm) capacities = _make_capacities(n) result = asyncio.run( matcher.match_capacities(task_profile=_task_profile(), capacities=capacities) ) # All categories empty for cat in _CATEGORIES: assert result.by_category[cat] == [] # All candidates appear in errors assert len(result.errors) == n error_ids = {e.item_id for e in result.errors} expected_ids = {str(i + 1) for i in range(n)} assert error_ids == expected_ids def test_default_max_concurrency_is_five(): """LlmFulltextMatcher without explicit max_concurrency uses 5.""" llm = _StaticLlm() matcher = LlmFulltextMatcher(db=_StubDb(), client=llm) assert matcher._max_concurrency == 5 def test_single_error_does_not_affect_others(): """One failing LLM call does not prevent other candidates from being categorized.""" n = 5 fail_index = 2 # The 3rd call (index 2) will fail llm = _FailOneLlm(fail_index=fail_index) matcher = LlmFulltextMatcher(db=_StubDb(), client=llm) capacities = _make_capacities(n) result = asyncio.run( matcher.match_capacities(task_profile=_task_profile(), capacities=capacities) ) # Exactly one error assert len(result.errors) == 1 # The other 4 are categorized (all in "Top" per _FailOneLlm) categorized_items = [] for items in result.by_category.values(): categorized_items.extend(items) assert len(categorized_items) == n - 1 # Total partition is preserved assert len(categorized_items) + len(result.errors) == n # All categorized items are in "Top" for item in categorized_items: assert item.category == "Top"