Saarland publiziert keine Wortprotokolle, sondern eigene HTML-Seiten
mit strukturierten Abstimmungsergebnissen pro Sitzung:
<p>Drucksache 17/2076 ... in Erster Lesung mit Stimmenmehrheit
angenommen ... [SPD: dafür; CDU und AfD: dagegen]</p>
Daher Input ist HTML, nicht PDF. Parser nutzt LI-Block-Iteration und
extrahiert pro Block:
- Drucksache aus "Drucksache N/M"
- Status aus "(einstimmig|mit Stimmenmehrheit)? (angenommen|abgelehnt)"
- Vote-Block aus "[SPD: dafür; CDU: dagegen; AfD: Enthaltung]"
- einstimmig=True falls Status enthaelt "einstimmig"
Vote-Bracket-Parser (eigenstaendig vs. Reden-Stil-Parser anderer BL):
- Splits per ; → "Phrase: Status"
- Phrase per Wortgrenzen-Regex auf {SPD,CDU,AfD} matchen
- Status-Map: dafür→ja, dagegen→nein, Enthaltung→enthaltung
URL-Pattern (nicht direkt vorhersagbar wegen Datums-Slug):
https://www.landtag-saar.de/aktuelles/mitteilungen/abstimmungsergebnisse-der-{n}-landtagssitzung-vom-{datum}/
Auto-Ingest via Index-Scrape (analog HH/HE/SH):
- /aktuelles/mitteilungen/ scrape
- WP16-URLs (mit "wahlperiode-vom") ueberspringen
- Pro neue Sitzung: HTML herunterladen, ingest_pdf-API auf .html-Datei
Tests: 18 SL-Tests (Verifikation Sitzung 46 → 18 Votes mit korrekten
JA/NEIN/ENTH-Listen). Stand: 9 produktive Parser
(NRW, BUND, BE, HH, TH, HE, SH, HB, SL).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
114 lines
3.6 KiB
Python
114 lines
3.6 KiB
Python
"""Tests fuer app/protokoll_parsers/sl.py — SL Abstimmungsergebnisse-Parser (#161).
|
|
|
|
SL ist HTML-basiert (nicht PDF) — eigene Abstimmungsergebnisse-Seite pro
|
|
Sitzung mit strukturiertem [SPD: dafür; CDU und AfD: dagegen]-Format.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.protokoll_parsers.sl import (
|
|
_normalize_fraktionen_sl,
|
|
_parse_vote_bracket,
|
|
_strip_html,
|
|
DS_RE_SL,
|
|
STATUS_RE,
|
|
VOTE_BRACKET_RE,
|
|
LI_BLOCK_RE,
|
|
ALLE_FRAKTIONEN_SL,
|
|
)
|
|
|
|
|
|
class TestNormalizeFraktionenSl:
|
|
def test_simple_spd(self):
|
|
assert _normalize_fraktionen_sl("SPD") == ["SPD"]
|
|
|
|
def test_spd_und_cdu(self):
|
|
assert _normalize_fraktionen_sl("SPD und CDU") == ["CDU", "SPD"]
|
|
|
|
def test_alle_drei(self):
|
|
assert _normalize_fraktionen_sl("SPD, CDU und AfD") == ["AfD", "CDU", "SPD"]
|
|
|
|
def test_empty(self):
|
|
assert _normalize_fraktionen_sl("") == []
|
|
|
|
def test_word_boundary(self):
|
|
# Kein Match auf 'SP' oder Substrings
|
|
assert _normalize_fraktionen_sl("SP-Partei") == []
|
|
|
|
|
|
class TestParseVoteBracket:
|
|
def test_klassisches_pattern(self):
|
|
votes = _parse_vote_bracket("SPD: dafür; CDU und AfD: dagegen")
|
|
assert set(votes["ja"]) == {"SPD"}
|
|
assert set(votes["nein"]) == {"CDU", "AfD"}
|
|
assert votes["enthaltung"] == []
|
|
|
|
def test_mit_enthaltung(self):
|
|
votes = _parse_vote_bracket("SPD: dafür; CDU: dagegen; AfD: Enthaltung")
|
|
assert set(votes["ja"]) == {"SPD"}
|
|
assert set(votes["nein"]) == {"CDU"}
|
|
assert set(votes["enthaltung"]) == {"AfD"}
|
|
|
|
def test_alle_dafuer(self):
|
|
votes = _parse_vote_bracket("SPD und CDU: dafür; AfD: Enthaltung")
|
|
assert set(votes["ja"]) == {"SPD", "CDU"}
|
|
assert votes["nein"] == []
|
|
assert set(votes["enthaltung"]) == {"AfD"}
|
|
|
|
def test_alle_dagegen(self):
|
|
votes = _parse_vote_bracket("AfD: dafür; SPD und CDU: dagegen")
|
|
assert set(votes["ja"]) == {"AfD"}
|
|
assert set(votes["nein"]) == {"SPD", "CDU"}
|
|
|
|
|
|
class TestStripHtml:
|
|
def test_removes_tags(self):
|
|
assert _strip_html("<p>Hello <em>world</em></p>") == "Hello world"
|
|
|
|
def test_decodes_entities(self):
|
|
assert _strip_html("a & b") == "a & b"
|
|
|
|
|
|
class TestDrucksacheRegex:
|
|
def test_matches_drucksache(self):
|
|
m = DS_RE_SL.search("Drucksache 17/2076")
|
|
assert m and m.group(1) == "17/2076"
|
|
|
|
def test_matches_with_spaces(self):
|
|
m = DS_RE_SL.search("(Drucksache 17/2074)")
|
|
assert m and m.group(1) == "17/2074"
|
|
|
|
|
|
class TestStatusRegex:
|
|
def test_matches_einstimmig_angenommen(self):
|
|
m = STATUS_RE.search("in Erster Lesung einstimmig angenommen")
|
|
assert m and m.group("ergebnis").lower() == "angenommen"
|
|
|
|
def test_matches_mit_stimmenmehrheit(self):
|
|
m = STATUS_RE.search("mit Stimmenmehrheit angenommen")
|
|
assert m and m.group("ergebnis").lower() == "angenommen"
|
|
|
|
def test_matches_abgelehnt(self):
|
|
m = STATUS_RE.search("in Erster Lesung abgelehnt")
|
|
assert m and m.group("ergebnis").lower() == "abgelehnt"
|
|
|
|
|
|
class TestVoteBracketRegex:
|
|
def test_matches_full_bracket(self):
|
|
m = VOTE_BRACKET_RE.search("[SPD: dafür; CDU und AfD: dagegen]")
|
|
assert m and "SPD" in m.group("inner")
|
|
|
|
|
|
class TestLiBlockRegex:
|
|
def test_extracts_li_content(self):
|
|
html = "<ul><li>foo</li><li>bar</li></ul>"
|
|
matches = list(LI_BLOCK_RE.finditer(html))
|
|
assert len(matches) == 2
|
|
assert matches[0].group(1) == "foo"
|
|
|
|
|
|
class TestConstants:
|
|
def test_all_fraktionen_set(self):
|
|
assert ALLE_FRAKTIONEN_SL == ["SPD", "CDU", "AfD"]
|