import json import tempfile import unittest from pathlib import Path from config.webapp_themes_config import ( WebappThemesConfig, apply_webapp_theme_env_overrides, builtin_webapp_themes_config, default_webapp_theme_descriptors, ensure_webapp_core_themes, load_webapp_theme_dir, public_themes_catalog_payload, resolved_webapp_themes_catalog, write_webapp_theme_dir, ) class WebappThemesConfigTests(unittest.TestCase): def test_builtin_has_core_themes(self): cfg = builtin_webapp_themes_config("#abcdef") self.assertEqual(cfg.default_theme, "dark") keys = {theme.key for theme in cfg.themes} self.assertEqual(keys, {"dark", "light", "windows95", "ascii"}) dark = cfg.theme_by_key("dark") self.assertIsNotNone(dark) self.assertTrue(dark.default) self.assertEqual(dark.tokens.accent, "#abcdef") win95 = cfg.theme_by_key("windows95") self.assertIsNotNone(win95) self.assertEqual(cfg.theme_by_key("light").css_file, "style.css") self.assertEqual(win95.css_file, "style.css") self.assertEqual(win95.tokens.style_preset, "win95") self.assertFalse(win95.use_primary_accent) self.assertTrue(win95.use_in_admin) self.assertEqual(win95.assets_version, 12) self.assertEqual(cfg.theme_by_key("light").assets_version, 4) ascii_theme = cfg.theme_by_key("ascii") self.assertIsNotNone(ascii_theme) self.assertEqual(ascii_theme.css_file, "style.css") self.assertFalse(ascii_theme.use_primary_accent) self.assertTrue(ascii_theme.use_in_admin) self.assertEqual(ascii_theme.assets_version, 5) def test_env_override_default_theme(self): cfg = builtin_webapp_themes_config("#00fe7a") out = apply_webapp_theme_env_overrides(cfg, "light") self.assertEqual(out.default_theme, "light") self.assertTrue(out.theme_by_key("light").default) self.assertFalse(out.theme_by_key("dark").default) def test_core_themes_are_merged_when_missing(self): cfg = WebappThemesConfig( default_theme="custom", themes=[ { "key": "custom", "enabled": True, "default": True, "use_primary_accent": False, "tokens": {"color_scheme": "dark"}, } ], ) merged, changed = ensure_webapp_core_themes(cfg, "#00fe7a") self.assertTrue(changed) self.assertEqual( {theme.key for theme in merged.themes}, {"custom", "dark", "light", "windows95", "ascii"}, ) self.assertEqual(merged.default_theme, "custom") self.assertTrue(merged.theme_by_key("custom").default) self.assertEqual(merged.theme_by_key("light").css_file, "style.css") self.assertTrue(merged.theme_by_key("custom").use_in_admin) self.assertFalse(merged.theme_by_key("custom").use_primary_accent) def test_default_theme_descriptors_are_read_from_source_files(self): descriptors = default_webapp_theme_descriptors() self.assertEqual(set(descriptors), {"dark", "light", "windows95", "ascii"}) self.assertTrue(descriptors["dark"]["default"]) self.assertEqual(descriptors["windows95"]["css_file"], "style.css") self.assertEqual(descriptors["ascii"]["css_file"], "style.css") def test_resolved_creates_default_files_when_missing(self): with tempfile.TemporaryDirectory() as tmp: themes_dir = Path(tmp) / "themes" cfg = resolved_webapp_themes_catalog( theme_dir=themes_dir, primary_accent="#abc123", env_default_theme=None, ) self.assertTrue((themes_dir / "dark" / "theme.json").exists()) self.assertTrue((themes_dir / "light" / "theme.json").exists()) self.assertTrue((themes_dir / "light" / "style.css").exists()) self.assertTrue((themes_dir / "windows95" / "theme.json").exists()) self.assertTrue((themes_dir / "windows95" / "style.css").exists()) self.assertTrue((themes_dir / "windows95" / "icons" / "save.png").exists()) self.assertTrue((themes_dir / "ascii" / "theme.json").exists()) self.assertTrue((themes_dir / "ascii" / "style.css").exists()) self.assertEqual(cfg.default_theme, "dark") self.assertIsNone(cfg.theme_by_key("dark").tokens.accent) def test_load_theme_dir_uses_filename_as_key_when_missing(self): with tempfile.TemporaryDirectory() as tmp: themes_dir = Path(tmp) / "themes" themes_dir.mkdir() (themes_dir / "custom").mkdir() (themes_dir / "custom" / "theme.json").write_text( json.dumps( { "names": {"en": "Custom"}, "enabled": True, "css_file": "style.css", "tokens": {"color_scheme": "light"}, } ), encoding="utf-8", ) themes = load_webapp_theme_dir(themes_dir) self.assertEqual(len(themes), 1) self.assertEqual(themes[0].key, "custom") self.assertEqual(themes[0].names["en"], "Custom") def test_resolved_catalog_includes_custom_mounted_theme_descriptor(self): with tempfile.TemporaryDirectory() as tmp: themes_dir = Path(tmp) / "themes" themes_dir.mkdir() (themes_dir / "neon").mkdir() (themes_dir / "neon" / "theme.json").write_text( json.dumps( { "names": {"en": "Neon"}, "enabled": True, "default": True, "css_file": "style.css", "tokens": {"color_scheme": "dark"}, } ), encoding="utf-8", ) cfg = resolved_webapp_themes_catalog( theme_dir=themes_dir, primary_accent="#00fe7a", env_default_theme=None, ) self.assertEqual(cfg.default_theme, "neon") self.assertIsNotNone(cfg.theme_by_key("neon")) self.assertEqual( {theme.key for theme in cfg.themes}, {"dark", "light", "windows95", "ascii", "neon"}, ) def test_env_default_overrides_descriptor_default(self): with tempfile.TemporaryDirectory() as tmp: themes_dir = Path(tmp) / "themes" themes_dir.mkdir() (themes_dir / "neon").mkdir() (themes_dir / "neon" / "theme.json").write_text( json.dumps( { "names": {"en": "Neon"}, "enabled": True, "default": True, "tokens": {"color_scheme": "dark"}, } ), encoding="utf-8", ) cfg = resolved_webapp_themes_catalog( theme_dir=themes_dir, primary_accent="#00fe7a", env_default_theme="windows95", ) self.assertEqual(cfg.default_theme, "windows95") self.assertTrue(cfg.theme_by_key("windows95").default) self.assertFalse(cfg.theme_by_key("neon").default) def test_custom_descriptor_default_wins_over_seeded_core_default(self): with tempfile.TemporaryDirectory() as tmp: themes_dir = Path(tmp) / "themes" themes_dir.mkdir() (themes_dir / "dark").mkdir() (themes_dir / "dark" / "theme.json").write_text( json.dumps( { "key": "dark", "names": {"en": "Dark"}, "enabled": True, "default": True, "tokens": {"color_scheme": "dark"}, } ), encoding="utf-8", ) (themes_dir / "neon").mkdir() (themes_dir / "neon" / "theme.json").write_text( json.dumps( { "names": {"en": "Neon"}, "enabled": True, "default": True, "tokens": {"color_scheme": "dark"}, } ), encoding="utf-8", ) cfg = resolved_webapp_themes_catalog( theme_dir=themes_dir, primary_accent="#00fe7a", env_default_theme=None, ) self.assertEqual(cfg.default_theme, "neon") self.assertTrue(cfg.theme_by_key("neon").default) self.assertFalse(cfg.theme_by_key("dark").default) def test_theme_dir_writer_writes_descriptors_with_single_default(self): with tempfile.TemporaryDirectory() as tmp: cfg = builtin_webapp_themes_config("#00fe7a") cfg = WebappThemesConfig(default_theme="windows95", themes=cfg.themes) themes_dir = Path(tmp) / "themes" write_webapp_theme_dir(themes_dir, cfg) dark = json.loads((themes_dir / "dark" / "theme.json").read_text(encoding="utf-8")) win95 = json.loads( (themes_dir / "windows95" / "theme.json").read_text(encoding="utf-8") ) self.assertFalse(dark["default"]) self.assertTrue(win95["default"]) resolved = resolved_webapp_themes_catalog( theme_dir=themes_dir, primary_accent="#00fe7a", env_default_theme=None, ) self.assertEqual(resolved.default_theme, "windows95") def test_resolved_falls_back_to_dark_when_saved_theme_is_missing(self): with tempfile.TemporaryDirectory() as tmp: themes_dir = Path(tmp) / "themes" cfg = builtin_webapp_themes_config("#00fe7a") cfg = WebappThemesConfig(default_theme="windows95", themes=cfg.themes) write_webapp_theme_dir(themes_dir, cfg) windows_theme = themes_dir / "windows95" / "theme.json" windows_theme.unlink() resolved = resolved_webapp_themes_catalog( theme_dir=themes_dir, primary_accent="#00fe7a", env_default_theme=None, ) self.assertEqual(resolved.default_theme, "dark") self.assertTrue(resolved.theme_by_key("dark").default) def test_public_payload_injects_primary_accent_when_enabled(self): cfg = builtin_webapp_themes_config("#abc123") payload = public_themes_catalog_payload(cfg, "#abc123") light = next(theme for theme in payload["themes"] if theme["key"] == "light") dark = next(theme for theme in payload["themes"] if theme["key"] == "dark") win95 = next(theme for theme in payload["themes"] if theme["key"] == "windows95") self.assertEqual(light["css_file"], "style.css") self.assertEqual(light["assets_version"], cfg.theme_by_key("light").assets_version) self.assertEqual(light["tokens"]["accent"], "#abc123") self.assertEqual(dark["tokens"]["accent"], "#abc123") self.assertFalse(win95["use_primary_accent"]) self.assertTrue(win95["use_in_admin"]) self.assertNotIn("accent", win95["tokens"]) def test_theme_accent_is_normalized_to_hex(self): cfg = WebappThemesConfig( default_theme="custom", themes=[ { "key": "custom", "enabled": True, "default": True, "tokens": {"color_scheme": "dark", "accent": "0F8"}, } ], ) self.assertEqual(cfg.theme_by_key("custom").tokens.accent, "#00ff88") def test_theme_home_logo_scale_is_public_token(self): cfg = WebappThemesConfig( default_theme="custom", themes=[ { "key": "custom", "enabled": True, "default": True, "tokens": {"color_scheme": "dark", "home_logo_scale": 135}, } ], ) payload = public_themes_catalog_payload(cfg, "#abc123") custom = payload["themes"][0] self.assertEqual(cfg.theme_by_key("custom").tokens.home_logo_scale, 135) self.assertEqual(custom["tokens"]["home_logo_scale"], 135) def test_theme_accent_rejects_non_hex_values(self): with self.assertRaises(ValueError): WebappThemesConfig( default_theme="custom", themes=[ { "key": "custom", "enabled": True, "default": True, "tokens": {"color_scheme": "dark", "accent": "lime"}, } ], ) def test_public_payload_keeps_admin_usage_flag(self): cfg = WebappThemesConfig( default_theme="custom", themes=[ { "key": "custom", "names": {"en": "Custom"}, "enabled": True, "default": True, "use_in_admin": False, "tokens": {"color_scheme": "dark"}, } ], ) payload = public_themes_catalog_payload(cfg, "#abc123") self.assertFalse(payload["themes"][0]["use_in_admin"]) def test_resolved_refreshes_stale_builtin_windows95_assets(self): with tempfile.TemporaryDirectory() as tmp: themes_dir = Path(tmp) / "themes" stale_theme_dir = themes_dir / "windows95" stale_theme_dir.mkdir(parents=True) (stale_theme_dir / "theme.json").write_text( json.dumps( { "key": "windows95", "names": {"en": "Windows 95"}, "enabled": True, "default": False, "use_primary_accent": True, "css_file": "style.css", "assets_version": 1, "tokens": {"color_scheme": "light", "style_preset": "win95"}, } ), encoding="utf-8", ) (stale_theme_dir / "style.css").write_text("/* stale */", encoding="utf-8") cfg = resolved_webapp_themes_catalog( theme_dir=themes_dir, primary_accent="#00fe7a", env_default_theme=None, ) descriptor = json.loads((stale_theme_dir / "theme.json").read_text(encoding="utf-8")) css = (stale_theme_dir / "style.css").read_text(encoding="utf-8") self.assertEqual( descriptor["assets_version"], cfg.theme_by_key("windows95").assets_version, ) self.assertEqual(descriptor["assets_version"], 12) self.assertIn("lucide-house", css) self.assertIn("lucide-earth", css) self.assertIn("lucide-circle-check", css) self.assertIn("lucide-circle-check-big", css) self.assertIn("filter: none !important", css) self.assertIn("Press Start 2P", css) self.assertIn("::-webkit-slider-thumb", css) self.assertIn("?v=9", css) self.assertIn("lucide-life-buoy", css) self.assertIn("lucide-qr-code", css) self.assertIn("New webapp surfaces: support, purchase info, password login", css) self.assertIn("Install guide theme surfaces", css) self.assertIn( ( ".theme-key-windows95 .support-list-card {\n" " grid-template-rows: auto auto minmax(0, 1fr);" ), css, ) self.assertIn(".theme-key-windows95 .traffic-top strong", css) self.assertTrue((stale_theme_dir / "icons" / "dashboard.png").exists()) def test_resolved_refreshes_stale_builtin_light_assets(self): with tempfile.TemporaryDirectory() as tmp: themes_dir = Path(tmp) / "themes" stale_theme_dir = themes_dir / "light" stale_theme_dir.mkdir(parents=True) (stale_theme_dir / "theme.json").write_text( json.dumps( { "key": "light", "names": {"en": "Light"}, "enabled": True, "default": False, "use_primary_accent": True, "css_file": "style.css", "assets_version": 1, "tokens": {"color_scheme": "light"}, } ), encoding="utf-8", ) (stale_theme_dir / "style.css").write_text("/* stale */", encoding="utf-8") resolved_webapp_themes_catalog( theme_dir=themes_dir, primary_accent="#00fe7a", env_default_theme=None, ) css = (stale_theme_dir / "style.css").read_text(encoding="utf-8") self.assertIn(".theme-key-light.app-shell", css) self.assertIn("Install guide theme surfaces", css) def test_resolved_refreshes_stale_builtin_ascii_assets(self): with tempfile.TemporaryDirectory() as tmp: themes_dir = Path(tmp) / "themes" stale_theme_dir = themes_dir / "ascii" stale_theme_dir.mkdir(parents=True) (stale_theme_dir / "theme.json").write_text( json.dumps( { "key": "ascii", "names": {"en": "ASCII"}, "enabled": True, "default": False, "use_primary_accent": False, "css_file": "style.css", "assets_version": 1, "tokens": {"color_scheme": "dark", "style_preset": "ascii"}, } ), encoding="utf-8", ) (stale_theme_dir / "style.css").write_text("/* stale */", encoding="utf-8") cfg = resolved_webapp_themes_catalog( theme_dir=themes_dir, primary_accent="#00fe7a", env_default_theme=None, ) descriptor = json.loads((stale_theme_dir / "theme.json").read_text(encoding="utf-8")) css = (stale_theme_dir / "style.css").read_text(encoding="utf-8") self.assertEqual(descriptor["assets_version"], cfg.theme_by_key("ascii").assets_version) self.assertEqual(descriptor["assets_version"], 5) self.assertIn("Console-style tables", css) self.assertIn("Install guide theme surfaces", css)