Procházet zdrojové kódy

Add CSS regression test suite wired into meson

tests/css_check.py parses the shipped stylesheets and css/index.html as
text (no browser, stdlib only) and asserts the visual contracts the goal
work established, printing spry-tests-style PASS/FAIL lines and a final
'---- N passed, M failed ----' summary:

- Airy's glide-in numerically equals the Win7 DWM Aero curve: the open
  ease sampled at t=0.25/0.5/0.75 matches f(t)=1-(1-t)^3 and the close
  ease matches t^3 within 1e-3 (bisection on x(s)=t, then y(s)); 180ms
  open / 150ms close duration; scale from the 0.90-0.95 band; anchored
  popovers additionally translateY toward their trigger.
- Core padding defaults: bare .card and bare dialog/[popover] carry
  padding, with the :has() zero-guards so structural children stay flush.
- Every theme keeps defining --spry-card-padding.
- Demo page links spry.css + all 7 themes, has a radio per slug, and
  exercises dialogs, drawers, .prose, expanders, otp, tables, tabs.
- Formal's h1/h2 font-size rules opt out of .prose on both activation
  routes (data-theme and the radio :has() hook).
- Brace/paren balance and no tabs across all shipped CSS; README has no
  stale ../Spry-CSS references and points at css/index.html.

meson: test('css-check', python3, workdir: project source root) so the
relative css/ paths resolve; 38 checks green standalone and via
'meson test' (3/3 total). Verified the drill: flipping Airy's ease to
cubic-bezier(0.16,1,0.3,1) and deleting the bare-card padding rule both
fail loudly (worst delta 2.47e-01 / missing :where(.card) rule).
saddle-ClankerSaddle před 3 hodinami
rodič
revize
5de0f387b4
2 změnil soubory, kde provedl 404 přidání a 0 odebrání
  1. 391 0
      tests/css_check.py
  2. 13 0
      tests/meson.build

+ 391 - 0
tests/css_check.py

@@ -0,0 +1,391 @@
+#!/usr/bin/env python3
+"""Spry CSS regression checks — the visual contracts of the framework.
+
+Parses the shipped stylesheets as text (no browser needed) and asserts the
+shape the goal work established:
+
+  * Airy's dialog glide-in really is the Win7 DWM Aero curve: the open ease
+    numerically equals f(t) = 1-(1-t)^3 and the close ease equals t^3,
+    duration 180 ms, scale-from-0.90..0.95, anchored popovers translating
+    toward their trigger.
+  * Core padding defaults: bare `.card` and bare `dialog`/`[popover]` carry
+    padding by default, with :has() zero-guards so structural children stay
+    flush to the frame.
+  * Every theme keeps defining the tokens the core now relies on.
+  * The demo page (css/index.html) links and switches everything it demos.
+  * Formal's heading scale opts out of .prose on BOTH activation routes.
+  * Brace/paren balance and no tabs across all shipped CSS.
+  * README points at the live demo, not stale paths.
+
+Plain stdlib, no pytest: prints PASS/FAIL lines and a final summary in the
+spry-tests style ("---- N passed, M failed ----"), exits non-zero on any
+failure. Run from anywhere:
+
+    python3 tests/css_check.py
+"""
+
+import re
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+
+THEMES = ["airy", "dwarf", "formal", "glassy", "h2o", "living-sky", "modern"]
+
+DEMO_MARKERS = [
+    "popover-anchor",
+    "dialog-titlebar",
+    "drawer",
+    "expander-group",
+    "otp",
+    "table-wrap",
+    "tabs",
+]
+
+passes = 0
+failures = 0
+
+
+def check(condition, label, detail=None):
+    global passes, failures
+    if condition:
+        passes += 1
+        print("PASS %s" % label)
+    else:
+        failures += 1
+        print("FAIL %s" % label)
+        if detail:
+            print("     %s" % detail)
+    return bool(condition)
+
+
+# --------------------------------------------------------------------------
+# Text helpers
+# --------------------------------------------------------------------------
+
+def read(rel):
+    return (ROOT / rel).read_text(encoding="utf-8")
+
+
+def strip_comments(css):
+    return re.sub(r"/\*.*?\*/", "", css, flags=re.S)
+
+
+def collapse_ws(text):
+    return re.sub(r"\s+", " ", text).strip()
+
+
+def iter_blocks(css):
+    """Yield (selector, body) for every top-level `{}` block, brace-matched."""
+    depth = 0
+    selector_start = 0
+    body_start = 0
+    for i, ch in enumerate(css):
+        if ch == "{":
+            if depth == 0:
+                selector = css[selector_start:i]
+                body_start = i + 1
+            depth += 1
+        elif ch == "}":
+            depth -= 1
+            if depth == 0:
+                yield selector, css[body_start:i]
+                selector_start = i + 1
+        elif depth == 0 and ch == ";":
+            # a declaration at top level ends any pseudo-selector window
+            selector_start = i + 1
+    if depth != 0:
+        raise ValueError("unbalanced braces while scanning blocks")
+
+
+def find_block(css, selector_regex):
+    """First top-level block whose selector matches; returns (selector, body)."""
+    rx = re.compile(selector_regex)
+    for selector, body in iter_blocks(css):
+        if rx.search(collapse_ws(selector)):
+            return selector, body
+    return None
+
+
+def token_value(css, name, occurrence=0):
+    """Nth definition (0-based) of a custom property, or None."""
+    matches = re.findall(r"%s:\s*([^;]+);" % re.escape(name), css)
+    return matches[occurrence].strip() if len(matches) > occurrence else None
+
+
+# --------------------------------------------------------------------------
+# a. Bezier math — Airy's glide-in must be the DWM curve
+# --------------------------------------------------------------------------
+
+BEZIER_RX = re.compile(
+    r"cubic-bezier\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)"
+)
+
+
+def parse_bezier(value):
+    m = BEZIER_RX.fullmatch(value.strip())
+    if not m:
+        return None
+    return tuple(float(g) for g in m.groups())
+
+
+def bezier_y(x1, y1, x2, y2, t):
+    """y(t) of a CSS cubic-bezier: solve x(s)=t for s (x is monotonic for
+    x1,x2 in [0,1]) by bisection, then evaluate y(s)."""
+    def coord(a, b, s):
+        return 3 * (1 - s) ** 2 * s * a + 3 * (1 - s) * s * s * b + s ** 3
+    if t <= 0.0:
+        return 0.0
+    if t >= 1.0:
+        return 1.0
+    lo, hi = 0.0, 1.0
+    for _ in range(200):  # far past 1e-9 tolerance
+        mid = (lo + hi) / 2
+        if coord(x1, x2, mid) < t:
+            lo = mid
+        else:
+            hi = mid
+    return coord(y1, y2, (lo + hi) / 2)
+
+
+def parse_ms(value):
+    m = re.fullmatch(r"(\d+(?:\.\d+)?)(ms|s)", value.strip())
+    if not m:
+        return None
+    return float(m.group(1)) * (1000.0 if m.group(2) == "s" else 1.0)
+
+
+def check_airy_bezier():
+    airy = strip_comments(read("css/themes/spry-theme-airy.css"))
+    print("-- Airy DWM glide-in curve --")
+
+    ease = parse_bezier(token_value(airy, "--spry-dialog-ease") or "")
+    if not check(ease is not None, "airy --spry-dialog-ease is a cubic-bezier()",
+                 "got: %r" % (token_value(airy, "--spry-dialog-ease"),)):
+        return
+    samples = [bezier_y(*ease, t) for t in (0.25, 0.5, 0.75)]
+    expected = [1 - (1 - t) ** 3 for t in (0.25, 0.5, 0.75)]
+    worst = max(abs(a - b) for a, b in zip(samples, expected))
+    check(worst < 1e-3, "airy open ease equals 1-(1-t)^3 at t=0.25/0.5/0.75",
+          "bezier%s sampled %s vs %s (worst delta %.2e)"
+          % (ease, ["%.5f" % s for s in samples],
+             ["%.5f" % e for e in expected], worst))
+
+    close = parse_bezier(token_value(airy, "--spry-dialog-close-ease") or "")
+    if not check(close is not None, "airy --spry-dialog-close-ease is a cubic-bezier()",
+                 "got: %r" % (token_value(airy, "--spry-dialog-close-ease"),)):
+        return
+    samples = [bezier_y(*close, t) for t in (0.25, 0.5, 0.75)]
+    expected = [t ** 3 for t in (0.25, 0.5, 0.75)]
+    worst = max(abs(a - b) for a, b in zip(samples, expected))
+    check(worst < 1e-3, "airy close ease equals t^3 at t=0.25/0.5/0.75",
+          "bezier%s sampled %s vs %s (worst delta %.2e)"
+          % (close, ["%.5f" % s for s in samples],
+             ["%.5f" % e for e in expected], worst))
+
+    check(parse_ms(token_value(airy, "--spry-dialog-duration") or "") == 180.0,
+          "airy --spry-dialog-duration is 180ms",
+          "got: %r (DWM window-open T ~= 180ms)" % (token_value(airy, "--spry-dialog-duration"),))
+    check(parse_ms(token_value(airy, "--spry-dialog-close-duration") or "") == 150.0,
+          "airy --spry-dialog-close-duration is 150ms",
+          "got: %r" % (token_value(airy, "--spry-dialog-close-duration"),))
+
+    dia_in = token_value(airy, "--spry-dialog-in") or ""
+    check(re.search(r"scale\(0\.9[0-5]", dia_in) is not None,
+          "airy --spry-dialog-in scales from the 0.90-0.95 band",
+          "got: %r (DWM starts at 90-95%% of final size)" % dia_in)
+
+    # Anchored popovers additionally translate toward their trigger. The rule
+    # is nested inside the theme's root block (& .popover-anchor), so scan for
+    # its declaration list directly rather than the top-level block iterator.
+    anchor = re.search(r"\.popover-anchor\b[^{]*\{([^{}]*)\}", airy)
+    anchor_in = token_value(anchor.group(1), "--spry-dialog-in") if anchor else ""
+    check(anchor is not None
+          and re.search(r"translateY\(-", anchor_in) is not None
+          and re.search(r"scale\(0\.9[0-5]", anchor_in) is not None,
+          "airy .popover-anchor glides in from above (translateY + 0.9x scale)",
+          "got: %r" % anchor_in)
+
+
+# --------------------------------------------------------------------------
+# b. Padding defaults — bare cards and dialogs must not hug their frame
+# --------------------------------------------------------------------------
+
+def check_padding_defaults():
+    core = strip_comments(read("css/spry.css"))
+    print("-- core padding defaults --")
+
+    bare_card = find_block(core, r"^:where\(\s*\.card\s*\)$")
+    check(bare_card is not None
+          and re.search(r"padding:\s*var\(--spry-card-padding\)", bare_card[1]) is not None,
+          "bare .card defaults to padding: var(--spry-card-padding)",
+          "expected a :where(.card) rule padding bare cards")
+
+    guard_card = find_block(core, r":where\(\.card:has\(")
+    guard_sel = collapse_ws(guard_card[0]) if guard_card else ""
+    check(guard_card is not None
+          and re.search(r"padding:\s*0", guard_card[1]) is not None
+          and all(c in guard_sel for c in (".card-header", ".card-body", ".card-footer")),
+          "structured .card (has header/body/footer) zeroes the default padding",
+          "got selector: %r" % guard_sel)
+
+    root_block = find_block(core, r":where\(:root\)")
+    check(root_block is not None
+          and token_value(root_block[1], "--spry-popover-padding") is not None,
+          "--spry-popover-padding is defined in :where(:root)")
+    check(token_value(core, "--spry-card-padding") is not None,
+          "--spry-card-padding is defined")
+
+    popover = find_block(core, r"^\[popover\]$")
+    check(popover is not None
+          and re.search(r"padding:\s*var\(--spry-popover-padding\)", popover[1]) is not None,
+          "[popover] pads with var(--spry-popover-padding)")
+
+    popover_win = find_block(core, r"\[popover\]:has\(")
+    win_sel = collapse_ws(popover_win[0]) if popover_win else ""
+    check(popover_win is not None
+          and re.search(r"padding:\s*0", popover_win[1]) is not None
+          and all(c in win_sel for c in (".dialog-titlebar", ".dialog-body", ".dialog-footer")),
+          "popover 'windows' (has dialog parts) drop the generic padding",
+          "got selector: %r" % win_sel)
+
+    bare_dialog = find_block(core, r"dialog:not\(:has\(")
+    dsel = collapse_ws(bare_dialog[0]) if bare_dialog else ""
+    check(bare_dialog is not None
+          and re.search(r"padding:\s*var\(--spry-popover-padding\)", bare_dialog[1]) is not None
+          and all(c in dsel for c in (".dialog-titlebar", ".dialog-body",
+                                      ".dialog-footer", ".card-header",
+                                      ".card-body", ".card-footer")),
+          "bare dialog:not(:has(structural parts)) pads itself",
+          "got selector: %r" % dsel)
+
+
+# --------------------------------------------------------------------------
+# c. Theme token sets
+# --------------------------------------------------------------------------
+
+def check_theme_tokens():
+    print("-- theme token sets --")
+    for slug in THEMES:
+        css = strip_comments(read("css/themes/spry-theme-%s.css" % slug))
+        check(token_value(css, "--spry-card-padding") is not None,
+              "theme %s defines --spry-card-padding" % slug)
+
+
+# --------------------------------------------------------------------------
+# d. Demo page coverage
+# --------------------------------------------------------------------------
+
+def check_demo():
+    html = read("css/index.html")
+    print("-- demo page coverage --")
+
+    missing = ["spry.css"] + ["themes/spry-theme-%s.css" % s for s in THEMES]
+    missing = [href for href in missing
+               if ('href="%s"' % href) not in html and ("href='%s'" % href) not in html]
+    check(not missing, "demo links spry.css and all 7 themes",
+          "missing links: %s" % ", ".join(missing))
+
+    inputs = re.findall(r"<input\b[^>]*>", html)
+    missing = [slug for slug in THEMES
+               if not any('type="radio"' in tag and ('id="spry-theme-%s"' % slug) in tag
+                          for tag in inputs)]
+    check(not missing, "demo has a theme radio for every slug",
+          "missing radios: %s" % ", ".join(missing))
+
+    missing = []
+    for marker in DEMO_MARKERS:
+        if not re.search(r"\b%s\b" % re.escape(marker), html):
+            missing.append(marker)
+    classes = re.findall(r'class="([^"]*)"', html)
+    if not any("prose" in c.split() for c in classes):
+        missing.append("prose (as a class)")
+    check(not missing, "demo exercises the key components (dialogs, drawers, prose, ...)",
+          "missing markers: %s" % ", ".join(missing))
+
+
+# --------------------------------------------------------------------------
+# e. Formal prose guard — both activation routes
+# --------------------------------------------------------------------------
+
+def check_formal_prose():
+    formal = strip_comments(read("css/themes/spry-theme-formal.css"))
+    print("-- formal .prose guard --")
+
+    block = find_block(formal, r'\[data-theme="formal"\]')
+    sel = collapse_ws(block[0]) if block else ""
+    check(block is not None and "html:has(#spry-theme-formal:checked)" in sel,
+          "formal rules are reachable via data-theme AND the demo radio route",
+          "got selector: %r" % sel)
+
+    body = block[1] if block else ""
+    check("h1:not(.prose" in body and "h2:not(.prose" in body,
+          "formal h1/h2 size rules opt out of .prose")
+
+    # Scan every h1/h2 rule: if its own declarations set font-size, the
+    # selector must carry the :not(.prose *) guard.
+    unguarded = []
+    for m in re.finditer(r"\bh([12])\b", formal):
+        nxt = re.search(r"[{};]", formal[m.end():])
+        if not nxt or nxt.group(0) != "{":
+            continue  # not in a selector position
+        brace = m.end() + nxt.start()
+        prev = max(formal.rfind("}", 0, brace), formal.rfind("{", 0, brace),
+                   formal.rfind(";", 0, brace))
+        selector = collapse_ws(formal[prev + 1:brace])
+        if not re.search(r"\bh[12]\b", selector):
+            continue  # the h1/h2 belonged to an enclosing context, not this selector
+        decls_end = re.search(r"[{}]", formal[brace + 1:])
+        decls = formal[brace + 1: brace + 1 + decls_end.start()] if decls_end else ""
+        if "font-size" in decls and ":not(.prose" not in selector:
+            unguarded.append(selector)
+    check(not unguarded, "every formal h1/h2 font-size rule is guarded by :not(.prose ...)",
+          "unguarded: %s" % " | ".join(unguarded))
+
+
+# --------------------------------------------------------------------------
+# f. Syntax sanity
+# --------------------------------------------------------------------------
+
+def check_syntax():
+    print("-- syntax sanity --")
+    files = ["css/spry.css"] + ["css/themes/spry-theme-%s.css" % s for s in THEMES]
+    for rel in files:
+        raw = read(rel)
+        stripped = strip_comments(raw)
+        problems = []
+        if stripped.count("{") != stripped.count("}"):
+            problems.append("braces %d/%d" % (stripped.count("{"), stripped.count("}")))
+        if stripped.count("(") != stripped.count(")"):
+            problems.append("parens %d/%d" % (stripped.count("("), stripped.count(")")))
+        if "\t" in raw:
+            problems.append("tab characters (files are 2-space)")
+        check(not problems, "%s is balanced and tab-free" % rel,
+              "; ".join(problems))
+
+
+# --------------------------------------------------------------------------
+# g. README freshness
+# --------------------------------------------------------------------------
+
+def check_readme():
+    print("-- README freshness --")
+    readme = read("README.md")
+    check("../Spry-CSS" not in readme, "README has no stale ../Spry-CSS references")
+    check("css/index.html" in readme, "README points at the css/index.html demo")
+
+
+def main():
+    for fn in (check_airy_bezier, check_padding_defaults, check_theme_tokens,
+               check_demo, check_formal_prose, check_syntax, check_readme):
+        try:
+            fn()
+        except Exception as exc:  # a crash is a failure, not a skip
+            check(False, "%s ran to completion" % fn.__name__,
+                  "raised: %r" % exc)
+    print("---- %d passed, %d failed ----" % (passes, failures))
+    return 1 if failures else 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 13 - 0
tests/meson.build

@@ -10,6 +10,14 @@
 # boots a real SQLite store through the same wiring as SpryModule and
 # verifies the full SA-16 path — register, force-stale the stored hash,
 # authenticate, hash rewritten at current parameters, epoch untouched.
+#
+# css-check parses the shipped stylesheets as text (no browser): the
+# Airy DWM glide-in curve is numerically the Win7 f(t)=1-(1-t)^3 open /
+# t^3 close, core padding defaults plus their :has() zero-guards, theme
+# token sets, demo-page coverage, formal's .prose opt-out, brace/paren
+# balance, README freshness.
+
+python3 = import('python').find_installation('python3')
 
 spry_tests = executable('spry-tests',
     ['TestMain.vala'],
@@ -29,3 +37,8 @@ test('spry-rehash-probe', rehash_probe,
     args: [join_paths(meson.current_build_dir(), 'rehash-probe.sqlite')],
     workdir: meson.current_build_dir()
 )
+
+test('css-check', python3,
+    args: files('css_check.py'),
+    workdir: meson.project_source_root()
+)