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.
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
# 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 ``"<name> (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 ``- <line>``
|
||||
# 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 ``- <name>`` 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
|
||||
Reference in New Issue
Block a user