gwoe-antragspruefer/tests/test_protokoll_parsers_nrw.py
Dotty Dotter 7de4df1fef feat(#126): protokoll_parsers/-Sub-Package + Registry-Pattern + ADR 0009
Architektur-Refactor zur Vorbereitung BL-uebergreifender Parser:

- app/protokoll_parser_nrw.py → app/protokoll_parsers/nrw.py
- app/ingest_votes_nrw.py → app/ingest_votes.py (BL-uebergreifend)
- Neue app/protokoll_parsers/__init__.py mit:
  - PROTOKOLL_PARSERS-Dict (BL-Code → Parser-Funktion, derzeit nur NRW)
  - parse_protocol(bundesland, pdf_path) als BL-uebergreifender Einstieg
  - supported_bundeslaender()-Helper
  - NotImplementedError mit hilfreicher Message bei unbekanntem BL

CLI bekommt --supported-Flag fuer BL-Discovery:
  python -m app.ingest_votes --supported  → 'NRW'

ADR 0009 dokumentiert das Muster (Sub-Package + Funktions-Registry,
analog zu ADR 0002 fuer ParlamentAdapter). Folge-BL bekommen je
eine eigene Datei und einen Eintrag in PROTOKOLL_PARSERS — kein
Refactoring der Bestands-Logik.

Tests:
- 7 neue Tests in test_protokoll_parsers.py fuer Registry und Dispatch
- Bestehende NRW-Tests umbenannt zu test_protokoll_parsers_nrw.py,
  Imports angepasst — keine Verhaltens-Aenderung
- Bestehende Ingest-Tests umbenannt zu test_ingest_votes.py

642 Tests gruen, kein Verhaltens-Drift.
2026-04-28 08:37:31 +02:00

207 lines
8.1 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