diff --git a/app/protokoll_parsers/__init__.py b/app/protokoll_parsers/__init__.py index e02a2fb..c38452e 100644 --- a/app/protokoll_parsers/__init__.py +++ b/app/protokoll_parsers/__init__.py @@ -34,6 +34,7 @@ from .be import parse_protocol as _parse_be from .hh import parse_protocol as _parse_hh from .th import parse_protocol as _parse_th from .he import parse_protocol as _parse_he +from .sh import parse_protocol as _parse_sh # Typ-Alias fuer Lesbarkeit; Parser-Signatur ist bewusst minimal. ProtokollParser = Callable[[str], list[dict]] @@ -45,6 +46,7 @@ PROTOKOLL_PARSERS: dict[str, ProtokollParser] = { "HH": _parse_hh, "TH": _parse_th, "HE": _parse_he, + "SH": _parse_sh, } diff --git a/app/protokoll_parsers/sh.py b/app/protokoll_parsers/sh.py index 12ab25c..a4bfd90 100644 --- a/app/protokoll_parsers/sh.py +++ b/app/protokoll_parsers/sh.py @@ -1,47 +1,220 @@ -"""Schleswig-Holstein (SH) — Plenarprotokoll-Parser STUB (#106 Folge, ADR 0009). +"""Schleswig-Holstein (SH) — Plenarprotokoll-Parser (#106 / #160, ADR 0009). -**Status: noch nicht implementiert.** Dieser Modul-Stub enthaelt -Recherche-Findings vom 2026-04-28, sodass die Implementer-Session -direkt produktiv loslegen kann. Der Stub wird **nicht** in -``app.protokoll_parsers.PROTOKOLL_PARSERS`` registriert — der -Auto-Ingest-Cron ueberspringt SH solange. +URL-Pattern (verifiziert WP20 Sitzungen 115, 116): +``https://www.landtag.ltsh.de/export/sites/ltsh/infothek/wahl20/plenum/plenprot/{YYYY}/20-{n:03}_{MM-YY}.pdf`` -## Recherche +Datums-Anteil (YYYY-Pfad + MM-YY-Suffix) macht reine URL-Vorhersage +unmoeglich → Auto-Ingest scrapt Index-Seite +``https://www.landtag.ltsh.de/infothek/wahl20/plenum/plenprot_seite/``. -| Feld | Wert | -|---|---| -| **Doku-System** | StarWeb | -| **Base-URL** | http://lissh.lvn.parlanet.de | -| **Familie** | HB/NI-Familie | -| **Format** | PDF erwartet | +## Anchor-Sprache (verifiziert WP20 Sitzung 116) -## URL-Discovery +``` +... – 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. +``` -Starfinder-CGI-Backend; Plenum-Protokolle als PDF-Direktlinks moeglich +Pattern (TH-aehnlich): +- **Result-Anchor:** ``Damit ist [Subjekt] (mehrheitlich|einstimmig|mit Mehrheit|trotzdem mit Mehrheit)? (angenommen|abgelehnt|überwiesen)`` +- **Vote-Block:** Q+A im Reden-Stil + - JA: ``Wer dem zustimmt ... Das sind die Fraktionen von [PHRASE]`` / + ``Das ist die [PHRASE]-Fraktion`` + - NEIN: ``Wer stimmt dagegen? ... [PHRASE]`` / ``Gegenstimmen? ... [PHRASE]`` + - ENTH: ``Wer enthaelt sich? ... [PHRASE]`` (oft fehlend) +- **Drucksachen-Lookup:** ``Drucksache 20/N`` rueckwaerts vom Anchor -## Bezug +## Fraktions-Mapping WP20 (ab 2022) -- Architektur: ADR 0009 (Plenarprotokoll-Parser-Registry) -- Roadmap: ``docs/protokoll-parser-roadmap.md`` -- Referenz-Implementation: ``app/protokoll_parsers/nrw.py`` - (38 Tests, 19/19-Fixture-Garantie) -- Folge-Issue: https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/160 (Titel: "protokoll-parser: SH (Schleswig-Holstein)") +WP20 Konstellation: CDU + GRÜNE (Koalition), SPD + FDP + SSW (Opposition). +SSW ist 5%-Huerden-befreit, daher fester Bestandteil. -## Aufwand - -Geschaetzt 1-3 Tage konzentrierte Arbeit: -- 2-4h URL-Discovery + Format-Inspektion (Sample-Protokoll inhaltlich anschauen) -- 4-8h Anchor-Phrasen-Reverse-Engineering + Parser-Implementierung -- 4h Tests mit Fixture-Pinning -- 1h Eintrag in PROTOKOLL_PARSERS + auto-ingest-protocols.sh +- ``CDU``, ``SPD``, ``FDP``, ``SSW`` +- ``BÜNDNIS 90/DIE GRÜNEN`` / ``GRÜNEN`` / ``GRÜNE`` → GRÜNE """ from __future__ import annotations +import re +from typing import Optional -def parse_protocol(path: str) -> list[dict]: - """STUB — siehe Modul-Docstring.""" - raise NotImplementedError( - "SH-Plenarprotokoll-Parser ist noch nicht implementiert. " - "Siehe app/protokoll_parsers/sh.py-Docstring fuer Recherche-Findings " - "und docs/protokoll-parser-roadmap.md." - ) +try: + import fitz +except ImportError: + fitz = None + + +ALLE_FRAKTIONEN_SH = ["CDU", "GRÜNE", "SPD", "FDP", "SSW"] + +FRAKTIONEN_MAP_SH = [ + ("BÜNDNIS 90/DIE GRÜNEN", ["GRÜNE"]), + ("BÜNDNIS 90", ["GRÜNE"]), + ("DIE GRÜNEN", ["GRÜNE"]), + ("GRÜNEN", ["GRÜNE"]), + ("GRÜNE", ["GRÜNE"]), + ("CDU", ["CDU"]), + ("SPD", ["SPD"]), + ("FDP", ["FDP"]), + ("SSW", ["SSW"]), +] + + +def _normalize_fraktionen_sh(text: str) -> list[str]: + found = set() + remaining = text + for phrase, codes in FRAKTIONEN_MAP_SH: + if phrase in remaining: + for c in codes: + found.add(c) + remaining = remaining.replace(phrase, " ") + return sorted(found) + + +# Result-Anchor: "Damit ist/sind [Subjekt] (modus)? (ergebnis)" +# Erfasst auch "Damit ist die Ausschussueberweisung einstimmig so beschlossen" +# als ergebnis="überwiesen". +RESULT_ANCHOR_RE = re.compile( + r"Damit\s+(?:ist|sind)\s+(?:der|die|das|dieser?|dieses|beide|alle|auch)?\s*" + r"(?PAntrag|Alternativantrag|Änderungsantrag|Gesetzentwurf|" + r"Beschlussempfehlung|Tagesordnungspunkt|Anträge|Ausschussüberweisung|" + r"trotzdem)?" + r"[^.]{0,200}?(?Peinstimmig|mehrheitlich|mit\s+(?:großer\s+)?Mehrheit|" + r"trotzdem mit Mehrheit)?\s*" + r"(?Pangenommen|abgelehnt|überwiesen|so\s+beschlossen)", + re.DOTALL, +) + + +# Vote-Sub-Patterns. SH-Form ist Q+A: +# Q: "Wer dem zustimmen will, den bitte ich um das Handzeichen." +# A: "– Das sind die Fraktionen von SPD, FDP und SSW." +# Wir matchen Q komplett, dann ab dem ersten `–` die Antwort. +JA_RE = re.compile( + r"Wer\s+(?:dem|dafür|so|der|den)?\s*" + r"(?:zustimmen|zustimmt|stimmen|verfahren|" + r"beschließen|so\s+beschließen|für\s+\S+|ist\s+dafür)" + r"\s*(?:will|möchte|kann|ist)?" + r"[^–]{0,200}–\s*" + r"(?:Das\s+(?:sind|ist)\s+(?:die\s+)?(?:Fraktionen?\s+(?:von|der)\s+|" + r"einstimmig)?)?" + r"(?P[^?.]+?)(?=\.\s|\?|Wer\s+(?:stimmt|enth|dagegen|ist\s+dagegen)|" + r"Gegenstimmen|Damit|Stimmenthaltungen?)", + re.DOTALL, +) +NEIN_RE = re.compile( + r"(?:Wer\s+(?:stimmt\s+dagegen|ist\s+dagegen)|Gegenstimmen)\??[^–]{0,80}–\s*" + r"(?:Bei\s+Gegenstimmen\s+(?:der\s+)?(?:Fraktionen\s+von\s+)?)?" + r"(?:Das\s+(?:sind|ist)\s+(?:die\s+)?(?:Fraktionen?\s+(?:von|der)\s+)?)?" + r"(?P[^?.]+?)(?=\.\s|\?|Wer\s+enth|Damit|Stimmenthaltung)", + re.DOTALL, +) +ENTH_RE = re.compile( + r"(?:Wer\s+enthält\s+sich|Stimmenthaltungen?)\??[^–]{0,80}–\s*" + r"(?:Das\s+(?:sind|ist)\s+(?:die\s+)?(?:Fraktionen?\s+(?:von|der)\s+)?)?" + r"(?P[^?.]+?)(?=\.\s|\?|Damit|Wer)", + re.DOTALL, +) + +DS_RE_SH = re.compile(r"Drucksache\s+20/(\d{2,5})") + + +def _last_match(pattern: re.Pattern, text: str) -> Optional[re.Match]: + """Return the LAST match — vote-blocks vor einem Anchor sollen den + naechstliegenden Q+A-Block treffen, nicht den ersten.""" + last = None + for m in pattern.finditer(text): + last = m + return last + + +def _parse_vote_block_sh(block: str) -> dict: + votes = {"ja": [], "nein": [], "enthaltung": []} + + ja_m = _last_match(JA_RE, block) + if ja_m: + votes["ja"] = _normalize_fraktionen_sh(ja_m.group("ja")) + + nein_m = _last_match(NEIN_RE, block) + if nein_m: + votes["nein"] = _normalize_fraktionen_sh(nein_m.group("nein")) + + enth_m = _last_match(ENTH_RE, block) + if enth_m: + enth_text = enth_m.group("enth") + low = enth_text.lower() + if "niemand" in low or "keine" in low: + votes["enthaltung"] = [] + else: + votes["enthaltung"] = _normalize_fraktionen_sh(enth_text) + + return votes + + +def _resolve_drucksache_sh(text: str, anchor_start: int) -> Optional[str]: + window_start = max(0, anchor_start - 1500) + window = text[window_start:anchor_start] + matches = list(DS_RE_SH.finditer(window)) + if matches: + return f"20/{matches[-1].group(1)}" + return None + + +def _normalize_text(text: str) -> str: + # Soft-hyphenation reparieren: 'zustim- men' → 'zustimmen' (PDF-Zeilen- + # umbruch mit Trennstrich, deutsche Silbentrennung am Wortinneren). + text = re.sub(r"(?<=[a-zäöüß])-\s+(?=[a-zäöüß])", "", text) + return re.sub(r"\s+", " ", text) + + +def parse_protocol(pdf_path: str) -> list[dict]: + if fitz is None: + raise ImportError("PyMuPDF (fitz) ist erforderlich fuer den SH-Parser") + + doc = fitz.open(pdf_path) + full = "".join(p.get_text() for p in doc) + doc.close() + full = _normalize_text(full) + + results = [] + for m in RESULT_ANCHOR_RE.finditer(full): + modus = (m.group("modus") or "").lower() + ergebnis_raw = m.group("ergebnis").lower() + # "so beschlossen" tritt bei Ausschussueberweisung auf + if "beschlossen" in ergebnis_raw: + ergebnis = "überwiesen" + else: + ergebnis = ergebnis_raw + block_start = max(0, m.start() - 1500) + block = full[block_start:m.end()] + + ds = _resolve_drucksache_sh(full, m.start()) + if not ds: + continue + + votes = _parse_vote_block_sh(block) + einstimmig = "einstimmig" in modus + + if einstimmig and not votes["ja"]: + votes["ja"] = list(ALLE_FRAKTIONEN_SH) + + # "Das sind alle anderen Fraktionen" → NEIN-Komplement von JA + if (votes["ja"] and not votes["nein"] + and "alle anderen" in block[-400:].lower()): + votes["nein"] = sorted(set(ALLE_FRAKTIONEN_SH) - set(votes["ja"])) + + results.append({ + "drucksache": ds, + "ergebnis": ergebnis, + "einstimmig": einstimmig, + "kind": "direct", + "votes": votes, + "anchor_pos": m.start(), + }) + + seen = set() + deduped = [] + for r in results: + if r["anchor_pos"] in seen: + continue + seen.add(r["anchor_pos"]) + deduped.append(r) + return deduped diff --git a/docs/protokoll-parser-roadmap.md b/docs/protokoll-parser-roadmap.md index 4362d31..43b4b66 100644 --- a/docs/protokoll-parser-roadmap.md +++ b/docs/protokoll-parser-roadmap.md @@ -23,15 +23,15 @@ Body und der Eintrag wird in `PROTOKOLL_PARSERS` ergaenzt. | BW | `bw.py` | [#151](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/151) | ⚠ Stub (Datenmodell-Inkompatibilitaet) | | BY | `by.py` | [#152](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/152) | 📋 Stub | | HB | `hb.py` | [#153](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/153) | 📋 Stub | -| HE | `he.py` | [#154](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/154) | 📋 Stub | +| **HE** | `he.py` | [#154](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/154) | ✅ produktiv (Status-Only, Beschlussprotokoll WP21, Cron via Index-Scrape) | | LSA | `lsa.py` | [#156](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/156) | 📋 Stub | | MV | `mv.py` | [#157](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/157) | 📋 Stub | | NI | `ni.py` | [#158](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/158) | 📋 Stub | | RP | `rp.py` | [#159](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/159) | 📋 Stub | -| SH | `sh.py` | [#160](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/160) | 📋 Stub | +| **SH** | `sh.py` | [#160](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/160) | ✅ produktiv (WP20 Sample-Tests S115/S116 grün, Cron via Index-Scrape) | | SL | `sl.py` | [#161](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/161) | 📋 Stub | | SN | `sn.py` | [#162](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/162) | 📋 Stub | -| TH | `th.py` | [#163](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/163) | 📋 Stub | +| **TH** | `th.py` | [#163](https://repo.toppyr.de/tobias/gwoe-antragspruefer/issues/163) | ✅ produktiv (WP8 Sample, Cron mit URL-Pattern) | ## Pattern fuer neue BL diff --git a/scripts/auto-ingest-protocols.sh b/scripts/auto-ingest-protocols.sh index c56bd40..98713d7 100755 --- a/scripts/auto-ingest-protocols.sh +++ b/scripts/auto-ingest-protocols.sh @@ -159,6 +159,66 @@ for m in matches: print(f" HE: {new_count} neue Protokolle ingestet") EOF +# ─── SH: Index-Page-Scrape (URL enthaelt Jahr + MM-YY-Suffix) ───────── +# SH-URL hat Jahr-Pfad + MM-YY-Suffix, daher Index-Scrape. +echo "--- SH WP20 (Index-Scrape) ---" +docker exec "$CONTAINER" python <<'EOF' +import re, sys +import urllib.request +import sqlite3 +import asyncio + +req = urllib.request.Request( + "https://www.landtag.ltsh.de/infothek/wahl20/plenum/plenprot_seite/", + headers={"User-Agent": "Mozilla/5.0 GWOeAntragspruefer"}, +) +try: + html = urllib.request.urlopen(req, timeout=20).read().decode("utf-8", errors="replace") +except Exception as e: + print(f" Index-Scrape fehlgeschlagen: {e}") + sys.exit(0) + +# href="/export/sites/ltsh/infothek/wahl20/plenum/plenprot/{YYYY}/20-{n:03}_{MM-YY}.pdf" +pdf_re = re.compile( + r'href="(/export/sites/ltsh/infothek/wahl20/plenum/plenprot/(\d{4})/' + r'20-(\d{3})_(\d{2}-\d{2})\.pdf)"' +) +matches = list(pdf_re.finditer(html)) +print(f" {len(matches)} SH-Plenarprotokolle in Index gefunden") + +db = sqlite3.connect("/app/data/gwoe-antraege.db") +existing = {row[0] for row in db.execute( + "SELECT quelle_protokoll FROM plenum_vote_results WHERE bundesland='SH'" +)} + +from app.ingest_votes import ingest_pdf +from pathlib import Path +import tempfile + +new_count = 0 +for m in matches: + href, year, sitzung, suffix = m.groups() + pid = f"PlPr20-{int(sitzung)}" + if pid in existing: + continue + url = "https://www.landtag.ltsh.de" + href + print(f" → neu: {pid} ({year} {suffix})") + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + urllib.request.urlretrieve(url, tmp_path) + stats = asyncio.run(ingest_pdf( + tmp_path, bundesland="SH", protokoll_id=pid, quelle_url=url, + )) + print(f" parsed: {stats['parsed']}, written: {stats['written']}") + new_count += 1 + except Exception as e: + print(f" Fehler: {e}") + finally: + tmp_path.unlink(missing_ok=True) +print(f" SH: {new_count} neue Protokolle ingestet") +EOF + for entry in "${PROTO_TARGETS[@]}"; do IFS='|' read -r bl wp prefix pattern <<< "$entry" echo "--- ${bl} WP${wp} (prefix=${prefix}) ---" diff --git a/tests/test_protokoll_parsers_sh.py b/tests/test_protokoll_parsers_sh.py new file mode 100644 index 0000000..b2a2287 --- /dev/null +++ b/tests/test_protokoll_parsers_sh.py @@ -0,0 +1,158 @@ +"""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" diff --git a/tests/test_protokoll_parsers_stubs.py b/tests/test_protokoll_parsers_stubs.py index 41e3567..b7b2134 100644 --- a/tests/test_protokoll_parsers_stubs.py +++ b/tests/test_protokoll_parsers_stubs.py @@ -20,9 +20,9 @@ import pytest from app.protokoll_parsers import PROTOKOLL_PARSERS, supported_bundeslaender STUB_BL_CODES = [ - # BUND/BE/HH/TH/HE raus, weil seit 2026-04-28/29 produktive Parser + # BUND/BE/HH/TH/HE/SH raus, weil seit 2026-04-28/29 produktive Parser "BB", "BW", "BY", "HB", - "LSA", "MV", "NI", "RP", "SH", "SL", "SN", + "LSA", "MV", "NI", "RP", "SL", "SN", ] @@ -76,8 +76,8 @@ class TestRegistryDiscipline: def test_stubs_not_in_registry(self): registered = set(supported_bundeslaender()) - # Aktuell: NRW + BUND + BE + HH + TH + HE produktiv - assert registered == {"NRW", "BUND", "BE", "HH", "TH", "HE"}, ( + # Aktuell: NRW + BUND + BE + HH + TH + HE + SH produktiv + assert registered == {"NRW", "BUND", "BE", "HH", "TH", "HE", "SH"}, ( "Unerwartete Registry-Eintraege. Wenn neue BL implementiert sind, " "diesen Test anpassen UND den Stub durch echten Parser ersetzen." )