gwoe-antragspruefer/tests/test_auswertungen_stimmverhalten.py
Dotty Dotter 5eabe0d9b3 feat: Stimmverhalten × Gemeinwohl-Orientierung in /auswertungen
Neue Auswertungs-Sicht: Welche Fraktionen stimmen häufiger gemeinwohl-
orientierten Anträgen zu? Verschneidet GWÖ-Bewertung pro Antrag mit
dem tatsächlichen Plenum-Stimmverhalten der Fraktionen.

Vier Aussagen, alle hinter dem neuen Tab "Stimmverhalten":

1. **Gemeinwohl-Stimm-Index** pro Fraktion: Ø-GWÖ-Score der JA-Anträge
   minus Ø-GWÖ-Score der NEIN-Anträge. Domain −10..+10. Positiv = stimmt
   eher Gemeinwohl-affinen Anträgen zu.

2. **Heuchelei-Quote** pro Fraktion: Anteil der Anträge mit
   wahlprogramm_score ≥ 7 (passt zum eigenen Wahlprogramm), bei denen
   die Fraktion trotzdem NEIN gestimmt hat.

3. **Stimm-Index pro GWÖ-Wert** als Heatmap: 5 Spalten (Würde,
   Solidarität, Nachhaltigkeit, Gerechtigkeit, Demokratie) aus den
   gwoe_matrix-Suffix-Spalten. Domain −5..+5 pro Zelle.

4. **Cross-BL-Vergleich** als Grouped Bar: gleiche Fraktion in
   mehreren Ländern. Nur Fraktionen in ≥2 BL mit ausreichender
   Datenbasis.

Querschnitt:
- `exclude_antragsteller=True` per Default (Toggle-Checkbox in UI),
  weil Antragsteller-Fraktionen quasi immer JA stimmen → würde Index
  verzerren. Toggle macht den Effekt sichtbar.
- `min_n=5` pro Fraktion fuer Stimm-Index, n=3 fuer Heatmaps.
  Fraktionen unter dem Cutoff werden als "Nicht aussagekräftig" separat
  gelistet.
- Caveat-Banner mit `n_assessments_matched` über jedem Chart.

Implementation:
- `app/auswertungen.py`: `_load_assessments_with_votes()` JOIN-Helper
  + 4 Aggregat-Funktionen analog zu `aggregate_matrix`-Pattern.
  Reuse: `normalize_partei` für Aliasing (BÜNDNIS 90/DIE GRÜNEN →
  GRÜNE), `wahlperiode_for` für WP-Filter.
- `app/main.py`: 4 neue read-only GET-Endpoints unter
  `/api/auswertungen/stimm-index|heuchelei|stimm-index-pro-wert|
  stimm-index-cross-bl`.
- `app/templates/v2/screens/auswertungen.html`: 4. Tab "Stimmverhalten"
  mit 4 Sub-Sektionen, Chart.js Bars + HTML-Heatmap-Tabelle.
- `tests/test_auswertungen_stimmverhalten.py`: 18 neue Tests
  (Fixture-DB mit 13 Assessments + 13 Vote-Results, Edge-Cases:
  GRÜNE-positiver-Index, AfD-negativer-Index, exclude_antragsteller-
  Effekt, min_n-Cutoff, leere DB).

Sparse-Data-Realität: aktuell 35 Assessments im prod, dünne Datenbasis
fuer einige Fraktionen. Feature wächst mit Issue #44 Batch-Bewertung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 15:30:02 +02:00

402 lines
19 KiB
Python

"""Tests fuer app.auswertungen.aggregate_stimm_index/heuchelei/pro_wert/cross_bl.
Verifiziert die JOIN-Aggregations-Logik gegen eine in-memory SQLite-DB
mit kontrollierten Sample-Assessments und Sample-Vote-Results.
"""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime
from pathlib import Path
import pytest
from app.auswertungen import (
aggregate_heuchelei,
aggregate_stimm_index,
aggregate_stimm_index_cross_bl,
aggregate_stimm_index_pro_wert,
_wert_score_for_assessment,
)
# ─────────────────────────────────────────────────────────────────────────────
# Fixture: assessments + plenum_vote_results
# ─────────────────────────────────────────────────────────────────────────────
def _matrix(*ratings: tuple[str, int]) -> str:
"""Build a gwoe_matrix JSON-Array. Args: (field, rating) tuples."""
return json.dumps([
{"field": f, "label": f, "aspect": f[-1], "rating": r,
"symbol": "++" if r >= 4 else ("+" if r >= 1 else "")}
for f, r in ratings
])
def _wp_scores(*items: tuple[str, int, bool]) -> str:
"""Build a wahlprogramm_scores JSON-Array. Args: (fraktion, score, is_antrag)."""
return json.dumps([
{
"fraktion": fr,
"ist_antragsteller": ist,
"wahlprogramm": {"score": sc, "begründung": "stub"},
}
for fr, sc, ist in items
])
@pytest.fixture
def sample_db(tmp_path: Path) -> Path:
"""Lege eine Mini-DB mit assessments + plenum_vote_results an, die typische
Faelle abdeckt:
- NRW Antrag mit hohem GWOe-Score, GRÜNE/SPD ja, CDU/AfD nein
- NRW Antrag mit niedrigem GWOe-Score, AfD ja, GRÜNE nein (Anti-Pattern)
- NRW Antrag mit hohem Score, CDU Antragsteller (sollte bei Default
excluded werden)
- MV Anträge fuer Cross-BL-Test
- Heuchelei-Sample: GRÜNE-Antrag mit GRÜNE-wahlprogramm_score=9 aber
GRÜNE stimmt NEIN (synthetisch konstruiert)
"""
db = tmp_path / "test_stimmverhalten.db"
conn = sqlite3.connect(str(db))
conn.execute("""
CREATE TABLE assessments (
drucksache TEXT PRIMARY KEY,
title TEXT,
fraktionen TEXT,
datum TEXT,
bundesland TEXT,
gwoe_score REAL,
link TEXT,
gwoe_begruendung TEXT,
gwoe_matrix TEXT,
gwoe_schwerpunkt TEXT,
wahlprogramm_scores TEXT,
verbesserungen TEXT,
staerken TEXT,
schwaechen TEXT,
empfehlung TEXT,
empfehlung_symbol TEXT,
verbesserungspotenzial TEXT,
themen TEXT,
antrag_zusammenfassung TEXT,
antrag_kernpunkte TEXT,
source TEXT,
model TEXT,
created_at TEXT,
updated_at TEXT
)
""")
conn.execute("""
CREATE TABLE plenum_vote_results (
bundesland TEXT NOT NULL,
drucksache TEXT NOT NULL,
ergebnis TEXT NOT NULL,
einstimmig INTEGER NOT NULL DEFAULT 0,
fraktionen_ja TEXT NOT NULL DEFAULT '[]',
fraktionen_nein TEXT NOT NULL DEFAULT '[]',
fraktionen_enthaltung TEXT NOT NULL DEFAULT '[]',
quelle_protokoll TEXT NOT NULL,
quelle_url TEXT,
parsed_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (bundesland, drucksache, quelle_protokoll)
)
""")
# ─── Assessments ───
now = datetime.utcnow().isoformat()
assessments = [
# NRW WP18
("18/A", "NRW", "2024-01-15", '["GRÜNE"]', 8.5,
_matrix(("A1", 4), ("B2", 3), ("C3", 5), ("D4", 4), ("E5", 5)),
_wp_scores(("GRÜNE", 9, True), ("CDU", 3, False),
("SPD", 7, False), ("AfD", 1, False))),
("18/B", "NRW", "2024-02-15", '["GRÜNE"]', 7.5,
_matrix(("A2", 3), ("C3", 4), ("D4", 3), ("E5", 4)),
_wp_scores(("GRÜNE", 8, True), ("CDU", 2, False),
("SPD", 6, False), ("AfD", 1, False))),
("18/C", "NRW", "2024-03-15", '["GRÜNE"]', 9.0,
_matrix(("A3", 5), ("D4", 4), ("E5", 5)),
_wp_scores(("GRÜNE", 9, True), ("CDU", 3, False),
("SPD", 7, False), ("AfD", 1, False))),
("18/D", "NRW", "2024-04-15", '["GRÜNE"]', 7.0,
_matrix(("B2", 3), ("C2", 4), ("D2", 3)),
_wp_scores(("GRÜNE", 8, True), ("CDU", 4, False),
("SPD", 7, False), ("AfD", 2, False))),
("18/E", "NRW", "2024-05-15", '["GRÜNE"]', 8.0,
_matrix(("A1", 4), ("B1", 3), ("E5", 4)),
_wp_scores(("GRÜNE", 9, True), ("CDU", 3, False),
("SPD", 7, False), ("AfD", 1, False))),
# AfD-Antrag mit niedrigem Score (Anti-Pattern)
("18/F", "NRW", "2024-06-15", '["AfD"]', 2.0,
_matrix(("A1", -3), ("B2", -2), ("E5", -4)),
_wp_scores(("AfD", 8, True), ("GRÜNE", 1, False),
("CDU", 4, False), ("SPD", 2, False))),
("18/G", "NRW", "2024-07-15", '["AfD"]', 1.5,
_matrix(("A1", -4), ("E5", -5)),
_wp_scores(("AfD", 9, True), ("GRÜNE", 1, False),
("CDU", 3, False), ("SPD", 1, False))),
("18/H", "NRW", "2024-08-15", '["CDU"]', 5.0,
_matrix(("D4", 1), ("D3", 0)),
_wp_scores(("CDU", 7, True), ("GRÜNE", 4, False),
("SPD", 5, False), ("AfD", 3, False))),
("18/I", "NRW", "2024-09-15", '["SPD"]', 6.5,
_matrix(("B2", 3), ("D2", 2)),
_wp_scores(("SPD", 8, True), ("GRÜNE", 6, False),
("CDU", 4, False), ("AfD", 1, False))),
("18/J", "NRW", "2024-10-15", '["SPD"]', 4.0,
_matrix(("D4", 1), ("E5", 0)),
_wp_scores(("SPD", 5, True), ("GRÜNE", 3, False),
("CDU", 5, False), ("AfD", 2, False))),
# MV WP8 fuer Cross-BL
("8/A", "MV", "2024-04-01", '["GRÜNE"]', 7.0,
_matrix(("A1", 3), ("D4", 4)),
_wp_scores(("GRÜNE", 8, True), ("CDU", 4, False),
("SPD", 6, False), ("AfD", 2, False))),
("8/B", "MV", "2024-05-01", '["GRÜNE"]', 8.0,
_matrix(("B2", 4), ("D4", 5)),
_wp_scores(("GRÜNE", 9, True), ("CDU", 3, False),
("SPD", 7, False), ("AfD", 1, False))),
("8/C", "MV", "2024-06-01", '["AfD"]', 2.0,
_matrix(("A1", -3), ("E5", -4)),
_wp_scores(("AfD", 9, True), ("GRÜNE", 1, False),
("CDU", 3, False), ("SPD", 2, False))),
]
for ds, bl, dat, fr, sc, mat, wps in assessments:
conn.execute(
"INSERT INTO assessments (drucksache, title, fraktionen, datum, "
"bundesland, gwoe_score, gwoe_matrix, wahlprogramm_scores, "
"source, model, created_at, updated_at) VALUES "
"(?, ?, ?, ?, ?, ?, ?, ?, 'test', 'test', ?, ?)",
(ds, f"Test {ds}", fr, dat, bl, sc, mat, wps, now, now),
)
# ─── Vote-Results ───
# Jeder NRW-Hoch-Antrag (A-E,I): GRÜNE+SPD ja, CDU+AfD nein
# AfD-Antrag F,G: AfD ja, GRÜNE+SPD+CDU nein
# CDU-Antrag H: CDU ja, GRÜNE+SPD+AfD nein
# SPD-Antrag J: SPD ja, GRÜNE+CDU+AfD nein
# MV-Hoch-Anträge: GRÜNE+SPD ja, CDU+AfD nein
# MV-AfD-Antrag: AfD ja, andere nein
votes = [
("NRW", "18/A", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "MMP18-A"),
("NRW", "18/B", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "MMP18-B"),
("NRW", "18/C", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "MMP18-C"),
("NRW", "18/D", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "MMP18-D"),
("NRW", "18/E", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "MMP18-E"),
("NRW", "18/F", "abgelehnt", '["AfD"]', '["GRÜNE","SPD","CDU"]', "MMP18-F"),
("NRW", "18/G", "abgelehnt", '["AfD"]', '["GRÜNE","SPD","CDU"]', "MMP18-G"),
("NRW", "18/H", "angenommen", '["CDU","AfD"]', '["GRÜNE","SPD"]', "MMP18-H"),
("NRW", "18/I", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "MMP18-I"),
("NRW", "18/J", "abgelehnt", '["SPD"]', '["GRÜNE","CDU","AfD"]', "MMP18-J"),
("MV", "8/A", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "PlPr8-A"),
("MV", "8/B", "angenommen", '["GRÜNE","SPD"]', '["CDU","AfD"]', "PlPr8-B"),
("MV", "8/C", "abgelehnt", '["AfD"]', '["GRÜNE","SPD","CDU"]', "PlPr8-C"),
]
for bl, ds, erg, ja, nein, prot in votes:
conn.execute(
"INSERT INTO plenum_vote_results "
"(bundesland, drucksache, ergebnis, fraktionen_ja, fraktionen_nein, "
" quelle_protokoll) VALUES (?, ?, ?, ?, ?, ?)",
(bl, ds, erg, ja, nein, prot),
)
conn.commit()
conn.close()
return db
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_stimm_index
# ─────────────────────────────────────────────────────────────────────────────
class TestAggregateStimmIndex:
def test_grüne_high_index(self, sample_db):
"""GRÜNE: stimmt JA bei hohen GWÖ-Anträgen, NEIN bei niedrigen → hoher Index."""
out = aggregate_stimm_index(db_path=sample_db, min_n=2)
gruene = next(f for f in out["fraktionen"] if f["partei"] == "GRÜNE")
# JA: 18/A,B,D,E,I + 8/A,B (NRW+MV high score). Antragsteller-exclude
# entfernt 18/A-E + 8/A,B fuer GRÜNE → bleibt 18/I als JA. NEIN: 18/H + 18/J + 18/F + 18/G + 8/C.
assert gruene["n_ja"] >= 1
assert gruene["n_nein"] >= 4
# Wenn beide N>0: stimm_index sollte positiv sein
if gruene["stimm_index"] is not None:
assert gruene["stimm_index"] > 0
def test_afd_low_index(self, sample_db):
"""AfD: stimmt NEIN bei hohen GWÖ-Anträgen, JA bei niedrigen → negativer Index."""
out = aggregate_stimm_index(db_path=sample_db, min_n=2)
afd = next(f for f in out["fraktionen"] if f["partei"] == "AfD")
# AfD ist Antragsteller bei 18/F,G,8/C → diese werden ausgeschlossen.
# Bleibt: NEIN bei 18/A-E, 18/I, 18/J, 8/A, 8/B. AfD JA bei 18/H (CDU-Antrag).
assert afd["n_nein"] >= 4
# Wenn JA und NEIN beide gefuellt: Index negativ
if afd["stimm_index"] is not None:
assert afd["stimm_index"] < 0
def test_exclude_antragsteller_default(self, sample_db):
"""Default schliesst eigene Antraege aus."""
with_excl = aggregate_stimm_index(
db_path=sample_db, exclude_antragsteller=True, min_n=2,
)
without_excl = aggregate_stimm_index(
db_path=sample_db, exclude_antragsteller=False, min_n=2,
)
# GRÜNE hat 7 eigene Anträge → ohne Exclude mehr n_ja
gr_with = next(f for f in with_excl["fraktionen"] if f["partei"] == "GRÜNE")
gr_without = next(f for f in without_excl["fraktionen"] if f["partei"] == "GRÜNE")
assert gr_without["n_ja"] > gr_with["n_ja"]
def test_filter_by_bundesland(self, sample_db):
nrw = aggregate_stimm_index(db_path=sample_db, filter_bl="NRW", min_n=2)
assert nrw["n_assessments_matched"] == 10
mv = aggregate_stimm_index(db_path=sample_db, filter_bl="MV", min_n=2)
assert mv["n_assessments_matched"] == 3
def test_min_n_threshold(self, sample_db):
"""Fraktion mit n_ja=2, min_n=5 → ausreichend=False."""
out = aggregate_stimm_index(db_path=sample_db, min_n=5)
for f in out["fraktionen"]:
if f["n_ja"] < 5 or f["n_nein"] < 5:
assert f["ausreichend"] is False
def test_empty_db(self, tmp_path):
out = aggregate_stimm_index(db_path=tmp_path / "missing.db")
assert out["n_assessments_matched"] == 0
assert out["fraktionen"] == []
def test_sorted_by_index_desc(self, sample_db):
"""Output sorted by stimm_index descending — None at end."""
out = aggregate_stimm_index(db_path=sample_db, min_n=1)
indices = [f["stimm_index"] for f in out["fraktionen"]]
# All not-None values should be in descending order
not_none = [v for v in indices if v is not None]
assert not_none == sorted(not_none, reverse=True)
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_heuchelei
# ─────────────────────────────────────────────────────────────────────────────
class TestAggregateHeuchelei:
def test_returns_structure(self, sample_db):
out = aggregate_heuchelei(db_path=sample_db, min_n=1)
assert "fraktionen" in out
assert "n_assessments_matched" in out
def test_heuchelei_quote_calculation(self, sample_db):
out = aggregate_heuchelei(
db_path=sample_db, score_threshold=7.0, min_n=1,
)
# SPD: wahlprogramm_score>=7 in 18/A-E (5x). SPD stimmt JA in allen
# → heuchelei_quote = 0/5 = 0.
spd = next(f for f in out["fraktionen"] if f["partei"] == "SPD")
assert spd["n_im_programm"] >= 5
assert spd["heuchelei_quote"] == 0 or spd["heuchelei_quote"] is None
def test_threshold_filter(self, sample_db):
"""Hoher Threshold reduziert n_im_programm."""
low = aggregate_heuchelei(db_path=sample_db, score_threshold=1.0, min_n=1)
high = aggregate_heuchelei(db_path=sample_db, score_threshold=9.5, min_n=1)
# Bei threshold=1 sind quasi alle Fraktionen drin, bei 9.5 sehr wenige
low_total = sum(f["n_im_programm"] for f in low["fraktionen"])
high_total = sum(f["n_im_programm"] for f in high["fraktionen"])
assert low_total > high_total
# ─────────────────────────────────────────────────────────────────────────────
# _wert_score_for_assessment helper
# ─────────────────────────────────────────────────────────────────────────────
class TestWertScoreHelper:
def test_extracts_per_column(self):
matrix = [
{"field": "A1", "rating": 3},
{"field": "B1", "rating": 5},
{"field": "C2", "rating": 4},
]
result = _wert_score_for_assessment(matrix)
# Spalte 1 (Würde): A1=3 + B1=5 → Ø=4
assert result["1"] == 4.0
# Spalte 2 (Solidarität): C2=4 → Ø=4
assert result["2"] == 4.0
def test_empty_matrix(self):
assert _wert_score_for_assessment([]) == {}
def test_invalid_entries_skipped(self):
matrix = [
{"field": "A1", "rating": 3},
{"field": "", "rating": 5}, # empty field skipped
{"field": "X9", "rating": 2}, # invalid column suffix
"not a dict", # type error
]
result = _wert_score_for_assessment(matrix)
assert result == {"1": 3.0}
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_stimm_index_pro_wert
# ─────────────────────────────────────────────────────────────────────────────
class TestAggregateProWert:
def test_structure(self, sample_db):
out = aggregate_stimm_index_pro_wert(db_path=sample_db, min_n=1)
assert out["werte"] == [
"Menschenwürde", "Solidarität", "Ökologische Nachhaltigkeit",
"Soziale Gerechtigkeit", "Transparenz & Demokratie",
]
assert "cells" in out
for partei in out["fraktionen"]:
assert set(out["cells"][partei].keys()) == set(out["werte"])
def test_cell_format(self, sample_db):
out = aggregate_stimm_index_pro_wert(db_path=sample_db, min_n=1)
if not out["fraktionen"]:
pytest.skip("no parteien — DB empty")
first_partei = out["fraktionen"][0]
cell = out["cells"][first_partei]["Menschenwürde"]
assert "stimm_index" in cell
assert "n_ja" in cell
assert "n_nein" in cell
assert "ausreichend" in cell
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_stimm_index_cross_bl
# ─────────────────────────────────────────────────────────────────────────────
class TestAggregateCrossBl:
def test_structure(self, sample_db):
out = aggregate_stimm_index_cross_bl(db_path=sample_db, min_n=1)
assert "NRW" in out["bundeslaender"]
assert "MV" in out["bundeslaender"]
assert "fraktionen" in out
assert "fraktionen_alle" in out
def test_only_multi_bl_in_fraktionen(self, sample_db):
"""Fraktionen-Liste enthält nur Parteien mit ≥2 BL ausreichend-erfüllt."""
out = aggregate_stimm_index_cross_bl(db_path=sample_db, min_n=2)
# Bei min_n=2 sind die meisten Kombinationen nicht ausreichend.
# Test prueft nur Struktur: fraktionen ⊆ fraktionen_alle.
assert set(out["fraktionen"]).issubset(set(out["fraktionen_alle"]))
def test_cell_format(self, sample_db):
out = aggregate_stimm_index_cross_bl(db_path=sample_db, min_n=1)
if out["fraktionen_alle"]:
partei = out["fraktionen_alle"][0]
for bl in out["bundeslaender"]:
cell = out["cells"][partei][bl]
assert "stimm_index" in cell
assert "n_ja" in cell
assert "n_nein" in cell