gwoe-antragspruefer/tests/test_protokoll_parsers_bund.py

236 lines
9.0 KiB
Python
Raw Normal View History

"""Tests fuer app/protokoll_parsers/bund.py — Bundestags-Plenarprotokoll-Parser (#148).
Stichprobe-getestet gegen WP20 Sitzung 30 + 100 (XML aus dserver.bundestag.de).
Pure-string-Tests fuer Vote-Block-Parsing, Anchor-Detection, Fraktions-Mapping.
"""
from __future__ import annotations
import pytest
from app.protokoll_parsers.bund import (
_normalize_fraktionen_bt,
_parse_vote_block_bt,
_resolve_drucksache_bt,
RESULT_ANCHOR_RE,
parse_protocol,
WP20_KOALITIONSFRAKTIONEN,
WP20_OPPOSITIONSFRAKTIONEN,
ALL_BT_FRAKTIONEN,
)
class TestNormalizeFraktionenBt:
def test_simple_spd(self):
assert _normalize_fraktionen_bt("SPD-Fraktion") == ["SPD"]
def test_cdu_csu(self):
assert _normalize_fraktionen_bt("CDU/CSU-Fraktion") == ["CDU/CSU"]
def test_buendnis_90_normalizes_to_gruene(self):
result = _normalize_fraktionen_bt("Fraktion Bündnis 90/Die Grünen")
assert result == ["GRÜNE"]
def test_koalitionsfraktionen_expands_wp20(self):
"""In WP20: Koalition = SPD + GRÜNE + FDP."""
result = _normalize_fraktionen_bt("der Koalitionsfraktionen")
assert set(result) == set(WP20_KOALITIONSFRAKTIONEN)
def test_oppositionsfraktionen_expands_wp20(self):
result = _normalize_fraktionen_bt("der Oppositionsfraktionen")
assert set(result) == set(WP20_OPPOSITIONSFRAKTIONEN)
def test_combined_phrase(self):
"""'Koalitionsfraktionen und der Fraktion Die Linke' → SPD+GRÜNE+FDP+LINKE."""
result = _normalize_fraktionen_bt(
"der Koalitionsfraktionen und der Fraktion Die Linke"
)
assert set(result) == {"SPD", "GRÜNE", "FDP", "LINKE"}
def test_empty_returns_empty(self):
assert _normalize_fraktionen_bt("") == []
def test_no_double_count(self):
"""SPD darf in 'SPD-Fraktion' nicht zweimal gezaehlt werden."""
result = _normalize_fraktionen_bt("der SPD-Fraktion und der SPD")
assert result.count("SPD") == 1
class TestParseVoteBlockBt:
def test_full_block_with_all_three_kinds(self):
block = (
" der Koalitionsfraktionen und der Fraktion Die Linke "
"gegen die Stimmen der CDU/CSU-Fraktion "
"bei Enthaltung der AfD-Fraktion"
)
votes = _parse_vote_block_bt(block)
assert set(votes["ja"]) == {"SPD", "GRÜNE", "FDP", "LINKE"}
assert votes["nein"] == ["CDU/CSU"]
assert votes["enthaltung"] == ["AfD"]
def test_only_ja_and_nein(self):
block = (
" der SPD-Fraktion, der Fraktion Bündnis 90/Die Grünen, der FDP-Fraktion, "
"der CDU/CSU-Fraktion und der Fraktion Die Linke "
"gegen die Stimmen der AfD-Fraktion"
)
votes = _parse_vote_block_bt(block)
assert "AfD" not in votes["ja"]
assert votes["nein"] == ["AfD"]
assert votes["enthaltung"] == []
assert set(votes["ja"]) == {"SPD", "GRÜNE", "FDP", "CDU/CSU", "LINKE"}
def test_only_ja(self):
block = " der Koalitionsfraktionen"
votes = _parse_vote_block_bt(block)
assert set(votes["ja"]) == set(WP20_KOALITIONSFRAKTIONEN)
assert votes["nein"] == []
assert votes["enthaltung"] == []
class TestResolveDrucksacheBt:
def test_finds_nearest_ds_before(self):
text = (
"Drucksache 20/100 ... irgendwas ... "
"Die Beschlussempfehlung ist mit den Stimmen ..."
)
anchor = text.index("Die Beschlussempfehlung")
assert _resolve_drucksache_bt(text, anchor) == "20/100"
def test_picks_closest_when_multiple(self):
"""Bei mehreren DS-Nrn vor dem Anchor wird die naechste gewaehlt."""
text = (
"Drucksache 20/100 ... Drucksache 20/200 ... "
"Die Beschlussempfehlung ..."
)
anchor = text.index("Die Beschlussempfehlung")
assert _resolve_drucksache_bt(text, anchor) == "20/200"
def test_returns_none_when_no_ds(self):
text = "Die Beschlussempfehlung ist mit den Stimmen ..."
anchor = 0
assert _resolve_drucksache_bt(text, anchor) is None
def test_neu_suffix_supported(self):
text = "auf Drucksache 20/4567(neu) ... Die Beschlussempfehlung ..."
anchor = text.index("Die Beschlussempfehlung")
assert _resolve_drucksache_bt(text, anchor) == "20/4567(neu)"
class TestResultAnchorRegex:
def test_matches_beschlussempfehlung_angenommen(self):
text = (
"Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen "
"gegen die Stimmen der CDU/CSU-Fraktion angenommen."
)
m = RESULT_ANCHOR_RE.search(text)
assert m
assert m.group("subject") == "Die Beschlussempfehlung"
assert m.group("ergebnis") == "angenommen"
def test_matches_ueberweisungsvorschlag_abgelehnt(self):
text = (
"Der Überweisungsvorschlag ist mit den Stimmen der Koalitionsfraktionen "
"gegen die Stimmen der AfD-Fraktion abgelehnt."
)
m = RESULT_ANCHOR_RE.search(text)
assert m
assert m.group("ergebnis") == "abgelehnt"
def test_no_match_in_speech(self):
"""'angenommen' in einer Rede (ohne mit-den-Stimmen-Form) darf nicht matchen."""
text = "Wir haben das Angebot angenommen, weil das Geld gut angelegt ist."
assert RESULT_ANCHOR_RE.search(text) is None
class TestParseProtocolEndToEnd:
"""Integration-light: parsen ein Mock-XML mit BT-typischen Beschluessen."""
def _write_xml(self, tmp_path, body_text):
xml_path = tmp_path / "test.xml"
# Minimal-XML, alles in einem <p>
xml_path.write_text(
f'<?xml version="1.0"?>'
f'<dbtplenarprotokoll><sitzungsverlauf><p>{body_text}</p>'
f'</sitzungsverlauf></dbtplenarprotokoll>',
encoding="utf-8",
)
return xml_path
def test_single_beschluss(self, tmp_path):
body = (
"Beschlussempfehlung auf Drucksache 20/1234. "
"Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen "
"gegen die Stimmen der CDU/CSU-Fraktion bei Enthaltung der AfD-Fraktion "
"angenommen."
)
xml = self._write_xml(tmp_path, body)
result = parse_protocol(str(xml))
assert len(result) == 1
r = result[0]
assert r["drucksache"] == "20/1234"
assert r["ergebnis"] == "angenommen"
assert set(r["votes"]["ja"]) == set(WP20_KOALITIONSFRAKTIONEN)
assert r["votes"]["nein"] == ["CDU/CSU"]
assert r["votes"]["enthaltung"] == ["AfD"]
assert r["einstimmig"] is False
def test_ueberweisungsvorschlag_kind(self, tmp_path):
body = (
"Drucksache 20/5000. "
"Der Überweisungsvorschlag ist mit den Stimmen "
"der Koalitionsfraktionen, der CDU/CSU-Fraktion, der Fraktion Die Linke "
"gegen die Stimmen der AfD-Fraktion angenommen."
)
xml = self._write_xml(tmp_path, body)
result = parse_protocol(str(xml))
assert len(result) == 1
assert result[0]["kind"] == "ueberweisung"
# Ueberweisungs-Anchor → ergebnis 'überwiesen'
assert result[0]["ergebnis"] == "überwiesen"
def test_einstimmig_heuristic(self, tmp_path):
"""Wenn alle 6 Fraktionen ja stimmen, einstimmig=True."""
body = (
"Drucksache 20/9999. "
"Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen, "
"der CDU/CSU-Fraktion, der AfD-Fraktion und der Fraktion Die Linke "
"angenommen."
)
xml = self._write_xml(tmp_path, body)
result = parse_protocol(str(xml))
assert result[0]["einstimmig"] is True
def test_skip_anchor_without_drucksache(self, tmp_path):
"""Anchor ohne aufloesbare DS wird uebersprungen."""
body = (
"Die Beschlussempfehlung ist mit den Stimmen der Koalitionsfraktionen "
"angenommen."
)
xml = self._write_xml(tmp_path, body)
assert parse_protocol(str(xml)) == []
def test_zero_results_for_pure_aussprache(self, tmp_path):
body = (
"Drucksache 20/100. Wir diskutieren den Antrag. "
"Die Linke hat das angenommen, dass die Politik gut ist."
)
xml = self._write_xml(tmp_path, body)
# Kein 'mit den Stimmen' → kein Treffer
assert parse_protocol(str(xml)) == []
class TestConstants:
def test_wp20_koalition_correct(self):
"""Sanity: WP20-Koalition = SPD + GRÜNE + FDP (Ampel)."""
assert set(WP20_KOALITIONSFRAKTIONEN) == {"SPD", "GRÜNE", "FDP"}
def test_wp20_opposition_correct(self):
"""WP20-Opposition = CDU/CSU + AfD + LINKE."""
assert set(WP20_OPPOSITIONSFRAKTIONEN) == {"CDU/CSU", "AfD", "LINKE"}
def test_all_bt_fraktionen_complete(self):
"""ALL_BT_FRAKTIONEN deckt alle 6 BT-Fraktionen der WP20 ab."""
assert set(ALL_BT_FRAKTIONEN) == {
"CDU/CSU", "SPD", "GRÜNE", "FDP", "AfD", "LINKE"
}