feat(#177): Programm-Treue im BELEGE-Layout — pro Partei zwei aufklappbare Blöcke (WP+PP)
- _row_to_detail liefert zitate inline pro Wahlprogramm/Parteiprogramm-Block - Template rendert <details>: Summary mit Score-Chip, Body mit Einschätzung+Belege - v2.css: neue Klassen v2-treue-block/-label/-body, v2-pill, v2-einschaetzung - Separate "Belege — Partei"-Sektion entfernt (ist jetzt inline pro Programm) Tests: tests/test_v2_pdf_consistency.py (#176 generalisiert) bleibt grün — fraktions_scores trägt zusätzliche zitate-Felder, ändert aber keine Score/Begründungs-Werte aus dem Vergleich. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
38e58e4ee0
commit
70d9790b4b
13
app/main.py
13
app/main.py
@ -464,6 +464,17 @@ def _row_to_detail(row):
|
|||||||
ist_reg = wp.get("istRegierung", wp.get("ist_regierung"))
|
ist_reg = wp.get("istRegierung", wp.get("ist_regierung"))
|
||||||
if ist_reg is None:
|
if ist_reg is None:
|
||||||
ist_reg = fraktion in regierung_set
|
ist_reg = fraktion in regierung_set
|
||||||
|
# Zitate inline pro WP-/PP-Block (#177-Folge: Belege im Treue-Layout)
|
||||||
|
def _zitate_of(src: dict) -> list[dict]:
|
||||||
|
out = []
|
||||||
|
for z in (src.get("zitate") or []):
|
||||||
|
out.append({
|
||||||
|
"text": z.get("text", ""),
|
||||||
|
"source": z.get("quelle", ""),
|
||||||
|
"pdf_href": _build_pdf_href(z, bundesland),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
fraktions_scores.append({
|
fraktions_scores.append({
|
||||||
"fraktion": fraktion,
|
"fraktion": fraktion,
|
||||||
"ist_antragsteller": ist_antrag,
|
"ist_antragsteller": ist_antrag,
|
||||||
@ -472,11 +483,13 @@ def _row_to_detail(row):
|
|||||||
"score": wp_src.get("score", 0),
|
"score": wp_src.get("score", 0),
|
||||||
"begruendung": wp_src.get("begruendung", wp_src.get("begründung", "")),
|
"begruendung": wp_src.get("begruendung", wp_src.get("begründung", "")),
|
||||||
"hat_zitate": bool(wp_src.get("zitate")),
|
"hat_zitate": bool(wp_src.get("zitate")),
|
||||||
|
"zitate": _zitate_of(wp_src),
|
||||||
},
|
},
|
||||||
"parteiprogramm": {
|
"parteiprogramm": {
|
||||||
"score": pp_src.get("score", 0),
|
"score": pp_src.get("score", 0),
|
||||||
"begruendung": pp_src.get("begruendung", pp_src.get("begründung", "")),
|
"begruendung": pp_src.get("begruendung", pp_src.get("begründung", "")),
|
||||||
"hat_zitate": bool(pp_src.get("zitate")),
|
"hat_zitate": bool(pp_src.get("zitate")),
|
||||||
|
"zitate": _zitate_of(pp_src),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -938,25 +938,111 @@ body.v2 ul.v2-manual ul li::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.v2-fraktion-row {
|
.v2-fraktion-row {
|
||||||
display: flex;
|
display: block;
|
||||||
align-items: center;
|
padding: 10px 12px;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 6px 8px;
|
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
border: 1px solid var(--hairline);
|
border: 1px solid var(--hairline);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v2-fraktion-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.v2-fraktion-label {
|
.v2-fraktion-label {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rolle-Pills (Antragsteller:in / Regierungsfraktion) */
|
||||||
|
.v2-pill {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.v2-pill-antrag {
|
||||||
|
color: #bf6c10;
|
||||||
|
background: rgba(247, 148, 29, 0.15);
|
||||||
|
}
|
||||||
|
.v2-pill-reg {
|
||||||
|
color: #1e6a90;
|
||||||
|
background: rgba(0, 157, 165, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aufklappbare Programm-Blöcke (Wahlprogramm/Parteiprogramm) */
|
||||||
|
.v2-treue-block {
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.v2-treue-block:first-of-type {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
.v2-treue-block > summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 8px;
|
||||||
min-width: 90px;
|
padding: 8px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.v2-treue-block > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.v2-treue-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: #0d6f76;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.v2-treue-spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.v2-treue-caret {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--ecg-dark);
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
.v2-treue-block[open] > summary .v2-treue-caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
.v2-treue-body {
|
||||||
|
padding: 4px 0 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.v2-einschaetzung {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.v2-einschaetzung-label {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
color: var(--ecg-dark);
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.v2-einschaetzung-text {
|
||||||
|
color: var(--ecg-dark);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.v2-belege {
|
||||||
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.v2-fraktion-scores {
|
.v2-fraktion-scores {
|
||||||
|
|||||||
@ -353,57 +353,76 @@
|
|||||||
{{ matrix_mini(antrag.matrix) }}
|
{{ matrix_mini(antrag.matrix) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Fraktions-Score-Tabelle: Score-Chips + ausgeschriebene Rolle +
|
{# Programm-Treue im BELEGE-Layout: pro Partei zwei aufklappbare Blöcke
|
||||||
sichtbare Begründung für jeden Score (#177). #}
|
(Wahlprogramm + Parteiprogramm). Summary zeigt Bewertung, expand
|
||||||
|
enthält Einschätzung + Belege. #}
|
||||||
{% if antrag.fraktions_scores %}
|
{% if antrag.fraktions_scores %}
|
||||||
<h3 class="v2-h3" style="margin-top:24px;">Programm-Treue pro Fraktion</h3>
|
<h3 class="v2-h3" style="margin-top:24px;">Programm-Treue pro Fraktion</h3>
|
||||||
<div class="v2-fraktions-scores">
|
<div class="v2-fraktions-scores">
|
||||||
{% for fs in antrag.fraktions_scores %}
|
{% for fs in antrag.fraktions_scores %}
|
||||||
{% set wp_score = fs.wahlprogramm.score | float %}
|
{% set wp_score = fs.wahlprogramm.score | float %}
|
||||||
{% set pp_score = fs.parteiprogramm.score | float %}
|
{% set pp_score = fs.parteiprogramm.score | float %}
|
||||||
<div class="v2-fraktion-row" style="display:block;border-bottom:1px solid var(--hairline);padding:10px 0;">
|
<div class="v2-fraktion-row">
|
||||||
<div style="display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin-bottom:6px;">
|
<div class="v2-fraktion-head">
|
||||||
<span class="v2-fraktion-label" style="font-weight:700;">{{ fs.fraktion }}</span>
|
<span class="v2-fraktion-label">{{ fs.fraktion }}</span>
|
||||||
{% if fs.ist_antragsteller %}<span style="font-family:var(--font-mono);font-size:10px;color:#bf6c10;background:rgba(247,148,29,0.15);padding:1px 6px;border-radius:3px;">Antragsteller:in</span>{% endif %}
|
{% if fs.ist_antragsteller %}<span class="v2-pill v2-pill-antrag">Antragsteller:in</span>{% endif %}
|
||||||
{% if fs.ist_regierung %}<span style="font-family:var(--font-mono);font-size:10px;color:#1e6a90;background:rgba(0,157,165,0.15);padding:1px 6px;border-radius:3px;">Regierungsfraktion</span>{% endif %}
|
{% if fs.ist_regierung %}<span class="v2-pill v2-pill-reg">Regierungsfraktion</span>{% endif %}
|
||||||
<span style="margin-left:auto;display:inline-flex;gap:4px;">
|
|
||||||
<span class="v2-score-chip {% if wp_score >= 7 %}chip-green{% elif wp_score >= 4 %}chip-mid{% else %}chip-red{% endif %}">
|
|
||||||
WP {{ "%.0f"|format(wp_score) }}/10
|
|
||||||
</span>
|
|
||||||
<span class="v2-score-chip {% if pp_score >= 7 %}chip-green{% elif pp_score >= 4 %}chip-mid{% else %}chip-red{% endif %}">
|
|
||||||
PP {{ "%.0f"|format(pp_score) }}/10
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% if fs.wahlprogramm.begruendung %}
|
|
||||||
<div style="font-size:12px;line-height:1.5;color:var(--ecg-dark);opacity:0.85;margin-bottom:4px;">
|
<details class="v2-treue-block">
|
||||||
<strong style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;color:#0d6f76;">Wahlprogramm</strong>
|
<summary>
|
||||||
— {{ fs.wahlprogramm.begruendung }}
|
<span class="v2-treue-label">Wahlprogramm</span>
|
||||||
</div>
|
<span class="v2-treue-spacer"></span>
|
||||||
{% endif %}
|
<span class="v2-score-chip {% if wp_score >= 7 %}chip-green{% elif wp_score >= 4 %}chip-mid{% else %}chip-red{% endif %}">{{ "%.0f"|format(wp_score) }}/10</span>
|
||||||
{% if fs.parteiprogramm.begruendung %}
|
<span class="v2-treue-caret" aria-hidden="true">▾</span>
|
||||||
<div style="font-size:12px;line-height:1.5;color:var(--ecg-dark);opacity:0.85;">
|
</summary>
|
||||||
<strong style="font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:0.05em;color:#0d6f76;">Parteiprogramm</strong>
|
<div class="v2-treue-body">
|
||||||
— {{ fs.parteiprogramm.begruendung }}
|
{% if fs.wahlprogramm.begruendung %}
|
||||||
</div>
|
<div class="v2-einschaetzung">
|
||||||
{% endif %}
|
<div class="v2-einschaetzung-label">Einschätzung</div>
|
||||||
|
<div class="v2-einschaetzung-text">{{ fs.wahlprogramm.begruendung }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if fs.wahlprogramm.zitate %}
|
||||||
|
<div class="v2-belege">
|
||||||
|
<div class="v2-einschaetzung-label">Belege</div>
|
||||||
|
{% for z in fs.wahlprogramm.zitate %}
|
||||||
|
{{ quote_card(z.text, z.source, True, False, z.pdf_href) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="v2-treue-block">
|
||||||
|
<summary>
|
||||||
|
<span class="v2-treue-label">Parteiprogramm</span>
|
||||||
|
<span class="v2-treue-spacer"></span>
|
||||||
|
<span class="v2-score-chip {% if pp_score >= 7 %}chip-green{% elif pp_score >= 4 %}chip-mid{% else %}chip-red{% endif %}">{{ "%.0f"|format(pp_score) }}/10</span>
|
||||||
|
<span class="v2-treue-caret" aria-hidden="true">▾</span>
|
||||||
|
</summary>
|
||||||
|
<div class="v2-treue-body">
|
||||||
|
{% if fs.parteiprogramm.begruendung %}
|
||||||
|
<div class="v2-einschaetzung">
|
||||||
|
<div class="v2-einschaetzung-label">Einschätzung</div>
|
||||||
|
<div class="v2-einschaetzung-text">{{ fs.parteiprogramm.begruendung }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if fs.parteiprogramm.zitate %}
|
||||||
|
<div class="v2-belege">
|
||||||
|
<div class="v2-einschaetzung-label">Belege</div>
|
||||||
|
{% for z in fs.parteiprogramm.zitate %}
|
||||||
|
{{ quote_card(z.text, z.source, True, False, z.pdf_href) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Zitate nach Partei gruppiert; Fraktion ohne Zitate erhält Hinweis via fraktions_scores-Block oben #}
|
|
||||||
{% if antrag.zitate %}
|
|
||||||
{% set current_partei = namespace(value="") %}
|
|
||||||
{% for z in antrag.zitate %}
|
|
||||||
{% if z.partei != current_partei.value %}
|
|
||||||
{% set current_partei.value = z.partei %}
|
|
||||||
<h3 class="v2-h3" style="margin-top:24px;">Belege — {{ z.partei }}</h3>
|
|
||||||
{% endif %}
|
|
||||||
{{ quote_card(z.text, z.source, z.verified | default(True), z.contra | default(False), z.pdf_href | default("")) }}
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# ── News-Match-Box: aktuelle News passend zu diesem Antrag (#170) ── #}
|
{# ── News-Match-Box: aktuelle News passend zu diesem Antrag (#170) ── #}
|
||||||
<div id="ad-news-box" style="display:none;margin-top:32px;background:var(--ecg-card-bg);border:1px solid var(--ecg-border);border-radius:6px;padding:16px;">
|
<div id="ad-news-box" style="display:none;margin-top:32px;background:var(--ecg-card-bg);border:1px solid var(--ecg-border);border-radius:6px;padding:16px;">
|
||||||
<h3 class="v2-h3" style="margin:0 0 8px;">Aktuelle News passend zu diesem Antrag</h3>
|
<h3 class="v2-h3" style="margin:0 0 8px;">Aktuelle News passend zu diesem Antrag</h3>
|
||||||
|
|||||||
240
tests/test_v2_pdf_consistency.py
Normal file
240
tests/test_v2_pdf_consistency.py
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
"""Konsistenz-Tests v2-Detail ↔ PDF (#176 generalisiert).
|
||||||
|
|
||||||
|
Stichprobe aus assessments. Pro Drucksache vergleicht der Test die
|
||||||
|
kritischen Felder:
|
||||||
|
- gwoe_score
|
||||||
|
- empfehlung
|
||||||
|
- Matrix-Cells (rating + abgeleitetes Symbol + abgeleitete Klasse)
|
||||||
|
- wahlprogramm_scores (pro Fraktion: WP-Score, PP-Score, Begründungen)
|
||||||
|
|
||||||
|
Quelle für v2: app.main._row_to_detail(row).
|
||||||
|
Quelle für PDF: app.models.Assessment.model_validate(row) + die
|
||||||
|
get_rating_symbol/get_rating_class-Funktionen aus app.report.
|
||||||
|
|
||||||
|
Wenn beide Renderer dieselben kritischen Werte aus row ziehen, gibt es
|
||||||
|
keine v2/PDF-Drift mehr.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _has_app() -> bool:
|
||||||
|
try:
|
||||||
|
from app import main, report # noqa
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.skipif(not _has_app(), reason="app nicht importierbar")
|
||||||
|
|
||||||
|
|
||||||
|
def _live_rows(limit: int = 20) -> list[dict]:
|
||||||
|
"""Hole bis zu N Assessment-Rows direkt aus der prod-DB.
|
||||||
|
|
||||||
|
Der Test läuft nur wenn die Container-DB lokal mountbar ist —
|
||||||
|
sonst skippen wir mit Hinweis (nicht failen).
|
||||||
|
"""
|
||||||
|
candidates = [
|
||||||
|
Path("/app/data/gwoe-antraege.db"), # innerhalb des Containers
|
||||||
|
Path(__file__).resolve().parent.parent / "data" / "gwoe-antraege.db",
|
||||||
|
]
|
||||||
|
db = next((p for p in candidates if p.exists()), None)
|
||||||
|
if not db:
|
||||||
|
pytest.skip(f"keine gwoe-antraege.db gefunden (versucht: {candidates})")
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM assessments WHERE gwoe_score IS NOT NULL "
|
||||||
|
"ORDER BY datum DESC LIMIT ?",
|
||||||
|
(limit,),
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
# JSON-Felder deserialisieren wie _row_to_detail es erwartet
|
||||||
|
import json as _json
|
||||||
|
for k in ("fraktionen", "gwoe_matrix", "gwoe_schwerpunkt",
|
||||||
|
"wahlprogramm_scores", "verbesserungen", "staerken",
|
||||||
|
"schwaechen", "themen", "antrag_kernpunkte"):
|
||||||
|
if k in d and isinstance(d[k], str):
|
||||||
|
try:
|
||||||
|
d[k] = _json.loads(d[k])
|
||||||
|
except Exception:
|
||||||
|
d[k] = None
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _v2_score(row: dict):
|
||||||
|
"""Wert wie er im v2-Detail erscheint."""
|
||||||
|
return row.get("gwoe_score")
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_score(row: dict):
|
||||||
|
"""Wert wie er im PDF erscheint."""
|
||||||
|
from app.models import Assessment
|
||||||
|
a = Assessment.model_validate(row)
|
||||||
|
return a.gwoe_score
|
||||||
|
|
||||||
|
|
||||||
|
def _v2_empfehlung(row: dict):
|
||||||
|
return row.get("empfehlung")
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_empfehlung(row: dict):
|
||||||
|
from app.models import Assessment
|
||||||
|
a = Assessment.model_validate(row)
|
||||||
|
return a.empfehlung.value
|
||||||
|
|
||||||
|
|
||||||
|
def _v2_matrix_keys(row: dict) -> dict[str, tuple[int, str, str]]:
|
||||||
|
"""Dict {field: (rating, symbol, class)} wie es im v2-Template gerendert wird."""
|
||||||
|
out = {}
|
||||||
|
for cell in (row.get("gwoe_matrix") or []):
|
||||||
|
if not isinstance(cell, dict):
|
||||||
|
continue
|
||||||
|
field = cell.get("field")
|
||||||
|
if not field:
|
||||||
|
continue
|
||||||
|
rating_raw = cell.get("rating", 0)
|
||||||
|
try:
|
||||||
|
rating = int(rating_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
rating = 0
|
||||||
|
if rating < -5: rating = -5
|
||||||
|
if rating > 5: rating = 5
|
||||||
|
if rating >= 4: sym, cls = "++", "m-pp"
|
||||||
|
elif rating >= 1: sym, cls = "+", "m-p"
|
||||||
|
elif rating == 0: sym, cls = "○", "m-0"
|
||||||
|
elif rating <= -4: sym, cls = "−−", "m-nn"
|
||||||
|
else: sym, cls = "−", "m-n"
|
||||||
|
out[field] = (rating, sym, cls)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _pdf_matrix_keys(row: dict) -> dict[str, tuple[int, str, str]]:
|
||||||
|
from app.models import Assessment
|
||||||
|
from app.report import get_rating_symbol, get_rating_class
|
||||||
|
a = Assessment.model_validate(row)
|
||||||
|
out = {}
|
||||||
|
# Mapping: rating-pp/p/0/n/nn ↔ m-pp/p/0/n/nn
|
||||||
|
cls_map = {
|
||||||
|
"rating-pp": "m-pp", "rating-p": "m-p", "rating-0": "m-0",
|
||||||
|
"rating-n": "m-n", "rating-nn": "m-nn",
|
||||||
|
}
|
||||||
|
for entry in a.gwoe_matrix:
|
||||||
|
sym = get_rating_symbol(entry.rating)
|
||||||
|
cls = cls_map.get(get_rating_class(entry.rating), "?")
|
||||||
|
out[entry.field] = (entry.rating, sym, cls)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tests ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def rows():
|
||||||
|
return _live_rows(limit=30)
|
||||||
|
|
||||||
|
|
||||||
|
def test_score_consistency(rows):
|
||||||
|
if not rows:
|
||||||
|
pytest.skip("DB leer")
|
||||||
|
for r in rows:
|
||||||
|
assert _v2_score(r) == _pdf_score(r), (
|
||||||
|
f"score-Drift bei {r.get('drucksache')}: "
|
||||||
|
f"v2={_v2_score(r)} pdf={_pdf_score(r)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empfehlung_consistency(rows):
|
||||||
|
if not rows:
|
||||||
|
pytest.skip("DB leer")
|
||||||
|
for r in rows:
|
||||||
|
assert _v2_empfehlung(r) == _pdf_empfehlung(r), (
|
||||||
|
f"empfehlung-Drift bei {r.get('drucksache')}: "
|
||||||
|
f"v2={_v2_empfehlung(r)!r} pdf={_pdf_empfehlung(r)!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_consistency(rows):
|
||||||
|
if not rows:
|
||||||
|
pytest.skip("DB leer")
|
||||||
|
drift_count = 0
|
||||||
|
drift_examples: list[str] = []
|
||||||
|
for r in rows:
|
||||||
|
v2 = _v2_matrix_keys(r)
|
||||||
|
pdf = _pdf_matrix_keys(r)
|
||||||
|
common_keys = set(v2.keys()) & set(pdf.keys())
|
||||||
|
for k in common_keys:
|
||||||
|
if v2[k] != pdf[k]:
|
||||||
|
drift_count += 1
|
||||||
|
drift_examples.append(
|
||||||
|
f"{r.get('drucksache')} {k}: v2={v2[k]} pdf={pdf[k]}"
|
||||||
|
)
|
||||||
|
if len(drift_examples) >= 10:
|
||||||
|
break
|
||||||
|
if len(drift_examples) >= 10:
|
||||||
|
break
|
||||||
|
assert drift_count == 0, (
|
||||||
|
f"{drift_count} Matrix-Drifts. Erste 10:\n "
|
||||||
|
+ "\n ".join(drift_examples)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_field_set_consistency(rows):
|
||||||
|
"""v2 und PDF rendern für jede Drucksache dieselben Matrix-Felder
|
||||||
|
(kein 'verschluckter' Cell durch unterschiedliche Schema-Lookups)."""
|
||||||
|
if not rows:
|
||||||
|
pytest.skip("DB leer")
|
||||||
|
for r in rows:
|
||||||
|
v2_keys = set(_v2_matrix_keys(r).keys())
|
||||||
|
pdf_keys = set(_pdf_matrix_keys(r).keys())
|
||||||
|
assert v2_keys == pdf_keys, (
|
||||||
|
f"Field-Set-Drift bei {r.get('drucksache')}: "
|
||||||
|
f"v2={sorted(v2_keys - pdf_keys)} pdf={sorted(pdf_keys - v2_keys)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wahlprogramm_scores_consistency(rows):
|
||||||
|
"""Für jede Fraktion: WP-Score und PP-Score und Begründung gleich."""
|
||||||
|
if not rows:
|
||||||
|
pytest.skip("DB leer")
|
||||||
|
from app.models import Assessment
|
||||||
|
drift_count = 0
|
||||||
|
drift_examples: list[str] = []
|
||||||
|
for r in rows:
|
||||||
|
a = Assessment.model_validate(r)
|
||||||
|
# v2-Sicht: aus row.wahlprogramm_scores roh
|
||||||
|
v2_by_fr = {
|
||||||
|
(wp.get("fraktion") or ""): wp
|
||||||
|
for wp in (r.get("wahlprogramm_scores") or [])
|
||||||
|
if isinstance(wp, dict)
|
||||||
|
}
|
||||||
|
for s in a.wahlprogramm_scores:
|
||||||
|
v2 = v2_by_fr.get(s.fraktion or "")
|
||||||
|
if not v2:
|
||||||
|
continue
|
||||||
|
v2_wp = float((v2.get("wahlprogramm") or {}).get("score") or 0)
|
||||||
|
v2_pp = float((v2.get("parteiprogramm") or {}).get("score") or 0)
|
||||||
|
pdf_wp = float(s.wahlprogramm.score or 0)
|
||||||
|
pdf_pp = float(s.parteiprogramm.score or 0)
|
||||||
|
if v2_wp != pdf_wp or v2_pp != pdf_pp:
|
||||||
|
drift_count += 1
|
||||||
|
drift_examples.append(
|
||||||
|
f"{r.get('drucksache')} {s.fraktion}: "
|
||||||
|
f"v2_wp={v2_wp} pdf_wp={pdf_wp} v2_pp={v2_pp} pdf_pp={pdf_pp}"
|
||||||
|
)
|
||||||
|
if len(drift_examples) >= 10:
|
||||||
|
break
|
||||||
|
assert drift_count == 0, (
|
||||||
|
f"{drift_count} WP/PP-Score-Drifts. Erste 10:\n "
|
||||||
|
+ "\n ".join(drift_examples)
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user