refactor + tests: marker.py + pm_render.py mit 56 Unit-Tests
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) <noreply@anthropic.com>
2026-05-06 15:44:12 +02:00
|
|
|
|
"""Unit-Tests für app.marker — Heuchelei + Konsistenz."""
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from app.marker import (
|
|
|
|
|
|
heuchelei_score,
|
2026-05-06 15:48:06 +02:00
|
|
|
|
opportunismus_score,
|
refactor + tests: marker.py + pm_render.py mit 56 Unit-Tests
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) <noreply@anthropic.com>
2026-05-06 15:44:12 +02:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-06 15:48:06 +02:00
|
|
|
|
# ─── opportunismus_score ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
class TestOpportunismusScore:
|
|
|
|
|
|
"""opportunismus_score liefert WP-Score wenn Fraktion JA-stimmt aber WP<3."""
|
|
|
|
|
|
|
|
|
|
|
|
def test_score_low_returns_score(self):
|
|
|
|
|
|
fs = [{"fraktion": "AfD", "wahlprogramm": {"score": 1.0}}]
|
|
|
|
|
|
assert opportunismus_score("AfD", fs) == 1.0
|
|
|
|
|
|
|
|
|
|
|
|
def test_score_zero_returns_score(self):
|
|
|
|
|
|
fs = [{"fraktion": "GRÜNE", "wahlprogramm": {"score": 0.0}}]
|
|
|
|
|
|
assert opportunismus_score("GRÜNE", fs) == 0.0
|
|
|
|
|
|
|
|
|
|
|
|
def test_score_at_threshold_returns_none(self):
|
|
|
|
|
|
# 3.0 ist genau Schwelle, sollte NICHT greifen (wir wollen < 3)
|
|
|
|
|
|
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 3.0}}]
|
|
|
|
|
|
assert opportunismus_score("CDU", fs) is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_score_above_returns_none(self):
|
|
|
|
|
|
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 5.0}}]
|
|
|
|
|
|
assert opportunismus_score("CDU", fs) is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_unknown_fraktion_returns_none(self):
|
|
|
|
|
|
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 1.0}}]
|
|
|
|
|
|
assert opportunismus_score("AfD", fs) is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_empty_list_returns_none(self):
|
|
|
|
|
|
assert opportunismus_score("CDU", []) is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_none_input_returns_none(self):
|
|
|
|
|
|
assert opportunismus_score("CDU", None) is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_missing_score_returns_none(self):
|
|
|
|
|
|
fs = [{"fraktion": "CDU", "wahlprogramm": {}}]
|
|
|
|
|
|
assert opportunismus_score("CDU", fs) is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_custom_threshold(self):
|
|
|
|
|
|
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 4.0}}]
|
|
|
|
|
|
# threshold=5 → 4 < 5 → opportunism greift
|
|
|
|
|
|
assert opportunismus_score("CDU", fs, threshold=5.0) == 4.0
|
|
|
|
|
|
# threshold=3 → 4 ≥ 3 → kein opportunism
|
|
|
|
|
|
assert opportunismus_score("CDU", fs, threshold=3.0) is None
|
|
|
|
|
|
|
|
|
|
|
|
|
refactor + tests: marker.py + pm_render.py mit 56 Unit-Tests
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) <noreply@anthropic.com>
2026-05-06 15:44:12 +02:00
|
|
|
|
# ─── 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"
|