refactor + tests: marker.py + pm_render.py mit 56 Unit-Tests

Logik aus dem Jinja-Template (Heuchelei-Marker, Konsistenz-Block,
decisive-Outcome-Selection) in app/marker.py extrahiert. Template
ruft die drei Helper als Jinja-Globals auf. Damit ist die Logik
testbar ohne Render-Kontext.

Plus: app/pm_render.py als Python-Spiegelbild des JS-Mini-Markdown-
Renderers in aktuelle-themen.html — fuer Tests und potenzielle
Server-side-Render-Optionen (z.B. PM-Mail).

Tests:
- tests/test_marker.py (35 Cases): heuchelei_score, decisive_outcome,
  consistency_state inkl. Multi-Vote, ambivalente Empfehlung,
  Edge-Cases.
- tests/test_pm_render.py (21 Cases): Bold, Italic, Listen,
  HTML-Escape, Paragraph-Splitting, snake_case-Schutz.

Refs: ADR 0010, ADR 0011

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-05-06 15:44:12 +02:00
parent abb6cf81a8
commit 9498ca4b97
6 changed files with 529 additions and 23 deletions

View File

@ -145,6 +145,12 @@ templates_dir.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=static_dir), name="static")
templates = Jinja2Templates(directory=str(templates_dir))
# Detail-Marker im Jinja-Environment registrieren (siehe ADR 0010)
from .marker import heuchelei_score, decisive_outcome, consistency_state # noqa: E402
templates.env.globals["heuchelei_score"] = heuchelei_score
templates.env.globals["decisive_outcome"] = decisive_outcome
templates.env.globals["consistency_state"] = consistency_state
# ─── Auth-Fehler bei HTML-Seiten: Redirect statt JSON-401/403 ─────────────────

103
app/marker.py Normal file
View File

@ -0,0 +1,103 @@
"""Detail-Marker fuer Stimmverhalten × GWÖ.
Zwei Funktionen, die im Antrag-Detail-Template aus Jinja heraus
aufgerufen werden:
- ``heuchelei_score``: liefert den Wahlprogramm-Score einer Fraktion,
wenn er ``threshold`` ist und damit den -Indikator triggert.
- ``decisive_outcome``: bestimmt das erste definitive" Outcome aus
einer Liste von Plenum-Votes (Überweisung/zurückgezogen/sammel
übersprungen, ``angenommen|abgelehnt|bestätigt`` priorisiert).
- ``consistency_state``: vergleicht GWÖ-Empfehlung mit dem definitiven
Outcome und liefert ``"conflict"``, ``"aligned"`` oder ``None``.
Die Logik soll testbar sein, ohne Jinja zu rendern. Daher reine
Funktionen ohne Seiteneffekte.
Siehe ADR 0010 (Stimmverhalten × GWÖ-Aggregat) für den Kontext.
"""
from __future__ import annotations
from typing import Iterable, Optional
DECISIVE_OUTCOMES = frozenset({"angenommen", "abgelehnt", "bestätigt"})
def heuchelei_score(
fraktion: str,
fraktions_scores: Optional[Iterable[dict]],
threshold: float = 7.0,
) -> Optional[float]:
"""Wahlprogramm-Score einer Fraktion, falls ≥ threshold; sonst None.
``fraktions_scores`` ist die Liste, die ``_row_to_detail`` ans
Template hängt jeder Eintrag hat ``fraktion`` (str) und
``wahlprogramm.score`` (float|int).
Wenn die Fraktion nicht gefunden wird oder der Score unter dem
Schwellwert liegt, wird ``None`` zurückgegeben das Template
rendert dann keinen -Marker.
"""
if not fraktions_scores:
return None
for fs in fraktions_scores:
if fs.get("fraktion") == fraktion:
wp = fs.get("wahlprogramm") or {}
score = wp.get("score")
if score is None:
return None
try:
score_f = float(score)
except (TypeError, ValueError):
return None
return score_f if score_f >= threshold else None
return None
def decisive_outcome(plenum_votes: Optional[Iterable[dict]]) -> Optional[str]:
"""Erstes definitives Outcome aus einer Liste von Plenum-Votes.
Bei mehreren Votes (Überweisung Endabstimmung) wird das erste
Vote zurückgegeben, dessen ``ergebnis`` (case-insensitiv) in
``DECISIVE_OUTCOMES`` liegt. ``None`` wenn keines vorhanden.
"""
if not plenum_votes:
return None
for v in plenum_votes:
ergebnis = (v.get("ergebnis") or "").lower()
if ergebnis in DECISIVE_OUTCOMES:
return ergebnis
return None
def consistency_state(
verdict_title: Optional[str],
plenum_votes: Optional[Iterable[dict]],
) -> Optional[str]:
"""Vergleich GWÖ-Empfehlung × tatsächlicher Beschluss.
Liefert:
- ``"conflict"`` Empfehlung unterstützen/befürworten" und
Beschluss abgelehnt", ODER Empfehlung „ablehnen" und Beschluss
angenommen/bestätigt".
- ``"aligned"`` Empfehlung und Beschluss decken sich.
- ``None`` keine eindeutige Aussage möglich (Beschluss
überwiesen", oder Empfehlung ambivalent wie „Mit Vorbehalt",
oder fehlende Daten).
"""
if not verdict_title or not plenum_votes:
return None
outcome = decisive_outcome(plenum_votes)
if outcome is None:
return None
text = verdict_title.lower()
rec_supports = ("unterstützen" in text) or ("befürworten" in text)
rec_rejects = "ablehnen" in text
out_passed = outcome in ("angenommen", "bestätigt")
out_failed = outcome == "abgelehnt"
if (rec_supports and out_failed) or (rec_rejects and out_passed):
return "conflict"
if (rec_supports and out_passed) or (rec_rejects and out_failed):
return "aligned"
return None

81
app/pm_render.py Normal file
View File

@ -0,0 +1,81 @@
"""Mini-Markdown-Renderer für PM-Bodies — Python-Spiegelbild der
JS-Funktion ``renderPmBody`` in ``v2/screens/aktuelle-themen.html``.
Der Renderer interpretiert eine kleine Untermenge von Markdown:
- ``**bold**`` und ``__bold__`` ``<strong>...</strong>``
- ``*italic*`` und ``_italic_`` ``<em>...</em>`` (vorsichtig nur
wenn nicht zwischen Ziffern oder Word-Charakters)
- Zeilen, die mit ``- `` oder ``* `` beginnen ``<ul><li>...</li></ul>``
- Doppel-Newlines trennen Absätze ``<p>...</p>``
- Einzelne Newlines innerhalb eines Absatzes ``<br>``
- HTML-Special-Chars (``&``, ``<``, ``>``) werden escaped.
Die Python-Variante existiert primär zum Testen. Im Produktiv-Frontend
wird die JS-Variante benutzt (kein Round-Trip zum Server).
"""
from __future__ import annotations
import re
# 1) HTML-Escape (analog zur JS-Variante, in dieser Reihenfolge)
def _html_escape(s: str) -> str:
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
# 2) Bold-Marker: **...** und __...__
_RE_BOLD_STAR = re.compile(r"\*\*([^*\n]+?)\*\*")
_RE_BOLD_UNDER = re.compile(r"__([^_\n]+?)__")
# 3) Italic-Marker: *…* und _…_, jeweils nicht zw. Word-Chars
_RE_ITALIC_STAR = re.compile(r"(?<![*\w])\*([^*\n]+?)\*(?![*\w])")
_RE_ITALIC_UNDER = re.compile(r"(?<![_\w])_([^_\n]+?)_(?![_\w])")
# 4) Listen-Bullet
_RE_BULLET = re.compile(r"^\s*[-*]\s+")
def render_pm_body(body: str) -> str:
"""Render Mini-Markdown zu HTML. Leere/None-Eingabe → leerer String."""
if not body:
return ""
s = _html_escape(body)
s = _RE_BOLD_STAR.sub(r"<strong>\1</strong>", s)
s = _RE_BOLD_UNDER.sub(r"<strong>\1</strong>", s)
s = _RE_ITALIC_STAR.sub(r"<em>\1</em>", s)
s = _RE_ITALIC_UNDER.sub(r"<em>\1</em>", s)
# Listen: konsekutive "- "/"* "-Zeilen zu einem <ul> zusammenfassen
out_lines = []
in_list = False
for line in s.split("\n"):
if _RE_BULLET.match(line):
if not in_list:
out_lines.append('<ul style="margin:0.5em 0;padding-left:1.4em;">')
in_list = True
out_lines.append("<li>" + _RE_BULLET.sub("", line) + "</li>")
else:
if in_list:
out_lines.append("</ul>")
in_list = False
out_lines.append(line)
if in_list:
out_lines.append("</ul>")
s = "\n".join(out_lines)
# Paragraphen-Split bei doppelten Newlines, einzelne → <br>
paras = re.split(r"\n\s*\n", s)
rendered = []
for p in paras:
trimmed = p.strip()
if not trimmed:
continue
if trimmed.startswith("<"):
rendered.append(trimmed)
else:
rendered.append(
'<p style="margin:0 0 0.9em;">'
+ trimmed.replace("\n", "<br>")
+ "</p>"
)
return "\n".join(rendered)

View File

@ -276,27 +276,15 @@
"sammel": "#0969da",
} %}
{# Konsistenz-Hinweis: GWÖ-Empfehlung vs. tatsächlicher Beschluss.
Bei mehreren Votes (Überweisung → Endabstimmung) erste mit
definitivem Outcome bevorzugen. #}
{% set verdict_text = (antrag.verdict_title or '') | lower %}
{% set decisive = namespace(ergebnis=None) %}
{% for v in antrag.plenum_votes %}
{% if not decisive.ergebnis and (v.ergebnis or '') | lower in ['angenommen', 'abgelehnt', 'bestätigt'] %}
{% set decisive.ergebnis = (v.ergebnis or '') | lower %}
{% endif %}
{% endfor %}
{% set rec_supports = ('unterstützen' in verdict_text) or ('befürworten' in verdict_text) %}
{% set rec_rejects = 'ablehnen' in verdict_text %}
{% set out_passed = decisive.ergebnis in ['angenommen', 'bestätigt'] %}
{% set out_failed = decisive.ergebnis == 'abgelehnt' %}
{% set conflict = (rec_supports and out_failed) or (rec_rejects and out_passed) %}
{% set aligned = (rec_supports and out_passed) or (rec_rejects and out_failed) %}
{% if conflict or aligned %}
Logik in app/marker.py — siehe ADR 0010. #}
{% set _state = consistency_state(antrag.verdict_title, antrag.plenum_votes) %}
{% set _decisive = decisive_outcome(antrag.plenum_votes) %}
{% if _state %}
<div style="margin-bottom:10px;padding:8px 12px;border-radius:6px;font-size:12px;line-height:1.5;
background:{% if conflict %}color-mix(in srgb,#cf222e 8%,transparent){% else %}color-mix(in srgb,#2da44e 8%,transparent){% endif %};
border-left:3px solid {% if conflict %}#cf222e{% else %}#2da44e{% endif %};">
<strong>{% if conflict %}Mehrheit kontra GWÖ-Empfehlung{% else %}Mehrheit deckt sich mit GWÖ-Empfehlung{% endif %}</strong>
— Empfohlen: <em>{{ antrag.verdict_title }}</em>; Beschluss: <em>{{ decisive.ergebnis | capitalize }}</em>.
background:{% if _state == 'conflict' %}color-mix(in srgb,#cf222e 8%,transparent){% else %}color-mix(in srgb,#2da44e 8%,transparent){% endif %};
border-left:3px solid {% if _state == 'conflict' %}#cf222e{% else %}#2da44e{% endif %};">
<strong>{% if _state == 'conflict' %}Mehrheit kontra GWÖ-Empfehlung{% else %}Mehrheit deckt sich mit GWÖ-Empfehlung{% endif %}</strong>
— Empfohlen: <em>{{ antrag.verdict_title }}</em>; Beschluss: <em>{{ _decisive | capitalize }}</em>.
</div>
{% endif %}
{% for v in antrag.plenum_votes %}
@ -319,9 +307,8 @@
{% if v.fraktionen_nein %}
<div><span style="color:#cf222e;font-weight:700;">Nein:</span>
{% for f in v.fraktionen_nein %}
{% set fs_match = (antrag.fraktions_scores or []) | selectattr('fraktion', 'equalto', f) | list %}
{% set wp_score_match = fs_match[0].wahlprogramm.score | float if fs_match else 0 %}
<span style="display:inline-block;padding:1px 6px;background:color-mix(in srgb,#cf222e 15%,transparent);color:#a40e26;border-radius:3px;margin-right:3px;">{{ f }}{% if wp_score_match >= 7 %}<span style="margin-left:4px;cursor:help;" title="Diese Fraktion stimmte mit Nein, obwohl der Antrag gut zum eigenen Wahlprogramm passt (WP-Score {{ '%.0f' | format(wp_score_match) }}/10)."></span>{% endif %}</span>
{% set _wp_match = heuchelei_score(f, antrag.fraktions_scores) %}
<span style="display:inline-block;padding:1px 6px;background:color-mix(in srgb,#cf222e 15%,transparent);color:#a40e26;border-radius:3px;margin-right:3px;">{{ f }}{% if _wp_match is not none %}<span style="margin-left:4px;cursor:help;" title="Diese Fraktion stimmte mit Nein, obwohl der Antrag gut zum eigenen Wahlprogramm passt (WP-Score {{ '%.0f' | format(_wp_match) }}/10)."></span>{% endif %}</span>
{% endfor %}
</div>
{% endif %}

192
tests/test_marker.py Normal file
View File

@ -0,0 +1,192 @@
"""Unit-Tests für app.marker — Heuchelei + Konsistenz."""
import pytest
try:
from app.marker import (
heuchelei_score,
decisive_outcome,
consistency_state,
DECISIVE_OUTCOMES,
)
_HAS_MARKER = True
except ImportError:
_HAS_MARKER = False
pytestmark = pytest.mark.skipif(not _HAS_MARKER, reason="app.marker nicht importierbar")
# ─── heuchelei_score ────────────────────────────────────────────────────────
class TestHeucheleiScore:
"""heuchelei_score liefert WP-Score wenn Fraktion NEIN-stimmt aber WP≥7."""
def test_score_high_returns_score(self):
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 7.0}}]
assert heuchelei_score("CDU", fs) == 7.0
def test_score_at_threshold_returns_score(self):
# 7.0 ist genau Schwelle und sollte greifen
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 7.0}}]
assert heuchelei_score("CDU", fs, threshold=7.0) == 7.0
def test_score_below_returns_none(self):
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 6.9}}]
assert heuchelei_score("CDU", fs) is None
def test_score_above_default_returns_score(self):
fs = [{"fraktion": "FDP", "wahlprogramm": {"score": 8.0}}]
assert heuchelei_score("FDP", fs) == 8.0
def test_unknown_fraktion_returns_none(self):
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 9.0}}]
assert heuchelei_score("AfD", fs) is None
def test_empty_list_returns_none(self):
assert heuchelei_score("CDU", []) is None
def test_none_input_returns_none(self):
assert heuchelei_score("CDU", None) is None
def test_missing_score_field_returns_none(self):
fs = [{"fraktion": "CDU", "wahlprogramm": {}}]
assert heuchelei_score("CDU", fs) is None
def test_score_as_int_handled(self):
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 7}}]
assert heuchelei_score("CDU", fs) == 7.0
def test_invalid_score_returns_none(self):
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": "not-a-number"}}]
assert heuchelei_score("CDU", fs) is None
def test_custom_threshold(self):
fs = [{"fraktion": "CDU", "wahlprogramm": {"score": 5.0}}]
# bei threshold=4 greift's
assert heuchelei_score("CDU", fs, threshold=4.0) == 5.0
# bei threshold=6 nicht
assert heuchelei_score("CDU", fs, threshold=6.0) is None
def test_finds_first_match(self):
"""Wenn dieselbe Fraktion mehrfach in der Liste ist, wird der erste Treffer genommen."""
fs = [
{"fraktion": "CDU", "wahlprogramm": {"score": 8.0}},
{"fraktion": "CDU", "wahlprogramm": {"score": 3.0}},
]
assert heuchelei_score("CDU", fs) == 8.0
# ─── decisive_outcome ───────────────────────────────────────────────────────
class TestDecisiveOutcome:
"""decisive_outcome wählt erstes Vote mit angenommen|abgelehnt|bestätigt."""
def test_single_decisive(self):
votes = [{"ergebnis": "angenommen"}]
assert decisive_outcome(votes) == "angenommen"
def test_single_abgelehnt(self):
votes = [{"ergebnis": "abgelehnt"}]
assert decisive_outcome(votes) == "abgelehnt"
def test_single_bestaetigt(self):
votes = [{"ergebnis": "bestätigt"}]
assert decisive_outcome(votes) == "bestätigt"
def test_skip_ueberwiesen_to_decisive(self):
"""Bei Überweisung → angenommen sollte angenommen gewählt werden."""
votes = [
{"ergebnis": "überwiesen"},
{"ergebnis": "angenommen"},
]
assert decisive_outcome(votes) == "angenommen"
def test_only_ueberwiesen_returns_none(self):
votes = [{"ergebnis": "überwiesen"}]
assert decisive_outcome(votes) is None
def test_only_zurueckgezogen_returns_none(self):
votes = [{"ergebnis": "zurückgezogen"}]
assert decisive_outcome(votes) is None
def test_empty_list_returns_none(self):
assert decisive_outcome([]) is None
def test_none_input_returns_none(self):
assert decisive_outcome(None) is None
def test_first_decisive_wins(self):
"""Wenn beide Votes definitiv sind, wird das erste gewählt."""
votes = [
{"ergebnis": "angenommen"},
{"ergebnis": "abgelehnt"},
]
assert decisive_outcome(votes) == "angenommen"
def test_case_insensitive(self):
votes = [{"ergebnis": "Angenommen"}]
assert decisive_outcome(votes) == "angenommen"
def test_decisive_outcomes_constant(self):
"""Konstante DECISIVE_OUTCOMES enthält genau die drei Werte."""
assert DECISIVE_OUTCOMES == frozenset({"angenommen", "abgelehnt", "bestätigt"})
# ─── consistency_state ──────────────────────────────────────────────────────
class TestConsistencyState:
"""consistency_state vergleicht GWÖ-Empfehlung × Beschluss."""
def test_supports_passed_aligned(self):
votes = [{"ergebnis": "angenommen"}]
assert consistency_state("Uneingeschränkt unterstützen", votes) == "aligned"
def test_supports_failed_conflict(self):
votes = [{"ergebnis": "abgelehnt"}]
assert consistency_state("Uneingeschränkt unterstützen", votes) == "conflict"
def test_rejects_failed_aligned(self):
votes = [{"ergebnis": "abgelehnt"}]
assert consistency_state("Ablehnen", votes) == "aligned"
def test_rejects_passed_conflict(self):
votes = [{"ergebnis": "angenommen"}]
assert consistency_state("Ablehnen", votes) == "conflict"
def test_supports_with_changes_passed_aligned(self):
"""„Unterstützen mit Änderungen" enthält 'unterstützen' → rec_supports."""
votes = [{"ergebnis": "angenommen"}]
assert consistency_state("Unterstützen mit Änderungen", votes) == "aligned"
def test_befuerworten_synonym(self):
votes = [{"ergebnis": "angenommen"}]
assert consistency_state("Befürworten", votes) == "aligned"
def test_ambivalent_returns_none(self):
"""„Mit Vorbehalt" enthält weder 'unterstützen' noch 'ablehnen'."""
votes = [{"ergebnis": "angenommen"}]
assert consistency_state("Mit Vorbehalt", votes) is None
def test_ueberwiesen_returns_none(self):
votes = [{"ergebnis": "überwiesen"}]
assert consistency_state("Uneingeschränkt unterstützen", votes) is None
def test_no_votes_returns_none(self):
assert consistency_state("Uneingeschränkt unterstützen", []) is None
def test_no_verdict_returns_none(self):
votes = [{"ergebnis": "angenommen"}]
assert consistency_state("", votes) is None
assert consistency_state(None, votes) is None
def test_multivote_picks_decisive(self):
"""Bei Überweisung → Endabstimmung wird die Endabstimmung gewertet."""
votes = [
{"ergebnis": "überwiesen"},
{"ergebnis": "abgelehnt"},
]
assert consistency_state("Uneingeschränkt unterstützen", votes) == "conflict"
def test_bestaetigt_treated_as_passed(self):
votes = [{"ergebnis": "bestätigt"}]
assert consistency_state("Uneingeschränkt unterstützen", votes) == "aligned"
assert consistency_state("Ablehnen", votes) == "conflict"

137
tests/test_pm_render.py Normal file
View File

@ -0,0 +1,137 @@
"""Tests für app.pm_render — Python-Spiegelbild des JS-Mini-Markdown-Renderers."""
import pytest
try:
from app.pm_render import render_pm_body
_HAS_RENDER = True
except ImportError:
_HAS_RENDER = False
pytestmark = pytest.mark.skipif(not _HAS_RENDER, reason="app.pm_render nicht importierbar")
class TestEmptyAndNone:
def test_empty_returns_empty(self):
assert render_pm_body("") == ""
def test_none_returns_empty(self):
assert render_pm_body(None) == ""
class TestPlainParagraph:
def test_single_paragraph(self):
out = render_pm_body("Ein einzelner Satz.")
assert out == '<p style="margin:0 0 0.9em;">Ein einzelner Satz.</p>'
def test_two_paragraphs(self):
out = render_pm_body("Erster Satz.\n\nZweiter Satz.")
assert "Erster Satz" in out
assert "Zweiter Satz" in out
assert out.count("<p ") == 2
def test_single_newline_becomes_br(self):
out = render_pm_body("Zeile A\nZeile B")
assert "Zeile A<br>Zeile B" in out
class TestHtmlEscape:
def test_lt_gt_escaped(self):
out = render_pm_body("Vergleich a < b > c.")
assert "&lt;" in out and "&gt;" in out
assert "<b>" not in out # nichts wird zu echten Tags
def test_amp_escaped(self):
out = render_pm_body("Tom & Jerry")
assert "&amp;" in out
class TestBold:
def test_double_star_to_strong(self):
out = render_pm_body("Wert: **30 %**")
assert "<strong>30 %</strong>" in out
def test_double_underscore_to_strong(self):
out = render_pm_body("Wert: __wichtig__")
assert "<strong>wichtig</strong>" in out
def test_no_bold_across_newline(self):
"""**...** mit Newline dazwischen wird nicht gemarkt."""
out = render_pm_body("**erste\nZeile**")
assert "<strong>" not in out
class TestItalic:
def test_single_star_to_em(self):
out = render_pm_body("Das ist *betont*.")
assert "<em>betont</em>" in out
def test_single_underscore_to_em(self):
out = render_pm_body("Das ist _wichtig_.")
assert "<em>wichtig</em>" in out
def test_underscore_in_word_not_em(self):
"""snake_case darf nicht als em gerendert werden."""
out = render_pm_body("Variable my_var hier.")
assert "<em>" not in out
def test_star_between_words_not_em(self):
"""Ein * zwischen Wort-Charaktern ist kein em-Marker."""
out = render_pm_body("Datei: foo*bar.txt")
assert "<em>" not in out
class TestBoldAndItalicCombined:
def test_bold_first_then_italic(self):
out = render_pm_body("**fett** und *kursiv*.")
assert "<strong>fett</strong>" in out
assert "<em>kursiv</em>" in out
class TestLists:
def test_simple_dash_list(self):
out = render_pm_body("- Item 1\n- Item 2")
assert "<ul" in out
assert "<li>Item 1</li>" in out
assert "<li>Item 2</li>" in out
def test_simple_star_list(self):
out = render_pm_body("* A\n* B")
assert "<ul" in out
assert "<li>A</li>" in out
assert "<li>B</li>" in out
def test_list_with_paragraph_around(self):
body = "Vor der Liste.\n\n- Item 1\n- Item 2\n\nNach der Liste."
out = render_pm_body(body)
assert "<p" in out
assert "<ul" in out
assert "<li>Item 1</li>" in out
def test_dash_in_word_not_list(self):
"""Ein Bindestrich mitten im Wort ist kein Bullet."""
out = render_pm_body("Halb-leer ist halb-voll.")
assert "<ul" not in out
class TestRealisticPmBody:
"""End-to-End-Sample wie qwen-max es typischerweise produziert."""
def test_sample_with_bold_and_paragraphs(self):
body = (
"Mehr junge Menschen entscheiden sich für die Pflege.\n\n"
"Auszubildende brechen heute zu rund **30 %** ab.\n\n"
"Wir fordern eine Mindest-Vergütung."
)
out = render_pm_body(body)
# Drei Paragraphen
assert out.count("<p ") == 3
# Bold-Markierung gerendert
assert "<strong>30 %</strong>" in out
# Keine rohen ** mehr
assert "**" not in out
def test_no_bold_when_unmatched(self):
body = "Ein *einfacher Stern."
out = render_pm_body(body)
# Unmatched * darf nicht als em gerendert werden
assert "<em>" not in out