# 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: `` and ``Projekte: ``. 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 ``- `` 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: `` and ``Projekte: ``. """ 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}" )