diff options
Diffstat (limited to 'scripts/theme-studio/test_generate.py')
| -rw-r--r-- | scripts/theme-studio/test_generate.py | 381 |
1 files changed, 330 insertions, 51 deletions
diff --git a/scripts/theme-studio/test_generate.py b/scripts/theme-studio/test_generate.py index ed2ce74ba..3bc78bdf8 100644 --- a/scripts/theme-studio/test_generate.py +++ b/scripts/theme-studio/test_generate.py @@ -10,6 +10,7 @@ Run: python3 -m unittest test_generate (from scripts/theme-studio/) """ import os import io +import json import tempfile import runpy import unittest @@ -17,6 +18,39 @@ from collections import Counter, defaultdict from contextlib import redirect_stdout import generate # importable without side effects: the file write is __main__-guarded +import face_coverage +from unittest import mock + + +class ClassifyBucket(unittest.TestCase): + """Characterization of face_coverage.classify's core/general/package decision, + locking each branch before the named-locals rewrite. bucket_of_source is mocked + to identity, so the src dict maps each face straight to its bucket name.""" + + def _classify(self, src, pkgfaces=(), name="x"): + with mock.patch.object(face_coverage, "bucket_of_source", lambda s: s): + return face_coverage.classify(name, list(src), src, set(pkgfaces)) + + def test_emacs_core_short_circuits_to_core(self): + self.assertEqual(face_coverage.classify("emacs-core", [], {}, set()), "core") + + def test_nothing_loaded_with_a_package_face_is_package(self): + self.assertEqual(self._classify({"a": "unloaded", "b": "unloaded"}, pkgfaces={"b"}), "package") + + def test_nothing_loaded_without_a_package_face_is_general(self): + self.assertEqual(self._classify({"a": "unloaded"}), "general") + + def test_elpa_plurality_is_package(self): + self.assertEqual(self._classify({"a": "elpa", "b": "elpa", "c": "builtin"}), "package") + + def test_elpa_tied_with_builtin_is_package(self): + self.assertEqual(self._classify({"a": "elpa", "b": "builtin"}), "package") + + def test_other_beats_builtin_and_ties_elpa_is_package(self): + self.assertEqual(self._classify({"a": "other", "b": "other", "c": "elpa", "d": "builtin"}), "package") + + def test_builtin_plurality_is_general(self): + self.assertEqual(self._classify({"a": "builtin", "b": "builtin", "c": "elpa"}), "general") from app_inventory import face_rows from default_faces import DefaultFaces, changed_summary from face_specs import face_spec, package_face_spec, ui_face_spec @@ -82,38 +116,36 @@ class AssembledPage(unittest.TestCase): "PALETTE_ACTIONS_J", "BROWSER_GATES_J", "COLORMATH_J", "SAMPLES_J", "PALETTE_J", "CATS_J", "UIFACES_J", "UIMAP_J", "APPS_J", "SYNTAX_J", "MAP_J", - "COLOR_NAMES_J", + "COLOR_NAMES_J", "FACE_DOCS_J", "SYNTAX_DOCS_J", ] def test_every_placeholder_is_substituted(self): for token in self.PLACEHOLDERS: self.assertNotIn(token, generate.HTML, f"{token} left unsubstituted") - def test_page_carries_the_colormath_body_verbatim(self): - # Python-side inline-integrity: the same guarantee the JS test asserts, but - # checked at the point the page is built rather than after a round-trip. - self.assertIn(generate.COLORMATH_BODY, generate.HTML) - - def test_page_carries_the_app_core_body_verbatim(self): - # app-core.js inlines verbatim (no data placeholders), so the inlined copy - # and the unit-tested module cannot drift. - self.assertIn(generate.APP_CORE_BODY, generate.HTML) - - def test_page_carries_the_app_util_body_verbatim(self): - # app-util.js inlines verbatim after its import line is stripped. - self.assertIn(generate.APP_UTIL_BODY, generate.HTML) - - def test_page_carries_palette_generator_core_verbatim(self): - self.assertIn(generate.PALETTE_GENERATOR_CORE_BODY, generate.HTML) - - def test_page_carries_palette_generator_ui_verbatim(self): - self.assertIn(generate.PALETTE_GENERATOR_UI_BODY, generate.HTML) - - def test_page_carries_palette_actions_verbatim(self): - self.assertIn(generate.PALETTE_ACTIONS_BODY, generate.HTML) - - def test_page_carries_browser_gates_verbatim(self): - self.assertIn(generate.BROWSER_GATES_BODY, generate.HTML) + def test_face_docs_maps_embed_a_known_docstring(self): + # The face/syntax docstring maps inline so element hovers can show them. + # default is always present; its first line is stable across Emacs builds. + self.assertIn("Basic default face.", generate.FACE_DOCS["default"]) + self.assertIn(json.dumps(generate.FACE_DOCS), generate.HTML) + + def test_syntax_docs_resolve_categories_to_face_docstrings(self): + # The syntax table is keyed by category (kw, doc, ...); each resolves to + # its font-lock face's docstring via build-theme's canonical map. + self.assertIn("keyword", generate.SYNTAX_DOCS["kw"].lower()) + self.assertIn(json.dumps(generate.SYNTAX_DOCS), generate.HTML) + + def test_page_carries_each_inlined_body_verbatim(self): + # Python-side inline-integrity: every verbatim-inlined module (no data + # placeholders, exports/imports stripped) must appear in the page byte for + # byte, so the inlined copy and the unit-tested module cannot drift. Checked + # at build time rather than after a round-trip. app-util.js's import line is + # already stripped in APP_UTIL_BODY. + for name in ("COLORMATH_BODY", "APP_CORE_BODY", "APP_UTIL_BODY", + "PALETTE_GENERATOR_CORE_BODY", "PALETTE_GENERATOR_UI_BODY", + "PALETTE_ACTIONS_BODY", "BROWSER_GATES_BODY"): + with self.subTest(body=name): + self.assertIn(getattr(generate, name), generate.HTML) def test_app_util_inlined_body_has_no_import_line(self): # The `import rl` line must be gone, or the page <script> is invalid. @@ -164,6 +196,20 @@ class LanguageSamples(unittest.TestCase): self.assertIn("@import", text) self.assertIn("error.MissingColor", text) + def test_expanded_language_set_is_registered_and_renders(self): + # Every added language is selectable and renders a non-trivial sample that + # exercises keywords and carries a comment. + added = ["Racket", "Scheme", "Haskell", "OCaml", "Scala", "Kotlin", + "Swift", "Lua", "Ruby", "Perl", "R", "Erlang", "SQL", "PHP", + "Ada", "Fortran", "MATLAB", "Assembly"] + for lang in added: + self.assertIn(lang, generate.SAMPLES, f"{lang} not in the language selector") + tokens = self._tokens(lang) + cats = {k for k, _ in tokens} + self.assertGreater(len(tokens), 40, f"{lang} sample is too short") + self.assertIn("kw", cats, f"{lang} sample has no keywords") + self.assertIn("cmd", cats, f"{lang} sample has no comment") + class FacesHelper(unittest.TestCase): def test_strips_prefix_and_derives_label_and_merges_seed(self): @@ -192,27 +238,54 @@ class FacesHelper(unittest.TestCase): class FaceSpecDefaults(unittest.TestCase): def test_ui_face_spec_fills_style_fields(self): + # The legacy "bold" migrates to weight "bold" through face_spec. self.assertEqual(ui_face_spec({"bg": "#ffffff", "bold": True}), { "fg": None, "bg": "#ffffff", - "bold": True, - "italic": False, - "underline": False, - "strike": False, + "distant-fg": None, + "family": None, + "weight": "bold", + "slant": None, + "underline": None, + "strike": None, + "overline": None, "box": None, + "inverse": False, + "extend": False, + "inherit": None, + "height": None, }) + def test_ui_face_spec_carries_inherit_and_height(self): + # inherit/height are no longer package-only; a ui face can set them. + spec = ui_face_spec({"inherit": "shadow", "height": 1.3}) + self.assertEqual(spec["inherit"], "shadow") + self.assertEqual(spec["height"], 1.3) + + def test_face_spec_migrates_legacy_style_booleans(self): + spec = ui_face_spec({"italic": True, "underline": True, "strike": True}) + self.assertEqual(spec["slant"], "italic") + self.assertEqual(spec["underline"], {"style": "line", "color": None}) + self.assertEqual(spec["strike"], {"color": None}) + self.assertNotIn("bold", spec) + self.assertNotIn("italic", spec) + def test_package_face_spec_fills_structure_fields(self): self.assertEqual(package_face_spec({"inherit": "base", "height": 1.2}), { "fg": None, "bg": None, - "bold": False, - "italic": False, - "underline": False, - "strike": False, + "distant-fg": None, + "family": None, + "weight": None, + "slant": None, + "underline": None, + "strike": None, + "overline": None, + "box": None, + "inverse": False, + "extend": False, "inherit": "base", "height": 1.2, - "box": None, }) def test_generated_color_names_are_base_columns_when_legacy(self): @@ -240,6 +313,19 @@ class GeneratorStateHelpers(unittest.TestCase): self.assertTrue(uimap["link"]["underline"]) self.assertEqual(uimap["mode-line"]["box"], {"style": "released", "width": 1, "color": None}) + def test_mode_line_highlight_defaults_to_raised_box(self): + # The face is absent from the snapshot, so it must get the raised box in + # both the with-snapshot and no-snapshot branches. + raised = {"style": "released", "width": 1, "color": None} + self.assertEqual(generate.UIMAP["mode-line-highlight"]["box"], raised) + no_snapshot = generate.build_uimap(generate.UI_FACES, DefaultFaces(None)) + self.assertEqual(no_snapshot["mode-line-highlight"]["box"], raised) + + def test_hover_box_default_yields_to_existing_box(self): + uimap = {"mode-line-highlight": ui_face_spec({"box": {"style": "line", "width": 2, "color": "#abcdef"}})} + generate.apply_hover_box_default(uimap) + self.assertEqual(uimap["mode-line-highlight"]["box"], {"style": "line", "width": 2, "color": "#abcdef"}) + def test_build_syntax_uses_map_and_style_fallbacks_without_defaults_snapshot(self): syntax = generate.build_syntax( {"kw": [None, True]}, @@ -249,8 +335,8 @@ class GeneratorStateHelpers(unittest.TestCase): DefaultFaces(None), ) self.assertEqual(syntax["kw"]["fg"], "#d3d3d3") - self.assertTrue(syntax["kw"]["bold"]) - self.assertFalse(syntax["kw"]["italic"]) + self.assertEqual(syntax["kw"]["weight"], "bold") + self.assertIsNone(syntax["kw"]["slant"]) def test_builtin_fallback_styles_fill_known_emacs_styles(self): uimap = { @@ -262,12 +348,13 @@ class GeneratorStateHelpers(unittest.TestCase): ) } generate.apply_builtin_fallback_styles(uimap) - self.assertTrue(uimap["link"]["underline"]) - self.assertTrue(uimap["lazy-highlight"]["underline"]) - self.assertTrue(uimap["show-paren-match"]["underline"]) - self.assertTrue(uimap["error"]["bold"]) - self.assertTrue(uimap["warning"]["bold"]) - self.assertTrue(uimap["success"]["bold"]) + line_underline = {"style": "line", "color": None} + self.assertEqual(uimap["link"]["underline"], line_underline) + self.assertEqual(uimap["lazy-highlight"]["underline"], line_underline) + self.assertEqual(uimap["show-paren-match"]["underline"], line_underline) + self.assertEqual(uimap["error"]["weight"], "bold") + self.assertEqual(uimap["warning"]["weight"], "bold") + self.assertEqual(uimap["success"]["weight"], "bold") self.assertEqual(uimap["mode-line"]["box"], {"style": "released", "width": 1, "color": None}) self.assertEqual(uimap["mode-line-inactive"]["box"], {"style": "released", "width": 1, "color": None}) @@ -303,7 +390,7 @@ class GeneratorStateHelpers(unittest.TestCase): } }, syntax, color_map) self.assertEqual(syntax["kw"]["fg"], "#222222") - self.assertTrue(syntax["kw"]["bold"]) + self.assertEqual(syntax["kw"]["weight"], "bold") self.assertEqual(color_map["kw"], "#222222") self.assertNotIn("unknown", syntax) @@ -380,6 +467,16 @@ class DefaultFaceAdapter(unittest.TestCase): }, "effectiveGuiLight": {}, }, + "rich": { + "chosenGuiLight": { + "distantForeground": "black", + "distantForegroundHex": "#000000", + "overline": "t", + "inverseVideo": "t", + "extend": "t", + }, + "effectiveGuiLight": {}, + }, } }) @@ -387,13 +484,26 @@ class DefaultFaceAdapter(unittest.TestCase): self.assertEqual(self.defaults.seed("sample", effective=False), { "fg": "#333333", "bg": "#ffffff", - "bold": True, - "italic": True, - "underline": True, + "weight": "bold", + "slant": "italic", + "underline": {"style": "line", "color": None}, "inherit": "parent", "box": {"style": "released", "width": 2, "color": None}, }) + def test_seed_emits_the_additive_attrs_when_the_snapshot_has_them(self): + self.assertEqual(self.defaults.seed("rich", effective=False), { + "distant-fg": "#000000", + "overline": {"color": None}, + "inverse": True, + "extend": True, + }) + + def test_seed_omits_additive_attrs_when_the_snapshot_lacks_them(self): + seeded = self.defaults.seed("sample", effective=False) + for key in ("distant-fg", "overline", "inverse", "extend"): + self.assertNotIn(key, seeded) + def test_color_reads_effective_hex_by_default(self): self.assertEqual(self.defaults.color("sample"), "#000000") @@ -513,11 +623,180 @@ class GeneratedDefaults(unittest.TestCase): def test_syntax_defaults_capture_font_lock_styles(self): self.assertEqual(generate.MAP["kw"], "#d3d3d3") - self.assertTrue(generate.SYNTAX["kw"]["bold"]) - self.assertFalse(generate.SYNTAX["kw"]["italic"]) + self.assertEqual(generate.SYNTAX["kw"]["weight"], "bold") + self.assertIsNone(generate.SYNTAX["kw"]["slant"]) self.assertEqual(generate.MAP["str"], "#696969") - self.assertFalse(generate.SYNTAX["str"]["bold"]) - self.assertTrue(generate.SYNTAX["str"]["italic"]) + self.assertIsNone(generate.SYNTAX["str"]["weight"]) + self.assertEqual(generate.SYNTAX["str"]["slant"], "italic") + + +class NerdIconsLegend(unittest.TestCase): + """The committed nerd-icons-legend.json artifact and the loader fallback.""" + + def _write(self, content): + path = os.path.join(tempfile.mkdtemp(), "nerd-icons-legend.json") + with open(path, "w") as out: + out.write(content) + return path + + def test_committed_artifact_has_valid_rows(self): + rows = generate.load_nerd_icons_legend() + self.assertIsNotNone(rows, "committed nerd-icons-legend.json should load") + self.assertTrue(rows) + for row in rows: + for field in generate.NERD_ICONS_LEGEND_FIELDS: + self.assertIsInstance(row.get(field), str) + self.assertTrue(row[field]) + self.assertTrue(row["face"].startswith("nerd-icons-")) + self.assertIn(row["category"], ("extension", "dir", "command", "buffer")) + + def test_absent_artifact_falls_back_to_none(self): + with redirect_stdout(io.StringIO()) as out: + self.assertIsNone(generate.load_nerd_icons_legend("/no/such/legend.json")) + self.assertIn("absent", out.getvalue()) + + def test_malformed_artifact_falls_back_to_none(self): + path = self._write("{not json") + with redirect_stdout(io.StringIO()) as out: + self.assertIsNone(generate.load_nerd_icons_legend(path)) + self.assertIn("malformed", out.getvalue()) + + def test_empty_artifact_falls_back_to_none(self): + path = self._write("[]") + with redirect_stdout(io.StringIO()) as out: + self.assertIsNone(generate.load_nerd_icons_legend(path)) + self.assertIn("empty", out.getvalue()) + + def test_row_missing_a_field_falls_back_to_none(self): + path = self._write(json.dumps([{"key": "ext:el", "label": "init.el", + "face": "nerd-icons-purple", "category": "extension"}])) + with redirect_stdout(io.StringIO()) as out: + self.assertIsNone(generate.load_nerd_icons_legend(path)) + self.assertIn("invalid", out.getvalue()) + + def test_nerd_icons_registered_as_bespoke_legend_app(self): + app = generate.APPS.get("nerd-icons") + self.assertIsNotNone(app, "nerd-icons should be a bespoke app with the legend present") + self.assertEqual(app["preview"], "nerdicons") + self.assertTrue(app.get("legend")) + self.assertGreaterEqual(len(app["faces"]), 30) + # The dir-completion face is a different package and keeps its own app. + self.assertIn("nerd-icons-completion", generate.APPS) + + def test_nerd_icons_app_faces_are_seeded_with_native_colors(self): + # apply_default_face_seeds fills the editable rows from emacs-default-faces.json. + rows = {r[0]: r[2] for r in generate.APPS["nerd-icons"]["faces"]} + self.assertIn("nerd-icons-blue", rows) + self.assertTrue(rows["nerd-icons-blue"], "nerd-icons-blue should carry a native seed") + + def test_legend_loads_from_object_shaped_artifact(self): + # The committed artifact is now an object {legend, gallery}; the legend + # loader must read the "legend" key, not assume a bare array. + path = self._write(json.dumps({"legend": [ + {"key": "ext:el", "label": "init.el", "face": "nerd-icons-purple", + "category": "extension", "glyph": "x"}], "gallery": []})) + rows = generate.load_nerd_icons_legend(path) + self.assertEqual(len(rows), 1) + self.assertEqual(rows[0]["face"], "nerd-icons-purple") + + +class NerdIconsGallery(unittest.TestCase): + """The committed gallery (full colored catalog) and its loader fallback.""" + + def _write(self, content): + path = os.path.join(tempfile.mkdtemp(), "nerd-icons-legend.json") + with open(path, "w") as out: + out.write(content) + return path + + def test_committed_artifact_has_valid_groups(self): + groups = generate.load_nerd_icons_gallery() + self.assertIsNotNone(groups, "committed gallery should load") + self.assertTrue(groups) + for g in groups: + self.assertTrue(g["face"].startswith("nerd-icons-")) + self.assertIsInstance(g["hue"], (int, float)) + self.assertTrue(g["glyphs"]) + for e in g["glyphs"]: + for field in generate.NERD_ICONS_GALLERY_GLYPH_FIELDS: + self.assertIsInstance(e.get(field), str) + self.assertTrue(e[field]) + + def test_groups_are_ordered_by_hue(self): + groups = generate.load_nerd_icons_gallery() + hues = [g["hue"] for g in groups] + self.assertEqual(hues, sorted(hues), "color rows cluster by hue (ascending)") + + def test_icons_are_deduplicated_within_a_group(self): + for g in generate.load_nerd_icons_gallery(): + names = [e["name"] for e in g["glyphs"]] + self.assertEqual(len(names), len(set(names)), f"{g['face']} repeats an icon name") + + def test_absent_artifact_falls_back_to_none(self): + with redirect_stdout(io.StringIO()): + self.assertIsNone(generate.load_nerd_icons_gallery("/no/such/legend.json")) + + def test_malformed_artifact_falls_back_to_none(self): + path = self._write("{not json") + with redirect_stdout(io.StringIO()): + self.assertIsNone(generate.load_nerd_icons_gallery(path)) + + def test_legacy_array_only_artifact_has_no_gallery(self): + # A v1-era bare-array file carries a legend but no gallery -> None, no crash. + path = self._write(json.dumps([{"key": "ext:el"}])) + with redirect_stdout(io.StringIO()): + self.assertIsNone(generate.load_nerd_icons_gallery(path)) + + def test_group_missing_a_field_falls_back_to_none(self): + # Missing hue and glyphs -> invalid. + path = self._write(json.dumps({"legend": [], "gallery": [{"face": "nerd-icons-blue"}]})) + with redirect_stdout(io.StringIO()) as out: + self.assertIsNone(generate.load_nerd_icons_gallery(path)) + self.assertIn("invalid", out.getvalue()) + + def test_glyph_entry_missing_a_field_falls_back_to_none(self): + path = self._write(json.dumps({"gallery": [ + {"face": "nerd-icons-blue", "hue": 212, "glyphs": [{"glyph": "x"}]}]})) + with redirect_stdout(io.StringIO()) as out: + self.assertIsNone(generate.load_nerd_icons_gallery(path)) + self.assertIn("invalid", out.getvalue()) + + def test_group_with_empty_glyphs_falls_back_to_none(self): + path = self._write(json.dumps({"gallery": [ + {"face": "nerd-icons-blue", "hue": 212, "glyphs": []}]})) + with redirect_stdout(io.StringIO()) as out: + self.assertIsNone(generate.load_nerd_icons_gallery(path)) + self.assertIn("invalid", out.getvalue()) + + def test_group_with_a_foreign_face_falls_back_to_none(self): + path = self._write(json.dumps({"gallery": [ + {"face": "rainbow-delimiters-depth-1", "hue": 212, + "glyphs": [{"glyph": "x", "name": "nf-x"}]}]})) + with redirect_stdout(io.StringIO()) as out: + self.assertIsNone(generate.load_nerd_icons_gallery(path)) + self.assertIn("invalid", out.getvalue()) + + def test_group_with_a_non_numeric_hue_falls_back_to_none(self): + path = self._write(json.dumps({"gallery": [ + {"face": "nerd-icons-blue", "hue": "212", + "glyphs": [{"glyph": "x", "name": "nf-x"}]}]})) + with redirect_stdout(io.StringIO()) as out: + self.assertIsNone(generate.load_nerd_icons_gallery(path)) + self.assertIn("invalid", out.getvalue()) + + def test_non_dict_glyph_entry_falls_back_to_none(self): + path = self._write(json.dumps({"gallery": [ + {"face": "nerd-icons-blue", "hue": 212, "glyphs": ["not-a-dict"]}]})) + with redirect_stdout(io.StringIO()) as out: + self.assertIsNone(generate.load_nerd_icons_gallery(path)) + self.assertIn("invalid", out.getvalue()) + + def test_nerd_icons_app_carries_the_gallery(self): + app = generate.APPS.get("nerd-icons") + self.assertIsNotNone(app) + self.assertTrue(app.get("gallery"), "nerd-icons app should carry the gallery groups") + faces = {g["face"] for g in app["gallery"]} + self.assertIn("nerd-icons-blue", faces) if __name__ == "__main__": |
