# 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 ``" (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 ``- `` # 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 ``- `` 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