Phase C: Auswertungen-Dashboard #58 + CSV-Export #45 (Roadmap #59)

Drei-dimensionale Aggregations-Sicht über Bundesland × Partei ×
Wahlperiode mit minimalem Frontend.

Backend (`app/auswertungen.py`):

- `aggregate_matrix(filter_wp=None)` — 2D-Matrix Bundesland × Partei mit
  (n, Ø-Score) pro Zelle, optional gefiltert nach Wahlperiode
- `aggregate_zeitreihe(bundesland, partei)` — Score-Verlauf einer
  (BL, Partei)-Kombination über alle bekannten WPs
- `export_long_format()` — Long-Format-CSV-Export für externe Tools
  (deckt #45 vollständig ab)
- Partei-Auflösung läuft strikt durch `normalize_partei()` aus #55 —
  damit wird BB-`FREIE WÄHLER` korrekt als `BVB-FW` aggregiert und
  NICHT mit dem RP-FW zusammengezählt

Wahlperioden-Helper (`app/wahlperioden.py`):

- `wahlperiode_for(datum, bundesland)` mappt ein ISO-Datum + BL auf eine
  Kennung wie `"NRW-WP18"` oder `"MV-WP7"` (Vorgänger-WP). Single Source
  of Truth ist `BUNDESLAENDER[bl].wahlperiode_start`
- `all_wahlperioden()` für UI-Filter-Dropdowns

Endpoints in `app/main.py`:

- `GET /auswertungen` — HTML-Seite (neues Template)
- `GET /api/auswertungen/matrix?wahlperiode=NRW-WP18` — JSON-Matrix
- `GET /api/auswertungen/zeitreihe?bundesland=MV&partei=CDU` — JSON-Verlauf
- `GET /api/auswertungen/export.csv` — CSV-Download

Frontend (`app/templates/auswertungen.html`):

- Statisches Template mit Vanilla-JS, kein Build-Step
- Wahlperioden-Dropdown + Reload-Button + CSV-Export-Button
- Matrix-Tabelle mit Score-Color-Coding (rot ≤ 3, gelb 3-6, grün > 6)
- Sticky-Bundesland-Spalte für horizontales Scrolling

Tests (`tests/test_auswertungen.py`):

- 19 Cases mit in-memory SQLite-Fixture
- Verifiziert WP-Mapping, Matrix-Aggregation, Koalitions-Counting,
  WP-Filter-Korrektheit, BVB-FW-Disambiguierung in der Matrix,
  CSV-Long-Format
- 176 Unit-Tests grün (157 alt + 19 neu)

Refs: #58, #45, #59 (Phase C)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-04-09 11:25:57 +02:00
parent eb045d0ed3
commit 3631e5418c
5 changed files with 739 additions and 0 deletions

219
app/auswertungen.py Normal file
View File

@ -0,0 +1,219 @@
"""Aggregations-Funktionen für die Auswertungen-Seite (#58).
Liest direkt aus ``data/gwoe-antraege.db`` (assessments-Tabelle) und baut
drei Sichten:
1. ``aggregate_matrix(filter_wp=None)`` 2D-Matrix Bundesland × Partei
mit (n, Ø-GWÖ-Score). Filterbar nach Wahlperiode.
2. ``aggregate_zeitreihe(bundesland, partei)`` Score-Verlauf einer
(BL, Partei)-Kombination über alle bekannten WPs.
3. ``export_long_format()`` Long-Format-Tabelle für CSV-Export
(deckt zusätzlich Issue #45 ab).
Partei-Auflösung läuft strikt über ``app.parteien.normalize_partei``
ohne den Mapper aus #55 würde z.B. BB-FW mit RP-FW in einen Topf
gerührt.
"""
from __future__ import annotations
import csv
import io
import json
import sqlite3
from collections import defaultdict
from pathlib import Path
from typing import Optional
from .config import settings
from .parteien import normalize_partei
from .wahlperioden import wahlperiode_for
# ─────────────────────────────────────────────────────────────────────────────
# Datenstrukturen
# ─────────────────────────────────────────────────────────────────────────────
def _load_assessments(db_path: Optional[Path] = None) -> list[dict]:
"""Lese alle Assessments aus der SQLite-DB. Kein Filter — die
Aggregations-Funktionen filtern selbst. Kein async, weil die
Sicht synchron berechnet werden kann."""
path = db_path or settings.db_path
if not Path(path).exists():
return []
conn = sqlite3.connect(str(path))
try:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT drucksache, bundesland, datum, fraktionen, gwoe_score
FROM assessments
WHERE gwoe_score IS NOT NULL
"""
).fetchall()
finally:
conn.close()
out: list[dict] = []
for r in rows:
try:
fraktionen = json.loads(r["fraktionen"]) if r["fraktionen"] else []
except (json.JSONDecodeError, TypeError):
fraktionen = []
out.append({
"drucksache": r["drucksache"],
"bundesland": r["bundesland"],
"datum": r["datum"] or "",
"fraktionen": fraktionen,
"gwoe_score": r["gwoe_score"],
})
return out
# ─────────────────────────────────────────────────────────────────────────────
# 1. Matrix Bundesland × Partei
# ─────────────────────────────────────────────────────────────────────────────
def aggregate_matrix(
filter_wp: Optional[str] = None,
db_path: Optional[Path] = None,
) -> dict:
"""Aggregate assessments to a 2D matrix.
Returns:
``{
"bundeslaender": [...],
"parteien": [...],
"cells": {
"<bl>": {"<partei>": {"n": int, "avg": float}}
},
"filter_wp": <filter_wp> | None,
"total": int,
}``
``filter_wp`` ist eine ``"<BL>-WP<n>"``-Kennung wie ``"NRW-WP18"``;
nur Assessments dieser Wahlperiode fließen ein. ``None`` = keine
WP-Einschränkung (alle WPs zusammen).
"""
rows = _load_assessments(db_path)
bundeslaender: set[str] = set()
parteien: set[str] = set()
sums: defaultdict[tuple[str, str], float] = defaultdict(float)
counts: defaultdict[tuple[str, str], int] = defaultdict(int)
total = 0
for row in rows:
bl = row["bundesland"]
if not bl:
continue
if filter_wp is not None:
wp = wahlperiode_for(row["datum"], bl)
if wp != filter_wp:
continue
bundeslaender.add(bl)
for raw_partei in row["fraktionen"]:
canonical = normalize_partei(raw_partei, bundesland=bl) or raw_partei
parteien.add(canonical)
key = (bl, canonical)
sums[key] += row["gwoe_score"]
counts[key] += 1
total += 1
cells: dict[str, dict[str, dict]] = {}
for (bl, partei), s in sums.items():
n = counts[(bl, partei)]
cells.setdefault(bl, {})[partei] = {
"n": n,
"avg": round(s / n, 2) if n else None,
}
return {
"bundeslaender": sorted(bundeslaender),
"parteien": sorted(parteien),
"cells": cells,
"filter_wp": filter_wp,
"total": total,
}
# ─────────────────────────────────────────────────────────────────────────────
# 2. Zeitreihe pro (BL, Partei) über alle Wahlperioden
# ─────────────────────────────────────────────────────────────────────────────
def aggregate_zeitreihe(
bundesland: str,
partei: str,
db_path: Optional[Path] = None,
) -> dict:
"""Score-Verlauf einer (BL, Partei)-Kombination über alle WPs.
Returns:
``{
"bundesland": str,
"partei": str,
"wahlperioden": [
{"wp": "<BL>-WP<n>", "n": int, "avg": float},
...
]
}``
"""
rows = _load_assessments(db_path)
sums: defaultdict[str, float] = defaultdict(float)
counts: defaultdict[str, int] = defaultdict(int)
for row in rows:
if row["bundesland"] != bundesland:
continue
canonical_partei_in_row = {
normalize_partei(p, bundesland=bundesland) or p
for p in row["fraktionen"]
}
if partei not in canonical_partei_in_row:
continue
wp = wahlperiode_for(row["datum"], bundesland)
if wp is None:
continue
sums[wp] += row["gwoe_score"]
counts[wp] += 1
wps = sorted(sums.keys())
return {
"bundesland": bundesland,
"partei": partei,
"wahlperioden": [
{"wp": wp, "n": counts[wp], "avg": round(sums[wp] / counts[wp], 2)}
for wp in wps
],
}
# ─────────────────────────────────────────────────────────────────────────────
# 3. Long-Format-Export für CSV (deckt #45 mit ab)
# ─────────────────────────────────────────────────────────────────────────────
def export_long_format(db_path: Optional[Path] = None) -> str:
"""Long-Format-CSV-Export aller Assessments für externe Auswertung.
Spalten: ``drucksache,bundesland,wahlperiode,datum,partei,gwoe_score``.
Eine Zeile pro (drucksache, partei) wenn ein Antrag mehrere
Fraktionen hat (Koalitionsanträge), erscheinen entsprechend mehrere
Zeilen mit identischer Drucksache.
"""
rows = _load_assessments(db_path)
buf = io.StringIO()
writer = csv.writer(buf, dialect="excel")
writer.writerow(["drucksache", "bundesland", "wahlperiode", "datum", "partei", "gwoe_score"])
for r in rows:
bl = r["bundesland"] or ""
wp = wahlperiode_for(r["datum"], bl) if bl else ""
for raw_partei in r["fraktionen"]:
canonical = normalize_partei(raw_partei, bundesland=bl) or raw_partei
writer.writerow([
r["drucksache"], bl, wp or "", r["datum"], canonical,
f"{r['gwoe_score']:.2f}",
])
return buf.getvalue()

View File

@ -636,6 +636,48 @@ async def index_programme(
raise HTTPException(status_code=400, detail="Ungültiges Programm") raise HTTPException(status_code=400, detail="Ungültiges Programm")
# ─────────────────────────────────────────────────────────────────────────────
# Auswertungen #58 — Bundesland × Partei × Wahlperiode Aggregations-Sicht
# ─────────────────────────────────────────────────────────────────────────────
@app.get("/auswertungen", response_class=HTMLResponse)
async def auswertungen_page(request: Request):
"""Statische Seite, die die Matrix-Endpoints per fetch() lädt."""
from .wahlperioden import all_wahlperioden
return templates.TemplateResponse("auswertungen.html", {
"request": request,
"app_name": settings.app_name,
"wahlperioden": sorted(all_wahlperioden()),
})
@app.get("/api/auswertungen/matrix")
async def auswertungen_matrix(wahlperiode: Optional[str] = None):
"""2D-Matrix Bundesland × Partei mit Anzahl + Ø-GWÖ-Score."""
from .auswertungen import aggregate_matrix
return aggregate_matrix(filter_wp=wahlperiode)
@app.get("/api/auswertungen/zeitreihe")
async def auswertungen_zeitreihe(bundesland: str, partei: str):
"""Score-Verlauf einer (BL, Partei)-Kombination über alle WPs."""
from .auswertungen import aggregate_zeitreihe
return aggregate_zeitreihe(bundesland, partei)
@app.get("/api/auswertungen/export.csv")
async def auswertungen_export_csv():
"""Long-Format-CSV-Export aller Assessments. Deckt #45 mit ab."""
from .auswertungen import export_long_format
csv_text = export_long_format()
return Response(
content=csv_text,
media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="gwoe-assessments.csv"'},
)
# Health check # Health check
@app.get("/health") @app.get("/health")
async def health(): async def health():

View File

@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auswertungen — {{ app_name }}</title>
<style>
:root {
--color-darkgray: #5a5a5a;
--color-green: #889e33;
--color-blue: #009da5;
--color-lightgray: #bfbfbf;
--color-bg: #f5f5f5;
--color-orange: #F7941D;
--color-red: #d00000;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Avenir', 'Segoe UI', sans-serif;
color: var(--color-darkgray);
line-height: 1.6;
background: var(--color-bg);
}
.header {
background: white;
padding: 1rem 2rem;
border-bottom: 1px solid var(--color-lightgray);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.header h1 { color: var(--color-blue); font-size: 1.3rem; }
.header nav a {
color: var(--color-blue);
text-decoration: none;
margin-right: 1rem;
font-size: 0.9rem;
}
.header nav a:hover { text-decoration: underline; }
main { max-width: 1400px; margin: 1.5rem auto; padding: 0 2rem; }
.controls {
background: white;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.controls label { font-size: 0.9rem; }
.controls select, .controls button {
padding: 0.4rem 0.7rem;
border: 1px solid var(--color-lightgray);
border-radius: 3px;
font-size: 0.9rem;
background: white;
color: var(--color-darkgray);
cursor: pointer;
}
.controls button.export {
background: var(--color-blue);
color: white;
border-color: var(--color-blue);
}
.matrix-wrap { background: white; padding: 1rem; border-radius: 4px; overflow-x: auto; }
table.matrix {
border-collapse: collapse;
width: 100%;
font-size: 0.85rem;
}
table.matrix th, table.matrix td {
border: 1px solid var(--color-lightgray);
padding: 0.4rem 0.6rem;
text-align: center;
}
table.matrix th {
background: var(--color-bg);
font-weight: 600;
color: var(--color-darkgray);
}
table.matrix th.row-header {
background: var(--color-blue);
color: white;
text-align: left;
position: sticky;
left: 0;
}
table.matrix .empty { color: var(--color-lightgray); }
table.matrix .score-high { background: rgba(136, 158, 51, 0.25); font-weight: 600; }
table.matrix .score-mid { background: rgba(247, 148, 29, 0.18); }
table.matrix .score-low { background: rgba(208, 0, 0, 0.18); font-weight: 600; }
.meta {
font-size: 0.8rem;
color: var(--color-lightgray);
margin-top: 0.6rem;
}
.empty-state {
background: white;
padding: 2rem;
text-align: center;
border-radius: 4px;
color: var(--color-lightgray);
}
</style>
</head>
<body>
<div class="header">
<h1>Auswertungen — Bundesland × Partei × Wahlperiode</h1>
<nav>
<a href="/">← zurück zur Suche</a>
<a href="/quellen">Quellen</a>
</nav>
</div>
<main>
<div class="controls">
<label>Wahlperiode:
<select id="wp-filter">
<option value="">— alle WPs —</option>
{% for wp in wahlperioden %}
<option value="{{ wp }}">{{ wp }}</option>
{% endfor %}
</select>
</label>
<button id="reload">Anwenden</button>
<button class="export" id="export-csv">CSV-Export (alle Daten)</button>
</div>
<div id="matrix-container" class="matrix-wrap">
<div class="empty-state">Lade Matrix …</div>
</div>
<div class="meta" id="meta"></div>
</main>
<script>
const wpFilter = document.getElementById('wp-filter');
const reloadBtn = document.getElementById('reload');
const exportBtn = document.getElementById('export-csv');
const container = document.getElementById('matrix-container');
const meta = document.getElementById('meta');
function scoreClass(avg) {
if (avg === null || avg === undefined) return '';
if (avg >= 6) return 'score-high';
if (avg >= 3) return 'score-mid';
return 'score-low';
}
async function loadMatrix() {
const wp = wpFilter.value;
const url = wp
? `/api/auswertungen/matrix?wahlperiode=${encodeURIComponent(wp)}`
: '/api/auswertungen/matrix';
container.innerHTML = '<div class="empty-state">Lade Matrix …</div>';
try {
const r = await fetch(url);
const data = await r.json();
if (!data.bundeslaender.length) {
container.innerHTML = '<div class="empty-state">Keine Assessments für diesen Filter.</div>';
meta.textContent = '';
return;
}
let html = '<table class="matrix"><thead><tr><th class="row-header">Bundesland</th>';
for (const partei of data.parteien) {
html += `<th>${partei}</th>`;
}
html += '</tr></thead><tbody>';
for (const bl of data.bundeslaender) {
html += `<tr><th class="row-header">${bl}</th>`;
for (const partei of data.parteien) {
const cell = (data.cells[bl] || {})[partei];
if (cell) {
html += `<td class="${scoreClass(cell.avg)}">${cell.avg.toFixed(1)}<br><small>n=${cell.n}</small></td>`;
} else {
html += '<td class="empty"></td>';
}
}
html += '</tr>';
}
html += '</tbody></table>';
container.innerHTML = html;
meta.textContent = `${data.total} Assessment(s) | Filter: ${data.filter_wp || 'alle WPs'}`;
} catch (e) {
container.innerHTML = `<div class="empty-state">Fehler beim Laden: ${e}</div>`;
}
}
reloadBtn.addEventListener('click', loadMatrix);
wpFilter.addEventListener('change', loadMatrix);
exportBtn.addEventListener('click', () => {
window.location.href = '/api/auswertungen/export.csv';
});
loadMatrix();
</script>
</body>
</html>

52
app/wahlperioden.py Normal file
View File

@ -0,0 +1,52 @@
"""Wahlperioden-Helper für die Auswertungen.
Maps a Drucksache-Datum + Bundesland auf eine Wahlperioden-Kennung.
Single Source of Truth ist ``BUNDESLAENDER[bl].wahlperiode_start``
alles vor diesem Datum gehört zur Vorgänger-WP, alles ab dem Datum zur
aktuellen.
Granularität: pro Bundesland kennen wir genau zwei WP die laufende und
die direkt davor. Das reicht für die Aggregations-Sicht (#58), denn der
gesamte Antragsbestand stammt aus den letzten 510 Jahren. Sollte später
eine echte Multi-WP-Historie nötig werden, ist der Erweiterungspunkt
``_wp_calendar`` als Liste von ``(start, ende, wp_id)``-Tupeln pro BL.
"""
from __future__ import annotations
from typing import Optional
from .bundeslaender import BUNDESLAENDER
def wahlperiode_for(datum: str, bundesland: str) -> Optional[str]:
"""Liefert die Wahlperioden-Kennung für ein Datum + Bundesland.
Format der Kennung: ``"<BL>-WP<n>"``, z.B. ``"NRW-WP18"``,
``"MV-WP7"`` (Vorgänger-WP). Returns ``None`` wenn das Bundesland
unbekannt oder das Datum nicht parsbar ist.
>>> wahlperiode_for("2026-03-18", "MV")
'MV-WP8'
>>> wahlperiode_for("2020-01-01", "MV")
'MV-WP7'
"""
bl = BUNDESLAENDER.get(bundesland)
if bl is None:
return None
if not datum:
return f"{bundesland}-WP{bl.wahlperiode}" # default: aktuelle WP
# ISO-Datum ist lexikographisch vergleichbar — keine datetime-Parsing
# nötig, solange das Format YYYY-MM-DD eingehalten wird.
if datum >= bl.wahlperiode_start:
return f"{bundesland}-WP{bl.wahlperiode}"
return f"{bundesland}-WP{bl.wahlperiode - 1}"
def all_wahlperioden() -> list[str]:
"""Liste aller bekannten Wahlperioden-Kennungen (aktuelle + Vorgänger
pro Bundesland). Nützlich für UI-Filter-Dropdowns."""
out: list[str] = []
for code, bl in BUNDESLAENDER.items():
out.append(f"{code}-WP{bl.wahlperiode}")
out.append(f"{code}-WP{bl.wahlperiode - 1}")
return out

225
tests/test_auswertungen.py Normal file
View File

@ -0,0 +1,225 @@
"""Tests für app.wahlperioden und app.auswertungen.
Issue #58 + Roadmap #59 Phase C. Verifiziert die Aggregations-Logik
gegen eine in-memory SQLite-DB mit kontrollierten Sample-Assessments.
"""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime
from pathlib import Path
import pytest
from app.auswertungen import (
aggregate_matrix,
aggregate_zeitreihe,
export_long_format,
)
from app.wahlperioden import all_wahlperioden, wahlperiode_for
# ─────────────────────────────────────────────────────────────────────────────
# wahlperioden helper
# ─────────────────────────────────────────────────────────────────────────────
class TestWahlperiodeFor:
def test_current_wp_for_recent_date(self):
assert wahlperiode_for("2026-03-18", "MV") == "MV-WP8"
def test_previous_wp_for_old_date(self):
# MV WP8 startete am 26.10.2021 — alles davor ist WP7
assert wahlperiode_for("2020-01-01", "MV") == "MV-WP7"
def test_unknown_bl_returns_none(self):
assert wahlperiode_for("2026-01-01", "XX") is None
def test_empty_datum_returns_current_wp(self):
# Wenn kein Datum bekannt → wir nehmen die aktuelle WP an,
# weil das die einzig sinnvolle Default-Annahme ist
assert wahlperiode_for("", "NRW") == "NRW-WP18"
def test_all_wahlperioden_lists_each_bl_twice(self):
out = all_wahlperioden()
# 16 Bundesländer × 2 WPs = 32 Einträge
assert len(out) == 32
# Aktuelle und vorherige WP für NRW
assert "NRW-WP18" in out
assert "NRW-WP17" in out
# ─────────────────────────────────────────────────────────────────────────────
# Test-DB-Fixture
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture
def sample_db(tmp_path: Path) -> Path:
"""Lege eine Mini-Assessments-DB an, die typische Fälle abdeckt."""
db = tmp_path / "test_assessments.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
)
""")
samples = [
# NRW WP18 — drei Anträge, zwei Parteien
("18/100", "NRW", "2024-01-15", '["CDU"]', 7.0),
("18/101", "NRW", "2024-02-15", '["SPD"]', 8.0),
("18/102", "NRW", "2024-03-15", '["CDU"]', 5.0),
# MV WP8 — Koalitionsantrag (zwei Parteien zählen beide)
("8/200", "MV", "2024-04-01", '["SPD","LINKE"]', 6.0),
("8/201", "MV", "2025-01-10", '["AfD"]', 2.0),
# MV WP7 — historischer Antrag vor wahlperiode_start (2021-10-26)
("7/100", "MV", "2020-05-01", '["CDU"]', 4.0),
# BB — FREIE WÄHLER soll als BVB-FW kanonisiert werden
("8/2", "BB", "2024-10-17", '["FREIE WÄHLER"]', 6.5),
]
now = datetime.utcnow().isoformat()
for ds, bl, dat, fr, sc in samples:
conn.execute(
"INSERT INTO assessments (drucksache, title, fraktionen, datum, bundesland, "
"gwoe_score, source, model, created_at, updated_at) VALUES "
"(?, ?, ?, ?, ?, ?, 'test', 'test', ?, ?)",
(ds, f"Test {ds}", fr, dat, bl, sc, now, now),
)
conn.commit()
conn.close()
return db
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_matrix
# ─────────────────────────────────────────────────────────────────────────────
class TestAggregateMatrix:
def test_total_count(self, sample_db):
m = aggregate_matrix(db_path=sample_db)
assert m["total"] == 7
def test_bundeslaender_listed(self, sample_db):
m = aggregate_matrix(db_path=sample_db)
assert set(m["bundeslaender"]) == {"NRW", "MV", "BB"}
def test_nrw_cdu_average(self, sample_db):
# NRW-CDU: 7.0 + 5.0 → Avg 6.0, n=2
m = aggregate_matrix(db_path=sample_db)
cell = m["cells"]["NRW"]["CDU"]
assert cell["n"] == 2
assert cell["avg"] == 6.0
def test_koalition_counts_both_parties(self, sample_db):
# MV-SPD und MV-LINKE bekommen beide den Score 6.0 (n=1)
m = aggregate_matrix(db_path=sample_db)
assert m["cells"]["MV"]["SPD"]["n"] == 1
assert m["cells"]["MV"]["LINKE"]["n"] == 1
assert m["cells"]["MV"]["SPD"]["avg"] == 6.0
def test_filter_by_wahlperiode(self, sample_db):
# NRW-WP18-Filter → nur die 3 NRW-Anträge
m = aggregate_matrix(filter_wp="NRW-WP18", db_path=sample_db)
assert m["total"] == 3
assert set(m["bundeslaender"]) == {"NRW"}
def test_filter_excludes_old_wp(self, sample_db):
# MV-WP8 darf den 7/100-Antrag (datum=2020) NICHT enthalten
m = aggregate_matrix(filter_wp="MV-WP8", db_path=sample_db)
assert m["total"] == 2 # nur 8/200 und 8/201
# CDU darf NICHT vorkommen, weil der CDU-Antrag in WP7 war
assert "CDU" not in m["cells"].get("MV", {})
def test_bb_freie_waehler_normalized_to_bvb(self, sample_db):
# Die BB-FW-Drucksache muss als BVB-FW gezählt werden, NICHT als
# generisches FREIE WÄHLER — das ist der eigentliche Mehrwert
# des Parteinamen-Mappers (#55)
m = aggregate_matrix(db_path=sample_db)
bb_cells = m["cells"]["BB"]
assert "BVB-FW" in bb_cells
assert bb_cells["BVB-FW"]["n"] == 1
assert "FREIE WÄHLER" not in bb_cells
def test_empty_db_returns_empty_matrix(self, tmp_path):
m = aggregate_matrix(db_path=tmp_path / "missing.db")
assert m["total"] == 0
assert m["bundeslaender"] == []
# ─────────────────────────────────────────────────────────────────────────────
# aggregate_zeitreihe
# ─────────────────────────────────────────────────────────────────────────────
class TestAggregateZeitreihe:
def test_mv_cdu_two_wps(self, sample_db):
# MV-CDU hat einen Eintrag in WP7 (4.0) und keinen in WP8
z = aggregate_zeitreihe("MV", "CDU", db_path=sample_db)
wps = {entry["wp"]: entry for entry in z["wahlperioden"]}
assert "MV-WP7" in wps
assert wps["MV-WP7"]["avg"] == 4.0
assert wps["MV-WP7"]["n"] == 1
def test_nrw_cdu_one_wp(self, sample_db):
z = aggregate_zeitreihe("NRW", "CDU", db_path=sample_db)
assert len(z["wahlperioden"]) == 1
assert z["wahlperioden"][0]["avg"] == 6.0
def test_unknown_combination_empty(self, sample_db):
z = aggregate_zeitreihe("NRW", "AfD", db_path=sample_db)
assert z["wahlperioden"] == []
# ─────────────────────────────────────────────────────────────────────────────
# export_long_format
# ─────────────────────────────────────────────────────────────────────────────
class TestExportLongFormat:
def test_csv_has_header(self, sample_db):
csv_text = export_long_format(db_path=sample_db)
first_line = csv_text.splitlines()[0]
assert "drucksache" in first_line
assert "bundesland" in first_line
assert "wahlperiode" in first_line
assert "partei" in first_line
assert "gwoe_score" in first_line
def test_koalition_yields_two_rows(self, sample_db):
csv_text = export_long_format(db_path=sample_db)
lines = csv_text.splitlines()[1:] # ohne Header
# 8/200 ist Koalitionsantrag (SPD+LINKE) → 2 Zeilen
mv_8_200_lines = [l for l in lines if l.startswith("8/200,")]
assert len(mv_8_200_lines) == 2
def test_bb_fw_normalized_in_csv(self, sample_db):
csv_text = export_long_format(db_path=sample_db)
assert "BVB-FW" in csv_text
# Generic FREIE WÄHLER darf in der Zeile NICHT auftauchen
bb_lines = [l for l in csv_text.splitlines() if "BB" in l and "8/2," in l]
assert any("BVB-FW" in l for l in bb_lines)