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.
189 lines
7.1 KiB
Python
189 lines
7.1 KiB
Python
# 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}"
|
|
)
|