Symmetrisch zur Heuchelei-Logik: bei JA-Fraktionen, deren eigener Wahlprogramm-Score < 3 ist, erscheint ein dezenter italic '!' mit Tooltip. 11 echte Cases gefunden auf dev (NRW + BB). app/marker.py: opportunismus_score() — neun neue Tests (test_marker.py jetzt 44 grün). Refs: ADR 0010, Phase 2.4 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
238 lines
9.5 KiB
Python
238 lines
9.5 KiB
Python
"""Unit-Tests für app.marker — Heuchelei + Konsistenz."""
|
||
import pytest
|
||
|
||
try:
|
||
from app.marker import (
|
||
heuchelei_score,
|
||
opportunismus_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
|
||
|
||
|
||
# ─── 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
|
||
|
||
|
||
# ─── 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"
|