Regression-Schutz fuer die heutigen UI-Fixes: - test_cluster_list_has_cards (#56c68d3 — drucksachen vs members) - test_cluster_detail_visible_after_click (#d72a6f3 — display:'') - test_cluster_force_graph_renders (#60db39d — Edge-IDs) - test_stimmverhalten_chart_has_size (#cb6971f — controls-bar-leak) - test_score_histogram_has_buckets - test_antrag_detail_has_markers (Heuchelei + Konsistenz + Mehrheits-Bar) - test_antrag_matrix_coloring (#ee93fcd — rating-Shift) - test_merkliste_add_and_delete (#c599e5f + #0394803 — onclick-quoting + ID-Lookup) - test_aktuelle_themen_5_tabs Aktivierung: DEBUG_AUTH_TOKEN gesetzt + pytest -m e2e. Lokaler Run: 9/9 passed in 52s. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
221 lines
8.1 KiB
Python
221 lines
8.1 KiB
Python
"""E2E-Smoketests gegen gwoe-dev.toppyr.de (Phase 16).
|
||
|
||
Nutzt den DEBUG_AUTH_TOKEN-Bypass (siehe app/auth.py:_check_debug_token,
|
||
ENV `DEBUG_AUTH_TOKEN`), damit Auth-required Pages ohne Keycloak-Login
|
||
geladen werden können.
|
||
|
||
Aktivierung lokal:
|
||
DEBUG_AUTH_TOKEN=<secret> pytest tests/e2e/test_smoke_browser.py -m e2e
|
||
|
||
Wird in CI ohne DEBUG_AUTH_TOKEN automatisch übersprungen.
|
||
|
||
Test-Coverage als Regression-Schutz für die heutigen UI-Fixes:
|
||
- Cluster-Liste rendert (vormals leere Cards: members vs drucksachen)
|
||
- Cluster-Detail wird sichtbar (vormals display:'')
|
||
- Force-Graph rendert (vormals Edge-IDs vs drucksache-IDs)
|
||
- Stimmverhalten-Tab hat Charts mit echter Größe
|
||
(vormals: ungeschlossener controls-bar div verschluckte alle Folge-Panels)
|
||
- Antrag-Detail-Marker rendern (Heuchelei + Konsistenz + Mehrheits-Bar)
|
||
- Merkliste: Add + Click-Delete entfernt Row aus DOM
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import urllib.parse
|
||
|
||
import pytest
|
||
|
||
TOKEN = os.environ.get("DEBUG_AUTH_TOKEN", "").strip()
|
||
BASE = os.environ.get("E2E_BASE_URL", "https://gwoe-dev.toppyr.de").rstrip("/")
|
||
|
||
pytestmark = [
|
||
pytest.mark.e2e,
|
||
pytest.mark.skipif(not TOKEN, reason="DEBUG_AUTH_TOKEN nicht gesetzt"),
|
||
]
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def browser_ctx():
|
||
try:
|
||
from playwright.sync_api import sync_playwright
|
||
except ImportError:
|
||
pytest.skip("playwright nicht installiert")
|
||
with sync_playwright() as p:
|
||
browser = p.chromium.launch(headless=True)
|
||
ctx = browser.new_context(
|
||
extra_http_headers={"X-Debug-Token": TOKEN},
|
||
)
|
||
yield ctx
|
||
browser.close()
|
||
|
||
|
||
@pytest.fixture
|
||
def page(browser_ctx):
|
||
p = browser_ctx.new_page()
|
||
p.on("pageerror", lambda exc: pytest.fail(f"page-error: {exc}"))
|
||
yield p
|
||
p.close()
|
||
|
||
|
||
# ─── Cluster ────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_cluster_list_has_cards(page):
|
||
"""`/v2/cluster` zeigt mindestens 1 Cluster-Card."""
|
||
page.goto(f"{BASE}/v2/cluster", wait_until="networkidle", timeout=15000)
|
||
page.wait_for_timeout(2000)
|
||
cards = page.query_selector_all(".cluster-card")
|
||
assert len(cards) >= 1, "keine Cluster-Cards im DOM"
|
||
|
||
|
||
def test_cluster_detail_visible_after_click(page):
|
||
"""Klick auf Card → `#cluster-detail` wird sichtbar (display:block)."""
|
||
page.goto(f"{BASE}/v2/cluster", wait_until="networkidle", timeout=15000)
|
||
page.wait_for_timeout(2000)
|
||
card = page.query_selector(".cluster-card")
|
||
if not card:
|
||
pytest.skip("keine Cluster zum Klicken")
|
||
card.click()
|
||
page.wait_for_timeout(1500)
|
||
detail = page.query_selector("#cluster-detail")
|
||
assert detail and detail.is_visible(), "cluster-detail nicht sichtbar"
|
||
|
||
|
||
def test_cluster_force_graph_renders(page):
|
||
"""Force-Graph hat mindestens 1 SVG-Circle nach Card-Klick."""
|
||
page.goto(f"{BASE}/v2/cluster", wait_until="networkidle", timeout=15000)
|
||
page.wait_for_timeout(2000)
|
||
card = page.query_selector(".cluster-card")
|
||
if not card:
|
||
pytest.skip()
|
||
card.click()
|
||
page.wait_for_timeout(2500) # d3 force-sim braucht etwas
|
||
circles = page.query_selector_all("#cluster-graph svg circle")
|
||
assert len(circles) >= 2, f"Force-Graph hat nur {len(circles)} Circles"
|
||
|
||
|
||
# ─── Stimmverhalten / Auswertungen ──────────────────────────────────────────
|
||
|
||
|
||
def test_stimmverhalten_chart_has_size(page):
|
||
"""`/stimmverhalten` rendert Stimm-Index-Chart mit echter Größe.
|
||
|
||
Regression: ungeschlossener `<div class='controls-bar'>` verschluckte
|
||
alle nachfolgenden Panels, Charts hatten 0×0 Bounding-Box.
|
||
"""
|
||
page.goto(f"{BASE}/stimmverhalten", wait_until="networkidle", timeout=15000)
|
||
page.wait_for_timeout(3000)
|
||
info = page.evaluate(
|
||
"() => {"
|
||
" const el = document.getElementById('sv-index-chart');"
|
||
" if (!el) return null;"
|
||
" const r = el.getBoundingClientRect();"
|
||
" return {w: r.width, h: r.height};"
|
||
"}"
|
||
)
|
||
assert info is not None, "sv-index-chart nicht im DOM"
|
||
assert info["w"] > 100 and info["h"] > 100, (
|
||
f"Chart hat zu kleine Größe: {info}"
|
||
)
|
||
|
||
|
||
def test_score_histogram_has_buckets(page):
|
||
"""`/auswertungen` Tab Score-Verteilung rendert Histogram-Canvas."""
|
||
page.goto(f"{BASE}/auswertungen", wait_until="networkidle", timeout=15000)
|
||
page.wait_for_timeout(2000)
|
||
btn = page.query_selector(".auswert-tab[onclick*=\"'histogram'\"]")
|
||
if not btn:
|
||
pytest.skip("histogram-tab nicht gefunden")
|
||
btn.click()
|
||
page.wait_for_timeout(2500)
|
||
info = page.evaluate(
|
||
"() => { const el = document.getElementById('hist-chart');"
|
||
" return el ? el.getBoundingClientRect() : null; }"
|
||
)
|
||
assert info and info["width"] > 100 and info["height"] > 50
|
||
|
||
|
||
# ─── Antrag-Detail ──────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_antrag_detail_has_markers(page):
|
||
"""Heuchelei-⚠ und Konsistenz-Block auf einem bekannten Vote-Antrag."""
|
||
page.goto(
|
||
f"{BASE}/antrag/18/18089?bundesland=NRW",
|
||
wait_until="networkidle", timeout=15000,
|
||
)
|
||
page.wait_for_timeout(2000)
|
||
body = page.content()
|
||
assert "stimmte mit Nein" in body or "stimmte mit Ja" in body, (
|
||
"weder Heuchelei- noch Opportunismus-Marker rendert"
|
||
)
|
||
assert "Mehrheit deckt sich" in body or "Mehrheit kontra" in body, (
|
||
"Konsistenz-Block fehlt"
|
||
)
|
||
assert "Fraktionen Ja" in body, "Mehrheits-Bar fehlt"
|
||
|
||
|
||
def test_antrag_matrix_coloring(page):
|
||
"""Matrix-Zelle mit rating>=4 hat m-pp-Klasse (kräftiges Grün), nicht m-p."""
|
||
page.goto(
|
||
f"{BASE}/antrag/18/18246?bundesland=NRW",
|
||
wait_until="networkidle", timeout=15000,
|
||
)
|
||
page.wait_for_timeout(1500)
|
||
pp_count = page.evaluate(
|
||
"() => document.querySelectorAll('.v2-matrix-mini .m-pp').length"
|
||
)
|
||
assert pp_count >= 1, "keine m-pp-Klasse — rating-Shift-Bug zurück?"
|
||
|
||
|
||
# ─── Merkliste ──────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _get_csrf_or_token_header():
|
||
return {"X-Debug-Token": TOKEN}
|
||
|
||
|
||
def test_merkliste_add_and_delete(browser_ctx, page):
|
||
"""End-to-End: Eintrag via API anlegen, dann via UI-Klick entfernen."""
|
||
import requests
|
||
headers = {"X-Debug-Token": TOKEN, "Content-Type": "application/json"}
|
||
# Cleanup: bestehenden Eintrag löschen (idempotent)
|
||
requests.delete(
|
||
f"{BASE}/api/me/merkliste/{urllib.parse.quote('18/18089', safe='')}",
|
||
headers=headers, timeout=10,
|
||
)
|
||
# Add
|
||
r = requests.post(
|
||
f"{BASE}/api/me/merkliste",
|
||
json={"antrag_id": "18/18089"},
|
||
headers=headers, timeout=10,
|
||
)
|
||
assert r.status_code == 200, f"add fehlgeschlagen: {r.status_code} {r.text}"
|
||
|
||
page.goto(f"{BASE}/v2/merkliste", wait_until="networkidle", timeout=15000)
|
||
page.wait_for_timeout(2500)
|
||
|
||
pre = page.evaluate(
|
||
"() => document.querySelectorAll('.merkliste-entry').length"
|
||
)
|
||
assert pre >= 1, "Eintrag nicht in Merkliste"
|
||
btn = page.query_selector('button[onclick*="merkliste_remove"]')
|
||
assert btn, "kein Lösch-Button"
|
||
btn.click()
|
||
page.wait_for_timeout(2000)
|
||
post = page.evaluate(
|
||
"() => document.querySelectorAll('.merkliste-entry').length"
|
||
)
|
||
assert post == pre - 1, f"Row nicht entfernt: pre={pre} post={post}"
|
||
|
||
|
||
# ─── Aktuelle Themen ────────────────────────────────────────────────────────
|
||
|
||
|
||
def test_aktuelle_themen_5_tabs(page):
|
||
"""`/aktuelle-themen` rendert 5 Tabs."""
|
||
page.goto(f"{BASE}/aktuelle-themen", wait_until="networkidle", timeout=15000)
|
||
page.wait_for_timeout(2000)
|
||
tabs = page.query_selector_all(".at-tab")
|
||
assert len(tabs) >= 5, f"erwartet ≥5 Tabs, gefunden {len(tabs)}"
|