Verifiziert auf WP20 Sitzungen 115 + 116. Format ist TH-aehnlich: Result-Anchor: "Damit ist [Subjekt] (mehrheitlich|einstimmig)? (angenommen|abgelehnt|überwiesen|so beschlossen)" Vote-Block (Q+A im Reden-Stil): - JA: "Wer dem zustimmen will ... Das sind die Fraktionen von X" - NEIN: "Wer stimmt dagegen? ... Das sind die Fraktionen von Y" - ENTH: "Wer enthaelt sich? ... Z" Drucksachen-Lookup: rueckwaerts vom Anchor Besonderheiten: - SSW (5%-Huerden-befreit) als feste Fraktion - "Damit ist die Ausschussueberweisung einstimmig so beschlossen" → ergebnis="ueberwiesen" - "Das sind alle anderen Fraktionen" → NEIN als Komplement von JA inferiert - Soft-Hyphen-Reparatur (PDF-Zeilenumbruch "zustim- men" → "zustimmen") - _last_match-Helper, weil 1500-char-Window mehrere Vote-Bloecke enthalten kann (TH-Limitierung gefixed) URL-Pattern (verifiziert): https://www.landtag.ltsh.de/export/sites/ltsh/infothek/wahl20/plenum/plenprot/{YYYY}/20-{n:03}_{MM-YY}.pdf Datum-Anteile (YYYY-Pfad + MM-YY-Suffix) machen URL-Vorhersage unmoeglich → Auto-Ingest-Cron via Index-Scrape (analog HH/HE): https://www.landtag.ltsh.de/infothek/wahl20/plenum/plenprot_seite/ Tests: 23 SH-Tests + Stub-Registry-Test angepasst. Stand: 7 produktive Parser (NRW, BUND, BE, HH, TH, HE, SH). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
159 lines
5.6 KiB
Python
159 lines
5.6 KiB
Python
"""Tests fuer app/protokoll_parsers/sh.py — SH Plenarprotokoll-Parser (#160).
|
||
|
||
Stichprobe-getestet gegen WP20 Sitzungen 115 + 116 (Schleswig-Holstein).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import pytest
|
||
|
||
from app.protokoll_parsers.sh import (
|
||
_normalize_fraktionen_sh,
|
||
_normalize_text,
|
||
_parse_vote_block_sh,
|
||
_resolve_drucksache_sh,
|
||
RESULT_ANCHOR_RE,
|
||
ALLE_FRAKTIONEN_SH,
|
||
FRAKTIONEN_MAP_SH,
|
||
)
|
||
|
||
|
||
class TestNormalizeFraktionenSh:
|
||
def test_simple_cdu(self):
|
||
assert _normalize_fraktionen_sh("die CDU") == ["CDU"]
|
||
|
||
def test_buendnis_normalizes_to_gruene(self):
|
||
assert _normalize_fraktionen_sh("BÜNDNIS 90/DIE GRÜNEN") == ["GRÜNE"]
|
||
|
||
def test_die_gruenen_normalizes(self):
|
||
assert _normalize_fraktionen_sh("DIE GRÜNEN") == ["GRÜNE"]
|
||
|
||
def test_ssw(self):
|
||
assert _normalize_fraktionen_sh("die SSW-Fraktion") == ["SSW"]
|
||
|
||
def test_combined_fraktionen(self):
|
||
result = _normalize_fraktionen_sh(
|
||
"die Fraktionen von SPD, FDP und SSW"
|
||
)
|
||
assert set(result) == {"SPD", "FDP", "SSW"}
|
||
|
||
def test_koalition_phrase(self):
|
||
result = _normalize_fraktionen_sh(
|
||
"die Fraktionen von CDU und BÜNDNIS 90/DIE GRÜNEN"
|
||
)
|
||
assert set(result) == {"CDU", "GRÜNE"}
|
||
|
||
def test_empty(self):
|
||
assert _normalize_fraktionen_sh("") == []
|
||
|
||
def test_no_double_count_gruene(self):
|
||
# 'BÜNDNIS 90/DIE GRÜNEN' und 'GRÜNE' beide getroffen → nur 1× GRÜNE
|
||
result = _normalize_fraktionen_sh("BÜNDNIS 90/DIE GRÜNEN und GRÜNE")
|
||
assert result.count("GRÜNE") == 1
|
||
|
||
|
||
class TestNormalizeText:
|
||
def test_collapses_whitespace(self):
|
||
assert _normalize_text("a b\n\tc") == "a b c"
|
||
|
||
def test_repairs_soft_hyphenation(self):
|
||
# Deutsche Silbentrennung am Zeilenumbruch
|
||
assert _normalize_text("zustim- men") == "zustimmen"
|
||
|
||
def test_preserves_legitimate_hyphens(self):
|
||
# Schulgeld-Kosten ist ein Kompositum, kein Trennstrich
|
||
# → "Schulgeld-Kosten" mit Großbuchstaben nach Bindestrich → bleibt
|
||
assert _normalize_text("Schulgeld-Kosten") == "Schulgeld-Kosten"
|
||
|
||
|
||
class TestParseVoteBlockSh:
|
||
def test_complete_qa_block(self):
|
||
block = (
|
||
"Wer dem zustimmen will, den bitte ich um das Handzeichen. "
|
||
"– Das sind die Fraktionen von SPD, FDP und SSW. "
|
||
"Wer stimmt dagegen? – Das sind die Fraktionen von CDU "
|
||
"und BÜNDNIS 90/DIE GRÜNEN. Damit ist der Antrag abgelehnt."
|
||
)
|
||
votes = _parse_vote_block_sh(block)
|
||
assert set(votes["ja"]) == {"SPD", "FDP", "SSW"}
|
||
assert set(votes["nein"]) == {"CDU", "GRÜNE"}
|
||
assert votes["enthaltung"] == []
|
||
|
||
def test_block_with_enthaltung(self):
|
||
block = (
|
||
"Wer dem zustimmen will, den bitte ich um das Handzeichen. "
|
||
"– Das sind die Fraktionen von FDP und SSW. "
|
||
"Wer stimmt dagegen? – Das ist die CDU-Fraktion. "
|
||
"Wer enthält sich? – Das sind die Fraktionen von "
|
||
"BÜNDNIS 90/DIE GRÜNEN und SPD. Damit ist der Antrag abgelehnt."
|
||
)
|
||
votes = _parse_vote_block_sh(block)
|
||
assert set(votes["ja"]) == {"FDP", "SSW"}
|
||
assert set(votes["nein"]) == {"CDU"}
|
||
assert set(votes["enthaltung"]) == {"GRÜNE", "SPD"}
|
||
|
||
|
||
class TestResolveDrucksacheSh:
|
||
def test_finds_drucksache_before_anchor(self):
|
||
text = (
|
||
"Drucksache 20/1234 ... Wer dem zustimmen will. – Das ist die SPD. "
|
||
"Damit ist der Antrag abgelehnt."
|
||
)
|
||
anchor = text.index("Damit")
|
||
assert _resolve_drucksache_sh(text, anchor) == "20/1234"
|
||
|
||
def test_picks_most_recent_drucksache(self):
|
||
text = (
|
||
"Drucksache 20/1000 ... Drucksache 20/2000 wird abgestimmt. "
|
||
"Damit ist der Antrag abgelehnt."
|
||
)
|
||
anchor = text.index("Damit")
|
||
assert _resolve_drucksache_sh(text, anchor) == "20/2000"
|
||
|
||
def test_returns_none_when_no_ds(self):
|
||
assert _resolve_drucksache_sh("Damit ist abgelehnt.", 0) is None
|
||
|
||
|
||
class TestResultAnchorRegex:
|
||
def test_matches_antrag_abgelehnt(self):
|
||
m = RESULT_ANCHOR_RE.search("Damit ist der Antrag abgelehnt.")
|
||
assert m and m.group("ergebnis") == "abgelehnt"
|
||
|
||
def test_matches_mehrheitlich_angenommen(self):
|
||
m = RESULT_ANCHOR_RE.search(
|
||
"Damit ist der Antrag mehrheitlich angenommen."
|
||
)
|
||
assert m and m.group("modus") == "mehrheitlich"
|
||
assert m.group("ergebnis") == "angenommen"
|
||
|
||
def test_matches_trotzdem_mit_mehrheit(self):
|
||
m = RESULT_ANCHOR_RE.search(
|
||
"Damit ist der Antrag trotzdem mit Mehrheit angenommen."
|
||
)
|
||
assert m and m.group("ergebnis") == "angenommen"
|
||
|
||
def test_matches_ausschussueberweisung_einstimmig(self):
|
||
m = RESULT_ANCHOR_RE.search(
|
||
"Damit ist die Ausschussüberweisung einstimmig so beschlossen."
|
||
)
|
||
assert m
|
||
assert m.group("modus") == "einstimmig"
|
||
assert "beschlossen" in m.group("ergebnis").lower()
|
||
|
||
def test_no_match_random_text(self):
|
||
m = RESULT_ANCHOR_RE.search("Der Antrag wurde abgelehnt.")
|
||
# 'Damit ist' fehlt
|
||
assert m is None
|
||
|
||
|
||
class TestConstants:
|
||
def test_all_fraktionen_complete(self):
|
||
# WP20-SH: CDU+GRÜNE Koalition, SPD+FDP+SSW Opposition
|
||
assert set(ALLE_FRAKTIONEN_SH) == {"CDU", "GRÜNE", "SPD", "FDP", "SSW"}
|
||
|
||
def test_mapping_covers_all_fraktionen(self):
|
||
all_codes = set()
|
||
for _phrase, codes in FRAKTIONEN_MAP_SH:
|
||
all_codes.update(codes)
|
||
for f in ALLE_FRAKTIONEN_SH:
|
||
assert f in all_codes, f"Fraktion {f} fehlt im FRAKTIONEN_MAP_SH"
|