gwoe-antragspruefer/tests/test_wahlprogramme.py
Dotty Dotter 9169e7699d fix(parteien): BIW → BiW (kanonische Schreibweise) für HB-Wahlprogramme
parteien.py listet 'BiW' als kanonischen Key (Mixed-Case), 'BIW' nur als
Alias. Bestehende HB-Assessments tragen bereits 'BiW' in
wahlprogramm_scores. Beim Block 2.4 hatte ich die historischen BiW-
Wahlprogramme aber mit "BIW" angelegt — Folge: find_relevant_chunks
mit parteien=["BiW"] hätte die 131 Chunks der biw-hb-*-PDFs nicht
gefunden, weil der partei-Filter exakt matcht.

Geändert:
- embeddings.PROGRAMME: 3 biw-hb-* Einträge "partei": "BIW" → "BiW"
- wahlprogramme.WAHLPROGRAMME["HB"]: Key "BIW" → "BiW", "partei" Feld
  "BIW Bremen" → "BiW Bremen"
- test_hb_has_five_parteien: erwartete Set entsprechend angepasst

Folge-Schritt: chunks.partei in der embeddings.db muss von 'BIW'
auf 'BiW' migriert werden — die 131 betroffenen Chunks werden beim
nächsten Reindex der biw-hb-* Programme ohnehin überschrieben.

92/92 Programme-Tests grün, 1242 Unit-Tests grün.
2026-05-08 14:28:12 +02:00

407 lines
20 KiB
Python

"""Tests for wahlprogramme.py — registry consistency + file existence."""
import re
import pytest
from app.wahlprogramme import (
WAHLPROGRAMME,
REFERENZEN_PATH,
get_wahlprogramm,
parteien_mit_wahlprogramm,
regierung_aktuell,
regierungsbildung_for,
regierungsende_for,
)
# ─────────────────────────────────────────────────────────────────────────────
# Registry consistency
# ─────────────────────────────────────────────────────────────────────────────
class TestRegistryStructure:
def test_active_bundeslaender_present(self):
for code in ["NRW", "LSA", "MV", "BE"]:
assert code in WAHLPROGRAMME, f"missing wahlprogramme entry for {code}"
def test_each_entry_has_required_keys(self):
required = {"file", "titel", "partei", "jahr", "seiten"}
for bl, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
missing = required - set(info.keys())
assert not missing, f"{bl}/{partei} missing keys: {missing}"
def test_jahr_is_integer(self):
for bl, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
assert isinstance(info["jahr"], int), f"{bl}/{partei} jahr not int"
def test_seiten_is_positive_integer(self):
for bl, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
assert isinstance(info["seiten"], int)
assert info["seiten"] > 0
def test_file_extension_is_pdf(self):
for bl, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
assert info["file"].endswith(".pdf")
# ─────────────────────────────────────────────────────────────────────────────
# Regierungsbildungs-Felder — Konsistenz pro Bundesland
# ─────────────────────────────────────────────────────────────────────────────
class TestRegierungsbildung:
"""Pro Bundesland gehören alle Wahlprogramm-Einträge zur gleichen
Legislatur — d.h. dasselbe regierungsbildung-Datum. Ausnahme: BUND, wo
Grundsatzprogramme stehen, die regierungsbildung=None tragen."""
_ISO_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def test_every_entry_has_regierungs_fields(self):
for bl, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
assert "regierungsbildung" in info, f"{bl}/{partei}: regierungsbildung fehlt"
assert "regierungsende" in info, f"{bl}/{partei}: regierungsende fehlt"
def test_regierungsbildung_iso_or_none(self):
for bl, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
rb = info.get("regierungsbildung")
if rb is not None:
assert isinstance(rb, str) and self._ISO_DATE.match(rb), \
f"{bl}/{partei}: regierungsbildung kein ISO-Datum: {rb!r}"
def test_regierungsende_iso_or_none(self):
for bl, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
re_ = info.get("regierungsende")
if re_ is not None:
assert isinstance(re_, str) and self._ISO_DATE.match(re_), \
f"{bl}/{partei}: regierungsende kein ISO-Datum: {re_!r}"
def test_alle_parteien_eines_bl_haben_gleiches_datum(self):
"""Alle Wahlprogramm-Einträge eines Bundeslands gehören zur selben
Regierung und müssen daher dasselbe Bildungs-/Endedatum tragen."""
for bl, parteien in WAHLPROGRAMME.items():
bildung = {info.get("regierungsbildung") for info in parteien.values()}
ende = {info.get("regierungsende") for info in parteien.values()}
assert len(bildung) == 1, \
f"{bl}: regierungsbildung divergent: {bildung}"
assert len(ende) == 1, \
f"{bl}: regierungsende divergent: {ende}"
def test_grundsatzprogramme_haben_keine_regierung(self):
"""Grundsatzprogramme (ist_grundsatz=True) tragen keine Regierungs-
bildung — sie sind zeitlos."""
for bl, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
if info.get("ist_grundsatz"):
assert info.get("regierungsbildung") is None, \
f"{bl}/{partei} ist Grundsatzprogramm, sollte regierungsbildung=None haben"
class TestRegierungsHelper:
def test_regierungsbildung_for_known_bl(self):
assert regierungsbildung_for("NRW") == "2022-06-29"
assert regierungsbildung_for("HH") == "2025-05-07"
def test_regierungsbildung_for_bund_btw2025(self):
# BUND tragt nun die BTW-2025-Wahlprogramme; Kabinett Merz I
# vereidigt 06.05.2025. Grundsatzprogramme bleiben nur in
# embeddings.PROGRAMME als zweite Referenz.
assert regierungsbildung_for("BUND") == "2025-05-06"
def test_regierungsbildung_for_unknown_bl(self):
assert regierungsbildung_for("XX") is None
def test_regierung_aktuell_true_for_active_bl(self):
assert regierung_aktuell("NRW") is True
assert regierung_aktuell("BB") is True
assert regierung_aktuell("BUND") is True
def test_regierungsende_for_active_is_none(self):
assert regierungsende_for("NRW") is None
# ─────────────────────────────────────────────────────────────────────────────
# File existence — every registered file must exist on disk
# ─────────────────────────────────────────────────────────────────────────────
class TestFileExistence:
"""Catches typos in the file field that would silently break embedding
indexing or PDF download links."""
def test_every_registered_pdf_exists(self):
missing = []
for bl, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
path = REFERENZEN_PATH / info["file"]
if not path.exists():
missing.append(f"{bl}/{partei}: {info['file']}")
assert not missing, "missing PDFs:\n " + "\n ".join(missing)
# ─────────────────────────────────────────────────────────────────────────────
# Lookup helpers
# ─────────────────────────────────────────────────────────────────────────────
class TestGetWahlprogramm:
def test_returns_dict_for_known_combination(self):
info = get_wahlprogramm("MV", "CDU")
assert info is not None
assert info["partei"] == "CDU Mecklenburg-Vorpommern"
def test_returns_none_for_unknown_bundesland(self):
assert get_wahlprogramm("XX", "CDU") is None
def test_returns_none_for_unknown_partei(self):
assert get_wahlprogramm("NRW", "BSW") is None
class TestParteienMitWahlprogramm:
def test_nrw_has_five_parteien(self):
parteien = parteien_mit_wahlprogramm("NRW")
assert len(parteien) == 5
assert set(parteien) == {"CDU", "SPD", "GRÜNE", "FDP", "AfD"}
def test_mv_has_six_parteien(self):
parteien = parteien_mit_wahlprogramm("MV")
assert set(parteien) == {"CDU", "SPD", "GRÜNE", "FDP", "AfD", "LINKE"}
def test_be_has_five_parteien(self):
parteien = parteien_mit_wahlprogramm("BE")
assert set(parteien) == {"CDU", "SPD", "GRÜNE", "LINKE", "AfD"}
def test_bund_has_eight_parteien(self):
# BTW 2025: CDU, CSU, SPD, GRÜNE, FDP, AfD, LINKE, BSW.
parteien = parteien_mit_wahlprogramm("BUND")
assert set(parteien) == {"CDU", "CSU", "SPD", "GRÜNE", "FDP", "AfD", "LINKE", "BSW"}
def test_by_has_five_parteien(self):
parteien = parteien_mit_wahlprogramm("BY")
assert set(parteien) == {"CSU", "FREIE WÄHLER", "GRÜNE", "SPD", "AfD"}
def test_hb_has_five_parteien(self):
# AfD war wegen Listenstreit nicht zur Bürgerschaftswahl 2023 zugelassen.
# Stattdessen ist BiW (Bürger in Wut) als 6. Fraktion in der 21. WP.
parteien = parteien_mit_wahlprogramm("HB")
assert set(parteien) == {"SPD", "CDU", "GRÜNE", "LINKE", "BiW"}
def test_he_has_five_parteien(self):
parteien = parteien_mit_wahlprogramm("HE")
assert set(parteien) == {"CDU", "SPD", "GRÜNE", "FDP", "AfD"}
def test_ni_has_four_parteien(self):
parteien = parteien_mit_wahlprogramm("NI")
assert set(parteien) == {"SPD", "CDU", "GRÜNE", "AfD"}
def test_sl_has_three_parteien(self):
parteien = parteien_mit_wahlprogramm("SL")
assert set(parteien) == {"SPD", "CDU", "AfD"}
def test_sn_has_six_parteien(self):
parteien = parteien_mit_wahlprogramm("SN")
assert set(parteien) == {"CDU", "SPD", "AfD", "BSW", "LINKE", "GRÜNE"}
def test_unknown_bundesland_empty_list(self):
assert parteien_mit_wahlprogramm("XX") == []
# ─────────────────────────────────────────────────────────────────────────────
# embeddings.PROGRAMME consistency cross-check
# ─────────────────────────────────────────────────────────────────────────────
class TestEmbeddingsRegistryConsistency:
"""Every entry in WAHLPROGRAMME must also exist in embeddings.PROGRAMME
so the indexer can find it. Mismatch is the kind of bug a manual smoke
misses but would show up during indexing."""
def test_every_wahlprogramm_has_embeddings_entry(self):
from app.embeddings import PROGRAMME
# Match WAHLPROGRAMME-Eintrag → PROGRAMME-Eintrag entweder ueber den
# file-stem (Standard: "cdu-mv-2021" matcht "cdu-mv-2021") ODER ueber
# den `pdf`-Wert in PROGRAMME (BUND-Grundsatzprogramme nutzen kuerzere
# PROGRAMME-Keys wie "cdu-grundsatz" obwohl die Datei
# "cdu-grundsatzprogramm.pdf" heisst).
pdf_to_pid = {p.get("pdf"): pid for pid, p in PROGRAMME.items() if p.get("pdf")}
missing = []
for bl, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
file_stem = info["file"].rsplit(".", 1)[0]
if file_stem in PROGRAMME:
continue
if info["file"] in pdf_to_pid:
continue
missing.append(f"{bl}/{partei}{info['file']}")
assert not missing, (
"WAHLPROGRAMME entries missing in embeddings.PROGRAMME:\n "
+ "\n ".join(missing)
)
# ─────────────────────────────────────────────────────────────────────────────
# Strikte Cross-Konsistenz mit programme.PROGRAMME (Drift-Schutz, ADR 0013)
# ─────────────────────────────────────────────────────────────────────────────
class TestWahlprogrammeProgrammeConsistency:
"""WAHLPROGRAMME (legacy, mit titel/seiten/regierung) und
programme.PROGRAMME (zentrale Registry mit Geltungsdaten) speichern
überlappende Felder. Diese Tests fangen stille Drift, bis #222 die
Quelle vereinheitlicht (Compat-Shim).
Invarianten:
- Für jedes (bl, partei) in WAHLPROGRAMME liefert
``aktuelles_wahlprogramm(bl, partei)`` einen Eintrag (nicht None).
- Der ``pdf``-Wert in PROGRAMME stimmt mit ``file`` in WAHLPROGRAMME
überein.
- Die ``partei``-Kurzform stimmt überein (PROGRAMME["partei"] ist die
Kurzform; WAHLPROGRAMME-Key ist auch Kurzform).
"""
def test_every_wahlprogramm_has_aktuelles_programm_match(self):
from app.programme import aktuelles_wahlprogramm
mismatches = []
for bl, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
prog = aktuelles_wahlprogramm(bl, partei)
if prog is None:
mismatches.append(
f"{bl}/{partei}: aktuelles_wahlprogramm liefert None, "
f"obwohl WAHLPROGRAMME-Eintrag {info['file']} existiert"
)
assert not mismatches, "\n ".join(mismatches)
def test_pdf_filenames_match_between_registries(self):
from app.programme import aktuelles_wahlprogramm
drift = []
for bl, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
prog = aktuelles_wahlprogramm(bl, partei)
if prog is None:
continue # vom Vortest abgedeckt
wp_pdf = info["file"]
pr_pdf = prog.get("pdf")
if wp_pdf != pr_pdf:
drift.append(f"{bl}/{partei}: WAHLPROGRAMME.file={wp_pdf!r} ≠ PROGRAMME.pdf={pr_pdf!r}")
assert not drift, "Drift zwischen WAHLPROGRAMME und PROGRAMME:\n " + "\n ".join(drift)
def test_partei_kurzform_consistency(self):
"""PROGRAMME["partei"] ist die Kurzform (z.B. 'CDU'), nicht
die Langform ('CDU NRW'). Test-Sicherheitsnetz, falls jemand
versehentlich die Langform reinträgt."""
from app.programme import aktuelles_wahlprogramm
wrong = []
for bl, parteien in WAHLPROGRAMME.items():
for partei in parteien.keys():
prog = aktuelles_wahlprogramm(bl, partei)
if prog is None:
continue
if prog.get("partei") != partei:
wrong.append(
f"{bl}/{partei}: PROGRAMME.partei={prog.get('partei')!r} "
f"≠ WAHLPROGRAMME-Key {partei!r}"
)
assert not wrong, "\n ".join(wrong)
def test_no_orphan_aktuelle_programme_in_registry(self):
"""Die andere Richtung: jedes aktuelle Wahlprogramm in PROGRAMME
(gueltig_bis IS NULL, typ='wahlprogramm') muss in WAHLPROGRAMME
vorhanden sein. Sonst ist die Bewertungs-Pipeline blind dafür."""
from app.programme import all_programme
orphans = []
for prog in all_programme():
if prog.get("typ") != "wahlprogramm":
continue
if prog.get("gueltig_bis") is not None:
continue # historisches Programm
bl = prog.get("bundesland")
partei = prog.get("partei")
if bl not in WAHLPROGRAMME:
orphans.append(f"{prog['id']}: BL {bl} nicht in WAHLPROGRAMME")
continue
if partei not in WAHLPROGRAMME[bl]:
orphans.append(f"{prog['id']}: {bl}/{partei} fehlt in WAHLPROGRAMME")
assert not orphans, "Aktuelle Wahlprogramme in PROGRAMME ohne WAHLPROGRAMME-Eintrag:\n " + "\n ".join(orphans)
# ─────────────────────────────────────────────────────────────────────────────
# load_wahlprogramm_text — Fallback-Pfade (#134 Coverage-Backfill)
# ─────────────────────────────────────────────────────────────────────────────
class TestLoadWahlprogrammText:
def test_returns_empty_for_unknown_combination(self):
from app.wahlprogramme import load_wahlprogramm_text
assert load_wahlprogramm_text("XX", "XYZ") == {}
def test_paged_textfile_used_when_present(self, tmp_path, monkeypatch):
"""Wenn die paged-Textdatei existiert, wird sie genutzt.
Format: '--- PAGE N ---'-Marker pro Seitenanfang."""
from app import wahlprogramme as wp_mod
# Mock get_wahlprogramm -> bekannte Datei
monkeypatch.setattr(wp_mod, "get_wahlprogramm",
lambda bl, p: {"file": "test.pdf"})
paged = tmp_path / "test-paged.txt"
paged.write_text("--- PAGE 1 ---\nseite eins\n--- PAGE 2 ---\nseite zwei")
monkeypatch.setattr(wp_mod, "KONTEXT_PATH", tmp_path)
result = wp_mod.load_wahlprogramm_text("X", "Y")
assert 2 in result
assert "seite zwei" in result[2]
def test_falls_back_to_normal_textfile(self, tmp_path, monkeypatch):
"""Ohne paged-Datei wird auf normale .txt-Datei zurueckgefallen,
komplett unter Seite 1."""
from app import wahlprogramme as wp_mod
monkeypatch.setattr(wp_mod, "get_wahlprogramm",
lambda bl, p: {"file": "test.pdf"})
normal = tmp_path / "test.txt"
normal.write_text("flacher text ohne seitenmarker")
monkeypatch.setattr(wp_mod, "KONTEXT_PATH", tmp_path)
result = wp_mod.load_wahlprogramm_text("X", "Y")
assert result == {1: "flacher text ohne seitenmarker"}
def test_returns_empty_when_no_textfile(self, tmp_path, monkeypatch):
"""Weder paged- noch normale Textdatei → leeres Dict."""
from app import wahlprogramme as wp_mod
monkeypatch.setattr(wp_mod, "get_wahlprogramm",
lambda bl, p: {"file": "test.pdf"})
# tmp_path ist leer
monkeypatch.setattr(wp_mod, "KONTEXT_PATH", tmp_path)
assert wp_mod.load_wahlprogramm_text("X", "Y") == {}
class TestSearchWahlprogramm:
def test_returns_empty_for_unknown_combination(self):
from app.wahlprogramme import search_wahlprogramm
assert search_wahlprogramm("XX", "XYZ", ["test"]) == []
def test_returns_empty_when_text_missing(self, monkeypatch):
"""Bekannte Partei + Bundesland aber keine Textdatei → leer."""
from app import wahlprogramme as wp_mod
monkeypatch.setattr(wp_mod, "get_wahlprogramm",
lambda bl, p: {"file": "missing.pdf"})
monkeypatch.setattr(wp_mod, "load_wahlprogramm_text",
lambda bl, p: {})
assert wp_mod.search_wahlprogramm("X", "Y", ["test"]) == []
class TestFindRelevantQuotes:
def test_unknown_bundesland_raises(self):
from app.wahlprogramme import find_relevant_quotes
with pytest.raises(ValueError, match="Unbekanntes Bundesland"):
find_relevant_quotes("Antrag-Text", ["CDU"], bundesland="ZZ")
class TestFormatQuoteForPrompt:
def test_empty_quotes_returns_empty_string(self):
from app.wahlprogramme import format_quote_for_prompt
assert format_quote_for_prompt({}) == ""