"""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({}) == ""