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:
parent
abb6cf81a8
commit
9498ca4b97
@ -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
103
app/marker.py
Normal 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
81
app/pm_render.py
Normal 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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
# 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)
|
||||
@ -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
192
tests/test_marker.py
Normal 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
137
tests/test_pm_render.py
Normal 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 "<" in out and ">" in out
|
||||
assert "<b>" not in out # nichts wird zu echten Tags
|
||||
|
||||
def test_amp_escaped(self):
|
||||
out = render_pm_body("Tom & Jerry")
|
||||
assert "&" 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
|
||||
Loading…
Reference in New Issue
Block a user