From 440877965e8879f3c9567cf923b7c65c0eee227c Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 11 Jul 2026 23:36:51 -0500 Subject: refactor(gallery): extract design tokens to one source for 3 targets The colors, glows, gradient ramp, and pulse rate were hardcoded literals scattered across ~40 sites in the gallery. Retuning the amber meant a find-and-replace through the whole file. I pulled them into tokens.json as the single source. gen_tokens.py reads that source and emits three targets: web CSS custom properties (into the gallery :root, between markers), waybar GTK @define-color declarations, and an elisp alist for the future svg.el renderer. The three genuinely differ (CSS --vars with rgb-triple glows, GTK @define-color with underscore names, elisp hex alist), which is why one generator beats three hand-maintained copies. The amber hue is defined once, and its glow triples are derived from the hex, so retuning it means editing one line and rerunning the generator. The gallery render is unchanged. I verified it pixel-identical against the prior commit with reduced-motion forced and the live clock masked (diff of 0). Tests cover hex conversion, the three emitters, marker replacement, and idempotency: 27 tests, 100% line coverage, wired into make test-unit. --- tests/gallery-tokens/test_gen_tokens.py | 268 ++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 tests/gallery-tokens/test_gen_tokens.py (limited to 'tests/gallery-tokens') diff --git a/tests/gallery-tokens/test_gen_tokens.py b/tests/gallery-tokens/test_gen_tokens.py new file mode 100644 index 0000000..414194e --- /dev/null +++ b/tests/gallery-tokens/test_gen_tokens.py @@ -0,0 +1,268 @@ +"""Tests for docs/prototypes/gen_tokens.py. + +gen_tokens.py is the single-source token generator for the panel widget +gallery. It reads docs/prototypes/tokens.json (the neutral source of truth +for the design tokens) and emits three target representations from it: + + - web CSS custom properties (:root { --gold:#e2a038; ... }) + - waybar GTK CSS (@define-color gold #e2a038; ...) + - Emacs elisp (an alist for the future svg.el renderer) + +The three differ on purpose: CSS uses hyphenated --vars and stores glow +colors as bare "r,g,b" triples (so rgba(var(--glow-hi),.5) works); GTK CSS +has no custom properties, so it uses @define-color with underscore names +and resolves glows to their source hex (GTK uses alpha(@color,a)); elisp +uses a hyphenated-symbol alist of hex strings. One source, three emitters +is the whole reason the generator earns its keep across the three targets. + +These tests import the REAL gen_tokens module from docs/prototypes/ (not a +copy) and exercise its functions directly against fixture token dicts, plus +one integration test that runs main() against temp copies. + +Run from repo root: + make test-unit + (or python3 -m unittest tests.gallery-tokens.test_gen_tokens) +""" + +import importlib.util +import json +import os +import tempfile +import unittest + + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +GEN = os.path.join(REPO_ROOT, "docs", "prototypes", "gen_tokens.py") +TOKENS_JSON = os.path.join(REPO_ROOT, "docs", "prototypes", "tokens.json") + + +def _load_module(): + """Import the real gen_tokens.py by path (no importable package name).""" + spec = importlib.util.spec_from_file_location("gen_tokens", GEN) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +gt = _load_module() + + +# A small deterministic fixture — not the real tokens.json, so these tests +# don't drift when the palette is retuned. +FIXTURE = { + "palette": {"ground": "#151311", "wash": "#2c2f32", "slate-hi": "#54677d"}, + "amber": {"gold": "#e2a038", "gold-hi": "#ffbe54", "amber-grad-top": "#f2c76a"}, + "glow": {"glow-hi": "gold-hi", "glow-lo": "gold"}, + "font": {"mono": '"Berkeley Mono",monospace'}, + "timing": {"pulse-rate": "1s"}, +} + + +class HexToTriple(unittest.TestCase): + def test_normal_values(self): + self.assertEqual(gt.hex_to_triple("#ffbe54"), "255,190,84") + self.assertEqual(gt.hex_to_triple("#e2a038"), "226,160,56") + + def test_boundary_black_and_white(self): + self.assertEqual(gt.hex_to_triple("#000000"), "0,0,0") + self.assertEqual(gt.hex_to_triple("#ffffff"), "255,255,255") + + def test_boundary_three_digit_shorthand(self): + self.assertEqual(gt.hex_to_triple("#fff"), "255,255,255") + self.assertEqual(gt.hex_to_triple("#0f0"), "0,255,0") + + def test_boundary_no_leading_hash(self): + self.assertEqual(gt.hex_to_triple("e2a038"), "226,160,56") + + def test_error_bad_chars(self): + with self.assertRaises(ValueError): + gt.hex_to_triple("#gggggg") + + def test_error_bad_length(self): + with self.assertRaises(ValueError): + gt.hex_to_triple("#12") + + +class ResolveColor(unittest.TestCase): + def test_finds_in_amber(self): + self.assertEqual(gt.resolve_color(FIXTURE, "gold"), "#e2a038") + + def test_finds_in_palette(self): + self.assertEqual(gt.resolve_color(FIXTURE, "wash"), "#2c2f32") + + def test_missing_raises(self): + with self.assertRaises(KeyError): + gt.resolve_color(FIXTURE, "nonexistent") + + +class EmitWebCss(unittest.TestCase): + def setUp(self): + self.css = gt.emit_web_css(FIXTURE) + + def test_solid_colors_are_hyphenated_vars(self): + self.assertIn("--gold:#e2a038;", self.css) + self.assertIn("--slate-hi:#54677d;", self.css) + self.assertIn("--amber-grad-top:#f2c76a;", self.css) + + def test_glow_stored_as_rgb_triple(self): + # so rgba(var(--glow-hi),.5) resolves; derived from the source hex + self.assertIn("--glow-hi:255,190,84;", self.css) + self.assertIn("--glow-lo:226,160,56;", self.css) + + def test_font_and_timing(self): + self.assertIn("--pulse-rate:1s;", self.css) + self.assertIn('--mono:"Berkeley Mono",monospace;', self.css) + + +class EmitWaybarGtk(unittest.TestCase): + def setUp(self): + self.gtk = gt.emit_waybar_gtk(FIXTURE) + + def test_uses_define_color_not_custom_props(self): + self.assertIn("@define-color gold #e2a038;", self.gtk) + self.assertNotIn("--gold", self.gtk) + self.assertNotIn(":root", self.gtk) + + def test_glow_resolved_to_source_hex(self): + # GTK has no bare triples; it uses alpha(@color,a), so glow -> hex + self.assertIn("@define-color glow_hi #ffbe54;", self.gtk) + self.assertIn("@define-color glow_lo #e2a038;", self.gtk) + + def test_names_use_underscores_not_hyphens(self): + self.assertIn("@define-color slate_hi #54677d;", self.gtk) + self.assertNotIn("slate-hi", self.gtk) + self.assertNotIn("amber-grad-top", self.gtk) + + +class EmitElisp(unittest.TestCase): + def setUp(self): + self.el = gt.emit_elisp(FIXTURE) + + def test_alist_of_hex_with_hyphenated_symbols(self): + self.assertIn('(gold . "#e2a038")', self.el) + self.assertIn('(slate-hi . "#54677d")', self.el) + + def test_glow_is_hex_for_svg(self): + # svg.el / librsvg wants a real color, not a bare triple + self.assertIn('(glow-hi . "#ffbe54")', self.el) + + def test_timing_included(self): + self.assertIn('(pulse-rate . "1s")', self.el) + + +class ReplaceBetweenMarkers(unittest.TestCase): + START = "/* @tokens:start */" + END = "/* @tokens:end */" + + def _wrap(self, inner): + return f"a\n{self.START}\n{inner}\n{self.END}\nb\n" + + def test_replaces_inner_block(self): + src = self._wrap("OLD") + out = gt.replace_between_markers(src, self.START, self.END, "NEW") + self.assertIn("NEW", out) + self.assertNotIn("OLD", out) + # markers and surrounding text survive + self.assertIn(self.START, out) + self.assertIn(self.END, out) + self.assertTrue(out.startswith("a\n")) + self.assertTrue(out.rstrip().endswith("b")) + + def test_idempotent(self): + src = self._wrap("OLD") + once = gt.replace_between_markers(src, self.START, self.END, "NEW") + twice = gt.replace_between_markers(once, self.START, self.END, "NEW") + self.assertEqual(once, twice) + + def test_missing_marker_raises(self): + with self.assertRaises(ValueError): + gt.replace_between_markers("no markers here", self.START, self.END, "NEW") + + def test_start_marker_on_final_line_without_newline(self): + # Defensive branch: no newline after the start marker. The guard must + # not let text[:si-of-newline+1] collapse to "" and wipe the prefix. + src = f"keep\n{self.START} {self.END}" + out = gt.replace_between_markers(src, self.START, self.END, "X") + self.assertTrue(out.startswith("keep\n")) + self.assertIn(self.END, out) + self.assertIn("X", out) + + +class RealTokensJson(unittest.TestCase): + """The committed tokens.json must stay well-formed and complete.""" + + def setUp(self): + with open(TOKENS_JSON) as f: + self.tokens = json.load(f) + + def test_has_required_sections(self): + for section in ("palette", "amber", "glow", "font", "timing"): + self.assertIn(section, self.tokens) + + def test_amber_defines_gold_and_glow_sources_resolve(self): + self.assertIn("gold", self.tokens["amber"]) + self.assertIn("gold-hi", self.tokens["amber"]) + for _name, source in self.tokens["glow"].items(): + # every glow source must resolve to a real color + gt.resolve_color(self.tokens, source) + + def test_pulse_rate_present(self): + self.assertIn("pulse-rate", self.tokens["timing"]) + + +class MainIntegration(unittest.TestCase): + """main() rewrites the html :root and writes the waybar + elisp files.""" + + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="gen-tokens-test-") + self.html = os.path.join(self.tmp, "gallery.html") + self.tokens_path = os.path.join(self.tmp, "tokens.json") + self.waybar = os.path.join(self.tmp, "tokens-waybar.css") + self.elisp = os.path.join(self.tmp, "tokens.el") + with open(self.tokens_path, "w") as f: + json.dump(FIXTURE, f) + with open(self.html, "w") as f: + f.write( + "\n" + ) + + def tearDown(self): + import shutil + shutil.rmtree(self.tmp, ignore_errors=True) + + def test_main_writes_all_three_targets(self): + gt.main( + tokens_path=self.tokens_path, + html_path=self.html, + waybar_path=self.waybar, + elisp_path=self.elisp, + ) + with open(self.html) as f: + html = f.read() + self.assertIn("--gold:#e2a038;", html) + self.assertNotIn("STALE", html) + self.assertTrue(os.path.exists(self.waybar)) + self.assertTrue(os.path.exists(self.elisp)) + with open(self.waybar) as f: + self.assertIn("@define-color gold #e2a038;", f.read()) + with open(self.elisp) as f: + self.assertIn('(gold . "#e2a038")', f.read()) + + def test_main_is_idempotent(self): + gt.main(tokens_path=self.tokens_path, html_path=self.html, + waybar_path=self.waybar, elisp_path=self.elisp) + with open(self.html) as f: + first = f.read() + gt.main(tokens_path=self.tokens_path, html_path=self.html, + waybar_path=self.waybar, elisp_path=self.elisp) + with open(self.html) as f: + second = f.read() + self.assertEqual(first, second) + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3