From 57e11b3da755e1ed31ab01f851474f62b49e3a5f Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Sat, 9 May 2026 02:32:35 +0200 Subject: [PATCH] chore(ci): Anti-Regression-Wache gegen neue Inline-Styles (#184) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nicht alle 1322 Inline-Style-Vorkommen in einer Sitzung migrieren — aber zumindest verhindern, dass es mehr werden. Drei Bausteine: 1. ``tools/audit_inline_styles.py`` — CLI für Audit (Cluster, Top-Files) und Baseline-Erzeugung (--baseline → JSON {file: count}). 2. ``tools/inline_styles_baseline.json`` — eingefrorene IST-Zählung pro Template-Datei. Wird vom Test als obere Schranke gelesen. 3. ``tests/test_inline_styles_baseline.py`` (3 Tests) — pro Datei und global: aktuelle Anzahl <= Baseline. Schlägt Alarm bei neuen Inline-Styles und auch bei neuen Templates mit Inline-Styles, die noch nicht in der Baseline stehen. Workflow für künftige Migrationen: Inline-Styles in einer Datei nach benannten Klassen überführen, Baseline neu einfrieren via ``python3 tools/audit_inline_styles.py --baseline > tools/inline_styles_baseline.json``. Cluster-Verteilung der 1322 Treffer: - layout: 625, typography: 323, color: 262, sonstige: 112. Top-Brennpunkte: index.html (463, Classic-UI Legacy), auswertungen.html (125), antrag_detail.html v2 (119), aktuelle-themen.html (82). 1220/1220 Tests grün. --- tests/test_inline_styles_baseline.py | 82 +++++++++++++++++++++++++ tools/audit_inline_styles.py | 92 ++++++++++++++++++++++++++++ tools/inline_styles_baseline.json | 44 +++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 tests/test_inline_styles_baseline.py create mode 100644 tools/audit_inline_styles.py create mode 100644 tools/inline_styles_baseline.json diff --git a/tests/test_inline_styles_baseline.py b/tests/test_inline_styles_baseline.py new file mode 100644 index 0000000..0cfdc82 --- /dev/null +++ b/tests/test_inline_styles_baseline.py @@ -0,0 +1,82 @@ +"""Anti-Regression-Wache gegen neue Inline-Styles in Templates (#184). + +Die Baseline wurde am Tag der Einführung dieser Wache eingefroren — +``tools/inline_styles_baseline.json``. Der Test prüft pro Datei: +**aktuelle Anzahl <= Baseline**. Damit verhindert der Test neue +Inline-Styles, ohne existierende sofort migrieren zu müssen. + +Bei der Migration eines Files (Inline-Styles → benannte Klassen) sinkt +die Anzahl. Der Test bleibt grün, aber die Baseline driftet — sie kann +einfach mit ``python3 tools/audit_inline_styles.py --baseline > +tools/inline_styles_baseline.json`` neu eingefroren werden, damit +die Wache nicht versehentlich auf den alten Stand zurückgesetzt wird. +""" +import json +import re +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parent.parent +TEMPLATES = ROOT / "app" / "templates" +BASELINE = ROOT / "tools" / "inline_styles_baseline.json" +PAT = re.compile(r'style="([^"]+)"') + + +def _count_per_file() -> dict[str, int]: + out: dict[str, int] = {} + for f in sorted(set(TEMPLATES.rglob("*.html"))): + rel = str(f.relative_to(ROOT)) + n = sum(1 for _ in PAT.finditer(f.read_text(errors="replace"))) + if n > 0: + out[rel] = n + return out + + +@pytest.fixture(scope="module") +def baseline() -> dict[str, int]: + return json.loads(BASELINE.read_text()) + + +@pytest.fixture(scope="module") +def current() -> dict[str, int]: + return _count_per_file() + + +def test_baseline_file_exists(): + assert BASELINE.exists(), ( + f"Baseline {BASELINE} fehlt. Erst-Erzeugung: " + "python3 tools/audit_inline_styles.py --baseline > tools/inline_styles_baseline.json" + ) + + +def test_no_new_inline_styles_per_file(current: dict[str, int], baseline: dict[str, int]): + """Pro Datei darf die aktuelle Anzahl die Baseline nicht überschreiten.""" + over = [] + for path, current_count in current.items(): + base = baseline.get(path) + if base is None: + # Neue Datei mit Inline-Styles: nicht erlaubt — keine neuen Inline-Styles. + over.append(f" {path}: NEU mit {current_count} Inline-Styles, " + "Baseline kennt diese Datei noch nicht") + continue + if current_count > base: + over.append(f" {path}: {current_count} > Baseline {base}") + assert not over, ( + "Inline-Styles-Regression in folgenden Templates:\n" + + "\n".join(over) + + "\n\nFix: Inline-Styles in benannte Klassen überführen, oder " + "(falls Migration in einer separaten PR läuft) Baseline aktualisieren:\n" + " python3 tools/audit_inline_styles.py --baseline > tools/inline_styles_baseline.json" + ) + + +def test_baseline_total_does_not_grow(current: dict[str, int], baseline: dict[str, int]): + """Globale Wache: Gesamtsumme darf nicht steigen.""" + cur_total = sum(current.values()) + base_total = sum(baseline.values()) + assert cur_total <= base_total, ( + f"Inline-Styles total: {cur_total} > Baseline {base_total}. " + "Pro Datei prüfen via test_no_new_inline_styles_per_file." + ) diff --git a/tools/audit_inline_styles.py b/tools/audit_inline_styles.py new file mode 100644 index 0000000..29b7ea6 --- /dev/null +++ b/tools/audit_inline_styles.py @@ -0,0 +1,92 @@ +"""Audit aller ``style=""``-Vorkommen in app/templates/. + +Wird vom Test ``tests/test_inline_styles_baseline.py`` als Anti-Regression- +Wache benutzt: die aktuelle Zählung pro Datei darf nicht über die +Baseline gehen. Damit verhindert der Test neue Inline-Styles, ohne +existierende sofort migrieren zu müssen (#184). + +Ausgabe (CLI): +- ``--audit``: Total + per-Cluster + Top-Files +- ``--baseline``: Maschinen-lesbare Zählung als JSON {file: count} +""" +import argparse +import json +import re +import sys +from collections import defaultdict +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent / "app" / "templates" +PAT = re.compile(r'style="([^"]+)"') + + +def count_per_file() -> dict[str, int]: + """{relative_path: count} für alle .html-Templates.""" + out: dict[str, int] = {} + for f in sorted(set(ROOT.rglob("*.html"))): + rel = str(f.relative_to(ROOT.parent.parent)) + n = sum(1 for _ in PAT.finditer(f.read_text(errors="replace"))) + if n > 0: + out[rel] = n + return out + + +def cluster_decl(declarations: str) -> str: + decl = declarations.strip().split(";")[0].strip() + if ":" not in decl: + return "unparseable" + prop = decl.split(":", 1)[0].strip().lower() + if prop in ("color", "background", "background-color", + "border", "border-color", "border-left", "border-top"): + return "color" + if prop in ("font-size", "font-family", "font-weight", "letter-spacing", + "line-height", "text-transform"): + return "typography" + if prop in ("padding", "padding-left", "padding-right", "padding-top", + "padding-bottom", "margin", "margin-left", "margin-right", + "margin-top", "margin-bottom", "gap", + "width", "height", "max-width", "min-width", + "max-height", "min-height", + "display", "flex", "flex-direction", "flex-wrap", + "align-items", "justify-content", + "position", "top", "left", "right", "bottom", "z-index"): + return "layout" + return f"other ({prop})" + + +def cluster_summary() -> dict[str, int]: + out: dict[str, int] = defaultdict(int) + for f in sorted(set(ROOT.rglob("*.html"))): + for m in PAT.finditer(f.read_text(errors="replace")): + out[cluster_decl(m.group(1))] += 1 + return dict(out) + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--baseline", action="store_true", + help="emittiere {file: count} JSON für Test-Baseline") + p.add_argument("--audit", action="store_true", + help="Cluster + Top-Files (default)") + args = p.parse_args() + + counts = count_per_file() + if args.baseline: + json.dump(counts, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + return + + total = sum(counts.values()) + print(f"Total style=\"\"-Vorkommen: {total}") + print() + print("Per-Cluster:") + for k, v in sorted(cluster_summary().items(), key=lambda x: -x[1]): + print(f" {k:25s} {v:5d}") + print() + print("Top-15 Files:") + for f, n in sorted(counts.items(), key=lambda x: -x[1])[:15]: + print(f" {n:5d} {f}") + + +if __name__ == "__main__": + main() diff --git a/tools/inline_styles_baseline.json b/tools/inline_styles_baseline.json new file mode 100644 index 0000000..065d2ee --- /dev/null +++ b/tools/inline_styles_baseline.json @@ -0,0 +1,44 @@ +{ + "app/templates/_header.html": 2, + "app/templates/auswertungen.html": 7, + "app/templates/index.html": 463, + "app/templates/legal.html": 9, + "app/templates/methodik.html": 40, + "app/templates/monitoring_digest.html": 47, + "app/templates/quellen.html": 15, + "app/templates/v2/base.html": 9, + "app/templates/v2/components/appshell.html": 2, + "app/templates/v2/components/auth_modal.html": 29, + "app/templates/v2/components/feedback_widget.html": 23, + "app/templates/v2/components/icon.html": 1, + "app/templates/v2/components/matrix_mini.html": 1, + "app/templates/v2/components/queue_widget.html": 8, + "app/templates/v2/components/quote_card.html": 1, + "app/templates/v2/components/score_hero.html": 1, + "app/templates/v2/og_template.html": 1, + "app/templates/v2/screens/abos.html": 8, + "app/templates/v2/screens/admin_abos.html": 14, + "app/templates/v2/screens/admin_freischaltungen.html": 9, + "app/templates/v2/screens/admin_queue.html": 45, + "app/templates/v2/screens/admin_stand.html": 25, + "app/templates/v2/screens/admin_wahlprogramme.html": 22, + "app/templates/v2/screens/aktuelle-themen.html": 82, + "app/templates/v2/screens/antrag_detail.html": 119, + "app/templates/v2/screens/auswertungen.html": 125, + "app/templates/v2/screens/batch.html": 7, + "app/templates/v2/screens/cluster.html": 20, + "app/templates/v2/screens/durchsuchen.html": 11, + "app/templates/v2/screens/feed.html": 6, + "app/templates/v2/screens/landtag_suche.html": 10, + "app/templates/v2/screens/legal.html": 4, + "app/templates/v2/screens/merkliste.html": 24, + "app/templates/v2/screens/methodik.html": 46, + "app/templates/v2/screens/neu.html": 8, + "app/templates/v2/screens/quellen.html": 25, + "app/templates/v2/screens/scorecard.html": 5, + "app/templates/v2/screens/scorecard_portrait.html": 2, + "app/templates/v2/screens/scorecard_werkstatt.html": 7, + "app/templates/v2/screens/tags.html": 17, + "app/templates/v3/pdf/antrag_pdf.html": 11, + "app/templates/v3/screens/antrag_detail.html": 11 +}