gwoe-antragspruefer/tests/test_protokoll_parsers_sh.py
Dotty Dotter 7ebdc78331 feat(#160): SH-Parser produktiv — Schleswig-Holsteiner Plenarprotokolle
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>
2026-04-29 01:29:06 +02:00

159 lines
5.6 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/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"