| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- #!/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())
|