Zentrale `app/parteien.py` als Single Source of Truth für die Partei- Auflösung: - `PARTEIEN`-Tabelle mit kanonischem Key, langem Display-Namen, allen bekannten Aliasen, optionalem `bundesland_scope` und Government- Marker. 14 Einträge (CDU, CSU, SPD, GRÜNE, FDP, LINKE, AfD, BSW, SSW, BiW + die Freie-Wähler-Familie BVB-FW, FW-BAYERN, FW-SL und der generische FREIE WÄHLER-Eintrag). - `normalize_partei(raw, *, bundesland=None)` für Single-String-Lookups mit Government-Vorrang und FW-Familien-Disambiguierung - `extract_fraktionen(text, *, bundesland=None)` als Funnel für die vier alten Adapter-Helper. Kommagetrennte Listen, MdL-mit-Klammer- partei, HTML-Reste — alles fließt durch eine Stelle, mit BL-Scope- Filter (SSW nur in SH, BVB-FW nur in BB, etc.). - `display_name(canonical, *, long=False)` für UI/PDF — kurze Form bleibt der kanonische Key, lange Form ist "BÜNDNIS 90/DIE GRÜNEN" statt "GRÜNE" etc. Adapter-Migration in `app/parlamente.py`: - Vier nahezu identische `_normalize_fraktion()`-Methoden in PortalaAdapter, ParLDokAdapter, StarFinderCGIAdapter, PARLISAdapter durch einen einzeiligen Shim ersetzt, der `extract_fraktionen` mit `self.bundesland` aufruft. ~120 Zeilen Duplikation entfernt. - `@staticmethod` aufgehoben, weil wir jetzt `self.bundesland` brauchen für die FW-Disambiguierung — alle Aufrufer waren bereits `self._...`, also keine Call-Site-Änderung nötig. `app/embeddings.py:496` Workaround-Hack entfernt: - `partei.upper() if partei != "GRÜNE" else "GRÜNE"` durch zentralen `normalize_partei()`-Aufruf ersetzt — der Hack war ein Kommentarzeichen dafür, dass die Partei-Schreibweise irgendwo zwischen Adapter und Embedding-Lookup driften konnte. Mit dem Mapper ist die Schreibweise überall garantiert kanonisch. Tests: - Neue `tests/test_parteien.py` mit 52 Cases — Single-Lookup, FW- Disambiguierung (BVB/Bayern/Saarland/RP), Volltext-Extraktion, Government-Marker, Tabellen-Konsistenz - `tests/test_parlamente.py` Test-Klasse umgeschrieben: statt der 6 statischen `PortalaAdapter._normalize_fraktion(...)`-Tests jetzt 4 Roundtrip-Tests über echte Adapter-Instanzen, inkl. expliziter BB→BVB-FW vs. RP→FREIE WÄHLER-Verifikation 157 Unit-Tests grün (105 alt + 52 neu). Backwards-kompatibel — die kanonischen Keys sind exakt die in der DB stehenden Strings, kein Migrations-Schritt nötig. Refs: #55, #59 (Phase B) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
242 lines
11 KiB
Python
242 lines
11 KiB
Python
"""Tests für app.parteien — den zentralen Parteinamen-Mapper.
|
|
|
|
Issue #55 + Roadmap #59 Phase B. Drei Schichten:
|
|
|
|
1. ``normalize_partei`` — single-string lookup (für Wahlprogramm-/
|
|
Embedding-Lookups, wo wir nur einen Namen haben)
|
|
2. ``extract_fraktionen`` — komma-separierte Listen, MdL-mit-Klammerpartei,
|
|
HTML-Reste; ersetzt die vier ``_normalize_fraktion()``-Methoden
|
|
3. ``display_name`` — Anzeigeform (kurz/lang)
|
|
|
|
Plus Roundtrip-Tests gegen die historisch in den Adaptern erkannten
|
|
Patterns (F.D.P., LL/PDS, BÜNDNIS 90, MINISTERIUM, etc.) damit der
|
|
Refactor garantiert nichts kaputt macht.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.parteien import (
|
|
GOVERNMENT_KEY,
|
|
PARTEIEN,
|
|
all_canonical_keys,
|
|
display_name,
|
|
extract_fraktionen,
|
|
normalize_partei,
|
|
)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# normalize_partei — single-string lookup
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestNormalizePartei:
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected"),
|
|
[
|
|
("CDU", "CDU"),
|
|
("SPD", "SPD"),
|
|
("FDP", "FDP"),
|
|
("F.D.P.", "FDP"),
|
|
("F. D. P.", "FDP"),
|
|
("AfD", "AfD"),
|
|
("AFD", "AfD"),
|
|
("Alternative für Deutschland", "AfD"),
|
|
("GRÜNE", "GRÜNE"),
|
|
("Grüne", "GRÜNE"),
|
|
("GRUENE", "GRÜNE"),
|
|
("Bündnis 90/Die Grünen", "GRÜNE"),
|
|
("LINKE", "LINKE"),
|
|
("Die Linke", "LINKE"),
|
|
("DIE LINKE", "LINKE"),
|
|
("LL/PDS", "LINKE"),
|
|
("BSW", "BSW"),
|
|
],
|
|
)
|
|
def test_canonical_keys_for_known_aliases(self, raw, expected):
|
|
assert normalize_partei(raw) == expected
|
|
|
|
def test_unknown_returns_none(self):
|
|
assert normalize_partei("Pirateneinhornpartei XYZ") is None
|
|
|
|
def test_empty_returns_none(self):
|
|
assert normalize_partei("") is None
|
|
assert normalize_partei(None) is None # type: ignore[arg-type]
|
|
|
|
def test_government_marker_takes_precedence(self):
|
|
# "Antrag der Landesregierung" → Government, nicht eine zufällig
|
|
# erkannte Partei aus dem Rest des Strings.
|
|
assert normalize_partei("Antrag der Landesregierung") == GOVERNMENT_KEY
|
|
assert normalize_partei("Ministerium der Finanzen") == GOVERNMENT_KEY
|
|
assert normalize_partei("Senat von Berlin") == GOVERNMENT_KEY
|
|
|
|
def test_ssw_only_in_sh(self):
|
|
# SSW ist BL-spezifisch — bei explizit SH klappt es, ohne BL
|
|
# akzeptieren wir es auch (tolerant) — die strenge BL-Constraint
|
|
# gilt nur bei extract_fraktionen.
|
|
assert normalize_partei("SSW", bundesland="SH") == "SSW"
|
|
|
|
def test_csu_only_in_by(self):
|
|
assert normalize_partei("CSU", bundesland="BY") == "CSU"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Freie-Wähler-Disambiguierung — der eigentliche Mehrwert des Mappers
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestFreieWaehlerDisambiguation:
|
|
def test_freie_waehler_in_brandenburg_resolves_to_bvb(self):
|
|
assert normalize_partei("FREIE WÄHLER", bundesland="BB") == "BVB-FW"
|
|
|
|
def test_freie_waehler_in_bayern_resolves_to_fw_bayern(self):
|
|
assert normalize_partei("FREIE WÄHLER", bundesland="BY") == "FW-BAYERN"
|
|
|
|
def test_freie_waehler_in_saarland_resolves_to_fw_sl(self):
|
|
assert normalize_partei("FREIE WÄHLER", bundesland="SL") == "FW-SL"
|
|
|
|
def test_freie_waehler_in_rlp_resolves_to_generic(self):
|
|
# In RP gehört der Landesverband zur Bundesvereinigung — der ist
|
|
# der generische "FREIE WÄHLER"-Eintrag (kein Scope).
|
|
assert normalize_partei("FREIE WÄHLER", bundesland="RP") == "FREIE WÄHLER"
|
|
|
|
def test_fw_kuerzel_disambiguates(self):
|
|
assert normalize_partei("FW", bundesland="BY") == "FW-BAYERN"
|
|
assert normalize_partei("FW", bundesland="BB") == "BVB-FW"
|
|
|
|
def test_freie_waehler_without_bundesland_falls_back_to_generic(self):
|
|
# Kein BL → wir können nicht disambiguieren, der generische
|
|
# Eintrag bleibt der Default
|
|
assert normalize_partei("FREIE WÄHLER") == "FREIE WÄHLER"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# extract_fraktionen — der Adapter-Funnel
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestExtractFraktionen:
|
|
def test_single_simple(self):
|
|
assert extract_fraktionen("CDU") == ["CDU"]
|
|
|
|
def test_comma_separated(self):
|
|
assert set(extract_fraktionen("CDU, SPD, FDP")) == {"CDU", "SPD", "FDP"}
|
|
|
|
def test_dedupe(self):
|
|
# CDU zweimal im Text → nur einmal im Output
|
|
out = extract_fraktionen("CDU und CDU")
|
|
assert out == ["CDU"]
|
|
|
|
def test_empty(self):
|
|
assert extract_fraktionen("") == []
|
|
assert extract_fraktionen(None) == [] # type: ignore[arg-type]
|
|
|
|
def test_mdl_with_paren_party(self):
|
|
# ParlDok-Stil: "Felix Herkens (GRÜNE), Saskia Frank (GRÜNE)"
|
|
out = extract_fraktionen("Felix Herkens (GRÜNE), Saskia Frank (GRÜNE)")
|
|
assert out == ["GRÜNE"]
|
|
|
|
def test_mixed_mdl_and_government(self):
|
|
out = extract_fraktionen("Antrag der Fraktion CDU und der Landesregierung")
|
|
assert "CDU" in out
|
|
assert GOVERNMENT_KEY in out
|
|
|
|
def test_fdp_with_dots(self):
|
|
assert extract_fraktionen("F.D.P.") == ["FDP"]
|
|
assert extract_fraktionen("Antrag der F.D.P.-Fraktion") == ["FDP"]
|
|
|
|
def test_bsw(self):
|
|
assert extract_fraktionen("BSW") == ["BSW"]
|
|
|
|
def test_linke_via_ll_pds_legacy(self):
|
|
# Historische ParlDok-Schreibweise
|
|
assert extract_fraktionen("LL/PDS") == ["LINKE"]
|
|
|
|
def test_gruene_via_buendnis_90(self):
|
|
assert extract_fraktionen("BÜNDNIS 90/DIE GRÜNEN") == ["GRÜNE"]
|
|
|
|
def test_ministerium_marker(self):
|
|
assert extract_fraktionen("Ministerium der Finanzen") == [GOVERNMENT_KEY]
|
|
|
|
def test_real_th_urheber(self):
|
|
# TH liefert oft "Antrag der Fraktion der CDU"
|
|
assert extract_fraktionen("Antrag der Fraktion der CDU") == ["CDU"]
|
|
|
|
def test_ssw_only_when_in_sh_context(self):
|
|
# SH-spezifisch — in einem SH-Antrag taucht SSW auf
|
|
assert extract_fraktionen("Christian Dirschauer (SSW)", bundesland="SH") == ["SSW"]
|
|
|
|
def test_ssw_filtered_in_other_bl(self):
|
|
# In einem MV-Antrag würde "SSW" nichts ergeben — BL-Filter greift
|
|
assert extract_fraktionen("SSW Beispiel", bundesland="MV") == []
|
|
|
|
def test_freie_waehler_bb_resolves_to_bvb_in_extract(self):
|
|
# BB-Drucksache der "FREIE WÄHLER"-Fraktion → BVB-FW
|
|
out = extract_fraktionen("Antrag der Fraktion FREIE WÄHLER", bundesland="BB")
|
|
assert out == ["BVB-FW"]
|
|
|
|
def test_freie_waehler_rp_resolves_to_generic(self):
|
|
out = extract_fraktionen("Antrag der Fraktion FREIE WÄHLER", bundesland="RP")
|
|
assert out == ["FREIE WÄHLER"]
|
|
|
|
def test_freie_waehler_by(self):
|
|
out = extract_fraktionen("Antrag der Fraktion FREIE WÄHLER", bundesland="BY")
|
|
assert out == ["FW-BAYERN"]
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# display_name
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestDisplayName:
|
|
def test_short_default(self):
|
|
assert display_name("CDU") == "CDU"
|
|
assert display_name("GRÜNE") == "GRÜNE"
|
|
|
|
def test_long_form(self):
|
|
assert display_name("GRÜNE", long=True) == "BÜNDNIS 90/DIE GRÜNEN"
|
|
assert display_name("LINKE", long=True) == "DIE LINKE"
|
|
assert display_name("BiW", long=True) == "BÜRGER IN WUT"
|
|
|
|
def test_government(self):
|
|
assert display_name(GOVERNMENT_KEY) == "Landesregierung"
|
|
|
|
def test_unknown_passthrough(self):
|
|
# Unbekannt — kein Fehler, einfach durchreichen
|
|
assert display_name("UnknownPartyXYZ") == "UnknownPartyXYZ"
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Tabellen-Konsistenz
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestTableConsistency:
|
|
def test_no_duplicate_canonical_keys(self):
|
|
keys = [p.canonical for p in PARTEIEN]
|
|
assert len(keys) == len(set(keys)), f"Duplikate: {[k for k in keys if keys.count(k) > 1]}"
|
|
|
|
def test_all_canonical_keys_resolvable_by_themselves(self):
|
|
# Jeder kanonische Key muss als Roh-Eingabe wieder auf sich selbst
|
|
# auflösen — das ist die Identitäts-Invariante des Mappers
|
|
for p in PARTEIEN:
|
|
if p.bundesland_scope:
|
|
# Disambiguierungs-Einträge brauchen ihren Scope mitgegeben
|
|
assert normalize_partei(p.canonical, bundesland=p.bundesland_scope) in {
|
|
p.canonical,
|
|
# FW-BAYERN/FW-SL/BVB-FW haben ihren canonical-Key nicht
|
|
# als alias drin — er ist nur ein interner Schlüssel.
|
|
# Sie matchen über die FW-Aliase.
|
|
"FREIE WÄHLER", "FW-BAYERN", "FW-SL", "BVB-FW",
|
|
}
|
|
else:
|
|
assert normalize_partei(p.canonical) == p.canonical
|
|
|
|
def test_all_canonical_keys_helper_includes_government(self):
|
|
keys = all_canonical_keys()
|
|
assert GOVERNMENT_KEY in keys
|
|
assert "CDU" in keys
|
|
assert "GRÜNE" in keys
|