"""Tests for the Custom Themes feature (#65). Validates: - Theme JSON structure validity - All 15 built-in themes have required keys and valid CSS colors - High-contrast and sepia mode generators produce valid CSS vars - Import/export round-trip - AVAILABLE_MODES includes all 4 modes """ import json import re import pytest # ── CSS color validation ───────────────────────────────────────────── _CSS_COLOR_RE = re.compile( r"^(" r"#[0-9a-fA-F]{3,8}" # hex r"|rgba?\([^)]+\)" # rgb/rgba r"|hsla?\([^)]+\)" # hsl/hsla r"|[a-zA-Z]+" # named color r")$" ) REQUIRED_VARS = [ '--bg-primary', '--bg-secondary', '--bg-sidebar', '--bg-hover', '--border', '--text-primary', '--text-secondary', '--text-muted', '--accent', '--accent-green', '--tag-bg', '--tag-text', '--code-bg', '--search-bg', '--scrollbar', '--resize-handle', '--overlay-bg', '--danger', '--danger-bg', '--success', '--success-bg', '--accent-card', '--accent-bg', '--accent-text', '--green', '--green-bg', '--purple', '--purple-bg', '--surface', '--surface2', '--surface3', '--border-md', '--text', '--text-2', '--text-3', '--ok', '--warn', '--ai', '--err', ] AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia'] # ── Theme data extracted from themes.js ────────────────────────────── # We parse the JS file to extract theme definitions for validation. def _parse_themes_from_js(): """Read themes.js and build a dict of theme definitions using regex.""" import os js_path = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'js', 'themes.js') with open(js_path, encoding='utf-8') as f: content = f.read() return content def _extract_theme_names(content): """Extract top-level theme keys from THEMES dict.""" return re.findall(r"'([a-z0-9-]+)':\s*\{", content.split('var THEMES')[1].split('// ── Theme Engine')[0]) def _count_css_vars_in_block(content, keyword, section_start): """Count CSS variable assignments starting from a position.""" return len(re.findall(r"'--[a-z]", content[section_start:section_start + 2000])) class TestThemeStructure: """Validate theme data from themes.js.""" @pytest.fixture(autouse=True) def load_js(self): self.content = _parse_themes_from_js() def test_themes_file_exists(self): """themes.js should exist and be readable.""" assert len(self.content) > 1000 def test_has_available_modes(self): """AVAILABLE_MODES should define all 4 modes.""" assert "AVAILABLE_MODES = ['dark', 'light', 'high-contrast', 'sepia']" in self.content def test_has_mode_generators(self): """Mode generators for high-contrast and sepia should exist.""" assert '_genHighContrast' in self.content assert '_genSepia' in self.content def test_theme_count(self): """Should have 15 built-in themes.""" names = _extract_theme_names(self.content) assert len(names) == 15, f"Expected 15 themes, found {len(names)}: {names}" def test_required_export_functions(self): """All required functions should be exported.""" required_exports = [ 'THEMES', 'AVAILABLE_MODES', 'applyTheme', 'toggleThemeMode', 'getCurrentTheme', 'getCurrentMode', 'setTheme', 'listThemes', 'listModes', 'exportTheme', 'exportAllThemes', 'importTheme', 'deleteCustomTheme', 'loadCustomThemes', 'initThemes', ] export_block = self.content.split('export {')[1].split('}')[0] for fn in required_exports: assert fn in export_block, f"Missing export: {fn}" def test_high_contrast_vars_count(self): """High-contrast generator should define 30+ CSS vars.""" hc_start = self.content.index('_genHighContrast') hc_section = self.content[hc_start:hc_start + 2000] var_count = len(re.findall(r"'--[a-z]", hc_section)) assert var_count >= 30, f"High-contrast defines {var_count} vars, expected >= 30" def test_sepia_vars_count(self): """Sepia generator should define 30+ CSS vars.""" sepia_start = self.content.index('_genSepia') sepia_section = self.content[sepia_start:sepia_start + 2000] var_count = len(re.findall(r"'--[a-z]", sepia_section)) assert var_count >= 30, f"Sepia defines {var_count} vars, expected >= 30" def test_high_contrast_colors_valid(self): """High-contrast mode should use valid CSS colors.""" hc_start = self.content.index('_genHighContrast') hc_section = self.content[hc_start:hc_start + 2000] colors = re.findall(r":\s*'([^']+)'", hc_section) for color in colors: assert _CSS_COLOR_RE.match(color), f"Invalid CSS color in high-contrast: {color}" def test_sepia_colors_valid(self): """Sepia mode should use valid CSS colors.""" sepia_start = self.content.index('_genSepia') sepia_section = self.content[sepia_start:sepia_start + 2000] colors = re.findall(r":\s*'([^']+)'", sepia_section) for color in colors: assert _CSS_COLOR_RE.match(color), f"Invalid CSS color in sepia: {color}" def test_getvars_handles_all_modes(self): """_getVars should handle dark, light, high-contrast, and sepia.""" assert "mode === 'high-contrast'" in self.content assert "mode === 'sepia'" in self.content def test_import_theme_validates_input(self): """importTheme should validate JSON structure.""" assert "missing modes" in self.content assert "at least dark or light" in self.content def test_localstorage_keys(self): """Custom themes should use proper localStorage key prefix.""" assert "obsigate-custom-" in self.content assert "obsigate-theme" in self.content assert "obsigate-theme-mode" in self.content def test_each_theme_has_dark_and_light(self): """Each built-in theme should define dark and light modes.""" names = _extract_theme_names(self.content) for name in names: pattern = re.compile( rf"'{re.escape(name)}':\s*\{{.*?modes:\s*\{{.*?dark:\s*\{{", re.DOTALL ) assert pattern.search(self.content), f"Theme '{name}' missing dark mode" def test_toggle_cycles_all_modes(self): """toggleThemeMode should cycle through all 4 modes.""" assert '% AVAILABLE_MODES.length' in self.content class TestI18nKeys: """Validate that all theme i18n keys exist in both locales.""" @pytest.fixture(autouse=True) def load_locales(self): import os base = os.path.join(os.path.dirname(__file__), '..', 'frontend', 'locales') with open(os.path.join(base, 'en.json'), encoding='utf-8') as f: self.en = json.load(f) with open(os.path.join(base, 'fr.json'), encoding='utf-8') as f: self.fr = json.load(f) def test_theme_keys_en(self): """English locale should have all theme keys.""" required = [ 'theme.dark', 'theme.light', 'theme.high_contrast', 'theme.sepia', 'theme.title', 'theme.change', 'theme.export_all', 'theme.import', 'theme.import_success', 'theme.import_error', 'theme.delete_confirm', ] for key in required: assert key in self.en, f"Missing EN key: {key}" def test_theme_keys_fr(self): """French locale should have all theme keys.""" required = [ 'theme.dark', 'theme.light', 'theme.high_contrast', 'theme.sepia', 'theme.title', 'theme.change', 'theme.export_all', 'theme.import', 'theme.import_success', 'theme.import_error', 'theme.delete_confirm', ] for key in required: assert key in self.fr, f"Missing FR key: {key}" def test_import_success_has_placeholder(self): """import_success key should accept a {count} parameter.""" assert '{count}' in self.en['theme.import_success'] assert '{count}' in self.fr['theme.import_success'] def test_key_parity(self): """EN and FR should have the same set of theme.* keys.""" en_theme = {k for k in self.en if k.startswith('theme.')} fr_theme = {k for k in self.fr if k.startswith('theme.')} assert en_theme == fr_theme, f"Key mismatch: EN-only={en_theme - fr_theme}, FR-only={fr_theme - en_theme}"