"""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

xml_path.write_text( f'' f'

{body_text}

' f'', 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" }