From 9498ca4b977b06376ce7f199c22b7078b4b1c7c8 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Wed, 6 May 2026 15:44:12 +0200 Subject: [PATCH] refactor + tests: marker.py + pm_render.py mit 56 Unit-Tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logik aus dem Jinja-Template (Heuchelei-Marker, Konsistenz-Block, decisive-Outcome-Selection) in app/marker.py extrahiert. Template ruft die drei Helper als Jinja-Globals auf. Damit ist die Logik testbar ohne Render-Kontext. Plus: app/pm_render.py als Python-Spiegelbild des JS-Mini-Markdown- Renderers in aktuelle-themen.html — fuer Tests und potenzielle Server-side-Render-Optionen (z.B. PM-Mail). Tests: - tests/test_marker.py (35 Cases): heuchelei_score, decisive_outcome, consistency_state inkl. Multi-Vote, ambivalente Empfehlung, Edge-Cases. - tests/test_pm_render.py (21 Cases): Bold, Italic, Listen, HTML-Escape, Paragraph-Splitting, snake_case-Schutz. Refs: ADR 0010, ADR 0011 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/main.py | 6 + app/marker.py | 103 +++++++++++ app/pm_render.py | 81 +++++++++ app/templates/v2/screens/antrag_detail.html | 33 +--- tests/test_marker.py | 192 ++++++++++++++++++++ tests/test_pm_render.py | 137 ++++++++++++++ 6 files changed, 529 insertions(+), 23 deletions(-) create mode 100644 app/marker.py create mode 100644 app/pm_render.py create mode 100644 tests/test_marker.py create mode 100644 tests/test_pm_render.py diff --git a/app/main.py b/app/main.py index 43dcbf5..b700c55 100644 --- a/app/main.py +++ b/app/main.py @@ -145,6 +145,12 @@ templates_dir.mkdir(exist_ok=True) app.mount("/static", StaticFiles(directory=static_dir), name="static") templates = Jinja2Templates(directory=str(templates_dir)) +# Detail-Marker im Jinja-Environment registrieren (siehe ADR 0010) +from .marker import heuchelei_score, decisive_outcome, consistency_state # noqa: E402 +templates.env.globals["heuchelei_score"] = heuchelei_score +templates.env.globals["decisive_outcome"] = decisive_outcome +templates.env.globals["consistency_state"] = consistency_state + # ─── Auth-Fehler bei HTML-Seiten: Redirect statt JSON-401/403 ───────────────── diff --git a/app/marker.py b/app/marker.py new file mode 100644 index 0000000..a47b7dc --- /dev/null +++ b/app/marker.py @@ -0,0 +1,103 @@ +"""Detail-Marker fuer Stimmverhalten × GWÖ. + +Zwei Funktionen, die im Antrag-Detail-Template aus Jinja heraus +aufgerufen werden: + +- ``heuchelei_score``: liefert den Wahlprogramm-Score einer Fraktion, + wenn er ≥ ``threshold`` ist und damit den ⚠-Indikator triggert. +- ``decisive_outcome``: bestimmt das erste „definitive" Outcome aus + einer Liste von Plenum-Votes (Überweisung/zurückgezogen/sammel + übersprungen, ``angenommen|abgelehnt|bestätigt`` priorisiert). +- ``consistency_state``: vergleicht GWÖ-Empfehlung mit dem definitiven + Outcome und liefert ``"conflict"``, ``"aligned"`` oder ``None``. + +Die Logik soll testbar sein, ohne Jinja zu rendern. Daher reine +Funktionen ohne Seiteneffekte. + +Siehe ADR 0010 (Stimmverhalten × GWÖ-Aggregat) für den Kontext. +""" +from __future__ import annotations + +from typing import Iterable, Optional + +DECISIVE_OUTCOMES = frozenset({"angenommen", "abgelehnt", "bestätigt"}) + + +def heuchelei_score( + fraktion: str, + fraktions_scores: Optional[Iterable[dict]], + threshold: float = 7.0, +) -> Optional[float]: + """Wahlprogramm-Score einer Fraktion, falls ≥ threshold; sonst None. + + ``fraktions_scores`` ist die Liste, die ``_row_to_detail`` ans + Template hängt — jeder Eintrag hat ``fraktion`` (str) und + ``wahlprogramm.score`` (float|int). + + Wenn die Fraktion nicht gefunden wird oder der Score unter dem + Schwellwert liegt, wird ``None`` zurückgegeben — das Template + rendert dann keinen ⚠-Marker. + """ + if not fraktions_scores: + return None + for fs in fraktions_scores: + if fs.get("fraktion") == fraktion: + wp = fs.get("wahlprogramm") or {} + score = wp.get("score") + if score is None: + return None + try: + score_f = float(score) + except (TypeError, ValueError): + return None + return score_f if score_f >= threshold else None + return None + + +def decisive_outcome(plenum_votes: Optional[Iterable[dict]]) -> Optional[str]: + """Erstes definitives Outcome aus einer Liste von Plenum-Votes. + + Bei mehreren Votes (Überweisung → Endabstimmung) wird das erste + Vote zurückgegeben, dessen ``ergebnis`` (case-insensitiv) in + ``DECISIVE_OUTCOMES`` liegt. ``None`` wenn keines vorhanden. + """ + if not plenum_votes: + return None + for v in plenum_votes: + ergebnis = (v.get("ergebnis") or "").lower() + if ergebnis in DECISIVE_OUTCOMES: + return ergebnis + return None + + +def consistency_state( + verdict_title: Optional[str], + plenum_votes: Optional[Iterable[dict]], +) -> Optional[str]: + """Vergleich GWÖ-Empfehlung × tatsächlicher Beschluss. + + Liefert: + + - ``"conflict"`` — Empfehlung „unterstützen/befürworten" und + Beschluss „abgelehnt", ODER Empfehlung „ablehnen" und Beschluss + „angenommen/bestätigt". + - ``"aligned"`` — Empfehlung und Beschluss decken sich. + - ``None`` — keine eindeutige Aussage möglich (Beschluss + „überwiesen", oder Empfehlung ambivalent wie „Mit Vorbehalt", + oder fehlende Daten). + """ + if not verdict_title or not plenum_votes: + return None + outcome = decisive_outcome(plenum_votes) + if outcome is None: + return None + text = verdict_title.lower() + rec_supports = ("unterstützen" in text) or ("befürworten" in text) + rec_rejects = "ablehnen" in text + out_passed = outcome in ("angenommen", "bestätigt") + out_failed = outcome == "abgelehnt" + if (rec_supports and out_failed) or (rec_rejects and out_passed): + return "conflict" + if (rec_supports and out_passed) or (rec_rejects and out_failed): + return "aligned" + return None diff --git a/app/pm_render.py b/app/pm_render.py new file mode 100644 index 0000000..5a92f3b --- /dev/null +++ b/app/pm_render.py @@ -0,0 +1,81 @@ +"""Mini-Markdown-Renderer für PM-Bodies — Python-Spiegelbild der +JS-Funktion ``renderPmBody`` in ``v2/screens/aktuelle-themen.html``. + +Der Renderer interpretiert eine kleine Untermenge von Markdown: + +- ``**bold**`` und ``__bold__`` → ``...`` +- ``*italic*`` und ``_italic_`` → ``...`` (vorsichtig — nur + wenn nicht zwischen Ziffern oder Word-Charakters) +- Zeilen, die mit ``- `` oder ``* `` beginnen → ```` +- Doppel-Newlines trennen Absätze → ``

...

`` +- Einzelne Newlines innerhalb eines Absatzes → ``
`` +- HTML-Special-Chars (``&``, ``<``, ``>``) werden escaped. + +Die Python-Variante existiert primär zum Testen. Im Produktiv-Frontend +wird die JS-Variante benutzt (kein Round-Trip zum Server). +""" +from __future__ import annotations + +import re + +# 1) HTML-Escape (analog zur JS-Variante, in dieser Reihenfolge) +def _html_escape(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + +# 2) Bold-Marker: **...** und __...__ +_RE_BOLD_STAR = re.compile(r"\*\*([^*\n]+?)\*\*") +_RE_BOLD_UNDER = re.compile(r"__([^_\n]+?)__") + +# 3) Italic-Marker: *…* und _…_, jeweils nicht zw. Word-Chars +_RE_ITALIC_STAR = re.compile(r"(? str: + """Render Mini-Markdown zu HTML. Leere/None-Eingabe → leerer String.""" + if not body: + return "" + s = _html_escape(body) + s = _RE_BOLD_STAR.sub(r"\1", s) + s = _RE_BOLD_UNDER.sub(r"\1", s) + s = _RE_ITALIC_STAR.sub(r"\1", s) + s = _RE_ITALIC_UNDER.sub(r"\1", s) + + # Listen: konsekutive "- "/"* "-Zeilen zu einem ") + s = "\n".join(out_lines) + + # Paragraphen-Split bei doppelten Newlines, einzelne →
+ paras = re.split(r"\n\s*\n", s) + rendered = [] + for p in paras: + trimmed = p.strip() + if not trimmed: + continue + if trimmed.startswith("<"): + rendered.append(trimmed) + else: + rendered.append( + '

' + + trimmed.replace("\n", "
") + + "

" + ) + return "\n".join(rendered) diff --git a/app/templates/v2/screens/antrag_detail.html b/app/templates/v2/screens/antrag_detail.html index 42e8d68..a94e799 100644 --- a/app/templates/v2/screens/antrag_detail.html +++ b/app/templates/v2/screens/antrag_detail.html @@ -276,27 +276,15 @@ "sammel": "#0969da", } %} {# Konsistenz-Hinweis: GWÖ-Empfehlung vs. tatsächlicher Beschluss. - Bei mehreren Votes (Überweisung → Endabstimmung) erste mit - definitivem Outcome bevorzugen. #} - {% set verdict_text = (antrag.verdict_title or '') | lower %} - {% set decisive = namespace(ergebnis=None) %} - {% for v in antrag.plenum_votes %} - {% if not decisive.ergebnis and (v.ergebnis or '') | lower in ['angenommen', 'abgelehnt', 'bestätigt'] %} - {% set decisive.ergebnis = (v.ergebnis or '') | lower %} - {% endif %} - {% endfor %} - {% set rec_supports = ('unterstützen' in verdict_text) or ('befürworten' in verdict_text) %} - {% set rec_rejects = 'ablehnen' in verdict_text %} - {% set out_passed = decisive.ergebnis in ['angenommen', 'bestätigt'] %} - {% set out_failed = decisive.ergebnis == 'abgelehnt' %} - {% set conflict = (rec_supports and out_failed) or (rec_rejects and out_passed) %} - {% set aligned = (rec_supports and out_passed) or (rec_rejects and out_failed) %} - {% if conflict or aligned %} + Logik in app/marker.py — siehe ADR 0010. #} + {% set _state = consistency_state(antrag.verdict_title, antrag.plenum_votes) %} + {% set _decisive = decisive_outcome(antrag.plenum_votes) %} + {% if _state %}
- {% if conflict %}Mehrheit kontra GWÖ-Empfehlung{% else %}Mehrheit deckt sich mit GWÖ-Empfehlung{% endif %} - — Empfohlen: {{ antrag.verdict_title }}; Beschluss: {{ decisive.ergebnis | capitalize }}. + background:{% if _state == 'conflict' %}color-mix(in srgb,#cf222e 8%,transparent){% else %}color-mix(in srgb,#2da44e 8%,transparent){% endif %}; + border-left:3px solid {% if _state == 'conflict' %}#cf222e{% else %}#2da44e{% endif %};"> + {% if _state == 'conflict' %}Mehrheit kontra GWÖ-Empfehlung{% else %}Mehrheit deckt sich mit GWÖ-Empfehlung{% endif %} + — Empfohlen: {{ antrag.verdict_title }}; Beschluss: {{ _decisive | capitalize }}.
{% endif %} {% for v in antrag.plenum_votes %} @@ -319,9 +307,8 @@ {% if v.fraktionen_nein %}
Nein: {% for f in v.fraktionen_nein %} - {% set fs_match = (antrag.fraktions_scores or []) | selectattr('fraktion', 'equalto', f) | list %} - {% set wp_score_match = fs_match[0].wahlprogramm.score | float if fs_match else 0 %} - {{ f }}{% if wp_score_match >= 7 %}{% endif %} + {% set _wp_match = heuchelei_score(f, antrag.fraktions_scores) %} + {{ f }}{% if _wp_match is not none %}{% endif %} {% endfor %}
{% endif %} diff --git a/tests/test_marker.py b/tests/test_marker.py new file mode 100644 index 0000000..5210521 --- /dev/null +++ b/tests/test_marker.py @@ -0,0 +1,192 @@ +"""Unit-Tests für app.marker — Heuchelei + Konsistenz.""" +import pytest + +try: + from app.marker import ( + heuchelei_score, + decisive_outcome, + consistency_state, + DECISIVE_OUTCOMES, + ) + _HAS_MARKER = True +except ImportError: + _HAS_MARKER = False + +pytestmark = pytest.mark.skipif(not _HAS_MARKER, reason="app.marker nicht importierbar") + + +# ─── heuchelei_score ──────────────────────────────────────────────────────── + +class TestHeucheleiScore: + """heuchelei_score liefert WP-Score wenn Fraktion NEIN-stimmt aber WP≥7.""" + + def test_score_high_returns_score(self): + fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 7.0}}] + assert heuchelei_score("CDU", fs) == 7.0 + + def test_score_at_threshold_returns_score(self): + # 7.0 ist genau Schwelle und sollte greifen + fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 7.0}}] + assert heuchelei_score("CDU", fs, threshold=7.0) == 7.0 + + def test_score_below_returns_none(self): + fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 6.9}}] + assert heuchelei_score("CDU", fs) is None + + def test_score_above_default_returns_score(self): + fs = [{"fraktion": "FDP", "wahlprogramm": {"score": 8.0}}] + assert heuchelei_score("FDP", fs) == 8.0 + + def test_unknown_fraktion_returns_none(self): + fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 9.0}}] + assert heuchelei_score("AfD", fs) is None + + def test_empty_list_returns_none(self): + assert heuchelei_score("CDU", []) is None + + def test_none_input_returns_none(self): + assert heuchelei_score("CDU", None) is None + + def test_missing_score_field_returns_none(self): + fs = [{"fraktion": "CDU", "wahlprogramm": {}}] + assert heuchelei_score("CDU", fs) is None + + def test_score_as_int_handled(self): + fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 7}}] + assert heuchelei_score("CDU", fs) == 7.0 + + def test_invalid_score_returns_none(self): + fs = [{"fraktion": "CDU", "wahlprogramm": {"score": "not-a-number"}}] + assert heuchelei_score("CDU", fs) is None + + def test_custom_threshold(self): + fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 5.0}}] + # bei threshold=4 greift's + assert heuchelei_score("CDU", fs, threshold=4.0) == 5.0 + # bei threshold=6 nicht + assert heuchelei_score("CDU", fs, threshold=6.0) is None + + def test_finds_first_match(self): + """Wenn dieselbe Fraktion mehrfach in der Liste ist, wird der erste Treffer genommen.""" + fs = [ + {"fraktion": "CDU", "wahlprogramm": {"score": 8.0}}, + {"fraktion": "CDU", "wahlprogramm": {"score": 3.0}}, + ] + assert heuchelei_score("CDU", fs) == 8.0 + + +# ─── decisive_outcome ─────────────────────────────────────────────────────── + +class TestDecisiveOutcome: + """decisive_outcome wählt erstes Vote mit angenommen|abgelehnt|bestätigt.""" + + def test_single_decisive(self): + votes = [{"ergebnis": "angenommen"}] + assert decisive_outcome(votes) == "angenommen" + + def test_single_abgelehnt(self): + votes = [{"ergebnis": "abgelehnt"}] + assert decisive_outcome(votes) == "abgelehnt" + + def test_single_bestaetigt(self): + votes = [{"ergebnis": "bestätigt"}] + assert decisive_outcome(votes) == "bestätigt" + + def test_skip_ueberwiesen_to_decisive(self): + """Bei Überweisung → angenommen sollte angenommen gewählt werden.""" + votes = [ + {"ergebnis": "überwiesen"}, + {"ergebnis": "angenommen"}, + ] + assert decisive_outcome(votes) == "angenommen" + + def test_only_ueberwiesen_returns_none(self): + votes = [{"ergebnis": "überwiesen"}] + assert decisive_outcome(votes) is None + + def test_only_zurueckgezogen_returns_none(self): + votes = [{"ergebnis": "zurückgezogen"}] + assert decisive_outcome(votes) is None + + def test_empty_list_returns_none(self): + assert decisive_outcome([]) is None + + def test_none_input_returns_none(self): + assert decisive_outcome(None) is None + + def test_first_decisive_wins(self): + """Wenn beide Votes definitiv sind, wird das erste gewählt.""" + votes = [ + {"ergebnis": "angenommen"}, + {"ergebnis": "abgelehnt"}, + ] + assert decisive_outcome(votes) == "angenommen" + + def test_case_insensitive(self): + votes = [{"ergebnis": "Angenommen"}] + assert decisive_outcome(votes) == "angenommen" + + def test_decisive_outcomes_constant(self): + """Konstante DECISIVE_OUTCOMES enthält genau die drei Werte.""" + assert DECISIVE_OUTCOMES == frozenset({"angenommen", "abgelehnt", "bestätigt"}) + + +# ─── consistency_state ────────────────────────────────────────────────────── + +class TestConsistencyState: + """consistency_state vergleicht GWÖ-Empfehlung × Beschluss.""" + + def test_supports_passed_aligned(self): + votes = [{"ergebnis": "angenommen"}] + assert consistency_state("Uneingeschränkt unterstützen", votes) == "aligned" + + def test_supports_failed_conflict(self): + votes = [{"ergebnis": "abgelehnt"}] + assert consistency_state("Uneingeschränkt unterstützen", votes) == "conflict" + + def test_rejects_failed_aligned(self): + votes = [{"ergebnis": "abgelehnt"}] + assert consistency_state("Ablehnen", votes) == "aligned" + + def test_rejects_passed_conflict(self): + votes = [{"ergebnis": "angenommen"}] + assert consistency_state("Ablehnen", votes) == "conflict" + + def test_supports_with_changes_passed_aligned(self): + """„Unterstützen mit Änderungen" enthält 'unterstützen' → rec_supports.""" + votes = [{"ergebnis": "angenommen"}] + assert consistency_state("Unterstützen mit Änderungen", votes) == "aligned" + + def test_befuerworten_synonym(self): + votes = [{"ergebnis": "angenommen"}] + assert consistency_state("Befürworten", votes) == "aligned" + + def test_ambivalent_returns_none(self): + """„Mit Vorbehalt" enthält weder 'unterstützen' noch 'ablehnen'.""" + votes = [{"ergebnis": "angenommen"}] + assert consistency_state("Mit Vorbehalt", votes) is None + + def test_ueberwiesen_returns_none(self): + votes = [{"ergebnis": "überwiesen"}] + assert consistency_state("Uneingeschränkt unterstützen", votes) is None + + def test_no_votes_returns_none(self): + assert consistency_state("Uneingeschränkt unterstützen", []) is None + + def test_no_verdict_returns_none(self): + votes = [{"ergebnis": "angenommen"}] + assert consistency_state("", votes) is None + assert consistency_state(None, votes) is None + + def test_multivote_picks_decisive(self): + """Bei Überweisung → Endabstimmung wird die Endabstimmung gewertet.""" + votes = [ + {"ergebnis": "überwiesen"}, + {"ergebnis": "abgelehnt"}, + ] + assert consistency_state("Uneingeschränkt unterstützen", votes) == "conflict" + + def test_bestaetigt_treated_as_passed(self): + votes = [{"ergebnis": "bestätigt"}] + assert consistency_state("Uneingeschränkt unterstützen", votes) == "aligned" + assert consistency_state("Ablehnen", votes) == "conflict" diff --git a/tests/test_pm_render.py b/tests/test_pm_render.py new file mode 100644 index 0000000..800a5b2 --- /dev/null +++ b/tests/test_pm_render.py @@ -0,0 +1,137 @@ +"""Tests für app.pm_render — Python-Spiegelbild des JS-Mini-Markdown-Renderers.""" +import pytest + +try: + from app.pm_render import render_pm_body + _HAS_RENDER = True +except ImportError: + _HAS_RENDER = False + +pytestmark = pytest.mark.skipif(not _HAS_RENDER, reason="app.pm_render nicht importierbar") + + +class TestEmptyAndNone: + def test_empty_returns_empty(self): + assert render_pm_body("") == "" + + def test_none_returns_empty(self): + assert render_pm_body(None) == "" + + +class TestPlainParagraph: + def test_single_paragraph(self): + out = render_pm_body("Ein einzelner Satz.") + assert out == '

Ein einzelner Satz.

' + + def test_two_paragraphs(self): + out = render_pm_body("Erster Satz.\n\nZweiter Satz.") + assert "Erster Satz" in out + assert "Zweiter Satz" in out + assert out.count("

Zeile B" in out + + +class TestHtmlEscape: + def test_lt_gt_escaped(self): + out = render_pm_body("Vergleich a < b > c.") + assert "<" in out and ">" in out + assert "" not in out # nichts wird zu echten Tags + + def test_amp_escaped(self): + out = render_pm_body("Tom & Jerry") + assert "&" in out + + +class TestBold: + def test_double_star_to_strong(self): + out = render_pm_body("Wert: **30 %**") + assert "30 %" in out + + def test_double_underscore_to_strong(self): + out = render_pm_body("Wert: __wichtig__") + assert "wichtig" in out + + def test_no_bold_across_newline(self): + """**...** mit Newline dazwischen wird nicht gemarkt.""" + out = render_pm_body("**erste\nZeile**") + assert "" not in out + + +class TestItalic: + def test_single_star_to_em(self): + out = render_pm_body("Das ist *betont*.") + assert "betont" in out + + def test_single_underscore_to_em(self): + out = render_pm_body("Das ist _wichtig_.") + assert "wichtig" in out + + def test_underscore_in_word_not_em(self): + """snake_case darf nicht als em gerendert werden.""" + out = render_pm_body("Variable my_var hier.") + assert "" not in out + + def test_star_between_words_not_em(self): + """Ein * zwischen Wort-Charaktern ist kein em-Marker.""" + out = render_pm_body("Datei: foo*bar.txt") + assert "" not in out + + +class TestBoldAndItalicCombined: + def test_bold_first_then_italic(self): + out = render_pm_body("**fett** und *kursiv*.") + assert "fett" in out + assert "kursiv" in out + + +class TestLists: + def test_simple_dash_list(self): + out = render_pm_body("- Item 1\n- Item 2") + assert "Item 1" in out + assert "

  • Item 2
  • " in out + + def test_simple_star_list(self): + out = render_pm_body("* A\n* B") + assert "A" in out + assert "
  • B
  • " in out + + def test_list_with_paragraph_around(self): + body = "Vor der Liste.\n\n- Item 1\n- Item 2\n\nNach der Liste." + out = render_pm_body(body) + assert "Item 1" in out + + def test_dash_in_word_not_list(self): + """Ein Bindestrich mitten im Wort ist kein Bullet.""" + out = render_pm_body("Halb-leer ist halb-voll.") + assert "30 %
    " in out + # Keine rohen ** mehr + assert "**" not in out + + def test_no_bold_when_unmatched(self): + body = "Ein *einfacher Stern." + out = render_pm_body(body) + # Unmatched * darf nicht als em gerendert werden + assert "" not in out