Migrate all repos into monorepo context folders
Bahn: aisupport, Analyse-O2C-C2S, awesome-bahn-mcp-servers, beam-mcp,
Confluence_Bot, db-planet-mcp-server, O2C-Harness, project-audit,
Projekt-KIQ-HP, teamlandkarte-mcp
Dhive: Jury-Voting
Privat: CV, NoteGraph (NOTE: NoteGraph needs complete redo after consolidation)
Shared: AI-Orchestrator, OrgMyLife, power_skills_and_more
Shared/references: symphony (read-only)
Bahn repos remain available as independent remotes - this monorepo
pulls them in via subtree, the originals are untouched.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
"""Global pytest configuration.
|
||||
|
||||
This repository's unit/integration tests must be deterministic and must not make
|
||||
any outbound network calls.
|
||||
|
||||
As a safety net, this conftest blocks common Python networking primitives by
|
||||
default.
|
||||
|
||||
If you *intentionally* need network access for a specific test (generally not
|
||||
recommended), you can opt out with:
|
||||
|
||||
@pytest.mark.allow_network
|
||||
|
||||
or for an entire module:
|
||||
|
||||
pytestmark = pytest.mark.allow_network
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _blocked(*args: Any, **kwargs: Any) -> None: # pragma: no cover
|
||||
raise AssertionError(
|
||||
"Outbound network access is blocked during tests. "
|
||||
"Patch the relevant client call, or mark the test with "
|
||||
"@pytest.mark.allow_network if this is truly required."
|
||||
)
|
||||
|
||||
|
||||
def _blocked_connect(self: socket.socket, address: Any) -> None: # pragma: no cover
|
||||
# Allow local-only connections needed for event loops / self-pipes.
|
||||
try:
|
||||
host = address[0] if isinstance(address, tuple) and address else None
|
||||
except Exception:
|
||||
host = None
|
||||
|
||||
if host in ("127.0.0.1", "localhost", "::1"):
|
||||
return _ORIG_SOCKET_CONNECT(self, address)
|
||||
|
||||
raise AssertionError(
|
||||
"Outbound network access is blocked during tests. "
|
||||
"Patch the relevant client call, or mark the test with "
|
||||
"@pytest.mark.allow_network if this is truly required."
|
||||
)
|
||||
|
||||
|
||||
_ORIG_SOCKET_CONNECT = socket.socket.connect
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _block_network(
|
||||
monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest
|
||||
) -> None:
|
||||
"""Block outbound network unless explicitly allowed."""
|
||||
|
||||
if request.node.get_closest_marker("allow_network") is not None:
|
||||
return
|
||||
|
||||
# Keep event-loop internals working (they rely on AF_UNIX socketpairs).
|
||||
# Only block outbound connections.
|
||||
monkeypatch.setattr(socket.socket, "connect", _blocked_connect, raising=True)
|
||||
monkeypatch.setattr(socket, "create_connection", _blocked)
|
||||
|
||||
# Best-effort blocks for popular HTTP clients.
|
||||
try: # urllib3 (requests)
|
||||
import urllib3.connectionpool # type: ignore
|
||||
|
||||
monkeypatch.setattr(
|
||||
urllib3.connectionpool.HTTPConnectionPool, "_new_conn", _blocked
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
urllib3.connectionpool.HTTPSConnectionPool, "_new_conn", _blocked
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try: # httpx
|
||||
import httpx # type: ignore
|
||||
|
||||
monkeypatch.setattr(httpx.Client, "send", _blocked)
|
||||
monkeypatch.setattr(httpx.AsyncClient, "send", _blocked)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try: # aiohttp
|
||||
import aiohttp # type: ignore
|
||||
|
||||
monkeypatch.setattr(aiohttp.ClientSession, "_request", _blocked)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,176 @@
|
||||
# filepath: tests/test_auto_tagger.py
|
||||
"""Unit tests for :mod:`teamlandkarte_mcp.matching.auto_tagger`.
|
||||
|
||||
All tests mock :class:`~teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient`
|
||||
— no real LLM calls are made.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
|
||||
from teamlandkarte_mcp.matching.auto_tagger import AutoTagger
|
||||
|
||||
|
||||
def _make_tagger(chat_completion_return: str) -> AutoTagger:
|
||||
"""Build an AutoTagger backed by a mock client."""
|
||||
client = MagicMock()
|
||||
client.chat_completion = AsyncMock(return_value=chat_completion_return)
|
||||
return AutoTagger(client=client)
|
||||
|
||||
|
||||
def _make_failing_tagger(exc: Exception) -> AutoTagger:
|
||||
"""Build an AutoTagger whose chat_completion always raises."""
|
||||
client = MagicMock()
|
||||
client.chat_completion = AsyncMock(side_effect=exc)
|
||||
return AutoTagger(client=client)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expand_adds_covered_required_terms() -> None:
|
||||
"""LLM returns a valid addition → appended to existing list."""
|
||||
tagger = _make_tagger(json.dumps({"additions": ["Machine Learning"]}))
|
||||
result = await tagger.expand_competences(
|
||||
required=["Machine Learning", "Python"],
|
||||
existing=["ML", "Python"],
|
||||
)
|
||||
assert "Machine Learning" in result
|
||||
assert "ML" in result
|
||||
assert "Python" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expand_no_additions_returns_existing_unchanged() -> None:
|
||||
"""LLM returns empty additions → existing list unchanged."""
|
||||
tagger = _make_tagger(json.dumps({"additions": []}))
|
||||
existing = ["Python", "React"]
|
||||
result = await tagger.expand_competences(
|
||||
required=["Machine Learning"],
|
||||
existing=existing,
|
||||
)
|
||||
assert result == existing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Graceful degradation on errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expand_api_error_returns_existing() -> None:
|
||||
"""LLM raises AzureAPIError → returns existing unchanged, no propagation."""
|
||||
tagger = _make_failing_tagger(AzureAPIError("timeout"))
|
||||
existing = ["Python"]
|
||||
result = await tagger.expand_competences(
|
||||
required=["Machine Learning"],
|
||||
existing=existing,
|
||||
)
|
||||
assert result == existing
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expand_generic_exception_returns_existing() -> None:
|
||||
"""Any unexpected exception → graceful degradation, returns existing."""
|
||||
tagger = _make_failing_tagger(RuntimeError("unexpected"))
|
||||
existing = ["TypeScript"]
|
||||
result = await tagger.expand_competences(
|
||||
required=["JavaScript"],
|
||||
existing=existing,
|
||||
)
|
||||
assert result == existing
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expand_malformed_json_returns_existing() -> None:
|
||||
"""Unparseable LLM response → existing list returned unchanged."""
|
||||
tagger = _make_tagger("not valid json {{")
|
||||
existing = ["Go"]
|
||||
result = await tagger.expand_competences(
|
||||
required=["Rust"],
|
||||
existing=existing,
|
||||
)
|
||||
assert result == existing
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expand_missing_additions_key_returns_existing() -> None:
|
||||
"""Valid JSON but no 'additions' key → treated as empty additions."""
|
||||
tagger = _make_tagger(json.dumps({"result": []}))
|
||||
existing = ["Kotlin"]
|
||||
result = await tagger.expand_competences(
|
||||
required=["Java"],
|
||||
existing=existing,
|
||||
)
|
||||
assert result == existing
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hallucination guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expand_hallucinated_term_dropped() -> None:
|
||||
"""Addition not in 'required' (hallucination) is silently dropped."""
|
||||
tagger = _make_tagger(json.dumps({"additions": ["Rust", "Machine Learning"]}))
|
||||
result = await tagger.expand_competences(
|
||||
required=["Machine Learning"],
|
||||
existing=["ML"],
|
||||
)
|
||||
assert "Rust" not in result
|
||||
assert "Machine Learning" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Duplicate guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expand_no_duplicate_if_already_in_existing() -> None:
|
||||
"""Addition already in existing → not duplicated in result."""
|
||||
tagger = _make_tagger(json.dumps({"additions": ["Python"]}))
|
||||
result = await tagger.expand_competences(
|
||||
required=["Python"],
|
||||
existing=["Python", "Pandas"],
|
||||
)
|
||||
assert result.count("Python") == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge cases: empty inputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expand_empty_required_returns_existing_no_llm_call() -> None:
|
||||
"""Empty required list → existing returned immediately, no LLM call."""
|
||||
client = MagicMock()
|
||||
client.chat_completion = AsyncMock()
|
||||
tagger = AutoTagger(client=client)
|
||||
existing = ["Python"]
|
||||
result = await tagger.expand_competences(required=[], existing=existing)
|
||||
assert result == existing
|
||||
client.chat_completion.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expand_empty_existing_returns_empty_no_llm_call() -> None:
|
||||
"""Empty existing list → empty list returned, no LLM call."""
|
||||
client = MagicMock()
|
||||
client.chat_completion = AsyncMock()
|
||||
tagger = AutoTagger(client=client)
|
||||
result = await tagger.expand_competences(
|
||||
required=["Python"],
|
||||
existing=[],
|
||||
)
|
||||
assert result == []
|
||||
client.chat_completion.assert_not_called()
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.azure.openai_client import AzureAPIError, AzureOpenAIClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_completion_success():
|
||||
client = AzureOpenAIClient(
|
||||
endpoint="https://example.openai.azure.com",
|
||||
api_version="2024-02-15-preview",
|
||||
chat_deployment="gpt-4o",
|
||||
llm_api_key="test-key",
|
||||
)
|
||||
|
||||
# Mock the internal chat client
|
||||
fake_choice = MagicMock()
|
||||
fake_choice.message.content = '{"result": "ok"}'
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.choices = [fake_choice]
|
||||
|
||||
fake_chat = MagicMock()
|
||||
fake_chat.chat.completions.create = AsyncMock(return_value=fake_resp)
|
||||
client._chat = cast(Any, fake_chat)
|
||||
|
||||
result = await client.chat_completion(system="sys", user="usr")
|
||||
assert result == '{"result": "ok"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_completion_retries_on_timeout():
|
||||
client = AzureOpenAIClient(
|
||||
endpoint="https://example.openai.azure.com",
|
||||
api_version="2024-02-15-preview",
|
||||
chat_deployment="gpt-4o",
|
||||
llm_api_key="test-key",
|
||||
max_retries=3,
|
||||
timeout_s=0.01,
|
||||
)
|
||||
|
||||
# First call times out, second succeeds
|
||||
fake_choice = MagicMock()
|
||||
fake_choice.message.content = '{"ok": true}'
|
||||
fake_resp = MagicMock()
|
||||
fake_resp.choices = [fake_choice]
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def _fake_create(**kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise RuntimeError("transient error")
|
||||
return fake_resp
|
||||
|
||||
fake_chat = MagicMock()
|
||||
fake_chat.chat.completions.create = _fake_create
|
||||
client._chat = cast(Any, fake_chat)
|
||||
|
||||
result = await client.chat_completion(system="sys", user="usr")
|
||||
assert result == '{"ok": true}'
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chat_completion_raises_after_all_retries():
|
||||
client = AzureOpenAIClient(
|
||||
endpoint="https://example.openai.azure.com",
|
||||
api_version="2024-02-15-preview",
|
||||
chat_deployment="gpt-4o",
|
||||
llm_api_key="test-key",
|
||||
max_retries=2,
|
||||
timeout_s=0.01,
|
||||
)
|
||||
|
||||
async def _always_fail(**kwargs):
|
||||
raise RuntimeError("persistent error")
|
||||
|
||||
fake_chat = MagicMock()
|
||||
fake_chat.chat.completions.create = _always_fail
|
||||
client._chat = cast(Any, fake_chat)
|
||||
|
||||
with pytest.raises(AzureAPIError, match="persistent error"):
|
||||
await client.chat_completion(system="sys", user="usr")
|
||||
@@ -0,0 +1,121 @@
|
||||
# Feature: llm-batch-matching, Property 1: Vollständigkeit der Ergebnisse
|
||||
"""Property-based test for result completeness (partition) in batch matching.
|
||||
|
||||
For any list of capacities and any mix of LLM successes/failures, the total
|
||||
number of items across all ``by_category`` buckets plus the number of entries
|
||||
in ``errors`` must equal the number of input candidates. No candidate may be
|
||||
lost or duplicated.
|
||||
|
||||
The fake LLM is driven by a Hypothesis-generated boolean mask: position ``i``
|
||||
decides whether the call for capacity ``i+1`` raises ``AzureAPIError``
|
||||
(``True``) or returns a valid JSON payload (``False``).
|
||||
|
||||
**Validates: Requirements 1.1, 1.4, 3.2, 4.2, 4.3**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
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
|
||||
|
||||
|
||||
class _MaskLlm:
|
||||
"""Returns valid JSON for mask[i] is False; raises AzureAPIError for True."""
|
||||
|
||||
def __init__(self, mask: list[bool]) -> None:
|
||||
self._mask = mask
|
||||
self._call_index = 0
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
idx = self._call_index
|
||||
self._call_index += 1
|
||||
if self._mask[idx]:
|
||||
raise AzureAPIError("simulated")
|
||||
return json.dumps({"category": "Top", "rationale": "ok"})
|
||||
|
||||
|
||||
class _StubDb:
|
||||
"""Minimal DBClient double returning empty extras for every id."""
|
||||
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
|
||||
def _make_capacities(n: int) -> list[Capacity]:
|
||||
return [
|
||||
Capacity(
|
||||
id=i + 1,
|
||||
owner_name=f"o{i + 1}",
|
||||
role_name="R",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=[],
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(mask=st.lists(st.booleans(), min_size=1, max_size=20))
|
||||
def test_batch_completeness_partition(mask: list[bool]) -> None:
|
||||
"""Property 1: all input candidates appear exactly once in output.
|
||||
|
||||
The sum of items across all by_category buckets plus the number of
|
||||
errors must equal the number of input capacities. Additionally, all
|
||||
item_ids across by_category and errors must be unique (no duplicates).
|
||||
"""
|
||||
matcher = LlmFulltextMatcher(db=_StubDb(), client=_MaskLlm(mask))
|
||||
capacities = _make_capacities(len(mask))
|
||||
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=task_profile,
|
||||
capacities=capacities,
|
||||
)
|
||||
)
|
||||
|
||||
# Collect all item_ids from by_category
|
||||
category_ids: list[str] = []
|
||||
for items in result.by_category.values():
|
||||
for it in items:
|
||||
category_ids.append(it.item_id)
|
||||
|
||||
# Collect all item_ids from errors
|
||||
error_ids: list[str] = [e.item_id for e in result.errors]
|
||||
|
||||
# Property: completeness – no candidate lost
|
||||
total = len(category_ids) + len(error_ids)
|
||||
assert total == len(capacities), (
|
||||
f"Expected {len(capacities)} total items, got {total} "
|
||||
f"({len(category_ids)} categorized + {len(error_ids)} errors)"
|
||||
)
|
||||
|
||||
# Property: no duplicates – all item_ids are unique
|
||||
all_ids = category_ids + error_ids
|
||||
assert len(all_ids) == len(set(all_ids)), (
|
||||
f"Duplicate item_ids detected: {len(all_ids)} total vs "
|
||||
f"{len(set(all_ids))} unique"
|
||||
)
|
||||
@@ -0,0 +1,120 @@
|
||||
# Feature: llm-batch-matching, Property 2: Concurrency-Begrenzung
|
||||
"""Property-based test for concurrency limiting in batch matching.
|
||||
|
||||
For any number of candidates and any valid ``max_concurrency`` value, the
|
||||
number of simultaneously running LLM calls must never exceed the configured
|
||||
``max_concurrency`` limit.
|
||||
|
||||
A mock LLM client uses an asyncio counter (protected by a lock) to track
|
||||
concurrent invocations and records the maximum observed concurrency. After
|
||||
matching completes, we assert that the peak never exceeded the configured
|
||||
limit.
|
||||
|
||||
**Validates: Requirements 1.2**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
|
||||
from teamlandkarte_mcp.matching.profiles import TaskProfile
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
|
||||
class _ConcurrencyTracker:
|
||||
"""Mock LLM client that tracks peak concurrent invocations."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._current = 0
|
||||
self._max_seen = 0
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
async with self._lock:
|
||||
self._current += 1
|
||||
if self._current > self._max_seen:
|
||||
self._max_seen = self._current
|
||||
await asyncio.sleep(0.01) # simulate I/O
|
||||
async with self._lock:
|
||||
self._current -= 1
|
||||
return json.dumps({"category": "Top", "rationale": "ok"})
|
||||
|
||||
@property
|
||||
def max_concurrent(self) -> int:
|
||||
return self._max_seen
|
||||
|
||||
|
||||
class _StubDb:
|
||||
"""Minimal DBClient double returning empty extras for every id."""
|
||||
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
|
||||
def _make_capacities(n: int) -> list[Capacity]:
|
||||
return [
|
||||
Capacity(
|
||||
id=i + 1,
|
||||
owner_name=f"o{i + 1}",
|
||||
role_name="R",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=[],
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
@settings(max_examples=50, deadline=None)
|
||||
@given(
|
||||
max_concurrency=st.integers(min_value=1, max_value=10),
|
||||
num_candidates=st.integers(min_value=1, max_value=30),
|
||||
)
|
||||
def test_batch_concurrency_limit(
|
||||
max_concurrency: int, num_candidates: int
|
||||
) -> None:
|
||||
"""Property 2: peak concurrent LLM calls never exceed max_concurrency.
|
||||
|
||||
We generate a random max_concurrency (1-10) and a random number of
|
||||
candidates (1-30), then verify that the concurrency tracker never
|
||||
observed more simultaneous calls than the configured limit.
|
||||
"""
|
||||
tracker = _ConcurrencyTracker()
|
||||
matcher = LlmFulltextMatcher(
|
||||
db=_StubDb(),
|
||||
client=tracker,
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
capacities = _make_capacities(num_candidates)
|
||||
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
|
||||
|
||||
asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=task_profile,
|
||||
capacities=capacities,
|
||||
)
|
||||
)
|
||||
|
||||
assert tracker.max_concurrent <= max_concurrency, (
|
||||
f"Observed {tracker.max_concurrent} concurrent calls, "
|
||||
f"but max_concurrency was set to {max_concurrency}"
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
# Feature: llm-batch-matching, Property 6: Config-Validierung
|
||||
"""Property-based test for azure_openai.max_concurrency config validation.
|
||||
|
||||
**Validates: Requirements 2.1, 2.3, 2.4**
|
||||
"""
|
||||
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.config import (
|
||||
AzureOpenAIConfig,
|
||||
ConfigError,
|
||||
_parse_azure_openai,
|
||||
)
|
||||
|
||||
|
||||
@given(n=st.integers(min_value=-100, max_value=100))
|
||||
def test_max_concurrency_valid_iff_in_range(n: int) -> None:
|
||||
"""max_concurrency succeeds iff 1 <= n <= 50, else ConfigError."""
|
||||
cfg = {
|
||||
"max_concurrency": n,
|
||||
"endpoint": "https://test.example.com",
|
||||
"chat_deployment": "test",
|
||||
}
|
||||
|
||||
if 1 <= n <= 50:
|
||||
result = _parse_azure_openai(cfg)
|
||||
assert isinstance(result, AzureOpenAIConfig)
|
||||
assert result.max_concurrency == n
|
||||
else:
|
||||
with pytest.raises(ConfigError):
|
||||
_parse_azure_openai(cfg)
|
||||
@@ -0,0 +1,193 @@
|
||||
# Feature: llm-batch-matching, Property 7: Logging-Konsistenz
|
||||
"""Property-based test for logging consistency in batch matching.
|
||||
|
||||
For every execution of ``match_capacities`` with at least one candidate, the
|
||||
INFO-level log messages (start and end) must contain the correct candidate
|
||||
count, the configured concurrency limit, the number of successful
|
||||
categorizations, and the number of errors. The sum of successes and errors in
|
||||
the end log must equal the input count.
|
||||
|
||||
The fake LLM is driven by a Hypothesis-generated boolean mask: position ``i``
|
||||
decides whether the call for capacity ``i+1`` raises ``AzureAPIError``
|
||||
(``True``) or returns a valid JSON payload (``False``).
|
||||
|
||||
**Validates: Requirements 6.1, 6.2**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Generator
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
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
|
||||
|
||||
_LOGGER_NAME = "teamlandkarte_mcp.matching.llm_fulltext_matcher"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test doubles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MaskLlm:
|
||||
"""Returns valid JSON for mask[i] is False; raises AzureAPIError for True."""
|
||||
|
||||
def __init__(self, mask: list[bool]) -> None:
|
||||
self._mask = mask
|
||||
self._call_index = 0
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
idx = self._call_index
|
||||
self._call_index += 1
|
||||
if self._mask[idx]:
|
||||
raise AzureAPIError("simulated")
|
||||
return json.dumps({"category": "Top", "rationale": "ok"})
|
||||
|
||||
|
||||
class _StubDb:
|
||||
"""Minimal DBClient double returning empty extras for every id."""
|
||||
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
|
||||
def _make_capacities(n: int) -> list[Capacity]:
|
||||
return [
|
||||
Capacity(
|
||||
id=i + 1,
|
||||
owner_name=f"o{i + 1}",
|
||||
role_name="R",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=[],
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log capture context manager (hypothesis doesn't support pytest fixtures)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ListHandler(logging.Handler):
|
||||
"""Simple handler that appends records to a list."""
|
||||
|
||||
def __init__(self, records: list[logging.LogRecord]) -> None:
|
||||
super().__init__()
|
||||
self._records = records
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
self._records.append(record)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _capture_logs(
|
||||
logger_name: str, level: int
|
||||
) -> Generator[list[logging.LogRecord], None, None]:
|
||||
"""Capture log records from a named logger for the duration of the block."""
|
||||
logger = logging.getLogger(logger_name)
|
||||
records: list[logging.LogRecord] = []
|
||||
handler = _ListHandler(records)
|
||||
handler.setLevel(level)
|
||||
logger.addHandler(handler)
|
||||
old_level = logger.level
|
||||
logger.setLevel(level)
|
||||
try:
|
||||
yield records
|
||||
finally:
|
||||
logger.removeHandler(handler)
|
||||
logger.setLevel(old_level)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(
|
||||
mask=st.lists(st.booleans(), min_size=1, max_size=15),
|
||||
max_concurrency=st.integers(min_value=1, max_value=5),
|
||||
)
|
||||
def test_batch_logging_consistency(mask: list[bool], max_concurrency: int) -> None:
|
||||
"""Property 7: log messages contain correct counts.
|
||||
|
||||
Start log must contain the candidate count and max_concurrency.
|
||||
End log must contain the number of categorized items and errors.
|
||||
The sum of categorized + errors must equal the total candidates.
|
||||
"""
|
||||
tracker_client = _MaskLlm(mask)
|
||||
matcher = LlmFulltextMatcher(
|
||||
db=_StubDb(),
|
||||
client=tracker_client,
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
capacities = _make_capacities(len(mask))
|
||||
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
|
||||
|
||||
with _capture_logs(_LOGGER_NAME, logging.INFO) as records:
|
||||
asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=task_profile, capacities=capacities
|
||||
)
|
||||
)
|
||||
|
||||
# Find start and end log messages
|
||||
start_msgs = [r for r in records if "gestartet" in r.getMessage()]
|
||||
end_msgs = [r for r in records if "abgeschlossen" in r.getMessage()]
|
||||
|
||||
assert len(start_msgs) == 1, f"Expected 1 start log, got {len(start_msgs)}"
|
||||
assert len(end_msgs) == 1, f"Expected 1 end log, got {len(end_msgs)}"
|
||||
|
||||
start_msg = start_msgs[0].getMessage()
|
||||
end_msg = end_msgs[0].getMessage()
|
||||
|
||||
# --- Start log: correct candidate count and max_concurrency ---
|
||||
assert str(len(mask)) in start_msg, (
|
||||
f"Expected candidate count {len(mask)} in start log: {start_msg}"
|
||||
)
|
||||
assert str(max_concurrency) in start_msg, (
|
||||
f"Expected max_concurrency {max_concurrency} in start log: {start_msg}"
|
||||
)
|
||||
|
||||
# --- End log: correct categorized and error counts ---
|
||||
expected_errors = sum(1 for m in mask if m)
|
||||
expected_success = sum(1 for m in mask if not m)
|
||||
|
||||
assert str(expected_success) in end_msg, (
|
||||
f"Expected {expected_success} categorized in end log: {end_msg}"
|
||||
)
|
||||
assert str(expected_errors) in end_msg, (
|
||||
f"Expected {expected_errors} errors in end log: {end_msg}"
|
||||
)
|
||||
|
||||
# --- Consistency: categorized + errors == total candidates ---
|
||||
# Parse the numbers from the end log message format:
|
||||
# "Batch-Matching abgeschlossen: %.1fs, %d kategorisiert, %d Fehler"
|
||||
# We verify the sum property by checking the actual values
|
||||
assert expected_success + expected_errors == len(mask), (
|
||||
f"Sum mismatch: {expected_success} + {expected_errors} != {len(mask)}"
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
# Feature: llm-batch-matching, Property 5: Korrekte Zuordnung (Response-Mapping)
|
||||
"""Property-based test for correct response-to-candidate mapping in batch matching.
|
||||
|
||||
For any list of capacities with item_id-specific category assignments, each
|
||||
item must land in exactly the category that the mock LLM returned for it.
|
||||
This validates that concurrent execution does not mix up responses between
|
||||
candidates.
|
||||
|
||||
**Validates: Requirements 3.4**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
|
||||
from teamlandkarte_mcp.matching.profiles import TaskProfile
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
_ID_RE = re.compile(r"ID:\s*(\S+)")
|
||||
|
||||
_CATEGORIES: tuple[str, ...] = ("Top", "Good", "Partial", "Low", "Irrelevant")
|
||||
|
||||
|
||||
class _MapLlm:
|
||||
def __init__(self, id_to_category: dict[str, str]) -> None:
|
||||
self._map = id_to_category
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
match = _ID_RE.search(user)
|
||||
cap_id = match.group(1) if match else "unknown"
|
||||
category = self._map.get(cap_id, "Irrelevant")
|
||||
await asyncio.sleep(0.005) # simulate I/O
|
||||
return json.dumps({"category": category, "rationale": f"r{cap_id}"})
|
||||
|
||||
|
||||
class _StubDb:
|
||||
def batch_get_capacity_descriptions(self, capacity_ids: list[Any]) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(self, capacity_ids: list[Any]) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(self, capacity_ids: list[Any]) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
|
||||
def _make_capacity(cap_id: int) -> Capacity:
|
||||
return Capacity(
|
||||
id=cap_id,
|
||||
owner_name=f"o{cap_id}",
|
||||
role_name="R",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=[],
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(
|
||||
ids=st.lists(st.integers(min_value=1, max_value=99), min_size=2, max_size=15, unique=True),
|
||||
max_concurrency=st.integers(min_value=1, max_value=5),
|
||||
data=st.data(),
|
||||
)
|
||||
def test_batch_correct_mapping(
|
||||
ids: list[int], max_concurrency: int, data: st.DataObject
|
||||
) -> None:
|
||||
"""Property 5: each item lands in exactly the category the mock assigned."""
|
||||
id_to_category = {str(i): data.draw(st.sampled_from(_CATEGORIES)) for i in ids}
|
||||
capacities = [_make_capacity(i) for i in ids]
|
||||
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
|
||||
|
||||
matcher = LlmFulltextMatcher(
|
||||
db=_StubDb(), client=_MapLlm(id_to_category), max_concurrency=max_concurrency,
|
||||
)
|
||||
result = asyncio.run(
|
||||
matcher.match_capacities(task_profile=task_profile, capacities=capacities)
|
||||
)
|
||||
|
||||
# Build a mapping from item_id to actual category in the result
|
||||
actual_mapping: dict[str, str] = {}
|
||||
for cat, items in result.by_category.items():
|
||||
for it in items:
|
||||
actual_mapping[it.item_id] = cat
|
||||
|
||||
# Assert: every capacity ended up in the correct category
|
||||
for cap_id_str, expected_cat in id_to_category.items():
|
||||
assert cap_id_str in actual_mapping, (
|
||||
f"Capacity {cap_id_str} not found in any category"
|
||||
)
|
||||
assert actual_mapping[cap_id_str] == expected_cat, (
|
||||
f"Capacity {cap_id_str}: expected {expected_cat}, "
|
||||
f"got {actual_mapping[cap_id_str]}"
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
# Feature: llm-batch-matching, Property 4: Eingabereihenfolge-Unabhängigkeit
|
||||
"""Property-based test for input order independence under concurrent batch matching.
|
||||
|
||||
For any permutation of the input candidate list and any max_concurrency value,
|
||||
the results (by_category and errors) must be identical — the input order has
|
||||
no influence on the output.
|
||||
|
||||
**Validates: Requirements 3.3**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
|
||||
from teamlandkarte_mcp.matching.profiles import TaskProfile
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
_ID_RE = re.compile(r"ID:\s*(\S+)")
|
||||
|
||||
_CATEGORIES: tuple[str, ...] = ("Top", "Good", "Partial", "Low", "Irrelevant")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test doubles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MapLlm:
|
||||
"""Returns a JSON payload whose category depends on the prompt's ID line.
|
||||
|
||||
Parses the ``ID: <capacity_id>`` line from the user prompt to determine
|
||||
which category to return. Includes a small sleep to simulate I/O and
|
||||
exercise concurrency paths.
|
||||
"""
|
||||
|
||||
def __init__(self, id_to_category: dict[str, str]) -> None:
|
||||
self._map = id_to_category
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
match = _ID_RE.search(user)
|
||||
cap_id = match.group(1) if match else "unknown"
|
||||
category = self._map.get(cap_id, "Irrelevant")
|
||||
await asyncio.sleep(0.005)
|
||||
return json.dumps({"category": category, "rationale": f"r{cap_id}"})
|
||||
|
||||
|
||||
class _StubDb:
|
||||
"""Minimal DBClient double returning empty extras for every id."""
|
||||
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_capacity(cap_id: int) -> Capacity:
|
||||
return Capacity(
|
||||
id=cap_id,
|
||||
owner_name=f"o{cap_id}",
|
||||
role_name="R",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=[],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(
|
||||
ids=st.lists(
|
||||
st.integers(min_value=1, max_value=99),
|
||||
min_size=2,
|
||||
max_size=10,
|
||||
unique=True,
|
||||
),
|
||||
max_concurrency=st.integers(min_value=1, max_value=5),
|
||||
data=st.data(),
|
||||
)
|
||||
def test_batch_order_independence(
|
||||
ids: list[int], max_concurrency: int, data: st.DataObject
|
||||
) -> None:
|
||||
"""Property 4: input order has no influence on the output.
|
||||
|
||||
Generates random unique capacity IDs, assigns random categories,
|
||||
creates two LlmFulltextMatcher instances with the same max_concurrency,
|
||||
runs match_capacities with the original list and a random permutation,
|
||||
and asserts that by_category and errors are identical.
|
||||
"""
|
||||
id_to_category = {
|
||||
str(i): data.draw(st.sampled_from(_CATEGORIES)) for i in ids
|
||||
}
|
||||
capacities = [_make_capacity(i) for i in ids]
|
||||
permuted = data.draw(st.permutations(capacities))
|
||||
|
||||
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
|
||||
|
||||
matcher_a = LlmFulltextMatcher(
|
||||
db=_StubDb(),
|
||||
client=_MapLlm(id_to_category),
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
result_a = asyncio.run(
|
||||
matcher_a.match_capacities(
|
||||
task_profile=task_profile, capacities=capacities
|
||||
)
|
||||
)
|
||||
|
||||
matcher_b = LlmFulltextMatcher(
|
||||
db=_StubDb(),
|
||||
client=_MapLlm(id_to_category),
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
result_b = asyncio.run(
|
||||
matcher_b.match_capacities(
|
||||
task_profile=task_profile, capacities=permuted
|
||||
)
|
||||
)
|
||||
|
||||
# Assert: by_category results are identical (same item_ids per category)
|
||||
for cat in _CATEGORIES:
|
||||
ids_a = [it.item_id for it in result_a.by_category[cat]]
|
||||
ids_b = [it.item_id for it in result_b.by_category[cat]]
|
||||
assert ids_a == ids_b, (
|
||||
f"Category {cat} differs between original and permuted: "
|
||||
f"{ids_a} vs {ids_b}"
|
||||
)
|
||||
|
||||
# Assert: errors lists are identical
|
||||
errors_a = [e.item_id for e in result_a.errors]
|
||||
errors_b = [e.item_id for e in result_b.errors]
|
||||
assert errors_a == errors_b, (
|
||||
f"Errors differ between original and permuted: "
|
||||
f"{errors_a} vs {errors_b}"
|
||||
)
|
||||
@@ -0,0 +1,143 @@
|
||||
# Feature: llm-batch-matching, Property 3: Deterministische Sortierung
|
||||
"""Property-based test for deterministic sorting under concurrent batch matching.
|
||||
|
||||
For any list of capacities with random category assignments and any
|
||||
max_concurrency value, the results within each category must be sorted
|
||||
by item_id ascending (lexicographic), and the errors list must also be
|
||||
sorted by item_id.
|
||||
|
||||
**Validates: Requirements 3.1**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
|
||||
from teamlandkarte_mcp.matching.profiles import TaskProfile
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
_ID_RE = re.compile(r"ID:\s*(\S+)")
|
||||
|
||||
_CATEGORIES: tuple[str, ...] = ("Top", "Good", "Partial", "Low", "Irrelevant")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test doubles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MapLlm:
|
||||
"""Returns a JSON payload whose category depends on the prompt's ID line.
|
||||
|
||||
Parses the ``ID: <capacity_id>`` line from the user prompt to determine
|
||||
which category to return. Includes a small sleep to simulate I/O and
|
||||
exercise concurrency paths.
|
||||
"""
|
||||
|
||||
def __init__(self, id_to_category: dict[str, str]) -> None:
|
||||
self._map = id_to_category
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
match = _ID_RE.search(user)
|
||||
cap_id = match.group(1) if match else "unknown"
|
||||
category = self._map.get(cap_id, "Irrelevant")
|
||||
await asyncio.sleep(0.005) # simulate I/O to exercise concurrency
|
||||
return json.dumps({"category": category, "rationale": f"r{cap_id}"})
|
||||
|
||||
|
||||
class _StubDb:
|
||||
"""Minimal DBClient double returning empty extras for every id."""
|
||||
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_capacity(cap_id: int) -> Capacity:
|
||||
return Capacity(
|
||||
id=cap_id,
|
||||
owner_name=f"o{cap_id}",
|
||||
role_name="R",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=[],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(
|
||||
ids=st.lists(
|
||||
st.integers(min_value=1, max_value=99),
|
||||
min_size=2,
|
||||
max_size=15,
|
||||
unique=True,
|
||||
),
|
||||
max_concurrency=st.integers(min_value=1, max_value=5),
|
||||
data=st.data(),
|
||||
)
|
||||
def test_batch_sort_deterministic(
|
||||
ids: list[int], max_concurrency: int, data: st.DataObject
|
||||
) -> None:
|
||||
"""Property 3: results within each category are sorted by item_id ascending.
|
||||
|
||||
Generates random capacity IDs, assigns random categories, and runs
|
||||
match_capacities with a random max_concurrency value. Asserts that
|
||||
within each category, items are sorted by item_id ascending
|
||||
(lexicographic), and the errors list is also sorted by item_id.
|
||||
"""
|
||||
id_to_category = {
|
||||
str(i): data.draw(st.sampled_from(_CATEGORIES)) for i in ids
|
||||
}
|
||||
capacities = [_make_capacity(i) for i in ids]
|
||||
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
|
||||
|
||||
matcher = LlmFulltextMatcher(
|
||||
db=_StubDb(),
|
||||
client=_MapLlm(id_to_category),
|
||||
max_concurrency=max_concurrency,
|
||||
)
|
||||
result = asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=task_profile, capacities=capacities
|
||||
)
|
||||
)
|
||||
|
||||
# Assert: within each category, items are sorted by item_id ascending
|
||||
for cat in _CATEGORIES:
|
||||
item_ids = [it.item_id for it in result.by_category[cat]]
|
||||
assert item_ids == sorted(item_ids), (
|
||||
f"Category {cat} not sorted ascending: {item_ids}"
|
||||
)
|
||||
|
||||
# Assert: errors list is also sorted by item_id ascending
|
||||
error_ids = [e.item_id for e in result.errors]
|
||||
assert error_ids == sorted(error_ids), f"Errors not sorted: {error_ids}"
|
||||
@@ -0,0 +1,177 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,245 @@
|
||||
# filepath: tests/test_bm25.py
|
||||
"""Unit tests for :mod:`teamlandkarte_mcp.matching.bm25`.
|
||||
|
||||
Covers tokenization, :class:`Bm25Index` behaviour, and the
|
||||
:func:`bm25_rank_competences` convenience wrapper.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from teamlandkarte_mcp.matching.bm25 import (
|
||||
Bm25Index,
|
||||
_tokenize,
|
||||
bm25_rank_competences,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tokenization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tokenize_lowercase() -> None:
|
||||
"""Tokens are always lowercase."""
|
||||
assert _tokenize("Python") == ["python"]
|
||||
|
||||
|
||||
def test_tokenize_slash() -> None:
|
||||
"""Slashes split tokens."""
|
||||
assert _tokenize("CI/CD Pipeline") == ["ci", "cd", "pipeline"]
|
||||
|
||||
|
||||
def test_tokenize_parentheses_and_space() -> None:
|
||||
"""Parentheses and spaces split tokens; PWA is its own token."""
|
||||
assert _tokenize("Progressive Web App (PWA)") == [
|
||||
"progressive",
|
||||
"web",
|
||||
"app",
|
||||
"pwa",
|
||||
]
|
||||
|
||||
|
||||
def test_tokenize_dot() -> None:
|
||||
"""Dots split tokens (e.g. React.js → react, js)."""
|
||||
assert _tokenize("React.js") == ["react", "js"]
|
||||
|
||||
|
||||
def test_tokenize_hyphen() -> None:
|
||||
"""Hyphens split tokens."""
|
||||
assert _tokenize("Type-Script") == ["type", "script"]
|
||||
|
||||
|
||||
def test_tokenize_empty_string() -> None:
|
||||
"""Empty string produces empty token list."""
|
||||
assert _tokenize("") == []
|
||||
|
||||
|
||||
def test_tokenize_only_special_chars() -> None:
|
||||
"""String of only special characters produces empty list."""
|
||||
assert _tokenize("---///") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bm25Index — empty corpus
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bm25_index_empty_corpus_rank_returns_empty() -> None:
|
||||
"""Empty corpus → rank() returns empty list."""
|
||||
index = Bm25Index(corpus=[])
|
||||
assert index.rank("Python") == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bm25Index — exact match
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bm25_index_exact_match_score_positive() -> None:
|
||||
"""Exact match produces a positive BM25 score in a realistic-sized corpus.
|
||||
|
||||
A corpus of five distinct skills ensures each term's IDF is positive
|
||||
(no term appears in more than half the documents), so BM25 assigns a
|
||||
meaningful positive score to the matching document.
|
||||
"""
|
||||
corpus = ["Python", "JavaScript", "Java", "Go", "Ruby"]
|
||||
index = Bm25Index(corpus=corpus)
|
||||
result = index.rank("Python")
|
||||
py_score = next(s for d, s in result if d == "Python")
|
||||
assert py_score > 0.0
|
||||
|
||||
|
||||
def test_bm25_index_case_insensitive_match() -> None:
|
||||
"""BM25 matching is case-insensitive (tokenization lowercases both sides).
|
||||
|
||||
Uses a realistic-sized corpus so IDF weights are positive.
|
||||
"""
|
||||
corpus = ["python", "JavaScript", "Java", "Go", "Ruby"]
|
||||
index = Bm25Index(corpus=corpus)
|
||||
result = index.rank("Python")
|
||||
py_score = next(s for d, s in result if d == "python")
|
||||
assert py_score > 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bm25Index — no token overlap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bm25_index_no_token_overlap_score_zero() -> None:
|
||||
"""No shared token between query and document → score 0.0."""
|
||||
index = Bm25Index(corpus=["JavaScript"])
|
||||
result = index.rank("Python")
|
||||
assert len(result) == 1
|
||||
assert result[0][0] == "JavaScript"
|
||||
assert result[0][1] == 0.0
|
||||
|
||||
|
||||
def test_bm25_index_partial_token_overlap() -> None:
|
||||
"""Partial token overlap gives positive score in a realistic-sized corpus.
|
||||
|
||||
"Machine Learning" shares the "learning" token with "Deep Learning".
|
||||
A five-document corpus keeps "learning" IDF positive (it appears in 2/5
|
||||
docs, well below the 50 % threshold that would zero out the IDF).
|
||||
"""
|
||||
corpus = ["Deep Learning", "Python", "JavaScript", "Java", "Go"]
|
||||
index = Bm25Index(corpus=corpus)
|
||||
result = index.rank("Machine Learning")
|
||||
dl_score = next(s for d, s in result if d == "Deep Learning")
|
||||
assert dl_score > 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bm25Index — multiple documents, ranking order
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bm25_index_best_match_ranked_first() -> None:
|
||||
"""The document with the highest score appears first.
|
||||
|
||||
Uses five documents so all query tokens have positive IDF and the
|
||||
exact-match document outscores partial-match documents.
|
||||
"""
|
||||
corpus = ["Machine Learning", "Deep Learning", "JavaScript", "Python", "Java"]
|
||||
index = Bm25Index(corpus=corpus)
|
||||
result = index.rank("Machine Learning")
|
||||
assert result[0][0] == "Machine Learning"
|
||||
assert result[0][1] > result[1][1]
|
||||
|
||||
|
||||
def test_bm25_index_zero_score_documents_included() -> None:
|
||||
"""Documents with score 0.0 are included in the ranked list.
|
||||
|
||||
Uses a realistic-sized corpus so the Python document itself gets a
|
||||
positive score, while the unrelated JavaScript document correctly
|
||||
scores 0.0 (no token overlap).
|
||||
"""
|
||||
corpus = ["Python", "JavaScript", "Java", "Go", "Ruby"]
|
||||
index = Bm25Index(corpus=corpus)
|
||||
result = index.rank("Python")
|
||||
docs = [r[0] for r in result]
|
||||
assert "JavaScript" in docs
|
||||
js_score = next(s for d, s in result if d == "JavaScript")
|
||||
assert js_score == 0.0
|
||||
|
||||
|
||||
def test_bm25_index_result_sorted_descending() -> None:
|
||||
"""Ranked list is sorted by score descending.
|
||||
|
||||
Uses a realistic-sized corpus to ensure stable positive IDF values.
|
||||
"""
|
||||
corpus = ["Python", "Python Advanced", "JavaScript", "Java", "Go"]
|
||||
index = Bm25Index(corpus=corpus)
|
||||
result = index.rank("Python")
|
||||
scores = [s for _, s in result]
|
||||
assert scores == sorted(scores, reverse=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# bm25_rank_competences convenience wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bm25_rank_competences_empty_candidates() -> None:
|
||||
"""Empty candidate list returns empty list."""
|
||||
assert bm25_rank_competences("Python", []) == []
|
||||
|
||||
|
||||
def test_bm25_rank_competences_consistent_with_index() -> None:
|
||||
"""Convenience wrapper produces same result as Bm25Index.rank()."""
|
||||
candidates = ["Python", "JavaScript", "Machine Learning", "Java", "Go"]
|
||||
index_result = Bm25Index(corpus=candidates).rank("Python")
|
||||
wrapper_result = bm25_rank_competences("Python", candidates)
|
||||
assert index_result == wrapper_result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global-index filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_bm25_index_rank_filters_to_candidate_subset() -> None:
|
||||
"""Scores from a global index can be filtered to a candidate's own skills.
|
||||
|
||||
This mirrors the production path in ``_bm25_rrf_similarity`` where a
|
||||
global ``Bm25Index`` is built over all candidates and then the result is
|
||||
filtered to only include the current candidate's competences.
|
||||
"""
|
||||
global_corpus = [
|
||||
"Python",
|
||||
"Machine Learning",
|
||||
"JavaScript",
|
||||
"TypeScript",
|
||||
"Java",
|
||||
"Go",
|
||||
"Ruby",
|
||||
]
|
||||
global_index = Bm25Index(corpus=global_corpus)
|
||||
|
||||
# Simulate one candidate who only knows Python + Machine Learning
|
||||
candidate_skills = {"Python", "Machine Learning"}
|
||||
|
||||
all_ranked = global_index.rank("Python")
|
||||
filtered = [(doc, score) for doc, score in all_ranked if doc in candidate_skills]
|
||||
|
||||
assert len(filtered) == 2
|
||||
docs = {d for d, _ in filtered}
|
||||
assert docs == candidate_skills
|
||||
# Python should score higher than Machine Learning for query "Python"
|
||||
py_score = next(s for d, s in filtered if d == "Python")
|
||||
ml_score = next(s for d, s in filtered if d == "Machine Learning")
|
||||
assert py_score > ml_score
|
||||
|
||||
|
||||
def test_bm25_ubiquitous_term_clamped_to_zero() -> None:
|
||||
"""A term that appears in more than half the corpus gets IDF ≤ 0.
|
||||
|
||||
``rank()`` clamps the resulting negative BM25 score to 0.0 — the term
|
||||
is non-discriminative and should not contribute positively to any match.
|
||||
"""
|
||||
# "dev" appears in 4 out of 5 documents → IDF < 0
|
||||
corpus = ["dev Python", "dev Java", "dev Go", "dev Ruby", "JavaScript"]
|
||||
index = Bm25Index(corpus=corpus)
|
||||
result = index.rank("dev")
|
||||
scores = [s for _, s in result]
|
||||
# All scores must be ≥ 0 (negative raw scores clamped to 0.0)
|
||||
assert all(s >= 0.0 for s in scores)
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Unit tests for the capacity details enrichment feature.
|
||||
|
||||
Tests verify that get_capacity_details correctly displays description,
|
||||
references, and certificates sections with proper formatting and ordering.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
from teamlandkarte_mcp.models import Capacity, Task
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Fake DB client for testing get_capacity_details enrichment."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
caps: list[Capacity],
|
||||
*,
|
||||
description: str | None = None,
|
||||
references: list[dict] | None = None,
|
||||
certificates: list[str] | None = None,
|
||||
) -> None:
|
||||
self._caps = caps
|
||||
self._description = description
|
||||
self._references = references if references is not None else []
|
||||
self._certificates = certificates if certificates is not None else []
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["Cloud Engineer"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["AWS"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20) -> list[Task]:
|
||||
return []
|
||||
|
||||
def get_task_by_id(self, task_id: str):
|
||||
return None
|
||||
|
||||
def get_task_by_name(self, name: str):
|
||||
return None
|
||||
|
||||
def get_all_capacities_with_competences(self) -> list[Capacity]:
|
||||
return list(self._caps)
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20) -> list[Capacity]:
|
||||
return list(self._caps)[:limit]
|
||||
|
||||
def get_capacity_by_id(self, capacity_id: int | str) -> Capacity | None:
|
||||
cid = int(capacity_id)
|
||||
for c in self._caps:
|
||||
if int(c.id) == cid:
|
||||
return c
|
||||
return None
|
||||
|
||||
def get_capacity_description(self, capacity_id: int | str) -> str | None:
|
||||
return self._description
|
||||
|
||||
def get_capacity_references(self, capacity_id: int | str) -> list[dict]:
|
||||
return self._references
|
||||
|
||||
def get_capacity_certificates(self, capacity_id: int | str) -> list[str]:
|
||||
return self._certificates
|
||||
|
||||
|
||||
def _make_config(tmp_path) -> str:
|
||||
"""Write a minimal config.toml and return its path."""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=10
|
||||
max_size=10
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return str(cfg)
|
||||
|
||||
|
||||
def _make_capacity(cap_id: int = 1) -> Capacity:
|
||||
return Capacity(
|
||||
id=cap_id,
|
||||
owner_name="Team Alpha",
|
||||
role_name="Cloud Engineer",
|
||||
role_level=None,
|
||||
begin_date=date(2026, 1, 1),
|
||||
end_date=date(2026, 6, 30),
|
||||
competences=["AWS", "Terraform"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2.1 Test full data scenario
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_data_scenario(monkeypatch, tmp_path) -> None:
|
||||
"""All enrichment sections are rendered with correct content and ordering."""
|
||||
|
||||
description = "Erfahrener Cloud-Architekt mit Fokus auf AWS-Infrastruktur."
|
||||
references = [
|
||||
{"partner_name": "Deutsche Bahn", "projects": "Projekt Reiseportal, Projekt Fahrplan"},
|
||||
{"partner_name": "", "projects": "Internes Migrationsprojekt"},
|
||||
{"partner_name": "Siemens", "projects": "IoT Platform"},
|
||||
]
|
||||
certificates = [
|
||||
"AWS Solutions Architect Professional",
|
||||
"Kubernetes Administrator (CKA)",
|
||||
]
|
||||
|
||||
cap = _make_capacity()
|
||||
fake_db = _FakeDB(
|
||||
[cap],
|
||||
description=description,
|
||||
references=references,
|
||||
certificates=certificates,
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
monkeypatch.setattr(mod, "create_db_client", lambda *_: fake_db)
|
||||
|
||||
srv = build_server(_make_config(tmp_path))
|
||||
content, structured = await srv.call_tool(
|
||||
"get_capacity_details", {"capacity_id": 1}
|
||||
)
|
||||
out = (structured or {}).get("result") or getattr(content[0], "text", "")
|
||||
|
||||
# Section headers present
|
||||
assert "## Beschreibung" in out
|
||||
assert "## Referenzen" in out
|
||||
assert "## Zertifizierungen" in out
|
||||
assert "## Next steps" in out
|
||||
|
||||
# Description content
|
||||
assert description in out
|
||||
|
||||
# References content: partner with name uses bold formatting
|
||||
assert "**Deutsche Bahn**: Projekt Reiseportal, Projekt Fahrplan" in out
|
||||
# Reference without partner_name shows only projects (no bold prefix)
|
||||
assert "- Internes Migrationsprojekt" in out
|
||||
assert "**Siemens**: IoT Platform" in out
|
||||
# Ensure the empty-partner reference does NOT have bold empty prefix
|
||||
assert "****:" not in out
|
||||
|
||||
# Certificates content
|
||||
assert "- AWS Solutions Architect Professional" in out
|
||||
assert "- Kubernetes Administrator (CKA)" in out
|
||||
|
||||
# Section ordering: Beschreibung < Referenzen < Zertifizierungen < Next steps
|
||||
idx_beschreibung = out.index("## Beschreibung")
|
||||
idx_referenzen = out.index("## Referenzen")
|
||||
idx_zertifizierungen = out.index("## Zertifizierungen")
|
||||
idx_next_steps = out.index("## Next steps")
|
||||
|
||||
assert idx_beschreibung < idx_referenzen < idx_zertifizierungen < idx_next_steps
|
||||
|
||||
# Capacity table appears before all enrichment sections
|
||||
assert "| capacity_id |" in out
|
||||
idx_table = out.index("| capacity_id |")
|
||||
assert idx_table < idx_beschreibung
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2.2 Test empty-state scenario
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_state_scenario(monkeypatch, tmp_path) -> None:
|
||||
"""When all enrichment data is empty/None, (keine) placeholders appear."""
|
||||
|
||||
cap = _make_capacity()
|
||||
fake_db = _FakeDB(
|
||||
[cap],
|
||||
description=None,
|
||||
references=[],
|
||||
certificates=[],
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
monkeypatch.setattr(mod, "create_db_client", lambda *_: fake_db)
|
||||
|
||||
srv = build_server(_make_config(tmp_path))
|
||||
content, structured = await srv.call_tool(
|
||||
"get_capacity_details", {"capacity_id": 1}
|
||||
)
|
||||
out = (structured or {}).get("result") or getattr(content[0], "text", "")
|
||||
|
||||
# All three sections show (keine) placeholders
|
||||
assert "Beschreibung: (keine)" in out
|
||||
assert "Referenzen: (keine)" in out
|
||||
assert "Zertifizierungen: (keine)" in out
|
||||
|
||||
# Section headers still present
|
||||
assert "## Beschreibung" in out
|
||||
assert "## Referenzen" in out
|
||||
assert "## Zertifizierungen" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2.3 Test capacity-not-found unchanged
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capacity_not_found_unchanged(monkeypatch, tmp_path) -> None:
|
||||
"""When capacity is not found, error message is returned without enrichment calls."""
|
||||
|
||||
fake_db = _FakeDB(
|
||||
[], # No capacities → get_capacity_by_id returns None
|
||||
description="Should not appear",
|
||||
references=[{"partner_name": "X", "projects": "Y"}],
|
||||
certificates=["Cert"],
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
monkeypatch.setattr(mod, "create_db_client", lambda *_: fake_db)
|
||||
|
||||
srv = build_server(_make_config(tmp_path))
|
||||
content, structured = await srv.call_tool(
|
||||
"get_capacity_details", {"capacity_id": 999}
|
||||
)
|
||||
out = (structured or {}).get("result") or getattr(content[0], "text", "")
|
||||
|
||||
# Returns the standard error message
|
||||
assert out == "Capacity not found: 999"
|
||||
|
||||
# Enrichment data should NOT appear
|
||||
assert "## Beschreibung" not in out
|
||||
assert "## Referenzen" not in out
|
||||
assert "## Zertifizierungen" not in out
|
||||
assert "Should not appear" not in out
|
||||
@@ -0,0 +1,446 @@
|
||||
"""Property-based tests for the capacity details enrichment feature.
|
||||
|
||||
Uses Hypothesis to verify correctness properties across many random inputs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
from teamlandkarte_mcp.models import Capacity, Task
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal fake DB client for property-based tests."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
capacity: Capacity | None,
|
||||
*,
|
||||
description: str | None = None,
|
||||
references: list[dict] | None = None,
|
||||
certificates: list[str] | None = None,
|
||||
) -> None:
|
||||
self._capacity = capacity
|
||||
self._description = description
|
||||
self._references = references if references is not None else []
|
||||
self._certificates = certificates if certificates is not None else []
|
||||
# Track calls for Property 1
|
||||
self.calls: dict[str, list] = {
|
||||
"get_capacity_description": [],
|
||||
"get_capacity_references": [],
|
||||
"get_capacity_certificates": [],
|
||||
}
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["Cloud Engineer"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["AWS"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20) -> list[Task]:
|
||||
return []
|
||||
|
||||
def get_task_by_id(self, task_id: str):
|
||||
return None
|
||||
|
||||
def get_task_by_name(self, name: str):
|
||||
return None
|
||||
|
||||
def get_all_capacities_with_competences(self) -> list[Capacity]:
|
||||
return [self._capacity] if self._capacity else []
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20) -> list[Capacity]:
|
||||
return [self._capacity] if self._capacity else []
|
||||
|
||||
def get_capacity_by_id(self, capacity_id: int | str) -> Capacity | None:
|
||||
if self._capacity and int(capacity_id) == self._capacity.id:
|
||||
return self._capacity
|
||||
return None
|
||||
|
||||
def get_capacity_description(self, capacity_id: int | str) -> str | None:
|
||||
self.calls["get_capacity_description"].append(capacity_id)
|
||||
return self._description
|
||||
|
||||
def get_capacity_references(self, capacity_id: int | str) -> list[dict]:
|
||||
self.calls["get_capacity_references"].append(capacity_id)
|
||||
return self._references
|
||||
|
||||
def get_capacity_certificates(self, capacity_id: int | str) -> list[str]:
|
||||
self.calls["get_capacity_certificates"].append(capacity_id)
|
||||
return self._certificates
|
||||
|
||||
|
||||
def _make_config(tmp_path) -> str:
|
||||
"""Write a minimal config.toml and return its path."""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=10
|
||||
max_size=10
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return str(cfg)
|
||||
|
||||
|
||||
def _make_capacity(cap_id: int) -> Capacity:
|
||||
return Capacity(
|
||||
id=cap_id,
|
||||
owner_name="Team Alpha",
|
||||
role_name="Cloud Engineer",
|
||||
role_level=None,
|
||||
begin_date=date(2026, 1, 1),
|
||||
end_date=date(2026, 6, 30),
|
||||
competences=["AWS", "Terraform"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 1: All enrichment data is fetched for any capacity
|
||||
# Feature: capacity-details-enrichment
|
||||
# Validates: Requirements 1.1, 2.1, 3.1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@settings(max_examples=100)
|
||||
@given(capacity_id=st.integers(min_value=1, max_value=100_000))
|
||||
async def test_all_enrichment_data_fetched(capacity_id: int, tmp_path_factory):
|
||||
"""Property 1: All enrichment data is fetched for any capacity.
|
||||
|
||||
For any valid capacity ID that resolves to an existing capacity,
|
||||
the tool SHALL invoke get_capacity_description, get_capacity_references,
|
||||
and get_capacity_certificates with that capacity ID.
|
||||
|
||||
**Validates: Requirements 1.1, 2.1, 3.1**
|
||||
"""
|
||||
tmp_path = tmp_path_factory.mktemp("cfg")
|
||||
cap = _make_capacity(capacity_id)
|
||||
fake_db = _FakeDB(
|
||||
cap,
|
||||
description="Some description",
|
||||
references=[{"partner_name": "P", "projects": "Proj"}],
|
||||
certificates=["Cert A"],
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
with patch.object(mod, "create_db_client", return_value=fake_db):
|
||||
srv = build_server(_make_config(tmp_path))
|
||||
await srv.call_tool("get_capacity_details", {"capacity_id": capacity_id})
|
||||
|
||||
# Each enrichment method must be called exactly once with the correct ID
|
||||
assert len(fake_db.calls["get_capacity_description"]) == 1
|
||||
assert fake_db.calls["get_capacity_description"][0] == capacity_id
|
||||
|
||||
assert len(fake_db.calls["get_capacity_references"]) == 1
|
||||
assert fake_db.calls["get_capacity_references"][0] == capacity_id
|
||||
|
||||
assert len(fake_db.calls["get_capacity_certificates"]) == 1
|
||||
assert fake_db.calls["get_capacity_certificates"][0] == capacity_id
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 2: Non-empty data appears in output
|
||||
# Feature: capacity-details-enrichment
|
||||
# Validates: Requirements 1.2, 2.2, 3.2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Strategies for generating test data
|
||||
_description_st = st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "Z"),
|
||||
blacklist_characters="\x00",
|
||||
),
|
||||
min_size=1,
|
||||
max_size=200,
|
||||
).filter(lambda s: s.strip())
|
||||
|
||||
_reference_st = st.fixed_dictionaries({
|
||||
"partner_name": st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "Z"),
|
||||
blacklist_characters="\x00",
|
||||
),
|
||||
min_size=1,
|
||||
max_size=50,
|
||||
).filter(lambda s: s.strip()),
|
||||
"projects": st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "Z"),
|
||||
blacklist_characters="\x00",
|
||||
),
|
||||
min_size=1,
|
||||
max_size=100,
|
||||
).filter(lambda s: s.strip()),
|
||||
})
|
||||
|
||||
_certificate_st = st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "Z"),
|
||||
blacklist_characters="\x00",
|
||||
),
|
||||
min_size=1,
|
||||
max_size=100,
|
||||
).filter(lambda s: s.strip())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
description=_description_st,
|
||||
references=st.lists(_reference_st, min_size=1, max_size=5),
|
||||
certificates=st.lists(_certificate_st, min_size=1, max_size=5),
|
||||
)
|
||||
async def test_nonempty_data_appears_in_output(
|
||||
description: str,
|
||||
references: list[dict],
|
||||
certificates: list[str],
|
||||
tmp_path_factory,
|
||||
):
|
||||
"""Property 2: Non-empty data appears in output.
|
||||
|
||||
For any capacity with a non-empty description, a non-empty list of
|
||||
references, and a non-empty list of certificates, the formatted output
|
||||
SHALL contain the description text, every reference's projects text,
|
||||
and every certificate string.
|
||||
|
||||
**Validates: Requirements 1.2, 2.2, 3.2**
|
||||
"""
|
||||
tmp_path = tmp_path_factory.mktemp("cfg")
|
||||
cap = _make_capacity(1)
|
||||
fake_db = _FakeDB(
|
||||
cap,
|
||||
description=description,
|
||||
references=references,
|
||||
certificates=certificates,
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
with patch.object(mod, "create_db_client", return_value=fake_db):
|
||||
srv = build_server(_make_config(tmp_path))
|
||||
content, structured = await srv.call_tool(
|
||||
"get_capacity_details", {"capacity_id": 1}
|
||||
)
|
||||
out = (structured or {}).get("result") or getattr(
|
||||
content[0], "text", ""
|
||||
)
|
||||
|
||||
# Description must appear in output
|
||||
assert description in out, (
|
||||
f"Description not found in output: {description!r}"
|
||||
)
|
||||
|
||||
# Every reference's projects text must appear
|
||||
for ref in references:
|
||||
assert ref["projects"] in out, (
|
||||
f"Reference projects not found: {ref['projects']!r}"
|
||||
)
|
||||
|
||||
# Every certificate must appear
|
||||
for cert in certificates:
|
||||
assert cert in out, f"Certificate not found: {cert!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 3: Section ordering is fixed
|
||||
# Feature: capacity-details-enrichment
|
||||
# Validates: Requirements 4.1, 4.2
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_optional_description_st = st.one_of(
|
||||
st.none(),
|
||||
st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "Z"),
|
||||
blacklist_characters="\x00",
|
||||
),
|
||||
min_size=1,
|
||||
max_size=100,
|
||||
).filter(lambda s: s.strip()),
|
||||
)
|
||||
|
||||
_optional_references_st = st.lists(
|
||||
st.fixed_dictionaries({
|
||||
"partner_name": st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N"),
|
||||
blacklist_characters="\x00",
|
||||
),
|
||||
min_size=0,
|
||||
max_size=30,
|
||||
),
|
||||
"projects": st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "Z"),
|
||||
blacklist_characters="\x00",
|
||||
),
|
||||
min_size=1,
|
||||
max_size=50,
|
||||
).filter(lambda s: s.strip()),
|
||||
}),
|
||||
min_size=0,
|
||||
max_size=5,
|
||||
)
|
||||
|
||||
_optional_certificates_st = st.lists(
|
||||
st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "P", "Z"),
|
||||
blacklist_characters="\x00",
|
||||
),
|
||||
min_size=1,
|
||||
max_size=50,
|
||||
).filter(lambda s: s.strip()),
|
||||
min_size=0,
|
||||
max_size=5,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(
|
||||
description=_optional_description_st,
|
||||
references=_optional_references_st,
|
||||
certificates=_optional_certificates_st,
|
||||
)
|
||||
async def test_section_ordering_is_fixed(
|
||||
description: str | None,
|
||||
references: list[dict],
|
||||
certificates: list[str],
|
||||
tmp_path_factory,
|
||||
):
|
||||
"""Property 3: Section ordering is fixed.
|
||||
|
||||
For any capacity (regardless of which fields are empty or populated),
|
||||
the output string SHALL contain the section markers in the order:
|
||||
capacity table first, then "Beschreibung", then "Referenzen", then
|
||||
"Zertifizierungen", then "Next steps" — and each section SHALL be
|
||||
separated by at least one blank line.
|
||||
|
||||
**Validates: Requirements 4.1, 4.2**
|
||||
"""
|
||||
tmp_path = tmp_path_factory.mktemp("cfg")
|
||||
cap = _make_capacity(1)
|
||||
fake_db = _FakeDB(
|
||||
cap,
|
||||
description=description,
|
||||
references=references,
|
||||
certificates=certificates,
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
with patch.object(mod, "create_db_client", return_value=fake_db):
|
||||
srv = build_server(_make_config(tmp_path))
|
||||
content, structured = await srv.call_tool(
|
||||
"get_capacity_details", {"capacity_id": 1}
|
||||
)
|
||||
out = (structured or {}).get("result") or getattr(
|
||||
content[0], "text", ""
|
||||
)
|
||||
|
||||
# All section headers must be present
|
||||
assert "## Beschreibung" in out
|
||||
assert "## Referenzen" in out
|
||||
assert "## Zertifizierungen" in out
|
||||
assert "## Next steps" in out
|
||||
|
||||
# Section ordering must be fixed
|
||||
idx_beschreibung = out.index("## Beschreibung")
|
||||
idx_referenzen = out.index("## Referenzen")
|
||||
idx_zertifizierungen = out.index("## Zertifizierungen")
|
||||
idx_next_steps = out.index("## Next steps")
|
||||
|
||||
assert idx_beschreibung < idx_referenzen, (
|
||||
"Beschreibung must come before Referenzen"
|
||||
)
|
||||
assert idx_referenzen < idx_zertifizierungen, (
|
||||
"Referenzen must come before Zertifizierungen"
|
||||
)
|
||||
assert idx_zertifizierungen < idx_next_steps, (
|
||||
"Zertifizierungen must come before Next steps"
|
||||
)
|
||||
|
||||
# Capacity table must come before all enrichment sections
|
||||
assert "| capacity_id |" in out
|
||||
idx_table = out.index("| capacity_id |")
|
||||
assert idx_table < idx_beschreibung, (
|
||||
"Capacity table must come before Beschreibung"
|
||||
)
|
||||
|
||||
# Each section must be separated by at least one blank line
|
||||
# A blank line means two consecutive newlines (\n\n)
|
||||
between_table_and_beschreibung = out[idx_table:idx_beschreibung]
|
||||
assert "\n\n" in between_table_and_beschreibung, (
|
||||
"Blank line required between table and Beschreibung"
|
||||
)
|
||||
|
||||
between_beschreibung_and_referenzen = out[idx_beschreibung:idx_referenzen]
|
||||
assert "\n\n" in between_beschreibung_and_referenzen, (
|
||||
"Blank line required between Beschreibung and Referenzen"
|
||||
)
|
||||
|
||||
between_referenzen_and_zertifizierungen = out[
|
||||
idx_referenzen:idx_zertifizierungen
|
||||
]
|
||||
assert "\n\n" in between_referenzen_and_zertifizierungen, (
|
||||
"Blank line required between Referenzen and Zertifizierungen"
|
||||
)
|
||||
|
||||
between_zertifizierungen_and_next = out[idx_zertifizierungen:idx_next_steps]
|
||||
assert "\n\n" in between_zertifizierungen_and_next, (
|
||||
"Blank line required between Zertifizierungen and Next steps"
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
# Bugfix: llm-competence-inference, Property 1: Fault Condition
|
||||
# Kompetenz-Inferenz liefert stets leere Liste (Bug-Bestätigung)
|
||||
|
||||
"""Property-based exploration test for the competence inference bug.
|
||||
|
||||
This test encodes the EXPECTED (correct) behaviour: given a non-empty task
|
||||
text and a non-empty competence list in the DB, `infer_competences` should
|
||||
return a non-empty list of up to 10 (name, confidence) tuples where each
|
||||
name is in the DB competence list and confidence is in [0.0, 1.0].
|
||||
|
||||
On UNFIXED code this test is EXPECTED TO FAIL — the failure confirms the bug
|
||||
exists (infer_competences either doesn't exist or always returns []).
|
||||
|
||||
**Validates: Requirements 1.1, 1.2, 1.3, 2.1, 2.2, 2.3**
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.vocabulary import VocabularyCache
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Non-empty task text (printable characters, min 1 char)
|
||||
_task_text_strategy = st.text(min_size=1, max_size=200).filter(lambda s: s.strip())
|
||||
|
||||
# Non-empty competence list (each competence is a non-empty string)
|
||||
_competence_list_strategy = st.lists(
|
||||
st.text(min_size=1, max_size=50).filter(lambda s: s.strip()),
|
||||
min_size=1,
|
||||
max_size=50,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Stub DBClient that returns a configurable competence list."""
|
||||
|
||||
def __init__(self, competences: list[str]) -> None:
|
||||
self._competences = competences
|
||||
|
||||
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 valid JSON with competences.
|
||||
|
||||
Simulates a working LLM that picks up to 5 competences from the
|
||||
provided list (via the user prompt) and assigns confidence scores.
|
||||
"""
|
||||
|
||||
def __init__(self, competences: list[str]) -> None:
|
||||
self._competences = competences
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
# Pick up to 5 competences from the list and return valid JSON
|
||||
selected = self._competences[:5]
|
||||
result = {
|
||||
"competences": [
|
||||
{"name": name, "confidence": round(0.6 + i * 0.05, 2)}
|
||||
for i, name in enumerate(selected)
|
||||
]
|
||||
}
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property Test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
task_text=_task_text_strategy,
|
||||
competences=_competence_list_strategy,
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_infer_competences_returns_nonempty_for_valid_input(
|
||||
task_text: str,
|
||||
competences: list[str],
|
||||
) -> None:
|
||||
"""Property 1 (Fault Condition): For any non-empty task text and non-empty
|
||||
competence list in the DB, infer_competences MUST return a non-empty list
|
||||
of up to 10 tuples (name, confidence) where each name is in the DB
|
||||
competence list and confidence is in [0.0, 1.0].
|
||||
|
||||
On unfixed code this WILL FAIL because:
|
||||
- The method `infer_competences` does not exist on VocabularyCache, OR
|
||||
- It always returns []
|
||||
"""
|
||||
db = _FakeDB(competences=competences)
|
||||
client = _FakeLLMClient(competences=competences)
|
||||
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
|
||||
|
||||
# The method must exist and be callable
|
||||
assert hasattr(cache, "infer_competences"), (
|
||||
"VocabularyCache has no 'infer_competences' method — "
|
||||
"the competence inference is not implemented"
|
||||
)
|
||||
|
||||
result = await cache.infer_competences(task_text=task_text)
|
||||
|
||||
# Must return a non-empty list
|
||||
assert isinstance(result, list), (
|
||||
f"Expected list, got {type(result).__name__}"
|
||||
)
|
||||
assert len(result) > 0, (
|
||||
f"infer_competences('{task_text[:50]}...') returned empty list [] "
|
||||
f"instead of competences — bug confirmed: no LLM inference happening"
|
||||
)
|
||||
|
||||
# Must return at most 10 tuples
|
||||
assert len(result) <= 10, (
|
||||
f"Expected at most 10 competences, got {len(result)}"
|
||||
)
|
||||
|
||||
# Each element must be a tuple (name, confidence)
|
||||
for item in result:
|
||||
assert isinstance(item, tuple) and len(item) == 2, (
|
||||
f"Expected tuple (name, confidence), got {item!r}"
|
||||
)
|
||||
name, confidence = item
|
||||
assert name in competences, (
|
||||
f"Returned competence '{name}' not in DB competence list"
|
||||
)
|
||||
assert isinstance(confidence, float), (
|
||||
f"Expected float confidence, got {type(confidence).__name__}"
|
||||
)
|
||||
assert 0.0 <= confidence <= 1.0, (
|
||||
f"Confidence {confidence} out of range [0.0, 1.0]"
|
||||
)
|
||||
@@ -0,0 +1,251 @@
|
||||
# Bugfix: llm-competence-inference, Property 2: Preservation
|
||||
# Rollen-Inferenz und Leer-Eingaben unverändert
|
||||
|
||||
"""Property-based preservation tests for the competence inference bugfix.
|
||||
|
||||
These tests verify that EXISTING behaviour is preserved both before and after
|
||||
the fix:
|
||||
|
||||
1. `infer_primary_role` continues to return a valid (role, confidence) tuple
|
||||
for non-empty task texts (or None on error).
|
||||
2. Empty task text yields [] from `infer_competences` (if the method exists).
|
||||
3. Empty competence list in DB yields [] from `infer_competences` (if exists).
|
||||
|
||||
On UNFIXED code these tests are EXPECTED TO PASS — they confirm baseline
|
||||
behaviour that must not regress after the fix is applied.
|
||||
|
||||
**Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5**
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.vocabulary import VocabularyCache
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Non-empty task text (printable ASCII, min 1 char after strip)
|
||||
_nonempty_task_text = st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "N", "Zs"),
|
||||
max_codepoint=127,
|
||||
),
|
||||
min_size=1,
|
||||
max_size=150,
|
||||
).filter(lambda s: s.strip())
|
||||
|
||||
# Empty or whitespace-only task text
|
||||
_empty_task_text = st.one_of(
|
||||
st.just(""),
|
||||
st.from_regex(r"^\s*$", fullmatch=True),
|
||||
)
|
||||
|
||||
# Non-empty competence list
|
||||
_competence_list = st.lists(
|
||||
st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L",), max_codepoint=127),
|
||||
min_size=2,
|
||||
max_size=30,
|
||||
).filter(lambda s: s.strip()),
|
||||
min_size=1,
|
||||
max_size=30,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
# Non-empty role list
|
||||
_role_list = st.lists(
|
||||
st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L",), max_codepoint=127),
|
||||
min_size=3,
|
||||
max_size=30,
|
||||
).filter(lambda s: s.strip()),
|
||||
min_size=1,
|
||||
max_size=10,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Stub DBClient with configurable roles and competences."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
roles: list[str] | None = None,
|
||||
competences: list[str] | None = None,
|
||||
) -> None:
|
||||
self._roles = roles or ["Software Engineer"]
|
||||
self._competences = competences or []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return self._roles
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return self._competences
|
||||
|
||||
|
||||
class _FakeRoleLLMClient:
|
||||
"""Stub AzureOpenAIClient that returns a valid role from the DB list.
|
||||
|
||||
Picks the first role from the list provided at construction time.
|
||||
"""
|
||||
|
||||
def __init__(self, role: str, confidence: float = 0.85) -> None:
|
||||
self._role = role
|
||||
self._confidence = confidence
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
return json.dumps({"role": self._role, "confidence": self._confidence})
|
||||
|
||||
|
||||
class _FakeCompetenceLLMClient:
|
||||
"""Stub AzureOpenAIClient that returns valid competence JSON."""
|
||||
|
||||
def __init__(self, competences: list[str] | None = None) -> None:
|
||||
self._competences = competences or []
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
selected = self._competences[:3]
|
||||
result = {
|
||||
"competences": [
|
||||
{"name": name, "confidence": 0.8} for name in selected
|
||||
]
|
||||
}
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property-Based Test 1: infer_primary_role preservation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
task_text=_nonempty_task_text,
|
||||
roles=_role_list,
|
||||
role_index=st.data(),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_infer_primary_role_returns_valid_tuple_for_nonempty_text(
|
||||
task_text: str,
|
||||
roles: list[str],
|
||||
role_index: st.DataObject,
|
||||
) -> None:
|
||||
"""Property 2.1 (Preservation): For all non-empty task texts,
|
||||
`infer_primary_role` returns a tuple (role_name, confidence) where
|
||||
role_name is in the DB role list and confidence is in [0.0, 1.0],
|
||||
or None on failure.
|
||||
|
||||
This verifies Requirement 3.1 and 3.5: infer_primary_role must continue
|
||||
to select exactly one role from the role list with a confidence score.
|
||||
|
||||
**Validates: Requirements 3.1, 3.4, 3.5**
|
||||
"""
|
||||
idx = role_index.draw(st.integers(min_value=0, max_value=len(roles) - 1))
|
||||
chosen_role = roles[idx]
|
||||
|
||||
db = _FakeDB(roles=roles)
|
||||
client = _FakeRoleLLMClient(role=chosen_role, confidence=0.85)
|
||||
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
|
||||
|
||||
result = await cache.infer_primary_role(task_text=task_text)
|
||||
|
||||
# Must return a valid tuple or None (None only on error/unknown role)
|
||||
assert result is not None, (
|
||||
f"infer_primary_role returned None for valid role '{chosen_role}' "
|
||||
f"in list {roles}"
|
||||
)
|
||||
role_name, confidence = result
|
||||
assert role_name in roles, (
|
||||
f"Returned role '{role_name}' not in DB role list"
|
||||
)
|
||||
assert isinstance(confidence, float), (
|
||||
f"Expected float confidence, got {type(confidence).__name__}"
|
||||
)
|
||||
assert 0.0 <= confidence <= 1.0, (
|
||||
f"Confidence {confidence} out of range [0.0, 1.0]"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property-Based Test 2: Empty task text → infer_competences returns []
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(task_text=_empty_task_text)
|
||||
@pytest.mark.asyncio
|
||||
async def test_infer_competences_returns_empty_for_empty_text(
|
||||
task_text: str,
|
||||
) -> None:
|
||||
"""Property 2.2 (Preservation): For all empty/whitespace-only task texts,
|
||||
`infer_competences` returns [].
|
||||
|
||||
If the method does not exist yet (unfixed code), the test passes trivially
|
||||
because the preservation property is satisfied — there is no method that
|
||||
could produce incorrect results for empty input.
|
||||
|
||||
**Validates: Requirements 3.2, 3.3**
|
||||
"""
|
||||
competences = ["Python", "Java", "TypeScript", "React", "Docker"]
|
||||
db = _FakeDB(roles=["Engineer"], competences=competences)
|
||||
client = _FakeCompetenceLLMClient(competences=competences)
|
||||
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
|
||||
|
||||
# If infer_competences doesn't exist yet, preservation is trivially satisfied
|
||||
if not hasattr(cache, "infer_competences"):
|
||||
return
|
||||
|
||||
result = await cache.infer_competences(task_text=task_text)
|
||||
|
||||
assert result == [], (
|
||||
f"infer_competences('{task_text!r}') should return [] for "
|
||||
f"empty/whitespace text, got {result!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property-Based Test 3: Empty competence list in DB → infer_competences []
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100)
|
||||
@given(task_text=_nonempty_task_text)
|
||||
@pytest.mark.asyncio
|
||||
async def test_infer_competences_returns_empty_for_empty_db_competences(
|
||||
task_text: str,
|
||||
) -> None:
|
||||
"""Property 2.3 (Preservation): For an empty competence list in the DB,
|
||||
`infer_competences` returns [] regardless of task text.
|
||||
|
||||
If the method does not exist yet (unfixed code), the test passes trivially
|
||||
because the preservation property is satisfied — there is no method that
|
||||
could produce incorrect results for empty DB competences.
|
||||
|
||||
**Validates: Requirements 3.2, 3.3**
|
||||
"""
|
||||
db = _FakeDB(roles=["Engineer"], competences=[]) # Empty competence list
|
||||
client = _FakeCompetenceLLMClient(competences=[])
|
||||
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
|
||||
|
||||
# If infer_competences doesn't exist yet, preservation is trivially satisfied
|
||||
if not hasattr(cache, "infer_competences"):
|
||||
return
|
||||
|
||||
result = await cache.infer_competences(task_text=task_text)
|
||||
|
||||
assert result == [], (
|
||||
f"infer_competences('{task_text[:50]}...') should return [] when "
|
||||
f"DB has no competences, got {result!r}"
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Unit tests for ``MatchingConfig.default_method`` validation in ``load_config``.
|
||||
|
||||
Covers the three cases from spec task 5.3:
|
||||
|
||||
* ``default_method`` missing -> default ``"score"``.
|
||||
* ``default_method = "llm_fulltext"`` -> accepted as-is.
|
||||
* ``default_method = "irgendwas"`` -> ``ConfigError`` with helpful message.
|
||||
|
||||
Validates: Requirements 12.3, 12.4
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.config import ConfigError, load_config
|
||||
|
||||
|
||||
def _write_config(tmp_path, *, default_method_line: str = ""):
|
||||
"""Write a minimal valid ``config.toml`` for ``load_config`` tests.
|
||||
|
||||
``default_method_line`` is inserted into the ``[matching]`` section as a
|
||||
raw TOML line (e.g. ``'default_method = "llm_fulltext"'``) or left empty
|
||||
to omit the key entirely.
|
||||
"""
|
||||
|
||||
content = f"""\
|
||||
[database]
|
||||
backend = "trino"
|
||||
host = "trino.example"
|
||||
port = 443
|
||||
http_scheme = "https"
|
||||
verify_ssl = true
|
||||
catalog = "hive"
|
||||
schema = "tier1_open_lake"
|
||||
connect_timeout = 10
|
||||
pool_size = 4
|
||||
|
||||
[matching]
|
||||
competence_weight = 0.8
|
||||
role_weight = 0.2
|
||||
require_confirmation = true
|
||||
{default_method_line}
|
||||
|
||||
[matching.thresholds]
|
||||
top = 0.8
|
||||
good = 0.65
|
||||
partial = 0.5
|
||||
low = 0.3
|
||||
|
||||
[matching.fuzzy]
|
||||
min_similarity = 0.7
|
||||
|
||||
[cache]
|
||||
db_ttl_hours = 12
|
||||
search_ttl_minutes = 60
|
||||
max_size = 100
|
||||
|
||||
[azure_openai]
|
||||
endpoint = "https://example.openai.azure.com"
|
||||
api_version = "2024-02-15-preview"
|
||||
chat_deployment = "gpt-4"
|
||||
show_costs_in_output = false
|
||||
"""
|
||||
p = tmp_path / "config.toml"
|
||||
p.write_text(content)
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_env(monkeypatch):
|
||||
"""Provide the credentials required by ``load_config``."""
|
||||
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
|
||||
def test_default_method_missing_falls_back_to_score(tmp_path):
|
||||
path = _write_config(tmp_path)
|
||||
cfg = load_config(path)
|
||||
assert cfg.matching.default_method == "score"
|
||||
|
||||
|
||||
def test_default_method_llm_fulltext_is_accepted(tmp_path):
|
||||
path = _write_config(
|
||||
tmp_path, default_method_line='default_method = "llm_fulltext"'
|
||||
)
|
||||
cfg = load_config(path)
|
||||
assert cfg.matching.default_method == "llm_fulltext"
|
||||
|
||||
|
||||
def test_default_method_score_is_accepted_explicit(tmp_path):
|
||||
path = _write_config(
|
||||
tmp_path, default_method_line='default_method = "score"'
|
||||
)
|
||||
cfg = load_config(path)
|
||||
assert cfg.matching.default_method == "score"
|
||||
|
||||
|
||||
def test_default_method_case_insensitive(tmp_path):
|
||||
path = _write_config(
|
||||
tmp_path, default_method_line='default_method = "LLM_FULLTEXT"'
|
||||
)
|
||||
cfg = load_config(path)
|
||||
assert cfg.matching.default_method == "llm_fulltext"
|
||||
|
||||
|
||||
def test_default_method_invalid_raises_config_error(tmp_path):
|
||||
path = _write_config(
|
||||
tmp_path, default_method_line='default_method = "irgendwas"'
|
||||
)
|
||||
with pytest.raises(ConfigError) as exc:
|
||||
load_config(path)
|
||||
msg = str(exc.value)
|
||||
assert "default_method" in msg
|
||||
assert "score" in msg
|
||||
assert "llm_fulltext" in msg
|
||||
@@ -0,0 +1,249 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeDBClient:
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["Cloud Engineer"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["AWS"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_capacities_with_competences(self): # pragma: no cover
|
||||
return []
|
||||
|
||||
|
||||
class FakeMatcher:
|
||||
async def match(self, capacities, requirements): # pragma: no cover
|
||||
raise AssertionError("match should not be called when gate blocks")
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
# FastMCP.call_tool returns (content, structured) in this project.
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(structured.get("result"), str):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
# Typically TextContent(type='text', text='...')
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return str(result)
|
||||
|
||||
|
||||
def _call_tool(
|
||||
srv: Any,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
if arguments is None:
|
||||
arguments = {}
|
||||
coro = srv.call_tool(name, arguments)
|
||||
return _result_to_text(asyncio.run(coro))
|
||||
|
||||
|
||||
def _get_tools(srv: Any) -> dict[str, Any]:
|
||||
"""Return a mapping of tool-name -> callable.
|
||||
|
||||
The MCP library's internal structure can vary across versions.
|
||||
We try a few known locations and fall back to asking the server object.
|
||||
"""
|
||||
|
||||
for attr in ("_tools", "tools"):
|
||||
tools = getattr(srv, attr, None)
|
||||
if isinstance(tools, dict):
|
||||
return tools
|
||||
|
||||
# Some MCP implementations expose tools via a method.
|
||||
for method_name in ("get_tools", "list_tools"):
|
||||
maybe = getattr(srv, method_name, None)
|
||||
if callable(maybe):
|
||||
tools = maybe()
|
||||
if isinstance(tools, dict):
|
||||
return tools
|
||||
|
||||
raise AssertionError("Unable to locate tool registry on server instance")
|
||||
|
||||
|
||||
def test_find_matching_capacities_requires_confirmation(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=true
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=10
|
||||
max_size=10
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Patch DB client creation inside server builder.
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
monkeypatch.setattr(mod, "create_db_client", lambda *_: FakeDBClient())
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
|
||||
msg = _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "Cloud Engineer",
|
||||
"competences": ["AWS"],
|
||||
"date_start": str(date(2025, 1, 1)),
|
||||
"date_end": str(date(2025, 1, 31)),
|
||||
},
|
||||
)
|
||||
|
||||
assert "must be confirmed" in msg
|
||||
|
||||
|
||||
def test_find_matching_capacities_auto_skips_when_disabled(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=10
|
||||
max_size=10
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
monkeypatch.setattr(mod, "create_db_client", lambda *_: FakeDBClient())
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
|
||||
# If auto-skip is enabled, the gate should not block. We don't assert the
|
||||
# full output (it depends on matcher/cache); just that it isn't blocked.
|
||||
# The call will still fail later because capacities list is empty, but it
|
||||
# must not fail with the confirmation message.
|
||||
msg = _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{"role_name": "Cloud Engineer", "competences": ["AWS"]},
|
||||
)
|
||||
|
||||
assert "must be confirmed" not in msg
|
||||
|
||||
|
||||
def test_confirm_requires_review_after_latest_requirement_update(
|
||||
monkeypatch, tmp_path
|
||||
) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=true
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=10
|
||||
max_size=10
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
monkeypatch.setattr(mod, "create_db_client", lambda *_: FakeDBClient())
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
|
||||
# Wrong order: review before any requirements exist.
|
||||
msg = _call_tool(srv, "show_pending_requirements")
|
||||
assert "No pending requirements" in msg
|
||||
|
||||
# Collect requirements afterwards.
|
||||
_call_tool(
|
||||
srv,
|
||||
"collect_structured_requirement_data",
|
||||
{"role_name": "Backend Developer", "competences": ["Python"]},
|
||||
)
|
||||
|
||||
# Confirm must still be blocked: latest requirements were not reviewed.
|
||||
msg = _call_tool(srv, "confirm_requirements", {"confirm": True})
|
||||
assert "User confirmation has not been requested" in msg
|
||||
|
||||
# Correct order: review after collecting, then confirm.
|
||||
_call_tool(srv, "show_pending_requirements")
|
||||
msg = _call_tool(srv, "confirm_requirements", {"confirm": True})
|
||||
assert "Requirements confirmed" in msg
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from teamlandkarte_mcp.utils.dates import availability_overlaps
|
||||
|
||||
|
||||
def test_availability_overlaps_open_ended_capacity_end() -> None:
|
||||
cap_begin = date(2025, 1, 1)
|
||||
cap_end = None
|
||||
|
||||
assert availability_overlaps(
|
||||
cap_begin, cap_end, date(2025, 2, 1), date(2025, 2, 28)
|
||||
)
|
||||
assert availability_overlaps(cap_begin, cap_end, None, date(2025, 2, 28))
|
||||
assert availability_overlaps(cap_begin, cap_end, date(2024, 12, 1), None)
|
||||
|
||||
|
||||
def test_availability_overlaps_non_overlapping() -> None:
|
||||
assert not availability_overlaps(
|
||||
date(2025, 1, 1),
|
||||
date(2025, 1, 31),
|
||||
date(2025, 2, 1),
|
||||
date(2025, 2, 28),
|
||||
)
|
||||
|
||||
|
||||
def test_availability_overlaps_inclusive_edges() -> None:
|
||||
assert availability_overlaps(
|
||||
date(2025, 1, 1),
|
||||
date(2025, 1, 31),
|
||||
date(2025, 1, 31),
|
||||
date(2025, 2, 1),
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.matching.task_helpers import (
|
||||
TaskHelpersDeprecatedError,
|
||||
extract_requirements_from_task,
|
||||
)
|
||||
|
||||
|
||||
class MissingDB:
|
||||
"""DB stub that simulates an unavailable database."""
|
||||
|
||||
def get_task_by_id(self, task_id: str):
|
||||
raise RuntimeError("db unavailable")
|
||||
|
||||
def get_task_by_name(self, name: str):
|
||||
raise RuntimeError("db unavailable")
|
||||
|
||||
|
||||
class NoopAnalyzer:
|
||||
async def extract_requirements(self, description: str): # pragma: no cover
|
||||
raise AssertionError("should not be called")
|
||||
|
||||
|
||||
def test_extract_requirements_from_task_is_deprecated() -> None:
|
||||
db = MissingDB()
|
||||
analyzer = NoopAnalyzer()
|
||||
|
||||
with pytest.raises(TaskHelpersDeprecatedError):
|
||||
asyncio.run(
|
||||
extract_requirements_from_task(
|
||||
db=db,
|
||||
analyzer=analyzer,
|
||||
task_id="t",
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,340 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from datetime import date
|
||||
|
||||
from teamlandkarte_mcp.cache.search_cache import SearchCache
|
||||
from teamlandkarte_mcp.models import Capacity, Team, TeamCompetence, TeamReference
|
||||
from teamlandkarte_mcp.utils.dates import availability_overlaps
|
||||
|
||||
|
||||
def _mk_capacity(
|
||||
*,
|
||||
cid: int,
|
||||
role: str,
|
||||
comps: list[str],
|
||||
begin: date | None = None,
|
||||
end: date | None = None,
|
||||
) -> dict:
|
||||
cap = Capacity(
|
||||
id=cid,
|
||||
owner_name=f"O{cid}",
|
||||
role_name=role,
|
||||
role_level=None,
|
||||
begin_date=begin,
|
||||
end_date=end,
|
||||
competences=comps,
|
||||
)
|
||||
return asdict(cap)
|
||||
|
||||
|
||||
def test_filter_semantics_role_only() -> None:
|
||||
"""Role-only filtering should keep matching roles and drop others."""
|
||||
|
||||
cache = SearchCache(ttl_minutes=60, max_size=10)
|
||||
search_id = cache.store_search(
|
||||
task_id=None,
|
||||
requirements={"role_name": "Backend Developer", "competences": ["Python"]},
|
||||
results={
|
||||
"Top": [
|
||||
_mk_capacity(cid=1, role="Backend Developer", comps=["Python"]),
|
||||
_mk_capacity(cid=2, role="Data Engineer", comps=["Python"]),
|
||||
],
|
||||
"Good": [],
|
||||
"Partial": [],
|
||||
"Low": [],
|
||||
},
|
||||
)
|
||||
|
||||
entry = cache.get(search_id)
|
||||
assert entry is not None
|
||||
|
||||
all_items = []
|
||||
for cat in ["Top", "Good", "Partial", "Low"]:
|
||||
all_items.extend(entry.results.get(cat, []))
|
||||
|
||||
role_filter = "backend developer"
|
||||
kept = [i for i in all_items if role_filter in (i.get("role_name") or "").lower()]
|
||||
|
||||
# Re-categorization is Scorer responsibility; here we just assert filtering.
|
||||
assert [k["id"] for k in kept] == [1]
|
||||
|
||||
|
||||
def test_filter_semantics_competence_only_all_required() -> None:
|
||||
"""Competence filter requires all requested competences to be present."""
|
||||
|
||||
required = ["Python", "FastAPI"]
|
||||
|
||||
items = [
|
||||
_mk_capacity(cid=1, role="Backend", comps=["Python", "FastAPI"]),
|
||||
_mk_capacity(cid=2, role="Backend", comps=["Python"]),
|
||||
]
|
||||
|
||||
def passes(item: dict) -> bool:
|
||||
comps = {c.lower() for c in (item.get("competences") or [])}
|
||||
return all(r.lower() in comps for r in required)
|
||||
|
||||
kept = [i for i in items if passes(i)]
|
||||
assert [k["id"] for k in kept] == [1]
|
||||
|
||||
|
||||
def test_filter_semantics_availability_open_ended_end_date() -> None:
|
||||
"""Availability overlap treats missing end_date as open-ended."""
|
||||
|
||||
cap_begin = date(2025, 1, 1)
|
||||
cap_end = None
|
||||
|
||||
assert availability_overlaps(
|
||||
cap_begin, cap_end, date(2025, 2, 1), date(2025, 2, 28)
|
||||
)
|
||||
assert not availability_overlaps(
|
||||
cap_begin, cap_end, date(2024, 1, 1), date(2024, 1, 31)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Team-search filter coverage (Profile_Type="team")
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Team searches re-purpose ``role_filter`` against ``team.focus_name``
|
||||
# (rather than ``cap.role_name``) and apply ``competence_filter``
|
||||
# against the team's competence-name list extracted from
|
||||
# ``competences[*].name``. Availability filters are ignored for team
|
||||
# searches. The tests below verify these semantics in pure form for
|
||||
# both ``matching_method`` flavours so the routing/filter surface is
|
||||
# covered for both ``Profile_Type`` values.
|
||||
#
|
||||
# _Requirements: 6.7, 8.5, 8.6, 12.7_
|
||||
|
||||
|
||||
def _mk_team_dict(
|
||||
*,
|
||||
tid: str,
|
||||
focus_name: str,
|
||||
competences: list[tuple[str, bool]],
|
||||
matching_method: str,
|
||||
) -> dict:
|
||||
"""Build a cached team item mirroring the persisted shape used by
|
||||
``find_matching_teams``.
|
||||
|
||||
The shape matches what ``filter_search_results`` operates on
|
||||
(``competences = [{"name": ..., "top_competency": ...}]``) and
|
||||
includes either score fields or ``rationale`` based on
|
||||
``matching_method``.
|
||||
"""
|
||||
team = Team(
|
||||
team_id=tid,
|
||||
ouid=f"ou-{tid}",
|
||||
team_name=f"Team {tid}",
|
||||
focus_name=focus_name,
|
||||
about_us="",
|
||||
offerings="",
|
||||
interests="",
|
||||
competences=[TeamCompetence(name=n, top_competency=t)
|
||||
for n, t in competences],
|
||||
references=[TeamReference(partner_name="", projects="P")],
|
||||
)
|
||||
base = asdict(team)
|
||||
base["category"] = "Top"
|
||||
if matching_method == "score":
|
||||
base.update(
|
||||
competence_score=1.0,
|
||||
role_score=1.0,
|
||||
overall_score=1.0,
|
||||
)
|
||||
else:
|
||||
base["rationale"] = "ok"
|
||||
return base
|
||||
|
||||
|
||||
def test_team_search_role_filter_matches_focus_name_score_mode() -> None:
|
||||
"""``role_filter`` for ``team_search`` matches against
|
||||
``focus_name`` (Anforderung 8.5) - score-mode payload."""
|
||||
items = [
|
||||
_mk_team_dict(
|
||||
tid="t1",
|
||||
focus_name="Backend Developer",
|
||||
competences=[("python", True)],
|
||||
matching_method="score",
|
||||
),
|
||||
_mk_team_dict(
|
||||
tid="t2",
|
||||
focus_name="Frontend Developer",
|
||||
competences=[("javascript", True)],
|
||||
matching_method="score",
|
||||
),
|
||||
_mk_team_dict(
|
||||
tid="t3",
|
||||
focus_name="Data Engineer",
|
||||
competences=[("python", False)],
|
||||
matching_method="score",
|
||||
),
|
||||
]
|
||||
|
||||
role_filter = "backend"
|
||||
kept = [
|
||||
it for it in items
|
||||
if role_filter in (it.get("focus_name") or "").lower()
|
||||
]
|
||||
assert [k["team_id"] for k in kept] == ["t1"]
|
||||
|
||||
|
||||
def test_team_search_role_filter_matches_focus_name_llm_mode() -> None:
|
||||
"""Same role-filter semantics in the LLM-fulltext mode payload
|
||||
shape (``rationale`` instead of score fields)."""
|
||||
items = [
|
||||
_mk_team_dict(
|
||||
tid="t1",
|
||||
focus_name="Backend Developer",
|
||||
competences=[("python", True)],
|
||||
matching_method="llm_fulltext",
|
||||
),
|
||||
_mk_team_dict(
|
||||
tid="t2",
|
||||
focus_name="Frontend Developer",
|
||||
competences=[("javascript", True)],
|
||||
matching_method="llm_fulltext",
|
||||
),
|
||||
]
|
||||
|
||||
role_filter = "frontend"
|
||||
kept = [
|
||||
it for it in items
|
||||
if role_filter in (it.get("focus_name") or "").lower()
|
||||
]
|
||||
assert [k["team_id"] for k in kept] == ["t2"]
|
||||
# Each kept item carries the LLM-mode rationale field used by the
|
||||
# ``Begründung`` column.
|
||||
assert all("rationale" in k for k in kept)
|
||||
|
||||
|
||||
def test_team_search_competence_filter_extracts_names_from_records() -> None:
|
||||
"""``competence_filter`` for ``team_search`` operates on the names
|
||||
extracted from the cached competence records, not on raw strings.
|
||||
"""
|
||||
required = ["python"]
|
||||
|
||||
items = [
|
||||
_mk_team_dict(
|
||||
tid="t1",
|
||||
focus_name="Backend Developer",
|
||||
competences=[("python", True), ("fastapi", False)],
|
||||
matching_method="score",
|
||||
),
|
||||
_mk_team_dict(
|
||||
tid="t2",
|
||||
focus_name="Frontend Developer",
|
||||
competences=[("javascript", True)],
|
||||
matching_method="score",
|
||||
),
|
||||
]
|
||||
|
||||
def passes(item: dict) -> bool:
|
||||
names = {
|
||||
(c.get("name") or "").lower()
|
||||
for c in (item.get("competences") or [])
|
||||
}
|
||||
return all(r.lower() in names for r in required)
|
||||
|
||||
kept = [it for it in items if passes(it)]
|
||||
assert [k["team_id"] for k in kept] == ["t1"]
|
||||
|
||||
|
||||
def test_team_search_top_competency_filter_suffix() -> None:
|
||||
"""``competence_filter`` values with the ``(Top)`` suffix restrict
|
||||
the match to top competences (Anforderung 9.7)."""
|
||||
items = [
|
||||
_mk_team_dict(
|
||||
tid="t1",
|
||||
focus_name="Backend Developer",
|
||||
# python is a top competence here
|
||||
competences=[("python", True)],
|
||||
matching_method="llm_fulltext",
|
||||
),
|
||||
_mk_team_dict(
|
||||
tid="t2",
|
||||
focus_name="Data Engineer",
|
||||
# python is present but NOT a top competence
|
||||
competences=[("python", False)],
|
||||
matching_method="llm_fulltext",
|
||||
),
|
||||
]
|
||||
|
||||
# Caller asks for a Top-only python match (mirrors the production
|
||||
# rule: filter values with ``(Top)`` suffix are stripped and
|
||||
# require ``top_competency=True`` on the matched record).
|
||||
raw_filter_value = "python (Top)"
|
||||
target = raw_filter_value.removesuffix("(Top)").strip().lower()
|
||||
|
||||
def passes_top_only(item: dict) -> bool:
|
||||
for c in item.get("competences") or []:
|
||||
name = (c.get("name") or "").strip().lower()
|
||||
if name == target and bool(c.get("top_competency")):
|
||||
return True
|
||||
return False
|
||||
|
||||
kept = [it for it in items if passes_top_only(it)]
|
||||
assert [k["team_id"] for k in kept] == ["t1"]
|
||||
|
||||
|
||||
def test_team_search_availability_filter_is_ignored() -> None:
|
||||
"""Availability filters do not change the item set for
|
||||
``team_search`` payloads (Anforderung 6.7 / 8.6).
|
||||
|
||||
The cached ``team_search`` items have no availability fields. The
|
||||
production filter path therefore short-circuits and ignores
|
||||
``availability_date_*`` and ``is_fully_available``. We mirror that
|
||||
invariant here in pure form: applying any availability predicate
|
||||
onto the team item set must never reduce the set.
|
||||
"""
|
||||
items = [
|
||||
_mk_team_dict(
|
||||
tid="t1",
|
||||
focus_name="Backend Developer",
|
||||
competences=[("python", True)],
|
||||
matching_method="score",
|
||||
),
|
||||
_mk_team_dict(
|
||||
tid="t2",
|
||||
focus_name="Frontend Developer",
|
||||
competences=[("javascript", True)],
|
||||
matching_method="score",
|
||||
),
|
||||
]
|
||||
|
||||
# No item carries begin_date/end_date so any availability predicate
|
||||
# over those fields would unduly drop everything. The team-search
|
||||
# path must therefore ignore the predicate entirely.
|
||||
cache = SearchCache(ttl_minutes=60, max_size=10)
|
||||
search_id = cache.store_search(
|
||||
task_id=None,
|
||||
requirements={"role_name": "Backend Developer",
|
||||
"competences": ["python"]},
|
||||
results={
|
||||
"search_type": "team_search",
|
||||
"matching_method": "score",
|
||||
"reference": {
|
||||
"availability_date_start": None,
|
||||
"availability_date_end": None,
|
||||
},
|
||||
"summary": {"Top": len(items)},
|
||||
"by_category": {"Top": items, "Good": [], "Partial": [],
|
||||
"Low": [], "Irrelevant": []},
|
||||
},
|
||||
)
|
||||
|
||||
entry = cache.get(search_id)
|
||||
assert entry is not None
|
||||
cached_items = entry.results["by_category"]["Top"]
|
||||
|
||||
# Property: filtering availability against absent fields would
|
||||
# reject every item. The team-search routing path therefore must
|
||||
# skip the predicate, which we model here by asserting that the
|
||||
# cached items still all match a "no availability filter applied"
|
||||
# short-circuit.
|
||||
def availability_short_circuit(_item: dict) -> bool:
|
||||
# team_search → ignore availability filters entirely.
|
||||
return True
|
||||
|
||||
kept = [it for it in cached_items if availability_short_circuit(it)]
|
||||
assert [k["team_id"] for k in kept] == ["t1", "t2"]
|
||||
@@ -0,0 +1,112 @@
|
||||
# Feature: llm-fulltext-matching, Property 7: Tabellen-Rationale ist gültiges Markdown und längenbegrenzt
|
||||
"""Property-based tests for ``_format_rationale_for_table``.
|
||||
|
||||
For any input string ``R``, the helper
|
||||
:func:`teamlandkarte_mcp.mcp_server._format_rationale_for_table` must:
|
||||
|
||||
1. Produce output of at most 280 characters.
|
||||
2. Never emit a literal pipe ``|`` (so the Markdown table stays valid).
|
||||
3. Never emit ``\\n`` or ``\\r`` (whitespace is collapsed to spaces).
|
||||
4. Match the canonical normalized form when the normalized form fits in
|
||||
280 characters; otherwise end with the ellipsis character ``…``
|
||||
(U+2026) and equal ``normalized[:279].rstrip() + "…"``.
|
||||
|
||||
The "normalized" form is defined as
|
||||
``" ".join(R.replace("|", "/").replace("\\r", " ").replace("\\n", " ").split())``.
|
||||
|
||||
**Validates: Requirements 8.4, 8.5**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.mcp_server import _format_rationale_for_table
|
||||
|
||||
|
||||
# Surrogate code points (Cs) are intentionally excluded; Hypothesis can
|
||||
# otherwise produce lone surrogates that would break the assertions on
|
||||
# string equality. Control characters (``\\n``, ``\\r``, ``\\t``...) are
|
||||
# kept on purpose: the helper is supposed to handle them explicitly.
|
||||
_TEXT_STRATEGY = st.text(
|
||||
alphabet=st.characters(blacklist_categories=("Cs",)),
|
||||
max_size=600,
|
||||
)
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
"""Replicate the helper's normalization (without truncation)."""
|
||||
cleaned = text.replace("|", "/").replace("\r", " ").replace("\n", " ")
|
||||
return " ".join(cleaned.split())
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(text=_TEXT_STRATEGY)
|
||||
def test_format_rationale_is_bounded_safe_and_canonical(text: str) -> None:
|
||||
"""Property 7: bounded length, no pipes/newlines, deterministic shape."""
|
||||
out = _format_rationale_for_table(text)
|
||||
|
||||
# 1. Length is bounded by 280.
|
||||
assert len(out) <= 280, (
|
||||
f"output longer than 280 chars: {len(out)} for input {text!r}"
|
||||
)
|
||||
|
||||
# 2. Pipe characters are translated.
|
||||
assert "|" not in out, f"pipe leaked into output: {out!r}"
|
||||
|
||||
# 3. Newlines/carriage returns are translated.
|
||||
assert "\n" not in out, f"newline leaked into output: {out!r}"
|
||||
assert "\r" not in out, f"carriage return leaked into output: {out!r}"
|
||||
|
||||
# 4. Match canonical form, with truncation behaviour.
|
||||
normalized = _normalize(text)
|
||||
if len(normalized) <= 280:
|
||||
assert out == normalized, (
|
||||
f"expected verbatim normalized form, got {out!r} "
|
||||
f"(normalized={normalized!r})"
|
||||
)
|
||||
# Note: ``out`` may legitimately end with ``…`` if the input itself
|
||||
# contained an ellipsis. The contract is that the helper does not
|
||||
# *append* an ellipsis when the input fits, which is implied by
|
||||
# ``out == normalized``.
|
||||
else:
|
||||
expected = normalized[:279].rstrip() + "\u2026"
|
||||
assert out == expected, (
|
||||
f"expected truncated form {expected!r}, got {out!r}"
|
||||
)
|
||||
assert out.endswith("\u2026"), (
|
||||
"truncated output must end with the ellipsis character"
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(
|
||||
text=st.text(
|
||||
alphabet=st.characters(
|
||||
blacklist_categories=("Cs",),
|
||||
blacklist_characters="|\r\n",
|
||||
),
|
||||
min_size=281,
|
||||
max_size=600,
|
||||
)
|
||||
)
|
||||
def test_format_rationale_truncates_when_normalized_too_long(text: str) -> None:
|
||||
"""Property 7 (forced truncation): outputs longer than 280 are shortened.
|
||||
|
||||
Generates text without pipes/newlines so the normalized form preserves
|
||||
most of the input length and almost certainly exceeds 280 characters
|
||||
(after whitespace collapsing). When that happens, the output must end
|
||||
with ``…`` and equal ``normalized[:279].rstrip() + "…"``.
|
||||
"""
|
||||
out = _format_rationale_for_table(text)
|
||||
normalized = _normalize(text)
|
||||
|
||||
assert len(out) <= 280
|
||||
if len(normalized) > 280:
|
||||
assert out.endswith("\u2026")
|
||||
assert out == normalized[:279].rstrip() + "\u2026"
|
||||
else:
|
||||
# Whitespace collapse may rarely shrink below 281; in that case
|
||||
# the helper returns the verbatim normalized form.
|
||||
assert out == normalized
|
||||
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
|
||||
|
||||
def _ratio(a: str, b: str) -> int:
|
||||
return int(difflib.SequenceMatcher(a=a, b=b).ratio() * 100)
|
||||
|
||||
|
||||
def test_similarity_thresholds_behave_monotonically() -> None:
|
||||
"""Similarity ratios should increase for more similar strings."""
|
||||
|
||||
a = "backend developer"
|
||||
b1 = "backend dev"
|
||||
b2 = "data engineer"
|
||||
|
||||
assert _ratio(a, b1) > _ratio(a, b2)
|
||||
@@ -0,0 +1,198 @@
|
||||
# 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)]
|
||||
@@ -0,0 +1,44 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.azure.openai_client import AzureOpenAIClient
|
||||
from teamlandkarte_mcp.config import load_config
|
||||
|
||||
# Allow these smoke tests to access the real Azure OpenAI endpoint when the
|
||||
# required env/config is present.
|
||||
pytestmark = [pytest.mark.integration, pytest.mark.allow_network]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_azure_openai_chat_completion_smoke() -> None:
|
||||
"""Smoke test against a real Azure OpenAI resource (chat completion)."""
|
||||
|
||||
cfg = load_config("config.toml")
|
||||
endpoint = (cfg.azure_openai.endpoint or "").strip()
|
||||
llm_key = os.getenv("AZURE_OPENAI_LLM_API_KEY", "").strip()
|
||||
|
||||
if not endpoint:
|
||||
pytest.skip("Azure OpenAI endpoint missing in config.toml")
|
||||
if "YOUR-RESOURCE" in endpoint.upper():
|
||||
pytest.skip("Azure OpenAI endpoint in config.toml is still a placeholder")
|
||||
if not endpoint.startswith("https://"):
|
||||
pytest.skip("Azure OpenAI endpoint must start with https://")
|
||||
if not llm_key:
|
||||
pytest.skip("AZURE_OPENAI_LLM_API_KEY is not set")
|
||||
if not cfg.azure_openai.chat_deployment:
|
||||
pytest.skip("chat_deployment not configured")
|
||||
|
||||
client = AzureOpenAIClient(
|
||||
endpoint=endpoint,
|
||||
api_version=cfg.azure_openai.api_version,
|
||||
chat_deployment=cfg.azure_openai.chat_deployment,
|
||||
llm_api_key=llm_key,
|
||||
)
|
||||
|
||||
result = await client.chat_completion(
|
||||
system="You are a test assistant. Respond with JSON: {\"status\": \"ok\"}",
|
||||
user="ping",
|
||||
)
|
||||
assert isinstance(result, str)
|
||||
assert "ok" in result
|
||||
@@ -0,0 +1,350 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(structured.get("result"), str):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(
|
||||
srv: Any,
|
||||
name: str,
|
||||
args: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
if args is None:
|
||||
args = {}
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_integration_capacity_search_filter_and_pagination(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
class _FakeDB:
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["X"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["A"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_capacities_with_competences(self):
|
||||
return [
|
||||
Capacity(
|
||||
id=1,
|
||||
owner_name="A",
|
||||
role_name="X",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 2, 1),
|
||||
end_date=date(2025, 7, 31),
|
||||
competences=["A"],
|
||||
),
|
||||
Capacity(
|
||||
id=2,
|
||||
owner_name="B",
|
||||
role_name="X",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 3, 15),
|
||||
end_date=date(2025, 6, 15),
|
||||
competences=["A"],
|
||||
),
|
||||
]
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_mod,
|
||||
"create_db_client",
|
||||
lambda *_args, **_kwargs: _FakeDB(),
|
||||
)
|
||||
|
||||
# Mock LLM chat_completion to avoid network calls.
|
||||
monkeypatch.setattr(
|
||||
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
|
||||
AsyncMock(return_value='{"similarity": 1.0}'),
|
||||
)
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"date_start": "2025-03-01",
|
||||
"date_end": "2025-06-30",
|
||||
},
|
||||
)
|
||||
assert "SEARCH_ID=" in res
|
||||
search_id = res.split("SEARCH_ID=")[1].splitlines()[0].strip()
|
||||
|
||||
filtered = await _call_tool(
|
||||
srv,
|
||||
"filter_search_results",
|
||||
{"search_id": search_id, "is_fully_available": True},
|
||||
)
|
||||
assert "FILTER_ID=" in filtered
|
||||
filter_id = filtered.split("FILTER_ID=")[1].splitlines()[0].strip()
|
||||
assert "Filtered total results: 1" in filtered
|
||||
|
||||
page = await _call_tool(
|
||||
srv,
|
||||
"get_results_by_category",
|
||||
{
|
||||
"search_id": search_id,
|
||||
"filter_id": filter_id,
|
||||
"category": "Low",
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
},
|
||||
)
|
||||
assert "Total items in category: 1" in page
|
||||
assert "| 1 |" in page
|
||||
assert "| 2 |" not in page
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_integration_task_search_filters_and_pagination(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class _Task:
|
||||
id: str
|
||||
name: str | None = None
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
created_date: datetime = datetime(2025, 1, 1)
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
skills: list[str] = None # type: ignore[assignment]
|
||||
|
||||
def __post_init__(self):
|
||||
if self.skills is None:
|
||||
self.skills = []
|
||||
|
||||
class _FakeDB:
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["Frontend Developer", "Backend Engineer"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["React", "TypeScript", "Python", "PostgreSQL"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
tasks = [
|
||||
_Task(
|
||||
id="T1",
|
||||
title="Frontend Developer",
|
||||
description="React and TypeScript",
|
||||
created_date=datetime(2025, 1, 1),
|
||||
start_date=date(2025, 5, 1),
|
||||
end_date=date(2025, 7, 31),
|
||||
skills=["React", "TypeScript"],
|
||||
),
|
||||
_Task(
|
||||
id="T2",
|
||||
title="Backend Engineer",
|
||||
description="Python and PostgreSQL",
|
||||
created_date=datetime(2025, 1, 1),
|
||||
start_date=date(2025, 5, 1),
|
||||
end_date=date(2025, 7, 31),
|
||||
skills=["Python"],
|
||||
),
|
||||
]
|
||||
return tasks if limit == 0 else tasks[:limit]
|
||||
|
||||
def get_task_by_id(self, task_id: str): # pragma: no cover
|
||||
return None
|
||||
|
||||
def get_task_by_name(self, name: str): # pragma: no cover
|
||||
return None
|
||||
|
||||
def get_all_capacities_with_competences(self): # pragma: no cover
|
||||
return []
|
||||
|
||||
def get_recent_free_capacities(
|
||||
self,
|
||||
limit: int = 20,
|
||||
): # pragma: no cover
|
||||
return []
|
||||
|
||||
def get_capacity_by_id(self, capacity_id):
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
return Capacity(
|
||||
id=1,
|
||||
owner_name="A",
|
||||
role_name="Frontend Developer",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 4, 1),
|
||||
end_date=date(2025, 8, 31),
|
||||
competences=["React", "TypeScript"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_mod,
|
||||
"create_db_client",
|
||||
lambda *_args, **_kwargs: _FakeDB(),
|
||||
)
|
||||
|
||||
# Mock LLM chat_completion to avoid network calls.
|
||||
monkeypatch.setattr(
|
||||
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
|
||||
AsyncMock(return_value='{"similarity": 1.0}'),
|
||||
)
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
|
||||
res = await _call_tool(srv, "find_matching_tasks", {"capacity_id": 1})
|
||||
assert "SEARCH_ID=" in res
|
||||
search_id = res.split("SEARCH_ID=")[1].splitlines()[0].strip()
|
||||
|
||||
out = await _call_tool(
|
||||
srv,
|
||||
"filter_search_results",
|
||||
{"search_id": search_id, "task_text_filter": "typescript"},
|
||||
)
|
||||
assert "Filtered total results: 1" in out
|
||||
assert "T1" in out
|
||||
assert "T2" not in out
|
||||
|
||||
out2 = await _call_tool(
|
||||
srv,
|
||||
"filter_search_results",
|
||||
{"search_id": search_id, "task_competence_filter": ["python"]},
|
||||
)
|
||||
assert "Filtered total results: 1" in out2
|
||||
assert "T2" in out2
|
||||
|
||||
page = await _call_tool(
|
||||
srv,
|
||||
"get_results_by_category",
|
||||
{
|
||||
"search_id": search_id,
|
||||
"category": "Top",
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
},
|
||||
)
|
||||
assert "Total items in category:" in page
|
||||
assert "task_id" in page
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from teamlandkarte_mcp.cache.search_cache import SearchCache
|
||||
|
||||
|
||||
def test_invalid_search_id_returns_none() -> None:
|
||||
cache = SearchCache(ttl_minutes=60, max_size=10)
|
||||
assert cache.get("does-not-exist") is None
|
||||
|
||||
|
||||
def test_non_uuid_search_id_rejected_in_tool_message() -> None:
|
||||
# Tool-level tests live elsewhere; here we assert the expected user-facing
|
||||
# wording for non-UUID search_id inputs.
|
||||
msg = "Invalid search_id format (expected UUID)."
|
||||
assert "UUID" in msg
|
||||
@@ -0,0 +1,120 @@
|
||||
# Feature: llm-fulltext-matching, Property 5: LLM-Fehler erscheinen in der Fehlerliste, nicht als Ergebnis
|
||||
"""Property-based test for LLM-error separation in ``LlmFulltextMatcher``.
|
||||
|
||||
For any list of capacities for which the LLM call fails on a subset
|
||||
``S``, every item in ``S`` must appear in ``result.errors`` and never in
|
||||
any bucket of ``result.by_category``; the remaining items must appear in
|
||||
exactly one category bucket.
|
||||
|
||||
The fake LLM is driven by a Hypothesis-generated boolean mask: position
|
||||
``i`` decides whether the call for capacity ``i+1`` raises
|
||||
``AzureAPIError`` (``True``) or returns a valid JSON payload routing the
|
||||
item to the ``Top`` bucket (``False``).
|
||||
|
||||
**Validates: Requirements 5.7, 6.7**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
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
|
||||
|
||||
|
||||
class _MaskLlm:
|
||||
"""Returns a valid ``Top``-JSON for ``mask[i] is False``; else raises.
|
||||
|
||||
The matcher invokes ``chat_completion`` exactly once per capacity in
|
||||
capacity order, so a simple call-index counter maps each call to its
|
||||
mask position.
|
||||
"""
|
||||
|
||||
def __init__(self, mask: list[bool]) -> None:
|
||||
self._mask = mask
|
||||
self._call_index = 0
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
idx = self._call_index
|
||||
self._call_index += 1
|
||||
if self._mask[idx]:
|
||||
raise AzureAPIError("simulated")
|
||||
return json.dumps({"category": "Top", "rationale": "ok"})
|
||||
|
||||
|
||||
class _StubDb:
|
||||
"""Minimal ``DBClient`` double returning empty extras for every id."""
|
||||
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
|
||||
def _make_capacities(n: int) -> list[Capacity]:
|
||||
return [
|
||||
Capacity(
|
||||
id=i + 1,
|
||||
owner_name=f"o{i + 1}",
|
||||
role_name="R",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=[],
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(mask=st.lists(st.booleans(), min_size=1, max_size=8))
|
||||
def test_llm_errors_appear_only_in_errors_list(mask: list[bool]) -> None:
|
||||
"""Property 5: failing items live in ``errors``, others in one bucket."""
|
||||
matcher = LlmFulltextMatcher(db=_StubDb(), client=_MaskLlm(mask))
|
||||
capacities = _make_capacities(len(mask))
|
||||
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=task_profile,
|
||||
capacities=capacities,
|
||||
)
|
||||
)
|
||||
|
||||
error_ids = {e.item_id for e in result.errors}
|
||||
success_ids: set[str] = set()
|
||||
for items in result.by_category.values():
|
||||
for it in items:
|
||||
success_ids.add(it.item_id)
|
||||
|
||||
expected_errors = {str(i + 1) for i, fail in enumerate(mask) if fail}
|
||||
expected_successes = {str(i + 1) for i, fail in enumerate(mask) if not fail}
|
||||
|
||||
assert error_ids == expected_errors, (
|
||||
f"errors mismatch: {error_ids} vs {expected_errors}"
|
||||
)
|
||||
assert success_ids == expected_successes, (
|
||||
f"successes mismatch: {success_ids} vs {expected_successes}"
|
||||
)
|
||||
|
||||
# Disjoint sets.
|
||||
assert error_ids.isdisjoint(success_ids)
|
||||
# Every capacity is accounted for exactly once across the two paths.
|
||||
assert len(error_ids) + len(success_ids) == len(mask)
|
||||
@@ -0,0 +1,732 @@
|
||||
"""End-to-end integration tests for LLM-fulltext matching (tasks 12.2 and
|
||||
12.3 of the ``llm-fulltext-matching`` spec).
|
||||
|
||||
These tests exercise ``find_matching_capacities`` and
|
||||
``find_matching_tasks`` through ``build_server`` using fully mocked
|
||||
``DBClient`` and ``AzureOpenAIClient`` collaborators. They cover:
|
||||
|
||||
* Task 12.2 — End-to-end runs in ``llm_fulltext`` mode for both
|
||||
directions:
|
||||
- The persisted ``SearchCache`` payload carries
|
||||
``matching_method``, the **ungekürzte** rationale and an
|
||||
``errors`` list.
|
||||
- The availability prefilter behaves identically in score and
|
||||
LLM-fulltext mode (out-of-window capacities are dropped).
|
||||
* Task 12.3 — Markdown table headers are stable across modes:
|
||||
- Score mode keeps the existing ``Role Score`` / ``Competence
|
||||
Score`` / ``Overall Score`` columns.
|
||||
- LLM mode ends with ``... | Category | Begründung |`` and
|
||||
drops all numeric score columns.
|
||||
|
||||
_Requirements: 5.1, 5.2, 6.1, 6.2, 7.1, 7.4, 8.1, 8.2, 8.6, 9.5_
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
|
||||
from teamlandkarte_mcp.cache.search_cache import SearchCache
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test config and helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_CONFIG_TEMPLATE = """
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
|
||||
[azure_openai]
|
||||
endpoint='https://example.openai.azure.com'
|
||||
api_version='2024-02-15-preview'
|
||||
chat_deployment='gpt-4'
|
||||
""".strip()
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"), str
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake DB
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Task:
|
||||
"""In-test stand-in for the production ``Task`` dataclass."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
skills: list[str] = field(default_factory=list)
|
||||
name: str | None = None
|
||||
created_date: datetime = datetime(2025, 1, 1)
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""In-memory ``DBClient`` double used across the integration tests.
|
||||
|
||||
The fake supports both directions (capacity_search and task_search)
|
||||
and exposes both the single-row and the batch capacity-fulltext
|
||||
methods used by the LLM matcher.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
capacities: list[Capacity] | None = None,
|
||||
tasks: list[Any] | None = None,
|
||||
capacity_extras: dict[
|
||||
str,
|
||||
dict[str, Any],
|
||||
]
|
||||
| None = None,
|
||||
) -> None:
|
||||
self._capacities = capacities or []
|
||||
self._tasks = tasks or []
|
||||
# Map capacity_id (str) -> {description, certificates, references}.
|
||||
self._extras = capacity_extras or {}
|
||||
|
||||
# ---- generic stubs used by build_server boot-time --------------------
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return list({c.role_name for c in self._capacities if c.role_name})
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
names: set[str] = set()
|
||||
for c in self._capacities:
|
||||
names.update(c.competences)
|
||||
for t in self._tasks:
|
||||
names.update(getattr(t, "skills", []) or [])
|
||||
return sorted(names)
|
||||
|
||||
# ---- capacity / task readers ----------------------------------------
|
||||
|
||||
def get_all_capacities_with_competences(self) -> list[Capacity]:
|
||||
return list(self._capacities)
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20) -> list[Capacity]:
|
||||
return []
|
||||
|
||||
def get_capacity_by_id(self, capacity_id: int | str) -> Capacity | None:
|
||||
target = str(capacity_id)
|
||||
for c in self._capacities:
|
||||
if str(c.id) == target:
|
||||
return c
|
||||
return None
|
||||
|
||||
def get_open_tasks(self, limit: int = 20) -> list[Any]:
|
||||
return list(self._tasks) if limit == 0 else list(self._tasks)[:limit]
|
||||
|
||||
def get_task_by_id(self, task_id: str): # pragma: no cover
|
||||
return None
|
||||
|
||||
def get_task_by_name(self, name: str): # pragma: no cover
|
||||
return None
|
||||
|
||||
# ---- capacity fulltext extras ---------------------------------------
|
||||
|
||||
def get_capacity_description(
|
||||
self, capacity_id: int | str
|
||||
) -> str | None:
|
||||
return self._extras.get(str(capacity_id), {}).get("description")
|
||||
|
||||
def get_capacity_certificates(
|
||||
self, capacity_id: int | str
|
||||
) -> list[str]:
|
||||
return list(
|
||||
self._extras.get(str(capacity_id), {}).get("certificates") or []
|
||||
)
|
||||
|
||||
def get_capacity_references(
|
||||
self, capacity_id: int | str
|
||||
) -> list[dict]:
|
||||
return list(
|
||||
self._extras.get(str(capacity_id), {}).get("references") or []
|
||||
)
|
||||
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {
|
||||
str(i): self._extras.get(str(i), {}).get("description")
|
||||
for i in capacity_ids
|
||||
}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {
|
||||
str(i): list(
|
||||
self._extras.get(str(i), {}).get("certificates") or []
|
||||
)
|
||||
for i in capacity_ids
|
||||
}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {
|
||||
str(i): list(
|
||||
self._extras.get(str(i), {}).get("references") or []
|
||||
)
|
||||
for i in capacity_ids
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server builder
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_server_with(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
*,
|
||||
fake_db: _FakeDB,
|
||||
chat_response: str | None = None,
|
||||
chat_side_effect: Any = None,
|
||||
) -> Any:
|
||||
"""Build an MCP server backed by ``fake_db`` and a stubbed LLM.
|
||||
|
||||
Either ``chat_response`` (constant payload) or ``chat_side_effect``
|
||||
(callable taking ``(system, user)`` and returning a string or
|
||||
raising) must be provided.
|
||||
"""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_mod, "create_db_client", lambda *_a, **_k: fake_db
|
||||
)
|
||||
|
||||
if chat_side_effect is not None:
|
||||
async def _fake_chat(self, system, user): # noqa: ARG001
|
||||
outcome = chat_side_effect(system, user)
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
return outcome
|
||||
else:
|
||||
async def _fake_chat(self, system, user): # noqa: ARG001
|
||||
return chat_response or "{}"
|
||||
|
||||
monkeypatch.setattr(
|
||||
"teamlandkarte_mcp.azure.openai_client."
|
||||
"AzureOpenAIClient.chat_completion",
|
||||
_fake_chat,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
return build_server(str(cfg))
|
||||
|
||||
|
||||
def _patch_capturing_store(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Patch ``SearchCache.store_search`` to capture its ``results``.
|
||||
|
||||
Returns a list that is appended to on every call (most recent
|
||||
payload last). The original method is still invoked so ``search_id``
|
||||
bookkeeping continues to work.
|
||||
"""
|
||||
captured: list[dict[str, Any]] = []
|
||||
original = SearchCache.store_search
|
||||
|
||||
def _capturing(self, *, task_id, requirements, results):
|
||||
captured.append(results)
|
||||
return original(
|
||||
self,
|
||||
task_id=task_id,
|
||||
requirements=requirements,
|
||||
results=results,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(SearchCache, "store_search", _capturing)
|
||||
return captured
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Common fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _two_capacities_one_in_window() -> list[Capacity]:
|
||||
"""Capacity 1 overlaps the test window 2025-03-01..2025-06-30, 2 does not."""
|
||||
return [
|
||||
Capacity(
|
||||
id=1,
|
||||
owner_name="Alice",
|
||||
role_name="X",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 2, 1),
|
||||
end_date=date(2025, 7, 31),
|
||||
competences=["A"],
|
||||
),
|
||||
Capacity(
|
||||
id=2,
|
||||
owner_name="Bob",
|
||||
role_name="X",
|
||||
role_level=None,
|
||||
begin_date=date(2026, 1, 1),
|
||||
end_date=date(2026, 12, 31),
|
||||
competences=["A"],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Task 12.2 — End-to-end integration tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_find_matching_capacities_llm_mode_caches_unshortened_rationale(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""LLM mode persists ``matching_method``, full rationale and errors list.
|
||||
|
||||
Validates: requirements 5.1, 7.4, 8.6, 9.5.
|
||||
"""
|
||||
long_rationale = "a" * 350
|
||||
fake = _FakeDB(capacities=_two_capacities_one_in_window())
|
||||
chat_payload = json.dumps(
|
||||
{"category": "Top", "rationale": long_rationale},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
captured = _patch_capturing_store(monkeypatch)
|
||||
srv = _build_server_with(
|
||||
monkeypatch, tmp_path, fake_db=fake, chat_response=chat_payload
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"date_start": "2025-03-01",
|
||||
"date_end": "2025-06-30",
|
||||
"matching_method": "llm_fulltext",
|
||||
},
|
||||
)
|
||||
assert "SEARCH_ID=" in res, res
|
||||
assert captured, "store_search was not called"
|
||||
|
||||
payload = captured[-1]
|
||||
assert payload["matching_method"] == "llm_fulltext"
|
||||
assert isinstance(payload.get("errors"), list)
|
||||
|
||||
top_items = payload["by_category"].get("Top", [])
|
||||
assert len(top_items) == 1, (
|
||||
f"only the in-window capacity should be categorised, got "
|
||||
f"{[i.get('id') for i in top_items]}"
|
||||
)
|
||||
persisted_rationale = top_items[0]["rationale"]
|
||||
assert len(persisted_rationale) >= 350, (
|
||||
f"persisted rationale was truncated: len={len(persisted_rationale)}"
|
||||
)
|
||||
assert persisted_rationale == long_rationale
|
||||
|
||||
# Capacity 2 is outside the window and must not appear in any
|
||||
# category (availability prefilter).
|
||||
all_items = [
|
||||
item
|
||||
for cat_items in payload["by_category"].values()
|
||||
for item in cat_items
|
||||
]
|
||||
ids = {str(it.get("id")) for it in all_items}
|
||||
assert ids == {"1"}, f"expected only capacity 1, got {ids!r}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_find_matching_capacities_availability_filter_same_in_both_modes(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""Score and LLM mode apply identical availability prefilters.
|
||||
|
||||
Validates: requirements 5.2, 9.5.
|
||||
"""
|
||||
fake = _FakeDB(capacities=_two_capacities_one_in_window())
|
||||
# Single payload satisfies both consumers (similarity for score path,
|
||||
# category/rationale for LLM path).
|
||||
chat_payload = json.dumps(
|
||||
{
|
||||
"similarity": 1.0,
|
||||
"category": "Top",
|
||||
"rationale": "ok",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
captured = _patch_capturing_store(monkeypatch)
|
||||
srv = _build_server_with(
|
||||
monkeypatch, tmp_path, fake_db=fake, chat_response=chat_payload
|
||||
)
|
||||
|
||||
args_common = {
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"date_start": "2025-03-01",
|
||||
"date_end": "2025-06-30",
|
||||
}
|
||||
|
||||
# Score-mode run.
|
||||
score_res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{**args_common, "matching_method": "score"},
|
||||
)
|
||||
assert "SEARCH_ID=" in score_res
|
||||
score_payload = captured[-1]
|
||||
assert score_payload["matching_method"] == "score"
|
||||
|
||||
score_ids = {
|
||||
str(item.get("id"))
|
||||
for cat in score_payload["by_category"].values()
|
||||
for item in cat
|
||||
}
|
||||
|
||||
# LLM-mode run.
|
||||
llm_res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{**args_common, "matching_method": "llm_fulltext"},
|
||||
)
|
||||
assert "SEARCH_ID=" in llm_res
|
||||
llm_payload = captured[-1]
|
||||
assert llm_payload["matching_method"] == "llm_fulltext"
|
||||
|
||||
llm_ids = {
|
||||
str(item.get("id"))
|
||||
for cat in llm_payload["by_category"].values()
|
||||
for item in cat
|
||||
}
|
||||
|
||||
assert score_ids == {"1"}, f"score-mode ids: {score_ids!r}"
|
||||
assert llm_ids == {"1"}, f"llm-mode ids: {llm_ids!r}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_find_matching_tasks_llm_mode_persists_errors_and_rationale(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""``find_matching_tasks`` persists errors list and full rationale.
|
||||
|
||||
The mock alternates between a successful payload (long rationale,
|
||||
Good) and ``AzureAPIError`` so exactly one task is categorised and
|
||||
the other ends up in ``errors``.
|
||||
|
||||
Validates: requirements 6.1, 6.2, 7.4, 8.6, 9.5.
|
||||
"""
|
||||
long_rationale = "b" * 360
|
||||
capacity = Capacity(
|
||||
id=1,
|
||||
owner_name="Alice",
|
||||
role_name="Frontend Developer",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 4, 1),
|
||||
end_date=date(2025, 8, 31),
|
||||
competences=["React", "TypeScript"],
|
||||
)
|
||||
tasks = [
|
||||
_Task(
|
||||
id="T1",
|
||||
title="Frontend Developer",
|
||||
description="React and TypeScript",
|
||||
skills=["React"],
|
||||
),
|
||||
_Task(
|
||||
id="T2",
|
||||
title="Backend Engineer",
|
||||
description="Python",
|
||||
skills=["Python"],
|
||||
),
|
||||
]
|
||||
fake = _FakeDB(capacities=[capacity], tasks=tasks)
|
||||
|
||||
state = {"call": 0}
|
||||
|
||||
def _side_effect(_system: str, _user: str) -> Any:
|
||||
n = state["call"]
|
||||
state["call"] += 1
|
||||
if n % 2 == 0:
|
||||
return json.dumps(
|
||||
{"category": "Good", "rationale": long_rationale},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return AzureAPIError("LLM call failed")
|
||||
|
||||
captured = _patch_capturing_store(monkeypatch)
|
||||
srv = _build_server_with(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
fake_db=fake,
|
||||
chat_side_effect=_side_effect,
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_tasks",
|
||||
{"capacity_id": 1, "matching_method": "llm_fulltext"},
|
||||
)
|
||||
assert "SEARCH_ID=" in res
|
||||
assert captured, "store_search was not called"
|
||||
|
||||
payload = captured[-1]
|
||||
assert payload["matching_method"] == "llm_fulltext"
|
||||
|
||||
good = payload["by_category"].get("Good", [])
|
||||
assert len(good) == 1, (
|
||||
f"expected exactly one Good item, got {len(good)}: {good!r}"
|
||||
)
|
||||
assert good[0]["rationale"] == long_rationale
|
||||
assert len(good[0]["rationale"]) >= 360
|
||||
|
||||
errors = payload["errors"]
|
||||
assert isinstance(errors, list)
|
||||
assert len(errors) == 1, f"expected one error, got {errors!r}"
|
||||
error_ids = {e["item_id"] for e in errors}
|
||||
good_ids = {str(item.get("task_id") or item.get("id")) for item in good}
|
||||
assert error_ids.isdisjoint(good_ids), (
|
||||
"an item must appear in either by_category or errors, never both"
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Task 12.3 — Markdown header snapshot tests
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_capacity_search_score_mode_header(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""Score mode keeps the existing 9-column capacity table header.
|
||||
|
||||
Validates: requirement 7.1.
|
||||
"""
|
||||
fake = _FakeDB(capacities=_two_capacities_one_in_window())
|
||||
srv = _build_server_with(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
fake_db=fake,
|
||||
chat_response='{"similarity": 1.0}',
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"date_start": "2025-03-01",
|
||||
"date_end": "2025-06-30",
|
||||
"matching_method": "score",
|
||||
},
|
||||
)
|
||||
|
||||
expected_header = (
|
||||
"| ID | Owner | Role | Competences | Availability "
|
||||
"| Role Score | Competence Score | Overall Score | Category |"
|
||||
)
|
||||
assert expected_header in res, res
|
||||
assert "Begründung" not in res
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_capacity_search_llm_mode_header(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""LLM mode header ends with ``... | Category | Begründung |``.
|
||||
|
||||
Validates: requirements 8.1, 8.2.
|
||||
"""
|
||||
fake = _FakeDB(capacities=_two_capacities_one_in_window())
|
||||
srv = _build_server_with(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
fake_db=fake,
|
||||
chat_response='{"category":"Top","rationale":"ok"}',
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"date_start": "2025-03-01",
|
||||
"date_end": "2025-06-30",
|
||||
"matching_method": "llm_fulltext",
|
||||
},
|
||||
)
|
||||
|
||||
assert "| Category | Begründung |" in res, res
|
||||
assert "| Role Score |" not in res
|
||||
assert "| Competence Score |" not in res
|
||||
assert "| Overall Score |" not in res
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_task_search_score_mode_header(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""Score mode for ``find_matching_tasks`` keeps the score columns.
|
||||
|
||||
Validates: requirement 7.1.
|
||||
"""
|
||||
capacity = Capacity(
|
||||
id=1,
|
||||
owner_name="Alice",
|
||||
role_name="Frontend Developer",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 4, 1),
|
||||
end_date=date(2025, 8, 31),
|
||||
competences=["React"],
|
||||
)
|
||||
tasks = [
|
||||
_Task(
|
||||
id="T1",
|
||||
title="Frontend Developer",
|
||||
description="React",
|
||||
skills=["React"],
|
||||
start_date=date(2025, 5, 1),
|
||||
end_date=date(2025, 7, 31),
|
||||
)
|
||||
]
|
||||
fake = _FakeDB(capacities=[capacity], tasks=tasks)
|
||||
srv = _build_server_with(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
fake_db=fake,
|
||||
chat_response='{"similarity": 1.0}',
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_tasks",
|
||||
{"capacity_id": 1, "matching_method": "score"},
|
||||
)
|
||||
|
||||
assert (
|
||||
"| Role Score | Competence Score | Overall Score | Category |"
|
||||
in res
|
||||
), res
|
||||
assert "Begründung" not in res
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_snapshot_task_search_llm_mode_header(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""LLM mode for ``find_matching_tasks`` ends with Begründung header.
|
||||
|
||||
Validates: requirements 8.1, 8.2.
|
||||
"""
|
||||
capacity = Capacity(
|
||||
id=1,
|
||||
owner_name="Alice",
|
||||
role_name="Frontend Developer",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 4, 1),
|
||||
end_date=date(2025, 8, 31),
|
||||
competences=["React"],
|
||||
)
|
||||
tasks = [
|
||||
_Task(
|
||||
id="T1",
|
||||
title="Frontend Developer",
|
||||
description="React",
|
||||
skills=["React"],
|
||||
start_date=date(2025, 5, 1),
|
||||
end_date=date(2025, 7, 31),
|
||||
)
|
||||
]
|
||||
fake = _FakeDB(capacities=[capacity], tasks=tasks)
|
||||
srv = _build_server_with(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
fake_db=fake,
|
||||
chat_response='{"category":"Good","rationale":"ok"}',
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_tasks",
|
||||
{"capacity_id": 1, "matching_method": "llm_fulltext"},
|
||||
)
|
||||
|
||||
assert "| Category | Begründung |" in res, res
|
||||
assert "| Role Score |" not in res
|
||||
assert "| Competence Score |" not in res
|
||||
assert "| Overall Score |" not in res
|
||||
@@ -0,0 +1,173 @@
|
||||
# Feature: llm-fulltext-matching, Property 4: Ungültige LLM-Kategorie wird auf Irrelevant gemappt
|
||||
"""Property-based test for invalid LLM categories in ``LlmFulltextMatcher``.
|
||||
|
||||
For any LLM response ``{"category": X, "rationale": R}`` where ``X`` is
|
||||
not one of the canonical categories
|
||||
``("Top", "Good", "Partial", "Low", "Irrelevant")`` (case-insensitive,
|
||||
trimmed), the matcher must:
|
||||
|
||||
1. Route the item exclusively to the ``Irrelevant`` bucket of
|
||||
``result.by_category``.
|
||||
2. Preserve the original rationale ``R`` in the stored item rationale.
|
||||
3. Append a deterministic hint of the form
|
||||
``[Hinweis: ungültige LLM-Kategorie: ...]`` to the rationale.
|
||||
4. Not record the item in ``result.errors``.
|
||||
|
||||
The test exercises the public ``match_capacities`` path with a single
|
||||
capacity to validate the matcher behaviour end-to-end (not just the
|
||||
``normalize_category`` helper). The ``AzureOpenAIClient`` and the
|
||||
``DBClient`` are replaced by minimal in-memory doubles.
|
||||
|
||||
**Validates: Requirements 5.6, 6.6**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
|
||||
from teamlandkarte_mcp.matching.profiles import TaskProfile
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
|
||||
_ALLOWED_LOWER = {"top", "good", "partial", "low", "irrelevant"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Any short string whose trimmed/lowercased form is NOT one of the
|
||||
# canonical categories. ``normalize_category`` will treat such values as
|
||||
# invalid and the matcher must route them to ``Irrelevant``.
|
||||
_invalid_category = st.text(max_size=20).filter(
|
||||
lambda s: s.strip().lower() not in _ALLOWED_LOWER
|
||||
)
|
||||
|
||||
# Free-form rationale text the fake LLM returns alongside the invalid
|
||||
# category. The matcher must keep the original (non-whitespace) content
|
||||
# in the stored rationale.
|
||||
_rationale = st.text(max_size=120)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test doubles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubLlm:
|
||||
"""Minimal ``AzureOpenAIClient`` double returning a fixed JSON payload."""
|
||||
|
||||
def __init__(self, *, category: str, rationale: str) -> None:
|
||||
self._payload = json.dumps(
|
||||
{"category": category, "rationale": rationale}
|
||||
)
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
return self._payload
|
||||
|
||||
|
||||
class _StubDb:
|
||||
"""Minimal ``DBClient`` double returning empty extras for every id."""
|
||||
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
|
||||
def _make_capacity() -> Capacity:
|
||||
return Capacity(
|
||||
id=42,
|
||||
owner_name="o",
|
||||
role_name="X",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=[],
|
||||
)
|
||||
|
||||
|
||||
def _make_task_profile() -> TaskProfile:
|
||||
return TaskProfile(id="t", title="T", description="D", skills=[])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(category=_invalid_category, rationale=_rationale)
|
||||
def test_invalid_llm_category_routes_to_irrelevant(
|
||||
category: str, rationale: str
|
||||
) -> None:
|
||||
"""Property 4: invalid LLM categories are mapped to ``Irrelevant``.
|
||||
|
||||
For any invalid category string and any rationale the LLM returns,
|
||||
the matcher places the item exclusively in the ``Irrelevant`` bucket,
|
||||
preserves the (trimmed) original rationale text in the stored
|
||||
rationale, appends the deterministic hint, and does not produce an
|
||||
error entry.
|
||||
"""
|
||||
matcher = LlmFulltextMatcher(
|
||||
db=_StubDb(),
|
||||
client=_StubLlm(category=category, rationale=rationale),
|
||||
)
|
||||
capacity = _make_capacity()
|
||||
task_profile = _make_task_profile()
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=task_profile,
|
||||
capacities=[capacity],
|
||||
)
|
||||
)
|
||||
|
||||
# All five canonical buckets are present.
|
||||
assert set(result.by_category.keys()) == {
|
||||
"Top",
|
||||
"Good",
|
||||
"Partial",
|
||||
"Low",
|
||||
"Irrelevant",
|
||||
}
|
||||
|
||||
# The item is routed exclusively to ``Irrelevant``.
|
||||
assert result.by_category["Top"] == []
|
||||
assert result.by_category["Good"] == []
|
||||
assert result.by_category["Partial"] == []
|
||||
assert result.by_category["Low"] == []
|
||||
assert len(result.by_category["Irrelevant"]) == 1
|
||||
|
||||
item = result.by_category["Irrelevant"][0]
|
||||
assert item.item_id == "42"
|
||||
assert item.category == "Irrelevant"
|
||||
|
||||
# The rationale carries the deterministic invalid-category hint.
|
||||
assert "[Hinweis: ungültige LLM-Kategorie:" in item.rationale
|
||||
|
||||
# The original rationale text is preserved (after trimming, since the
|
||||
# matcher applies ``.strip()`` to the combined rationale).
|
||||
trimmed = rationale.strip()
|
||||
if trimmed:
|
||||
assert trimmed in item.rationale
|
||||
|
||||
# No error entries are produced for invalid categories: the item is a
|
||||
# regular (re-routed) result, not a failure.
|
||||
assert result.errors == []
|
||||
@@ -0,0 +1,363 @@
|
||||
"""Unit tests for ``LlmFulltextMatcher`` with a mocked ``AzureOpenAIClient``.
|
||||
|
||||
These tests cover task 3.10 of the ``llm-fulltext-matching`` spec:
|
||||
|
||||
* success case (valid category + rationale),
|
||||
* invalid JSON,
|
||||
* valid JSON but not an object,
|
||||
* valid JSON with an unknown category,
|
||||
* ``AzureAPIError`` raised by ``chat_completion``,
|
||||
* generic exception raised by ``chat_completion``,
|
||||
* mixed batch (deterministic ordering, sorting, error separation),
|
||||
* task-direction success (``match_tasks``).
|
||||
|
||||
Validates: Requirements 5.4, 5.5, 5.6, 5.7, 5.8, 6.4, 6.5, 6.6, 6.7, 6.8.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
|
||||
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
|
||||
from teamlandkarte_mcp.matching.profiles import (
|
||||
CapacityProfile,
|
||||
CapacityReferenceEntry,
|
||||
TaskProfile,
|
||||
)
|
||||
from teamlandkarte_mcp.models import Capacity, Task
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test doubles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubDb:
|
||||
"""Minimal ``DBClient`` double returning empty extras for every id."""
|
||||
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
|
||||
class _StaticLlm:
|
||||
"""Returns the same payload (or raises) for every ``chat_completion`` call.
|
||||
|
||||
Use for single-item tests where the prompt content is irrelevant.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
payload: str | None = None,
|
||||
exc: BaseException | None = None,
|
||||
) -> None:
|
||||
self._payload = payload
|
||||
self._exc = exc
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
if self._exc is not None:
|
||||
raise self._exc
|
||||
assert self._payload is not None
|
||||
return self._payload
|
||||
|
||||
|
||||
class _MapLlm:
|
||||
"""Returns a payload (or raises) keyed by the ``ID:`` line of the prompt.
|
||||
|
||||
The matcher emits a line ``ID: <id>`` in the user prompt for both
|
||||
directions. The mock parses that line to look up the response, which
|
||||
keeps the test deterministic regardless of iteration order.
|
||||
"""
|
||||
|
||||
_ID_RE = re.compile(r"ID:\s*(\S+)")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id_to_payload: dict[str, str],
|
||||
id_to_exception: dict[str, BaseException] | None = None,
|
||||
) -> None:
|
||||
self._payloads = id_to_payload
|
||||
self._exceptions = id_to_exception or {}
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
match = self._ID_RE.search(user)
|
||||
cid = match.group(1) if match else "unknown"
|
||||
if cid in self._exceptions:
|
||||
raise self._exceptions[cid]
|
||||
return self._payloads.get(
|
||||
cid, '{"category":"Irrelevant","rationale":"none"}'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cap(cid: int) -> Capacity:
|
||||
return Capacity(
|
||||
id=cid,
|
||||
owner_name=f"o{cid}",
|
||||
role_name="R",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=[],
|
||||
)
|
||||
|
||||
|
||||
def _task_profile() -> TaskProfile:
|
||||
return TaskProfile(id="t1", title="T", description="D", skills=[])
|
||||
|
||||
|
||||
def _capacity_profile() -> CapacityProfile:
|
||||
return CapacityProfile(
|
||||
id="42",
|
||||
owner_name="alice",
|
||||
role_name="Engineer",
|
||||
competences=["python", "sql"],
|
||||
description="senior backend engineer",
|
||||
references=[
|
||||
CapacityReferenceEntry(partner_name="ACME", projects="proj-x"),
|
||||
CapacityReferenceEntry(partner_name="", projects="internal"),
|
||||
],
|
||||
certificates=["cert-A"],
|
||||
)
|
||||
|
||||
|
||||
def _make_task(tid: str) -> Task:
|
||||
return Task(
|
||||
id=tid,
|
||||
name=f"name-{tid}",
|
||||
title=f"Task {tid}",
|
||||
description=f"desc {tid}",
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
created_date=datetime(2025, 1, 1),
|
||||
skills=["python"],
|
||||
)
|
||||
|
||||
|
||||
_CATEGORIES = ("Top", "Good", "Partial", "Low", "Irrelevant")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: match_capacities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_success_case_valid_category_and_rationale() -> None:
|
||||
"""Valid JSON with a canonical category lands in the right bucket."""
|
||||
payload = json.dumps({"category": "Top", "rationale": "perfect fit"})
|
||||
matcher = LlmFulltextMatcher(db=_StubDb(), client=_StaticLlm(payload))
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=_task_profile(),
|
||||
capacities=[_cap(7)],
|
||||
)
|
||||
)
|
||||
|
||||
assert result.errors == []
|
||||
assert result.by_category["Top"] and len(result.by_category["Top"]) == 1
|
||||
item = result.by_category["Top"][0]
|
||||
assert item.item_id == "7"
|
||||
assert item.category == "Top"
|
||||
assert item.rationale == "perfect fit"
|
||||
# Other buckets are empty but present.
|
||||
for cat in ("Good", "Partial", "Low", "Irrelevant"):
|
||||
assert result.by_category[cat] == []
|
||||
|
||||
|
||||
def test_invalid_json_routes_to_errors() -> None:
|
||||
"""Non-JSON LLM output is recorded as an error, not a result."""
|
||||
matcher = LlmFulltextMatcher(
|
||||
db=_StubDb(), client=_StaticLlm("not json at all")
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=_task_profile(),
|
||||
capacities=[_cap(7)],
|
||||
)
|
||||
)
|
||||
|
||||
assert len(result.errors) == 1
|
||||
err = result.errors[0]
|
||||
assert err.item_id == "7"
|
||||
assert err.error.startswith("invalid JSON: ")
|
||||
# No item is categorized.
|
||||
for cat in _CATEGORIES:
|
||||
assert result.by_category[cat] == []
|
||||
|
||||
|
||||
def test_valid_json_not_an_object_routes_to_errors() -> None:
|
||||
"""JSON that is not a dict is treated as an invalid payload."""
|
||||
payload = json.dumps("just a string")
|
||||
matcher = LlmFulltextMatcher(db=_StubDb(), client=_StaticLlm(payload))
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=_task_profile(),
|
||||
capacities=[_cap(9)],
|
||||
)
|
||||
)
|
||||
|
||||
assert len(result.errors) == 1
|
||||
err = result.errors[0]
|
||||
assert err.item_id == "9"
|
||||
assert err.error.startswith("invalid JSON: not an object")
|
||||
for cat in _CATEGORIES:
|
||||
assert result.by_category[cat] == []
|
||||
|
||||
|
||||
def test_unknown_category_is_routed_to_irrelevant_with_hint() -> None:
|
||||
"""Unknown categories are remapped to ``Irrelevant`` with a hint."""
|
||||
payload = json.dumps({"category": "Bogus", "rationale": "rea"})
|
||||
matcher = LlmFulltextMatcher(db=_StubDb(), client=_StaticLlm(payload))
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=_task_profile(),
|
||||
capacities=[_cap(3)],
|
||||
)
|
||||
)
|
||||
|
||||
assert result.errors == []
|
||||
assert len(result.by_category["Irrelevant"]) == 1
|
||||
item = result.by_category["Irrelevant"][0]
|
||||
assert item.item_id == "3"
|
||||
assert item.category == "Irrelevant"
|
||||
assert "rea" in item.rationale
|
||||
assert "[Hinweis: ungültige LLM-Kategorie:" in item.rationale
|
||||
for cat in ("Top", "Good", "Partial", "Low"):
|
||||
assert result.by_category[cat] == []
|
||||
|
||||
|
||||
def test_azure_api_error_is_recorded_in_errors() -> None:
|
||||
"""``AzureAPIError`` from ``chat_completion`` lands in the error list."""
|
||||
matcher = LlmFulltextMatcher(
|
||||
db=_StubDb(), client=_StaticLlm(exc=AzureAPIError("boom"))
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=_task_profile(),
|
||||
capacities=[_cap(11)],
|
||||
)
|
||||
)
|
||||
|
||||
assert len(result.errors) == 1
|
||||
err = result.errors[0]
|
||||
assert err.item_id == "11"
|
||||
assert err.error == "AzureAPIError: boom"
|
||||
for cat in _CATEGORIES:
|
||||
assert result.by_category[cat] == []
|
||||
|
||||
|
||||
def test_generic_exception_is_recorded_in_errors() -> None:
|
||||
"""A non-Azure exception is also routed to the error list."""
|
||||
matcher = LlmFulltextMatcher(
|
||||
db=_StubDb(), client=_StaticLlm(exc=RuntimeError("nope"))
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=_task_profile(),
|
||||
capacities=[_cap(13)],
|
||||
)
|
||||
)
|
||||
|
||||
assert len(result.errors) == 1
|
||||
err = result.errors[0]
|
||||
assert err.item_id == "13"
|
||||
assert err.error == "RuntimeError: nope"
|
||||
for cat in _CATEGORIES:
|
||||
assert result.by_category[cat] == []
|
||||
|
||||
|
||||
def test_mixed_batch_orders_buckets_lexicographically() -> None:
|
||||
"""Mixed batch: success, success, JSON error, success → sorted output."""
|
||||
id_to_payload = {
|
||||
"30": json.dumps({"category": "Top", "rationale": "t30"}),
|
||||
"10": json.dumps({"category": "Top", "rationale": "t10"}),
|
||||
"20": "not json",
|
||||
"40": json.dumps({"category": "Good", "rationale": "g40"}),
|
||||
}
|
||||
matcher = LlmFulltextMatcher(
|
||||
db=_StubDb(), client=_MapLlm(id_to_payload)
|
||||
)
|
||||
capacities = [_cap(30), _cap(10), _cap(20), _cap(40)]
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=_task_profile(),
|
||||
capacities=capacities,
|
||||
)
|
||||
)
|
||||
|
||||
# Lexicographically ascending within each category.
|
||||
top_ids = [it.item_id for it in result.by_category["Top"]]
|
||||
assert top_ids == ["10", "30"]
|
||||
good_ids = [it.item_id for it in result.by_category["Good"]]
|
||||
assert good_ids == ["40"]
|
||||
for cat in ("Partial", "Low", "Irrelevant"):
|
||||
assert result.by_category[cat] == []
|
||||
|
||||
# All five canonical buckets are present in the dict.
|
||||
assert set(result.by_category.keys()) == set(_CATEGORIES)
|
||||
|
||||
# The invalid-JSON item is only in the error list.
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].item_id == "20"
|
||||
assert result.errors[0].error.startswith("invalid JSON: ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: match_tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_match_tasks_success_case() -> None:
|
||||
"""``match_tasks`` mirrors the capacity direction for the success path."""
|
||||
payload = json.dumps({"category": "Good", "rationale": "task fit"})
|
||||
matcher = LlmFulltextMatcher(db=_StubDb(), client=_StaticLlm(payload))
|
||||
task = _make_task("00T123")
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_tasks(
|
||||
capacity_profile=_capacity_profile(),
|
||||
tasks=[task],
|
||||
)
|
||||
)
|
||||
|
||||
assert result.errors == []
|
||||
assert len(result.by_category["Good"]) == 1
|
||||
item = result.by_category["Good"][0]
|
||||
assert item.item_id == "00T123"
|
||||
assert item.category == "Good"
|
||||
assert item.rationale == "task fit"
|
||||
for cat in ("Top", "Partial", "Low", "Irrelevant"):
|
||||
assert result.by_category[cat] == []
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(pytest.main([__file__, "-x", "--tb=short", "-q"]))
|
||||
@@ -0,0 +1,168 @@
|
||||
# Feature: llm-fulltext-matching, Property 6:
|
||||
# Ergebnisse sind innerhalb jeder Kategorie deterministisch sortiert
|
||||
"""Property-based test for deterministic sorting in ``LlmFulltextMatcher``.
|
||||
|
||||
For any ``LlmFulltextResult`` produced by ``match_capacities``, the
|
||||
``item_id``-sequence within each category bucket must be strictly
|
||||
ascending (lexicographic) and must remain identical when the input
|
||||
``capacities`` list is permuted.
|
||||
|
||||
The fake LLM is driven by an injected ``capacity_id → category`` mapping.
|
||||
Because the matcher invokes ``chat_completion`` once per capacity in
|
||||
input order, we cannot rely on call index alone (the index would point
|
||||
to a different capacity in the permuted run). Instead, the fake LLM
|
||||
parses the ``ID:`` line emitted by the matcher's user prompt to recover
|
||||
the capacity id and looks up the assigned category.
|
||||
|
||||
**Validates: Requirements 5.8, 6.8**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
|
||||
from teamlandkarte_mcp.matching.profiles import TaskProfile
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
|
||||
_ID_RE = re.compile(r"ID:\s*(\S+)")
|
||||
|
||||
_CATEGORIES: tuple[str, ...] = (
|
||||
"Top",
|
||||
"Good",
|
||||
"Partial",
|
||||
"Low",
|
||||
"Irrelevant",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test doubles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MapLlm:
|
||||
"""Returns a JSON payload whose category depends on the prompt's ID line.
|
||||
|
||||
The matcher emits a line ``ID: <capacity_id>`` in the user prompt. By
|
||||
parsing that line we can return the same category for the same
|
||||
capacity regardless of the order in which the matcher iterates.
|
||||
"""
|
||||
|
||||
def __init__(self, id_to_category: dict[str, str]) -> None:
|
||||
self._map = id_to_category
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
match = _ID_RE.search(user)
|
||||
cap_id = match.group(1) if match else "unknown"
|
||||
category = self._map.get(cap_id, "Irrelevant")
|
||||
return json.dumps(
|
||||
{"category": category, "rationale": f"reason for {cap_id}"}
|
||||
)
|
||||
|
||||
|
||||
class _StubDb:
|
||||
"""Minimal ``DBClient`` double returning empty extras for every id."""
|
||||
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ids = st.lists(
|
||||
st.integers(min_value=1, max_value=99),
|
||||
min_size=2,
|
||||
max_size=8,
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
def _make_capacity(cap_id: int) -> Capacity:
|
||||
return Capacity(
|
||||
id=cap_id,
|
||||
owner_name=f"o{cap_id}",
|
||||
role_name="R",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=[],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(ids=_ids, data=st.data())
|
||||
def test_sort_within_category_is_deterministic_under_permutations(
|
||||
ids: list[int], data: st.DataObject
|
||||
) -> None:
|
||||
"""Property 6: order within each category is stable under permutations.
|
||||
|
||||
Two runs of ``match_capacities`` with the same ``capacity_id → category``
|
||||
mapping but with the input list in different orders must yield the
|
||||
same ``item_id`` sequence per category, and that sequence must be
|
||||
lexicographically ascending.
|
||||
"""
|
||||
id_to_category = {
|
||||
str(i): data.draw(st.sampled_from(_CATEGORIES)) for i in ids
|
||||
}
|
||||
|
||||
capacities = [_make_capacity(i) for i in ids]
|
||||
permutation_order = data.draw(st.permutations(capacities))
|
||||
|
||||
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
|
||||
|
||||
matcher_a = LlmFulltextMatcher(
|
||||
db=_StubDb(), client=_MapLlm(id_to_category)
|
||||
)
|
||||
result_a = asyncio.run(
|
||||
matcher_a.match_capacities(
|
||||
task_profile=task_profile,
|
||||
capacities=capacities,
|
||||
)
|
||||
)
|
||||
|
||||
matcher_b = LlmFulltextMatcher(
|
||||
db=_StubDb(), client=_MapLlm(id_to_category)
|
||||
)
|
||||
result_b = asyncio.run(
|
||||
matcher_b.match_capacities(
|
||||
task_profile=task_profile,
|
||||
capacities=permutation_order,
|
||||
)
|
||||
)
|
||||
|
||||
for cat in _CATEGORIES:
|
||||
ids_a = [it.item_id for it in result_a.by_category[cat]]
|
||||
ids_b = [it.item_id for it in result_b.by_category[cat]]
|
||||
assert ids_a == ids_b, (
|
||||
f"category {cat} order differs: {ids_a} vs {ids_b}"
|
||||
)
|
||||
assert ids_a == sorted(ids_a), (
|
||||
f"category {cat} not sorted ascending: {ids_a}"
|
||||
)
|
||||
@@ -0,0 +1,311 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.config import MatchingConfig, MatchingThresholds
|
||||
from teamlandkarte_mcp.matching.matcher import Matcher
|
||||
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
|
||||
from teamlandkarte_mcp.models import Capacity, Requirements
|
||||
|
||||
|
||||
class _FakeSimilarityEngine:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
comp_score: float = 1.0,
|
||||
role_score: float = 0.0,
|
||||
):
|
||||
self._comp_score = comp_score
|
||||
self._role_score = role_score
|
||||
|
||||
@property
|
||||
def use_bm25_search(self) -> bool:
|
||||
"""Always False — the fake engine never builds a global index."""
|
||||
return False
|
||||
|
||||
async def compute_competence_similarity( # noqa: ANN001
|
||||
self,
|
||||
required,
|
||||
_candidate,
|
||||
global_index=None, # noqa: ANN001
|
||||
):
|
||||
# Return per required competence entries.
|
||||
return {
|
||||
r: {"score": self._comp_score, "best_match": None, "rationale": ""}
|
||||
for r in required
|
||||
}
|
||||
|
||||
async def compute_role_similarity( # noqa: ANN001
|
||||
self,
|
||||
_required_role,
|
||||
_candidate_role,
|
||||
):
|
||||
return self._role_score
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matcher_uses_similarity_engine_scores() -> None:
|
||||
cfg = MatchingConfig(
|
||||
competence_weight=0.8,
|
||||
role_weight=0.2,
|
||||
thresholds=MatchingThresholds(top=0.8, good=0.6, partial=0.4),
|
||||
require_confirmation=True,
|
||||
)
|
||||
|
||||
matcher = Matcher(_FakeSimilarityEngine(), cfg) # type: ignore[arg-type]
|
||||
|
||||
caps = [
|
||||
Capacity(
|
||||
id=1,
|
||||
owner_name="A",
|
||||
role_name="Dev",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 1, 1),
|
||||
end_date=date(2025, 12, 31),
|
||||
competences=["Python"],
|
||||
)
|
||||
]
|
||||
|
||||
req = Requirements(
|
||||
role_name="Dev",
|
||||
competences=["Python"],
|
||||
date_start=None,
|
||||
date_end=None,
|
||||
description=None,
|
||||
)
|
||||
|
||||
result = await matcher.match(caps, req)
|
||||
assert len(result.scored) == 1
|
||||
assert result.scored[0].competence_score == 1.0
|
||||
assert result.scored[0].overall_score == 0.8
|
||||
assert result.scored[0].category == "Top"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BM25 integration: false-positive elimination
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _cfg() -> MatchingConfig:
|
||||
return MatchingConfig(
|
||||
competence_weight=0.8,
|
||||
role_weight=0.2,
|
||||
thresholds=MatchingThresholds(top=0.8, good=0.6, partial=0.4),
|
||||
require_confirmation=False,
|
||||
)
|
||||
|
||||
|
||||
def _async_client() -> MagicMock:
|
||||
"""Mock AzureOpenAIClient."""
|
||||
client = MagicMock()
|
||||
client.chat_completion = AsyncMock(return_value='{"similarity": 0.0}')
|
||||
return client
|
||||
|
||||
|
||||
def _bm25_engine() -> SimilarityEngine:
|
||||
"""Minimal SimilarityEngine with BM25 competence similarity."""
|
||||
return SimilarityEngine(
|
||||
client=_async_client(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bm25_candidate_with_exact_skills_scores_higher() -> None:
|
||||
"""BM25: candidate with exact skills scores higher than JS-only candidate."""
|
||||
engine = _bm25_engine()
|
||||
matcher = Matcher(engine, _cfg())
|
||||
|
||||
caps = [
|
||||
Capacity(
|
||||
id=1,
|
||||
owner_name="Alice",
|
||||
role_name="ML Engineer",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=["Python", "Machine Learning", "Pandas"],
|
||||
),
|
||||
Capacity(
|
||||
id=2,
|
||||
owner_name="Bob",
|
||||
role_name="Frontend Dev",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=["JavaScript", "TypeScript", "Node.js", "Vue.js"],
|
||||
),
|
||||
]
|
||||
req = Requirements(
|
||||
role_name="ML Engineer",
|
||||
competences=["Python", "Machine Learning"],
|
||||
date_start=None,
|
||||
date_end=None,
|
||||
description=None,
|
||||
)
|
||||
|
||||
result = await matcher.match(caps, req)
|
||||
scored = {s.capacity.id: s for s in result.scored}
|
||||
|
||||
# Alice has exact matches → competence_score > 0
|
||||
assert scored[1].competence_score > 0.0
|
||||
# Bob has zero token overlap → competence_score == 0.0 (false-positive eliminated)
|
||||
assert scored[2].competence_score == 0.0
|
||||
# Alice scores higher overall
|
||||
assert scored[1].overall_score > scored[2].overall_score
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bm25_false_positive_eliminated() -> None:
|
||||
"""BM25: JS/TS candidate gets 0.0 when Python/ML is required."""
|
||||
engine = _bm25_engine()
|
||||
matcher = Matcher(engine, _cfg())
|
||||
|
||||
caps = [
|
||||
Capacity(
|
||||
id=3,
|
||||
owner_name="Carol",
|
||||
role_name="Frontend Dev",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=["JavaScript", "TypeScript", "Node.js", "Vue.js",
|
||||
"PWA", "CI/CD"],
|
||||
),
|
||||
]
|
||||
req = Requirements(
|
||||
role_name="Data Scientist",
|
||||
competences=["Python", "Machine Learning"],
|
||||
date_start=None,
|
||||
date_end=None,
|
||||
description=None,
|
||||
)
|
||||
|
||||
result = await matcher.match(caps, req)
|
||||
assert result.scored[0].competence_score == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bm25_auto_tag_candidate_matches_after_expansion() -> None:
|
||||
"""BM25 + auto-tag: 'ML' candidate matches 'Machine Learning' via mock tagger.
|
||||
|
||||
A second candidate "Eve" holds "Machine Learning" as an original skill so
|
||||
it enters the global BM25 corpus. After the mock tagger expands Dave's
|
||||
competences to include "Machine Learning", the global index can score it
|
||||
with positive IDF (N=5 unique skills in the pool).
|
||||
"""
|
||||
mock_tagger = MagicMock()
|
||||
mock_tagger.expand_competences = AsyncMock(
|
||||
return_value=["ML", "Python", "Machine Learning"]
|
||||
)
|
||||
|
||||
engine = SimilarityEngine(
|
||||
client=_async_client(), # type: ignore[arg-type]
|
||||
use_auto_tagging=True,
|
||||
auto_tagger=mock_tagger,
|
||||
)
|
||||
matcher = Matcher(engine, _cfg())
|
||||
|
||||
caps = [
|
||||
Capacity(
|
||||
id=4,
|
||||
owner_name="Dave",
|
||||
role_name="Data Scientist",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=["ML", "Python"],
|
||||
),
|
||||
# Eve's original skills make "Machine Learning" part of the global
|
||||
# BM25 corpus, giving it a stable positive IDF weight.
|
||||
Capacity(
|
||||
id=5,
|
||||
owner_name="Eve",
|
||||
role_name="ML Engineer",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=["Machine Learning", "Data Science", "Statistics"],
|
||||
),
|
||||
]
|
||||
req = Requirements(
|
||||
role_name="Data Scientist",
|
||||
competences=["Machine Learning", "Python"],
|
||||
date_start=None,
|
||||
date_end=None,
|
||||
description=None,
|
||||
)
|
||||
|
||||
result = await matcher.match(caps, req)
|
||||
dave = next(s for s in result.scored if s.capacity.id == 4)
|
||||
# After auto-tag expansion "Machine Learning" is in Dave's working list
|
||||
# and in the global corpus → competence_score > 0
|
||||
assert dave.competence_score > 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global BM25 index construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matcher_builds_global_bm25_index_across_all_candidates() -> None:
|
||||
"""Matcher builds one global BM25 index from all filtered candidates' skills.
|
||||
|
||||
With a global corpus of seven distinct skills the IDF weights are stable
|
||||
and each candidate is scored against the *same* index. Two candidates
|
||||
with non-overlapping skill sets must receive different competence scores:
|
||||
the one whose skills share tokens with the required competence scores
|
||||
above zero, the other must score exactly zero.
|
||||
|
||||
This verifies the fix for the per-person IDF pathology: if a separate
|
||||
index were built per-person the candidate with only one skill would get
|
||||
IDF ≤ 0 and score 0.0 incorrectly.
|
||||
"""
|
||||
engine = _bm25_engine()
|
||||
matcher = Matcher(engine, _cfg())
|
||||
|
||||
caps = [
|
||||
# Frank: single relevant skill — previously broken by per-person N=1
|
||||
Capacity(
|
||||
id=10,
|
||||
owner_name="Frank",
|
||||
role_name="Backend Dev",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=["Python"],
|
||||
),
|
||||
# Grace: unrelated skills only
|
||||
Capacity(
|
||||
id=11,
|
||||
owner_name="Grace",
|
||||
role_name="Designer",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=["Figma", "Sketch", "CSS", "HTML", "Illustrator", "InDesign"],
|
||||
),
|
||||
]
|
||||
req = Requirements(
|
||||
role_name="Backend Dev",
|
||||
competences=["Python"],
|
||||
date_start=None,
|
||||
date_end=None,
|
||||
description=None,
|
||||
)
|
||||
|
||||
result = await matcher.match(caps, req)
|
||||
scored = {s.capacity.id: s for s in result.scored}
|
||||
|
||||
# With a global corpus of 7 unique skills, IDF("python") > 0.
|
||||
# Frank's single-skill corpus would have given IDF < 0 (per-person bug).
|
||||
assert scored[10].competence_score > 0.0, (
|
||||
"Frank should score > 0: global IDF fixes the per-person N=1 pathology"
|
||||
)
|
||||
# Grace has no token overlap with "Python" → score exactly 0.0
|
||||
assert scored[11].competence_score == 0.0
|
||||
# Frank ranks above Grace
|
||||
assert scored[10].overall_score > scored[11].overall_score
|
||||
@@ -0,0 +1,230 @@
|
||||
# Feature: llm-fulltext-matching, Property 9: matching_method-Validierung lehnt unbekannte Werte ab
|
||||
"""Property test for ``matching_method`` validation.
|
||||
|
||||
The MCP tools ``find_matching_capacities`` and ``find_matching_tasks``
|
||||
must reject any ``matching_method`` value whose ``strip().lower()``
|
||||
representation is neither ``"score"`` nor ``"llm_fulltext"``. The
|
||||
rejection must:
|
||||
|
||||
1. Produce an error message that mentions both allowed values
|
||||
(``score`` and ``llm_fulltext``).
|
||||
2. Avoid touching the database (no capacity loading) and the LLM (no
|
||||
``chat_completion`` call). The validation must short-circuit BEFORE
|
||||
any side-effect.
|
||||
|
||||
The fake database and the fake ``chat_completion`` track call counts;
|
||||
both counters must remain zero for every Hypothesis-generated invalid
|
||||
input.
|
||||
|
||||
**Validates: Requirements 1.5**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
|
||||
|
||||
_CONFIG_TEMPLATE = """
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
|
||||
[azure_openai]
|
||||
endpoint='https://example.openai.azure.com'
|
||||
api_version='2024-02-15-preview'
|
||||
chat_deployment='gpt-4'
|
||||
""".strip()
|
||||
|
||||
|
||||
class _CountingDB:
|
||||
"""Fake DB that counts how often ``get_all_capacities_with_competences``
|
||||
is invoked. Any non-zero counter after a rejected validation is a bug.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.capacity_calls = 0
|
||||
self.capacity_by_id_calls = 0
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["X"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["A"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_capacities_with_competences(self):
|
||||
self.capacity_calls += 1
|
||||
return []
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_capacity_by_id(self, capacity_id): # pragma: no cover
|
||||
self.capacity_by_id_calls += 1
|
||||
return None
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"), str
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
def _build_test_server(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> tuple[Any, _CountingDB, dict[str, int]]:
|
||||
"""Construct a server backed by counting DB and LLM doubles."""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
||||
|
||||
fake_db = _CountingDB()
|
||||
monkeypatch.setattr(
|
||||
mcp_mod, "create_db_client", lambda *_a, **_k: fake_db
|
||||
)
|
||||
|
||||
llm_calls = {"count": 0}
|
||||
|
||||
async def _fake_chat_completion(self, system, user): # noqa: ARG001
|
||||
llm_calls["count"] += 1
|
||||
return '{"category":"Top","rationale":"r"}'
|
||||
|
||||
monkeypatch.setattr(
|
||||
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
|
||||
_fake_chat_completion,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
return srv, fake_db, llm_calls
|
||||
|
||||
|
||||
# Reject anything that, after trimming and lower-casing, is one of the
|
||||
# canonical values (those are valid inputs and would not exercise the
|
||||
# error path) or the empty string. The empty/whitespace-only string is
|
||||
# treated by the server as "no value provided" (it then falls back to
|
||||
# the configured default), so it is not part of the invalid-input space
|
||||
# this property test targets. Hypothesis is otherwise free to generate
|
||||
# any text.
|
||||
def _is_invalid_method(value: str) -> bool:
|
||||
norm = value.strip().lower()
|
||||
if norm == "":
|
||||
return False
|
||||
return norm not in ("score", "llm_fulltext")
|
||||
|
||||
|
||||
_INVALID_METHOD = st.text(max_size=20).filter(_is_invalid_method)
|
||||
|
||||
|
||||
@settings(
|
||||
max_examples=50,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
@given(value=_INVALID_METHOD)
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_matching_method_is_rejected_without_side_effects(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
value: str,
|
||||
) -> None:
|
||||
"""Property 9: invalid matching_method values are rejected verbatim.
|
||||
|
||||
The error message must list both allowed values; the database and the
|
||||
LLM mock must not be touched.
|
||||
"""
|
||||
srv, fake_db, llm_calls = _build_test_server(monkeypatch, tmp_path)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"matching_method": value,
|
||||
},
|
||||
)
|
||||
|
||||
# The error message references both allowed values.
|
||||
assert "score" in res, (
|
||||
f"missing 'score' in error response for {value!r}: {res!r}"
|
||||
)
|
||||
assert "llm_fulltext" in res, (
|
||||
f"missing 'llm_fulltext' in error response for {value!r}: {res!r}"
|
||||
)
|
||||
|
||||
# The response must signal an error (not a successful search).
|
||||
lowered = res.lower()
|
||||
assert ("error" in lowered) or ("invalid" in lowered), (
|
||||
f"response is not an error for {value!r}: {res!r}"
|
||||
)
|
||||
|
||||
# No DB/LLM access for the rejected validation path.
|
||||
assert fake_db.capacity_calls == 0, (
|
||||
f"DB capacity loader was invoked for invalid value {value!r}"
|
||||
)
|
||||
assert llm_calls["count"] == 0, (
|
||||
f"LLM was invoked for invalid value {value!r}"
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
import datetime as dt
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.matching.matcher import Matcher
|
||||
from teamlandkarte_mcp.matching.scorer import categorize
|
||||
from teamlandkarte_mcp.mcp_server import _calculate_overlap_percentage, _task_role_text
|
||||
from teamlandkarte_mcp.models import Capacity, Requirements
|
||||
|
||||
|
||||
def test_task_role_text_title_first_fallback_to_description() -> None:
|
||||
assert _task_role_text("DevOps Engineer", "foo") == "DevOps Engineer"
|
||||
assert _task_role_text(" ", "Backend Developer") == "Backend Developer"
|
||||
assert _task_role_text(None, " Backend Developer ") == "Backend Developer"
|
||||
|
||||
|
||||
def test_overlap_percentage_basic() -> None:
|
||||
ref_start = dt.date(2025, 3, 1)
|
||||
ref_end = dt.date(2025, 6, 30)
|
||||
|
||||
other_start = dt.date(2025, 3, 15)
|
||||
other_end = dt.date(2025, 6, 30)
|
||||
|
||||
# 2025-03-01..2025-06-30 inclusive is 122 days; overlap is 108 days.
|
||||
assert (
|
||||
_calculate_overlap_percentage(
|
||||
ref_start=ref_start,
|
||||
ref_end=ref_end,
|
||||
other_start=other_start,
|
||||
other_end=other_end,
|
||||
)
|
||||
== "89%"
|
||||
)
|
||||
|
||||
|
||||
def test_overlap_percentage_open_ended_other_end() -> None:
|
||||
ref_start = dt.date(2025, 3, 1)
|
||||
ref_end = dt.date(2025, 3, 31)
|
||||
|
||||
other_start = dt.date(2025, 3, 15)
|
||||
other_end = None
|
||||
|
||||
assert (
|
||||
_calculate_overlap_percentage(
|
||||
ref_start=ref_start,
|
||||
ref_end=ref_end,
|
||||
other_start=other_start,
|
||||
other_end=other_end,
|
||||
)
|
||||
== "55%"
|
||||
)
|
||||
|
||||
|
||||
def test_categorize_includes_irrelevant() -> None:
|
||||
class _T:
|
||||
top = 0.8
|
||||
good = 0.6
|
||||
partial = 0.4
|
||||
low = 0.2
|
||||
|
||||
class _Cfg:
|
||||
thresholds = _T()
|
||||
|
||||
assert categorize(0.19, _Cfg()) == "Irrelevant"
|
||||
assert categorize(0.20, _Cfg()) == "Low"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_matcher_includes_irrelevant_bucket(monkeypatch) -> None:
|
||||
# Arrange: Build a matcher with a fake similarity engine that returns 0 scores.
|
||||
class _FakeSim:
|
||||
use_bm25_search = False # tells Matcher not to build a global index
|
||||
|
||||
async def compute_competence_similarity(self, required, present, global_index=None):
|
||||
return {r: {"score": 0.0} for r in required}
|
||||
|
||||
async def compute_role_similarity(self, required_role, cap_role):
|
||||
return 0.0
|
||||
|
||||
class _Thresh:
|
||||
top = 0.8
|
||||
good = 0.6
|
||||
partial = 0.4
|
||||
low = 0.2
|
||||
|
||||
class _Cfg:
|
||||
competence_weight = 0.8
|
||||
role_weight = 0.2
|
||||
thresholds = _Thresh()
|
||||
|
||||
matcher = Matcher(_FakeSim(), _Cfg())
|
||||
|
||||
cap = Capacity(
|
||||
id=1,
|
||||
owner_name="A",
|
||||
role_name="X",
|
||||
role_level=None,
|
||||
begin_date=dt.date(2025, 1, 1),
|
||||
end_date=dt.date(2025, 12, 31),
|
||||
competences=["a"],
|
||||
)
|
||||
req = Requirements(
|
||||
role_name="Y",
|
||||
competences=["b"],
|
||||
date_start=dt.date(2025, 1, 1),
|
||||
date_end=dt.date(2025, 1, 2),
|
||||
)
|
||||
|
||||
# Act
|
||||
res = await matcher.match([cap], req)
|
||||
|
||||
# Assert
|
||||
assert "Irrelevant" in res.by_category
|
||||
assert len(res.by_category["Irrelevant"]) == 1
|
||||
@@ -0,0 +1,429 @@
|
||||
"""Unit tests for ``filter_search_results`` and ``get_results_by_category``
|
||||
covering both score and LLM-fulltext modes (task 11.5 of the
|
||||
``llm-fulltext-matching`` spec).
|
||||
|
||||
The tests verify that:
|
||||
|
||||
* ``get_results_by_category`` keeps the score-mode columns unchanged for
|
||||
legacy ``score`` searches (regression).
|
||||
* ``get_results_by_category`` renders the ``Begründung`` column and no
|
||||
numeric score columns for ``llm_fulltext`` searches; META reports the
|
||||
matching method.
|
||||
* ``filter_search_results`` keeps the score-mode columns unchanged for
|
||||
``score`` searches and tags META with ``"score"``.
|
||||
* ``filter_search_results`` renders the ``Begründung`` column and no
|
||||
numeric score columns for ``llm_fulltext`` searches and tags META
|
||||
with ``"llm_fulltext"``.
|
||||
* ``filter_search_results`` ignores ``min_similarity`` in
|
||||
``llm_fulltext`` mode and surfaces the ignore note in the
|
||||
``Applied Filters`` table while *not* dropping items that would
|
||||
otherwise survive the remaining filters.
|
||||
|
||||
_Requirements: 9.1, 9.2, 9.3, 9.4, 9.5_
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
|
||||
_CONFIG_TEMPLATE = """
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
|
||||
[azure_openai]
|
||||
endpoint='https://example.openai.azure.com'
|
||||
api_version='2024-02-15-preview'
|
||||
chat_deployment='gpt-4'
|
||||
""".strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test doubles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal DB stub returning two capacities that overlap the window."""
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["X"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["A"]
|
||||
|
||||
def get_all_capacities_with_competences(self) -> list[Capacity]:
|
||||
return [
|
||||
Capacity(
|
||||
id=1,
|
||||
owner_name="Alice",
|
||||
role_name="X",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 2, 1),
|
||||
end_date=date(2025, 7, 31),
|
||||
competences=["A"],
|
||||
),
|
||||
Capacity(
|
||||
id=2,
|
||||
owner_name="Bob",
|
||||
role_name="X",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 3, 15),
|
||||
end_date=date(2025, 6, 15),
|
||||
competences=["A"],
|
||||
),
|
||||
]
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20) -> list[Capacity]:
|
||||
return []
|
||||
|
||||
def get_open_tasks(self, limit: int = 20) -> list[Any]:
|
||||
return []
|
||||
|
||||
# --- LLM-fulltext capacity extras ---------------------------------------
|
||||
|
||||
def get_capacity_description(self, capacity_id: int | str) -> str | None:
|
||||
return None
|
||||
|
||||
def get_capacity_certificates(
|
||||
self, capacity_id: int | str
|
||||
) -> list[str]:
|
||||
return []
|
||||
|
||||
def get_capacity_references(
|
||||
self, capacity_id: int | str
|
||||
) -> list[dict]:
|
||||
return []
|
||||
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"), str
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
def _extract_meta(output: str) -> dict[str, Any]:
|
||||
for line in output.splitlines():
|
||||
if line.startswith("META="):
|
||||
return json.loads(line[len("META=") :])
|
||||
raise AssertionError(f"no META= line in tool output:\n{output}")
|
||||
|
||||
|
||||
def _extract_search_id(output: str) -> str:
|
||||
assert "SEARCH_ID=" in output, f"no SEARCH_ID= in output:\n{output}"
|
||||
return output.split("SEARCH_ID=")[1].splitlines()[0].strip()
|
||||
|
||||
|
||||
def _build_server_with(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
*,
|
||||
chat_response: str,
|
||||
) -> Any:
|
||||
"""Build an MCP server backed by ``_FakeDB`` and a stubbed LLM."""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_mod, "create_db_client", lambda *_a, **_k: _FakeDB()
|
||||
)
|
||||
|
||||
async def _fake_chat(self, system, user): # noqa: ARG001
|
||||
return chat_response
|
||||
|
||||
monkeypatch.setattr(
|
||||
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
|
||||
_fake_chat,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
return build_server(str(cfg))
|
||||
|
||||
|
||||
async def _create_score_search(srv: Any) -> str:
|
||||
"""Run a score-mode search and return its ``search_id``."""
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"date_start": "2025-03-01",
|
||||
"date_end": "2025-06-30",
|
||||
},
|
||||
)
|
||||
return _extract_search_id(res)
|
||||
|
||||
|
||||
async def _create_llm_search(srv: Any) -> str:
|
||||
"""Run an LLM-mode search and return its ``search_id``."""
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"date_start": "2025-03-01",
|
||||
"date_end": "2025-06-30",
|
||||
"matching_method": "llm_fulltext",
|
||||
},
|
||||
)
|
||||
return _extract_search_id(res)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: get_results_by_category
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_results_by_category_score_mode_preserves_columns(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Score mode keeps the original 9-column capacity table."""
|
||||
srv = _build_server_with(
|
||||
monkeypatch, tmp_path, chat_response='{"similarity": 1.0}'
|
||||
)
|
||||
|
||||
search_id = await _create_score_search(srv)
|
||||
|
||||
page = await _call_tool(
|
||||
srv,
|
||||
"get_results_by_category",
|
||||
{
|
||||
"search_id": search_id,
|
||||
"category": "Top",
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
},
|
||||
)
|
||||
|
||||
# Score columns are present.
|
||||
assert "| Role Score |" in page
|
||||
assert "| Competence Score |" in page
|
||||
assert "| Overall Score |" in page
|
||||
assert "| Category |" in page
|
||||
|
||||
# Begründung must be absent in score mode.
|
||||
assert "Begründung" not in page
|
||||
|
||||
# META still reports the matching method.
|
||||
meta = _extract_meta(page)
|
||||
assert meta.get("matching_method") == "score"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_results_by_category_llm_mode_renders_begruendung(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""LLM mode renders ``Begründung`` and drops numeric score columns."""
|
||||
srv = _build_server_with(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
chat_response='{"category":"Good","rationale":"good fit"}',
|
||||
)
|
||||
|
||||
search_id = await _create_llm_search(srv)
|
||||
|
||||
# Capacities are categorised as ``Good`` by the stubbed LLM response.
|
||||
page = await _call_tool(
|
||||
srv,
|
||||
"get_results_by_category",
|
||||
{
|
||||
"search_id": search_id,
|
||||
"category": "Good",
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
},
|
||||
)
|
||||
|
||||
assert "Begründung" in page, page
|
||||
assert "good fit" in page
|
||||
|
||||
# Score columns must be absent in LLM mode.
|
||||
assert "| Role Score |" not in page
|
||||
assert "| Competence Score |" not in page
|
||||
assert "| Overall Score |" not in page
|
||||
|
||||
# META reports llm_fulltext.
|
||||
meta = _extract_meta(page)
|
||||
assert meta.get("matching_method") == "llm_fulltext"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: filter_search_results
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_search_results_score_mode_unchanged(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Score-mode filter preview keeps numeric score columns."""
|
||||
srv = _build_server_with(
|
||||
monkeypatch, tmp_path, chat_response='{"similarity": 1.0}'
|
||||
)
|
||||
|
||||
search_id = await _create_score_search(srv)
|
||||
|
||||
filtered = await _call_tool(
|
||||
srv,
|
||||
"filter_search_results",
|
||||
{"search_id": search_id, "role_filter": "X"},
|
||||
)
|
||||
|
||||
assert "| Role Score |" in filtered
|
||||
assert "| Competence Score |" in filtered
|
||||
assert "| Overall Score |" in filtered
|
||||
assert "Begründung" not in filtered
|
||||
|
||||
meta = _extract_meta(filtered)
|
||||
assert meta.get("matching_method") == "score"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_search_results_llm_mode_uses_begruendung_table(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""LLM-mode filter preview uses the ``Begründung`` layout."""
|
||||
srv = _build_server_with(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
chat_response='{"category":"Good","rationale":"good fit"}',
|
||||
)
|
||||
|
||||
search_id = await _create_llm_search(srv)
|
||||
|
||||
filtered = await _call_tool(
|
||||
srv,
|
||||
"filter_search_results",
|
||||
{"search_id": search_id, "role_filter": "X"},
|
||||
)
|
||||
|
||||
assert "Begründung" in filtered
|
||||
assert "| Role Score |" not in filtered
|
||||
assert "| Competence Score |" not in filtered
|
||||
assert "| Overall Score |" not in filtered
|
||||
|
||||
meta = _extract_meta(filtered)
|
||||
assert meta.get("matching_method") == "llm_fulltext"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_search_results_llm_mode_ignores_min_similarity_with_hint(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""``min_similarity`` is recorded as ignored in LLM mode and inactive."""
|
||||
srv = _build_server_with(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
chat_response='{"category":"Good","rationale":"good fit"}',
|
||||
)
|
||||
|
||||
search_id = await _create_llm_search(srv)
|
||||
|
||||
# Use a threshold that would normally drop everything in score mode.
|
||||
filtered = await _call_tool(
|
||||
srv,
|
||||
"filter_search_results",
|
||||
{
|
||||
"search_id": search_id,
|
||||
"role_filter": "X",
|
||||
"min_similarity": 0.99,
|
||||
},
|
||||
)
|
||||
|
||||
# Hint surfaces in the ``Applied Filters`` table.
|
||||
assert "ignored: not applicable in llm_fulltext mode" in filtered
|
||||
|
||||
# The threshold did not eliminate the items: both fake capacities
|
||||
# remain in the filtered result set.
|
||||
assert "Filtered total results: 2" in filtered, filtered
|
||||
|
||||
@@ -0,0 +1,623 @@
|
||||
"""Unit tests for matching_method routing in both MCP matching tools.
|
||||
|
||||
Covers task 6.6 of the ``llm-fulltext-matching`` spec:
|
||||
|
||||
* ``matching_method=None`` → default (score) mode is used; the rendered
|
||||
table keeps the score columns and the META JSON tags ``"score"``.
|
||||
* ``matching_method="llm_fulltext"`` → the LLM matcher is invoked; the
|
||||
rendered table shows the ``Begründung`` column instead of score
|
||||
columns and META tags ``"llm_fulltext"``.
|
||||
* ``matching_method="bogus"`` → an error message is returned that lists
|
||||
both allowed values; neither the database (capacity loader / capacity
|
||||
by id) nor the LLM is touched.
|
||||
|
||||
The tests cover both ``find_matching_capacities`` and
|
||||
``find_matching_tasks``.
|
||||
|
||||
_Requirements: 1.1, 1.2, 1.3, 1.4, 1.5_
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
from teamlandkarte_mcp.models import (
|
||||
Capacity,
|
||||
Task,
|
||||
Team,
|
||||
TeamCompetence,
|
||||
TeamReference,
|
||||
)
|
||||
|
||||
|
||||
_CONFIG_TEMPLATE = """
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[matching.team]
|
||||
top_competency_weight=1.5
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
|
||||
[azure_openai]
|
||||
endpoint='https://example.openai.azure.com'
|
||||
api_version='2024-02-15-preview'
|
||||
chat_deployment='gpt-4'
|
||||
""".strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test doubles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _CountingDB:
|
||||
"""Fake DB tracking calls into the capacity/task data paths."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.capacity_calls = 0
|
||||
self.capacity_by_id_calls = 0
|
||||
self.task_calls = 0
|
||||
self.description_calls = 0
|
||||
self.certificates_calls = 0
|
||||
self.references_calls = 0
|
||||
self.team_calls = 0
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["X", "Frontend Developer"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["A", "React"]
|
||||
|
||||
# --- capacity-search direction ------------------------------------------
|
||||
|
||||
def get_all_capacities_with_competences(self) -> list[Capacity]:
|
||||
self.capacity_calls += 1
|
||||
return [
|
||||
Capacity(
|
||||
id=1,
|
||||
owner_name="A",
|
||||
role_name="X",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 1, 1),
|
||||
end_date=date(2025, 12, 31),
|
||||
competences=["A"],
|
||||
)
|
||||
]
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20) -> list[Capacity]:
|
||||
return []
|
||||
|
||||
# --- task-search direction ----------------------------------------------
|
||||
|
||||
def get_open_tasks(self, limit: int = 20) -> list[Task]:
|
||||
self.task_calls += 1
|
||||
tasks = [
|
||||
Task(
|
||||
id="T1",
|
||||
name="t1",
|
||||
title="Frontend Developer",
|
||||
description="React",
|
||||
start_date=date(2025, 5, 1),
|
||||
end_date=date(2025, 7, 31),
|
||||
created_date=datetime(2025, 1, 1),
|
||||
skills=["React"],
|
||||
)
|
||||
]
|
||||
return tasks if limit == 0 else tasks[:limit]
|
||||
|
||||
def get_capacity_by_id(self, capacity_id: int | str) -> Capacity | None:
|
||||
self.capacity_by_id_calls += 1
|
||||
return Capacity(
|
||||
id=int(str(capacity_id)),
|
||||
owner_name="A",
|
||||
role_name="Frontend Developer",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 4, 1),
|
||||
end_date=date(2025, 8, 31),
|
||||
competences=["React"],
|
||||
)
|
||||
|
||||
# --- LLM-fulltext capacity extras ---------------------------------------
|
||||
|
||||
def get_capacity_description(self, capacity_id: int | str) -> str | None:
|
||||
self.description_calls += 1
|
||||
return None
|
||||
|
||||
def get_capacity_certificates(
|
||||
self, capacity_id: int | str
|
||||
) -> list[str]:
|
||||
self.certificates_calls += 1
|
||||
return []
|
||||
|
||||
def get_capacity_references(
|
||||
self, capacity_id: int | str
|
||||
) -> list[dict]:
|
||||
self.references_calls += 1
|
||||
return []
|
||||
|
||||
# Batch variants (used by ``match_capacities``).
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
# --- team-search direction ----------------------------------------------
|
||||
|
||||
def get_all_teams(self) -> list[Team]:
|
||||
self.team_calls += 1
|
||||
return [
|
||||
Team(
|
||||
team_id="t1",
|
||||
ouid="ou-1",
|
||||
team_name="Team Alpha",
|
||||
focus_name="X",
|
||||
about_us="We build things.",
|
||||
offerings="APIs.",
|
||||
interests="Distributed systems.",
|
||||
competences=[
|
||||
TeamCompetence(name="A", top_competency=True),
|
||||
],
|
||||
references=[
|
||||
TeamReference(
|
||||
partner_name="Partner One", projects="Project P"
|
||||
),
|
||||
],
|
||||
)
|
||||
]
|
||||
|
||||
def get_team_by_id(self, team_id: str) -> Team | None: # pragma: no cover
|
||||
for t in self.get_all_teams():
|
||||
if t.team_id == str(team_id):
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"), str
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
def _extract_meta(output: str) -> dict[str, Any]:
|
||||
for line in output.splitlines():
|
||||
if line.startswith("META="):
|
||||
return json.loads(line[len("META=") :])
|
||||
raise AssertionError(f"no META= line in tool output:\n{output}")
|
||||
|
||||
|
||||
def _build_server_with(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
*,
|
||||
chat_completion: Any,
|
||||
) -> tuple[Any, _CountingDB, dict[str, int]]:
|
||||
"""Construct an MCP server backed by a counting DB and a counted LLM."""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
||||
|
||||
fake_db = _CountingDB()
|
||||
monkeypatch.setattr(
|
||||
mcp_mod, "create_db_client", lambda *_a, **_k: fake_db
|
||||
)
|
||||
|
||||
llm_calls = {"count": 0}
|
||||
|
||||
async def _wrapped(self, system, user): # noqa: ARG001
|
||||
llm_calls["count"] += 1
|
||||
return await chat_completion(system, user)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
|
||||
_wrapped,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
return srv, fake_db, llm_calls
|
||||
|
||||
|
||||
async def _score_payload(system: str, user: str) -> str:
|
||||
return '{"similarity": 1.0}'
|
||||
|
||||
|
||||
async def _llm_payload(system: str, user: str) -> str:
|
||||
return '{"category":"Top","rationale":"good fit"}'
|
||||
|
||||
|
||||
async def _fail_if_called(system: str, user: str) -> str: # pragma: no cover
|
||||
raise AssertionError(
|
||||
"chat_completion must not be called for invalid matching_method"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: find_matching_capacities
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_matching_capacities_default_uses_score_mode(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""No ``matching_method`` → default ``score`` schema."""
|
||||
srv, _db, _llm = _build_server_with(
|
||||
monkeypatch, tmp_path, chat_completion=_score_payload
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{"role_name": "X", "competences": ["A"]},
|
||||
)
|
||||
|
||||
# Score-mode columns are present.
|
||||
assert "| Role Score |" in res
|
||||
assert "| Competence Score |" in res
|
||||
assert "| Overall Score |" in res
|
||||
# LLM-mode column is absent.
|
||||
assert "Begründung" not in res
|
||||
# META tags the search as score mode.
|
||||
meta = _extract_meta(res)
|
||||
assert meta.get("matching_method") == "score"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_matching_capacities_llm_mode_invokes_llm_matcher(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""``matching_method='llm_fulltext'`` → LLM table, no score columns."""
|
||||
srv, _db, llm_calls = _build_server_with(
|
||||
monkeypatch, tmp_path, chat_completion=_llm_payload
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"matching_method": "llm_fulltext",
|
||||
},
|
||||
)
|
||||
|
||||
# The LLM was invoked at least once for the single matching capacity.
|
||||
assert llm_calls["count"] >= 1, "LLM matcher path was not exercised"
|
||||
|
||||
# META reflects the LLM mode.
|
||||
meta = _extract_meta(res)
|
||||
assert meta.get("matching_method") == "llm_fulltext"
|
||||
|
||||
# The LLM result table includes the Begründung column.
|
||||
assert "Begründung" in res, "LLM table missing the Begründung column"
|
||||
|
||||
# The score columns are gone in LLM mode.
|
||||
assert "| Role Score |" not in res
|
||||
assert "| Competence Score |" not in res
|
||||
assert "| Overall Score |" not in res
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_matching_capacities_bogus_method_returns_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Invalid ``matching_method`` → error mentioning both allowed values.
|
||||
|
||||
Neither the capacity loader nor the LLM may be invoked.
|
||||
"""
|
||||
srv, fake_db, llm_calls = _build_server_with(
|
||||
monkeypatch, tmp_path, chat_completion=_fail_if_called
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"matching_method": "bogus",
|
||||
},
|
||||
)
|
||||
|
||||
lowered = res.lower()
|
||||
assert "error" in lowered or "invalid" in lowered, res
|
||||
assert "score" in res
|
||||
assert "llm_fulltext" in res
|
||||
|
||||
# No side effects.
|
||||
assert fake_db.capacity_calls == 0, (
|
||||
"DB capacity loader was hit despite invalid matching_method"
|
||||
)
|
||||
assert llm_calls["count"] == 0, (
|
||||
"LLM was called despite invalid matching_method"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: find_matching_tasks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_matching_tasks_llm_mode_invokes_llm_matcher(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""``matching_method='llm_fulltext'`` for task search uses LLM matcher."""
|
||||
srv, fake_db, llm_calls = _build_server_with(
|
||||
monkeypatch, tmp_path, chat_completion=_llm_payload
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_tasks",
|
||||
{"capacity_id": 1, "matching_method": "llm_fulltext"},
|
||||
)
|
||||
|
||||
# The LLM was invoked for the candidate task.
|
||||
assert llm_calls["count"] >= 1, "LLM matcher path was not exercised"
|
||||
|
||||
# META reflects the LLM mode.
|
||||
meta = _extract_meta(res)
|
||||
assert meta.get("matching_method") == "llm_fulltext"
|
||||
|
||||
# LLM-mode column is present, score columns absent.
|
||||
assert "Begründung" in res, "LLM task table missing Begründung column"
|
||||
assert "| Role Score |" not in res
|
||||
assert "| Competence Score |" not in res
|
||||
assert "| Overall Score |" not in res
|
||||
|
||||
# The LLM-fulltext direction loads description/certificates/references
|
||||
# for the resolved capacity.
|
||||
assert fake_db.description_calls == 1
|
||||
assert fake_db.certificates_calls == 1
|
||||
assert fake_db.references_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_matching_tasks_bogus_method_returns_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Invalid method on task search → error, no DB/LLM access."""
|
||||
srv, fake_db, llm_calls = _build_server_with(
|
||||
monkeypatch, tmp_path, chat_completion=_fail_if_called
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_tasks",
|
||||
{"capacity_id": 1, "matching_method": "bogus"},
|
||||
)
|
||||
|
||||
lowered = res.lower()
|
||||
assert "error" in lowered or "invalid" in lowered, res
|
||||
assert "score" in res
|
||||
assert "llm_fulltext" in res
|
||||
|
||||
# No DB lookups for the rejected validation path.
|
||||
assert fake_db.capacity_by_id_calls == 0, (
|
||||
"get_capacity_by_id was called despite invalid matching_method"
|
||||
)
|
||||
assert fake_db.task_calls == 0, (
|
||||
"get_open_tasks was called despite invalid matching_method"
|
||||
)
|
||||
assert llm_calls["count"] == 0, (
|
||||
"LLM was called despite invalid matching_method"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: find_matching_teams (Profile_Type="team")
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# These cases mirror the capacity-routing tests above but exercise the
|
||||
# team-profile path so that the routing surface is covered for both
|
||||
# ``Profile_Type`` values (``capacity``, ``team``) and both
|
||||
# ``matching_method`` values (gemocktes LLM und gemockte DB).
|
||||
#
|
||||
# _Requirements: 12.7_
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_matching_teams_score_mode_renders_team_columns(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""``matching_method='score'`` on team search → score columns."""
|
||||
srv, fake_db, llm_calls = _build_server_with(
|
||||
monkeypatch, tmp_path, chat_completion=_score_payload
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_teams",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"matching_method": "score",
|
||||
},
|
||||
)
|
||||
|
||||
# Score-mode + team column layout (spec: 6.6 / 8.4).
|
||||
for header in (
|
||||
"Team Name",
|
||||
"Schwerpunkt",
|
||||
"Top-Kompetenzen",
|
||||
"Role Score",
|
||||
"Competence Score",
|
||||
"Overall Score",
|
||||
"Category",
|
||||
):
|
||||
assert header in res, f"missing team-search header {header!r}"
|
||||
assert "Begründung" not in res
|
||||
|
||||
meta = _extract_meta(res)
|
||||
assert meta.get("search_type") == "team_search"
|
||||
assert meta.get("matching_method") == "score"
|
||||
|
||||
# The team loader was hit; the capacity loader was not.
|
||||
assert fake_db.team_calls == 1
|
||||
assert fake_db.capacity_calls == 0
|
||||
# The mocked LLM may be invoked by the score path's role-similarity
|
||||
# cache, but that is not relevant here. We only assert the routing
|
||||
# decision via META and the column layout.
|
||||
_ = llm_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_matching_teams_llm_mode_invokes_llm_matcher(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""``matching_method='llm_fulltext'`` on team search uses the LLM."""
|
||||
srv, fake_db, llm_calls = _build_server_with(
|
||||
monkeypatch, tmp_path, chat_completion=_llm_payload
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_teams",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"matching_method": "llm_fulltext",
|
||||
},
|
||||
)
|
||||
|
||||
# LLM matcher was actually invoked for the single candidate team.
|
||||
assert llm_calls["count"] >= 1, "LLM matcher path was not exercised"
|
||||
|
||||
# META tags the LLM mode and the team search type.
|
||||
meta = _extract_meta(res)
|
||||
assert meta.get("search_type") == "team_search"
|
||||
assert meta.get("matching_method") == "llm_fulltext"
|
||||
|
||||
# LLM-mode + team column layout (spec: 7.8 / 8.4).
|
||||
for header in (
|
||||
"Team Name",
|
||||
"Schwerpunkt",
|
||||
"Top-Kompetenzen",
|
||||
"Category",
|
||||
"Begründung",
|
||||
):
|
||||
assert header in res, f"missing team-search llm header {header!r}"
|
||||
|
||||
# Score columns must be absent in LLM mode.
|
||||
assert "| Role Score |" not in res
|
||||
assert "| Competence Score |" not in res
|
||||
assert "| Overall Score |" not in res
|
||||
|
||||
# Team loader was hit; capacity loader was not.
|
||||
assert fake_db.team_calls == 1
|
||||
assert fake_db.capacity_calls == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_matching_teams_bogus_method_returns_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Invalid ``matching_method`` on team search → error, no DB/LLM."""
|
||||
srv, fake_db, llm_calls = _build_server_with(
|
||||
monkeypatch, tmp_path, chat_completion=_fail_if_called
|
||||
)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_teams",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"matching_method": "bogus",
|
||||
},
|
||||
)
|
||||
|
||||
lowered = res.lower()
|
||||
assert "error" in lowered or "invalid" in lowered, res
|
||||
assert "score" in res
|
||||
assert "llm_fulltext" in res
|
||||
|
||||
# No side effects: the team loader and the LLM are untouched.
|
||||
assert fake_db.team_calls == 0, (
|
||||
"Team loader was hit despite invalid matching_method"
|
||||
)
|
||||
assert llm_calls["count"] == 0, (
|
||||
"LLM was called despite invalid matching_method"
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
# Feature: llm-fulltext-matching, Property 11: META enthält das verwendete Verfahren
|
||||
"""Property test verifying that META-JSON includes ``matching_method``.
|
||||
|
||||
The MCP server emits a ``META=...`` line in the response of
|
||||
``find_matching_capacities`` and ``find_matching_tasks`` containing a
|
||||
JSON object with ``search_id``, ``filter_id``, ``default_category`` and
|
||||
``matching_method``. For any successful tool call, the value of
|
||||
``matching_method`` in this JSON must be exactly the method that was
|
||||
used (``"score"`` or ``"llm_fulltext"``).
|
||||
|
||||
Hypothesis would only enumerate two distinct values here, so the
|
||||
property is encoded as a parametrized pytest test which still rebuilds
|
||||
the server per case for full isolation. The header retains the
|
||||
"Property 11" tag for traceability with the spec.
|
||||
|
||||
**Validates: Requirements 1.6, 9.5**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
|
||||
_CONFIG_TEMPLATE = """
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
|
||||
[azure_openai]
|
||||
endpoint='https://example.openai.azure.com'
|
||||
api_version='2024-02-15-preview'
|
||||
chat_deployment='gpt-4'
|
||||
""".strip()
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal fake DB that returns one matching capacity."""
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["X"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["A"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_capacities_with_competences(self):
|
||||
return [
|
||||
Capacity(
|
||||
id=1,
|
||||
owner_name="A",
|
||||
role_name="X",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 1, 1),
|
||||
end_date=date(2025, 12, 31),
|
||||
competences=["A"],
|
||||
)
|
||||
]
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
# LLM-fulltext path needs the batch description/cert/ref methods.
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"), str
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
def _extract_meta(output: str) -> dict[str, Any]:
|
||||
for line in output.splitlines():
|
||||
if line.startswith("META="):
|
||||
return json.loads(line[len("META=") :])
|
||||
raise AssertionError(f"no META= line in tool output:\n{output}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("method", ["score", "llm_fulltext"])
|
||||
async def test_meta_includes_matching_method(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
_env,
|
||||
method: str,
|
||||
) -> None:
|
||||
"""Property 11: META carries the matching_method actually used."""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_mod, "create_db_client", lambda *_a, **_k: _FakeDB()
|
||||
)
|
||||
|
||||
# A single payload that satisfies both consumers:
|
||||
# - score path reads ``similarity``
|
||||
# - llm_fulltext path reads ``category``/``rationale``
|
||||
monkeypatch.setattr(
|
||||
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
|
||||
AsyncMock(
|
||||
return_value=(
|
||||
'{"similarity": 1.0, "category": "Top", "rationale": "ok"}'
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"matching_method": method,
|
||||
},
|
||||
)
|
||||
|
||||
meta = _extract_meta(res)
|
||||
assert meta.get("matching_method") == method, (
|
||||
f"expected matching_method={method!r}, got META={meta!r}"
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from teamlandkarte_mcp.cache.search_cache import SearchCache
|
||||
|
||||
|
||||
def test_multi_step_filter_ids_increment_under_same_search_id() -> None:
|
||||
cache = SearchCache(ttl_minutes=60, max_size=10)
|
||||
search_id = cache.store_search(
|
||||
task_id=None,
|
||||
requirements={"role_name": "X", "competences": ["A"]},
|
||||
results={"Top": [1, 2, 3], "Good": [], "Partial": [], "Low": []},
|
||||
)
|
||||
|
||||
f1 = cache.add_filter(
|
||||
search_id,
|
||||
filtered_results={"Top": [1], "Good": [], "Partial": [], "Low": []},
|
||||
filter_meta={"role_filter": "x"},
|
||||
)
|
||||
f2 = cache.add_filter(
|
||||
search_id,
|
||||
filtered_results={"Top": [1], "Good": [], "Partial": [], "Low": []},
|
||||
filter_meta={"competence_filter": ["A"]},
|
||||
)
|
||||
|
||||
assert f1 == "filter-1"
|
||||
assert f2 == "filter-2"
|
||||
|
||||
entry = cache.get(search_id)
|
||||
assert entry is not None
|
||||
assert set(entry.filters.keys()) == {"filter-1", "filter-2"}
|
||||
@@ -0,0 +1,139 @@
|
||||
# Feature: llm-fulltext-matching, Property 3: Kategorienormalisierung bildet auf erlaubte Menge ab
|
||||
"""Property-based tests for ``normalize_category``.
|
||||
|
||||
These tests cover the behaviour of
|
||||
:func:`teamlandkarte_mcp.matching.llm_fulltext_matcher.normalize_category`
|
||||
across a wide range of inputs. They verify three core properties:
|
||||
|
||||
1. **Output set membership for arbitrary text**: for any string input the
|
||||
returned ``category`` is always one of the canonical categories
|
||||
``("Top", "Good", "Partial", "Low", "Irrelevant")`` and ``is_valid`` is
|
||||
a ``bool`` that correctly reflects whether the trimmed/lower-cased
|
||||
input matches a known alias.
|
||||
2. **Alias acceptance with random casing and whitespace**: any canonical
|
||||
category transformed by random casing of its characters together with
|
||||
leading/trailing whitespace must round-trip to the canonical spelling
|
||||
with ``is_valid == True``.
|
||||
3. **Non-string inputs are invalid**: ``None``, integers, floats, lists
|
||||
and dictionaries always normalize to ``("Irrelevant", False)``.
|
||||
|
||||
**Validates: Requirements 5.3, 5.6, 6.3, 6.6**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.llm_fulltext_matcher import normalize_category
|
||||
|
||||
|
||||
_ALLOWED = ("Top", "Good", "Partial", "Low", "Irrelevant")
|
||||
_ALLOWED_LOWER = tuple(c.lower() for c in _ALLOWED)
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(value=st.text())
|
||||
def test_normalize_category_output_in_allowed_set_for_arbitrary_text(
|
||||
value: str,
|
||||
) -> None:
|
||||
"""Property 3 (arbitrary text): output is always in the allowed set.
|
||||
|
||||
For any string ``value`` the returned ``category`` must be one of the
|
||||
canonical categories and ``is_valid`` must be ``True`` exactly when
|
||||
``value.strip().lower()`` matches one of the canonical aliases.
|
||||
"""
|
||||
category, is_valid = normalize_category(value)
|
||||
|
||||
# The category is always in the canonical set.
|
||||
assert category in _ALLOWED, (
|
||||
f"category {category!r} not in allowed set for input {value!r}"
|
||||
)
|
||||
# `is_valid` is a real bool, not a truthy proxy.
|
||||
assert isinstance(is_valid, bool)
|
||||
|
||||
normalized = value.strip().lower()
|
||||
if normalized in _ALLOWED_LOWER:
|
||||
# Known alias: must be accepted and mapped to canonical spelling.
|
||||
assert is_valid is True, (
|
||||
f"expected is_valid=True for alias {value!r}, got False"
|
||||
)
|
||||
# Canonical spelling capitalises the first letter.
|
||||
expected = normalized.capitalize()
|
||||
assert category == expected, (
|
||||
f"expected canonical spelling {expected!r} for alias {value!r}, "
|
||||
f"got {category!r}"
|
||||
)
|
||||
else:
|
||||
# Anything else: invalid input falls back to Irrelevant.
|
||||
assert is_valid is False, (
|
||||
f"expected is_valid=False for non-alias {value!r}, got True"
|
||||
)
|
||||
assert category == "Irrelevant"
|
||||
|
||||
|
||||
@st.composite
|
||||
def _aliased_category(draw: st.DrawFn) -> tuple[str, str]:
|
||||
"""Build a (canonical, transformed) pair with random casing + whitespace.
|
||||
|
||||
The transformed string keeps the canonical letters in order but mixes
|
||||
upper- and lower-case per character and adds short leading/trailing
|
||||
whitespace. ``normalize_category`` must still recover the canonical
|
||||
spelling.
|
||||
"""
|
||||
canonical = draw(st.sampled_from(_ALLOWED))
|
||||
chars: list[str] = []
|
||||
for ch in canonical:
|
||||
upper = draw(st.booleans())
|
||||
chars.append(ch.upper() if upper else ch.lower())
|
||||
body = "".join(chars)
|
||||
leading = draw(st.text(alphabet=" \t", max_size=4))
|
||||
trailing = draw(st.text(alphabet=" \t", max_size=4))
|
||||
return canonical, leading + body + trailing
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(pair=_aliased_category())
|
||||
def test_normalize_category_recognizes_aliases_with_random_casing_and_whitespace(
|
||||
pair: tuple[str, str],
|
||||
) -> None:
|
||||
"""Property 3 (aliases): canonical categories survive casing/whitespace.
|
||||
|
||||
For any canonical category transformed by random casing of its
|
||||
characters and surrounded by tab/space whitespace, the function must
|
||||
return the canonical spelling together with ``is_valid == True``.
|
||||
"""
|
||||
canonical, raw = pair
|
||||
category, is_valid = normalize_category(raw)
|
||||
assert is_valid is True, (
|
||||
f"expected is_valid=True for transformed alias {raw!r}, got False"
|
||||
)
|
||||
assert category == canonical, (
|
||||
f"expected canonical spelling {canonical!r} for {raw!r}, got {category!r}"
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(
|
||||
value=st.one_of(
|
||||
st.none(),
|
||||
st.integers(),
|
||||
st.lists(st.text(), max_size=3),
|
||||
st.dictionaries(st.text(), st.text(), max_size=3),
|
||||
st.floats(allow_nan=False),
|
||||
)
|
||||
)
|
||||
def test_normalize_category_non_string_inputs_are_invalid(value: object) -> None:
|
||||
"""Property 3 (non-string): non-string inputs always return Irrelevant.
|
||||
|
||||
For any non-string input (``None``, int, float, list, dict)
|
||||
``normalize_category`` must return exactly ``("Irrelevant", False)``.
|
||||
"""
|
||||
category, is_valid = normalize_category(value)
|
||||
assert category == "Irrelevant", (
|
||||
f"expected 'Irrelevant' for non-string input {value!r}, got {category!r}"
|
||||
)
|
||||
assert is_valid is False, (
|
||||
f"expected is_valid=False for non-string input {value!r}, got True"
|
||||
)
|
||||
assert isinstance(is_valid, bool)
|
||||
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.cache.search_cache import SearchCache
|
||||
|
||||
|
||||
def test_search_cache_pagination_slice() -> None:
|
||||
"""Sanity-check pagination math used by get_results_by_category.
|
||||
|
||||
This test does not execute the MCP tool. It validates the expected slice
|
||||
behavior that the tool should apply: 1-based pages and stable page sizes.
|
||||
"""
|
||||
|
||||
cache = SearchCache(ttl_minutes=60, max_size=10)
|
||||
search_id = cache.store_search(
|
||||
task_id=None,
|
||||
requirements={"role_name": "X", "competences": ["A"]},
|
||||
results={"Top": list(range(1, 51))},
|
||||
)
|
||||
|
||||
entry = cache.get(search_id)
|
||||
assert entry is not None
|
||||
|
||||
items = entry.results["Top"]
|
||||
|
||||
page_size = 20
|
||||
page1 = items[(1 - 1) * page_size : 1 * page_size]
|
||||
page2 = items[(2 - 1) * page_size : 2 * page_size]
|
||||
page3 = items[(3 - 1) * page_size : 3 * page_size]
|
||||
|
||||
assert page1 == list(range(1, 21))
|
||||
assert page2 == list(range(21, 41))
|
||||
assert page3 == list(range(41, 51))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"page,page_size,expected",
|
||||
[
|
||||
(1, 10, list(range(1, 11))),
|
||||
(2, 10, list(range(11, 21))),
|
||||
(3, 10, list(range(21, 31))),
|
||||
],
|
||||
)
|
||||
def test_pagination_param_matrix(
|
||||
page: int, page_size: int, expected: list[int]
|
||||
) -> None:
|
||||
items = list(range(1, 31))
|
||||
got = items[(page - 1) * page_size : page * page_size]
|
||||
assert got == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Team-search pagination coverage (Profile_Type="team")
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Pagination is search-type-agnostic: ``get_results_by_category``
|
||||
# slices ``entry.results["by_category"][cat]`` regardless of whether
|
||||
# the entry is a ``capacity_search`` or a ``team_search``. The tests
|
||||
# below exercise the same slicing logic for ``team_search`` payloads
|
||||
# in both ``matching_method`` flavours so the routing/pagination
|
||||
# surface is covered for both ``Profile_Type`` values.
|
||||
#
|
||||
# _Requirements: 8.1, 8.4, 12.7_
|
||||
|
||||
|
||||
def _team_item(idx: int, *, matching_method: str) -> dict:
|
||||
"""Build a minimal cached team item for pagination assertions."""
|
||||
base = {
|
||||
"team_id": f"t{idx}",
|
||||
"ouid": f"ou-{idx}",
|
||||
"team_name": f"Team {idx}",
|
||||
"focus_name": "Backend Developer",
|
||||
"about_us": "",
|
||||
"offerings": "",
|
||||
"interests": "",
|
||||
"competences": [],
|
||||
"references": [],
|
||||
"category": "Top",
|
||||
}
|
||||
if matching_method == "score":
|
||||
return {
|
||||
**base,
|
||||
"competence_score": 1.0,
|
||||
"role_score": 1.0,
|
||||
"overall_score": 1.0,
|
||||
}
|
||||
return {**base, "rationale": f"rationale {idx}"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("matching_method", ["score", "llm_fulltext"])
|
||||
def test_team_search_pagination_slice(matching_method: str) -> None:
|
||||
"""Slicing of a ``team_search`` ``by_category`` bucket is identical
|
||||
to the capacity-search slicing math (1-based pages, stable size).
|
||||
"""
|
||||
cache = SearchCache(ttl_minutes=60, max_size=10)
|
||||
items = [_team_item(i, matching_method=matching_method)
|
||||
for i in range(1, 51)]
|
||||
|
||||
payload = {
|
||||
"search_type": "team_search",
|
||||
"matching_method": matching_method,
|
||||
"reference": {
|
||||
"availability_date_start": None,
|
||||
"availability_date_end": None,
|
||||
},
|
||||
"summary": {"Top": len(items)},
|
||||
"by_category": {
|
||||
"Top": items,
|
||||
"Good": [],
|
||||
"Partial": [],
|
||||
"Low": [],
|
||||
"Irrelevant": [],
|
||||
},
|
||||
}
|
||||
if matching_method == "llm_fulltext":
|
||||
payload["errors"] = []
|
||||
|
||||
search_id = cache.store_search(
|
||||
task_id=None,
|
||||
requirements={"role_name": "Backend Developer",
|
||||
"competences": ["python"]},
|
||||
results=payload,
|
||||
)
|
||||
|
||||
entry = cache.get(search_id)
|
||||
assert entry is not None
|
||||
assert entry.results["search_type"] == "team_search"
|
||||
assert entry.results["matching_method"] == matching_method
|
||||
|
||||
bucket = entry.results["by_category"]["Top"]
|
||||
|
||||
page_size = 20
|
||||
page1 = bucket[(1 - 1) * page_size : 1 * page_size]
|
||||
page2 = bucket[(2 - 1) * page_size : 2 * page_size]
|
||||
page3 = bucket[(3 - 1) * page_size : 3 * page_size]
|
||||
|
||||
assert [it["team_id"] for it in page1] == [f"t{i}" for i in range(1, 21)]
|
||||
assert [it["team_id"] for it in page2] == [f"t{i}" for i in range(21, 41)]
|
||||
assert [it["team_id"] for it in page3] == [f"t{i}" for i in range(41, 51)]
|
||||
# Page 3 has the partial-tail (10 items) like the capacity case.
|
||||
assert len(page3) == 10
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# Feature: remove-embedding-competence-similarity, Property 4: Graceful Degradation bei LLM-Fehler
|
||||
"""Property-based tests for graceful degradation on LLM failure."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
|
||||
from teamlandkarte_mcp.matching.vocabulary import VocabularyCache
|
||||
|
||||
|
||||
# Strategy: role name strings (ASCII letters only, 1-50 chars,
|
||||
# no "(unknown)")
|
||||
_role_str = st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L",),
|
||||
max_codepoint=127,
|
||||
),
|
||||
min_size=1,
|
||||
max_size=50,
|
||||
).filter(lambda s: s.strip().lower() != "(unknown)")
|
||||
|
||||
# Strategy: non-empty task text (ASCII letters + spaces, 1-100 chars)
|
||||
_task_text = st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "Zs"),
|
||||
max_codepoint=127,
|
||||
),
|
||||
min_size=1,
|
||||
max_size=100,
|
||||
).filter(lambda s: s.strip())
|
||||
|
||||
# Strategy: non-empty role names for DB list (ASCII letters, 3-30 chars)
|
||||
_role_name = st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L",),
|
||||
max_codepoint=127,
|
||||
),
|
||||
min_size=3,
|
||||
max_size=30,
|
||||
)
|
||||
|
||||
# Strategy: list of unique role names (1-10 roles)
|
||||
_role_list = st.lists(_role_name, min_size=1, max_size=10, unique=True)
|
||||
|
||||
# Strategy: exception class to raise
|
||||
_exception_class = st.sampled_from([TimeoutError, RuntimeError, ValueError])
|
||||
|
||||
|
||||
class _FailingClient:
|
||||
"""Stub AzureOpenAIClient that raises a configured exception."""
|
||||
|
||||
def __init__(self, exc_class: type) -> None:
|
||||
self._exc_class = exc_class
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
if self._exc_class is ValueError:
|
||||
raise ValueError("simulated LLM failure")
|
||||
elif self._exc_class is json.JSONDecodeError:
|
||||
raise json.JSONDecodeError("simulated", "", 0)
|
||||
else:
|
||||
raise self._exc_class("simulated LLM failure")
|
||||
|
||||
|
||||
class _JSONDecodeErrorClient:
|
||||
"""Stub that always raises json.JSONDecodeError."""
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
raise json.JSONDecodeError("simulated parse error", "", 0)
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Stub DBClient that returns a configurable role list."""
|
||||
|
||||
def __init__(self, roles: list[str]) -> None:
|
||||
self._roles = roles
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return self._roles
|
||||
|
||||
|
||||
# **Validates: Requirements 5.6**
|
||||
@settings(max_examples=100)
|
||||
@given(role_a=_role_str, role_b=_role_str, exc_class=_exception_class)
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_role_similarity_returns_zero_on_exception(
|
||||
role_a: str, role_b: str, exc_class: type
|
||||
) -> None:
|
||||
"""Property 4a: compute_role_similarity returns 0.0 on any LLM exception.
|
||||
|
||||
For any two valid role names and any exception type raised by the LLM,
|
||||
compute_role_similarity shall return 0.0 without propagating the
|
||||
exception to the caller.
|
||||
"""
|
||||
# Skip identical roles (they short-circuit to 1.0 without LLM call)
|
||||
if role_a.strip().lower() == role_b.strip().lower():
|
||||
return
|
||||
|
||||
client = _FailingClient(exc_class=exc_class)
|
||||
engine = SimilarityEngine(client=client) # type: ignore[arg-type]
|
||||
|
||||
result = await engine.compute_role_similarity(role_a, role_b)
|
||||
|
||||
assert result == 0.0, (
|
||||
f"Expected 0.0 on {exc_class.__name__}, got {result} "
|
||||
f"(roles: {role_a!r}, {role_b!r})"
|
||||
)
|
||||
|
||||
|
||||
# **Validates: Requirements 5.6**
|
||||
@settings(max_examples=100)
|
||||
@given(role_a=_role_str, role_b=_role_str)
|
||||
@pytest.mark.asyncio
|
||||
async def test_compute_role_similarity_returns_zero_on_json_decode_error(
|
||||
role_a: str, role_b: str,
|
||||
) -> None:
|
||||
"""Property 4b: compute_role_similarity returns 0.0 on JSONDecodeError.
|
||||
|
||||
json.JSONDecodeError requires positional args, so it is tested
|
||||
separately with a dedicated stub.
|
||||
"""
|
||||
# Skip identical roles (they short-circuit to 1.0 without LLM call)
|
||||
if role_a.strip().lower() == role_b.strip().lower():
|
||||
return
|
||||
|
||||
client = _JSONDecodeErrorClient()
|
||||
engine = SimilarityEngine(client=client) # type: ignore[arg-type]
|
||||
|
||||
result = await engine.compute_role_similarity(role_a, role_b)
|
||||
|
||||
assert result == 0.0, (
|
||||
f"Expected 0.0 on JSONDecodeError, got {result} "
|
||||
f"(roles: {role_a!r}, {role_b!r})"
|
||||
)
|
||||
|
||||
|
||||
# **Validates: Requirements 3.5**
|
||||
@settings(max_examples=100)
|
||||
@given(task_text=_task_text, roles=_role_list, exc_class=_exception_class)
|
||||
@pytest.mark.asyncio
|
||||
async def test_infer_primary_role_returns_none_on_exception(
|
||||
task_text: str, roles: list[str], exc_class: type
|
||||
) -> None:
|
||||
"""Property 4c: infer_primary_role returns None on any LLM exception.
|
||||
|
||||
For any valid task text and role list, if the LLM raises any exception,
|
||||
infer_primary_role shall return None without propagating the exception
|
||||
to the caller.
|
||||
"""
|
||||
client = _FailingClient(exc_class=exc_class)
|
||||
db = _FakeDB(roles=roles)
|
||||
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
|
||||
|
||||
result = await cache.infer_primary_role(task_text=task_text)
|
||||
|
||||
assert result is None, (
|
||||
f"Expected None on {exc_class.__name__}, got {result} "
|
||||
f"(task_text: {task_text!r})"
|
||||
)
|
||||
|
||||
|
||||
# **Validates: Requirements 3.5**
|
||||
@settings(max_examples=100)
|
||||
@given(task_text=_task_text, roles=_role_list)
|
||||
@pytest.mark.asyncio
|
||||
async def test_infer_primary_role_returns_none_on_json_decode_error(
|
||||
task_text: str, roles: list[str],
|
||||
) -> None:
|
||||
"""Property 4d: infer_primary_role returns None on JSONDecodeError.
|
||||
|
||||
json.JSONDecodeError requires positional args, so it is tested
|
||||
separately with a dedicated stub.
|
||||
"""
|
||||
client = _JSONDecodeErrorClient()
|
||||
db = _FakeDB(roles=roles)
|
||||
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
|
||||
|
||||
result = await cache.infer_primary_role(task_text=task_text)
|
||||
|
||||
assert result is None, (
|
||||
f"Expected None on JSONDecodeError, got {result} "
|
||||
f"(task_text: {task_text!r})"
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
# Feature: remove-embedding-competence-similarity, Property 3: infer_primary_role Ausgabe-Validität
|
||||
|
||||
"""Property-based tests for infer_primary_role output validity."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.vocabulary import VocabularyCache
|
||||
|
||||
|
||||
# Strategy: non-empty task text (ASCII letters + spaces, 1-100 chars)
|
||||
_task_text = st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L", "Zs"),
|
||||
max_codepoint=127,
|
||||
),
|
||||
min_size=1,
|
||||
max_size=100,
|
||||
).filter(lambda s: s.strip())
|
||||
|
||||
# Strategy: non-empty role names (ASCII letters, 3-30 chars)
|
||||
_role_name = st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L",),
|
||||
max_codepoint=127,
|
||||
),
|
||||
min_size=3,
|
||||
max_size=30,
|
||||
)
|
||||
|
||||
# Strategy: list of unique role names (1-10 roles)
|
||||
_role_list = st.lists(_role_name, min_size=1, max_size=10, unique=True)
|
||||
|
||||
# Strategy: confidence score returned by the LLM (includes out-of-range
|
||||
# values to verify clamping)
|
||||
_llm_confidence = st.floats(min_value=-1.0, max_value=2.0)
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Stub DBClient that returns a configurable role list."""
|
||||
|
||||
def __init__(self, roles: list[str]) -> None:
|
||||
self._roles = roles
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return self._roles
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""Stub AzureOpenAIClient that returns a chosen role and confidence."""
|
||||
|
||||
def __init__(self, role: str, confidence: float) -> None:
|
||||
self._role = role
|
||||
self._confidence = confidence
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
return json.dumps({"role": self._role, "confidence": self._confidence})
|
||||
|
||||
|
||||
# **Validates: Requirements 3.1, 3.4**
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
task_text=_task_text,
|
||||
roles=_role_list,
|
||||
confidence=_llm_confidence,
|
||||
role_index=st.data(),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_infer_primary_role_output_in_role_list(
|
||||
task_text: str,
|
||||
roles: list[str],
|
||||
confidence: float,
|
||||
role_index: st.DataObject,
|
||||
) -> None:
|
||||
"""Property 3a: If infer_primary_role returns non-None, the role is in the DB list
|
||||
and confidence is in [0.0, 1.0].
|
||||
|
||||
For any non-empty task text and non-empty role list, if the LLM returns
|
||||
a valid role from the list, the output must satisfy:
|
||||
- role_name is an element of the database role list
|
||||
- confidence is a float in [0.0, 1.0]
|
||||
"""
|
||||
# Pick a random role from the generated list for the LLM to "return"
|
||||
idx = role_index.draw(st.integers(min_value=0, max_value=len(roles) - 1))
|
||||
chosen_role = roles[idx]
|
||||
|
||||
db = _FakeDB(roles=roles)
|
||||
client = _FakeClient(role=chosen_role, confidence=confidence)
|
||||
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
|
||||
|
||||
result = await cache.infer_primary_role(task_text=task_text)
|
||||
|
||||
assert result is not None, (
|
||||
f"Expected non-None result for valid role {chosen_role!r} in list"
|
||||
)
|
||||
role_name, conf = result
|
||||
assert role_name in roles, (
|
||||
f"Returned role {role_name!r} not in DB role list {roles!r}"
|
||||
)
|
||||
assert isinstance(conf, float), (
|
||||
f"Expected float confidence, got {type(conf)}"
|
||||
)
|
||||
assert 0.0 <= conf <= 1.0, (
|
||||
f"Confidence {conf} out of range [0.0, 1.0] "
|
||||
f"(llm_confidence: {confidence})"
|
||||
)
|
||||
|
||||
|
||||
# **Validates: Requirement 3.4 (role must be in DB list)**
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
task_text=_task_text,
|
||||
roles=_role_list,
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_infer_primary_role_rejects_unknown_role(
|
||||
task_text: str,
|
||||
roles: list[str],
|
||||
) -> None:
|
||||
"""Property 3b: If the LLM returns a role NOT in the DB list, result is None.
|
||||
|
||||
The VocabularyCache must validate that the returned role exists in the
|
||||
database role list. If it doesn't, the function returns None.
|
||||
"""
|
||||
# Return a role that is guaranteed not to be in the list
|
||||
fake_role = "ZZZZZ_NOT_A_REAL_ROLE_99999"
|
||||
assert fake_role not in roles
|
||||
|
||||
db = _FakeDB(roles=roles)
|
||||
client = _FakeClient(role=fake_role, confidence=0.9)
|
||||
cache = VocabularyCache(db=db, client=client) # type: ignore[arg-type]
|
||||
|
||||
result = await cache.infer_primary_role(task_text=task_text)
|
||||
|
||||
assert result is None, (
|
||||
f"Expected None when LLM returns unknown role {fake_role!r}, "
|
||||
f"got {result!r}"
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
# Feature: remove-embedding-competence-similarity, Property 2: Matcher baut BM25-Index bedingungslos
|
||||
"""Property-based tests for unconditional BM25 index building in Matcher.
|
||||
|
||||
For any non-empty list of filtered candidates with competences, the Matcher
|
||||
shall always build a global BM25 index and use it for competence scoring,
|
||||
producing results where candidates with no token overlap receive score 0.0.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.config import MatchingConfig
|
||||
from teamlandkarte_mcp.matching.matcher import Matcher
|
||||
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
|
||||
from teamlandkarte_mcp.models import Capacity, Requirements
|
||||
|
||||
|
||||
class _DummyClient:
|
||||
"""Minimal stub for AzureOpenAIClient."""
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
return '{"similarity": 0.5}'
|
||||
|
||||
|
||||
def _make_matcher() -> Matcher:
|
||||
engine = SimilarityEngine(client=_DummyClient()) # type: ignore[arg-type]
|
||||
cfg = MatchingConfig()
|
||||
return Matcher(similarity=engine, cfg=cfg)
|
||||
|
||||
|
||||
# Strategy: ASCII-only letters to avoid Unicode tokenization edge cases.
|
||||
# The BM25 tokenizer lowercases then splits on [\W_]+. Some Unicode letters
|
||||
# produce non-word characters when lowercased (e.g. İ -> i + combining dot),
|
||||
# which would create unexpected token boundaries and shared tokens.
|
||||
_ascii_letters = st.text(
|
||||
alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz"),
|
||||
min_size=3,
|
||||
max_size=15,
|
||||
)
|
||||
|
||||
_competence_list = st.lists(_ascii_letters, min_size=1, max_size=4)
|
||||
|
||||
|
||||
@st.composite
|
||||
def _capacity_strategy(draw: st.DrawFn) -> Capacity:
|
||||
"""Generate a Capacity with competences prefixed by 'cand'."""
|
||||
comps = draw(_competence_list)
|
||||
prefixed = [f"cand{c}" for c in comps]
|
||||
return Capacity(
|
||||
id=draw(st.integers(min_value=1, max_value=10000)),
|
||||
owner_name="TestOwner",
|
||||
role_name="TestRole",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 1, 1),
|
||||
end_date=date(2025, 12, 31),
|
||||
competences=prefixed,
|
||||
)
|
||||
|
||||
|
||||
# **Validates: Requirements 2.6**
|
||||
@settings(max_examples=100)
|
||||
@given(
|
||||
capacities=st.lists(_capacity_strategy(), min_size=1, max_size=5),
|
||||
required_comps=_competence_list,
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_matcher_builds_bm25_index_unconditionally(
|
||||
capacities: list[Capacity],
|
||||
required_comps: list[str],
|
||||
) -> None:
|
||||
"""Property 2: Matcher builds BM25 index unconditionally for all filtered
|
||||
candidates.
|
||||
|
||||
For any non-empty list of filtered candidates, the Matcher always builds
|
||||
a global BM25 index. When required competences have no token overlap with
|
||||
any candidate competence, the competence score must be 0.0.
|
||||
"""
|
||||
# Prefix required competences with "req" to guarantee disjoint tokens.
|
||||
# Since all generated strings are pure ASCII lowercase letters, and
|
||||
# "req" != "cand", the tokens "req<x>" and "cand<y>" can never overlap.
|
||||
disjoint_required = [f"req{c}" for c in required_comps]
|
||||
|
||||
requirements = Requirements(
|
||||
role_name="TestRole",
|
||||
competences=disjoint_required,
|
||||
date_start=date(2025, 1, 1),
|
||||
date_end=date(2025, 12, 31),
|
||||
)
|
||||
|
||||
matcher = _make_matcher()
|
||||
result = await matcher.match(capacities, requirements)
|
||||
|
||||
# All candidates should be in the result (dates overlap)
|
||||
assert len(result.scored) == len(capacities)
|
||||
|
||||
# Since required competences (req*) have no token overlap with
|
||||
# candidate competences (cand*), all competence scores must be 0.0
|
||||
for scored in result.scored:
|
||||
assert scored.competence_score == 0.0, (
|
||||
f"Expected competence_score 0.0 for capacity {scored.capacity.id}, "
|
||||
f"got {scored.competence_score}. "
|
||||
f"Required: {disjoint_required}, "
|
||||
f"Candidate: {scored.capacity.competences}"
|
||||
)
|
||||
assert set(scored.missing_competences) == set(disjoint_required), (
|
||||
f"Expected all required competences to be missing, "
|
||||
f"got missing={scored.missing_competences}"
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def _capacities_with_unique_token(draw: st.DrawFn) -> list[Capacity]:
|
||||
"""Generate 3 capacities where the first has a unique competence.
|
||||
|
||||
BM25 IDF = log((N - df + 0.5) / (df + 0.5)). With N=2 and df=1,
|
||||
IDF = log(1) = 0. We need N >= 3 with df=1 for IDF > 0.
|
||||
|
||||
We generate 3 capacities with 3 distinct competences in the global
|
||||
corpus. The unique token appears only in cap1 (df=1, N=3 → IDF > 0).
|
||||
"""
|
||||
base = draw(_ascii_letters)
|
||||
other = f"other{draw(_ascii_letters)}"
|
||||
unique_token = f"zzuniq{draw(_ascii_letters)}"
|
||||
cap1 = Capacity(
|
||||
id=1,
|
||||
owner_name="TestOwner",
|
||||
role_name="TestRole",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 1, 1),
|
||||
end_date=date(2025, 12, 31),
|
||||
competences=[base, unique_token],
|
||||
)
|
||||
cap2 = Capacity(
|
||||
id=2,
|
||||
owner_name="TestOwner",
|
||||
role_name="TestRole",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 1, 1),
|
||||
end_date=date(2025, 12, 31),
|
||||
competences=[base],
|
||||
)
|
||||
cap3 = Capacity(
|
||||
id=3,
|
||||
owner_name="TestOwner",
|
||||
role_name="TestRole",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 1, 1),
|
||||
end_date=date(2025, 12, 31),
|
||||
competences=[other],
|
||||
)
|
||||
return [cap1, cap2, cap3]
|
||||
|
||||
|
||||
@settings(max_examples=50)
|
||||
@given(capacities=_capacities_with_unique_token())
|
||||
@pytest.mark.asyncio
|
||||
async def test_matcher_scores_nonzero_for_token_overlap(
|
||||
capacities: list[Capacity],
|
||||
) -> None:
|
||||
"""Property 2 (supplementary): When the BM25 index is built and a required
|
||||
competence shares tokens with a candidate, the score is non-zero.
|
||||
|
||||
This confirms the index is actually built and used for scoring (not
|
||||
skipped). BM25 IDF is positive when a term appears in fewer than half
|
||||
the corpus documents. With 3+ corpus docs and df=1, IDF > 0.
|
||||
"""
|
||||
unique_comp = capacities[0].competences[1]
|
||||
|
||||
requirements = Requirements(
|
||||
role_name="TestRole",
|
||||
competences=[unique_comp],
|
||||
date_start=date(2025, 1, 1),
|
||||
date_end=date(2025, 12, 31),
|
||||
)
|
||||
|
||||
matcher = _make_matcher()
|
||||
result = await matcher.match(capacities, requirements)
|
||||
|
||||
assert len(result.scored) == len(capacities)
|
||||
|
||||
# Cap1 must have non-zero competence score (it has the unique token)
|
||||
first_cap_scored = next(
|
||||
s for s in result.scored if s.capacity.id == capacities[0].id
|
||||
)
|
||||
assert first_cap_scored.competence_score > 0.0, (
|
||||
f"Expected non-zero competence score for unique token match, "
|
||||
f"got {first_cap_scored.competence_score}. "
|
||||
f"Required: [{unique_comp}], Candidate: {capacities[0].competences}"
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
# Feature: remove-embedding-competence-similarity, Property 6: Rollen-Similarity-Cache ist symmetrisch und idempotent
|
||||
"""Property-based tests for role similarity cache symmetry and idempotency."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
|
||||
|
||||
|
||||
# Strategy: role name strings (ASCII letters only, 1-50 chars,
|
||||
# no "(unknown)")
|
||||
_role_str = st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L",),
|
||||
max_codepoint=127,
|
||||
),
|
||||
min_size=1,
|
||||
max_size=50,
|
||||
).filter(lambda s: s.strip().lower() != "(unknown)")
|
||||
|
||||
|
||||
class _DummyClient:
|
||||
"""Stub that returns a fixed similarity score and tracks call count."""
|
||||
|
||||
def __init__(self, score: float = 0.72) -> None:
|
||||
self._score = score
|
||||
self.call_count = 0
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
self.call_count += 1
|
||||
return json.dumps({"similarity": self._score})
|
||||
|
||||
|
||||
# **Validates: Requirements 5.8**
|
||||
@settings(max_examples=100)
|
||||
@given(role_a=_role_str, role_b=_role_str)
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_similarity_cache_symmetry(
|
||||
role_a: str, role_b: str,
|
||||
) -> None:
|
||||
"""Property 6a: Cache is symmetric.
|
||||
|
||||
For any role pair (A, B) where A != B (case-insensitive),
|
||||
compute_role_similarity(A, B) == compute_role_similarity(B, A),
|
||||
and the second call shall not invoke the LLM (cache hit).
|
||||
"""
|
||||
# Skip identical roles (they short-circuit to 1.0 without LLM)
|
||||
if role_a.strip().lower() == role_b.strip().lower():
|
||||
return
|
||||
|
||||
client = _DummyClient(score=0.72)
|
||||
engine = SimilarityEngine(client=client) # type: ignore[arg-type]
|
||||
|
||||
# First call: A, B
|
||||
score_ab = await engine.compute_role_similarity(role_a, role_b)
|
||||
# Second call: B, A (should use cache)
|
||||
score_ba = await engine.compute_role_similarity(role_b, role_a)
|
||||
|
||||
# Symmetry: both calls return the same score
|
||||
assert score_ab == score_ba, (
|
||||
f"Symmetry violated: "
|
||||
f"sim({role_a!r}, {role_b!r})={score_ab} != "
|
||||
f"sim({role_b!r}, {role_a!r})={score_ba}"
|
||||
)
|
||||
|
||||
# Only one LLM call should have been made (second was a cache hit)
|
||||
assert client.call_count == 1, (
|
||||
f"Expected 1 LLM call but got {client.call_count} "
|
||||
f"(roles: {role_a!r}, {role_b!r})"
|
||||
)
|
||||
|
||||
|
||||
# **Validates: Requirements 5.8**
|
||||
@settings(max_examples=100)
|
||||
@given(role_a=_role_str, role_b=_role_str)
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_similarity_cache_idempotent(
|
||||
role_a: str, role_b: str,
|
||||
) -> None:
|
||||
"""Property 6b: Cache is idempotent.
|
||||
|
||||
Repeated calls with the same role pair always return the same
|
||||
cached value without additional LLM calls.
|
||||
"""
|
||||
# Skip identical roles (they short-circuit to 1.0 without LLM)
|
||||
if role_a.strip().lower() == role_b.strip().lower():
|
||||
return
|
||||
|
||||
client = _DummyClient(score=0.65)
|
||||
engine = SimilarityEngine(client=client) # type: ignore[arg-type]
|
||||
|
||||
# First call populates the cache
|
||||
score_1 = await engine.compute_role_similarity(role_a, role_b)
|
||||
assert client.call_count == 1
|
||||
|
||||
# Second call with same order: cache hit
|
||||
score_2 = await engine.compute_role_similarity(role_a, role_b)
|
||||
assert score_2 == score_1, (
|
||||
f"Idempotency violated: call 1={score_1}, call 2={score_2}"
|
||||
)
|
||||
assert client.call_count == 1, (
|
||||
f"Expected 1 LLM call after 2 same-order calls, "
|
||||
f"got {client.call_count}"
|
||||
)
|
||||
|
||||
# Third call with reversed order: also cache hit
|
||||
score_3 = await engine.compute_role_similarity(role_b, role_a)
|
||||
assert score_3 == score_1, (
|
||||
f"Idempotency violated on reverse: "
|
||||
f"call 1={score_1}, call 3={score_3}"
|
||||
)
|
||||
assert client.call_count == 1, (
|
||||
f"Expected 1 LLM call after 3 total calls, "
|
||||
f"got {client.call_count}"
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
# Feature: remove-embedding-competence-similarity, Property 5: compute_role_similarity Wertebereich
|
||||
"""Property-based tests for compute_role_similarity value range."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
|
||||
|
||||
|
||||
# Strategy: role name strings (ASCII letters only, 1-50 chars,
|
||||
# no "(unknown)")
|
||||
_role_str = st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("L",),
|
||||
whitelist_characters="",
|
||||
max_codepoint=127,
|
||||
),
|
||||
min_size=1,
|
||||
max_size=50,
|
||||
).filter(lambda s: s.strip().lower() != "(unknown)")
|
||||
|
||||
# Strategy: similarity score returned by the LLM (intentionally includes
|
||||
# out-of-range values to verify clamping behaviour)
|
||||
_llm_score = st.floats(min_value=-1.0, max_value=2.0)
|
||||
|
||||
|
||||
class _DummyClient:
|
||||
"""Stub that returns a configurable similarity score."""
|
||||
|
||||
def __init__(self, score: float = 0.5) -> None:
|
||||
self._score = score
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
return json.dumps({"similarity": self._score})
|
||||
|
||||
|
||||
# **Validates: Requirement 5.1 (value range [0.0, 1.0])**
|
||||
@settings(max_examples=100)
|
||||
@given(role_a=_role_str, role_b=_role_str, score=_llm_score)
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_similarity_always_in_unit_interval(
|
||||
role_a: str, role_b: str, score: float
|
||||
) -> None:
|
||||
"""Property 5a: compute_role_similarity always returns a value in [0.0, 1.0].
|
||||
|
||||
For any two non-empty, non-"(unknown)" role names, the returned
|
||||
similarity must lie in the closed interval [0.0, 1.0], regardless of
|
||||
what the underlying LLM returns (clamping).
|
||||
"""
|
||||
client = _DummyClient(score=score)
|
||||
engine = SimilarityEngine(client=client) # type: ignore[arg-type]
|
||||
|
||||
result = await engine.compute_role_similarity(role_a, role_b)
|
||||
|
||||
assert isinstance(result, float), (
|
||||
f"Expected float, got {type(result)}"
|
||||
)
|
||||
assert 0.0 <= result <= 1.0, (
|
||||
f"Score {result} out of range [0.0, 1.0] "
|
||||
f"(roles: {role_a!r}, {role_b!r}, llm_score: {score})"
|
||||
)
|
||||
|
||||
|
||||
# **Validates: Requirement 5.1 (identity → 1.0)**
|
||||
@settings(max_examples=100)
|
||||
@given(role=_role_str)
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_similarity_identity_returns_one(
|
||||
role: str,
|
||||
) -> None:
|
||||
"""Property 5b: identical role names (case-insensitive) return 1.0.
|
||||
|
||||
The engine short-circuits without calling the LLM when both role
|
||||
arguments normalize to the same string.
|
||||
"""
|
||||
|
||||
class _FailingClient:
|
||||
"""Client that fails if called (identity must short-circuit)."""
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
raise AssertionError(
|
||||
"LLM should not be called for identical roles"
|
||||
)
|
||||
|
||||
engine = SimilarityEngine(client=_FailingClient()) # type: ignore[arg-type]
|
||||
|
||||
# Same role, same casing
|
||||
result = await engine.compute_role_similarity(role, role)
|
||||
assert result == 1.0, (
|
||||
f"Expected 1.0 for identical role {role!r}, got {result}"
|
||||
)
|
||||
|
||||
# Same role, different casing (upper vs lower)
|
||||
result_mixed = await engine.compute_role_similarity(
|
||||
role.upper(), role.lower()
|
||||
)
|
||||
assert result_mixed == 1.0, (
|
||||
f"Expected 1.0 for case-insensitive match "
|
||||
f"({role.upper()!r} vs {role.lower()!r}), got {result_mixed}"
|
||||
)
|
||||
@@ -0,0 +1,106 @@
|
||||
# Feature: remove-embedding-competence-similarity, Property 1: BM25 Zero-Score für fehlenden Token-Overlap
|
||||
"""Property-based tests for SimilarityEngine (BM25+RRF competence matching)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.bm25 import Bm25Index
|
||||
from teamlandkarte_mcp.matching.similarity import SimilarityEngine
|
||||
|
||||
|
||||
class _DummyClient:
|
||||
"""Minimal stub for AzureOpenAIClient (unused for competence similarity)."""
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
return '{"similarity": 0.5}'
|
||||
|
||||
|
||||
def _bm25_engine() -> SimilarityEngine:
|
||||
return SimilarityEngine(client=_DummyClient()) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _tokenize(text: str) -> set[str]:
|
||||
"""Mirror the BM25 tokenizer: lowercase + split on [\\W_]+."""
|
||||
return {t for t in re.split(r"[\W_]+", text.lower()) if t}
|
||||
|
||||
|
||||
# Strategy: generate competence strings using ASCII letters only (min 3 chars).
|
||||
# We restrict to ASCII to avoid Unicode casefolding edge-cases (e.g. İ → i̇)
|
||||
# that produce unexpected token splits and break the prefix-disjointness guarantee.
|
||||
_competence_str = st.text(
|
||||
alphabet=st.characters(whitelist_categories=("L",), max_codepoint=127),
|
||||
min_size=3,
|
||||
max_size=30,
|
||||
)
|
||||
|
||||
# Strategy: generate lists of competence strings (1-5 items)
|
||||
_competence_list = st.lists(_competence_str, min_size=1, max_size=5)
|
||||
|
||||
|
||||
def _make_disjoint_pair(
|
||||
required: list[str], candidate: list[str]
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Prefix-separate required and candidate to guarantee disjoint tokens.
|
||||
|
||||
Required competences get prefixed with 'req' and candidate competences
|
||||
get prefixed with 'cand'. Since both prefixes are pure letters and
|
||||
differ, and the original strings are pure letters, the resulting token
|
||||
sets are guaranteed to be disjoint after the BM25 tokenizer processes them.
|
||||
"""
|
||||
prefixed_req = [f"req{s}" for s in required]
|
||||
prefixed_cand = [f"cand{s}" for s in candidate]
|
||||
return prefixed_req, prefixed_cand
|
||||
|
||||
|
||||
# **Validates: Requirements 1.1**
|
||||
@settings(max_examples=100)
|
||||
@given(required=_competence_list, candidate=_competence_list)
|
||||
@pytest.mark.asyncio
|
||||
async def test_bm25_zero_score_on_disjoint_tokens(
|
||||
required: list[str], candidate: list[str]
|
||||
) -> None:
|
||||
"""Property 1: BM25 returns 0.0 for all required competences when there is
|
||||
no token overlap with any candidate competence.
|
||||
|
||||
For any set of required competences and candidate competences where no
|
||||
candidate shares any token with a required competence,
|
||||
compute_competence_similarity shall return a score of 0.0 for that
|
||||
required competence.
|
||||
"""
|
||||
# Ensure disjoint tokens via prefix separation
|
||||
disjoint_req, disjoint_cand = _make_disjoint_pair(required, candidate)
|
||||
|
||||
# Verify our disjoint guarantee holds (sanity check on the strategy)
|
||||
req_tokens = set()
|
||||
for r in disjoint_req:
|
||||
req_tokens.update(_tokenize(r))
|
||||
cand_tokens = set()
|
||||
for c in disjoint_cand:
|
||||
cand_tokens.update(_tokenize(c))
|
||||
assert req_tokens.isdisjoint(cand_tokens), (
|
||||
f"Tokens not disjoint: {req_tokens & cand_tokens}"
|
||||
)
|
||||
|
||||
# Build a global BM25 index from the candidate competences (realistic usage)
|
||||
global_index = Bm25Index(corpus=disjoint_cand)
|
||||
|
||||
engine = _bm25_engine()
|
||||
result = await engine.compute_competence_similarity(
|
||||
required=disjoint_req,
|
||||
candidate=disjoint_cand,
|
||||
global_index=global_index,
|
||||
)
|
||||
|
||||
# All required competences must have score 0.0
|
||||
for req_competence in disjoint_req:
|
||||
assert req_competence in result, (
|
||||
f"Missing key {req_competence!r} in result"
|
||||
)
|
||||
score = float(result[req_competence]["score"]) # type: ignore[arg-type]
|
||||
assert score == 0.0, (
|
||||
f"Expected 0.0 for {req_competence!r} but got {score}"
|
||||
)
|
||||
@@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, date
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
from teamlandkarte_mcp.models import Capacity, Task
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, caps: list[Capacity], tasks: list[Task]) -> None:
|
||||
self._caps = caps
|
||||
self._tasks = tasks
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["Cloud Engineer"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["AWS"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20) -> list[Task]:
|
||||
if limit == 0:
|
||||
return list(self._tasks)
|
||||
return list(self._tasks)[:limit]
|
||||
|
||||
def get_task_by_id(self, task_id: str): # pragma: no cover
|
||||
for t in self._tasks:
|
||||
if t.id == task_id:
|
||||
return t
|
||||
return None
|
||||
|
||||
def get_task_by_name(self, name: str): # pragma: no cover
|
||||
if not name:
|
||||
return None
|
||||
for t in self._tasks:
|
||||
if getattr(t, "name", None) == name:
|
||||
return t
|
||||
return None
|
||||
|
||||
def get_all_capacities_with_competences(self): # pragma: no cover
|
||||
return list(self._caps)
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20) -> list[Capacity]:
|
||||
return list(self._caps)[:limit]
|
||||
|
||||
def get_capacity_by_id(self, capacity_id: int | str):
|
||||
cid = int(capacity_id)
|
||||
for c in self._caps:
|
||||
if int(c.id) == cid:
|
||||
return c
|
||||
return None
|
||||
|
||||
def get_capacity_description(self, capacity_id: int | str) -> str | None:
|
||||
return None
|
||||
|
||||
def get_capacity_references(self, capacity_id: int | str) -> list[dict]:
|
||||
return []
|
||||
|
||||
def get_capacity_certificates(self, capacity_id: int | str) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_free_capacities_empty_table(monkeypatch, tmp_path) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=10
|
||||
max_size=10
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
monkeypatch.setattr(mod, "create_db_client", lambda *_: _FakeDB([], []))
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
content, structured = await srv.call_tool("list_free_capacities", {"limit": 5})
|
||||
out = (structured or {}).get("result") or getattr(content[0], "text", "")
|
||||
|
||||
assert "| capacity_id |" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_capacity_details_next_steps(monkeypatch, tmp_path) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=10
|
||||
max_size=10
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cap = Capacity(
|
||||
id=1,
|
||||
owner_name="Team",
|
||||
role_name="Cloud Engineer",
|
||||
role_level=None,
|
||||
begin_date=date(2026, 1, 1),
|
||||
end_date=date(2026, 1, 31),
|
||||
competences=["AWS"],
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
monkeypatch.setattr(mod, "create_db_client", lambda *_: _FakeDB([cap], []))
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
content, structured = await srv.call_tool(
|
||||
"get_capacity_details", {"capacity_id": 1}
|
||||
)
|
||||
out = (structured or {}).get("result") or getattr(content[0], "text", "")
|
||||
|
||||
assert "| capacity_id |" in out
|
||||
assert "## Next steps" in out
|
||||
assert "find_matching_tasks" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_matching_tasks_creates_search_id(monkeypatch, tmp_path) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[matching.inference]
|
||||
max_competences=2
|
||||
min_similarity=0.0
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=10
|
||||
max_size=10
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
cap = Capacity(
|
||||
id=1,
|
||||
owner_name="Team",
|
||||
role_name="Cloud Engineer",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=["AWS"],
|
||||
)
|
||||
|
||||
task = Task(
|
||||
id="t1",
|
||||
name=None,
|
||||
title="Task",
|
||||
description="AWS work",
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
created_date=datetime(2026, 1, 1, 10, 0, 0),
|
||||
skills=[],
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
monkeypatch.setattr(mod, "create_db_client", lambda *_: _FakeDB([cap], [task]))
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
content, structured = await srv.call_tool("find_matching_tasks", {"capacity_id": 1})
|
||||
out = (structured or {}).get("result") or getattr(content[0], "text", "")
|
||||
|
||||
assert "SEARCH_ID=" in out
|
||||
assert "## Summary" in out
|
||||
assert "| task_id |" in out
|
||||
@@ -0,0 +1,298 @@
|
||||
# Feature: llm-fulltext-matching, Property 2: Leere/None-Felder verwerfen das Profil nicht
|
||||
"""Property-based tests for empty/None handling in the profile builders.
|
||||
|
||||
These tests cover the profile builder helpers
|
||||
``build_capacity_profile``, ``build_task_profile`` and
|
||||
``build_task_profile_from_requirements`` defined in
|
||||
``teamlandkarte_mcp.matching.profiles``.
|
||||
|
||||
The key invariant under test is that the builders **never discard a
|
||||
profile**: empty strings, ``None`` values and empty lists in the input
|
||||
must be mapped to empty strings or empty lists in the output, and the
|
||||
deterministic serializers must still include every field heading.
|
||||
|
||||
In particular, references with an empty ``partner_name`` (NULL
|
||||
``partner_id`` or join mismatch) are kept in the resulting
|
||||
:class:`CapacityProfile` and only their partner token is suppressed in
|
||||
the serialized output.
|
||||
|
||||
**Validates: Requirements 3.2, 3.4, 4.2**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.profiles import (
|
||||
CapacityProfile,
|
||||
CapacityReferenceEntry,
|
||||
TaskProfile,
|
||||
build_capacity_profile,
|
||||
build_task_profile,
|
||||
build_task_profile_from_requirements,
|
||||
serialize_capacity_profile,
|
||||
serialize_task_profile,
|
||||
)
|
||||
from teamlandkarte_mcp.models import Capacity, Requirements, Task
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Optional text fields (model fields typed ``Optional[str]``): either
|
||||
# ``None`` or a short text. The builders must coerce ``None`` and
|
||||
# whitespace-only inputs to empty strings.
|
||||
_maybe_text = st.one_of(st.none(), st.text(max_size=30))
|
||||
|
||||
# Optional list-of-strings inputs (e.g. extra description, certificates).
|
||||
# The builders must coerce ``None`` to an empty list.
|
||||
_maybe_text_list = st.one_of(
|
||||
st.none(), st.lists(st.text(max_size=20), max_size=5)
|
||||
)
|
||||
|
||||
# Partner name on DB rows: either empty (NULL ``partner_id`` or join
|
||||
# mismatch, see requirement 2.8) or a short non-empty string.
|
||||
_maybe_partner = st.one_of(
|
||||
st.just(""), st.text(min_size=1, max_size=20)
|
||||
)
|
||||
|
||||
# A single ``CapacityReferenceRow`` (TypedDict) as returned by the
|
||||
# ``DBClient``. ``projects`` is constrained to non-whitespace text so the
|
||||
# builder never drops the row defensively; this lets us assert the
|
||||
# stronger invariant "input row count == output entry count".
|
||||
_ref_dict = st.fixed_dictionaries(
|
||||
{
|
||||
"partner_name": _maybe_partner,
|
||||
"projects": st.text(min_size=1, max_size=30).filter(
|
||||
lambda s: s.strip() != ""
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
_maybe_ref_list = st.one_of(st.none(), st.lists(_ref_dict, max_size=5))
|
||||
|
||||
|
||||
# ``Capacity`` instances with empty/None-heavy fields. ``begin_date`` and
|
||||
# ``end_date`` are pinned to ``None`` to keep the strategy fast and
|
||||
# deterministic; the builder ignores them anyway.
|
||||
_capacity = st.builds(
|
||||
Capacity,
|
||||
id=st.integers(min_value=0, max_value=10**9),
|
||||
owner_name=st.one_of(st.just(""), st.text(max_size=30)),
|
||||
role_name=_maybe_text,
|
||||
role_level=_maybe_text,
|
||||
begin_date=st.none(),
|
||||
end_date=st.none(),
|
||||
competences=st.one_of(
|
||||
st.just([]), st.lists(st.text(max_size=20), max_size=5)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ``Task`` instances with empty/None-heavy fields. ``created_date`` is
|
||||
# pinned to a fixed value (the dataclass requires a ``datetime``).
|
||||
_task = st.builds(
|
||||
Task,
|
||||
id=st.text(min_size=1, max_size=20),
|
||||
name=_maybe_text,
|
||||
title=st.one_of(st.just(""), st.text(max_size=30)),
|
||||
description=st.one_of(st.just(""), st.text(max_size=30)),
|
||||
start_date=st.none(),
|
||||
end_date=st.none(),
|
||||
created_date=st.just(datetime(2024, 1, 1)),
|
||||
skills=st.one_of(
|
||||
st.just([]), st.lists(st.text(max_size=20), max_size=5)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ``Requirements`` instances with empty/None-heavy fields.
|
||||
_requirements = st.builds(
|
||||
Requirements,
|
||||
role_name=_maybe_text,
|
||||
competences=st.one_of(
|
||||
st.just([]), st.lists(st.text(max_size=20), max_size=5)
|
||||
),
|
||||
date_start=st.none(),
|
||||
date_end=st.none(),
|
||||
description=_maybe_text,
|
||||
)
|
||||
|
||||
|
||||
_CAPACITY_HEADINGS = (
|
||||
"Rolle:",
|
||||
"Kompetenzen:",
|
||||
"Beschreibung:",
|
||||
"Referenzen:",
|
||||
"Zertifikate:",
|
||||
)
|
||||
|
||||
_TASK_HEADINGS = (
|
||||
"Titel:",
|
||||
"Beschreibung:",
|
||||
"Gesuchte Kompetenzen:",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(
|
||||
capacity=_capacity,
|
||||
description=_maybe_text,
|
||||
certificates=_maybe_text_list,
|
||||
references=_maybe_ref_list,
|
||||
)
|
||||
def test_build_capacity_profile_handles_empty_or_none_fields(
|
||||
capacity: Capacity,
|
||||
description: str | None,
|
||||
certificates: list[str] | None,
|
||||
references: list[dict] | None,
|
||||
) -> None:
|
||||
"""Property 2 (Capacity): empty/None inputs never discard the profile.
|
||||
|
||||
For any combination of ``None``/empty/whitespace inputs (including
|
||||
references with empty ``partner_name``), ``build_capacity_profile``
|
||||
must return a fully populated :class:`CapacityProfile` whose string
|
||||
fields are real strings (never ``None``) and whose list fields are
|
||||
real lists (never ``None``). References with non-whitespace
|
||||
``projects`` are kept regardless of the partner name, and the
|
||||
deterministic serialization still contains every field heading.
|
||||
"""
|
||||
profile = build_capacity_profile(
|
||||
capacity,
|
||||
description=description,
|
||||
certificates=certificates,
|
||||
references=references,
|
||||
)
|
||||
|
||||
# Profile is never discarded.
|
||||
assert isinstance(profile, CapacityProfile)
|
||||
|
||||
# ``description`` is always a string. ``None`` and whitespace-only
|
||||
# inputs are mapped to the empty string.
|
||||
assert isinstance(profile.description, str)
|
||||
if description is None or not description.strip():
|
||||
assert profile.description == ""
|
||||
|
||||
# All list-typed fields are real lists (never ``None``).
|
||||
assert isinstance(profile.competences, list)
|
||||
assert isinstance(profile.references, list)
|
||||
assert isinstance(profile.certificates, list)
|
||||
|
||||
# Every reference entry is a ``CapacityReferenceEntry``; references
|
||||
# with an empty ``partner_name`` are kept.
|
||||
for entry in profile.references:
|
||||
assert isinstance(entry, CapacityReferenceEntry)
|
||||
assert isinstance(entry.partner_name, str)
|
||||
assert isinstance(entry.projects, str)
|
||||
|
||||
# The strategy guarantees non-whitespace ``projects``, so the
|
||||
# builder must keep every input row regardless of partner name.
|
||||
expected_count = len(references) if references else 0
|
||||
assert len(profile.references) == expected_count
|
||||
|
||||
# The deterministic serialization still contains every heading,
|
||||
# even when all input fields were empty/None.
|
||||
serialized = serialize_capacity_profile(profile)
|
||||
for heading in _CAPACITY_HEADINGS:
|
||||
assert heading in serialized, (
|
||||
f"Heading {heading!r} missing from serialization: "
|
||||
f"{serialized!r}"
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(task=_task)
|
||||
def test_build_task_profile_handles_empty_or_none_fields(
|
||||
task: Task,
|
||||
) -> None:
|
||||
"""Property 2 (Task): empty/None inputs never discard the profile.
|
||||
|
||||
For any :class:`Task` with empty/None-heavy fields,
|
||||
``build_task_profile`` must return a fully populated
|
||||
:class:`TaskProfile` whose string fields are real strings (never
|
||||
``None``) and whose ``skills`` list is a real list. The
|
||||
deterministic serialization still contains every field heading.
|
||||
"""
|
||||
profile = build_task_profile(task)
|
||||
|
||||
# Profile is never discarded.
|
||||
assert isinstance(profile, TaskProfile)
|
||||
|
||||
# All string fields are real strings (never ``None``).
|
||||
assert isinstance(profile.id, str)
|
||||
assert isinstance(profile.title, str)
|
||||
assert isinstance(profile.description, str)
|
||||
|
||||
# ``skills`` is always a real list.
|
||||
assert isinstance(profile.skills, list)
|
||||
for skill in profile.skills:
|
||||
assert isinstance(skill, str)
|
||||
|
||||
# Empty inputs are mirrored as empty strings on the output.
|
||||
if not task.title:
|
||||
assert profile.title == ""
|
||||
if not task.description:
|
||||
assert profile.description == ""
|
||||
|
||||
# The deterministic serialization still contains every heading.
|
||||
serialized = serialize_task_profile(profile)
|
||||
for heading in _TASK_HEADINGS:
|
||||
assert heading in serialized, (
|
||||
f"Heading {heading!r} missing from serialization: "
|
||||
f"{serialized!r}"
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(requirements=_requirements)
|
||||
def test_build_task_profile_from_requirements_handles_empty_or_none_fields(
|
||||
requirements: Requirements,
|
||||
) -> None:
|
||||
"""Property 2 (Requirements): empty/None inputs never discard profile.
|
||||
|
||||
For any :class:`Requirements` with empty/None-heavy fields,
|
||||
``build_task_profile_from_requirements`` must return a fully
|
||||
populated :class:`TaskProfile` whose string fields are real strings
|
||||
(never ``None``) and whose ``skills`` list is a real list. The
|
||||
deterministic serialization still contains every field heading.
|
||||
"""
|
||||
profile = build_task_profile_from_requirements(requirements)
|
||||
|
||||
# Profile is never discarded.
|
||||
assert isinstance(profile, TaskProfile)
|
||||
|
||||
# All string fields are real strings (never ``None``).
|
||||
assert isinstance(profile.id, str)
|
||||
assert isinstance(profile.title, str)
|
||||
assert isinstance(profile.description, str)
|
||||
|
||||
# ``skills`` is always a real list.
|
||||
assert isinstance(profile.skills, list)
|
||||
for skill in profile.skills:
|
||||
assert isinstance(skill, str)
|
||||
|
||||
# ``None`` inputs from the requirements are mirrored as empty
|
||||
# strings/lists on the output.
|
||||
if requirements.role_name is None:
|
||||
assert profile.title == ""
|
||||
if requirements.description is None:
|
||||
assert profile.description == ""
|
||||
if not requirements.competences:
|
||||
assert profile.skills == []
|
||||
|
||||
# The deterministic serialization still contains every heading.
|
||||
serialized = serialize_task_profile(profile)
|
||||
for heading in _TASK_HEADINGS:
|
||||
assert heading in serialized, (
|
||||
f"Heading {heading!r} missing from serialization: "
|
||||
f"{serialized!r}"
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
# Feature: llm-fulltext-matching, Property 1: Profil-Serialisierung ist deterministisch und feldvollständig
|
||||
"""Property-based tests for deterministic profile serialization.
|
||||
|
||||
These tests cover the deterministic serialization helpers
|
||||
``serialize_capacity_profile`` and ``serialize_task_profile`` defined in
|
||||
``teamlandkarte_mcp.matching.profiles``. They verify three core properties
|
||||
that hold for every randomly generated profile:
|
||||
|
||||
1. **Determinism**: calling the serializer twice on the same profile yields
|
||||
exactly identical strings.
|
||||
2. **Field-heading completeness**: the serialized output always contains
|
||||
every field heading required by the spec.
|
||||
3. **Partner-name visibility**: when a ``CapacityReferenceEntry`` has a
|
||||
non-empty ``partner_name``, that name appears as a substring of the
|
||||
serialized capacity profile.
|
||||
|
||||
**Validates: Requirements 3.3, 3.4, 3.6, 3.7, 4.3, 4.4**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.profiles import (
|
||||
CapacityProfile,
|
||||
CapacityReferenceEntry,
|
||||
TaskProfile,
|
||||
serialize_capacity_profile,
|
||||
serialize_task_profile,
|
||||
)
|
||||
|
||||
|
||||
# Small text strategy to keep generation fast and deterministic.
|
||||
_text = st.text(max_size=40)
|
||||
|
||||
# `partner_name` may be empty (NULL `partner_id` / Join-Mismatch) or any
|
||||
# short non-empty text.
|
||||
_partner_name = st.one_of(st.just(""), st.text(min_size=1, max_size=20))
|
||||
|
||||
# The builder strips empty `projects`, but the serializer renders every
|
||||
# entry it receives. Use non-whitespace projects so containment assertions
|
||||
# are unambiguous.
|
||||
_projects = st.text(min_size=1, max_size=40).filter(lambda s: s.strip() != "")
|
||||
|
||||
_reference = st.builds(
|
||||
CapacityReferenceEntry,
|
||||
partner_name=_partner_name,
|
||||
projects=_projects,
|
||||
)
|
||||
|
||||
_capacity_profile = st.builds(
|
||||
CapacityProfile,
|
||||
id=_text,
|
||||
owner_name=_text,
|
||||
role_name=_text,
|
||||
competences=st.lists(_text, max_size=5),
|
||||
description=_text,
|
||||
references=st.lists(_reference, max_size=5),
|
||||
certificates=st.lists(
|
||||
_text.filter(lambda s: s.strip() != ""), max_size=5
|
||||
),
|
||||
)
|
||||
|
||||
_task_profile = st.builds(
|
||||
TaskProfile,
|
||||
id=_text,
|
||||
title=_text,
|
||||
description=_text,
|
||||
skills=st.lists(_text, max_size=5),
|
||||
)
|
||||
|
||||
|
||||
_CAPACITY_HEADINGS = (
|
||||
"Rolle:",
|
||||
"Kompetenzen:",
|
||||
"Beschreibung:",
|
||||
"Referenzen:",
|
||||
"Zertifikate:",
|
||||
)
|
||||
|
||||
_TASK_HEADINGS = (
|
||||
"Titel:",
|
||||
"Beschreibung:",
|
||||
"Gesuchte Kompetenzen:",
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(profile=_capacity_profile)
|
||||
def test_capacity_profile_serialization_is_deterministic_and_complete(
|
||||
profile: CapacityProfile,
|
||||
) -> None:
|
||||
"""Property 1 (Capacity): determinism, heading completeness, partner name.
|
||||
|
||||
For any :class:`CapacityProfile` (including references with empty or
|
||||
non-empty ``partner_name``) the serializer must:
|
||||
|
||||
* produce identical strings on repeated calls (determinism),
|
||||
* always include every field heading required by the spec, and
|
||||
* include every non-empty ``partner_name`` from the references list as
|
||||
a substring of the serialized output.
|
||||
"""
|
||||
first = serialize_capacity_profile(profile)
|
||||
second = serialize_capacity_profile(profile)
|
||||
|
||||
# Determinism: two consecutive calls return the exact same string.
|
||||
assert first == second, "serialize_capacity_profile is not deterministic"
|
||||
|
||||
# Every field heading must always be present.
|
||||
for heading in _CAPACITY_HEADINGS:
|
||||
assert heading in first, (
|
||||
f"Heading {heading!r} missing from capacity serialization: {first!r}"
|
||||
)
|
||||
|
||||
# Each reference with a non-empty partner_name must appear verbatim.
|
||||
for ref in profile.references:
|
||||
if ref.partner_name:
|
||||
assert ref.partner_name in first, (
|
||||
f"Partner name {ref.partner_name!r} missing from "
|
||||
f"serialization: {first!r}"
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(profile=_task_profile)
|
||||
def test_task_profile_serialization_is_deterministic_and_complete(
|
||||
profile: TaskProfile,
|
||||
) -> None:
|
||||
"""Property 1 (Task): determinism and heading completeness.
|
||||
|
||||
For any :class:`TaskProfile` the serializer must produce identical
|
||||
strings on repeated calls and always include every field heading
|
||||
required by the spec.
|
||||
"""
|
||||
first = serialize_task_profile(profile)
|
||||
second = serialize_task_profile(profile)
|
||||
|
||||
# Determinism: two consecutive calls return the exact same string.
|
||||
assert first == second, "serialize_task_profile is not deterministic"
|
||||
|
||||
# Every field heading must always be present.
|
||||
for heading in _TASK_HEADINGS:
|
||||
assert heading in first, (
|
||||
f"Heading {heading!r} missing from task serialization: {first!r}"
|
||||
)
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from teamlandkarte_mcp.cache.query_cache import QueryCache
|
||||
|
||||
|
||||
def test_query_cache_hit_miss_behavior() -> None:
|
||||
cache: QueryCache[int] = QueryCache(ttl_hours=12, max_size=10)
|
||||
calls = {"n": 0}
|
||||
|
||||
def fetch() -> int:
|
||||
calls["n"] += 1
|
||||
return 123
|
||||
|
||||
assert cache.get_or_fetch("k", fetch) == 123
|
||||
assert cache.stats["misses"] == 1
|
||||
assert cache.stats["hits"] == 0
|
||||
assert calls["n"] == 1
|
||||
|
||||
assert cache.get_or_fetch("k", fetch) == 123
|
||||
assert cache.stats["hits"] == 1
|
||||
assert calls["n"] == 1
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from teamlandkarte_mcp.database.query_logger import normalize_sql, redact_params
|
||||
|
||||
|
||||
def test_normalize_sql_collapses_whitespace() -> None:
|
||||
assert normalize_sql("SELECT\n 1\t\tFROM t") == "SELECT 1 FROM t"
|
||||
|
||||
|
||||
def test_redact_params_redacts_strings() -> None:
|
||||
assert redact_params(["secret"]) == ("se***et",)
|
||||
assert redact_params(["a"]) == ("***",)
|
||||
assert redact_params([123, None, True]) == (123, None, True)
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import teamlandkarte_mcp.database.read_only as ro
|
||||
|
||||
|
||||
def test_ensure_select_only_allows_select() -> None:
|
||||
ro.ensure_select_only("SELECT 1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sql",
|
||||
[
|
||||
"DELETE FROM t",
|
||||
"UPDATE t SET x = 1",
|
||||
"INSERT INTO t VALUES (1)",
|
||||
"DROP TABLE t",
|
||||
],
|
||||
)
|
||||
def test_ensure_select_only_rejects_writes(sql: str) -> None:
|
||||
with pytest.raises(ro.DatabaseError):
|
||||
ro.ensure_select_only(sql)
|
||||
@@ -0,0 +1,188 @@
|
||||
# Feature: llm-fulltext-matching, Property 2b:
|
||||
# Referenzen mit leerem Partner-Name behalten projects, ohne Partner-Token
|
||||
"""Property-based tests for reference rendering with empty partner names.
|
||||
|
||||
These tests cover the deterministic serializer
|
||||
``serialize_capacity_profile`` defined in
|
||||
``teamlandkarte_mcp.matching.profiles``. They focus specifically on the
|
||||
``Referenzen:`` block and verify three invariants for any list of
|
||||
:class:`CapacityReferenceEntry` values that mixes empty and non-empty
|
||||
``partner_name`` values:
|
||||
|
||||
(a) The number of rendered reference lines equals the number of input
|
||||
entries with non-whitespace ``projects``.
|
||||
(b) Each line that corresponds to an entry with empty ``partner_name``
|
||||
starts with ``Projekte:`` and does NOT contain a ``Partner:`` token.
|
||||
(c) Each line that corresponds to an entry with non-empty
|
||||
``partner_name`` contains both ``Partner: <name>`` and
|
||||
``Projekte: <projects>``.
|
||||
|
||||
Because the serializer classifies entries via ``if entry.partner_name:``
|
||||
(truthiness), this test mirrors that exact logic and uses
|
||||
``bool(entry.partner_name)`` to decide whether the ``Partner:`` token is
|
||||
expected.
|
||||
|
||||
The strategies deliberately exclude line-break-like characters
|
||||
(everything ``str.splitlines`` treats as a line boundary) so the
|
||||
line-based extraction of the rendered ``Referenzen:`` block remains
|
||||
unambiguous. The property under test is the *content* of the rendered
|
||||
references, not how the serializer would behave on text containing
|
||||
literal newlines.
|
||||
|
||||
**Validates: Requirements 2.8, 3.4, 3.6**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.profiles import (
|
||||
CapacityProfile,
|
||||
CapacityReferenceEntry,
|
||||
serialize_capacity_profile,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Alphabet for partner_name/projects: any unicode codepoint that
|
||||
# ``str.splitlines`` does NOT treat as a line boundary, so the rendered
|
||||
# output remains a stable, single-line-per-entry block. We exclude:
|
||||
#
|
||||
# * ``Cs`` (surrogates) — invalid in well-formed strings.
|
||||
# * ``Cc`` (control characters) — includes ``\n``, ``\r``, ``\v``,
|
||||
# ``\f``, ``\x1c``-``\x1f``, ``\x85`` etc.
|
||||
# * ``Zl`` (line separator, ``\u2028``).
|
||||
# * ``Zp`` (paragraph separator, ``\u2029``).
|
||||
_safe_chars = st.characters(
|
||||
blacklist_categories=("Cs", "Cc", "Zl", "Zp"),
|
||||
)
|
||||
|
||||
# ``partner_name``: either empty (NULL ``partner_id`` or join mismatch,
|
||||
# see requirement 2.8) or a short non-empty string. The serializer uses
|
||||
# truthy-checking, so any non-empty string (including whitespace-only)
|
||||
# counts as "has partner" — we keep this strategy aligned with that
|
||||
# logic and classify entries by ``bool(entry.partner_name)`` in the
|
||||
# assertions below.
|
||||
_partner_name = st.one_of(
|
||||
st.just(""),
|
||||
st.text(alphabet=_safe_chars, min_size=1, max_size=20),
|
||||
)
|
||||
|
||||
# ``projects``: non-whitespace text so the serializer never drops the
|
||||
# entry defensively. This lets us assert the stronger invariant
|
||||
# "input entry count == rendered line count".
|
||||
_projects = st.text(
|
||||
alphabet=_safe_chars, min_size=1, max_size=30
|
||||
).filter(lambda s: s.strip() != "")
|
||||
|
||||
_reference = st.builds(
|
||||
CapacityReferenceEntry,
|
||||
partner_name=_partner_name,
|
||||
projects=_projects,
|
||||
)
|
||||
|
||||
_reference_list = st.lists(_reference, min_size=1, max_size=8)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_reference_lines(serialized: str) -> list[str]:
|
||||
"""Extract the rendered reference lines from a serialized profile.
|
||||
|
||||
The serializer renders the ``Referenzen:`` block as a heading line
|
||||
followed by either a bullet list (one ``- <line>`` per entry) or
|
||||
`` (keine)`` on the same line as the heading. The next field
|
||||
heading is always ``Zertifikate:`` at the start of its own line,
|
||||
which we use as the terminator. The leading ``- `` prefix is
|
||||
stripped from each line so callers can assert directly on the
|
||||
rendered content.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
in_references = False
|
||||
for raw_line in serialized.splitlines():
|
||||
if raw_line.startswith("Zertifikate:"):
|
||||
break
|
||||
if raw_line.startswith("Referenzen:"):
|
||||
in_references = True
|
||||
continue
|
||||
if in_references and raw_line.startswith("- "):
|
||||
lines.append(raw_line[2:])
|
||||
return lines
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(refs=_reference_list)
|
||||
def test_references_with_empty_partner_name_keep_projects_without_partner_token(
|
||||
refs: list[CapacityReferenceEntry],
|
||||
) -> None:
|
||||
"""Property 2b: empty ``partner_name`` keeps ``projects`` without token.
|
||||
|
||||
For any list of :class:`CapacityReferenceEntry` values with mixed
|
||||
``partner_name``:
|
||||
|
||||
(a) The number of rendered reference lines equals the number of
|
||||
input entries (the strategy guarantees non-whitespace
|
||||
``projects``, so no entry is dropped).
|
||||
(b) Every line for an entry with empty ``partner_name`` starts with
|
||||
``Projekte:`` and contains no ``Partner:`` token.
|
||||
(c) Every line for an entry with non-empty ``partner_name`` contains
|
||||
both ``Partner: <name>`` and ``Projekte: <projects>``.
|
||||
"""
|
||||
profile = CapacityProfile(
|
||||
id="x",
|
||||
owner_name="o",
|
||||
role_name="r",
|
||||
competences=[],
|
||||
description="",
|
||||
references=list(refs),
|
||||
certificates=[],
|
||||
)
|
||||
|
||||
serialized = serialize_capacity_profile(profile)
|
||||
lines = _extract_reference_lines(serialized)
|
||||
|
||||
# (a) One rendered line per input entry, in input order.
|
||||
assert len(lines) == len(refs), (
|
||||
f"Expected {len(refs)} reference lines, got {len(lines)}: "
|
||||
f"{lines!r}"
|
||||
)
|
||||
|
||||
# (b) and (c): per-entry assertions, mirroring the serializer's
|
||||
# ``if entry.partner_name:`` truthiness check.
|
||||
for entry, line in zip(refs, lines):
|
||||
projects_clean = entry.projects.strip()
|
||||
if entry.partner_name:
|
||||
# (c) Non-empty partner name → both tokens present.
|
||||
assert f"Partner: {entry.partner_name}" in line, (
|
||||
f"Expected 'Partner: {entry.partner_name}' in line: "
|
||||
f"{line!r}"
|
||||
)
|
||||
assert f"Projekte: {projects_clean}" in line, (
|
||||
f"Expected 'Projekte: {projects_clean}' in line: "
|
||||
f"{line!r}"
|
||||
)
|
||||
else:
|
||||
# (b) Empty partner name → starts with ``Projekte:`` and no
|
||||
# ``Partner:`` token at all.
|
||||
assert line.startswith("Projekte:"), (
|
||||
f"Expected line to start with 'Projekte:', got: {line!r}"
|
||||
)
|
||||
assert "Partner:" not in line, (
|
||||
f"Did not expect 'Partner:' token in line: {line!r}"
|
||||
)
|
||||
assert line == f"Projekte: {projects_clean}", (
|
||||
f"Expected exact 'Projekte: {projects_clean}', got: "
|
||||
f"{line!r}"
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
# filepath: tests/test_rrf.py
|
||||
"""Unit tests for :mod:`teamlandkarte_mcp.matching.rrf`.
|
||||
|
||||
Covers edge cases of :func:`reciprocal_rank_fusion`: empty input, all-zero
|
||||
input, single list, multiple lists, normalization, and the ``k`` parameter.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.matching.rrf import reciprocal_rank_fusion
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Empty / all-zero inputs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rrf_empty_input_returns_empty() -> None:
|
||||
"""No ranked lists → empty result."""
|
||||
assert reciprocal_rank_fusion([]) == {}
|
||||
|
||||
|
||||
def test_rrf_empty_list_returns_empty() -> None:
|
||||
"""Single empty ranked list → empty result."""
|
||||
assert reciprocal_rank_fusion([[]]) == {}
|
||||
|
||||
|
||||
def test_rrf_all_zero_scores_returns_empty() -> None:
|
||||
"""All-zero BM25 scores → no token overlap signal → empty result."""
|
||||
result = reciprocal_rank_fusion([[("A", 0.0), ("B", 0.0)]])
|
||||
assert result == {}
|
||||
|
||||
|
||||
def test_rrf_mixed_zero_and_nonzero_list() -> None:
|
||||
"""Only the non-zero list contributes; all-zero list is skipped."""
|
||||
result = reciprocal_rank_fusion(
|
||||
[
|
||||
[("A", 3.5), ("B", 1.0)],
|
||||
[("A", 0.0), ("B", 0.0)],
|
||||
]
|
||||
)
|
||||
# Both A and B should appear (from first list only)
|
||||
assert "A" in result
|
||||
assert "B" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single list — normalization to [0, 1]
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rrf_single_list_single_candidate_score_one() -> None:
|
||||
"""Single candidate in single list → score 1.0."""
|
||||
result = reciprocal_rank_fusion([[("Python", 3.5)]])
|
||||
assert result == {"Python": 1.0}
|
||||
|
||||
|
||||
def test_rrf_single_list_top_candidate_score_one() -> None:
|
||||
"""Top-ranked candidate always maps to 1.0 after normalization."""
|
||||
result = reciprocal_rank_fusion(
|
||||
[[("A", 5.0), ("B", 2.0), ("C", 0.5)]]
|
||||
)
|
||||
assert math.isclose(result["A"], 1.0, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_rrf_single_list_scores_in_descending_order() -> None:
|
||||
"""Higher-ranked candidates receive higher normalized scores."""
|
||||
result = reciprocal_rank_fusion(
|
||||
[[("A", 5.0), ("B", 2.0), ("C", 0.5)]]
|
||||
)
|
||||
assert result["A"] > result["B"] > result["C"]
|
||||
|
||||
|
||||
def test_rrf_single_list_all_scores_in_unit_interval() -> None:
|
||||
"""All scores are in [0.0, 1.0]."""
|
||||
result = reciprocal_rank_fusion(
|
||||
[[("A", 5.0), ("B", 2.0), ("C", 0.5)]]
|
||||
)
|
||||
for score in result.values():
|
||||
assert 0.0 <= score <= 1.0
|
||||
|
||||
|
||||
def test_rrf_zero_score_entry_not_in_result() -> None:
|
||||
"""Candidate with BM25 score 0.0 receives no RRF credit (zero-out rule)."""
|
||||
result = reciprocal_rank_fusion(
|
||||
[[("A", 3.5), ("B", 0.0)]]
|
||||
)
|
||||
assert "A" in result
|
||||
assert "B" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# k parameter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rrf_default_k_rank2_score() -> None:
|
||||
"""With default k=60, rank-2 score normalized = 61/62 ≈ 0.9839."""
|
||||
result = reciprocal_rank_fusion(
|
||||
[[("A", 2.0), ("B", 1.0)]]
|
||||
)
|
||||
# raw(A) = 1/61, raw(B) = 1/62
|
||||
# normalized(B) = (1/62) / (1/61) = 61/62
|
||||
expected_b = 61.0 / 62.0
|
||||
assert math.isclose(result["B"], expected_b, rel_tol=1e-9)
|
||||
|
||||
|
||||
def test_rrf_higher_k_flattens_differences() -> None:
|
||||
"""Higher k → smaller gap between rank-1 and rank-2 scores."""
|
||||
result_low_k = reciprocal_rank_fusion(
|
||||
[[("A", 2.0), ("B", 1.0)]], k=1
|
||||
)
|
||||
result_high_k = reciprocal_rank_fusion(
|
||||
[[("A", 2.0), ("B", 1.0)]], k=1000
|
||||
)
|
||||
gap_low = result_low_k["A"] - result_low_k["B"]
|
||||
gap_high = result_high_k["A"] - result_high_k["B"]
|
||||
assert gap_low > gap_high
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multiple lists (fusion)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_rrf_two_lists_fuse_correctly() -> None:
|
||||
"""Candidate appearing in both lists accumulates RRF scores."""
|
||||
result = reciprocal_rank_fusion(
|
||||
[
|
||||
[("A", 3.0), ("B", 1.0)],
|
||||
[("B", 2.5), ("A", 0.5)],
|
||||
]
|
||||
)
|
||||
# A gets credit from list-1 (rank 1) and list-2 (rank 2).
|
||||
# B gets credit from list-1 (rank 2) and list-2 (rank 1).
|
||||
assert "A" in result
|
||||
assert "B" in result
|
||||
# Both should be < 1.0 unless one dominates; top candidate maps to 1.0.
|
||||
assert max(result.values()) == pytest.approx(1.0)
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from teamlandkarte_mcp.database.schema_verifier import verify_required_columns
|
||||
|
||||
|
||||
class StubDB:
|
||||
def __init__(self, mapping: dict[str, list[str]]):
|
||||
self._mapping = mapping
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
return self._mapping.get(table, [])
|
||||
|
||||
|
||||
def test_verify_required_columns_ok() -> None:
|
||||
db = StubDB({"t": ["id", "name"]})
|
||||
issues = verify_required_columns(db=db, expected={"t": {"id", "name"}})
|
||||
assert issues == []
|
||||
|
||||
|
||||
def test_verify_required_columns_missing() -> None:
|
||||
db = StubDB({"t": ["id"]})
|
||||
issues = verify_required_columns(db=db, expected={"t": {"id", "name"}})
|
||||
assert len(issues) == 1
|
||||
assert issues[0].table == "t"
|
||||
assert "missing columns" in issues[0].message
|
||||
@@ -0,0 +1,198 @@
|
||||
# Feature: llm-fulltext-matching, Property 10: Score-Modus ist abwärtskompatibel
|
||||
"""Property test verifying that the score mode is fully backward compatible.
|
||||
|
||||
When ``find_matching_capacities`` is invoked without ``matching_method``
|
||||
(default), or explicitly with ``matching_method="score"``, the rendered
|
||||
result table must keep the existing 9-column layout including the score
|
||||
columns ``Role Score``, ``Competence Score`` and ``Overall Score``, and
|
||||
must NOT include a ``Begründung`` column. The persisted payload schema
|
||||
maps directly to those columns, so the table contract stands in for the
|
||||
payload schema check (and the table headers ARE the visible part of the
|
||||
schema for downstream consumers).
|
||||
|
||||
The two distinct invocation styles (``method=None`` and
|
||||
``method="score"``) are enumerated by ``pytest.mark.parametrize``; the
|
||||
header keeps the "Property 10" tag for spec traceability.
|
||||
|
||||
**Validates: Requirements 1.2, 1.3, 7.3**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
|
||||
_CONFIG_TEMPLATE = """
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
|
||||
[azure_openai]
|
||||
endpoint='https://example.openai.azure.com'
|
||||
api_version='2024-02-15-preview'
|
||||
chat_deployment='gpt-4'
|
||||
""".strip()
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal fake DB returning a single matching capacity."""
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["X"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["A"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_capacities_with_competences(self):
|
||||
return [
|
||||
Capacity(
|
||||
id=1,
|
||||
owner_name="A",
|
||||
role_name="X",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 1, 1),
|
||||
end_date=date(2025, 12, 31),
|
||||
competences=["A"],
|
||||
)
|
||||
]
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"), str
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
def _extract_meta(output: str) -> dict[str, Any]:
|
||||
for line in output.splitlines():
|
||||
if line.startswith("META="):
|
||||
return json.loads(line[len("META=") :])
|
||||
raise AssertionError(f"no META= line in tool output:\n{output}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("method_arg", [None, "score"])
|
||||
async def test_score_mode_payload_is_backward_compatible(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
_env,
|
||||
method_arg: str | None,
|
||||
) -> None:
|
||||
"""Property 10: score-mode invocations preserve the legacy schema.
|
||||
|
||||
Both the implicit default (``matching_method`` omitted) and the
|
||||
explicit ``"score"`` value must yield the legacy table headers and
|
||||
a META payload tagged with ``"score"``.
|
||||
"""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_mod, "create_db_client", lambda *_a, **_k: _FakeDB()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
|
||||
AsyncMock(return_value='{"similarity": 1.0}'),
|
||||
)
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
|
||||
args: dict[str, Any] = {"role_name": "X", "competences": ["A"]}
|
||||
if method_arg is not None:
|
||||
args["matching_method"] = method_arg
|
||||
|
||||
res = await _call_tool(srv, "find_matching_capacities", args)
|
||||
|
||||
# Legacy 9-column header MUST be present.
|
||||
assert "| Role Score |" in res, (
|
||||
"score mode must keep the Role Score column"
|
||||
)
|
||||
assert "| Competence Score |" in res, (
|
||||
"score mode must keep the Competence Score column"
|
||||
)
|
||||
assert "| Overall Score |" in res, (
|
||||
"score mode must keep the Overall Score column"
|
||||
)
|
||||
|
||||
# Begründung column belongs exclusively to the LLM mode.
|
||||
assert "Begründung" not in res, (
|
||||
"score mode must not render the Begründung column"
|
||||
)
|
||||
|
||||
# META JSON must tag the search as score mode.
|
||||
meta = _extract_meta(res)
|
||||
assert meta.get("matching_method") == "score", (
|
||||
f"expected matching_method='score', got META={meta!r}"
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from teamlandkarte_mcp.config import MatchingConfig, MatchingThresholds
|
||||
from teamlandkarte_mcp.matching.scorer import categorize, compute_overall
|
||||
|
||||
|
||||
def test_compute_overall_round6_weights() -> None:
|
||||
cfg = MatchingConfig(
|
||||
competence_weight=0.8,
|
||||
role_weight=0.2,
|
||||
thresholds=MatchingThresholds(top=0.8, good=0.6, partial=0.4),
|
||||
)
|
||||
|
||||
b = compute_overall(competence_score=1.0, role_score=0.0, cfg=cfg)
|
||||
assert b.overall_score == 0.8
|
||||
assert b.category == "Top"
|
||||
|
||||
b = compute_overall(competence_score=0.0, role_score=1.0, cfg=cfg)
|
||||
assert b.overall_score == 0.2
|
||||
assert b.category == "Low"
|
||||
|
||||
|
||||
def test_categorize_threshold_boundaries() -> None:
|
||||
"""Test categorization with recommended thresholds (Feb 2026)."""
|
||||
cfg = MatchingConfig(
|
||||
competence_weight=0.8,
|
||||
role_weight=0.2,
|
||||
thresholds=MatchingThresholds(
|
||||
top=0.8,
|
||||
good=0.65,
|
||||
partial=0.5,
|
||||
low=0.3,
|
||||
),
|
||||
)
|
||||
|
||||
# Top boundary
|
||||
assert categorize(0.8, cfg) == "Top"
|
||||
assert categorize(0.79, cfg) == "Good"
|
||||
|
||||
# Good boundary
|
||||
assert categorize(0.65, cfg) == "Good"
|
||||
assert categorize(0.64, cfg) == "Partial"
|
||||
|
||||
# Partial boundary
|
||||
assert categorize(0.5, cfg) == "Partial"
|
||||
assert categorize(0.49, cfg) == "Low"
|
||||
|
||||
# Low boundary
|
||||
assert categorize(0.3, cfg) == "Low"
|
||||
assert categorize(0.29, cfg) == "Irrelevant"
|
||||
|
||||
|
||||
def test_categorize_with_low_threshold_config() -> None:
|
||||
"""Test categorization with low=0.2 threshold (user's actual config)."""
|
||||
cfg = MatchingConfig(
|
||||
competence_weight=1.0,
|
||||
role_weight=0.0,
|
||||
thresholds=MatchingThresholds(
|
||||
top=0.8,
|
||||
good=0.6,
|
||||
partial=0.4,
|
||||
low=0.2, # User's actual config
|
||||
),
|
||||
)
|
||||
|
||||
# Exact boundaries
|
||||
assert categorize(0.8, cfg) == "Top"
|
||||
assert categorize(0.6, cfg) == "Good"
|
||||
assert categorize(0.4, cfg) == "Partial" # ← Should be Partial!
|
||||
assert categorize(0.2, cfg) == "Low"
|
||||
assert categorize(0.19, cfg) == "Irrelevant"
|
||||
|
||||
# Scores slightly above 0.4 should be Partial
|
||||
assert categorize(0.41, cfg) == "Partial"
|
||||
assert categorize(0.42, cfg) == "Partial"
|
||||
assert categorize(0.45, cfg) == "Partial"
|
||||
assert categorize(0.5, cfg) == "Partial"
|
||||
|
||||
# Scores between 0.2 and 0.4 should be Low
|
||||
assert categorize(0.21, cfg) == "Low"
|
||||
assert categorize(0.3, cfg) == "Low"
|
||||
assert categorize(0.39, cfg) == "Low"
|
||||
|
||||
# Scores below 0.2 should be Irrelevant
|
||||
assert categorize(0.15, cfg) == "Irrelevant"
|
||||
assert categorize(0.1, cfg) == "Irrelevant"
|
||||
|
||||
|
||||
def test_compute_overall_with_user_config() -> None:
|
||||
"""Test overall score computation with user's actual config (competence_weight=1.0)."""
|
||||
cfg = MatchingConfig(
|
||||
competence_weight=1.0,
|
||||
role_weight=0.0,
|
||||
thresholds=MatchingThresholds(
|
||||
top=0.8,
|
||||
good=0.6,
|
||||
partial=0.4,
|
||||
low=0.2,
|
||||
),
|
||||
)
|
||||
|
||||
# When role_weight=0, overall score should equal competence score
|
||||
b = compute_overall(competence_score=0.45, role_score=0.0, cfg=cfg)
|
||||
assert b.overall_score == 0.45
|
||||
assert b.category == "Partial", f"Score 0.45 should be Partial, got {b.category}"
|
||||
|
||||
b = compute_overall(competence_score=0.4, role_score=0.0, cfg=cfg)
|
||||
assert b.overall_score == 0.4
|
||||
assert b.category == "Partial", f"Score 0.4 should be Partial, got {b.category}"
|
||||
|
||||
b = compute_overall(competence_score=0.39, role_score=0.5, cfg=cfg)
|
||||
assert b.overall_score == 0.39 # role is ignored (weight=0)
|
||||
assert b.category == "Low", f"Score 0.39 should be Low, got {b.category}"
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from teamlandkarte_mcp.cache.search_cache import SearchCache
|
||||
|
||||
|
||||
def test_filter_id_increments_under_same_search_id() -> None:
|
||||
cache = SearchCache(ttl_minutes=60, max_size=10)
|
||||
search_id = cache.store_search(
|
||||
task_id=None, requirements={}, results={"by_category": {}}
|
||||
)
|
||||
|
||||
filter_id_1 = cache.add_filter(
|
||||
search_id, filtered_results={"by_category": {}}, filter_meta={}
|
||||
)
|
||||
filter_id_2 = cache.add_filter(
|
||||
search_id, filtered_results={"by_category": {}}, filter_meta={}
|
||||
)
|
||||
|
||||
assert filter_id_1 == "filter-1"
|
||||
assert filter_id_2 == "filter-2"
|
||||
|
||||
entry = cache.get(search_id)
|
||||
assert entry is not None
|
||||
assert filter_id_1 in entry.filters
|
||||
assert filter_id_2 in entry.filters
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Team-search coverage (Profile_Type="team")
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The SearchCache itself is search-type-agnostic - it stores whatever
|
||||
# ``results`` payload it receives. The tests below exercise the same
|
||||
# cache primitives (``store_search``, ``add_filter``, ``get``) for
|
||||
# ``team_search`` payloads in both ``matching_method`` flavours
|
||||
# (``score`` and ``llm_fulltext``) so the entire cache surface is
|
||||
# covered for both ``Profile_Type`` values.
|
||||
#
|
||||
# _Requirements: 8.1, 8.2, 8.3, 12.7_
|
||||
|
||||
|
||||
def _team_payload(matching_method: str) -> dict:
|
||||
"""Return a minimal team_search payload mimicking the shapes that
|
||||
``find_matching_teams`` persists in either ``matching_method`` mode.
|
||||
"""
|
||||
base_team = {
|
||||
"team_id": "t1",
|
||||
"ouid": "ou-1",
|
||||
"team_name": "Team Alpha",
|
||||
"focus_name": "Backend Developer",
|
||||
"about_us": "",
|
||||
"offerings": "",
|
||||
"interests": "",
|
||||
"competences": [{"name": "python", "top_competency": True}],
|
||||
"references": [],
|
||||
"category": "Top",
|
||||
}
|
||||
if matching_method == "score":
|
||||
scored_team = {
|
||||
**base_team,
|
||||
"competence_score": 1.0,
|
||||
"role_score": 1.0,
|
||||
"overall_score": 1.0,
|
||||
}
|
||||
return {
|
||||
"search_type": "team_search",
|
||||
"matching_method": "score",
|
||||
"reference": {
|
||||
"availability_date_start": None,
|
||||
"availability_date_end": None,
|
||||
},
|
||||
"summary": {"Top": 1, "Good": 0, "Partial": 0, "Low": 0,
|
||||
"Irrelevant": 0},
|
||||
"by_category": {
|
||||
"Top": [scored_team],
|
||||
"Good": [],
|
||||
"Partial": [],
|
||||
"Low": [],
|
||||
"Irrelevant": [],
|
||||
},
|
||||
}
|
||||
# llm_fulltext
|
||||
llm_team = {**base_team, "rationale": "Backend match."}
|
||||
return {
|
||||
"search_type": "team_search",
|
||||
"matching_method": "llm_fulltext",
|
||||
"reference": {
|
||||
"availability_date_start": None,
|
||||
"availability_date_end": None,
|
||||
},
|
||||
"summary": {"Top": 1, "Good": 0, "Partial": 0, "Low": 0,
|
||||
"Irrelevant": 0},
|
||||
"by_category": {
|
||||
"Top": [llm_team],
|
||||
"Good": [],
|
||||
"Partial": [],
|
||||
"Low": [],
|
||||
"Irrelevant": [],
|
||||
},
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
|
||||
def test_team_search_cache_roundtrip_score_mode() -> None:
|
||||
"""``team_search`` payloads round-trip through the cache and keep
|
||||
their ``search_type``/``matching_method`` markers (Anforderungen
|
||||
8.1, 8.2, 8.3)."""
|
||||
cache = SearchCache(ttl_minutes=60, max_size=10)
|
||||
payload = _team_payload("score")
|
||||
|
||||
search_id = cache.store_search(
|
||||
task_id=None,
|
||||
requirements={"role_name": "Backend Developer",
|
||||
"competences": ["python"]},
|
||||
results=payload,
|
||||
)
|
||||
|
||||
entry = cache.get(search_id)
|
||||
assert entry is not None
|
||||
assert entry.results["search_type"] == "team_search"
|
||||
assert entry.results["matching_method"] == "score"
|
||||
# The cached items keep the team-specific fields used by the
|
||||
# team-search column layout (Anforderung 8.4).
|
||||
top = entry.results["by_category"]["Top"]
|
||||
assert top and top[0]["team_id"] == "t1"
|
||||
assert "competence_score" in top[0]
|
||||
assert "role_score" in top[0]
|
||||
assert "overall_score" in top[0]
|
||||
|
||||
|
||||
def test_team_search_cache_roundtrip_llm_fulltext_mode() -> None:
|
||||
"""``team_search`` LLM-mode payloads survive a cache round trip
|
||||
including the ``rationale`` field used by ``Begründung``."""
|
||||
cache = SearchCache(ttl_minutes=60, max_size=10)
|
||||
payload = _team_payload("llm_fulltext")
|
||||
|
||||
search_id = cache.store_search(
|
||||
task_id=None,
|
||||
requirements={"role_name": "Backend Developer",
|
||||
"competences": ["python"]},
|
||||
results=payload,
|
||||
)
|
||||
|
||||
entry = cache.get(search_id)
|
||||
assert entry is not None
|
||||
assert entry.results["search_type"] == "team_search"
|
||||
assert entry.results["matching_method"] == "llm_fulltext"
|
||||
# ``errors`` list is preserved for the LLM-fulltext path
|
||||
# (Anforderung 7.6).
|
||||
assert entry.results.get("errors") == []
|
||||
top = entry.results["by_category"]["Top"]
|
||||
assert top and top[0]["team_id"] == "t1"
|
||||
assert top[0]["category"] == "Top"
|
||||
assert top[0]["rationale"] == "Backend match."
|
||||
|
||||
|
||||
def test_team_search_filter_ids_increment_independently() -> None:
|
||||
"""``add_filter`` produces incrementing ids for ``team_search``
|
||||
entries just like for capacity searches.
|
||||
"""
|
||||
cache = SearchCache(ttl_minutes=60, max_size=10)
|
||||
search_id = cache.store_search(
|
||||
task_id=None,
|
||||
requirements={"role_name": "Backend Developer",
|
||||
"competences": ["python"]},
|
||||
results=_team_payload("score"),
|
||||
)
|
||||
|
||||
fid1 = cache.add_filter(
|
||||
search_id,
|
||||
filtered_results={"by_category": {}, "search_type": "team_search",
|
||||
"matching_method": "score"},
|
||||
filter_meta={"role_filter": "Backend"},
|
||||
)
|
||||
fid2 = cache.add_filter(
|
||||
search_id,
|
||||
filtered_results={"by_category": {}, "search_type": "team_search",
|
||||
"matching_method": "score"},
|
||||
filter_meta={"role_filter": "Frontend"},
|
||||
)
|
||||
|
||||
assert fid1 == "filter-1"
|
||||
assert fid2 == "filter-2"
|
||||
|
||||
entry = cache.get(search_id)
|
||||
assert entry is not None
|
||||
# The filter payloads keep the team_search marker independent of
|
||||
# the parent entry, so downstream consumers can distinguish them.
|
||||
for fid in (fid1, fid2):
|
||||
fdata = entry.filters[fid]
|
||||
assert fdata["results"]["search_type"] == "team_search"
|
||||
assert fdata["results"]["matching_method"] == "score"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.cache.search_cache import SearchCache
|
||||
|
||||
|
||||
def test_cache_diagnostics_has_instance_id_and_ttl() -> None:
|
||||
cache = SearchCache(ttl_minutes=1, max_size=2, enable_diagnostics=True)
|
||||
diag = cache.diagnostics()
|
||||
assert isinstance(diag["instance_id"], str)
|
||||
assert diag["ttl_seconds"] == 60
|
||||
assert diag["max_size"] == 2
|
||||
assert diag["size"] == 0
|
||||
|
||||
|
||||
def test_ttl_like_miss_leads_to_none() -> None:
|
||||
cache = SearchCache(ttl_minutes=1, max_size=10, enable_diagnostics=True)
|
||||
search_id = cache.store_search(
|
||||
task_id=None, requirements={}, results={"by_category": {}}
|
||||
)
|
||||
|
||||
# Real-time TTL expiry would require sleeping > 60s. Instead, we assert
|
||||
# miss semantics by removing the entry and verifying get() returns None.
|
||||
cache._cache.pop(search_id, None) # type: ignore[attr-defined]
|
||||
|
||||
assert cache.get(search_id) is None
|
||||
|
||||
|
||||
def test_max_size_eviction_can_remove_search_id() -> None:
|
||||
cache = SearchCache(ttl_minutes=60, max_size=1, enable_diagnostics=True)
|
||||
first = cache.store_search(
|
||||
task_id=None, requirements={}, results={"by_category": {}}
|
||||
)
|
||||
_ = cache.store_search(task_id=None, requirements={}, results={"by_category": {}})
|
||||
assert cache.get(first) is None
|
||||
|
||||
|
||||
def test_add_filter_unknown_search_id_raises_keyerror() -> None:
|
||||
cache = SearchCache(ttl_minutes=60, max_size=10, enable_diagnostics=True)
|
||||
with pytest.raises(KeyError):
|
||||
cache.add_filter("does-not-exist", filtered_results={}, filter_meta={})
|
||||
diag = cache.diagnostics()
|
||||
assert diag["misses"] >= 0
|
||||
@@ -0,0 +1,134 @@
|
||||
# Feature: llm-fulltext-matching, Property 8: Ungekürzte Rationale wird persistiert
|
||||
"""Property-based test verifying that long rationales are persisted verbatim.
|
||||
|
||||
The MCP server stores LLM matcher results into ``SearchCache`` by merging
|
||||
the source ``Capacity``/``Task`` payload with ``category`` and
|
||||
``rationale`` fields from each :class:`LlmFulltextItem` (see
|
||||
``mcp_server.find_matching_capacities``). The persistence step is a
|
||||
simple pass-through:
|
||||
|
||||
merged = dict(it.raw)
|
||||
merged["rationale"] = it.rationale
|
||||
|
||||
That means the LLM-returned rationale on the
|
||||
:class:`~teamlandkarte_mcp.matching.llm_fulltext_matcher.LlmFulltextItem`
|
||||
must already be byte-for-byte identical to the LLM payload. This test
|
||||
exercises the matcher directly with a fake LLM that echoes long
|
||||
rationales (>= 300 chars) and asserts the resulting item carries the
|
||||
same string back.
|
||||
|
||||
Testing at the matcher level is sufficient evidence for requirement 8.6:
|
||||
the cache pass-through cannot introduce truncation if the source item
|
||||
already holds the unmodified text. The table-rendering helper
|
||||
``_format_rationale_for_table`` is covered separately by the Property 7
|
||||
test (``test_format_rationale_pbt.py``).
|
||||
|
||||
**Validates: Requirements 8.6**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
|
||||
from teamlandkarte_mcp.matching.profiles import TaskProfile
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
|
||||
# Exclude characters that would be mangled by JSON serialization or that
|
||||
# Hypothesis would otherwise generate (lone surrogates, control chars,
|
||||
# line/paragraph separators). The remaining alphabet is wide enough to
|
||||
# exercise multi-byte text while staying round-trippable through
|
||||
# ``json.dumps``/``json.loads``.
|
||||
_RATIONALE_STRATEGY = st.text(
|
||||
alphabet=st.characters(
|
||||
blacklist_categories=("Cs", "Cc", "Zl", "Zp"),
|
||||
),
|
||||
min_size=300,
|
||||
max_size=400,
|
||||
)
|
||||
|
||||
|
||||
class _StubDb:
|
||||
"""Minimal ``DBClient`` double returning empty extras for every id."""
|
||||
|
||||
def batch_get_capacity_descriptions(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, str | None]:
|
||||
return {str(i): None for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_certificates(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[str]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
def batch_get_capacity_references(
|
||||
self, capacity_ids: list[Any]
|
||||
) -> dict[str, list[dict]]:
|
||||
return {str(i): [] for i in capacity_ids}
|
||||
|
||||
|
||||
class _EchoLlm:
|
||||
"""Returns the supplied rationale wrapped in a ``Top`` JSON payload."""
|
||||
|
||||
def __init__(self, rationale: str) -> None:
|
||||
self._rationale = rationale
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
return json.dumps(
|
||||
{"category": "Top", "rationale": self._rationale},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
def _capacity() -> Capacity:
|
||||
return Capacity(
|
||||
id=1,
|
||||
owner_name="o",
|
||||
role_name="R",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=[],
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(rationale=_RATIONALE_STRATEGY)
|
||||
def test_long_rationale_is_persisted_unchanged(rationale: str) -> None:
|
||||
"""Property 8: matcher returns the rationale verbatim, length >= 300.
|
||||
|
||||
The matcher's per-item ``rationale`` is what the MCP server merges
|
||||
into the persisted SearchCache payload, so equality at this layer
|
||||
proves the rationale survives the pass-through unchanged.
|
||||
"""
|
||||
matcher = LlmFulltextMatcher(
|
||||
db=_StubDb(),
|
||||
client=_EchoLlm(rationale),
|
||||
)
|
||||
task_profile = TaskProfile(id="t", title="T", description="D", skills=[])
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_capacities(
|
||||
task_profile=task_profile,
|
||||
capacities=[_capacity()],
|
||||
)
|
||||
)
|
||||
|
||||
assert result.errors == [], f"unexpected errors: {result.errors}"
|
||||
items = result.by_category["Top"]
|
||||
assert len(items) == 1, "expected exactly one Top item"
|
||||
|
||||
item = items[0]
|
||||
assert item.category == "Top"
|
||||
# The rationale must be bit-identical to what the LLM returned, even
|
||||
# though the input is well above the 280-char display limit.
|
||||
assert item.rationale == rationale, (
|
||||
"rationale was modified between LLM response and matcher item"
|
||||
)
|
||||
assert len(item.rationale) >= 300
|
||||
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
from typing import Any, cast
|
||||
|
||||
from teamlandkarte_mcp.matching.similarity import (
|
||||
SimilarityEngine,
|
||||
)
|
||||
from teamlandkarte_mcp.matching.bm25 import Bm25Index
|
||||
|
||||
|
||||
class _DummyClient: # pragma: no cover
|
||||
"""Minimal stub for AzureOpenAIClient."""
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
return json.dumps({"similarity": 0.5})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BM25 + RRF mode (now the only competence similarity path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bm25_engine(**kwargs: Any) -> SimilarityEngine:
|
||||
"""Create a SimilarityEngine (BM25 is now always active)."""
|
||||
return SimilarityEngine(
|
||||
client=_DummyClient(), # type: ignore[arg-type]
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bm25_exact_match_scores_one() -> None:
|
||||
"""Required 'Python', candidate ['Python'] → score 1.0 with global index.
|
||||
|
||||
A global index over five distinct skills ensures 'python' has a positive
|
||||
IDF, so the exact-match document receives a positive BM25 score and RRF
|
||||
normalizes the single-result list to 1.0.
|
||||
"""
|
||||
engine = _bm25_engine()
|
||||
global_index = Bm25Index(corpus=["Python", "JavaScript", "Java", "Go", "Ruby"])
|
||||
result = await engine.compute_competence_similarity(
|
||||
required=["Python"],
|
||||
candidate=["Python"],
|
||||
global_index=global_index,
|
||||
)
|
||||
assert result["Python"]["score"] == pytest.approx(1.0)
|
||||
assert result["Python"]["best_match"] == "Python"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bm25_no_overlap_scores_zero() -> None:
|
||||
"""Required 'Python', candidate ['JavaScript'] → score 0.0."""
|
||||
engine = _bm25_engine()
|
||||
result = await engine.compute_competence_similarity(
|
||||
required=["Python"],
|
||||
candidate=["JavaScript"],
|
||||
)
|
||||
assert result["Python"]["score"] == 0.0
|
||||
assert result["Python"]["best_match"] is None
|
||||
assert "no token overlap" in str(result["Python"]["rationale"]).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bm25_multiple_required_no_overlap_all_zero() -> None:
|
||||
"""Required ['Python', 'ML'], candidate JS-only → both scores 0.0."""
|
||||
engine = _bm25_engine()
|
||||
result = await engine.compute_competence_similarity(
|
||||
required=["Python", "Machine Learning"],
|
||||
candidate=["JavaScript", "TypeScript"],
|
||||
)
|
||||
assert result["Python"]["score"] == 0.0
|
||||
assert result["Machine Learning"]["score"] == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bm25_multiple_required_all_match() -> None:
|
||||
"""Required ['Python', 'ML'], candidate exact match → both scores 1.0.
|
||||
|
||||
A global index over five distinct skills ensures all query tokens have
|
||||
positive IDF, so both exact-match documents score 1.0 after RRF.
|
||||
"""
|
||||
engine = _bm25_engine()
|
||||
global_index = Bm25Index(
|
||||
corpus=["Python", "Machine Learning", "JavaScript", "TypeScript", "Java"]
|
||||
)
|
||||
result = await engine.compute_competence_similarity(
|
||||
required=["Python", "Machine Learning"],
|
||||
candidate=["Python", "Machine Learning"],
|
||||
global_index=global_index,
|
||||
)
|
||||
assert result["Python"]["score"] == pytest.approx(1.0)
|
||||
assert result["Machine Learning"]["score"] == pytest.approx(1.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bm25_no_candidates_returns_zero() -> None:
|
||||
"""Empty candidate list → score 0.0 for all required."""
|
||||
engine = _bm25_engine()
|
||||
result = await engine.compute_competence_similarity(
|
||||
required=["Python"],
|
||||
candidate=[],
|
||||
)
|
||||
assert result["Python"]["score"] == 0.0
|
||||
assert result["Python"]["best_match"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bm25_rationale_contains_bm25_rrf_on_match() -> None:
|
||||
"""Rationale mentions 'BM25+RRF' when a match is found.
|
||||
|
||||
Uses a global index so the matched document receives a positive BM25
|
||||
score and RRF fusion produces a non-zero result with the expected
|
||||
rationale prefix.
|
||||
"""
|
||||
engine = _bm25_engine()
|
||||
global_index = Bm25Index(
|
||||
corpus=["Python", "Java", "JavaScript", "Go", "Ruby"]
|
||||
)
|
||||
result = await engine.compute_competence_similarity(
|
||||
required=["Python"],
|
||||
candidate=["Python", "Java"],
|
||||
global_index=global_index,
|
||||
)
|
||||
assert "BM25+RRF" in str(result["Python"]["rationale"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BM25 + auto-tagging (mocked AutoTagger)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bm25_auto_tagging_expands_candidate_list() -> None:
|
||||
"""AutoTagger injects 'Machine Learning' into ['ML'] → score > 0.
|
||||
|
||||
A global index is provided that includes 'Machine Learning' (simulating
|
||||
it existing in another candidate's original skill list in the pool).
|
||||
After expansion the working list contains 'Machine Learning', which is
|
||||
also in the global corpus, so BM25 can assign it a positive IDF score.
|
||||
"""
|
||||
mock_tagger = MagicMock()
|
||||
mock_tagger.expand_competences = AsyncMock(
|
||||
return_value=["ML", "Machine Learning"]
|
||||
)
|
||||
|
||||
engine = _bm25_engine(use_auto_tagging=True, auto_tagger=mock_tagger)
|
||||
global_index = Bm25Index(
|
||||
corpus=["ML", "Machine Learning", "Python", "JavaScript", "Java"]
|
||||
)
|
||||
result = await engine.compute_competence_similarity(
|
||||
required=["Machine Learning"],
|
||||
candidate=["ML"],
|
||||
global_index=global_index,
|
||||
)
|
||||
assert float(cast(Any, result["Machine Learning"]["score"])) > 0.0
|
||||
mock_tagger.expand_competences.assert_called_once_with(
|
||||
["Machine Learning"], ["ML"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bm25_auto_tagging_disabled_uses_raw_candidate() -> None:
|
||||
"""When use_auto_tagging=False, AutoTagger is never called."""
|
||||
mock_tagger = MagicMock()
|
||||
mock_tagger.expand_competences = AsyncMock()
|
||||
|
||||
engine = _bm25_engine(use_auto_tagging=False, auto_tagger=mock_tagger)
|
||||
await engine.compute_competence_similarity(
|
||||
required=["Machine Learning"],
|
||||
candidate=["ML"],
|
||||
)
|
||||
mock_tagger.expand_competences.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bm25_global_index_filters_to_candidate_subset() -> None:
|
||||
"""Global index is queried and results filtered to the candidate's own skills.
|
||||
|
||||
A global corpus of seven skills is provided; the candidate only knows
|
||||
'Python'. The engine must return a positive score for the 'Python'
|
||||
required competence because the global index assigns it positive IDF,
|
||||
and all other global corpus items are excluded by the filter.
|
||||
"""
|
||||
engine = _bm25_engine()
|
||||
global_index = Bm25Index(
|
||||
corpus=["Python", "JavaScript", "Java", "Go", "Ruby", "Rust", "TypeScript"]
|
||||
)
|
||||
result = await engine.compute_competence_similarity(
|
||||
required=["Python"],
|
||||
candidate=["Python"],
|
||||
global_index=global_index,
|
||||
)
|
||||
assert result["Python"]["score"] == pytest.approx(1.0)
|
||||
assert result["Python"]["best_match"] == "Python"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM-based role similarity
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_similarity_bad_roles_return_zero() -> None:
|
||||
"""None, empty, and '(unknown)' roles return 0.0 without LLM call."""
|
||||
engine = _bm25_engine()
|
||||
assert await engine.compute_role_similarity(None, "Developer") == 0.0
|
||||
assert await engine.compute_role_similarity("Developer", None) == 0.0
|
||||
assert await engine.compute_role_similarity("", "Developer") == 0.0
|
||||
assert await engine.compute_role_similarity("Developer", "") == 0.0
|
||||
assert await engine.compute_role_similarity(" ", "Developer") == 0.0
|
||||
assert await engine.compute_role_similarity("(unknown)", "Developer") == 0.0
|
||||
assert await engine.compute_role_similarity("(Unknown)", "Developer") == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_similarity_identical_roles_return_one() -> None:
|
||||
"""Identical roles (case-insensitive) return 1.0 without LLM call."""
|
||||
engine = _bm25_engine()
|
||||
assert await engine.compute_role_similarity("Developer", "Developer") == 1.0
|
||||
assert await engine.compute_role_similarity("Developer", "developer") == 1.0
|
||||
assert await engine.compute_role_similarity("BACKEND DEV", "backend dev") == 1.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_similarity_calls_llm_and_parses_response() -> None:
|
||||
"""LLM returns a similarity score that is parsed and returned."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.chat_completion = AsyncMock(
|
||||
return_value=json.dumps({"similarity": 0.75})
|
||||
)
|
||||
|
||||
engine = SimilarityEngine(client=mock_client)
|
||||
score = await engine.compute_role_similarity("Backend Developer", "Software Engineer")
|
||||
assert score == pytest.approx(0.75)
|
||||
mock_client.chat_completion.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_similarity_caches_result_symmetrically() -> None:
|
||||
"""Second call with swapped roles uses cache, no second LLM call."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.chat_completion = AsyncMock(
|
||||
return_value=json.dumps({"similarity": 0.8})
|
||||
)
|
||||
|
||||
engine = SimilarityEngine(client=mock_client)
|
||||
score1 = await engine.compute_role_similarity("Role A", "Role B")
|
||||
score2 = await engine.compute_role_similarity("Role B", "Role A")
|
||||
|
||||
assert score1 == 0.8
|
||||
assert score2 == 0.8
|
||||
# Only one LLM call should have been made
|
||||
assert mock_client.chat_completion.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_similarity_clamps_score() -> None:
|
||||
"""Scores outside [0, 1] are clamped."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.chat_completion = AsyncMock(
|
||||
return_value=json.dumps({"similarity": 1.5})
|
||||
)
|
||||
|
||||
engine = SimilarityEngine(client=mock_client)
|
||||
score = await engine.compute_role_similarity("A", "B")
|
||||
assert score == 1.0
|
||||
|
||||
# Reset for negative test
|
||||
mock_client.chat_completion = AsyncMock(
|
||||
return_value=json.dumps({"similarity": -0.3})
|
||||
)
|
||||
engine2 = SimilarityEngine(client=mock_client)
|
||||
score2 = await engine2.compute_role_similarity("C", "D")
|
||||
assert score2 == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_similarity_exception_returns_zero() -> None:
|
||||
"""On any exception, return 0.0 and do not cache."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.chat_completion = AsyncMock(side_effect=RuntimeError("API down"))
|
||||
|
||||
engine = SimilarityEngine(client=mock_client)
|
||||
score = await engine.compute_role_similarity("A", "B")
|
||||
assert score == 0.0
|
||||
|
||||
# Cache should be empty (failed results not cached)
|
||||
assert len(engine._role_similarity_cache) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_similarity_invalid_json_returns_zero() -> None:
|
||||
"""Invalid JSON from LLM returns 0.0."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.chat_completion = AsyncMock(return_value="not json at all")
|
||||
|
||||
engine = SimilarityEngine(client=mock_client)
|
||||
score = await engine.compute_role_similarity("A", "B")
|
||||
assert score == 0.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_role_similarity_missing_key_returns_zero() -> None:
|
||||
"""JSON without 'similarity' key returns 0.0."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.chat_completion = AsyncMock(
|
||||
return_value=json.dumps({"score": 0.9}) # wrong key
|
||||
)
|
||||
|
||||
engine = SimilarityEngine(client=mock_client)
|
||||
score = await engine.compute_role_similarity("A", "B")
|
||||
assert score == 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# clear_role_cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_role_cache() -> None:
|
||||
"""clear_role_cache empties the cache."""
|
||||
mock_client = AsyncMock()
|
||||
mock_client.chat_completion = AsyncMock(
|
||||
return_value=json.dumps({"similarity": 0.6})
|
||||
)
|
||||
|
||||
engine = SimilarityEngine(client=mock_client)
|
||||
await engine.compute_role_similarity("A", "B")
|
||||
assert len(engine._role_similarity_cache) == 1
|
||||
|
||||
engine.clear_role_cache()
|
||||
assert len(engine._role_similarity_cache) == 0
|
||||
@@ -0,0 +1,12 @@
|
||||
"""TaskAnalyzer tests removed.
|
||||
|
||||
The chat-based TaskAnalyzer was retired in Phase 7 (Azure chat removal).
|
||||
Requirement extraction now uses structured input collection.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="TaskAnalyzer retired (Azure chat removed)")
|
||||
def test_task_analyzer_retired() -> None:
|
||||
assert True
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Tests for deprecated task helpers.
|
||||
|
||||
The legacy helper `extract_requirements_from_task` was removed in Phase 7 when
|
||||
Azure chat support was retired.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="task_helpers.extract_requirements_from_task deprecated")
|
||||
def test_task_helpers_deprecated() -> None:
|
||||
assert True
|
||||
@@ -0,0 +1,317 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"),
|
||||
str,
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(
|
||||
srv: Any,
|
||||
name: str,
|
||||
args: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
if args is None:
|
||||
args = {}
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_search_filters_apply_only_to_task_search(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
|
||||
@dataclass
|
||||
class _Task:
|
||||
id: str
|
||||
name: str | None = None
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
created_date: datetime = datetime(2025, 1, 1)
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
skills: list[str] = None # type: ignore[assignment]
|
||||
|
||||
def __post_init__(self):
|
||||
if self.skills is None:
|
||||
self.skills = []
|
||||
|
||||
class _FakeDB:
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["Frontend Developer", "Backend Engineer"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["React", "TypeScript", "Python", "PostgreSQL"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
tasks = [
|
||||
_Task(
|
||||
id="T1",
|
||||
title="Frontend Developer",
|
||||
description="React and TypeScript",
|
||||
created_date=datetime(2025, 1, 1),
|
||||
start_date=date(2025, 5, 1),
|
||||
end_date=date(2025, 7, 31),
|
||||
skills=["React", "TypeScript"],
|
||||
),
|
||||
_Task(
|
||||
id="T2",
|
||||
title="Backend Engineer",
|
||||
description="Python and PostgreSQL",
|
||||
created_date=datetime(2025, 1, 1),
|
||||
start_date=date(2025, 5, 1),
|
||||
end_date=date(2025, 7, 31),
|
||||
skills=["Python"],
|
||||
),
|
||||
]
|
||||
return tasks if limit == 0 else tasks[:limit]
|
||||
|
||||
def get_task_by_id(self, task_id: str): # pragma: no cover
|
||||
return None
|
||||
|
||||
def get_task_by_name(self, name: str): # pragma: no cover
|
||||
return None
|
||||
|
||||
def get_all_capacities_with_competences(self): # pragma: no cover
|
||||
return []
|
||||
|
||||
def get_recent_free_capacities(
|
||||
self,
|
||||
limit: int = 20,
|
||||
): # pragma: no cover
|
||||
return []
|
||||
|
||||
def get_capacity_by_id(self, capacity_id):
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
return Capacity(
|
||||
id=1,
|
||||
owner_name="A",
|
||||
role_name="Frontend Developer",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 4, 1),
|
||||
end_date=date(2025, 8, 31),
|
||||
competences=["React", "TypeScript"],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_mod,
|
||||
"create_db_client",
|
||||
lambda *_args, **_kwargs: _FakeDB(),
|
||||
)
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
|
||||
# Create a task search.
|
||||
res = await _call_tool(srv, "find_matching_tasks", {"capacity_id": 1})
|
||||
assert "SEARCH_ID=" in res
|
||||
search_id = res.split("SEARCH_ID=")[1].splitlines()[0].strip()
|
||||
|
||||
out = await _call_tool(
|
||||
srv,
|
||||
"filter_search_results",
|
||||
{"search_id": search_id, "task_text_filter": "typescript"},
|
||||
)
|
||||
assert "Filtered total results: 1" in out
|
||||
assert "T1" in out
|
||||
assert "T2" not in out
|
||||
|
||||
out2 = await _call_tool(
|
||||
srv,
|
||||
"filter_search_results",
|
||||
{"search_id": search_id, "task_competence_filter": ["python"]},
|
||||
)
|
||||
assert "Filtered total results: 1" in out2
|
||||
assert "T2" in out2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_fully_available_uses_stored_reference_dates(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
from datetime import date
|
||||
|
||||
from teamlandkarte_mcp.models import Capacity
|
||||
|
||||
class _FakeDB:
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["X"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["A"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_capacities_with_competences(self):
|
||||
return [
|
||||
Capacity(
|
||||
id=1,
|
||||
owner_name="A",
|
||||
role_name="X",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 2, 1),
|
||||
end_date=date(2025, 7, 31),
|
||||
competences=[],
|
||||
),
|
||||
Capacity(
|
||||
id=2,
|
||||
owner_name="B",
|
||||
role_name="X",
|
||||
role_level=None,
|
||||
begin_date=date(2025, 3, 15),
|
||||
end_date=date(2025, 6, 15),
|
||||
competences=[],
|
||||
),
|
||||
]
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_mod,
|
||||
"create_db_client",
|
||||
lambda *_args, **_kwargs: _FakeDB(),
|
||||
)
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"date_start": "2025-03-01",
|
||||
"date_end": "2025-06-30",
|
||||
},
|
||||
)
|
||||
assert "SEARCH_ID=" in res
|
||||
search_id = res.split("SEARCH_ID=")[1].splitlines()[0].strip()
|
||||
|
||||
out = await _call_tool(
|
||||
srv,
|
||||
"filter_search_results",
|
||||
{"search_id": search_id, "is_fully_available": True},
|
||||
)
|
||||
|
||||
assert "Filtered total results: 1" in out
|
||||
assert "| 1 |" in out
|
||||
assert "| 2 |" not in out
|
||||
@@ -0,0 +1,369 @@
|
||||
# Feature: team-profile-matching, Property 4: Kompetenz-Batch ist konsistent zur Einzel-Variante
|
||||
"""Property-based test for the batch/single consistency of the team
|
||||
competence queries on ``TrinoClient``.
|
||||
|
||||
Validates Property 4 from the team-profile-matching design:
|
||||
|
||||
*Für jede* Liste von OUIDs ``ouids`` und jede Stub-DB-Antwort gilt:
|
||||
``batch_get_team_competences(ouids)[ouid]`` ist für jedes ``ouid`` in
|
||||
``ouids`` gleich ``get_team_competences(ouid)``. Außerdem gilt für jeden
|
||||
Eintrag der Ergebnislisten:
|
||||
|
||||
- ``top_competency`` ist ``False``, wenn die Quellzeile ``NULL`` enthielt
|
||||
(Anforderung 3.5),
|
||||
- Einträge ohne auflösbaren Kompetenz-Namen sind nicht enthalten
|
||||
(Anforderung 3.6),
|
||||
- die Reihenfolge ist ``(top_competency desc, name asc)`` und damit
|
||||
deterministisch (Anforderung 3.7),
|
||||
- für ``ouid``-Werte ohne Kompetenzen ist die Liste leer
|
||||
(Anforderung 3.4).
|
||||
|
||||
The test installs a stub cursor that simulates the SQL ``ORDER BY`` and
|
||||
``COALESCE`` semantics for both the single (``WHERE tc.ouid = ?``) and
|
||||
the batch (``WHERE tc.ouid IN (?, ?, ...)``) form. The simulated cursor
|
||||
holds an in-memory list of competence rows
|
||||
``(ouid, name, top_competency)`` where ``name`` may be ``None`` or empty
|
||||
(to exercise the Python-side filter, Requirement 3.6) and
|
||||
``top_competency`` may be ``None`` (to exercise the COALESCE
|
||||
normalisation, Requirement 3.5).
|
||||
|
||||
**Validates: Requirements 3.1, 3.3, 3.4, 3.5, 3.6, 3.7**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.database.trino_client import TrinoClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub-DB row container
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubDB:
|
||||
"""Holds the in-memory competence rows for the fake cursor.
|
||||
|
||||
Each row is a ``(ouid, name, top_competency)`` tuple. ``name`` may
|
||||
be ``None`` (unresolvable competence_id) or ``""`` (empty resolved
|
||||
name); both must be filtered out by the Trino client. ``top``
|
||||
may be ``None`` (NULL in the source view) and must be normalised
|
||||
to ``False``.
|
||||
"""
|
||||
|
||||
def __init__(self, rows: list[tuple[str, Any, Any]]) -> None:
|
||||
self.rows = rows
|
||||
|
||||
|
||||
def _top_norm(value: Any) -> bool:
|
||||
"""Mirror ``COALESCE(tc.top_competency, FALSE)`` on the SQL side."""
|
||||
|
||||
if value is None:
|
||||
return False
|
||||
return bool(value)
|
||||
|
||||
|
||||
def _name_sort_key(name: Any) -> tuple[int, str]:
|
||||
"""Order keys with ``None`` last, then by string ascending.
|
||||
|
||||
Trino orders ``NULL`` last for ``ASC``; we mirror that here so the
|
||||
simulated cursor produces a deterministic order even for the rows
|
||||
that the Python-side filter will subsequently drop.
|
||||
"""
|
||||
|
||||
if name is None:
|
||||
return (1, "")
|
||||
return (0, str(name))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake cursor / connection plumbing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
"""Stub cursor that dispatches by SQL substring match.
|
||||
|
||||
For the team-competence SQL the cursor inspects whether the
|
||||
statement contains ``WHERE tc.ouid = ?`` (single variant) or
|
||||
``WHERE tc.ouid IN`` (batch variant) and returns rows in the
|
||||
column shape the production code expects.
|
||||
|
||||
Single variant column shape: ``(name, top_competency)``.
|
||||
Batch variant column shape: ``(ouid, name, top_competency)``.
|
||||
"""
|
||||
|
||||
def __init__(self, stub: _StubDB) -> None:
|
||||
self.stub = stub
|
||||
self._next_rows: list[tuple] = []
|
||||
|
||||
def execute(self, sql: str, params: Any = None) -> None: # noqa: ANN401
|
||||
if params is None:
|
||||
params_tuple: tuple[str, ...] = ()
|
||||
elif isinstance(params, tuple):
|
||||
params_tuple = tuple(str(p) for p in params)
|
||||
else:
|
||||
params_tuple = tuple(str(p) for p in params)
|
||||
|
||||
if (
|
||||
"teamlandkarte_v_teammeter_team_competences_latest" not in sql
|
||||
):
|
||||
self._next_rows = []
|
||||
return
|
||||
|
||||
if "WHERE tc.ouid IN" in sql:
|
||||
self._next_rows = self._simulate_batch(params_tuple)
|
||||
return
|
||||
|
||||
if "WHERE tc.ouid = ?" in sql:
|
||||
self._next_rows = self._simulate_single(params_tuple)
|
||||
return
|
||||
|
||||
# Defensive default: the production code only emits the two
|
||||
# shapes above, so any other shape is unexpected.
|
||||
self._next_rows = []
|
||||
|
||||
def _simulate_single(
|
||||
self, params: tuple[str, ...]
|
||||
) -> list[tuple]:
|
||||
"""Simulate the single-OUID query.
|
||||
|
||||
Mirrors the SQL ``ORDER BY CASE WHEN COALESCE(top, FALSE)
|
||||
THEN 0 ELSE 1 END ASC, c.name ASC`` and emits the two-column
|
||||
result shape used by ``get_team_competences``.
|
||||
"""
|
||||
|
||||
target = params[0] if params else ""
|
||||
rows = [r for r in self.stub.rows if r[0] == target]
|
||||
rows.sort(
|
||||
key=lambda r: (
|
||||
0 if _top_norm(r[2]) else 1,
|
||||
_name_sort_key(r[1]),
|
||||
)
|
||||
)
|
||||
return [(r[1], _top_norm(r[2])) for r in rows]
|
||||
|
||||
def _simulate_batch(
|
||||
self, params: tuple[str, ...]
|
||||
) -> list[tuple]:
|
||||
"""Simulate the batch-OUID query.
|
||||
|
||||
Mirrors the SQL ``ORDER BY tc.ouid ASC, CASE WHEN COALESCE(top,
|
||||
FALSE) THEN 0 ELSE 1 END ASC, c.name ASC`` and emits the
|
||||
three-column result shape used by ``batch_get_team_competences``.
|
||||
"""
|
||||
|
||||
wanted = set(params)
|
||||
rows = [r for r in self.stub.rows if r[0] in wanted]
|
||||
rows.sort(
|
||||
key=lambda r: (
|
||||
str(r[0]),
|
||||
0 if _top_norm(r[2]) else 1,
|
||||
_name_sort_key(r[1]),
|
||||
)
|
||||
)
|
||||
return [(r[0], r[1], _top_norm(r[2])) for r in rows]
|
||||
|
||||
def fetchone(self) -> Any: # noqa: ANN401
|
||||
return self._next_rows[0] if self._next_rows else None
|
||||
|
||||
def fetchall(self) -> list[tuple]:
|
||||
return list(self._next_rows)
|
||||
|
||||
|
||||
class _FakeCursorContext:
|
||||
"""Context-manager wrapper around a single :class:`_FakeCursor`."""
|
||||
|
||||
def __init__(self, cursor: _FakeCursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self) -> _FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
return None
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
"""Minimal ``DatabaseConfig`` stand-in for ``TrinoClient(...)``."""
|
||||
|
||||
host = "x"
|
||||
port = 1
|
||||
username = "u"
|
||||
password = "p"
|
||||
http_scheme = "http"
|
||||
verify_ssl = False
|
||||
catalog = "c"
|
||||
schema = "s"
|
||||
pool_size = 1
|
||||
|
||||
|
||||
def _make_client(stub: _StubDB) -> TrinoClient:
|
||||
"""Build a ``TrinoClient`` whose ``_cursor`` yields a fake cursor."""
|
||||
|
||||
cursor = _FakeCursor(stub)
|
||||
client = TrinoClient(_FakeConfig()) # type: ignore[arg-type]
|
||||
client._cursor = lambda: _FakeCursorContext(cursor) # type: ignore[method-assign]
|
||||
return client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hypothesis strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# A small pool of distinct OUID strings keeps the search space tight and
|
||||
# makes it likely that several rows share the same ouid (so grouping
|
||||
# behaviour and ordering inside a group are exercised).
|
||||
_KNOWN_OUIDS = ("OU1", "OU2", "OU3", "OU4")
|
||||
_UNKNOWN_OUIDS = ("UNKNOWN1", "UNKNOWN2")
|
||||
_BLANK_OUIDS = ("", " ")
|
||||
|
||||
# ``name`` may be ``None``/``""`` (must be filtered) or any non-empty
|
||||
# string. We use a small alphabet so duplicates inside an ouid group
|
||||
# are likely, which exercises the secondary ``name ASC`` sort key.
|
||||
_name_value = st.one_of(
|
||||
st.none(),
|
||||
st.just(""),
|
||||
st.text(
|
||||
alphabet=st.characters(whitelist_categories=("Lu", "Ll", "Nd")),
|
||||
min_size=1,
|
||||
max_size=4,
|
||||
),
|
||||
)
|
||||
|
||||
# ``top_competency`` can be ``None`` (NULL → must normalise to False),
|
||||
# ``True`` or ``False``. Integers (truthy/falsy) are not used because
|
||||
# the production SQL exposes a Boolean column after ``COALESCE``.
|
||||
_top_value = st.one_of(st.none(), st.booleans())
|
||||
|
||||
|
||||
@st.composite
|
||||
def _stub_and_inputs(draw: st.DrawFn) -> tuple[_StubDB, list[str]]:
|
||||
"""Generate stub competence rows and a list of input OUIDs.
|
||||
|
||||
The strategy intentionally mixes:
|
||||
|
||||
- rows for OUIDs in :data:`_KNOWN_OUIDS` (so batch/single both find
|
||||
data),
|
||||
- input OUIDs that resolve to known/unknown/blank values (so
|
||||
Requirements 3.4 and the blank-input short-circuit both fire),
|
||||
- ``None``/``""`` names and ``None`` top values (Requirements 3.5,
|
||||
3.6).
|
||||
|
||||
Duplicate input OUIDs are allowed: the production batch method
|
||||
deduplicates via the output dict's keys, so the test asserts the
|
||||
consistency property only over the set of distinct input OUIDs.
|
||||
"""
|
||||
|
||||
rows = draw(
|
||||
st.lists(
|
||||
st.tuples(
|
||||
st.sampled_from(_KNOWN_OUIDS),
|
||||
_name_value,
|
||||
_top_value,
|
||||
),
|
||||
min_size=0,
|
||||
max_size=15,
|
||||
)
|
||||
)
|
||||
|
||||
candidates = list(_KNOWN_OUIDS) + list(_UNKNOWN_OUIDS) + list(_BLANK_OUIDS)
|
||||
input_ouids = draw(
|
||||
st.lists(
|
||||
st.sampled_from(candidates),
|
||||
min_size=0,
|
||||
max_size=6,
|
||||
)
|
||||
)
|
||||
|
||||
return _StubDB(rows=rows), input_ouids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SETTINGS = settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
|
||||
|
||||
@_SETTINGS
|
||||
@given(payload=_stub_and_inputs())
|
||||
def test_batch_team_competences_matches_single_variant(
|
||||
payload: tuple[_StubDB, list[str]],
|
||||
) -> None:
|
||||
"""Property 4: Kompetenz-Batch ist konsistent zur Einzel-Variante.
|
||||
|
||||
Asserts the four sub-properties listed in the module docstring
|
||||
against the stub-DB-driven fake cursor.
|
||||
"""
|
||||
|
||||
stub, input_ouids = payload
|
||||
client = _make_client(stub)
|
||||
|
||||
batch = client.batch_get_team_competences(input_ouids)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# (1) Batch result has exactly the requested OUIDs as keys
|
||||
# (Requirement 3.4: empty list for OUIDs without competences).
|
||||
# ---------------------------------------------------------------
|
||||
expected_keys = {str(o) for o in input_ouids}
|
||||
assert set(batch.keys()) == expected_keys, (
|
||||
"batch_get_team_competences must contain every input ouid; "
|
||||
f"expected {expected_keys!r}, got {set(batch.keys())!r}"
|
||||
)
|
||||
|
||||
for ouid in input_ouids:
|
||||
ouid_key = str(ouid)
|
||||
single = client.get_team_competences(ouid)
|
||||
batch_entry = batch[ouid_key]
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# (2) Batch-vs-single consistency (Requirements 3.1, 3.3).
|
||||
# -----------------------------------------------------------
|
||||
assert batch_entry == single, (
|
||||
f"batch[{ouid_key!r}] differs from get_team_competences"
|
||||
f"({ouid!r}): batch={batch_entry!r}, single={single!r}"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------
|
||||
# (3) Per-entry invariants (Requirements 3.5, 3.6, 3.7).
|
||||
# -----------------------------------------------------------
|
||||
for entry in single:
|
||||
assert isinstance(entry, dict)
|
||||
assert set(entry.keys()) == {"name", "top_competency"}
|
||||
# 3.6: filtered out NULL/empty names.
|
||||
assert entry["name"], (
|
||||
"entries with NULL or empty name must be filtered out, "
|
||||
f"got {entry!r}"
|
||||
)
|
||||
# 3.5: NULL top_competency normalised to False.
|
||||
assert isinstance(entry["top_competency"], bool), (
|
||||
"top_competency must be a bool after COALESCE, got "
|
||||
f"{entry['top_competency']!r}"
|
||||
)
|
||||
|
||||
# 3.7: order is (top_competency desc, name asc).
|
||||
ordering = [
|
||||
(0 if e["top_competency"] else 1, e["name"]) for e in single
|
||||
]
|
||||
assert ordering == sorted(ordering), (
|
||||
"competence list is not ordered by (top desc, name asc) "
|
||||
f"for ouid={ouid!r}: {single!r}"
|
||||
)
|
||||
|
||||
# 3.4: blank/unknown OUIDs map to an empty list.
|
||||
if not str(ouid).strip() or str(ouid) in _UNKNOWN_OUIDS:
|
||||
assert batch_entry == [], (
|
||||
"OUID without competences must map to an empty list; "
|
||||
f"ouid={ouid!r}, got {batch_entry!r}"
|
||||
)
|
||||
@@ -0,0 +1,298 @@
|
||||
# Feature: team-profile-matching, Property 13: Konfig-Validierung von top_competency_weight
|
||||
"""Property-based test for ``matching.team.top_competency_weight`` validation.
|
||||
|
||||
Covers Property 13 from the team-profile-matching design:
|
||||
|
||||
1. For every numeric value ``v >= 1.0`` (positive), ``load_config(...)``
|
||||
returns an ``AppConfig`` with
|
||||
``cfg.matching.team.top_competency_weight == float(v)``.
|
||||
2. For every non-numeric value or numeric value ``< 1.0`` (negative),
|
||||
``load_config(...)`` raises a ``ConfigError`` whose message contains
|
||||
both the key name ``matching.team.top_competency_weight`` and the
|
||||
offending value's repr.
|
||||
3. When the ``[matching.team]`` block (or the key inside it) is absent
|
||||
from the TOML file, ``cfg.matching.team.top_competency_weight ==
|
||||
1.5``.
|
||||
|
||||
**Validates: Requirements 12.5, 12.6**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.config import ConfigError, load_config
|
||||
|
||||
|
||||
# Minimal TOML scaffold containing every section ``load_config`` requires.
|
||||
# A placeholder ``{team_block}`` is replaced per-test to inject (or omit)
|
||||
# the ``[matching.team]`` block being exercised.
|
||||
_CONFIG_TEMPLATE = """\
|
||||
[database]
|
||||
backend = "trino"
|
||||
host = "trino.example"
|
||||
port = 443
|
||||
http_scheme = "https"
|
||||
verify_ssl = true
|
||||
catalog = "hive"
|
||||
schema = "tier1_open_lake"
|
||||
connect_timeout = 10
|
||||
pool_size = 4
|
||||
|
||||
[matching]
|
||||
competence_weight = 0.8
|
||||
role_weight = 0.2
|
||||
require_confirmation = true
|
||||
|
||||
[matching.thresholds]
|
||||
top = 0.8
|
||||
good = 0.65
|
||||
partial = 0.5
|
||||
low = 0.3
|
||||
|
||||
[matching.fuzzy]
|
||||
min_similarity = 0.7
|
||||
|
||||
{team_block}
|
||||
|
||||
[cache]
|
||||
db_ttl_hours = 12
|
||||
search_ttl_minutes = 60
|
||||
max_size = 100
|
||||
|
||||
[azure_openai]
|
||||
endpoint = "https://example.openai.azure.com"
|
||||
api_version = "2024-02-15-preview"
|
||||
chat_deployment = "gpt-4"
|
||||
show_costs_in_output = false
|
||||
"""
|
||||
|
||||
|
||||
def _write_config(tmp_path: Path, *, team_block: str) -> Path:
|
||||
"""Materialize a config.toml file with the given ``team_block``.
|
||||
|
||||
``team_block`` is the raw TOML text that replaces the
|
||||
``{team_block}`` placeholder. Pass ``""`` to omit the
|
||||
``[matching.team]`` section entirely.
|
||||
"""
|
||||
|
||||
path = tmp_path / "config.toml"
|
||||
path.write_text(
|
||||
_CONFIG_TEMPLATE.format(team_block=team_block),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_required_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``load_config`` requires DB credentials and the LLM API key."""
|
||||
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
|
||||
def _format_float_for_toml(v: float) -> str:
|
||||
"""Render a float as a TOML-safe literal.
|
||||
|
||||
TOML does not support ``inf`` or ``nan`` literals, but the parsing in
|
||||
``load_config`` does accept them via Python's ``float()``. To exercise
|
||||
those values we'd need to feed them through a string. For finite
|
||||
floats we render with ``repr`` which gives a TOML-compatible literal.
|
||||
"""
|
||||
|
||||
return repr(v)
|
||||
|
||||
|
||||
# --- Positive case: numeric values >= 1.0 are accepted ----------------------
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
@given(
|
||||
value=st.floats(
|
||||
min_value=1.0,
|
||||
max_value=1e6,
|
||||
allow_nan=False,
|
||||
allow_infinity=False,
|
||||
)
|
||||
)
|
||||
def test_top_competency_weight_accepts_floats_ge_one(
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
value: float,
|
||||
) -> None:
|
||||
"""Numeric ``v >= 1.0`` is loaded as ``float(v)``."""
|
||||
|
||||
tmp_path = tmp_path_factory.mktemp("cfg")
|
||||
team_block = (
|
||||
"[matching.team]\n"
|
||||
f"top_competency_weight = {_format_float_for_toml(value)}\n"
|
||||
)
|
||||
cfg_path = _write_config(tmp_path, team_block=team_block)
|
||||
|
||||
cfg = load_config(cfg_path)
|
||||
|
||||
assert math.isclose(
|
||||
cfg.matching.team.top_competency_weight,
|
||||
float(value),
|
||||
rel_tol=0.0,
|
||||
abs_tol=0.0,
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
@given(
|
||||
value=st.integers(min_value=1, max_value=10_000),
|
||||
)
|
||||
def test_top_competency_weight_accepts_integers_ge_one(
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
value: int,
|
||||
) -> None:
|
||||
"""Integer ``v >= 1`` is also accepted and coerced to float."""
|
||||
|
||||
tmp_path = tmp_path_factory.mktemp("cfg")
|
||||
team_block = (
|
||||
"[matching.team]\n"
|
||||
f"top_competency_weight = {value}\n"
|
||||
)
|
||||
cfg_path = _write_config(tmp_path, team_block=team_block)
|
||||
|
||||
cfg = load_config(cfg_path)
|
||||
|
||||
assert cfg.matching.team.top_competency_weight == float(value)
|
||||
|
||||
|
||||
# --- Negative case (numeric, < 1.0): ConfigError ----------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
@given(
|
||||
value=st.floats(
|
||||
min_value=-1e6,
|
||||
max_value=1.0,
|
||||
exclude_max=True,
|
||||
allow_nan=False,
|
||||
allow_infinity=False,
|
||||
)
|
||||
)
|
||||
def test_top_competency_weight_rejects_floats_below_one(
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
value: float,
|
||||
) -> None:
|
||||
"""Numeric ``v < 1.0`` raises a ``ConfigError`` mentioning key and value."""
|
||||
|
||||
tmp_path = tmp_path_factory.mktemp("cfg")
|
||||
literal = _format_float_for_toml(value)
|
||||
team_block = (
|
||||
"[matching.team]\n"
|
||||
f"top_competency_weight = {literal}\n"
|
||||
)
|
||||
cfg_path = _write_config(tmp_path, team_block=team_block)
|
||||
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
load_config(cfg_path)
|
||||
|
||||
msg = str(exc_info.value)
|
||||
assert "matching.team.top_competency_weight" in msg
|
||||
# The offending numeric value must appear in the error message in some
|
||||
# parsable form. We accept either the float repr or its TOML literal.
|
||||
assert (str(float(value)) in msg) or (literal in msg)
|
||||
|
||||
|
||||
# --- Negative case (non-numeric): ConfigError -------------------------------
|
||||
|
||||
|
||||
# Printable ASCII strings that survive as TOML basic-string literals
|
||||
# without escaping (no control chars, no double-quote, no backslash).
|
||||
_safe_string = st.text(
|
||||
alphabet=st.characters(
|
||||
min_codepoint=0x20,
|
||||
max_codepoint=0x7E,
|
||||
blacklist_characters='"\\',
|
||||
),
|
||||
min_size=0,
|
||||
max_size=20,
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
@given(value=_safe_string)
|
||||
def test_top_competency_weight_rejects_non_numeric_strings(
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
value: str,
|
||||
) -> None:
|
||||
"""Non-numeric string values raise a ``ConfigError`` mentioning key + value."""
|
||||
|
||||
# Skip strings that ``float()`` would accept (e.g. "1.5", "1e3").
|
||||
try:
|
||||
float(value)
|
||||
return
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
tmp_path = tmp_path_factory.mktemp("cfg")
|
||||
team_block = (
|
||||
"[matching.team]\n"
|
||||
f'top_competency_weight = "{value}"\n'
|
||||
)
|
||||
cfg_path = _write_config(tmp_path, team_block=team_block)
|
||||
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
load_config(cfg_path)
|
||||
|
||||
msg = str(exc_info.value)
|
||||
assert "matching.team.top_competency_weight" in msg
|
||||
# ``repr`` of the string (with quotes) is part of the error message.
|
||||
assert repr(value) in msg
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None, suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
@given(value=st.booleans())
|
||||
def test_top_competency_weight_rejects_booleans(
|
||||
tmp_path_factory: pytest.TempPathFactory,
|
||||
value: bool,
|
||||
) -> None:
|
||||
"""Boolean values raise a ``ConfigError`` even though Python coerces them."""
|
||||
|
||||
tmp_path = tmp_path_factory.mktemp("cfg")
|
||||
literal = "true" if value else "false"
|
||||
team_block = (
|
||||
"[matching.team]\n"
|
||||
f"top_competency_weight = {literal}\n"
|
||||
)
|
||||
cfg_path = _write_config(tmp_path, team_block=team_block)
|
||||
|
||||
with pytest.raises(ConfigError) as exc_info:
|
||||
load_config(cfg_path)
|
||||
|
||||
msg = str(exc_info.value)
|
||||
assert "matching.team.top_competency_weight" in msg
|
||||
assert repr(value) in msg
|
||||
|
||||
|
||||
# --- Default case: missing key -> 1.5 ---------------------------------------
|
||||
|
||||
|
||||
def test_top_competency_weight_defaults_to_one_point_five_when_section_missing(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""No ``[matching.team]`` block at all -> default 1.5."""
|
||||
|
||||
cfg_path = _write_config(tmp_path, team_block="")
|
||||
cfg = load_config(cfg_path)
|
||||
assert cfg.matching.team.top_competency_weight == 1.5
|
||||
|
||||
|
||||
def test_top_competency_weight_defaults_to_one_point_five_when_key_missing(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""``[matching.team]`` present but key missing -> default 1.5."""
|
||||
|
||||
cfg_path = _write_config(tmp_path, team_block="[matching.team]\n")
|
||||
cfg = load_config(cfg_path)
|
||||
assert cfg.matching.team.top_competency_weight == 1.5
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Documentation snapshot tests for the team profile matching feature.
|
||||
|
||||
These tests assert that the user-facing documentation files contain the
|
||||
keywords that pin the team-search surface (Profile_Type, find_matching_teams,
|
||||
team_search, top_competency_weight). They guard against accidental removal
|
||||
during future doc rewrites.
|
||||
|
||||
Validates: Requirements 10.1, 10.2, 10.3, 10.4, 10.5, 10.6, 11.1, 11.2,
|
||||
11.3, 11.4, 11.5, 11.6, 11.7, 11.8, 11.9
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
DOC_FILES = (
|
||||
"docs/architecture.md",
|
||||
"README.md",
|
||||
".kiro/agents/teamlandkarte.md",
|
||||
".github/agents/teamlandkarte_agent.md",
|
||||
)
|
||||
|
||||
REQUIRED_KEYWORDS = (
|
||||
"Profile_Type",
|
||||
"find_matching_teams",
|
||||
"team_search",
|
||||
"top_competency_weight",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("doc_path", DOC_FILES)
|
||||
@pytest.mark.parametrize("keyword", REQUIRED_KEYWORDS)
|
||||
def test_doc_contains_team_keyword(doc_path: str, keyword: str) -> None:
|
||||
"""Each documentation file must mention the team-search keywords verbatim."""
|
||||
|
||||
full_path = REPO_ROOT / doc_path
|
||||
assert full_path.exists(), f"Documentation file missing: {doc_path}"
|
||||
|
||||
content = full_path.read_text(encoding="utf-8")
|
||||
assert keyword in content, (
|
||||
f"Keyword '{keyword}' missing from documentation file '{doc_path}'."
|
||||
)
|
||||
@@ -0,0 +1,393 @@
|
||||
# Feature: team-profile-matching, Property 12: Verfügbarkeitsfilter werden in Team-Suchen ignoriert
|
||||
"""Property test that availability filters are ignored for team searches.
|
||||
|
||||
For every ``filter_search_results`` invocation on a ``team_search``
|
||||
cache entry with arbitrary values for ``availability_date_start``,
|
||||
``availability_date_end`` and ``is_fully_available`` the following must
|
||||
hold:
|
||||
|
||||
1. The set of filtered items is the same as in a call without any
|
||||
availability filter (same ``role_filter`` and ``competence_filter``).
|
||||
2. For every availability value that is set, the rendered
|
||||
``Applied Filters`` table contains a hint that includes the
|
||||
substring ``"team_search"`` and marks the filter as not effective
|
||||
(``"nicht wirksam"``).
|
||||
|
||||
The test runs ``find_matching_teams`` once per Hypothesis example to
|
||||
create a ``team_search`` cache entry, then calls
|
||||
``filter_search_results`` twice on the same ``search_id``: once with
|
||||
just a ``role_filter`` and once with the same ``role_filter`` plus the
|
||||
generated availability values. The two filter results must match in
|
||||
total count, and the second output must carry the ``team_search``
|
||||
hint for every availability value that was actively passed.
|
||||
|
||||
**Validates: Requirements 6.7, 8.5, 8.6**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, assume, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
|
||||
|
||||
|
||||
_CONFIG_TEMPLATE = """
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[matching.team]
|
||||
top_competency_weight=1.5
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
|
||||
[azure_openai]
|
||||
endpoint='https://example.openai.azure.com'
|
||||
api_version='2024-02-15-preview'
|
||||
chat_deployment='gpt-4'
|
||||
""".strip()
|
||||
|
||||
|
||||
def _make_teams() -> list[Team]:
|
||||
"""Three teams with diverse focus_name values.
|
||||
|
||||
The diversity ensures that ``role_filter="Backend"`` produces a
|
||||
non-trivial subset (at least one match, at least one non-match)
|
||||
so the property check is not vacuous.
|
||||
"""
|
||||
return [
|
||||
Team(
|
||||
team_id="t1",
|
||||
ouid="ou-1",
|
||||
team_name="Team Alpha",
|
||||
focus_name="Backend Developer",
|
||||
about_us="We build backends.",
|
||||
offerings="APIs and services.",
|
||||
interests="Distributed systems.",
|
||||
competences=[
|
||||
TeamCompetence(name="python", top_competency=True),
|
||||
TeamCompetence(name="fastapi", top_competency=False),
|
||||
],
|
||||
references=[
|
||||
TeamReference(partner_name="DB Cargo", projects="ETL"),
|
||||
],
|
||||
),
|
||||
Team(
|
||||
team_id="t2",
|
||||
ouid="ou-2",
|
||||
team_name="Team Beta",
|
||||
focus_name="Frontend Developer",
|
||||
about_us="We build frontends.",
|
||||
offerings="UIs and apps.",
|
||||
interests="Web technologies.",
|
||||
competences=[
|
||||
TeamCompetence(name="javascript", top_competency=True),
|
||||
TeamCompetence(name="react", top_competency=False),
|
||||
],
|
||||
references=[],
|
||||
),
|
||||
Team(
|
||||
team_id="t3",
|
||||
ouid="ou-3",
|
||||
team_name="Team Gamma",
|
||||
focus_name="Data Engineer",
|
||||
about_us="We build pipelines.",
|
||||
offerings="Data flows.",
|
||||
interests="Big data.",
|
||||
competences=[
|
||||
TeamCompetence(name="python", top_competency=False),
|
||||
TeamCompetence(name="spark", top_competency=False),
|
||||
],
|
||||
references=[],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal fake DB returning a small fixed set of teams."""
|
||||
|
||||
def test_connection(self) -> None:
|
||||
"""No-op so server start-up succeeds."""
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
"""Return the columns expected by the schema verifier."""
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
"""Return a non-empty role list to satisfy server start-up."""
|
||||
return ["Backend Developer", "Frontend Developer", "Data Engineer"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
"""Return the competence pool used in the fake teams."""
|
||||
return ["python", "fastapi", "javascript", "react", "spark"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
"""No tasks needed for this property test."""
|
||||
return []
|
||||
|
||||
def get_all_capacities_with_competences(self):
|
||||
"""No capacities needed for this property test."""
|
||||
return []
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20):
|
||||
"""No free capacities needed for this property test."""
|
||||
return []
|
||||
|
||||
def get_all_teams(self) -> list[Team]:
|
||||
"""Return the fixed three-team fixture."""
|
||||
return _make_teams()
|
||||
|
||||
def get_team_by_id(self, team_id): # pragma: no cover
|
||||
"""Look up a single team by id from the fixture."""
|
||||
for team in _make_teams():
|
||||
if team.team_id == str(team_id):
|
||||
return team
|
||||
return None
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
"""Extract the text payload from an MCP tool result."""
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"), str
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
|
||||
"""Invoke an MCP tool and return its rendered text output."""
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
def _extract_search_id(output: str) -> str:
|
||||
"""Parse the ``SEARCH_ID=<uuid>`` line out of a tool output."""
|
||||
for line in output.splitlines():
|
||||
if line.startswith("SEARCH_ID="):
|
||||
return line[len("SEARCH_ID="):].strip()
|
||||
raise AssertionError(f"no SEARCH_ID= line in tool output:\n{output}")
|
||||
|
||||
|
||||
def _extract_meta(output: str) -> dict[str, Any]:
|
||||
"""Parse the ``META=<json>`` line out of a tool output."""
|
||||
for line in output.splitlines():
|
||||
if line.startswith("META="):
|
||||
return json.loads(line[len("META="):])
|
||||
raise AssertionError(f"no META= line in tool output:\n{output}")
|
||||
|
||||
|
||||
def _extract_applied_filter_row(output: str, filter_name: str) -> str:
|
||||
"""Return the ``Value`` cell for a row in the Applied Filters table."""
|
||||
# The filter table is a markdown table where each data row looks
|
||||
# like ``| <filter_name> | <value> |``. Extract the value cell.
|
||||
pattern = rf"^\|\s*{re.escape(filter_name)}\s*\|\s*(.*?)\s*\|\s*$"
|
||||
rgx = re.compile(pattern, re.MULTILINE)
|
||||
match = rgx.search(output)
|
||||
if match is None:
|
||||
raise AssertionError(
|
||||
f"missing Applied Filters row for {filter_name!r}:\n{output}"
|
||||
)
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _build_test_server(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Any:
|
||||
"""Construct a server backed by ``_FakeDB`` and a mocked LLM."""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_mod, "create_db_client", lambda *_a, **_k: _FakeDB()
|
||||
)
|
||||
|
||||
# The score path needs ``compute_role_similarity`` which calls the
|
||||
# LLM client. Mock it to avoid network and make scoring stable.
|
||||
monkeypatch.setattr(
|
||||
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
|
||||
AsyncMock(
|
||||
return_value=(
|
||||
'{"similarity": 1.0, "category": "Top", "rationale": "ok"}'
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
return build_server(str(cfg))
|
||||
|
||||
|
||||
# Strategies: ISO date strings or None for the date filters, plus a
|
||||
# random boolean for ``is_fully_available``. We require at least one of
|
||||
# the three values to be "active" so the property's clause about the
|
||||
# ``team_search`` hint is exercised in every example.
|
||||
_DATE_STRATEGY = st.one_of(
|
||||
st.none(),
|
||||
st.dates(
|
||||
min_value=date(2020, 1, 1),
|
||||
max_value=date(2030, 12, 31),
|
||||
).map(lambda d: d.isoformat()),
|
||||
)
|
||||
|
||||
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
@given(
|
||||
avail_start=_DATE_STRATEGY,
|
||||
avail_end=_DATE_STRATEGY,
|
||||
is_fully_available=st.booleans(),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_search_ignores_availability_filters(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
avail_start: str | None,
|
||||
avail_end: str | None,
|
||||
is_fully_available: bool,
|
||||
) -> None:
|
||||
"""Property 12: availability filters are ignored for team searches.
|
||||
|
||||
- The filtered total must match the no-availability call
|
||||
(item set equality via ``META["total"]``).
|
||||
- For every availability value that is actively passed, the
|
||||
``Applied Filters`` table must contain a ``team_search`` hint
|
||||
including ``"nicht wirksam"``.
|
||||
"""
|
||||
# At least one availability value must be active so the second
|
||||
# clause of the property is non-vacuous.
|
||||
assume(avail_start or avail_end or is_fully_available)
|
||||
|
||||
srv = _build_test_server(monkeypatch, tmp_path)
|
||||
|
||||
# Step 1: create a team_search cache entry. The role/competence
|
||||
# arguments overlap the fixture so the search succeeds without
|
||||
# validation errors.
|
||||
search_out = await _call_tool(
|
||||
srv,
|
||||
"find_matching_teams",
|
||||
{
|
||||
"role_name": "Backend Developer",
|
||||
"competences": ["python"],
|
||||
"matching_method": "score",
|
||||
},
|
||||
)
|
||||
search_id = _extract_search_id(search_out)
|
||||
search_meta = _extract_meta(search_out)
|
||||
assert search_meta.get("search_type") == "team_search", (
|
||||
f"expected search_type='team_search', got META={search_meta!r}"
|
||||
)
|
||||
|
||||
# Step 2: filter without any availability filter. ``role_filter``
|
||||
# is the only active filter so the call satisfies the
|
||||
# "at least one filter" guard.
|
||||
base_out = await _call_tool(
|
||||
srv,
|
||||
"filter_search_results",
|
||||
{
|
||||
"search_id": search_id,
|
||||
"role_filter": "Backend",
|
||||
},
|
||||
)
|
||||
base_meta = _extract_meta(base_out)
|
||||
base_total = int(base_meta.get("total", -1))
|
||||
assert base_total >= 0, (
|
||||
f"missing/invalid total in base META={base_meta!r}"
|
||||
)
|
||||
|
||||
# Step 3: filter with the same ``role_filter`` PLUS the random
|
||||
# availability filter values.
|
||||
avail_args: dict[str, Any] = {
|
||||
"search_id": search_id,
|
||||
"role_filter": "Backend",
|
||||
"is_fully_available": is_fully_available,
|
||||
}
|
||||
if avail_start is not None:
|
||||
avail_args["availability_date_start"] = avail_start
|
||||
if avail_end is not None:
|
||||
avail_args["availability_date_end"] = avail_end
|
||||
|
||||
avail_out = await _call_tool(srv, "filter_search_results", avail_args)
|
||||
avail_meta = _extract_meta(avail_out)
|
||||
avail_total = int(avail_meta.get("total", -1))
|
||||
assert avail_total >= 0, (
|
||||
f"missing/invalid total in availability META={avail_meta!r}"
|
||||
)
|
||||
|
||||
# Property clause 1: the filtered item set must be unchanged.
|
||||
assert avail_total == base_total, (
|
||||
f"availability filter changed total: base={base_total}, "
|
||||
f"with availability={avail_total}; "
|
||||
f"avail_args={avail_args!r}"
|
||||
)
|
||||
|
||||
# Property clause 2: each actively-passed availability value must
|
||||
# be reflected in the Applied Filters table with a hint that
|
||||
# contains ``team_search`` and marks the filter as not effective.
|
||||
def _check_hint(filter_name: str) -> None:
|
||||
cell = _extract_applied_filter_row(avail_out, filter_name)
|
||||
assert "team_search" in cell, (
|
||||
f"Applied Filters row {filter_name!r} is missing the "
|
||||
f"'team_search' substring; cell={cell!r}\n"
|
||||
f"output:\n{avail_out}"
|
||||
)
|
||||
assert "nicht wirksam" in cell, (
|
||||
f"Applied Filters row {filter_name!r} does not mark the "
|
||||
f"filter as not effective; cell={cell!r}\n"
|
||||
f"output:\n{avail_out}"
|
||||
)
|
||||
|
||||
if avail_start is not None:
|
||||
_check_hint("availability_date_start")
|
||||
if avail_end is not None:
|
||||
_check_hint("availability_date_end")
|
||||
if is_fully_available:
|
||||
_check_hint("is_fully_available")
|
||||
@@ -0,0 +1,445 @@
|
||||
# Feature: team-profile-matching, Task 11.2
|
||||
"""End-to-end integration smoke test for ``find_matching_teams`` in
|
||||
``llm_fulltext`` mode.
|
||||
|
||||
Exercises the full request path through ``build_server`` with a
|
||||
fully-mocked ``DBClient`` and a ``MagicMock``-backed
|
||||
``AzureOpenAIClient``. The mock returns a valid LLM JSON payload for
|
||||
two of the three teams and raises :class:`AzureAPIError` for the third
|
||||
so the integration verifies both happy and error paths:
|
||||
|
||||
find_matching_teams (llm_fulltext)
|
||||
-> LlmFulltextMatcher.match_teams
|
||||
-> SearchCache.store_search (search_type="team_search",
|
||||
matching_method="llm_fulltext")
|
||||
-> get_results_by_category (renders LLM-mode columns)
|
||||
-> filter_search_results (role_filter)
|
||||
|
||||
The test verifies that:
|
||||
|
||||
* A simulated LLM error places the affected team into the response's
|
||||
``## Errors`` section and the persisted ``errors`` list (Anforderung
|
||||
7.6).
|
||||
* The persisted ``SearchCache`` payload carries
|
||||
``search_type == "team_search"`` and
|
||||
``matching_method == "llm_fulltext"`` (Anforderungen 8.1, 8.2, 8.3).
|
||||
* The Markdown output contains the LLM-mode column layout for teams
|
||||
(``Team Name``, ``Schwerpunkt``, ``Top-Kompetenzen``, ``Category``,
|
||||
``Begründung``) (Anforderung 8.4).
|
||||
* Subsequent calls to ``get_results_by_category`` and
|
||||
``filter_search_results`` operate on the persisted ``team_search``
|
||||
entry and reflect the same markers.
|
||||
|
||||
_Requirements: 7.1, 7.6, 8.1, 8.2, 8.4_
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.azure.openai_client import AzureAPIError
|
||||
from teamlandkarte_mcp.cache.search_cache import SearchCache
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
|
||||
|
||||
|
||||
_CONFIG_TEMPLATE = """
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[matching.team]
|
||||
top_competency_weight=1.5
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
|
||||
[azure_openai]
|
||||
endpoint='https://example.openai.azure.com'
|
||||
api_version='2024-02-15-preview'
|
||||
chat_deployment='gpt-4'
|
||||
""".strip()
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"), str
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
def _extract_search_id(output: str) -> str:
|
||||
for line in output.splitlines():
|
||||
if line.startswith("SEARCH_ID="):
|
||||
return line[len("SEARCH_ID="):].strip()
|
||||
raise AssertionError(f"no SEARCH_ID= line in tool output:\n{output}")
|
||||
|
||||
|
||||
def _extract_meta(output: str) -> dict[str, Any]:
|
||||
for line in output.splitlines():
|
||||
if line.startswith("META="):
|
||||
return json.loads(line[len("META="):])
|
||||
raise AssertionError(f"no META= line in tool output:\n{output}")
|
||||
|
||||
|
||||
def _make_teams() -> list[Team]:
|
||||
"""Three deterministic teams used to exercise both LLM outcomes."""
|
||||
return [
|
||||
Team(
|
||||
team_id="t1",
|
||||
ouid="ou-1",
|
||||
team_name="Team Alpha",
|
||||
focus_name="Backend Developer",
|
||||
about_us="We build robust backends.",
|
||||
offerings="APIs and services.",
|
||||
interests="Distributed systems.",
|
||||
competences=[
|
||||
TeamCompetence(name="python", top_competency=True),
|
||||
TeamCompetence(name="fastapi", top_competency=False),
|
||||
],
|
||||
references=[
|
||||
TeamReference(partner_name="DB Cargo", projects="ETL Pipeline"),
|
||||
],
|
||||
),
|
||||
Team(
|
||||
team_id="t2",
|
||||
ouid="ou-2",
|
||||
team_name="Team Beta",
|
||||
focus_name="Frontend Developer",
|
||||
about_us="We build slick UIs.",
|
||||
offerings="Web apps.",
|
||||
interests="Web technologies.",
|
||||
competences=[
|
||||
TeamCompetence(name="javascript", top_competency=True),
|
||||
],
|
||||
references=[],
|
||||
),
|
||||
Team(
|
||||
team_id="t3",
|
||||
ouid="ou-3",
|
||||
team_name="Team Gamma",
|
||||
focus_name="Data Engineer",
|
||||
about_us="We build pipelines.",
|
||||
offerings="Data flows.",
|
||||
interests="Big data.",
|
||||
competences=[
|
||||
TeamCompetence(name="spark", top_competency=False),
|
||||
],
|
||||
references=[],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal fake ``DBClient`` for the LLM team integration smoke test."""
|
||||
|
||||
def __init__(self, teams: list[Team]) -> None:
|
||||
self._teams = list(teams)
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["Backend Developer", "Frontend Developer", "Data Engineer"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["python", "fastapi", "javascript", "spark"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_capacities_with_competences(self):
|
||||
return []
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_teams(self) -> list[Team]:
|
||||
return list(self._teams)
|
||||
|
||||
def get_team_by_id(self, team_id): # pragma: no cover
|
||||
for t in self._teams:
|
||||
if t.team_id == str(team_id):
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def _patch_capturing_store(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Capture every payload passed to ``SearchCache.store_search``."""
|
||||
captured: list[dict[str, Any]] = []
|
||||
original = SearchCache.store_search
|
||||
|
||||
def _capturing(self, *, task_id, requirements, results):
|
||||
captured.append(results)
|
||||
return original(
|
||||
self,
|
||||
task_id=task_id,
|
||||
requirements=requirements,
|
||||
results=results,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(SearchCache, "store_search", _capturing)
|
||||
return captured
|
||||
|
||||
|
||||
def _build_server_with_llm_mock(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
*,
|
||||
team_responses: dict[str, Any],
|
||||
) -> Any:
|
||||
"""Build a server backed by ``_FakeDB`` and a mocked LLM.
|
||||
|
||||
``team_responses`` maps each ``team_id`` (str) to either a JSON
|
||||
string (success) or an :class:`AzureAPIError` instance (simulated
|
||||
failure). The fake ``chat_completion`` parses the team_id out of
|
||||
the prompt's ``ID: <team_id>`` line that
|
||||
:func:`_build_user_prompt_team_for_task` embeds, so the mapping is
|
||||
deterministic regardless of ``asyncio.gather`` scheduling.
|
||||
"""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
||||
|
||||
fake_db = _FakeDB(_make_teams())
|
||||
monkeypatch.setattr(
|
||||
mcp_mod, "create_db_client", lambda *_a, **_k: fake_db
|
||||
)
|
||||
|
||||
async def _fake_chat(self, system: str, user: str) -> str: # noqa: ARG001
|
||||
team_id = ""
|
||||
for line in user.splitlines():
|
||||
if line.startswith("ID: "):
|
||||
team_id = line[len("ID: "):].strip()
|
||||
break
|
||||
outcome = team_responses.get(team_id)
|
||||
if isinstance(outcome, BaseException):
|
||||
raise outcome
|
||||
if outcome is None:
|
||||
# Defensive default: any unmapped team is reported as
|
||||
# ``Irrelevant`` so the test still terminates.
|
||||
return json.dumps(
|
||||
{"category": "Irrelevant", "rationale": "default"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
return outcome
|
||||
|
||||
monkeypatch.setattr(
|
||||
"teamlandkarte_mcp.azure.openai_client."
|
||||
"AzureOpenAIClient.chat_completion",
|
||||
_fake_chat,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
return build_server(str(cfg))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_find_matching_teams_llm_fulltext_full_path(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""End-to-end smoke test for ``find_matching_teams`` (llm_fulltext).
|
||||
|
||||
Validates: requirements 7.1 (LLM-categorisation per team), 7.6
|
||||
(LLM error surfaces in ``errors`` list), 8.1, 8.2 (cache markers)
|
||||
and 8.4 (LLM-mode column layout).
|
||||
"""
|
||||
# Two valid LLM payloads and one simulated failure.
|
||||
team_responses: dict[str, Any] = {
|
||||
"t1": json.dumps(
|
||||
{"category": "Top", "rationale": "Backend match."},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
"t2": json.dumps(
|
||||
{"category": "Partial", "rationale": "Frontend overlap."},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
"t3": AzureAPIError("LLM call failed"),
|
||||
}
|
||||
|
||||
captured = _patch_capturing_store(monkeypatch)
|
||||
srv = _build_server_with_llm_mock(
|
||||
monkeypatch, tmp_path, team_responses=team_responses
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1) find_matching_teams (llm_fulltext mode)
|
||||
# ------------------------------------------------------------------
|
||||
out = await _call_tool(
|
||||
srv,
|
||||
"find_matching_teams",
|
||||
{
|
||||
"role_name": "Backend Developer",
|
||||
"competences": ["python"],
|
||||
"matching_method": "llm_fulltext",
|
||||
},
|
||||
)
|
||||
|
||||
# SEARCH_ID + META markers (Anforderungen 8.1, 8.3).
|
||||
search_id = _extract_search_id(out)
|
||||
meta = _extract_meta(out)
|
||||
assert meta.get("search_id") == search_id
|
||||
assert meta.get("search_type") == "team_search"
|
||||
assert meta.get("matching_method") == "llm_fulltext"
|
||||
|
||||
# The errored team (t3) must be visible in the rendered output via
|
||||
# the ``## Errors`` section (Anforderung 7.6).
|
||||
assert "## Errors" in out, out
|
||||
assert "t3" in out, out
|
||||
# Team t3 must NOT appear in the rendered category table because
|
||||
# errored teams never end up in by_category.
|
||||
# (We check the persisted payload below for the strict invariant.)
|
||||
|
||||
# The team-search LLM column layout must be present
|
||||
# (Anforderung 8.4).
|
||||
for header in (
|
||||
"Team Name",
|
||||
"Schwerpunkt",
|
||||
"Top-Kompetenzen",
|
||||
"Category",
|
||||
"Begründung",
|
||||
):
|
||||
assert header in out, (
|
||||
f"missing team-search llm-mode header {header!r} in:\n{out}"
|
||||
)
|
||||
|
||||
# The persisted SearchCache payload carries the team_search markers
|
||||
# and the errors list (Anforderungen 7.6, 8.2).
|
||||
assert captured, "SearchCache.store_search was never called"
|
||||
payload = captured[-1]
|
||||
assert payload["search_type"] == "team_search"
|
||||
assert payload["matching_method"] == "llm_fulltext"
|
||||
|
||||
errors = payload.get("errors") or []
|
||||
error_ids = {e["item_id"] for e in errors}
|
||||
assert error_ids == {"t3"}, errors
|
||||
|
||||
by_category = payload.get("by_category") or {}
|
||||
flat_items = [item for items in by_category.values() for item in items]
|
||||
flat_ids = {str(it.get("team_id")) for it in flat_items}
|
||||
# The two successful teams are present, the errored team is not.
|
||||
assert flat_ids == {"t1", "t2"}, flat_ids
|
||||
# by_category must NOT contain the errored team.
|
||||
assert "t3" not in flat_ids
|
||||
|
||||
# Each persisted item carries category + rationale fields.
|
||||
for item in flat_items:
|
||||
assert "team_id" in item
|
||||
assert "category" in item
|
||||
assert "rationale" in item
|
||||
assert isinstance(item["rationale"], str)
|
||||
assert item["rationale"].strip()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2) get_results_by_category — renders the LLM-mode team table.
|
||||
# ------------------------------------------------------------------
|
||||
default_category = str(meta.get("default_category") or "Top")
|
||||
cat_out = await _call_tool(
|
||||
srv,
|
||||
"get_results_by_category",
|
||||
{
|
||||
"search_id": search_id,
|
||||
"category": default_category,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
},
|
||||
)
|
||||
|
||||
cat_meta = _extract_meta(cat_out)
|
||||
assert cat_meta.get("search_type") == "team_search"
|
||||
assert cat_meta.get("matching_method") == "llm_fulltext"
|
||||
assert cat_meta.get("search_id") == search_id
|
||||
|
||||
for header in (
|
||||
"Team Name",
|
||||
"Schwerpunkt",
|
||||
"Top-Kompetenzen",
|
||||
"Category",
|
||||
"Begründung",
|
||||
):
|
||||
assert header in cat_out, (
|
||||
f"missing team-search llm-mode header {header!r} in "
|
||||
f"get_results_by_category output:\n{cat_out}"
|
||||
)
|
||||
# Score-only columns must NOT appear in LLM-mode tables.
|
||||
assert "Role Score" not in cat_out
|
||||
assert "Overall Score" not in cat_out
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3) filter_search_results — round-trip on the team_search entry.
|
||||
# ------------------------------------------------------------------
|
||||
filtered_out = await _call_tool(
|
||||
srv,
|
||||
"filter_search_results",
|
||||
{
|
||||
"search_id": search_id,
|
||||
"role_filter": "Backend",
|
||||
},
|
||||
)
|
||||
|
||||
assert "FILTER_ID=" in filtered_out, filtered_out
|
||||
filter_meta = _extract_meta(filtered_out)
|
||||
assert filter_meta.get("search_type") == "team_search"
|
||||
assert filter_meta.get("matching_method") == "llm_fulltext"
|
||||
assert filter_meta.get("status") == "ok"
|
||||
# Only Team Alpha's focus_name matches "Backend"; t3 is in errors
|
||||
# (not in by_category), so the filtered total must be exactly 1.
|
||||
assert filter_meta.get("total") == 1, filter_meta
|
||||
|
||||
assert "## Applied Filters" in filtered_out
|
||||
assert "role_filter" in filtered_out
|
||||
assert "Backend" in filtered_out
|
||||
# LLM-mode preview must include the Begründung column.
|
||||
assert "Begründung" in filtered_out
|
||||
assert "Filtered total results: 1" in filtered_out
|
||||
@@ -0,0 +1,470 @@
|
||||
# Feature: team-profile-matching, Task 11.1
|
||||
"""End-to-end integration smoke test for ``find_matching_teams`` in
|
||||
``score`` mode.
|
||||
|
||||
Exercises the full request path through ``build_server`` with a
|
||||
fully-mocked ``DBClient`` and a stubbed ``AzureOpenAIClient``:
|
||||
|
||||
find_matching_teams (score)
|
||||
-> Matcher.match_teams (uses SimilarityEngine)
|
||||
-> SearchCache.store_search (search_type="team_search")
|
||||
-> get_results_by_category (renders team-search columns)
|
||||
-> filter_search_results (role_filter)
|
||||
|
||||
The test verifies that:
|
||||
|
||||
* The persisted ``SearchCache`` payload carries
|
||||
``search_type == "team_search"`` and ``matching_method == "score"``.
|
||||
* The Markdown output emits the ``SEARCH_ID=`` and ``META=`` lines and
|
||||
the response renders a per-category table with the team-specific
|
||||
columns including ``Role Score``, ``Competence Score``,
|
||||
``Overall Score`` and ``Category``.
|
||||
* Subsequent calls to ``get_results_by_category`` and
|
||||
``filter_search_results`` work end-to-end against the persisted
|
||||
``team_search`` entry and reflect the same ``search_type`` and
|
||||
``matching_method`` markers.
|
||||
|
||||
_Requirements: 6.1, 8.1, 8.2, 8.4_
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.cache.search_cache import SearchCache
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
|
||||
|
||||
|
||||
_CONFIG_TEMPLATE = """
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[matching.team]
|
||||
top_competency_weight=1.5
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
|
||||
[azure_openai]
|
||||
endpoint='https://example.openai.azure.com'
|
||||
api_version='2024-02-15-preview'
|
||||
chat_deployment='gpt-4'
|
||||
""".strip()
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"), str
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
def _extract_search_id(output: str) -> str:
|
||||
for line in output.splitlines():
|
||||
if line.startswith("SEARCH_ID="):
|
||||
return line[len("SEARCH_ID="):].strip()
|
||||
raise AssertionError(f"no SEARCH_ID= line in tool output:\n{output}")
|
||||
|
||||
|
||||
def _extract_meta(output: str) -> dict[str, Any]:
|
||||
for line in output.splitlines():
|
||||
if line.startswith("META="):
|
||||
return json.loads(line[len("META="):])
|
||||
raise AssertionError(f"no META= line in tool output:\n{output}")
|
||||
|
||||
|
||||
def _make_teams() -> list[Team]:
|
||||
"""Three deterministic teams with different focus areas.
|
||||
|
||||
The first team's competences and ``focus_name`` match the request
|
||||
exactly so it should land in a high category. The other two teams
|
||||
have orthogonal focus areas, ensuring at least one team lies in a
|
||||
different bucket - which makes the role_filter step in this test
|
||||
meaningful.
|
||||
"""
|
||||
return [
|
||||
Team(
|
||||
team_id="t1",
|
||||
ouid="ou-1",
|
||||
team_name="Team Alpha",
|
||||
focus_name="Backend Developer",
|
||||
about_us="We build robust backends.",
|
||||
offerings="APIs and services.",
|
||||
interests="Distributed systems.",
|
||||
competences=[
|
||||
TeamCompetence(name="python", top_competency=True),
|
||||
TeamCompetence(name="fastapi", top_competency=False),
|
||||
],
|
||||
references=[
|
||||
TeamReference(partner_name="DB Cargo", projects="ETL Pipeline"),
|
||||
],
|
||||
),
|
||||
Team(
|
||||
team_id="t2",
|
||||
ouid="ou-2",
|
||||
team_name="Team Beta",
|
||||
focus_name="Frontend Developer",
|
||||
about_us="We build slick UIs.",
|
||||
offerings="Web apps.",
|
||||
interests="Web technologies.",
|
||||
competences=[
|
||||
TeamCompetence(name="javascript", top_competency=True),
|
||||
],
|
||||
references=[],
|
||||
),
|
||||
Team(
|
||||
team_id="t3",
|
||||
ouid="ou-3",
|
||||
team_name="Team Gamma",
|
||||
focus_name="Data Engineer",
|
||||
about_us="We build pipelines.",
|
||||
offerings="Data flows.",
|
||||
interests="Big data.",
|
||||
competences=[
|
||||
TeamCompetence(name="spark", top_competency=False),
|
||||
],
|
||||
references=[],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal fake ``DBClient`` for the team integration smoke test."""
|
||||
|
||||
def __init__(self, teams: list[Team]) -> None:
|
||||
self._teams = list(teams)
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["Backend Developer", "Frontend Developer", "Data Engineer"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["python", "fastapi", "javascript", "spark"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_capacities_with_competences(self):
|
||||
return []
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_teams(self) -> list[Team]:
|
||||
return list(self._teams)
|
||||
|
||||
def get_team_by_id(self, team_id): # pragma: no cover
|
||||
for t in self._teams:
|
||||
if t.team_id == str(team_id):
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
class _StubSimilarityEngine:
|
||||
"""Deterministic ``SimilarityEngine`` stand-in.
|
||||
|
||||
Returns a perfect role similarity when the role names match
|
||||
case-insensitively (and zero otherwise) and a per-competence
|
||||
score of 1.0 when the required name appears (case-insensitively)
|
||||
in the candidate's competences.
|
||||
|
||||
The return shape mirrors the production
|
||||
:meth:`SimilarityEngine.compute_competence_similarity` contract:
|
||||
``dict[required, {"score", "best_match", "rationale"}]`` (see
|
||||
``matching/similarity.py``). Avoiding any LLM round-trip keeps
|
||||
this integration test fully offline and deterministic.
|
||||
"""
|
||||
|
||||
async def compute_role_similarity(
|
||||
self, requested: str, candidate: str
|
||||
) -> float:
|
||||
a = (requested or "").strip().lower()
|
||||
b = (candidate or "").strip().lower()
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
return 1.0 if a == b else 0.0
|
||||
|
||||
async def compute_competence_similarity(
|
||||
self,
|
||||
required: list[str],
|
||||
candidate: list[str],
|
||||
global_index: Any = None,
|
||||
) -> dict[str, dict[str, object]]:
|
||||
"""Stub of the production BM25+RRF similarity.
|
||||
|
||||
For every required competence, returns ``score=1.0`` plus the
|
||||
actual candidate name when there is an exact case-insensitive
|
||||
match, and ``score=0.0`` with ``best_match=None`` otherwise.
|
||||
|
||||
``global_index`` is accepted for signature compatibility but
|
||||
not used.
|
||||
"""
|
||||
avail_norm = {a.strip().lower(): a for a in (candidate or []) if a}
|
||||
result: dict[str, dict[str, object]] = {}
|
||||
for req in required or []:
|
||||
key = (req or "").strip().lower()
|
||||
if key in avail_norm:
|
||||
result[req] = {
|
||||
"score": 1.0,
|
||||
"best_match": avail_norm[key],
|
||||
"rationale": "exact match (stub)",
|
||||
}
|
||||
else:
|
||||
result[req] = {
|
||||
"score": 0.0,
|
||||
"best_match": None,
|
||||
"rationale": "no match (stub)",
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _build_server_with_stub_similarity(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> Any:
|
||||
"""Build a server backed by ``_FakeDB`` and a stubbed
|
||||
``SimilarityEngine`` so the score path is fully offline.
|
||||
|
||||
The score path uses :class:`SimilarityEngine` (which would otherwise
|
||||
call the LLM through ``compute_role_similarity``). Replacing the
|
||||
engine with the stub above keeps the integration test deterministic
|
||||
without depending on the BM25 corpus or LLM caching layers.
|
||||
"""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
||||
|
||||
fake_db = _FakeDB(_make_teams())
|
||||
monkeypatch.setattr(
|
||||
mcp_mod, "create_db_client", lambda *_a, **_k: fake_db
|
||||
)
|
||||
|
||||
# Replace the SimilarityEngine class with the stub so build_server
|
||||
# constructs a deterministic engine with zero network surface.
|
||||
monkeypatch.setattr(
|
||||
mcp_mod, "SimilarityEngine", lambda **_kw: _StubSimilarityEngine()
|
||||
)
|
||||
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
return build_server(str(cfg))
|
||||
|
||||
|
||||
def _patch_capturing_store(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Capture every payload passed to ``SearchCache.store_search``."""
|
||||
captured: list[dict[str, Any]] = []
|
||||
original = SearchCache.store_search
|
||||
|
||||
def _capturing(self, *, task_id, requirements, results):
|
||||
captured.append(results)
|
||||
return original(
|
||||
self,
|
||||
task_id=task_id,
|
||||
requirements=requirements,
|
||||
results=results,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(SearchCache, "store_search", _capturing)
|
||||
return captured
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_find_matching_teams_score_mode_full_path(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""End-to-end smoke test for ``find_matching_teams`` in ``score`` mode.
|
||||
|
||||
Validates: requirements 6.1 (score-based team matching), 8.1
|
||||
(search_id propagation), 8.2 (search_type='team_search' marker in
|
||||
SearchCache) and 8.4 (team-specific column layout in
|
||||
``get_results_by_category``).
|
||||
"""
|
||||
captured = _patch_capturing_store(monkeypatch)
|
||||
srv = _build_server_with_stub_similarity(monkeypatch, tmp_path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1) find_matching_teams (score mode)
|
||||
# ------------------------------------------------------------------
|
||||
out = await _call_tool(
|
||||
srv,
|
||||
"find_matching_teams",
|
||||
{
|
||||
"role_name": "Backend Developer",
|
||||
"competences": ["python"],
|
||||
"matching_method": "score",
|
||||
},
|
||||
)
|
||||
|
||||
# SEARCH_ID and META lines exist (Anforderung 8.1).
|
||||
search_id = _extract_search_id(out)
|
||||
meta = _extract_meta(out)
|
||||
assert meta.get("search_id") == search_id
|
||||
assert meta.get("search_type") == "team_search"
|
||||
assert meta.get("matching_method") == "score"
|
||||
|
||||
# Score-mode column headers must be present in the rendered top
|
||||
# category table (Anforderung 6.6 / 8.4). The exact category bucket
|
||||
# is data-dependent, but at least one ``## <Category> Results``
|
||||
# section must appear.
|
||||
assert any(
|
||||
f"## {cat} Results" in out
|
||||
for cat in ("Top", "Good", "Partial", "Low", "Irrelevant")
|
||||
), out
|
||||
for header in (
|
||||
"Team Name",
|
||||
"Schwerpunkt",
|
||||
"Top-Kompetenzen",
|
||||
"Role Score",
|
||||
"Competence Score",
|
||||
"Overall Score",
|
||||
"Category",
|
||||
):
|
||||
assert header in out, (
|
||||
f"missing team-search score-mode header {header!r} in:\n{out}"
|
||||
)
|
||||
|
||||
# The persisted SearchCache payload carries the team_search markers
|
||||
# (Anforderung 8.2).
|
||||
assert captured, "SearchCache.store_search was never called"
|
||||
payload = captured[-1]
|
||||
assert payload["search_type"] == "team_search"
|
||||
assert payload["matching_method"] == "score"
|
||||
assert "by_category" in payload
|
||||
# Score mode must emit per-team score fields on the persisted items.
|
||||
flat_items = [
|
||||
item
|
||||
for items in payload["by_category"].values()
|
||||
for item in items
|
||||
]
|
||||
assert flat_items, (
|
||||
"score-mode payload must contain at least one persisted team item"
|
||||
)
|
||||
for item in flat_items:
|
||||
assert "team_id" in item, item
|
||||
assert "team_name" in item, item
|
||||
assert "focus_name" in item, item
|
||||
assert "competences" in item, item
|
||||
assert "competence_score" in item, item
|
||||
assert "role_score" in item, item
|
||||
assert "overall_score" in item, item
|
||||
assert "category" in item, item
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2) get_results_by_category — uses the cached payload, must render
|
||||
# the team-specific column layout (Anforderung 8.4).
|
||||
# ------------------------------------------------------------------
|
||||
default_category = str(meta.get("default_category") or "Top")
|
||||
cat_out = await _call_tool(
|
||||
srv,
|
||||
"get_results_by_category",
|
||||
{
|
||||
"search_id": search_id,
|
||||
"category": default_category,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
},
|
||||
)
|
||||
|
||||
cat_meta = _extract_meta(cat_out)
|
||||
assert cat_meta.get("search_type") == "team_search"
|
||||
assert cat_meta.get("matching_method") == "score"
|
||||
assert cat_meta.get("search_id") == search_id
|
||||
|
||||
for header in (
|
||||
"Team Name",
|
||||
"Schwerpunkt",
|
||||
"Top-Kompetenzen",
|
||||
"Role Score",
|
||||
"Competence Score",
|
||||
"Overall Score",
|
||||
"Category",
|
||||
):
|
||||
assert header in cat_out, (
|
||||
f"missing team-search header {header!r} in "
|
||||
f"get_results_by_category output:\n{cat_out}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3) filter_search_results — applying ``role_filter='Backend'``
|
||||
# must operate against the team's ``focus_name`` and round-trip
|
||||
# successfully (Anforderung 8.4 / 8.5).
|
||||
# ------------------------------------------------------------------
|
||||
filtered_out = await _call_tool(
|
||||
srv,
|
||||
"filter_search_results",
|
||||
{
|
||||
"search_id": search_id,
|
||||
"role_filter": "Backend",
|
||||
},
|
||||
)
|
||||
|
||||
assert "FILTER_ID=" in filtered_out, filtered_out
|
||||
filter_meta = _extract_meta(filtered_out)
|
||||
assert filter_meta.get("search_type") == "team_search"
|
||||
assert filter_meta.get("matching_method") == "score"
|
||||
assert filter_meta.get("status") == "ok"
|
||||
# The role_filter only matches Team Alpha's ``focus_name`` so the
|
||||
# filtered total must be exactly 1.
|
||||
assert filter_meta.get("total") == 1, filter_meta
|
||||
|
||||
# The Applied Filters table must show the role_filter we passed and
|
||||
# the filtered preview must include the team-search column layout.
|
||||
assert "## Applied Filters" in filtered_out
|
||||
assert "role_filter" in filtered_out
|
||||
assert "Backend" in filtered_out
|
||||
assert "Team Name" in filtered_out
|
||||
assert "Schwerpunkt" in filtered_out
|
||||
assert "Filtered total results: 1" in filtered_out
|
||||
@@ -0,0 +1,225 @@
|
||||
# Feature: team-profile-matching, Property 9: LLM-Pfad ist eine vollständige Partition mit gültigen Kategorien
|
||||
"""Property-based test for the team LLM-fulltext matcher partition.
|
||||
|
||||
For any list of teams and any deterministic LLM error mask
|
||||
(``mask[i] == True`` simulates an Azure failure for ``team_id == str(i)``,
|
||||
``mask[i] == False`` returns a valid JSON category), the result of
|
||||
:meth:`LlmFulltextMatcher.match_teams` MUST be a strict partition of the
|
||||
input set with valid categories and stable ordering.
|
||||
|
||||
The fake :class:`AzureOpenAIClient` looks up the failure flag by
|
||||
``team_id``: the matcher submits its ``chat_completion`` calls
|
||||
concurrently via :func:`asyncio.gather` (throttled by an
|
||||
:class:`asyncio.Semaphore`), so a positional counter would be
|
||||
order-dependent. Resolving the mask through the ``ID: <team_id>`` line
|
||||
embedded in every user prompt (see
|
||||
``_build_user_prompt_team_for_task`` in
|
||||
``matching/llm_fulltext_matcher.py``) keeps the test deterministic
|
||||
regardless of the actual LLM completion order.
|
||||
|
||||
The properties checked are:
|
||||
|
||||
1. Completeness: every team appears either in ``by_category`` or in
|
||||
``errors`` (sum equals ``len(teams)``).
|
||||
2. Uniqueness: no team_id appears twice across ``by_category`` and
|
||||
``errors``.
|
||||
3. Valid categories: every ``LlmFulltextItem.category`` is one of the
|
||||
five canonical categories.
|
||||
4. Stable per-category sort: items inside each ``by_category`` bucket
|
||||
are sorted by ``item_id`` (team_id) ascending.
|
||||
5. Success/error split: successful items carry a non-empty rationale
|
||||
string; teams with simulated LLM failures appear only in
|
||||
``errors`` and never in ``by_category``.
|
||||
|
||||
**Validates: Requirements 7.1, 7.2, 7.3, 7.6, 7.7**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
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 Team, TeamCompetence, TeamReference
|
||||
|
||||
_ALLOWED_CATEGORIES = ("Top", "Good", "Partial", "Low", "Irrelevant")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test doubles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _MaskLlm:
|
||||
"""Fake ``AzureOpenAIClient`` driven by a per-team_id boolean mask.
|
||||
|
||||
Resolves the failure flag through the ``ID: <team_id>`` line that the
|
||||
matcher embeds in every user prompt, so the behaviour is independent
|
||||
of the (concurrent) call order produced by ``asyncio.gather``.
|
||||
"""
|
||||
|
||||
def __init__(self, mask_by_id: dict[str, bool]) -> None:
|
||||
self._mask_by_id = mask_by_id
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
team_id = ""
|
||||
for line in user.splitlines():
|
||||
if line.startswith("ID: "):
|
||||
team_id = line[len("ID: ") :].strip()
|
||||
break
|
||||
if self._mask_by_id.get(team_id, False):
|
||||
raise AzureAPIError("simulated")
|
||||
return json.dumps(
|
||||
{"category": "Good", "rationale": "stub rationale"}
|
||||
)
|
||||
|
||||
|
||||
class _StubDb:
|
||||
"""Minimal ``DBClient`` double.
|
||||
|
||||
``LlmFulltextMatcher.match_teams`` does not perform any database
|
||||
lookups (the :class:`Team` already carries its competences and
|
||||
references), so the stub does not need to implement any method
|
||||
bodies. The constructor still requires a ``db`` argument, which is
|
||||
why the stub exists.
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_teams(n: int) -> list[Team]:
|
||||
"""Build ``n`` teams with deterministic, unique ``team_id`` values."""
|
||||
return [
|
||||
Team(
|
||||
team_id=str(i),
|
||||
ouid=f"ou-{i}",
|
||||
team_name=f"Team {i}",
|
||||
focus_name="Cloud",
|
||||
about_us="",
|
||||
offerings="",
|
||||
interests="",
|
||||
competences=[
|
||||
TeamCompetence(name="Python", top_competency=True),
|
||||
],
|
||||
references=[
|
||||
TeamReference(
|
||||
partner_name=f"Partner {i}",
|
||||
projects=f"Project {i}",
|
||||
),
|
||||
],
|
||||
)
|
||||
for i in range(n)
|
||||
]
|
||||
|
||||
|
||||
def _make_task_profile() -> TaskProfile:
|
||||
return TaskProfile(
|
||||
id="t1",
|
||||
title="Cloud Architect",
|
||||
description="Design and implement cloud platforms.",
|
||||
skills=["Python", "Kubernetes"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(mask=st.lists(st.booleans(), min_size=1, max_size=20))
|
||||
def test_team_llm_fulltext_partition(mask: list[bool]) -> None:
|
||||
"""Property 9: the LLM team path is a complete, deterministic partition.
|
||||
|
||||
Validates Requirements 7.1, 7.2, 7.3, 7.6, 7.7:
|
||||
completeness, uniqueness, valid categories, per-category sort and
|
||||
the success/error split.
|
||||
"""
|
||||
teams = _make_teams(len(mask))
|
||||
mask_by_id = {team.team_id: flag for team, flag in zip(teams, mask)}
|
||||
|
||||
matcher = LlmFulltextMatcher(
|
||||
db=_StubDb(), # type: ignore[arg-type]
|
||||
client=_MaskLlm(mask_by_id), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_teams(
|
||||
task_profile=_make_task_profile(),
|
||||
teams=teams,
|
||||
)
|
||||
)
|
||||
|
||||
# All five canonical buckets are always present in by_category.
|
||||
assert set(result.by_category.keys()) == set(_ALLOWED_CATEGORIES)
|
||||
|
||||
# Property 1: completeness — every team appears exactly once across
|
||||
# by_category and errors (Requirement 7.1, 7.6).
|
||||
category_ids: list[str] = [
|
||||
item.item_id
|
||||
for items in result.by_category.values()
|
||||
for item in items
|
||||
]
|
||||
error_ids: list[str] = [err.item_id for err in result.errors]
|
||||
|
||||
total = len(category_ids) + len(error_ids)
|
||||
assert total == len(teams), (
|
||||
f"Expected {len(teams)} total items, got {total} "
|
||||
f"({len(category_ids)} categorized + {len(error_ids)} errors)"
|
||||
)
|
||||
|
||||
# Property 2: uniqueness — no team_id is duplicated.
|
||||
all_ids = category_ids + error_ids
|
||||
assert len(all_ids) == len(set(all_ids)), (
|
||||
f"Duplicate team_ids detected: {len(all_ids)} total vs "
|
||||
f"{len(set(all_ids))} unique"
|
||||
)
|
||||
|
||||
# Property 3: every category value is one of the canonical five
|
||||
# (Requirement 7.2).
|
||||
for items in result.by_category.values():
|
||||
for item in items:
|
||||
assert item.category in _ALLOWED_CATEGORIES, (
|
||||
f"Unexpected category: {item.category!r}"
|
||||
)
|
||||
|
||||
# Property 4: per-category items are sorted ascending by item_id
|
||||
# (Requirement 7.7).
|
||||
for category, items in result.by_category.items():
|
||||
item_ids = [item.item_id for item in items]
|
||||
assert item_ids == sorted(item_ids), (
|
||||
f"Items in category {category!r} are not sorted by "
|
||||
f"item_id ascending: {item_ids}"
|
||||
)
|
||||
|
||||
# Property 5a: every successfully categorized team has a non-empty
|
||||
# rationale string (Requirement 7.3).
|
||||
for items in result.by_category.values():
|
||||
for item in items:
|
||||
assert isinstance(item.rationale, str)
|
||||
assert item.rationale.strip(), (
|
||||
f"Empty rationale for categorized team {item.item_id}"
|
||||
)
|
||||
|
||||
# Property 5b: success/error split matches the mask exactly
|
||||
# (Requirement 7.6). Failing-mask teams must not appear in
|
||||
# by_category, succeeding-mask teams must not appear in errors.
|
||||
expected_error_ids = {tid for tid, flag in mask_by_id.items() if flag}
|
||||
expected_success_ids = {tid for tid, flag in mask_by_id.items() if not flag}
|
||||
|
||||
assert set(error_ids) == expected_error_ids, (
|
||||
f"Mismatch between expected and observed error team_ids: "
|
||||
f"expected {expected_error_ids}, got {set(error_ids)}"
|
||||
)
|
||||
assert set(category_ids) == expected_success_ids, (
|
||||
f"Mismatch between expected and observed categorized team_ids: "
|
||||
f"expected {expected_success_ids}, got {set(category_ids)}"
|
||||
)
|
||||
@@ -0,0 +1,186 @@
|
||||
# Feature: team-profile-matching, Property 10: Ungültige LLM-Kategorien fallen auf Irrelevant zurück
|
||||
"""Property-based test for invalid LLM categories in the team path.
|
||||
|
||||
For any LLM response ``{"category": c, "rationale": r}`` where ``c`` is
|
||||
not one of the canonical categories
|
||||
``("Top", "Good", "Partial", "Low", "Irrelevant")`` (case-insensitive,
|
||||
trimmed), the team-side matcher must:
|
||||
|
||||
1. Route the team exclusively to the ``Irrelevant`` bucket of
|
||||
``result.by_category``.
|
||||
2. Append a deterministic hint of the form
|
||||
``[Hinweis: ungültige LLM-Kategorie: ...]`` to the rationale, so the
|
||||
result remains traceable to the offending LLM response.
|
||||
3. Preserve the original (trimmed) rationale text alongside the hint.
|
||||
4. Not record the team in ``result.errors`` — invalid categories are a
|
||||
re-routed result, not a failure.
|
||||
|
||||
The test exercises the public :meth:`LlmFulltextMatcher.match_teams`
|
||||
path with a single team to validate the matcher behaviour end-to-end
|
||||
(not just the ``normalize_category`` helper). The
|
||||
``AzureOpenAIClient`` is replaced by a minimal in-memory double that
|
||||
returns the invalid-category JSON; the ``DBClient`` is unused by
|
||||
``match_teams`` because :class:`Team` already carries its competences
|
||||
and references.
|
||||
|
||||
**Validates: Requirements 7.5**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.llm_fulltext_matcher import LlmFulltextMatcher
|
||||
from teamlandkarte_mcp.matching.profiles import TaskProfile
|
||||
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
|
||||
|
||||
|
||||
_ALLOWED_LOWER = {"top", "good", "partial", "low", "irrelevant"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Any short string whose trimmed/lowercased form is NOT one of the
|
||||
# canonical categories. ``normalize_category`` will treat such values as
|
||||
# invalid and the matcher must route them to ``Irrelevant``.
|
||||
_invalid_category = st.text(max_size=20).filter(
|
||||
lambda s: s.strip().lower() not in _ALLOWED_LOWER
|
||||
)
|
||||
|
||||
# Free-form rationale text the fake LLM returns alongside the invalid
|
||||
# category. The matcher must keep the original (non-whitespace) content
|
||||
# in the stored rationale.
|
||||
_rationale = st.text(max_size=120)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test doubles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubLlm:
|
||||
"""Minimal ``AzureOpenAIClient`` double returning a fixed JSON payload.
|
||||
|
||||
Mirrors the fake-client pattern used in
|
||||
:mod:`tests.test_team_llm_fulltext_partition_pbt` (Property 9), but
|
||||
instead of a per-team mask driving success/failure, this double
|
||||
deterministically responds with the configured invalid category for
|
||||
every prompt.
|
||||
"""
|
||||
|
||||
def __init__(self, *, category: str, rationale: str) -> None:
|
||||
self._payload = json.dumps(
|
||||
{"category": category, "rationale": rationale}
|
||||
)
|
||||
|
||||
async def chat_completion(self, system: str, user: str) -> str:
|
||||
return self._payload
|
||||
|
||||
|
||||
class _StubDb:
|
||||
"""Minimal ``DBClient`` double.
|
||||
|
||||
:meth:`LlmFulltextMatcher.match_teams` does not perform any database
|
||||
lookups (the :class:`Team` already carries its competences and
|
||||
references), so the stub does not need to implement any method
|
||||
bodies. The constructor still requires a ``db`` argument, which is
|
||||
why the stub exists.
|
||||
"""
|
||||
|
||||
|
||||
def _make_team() -> Team:
|
||||
return Team(
|
||||
team_id="42",
|
||||
ouid="ou-42",
|
||||
team_name="Team 42",
|
||||
focus_name="Cloud",
|
||||
about_us="",
|
||||
offerings="",
|
||||
interests="",
|
||||
competences=[TeamCompetence(name="Python", top_competency=True)],
|
||||
references=[
|
||||
TeamReference(partner_name="Partner 42", projects="Project 42"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _make_task_profile() -> TaskProfile:
|
||||
return TaskProfile(
|
||||
id="t1",
|
||||
title="Cloud Architect",
|
||||
description="Design and implement cloud platforms.",
|
||||
skills=["Python", "Kubernetes"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(category=_invalid_category, rationale=_rationale)
|
||||
def test_team_invalid_llm_category_routes_to_irrelevant(
|
||||
category: str, rationale: str
|
||||
) -> None:
|
||||
"""Property 10: invalid LLM categories on the team path map to ``Irrelevant``.
|
||||
|
||||
For any invalid category string and any rationale the LLM returns,
|
||||
the team matcher places the team exclusively in the ``Irrelevant``
|
||||
bucket, preserves the (trimmed) original rationale text in the
|
||||
stored rationale, appends the deterministic invalid-category hint,
|
||||
and does not produce an error entry.
|
||||
"""
|
||||
matcher = LlmFulltextMatcher(
|
||||
db=_StubDb(), # type: ignore[arg-type]
|
||||
client=_StubLlm(category=category, rationale=rationale), # type: ignore[arg-type]
|
||||
)
|
||||
team = _make_team()
|
||||
task_profile = _make_task_profile()
|
||||
|
||||
result = asyncio.run(
|
||||
matcher.match_teams(
|
||||
task_profile=task_profile,
|
||||
teams=[team],
|
||||
)
|
||||
)
|
||||
|
||||
# All five canonical buckets are present in by_category.
|
||||
assert set(result.by_category.keys()) == {
|
||||
"Top",
|
||||
"Good",
|
||||
"Partial",
|
||||
"Low",
|
||||
"Irrelevant",
|
||||
}
|
||||
|
||||
# The team is routed exclusively to ``Irrelevant``.
|
||||
assert result.by_category["Top"] == []
|
||||
assert result.by_category["Good"] == []
|
||||
assert result.by_category["Partial"] == []
|
||||
assert result.by_category["Low"] == []
|
||||
assert len(result.by_category["Irrelevant"]) == 1
|
||||
|
||||
item = result.by_category["Irrelevant"][0]
|
||||
assert item.item_id == team.team_id
|
||||
assert item.category == "Irrelevant"
|
||||
|
||||
# The rationale carries the deterministic invalid-category hint.
|
||||
assert "ungültige LLM-Kategorie" in item.rationale
|
||||
assert "[Hinweis: ungültige LLM-Kategorie:" in item.rationale
|
||||
|
||||
# The original rationale text is preserved (after trimming, since the
|
||||
# matcher applies ``.strip()`` to the combined rationale).
|
||||
trimmed = rationale.strip()
|
||||
if trimmed:
|
||||
assert trimmed in item.rationale
|
||||
|
||||
# No error entries are produced for invalid categories: the team is a
|
||||
# regular (re-routed) result, not a failure.
|
||||
assert result.errors == []
|
||||
@@ -0,0 +1,415 @@
|
||||
# Feature: team-profile-matching, Property 3: Stammdaten-Konsistenz und INNER-JOIN-Filter
|
||||
"""Property-based test for team master-data consistency and the
|
||||
INNER JOIN filter implemented in
|
||||
``TrinoClient._fetch_teams_base`` / ``get_all_teams`` /
|
||||
``get_team_by_id``.
|
||||
|
||||
Validates Property 3 from the team-profile-matching design:
|
||||
|
||||
*Für jede* Stub-DB-Antwort (Listen von Teams- und OrganizationalUnits-
|
||||
Zeilen mit beliebigen NULL/Empty-Verteilungen) gilt:
|
||||
``get_all_teams()``
|
||||
|
||||
1. enthält ausschließlich Teams, deren ``team_id`` in der
|
||||
OrganizationalUnits-Liste über ``id`` einen Treffer hat
|
||||
(Anforderung 2.3),
|
||||
2. liefert für jede der Spalten ``about_us``, ``offerings``,
|
||||
``interests``, ``focus_name`` einen leeren String, wenn die Quelle
|
||||
``NULL`` oder leer ist, und niemals ``None`` (Anforderung 2.4),
|
||||
3. setzt ``Team.team_name`` auf den Namen aus der OU-Zeile, der dem
|
||||
``team_id``-Match entspricht (Anforderung 2.2),
|
||||
4. liefert für jede gefundene ``team_id`` ein konsistentes Ergebnis
|
||||
mit ``get_team_by_id(team_id)`` (Anforderung 2.5).
|
||||
|
||||
The test does *not* exercise the real Trino driver. Instead it
|
||||
installs a stub-DB-driven ``_FakeCursor`` that simulates the SQL
|
||||
operations the production code expresses (INNER JOIN over the Teams
|
||||
and OUs row lists, plus ``COALESCE`` for nullable text fields). The
|
||||
cursor inspects the executed SQL string to dispatch between the
|
||||
team-master-data query (with or without ``WHERE`` clause) and the
|
||||
batch-competences / batch-references queries. The latter return
|
||||
empty rows in this test scenario because Property 3 only constrains
|
||||
the team master-data path (competences and references are covered
|
||||
by Properties 4 and 5).
|
||||
|
||||
**Validates: Requirements 2.1, 2.2, 2.3, 2.4, 2.5**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.database.trino_client import TrinoClient
|
||||
from teamlandkarte_mcp.models import Team
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub-DB row containers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubDB:
|
||||
"""A pair of stub row lists feeding the fake cursor.
|
||||
|
||||
``team_rows`` are dicts with keys ``team_id``, ``ouid``,
|
||||
``focus_name``, ``about_us``, ``offerings``, ``interests`` (the
|
||||
text columns may be ``None`` or empty strings to exercise the
|
||||
``COALESCE`` paths).
|
||||
|
||||
``ou_rows`` are dicts with keys ``id`` and ``name`` modelling
|
||||
rows of ``teamlandkarte_v_teammeter_organizational_units_latest``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
team_rows: list[dict[str, Any]],
|
||||
ou_rows: list[dict[str, Any]],
|
||||
) -> None:
|
||||
self.team_rows = team_rows
|
||||
self.ou_rows = ou_rows
|
||||
|
||||
|
||||
def _coalesce_empty(value: Any) -> str:
|
||||
"""Simulate SQL ``COALESCE(<col>, '')`` for nullable text columns."""
|
||||
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _simulate_teams_join(
|
||||
stub: _StubDB,
|
||||
*,
|
||||
where_params: tuple[str, ...] | None,
|
||||
) -> list[tuple]:
|
||||
"""Simulate the INNER JOIN of ``_fetch_teams_base``.
|
||||
|
||||
Mirrors the production SQL:
|
||||
|
||||
- INNER JOIN ``teams_latest`` × ``organizational_units_latest`` on
|
||||
``CAST(team_id AS VARCHAR) = CAST(id AS VARCHAR)`` (teams
|
||||
without a matching OU are dropped).
|
||||
- ``COALESCE(<text>, '')`` for ``focus_name``, ``about_us``,
|
||||
``offerings``, ``interests`` (empty string for ``NULL``).
|
||||
- Optional ``WHERE CAST(t.team_id AS VARCHAR) = ? OR t.ouid = ?``
|
||||
filter when
|
||||
``where_params`` is non-``None``.
|
||||
- ``ORDER BY ou.name ASC, t.team_id ASC``.
|
||||
|
||||
Returns rows in the shape ``_fetch_teams_base`` consumes:
|
||||
``(team_id, ouid, team_name, focus_name, about_us, offerings,
|
||||
interests)``.
|
||||
"""
|
||||
|
||||
ou_by_id = {str(ou["id"]): ou for ou in stub.ou_rows}
|
||||
rows: list[tuple] = []
|
||||
for team in stub.team_rows:
|
||||
ou = ou_by_id.get(str(team["team_id"]))
|
||||
if ou is None:
|
||||
# INNER JOIN excludes teams without a matching OU.
|
||||
continue
|
||||
if where_params is not None:
|
||||
tid_param, ouid_param = where_params
|
||||
if (
|
||||
str(team["team_id"]) != tid_param
|
||||
and team["ouid"] != ouid_param
|
||||
):
|
||||
continue
|
||||
rows.append(
|
||||
(
|
||||
team["team_id"],
|
||||
team["ouid"],
|
||||
ou["name"],
|
||||
_coalesce_empty(team.get("focus_name")),
|
||||
_coalesce_empty(team.get("about_us")),
|
||||
_coalesce_empty(team.get("offerings")),
|
||||
_coalesce_empty(team.get("interests")),
|
||||
)
|
||||
)
|
||||
rows.sort(key=lambda r: (r[2], r[0]))
|
||||
return rows
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake cursor / connection plumbing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
"""Stub cursor that dispatches by SQL substring match.
|
||||
|
||||
For the team-master-data query (matched via the
|
||||
``teamlandkarte_v_teams_latest`` table reference) the cursor
|
||||
simulates the INNER JOIN and ``COALESCE`` semantics via
|
||||
:func:`_simulate_teams_join`. For the batch-competences and
|
||||
batch-references queries it returns an empty result set: those
|
||||
code paths are exercised by Properties 4 and 5 and only need to
|
||||
not break here.
|
||||
"""
|
||||
|
||||
def __init__(self, stub: _StubDB) -> None:
|
||||
self.stub = stub
|
||||
self._next_rows: list[tuple] = []
|
||||
|
||||
def execute(self, sql: str, params: Any = None) -> None: # noqa: ANN401
|
||||
params_tuple: tuple[str, ...]
|
||||
if params is None:
|
||||
params_tuple = ()
|
||||
elif isinstance(params, tuple):
|
||||
params_tuple = tuple(str(p) for p in params)
|
||||
else:
|
||||
params_tuple = tuple(str(p) for p in params)
|
||||
|
||||
if "teamlandkarte_v_teams_latest" in sql:
|
||||
where_params: tuple[str, ...] | None
|
||||
if "CAST(t.team_id AS VARCHAR) = ? OR t.ouid = ?" in sql:
|
||||
# ``get_team_by_id`` passes the same id as both params.
|
||||
if len(params_tuple) >= 2:
|
||||
where_params = (params_tuple[0], params_tuple[1])
|
||||
else:
|
||||
where_params = ("", "")
|
||||
else:
|
||||
where_params = None
|
||||
self._next_rows = _simulate_teams_join(
|
||||
self.stub, where_params=where_params
|
||||
)
|
||||
return
|
||||
|
||||
if (
|
||||
"teamlandkarte_v_teammeter_team_competences_latest" in sql
|
||||
or "teamlandkarte_v_team_references_latest" in sql
|
||||
):
|
||||
# No competences / references rows in this scenario.
|
||||
self._next_rows = []
|
||||
return
|
||||
|
||||
# Defensive default: no rows for unrecognised statements.
|
||||
self._next_rows = []
|
||||
|
||||
def fetchone(self) -> Any: # noqa: ANN401
|
||||
return self._next_rows[0] if self._next_rows else None
|
||||
|
||||
def fetchall(self) -> list[tuple]:
|
||||
return list(self._next_rows)
|
||||
|
||||
|
||||
class _FakeCursorContext:
|
||||
"""Context-manager wrapper around :class:`_FakeCursor`."""
|
||||
|
||||
def __init__(self, cursor: _FakeCursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self) -> _FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
return None
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
"""Minimal ``DatabaseConfig`` stand-in for ``TrinoClient(...)``."""
|
||||
|
||||
host = "x"
|
||||
port = 1
|
||||
username = "u"
|
||||
password = "p"
|
||||
http_scheme = "http"
|
||||
verify_ssl = False
|
||||
catalog = "c"
|
||||
schema = "s"
|
||||
pool_size = 1
|
||||
|
||||
|
||||
def _make_client(stub: _StubDB) -> TrinoClient:
|
||||
"""Build a ``TrinoClient`` whose ``_cursor`` yields a fake cursor."""
|
||||
|
||||
cursor = _FakeCursor(stub)
|
||||
client = TrinoClient(_FakeConfig()) # type: ignore[arg-type]
|
||||
client._cursor = lambda: _FakeCursorContext(cursor) # type: ignore[method-assign]
|
||||
return client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hypothesis strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Text-field generator: ``None`` (NULL), ``""`` (empty), or a non-empty
|
||||
# printable string. Mirrors realistic data-lake content for the four
|
||||
# nullable text columns.
|
||||
_text_or_null = st.one_of(
|
||||
st.none(),
|
||||
st.just(""),
|
||||
st.text(min_size=1, max_size=20),
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def _stub_db(draw: st.DrawFn) -> _StubDB:
|
||||
"""Build a stub DB with intentionally varied INNER JOIN coverage.
|
||||
|
||||
The strategy generates a small pool of OU rows (with non-empty,
|
||||
non-conflicting names so the SQL ``ORDER BY`` is well-defined)
|
||||
and a list of team rows whose ``team_id`` either references an
|
||||
existing OU id or a deliberately non-matching id (to exercise
|
||||
the INNER JOIN filter, Anforderung 2.3).
|
||||
|
||||
Team ``team_id`` values are kept unique within a single stub DB
|
||||
so ``get_team_by_id(team_id)`` is unambiguous (Anforderung 2.5).
|
||||
Team ``ouid`` values are likewise unique. Text columns are drawn
|
||||
from :data:`_text_or_null` to cover both ``NULL`` and ``""``
|
||||
branches (Anforderung 2.4).
|
||||
"""
|
||||
|
||||
# 1) Generate OU rows with unique ids and unique non-empty names.
|
||||
n_ous = draw(st.integers(min_value=0, max_value=4))
|
||||
ou_names = draw(
|
||||
st.lists(
|
||||
st.text(
|
||||
alphabet=st.characters(
|
||||
whitelist_categories=("Lu", "Ll", "Nd")
|
||||
),
|
||||
min_size=1,
|
||||
max_size=8,
|
||||
),
|
||||
min_size=n_ous,
|
||||
max_size=n_ous,
|
||||
unique=True,
|
||||
)
|
||||
)
|
||||
ou_rows = [
|
||||
{"id": f"OU{i}", "name": name}
|
||||
for i, name in enumerate(ou_names)
|
||||
]
|
||||
|
||||
# 2) Generate team rows. Each team independently picks an existing
|
||||
# OU id (matching team_id) or a deliberately missing id.
|
||||
n_teams = draw(st.integers(min_value=0, max_value=6))
|
||||
teams: list[dict[str, Any]] = []
|
||||
used_team_ids: set[str] = set()
|
||||
for i in range(n_teams):
|
||||
prefer_match = draw(st.booleans()) and ou_rows
|
||||
if prefer_match:
|
||||
candidate = draw(
|
||||
st.sampled_from([ou["id"] for ou in ou_rows])
|
||||
)
|
||||
else:
|
||||
candidate = f"MISS{i}"
|
||||
if candidate in used_team_ids:
|
||||
# Skip duplicates so ``get_team_by_id`` is unambiguous.
|
||||
continue
|
||||
used_team_ids.add(candidate)
|
||||
teams.append(
|
||||
{
|
||||
"team_id": candidate,
|
||||
"ouid": f"TEAM_OU_{i}",
|
||||
"focus_name": draw(_text_or_null),
|
||||
"about_us": draw(_text_or_null),
|
||||
"offerings": draw(_text_or_null),
|
||||
"interests": draw(_text_or_null),
|
||||
}
|
||||
)
|
||||
return _StubDB(team_rows=teams, ou_rows=ou_rows)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SETTINGS = settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
|
||||
|
||||
@_SETTINGS
|
||||
@given(stub=_stub_db())
|
||||
def test_team_master_data_consistency_and_inner_join(
|
||||
stub: _StubDB,
|
||||
) -> None:
|
||||
"""Property 3: Stammdaten-Konsistenz und INNER-JOIN-Filter.
|
||||
|
||||
Asserts the four sub-properties listed in the module docstring
|
||||
against the stub-DB-driven fake cursor.
|
||||
"""
|
||||
|
||||
client = _make_client(stub)
|
||||
|
||||
teams = client.get_all_teams()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# (1) INNER JOIN filter: only teams with a matching OU id
|
||||
# (Anforderung 2.3).
|
||||
# ---------------------------------------------------------------
|
||||
ou_by_id = {ou["id"]: ou for ou in stub.ou_rows}
|
||||
expected_team_ids = {
|
||||
t["team_id"] for t in stub.team_rows if t["team_id"] in ou_by_id
|
||||
}
|
||||
actual_team_ids = {t.team_id for t in teams}
|
||||
assert actual_team_ids == expected_team_ids, (
|
||||
"INNER JOIN filter mismatch: expected "
|
||||
f"{expected_team_ids!r}, got {actual_team_ids!r}"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# (2) NULL/empty → ""; never ``None`` (Anforderung 2.4).
|
||||
# ---------------------------------------------------------------
|
||||
for team in teams:
|
||||
assert isinstance(team, Team)
|
||||
for field_name in (
|
||||
"focus_name",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
):
|
||||
value = getattr(team, field_name)
|
||||
assert isinstance(value, str), (
|
||||
f"{field_name} must be str, got "
|
||||
f"{type(value).__name__}: {value!r}"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# (3) team_name must come from the matched OU row
|
||||
# (Anforderung 2.2).
|
||||
# ---------------------------------------------------------------
|
||||
for team in teams:
|
||||
ou = ou_by_id[team.team_id]
|
||||
assert team.team_name == ou["name"], (
|
||||
f"team_name mismatch for {team.team_id!r}: "
|
||||
f"expected {ou['name']!r}, got {team.team_name!r}"
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# (4) ``get_team_by_id`` is consistent with ``get_all_teams``
|
||||
# (Anforderung 2.5).
|
||||
# ---------------------------------------------------------------
|
||||
by_team_id = {t.team_id: t for t in teams}
|
||||
for team_id, expected_team in by_team_id.items():
|
||||
single = client.get_team_by_id(team_id)
|
||||
assert single is not None, (
|
||||
f"get_team_by_id({team_id!r}) returned None despite the "
|
||||
f"team appearing in get_all_teams()"
|
||||
)
|
||||
assert single.team_id == expected_team.team_id
|
||||
assert single.ouid == expected_team.ouid
|
||||
assert single.team_name == expected_team.team_name
|
||||
assert single.focus_name == expected_team.focus_name
|
||||
assert single.about_us == expected_team.about_us
|
||||
assert single.offerings == expected_team.offerings
|
||||
assert single.interests == expected_team.interests
|
||||
|
||||
# And: a team_id that the INNER JOIN excluded must not be
|
||||
# retrievable via ``get_team_by_id`` either.
|
||||
excluded = [
|
||||
t["team_id"]
|
||||
for t in stub.team_rows
|
||||
if t["team_id"] not in ou_by_id
|
||||
]
|
||||
for missing_id in excluded:
|
||||
assert client.get_team_by_id(missing_id) is None, (
|
||||
f"get_team_by_id({missing_id!r}) returned a team that "
|
||||
f"was excluded by the INNER JOIN"
|
||||
)
|
||||
@@ -0,0 +1,251 @@
|
||||
# Feature: team-profile-matching, Property 1: matching_method-Validierung in find_matching_teams
|
||||
"""Property test for ``matching_method`` validation in ``find_matching_teams``.
|
||||
|
||||
The MCP tool ``find_matching_teams`` must reject any ``matching_method``
|
||||
value whose ``strip().lower()`` representation is neither ``"score"``
|
||||
nor ``"llm_fulltext"``. The rejection must:
|
||||
|
||||
1. Produce an error message that mentions both allowed values
|
||||
(``score`` and ``llm_fulltext``).
|
||||
2. Avoid touching the database (no team loading) and the LLM (no
|
||||
``chat_completion`` call). The validation must short-circuit BEFORE
|
||||
any side-effect.
|
||||
|
||||
The fake database and the fake ``chat_completion`` track call counts;
|
||||
both counters must remain zero for every Hypothesis-generated invalid
|
||||
input.
|
||||
|
||||
**Validates: Requirements 1.5, 1.6**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
|
||||
|
||||
_CONFIG_TEMPLATE = """
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[matching.team]
|
||||
top_competency_weight=1.5
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
|
||||
[azure_openai]
|
||||
endpoint='https://example.openai.azure.com'
|
||||
api_version='2024-02-15-preview'
|
||||
chat_deployment='gpt-4'
|
||||
""".strip()
|
||||
|
||||
|
||||
class _CountingDB:
|
||||
"""Fake DB that counts how often the team loaders are invoked.
|
||||
|
||||
Any non-zero counter after a rejected validation is a bug: the
|
||||
``find_matching_teams`` validation must short-circuit before the
|
||||
DB is touched.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.team_calls = 0
|
||||
self.team_by_id_calls = 0
|
||||
# Capacity counters are kept for symmetry with the capacity
|
||||
# equivalent test; the team validation path must never call them
|
||||
# either, but they would otherwise be unused attributes.
|
||||
self.capacity_calls = 0
|
||||
self.capacity_by_id_calls = 0
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["X"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["A"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_capacities_with_competences(self):
|
||||
self.capacity_calls += 1
|
||||
return []
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_capacity_by_id(self, capacity_id): # pragma: no cover
|
||||
self.capacity_by_id_calls += 1
|
||||
return None
|
||||
|
||||
def get_all_teams(self):
|
||||
self.team_calls += 1
|
||||
return []
|
||||
|
||||
def get_team_by_id(self, team_id): # pragma: no cover
|
||||
self.team_by_id_calls += 1
|
||||
return None
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"), str
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
def _build_test_server(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> tuple[Any, _CountingDB, dict[str, int]]:
|
||||
"""Construct a server backed by counting DB and LLM doubles."""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
||||
|
||||
fake_db = _CountingDB()
|
||||
monkeypatch.setattr(
|
||||
mcp_mod, "create_db_client", lambda *_a, **_k: fake_db
|
||||
)
|
||||
|
||||
llm_calls = {"count": 0}
|
||||
|
||||
async def _fake_chat_completion(self, system, user): # noqa: ARG001
|
||||
llm_calls["count"] += 1
|
||||
return '{"category":"Top","rationale":"r"}'
|
||||
|
||||
monkeypatch.setattr(
|
||||
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
|
||||
_fake_chat_completion,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
return srv, fake_db, llm_calls
|
||||
|
||||
|
||||
# Reject anything that, after trimming and lower-casing, is one of the
|
||||
# canonical values (those would be valid inputs and would not exercise
|
||||
# the error path) or the empty string. The empty/whitespace-only string
|
||||
# is treated by the server as "no value provided" (it then falls back
|
||||
# to the configured default), so it is not part of the invalid-input
|
||||
# space this property test targets. Hypothesis is otherwise free to
|
||||
# generate any text.
|
||||
def _is_invalid_method(value: str) -> bool:
|
||||
norm = value.strip().lower()
|
||||
if norm == "":
|
||||
return False
|
||||
return norm not in ("score", "llm_fulltext")
|
||||
|
||||
|
||||
_INVALID_METHOD = st.text(max_size=20).filter(_is_invalid_method)
|
||||
|
||||
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
@given(value=_INVALID_METHOD)
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_matching_method_in_find_matching_teams_is_rejected_without_side_effects(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
value: str,
|
||||
) -> None:
|
||||
"""Property 1: invalid matching_method values are rejected verbatim.
|
||||
|
||||
The error message must list both allowed values; the team DB loader
|
||||
and the LLM mock must not be touched.
|
||||
"""
|
||||
srv, fake_db, llm_calls = _build_test_server(monkeypatch, tmp_path)
|
||||
|
||||
res = await _call_tool(
|
||||
srv,
|
||||
"find_matching_teams",
|
||||
{
|
||||
"role_name": "X",
|
||||
"competences": ["A"],
|
||||
"matching_method": value,
|
||||
},
|
||||
)
|
||||
|
||||
# The error message references both allowed values.
|
||||
assert "score" in res, (
|
||||
f"missing 'score' in error response for {value!r}: {res!r}"
|
||||
)
|
||||
assert "llm_fulltext" in res, (
|
||||
f"missing 'llm_fulltext' in error response for {value!r}: {res!r}"
|
||||
)
|
||||
|
||||
# The response must signal an error (not a successful search).
|
||||
lowered = res.lower()
|
||||
assert ("error" in lowered) or ("invalid" in lowered), (
|
||||
f"response is not an error for {value!r}: {res!r}"
|
||||
)
|
||||
|
||||
# No DB/LLM access for the rejected validation path.
|
||||
assert fake_db.team_calls == 0, (
|
||||
f"DB team loader was invoked for invalid value {value!r}"
|
||||
)
|
||||
assert fake_db.team_by_id_calls == 0, (
|
||||
f"DB get_team_by_id was invoked for invalid value {value!r}"
|
||||
)
|
||||
assert llm_calls["count"] == 0, (
|
||||
f"LLM was invoked for invalid value {value!r}"
|
||||
)
|
||||
@@ -0,0 +1,328 @@
|
||||
# Feature: team-profile-matching, Property 11: META und SearchCache markieren Team-Suchen korrekt
|
||||
"""Property test for ``META`` and ``SearchCache`` markings of team searches.
|
||||
|
||||
For every successful ``find_matching_teams`` invocation the following
|
||||
must hold:
|
||||
|
||||
1. The output contains a line ``SEARCH_ID=<uuid>`` where ``<uuid>``
|
||||
parses as a valid UUID (Anforderung 8.1).
|
||||
2. The output contains a line ``META=<json>`` whose JSON object has
|
||||
``search_type == "team_search"`` and ``matching_method`` in
|
||||
``{"score", "llm_fulltext"}`` (Anforderungen 1.4, 8.3).
|
||||
3. The associated ``SearchCache`` entry carries
|
||||
``results["search_type"] == "team_search"`` and
|
||||
``results["matching_method"]`` equal to the ``META`` value
|
||||
(Anforderung 8.2). This is verified indirectly via
|
||||
``get_results_by_category`` whose own ``META`` line is sourced from
|
||||
the cache entry's ``results`` dict.
|
||||
4. ``get_results_by_category(search_id, ...)`` produces a table that
|
||||
contains the team-specific headers ``Team Name``, ``Schwerpunkt``
|
||||
and ``Top-Kompetenzen`` (Anforderung 8.4).
|
||||
|
||||
The test rebuilds the server per Hypothesis example with a counting
|
||||
fake ``DBClient`` that returns a single team whose competences and
|
||||
``focus_name`` overlap with the generated request, and an
|
||||
``AsyncMock`` for ``AzureOpenAIClient.chat_completion`` that returns a
|
||||
shared payload satisfying both the role-similarity (``similarity``)
|
||||
and the LLM-fulltext (``category`` + ``rationale``) consumers.
|
||||
|
||||
**Validates: Requirements 1.4, 8.1, 8.2, 8.3, 8.4**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mcp_mod
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
|
||||
|
||||
|
||||
_CONFIG_TEMPLATE = """
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[matching.team]
|
||||
top_competency_weight=1.5
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=60
|
||||
max_size=100
|
||||
|
||||
[azure_openai]
|
||||
endpoint='https://example.openai.azure.com'
|
||||
api_version='2024-02-15-preview'
|
||||
chat_deployment='gpt-4'
|
||||
""".strip()
|
||||
|
||||
|
||||
# Fixed pools so the generated request data overlaps with the team
|
||||
# returned by the fake DB. Overlap guarantees that the score path
|
||||
# produces non-trivial similarities and that the team lands in some
|
||||
# category bucket; the property does not depend on which bucket it is
|
||||
# (we read ``default_category`` from META).
|
||||
_COMPETENCE_POOL = ("python", "java", "javascript", "fastapi")
|
||||
_ROLE_POOL = ("Backend Developer", "Data Engineer", "Beliebige Rolle")
|
||||
|
||||
|
||||
def _make_team() -> Team:
|
||||
"""Single deterministic team containing the full competence pool."""
|
||||
return Team(
|
||||
team_id="t1",
|
||||
ouid="ou-1",
|
||||
team_name="Team Alpha",
|
||||
focus_name="Backend Developer",
|
||||
about_us="We build backends.",
|
||||
offerings="APIs and services.",
|
||||
interests="Distributed systems.",
|
||||
competences=[
|
||||
TeamCompetence(name="python", top_competency=True),
|
||||
TeamCompetence(name="java", top_competency=False),
|
||||
TeamCompetence(name="javascript", top_competency=False),
|
||||
TeamCompetence(name="fastapi", top_competency=False),
|
||||
],
|
||||
references=[
|
||||
TeamReference(partner_name="DB Cargo", projects="ETL Pipeline"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal fake DB returning a single, fixed team."""
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["Backend Developer"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return list(_COMPETENCE_POOL)
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_capacities_with_competences(self):
|
||||
return []
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_teams(self) -> list[Team]:
|
||||
return [_make_team()]
|
||||
|
||||
def get_team_by_id(self, team_id): # pragma: no cover
|
||||
team = _make_team()
|
||||
if str(team_id) == team.team_id:
|
||||
return team
|
||||
return None
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"), str
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
def _extract_search_id(output: str) -> str:
|
||||
for line in output.splitlines():
|
||||
if line.startswith("SEARCH_ID="):
|
||||
return line[len("SEARCH_ID=") :].strip()
|
||||
raise AssertionError(f"no SEARCH_ID= line in tool output:\n{output}")
|
||||
|
||||
|
||||
def _extract_meta(output: str) -> dict[str, Any]:
|
||||
for line in output.splitlines():
|
||||
if line.startswith("META="):
|
||||
return json.loads(line[len("META=") :])
|
||||
raise AssertionError(f"no META= line in tool output:\n{output}")
|
||||
|
||||
|
||||
def _build_test_server(monkeypatch: pytest.MonkeyPatch, tmp_path) -> Any:
|
||||
"""Construct a server backed by ``_FakeDB`` and a mocked LLM."""
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mcp_mod, "create_db_client", lambda *_a, **_k: _FakeDB()
|
||||
)
|
||||
|
||||
# A single payload satisfies both the score path
|
||||
# (``similarity``) and the LLM-fulltext path (``category``,
|
||||
# ``rationale``).
|
||||
monkeypatch.setattr(
|
||||
"teamlandkarte_mcp.azure.openai_client.AzureOpenAIClient.chat_completion",
|
||||
AsyncMock(
|
||||
return_value=(
|
||||
'{"similarity": 1.0, "category": "Top", "rationale": "ok"}'
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setenv("DATA_LAKE_USERNAME", "u")
|
||||
monkeypatch.setenv("DATA_LAKE_PASSWORD", "p")
|
||||
monkeypatch.setenv("AZURE_OPENAI_LLM_API_KEY", "k")
|
||||
|
||||
return build_server(str(cfg))
|
||||
|
||||
|
||||
# Hypothesis strategies for valid inputs. ``matching_method`` covers
|
||||
# both supported values; we deliberately do not include the empty
|
||||
# string here because that path resolves to the configured default and
|
||||
# would make assertions on the META value match-dependent on config.
|
||||
_VALID_METHODS = st.sampled_from(("score", "llm_fulltext"))
|
||||
_ROLE_NAMES = st.sampled_from(_ROLE_POOL)
|
||||
_COMPETENCES = st.lists(
|
||||
st.sampled_from(_COMPETENCE_POOL),
|
||||
min_size=1,
|
||||
max_size=len(_COMPETENCE_POOL),
|
||||
unique=True,
|
||||
)
|
||||
|
||||
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
@given(
|
||||
role_name=_ROLE_NAMES,
|
||||
competences=_COMPETENCES,
|
||||
matching_method=_VALID_METHODS,
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_search_meta_and_cache_markings_are_correct(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
role_name: str,
|
||||
competences: list[str],
|
||||
matching_method: str,
|
||||
) -> None:
|
||||
"""Property 11: META and SearchCache mark team searches correctly."""
|
||||
srv = _build_test_server(monkeypatch, tmp_path)
|
||||
|
||||
# Run find_matching_teams with the generated valid inputs.
|
||||
out = await _call_tool(
|
||||
srv,
|
||||
"find_matching_teams",
|
||||
{
|
||||
"role_name": role_name,
|
||||
"competences": competences,
|
||||
"matching_method": matching_method,
|
||||
},
|
||||
)
|
||||
|
||||
# (1) SEARCH_ID is present and parses as a UUID.
|
||||
search_id = _extract_search_id(out)
|
||||
parsed = uuid.UUID(search_id)
|
||||
assert str(parsed) == search_id, (
|
||||
f"SEARCH_ID is not a canonical UUID string: {search_id!r}"
|
||||
)
|
||||
|
||||
# (2) META has search_type='team_search' and a valid matching_method.
|
||||
meta = _extract_meta(out)
|
||||
assert meta.get("search_type") == "team_search", (
|
||||
f"expected search_type='team_search', got META={meta!r}"
|
||||
)
|
||||
assert meta.get("matching_method") in ("score", "llm_fulltext"), (
|
||||
f"matching_method not in allowed set: META={meta!r}"
|
||||
)
|
||||
assert meta.get("matching_method") == matching_method, (
|
||||
f"META matching_method {meta.get('matching_method')!r} does "
|
||||
f"not match the request value {matching_method!r}"
|
||||
)
|
||||
assert meta.get("search_id") == search_id, (
|
||||
f"META search_id {meta.get('search_id')!r} does not match "
|
||||
f"SEARCH_ID line {search_id!r}"
|
||||
)
|
||||
|
||||
# (3) The SearchCache entry must carry the same markings. We prove
|
||||
# this indirectly via ``get_results_by_category`` whose META is
|
||||
# sourced from ``entry.results``: if META has the right values,
|
||||
# the persisted entry has them too.
|
||||
default_category = str(meta.get("default_category") or "Top")
|
||||
cat_out = await _call_tool(
|
||||
srv,
|
||||
"get_results_by_category",
|
||||
{
|
||||
"search_id": search_id,
|
||||
"category": default_category,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
},
|
||||
)
|
||||
|
||||
cat_meta = _extract_meta(cat_out)
|
||||
assert cat_meta.get("search_type") == "team_search", (
|
||||
"SearchCache entry is missing search_type='team_search': "
|
||||
f"META={cat_meta!r}"
|
||||
)
|
||||
assert cat_meta.get("matching_method") == matching_method, (
|
||||
"SearchCache entry has wrong matching_method: "
|
||||
f"expected {matching_method!r}, META={cat_meta!r}"
|
||||
)
|
||||
assert cat_meta.get("search_id") == search_id, (
|
||||
f"category META search_id {cat_meta.get('search_id')!r} "
|
||||
f"does not match SEARCH_ID {search_id!r}"
|
||||
)
|
||||
|
||||
# (4) The team-specific table headers must be present in the
|
||||
# rendered category table (regardless of whether the bucket is
|
||||
# empty - the headers are emitted unconditionally).
|
||||
for header in ("Team Name", "Schwerpunkt", "Top-Kompetenzen"):
|
||||
assert header in cat_out, (
|
||||
f"missing team header {header!r} in get_results_by_category "
|
||||
f"output for matching_method={matching_method!r}, "
|
||||
f"category={default_category!r}:\n{cat_out}"
|
||||
)
|
||||
@@ -0,0 +1,344 @@
|
||||
# Feature: team-profile-matching, Property 6: Determinismus und Vollständigkeit der Team-Serialisierung
|
||||
"""Property-based tests for deterministic team-profile serialization.
|
||||
|
||||
Covers Property 6 from the team-profile-matching design:
|
||||
|
||||
For every :class:`TeamProfile` instance ``p`` (including instances with all
|
||||
mandatory fields empty, empty competence/reference lists, and references
|
||||
with empty ``partner_name``) the helpers ``serialize_team_profile`` and
|
||||
``build_team_profile`` from
|
||||
``teamlandkarte_mcp.matching.profiles`` must satisfy:
|
||||
|
||||
1. ``serialize_team_profile(p) == serialize_team_profile(p)``
|
||||
(determinism, requirement 13.1).
|
||||
2. ``serialize_team_profile(build_team_profile(team)) ==
|
||||
serialize_team_profile(build_team_profile(team))`` for every ``Team``
|
||||
(idempotency of profile construction, requirement 13.2).
|
||||
3. The output contains every one of the seven headings ``Teamname:``,
|
||||
``Schwerpunkt:``, ``Über uns:``, ``Leistungen:``, ``Interessen:``,
|
||||
``Kompetenzen:``, ``Referenzen:`` at least once and exactly in this
|
||||
order (requirements 5.4, 5.7, 13.3).
|
||||
4. For every competence with ``top_competency=True`` the output contains
|
||||
the marker suffix ``(Top)`` directly attached to the competence name
|
||||
(requirement 5.5).
|
||||
5. For every reference with ``partner_name != ""`` the output contains
|
||||
both the ``partner_name`` and the ``projects`` text as substrings
|
||||
(requirement 5.6); for references with ``partner_name == ""`` the
|
||||
corresponding line does **not** contain the ``Partner: `` token
|
||||
(requirement 5.3).
|
||||
6. The order of competence and reference list entries in the
|
||||
serialization matches the order of ``profile.competences`` and
|
||||
``profile.references`` (requirement 13.4).
|
||||
|
||||
**Validates: Requirements 5.1, 5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 13.1, 13.2, 13.3, 13.4**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.matching.profiles import (
|
||||
TeamCompetenceEntry,
|
||||
TeamProfile,
|
||||
TeamReferenceEntry,
|
||||
build_team_profile,
|
||||
serialize_team_profile,
|
||||
)
|
||||
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hypothesis strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Small text strategy to keep generation fast and deterministic. Allows the
|
||||
# empty string so the property covers the "all mandatory fields empty"
|
||||
# case explicitly mentioned in the design.
|
||||
_text = st.text(max_size=40)
|
||||
|
||||
# Competence names must be non-whitespace so substring assertions (e.g.
|
||||
# ``f"{name} (Top)" in output``) are unambiguous.
|
||||
_competence_name = st.text(min_size=1, max_size=30).filter(lambda s: s.strip() != "" and "\n" not in s)
|
||||
|
||||
# ``partner_name`` may be empty (NULL ``partner_id`` / Join mismatch) or
|
||||
# any short non-empty text without newlines so substring checks remain
|
||||
# unambiguous.
|
||||
_partner_name = st.one_of(
|
||||
st.just(""),
|
||||
st.text(min_size=1, max_size=20).filter(lambda s: s.strip() != "" and "\n" not in s),
|
||||
)
|
||||
|
||||
# The DB layer has filtered whitespace-only ``projects``, so every
|
||||
# generated reference uses a non-empty, non-whitespace value. We also
|
||||
# avoid newlines so per-reference line containment checks are robust.
|
||||
_projects = st.text(min_size=1, max_size=40).filter(lambda s: s.strip() != "" and "\n" not in s)
|
||||
|
||||
_team_competence_entry = st.builds(
|
||||
TeamCompetenceEntry,
|
||||
name=_competence_name,
|
||||
top_competency=st.booleans(),
|
||||
)
|
||||
|
||||
_team_reference_entry = st.builds(
|
||||
TeamReferenceEntry,
|
||||
partner_name=_partner_name,
|
||||
projects=_projects,
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def _team_profile(draw: st.DrawFn) -> TeamProfile:
|
||||
"""Generate a ``TeamProfile`` with mixed empty/non-empty fields and lists."""
|
||||
|
||||
return TeamProfile(
|
||||
id=draw(_text),
|
||||
ouid=draw(_text),
|
||||
team_name=draw(_text),
|
||||
focus_name=draw(_text),
|
||||
about_us=draw(_text),
|
||||
offerings=draw(_text),
|
||||
interests=draw(_text),
|
||||
competences=draw(st.lists(_team_competence_entry, max_size=5)),
|
||||
references=draw(st.lists(_team_reference_entry, max_size=5)),
|
||||
)
|
||||
|
||||
|
||||
_team_competence_model = st.builds(
|
||||
TeamCompetence,
|
||||
name=_competence_name,
|
||||
top_competency=st.booleans(),
|
||||
)
|
||||
|
||||
_team_reference_model = st.builds(
|
||||
TeamReference,
|
||||
partner_name=_partner_name,
|
||||
projects=_projects,
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def _team(draw: st.DrawFn) -> Team:
|
||||
"""Generate a ``Team`` with mixed empty/non-empty fields and lists."""
|
||||
|
||||
return Team(
|
||||
team_id=draw(_text),
|
||||
ouid=draw(_text),
|
||||
team_name=draw(_text),
|
||||
focus_name=draw(_text),
|
||||
about_us=draw(_text),
|
||||
offerings=draw(_text),
|
||||
interests=draw(_text),
|
||||
competences=draw(st.lists(_team_competence_model, max_size=5)),
|
||||
references=draw(st.lists(_team_reference_model, max_size=5)),
|
||||
)
|
||||
|
||||
|
||||
_HEADINGS = (
|
||||
"Teamname:",
|
||||
"Schwerpunkt:",
|
||||
"Über uns:",
|
||||
"Leistungen:",
|
||||
"Interessen:",
|
||||
"Kompetenzen:",
|
||||
"Referenzen:",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(profile=_team_profile())
|
||||
def test_serialize_team_profile_is_deterministic(profile: TeamProfile) -> None:
|
||||
"""Property 6.1: ``serialize_team_profile`` is deterministic.
|
||||
|
||||
Two consecutive calls on the same profile must yield byte-identical
|
||||
strings (requirement 13.1).
|
||||
"""
|
||||
|
||||
first = serialize_team_profile(profile)
|
||||
second = serialize_team_profile(profile)
|
||||
|
||||
assert first == second, "serialize_team_profile is not deterministic"
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(team=_team())
|
||||
def test_serialize_team_profile_is_idempotent_via_build(team: Team) -> None:
|
||||
"""Property 6.2: ``build_team_profile`` is idempotent under serialization.
|
||||
|
||||
For every ``Team``, building a profile and serializing it must yield
|
||||
the exact same string on repeated invocations (requirement 13.2).
|
||||
"""
|
||||
|
||||
first = serialize_team_profile(build_team_profile(team))
|
||||
second = serialize_team_profile(build_team_profile(team))
|
||||
|
||||
assert first == second, "serialize_team_profile(build_team_profile(team)) is not idempotent"
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(profile=_team_profile())
|
||||
def test_serialize_team_profile_contains_all_headings_in_order(
|
||||
profile: TeamProfile,
|
||||
) -> None:
|
||||
"""Property 6.3: Every heading appears at least once and in fixed order.
|
||||
|
||||
The serialized output must contain each of the seven headings
|
||||
``Teamname:``, ``Schwerpunkt:``, ``Über uns:``, ``Leistungen:``,
|
||||
``Interessen:``, ``Kompetenzen:``, ``Referenzen:`` and these
|
||||
headings must appear strictly in this order
|
||||
(requirements 5.4, 5.7, 13.3).
|
||||
"""
|
||||
|
||||
output = serialize_team_profile(profile)
|
||||
|
||||
# Headings are emitted by the serializer at the start of a line.
|
||||
# We require each heading to occur as a line prefix and search
|
||||
# strictly past the previously-found heading line so user-provided
|
||||
# text containing the same heading substring (e.g.
|
||||
# ``interests='Referenzen:'``) cannot perturb the ordering check.
|
||||
lines = output.split("\n")
|
||||
last_line_index = -1
|
||||
for heading in _HEADINGS:
|
||||
found_line = -1
|
||||
for i in range(last_line_index + 1, len(lines)):
|
||||
if lines[i].startswith(heading):
|
||||
found_line = i
|
||||
break
|
||||
assert found_line != -1, (
|
||||
f"Heading {heading!r} missing at or after line "
|
||||
f"{last_line_index + 1} in team serialization: "
|
||||
f"{output!r}"
|
||||
)
|
||||
last_line_index = found_line
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(profile=_team_profile())
|
||||
def test_serialize_team_profile_marks_top_competences(
|
||||
profile: TeamProfile,
|
||||
) -> None:
|
||||
"""Property 6.4: Top competences are marked with ``(Top)``.
|
||||
|
||||
For every competence with ``top_competency=True`` the serialized
|
||||
output must contain the substring ``"<name> (Top)"`` (requirement
|
||||
5.5).
|
||||
"""
|
||||
|
||||
output = serialize_team_profile(profile)
|
||||
|
||||
for competence in profile.competences:
|
||||
if competence.top_competency:
|
||||
marker = f"{competence.name} (Top)"
|
||||
assert marker in output, (
|
||||
f"Top-competence marker {marker!r} missing from serialization: {output!r}"
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(profile=_team_profile())
|
||||
def test_serialize_team_profile_partner_handling(
|
||||
profile: TeamProfile,
|
||||
) -> None:
|
||||
"""Property 6.5: Partner-name visibility and absence handling.
|
||||
|
||||
For every reference with non-empty ``partner_name`` the serialized
|
||||
output contains both the partner name and the projects text as a
|
||||
substring (requirement 5.6). For every reference with empty
|
||||
``partner_name`` the corresponding line does not contain the
|
||||
``"Partner: "`` token (requirement 5.3).
|
||||
"""
|
||||
|
||||
output = serialize_team_profile(profile)
|
||||
|
||||
# Locate the references block; entries are emitted as ``- <line>``
|
||||
# bullets directly after the ``Referenzen:`` heading line. The
|
||||
# serializer separates lines exclusively with ``\n``; ``str.splitlines``
|
||||
# would also split on Unicode separators (e.g. U+0085) that may legally
|
||||
# appear inside ``projects``, so we deliberately use ``split("\n")``.
|
||||
# ``Referenzen:`` is always preceded by ``Interessen: ...\n`` in the
|
||||
# serializer's fixed field order, so the leading-newline anchor is
|
||||
# guaranteed to match exactly the heading line (and not a heading-like
|
||||
# substring inside one of the text fields above).
|
||||
refs_anchor = output.find("\nReferenzen:")
|
||||
assert refs_anchor != -1, f"References heading missing from serialization: {output!r}"
|
||||
refs_block = output[refs_anchor + 1 :]
|
||||
|
||||
if not profile.references:
|
||||
# No references - the block is rendered as ``Referenzen: (keine)``
|
||||
# and must not contain any partner token.
|
||||
assert "Partner: " not in refs_block
|
||||
return
|
||||
|
||||
# Split into per-reference bullet lines (skip the heading line itself).
|
||||
bullet_lines = [line[2:] for line in refs_block.split("\n")[1:] if line.startswith("- ")]
|
||||
assert len(bullet_lines) == len(profile.references), (
|
||||
f"Expected {len(profile.references)} reference bullet lines, got {len(bullet_lines)} in: {output!r}"
|
||||
)
|
||||
|
||||
for line, ref in zip(bullet_lines, profile.references):
|
||||
if ref.partner_name:
|
||||
assert ref.partner_name in line, f"Partner name {ref.partner_name!r} missing from line {line!r}"
|
||||
assert ref.projects.strip() in line, f"Projects text {ref.projects!r} missing from line {line!r}"
|
||||
else:
|
||||
assert "Partner: " not in line, (
|
||||
f"Empty-partner reference line must not contain 'Partner: ' token: {line!r}"
|
||||
)
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(profile=_team_profile())
|
||||
def test_serialize_team_profile_preserves_list_order(
|
||||
profile: TeamProfile,
|
||||
) -> None:
|
||||
"""Property 6.6: List entry order matches the input order.
|
||||
|
||||
The order of competence and reference entries in the serialized
|
||||
output matches the order in ``profile.competences`` and
|
||||
``profile.references`` exactly (requirement 13.4).
|
||||
"""
|
||||
|
||||
output = serialize_team_profile(profile)
|
||||
|
||||
# Verify competence ordering by scanning the competence block only;
|
||||
# text fields above (e.g. ``team_name``) might coincidentally contain
|
||||
# a ``- <name>`` substring, so we restrict the search window.
|
||||
if profile.competences:
|
||||
comp_start = output.find("\nKompetenzen:")
|
||||
# Defensive: ``Kompetenzen:`` is always preceded by other lines
|
||||
# (Teamname: etc.), so ``\nKompetenzen:`` must be present.
|
||||
assert comp_start != -1
|
||||
# Competence block ends just before the ``Referenzen:`` heading.
|
||||
refs_start = output.find("\nReferenzen:", comp_start)
|
||||
comp_block = output[comp_start:refs_start] if refs_start != -1 else output[comp_start:]
|
||||
last_idx = -1
|
||||
for competence in profile.competences:
|
||||
# Use the bullet-prefixed form to avoid spurious matches in
|
||||
# other fields (e.g. team_name).
|
||||
needle = f"- {competence.name}"
|
||||
idx = comp_block.find(needle, last_idx + 1)
|
||||
assert idx > last_idx, (
|
||||
f"Competence {competence.name!r} not found in expected order in serialization: {output!r}"
|
||||
)
|
||||
last_idx = idx
|
||||
|
||||
# Verify reference ordering by locating each ``projects`` text in
|
||||
# turn within the references block (it is unique-enough because
|
||||
# every reference's projects value is non-whitespace).
|
||||
if profile.references:
|
||||
refs_index = output.find("\nReferenzen:")
|
||||
assert refs_index != -1
|
||||
refs_block = output[refs_index:]
|
||||
last_idx = -1
|
||||
for reference in profile.references:
|
||||
needle = reference.projects.strip()
|
||||
idx = refs_block.find(needle, last_idx + 1)
|
||||
assert idx > last_idx, (
|
||||
f"Reference projects {reference.projects!r} not found in "
|
||||
f"expected order in serialization: {output!r}"
|
||||
)
|
||||
last_idx = idx
|
||||
@@ -0,0 +1,361 @@
|
||||
# Feature: team-profile-matching, Task 5.5: Unit tests for build_team_profile / serialize_team_profile
|
||||
"""Example-based unit tests for the team-profile builder and serializer.
|
||||
|
||||
These tests complement the property-based tests in
|
||||
``tests/test_team_profile_serialization_pbt.py`` by pinning down concrete
|
||||
example outputs for ``build_team_profile`` and ``serialize_team_profile``
|
||||
defined in ``teamlandkarte_mcp.matching.profiles``.
|
||||
|
||||
Covered scenarios (see task 5.5 in the team-profile-matching spec):
|
||||
|
||||
* Empty strings on every text field plus empty competence/reference lists
|
||||
still produce an output that contains all seven fixed German headings,
|
||||
with empty values rendered after the heading and lists rendered as
|
||||
``Kompetenzen: (keine)`` / ``Referenzen: (keine)``.
|
||||
* Mixed top / non-top competences are rendered with the ``(Top)`` suffix
|
||||
only on the top entries.
|
||||
* References with a non-empty ``partner_name`` use the
|
||||
``Partner: <name> – Projekte: <projects>`` form (en-dash U+2013).
|
||||
* References with an empty ``partner_name`` use the bare
|
||||
``Projekte: <projects>`` form (no ``Partner:`` token, no placeholder).
|
||||
* ``build_team_profile`` performs a 1:1 mapping from ``Team`` to
|
||||
``TeamProfile`` without re-sorting, including the ouid, team_id and the
|
||||
competence / reference lists.
|
||||
* Empty competence / reference lists pass through ``build_team_profile``
|
||||
unchanged.
|
||||
|
||||
**Validates: Requirements 5.1, 5.3, 5.4, 5.5, 5.6**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from teamlandkarte_mcp.matching.profiles import (
|
||||
TeamCompetenceEntry,
|
||||
TeamProfile,
|
||||
TeamReferenceEntry,
|
||||
build_team_profile,
|
||||
serialize_team_profile,
|
||||
)
|
||||
from teamlandkarte_mcp.models import Team, TeamCompetence, TeamReference
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_team_profile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_team_profile_maps_all_fields_one_to_one() -> None:
|
||||
"""``build_team_profile`` copies every ``Team`` field onto the profile.
|
||||
|
||||
Validates requirement 5.1: every team field (team_id, ouid, team_name,
|
||||
focus_name, about_us, offerings, interests, competences, references)
|
||||
appears on the resulting :class:`TeamProfile` with the exact same
|
||||
value, in the exact same list order. ``team_id`` is converted to
|
||||
``str`` for the profile ``id`` field.
|
||||
"""
|
||||
team = Team(
|
||||
team_id="T-42",
|
||||
ouid="ou-9",
|
||||
team_name="Alpha Team",
|
||||
focus_name="Backend",
|
||||
about_us="Wir bauen APIs.",
|
||||
offerings="Beratung",
|
||||
interests="Cloud",
|
||||
competences=[
|
||||
TeamCompetence(name="Python", top_competency=True),
|
||||
TeamCompetence(name="FastAPI", top_competency=False),
|
||||
],
|
||||
references=[
|
||||
TeamReference(partner_name="Acme", projects="Project X"),
|
||||
TeamReference(partner_name="", projects="Project Y"),
|
||||
],
|
||||
)
|
||||
|
||||
profile = build_team_profile(team)
|
||||
|
||||
assert profile.id == "T-42"
|
||||
assert profile.ouid == "ou-9"
|
||||
assert profile.team_name == "Alpha Team"
|
||||
assert profile.focus_name == "Backend"
|
||||
assert profile.about_us == "Wir bauen APIs."
|
||||
assert profile.offerings == "Beratung"
|
||||
assert profile.interests == "Cloud"
|
||||
|
||||
# Competences are preserved 1:1 and in the original order.
|
||||
assert profile.competences == [
|
||||
TeamCompetenceEntry(name="Python", top_competency=True),
|
||||
TeamCompetenceEntry(name="FastAPI", top_competency=False),
|
||||
]
|
||||
|
||||
# References are preserved 1:1 and in the original order.
|
||||
assert profile.references == [
|
||||
TeamReferenceEntry(partner_name="Acme", projects="Project X"),
|
||||
TeamReferenceEntry(partner_name="", projects="Project Y"),
|
||||
]
|
||||
|
||||
|
||||
def test_build_team_profile_passes_empty_lists_through() -> None:
|
||||
"""Empty ``competences`` and ``references`` lists pass through unchanged.
|
||||
|
||||
Validates requirement 5.1: an empty list on the input team yields an
|
||||
empty list on the resulting profile, without dropping the profile or
|
||||
inserting placeholder entries.
|
||||
"""
|
||||
team = Team(
|
||||
team_id="T-empty",
|
||||
ouid="ou-empty",
|
||||
team_name="",
|
||||
focus_name="",
|
||||
about_us="",
|
||||
offerings="",
|
||||
interests="",
|
||||
competences=[],
|
||||
references=[],
|
||||
)
|
||||
|
||||
profile = build_team_profile(team)
|
||||
|
||||
assert profile.competences == []
|
||||
assert profile.references == []
|
||||
# The string fields stay empty without any placeholder.
|
||||
assert profile.team_name == ""
|
||||
assert profile.focus_name == ""
|
||||
assert profile.about_us == ""
|
||||
assert profile.offerings == ""
|
||||
assert profile.interests == ""
|
||||
|
||||
|
||||
def test_build_team_profile_does_not_reorder_competences() -> None:
|
||||
"""``build_team_profile`` preserves the input order of competences.
|
||||
|
||||
The DB layer is the single source of truth for the deterministic
|
||||
competence order ``(top_competency desc, name asc)``; the builder
|
||||
must not re-sort. Validates requirement 5.1 / 13.4.
|
||||
"""
|
||||
team = Team(
|
||||
team_id="T-1",
|
||||
ouid="ou-1",
|
||||
team_name="Team",
|
||||
focus_name="",
|
||||
about_us="",
|
||||
offerings="",
|
||||
interests="",
|
||||
competences=[
|
||||
# Intentionally not in (top desc, name asc) order so a
|
||||
# re-sort would be visible.
|
||||
TeamCompetence(name="Zeta", top_competency=False),
|
||||
TeamCompetence(name="Alpha", top_competency=True),
|
||||
TeamCompetence(name="Beta", top_competency=False),
|
||||
],
|
||||
references=[],
|
||||
)
|
||||
|
||||
profile = build_team_profile(team)
|
||||
|
||||
assert [c.name for c in profile.competences] == ["Zeta", "Alpha", "Beta"]
|
||||
assert [c.top_competency for c in profile.competences] == [False, True, False]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# serialize_team_profile
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_serialize_team_profile_empty_profile_emits_all_headings() -> None:
|
||||
"""An empty profile still emits the seven fixed German headings.
|
||||
|
||||
Validates requirements 5.4 (fixed heading order) and the ``(keine)``
|
||||
fallback for empty competence and reference lists.
|
||||
"""
|
||||
profile = TeamProfile(
|
||||
id="",
|
||||
ouid="",
|
||||
team_name="",
|
||||
focus_name="",
|
||||
about_us="",
|
||||
offerings="",
|
||||
interests="",
|
||||
competences=[],
|
||||
references=[],
|
||||
)
|
||||
|
||||
output = serialize_team_profile(profile)
|
||||
|
||||
expected = "\n".join(
|
||||
[
|
||||
"Teamname: ",
|
||||
"Schwerpunkt: ",
|
||||
"Über uns: ",
|
||||
"Leistungen: ",
|
||||
"Interessen: ",
|
||||
"Kompetenzen: (keine)",
|
||||
"Referenzen: (keine)",
|
||||
]
|
||||
)
|
||||
assert output == expected
|
||||
|
||||
|
||||
def test_serialize_team_profile_mixed_top_and_non_top_competences() -> None:
|
||||
"""Top competences carry the ``(Top)`` suffix; non-top entries do not.
|
||||
|
||||
Validates requirement 5.5: the marker is rendered as ``" (Top)"``
|
||||
directly after the competence name, exactly for entries with
|
||||
``top_competency=True``.
|
||||
"""
|
||||
profile = TeamProfile(
|
||||
id="T-1",
|
||||
ouid="ou-1",
|
||||
team_name="Team Alpha",
|
||||
focus_name="Backend",
|
||||
about_us="",
|
||||
offerings="",
|
||||
interests="",
|
||||
competences=[
|
||||
TeamCompetenceEntry(name="Python", top_competency=True),
|
||||
TeamCompetenceEntry(name="FastAPI", top_competency=False),
|
||||
TeamCompetenceEntry(name="SQL", top_competency=True),
|
||||
],
|
||||
references=[],
|
||||
)
|
||||
|
||||
output = serialize_team_profile(profile)
|
||||
|
||||
assert "Kompetenzen:\n- Python (Top)\n- FastAPI\n- SQL (Top)" in output
|
||||
# Non-top entries must not accidentally pick up the marker.
|
||||
assert "FastAPI (Top)" not in output
|
||||
|
||||
|
||||
def test_serialize_team_profile_reference_with_partner_name() -> None:
|
||||
"""References with a partner render as ``Partner: <name> – Projekte: <projects>``.
|
||||
|
||||
Validates requirement 5.6: both partner name and projects text are
|
||||
visible, joined with the en-dash U+2013 (``\u2013``).
|
||||
"""
|
||||
profile = TeamProfile(
|
||||
id="T-1",
|
||||
ouid="ou-1",
|
||||
team_name="Team",
|
||||
focus_name="",
|
||||
about_us="",
|
||||
offerings="",
|
||||
interests="",
|
||||
competences=[],
|
||||
references=[
|
||||
TeamReferenceEntry(partner_name="Acme", projects="Project X"),
|
||||
],
|
||||
)
|
||||
|
||||
output = serialize_team_profile(profile)
|
||||
|
||||
assert "Referenzen:\n- Partner: Acme \u2013 Projekte: Project X" in output
|
||||
|
||||
|
||||
def test_serialize_team_profile_reference_without_partner_name() -> None:
|
||||
"""References without a partner render as bare ``Projekte: <projects>``.
|
||||
|
||||
Validates requirement 5.3: an empty ``partner_name`` suppresses the
|
||||
``Partner: `` token entirely, and no placeholder is inserted.
|
||||
"""
|
||||
profile = TeamProfile(
|
||||
id="T-1",
|
||||
ouid="ou-1",
|
||||
team_name="Team",
|
||||
focus_name="",
|
||||
about_us="",
|
||||
offerings="",
|
||||
interests="",
|
||||
competences=[],
|
||||
references=[
|
||||
TeamReferenceEntry(partner_name="", projects="Project Y"),
|
||||
],
|
||||
)
|
||||
|
||||
output = serialize_team_profile(profile)
|
||||
|
||||
# Locate the references block to make the absence-assertion robust
|
||||
# against unrelated text in other fields.
|
||||
refs_start = output.index("Referenzen:")
|
||||
refs_block = output[refs_start:]
|
||||
assert "- Projekte: Project Y" in refs_block
|
||||
assert "Partner: " not in refs_block
|
||||
# No en-dash filler when there is no partner.
|
||||
assert "\u2013" not in refs_block
|
||||
|
||||
|
||||
def test_serialize_team_profile_mixed_references_with_and_without_partner() -> None:
|
||||
"""Mixed references render each entry with the appropriate shape.
|
||||
|
||||
Validates requirements 5.3 and 5.6 jointly: in a single profile, a
|
||||
reference with a partner uses the ``Partner: ... – Projekte: ...``
|
||||
form, and a reference without a partner uses the bare
|
||||
``Projekte: ...`` form. Order is preserved 1:1.
|
||||
"""
|
||||
profile = TeamProfile(
|
||||
id="T-1",
|
||||
ouid="ou-1",
|
||||
team_name="Team",
|
||||
focus_name="",
|
||||
about_us="",
|
||||
offerings="",
|
||||
interests="",
|
||||
competences=[],
|
||||
references=[
|
||||
TeamReferenceEntry(partner_name="Acme", projects="Project X"),
|
||||
TeamReferenceEntry(partner_name="", projects="Project Y"),
|
||||
],
|
||||
)
|
||||
|
||||
output = serialize_team_profile(profile)
|
||||
|
||||
expected_block = (
|
||||
"Referenzen:\n"
|
||||
"- Partner: Acme \u2013 Projekte: Project X\n"
|
||||
"- Projekte: Project Y"
|
||||
)
|
||||
assert expected_block in output
|
||||
|
||||
|
||||
def test_serialize_team_profile_full_example_round_trip() -> None:
|
||||
"""End-to-end example: build then serialize a populated team.
|
||||
|
||||
Validates requirements 5.1, 5.3, 5.4, 5.5, 5.6 together: the full
|
||||
serialized output for a representative team matches the expected
|
||||
fixed-format string exactly, including heading order, ``(Top)``
|
||||
markers, and reference shapes.
|
||||
"""
|
||||
team = Team(
|
||||
team_id="T-42",
|
||||
ouid="ou-9",
|
||||
team_name="Alpha Team",
|
||||
focus_name="Backend",
|
||||
about_us="Wir bauen APIs.",
|
||||
offerings="Beratung",
|
||||
interests="Cloud",
|
||||
competences=[
|
||||
TeamCompetence(name="Python", top_competency=True),
|
||||
TeamCompetence(name="FastAPI", top_competency=False),
|
||||
],
|
||||
references=[
|
||||
TeamReference(partner_name="Acme", projects="Project X"),
|
||||
TeamReference(partner_name="", projects="Project Y"),
|
||||
],
|
||||
)
|
||||
|
||||
output = serialize_team_profile(build_team_profile(team))
|
||||
|
||||
expected = "\n".join(
|
||||
[
|
||||
"Teamname: Alpha Team",
|
||||
"Schwerpunkt: Backend",
|
||||
"Über uns: Wir bauen APIs.",
|
||||
"Leistungen: Beratung",
|
||||
"Interessen: Cloud",
|
||||
"Kompetenzen:",
|
||||
"- Python (Top)",
|
||||
"- FastAPI",
|
||||
"Referenzen:",
|
||||
"- Partner: Acme \u2013 Projekte: Project X",
|
||||
"- Projekte: Project Y",
|
||||
]
|
||||
)
|
||||
assert output == expected
|
||||
@@ -0,0 +1,431 @@
|
||||
# Feature: team-profile-matching, Property 5: Referenz-Batch ist konsistent zur Einzel-Variante
|
||||
"""Property-based test for the batch/single consistency of the team
|
||||
reference queries on ``TrinoClient``.
|
||||
|
||||
Validates Property 5 from the team-profile-matching design:
|
||||
|
||||
*Für jede* Liste von OUIDs ``ouids`` und jede Stub-DB-Antwort gilt:
|
||||
``batch_get_team_references(ouids)[ouid]`` ist für jedes ``ouid`` in
|
||||
``ouids`` gleich ``get_team_references(ouid)``. Außerdem gilt für
|
||||
jeden Eintrag der Ergebnislisten:
|
||||
|
||||
- bei ``partner_id IS NULL`` oder leerem Partner-Join ist
|
||||
``partner_name == ""``, der Eintrag bleibt aber in der Liste
|
||||
erhalten (Anforderung 4.5),
|
||||
- Einträge mit leerem oder ausschließlich Whitespace gefülltem
|
||||
``projects`` sind nicht enthalten (Anforderung 4.6),
|
||||
- die Reihenfolge ist ``(partner_name asc, projects asc)`` und damit
|
||||
deterministisch (Anforderung 4.7),
|
||||
- für ``ouid``-Werte ohne Referenzen ist die Liste leer
|
||||
(Anforderung 4.4).
|
||||
|
||||
Zusätzlich ruft die Batch-Methode genau einen ``cur.execute(...)``-Call
|
||||
gegen ``teamlandkarte_v_team_references_latest`` ab (Anforderung 4.3).
|
||||
|
||||
The test installs a stub cursor that simulates the SQL
|
||||
``COALESCE(p.name, '')`` and ``ORDER BY`` semantics for both the
|
||||
single (``WHERE r.ouid = ?``) and the batch (``WHERE r.ouid IN
|
||||
(?, ?, ...)``) form. The simulated cursor holds an in-memory list of
|
||||
reference rows ``(ouid, projects, partner_name)`` where ``projects``
|
||||
may be ``None``, empty or whitespace-only (to exercise the
|
||||
Python-side filter, Requirement 4.6) and ``partner_name`` may be
|
||||
``None`` (to exercise the COALESCE normalisation, Requirement 4.5).
|
||||
|
||||
**Validates: Requirements 4.1, 4.3, 4.4, 4.5, 4.6, 4.7**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.database.trino_client import TrinoClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub-DB row container
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubDB:
|
||||
"""Holds the in-memory reference rows for the fake cursor.
|
||||
|
||||
Each row is a ``(ouid, projects, partner_name)`` tuple. ``projects``
|
||||
may be ``None``, ``""`` or whitespace-only (all must be filtered
|
||||
by the Trino client, Requirement 4.6) or any non-empty string.
|
||||
``partner_name`` may be ``None`` (NULL after partner_id IS NULL or
|
||||
no join match) and must be normalised to ``""`` (Requirement 4.5).
|
||||
"""
|
||||
|
||||
def __init__(self, rows: list[tuple[str, Any, Any]]) -> None:
|
||||
self.rows = rows
|
||||
|
||||
|
||||
def _partner_norm(value: Any) -> str:
|
||||
"""Mirror ``COALESCE(p.name, '')`` on the SQL side."""
|
||||
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def _projects_sort_key(projects: Any) -> tuple[int, str]:
|
||||
"""Order keys with ``None`` last, then by string ascending.
|
||||
|
||||
Trino orders ``NULL`` last for ``ASC``; we mirror that here so the
|
||||
simulated cursor produces a deterministic order even for the rows
|
||||
that the Python-side filter will subsequently drop.
|
||||
"""
|
||||
|
||||
if projects is None:
|
||||
return (1, "")
|
||||
return (0, str(projects))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fake cursor / connection plumbing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_REFERENCES_VIEW = "teamlandkarte_v_team_references_latest"
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
"""Stub cursor that dispatches by SQL substring match.
|
||||
|
||||
For the team-reference SQL the cursor inspects whether the
|
||||
statement contains ``WHERE r.ouid = ?`` (single variant) or
|
||||
``WHERE r.ouid IN`` (batch variant) and returns rows in the
|
||||
column shape the production code expects.
|
||||
|
||||
Single variant column shape: ``(projects, partner_name)``.
|
||||
Batch variant column shape: ``(ouid, projects, partner_name)``.
|
||||
|
||||
The cursor counts how often it has been asked to execute a
|
||||
statement against the references view so the test can assert the
|
||||
batch method issues exactly one such call (Requirement 4.3).
|
||||
"""
|
||||
|
||||
def __init__(self, stub: _StubDB) -> None:
|
||||
self.stub = stub
|
||||
self._next_rows: list[tuple] = []
|
||||
self.references_executes = 0
|
||||
|
||||
def execute(self, sql: str, params: Any = None) -> None: # noqa: ANN401
|
||||
if params is None:
|
||||
params_tuple: tuple[str, ...] = ()
|
||||
elif isinstance(params, tuple):
|
||||
params_tuple = tuple(str(p) for p in params)
|
||||
else:
|
||||
params_tuple = tuple(str(p) for p in params)
|
||||
|
||||
if _REFERENCES_VIEW not in sql:
|
||||
self._next_rows = []
|
||||
return
|
||||
|
||||
self.references_executes += 1
|
||||
|
||||
if "WHERE r.ouid IN" in sql:
|
||||
self._next_rows = self._simulate_batch(params_tuple)
|
||||
return
|
||||
|
||||
if "WHERE r.ouid = ?" in sql:
|
||||
self._next_rows = self._simulate_single(params_tuple)
|
||||
return
|
||||
|
||||
# Defensive default: the production code only emits the two
|
||||
# shapes above, so any other shape is unexpected.
|
||||
self._next_rows = []
|
||||
|
||||
def _simulate_single(
|
||||
self, params: tuple[str, ...]
|
||||
) -> list[tuple]:
|
||||
"""Simulate the single-OUID query.
|
||||
|
||||
Mirrors the SQL ``ORDER BY partner_name ASC, r.projects ASC``
|
||||
and emits the two-column result shape used by
|
||||
``get_team_references``. ``partner_name`` is COALESCE-d on
|
||||
the SQL side, so the emitted shape never carries ``None``
|
||||
for the partner column.
|
||||
"""
|
||||
|
||||
target = params[0] if params else ""
|
||||
rows = [r for r in self.stub.rows if r[0] == target]
|
||||
rows.sort(
|
||||
key=lambda r: (
|
||||
_partner_norm(r[2]),
|
||||
_projects_sort_key(r[1]),
|
||||
)
|
||||
)
|
||||
return [(r[1], _partner_norm(r[2])) for r in rows]
|
||||
|
||||
def _simulate_batch(
|
||||
self, params: tuple[str, ...]
|
||||
) -> list[tuple]:
|
||||
"""Simulate the batch-OUID query.
|
||||
|
||||
Mirrors the SQL ``ORDER BY r.ouid ASC, partner_name ASC,
|
||||
r.projects ASC`` and emits the three-column result shape used
|
||||
by ``batch_get_team_references``.
|
||||
"""
|
||||
|
||||
wanted = set(params)
|
||||
rows = [r for r in self.stub.rows if r[0] in wanted]
|
||||
rows.sort(
|
||||
key=lambda r: (
|
||||
str(r[0]),
|
||||
_partner_norm(r[2]),
|
||||
_projects_sort_key(r[1]),
|
||||
)
|
||||
)
|
||||
return [(r[0], r[1], _partner_norm(r[2])) for r in rows]
|
||||
|
||||
def fetchone(self) -> Any: # noqa: ANN401
|
||||
return self._next_rows[0] if self._next_rows else None
|
||||
|
||||
def fetchall(self) -> list[tuple]:
|
||||
return list(self._next_rows)
|
||||
|
||||
|
||||
class _FakeCursorContext:
|
||||
"""Context-manager wrapper around a single :class:`_FakeCursor`."""
|
||||
|
||||
def __init__(self, cursor: _FakeCursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self) -> _FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
return None
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
"""Minimal ``DatabaseConfig`` stand-in for ``TrinoClient(...)``."""
|
||||
|
||||
host = "x"
|
||||
port = 1
|
||||
username = "u"
|
||||
password = "p"
|
||||
http_scheme = "http"
|
||||
verify_ssl = False
|
||||
catalog = "c"
|
||||
schema = "s"
|
||||
pool_size = 1
|
||||
|
||||
|
||||
def _make_client(stub: _StubDB) -> tuple[TrinoClient, _FakeCursor]:
|
||||
"""Build a ``TrinoClient`` whose ``_cursor`` yields a fake cursor.
|
||||
|
||||
Returns the client together with the underlying fake cursor so
|
||||
callers can inspect its ``references_executes`` counter.
|
||||
"""
|
||||
|
||||
cursor = _FakeCursor(stub)
|
||||
client = TrinoClient(_FakeConfig()) # type: ignore[arg-type]
|
||||
client._cursor = lambda: _FakeCursorContext(cursor) # type: ignore[method-assign]
|
||||
return client, cursor
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hypothesis strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# A small pool of distinct OUID strings keeps the search space tight and
|
||||
# makes it likely that several rows share the same ouid (so grouping
|
||||
# behaviour and ordering inside a group are exercised).
|
||||
_KNOWN_OUIDS = ("OU1", "OU2", "OU3", "OU4")
|
||||
_UNKNOWN_OUIDS = ("UNKNOWN1", "UNKNOWN2")
|
||||
_BLANK_OUIDS = ("", " ")
|
||||
|
||||
# ``projects`` covers four shapes:
|
||||
# - ``None`` (NULL projects -> must be filtered, Req 4.6)
|
||||
# - ``""`` (empty -> must be filtered, Req 4.6)
|
||||
# - ``" "`` (whitespace-only -> must be filtered, Req 4.6)
|
||||
# - non-empty text (kept; trimmed and used for ordering)
|
||||
_projects_value = st.one_of(
|
||||
st.none(),
|
||||
st.just(""),
|
||||
st.just(" "),
|
||||
st.text(
|
||||
alphabet=st.characters(whitelist_categories=("Lu", "Ll", "Nd")),
|
||||
min_size=1,
|
||||
max_size=4,
|
||||
),
|
||||
)
|
||||
|
||||
# ``partner_name`` covers three shapes:
|
||||
# - ``None`` (partner_id IS NULL or join miss -> COALESCE to "", Req 4.5)
|
||||
# - ``""`` (partner row has empty name -> kept as "")
|
||||
# - ``"Acme"`` (concrete partner name)
|
||||
_partner_value = st.one_of(
|
||||
st.none(),
|
||||
st.just(""),
|
||||
st.just("Acme"),
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def _stub_and_inputs(draw: st.DrawFn) -> tuple[_StubDB, list[str]]:
|
||||
"""Generate stub reference rows and a list of input OUIDs.
|
||||
|
||||
The strategy intentionally mixes:
|
||||
|
||||
- rows for OUIDs in :data:`_KNOWN_OUIDS` (so batch/single both find
|
||||
data),
|
||||
- input OUIDs that resolve to known/unknown/blank values (so
|
||||
Requirements 4.4 and the blank-input short-circuit both fire),
|
||||
- ``None``/``""``/whitespace ``projects`` values (Requirement 4.6),
|
||||
- ``None``/``""`` ``partner_name`` values (Requirement 4.5).
|
||||
|
||||
Duplicate input OUIDs are allowed: the production batch method
|
||||
deduplicates via the output dict's keys, so the test asserts the
|
||||
consistency property only over the set of distinct input OUIDs.
|
||||
"""
|
||||
|
||||
rows = draw(
|
||||
st.lists(
|
||||
st.tuples(
|
||||
st.sampled_from(_KNOWN_OUIDS),
|
||||
_projects_value,
|
||||
_partner_value,
|
||||
),
|
||||
min_size=0,
|
||||
max_size=15,
|
||||
)
|
||||
)
|
||||
|
||||
candidates = list(_KNOWN_OUIDS) + list(_UNKNOWN_OUIDS) + list(_BLANK_OUIDS)
|
||||
input_ouids = draw(
|
||||
st.lists(
|
||||
st.sampled_from(candidates),
|
||||
min_size=0,
|
||||
max_size=6,
|
||||
)
|
||||
)
|
||||
|
||||
return _StubDB(rows=rows), input_ouids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SETTINGS = settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
|
||||
|
||||
@_SETTINGS
|
||||
@given(payload=_stub_and_inputs())
|
||||
def test_batch_team_references_matches_single_variant(
|
||||
payload: tuple[_StubDB, list[str]],
|
||||
) -> None:
|
||||
"""Property 5: Referenz-Batch ist konsistent zur Einzel-Variante.
|
||||
|
||||
Asserts the consistency, per-entry invariants and the
|
||||
single-execute-call property listed in the module docstring
|
||||
against the stub-DB-driven fake cursor.
|
||||
"""
|
||||
|
||||
stub, input_ouids = payload
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# (6) Batch-method-call-counter (Requirement 4.3): the batch method
|
||||
# must issue exactly one cur.execute(...) against the references
|
||||
# view, regardless of how many OUIDs are requested. This is checked
|
||||
# on a dedicated client instance so the counter is not polluted by
|
||||
# the per-OUID single-variant calls below.
|
||||
# -----------------------------------------------------------------
|
||||
batch_client, batch_cursor = _make_client(stub)
|
||||
batch_for_count = batch_client.batch_get_team_references(input_ouids)
|
||||
|
||||
if input_ouids and any(str(o).strip() for o in input_ouids):
|
||||
assert batch_cursor.references_executes == 1, (
|
||||
"batch_get_team_references must issue exactly one execute "
|
||||
"against the references view; got "
|
||||
f"{batch_cursor.references_executes}"
|
||||
)
|
||||
else:
|
||||
# All-blank or empty inputs short-circuit before issuing SQL.
|
||||
assert batch_cursor.references_executes == 0, (
|
||||
"batch_get_team_references must not issue SQL when all "
|
||||
"OUIDs are blank/empty; got "
|
||||
f"{batch_cursor.references_executes}"
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# (1) Batch result has exactly the requested OUIDs as keys
|
||||
# (Requirement 4.4: empty list for OUIDs without references).
|
||||
# -----------------------------------------------------------------
|
||||
expected_keys = {str(o) for o in input_ouids}
|
||||
assert set(batch_for_count.keys()) == expected_keys, (
|
||||
"batch_get_team_references must contain every input ouid; "
|
||||
f"expected {expected_keys!r}, got {set(batch_for_count.keys())!r}"
|
||||
)
|
||||
|
||||
# Use a fresh client for the per-OUID consistency comparison so
|
||||
# the fake cursor's row buffer and execute counter are isolated
|
||||
# from the batch call above.
|
||||
consistency_client, _ = _make_client(stub)
|
||||
|
||||
for ouid in input_ouids:
|
||||
ouid_key = str(ouid)
|
||||
single = consistency_client.get_team_references(ouid)
|
||||
batch_entry = batch_for_count[ouid_key]
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# (2) Batch-vs-single consistency (Requirements 4.1, 4.3).
|
||||
# -------------------------------------------------------------
|
||||
assert batch_entry == single, (
|
||||
f"batch[{ouid_key!r}] differs from get_team_references"
|
||||
f"({ouid!r}): batch={batch_entry!r}, single={single!r}"
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# (3, 4) Per-entry invariants (Requirements 4.5, 4.6).
|
||||
# -------------------------------------------------------------
|
||||
for entry in single:
|
||||
assert isinstance(entry, dict)
|
||||
assert set(entry.keys()) == {"partner_name", "projects"}
|
||||
# 4.6: filtered out NULL/empty/whitespace-only projects.
|
||||
assert isinstance(entry["projects"], str), (
|
||||
"projects must be a string after filtering, got "
|
||||
f"{entry['projects']!r}"
|
||||
)
|
||||
assert entry["projects"].strip() == entry["projects"], (
|
||||
"projects must be stripped of surrounding whitespace, "
|
||||
f"got {entry['projects']!r}"
|
||||
)
|
||||
assert entry["projects"], (
|
||||
"entries with NULL, empty or whitespace-only projects "
|
||||
f"must be filtered out, got {entry!r}"
|
||||
)
|
||||
# 4.5: partner_name is always a string (never None); may
|
||||
# be empty when the SQL-side COALESCE produced "".
|
||||
assert isinstance(entry["partner_name"], str), (
|
||||
"partner_name must be a string after COALESCE, got "
|
||||
f"{entry['partner_name']!r}"
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# (5) Order is (partner_name asc, projects asc) (Req 4.7).
|
||||
# -------------------------------------------------------------
|
||||
ordering = [(e["partner_name"], e["projects"]) for e in single]
|
||||
assert ordering == sorted(ordering), (
|
||||
"reference list is not ordered by (partner_name asc, "
|
||||
f"projects asc) for ouid={ouid!r}: {single!r}"
|
||||
)
|
||||
|
||||
# 4.4: blank/unknown OUIDs map to an empty list.
|
||||
if not str(ouid).strip() or str(ouid) in _UNKNOWN_OUIDS:
|
||||
assert batch_entry == [], (
|
||||
"OUID without references must map to an empty list; "
|
||||
f"ouid={ouid!r}, got {batch_entry!r}"
|
||||
)
|
||||
@@ -0,0 +1,250 @@
|
||||
# Feature: team-profile-matching, Property 14: Schema-Verifikation deckt fehlende Spalten in den vier neuen Views auf
|
||||
"""Property-based test for schema verification of the four new team views.
|
||||
|
||||
Validates Property 14 from the team-profile-matching design:
|
||||
|
||||
*Für jede* Mock-DB-Antwort, die in einer der vier Views
|
||||
(``teamlandkarte_v_teams_latest``,
|
||||
``teamlandkarte_v_teammeter_organizational_units_latest``,
|
||||
``teamlandkarte_v_teammeter_team_competences_latest``,
|
||||
``teamlandkarte_v_team_references_latest``) eine erwartete Spalte
|
||||
weglässt, liefert ``verify_required_columns(...)`` für die in
|
||||
``mcp_server.build_server`` konfigurierte ``schema_expected``-Erweiterung
|
||||
einen ``SchemaIssue`` mit dem betroffenen Tabellennamen und enthält die
|
||||
fehlende Spalte in ``message``. Wenn alle erwarteten Spalten vorhanden
|
||||
sind, ist das Issue-Set für diese vier Views leer.
|
||||
|
||||
The Hypothesis strategy generates, for a randomly chosen view, a
|
||||
non-empty subset of its expected columns to drop. The stub DB then
|
||||
returns ``full_set - dropped`` for that view (and the full sets for the
|
||||
other three views). The test asserts that ``verify_required_columns``:
|
||||
|
||||
1. Returns at least one ``SchemaIssue`` whose ``table`` equals the
|
||||
chosen view.
|
||||
2. The issue's ``message`` mentions every dropped column (lowercased,
|
||||
per the verifier's normalization).
|
||||
3. No issues are reported for the other three views (which received
|
||||
their full column sets).
|
||||
|
||||
The positive case is covered by an additional non-property test that
|
||||
feeds the full column sets and expects an empty issue list.
|
||||
|
||||
**Validates: Requirements 12.1, 12.2, 12.3, 12.4**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Iterable
|
||||
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.database.schema_verifier import (
|
||||
SchemaIssue,
|
||||
verify_required_columns,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Source of truth: the four new views and their expected columns.
|
||||
# Mirrors the ``schema_expected`` extension configured in
|
||||
# ``mcp_server.build_server`` (see design.md, "Schema-Verifikation beim
|
||||
# Start"). Keeping this map local keeps the test independent of
|
||||
# ``build_server`` runtime wiring (which requires a full config + DB).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_EXPECTED_COLUMNS: dict[str, frozenset[str]] = {
|
||||
"teamlandkarte_v_teams_latest": frozenset(
|
||||
{"team_id", "ouid", "about_us", "offerings", "interests", "focus_name"}
|
||||
),
|
||||
"teamlandkarte_v_teammeter_organizational_units_latest": frozenset(
|
||||
{"id", "name"}
|
||||
),
|
||||
"teamlandkarte_v_teammeter_team_competences_latest": frozenset(
|
||||
{"ouid", "competence_id", "top_competency"}
|
||||
),
|
||||
"teamlandkarte_v_team_references_latest": frozenset(
|
||||
{"ouid", "partner_id", "projects"}
|
||||
),
|
||||
}
|
||||
|
||||
_VIEW_NAMES: tuple[str, ...] = tuple(_EXPECTED_COLUMNS.keys())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test double
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubDB:
|
||||
"""Stub DB exposing ``get_table_columns`` for a fixed mapping.
|
||||
|
||||
The verifier protocol only requires this single method (see
|
||||
``schema_verifier.SchemaIntrospector``), so this stub is sufficient
|
||||
to exercise the full code path without touching a real database.
|
||||
"""
|
||||
|
||||
def __init__(self, mapping: dict[str, list[str]]):
|
||||
self._mapping = mapping
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
# ``verify_required_columns`` treats unknown tables as "no columns".
|
||||
return list(self._mapping.get(table, []))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _full_columns_for_all_views() -> dict[str, list[str]]:
|
||||
"""Return the full column lists for every configured view."""
|
||||
|
||||
return {view: sorted(cols) for view, cols in _EXPECTED_COLUMNS.items()}
|
||||
|
||||
|
||||
def _expected_map() -> dict[str, set[str]]:
|
||||
"""Return a fresh ``expected`` map matching the verifier's signature.
|
||||
|
||||
The verifier expects ``dict[str, set[str]]``; the canonical map uses
|
||||
``frozenset`` for immutability, so we materialize plain ``set``s here.
|
||||
"""
|
||||
|
||||
return {view: set(cols) for view, cols in _EXPECTED_COLUMNS.items()}
|
||||
|
||||
|
||||
def _issues_for_table(
|
||||
issues: Iterable[SchemaIssue], table: str
|
||||
) -> list[SchemaIssue]:
|
||||
return [issue for issue in issues if issue.table == table]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hypothesis strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _drop_subset_strategy() -> st.SearchStrategy[tuple[str, frozenset[str]]]:
|
||||
"""Strategy for ``(view, columns_to_drop)`` pairs.
|
||||
|
||||
Picks a view from the four configured views and a non-empty subset
|
||||
of its expected columns to "drop" (i.e. omit from the stub DB's
|
||||
response for that view). The subset is non-empty so the property's
|
||||
precondition ("at least one expected column is omitted") always
|
||||
holds.
|
||||
"""
|
||||
|
||||
def _for_view(view: str) -> st.SearchStrategy[tuple[str, frozenset[str]]]:
|
||||
cols = sorted(_EXPECTED_COLUMNS[view])
|
||||
# Subset of size in [1, len(cols)]: covers the full powerset
|
||||
# minus the empty set, which corresponds to the design's
|
||||
# "Powerset ... minus a randomly chosen column".
|
||||
return st.lists(
|
||||
st.sampled_from(cols),
|
||||
min_size=1,
|
||||
max_size=len(cols),
|
||||
unique=True,
|
||||
).map(lambda picks: (view, frozenset(picks)))
|
||||
|
||||
return st.sampled_from(_VIEW_NAMES).flatmap(_for_view)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property: dropping any subset of columns yields an issue per dropped column
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(case=_drop_subset_strategy())
|
||||
def test_missing_columns_in_any_of_the_four_views_produces_schema_issue(
|
||||
case: tuple[str, frozenset[str]],
|
||||
) -> None:
|
||||
"""Property 14 - negative direction.
|
||||
|
||||
For every (view, non-empty drop-subset), the verifier emits a
|
||||
``SchemaIssue`` whose ``table`` is the view and whose ``message``
|
||||
mentions every dropped column. Other views (with full column sets)
|
||||
must not produce any issues.
|
||||
"""
|
||||
|
||||
view, dropped = case
|
||||
full_cols = _EXPECTED_COLUMNS[view]
|
||||
remaining = sorted(full_cols - dropped)
|
||||
|
||||
db_mapping = _full_columns_for_all_views()
|
||||
db_mapping[view] = remaining
|
||||
db = _StubDB(db_mapping)
|
||||
|
||||
issues = verify_required_columns(db=db, expected=_expected_map())
|
||||
|
||||
# 1) The affected view is reported exactly once.
|
||||
affected = _issues_for_table(issues, view)
|
||||
assert len(affected) == 1, (
|
||||
f"Expected exactly one SchemaIssue for {view!r}, got {affected!r}"
|
||||
)
|
||||
|
||||
# 2) Each dropped column (normalized to lowercase, like the verifier
|
||||
# does internally) appears in the issue message.
|
||||
message = affected[0].message
|
||||
assert message.startswith("missing columns: "), message
|
||||
for col in dropped:
|
||||
assert col.lower() in message, (
|
||||
f"Dropped column {col!r} not mentioned in issue message {message!r}"
|
||||
)
|
||||
|
||||
# 3) The other three views must not produce any issues, since they
|
||||
# were given their full expected column sets.
|
||||
for other in _VIEW_NAMES:
|
||||
if other == view:
|
||||
continue
|
||||
assert _issues_for_table(issues, other) == [], (
|
||||
f"Unexpected issue for unaffected view {other!r}: "
|
||||
f"{_issues_for_table(issues, other)!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Positive case: full column sets -> no issues for the four views
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_full_column_sets_for_all_four_views_yield_no_issues() -> None:
|
||||
"""Property 14 - positive direction.
|
||||
|
||||
When every view exposes its full expected column set, the verifier
|
||||
must return zero issues for the four configured team views.
|
||||
"""
|
||||
|
||||
db = _StubDB(_full_columns_for_all_views())
|
||||
issues = verify_required_columns(db=db, expected=_expected_map())
|
||||
issues_for_team_views = [
|
||||
issue for issue in issues if issue.table in _EXPECTED_COLUMNS
|
||||
]
|
||||
assert issues_for_team_views == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sanity check: the configured map in ``build_server`` matches the
|
||||
# canonical map used by this test. This is not part of Property 14 but
|
||||
# guards against silent drift between the schema verifier wiring and
|
||||
# the test's source of truth.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_canonical_map_matches_mcp_server_configuration() -> None:
|
||||
"""The four-view subset of ``schema_expected`` matches this test."""
|
||||
|
||||
import inspect
|
||||
|
||||
from teamlandkarte_mcp import mcp_server
|
||||
|
||||
source = inspect.getsource(mcp_server.build_server)
|
||||
for view, cols in _EXPECTED_COLUMNS.items():
|
||||
assert view in source, (
|
||||
f"View {view!r} missing from mcp_server.build_server"
|
||||
)
|
||||
for col in cols:
|
||||
assert f'"{col}"' in source, (
|
||||
f"Column {col!r} for view {view!r} missing "
|
||||
f"from mcp_server.build_server"
|
||||
)
|
||||
@@ -0,0 +1,429 @@
|
||||
# Feature: team-profile-matching, Property 8: Score-Pfad liefert gültige Score- und Kategorie-Werte # noqa: E501
|
||||
"""Property-based tests for the team score pipeline.
|
||||
|
||||
Validates Property 8 from the team-profile-matching design:
|
||||
|
||||
*Für jede* Liste ``teams: list[Team]`` und alle gültigen
|
||||
:class:`Requirements` ``req`` liefert
|
||||
``Matcher.match_teams(teams, req,
|
||||
top_competency_weight=cfg.matching.team.top_competency_weight)`` ein
|
||||
:class:`TeamMatchResult`, in dem für jedes :class:`ScoredTeam` gilt:
|
||||
|
||||
* ``0.0 <= competence_score <= 1.0``,
|
||||
``0.0 <= role_score <= 1.0``,
|
||||
``0.0 <= overall_score <= 1.0``
|
||||
* ``category ∈ {"Top","Good","Partial","Low","Irrelevant"}``
|
||||
* ``category`` ist konsistent mit ``categorize(overall_score,
|
||||
cfg.matching)`` (gleiche Schwellwerte wie für Kapazitäten).
|
||||
* Die Verfügbarkeitsfelder von ``req`` (``date_start``, ``date_end``)
|
||||
ändern weder die Liste noch die Scores (Anforderung 6.7).
|
||||
|
||||
The test injects a deterministic stub :class:`SimilarityEngine` so it
|
||||
neither depends on Azure OpenAI nor on any network access. Because the
|
||||
stub returns the same payload for the same ``(required, candidate)``
|
||||
pair, score outputs are reproducible across runs and identical for
|
||||
``Requirements`` instances that differ only in date fields.
|
||||
|
||||
**Validates: Requirements 6.1, 6.2, 6.3, 6.5, 6.7**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.config import MatchingConfig
|
||||
from teamlandkarte_mcp.matching.matcher import Matcher
|
||||
from teamlandkarte_mcp.matching.scorer import categorize
|
||||
from teamlandkarte_mcp.models import (
|
||||
Requirements,
|
||||
Team,
|
||||
TeamCompetence,
|
||||
)
|
||||
|
||||
|
||||
# Allowed score-mode categories (Anforderung 6.5).
|
||||
_ALLOWED_CATEGORIES = {"Top", "Good", "Partial", "Low", "Irrelevant"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub SimilarityEngine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubSimilarityEngine:
|
||||
"""Deterministic stand-in for :class:`SimilarityEngine`.
|
||||
|
||||
Returns substring/ratio-based similarity scores using
|
||||
:class:`difflib.SequenceMatcher`. The role-score is also derived
|
||||
deterministically from the two role strings, so two ``Requirements``
|
||||
instances that differ only in date fields produce identical
|
||||
similarity payloads for every team.
|
||||
"""
|
||||
|
||||
async def compute_competence_similarity(
|
||||
self,
|
||||
required: list[str],
|
||||
candidate: list[str],
|
||||
global_index: Any = None,
|
||||
) -> dict[str, dict[str, object]]:
|
||||
del global_index # global IDF not relevant for the deterministic stub
|
||||
result: dict[str, dict[str, object]] = {}
|
||||
for req in required:
|
||||
if not candidate:
|
||||
result[req] = {
|
||||
"score": 0.0,
|
||||
"best_match": None,
|
||||
"rationale": "stub: no candidates",
|
||||
}
|
||||
continue
|
||||
best_pair = max(
|
||||
(
|
||||
(c, SequenceMatcher(None, req, c).ratio())
|
||||
for c in candidate
|
||||
),
|
||||
key=lambda pair: (pair[1], pair[0]),
|
||||
)
|
||||
best, score = best_pair
|
||||
result[req] = {
|
||||
"score": float(score),
|
||||
"best_match": best,
|
||||
"rationale": f"stub ratio {score:.3f}",
|
||||
}
|
||||
return result
|
||||
|
||||
async def compute_role_similarity(
|
||||
self,
|
||||
required_role: str | None,
|
||||
candidate_role: str | None,
|
||||
) -> float:
|
||||
a = (required_role or "").strip()
|
||||
b = (candidate_role or "").strip()
|
||||
if not a and not b:
|
||||
return 0.0
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
return float(SequenceMatcher(None, a, b).ratio())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hypothesis strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Plain ASCII text keeps tokenisation predictable and avoids whitespace-only
|
||||
# strings that the matcher's input filtering would drop.
|
||||
_name = st.text(
|
||||
alphabet=st.sampled_from(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -+"
|
||||
),
|
||||
min_size=1,
|
||||
max_size=12,
|
||||
).filter(lambda s: s.strip() != "")
|
||||
|
||||
|
||||
_required_name = st.text(
|
||||
alphabet=st.sampled_from(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .#"
|
||||
),
|
||||
min_size=2,
|
||||
max_size=12,
|
||||
).filter(lambda s: s.strip() != "")
|
||||
|
||||
|
||||
_team_competence = st.builds(
|
||||
TeamCompetence,
|
||||
name=_name,
|
||||
top_competency=st.booleans(),
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def _team(draw: st.DrawFn, team_id_pool: list[str]) -> Team:
|
||||
tid = draw(st.sampled_from(team_id_pool))
|
||||
competences = draw(
|
||||
st.lists(
|
||||
_team_competence,
|
||||
min_size=0,
|
||||
max_size=5,
|
||||
unique_by=lambda c: c.name,
|
||||
)
|
||||
)
|
||||
return Team(
|
||||
team_id=tid,
|
||||
ouid=f"ou-{tid}",
|
||||
team_name=f"Team {tid}",
|
||||
focus_name=draw(st.one_of(st.just(""), _name)),
|
||||
about_us="",
|
||||
offerings="",
|
||||
interests="",
|
||||
competences=competences,
|
||||
references=[],
|
||||
)
|
||||
|
||||
|
||||
@st.composite
|
||||
def _team_list(draw: st.DrawFn) -> list[Team]:
|
||||
n = draw(st.integers(min_value=0, max_value=4))
|
||||
if n == 0:
|
||||
return []
|
||||
pool = [f"t{i}" for i in range(n)]
|
||||
return [draw(_team(team_id_pool=[pool[i]])) for i in range(n)]
|
||||
|
||||
|
||||
_required_list = st.lists(
|
||||
_required_name, min_size=1, max_size=4, unique=True
|
||||
)
|
||||
|
||||
|
||||
_role_name = st.one_of(st.none(), _required_name)
|
||||
|
||||
|
||||
@st.composite
|
||||
def _date_range(draw: st.DrawFn) -> tuple[date | None, date | None]:
|
||||
"""Generate an arbitrary (date_start, date_end) pair (each may be None)."""
|
||||
|
||||
def _maybe_date() -> Any:
|
||||
return st.one_of(
|
||||
st.none(),
|
||||
st.dates(
|
||||
min_value=date(2000, 1, 1),
|
||||
max_value=date(2099, 12, 31),
|
||||
),
|
||||
)
|
||||
|
||||
a = draw(_maybe_date())
|
||||
b = draw(_maybe_date())
|
||||
return a, b
|
||||
|
||||
|
||||
_top_weight = st.floats(
|
||||
min_value=1.0,
|
||||
max_value=5.0,
|
||||
allow_nan=False,
|
||||
allow_infinity=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _new_matcher() -> Matcher:
|
||||
return Matcher(
|
||||
similarity=_StubSimilarityEngine(), # type: ignore[arg-type]
|
||||
cfg=MatchingConfig(),
|
||||
)
|
||||
|
||||
|
||||
def _result_signature(
|
||||
result: Any,
|
||||
) -> list[tuple[str, float, float, float, str]]:
|
||||
"""Project a TeamMatchResult.scored list into a comparable tuple list.
|
||||
|
||||
Order matters: the matcher sorts by overall score descending, so
|
||||
invariance under date-field changes implies identical sequences.
|
||||
"""
|
||||
|
||||
return [
|
||||
(
|
||||
s.team.team_id,
|
||||
s.competence_score,
|
||||
s.role_score,
|
||||
s.overall_score,
|
||||
s.category,
|
||||
)
|
||||
for s in result.scored
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 8: Score range, category set, category ↔ overall_score consistency.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# **Validates: Requirements 6.1, 6.2, 6.3, 6.5**
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[
|
||||
HealthCheck.too_slow,
|
||||
HealthCheck.function_scoped_fixture,
|
||||
],
|
||||
)
|
||||
@given(
|
||||
teams=_team_list(),
|
||||
required=_required_list,
|
||||
top_weight=_top_weight,
|
||||
role_name=_role_name,
|
||||
dates=_date_range(),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_score_pipeline_validity(
|
||||
teams: list[Team],
|
||||
required: list[str],
|
||||
top_weight: float,
|
||||
role_name: str | None,
|
||||
dates: tuple[date | None, date | None],
|
||||
) -> None:
|
||||
"""Every scored team has scores in [0,1] and a consistent category."""
|
||||
|
||||
cfg = MatchingConfig()
|
||||
requirements = Requirements(
|
||||
role_name=role_name,
|
||||
competences=required,
|
||||
date_start=dates[0],
|
||||
date_end=dates[1],
|
||||
)
|
||||
|
||||
matcher = _new_matcher()
|
||||
result = await matcher.match_teams(
|
||||
teams,
|
||||
requirements,
|
||||
top_competency_weight=top_weight,
|
||||
)
|
||||
|
||||
# The flat list contains exactly one entry per input team and the
|
||||
# category buckets together also contain exactly that many entries.
|
||||
assert len(result.scored) == len(teams)
|
||||
bucket_total = sum(len(v) for v in result.by_category.values())
|
||||
assert bucket_total == len(teams)
|
||||
|
||||
for scored in result.scored:
|
||||
# Score ranges (Anforderung 6.1, 6.2, 6.3).
|
||||
assert 0.0 <= scored.competence_score <= 1.0, (
|
||||
f"competence_score out of [0,1]: {scored.competence_score!r} "
|
||||
f"for team_id={scored.team.team_id!r}"
|
||||
)
|
||||
assert 0.0 <= scored.role_score <= 1.0, (
|
||||
f"role_score out of [0,1]: {scored.role_score!r} "
|
||||
f"for team_id={scored.team.team_id!r}"
|
||||
)
|
||||
assert 0.0 <= scored.overall_score <= 1.0, (
|
||||
f"overall_score out of [0,1]: {scored.overall_score!r} "
|
||||
f"for team_id={scored.team.team_id!r}"
|
||||
)
|
||||
|
||||
# Category set (Anforderung 6.5).
|
||||
assert scored.category in _ALLOWED_CATEGORIES, (
|
||||
f"category {scored.category!r} not in {_ALLOWED_CATEGORIES} "
|
||||
f"for team_id={scored.team.team_id!r}"
|
||||
)
|
||||
|
||||
# Category ↔ overall_score consistency (same thresholds as for
|
||||
# capacities, via categorize() in matching/scorer.py).
|
||||
expected_category = categorize(scored.overall_score, cfg)
|
||||
assert scored.category == expected_category, (
|
||||
"category inconsistent with categorize(overall_score, cfg): "
|
||||
f"got {scored.category!r}, expected {expected_category!r} "
|
||||
f"for overall_score={scored.overall_score!r}, "
|
||||
f"team_id={scored.team.team_id!r}"
|
||||
)
|
||||
|
||||
# by_category keys are a subset of the allowed set and bucket
|
||||
# contents are consistent with each entry's own category.
|
||||
for cat, items in result.by_category.items():
|
||||
assert cat in _ALLOWED_CATEGORIES, (
|
||||
f"by_category key {cat!r} not in {_ALLOWED_CATEGORIES}"
|
||||
)
|
||||
for item in items:
|
||||
assert item.category == cat, (
|
||||
f"item category {item.category!r} placed in bucket {cat!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Property 8 (cont.): availability fields do not change list or scores.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# **Validates: Requirements 6.7**
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[
|
||||
HealthCheck.too_slow,
|
||||
HealthCheck.function_scoped_fixture,
|
||||
],
|
||||
)
|
||||
@given(
|
||||
teams=_team_list(),
|
||||
required=_required_list,
|
||||
top_weight=_top_weight,
|
||||
role_name=_role_name,
|
||||
dates_a=_date_range(),
|
||||
dates_b=_date_range(),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_team_score_pipeline_availability_invariance(
|
||||
teams: list[Team],
|
||||
required: list[str],
|
||||
top_weight: float,
|
||||
role_name: str | None,
|
||||
dates_a: tuple[date | None, date | None],
|
||||
dates_b: tuple[date | None, date | None],
|
||||
) -> None:
|
||||
"""Two requirements differing only in dates yield identical results."""
|
||||
|
||||
req_a = Requirements(
|
||||
role_name=role_name,
|
||||
competences=required,
|
||||
date_start=dates_a[0],
|
||||
date_end=dates_a[1],
|
||||
)
|
||||
req_b = Requirements(
|
||||
role_name=role_name,
|
||||
competences=required,
|
||||
date_start=dates_b[0],
|
||||
date_end=dates_b[1],
|
||||
)
|
||||
|
||||
matcher = _new_matcher()
|
||||
result_a = await matcher.match_teams(
|
||||
teams, req_a, top_competency_weight=top_weight
|
||||
)
|
||||
result_b = await matcher.match_teams(
|
||||
teams, req_b, top_competency_weight=top_weight
|
||||
)
|
||||
|
||||
sig_a = _result_signature(result_a)
|
||||
sig_b = _result_signature(result_b)
|
||||
assert sig_a == sig_b, (
|
||||
"Availability date fields must not change the scored list: "
|
||||
f"a={sig_a!r}, b={sig_b!r}"
|
||||
)
|
||||
|
||||
# Bucket structure must also be identical (same keys, same items in
|
||||
# each bucket, same order).
|
||||
assert set(result_a.by_category.keys()) == set(result_b.by_category.keys())
|
||||
for cat in result_a.by_category:
|
||||
items_a = [
|
||||
(
|
||||
s.team.team_id,
|
||||
s.competence_score,
|
||||
s.role_score,
|
||||
s.overall_score,
|
||||
s.category,
|
||||
)
|
||||
for s in result_a.by_category[cat]
|
||||
]
|
||||
items_b = [
|
||||
(
|
||||
s.team.team_id,
|
||||
s.competence_score,
|
||||
s.role_score,
|
||||
s.overall_score,
|
||||
s.category,
|
||||
)
|
||||
for s in result_b.by_category[cat]
|
||||
]
|
||||
assert items_a == items_b, (
|
||||
f"Bucket {cat!r} differs under availability change: "
|
||||
f"a={items_a!r}, b={items_b!r}"
|
||||
)
|
||||
@@ -0,0 +1,241 @@
|
||||
# Feature: team-profile-matching, Property 7: Top-Kompetenz-Monotonie im Score-Matching
|
||||
"""Property-based tests for top-competency monotonicity in team scoring.
|
||||
|
||||
Validates Property 7 from the team-profile-matching design:
|
||||
|
||||
*Für alle* :class:`Requirements`, alle Listen ``cs`` von Kompetenz-Namen
|
||||
und alle Konfigurationswerte ``top_weight ≥ 1.0`` gilt:
|
||||
|
||||
``Matcher.match_teams([T_top], req, top_competency_weight=top_weight)``
|
||||
ergibt für ``T_top`` einen ``competence_score``, der **größer oder
|
||||
gleich** dem ``competence_score`` von ``T_plain`` ist, wobei
|
||||
|
||||
- ``T_plain`` ein Team mit ``competences=[TeamCompetence(c,
|
||||
top_competency=False) for c in cs]`` ist,
|
||||
- ``T_top`` ein Team mit identischer ``competences``-Liste, jedoch
|
||||
``top_competency=True`` für alle Einträge ist.
|
||||
|
||||
Außerdem ist bei ``top_weight == 1.0`` die Differenz exakt ``0.0``
|
||||
(Anforderung 6.4: Top-Markierung ohne Gewicht-Effekt).
|
||||
|
||||
The test injects a deterministic stub :class:`SimilarityEngine` so it
|
||||
neither depends on Azure OpenAI nor on the real BM25 index. The stub's
|
||||
``compute_competence_similarity`` returns the same payload for both
|
||||
teams (since both teams share the same competence list), which is
|
||||
the precondition for a meaningful T_top vs T_plain comparison.
|
||||
|
||||
**Validates: Requirements 6.4**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.config import MatchingConfig
|
||||
from teamlandkarte_mcp.matching.matcher import Matcher
|
||||
from teamlandkarte_mcp.models import (
|
||||
Requirements,
|
||||
Team,
|
||||
TeamCompetence,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stub SimilarityEngine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubSimilarityEngine:
|
||||
"""Deterministic stand-in for :class:`SimilarityEngine`.
|
||||
|
||||
Returns substring/ratio-based scores using
|
||||
:class:`difflib.SequenceMatcher`. Crucially, the stub depends only on
|
||||
``(required, candidate)``; ``global_index`` is ignored. This guarantees
|
||||
that both ``T_top`` and ``T_plain`` (which share the same competence
|
||||
list) receive identical raw scores **and** identical ``best_match``
|
||||
values, so any score difference originates exclusively from the
|
||||
top-competency weight under test.
|
||||
"""
|
||||
|
||||
async def compute_competence_similarity(
|
||||
self,
|
||||
required: list[str],
|
||||
candidate: list[str],
|
||||
global_index: Any = None,
|
||||
) -> dict[str, dict[str, object]]:
|
||||
del global_index # global IDF irrelevant for the deterministic stub
|
||||
result: dict[str, dict[str, object]] = {}
|
||||
for req in required:
|
||||
if not candidate:
|
||||
result[req] = {
|
||||
"score": 0.0,
|
||||
"best_match": None,
|
||||
"rationale": "stub: no candidates",
|
||||
}
|
||||
continue
|
||||
# Deterministic non-trivial scoring via SequenceMatcher.
|
||||
best_pair = max(
|
||||
((c, SequenceMatcher(None, req, c).ratio()) for c in candidate),
|
||||
key=lambda pair: (pair[1], pair[0]),
|
||||
)
|
||||
best, score = best_pair
|
||||
result[req] = {
|
||||
"score": float(score),
|
||||
"best_match": best,
|
||||
"rationale": f"stub ratio {score:.3f}",
|
||||
}
|
||||
return result
|
||||
|
||||
async def compute_role_similarity(
|
||||
self,
|
||||
required_role: str | None,
|
||||
candidate_role: str | None,
|
||||
) -> float:
|
||||
del required_role, candidate_role # role similarity is fixed
|
||||
# Constant role score: this property only constrains the
|
||||
# competence_score, so role similarity is fixed and irrelevant.
|
||||
return 0.5
|
||||
|
||||
|
||||
def _make_team(
|
||||
*,
|
||||
team_id: str,
|
||||
competences: list[str],
|
||||
top: bool,
|
||||
) -> Team:
|
||||
return Team(
|
||||
team_id=team_id,
|
||||
ouid=f"ou-{team_id}",
|
||||
team_name=f"Team {team_id}",
|
||||
focus_name="Beliebiger Schwerpunkt",
|
||||
about_us="",
|
||||
offerings="",
|
||||
interests="",
|
||||
competences=[
|
||||
TeamCompetence(name=c, top_competency=top) for c in competences
|
||||
],
|
||||
references=[],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hypothesis strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Plain ASCII letters keep tokenization predictable and prevent Hypothesis
|
||||
# from synthesising whitespace-only strings that the matcher's input
|
||||
# filtering would drop.
|
||||
_competence_name = st.text(
|
||||
alphabet=st.sampled_from(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 -+"
|
||||
),
|
||||
min_size=2,
|
||||
max_size=20,
|
||||
).filter(lambda s: s.strip() != "")
|
||||
|
||||
# Required competences are intentionally drawn from a slightly different
|
||||
# alphabet pool so that the stub's SequenceMatcher returns a wide range of
|
||||
# scores (including some near-zero ones), which exercises the
|
||||
# clamp-to-1.0 path inside the matcher's top-weight multiplication.
|
||||
_required_name = st.text(
|
||||
alphabet=st.sampled_from(
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .#"
|
||||
),
|
||||
min_size=2,
|
||||
max_size=20,
|
||||
).filter(lambda s: s.strip() != "")
|
||||
|
||||
_competence_list = st.lists(_competence_name, min_size=1, max_size=6, unique=True)
|
||||
_required_list = st.lists(_required_name, min_size=1, max_size=4, unique=True)
|
||||
|
||||
|
||||
# **Validates: Requirements 6.4**
|
||||
@settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.too_slow],
|
||||
)
|
||||
@given(
|
||||
cs=_competence_list,
|
||||
required=_required_list,
|
||||
top_weight=st.floats(
|
||||
min_value=1.0,
|
||||
max_value=5.0,
|
||||
allow_nan=False,
|
||||
allow_infinity=False,
|
||||
),
|
||||
role_name=st.one_of(st.none(), _required_name),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_top_competency_monotonicity_in_score_matching(
|
||||
cs: list[str],
|
||||
required: list[str],
|
||||
top_weight: float,
|
||||
role_name: str | None,
|
||||
) -> None:
|
||||
"""Property 7: top-marker never lowers competence_score; weight 1.0 ⇒ no diff.
|
||||
|
||||
Both teams share the same competence list ``cs``. Only the
|
||||
``top_competency`` flag differs:
|
||||
|
||||
* ``T_top`` flags every competence as a top competency,
|
||||
* ``T_plain`` flags none of them as top competencies.
|
||||
|
||||
Under ``Matcher.match_teams`` with ``top_competency_weight = top_weight``
|
||||
the resulting ``competence_score`` for ``T_top`` must therefore be
|
||||
``>=`` the score for ``T_plain`` for every ``top_weight >= 1.0``, and
|
||||
it must be **exactly equal** when ``top_weight == 1.0``.
|
||||
"""
|
||||
|
||||
team_top = _make_team(team_id="top", competences=cs, top=True)
|
||||
team_plain = _make_team(team_id="plain", competences=cs, top=False)
|
||||
|
||||
requirements = Requirements(
|
||||
role_name=role_name,
|
||||
competences=required,
|
||||
date_start=None,
|
||||
date_end=None,
|
||||
)
|
||||
|
||||
matcher = Matcher(
|
||||
similarity=_StubSimilarityEngine(), # type: ignore[arg-type]
|
||||
cfg=MatchingConfig(),
|
||||
)
|
||||
|
||||
# Run the two teams in separate matcher invocations so the global
|
||||
# BM25 corpora are identical (each matcher sees a single team with
|
||||
# the same competence list) and any score difference is provably
|
||||
# caused by the top-competency weight alone.
|
||||
result_top = await matcher.match_teams(
|
||||
[team_top], requirements, top_competency_weight=top_weight
|
||||
)
|
||||
result_plain = await matcher.match_teams(
|
||||
[team_plain], requirements, top_competency_weight=top_weight
|
||||
)
|
||||
|
||||
assert len(result_top.scored) == 1
|
||||
assert len(result_plain.scored) == 1
|
||||
|
||||
score_top = result_top.scored[0].competence_score
|
||||
score_plain = result_plain.scored[0].competence_score
|
||||
|
||||
# Monotonicity: top-marked competences must never reduce the score.
|
||||
assert score_top >= score_plain - 1e-12, (
|
||||
"T_top.competence_score must be >= T_plain.competence_score "
|
||||
f"for top_weight={top_weight!r}, "
|
||||
f"got top={score_top!r} plain={score_plain!r}"
|
||||
)
|
||||
|
||||
# Identity at top_weight == 1.0: the marker has no scoring effect
|
||||
# because the multiplier is 1.0 in both branches.
|
||||
if top_weight == 1.0:
|
||||
assert score_top == score_plain, (
|
||||
"At top_weight == 1.0 the top marker must have no effect, "
|
||||
f"got top={score_top!r} plain={score_plain!r}"
|
||||
)
|
||||
@@ -0,0 +1,429 @@
|
||||
# Feature: team-profile-matching, Task 9.11
|
||||
"""Unit tests for the team-related MCP tools.
|
||||
|
||||
Validates the following acceptance criteria:
|
||||
|
||||
* **Anforderung 1.2**: The existing ``find_matching_capacities`` tool keeps its
|
||||
Markdown output structure after the team-profile refactoring (snapshot-style
|
||||
assertion on stable substrings).
|
||||
* **Anforderung 1.3**: ``find_matching_teams`` is registered with ``FastMCP``.
|
||||
* **Anforderung 9.1**: ``list_teams`` is registered and renders a Markdown
|
||||
table whose headers are exactly ``Team Id``, ``Team Name``, ``Schwerpunkt``,
|
||||
``Anzahl Kompetenzen``, ``Anzahl Referenzen``.
|
||||
* **Anforderung 9.2**: ``get_team_details`` is registered and renders the full
|
||||
layout (summary table + ``## Über uns`` / ``## Leistungen`` / ``## Interessen``
|
||||
/ ``## Kompetenzen`` / ``## Referenzen`` / ``## Next steps``) for an existing
|
||||
team.
|
||||
* **Anforderung 9.3**: ``get_team_details`` returns an error message that
|
||||
contains the requested (unknown) ``team_id`` substring when no team matches.
|
||||
* **Anforderung 9.4**: Top competences are rendered with the ``(Top)`` suffix
|
||||
and references with a ``partner_name`` are rendered as
|
||||
``**<partner>**: <projects>`` (bold partner name).
|
||||
|
||||
The tests use a minimal fake ``DBClient`` and stub out ``create_db_client`` so
|
||||
that no real database is touched. The LLM is not exercised here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
from teamlandkarte_mcp.models import (
|
||||
Capacity,
|
||||
Team,
|
||||
TeamCompetence,
|
||||
TeamReference,
|
||||
)
|
||||
|
||||
|
||||
_CONFIG_TEMPLATE = """
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[matching.team]
|
||||
top_competency_weight=1.5
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=10
|
||||
max_size=10
|
||||
""".strip()
|
||||
|
||||
|
||||
def _make_team(team_id: str = "t1") -> Team:
|
||||
"""Construct a deterministic team with mixed top/non-top competences and
|
||||
a reference with and without partner name."""
|
||||
return Team(
|
||||
team_id=team_id,
|
||||
ouid=f"ou-{team_id}",
|
||||
team_name=f"Team {team_id.upper()}",
|
||||
focus_name="Backend Engineering",
|
||||
about_us="We build distributed backends.",
|
||||
offerings="APIs, ETL pipelines, cloud migrations.",
|
||||
interests="Event-driven architectures.",
|
||||
competences=[
|
||||
TeamCompetence(name="Python", top_competency=True),
|
||||
TeamCompetence(name="Java", top_competency=False),
|
||||
],
|
||||
references=[
|
||||
TeamReference(partner_name="DB Cargo", projects="ETL Pipeline"),
|
||||
TeamReference(partner_name="", projects="Internal tooling"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
"""Minimal fake ``DBClient`` covering all tools used by this test
|
||||
module (capacities + teams).
|
||||
|
||||
``get_all_teams`` returns the configured list of teams; ``get_team_by_id``
|
||||
looks up by ``team_id`` and returns ``None`` for unknown ids.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
teams: list[Team] | None = None,
|
||||
capacities: list[Capacity] | None = None,
|
||||
) -> None:
|
||||
self._teams = list(teams or [])
|
||||
self._caps = list(capacities or [])
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["Backend Engineering"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["Python", "Java"]
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
return []
|
||||
|
||||
def get_all_capacities_with_competences(self):
|
||||
return list(self._caps)
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20):
|
||||
return list(self._caps)[:limit]
|
||||
|
||||
def get_capacity_by_id(self, capacity_id): # pragma: no cover
|
||||
return None
|
||||
|
||||
def get_capacity_description(self, capacity_id): # pragma: no cover
|
||||
return None
|
||||
|
||||
def get_capacity_references(self, capacity_id): # pragma: no cover
|
||||
return []
|
||||
|
||||
def get_capacity_certificates(self, capacity_id): # pragma: no cover
|
||||
return []
|
||||
|
||||
def get_all_teams(self) -> list[Team]:
|
||||
return list(self._teams)
|
||||
|
||||
def get_team_by_id(self, team_id) -> Team | None:
|
||||
for t in self._teams:
|
||||
if t.team_id == str(team_id):
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
"""Coerce a FastMCP ``call_tool`` result to a plain text string."""
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(
|
||||
structured.get("result"), str
|
||||
):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return str(result)
|
||||
|
||||
|
||||
async def _call_tool(srv: Any, name: str, args: dict[str, Any]) -> str:
|
||||
return _result_to_text(await srv.call_tool(name, args))
|
||||
|
||||
|
||||
def _build_server(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
*,
|
||||
teams: list[Team] | None = None,
|
||||
capacities: list[Capacity] | None = None,
|
||||
):
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(_CONFIG_TEMPLATE, encoding="utf-8")
|
||||
|
||||
fake_db = _FakeDB(teams=teams, capacities=capacities)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
monkeypatch.setattr(mod, "create_db_client", lambda *_a, **_k: fake_db)
|
||||
|
||||
return build_server(str(cfg))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool registration tests (Anforderungen 1.3, 9.1, 9.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_matching_teams_tool_is_registered(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""``find_matching_teams`` must be registered with FastMCP."""
|
||||
srv = _build_server(monkeypatch, tmp_path, teams=[_make_team()])
|
||||
tool_names = {t.name for t in await srv.list_tools()}
|
||||
assert "find_matching_teams" in tool_names, (
|
||||
f"expected 'find_matching_teams' to be registered; got {sorted(tool_names)!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_teams_tool_is_registered(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""``list_teams`` must be registered with FastMCP."""
|
||||
srv = _build_server(monkeypatch, tmp_path, teams=[_make_team()])
|
||||
tool_names = {t.name for t in await srv.list_tools()}
|
||||
assert "list_teams" in tool_names, (
|
||||
f"expected 'list_teams' to be registered; got {sorted(tool_names)!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_details_tool_is_registered(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""``get_team_details`` must be registered with FastMCP."""
|
||||
srv = _build_server(monkeypatch, tmp_path, teams=[_make_team()])
|
||||
tool_names = {t.name for t in await srv.list_tools()}
|
||||
assert "get_team_details" in tool_names, (
|
||||
f"expected 'get_team_details' to be registered; got {sorted(tool_names)!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown layout tests (Anforderungen 9.1, 9.2, 9.4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_teams_renders_markdown_table(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""``list_teams`` must render a Markdown table with the spec headers."""
|
||||
teams = [_make_team("t1"), _make_team("t2")]
|
||||
srv = _build_server(monkeypatch, tmp_path, teams=teams)
|
||||
|
||||
out = await _call_tool(srv, "list_teams", {"limit": 20})
|
||||
|
||||
for header in (
|
||||
"Team Id",
|
||||
"Team Name",
|
||||
"Schwerpunkt",
|
||||
"Anzahl Kompetenzen",
|
||||
"Anzahl Referenzen",
|
||||
):
|
||||
assert header in out, (
|
||||
f"missing header {header!r} in list_teams output:\n{out}"
|
||||
)
|
||||
|
||||
# Both team_ids must show up in the body of the table.
|
||||
assert "t1" in out, f"missing team_id 't1' in list_teams output:\n{out}"
|
||||
assert "t2" in out, f"missing team_id 't2' in list_teams output:\n{out}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_details_renders_full_layout(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""``get_team_details`` must render summary table + all sections."""
|
||||
team = _make_team("t1")
|
||||
srv = _build_server(monkeypatch, tmp_path, teams=[team])
|
||||
|
||||
out = await _call_tool(srv, "get_team_details", {"team_id": "t1"})
|
||||
|
||||
# Summary table headers (same as list_teams).
|
||||
for header in (
|
||||
"Team Id",
|
||||
"Team Name",
|
||||
"Schwerpunkt",
|
||||
"Anzahl Kompetenzen",
|
||||
"Anzahl Referenzen",
|
||||
):
|
||||
assert header in out, (
|
||||
f"missing summary header {header!r} in get_team_details output:\n{out}"
|
||||
)
|
||||
|
||||
# All required sections.
|
||||
for section in (
|
||||
"## Über uns",
|
||||
"## Leistungen",
|
||||
"## Interessen",
|
||||
"## Kompetenzen",
|
||||
"## Referenzen",
|
||||
"## Next steps",
|
||||
):
|
||||
assert section in out, (
|
||||
f"missing section {section!r} in get_team_details output:\n{out}"
|
||||
)
|
||||
|
||||
# Top-competence marker (Anforderung 9.4 / 5.5).
|
||||
# The team has ``Python`` flagged top, ``Java`` not.
|
||||
assert "Python (Top)" in out, (
|
||||
"expected 'Python (Top)' marker in Kompetenzen section, "
|
||||
f"output was:\n{out}"
|
||||
)
|
||||
assert "Java" in out, f"expected 'Java' to appear in output:\n{out}"
|
||||
assert "Java (Top)" not in out, (
|
||||
"Non-top competence 'Java' must not be marked as Top, "
|
||||
f"output was:\n{out}"
|
||||
)
|
||||
|
||||
# Bold partner_name in references (Anforderung 9.4 / 5.6).
|
||||
assert "**DB Cargo**" in out, (
|
||||
"expected partner_name to be bolded with ** ** in references, "
|
||||
f"output was:\n{out}"
|
||||
)
|
||||
# The reference without partner_name renders only the projects text.
|
||||
assert "Internal tooling" in out, (
|
||||
"expected partner-less reference projects text in output:\n" + out
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_details_unknown_team_id(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""``get_team_details`` must mention the unknown id in its error
|
||||
message (Anforderung 9.3)."""
|
||||
srv = _build_server(monkeypatch, tmp_path, teams=[_make_team("t1")])
|
||||
|
||||
out = await _call_tool(srv, "get_team_details", {"team_id": "unknown"})
|
||||
|
||||
assert "Team not found" in out, (
|
||||
f"expected 'Team not found' in error response, got:\n{out}"
|
||||
)
|
||||
assert "unknown" in out, (
|
||||
f"expected the requested team_id substring 'unknown' in error "
|
||||
f"response, got:\n{out}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_matching_capacities snapshot test (Anforderung 1.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_matching_capacities_output_structure_unchanged(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""The existing ``find_matching_capacities`` tool keeps its Markdown
|
||||
output structure after the team-profile refactoring.
|
||||
|
||||
This is a snapshot-style assertion on stable, contract-relevant
|
||||
substrings (``SEARCH_ID=``, ``META=``, ``## Summary``, the Category /
|
||||
Count summary header, and the per-category Results header). It is
|
||||
deliberately not byte-exact because per-line scores depend on the
|
||||
SimilarityEngine; the structural envelope is what callers depend on.
|
||||
"""
|
||||
cap = Capacity(
|
||||
id=1,
|
||||
owner_name="Alice Example",
|
||||
role_name="Backend Engineering",
|
||||
role_level=None,
|
||||
begin_date=None,
|
||||
end_date=None,
|
||||
competences=["Python"],
|
||||
)
|
||||
srv = _build_server(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
teams=[_make_team("t1")],
|
||||
capacities=[cap],
|
||||
)
|
||||
|
||||
out = await _call_tool(
|
||||
srv,
|
||||
"find_matching_capacities",
|
||||
{
|
||||
"role_name": "Backend Engineering",
|
||||
"competences": ["Python"],
|
||||
},
|
||||
)
|
||||
|
||||
# Stable contract markers that survive any Score/LLM refactoring.
|
||||
for marker in (
|
||||
"SEARCH_ID=",
|
||||
"META=",
|
||||
"## Summary",
|
||||
"| Category | Count |",
|
||||
):
|
||||
assert marker in out, (
|
||||
f"missing structural marker {marker!r} in "
|
||||
f"find_matching_capacities output:\n{out}"
|
||||
)
|
||||
|
||||
# The default-shown category section header must be present (one of
|
||||
# the five categories). The exact one depends on score thresholds
|
||||
# but at least one Results section header must appear.
|
||||
assert any(
|
||||
f"## {cat} Results" in out
|
||||
for cat in ("Top", "Good", "Partial", "Low", "Irrelevant")
|
||||
), (
|
||||
"expected at least one '## <Category> Results' header in "
|
||||
f"find_matching_capacities output:\n{out}"
|
||||
)
|
||||
|
||||
# The capacity-search column layout must include the legacy headers
|
||||
# (the team refactoring must not have leaked team headers in here).
|
||||
assert "| ID |" in out, (
|
||||
f"expected legacy capacity-search 'ID' column in output:\n{out}"
|
||||
)
|
||||
assert "Team Name" not in out, (
|
||||
"team-search column 'Team Name' must not appear in capacity-search "
|
||||
f"output:\n{out}"
|
||||
)
|
||||
@@ -0,0 +1,268 @@
|
||||
# Feature: team-profile-matching, Property 2: SELECT-Only-Eigenschaft aller neuen Trino-Queries
|
||||
"""Property-based test for the SELECT-only invariant of the six new
|
||||
``TrinoClient`` methods that back team profiles.
|
||||
|
||||
Validates Property 2 from the team-profile-matching design:
|
||||
|
||||
*Für alle* Aufrufe von ``TrinoClient.get_all_teams``,
|
||||
``get_team_by_id``, ``get_team_competences``,
|
||||
``batch_get_team_competences``, ``get_team_references`` und
|
||||
``batch_get_team_references`` (mit beliebigen gültigen Eingaben) ist die
|
||||
ausgeführte SQL-Anfrage ein ``SELECT``- oder ``WITH``-Statement, sodass
|
||||
``_ensure_select_only(sql)`` ohne Fehler durchläuft.
|
||||
|
||||
The test installs a stub cursor that invokes ``_ensure_select_only``
|
||||
from ``teamlandkarte_mcp.database.read_only`` on *every* SQL string
|
||||
passed to ``cursor.execute(...)``. Hypothesis generates inputs for the
|
||||
six new methods (single string OUIDs/team-ids and lists of OUIDs).
|
||||
After each call, the test asserts that the stub cursor recorded zero
|
||||
``DatabaseError`` raises and that every recorded statement starts with
|
||||
``SELECT`` or ``WITH`` once trimmed.
|
||||
|
||||
Blank string inputs (e.g. ``""`` or whitespace-only) are valid inputs
|
||||
that intentionally short-circuit some methods *without* issuing SQL;
|
||||
those calls are still covered by the property because the property is
|
||||
"every executed SQL passes the read-only guard", which holds vacuously
|
||||
when no SQL is executed.
|
||||
|
||||
**Validates: Requirements 2.6**
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from teamlandkarte_mcp.database.read_only import (
|
||||
DatabaseError,
|
||||
_ensure_select_only,
|
||||
)
|
||||
from teamlandkarte_mcp.database.trino_client import TrinoClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test doubles
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _SelectOnlyCursor:
|
||||
"""Stub cursor that runs ``_ensure_select_only`` for every SQL.
|
||||
|
||||
The cursor records every executed SQL string and re-raises any
|
||||
``DatabaseError`` from the read-only guard so the test can assert
|
||||
on the recorded statements after the call returns. ``fetchone`` and
|
||||
``fetchall`` return canned empty results so all six methods take
|
||||
their "no rows" path and stay deterministic.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.executed: list[str] = []
|
||||
|
||||
def execute(self, sql: str, params: Any = None) -> None: # noqa: ANN401
|
||||
# Run the read-only guard; any failure surfaces as ``DatabaseError``
|
||||
# which makes the property test fail with a clear counterexample.
|
||||
_ensure_select_only(sql)
|
||||
self.executed.append(sql)
|
||||
|
||||
def fetchone(self) -> Any: # noqa: ANN401
|
||||
return None
|
||||
|
||||
def fetchall(self) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
class _CursorContext:
|
||||
"""Context-manager wrapper around a single ``_SelectOnlyCursor``."""
|
||||
|
||||
def __init__(self, cursor: _SelectOnlyCursor) -> None:
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self) -> _SelectOnlyCursor:
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
return None
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
"""Minimal ``DatabaseConfig`` stand-in for ``TrinoClient(...)``."""
|
||||
|
||||
host = "x"
|
||||
port = 1
|
||||
username = "u"
|
||||
password = "p"
|
||||
http_scheme = "http"
|
||||
verify_ssl = False
|
||||
catalog = "c"
|
||||
schema = "s"
|
||||
pool_size = 1
|
||||
|
||||
|
||||
def _make_client() -> tuple[TrinoClient, _SelectOnlyCursor]:
|
||||
"""Build a ``TrinoClient`` whose ``_cursor`` yields a stub cursor."""
|
||||
|
||||
cursor = _SelectOnlyCursor()
|
||||
client = TrinoClient(_FakeConfig()) # type: ignore[arg-type]
|
||||
client._cursor = lambda: _CursorContext(cursor) # type: ignore[method-assign]
|
||||
return client, cursor
|
||||
|
||||
|
||||
def _assert_select_or_with(sql: str) -> None:
|
||||
"""Cross-check that ``sql`` starts with ``SELECT``/``WITH``.
|
||||
|
||||
This duplicates the contract of ``_ensure_select_only`` so the
|
||||
property test is robust against future relaxations of that helper:
|
||||
even if the helper were widened, the property assertion would
|
||||
catch any non-read-only statement.
|
||||
"""
|
||||
|
||||
head = sql.strip().lower()
|
||||
assert head.startswith("select") or head.startswith("with"), (
|
||||
f"executed SQL is not SELECT/WITH: {sql!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hypothesis strategies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# OUID/team_id strings: arbitrary text including blanks. The trino-client
|
||||
# methods explicitly handle blank strings as "no SQL" short-circuits,
|
||||
# which is fine: the SELECT-only property holds vacuously in that case.
|
||||
_id_text = st.text(max_size=20)
|
||||
|
||||
# Lists of OUIDs for the batch variants. Bound the list size so the
|
||||
# in-clause stays reasonable; allow empty lists to exercise the
|
||||
# "no SQL issued" short-circuit.
|
||||
_id_list = st.lists(_id_text, min_size=0, max_size=8)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Properties
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_SETTINGS = settings(
|
||||
max_examples=100,
|
||||
deadline=None,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture],
|
||||
)
|
||||
|
||||
|
||||
@_SETTINGS
|
||||
@given(st.data())
|
||||
def test_get_all_teams_executes_select_only(data: st.DataObject) -> None:
|
||||
"""``get_all_teams()`` must only execute SELECT/WITH statements."""
|
||||
|
||||
# ``get_all_teams`` takes no inputs but we still drive Hypothesis
|
||||
# so the example counter and shrinking machinery match the rest
|
||||
# of the file.
|
||||
data.draw(st.just(None))
|
||||
|
||||
client, cursor = _make_client()
|
||||
try:
|
||||
client.get_all_teams()
|
||||
except DatabaseError as exc: # pragma: no cover - property failure
|
||||
raise AssertionError(
|
||||
f"_ensure_select_only rejected a SQL statement: {exc}"
|
||||
) from exc
|
||||
|
||||
for sql in cursor.executed:
|
||||
_assert_select_or_with(sql)
|
||||
|
||||
|
||||
@_SETTINGS
|
||||
@given(team_id=_id_text)
|
||||
def test_get_team_by_id_executes_select_only(team_id: str) -> None:
|
||||
"""``get_team_by_id(team_id)`` must only execute SELECT/WITH."""
|
||||
|
||||
client, cursor = _make_client()
|
||||
try:
|
||||
client.get_team_by_id(team_id)
|
||||
except DatabaseError as exc: # pragma: no cover - property failure
|
||||
raise AssertionError(
|
||||
f"_ensure_select_only rejected a SQL statement for "
|
||||
f"team_id={team_id!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
for sql in cursor.executed:
|
||||
_assert_select_or_with(sql)
|
||||
|
||||
|
||||
@_SETTINGS
|
||||
@given(ouid=_id_text)
|
||||
def test_get_team_competences_executes_select_only(ouid: str) -> None:
|
||||
"""``get_team_competences(ouid)`` must only execute SELECT/WITH."""
|
||||
|
||||
client, cursor = _make_client()
|
||||
try:
|
||||
client.get_team_competences(ouid)
|
||||
except DatabaseError as exc: # pragma: no cover - property failure
|
||||
raise AssertionError(
|
||||
f"_ensure_select_only rejected a SQL statement for "
|
||||
f"ouid={ouid!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
for sql in cursor.executed:
|
||||
_assert_select_or_with(sql)
|
||||
|
||||
|
||||
@_SETTINGS
|
||||
@given(ouids=_id_list)
|
||||
def test_batch_get_team_competences_executes_select_only(
|
||||
ouids: list[str],
|
||||
) -> None:
|
||||
"""``batch_get_team_competences(ouids)`` must only execute SELECT/WITH."""
|
||||
|
||||
client, cursor = _make_client()
|
||||
try:
|
||||
client.batch_get_team_competences(ouids)
|
||||
except DatabaseError as exc: # pragma: no cover - property failure
|
||||
raise AssertionError(
|
||||
f"_ensure_select_only rejected a SQL statement for "
|
||||
f"ouids={ouids!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
for sql in cursor.executed:
|
||||
_assert_select_or_with(sql)
|
||||
|
||||
|
||||
@_SETTINGS
|
||||
@given(ouid=_id_text)
|
||||
def test_get_team_references_executes_select_only(ouid: str) -> None:
|
||||
"""``get_team_references(ouid)`` must only execute SELECT/WITH."""
|
||||
|
||||
client, cursor = _make_client()
|
||||
try:
|
||||
client.get_team_references(ouid)
|
||||
except DatabaseError as exc: # pragma: no cover - property failure
|
||||
raise AssertionError(
|
||||
f"_ensure_select_only rejected a SQL statement for "
|
||||
f"ouid={ouid!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
for sql in cursor.executed:
|
||||
_assert_select_or_with(sql)
|
||||
|
||||
|
||||
@_SETTINGS
|
||||
@given(ouids=_id_list)
|
||||
def test_batch_get_team_references_executes_select_only(
|
||||
ouids: list[str],
|
||||
) -> None:
|
||||
"""``batch_get_team_references(ouids)`` must only execute SELECT/WITH."""
|
||||
|
||||
client, cursor = _make_client()
|
||||
try:
|
||||
client.batch_get_team_references(ouids)
|
||||
except DatabaseError as exc: # pragma: no cover - property failure
|
||||
raise AssertionError(
|
||||
f"_ensure_select_only rejected a SQL statement for "
|
||||
f"ouids={ouids!r}: {exc}"
|
||||
) from exc
|
||||
|
||||
for sql in cursor.executed:
|
||||
_assert_select_or_with(sql)
|
||||
@@ -0,0 +1,259 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.mcp_server import build_server
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeTask:
|
||||
id: str
|
||||
name: str | None = None
|
||||
title: str = ""
|
||||
description: str = ""
|
||||
created_date: datetime = datetime(2026, 1, 1)
|
||||
start_date: date | None = None
|
||||
end_date: date | None = None
|
||||
skills: list[str] = None # type: ignore[assignment]
|
||||
|
||||
def __post_init__(self):
|
||||
if self.skills is None:
|
||||
self.skills = []
|
||||
|
||||
|
||||
class FakeDBClient:
|
||||
def __init__(self, task: FakeTask):
|
||||
self._task = task
|
||||
|
||||
def test_connection(self) -> None:
|
||||
return
|
||||
|
||||
def get_table_columns(self, table: str) -> list[str]:
|
||||
if table == "teamlandkarte_v_capacity_roles_latest":
|
||||
return ["name", "active", "staffing_board_relevant"]
|
||||
if table == "teamlandkarte_v_capacities_latest":
|
||||
return ["creation_date"]
|
||||
if table == "teamlandkarte_v_teams_latest":
|
||||
return [
|
||||
"team_id",
|
||||
"ouid",
|
||||
"about_us",
|
||||
"offerings",
|
||||
"interests",
|
||||
"focus_name",
|
||||
]
|
||||
if table == "teamlandkarte_v_teammeter_organizational_units_latest":
|
||||
return ["id", "name"]
|
||||
if table == "teamlandkarte_v_teammeter_team_competences_latest":
|
||||
return ["ouid", "competence_id", "top_competency"]
|
||||
if table == "teamlandkarte_v_team_references_latest":
|
||||
return ["ouid", "partner_id", "projects"]
|
||||
return []
|
||||
|
||||
def get_open_tasks(self, limit: int = 20):
|
||||
return [self._task]
|
||||
|
||||
def get_task_by_id(self, task_id: str):
|
||||
if task_id != self._task.id:
|
||||
return None
|
||||
return self._task
|
||||
|
||||
def get_task_by_name(self, name: str):
|
||||
if not name:
|
||||
return None
|
||||
if getattr(self._task, "name", None) == name:
|
||||
return self._task
|
||||
return None
|
||||
|
||||
def get_all_capacities_with_competences(self): # pragma: no cover
|
||||
return []
|
||||
|
||||
def get_recent_free_capacities(self, limit: int = 20): # pragma: no cover
|
||||
return []
|
||||
|
||||
def get_capacity_by_id(self, capacity_id): # pragma: no cover
|
||||
return None
|
||||
|
||||
def get_all_role_names(self) -> list[str]:
|
||||
return ["Cloud Engineer"]
|
||||
|
||||
def get_all_competence_names(self) -> list[str]:
|
||||
return ["AWS", "Kubernetes"]
|
||||
|
||||
|
||||
def _result_to_text(result: Any) -> str:
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, structured = result
|
||||
if isinstance(structured, dict) and isinstance(structured.get("result"), str):
|
||||
return structured["result"]
|
||||
if isinstance(content, list) and content:
|
||||
text = getattr(content[0], "text", None)
|
||||
if isinstance(text, str):
|
||||
return text
|
||||
return str(result)
|
||||
|
||||
|
||||
def _call_tool(srv: Any, name: str, args: dict[str, Any] | None = None) -> str:
|
||||
if args is None:
|
||||
args = {}
|
||||
import asyncio
|
||||
|
||||
return _result_to_text(asyncio.run(srv.call_tool(name, args)))
|
||||
|
||||
|
||||
def test_infer_primary_role_table_output(monkeypatch, tmp_path) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=10
|
||||
max_size=10
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
task = FakeTask(
|
||||
id="t1",
|
||||
title="Task",
|
||||
description="We need AWS and Kubernetes work.",
|
||||
created_date=datetime(2026, 1, 1, 10, 0, 0),
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
skills=["AWS"],
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
monkeypatch.setattr(mod, "create_db_client", lambda *_: FakeDBClient(task))
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
out = _call_tool(
|
||||
srv,
|
||||
"infer_primary_role",
|
||||
{"task_text": task.description},
|
||||
)
|
||||
|
||||
assert "| Role | Similarity |" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_task_details_table_first(monkeypatch, tmp_path) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=10
|
||||
max_size=10
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
task = FakeTask(
|
||||
id="t1",
|
||||
title="Title",
|
||||
description="Desc",
|
||||
created_date=datetime(2026, 1, 2, 12, 0, 0),
|
||||
start_date=date(2026, 2, 1),
|
||||
end_date=date(2026, 2, 28),
|
||||
skills=["AWS", "Kubernetes"],
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
monkeypatch.setattr(mod, "create_db_client", lambda *_: FakeDBClient(task))
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
content, structured = await srv.call_tool(
|
||||
"get_task_details",
|
||||
{"task_id": "t1"},
|
||||
)
|
||||
out = _result_to_text((content, structured))
|
||||
|
||||
assert out.lstrip().startswith("|")
|
||||
assert "| Task ID |" in out
|
||||
assert "## Description" in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_task_requirements_single_table(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
cfg = tmp_path / "config.toml"
|
||||
cfg.write_text(
|
||||
"""
|
||||
[database]
|
||||
host='x'
|
||||
port=1
|
||||
username='u'
|
||||
password='p'
|
||||
backend='trino'
|
||||
|
||||
[matching]
|
||||
competence_weight=0.8
|
||||
role_weight=0.2
|
||||
require_confirmation=false
|
||||
|
||||
[cache]
|
||||
db_ttl_hours=1
|
||||
search_ttl_minutes=10
|
||||
max_size=10
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
task = FakeTask(
|
||||
id="t1",
|
||||
title="Title",
|
||||
description="We need AWS.",
|
||||
created_date=datetime(2026, 1, 2, 12, 0, 0),
|
||||
start_date=None,
|
||||
end_date=None,
|
||||
skills=["AWS"],
|
||||
)
|
||||
|
||||
import teamlandkarte_mcp.mcp_server as mod
|
||||
|
||||
monkeypatch.setattr(mod, "create_db_client", lambda *_: FakeDBClient(task))
|
||||
|
||||
srv = build_server(str(cfg))
|
||||
content, structured = await srv.call_tool(
|
||||
"validate_task_requirements", {"task_id": "t1"}
|
||||
)
|
||||
out = _result_to_text((content, structured))
|
||||
|
||||
assert "| Task ID |" in out
|
||||
assert "| Time range (DB) |" in out
|
||||
assert "## Summary" in out
|
||||
assert "## Inferred Primary Role" in out
|
||||
assert "## Inferred Competences" in out
|
||||
@@ -0,0 +1,365 @@
|
||||
"""Unit tests for the new capacity full-text DB methods on TrinoClient.
|
||||
|
||||
Covers single-row and batch variants for capacity description,
|
||||
certificates, and references including the LEFT JOIN on
|
||||
`teamlandkarte_v_partners_latest`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.database.trino_client import TrinoClient
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
"""Minimal cursor double recording executes and returning canned rows."""
|
||||
|
||||
def __init__(self, rows: list[tuple] | None = None) -> None:
|
||||
self.executed: list[tuple[str, tuple[object, ...] | None]] = []
|
||||
self._rows: list[tuple] = rows or []
|
||||
|
||||
def execute(self, sql: str, params=None) -> None: # noqa: ANN001
|
||||
if params is None:
|
||||
params_tuple: tuple[object, ...] | None = None
|
||||
elif isinstance(params, tuple):
|
||||
params_tuple = params
|
||||
else:
|
||||
params_tuple = tuple(params)
|
||||
self.executed.append((sql, params_tuple))
|
||||
|
||||
def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def fetchall(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _FakeCursorContext:
|
||||
def __init__(self, cursor: _FakeCursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self) -> _FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
return None
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
host = "x"
|
||||
port = 1
|
||||
username = "u"
|
||||
password = "p"
|
||||
http_scheme = "http"
|
||||
verify_ssl = False
|
||||
catalog = "c"
|
||||
schema = "s"
|
||||
pool_size = 1
|
||||
|
||||
|
||||
def _mk_client(cur: _FakeCursor) -> TrinoClient:
|
||||
client = TrinoClient(_FakeConfig())
|
||||
client._cursor = lambda: _FakeCursorContext(cur) # type: ignore[method-assign]
|
||||
return client
|
||||
|
||||
|
||||
def _norm_sql(sql: str) -> str:
|
||||
return re.sub(r"\s+", " ", sql).strip()
|
||||
|
||||
|
||||
def _assert_select_only(sql: str) -> None:
|
||||
upper = sql.strip().upper()
|
||||
assert upper.startswith("SELECT"), f"expected SELECT, got: {sql!r}"
|
||||
for kw in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE"):
|
||||
assert kw not in upper, f"unexpected write keyword {kw} in SQL: {sql!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-row methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_capacity_description_one_query_select_only_and_trims() -> None:
|
||||
cur = _FakeCursor(rows=[(" hello world ",)])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_capacity_description(42)
|
||||
|
||||
assert len(cur.executed) == 1, "exactly one SQL query expected"
|
||||
sql, params = cur.executed[0]
|
||||
sql_n = _norm_sql(sql)
|
||||
_assert_select_only(sql)
|
||||
assert "FROM teamlandkarte_v_capacities_latest" in sql_n
|
||||
assert "WHERE id = ?" in sql_n
|
||||
assert params == (42,)
|
||||
assert out == "hello world"
|
||||
|
||||
|
||||
def test_get_capacity_description_null_returns_none() -> None:
|
||||
cur = _FakeCursor(rows=[(None,)])
|
||||
c = _mk_client(cur)
|
||||
|
||||
assert c.get_capacity_description(1) is None
|
||||
|
||||
|
||||
def test_get_capacity_description_whitespace_returns_none() -> None:
|
||||
cur = _FakeCursor(rows=[(" \t ",)])
|
||||
c = _mk_client(cur)
|
||||
|
||||
assert c.get_capacity_description(1) is None
|
||||
|
||||
|
||||
def test_get_capacity_description_no_row_returns_none() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
assert c.get_capacity_description(99) is None
|
||||
|
||||
|
||||
def test_get_capacity_certificates_one_query_filters_empty() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("ISTQB Foundation",),
|
||||
("",),
|
||||
(" ",),
|
||||
(None,),
|
||||
("PMP ",),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_capacity_certificates(7)
|
||||
|
||||
assert len(cur.executed) == 1
|
||||
sql, params = cur.executed[0]
|
||||
sql_n = _norm_sql(sql)
|
||||
_assert_select_only(sql)
|
||||
assert "FROM teamlandkarte_v_capacity_certificates_latest" in sql_n
|
||||
assert "WHERE capacity_id = ?" in sql_n
|
||||
assert params == (7,)
|
||||
assert out == ["ISTQB Foundation", "PMP"]
|
||||
|
||||
|
||||
def test_get_capacity_certificates_no_rows_returns_empty_list() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
assert c.get_capacity_certificates(123) == []
|
||||
assert len(cur.executed) == 1
|
||||
|
||||
|
||||
def test_get_capacity_references_single_query_includes_partner_left_join() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("Project Alpha", "Acme GmbH"),
|
||||
("Project Beta", ""), # NULL partner_id → COALESCE → ""
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_capacity_references(11)
|
||||
|
||||
assert len(cur.executed) == 1, "partner LEFT JOIN must be in the same query"
|
||||
sql, params = cur.executed[0]
|
||||
sql_n = _norm_sql(sql)
|
||||
_assert_select_only(sql)
|
||||
assert "FROM teamlandkarte_v_capacity_references_latest" in sql_n
|
||||
assert "LEFT JOIN teamlandkarte_v_partners_latest" in sql_n
|
||||
assert "r.partner_id = p.id" in sql_n
|
||||
assert "WHERE r.capacity_id = ?" in sql_n
|
||||
assert params == (11,)
|
||||
assert out == [
|
||||
{"projects": "Project Alpha", "partner_name": "Acme GmbH"},
|
||||
{"projects": "Project Beta", "partner_name": ""},
|
||||
]
|
||||
|
||||
|
||||
def test_get_capacity_references_filters_empty_projects_keeps_empty_partner() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("", "Acme GmbH"), # empty projects → drop
|
||||
(" ", "X"), # whitespace only → drop
|
||||
(None, "Y"), # non-string → drop
|
||||
("Migration", ""), # empty partner is allowed
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_capacity_references(11)
|
||||
|
||||
assert out == [{"projects": "Migration", "partner_name": ""}]
|
||||
|
||||
|
||||
def test_get_capacity_references_partner_name_none_normalizes_to_empty() -> None:
|
||||
"""Defensive: if the driver returns None for partner_name, treat as ''."""
|
||||
cur = _FakeCursor(rows=[("Project Alpha", None)])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_capacity_references(11)
|
||||
|
||||
assert out == [{"projects": "Project Alpha", "partner_name": ""}]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch variants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_batch_get_capacity_descriptions_empty_input_returns_empty_no_sql() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_capacity_descriptions([])
|
||||
|
||||
assert out == {}
|
||||
assert cur.executed == []
|
||||
|
||||
|
||||
def test_batch_get_capacity_descriptions_one_query_groups_and_defaults() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
(1, " desc one "),
|
||||
(2, None),
|
||||
(3, " "),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_capacity_descriptions([1, 2, 3, 4])
|
||||
|
||||
assert len(cur.executed) == 1
|
||||
sql, params = cur.executed[0]
|
||||
sql_n = _norm_sql(sql)
|
||||
_assert_select_only(sql)
|
||||
assert "FROM teamlandkarte_v_capacities_latest" in sql_n
|
||||
assert "WHERE id IN (?, ?, ?, ?)" in sql_n
|
||||
assert params == (1, 2, 3, 4)
|
||||
assert out == {"1": "desc one", "2": None, "3": None, "4": None}
|
||||
|
||||
|
||||
def test_batch_get_capacity_certificates_empty_input_returns_empty_no_sql() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_capacity_certificates([])
|
||||
|
||||
assert out == {}
|
||||
assert cur.executed == []
|
||||
|
||||
|
||||
def test_batch_get_capacity_certificates_groups_n_to_1_and_defaults() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
(1, "ISTQB"),
|
||||
(1, " PMP "),
|
||||
(1, ""), # filtered
|
||||
(2, "AWS Solutions Architect"),
|
||||
(1, None), # filtered
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_capacity_certificates([1, 2, 3])
|
||||
|
||||
assert len(cur.executed) == 1
|
||||
sql, params = cur.executed[0]
|
||||
sql_n = _norm_sql(sql)
|
||||
_assert_select_only(sql)
|
||||
assert "FROM teamlandkarte_v_capacity_certificates_latest" in sql_n
|
||||
assert "WHERE capacity_id IN (?, ?, ?)" in sql_n
|
||||
assert params == (1, 2, 3)
|
||||
assert out == {
|
||||
"1": ["ISTQB", "PMP"],
|
||||
"2": ["AWS Solutions Architect"],
|
||||
"3": [], # default for missing id
|
||||
}
|
||||
|
||||
|
||||
def test_batch_get_capacity_references_empty_input_returns_empty_no_sql() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_capacity_references([])
|
||||
|
||||
assert out == {}
|
||||
assert cur.executed == []
|
||||
|
||||
|
||||
def test_batch_get_capacity_references_partner_join_same_statement_and_groups() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
(1, "Project Alpha", "Acme GmbH"),
|
||||
(1, "Project Beta", ""), # NULL partner_id → kept with empty partner
|
||||
(2, "Migration", "BetaCorp"),
|
||||
(1, " ", "X"), # filtered (empty projects)
|
||||
(1, None, "Y"), # filtered (non-string projects)
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_capacity_references([1, 2, 3])
|
||||
|
||||
assert len(cur.executed) == 1, (
|
||||
"partner LEFT JOIN must be part of the same single batch query"
|
||||
)
|
||||
sql, params = cur.executed[0]
|
||||
sql_n = _norm_sql(sql)
|
||||
_assert_select_only(sql)
|
||||
assert "FROM teamlandkarte_v_capacity_references_latest" in sql_n
|
||||
assert "LEFT JOIN teamlandkarte_v_partners_latest" in sql_n
|
||||
assert "r.partner_id = p.id" in sql_n
|
||||
assert "WHERE r.capacity_id IN (?, ?, ?)" in sql_n
|
||||
assert params == (1, 2, 3)
|
||||
assert out == {
|
||||
"1": [
|
||||
{"projects": "Project Alpha", "partner_name": "Acme GmbH"},
|
||||
{"projects": "Project Beta", "partner_name": ""},
|
||||
],
|
||||
"2": [{"projects": "Migration", "partner_name": "BetaCorp"}],
|
||||
"3": [],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_get_capacity_references_partner_name_none_normalizes_to_empty() -> None:
|
||||
"""Defensive: driver returns None for partner_name → treated as ''."""
|
||||
cur = _FakeCursor(rows=[(1, "Project Alpha", None)])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_capacity_references([1])
|
||||
|
||||
assert out == {
|
||||
"1": [{"projects": "Project Alpha", "partner_name": ""}],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SELECT-only guard parametric coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,args,rows",
|
||||
[
|
||||
("get_capacity_description", (1,), [("x",)]),
|
||||
("get_capacity_certificates", (1,), [("x",)]),
|
||||
("get_capacity_references", (1,), [("x", "")]),
|
||||
("batch_get_capacity_descriptions", ([1, 2],), [(1, "x")]),
|
||||
("batch_get_capacity_certificates", ([1, 2],), [(1, "x")]),
|
||||
("batch_get_capacity_references", ([1, 2],), [(1, "x", "")]),
|
||||
],
|
||||
)
|
||||
def test_capacity_fulltext_methods_emit_select_only(
|
||||
method: str, args: tuple, rows: list[tuple]
|
||||
) -> None:
|
||||
cur = _FakeCursor(rows=rows)
|
||||
c = _mk_client(cur)
|
||||
|
||||
getattr(c, method)(*args)
|
||||
|
||||
assert cur.executed, f"{method} should have executed a query"
|
||||
assert len(cur.executed) == 1, f"{method} should issue exactly one query"
|
||||
sql, _params = cur.executed[0]
|
||||
_assert_select_only(sql)
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Unit tests for `TrinoClient.get_team_competences` and
|
||||
`batch_get_team_competences`.
|
||||
|
||||
Covers SQL shape (INNER JOIN on the competences view, COALESCE for
|
||||
`top_competency`, deterministic `ORDER BY`), grouping by `ouid` in
|
||||
the batch variant, NULL-name filtering and empty-input handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.database.trino_client import TrinoClient
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
"""Minimal cursor double recording executes and returning canned rows."""
|
||||
|
||||
def __init__(self, rows: list[tuple] | None = None) -> None:
|
||||
self.executed: list[tuple[str, tuple[object, ...] | None]] = []
|
||||
self._rows: list[tuple] = rows or []
|
||||
|
||||
def execute(self, sql: str, params=None) -> None: # noqa: ANN001
|
||||
if params is None:
|
||||
params_tuple: tuple[object, ...] | None = None
|
||||
elif isinstance(params, tuple):
|
||||
params_tuple = params
|
||||
else:
|
||||
params_tuple = tuple(params)
|
||||
self.executed.append((sql, params_tuple))
|
||||
|
||||
def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def fetchall(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _FakeCursorContext:
|
||||
def __init__(self, cursor: _FakeCursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self) -> _FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
return None
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
host = "x"
|
||||
port = 1
|
||||
username = "u"
|
||||
password = "p"
|
||||
http_scheme = "http"
|
||||
verify_ssl = False
|
||||
catalog = "c"
|
||||
schema = "s"
|
||||
pool_size = 1
|
||||
|
||||
|
||||
def _mk_client(cur: _FakeCursor) -> TrinoClient:
|
||||
client = TrinoClient(_FakeConfig())
|
||||
client._cursor = lambda: _FakeCursorContext(cur) # type: ignore[method-assign]
|
||||
return client
|
||||
|
||||
|
||||
def _norm_sql(sql: str) -> str:
|
||||
return re.sub(r"\s+", " ", sql).strip()
|
||||
|
||||
|
||||
def _assert_select_only(sql: str) -> None:
|
||||
upper = sql.strip().upper()
|
||||
assert upper.startswith("SELECT"), f"expected SELECT, got: {sql!r}"
|
||||
for kw in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE"):
|
||||
assert kw not in upper, f"unexpected write keyword {kw} in SQL: {sql!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-row variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_team_competences_returns_empty_for_blank_input() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
assert c.get_team_competences("") == []
|
||||
assert c.get_team_competences(" ") == []
|
||||
assert cur.executed == []
|
||||
|
||||
|
||||
def test_get_team_competences_one_query_select_only_and_join_shape() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("Python", True),
|
||||
("Cloud", False),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_team_competences("OU1")
|
||||
|
||||
assert len(cur.executed) == 1
|
||||
sql, params = cur.executed[0]
|
||||
sql_n = _norm_sql(sql)
|
||||
_assert_select_only(sql)
|
||||
assert (
|
||||
"FROM teamlandkarte_v_teammeter_team_competences_latest tc"
|
||||
in sql_n
|
||||
)
|
||||
assert "JOIN teamlandkarte_v_competences_latest c" in sql_n
|
||||
assert "CAST(tc.competence_id AS VARCHAR) = CAST(c.id AS VARCHAR)" in sql_n
|
||||
assert "WHERE CAST(tc.ouid AS VARCHAR) = ?" in sql_n
|
||||
assert "COALESCE(tc.top_competency, FALSE)" in sql_n
|
||||
# Order: top first, then name asc
|
||||
assert "ORDER BY" in sql_n
|
||||
assert "CASE WHEN COALESCE(tc.top_competency, FALSE) THEN 0 ELSE 1 END" in sql_n
|
||||
assert "c.name ASC" in sql_n
|
||||
assert params == ("OU1",)
|
||||
assert out == [
|
||||
{"name": "Python", "top_competency": True},
|
||||
{"name": "Cloud", "top_competency": False},
|
||||
]
|
||||
|
||||
|
||||
def test_get_team_competences_normalises_null_top_competency() -> None:
|
||||
# SQL-side COALESCE turns NULL into FALSE, but the Python layer also
|
||||
# defends against `None` defensively via bool(...).
|
||||
cur = _FakeCursor(rows=[("Python", None), ("Cloud", 1)])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_team_competences("OU1")
|
||||
|
||||
assert out == [
|
||||
{"name": "Python", "top_competency": False},
|
||||
{"name": "Cloud", "top_competency": True},
|
||||
]
|
||||
|
||||
|
||||
def test_get_team_competences_filters_rows_without_resolvable_name() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
(None, True), # join produced no name → drop
|
||||
("", False), # empty name → drop
|
||||
("Python", True),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
assert c.get_team_competences("OU1") == [
|
||||
{"name": "Python", "top_competency": True},
|
||||
]
|
||||
|
||||
|
||||
def test_get_team_competences_no_rows_returns_empty_list() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
assert c.get_team_competences("OU1") == []
|
||||
assert len(cur.executed) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_batch_get_team_competences_empty_input_returns_empty_no_sql() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_competences([])
|
||||
|
||||
assert out == {}
|
||||
assert cur.executed == []
|
||||
|
||||
|
||||
def test_batch_get_team_competences_groups_and_defaults_to_empty_list() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("OU1", "Python", True),
|
||||
("OU1", "Cloud", False),
|
||||
("OU2", "Architecture", True),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_competences(["OU1", "OU2", "OU3"])
|
||||
|
||||
assert len(cur.executed) == 1, "batch must use exactly one SELECT"
|
||||
sql, params = cur.executed[0]
|
||||
sql_n = _norm_sql(sql)
|
||||
_assert_select_only(sql)
|
||||
assert (
|
||||
"FROM teamlandkarte_v_teammeter_team_competences_latest tc"
|
||||
in sql_n
|
||||
)
|
||||
assert "JOIN teamlandkarte_v_competences_latest c" in sql_n
|
||||
assert "CAST(tc.competence_id AS VARCHAR) = CAST(c.id AS VARCHAR)" in sql_n
|
||||
assert "WHERE CAST(tc.ouid AS VARCHAR) IN (?, ?, ?)" in sql_n
|
||||
assert "ORDER BY tc.ouid ASC" in sql_n
|
||||
assert "CASE WHEN COALESCE(tc.top_competency, FALSE) THEN 0 ELSE 1 END" in sql_n
|
||||
assert "c.name ASC" in sql_n
|
||||
assert params == ("OU1", "OU2", "OU3")
|
||||
assert out == {
|
||||
"OU1": [
|
||||
{"name": "Python", "top_competency": True},
|
||||
{"name": "Cloud", "top_competency": False},
|
||||
],
|
||||
"OU2": [
|
||||
{"name": "Architecture", "top_competency": True},
|
||||
],
|
||||
"OU3": [],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_get_team_competences_filters_unresolved_competence_names() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("OU1", None, True), # no resolvable name → drop
|
||||
("OU1", "Python", True),
|
||||
("OU2", "", False), # empty name → drop
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_competences(["OU1", "OU2"])
|
||||
|
||||
assert out == {
|
||||
"OU1": [{"name": "Python", "top_competency": True}],
|
||||
"OU2": [],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_get_team_competences_normalises_null_top_competency() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("OU1", "Python", None),
|
||||
("OU1", "Cloud", 1),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_competences(["OU1"])
|
||||
|
||||
assert out == {
|
||||
"OU1": [
|
||||
{"name": "Python", "top_competency": False},
|
||||
{"name": "Cloud", "top_competency": True},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_get_team_competences_ignores_blank_ouid_inputs() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_competences(["", " "])
|
||||
|
||||
assert out == {"": [], " ": []}
|
||||
assert cur.executed == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SELECT-only guard parametric coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,args,rows",
|
||||
[
|
||||
("get_team_competences", ("OU1",), [("Python", True)]),
|
||||
(
|
||||
"batch_get_team_competences",
|
||||
(["OU1", "OU2"],),
|
||||
[("OU1", "Python", True)],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_team_competence_methods_emit_select_only(
|
||||
method: str, args: tuple, rows: list[tuple]
|
||||
) -> None:
|
||||
cur = _FakeCursor(rows=rows)
|
||||
c = _mk_client(cur)
|
||||
|
||||
getattr(c, method)(*args)
|
||||
|
||||
assert cur.executed, f"{method} should have executed a query"
|
||||
assert len(cur.executed) == 1, f"{method} should issue exactly one query"
|
||||
sql, _params = cur.executed[0]
|
||||
_assert_select_only(sql)
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Unit tests for `TrinoClient.get_all_teams` and `get_team_by_id`.
|
||||
|
||||
Covers SQL shape (INNER JOIN on the OU view, COALESCE for nullable
|
||||
text columns, deterministic ORDER BY), aggregation into `Team`
|
||||
instances, and the `None`-on-no-match contract for
|
||||
`get_team_by_id`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from teamlandkarte_mcp.database.trino_client import TrinoClient
|
||||
from teamlandkarte_mcp.models import Team
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
"""Minimal cursor double recording executes and returning canned rows."""
|
||||
|
||||
def __init__(self, rows: list[tuple] | None = None) -> None:
|
||||
self.executed: list[tuple[str, tuple[object, ...] | None]] = []
|
||||
self._rows: list[tuple] = rows or []
|
||||
|
||||
def execute(self, sql: str, params=None) -> None: # noqa: ANN001
|
||||
if params is None:
|
||||
params_tuple: tuple[object, ...] | None = None
|
||||
elif isinstance(params, tuple):
|
||||
params_tuple = params
|
||||
else:
|
||||
params_tuple = tuple(params)
|
||||
self.executed.append((sql, params_tuple))
|
||||
|
||||
def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def fetchall(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _FakeCursorContext:
|
||||
def __init__(self, cursor: _FakeCursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self) -> _FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
return None
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
host = "x"
|
||||
port = 1
|
||||
username = "u"
|
||||
password = "p"
|
||||
http_scheme = "http"
|
||||
verify_ssl = False
|
||||
catalog = "c"
|
||||
schema = "s"
|
||||
pool_size = 1
|
||||
|
||||
|
||||
def _mk_client(cur: _FakeCursor) -> TrinoClient:
|
||||
client = TrinoClient(_FakeConfig())
|
||||
client._cursor = lambda: _FakeCursorContext(cur) # type: ignore[method-assign]
|
||||
return client
|
||||
|
||||
|
||||
def _norm_sql(sql: str) -> str:
|
||||
return re.sub(r"\s+", " ", sql).strip()
|
||||
|
||||
|
||||
def _assert_select_only(sql: str) -> None:
|
||||
upper = sql.strip().upper()
|
||||
assert upper.startswith("SELECT"), f"expected SELECT, got: {sql!r}"
|
||||
for kw in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE"):
|
||||
assert kw not in upper, f"unexpected write keyword in SQL: {sql!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_all_teams_empty_returns_empty_list() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
client = _mk_client(cur)
|
||||
|
||||
result = client.get_all_teams()
|
||||
|
||||
assert result == []
|
||||
assert len(cur.executed) == 1
|
||||
sql = _norm_sql(cur.executed[0][0])
|
||||
_assert_select_only(sql)
|
||||
assert "INNER JOIN" in sql.upper()
|
||||
assert "teamlandkarte_v_teams_latest" in sql
|
||||
assert (
|
||||
"teamlandkarte_v_teammeter_organizational_units_latest" in sql
|
||||
)
|
||||
assert "CAST(t.team_id AS VARCHAR) = CAST(ou.id AS VARCHAR)" in sql
|
||||
assert "ORDER BY ou.name ASC, t.team_id ASC" in sql
|
||||
assert "COALESCE(t.focus_name" in sql
|
||||
assert "COALESCE(t.about_us" in sql
|
||||
assert "COALESCE(t.offerings" in sql
|
||||
assert "COALESCE(t.interests" in sql
|
||||
|
||||
|
||||
def test_get_all_teams_aggregates_rows_into_team_instances() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
(
|
||||
"T1",
|
||||
"OU1",
|
||||
"Alpha Team",
|
||||
"Backend",
|
||||
"About alpha",
|
||||
"API offerings",
|
||||
"Cloud interests",
|
||||
),
|
||||
(
|
||||
"T2",
|
||||
"OU2",
|
||||
"Beta Team",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
),
|
||||
]
|
||||
)
|
||||
client = _mk_client(cur)
|
||||
|
||||
teams = client.get_all_teams()
|
||||
|
||||
assert len(teams) == 2
|
||||
assert all(isinstance(t, Team) for t in teams)
|
||||
assert teams[0].team_id == "T1"
|
||||
assert teams[0].ouid == "OU1"
|
||||
assert teams[0].team_name == "Alpha Team"
|
||||
assert teams[0].focus_name == "Backend"
|
||||
assert teams[0].about_us == "About alpha"
|
||||
assert teams[0].offerings == "API offerings"
|
||||
assert teams[0].interests == "Cloud interests"
|
||||
assert teams[0].competences == []
|
||||
assert teams[0].references == []
|
||||
assert teams[1].team_id == "T2"
|
||||
assert teams[1].team_name == "Beta Team"
|
||||
assert teams[1].focus_name == ""
|
||||
assert teams[1].about_us == ""
|
||||
assert teams[1].offerings == ""
|
||||
assert teams[1].interests == ""
|
||||
|
||||
|
||||
def test_get_team_by_id_returns_none_when_no_row() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
client = _mk_client(cur)
|
||||
|
||||
result = client.get_team_by_id("does-not-exist")
|
||||
|
||||
assert result is None
|
||||
assert len(cur.executed) == 1
|
||||
sql, params = cur.executed[0]
|
||||
sql_norm = _norm_sql(sql)
|
||||
_assert_select_only(sql_norm)
|
||||
assert "CAST(t.team_id AS VARCHAR) = ?" in sql_norm
|
||||
assert "t.ouid = ?" in sql_norm
|
||||
assert params == ("does-not-exist", "does-not-exist")
|
||||
|
||||
|
||||
def test_get_team_by_id_returns_none_for_blank_input() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
client = _mk_client(cur)
|
||||
|
||||
assert client.get_team_by_id("") is None
|
||||
assert client.get_team_by_id(" ") is None
|
||||
# Blank input should short-circuit without issuing SQL.
|
||||
assert cur.executed == []
|
||||
|
||||
|
||||
def test_get_team_by_id_builds_team_from_row() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
(
|
||||
"T42",
|
||||
"OU42",
|
||||
"Gamma Team",
|
||||
"Data",
|
||||
"About gamma",
|
||||
"Analytics",
|
||||
"AI interests",
|
||||
)
|
||||
]
|
||||
)
|
||||
client = _mk_client(cur)
|
||||
|
||||
team = client.get_team_by_id("T42")
|
||||
|
||||
assert isinstance(team, Team)
|
||||
assert team.team_id == "T42"
|
||||
assert team.ouid == "OU42"
|
||||
assert team.team_name == "Gamma Team"
|
||||
assert team.focus_name == "Data"
|
||||
assert team.about_us == "About gamma"
|
||||
assert team.offerings == "Analytics"
|
||||
assert team.interests == "AI interests"
|
||||
assert team.competences == []
|
||||
assert team.references == []
|
||||
|
||||
|
||||
def test_get_team_by_id_normalises_null_text_columns() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[("T1", "OU1", "Solo Team", None, None, None, None)]
|
||||
)
|
||||
client = _mk_client(cur)
|
||||
|
||||
team = client.get_team_by_id("T1")
|
||||
|
||||
assert team is not None
|
||||
# COALESCE handles NULL on the SQL side; the Python layer also
|
||||
# defends against `None` defensively.
|
||||
assert team.focus_name == ""
|
||||
assert team.about_us == ""
|
||||
assert team.offerings == ""
|
||||
assert team.interests == ""
|
||||
@@ -0,0 +1,233 @@
|
||||
"""SQL-shape snapshot tests for `TrinoClient` team queries.
|
||||
|
||||
These tests focus exclusively on the *string shape* of the production
|
||||
SQL emitted by the team-related methods. They complement the more
|
||||
behaviour-oriented tests in:
|
||||
|
||||
* ``tests/test_trino_client_team_master_data.py``
|
||||
* ``tests/test_trino_client_team_competences.py``
|
||||
* ``tests/test_trino_client_team_references.py``
|
||||
|
||||
by acting as a focused snapshot guard that ``INNER JOIN``,
|
||||
``ORDER BY``, ``COALESCE`` and the ``LEFT JOIN`` against
|
||||
``teamlandkarte_v_partners_latest`` remain correctly formulated.
|
||||
|
||||
Each test calls a method on a ``TrinoClient`` whose ``_cursor`` is
|
||||
swapped for a fake context yielding canned rows, then inspects
|
||||
``cur.executed[0][0]`` (whitespace-normalised) for the expected
|
||||
substrings. Returning canned rows lets every method run end-to-end
|
||||
without touching a real database.
|
||||
|
||||
Validates: Requirements 2.1, 2.2, 3.1, 4.1, 4.2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from teamlandkarte_mcp.database.trino_client import TrinoClient
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
"""Minimal cursor double recording executes and returning canned rows."""
|
||||
|
||||
def __init__(self, rows: list[tuple] | None = None) -> None:
|
||||
self.executed: list[tuple[str, tuple[object, ...] | None]] = []
|
||||
self._rows: list[tuple] = rows or []
|
||||
|
||||
def execute(self, sql: str, params=None) -> None: # noqa: ANN001
|
||||
if params is None:
|
||||
params_tuple: tuple[object, ...] | None = None
|
||||
elif isinstance(params, tuple):
|
||||
params_tuple = params
|
||||
else:
|
||||
params_tuple = tuple(params)
|
||||
self.executed.append((sql, params_tuple))
|
||||
|
||||
def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def fetchall(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _FakeCursorContext:
|
||||
def __init__(self, cursor: _FakeCursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self) -> _FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
return None
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
host = "x"
|
||||
port = 1
|
||||
username = "u"
|
||||
password = "p"
|
||||
http_scheme = "http"
|
||||
verify_ssl = False
|
||||
catalog = "c"
|
||||
schema = "s"
|
||||
pool_size = 1
|
||||
|
||||
|
||||
def _mk_client(cur: _FakeCursor) -> TrinoClient:
|
||||
client = TrinoClient(_FakeConfig())
|
||||
client._cursor = lambda: _FakeCursorContext(cur) # type: ignore[method-assign]
|
||||
return client
|
||||
|
||||
|
||||
def _norm_sql(sql: str) -> str:
|
||||
"""Collapse all whitespace runs into single spaces for stable matching."""
|
||||
|
||||
return re.sub(r"\s+", " ", sql).strip()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_all_teams – master-data SQL shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_all_teams_sql_shape_snapshot() -> None:
|
||||
"""`get_all_teams` emits the documented master-data SQL.
|
||||
|
||||
Validates that the first executed query joins teams with the
|
||||
organizational-units view via INNER JOIN, COALESCEs nullable text
|
||||
columns and applies the deterministic ORDER BY documented in the
|
||||
design.
|
||||
"""
|
||||
|
||||
cur = _FakeCursor(rows=[])
|
||||
client = _mk_client(cur)
|
||||
|
||||
client.get_all_teams()
|
||||
|
||||
assert cur.executed, "expected at least one query to be executed"
|
||||
sql = _norm_sql(cur.executed[0][0])
|
||||
|
||||
assert (
|
||||
"INNER JOIN teamlandkarte_v_teammeter_organizational_units_latest"
|
||||
in sql
|
||||
)
|
||||
assert "CAST(t.team_id AS VARCHAR) = CAST(ou.id AS VARCHAR)" in sql
|
||||
assert "COALESCE(t.focus_name, '')" in sql
|
||||
assert "COALESCE(t.about_us, '')" in sql
|
||||
assert "COALESCE(t.offerings, '')" in sql
|
||||
assert "COALESCE(t.interests, '')" in sql
|
||||
assert "ORDER BY ou.name ASC, t.team_id ASC" in sql
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_team_by_id – same shape plus the team_id/ouid filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_team_by_id_sql_shape_snapshot() -> None:
|
||||
"""`get_team_by_id` reuses the master-data SQL plus a filter clause.
|
||||
|
||||
Validates that the same INNER JOIN / COALESCE / ORDER BY scaffolding
|
||||
is in place and that the additional
|
||||
``CAST(t.team_id AS VARCHAR) = ?`` and
|
||||
``CAST(t.ouid AS VARCHAR) = ?`` filter terms are present.
|
||||
"""
|
||||
|
||||
cur = _FakeCursor(rows=[])
|
||||
client = _mk_client(cur)
|
||||
|
||||
client.get_team_by_id("T1")
|
||||
|
||||
assert cur.executed, "expected the lookup query to be executed"
|
||||
sql = _norm_sql(cur.executed[0][0])
|
||||
|
||||
# Same INNER JOIN scaffolding as get_all_teams.
|
||||
assert (
|
||||
"INNER JOIN teamlandkarte_v_teammeter_organizational_units_latest"
|
||||
in sql
|
||||
)
|
||||
assert "CAST(t.team_id AS VARCHAR) = CAST(ou.id AS VARCHAR)" in sql
|
||||
assert "COALESCE(t.focus_name, '')" in sql
|
||||
assert "COALESCE(t.about_us, '')" in sql
|
||||
assert "COALESCE(t.offerings, '')" in sql
|
||||
assert "COALESCE(t.interests, '')" in sql
|
||||
assert "ORDER BY ou.name ASC, t.team_id ASC" in sql
|
||||
|
||||
# Filter clause specific to the by-id lookup.
|
||||
assert "CAST(t.team_id AS VARCHAR) = ?" in sql
|
||||
assert "CAST(t.ouid AS VARCHAR) = ?" in sql
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_team_competences / batch_get_team_competences – competence join shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_team_competences_sql_shape_snapshot() -> None:
|
||||
"""`get_team_competences` joins competences and COALESCEs `top_competency`."""
|
||||
|
||||
cur = _FakeCursor(rows=[])
|
||||
client = _mk_client(cur)
|
||||
|
||||
client.get_team_competences("OU1")
|
||||
|
||||
assert cur.executed, "expected the competences query to be executed"
|
||||
sql = _norm_sql(cur.executed[0][0])
|
||||
|
||||
assert "JOIN teamlandkarte_v_competences_latest" in sql
|
||||
assert "CAST(tc.competence_id AS VARCHAR) = CAST(c.id AS VARCHAR)" in sql
|
||||
assert "COALESCE(tc.top_competency, FALSE)" in sql
|
||||
|
||||
|
||||
def test_batch_get_team_competences_sql_shape_snapshot() -> None:
|
||||
"""`batch_get_team_competences` keeps the same join shape."""
|
||||
|
||||
cur = _FakeCursor(rows=[])
|
||||
client = _mk_client(cur)
|
||||
|
||||
client.batch_get_team_competences(["OU1", "OU2"])
|
||||
|
||||
assert cur.executed, "expected the batch competences query to be executed"
|
||||
sql = _norm_sql(cur.executed[0][0])
|
||||
|
||||
assert "JOIN teamlandkarte_v_competences_latest" in sql
|
||||
assert "CAST(tc.competence_id AS VARCHAR) = CAST(c.id AS VARCHAR)" in sql
|
||||
assert "COALESCE(tc.top_competency, FALSE)" in sql
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_team_references / batch_get_team_references – partner LEFT JOIN shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_team_references_sql_shape_snapshot() -> None:
|
||||
"""`get_team_references` LEFT JOINs partners and COALESCEs `p.name`."""
|
||||
|
||||
cur = _FakeCursor(rows=[])
|
||||
client = _mk_client(cur)
|
||||
|
||||
client.get_team_references("OU1")
|
||||
|
||||
assert cur.executed, "expected the references query to be executed"
|
||||
sql = _norm_sql(cur.executed[0][0])
|
||||
|
||||
assert "LEFT JOIN teamlandkarte_v_partners_latest" in sql
|
||||
assert "CAST(r.partner_id AS VARCHAR) = CAST(p.id AS VARCHAR)" in sql
|
||||
assert "COALESCE(p.name, '')" in sql
|
||||
|
||||
|
||||
def test_batch_get_team_references_sql_shape_snapshot() -> None:
|
||||
"""`batch_get_team_references` keeps the same partner LEFT JOIN shape."""
|
||||
|
||||
cur = _FakeCursor(rows=[])
|
||||
client = _mk_client(cur)
|
||||
|
||||
client.batch_get_team_references(["OU1", "OU2"])
|
||||
|
||||
assert cur.executed, "expected the batch references query to be executed"
|
||||
sql = _norm_sql(cur.executed[0][0])
|
||||
|
||||
assert "LEFT JOIN teamlandkarte_v_partners_latest" in sql
|
||||
assert "CAST(r.partner_id AS VARCHAR) = CAST(p.id AS VARCHAR)" in sql
|
||||
assert "COALESCE(p.name, '')" in sql
|
||||
@@ -0,0 +1,338 @@
|
||||
"""Unit tests for `TrinoClient.get_team_references` and
|
||||
`batch_get_team_references`.
|
||||
|
||||
Covers SQL shape (LEFT JOIN on the partners view, COALESCE for
|
||||
`partner_name`, deterministic `ORDER BY`), grouping by `ouid` in
|
||||
the batch variant, whitespace-only `projects` filtering, empty
|
||||
partner-name handling and empty-input handling.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.database.trino_client import TrinoClient
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
"""Minimal cursor double recording executes and returning canned rows."""
|
||||
|
||||
def __init__(self, rows: list[tuple] | None = None) -> None:
|
||||
self.executed: list[tuple[str, tuple[object, ...] | None]] = []
|
||||
self._rows: list[tuple] = rows or []
|
||||
|
||||
def execute(self, sql: str, params=None) -> None: # noqa: ANN001
|
||||
if params is None:
|
||||
params_tuple: tuple[object, ...] | None = None
|
||||
elif isinstance(params, tuple):
|
||||
params_tuple = params
|
||||
else:
|
||||
params_tuple = tuple(params)
|
||||
self.executed.append((sql, params_tuple))
|
||||
|
||||
def fetchone(self):
|
||||
return self._rows[0] if self._rows else None
|
||||
|
||||
def fetchall(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _FakeCursorContext:
|
||||
def __init__(self, cursor: _FakeCursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self) -> _FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
return None
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
host = "x"
|
||||
port = 1
|
||||
username = "u"
|
||||
password = "p"
|
||||
http_scheme = "http"
|
||||
verify_ssl = False
|
||||
catalog = "c"
|
||||
schema = "s"
|
||||
pool_size = 1
|
||||
|
||||
|
||||
def _mk_client(cur: _FakeCursor) -> TrinoClient:
|
||||
client = TrinoClient(_FakeConfig())
|
||||
client._cursor = lambda: _FakeCursorContext(cur) # type: ignore[method-assign]
|
||||
return client
|
||||
|
||||
|
||||
def _norm_sql(sql: str) -> str:
|
||||
return re.sub(r"\s+", " ", sql).strip()
|
||||
|
||||
|
||||
def _assert_select_only(sql: str) -> None:
|
||||
upper = sql.strip().upper()
|
||||
assert upper.startswith("SELECT"), f"expected SELECT, got: {sql!r}"
|
||||
for kw in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "TRUNCATE"):
|
||||
assert kw not in upper, f"unexpected write keyword {kw} in SQL: {sql!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Single-row variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_team_references_returns_empty_for_blank_input() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
assert c.get_team_references("") == []
|
||||
assert c.get_team_references(" ") == []
|
||||
assert not cur.executed
|
||||
|
||||
|
||||
def test_get_team_references_one_query_select_only_and_join_shape() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("Project Apollo", "Acme"),
|
||||
("Project Beta", ""),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_team_references("OU1")
|
||||
|
||||
assert len(cur.executed) == 1
|
||||
sql, params = cur.executed[0]
|
||||
sql_n = _norm_sql(sql)
|
||||
_assert_select_only(sql)
|
||||
assert "FROM teamlandkarte_v_team_references_latest r" in sql_n
|
||||
assert "LEFT JOIN teamlandkarte_v_partners_latest p" in sql_n
|
||||
assert "ON CAST(r.partner_id AS VARCHAR) = CAST(p.id AS VARCHAR)" in sql_n
|
||||
assert "WHERE CAST(r.ouid AS VARCHAR) = ?" in sql_n
|
||||
assert "COALESCE(p.name, '')" in sql_n
|
||||
assert "ORDER BY partner_name ASC, r.projects ASC" in sql_n
|
||||
assert params == ("OU1",)
|
||||
assert out == [
|
||||
{"partner_name": "Acme", "projects": "Project Apollo"},
|
||||
{"partner_name": "", "projects": "Project Beta"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_team_references_filters_whitespace_only_projects() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
(" ", "Acme"), # whitespace-only → drop
|
||||
("", "Beta"), # empty → drop
|
||||
("Project Real", "Gamma"),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_team_references("OU1")
|
||||
|
||||
assert out == [
|
||||
{"partner_name": "Gamma", "projects": "Project Real"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_team_references_trims_projects() -> None:
|
||||
cur = _FakeCursor(rows=[(" Project Apollo ", "Acme")])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_team_references("OU1")
|
||||
|
||||
assert out == [
|
||||
{"partner_name": "Acme", "projects": "Project Apollo"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_team_references_keeps_entries_without_partner_name() -> None:
|
||||
# The COALESCE is on the SQL side but the Python layer also
|
||||
# defends against `None` for `partner_name` defensively.
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("Project Solo", None),
|
||||
("Project Duo", ""),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_team_references("OU1")
|
||||
|
||||
assert out == [
|
||||
{"partner_name": "", "projects": "Project Solo"},
|
||||
{"partner_name": "", "projects": "Project Duo"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_team_references_skips_non_string_projects() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
(None, "Acme"), # non-string projects → drop
|
||||
(123, "Beta"), # non-string projects → drop
|
||||
("Project Real", "Gamma"),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.get_team_references("OU1")
|
||||
|
||||
assert out == [
|
||||
{"partner_name": "Gamma", "projects": "Project Real"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_team_references_no_rows_returns_empty_list() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
assert c.get_team_references("OU1") == []
|
||||
assert len(cur.executed) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch variant
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_batch_get_team_references_empty_input_returns_empty_no_sql() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_references([])
|
||||
|
||||
assert out == {}
|
||||
assert not cur.executed
|
||||
|
||||
|
||||
def test_batch_get_team_references_groups_and_defaults_to_empty_list() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("OU1", "Project Apollo", "Acme"),
|
||||
("OU1", "Project Beta", ""),
|
||||
("OU2", "Project Gamma", "Foo"),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_references(["OU1", "OU2", "OU3"])
|
||||
|
||||
assert len(cur.executed) == 1, "batch must use exactly one SELECT"
|
||||
sql, params = cur.executed[0]
|
||||
sql_n = _norm_sql(sql)
|
||||
_assert_select_only(sql)
|
||||
assert "FROM teamlandkarte_v_team_references_latest r" in sql_n
|
||||
assert "LEFT JOIN teamlandkarte_v_partners_latest p" in sql_n
|
||||
assert "ON CAST(r.partner_id AS VARCHAR) = CAST(p.id AS VARCHAR)" in sql_n
|
||||
assert "WHERE CAST(r.ouid AS VARCHAR) IN (?, ?, ?)" in sql_n
|
||||
assert "COALESCE(p.name, '')" in sql_n
|
||||
assert (
|
||||
"ORDER BY r.ouid ASC, partner_name ASC, r.projects ASC" in sql_n
|
||||
)
|
||||
assert params == ("OU1", "OU2", "OU3")
|
||||
assert out == {
|
||||
"OU1": [
|
||||
{"partner_name": "Acme", "projects": "Project Apollo"},
|
||||
{"partner_name": "", "projects": "Project Beta"},
|
||||
],
|
||||
"OU2": [
|
||||
{"partner_name": "Foo", "projects": "Project Gamma"},
|
||||
],
|
||||
"OU3": [],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_get_team_references_filters_whitespace_only_projects() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("OU1", " ", "Acme"), # whitespace-only → drop
|
||||
("OU1", "Project Real", "Beta"),
|
||||
("OU2", "", "Gamma"), # empty → drop
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_references(["OU1", "OU2"])
|
||||
|
||||
assert out == {
|
||||
"OU1": [{"partner_name": "Beta", "projects": "Project Real"}],
|
||||
"OU2": [],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_get_team_references_keeps_entries_without_partner_name() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
("OU1", "Project Solo", None),
|
||||
("OU1", "Project Duo", ""),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_references(["OU1"])
|
||||
|
||||
assert out == {
|
||||
"OU1": [
|
||||
{"partner_name": "", "projects": "Project Solo"},
|
||||
{"partner_name": "", "projects": "Project Duo"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_batch_get_team_references_ignores_blank_ouid_inputs() -> None:
|
||||
cur = _FakeCursor(rows=[])
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_references(["", " "])
|
||||
|
||||
assert out == {"": [], " ": []}
|
||||
assert not cur.executed
|
||||
|
||||
|
||||
def test_batch_get_team_references_skips_rows_with_null_ouid() -> None:
|
||||
cur = _FakeCursor(
|
||||
rows=[
|
||||
(None, "Project Ghost", "Acme"), # null ouid → drop
|
||||
("OU1", "Project Real", "Beta"),
|
||||
]
|
||||
)
|
||||
c = _mk_client(cur)
|
||||
|
||||
out = c.batch_get_team_references(["OU1"])
|
||||
|
||||
assert out == {
|
||||
"OU1": [{"partner_name": "Beta", "projects": "Project Real"}],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SELECT-only guard parametric coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,args,rows",
|
||||
[
|
||||
("get_team_references", ("OU1",), [("Project Apollo", "Acme")]),
|
||||
(
|
||||
"batch_get_team_references",
|
||||
(["OU1", "OU2"],),
|
||||
[("OU1", "Project Apollo", "Acme")],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_team_reference_methods_emit_select_only(
|
||||
method: str, args: tuple, rows: list[tuple]
|
||||
) -> None:
|
||||
cur = _FakeCursor(rows=rows)
|
||||
c = _mk_client(cur)
|
||||
|
||||
getattr(c, method)(*args)
|
||||
|
||||
assert cur.executed, f"{method} should have executed a query"
|
||||
assert len(cur.executed) == 1, f"{method} should issue exactly one query"
|
||||
sql, _params = cur.executed[0]
|
||||
_assert_select_only(sql)
|
||||
@@ -0,0 +1,153 @@
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from teamlandkarte_mcp.database.trino_client import TrinoClient
|
||||
|
||||
|
||||
class _FakeCursor:
|
||||
def __init__(self) -> None:
|
||||
self.executed: list[tuple[str, tuple[object, ...] | None]] = []
|
||||
|
||||
def execute(self, sql: str, params=None) -> None: # noqa: ANN001
|
||||
if params is None:
|
||||
params_tuple: tuple[object, ...] | None = None
|
||||
elif isinstance(params, tuple):
|
||||
params_tuple = params
|
||||
else:
|
||||
# Trino DB-API accepts sequences; tests only need tuple form.
|
||||
params_tuple = tuple(params)
|
||||
self.executed.append((sql, params_tuple))
|
||||
|
||||
def fetchone(self): # pragma: no cover
|
||||
return (1,)
|
||||
|
||||
def fetchall(self): # pragma: no cover
|
||||
return []
|
||||
|
||||
|
||||
class _FakeCursorContext:
|
||||
def __init__(self, cursor: _FakeCursor):
|
||||
self._cursor = cursor
|
||||
|
||||
def __enter__(self) -> _FakeCursor:
|
||||
return self._cursor
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
||||
return None
|
||||
|
||||
|
||||
class _FakeConfig:
|
||||
host = "x"
|
||||
port = 1
|
||||
username = "u"
|
||||
password = "p"
|
||||
http_scheme = "http"
|
||||
verify_ssl = False
|
||||
catalog = "c"
|
||||
schema = "s"
|
||||
pool_size = 1
|
||||
|
||||
|
||||
def _mk_client(cur: _FakeCursor) -> TrinoClient:
|
||||
client = TrinoClient(_FakeConfig())
|
||||
|
||||
# Avoid connecting in tests; ensure all methods use our fake cursor.
|
||||
client._cursor = lambda: _FakeCursorContext(cur) # type: ignore[method-assign]
|
||||
return client
|
||||
|
||||
|
||||
def _norm_sql(sql: str) -> str:
|
||||
sql = re.sub(r"\s+", " ", sql).strip()
|
||||
return sql
|
||||
|
||||
|
||||
def test_get_recent_free_capacities_query_ordering_and_limit() -> None:
|
||||
cur = _FakeCursor()
|
||||
c = _mk_client(cur)
|
||||
|
||||
c.get_recent_free_capacities(limit=7)
|
||||
|
||||
assert cur.executed, "Expected query to execute"
|
||||
sql, params = cur.executed[-1]
|
||||
sql_n = _norm_sql(sql)
|
||||
|
||||
assert "FROM teamlandkarte_v_capacities_latest" in sql_n
|
||||
assert "WHERE cap.deletion_reason IS NULL" in sql_n
|
||||
assert "ORDER BY cap.creation_date DESC" in sql_n
|
||||
assert "LIMIT ?" in sql_n
|
||||
assert params == (7,)
|
||||
|
||||
|
||||
def test_get_capacity_by_id_query_filters_and_ordering() -> None:
|
||||
cur = _FakeCursor()
|
||||
c = _mk_client(cur)
|
||||
|
||||
c.get_capacity_by_id(123)
|
||||
|
||||
assert cur.executed
|
||||
sql, params = cur.executed[-1]
|
||||
sql_n = _norm_sql(sql)
|
||||
|
||||
assert "FROM teamlandkarte_v_capacities_latest" in sql_n
|
||||
assert "LEFT JOIN teamlandkarte_v_capacity_competences_latest" in sql_n
|
||||
assert "LEFT JOIN teamlandkarte_v_competences_latest" in sql_n
|
||||
assert "cap.deletion_reason IS NULL" in sql_n
|
||||
assert "cap.id = ?" in sql_n
|
||||
assert "ORDER BY comp.name" in sql_n
|
||||
assert params == (123,)
|
||||
|
||||
|
||||
def test_get_all_role_names_query_filters_and_ordering() -> None:
|
||||
cur = _FakeCursor()
|
||||
c = _mk_client(cur)
|
||||
|
||||
c.get_all_role_names()
|
||||
|
||||
assert cur.executed
|
||||
sql, params = cur.executed[-1]
|
||||
sql_n = _norm_sql(sql)
|
||||
|
||||
assert "FROM teamlandkarte_v_capacity_roles_latest" in sql_n
|
||||
assert "WHERE active = true" in sql_n
|
||||
assert "AND staffing_board_relevant = true" in sql_n
|
||||
assert "ORDER BY name" in sql_n
|
||||
assert params is None
|
||||
|
||||
|
||||
def test_get_all_competence_names_query_filters_and_ordering() -> None:
|
||||
cur = _FakeCursor()
|
||||
c = _mk_client(cur)
|
||||
|
||||
c.get_all_competence_names()
|
||||
|
||||
assert cur.executed
|
||||
sql, params = cur.executed[-1]
|
||||
sql_n = _norm_sql(sql)
|
||||
|
||||
assert "FROM beschaffungstool_kmp_skill_latest" in sql_n
|
||||
assert "WHERE skillname__c IS NOT NULL" in sql_n
|
||||
assert "ORDER BY skillname__c" in sql_n
|
||||
assert params is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,args",
|
||||
[
|
||||
("get_recent_free_capacities", (1,)),
|
||||
("get_capacity_by_id", ("1",)),
|
||||
("get_all_role_names", ()),
|
||||
("get_all_competence_names", ()),
|
||||
],
|
||||
)
|
||||
def test_phase2_queries_are_select_only(method: str, args: tuple[object, ...]) -> None:
|
||||
cur = _FakeCursor()
|
||||
c = _mk_client(cur)
|
||||
|
||||
getattr(c, method)(*args)
|
||||
|
||||
assert cur.executed
|
||||
sql, _params = cur.executed[-1]
|
||||
assert sql.strip().upper().startswith("SELECT") or sql.strip().upper().startswith(
|
||||
"WITH"
|
||||
)
|
||||
Reference in New Issue
Block a user