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>
193 lines
7.5 KiB
Python
193 lines
7.5 KiB
Python
"""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"
|