"""Tests fuer app/protokoll_parsers/nrw.py — NRW-Plenarprotokoll-Parser v5. Backfill aus #134, BL-Refactor aus #126. Der Parser ist deterministisch und anchor-basiert; jede Aenderung an den RESULT_ANCHORS oder den Vote-Block-Regexes muss sofort durch diese Tests fallen. Die echte 19/19-Garantie auf MMP18-119 laeuft separat als Integration-Test (braucht das PDF). Hier: pure-string-Tests fuer alle Reverse-Engineering-Findings, die bei der iterativen Entwicklung 1-15 dokumentiert wurden. """ from __future__ import annotations import sys import types # fitz ist via tests/conftest.py gestubbed — Pure-String-Funktionen kommen ohne aus. from app.protokoll_parsers.nrw import ( normalize_fraktionen, find_results, resolve_drucksache_for_ueber, normalize_text, _is_empty_phrase, _parse_vote_block, ALLE_FRAKTIONEN_NRW, ) class TestNormalizeFraktionen: def test_simple_cdu(self): assert normalize_fraktionen("Wer stimmt zu? – CDU") == ["CDU"] def test_multiple_fraktionen(self): result = normalize_fraktionen("CDU, SPD und GRÜNE") assert result == sorted(["CDU", "SPD", "GRÜNE"]) def test_buendnis_90_normalizes_to_gruene(self): assert normalize_fraktionen("Bündnis 90/Die Grünen") == ["GRÜNE"] def test_fdp_with_dots_normalizes(self): """F.D.P. (mit Punkten) muss als FDP erkannt werden.""" assert normalize_fraktionen("F.D.P.") == ["FDP"] def test_no_double_match_for_overlapping_keys(self): """'GRÜNE' darf nicht zusaetzlich als 'Grünen' wieder gematcht werden.""" result = normalize_fraktionen("GRÜNE und Grünen") # Beide Tokens sind dieselbe Fraktion → nur einmal in der Liste assert result.count("GRÜNE") == 1 def test_landesregierung_recognized(self): assert "Landesregierung" in normalize_fraktionen("Landesregierung") def test_empty_text_returns_empty(self): assert normalize_fraktionen("") == [] def test_no_known_partei(self): assert normalize_fraktionen("Some random text") == [] class TestIsEmptyPhrase: def test_niemand_is_empty(self): assert _is_empty_phrase("Stimmt jemand dagegen? – Niemand") is True def test_keine_is_empty(self): assert _is_empty_phrase("Enthaltungen? – Keine") is True def test_nicht_der_fall(self): assert _is_empty_phrase("Das ist nicht der Fall.") is True def test_actual_fraktion_is_not_empty(self): assert _is_empty_phrase("CDU und SPD") is False class TestParseVoteBlock: def test_simple_ja_extraction(self): block = "Wer stimmt zu? – CDU und SPD." votes = _parse_vote_block(block) assert "CDU" in votes["ja"] and "SPD" in votes["ja"] def test_ja_with_negation_returns_empty(self): """'Wer stimmt zu? – Niemand.' → ja-Liste muss leer sein.""" block = "Wer stimmt zu? – Niemand." votes = _parse_vote_block(block) assert votes["ja"] == [] def test_nein_extraction(self): block = "Wer stimmt dagegen? – AfD." votes = _parse_vote_block(block) assert "AfD" in votes["nein"] def test_dagegen_negation(self): block = "Wer stimmt dagegen? – Das ist nicht der Fall." votes = _parse_vote_block(block) assert votes["nein"] == [] class TestFindResults: def test_direct_angenommen(self): text = ( "Damit ist der Antrag Drucksache 18/123 mit den Stimmen " "der CDU und der SPD angenommen." ) results = find_results(text) assert len(results) == 1 r = results[0] assert r["drucksache"] == "18/123" assert r["ergebnis"] == "angenommen" def test_direct_abgelehnt(self): text = ( "Damit ist der Antrag Drucksache 18/9999 mit den Stimmen " "der CDU gegen die Stimmen der SPD abgelehnt." ) results = find_results(text) assert any(r["drucksache"] == "18/9999" and r["ergebnis"] == "abgelehnt" for r in results) def test_einstimmig_flag_only_for_ueber_kind(self): """v5-Verhalten dokumentiert: 'einstimmig' wird in direct-kind-Anchors NICHT gesetzt, nur in ueber/petition/uebersicht. Dieser Test pinnt das aktuelle Verhalten — wenn v6 einstimmig auch fuer direct erkennt, muss der Test angepasst werden.""" text = "Damit ist der Antrag Drucksache 18/100 einstimmig angenommen." results = find_results(text) assert results[0]["kind"] == "direct_broad" # einstimmig wird hier (noch) nicht gesetzt — Reverse-Engineering-Befund assert results[0]["einstimmig"] is False def test_einstimmig_flag_for_ueberweisung(self): """Bei Ueberweisungs-Anchors mit 'einstimmig' im naechsten Token-Bereich wird das Flag gesetzt.""" text = "Drucksache 18/100 ... Damit ist diese Überweisungsempfehlung einstimmig angenommen." results = find_results(text) ueber_results = [r for r in results if r["kind"] == "ueber"] assert ueber_results, "kein ueber-Result im Test-Text gefunden" assert ueber_results[0]["einstimmig"] is True def test_ueberweisung_so_beschlossen_implies_einstimmig(self): """'Damit ist das so beschlossen' = implizit einstimmige Ueberweisung.""" text = "Drucksache 18/200 ... Damit ist das so beschlossen." results = find_results(text) assert any(r["kind"] == "ueber" and r["einstimmig"] for r in results) def test_neu_suffix_in_drucksachenummer(self): """Drucksache-Nummern mit (neu)-Suffix muessen matchen.""" text = "Damit ist der Antrag Drucksache 18/4567(neu) angenommen." results = find_results(text) # Match irgendwo in den Results assert any(r["drucksache"] == "18/4567(neu)" for r in results) def test_results_sorted_by_position(self): """Mehrere Anchors muessen nach anchor_start aufsteigend sortiert sein.""" text = ( "Damit ist der Antrag Drucksache 18/100 angenommen. " "Spaeter im Text. Damit ist der Antrag Drucksache 18/200 abgelehnt." ) results = find_results(text) positions = [r["anchor_start"] for r in results] assert positions == sorted(positions) def test_dedup_same_position(self): """Wenn zwei Patterns am selben anchor_start matchen, nur einer im Output.""" text = "Damit ist der Antrag Drucksache 18/300 angenommen." results = find_results(text) positions = [r["anchor_start"] for r in results] assert len(positions) == len(set(positions)) class TestResolveDrucksacheForUeber: def test_finds_nearest_ds_before_anchor(self): text = "Drucksache 18/100 ... irgendein Text ... Damit ist das so beschlossen." anchor_start = text.find("Damit") ds = resolve_drucksache_for_ueber(text, anchor_start) assert ds == "18/100" def test_picks_closest_when_multiple(self): """Bei mehreren DS-Nrn vor dem Anchor wird die naechste gewaehlt.""" text = "Drucksache 18/100 ... Drucksache 18/200 ... Damit ist das so beschlossen." anchor_start = text.find("Damit") ds = resolve_drucksache_for_ueber(text, anchor_start) assert ds == "18/200" def test_returns_none_when_no_ds_before(self): text = "Damit ist das so beschlossen. Drucksache 18/100 spaeter." anchor_start = 0 ds = resolve_drucksache_for_ueber(text, anchor_start) assert ds is None class TestNormalizeText: def test_collapses_whitespace(self): """Mehrfach-Whitespace wird zu einzelnem Leerzeichen kollabiert.""" result = normalize_text("Damit ist\nder\tAntrag") assert " " not in result def test_preserves_drucksache_format(self): """Drucksache-Schreibweise mit Slash muss erhalten bleiben.""" result = normalize_text("Drucksache 18/123") assert "18/123" in result class TestKnownFraktionsList: def test_alle_fraktionen_nrw_complete(self): """ALLE_FRAKTIONEN_NRW deckt die WP18-Fraktionen ab (CDU, SPD, GRÜNE, FDP, AfD).""" for f in ("CDU", "SPD", "GRÜNE", "FDP", "AfD"): assert f in ALLE_FRAKTIONEN_NRW # ─── parse_protocol mit fitz-Mock (#134 Backfill) ───────────────────────────── class TestParseProtocol: """Integration-light: parse_protocol mit gemocktem fitz, sodass die Pipeline find_results → segment-detection → vote-block-Aufloesung end-to-end laeuft.""" def _patch_fitz(self, monkeypatch, full_text: str): """Patcht fitz.open so, dass ein Mock-Document mit dem gegebenen Volltext zurueckkommt.""" from unittest.mock import MagicMock from app.protokoll_parsers import nrw as nrw_mod class FakePage: def __init__(self, text): self._text = text def get_text(self): return self._text class FakeDoc: def __init__(self, text): self._pages = [FakePage(text)] def __iter__(self): return iter(self._pages) def close(self): pass monkeypatch.setattr(nrw_mod.fitz, "open", lambda path: FakeDoc(full_text), raising=False) def test_simple_angenommen(self, monkeypatch): from app.protokoll_parsers.nrw import parse_protocol text = ( "Wir kommen zur Abstimmung über Drucksache 18/100. " "Wer stimmt zu? – CDU und SPD. Wer stimmt dagegen? – AfD. " "Damit ist der Antrag Drucksache 18/100 angenommen." ) self._patch_fitz(monkeypatch, text) result = parse_protocol("/tmp/dummy.pdf") assert result first = result[0] assert first["drucksache"] == "18/100" assert first["ergebnis"] == "angenommen" assert "CDU" in first["votes"]["ja"] assert "AfD" in first["votes"]["nein"] def test_einstimmig_fills_all_fraktionen(self, monkeypatch): from app.protokoll_parsers.nrw import parse_protocol from app.protokoll_parsers.nrw import ALLE_FRAKTIONEN_NRW text = "Damit ist der Antrag Drucksache 18/200 einstimmig beschlossen." self._patch_fitz(monkeypatch, text) result = parse_protocol("/tmp/dummy.pdf") # Auch wenn der Parser nicht einstimmig=True setzt fuer direct_broad, # muessen alle ja-Fraktionen drin sein wenn das Flag korrekt war. # Hier akzeptieren wir, dass ergebnis 'angenommen' (verabschiedet→angenommen), # einstimmig-Verhalten wie find_results-Test schon validiert. assert result assert result[0]["drucksache"] == "18/200" assert result[0]["ergebnis"] == "angenommen" def test_ueberweisung_so_beschlossen_uses_einstimmig_fallback(self, monkeypatch): from app.protokoll_parsers.nrw import parse_protocol, ALLE_FRAKTIONEN_NRW text = ( "Wir kommen zur Abstimmung über Drucksache 18/300. " "Damit ist das so beschlossen." ) self._patch_fitz(monkeypatch, text) result = parse_protocol("/tmp/dummy.pdf") assert result # ueber-Kind + 'so beschlossen' → einstimmig-Fallback fuellt ja-Liste ja = result[0]["votes"]["ja"] for frak in ALLE_FRAKTIONEN_NRW: assert frak in ja assert result[0]["votes"]["nein"] == [] assert result[0]["ergebnis"] == "überwiesen" def test_skips_anchor_without_drucksache(self, monkeypatch): from app.protokoll_parsers.nrw import parse_protocol # Anchor ohne aufloesbare Drucksache (kein vorheriges 'Drucksache N/M') text = "Damit ist das so beschlossen. Drucksache 18/400 ist spaeter." self._patch_fitz(monkeypatch, text) result = parse_protocol("/tmp/dummy.pdf") # Anchor wird uebersprungen assert result == [] def test_compare_to_fixture_perfect_match(self): """compare_to_fixture: Parser-Output entspricht der Ground-Truth → 1/1.""" from app.protokoll_parsers.nrw import compare_to_fixture parsed = [{"drucksache": "18/1", "ergebnis": "angenommen", "votes": {"ja": ["CDU"], "nein": [], "enthaltung": []}}] fixture = { "drucksachen": [ {"drucksache": "18/1", "ergebnis": "angenommen", "ja": ["CDU"], "nein": [], "enthaltung": []} ] } matches, errors = compare_to_fixture(parsed, fixture) assert matches == 1 assert errors == [] def test_compare_to_fixture_not_found(self): from app.protokoll_parsers.nrw import compare_to_fixture parsed = [] fixture = { "drucksachen": [ {"drucksache": "18/99", "ergebnis": "angenommen", "ja": [], "nein": [], "enthaltung": []} ] } matches, errors = compare_to_fixture(parsed, fixture) assert matches == 0 assert any("NOT FOUND" in e for e in errors) def test_compare_to_fixture_nicht_gesondert(self): """Parser darf bei 'nicht_gesondert_abgestimmt' den Eintrag nicht finden.""" from app.protokoll_parsers.nrw import compare_to_fixture # Nicht in parsed enthalten → korrekt parsed = [] fixture = { "drucksachen": [ {"drucksache": "18/77", "ergebnis": "nicht_gesondert_abgestimmt", "ja": [], "nein": [], "enthaltung": []} ] } matches, _ = compare_to_fixture(parsed, fixture) assert matches == 1 def test_compare_to_fixture_wrong_ergebnis(self): from app.protokoll_parsers.nrw import compare_to_fixture parsed = [{"drucksache": "18/3", "ergebnis": "abgelehnt", "votes": {"ja": [], "nein": ["CDU"], "enthaltung": []}}] fixture = { "drucksachen": [ {"drucksache": "18/3", "ergebnis": "angenommen", "ja": ["CDU"], "nein": [], "enthaltung": []} ] } matches, errors = compare_to_fixture(parsed, fixture) assert matches == 0 assert any("ergebnis abgelehnt != angenommen" in e for e in errors)