gwoe-antragspruefer/tests/test_wahlprogramme.py

197 lines
9.5 KiB
Python
Raw Normal View History

Add pytest suite + fix two regex bugs uncovered by it (#46) Erste Tests für die Codebase. 77 Tests, 0.08s Laufzeit, decken die drei Bug-Klassen aus der April-2026-Adapter-Session ab plus haben schon zwei weitere Bugs in Production-Code aufgedeckt. ## Setup - requirements-dev.txt mit pytest + pytest-asyncio - pytest.ini mit asyncio_mode=auto - tests/conftest.py stubbt fitz/bs4/openai/pydantic_settings, damit die Suite ohne den vollen prod-requirements-Satz läuft (pure unit tests, kein PDF-Parsing, kein HTTP) ## Tests - tests/test_parlamente.py (33 Tests) * PortalaAdapter._parse_hit_list_cards: doctype/doctype_full NameError-Regression aus 1cb030a, plus Title/Drucksache/Fraktion- /Datum/PDF-Extraktion gegen ein BE-Card-Fixture * PortalaAdapter._parse_hit_list_dump: gegen ein LSA-Perl-Dump- Fixture inkl. Hex-Escape-Decoding (\x{fc} → ü) * PortalaAdapter._parse_hit_list_html: Auto-Detection zwischen Card- und Dump-Format * PortalaAdapter._normalize_fraktion: kanonische Fraktion-Codes inkl. F.D.P.-mit-Punkten, BÜNDNIS 90, DIE LINKE, BSW * ParLDokAdapter._hit_to_drucksache: JSON-Hit → Drucksache Mapping inkl. /navpanes-Stripping, MdL-mit-Partei-in-Klammern, Landesregierung-Detection * ParLDokAdapter._fulltext_id: bundle.js-mirroring (deferred, aber dokumentiert) * ADAPTERS-Registry-Sanity - tests/test_embeddings.py (11 Tests) * _chunk_source_label: Programm-Name + Seite (Halluzinations- Bug-Regression aus 1b5fd96) * format_quotes_for_prompt: jeder Chunk muss Programm-Name enthalten, strict-citation-Hinweis muss im Output sein, keine NRW-Halluzinationen für MV/BE-Chunk-Sets - tests/test_wahlprogramme.py (14 Tests) * Registry-Struktur (jahr int, seiten int, .pdf-Endung) * File-Existenz: jede registrierte PDF muss in static/referenzen/ liegen — würde Tippfehler in den 22 indexierten Programmen sofort fangen * embeddings.PROGRAMME-Konsistenz-Cross-Check - tests/test_bundeslaender.py (15 Tests) * Sanity über 16-State-Registry * #48-Klassifikations-Regression: TH=ParlDok, HB=StarWeb, SN=Eigensystem * Wahltermine plausibel (zwischen 2026 und 2035) - tests/test_analyzer.py (4 Tests) * Markdown-Codeblock-Stripping aus dem JSON-Retry-Loop ## Bug-Funde während der Test-Schreibphase Zwei Production-Bugs in den _normalize_fraktion-Helfern wurden durch die neuen Tests sofort aufgedeckt und im selben Commit gefixt: 1. PortalaAdapter._normalize_fraktion matched "F.D.P." (mit Punkten, wie historische SH/HB-Drucksachen) nicht — Regex \bFDP\b ist zu strikt. Fix: \bF\.?\s*D\.?\s*P\.?\b analog zu ParLDokAdapter. 2. ParLDokAdapter._normalize_fraktion (auch PortalaAdapter) matched "Ministerium der Finanzen" nicht als Landesregierung, weil \bMINISTER\b die Wortgrenze auch nach MINISTER verlangt — bei MINISTERIUM steht aber IUM danach, keine Wortgrenze. Fix: \bMINISTER ohne abschließendes \b. Beide Bugs hätten Fraktion-Felder bei Drucksachen der Bremischen Bürgerschaft (FDP-Listen) und bei Landesregierungs-Drucksachen in MV/LSA fälschlich leer gelassen — exakt der "fraktionen=[]"- Befund aus dem MV-Smoke-Test in #4. Phase 0 aus Roadmap-Issue #49. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:26:06 +02:00
"""Tests for wahlprogramme.py — registry consistency + file existence."""
import pytest
Add pytest suite + fix two regex bugs uncovered by it (#46) Erste Tests für die Codebase. 77 Tests, 0.08s Laufzeit, decken die drei Bug-Klassen aus der April-2026-Adapter-Session ab plus haben schon zwei weitere Bugs in Production-Code aufgedeckt. ## Setup - requirements-dev.txt mit pytest + pytest-asyncio - pytest.ini mit asyncio_mode=auto - tests/conftest.py stubbt fitz/bs4/openai/pydantic_settings, damit die Suite ohne den vollen prod-requirements-Satz läuft (pure unit tests, kein PDF-Parsing, kein HTTP) ## Tests - tests/test_parlamente.py (33 Tests) * PortalaAdapter._parse_hit_list_cards: doctype/doctype_full NameError-Regression aus 1cb030a, plus Title/Drucksache/Fraktion- /Datum/PDF-Extraktion gegen ein BE-Card-Fixture * PortalaAdapter._parse_hit_list_dump: gegen ein LSA-Perl-Dump- Fixture inkl. Hex-Escape-Decoding (\x{fc} → ü) * PortalaAdapter._parse_hit_list_html: Auto-Detection zwischen Card- und Dump-Format * PortalaAdapter._normalize_fraktion: kanonische Fraktion-Codes inkl. F.D.P.-mit-Punkten, BÜNDNIS 90, DIE LINKE, BSW * ParLDokAdapter._hit_to_drucksache: JSON-Hit → Drucksache Mapping inkl. /navpanes-Stripping, MdL-mit-Partei-in-Klammern, Landesregierung-Detection * ParLDokAdapter._fulltext_id: bundle.js-mirroring (deferred, aber dokumentiert) * ADAPTERS-Registry-Sanity - tests/test_embeddings.py (11 Tests) * _chunk_source_label: Programm-Name + Seite (Halluzinations- Bug-Regression aus 1b5fd96) * format_quotes_for_prompt: jeder Chunk muss Programm-Name enthalten, strict-citation-Hinweis muss im Output sein, keine NRW-Halluzinationen für MV/BE-Chunk-Sets - tests/test_wahlprogramme.py (14 Tests) * Registry-Struktur (jahr int, seiten int, .pdf-Endung) * File-Existenz: jede registrierte PDF muss in static/referenzen/ liegen — würde Tippfehler in den 22 indexierten Programmen sofort fangen * embeddings.PROGRAMME-Konsistenz-Cross-Check - tests/test_bundeslaender.py (15 Tests) * Sanity über 16-State-Registry * #48-Klassifikations-Regression: TH=ParlDok, HB=StarWeb, SN=Eigensystem * Wahltermine plausibel (zwischen 2026 und 2035) - tests/test_analyzer.py (4 Tests) * Markdown-Codeblock-Stripping aus dem JSON-Retry-Loop ## Bug-Funde während der Test-Schreibphase Zwei Production-Bugs in den _normalize_fraktion-Helfern wurden durch die neuen Tests sofort aufgedeckt und im selben Commit gefixt: 1. PortalaAdapter._normalize_fraktion matched "F.D.P." (mit Punkten, wie historische SH/HB-Drucksachen) nicht — Regex \bFDP\b ist zu strikt. Fix: \bF\.?\s*D\.?\s*P\.?\b analog zu ParLDokAdapter. 2. ParLDokAdapter._normalize_fraktion (auch PortalaAdapter) matched "Ministerium der Finanzen" nicht als Landesregierung, weil \bMINISTER\b die Wortgrenze auch nach MINISTER verlangt — bei MINISTERIUM steht aber IUM danach, keine Wortgrenze. Fix: \bMINISTER ohne abschließendes \b. Beide Bugs hätten Fraktion-Felder bei Drucksachen der Bremischen Bürgerschaft (FDP-Listen) und bei Landesregierungs-Drucksachen in MV/LSA fälschlich leer gelassen — exakt der "fraktionen=[]"- Befund aus dem MV-Smoke-Test in #4. Phase 0 aus Roadmap-Issue #49. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:26:06 +02:00
from app.wahlprogramme import (
WAHLPROGRAMME,
REFERENZEN_PATH,
get_wahlprogramm,
parteien_mit_wahlprogramm,
)
# ─────────────────────────────────────────────────────────────────────────────
# 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")
# ─────────────────────────────────────────────────────────────────────────────
# 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_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
# Build expected programm_id from filename: "cdu-mv-2021.pdf" → "cdu-mv-2021"
missing = []
for bl, parteien in WAHLPROGRAMME.items():
for partei, info in parteien.items():
pid = info["file"].rsplit(".", 1)[0]
if pid not in PROGRAMME:
missing.append(f"{bl}/{partei}{pid}")
assert not missing, (
"WAHLPROGRAMME entries missing in embeddings.PROGRAMME:\n "
+ "\n ".join(missing)
)
# ─────────────────────────────────────────────────────────────────────────────
# 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({}) == ""