"""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 DefaultElispFilename(unittest.TestCase): """The default elisp target must be gallery-tokens.el so that the file's (provide 'gallery-tokens) matches its name and `require` can resolve it from a load-path. A tokens.el/gallery-tokens feature mismatch is a trap.""" def test_default_elisp_path_matches_provided_feature(self): self.assertEqual( os.path.basename(gt.DEFAULT_ELISP_NAME), "gallery-tokens.el") 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, "gallery-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()