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")
|
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||||
templates = Jinja2Templates(directory=str(templates_dir))
|
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 ─────────────────
|
# ─── 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",
|
"sammel": "#0969da",
|
||||||
} %}
|
} %}
|
||||||
{# Konsistenz-Hinweis: GWÖ-Empfehlung vs. tatsächlicher Beschluss.
|
{# Konsistenz-Hinweis: GWÖ-Empfehlung vs. tatsächlicher Beschluss.
|
||||||
Bei mehreren Votes (Überweisung → Endabstimmung) erste mit
|
Logik in app/marker.py — siehe ADR 0010. #}
|
||||||
definitivem Outcome bevorzugen. #}
|
{% set _state = consistency_state(antrag.verdict_title, antrag.plenum_votes) %}
|
||||||
{% set verdict_text = (antrag.verdict_title or '') | lower %}
|
{% set _decisive = decisive_outcome(antrag.plenum_votes) %}
|
||||||
{% set decisive = namespace(ergebnis=None) %}
|
{% if _state %}
|
||||||
{% 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 %}
|
|
||||||
<div style="margin-bottom:10px;padding:8px 12px;border-radius:6px;font-size:12px;line-height:1.5;
|
<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 %};
|
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 conflict %}#cf222e{% else %}#2da44e{% endif %};">
|
border-left:3px solid {% if _state == 'conflict' %}#cf222e{% else %}#2da44e{% endif %};">
|
||||||
<strong>{% if conflict %}Mehrheit kontra GWÖ-Empfehlung{% else %}Mehrheit deckt sich mit GWÖ-Empfehlung{% endif %}</strong>
|
<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.ergebnis | capitalize }}</em>.
|
— Empfohlen: <em>{{ antrag.verdict_title }}</em>; Beschluss: <em>{{ _decisive | capitalize }}</em>.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for v in antrag.plenum_votes %}
|
{% for v in antrag.plenum_votes %}
|
||||||
@ -319,9 +307,8 @@
|
|||||||
{% if v.fraktionen_nein %}
|
{% if v.fraktionen_nein %}
|
||||||
<div><span style="color:#cf222e;font-weight:700;">Nein:</span>
|
<div><span style="color:#cf222e;font-weight:700;">Nein:</span>
|
||||||
{% for f in v.fraktionen_nein %}
|
{% for f in v.fraktionen_nein %}
|
||||||
{% set fs_match = (antrag.fraktions_scores or []) | selectattr('fraktion', 'equalto', f) | list %}
|
{% set _wp_match = heuchelei_score(f, antrag.fraktions_scores) %}
|
||||||
{% 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_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>
|
||||||
<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>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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