chore(ci): Anti-Regression-Wache gegen neue Inline-Styles (#184)
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.
This commit is contained in:
parent
c7eab5a695
commit
57e11b3da7
82
tests/test_inline_styles_baseline.py
Normal file
82
tests/test_inline_styles_baseline.py
Normal file
@ -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."
|
||||
)
|
||||
92
tools/audit_inline_styles.py
Normal file
92
tools/audit_inline_styles.py
Normal file
@ -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()
|
||||
44
tools/inline_styles_baseline.json
Normal file
44
tools/inline_styles_baseline.json
Normal file
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user