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.
299 lines
10 KiB
Python
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}"
|
|
)
|