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,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}"
|
||||
)
|
||||
Reference in New Issue
Block a user