gwoe-antragspruefer/tests/test_protokoll_parsers_nrw.py
Dotty Dotter ccff2e3e8e test(#134): NRW Protokoll-Parser Coverage 51.7% → 85.1%
parse_protocol mit fitz-Mock (FakeDoc/FakePage):
- simple_angenommen mit ja/nein-Block
- einstimmig direct_broad → ja-Liste fallback
- ueber + so beschlossen → einstimmig-Fallback fuellt ja-Liste mit
  ALLE_FRAKTIONEN_NRW
- skips_anchor_without_drucksache: kein vorheriges 'Drucksache' → skip

compare_to_fixture:
- perfect_match → 1/1
- not_found → 0/1 mit 'NOT FOUND'-Error
- nicht_gesondert_abgestimmt: korrekt nicht-gefunden zaehlt als match
- wrong_ergebnis → error 'ergebnis X != Y'

Total Coverage: 52.1% → 53.2%, 769 → 777 Tests.
2026-04-28 11:11:52 +02:00

350 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)