Files
Orchestrator/bahn/teamlandkarte-mcp/tests/test_profile_builder_pbt.py
T
ankn a5f8fb49ab 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.
2026-06-30 20:39:52 +02:00

299 lines
10 KiB
Python

# 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}"
)