"""Tests für _parse_redline_segments aus app.main. Prüft alle Marker-Formate (§INS§/§DEL§ und **/**+~~), Edge-Cases und gemischte Eingaben. Keine DB- oder HTTP-Abhängigkeiten. """ from __future__ import annotations import pytest from app.redline_utils import parse_redline_segments as _parse_redline_segments # ───────────────────────────────────────────────────────────────────────────── # Hilfsfunktionen # ───────────────────────────────────────────────────────────────────────────── def types(segments): return [s["type"] for s in segments] def texts(segments): return [s["text"] for s in segments] # ───────────────────────────────────────────────────────────────────────────── # Basis-Parsing # ───────────────────────────────────────────────────────────────────────────── class TestBasicParsing: def test_empty_string_returns_empty(self): assert _parse_redline_segments("") == [] def test_none_returns_empty(self): assert _parse_redline_segments(None) == [] # type: ignore[arg-type] def test_plain_text_is_ctx(self): segs = _parse_redline_segments("kein Marker hier") assert types(segs) == ["ctx"] assert texts(segs) == ["kein Marker hier"] def test_ins_marker_tag_format(self): segs = _parse_redline_segments("§INS§neuer Text§INS§") assert types(segs) == ["ins"] assert texts(segs) == ["neuer Text"] def test_del_marker_tag_format(self): segs = _parse_redline_segments("§DEL§alter Text§DEL§") assert types(segs) == ["del"] assert texts(segs) == ["alter Text"] def test_markdown_bold_becomes_ins(self): segs = _parse_redline_segments("**eingefügt**") assert types(segs) == ["ins"] assert texts(segs) == ["eingefügt"] def test_markdown_strikethrough_becomes_del(self): segs = _parse_redline_segments("~~gestrichen~~") assert types(segs) == ["del"] assert texts(segs) == ["gestrichen"] # ───────────────────────────────────────────────────────────────────────────── # Kontext + Marker gemischt # ───────────────────────────────────────────────────────────────────────────── class TestMixedContent: def test_ctx_ins_ctx(self): segs = _parse_redline_segments("§ 3 Abs. 2 §INS§verpflichtend§INS§ ab 2026") assert types(segs) == ["ctx", "ins", "ctx"] assert texts(segs)[1] == "verpflichtend" def test_ctx_del_ins(self): segs = _parse_redline_segments("Text §DEL§alt§DEL§§INS§neu§INS§ Ende") assert types(segs) == ["ctx", "del", "ins", "ctx"] def test_markdown_mixed(self): segs = _parse_redline_segments("Vor ~~weg~~ und **rein** nach") assert types(segs) == ["ctx", "del", "ctx", "ins", "ctx"] def test_both_formats_in_one_string(self): segs = _parse_redline_segments("§DEL§raus§DEL§ und **rein**") assert "del" in types(segs) assert "ins" in types(segs) # ───────────────────────────────────────────────────────────────────────────── # Edge-Cases # ───────────────────────────────────────────────────────────────────────────── class TestEdgeCases: def test_empty_ins_marker(self): segs = _parse_redline_segments("§INS§§INS§") # Leerer ins-Marker bleibt ein ins-Segment mit leerem Text assert any(s["type"] == "ins" for s in segs) def test_empty_del_marker(self): segs = _parse_redline_segments("§DEL§§DEL§") assert any(s["type"] == "del" for s in segs) def test_unbalanced_marker_treated_as_ctx(self): # Nur ein §INS§ ohne schließenden Partner → kein ins-Segment segs = _parse_redline_segments("§INS§unvollständig") assert all(s["type"] == "ctx" for s in segs) def test_marker_with_whitespace_only(self): segs = _parse_redline_segments("§INS§ §INS§") ins_segs = [s for s in segs if s["type"] == "ins"] assert len(ins_segs) == 1 assert ins_segs[0]["text"].strip() == "" def test_multiple_ins_markers(self): segs = _parse_redline_segments("§INS§A§INS§ und §INS§B§INS§") ins_texts = [s["text"] for s in segs if s["type"] == "ins"] assert ins_texts == ["A", "B"] def test_multiple_del_markers(self): segs = _parse_redline_segments("§DEL§X§DEL§ und §DEL§Y§DEL§") del_texts = [s["text"] for s in segs if s["type"] == "del"] assert del_texts == ["X", "Y"] def test_no_empty_ctx_segments(self): # Leere ctx-Segmente sollen nicht in der Ergebnisliste auftauchen segs = _parse_redline_segments("§INS§nur Marker§INS§") ctx_segs = [s for s in segs if s["type"] == "ctx"] # Leere ctx-Strings dürfen nicht enthalten sein for seg in ctx_segs: assert seg["text"] != "" def test_newline_inside_marker(self): segs = _parse_redline_segments("§INS§Zeile 1\nZeile 2§INS§") ins_segs = [s for s in segs if s["type"] == "ins"] assert len(ins_segs) == 1 assert "Zeile 1" in ins_segs[0]["text"] def test_long_realistic_redline(self): text = ( "Die Gemeinde §DEL§soll§DEL§ §INS§muss§INS§ bis zum " "§DEL§31.12.2026§DEL§ §INS§30.06.2025§INS§ einen Plan vorlegen." ) segs = _parse_redline_segments(text) del_texts = [s["text"] for s in segs if s["type"] == "del"] ins_texts = [s["text"] for s in segs if s["type"] == "ins"] assert "soll" in del_texts assert "muss" in ins_texts assert "31.12.2026" in del_texts assert "30.06.2025" in ins_texts # ─── build_pdf_href Tests (#134 Coverage-Backfill) ─────────────────────────── class TestBuildPdfHref: """Tests fuer build_pdf_href: rekonstruiert PDF-URLs aus Zitat-Metadaten, bevorzugt die explizite url, faellt auf WAHLPROGRAMME-Lookup zurueck.""" def test_explicit_url_passed_through(self): from app.redline_utils import build_pdf_href zitat = {"url": "/api/wahlprogramm-cite?pid=cdu-nrw-2022&seite=15"} assert build_pdf_href(zitat) == "/api/wahlprogramm-cite?pid=cdu-nrw-2022&seite=15" def test_empty_url_falls_back_to_quelle_lookup(self): """Ohne url muss die quelle reconstruiert werden via WAHLPROGRAMME.""" from app.redline_utils import build_pdf_href # Ein in WAHLPROGRAMME hinterlegter Titel from app.wahlprogramme import WAHLPROGRAMME # Pick the first programme from the registry bl, parteien = next(iter(WAHLPROGRAMME.items())) partei, info = next(iter(parteien.items())) titel = info.get("titel", "") if not titel: pytest.skip("Kein WAHLPROGRAMME-Eintrag mit titel verfuegbar") zitat = { "quelle": f"{titel} · S. 42", "text": "Wir wollen die Energiewende", "url": "", } href = build_pdf_href(zitat) assert "/api/wahlprogramm-cite" in href assert "seite=42" in href assert "#page=42" in href # URL-Hash fuer Browser-PDF-Viewer def test_no_seitenzahl_returns_empty(self): from app.redline_utils import build_pdf_href zitat = {"quelle": "Irgendein Programm ohne Seite", "text": "x", "url": ""} assert build_pdf_href(zitat) == "" def test_unmatched_quelle_returns_empty(self): from app.redline_utils import build_pdf_href zitat = { "quelle": "Erfundenes Programm 1995, S. 1", "text": "x", "url": "", } assert build_pdf_href(zitat) == "" def test_query_uses_first_5_words_of_text(self): from app.redline_utils import build_pdf_href from app.wahlprogramme import WAHLPROGRAMME bl, parteien = next(iter(WAHLPROGRAMME.items())) partei, info = next(iter(parteien.items())) titel = info.get("titel", "") if not titel: pytest.skip("Kein WAHLPROGRAMME-Eintrag mit titel verfuegbar") zitat = { "quelle": f"{titel} · S. 5", "text": "Eins zwei drei vier fünf sechs sieben", "url": "", } href = build_pdf_href(zitat) # max. 5 Worte → "sechs sieben" muessen im Query fehlen assert "sechs" not in href assert "sieben" not in href # erste fuenf Wortteile sollten kodiert in q= auftauchen assert "Eins" in href or "Eins" in href.replace("+", " ") def test_handles_seite_with_comma_separator(self): """Quelle 'Titel, S. 42' (Komma) muss genauso parsen wie '· S. 42'.""" from app.redline_utils import build_pdf_href from app.wahlprogramme import WAHLPROGRAMME bl, parteien = next(iter(WAHLPROGRAMME.items())) partei, info = next(iter(parteien.items())) titel = info.get("titel", "") if not titel: pytest.skip("Kein WAHLPROGRAMME-Eintrag mit titel verfuegbar") zitat = {"quelle": f"{titel}, S. 17", "text": "x", "url": ""} href = build_pdf_href(zitat) assert "seite=17" in href