diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/gallery-tokens/test_gen_tokens.py | 268 |
1 files changed, 268 insertions, 0 deletions
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( + "<style>\n:root{\n" + "/* @tokens:start */\n" + "STALE\n" + "/* @tokens:end */\n" + "}\n</style>\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() |
