# 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}" )