gwoe-antragspruefer/tests/test_protokoll_parser_nrw.py
Dotty Dotter d640734641 feat(#106,#134): NRW-Protokoll-Parser v5 ins Repo migriert
Vorher als parser_v5_iteration15.py nur auf Prod-Server, nicht
versionskontrolliert. Jetzt unter app/protokoll_parser_nrw.py
mit klarem Naming-Schema (BL-Suffix, damit Folge-Adapter analog
heissen koennen, vgl. ADR 0002).

Aenderungen am Code:
- from __future__ import annotations (Py3.9-kompatibel fuer 'str | None')
- fitz-Import optional (try/except), damit pure-string-Funktionen
  auch im Stub-conftest funktionieren

30 Tests in test_protokoll_parser_nrw.py (#134 Phase 2):
- normalize_fraktionen: F.D.P., GRÜNE-Aliase, Landesregierung
- _is_empty_phrase: Niemand/Keine/nicht-Mustern
- _parse_vote_block: ja/nein-Extraktion plus Negationen
- find_results: angenommen/abgelehnt, einstimmig (nur ueber-Kind!),
  (neu)-Suffix in Drucksachen-Nrn, Sortierung, Dedup
- resolve_drucksache_for_ueber: Backward-Search mit closest-match

Refs #106 (Abstimmungsverhalten verknuepfen — Vorbereitung fuer DB-Schema)
Refs #126 (BL-uebergreifender Parser — NRW als Referenz-Implementierung)
Refs #134 (Test-Suite Audit — Phase 2)
2026-04-28 02:08:03 +02:00

206 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_parser_nrw.py — NRW-Plenarprotokoll-Parser v5.
Backfill aus #134. 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_parser_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