# Feature: llm-fulltext-matching, Property 2: Leere/None-Felder verwerfen das Profil nicht """Property-based tests for empty/None handling in the profile builders. These tests cover the profile builder helpers ``build_capacity_profile``, ``build_task_profile`` and ``build_task_profile_from_requirements`` defined in ``teamlandkarte_mcp.matching.profiles``. The key invariant under test is that the builders **never discard a profile**: empty strings, ``None`` values and empty lists in the input must be mapped to empty strings or empty lists in the output, and the deterministic serializers must still include every field heading. In particular, references with an empty ``partner_name`` (NULL ``partner_id`` or join mismatch) are kept in the resulting :class:`CapacityProfile` and only their partner token is suppressed in the serialized output. **Validates: Requirements 3.2, 3.4, 4.2** """ from __future__ import annotations from datetime import datetime from hypothesis import given, settings from hypothesis import strategies as st from teamlandkarte_mcp.matching.profiles import ( CapacityProfile, CapacityReferenceEntry, TaskProfile, build_capacity_profile, build_task_profile, build_task_profile_from_requirements, serialize_capacity_profile, serialize_task_profile, ) from teamlandkarte_mcp.models import Capacity, Requirements, Task # --------------------------------------------------------------------------- # Strategies # --------------------------------------------------------------------------- # Optional text fields (model fields typed ``Optional[str]``): either # ``None`` or a short text. The builders must coerce ``None`` and # whitespace-only inputs to empty strings. _maybe_text = st.one_of(st.none(), st.text(max_size=30)) # Optional list-of-strings inputs (e.g. extra description, certificates). # The builders must coerce ``None`` to an empty list. _maybe_text_list = st.one_of( st.none(), st.lists(st.text(max_size=20), max_size=5) ) # Partner name on DB rows: either empty (NULL ``partner_id`` or join # mismatch, see requirement 2.8) or a short non-empty string. _maybe_partner = st.one_of( st.just(""), st.text(min_size=1, max_size=20) ) # A single ``CapacityReferenceRow`` (TypedDict) as returned by the # ``DBClient``. ``projects`` is constrained to non-whitespace text so the # builder never drops the row defensively; this lets us assert the # stronger invariant "input row count == output entry count". _ref_dict = st.fixed_dictionaries( { "partner_name": _maybe_partner, "projects": st.text(min_size=1, max_size=30).filter( lambda s: s.strip() != "" ), } ) _maybe_ref_list = st.one_of(st.none(), st.lists(_ref_dict, max_size=5)) # ``Capacity`` instances with empty/None-heavy fields. ``begin_date`` and # ``end_date`` are pinned to ``None`` to keep the strategy fast and # deterministic; the builder ignores them anyway. _capacity = st.builds( Capacity, id=st.integers(min_value=0, max_value=10**9), owner_name=st.one_of(st.just(""), st.text(max_size=30)), role_name=_maybe_text, role_level=_maybe_text, begin_date=st.none(), end_date=st.none(), competences=st.one_of( st.just([]), st.lists(st.text(max_size=20), max_size=5) ), ) # ``Task`` instances with empty/None-heavy fields. ``created_date`` is # pinned to a fixed value (the dataclass requires a ``datetime``). _task = st.builds( Task, id=st.text(min_size=1, max_size=20), name=_maybe_text, title=st.one_of(st.just(""), st.text(max_size=30)), description=st.one_of(st.just(""), st.text(max_size=30)), start_date=st.none(), end_date=st.none(), created_date=st.just(datetime(2024, 1, 1)), skills=st.one_of( st.just([]), st.lists(st.text(max_size=20), max_size=5) ), ) # ``Requirements`` instances with empty/None-heavy fields. _requirements = st.builds( Requirements, role_name=_maybe_text, competences=st.one_of( st.just([]), st.lists(st.text(max_size=20), max_size=5) ), date_start=st.none(), date_end=st.none(), description=_maybe_text, ) _CAPACITY_HEADINGS = ( "Rolle:", "Kompetenzen:", "Beschreibung:", "Referenzen:", "Zertifikate:", ) _TASK_HEADINGS = ( "Titel:", "Beschreibung:", "Gesuchte Kompetenzen:", ) # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @settings(max_examples=100, deadline=None) @given( capacity=_capacity, description=_maybe_text, certificates=_maybe_text_list, references=_maybe_ref_list, ) def test_build_capacity_profile_handles_empty_or_none_fields( capacity: Capacity, description: str | None, certificates: list[str] | None, references: list[dict] | None, ) -> None: """Property 2 (Capacity): empty/None inputs never discard the profile. For any combination of ``None``/empty/whitespace inputs (including references with empty ``partner_name``), ``build_capacity_profile`` must return a fully populated :class:`CapacityProfile` whose string fields are real strings (never ``None``) and whose list fields are real lists (never ``None``). References with non-whitespace ``projects`` are kept regardless of the partner name, and the deterministic serialization still contains every field heading. """ profile = build_capacity_profile( capacity, description=description, certificates=certificates, references=references, ) # Profile is never discarded. assert isinstance(profile, CapacityProfile) # ``description`` is always a string. ``None`` and whitespace-only # inputs are mapped to the empty string. assert isinstance(profile.description, str) if description is None or not description.strip(): assert profile.description == "" # All list-typed fields are real lists (never ``None``). assert isinstance(profile.competences, list) assert isinstance(profile.references, list) assert isinstance(profile.certificates, list) # Every reference entry is a ``CapacityReferenceEntry``; references # with an empty ``partner_name`` are kept. for entry in profile.references: assert isinstance(entry, CapacityReferenceEntry) assert isinstance(entry.partner_name, str) assert isinstance(entry.projects, str) # The strategy guarantees non-whitespace ``projects``, so the # builder must keep every input row regardless of partner name. expected_count = len(references) if references else 0 assert len(profile.references) == expected_count # The deterministic serialization still contains every heading, # even when all input fields were empty/None. serialized = serialize_capacity_profile(profile) for heading in _CAPACITY_HEADINGS: assert heading in serialized, ( f"Heading {heading!r} missing from serialization: " f"{serialized!r}" ) @settings(max_examples=100, deadline=None) @given(task=_task) def test_build_task_profile_handles_empty_or_none_fields( task: Task, ) -> None: """Property 2 (Task): empty/None inputs never discard the profile. For any :class:`Task` with empty/None-heavy fields, ``build_task_profile`` must return a fully populated :class:`TaskProfile` whose string fields are real strings (never ``None``) and whose ``skills`` list is a real list. The deterministic serialization still contains every field heading. """ profile = build_task_profile(task) # Profile is never discarded. assert isinstance(profile, TaskProfile) # All string fields are real strings (never ``None``). assert isinstance(profile.id, str) assert isinstance(profile.title, str) assert isinstance(profile.description, str) # ``skills`` is always a real list. assert isinstance(profile.skills, list) for skill in profile.skills: assert isinstance(skill, str) # Empty inputs are mirrored as empty strings on the output. if not task.title: assert profile.title == "" if not task.description: assert profile.description == "" # The deterministic serialization still contains every heading. serialized = serialize_task_profile(profile) for heading in _TASK_HEADINGS: assert heading in serialized, ( f"Heading {heading!r} missing from serialization: " f"{serialized!r}" ) @settings(max_examples=100, deadline=None) @given(requirements=_requirements) def test_build_task_profile_from_requirements_handles_empty_or_none_fields( requirements: Requirements, ) -> None: """Property 2 (Requirements): empty/None inputs never discard profile. For any :class:`Requirements` with empty/None-heavy fields, ``build_task_profile_from_requirements`` must return a fully populated :class:`TaskProfile` whose string fields are real strings (never ``None``) and whose ``skills`` list is a real list. The deterministic serialization still contains every field heading. """ profile = build_task_profile_from_requirements(requirements) # Profile is never discarded. assert isinstance(profile, TaskProfile) # All string fields are real strings (never ``None``). assert isinstance(profile.id, str) assert isinstance(profile.title, str) assert isinstance(profile.description, str) # ``skills`` is always a real list. assert isinstance(profile.skills, list) for skill in profile.skills: assert isinstance(skill, str) # ``None`` inputs from the requirements are mirrored as empty # strings/lists on the output. if requirements.role_name is None: assert profile.title == "" if requirements.description is None: assert profile.description == "" if not requirements.competences: assert profile.skills == [] # The deterministic serialization still contains every heading. serialized = serialize_task_profile(profile) for heading in _TASK_HEADINGS: assert heading in serialized, ( f"Heading {heading!r} missing from serialization: " f"{serialized!r}" )