""" Tests for the multi-theme system. These tests ensure: 1. All themes define all required CSS variables 2. No hardcoded colors exist in critical template/CSS/JS files 3. Theme switching works correctly 4. RGB variants are defined for rgba() usage """ import re from pathlib import Path import pytest # Base paths WEB_DIR = ( Path(__file__).parent.parent.parent / "src" / "local_deep_research" / "web" ) STATIC_DIR = WEB_DIR / "static" TEMPLATES_DIR = WEB_DIR / "templates" CSS_DIR = STATIC_DIR / "css" JS_DIR = STATIC_DIR / "js" # Required CSS variables that every theme must define REQUIRED_VARIABLES = [ # Background colors "--bg-primary", "--bg-secondary", "--bg-tertiary", # Accent colors "--accent-primary", "--accent-secondary", "--accent-tertiary", # Text colors "--text-primary", "--text-secondary", "--text-muted", # Border "--border-color", # Status colors "--success-color", "--warning-color", "--error-color", ] # RGB variants required for rgba() usage REQUIRED_RGB_VARIANTS = [ "--bg-primary-rgb", "--bg-secondary-rgb", "--bg-tertiary-rgb", "--accent-primary-rgb", "--accent-secondary-rgb", "--accent-tertiary-rgb", "--text-primary-rgb", "--text-secondary-rgb", "--text-muted-rgb", "--border-color-rgb", "--success-color-rgb", "--warning-color-rgb", "--error-color-rgb", ] # Auto-detect themes from theme registry (single source of truth) def _get_themes_from_registry() -> list[str]: """Get theme names from the theme registry.""" try: from local_deep_research.web.themes import theme_registry # Exclude 'system' virtual theme for CSS tests return sorted( [t for t in theme_registry.get_theme_ids() if t != "system"] ) except ImportError: # Fallback to parsing CSS if registry not available themes = set() if CSS_DIR.exists(): themes_css = CSS_DIR / "themes.css" if themes_css.exists(): content = themes_css.read_text() pattern = r'\[data-theme="([^"]+)"\]\s*\{' matches = re.findall(pattern, content) themes = set(matches) return sorted(themes) def _generate_combined_css() -> str: """Generate combined CSS from theme registry for testing.""" try: from local_deep_research.web.themes import theme_registry return theme_registry.get_combined_css() except ImportError: # Fallback to reading existing themes.css themes_css = CSS_DIR / "themes.css" if themes_css.exists(): return themes_css.read_text() return "" # All themes are auto-detected from theme registry ALL_THEMES = _get_themes_from_registry() # Hex color pattern (matches #fff, #ffffff, #FFFFFF) HEX_COLOR_PATTERN = re.compile(r"#[0-9a-fA-F]{3,8}\b") # RGB/RGBA color pattern (matches rgb(x,x,x) and rgba(x,x,x,x)) RGBA_LITERAL_PATTERN = re.compile( r"rgba?\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*(?:,\s*[\d.]+\s*)?\)" ) # Files that are allowed to have some hardcoded colors (brand colors, intentional) ALLOWED_HARDCODED_FILES = [ "pdf-viewer.css", # PDF viewer needs light background for readability "details.js", # Chart colors may need to be fixed for data visualization ] # Brand colors that are intentionally hardcoded (provider logos, etc.) BRAND_COLOR_EXCEPTIONS = [ "#10a37f", # OpenAI green "#d97706", # Anthropic orange "#4285f4", # Google blue "#000000", # Pure black (often intentional) "#ffffff", # Pure white (often intentional) "#fff", # Pure white shorthand "#000", # Pure black shorthand ] class TestThemeDefinitions: """Test that all themes are properly defined.""" @pytest.fixture def themes_css_content(self): """Load or generate themes.css content.""" # Use the generated combined CSS from theme registry content = _generate_combined_css() assert content, "Failed to load or generate themes CSS" return content def test_theme_registry_loads(self): """Verify theme registry loads themes correctly.""" try: from local_deep_research.web.themes import theme_registry themes = theme_registry.themes assert len(themes) > 0, "No themes loaded from registry" assert "hashed" in themes, "Default 'hashed' theme not found" assert "system" in themes, "Virtual 'system' theme not found" except ImportError: pytest.skip("Theme registry not available") def test_themes_css_can_be_generated(self): """Verify combined CSS can be generated.""" content = _generate_combined_css() assert content, "Failed to generate combined CSS" assert "[data-theme=" in content, ( "Generated CSS has no theme definitions" ) def test_all_themes_defined(self, themes_css_content): """Check that all themes are defined in the combined CSS.""" for theme in ALL_THEMES: if theme == "hashed": # Hashed theme is defined both in :root and as [data-theme="hashed"] assert ( ":root" in themes_css_content or '[data-theme="hashed"]' in themes_css_content ), "Default hashed theme not found" else: theme_selector = f'[data-theme="{theme}"]' assert theme_selector in themes_css_content, ( f"Theme '{theme}' not defined in combined CSS" ) def test_required_variables_in_root(self, themes_css_content): """Check that :root defines all required variables.""" # Extract :root block root_match = re.search( r":root[^{]*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}", themes_css_content ) assert root_match, ":root block not found in themes.css" root_content = root_match.group(1) for var in REQUIRED_VARIABLES: assert var in root_content, ( f"Required variable '{var}' not defined in :root" ) def test_rgb_variants_in_root(self, themes_css_content): """Check that :root defines RGB variants for rgba() usage.""" root_match = re.search( r":root[^{]*\{([^}]+(?:\{[^}]*\}[^}]*)*)\}", themes_css_content ) assert root_match, ":root block not found" root_content = root_match.group(1) for var in REQUIRED_RGB_VARIANTS: assert var in root_content, ( f"RGB variant '{var}' not defined in :root" ) @pytest.mark.parametrize("theme", ALL_THEMES) def test_theme_has_required_variables(self, themes_css_content, theme): """Check each theme defines all required variables.""" theme_pattern = ( rf'\[data-theme="{theme}"\]\s*\{{([^}}]+(?:\{{[^}}]*\}}[^}}]*)*)\}}' ) theme_match = re.search(theme_pattern, themes_css_content) assert theme_match, f"Theme '{theme}' block not found" theme_content = theme_match.group(1) for var in REQUIRED_VARIABLES: assert var in theme_content, ( f"Theme '{theme}' missing required variable '{var}'" ) @pytest.mark.parametrize("theme", ALL_THEMES) def test_theme_has_rgb_variants(self, themes_css_content, theme): """Check each theme defines RGB variants.""" theme_pattern = ( rf'\[data-theme="{theme}"\]\s*\{{([^}}]+(?:\{{[^}}]*\}}[^}}]*)*)\}}' ) theme_match = re.search(theme_pattern, themes_css_content) assert theme_match, f"Theme '{theme}' block not found" theme_content = theme_match.group(1) for var in REQUIRED_RGB_VARIANTS: assert var in theme_content, ( f"Theme '{theme}' missing RGB variant '{var}'" ) class TestHardcodedColors: """Test for hardcoded colors that should use theme variables.""" def _get_files(self, directory: Path, pattern: str) -> list[Path]: """Get files matching pattern in directory.""" if not directory.exists(): return [] return list(directory.rglob(pattern)) def _extract_hardcoded_colors( self, content: str, file_path: Path ) -> list[tuple[str, int, str]]: """Extract hardcoded colors from content. Returns list of (color, line_number, line_content) tuples. """ colors = [] lines = content.split("\n") for i, line in enumerate(lines, 1): # Skip comments if line.strip().startswith("//") or line.strip().startswith("/*"): continue # Skip lines that use CSS variables correctly if "var(--" in line: continue # Find hex colors for match in HEX_COLOR_PATTERN.finditer(line): color = match.group().lower() if color not in [c.lower() for c in BRAND_COLOR_EXCEPTIONS]: colors.append((color, i, line.strip())) return colors def _extract_literal_rgba( self, content: str, file_path: Path ) -> list[tuple[str, int, str]]: """Extract literal rgba() colors that should use CSS variables. Returns list of (color, line_number, line_content) tuples. """ colors = [] lines = content.split("\n") for i, line in enumerate(lines, 1): # Skip if already using CSS variable pattern: rgba(var(--xxx-rgb), 0.5) if "rgba(var(--" in line: continue # Skip comments if line.strip().startswith("//") or line.strip().startswith("/*"): continue # Find literal rgba() calls for match in RGBA_LITERAL_PATTERN.finditer(line): color = match.group() # Allow black/white rgba as they're often intentional if not any( x in color for x in [ "0, 0, 0", "255, 255, 255", "0,0,0", "255,255,255", ] ): colors.append((color, i, line.strip())) return colors def test_templates_no_hardcoded_hex_colors(self): """Check HTML templates don't have hardcoded hex colors in style attributes.""" templates = self._get_files(TEMPLATES_DIR, "*.html") issues = [] for template in templates: if any( allowed in template.name for allowed in ALLOWED_HARDCODED_FILES ): continue content = template.read_text() # Focus on style attributes and inline styles style_pattern = re.compile( r'style\s*=\s*["\'][^"\']*#[0-9a-fA-F]{3,8}[^"\']*["\']' ) for i, line in enumerate(content.split("\n"), 1): if style_pattern.search(line): # Extract the actual color colors = HEX_COLOR_PATTERN.findall(line) for color in colors: if color.lower() not in [ c.lower() for c in BRAND_COLOR_EXCEPTIONS ]: issues.append( f"{template.name}:{i} - hardcoded color {color}" ) if issues: issue_sample = issues[:10] pytest.fail( f"Found {len(issues)} hardcoded colors in templates:\n" + "\n".join(issue_sample) + ("\n..." if len(issues) > 10 else "") ) def test_css_files_no_literal_rgba(self): """Check CSS files don't have literal rgba() that should use variables.""" css_files = self._get_files(CSS_DIR, "*.css") issues = [] for css_file in css_files: # Skip themes.css (it defines the colors) if css_file.name == "themes.css": continue if any( allowed in css_file.name for allowed in ALLOWED_HARDCODED_FILES ): continue content = css_file.read_text() colors = self._extract_literal_rgba(content, css_file) for color, line_num, line in colors: issues.append(f"{css_file.name}:{line_num} - {color}") if issues: issue_sample = issues[:15] pytest.fail( f"Found {len(issues)} literal rgba() colors that should use CSS variables:\n" + "\n".join(issue_sample) + ("\n..." if len(issues) > 15 else "") ) def test_js_files_no_inline_style_colors(self): """Check JS files don't set inline style colors directly.""" js_files = self._get_files(JS_DIR, "*.js") issues = [] # Pattern to find style.color = '#xxx' or style.backgroundColor = '#xxx' js_color_pattern = re.compile( r'\.style\.\w+\s*=\s*["\']#[0-9a-fA-F]{3,8}["\']' ) for js_file in js_files: if any( allowed in js_file.name for allowed in ALLOWED_HARDCODED_FILES ): continue content = js_file.read_text() for i, line in enumerate(content.split("\n"), 1): if js_color_pattern.search(line): issues.append(f"{js_file.name}:{i} - {line.strip()[:80]}") if issues: issue_sample = issues[:10] pytest.fail( f"Found {len(issues)} inline style color assignments in JS:\n" + "\n".join(issue_sample) + ("\n..." if len(issues) > 10 else "") ) class TestThemeVariableUsage: """Test that theme variables are used correctly throughout the codebase.""" def test_styles_css_uses_variables(self): """Verify main styles.css uses theme variables.""" styles_css = CSS_DIR / "styles.css" assert styles_css.exists(), "styles.css not found" content = styles_css.read_text() # Should use theme variables assert "var(--bg-primary)" in content, ( "styles.css should use --bg-primary" ) assert "var(--text-primary)" in content, ( "styles.css should use --text-primary" ) assert "var(--accent-primary)" in content, ( "styles.css should use --accent-primary" ) def test_base_html_has_theme_attribute(self): """Verify base.html supports data-theme attribute.""" base_html = TEMPLATES_DIR / "base.html" assert base_html.exists(), "base.html not found" content = base_html.read_text() # Should have data-theme or theme initialization assert "data-theme" in content or "theme" in content.lower(), ( "base.html should support theme attribute" ) def test_theme_toggle_exists(self): """Verify theme toggle functionality exists.""" # Check for theme-related JavaScript js_files = list(JS_DIR.rglob("*.js")) theme_js_found = False for js_file in js_files: content = js_file.read_text() if ( "setTheme" in content or "data-theme" in content or "theme" in content.lower() ): theme_js_found = True break assert theme_js_found, ( "No theme toggle functionality found in JavaScript" ) class TestDarkLightThemes: """Test that dark and light themes have appropriate brightness levels.""" # Light themes are identified by having "light" in name or specific names LIGHT_THEME_PATTERNS = ["light", "dawn", "sepia"] # Dynamically determine light/dark themes from ALL_THEMES @classmethod def _is_light_theme(cls, theme: str) -> bool: """Check if a theme is a light theme based on name patterns.""" theme_lower = theme.lower() return any( pattern in theme_lower for pattern in cls.LIGHT_THEME_PATTERNS ) # Auto-detect dark and light themes DARK_THEMES = [ t for t in ALL_THEMES if not any(p in t.lower() for p in ["light", "dawn", "sepia"]) ] LIGHT_THEMES = [ t for t in ALL_THEMES if any(p in t.lower() for p in ["light", "dawn", "sepia"]) ] @pytest.fixture def themes_css_content(self): """Combined theme CSS from the theme registry (single source of truth). Must NOT read ``static/css/themes.css`` directly: that file is auto-generated and gitignored (PR #3400), so a fresh checkout / CI run does not have it — which made these tests error with FileNotFoundError (or fail extraction against a separately-built copy). Generating from the registry is what the app serves and what the TestThemeDefinitions fixture already does. """ return _generate_combined_css() def _get_luminance(self, hex_color: str) -> float: """Calculate relative luminance of a color (0-1 scale).""" r = int(hex_color[1:3], 16) / 255 g = int(hex_color[3:5], 16) / 255 b = int(hex_color[5:7], 16) / 255 return 0.299 * r + 0.587 * g + 0.114 * b def _extract_theme_bg_primary( self, themes_css_content: str, theme: str ) -> str | None: """Extract --bg-primary value for a theme.""" if theme == "dark": pattern = r":root[^{]*\{[^}]*--bg-primary:\s*(#[0-9a-fA-F]{6})" else: pattern = rf'\[data-theme="{theme}"\][^{{]*\{{[^}}]*--bg-primary:\s*(#[0-9a-fA-F]{{6}})' match = re.search(pattern, themes_css_content, re.DOTALL) return match.group(1) if match else None @pytest.mark.parametrize("theme", DARK_THEMES) def test_dark_theme_has_dark_background(self, themes_css_content, theme): """Dark themes should have low luminance backgrounds.""" bg_color = self._extract_theme_bg_primary(themes_css_content, theme) assert bg_color, f"Could not extract --bg-primary for theme '{theme}'" luminance = self._get_luminance(bg_color) assert luminance < 0.35, ( f"Dark theme '{theme}' has too bright background {bg_color} " f"(luminance: {luminance:.2f}, expected < 0.35)" ) @pytest.mark.parametrize("theme", LIGHT_THEMES) def test_light_theme_has_light_background(self, themes_css_content, theme): """Light themes should have high luminance backgrounds.""" bg_color = self._extract_theme_bg_primary(themes_css_content, theme) assert bg_color, f"Could not extract --bg-primary for theme '{theme}'" luminance = self._get_luminance(bg_color) assert luminance > 0.5, ( f"Light theme '{theme}' has too dark background {bg_color} " f"(luminance: {luminance:.2f}, expected > 0.5)" ) class TestTextContrast: """Test that text colors have sufficient contrast against backgrounds.""" @pytest.fixture def themes_css_content(self): """Combined theme CSS from the theme registry (single source of truth). Must NOT read ``static/css/themes.css`` directly: that file is auto-generated and gitignored (PR #3400), so a fresh checkout / CI run does not have it — which made these tests error with FileNotFoundError (or fail extraction against a separately-built copy). Generating from the registry is what the app serves and what the TestThemeDefinitions fixture already does. """ return _generate_combined_css() def _get_luminance(self, hex_color: str) -> float: """Calculate relative luminance.""" r = int(hex_color[1:3], 16) / 255 g = int(hex_color[3:5], 16) / 255 b = int(hex_color[5:7], 16) / 255 return 0.299 * r + 0.587 * g + 0.114 * b def _contrast_ratio(self, l1: float, l2: float) -> float: """Calculate contrast ratio between two luminance values.""" lighter = max(l1, l2) darker = min(l1, l2) return (lighter + 0.05) / (darker + 0.05) def test_root_text_contrast(self, themes_css_content): """Test that :root has adequate text-to-background contrast.""" # Extract colors from :root bg_match = re.search( r":root[^{]*\{[^}]*--bg-primary:\s*(#[0-9a-fA-F]{6})", themes_css_content, re.DOTALL, ) text_match = re.search( r":root[^{]*\{[^}]*--text-primary:\s*(#[0-9a-fA-F]{6})", themes_css_content, re.DOTALL, ) assert bg_match and text_match, ( "Could not find bg-primary or text-primary in :root" ) bg_lum = self._get_luminance(bg_match.group(1)) text_lum = self._get_luminance(text_match.group(1)) ratio = self._contrast_ratio(bg_lum, text_lum) # WCAG AA requires 4.5:1 for normal text assert ratio >= 4.5, ( f"Insufficient contrast ratio in :root: {ratio:.2f} " f"(bg: {bg_match.group(1)}, text: {text_match.group(1)})" ) class TestComponentCoverage: """Test that key CSS files properly use theme variables.""" CSS_FILES_REQUIRING_THEME_VARS = [ "styles.css", "collections.css", "settings.css", ] REQUIRED_VAR_USAGE = [ "var(--bg-", "var(--text-", "var(--accent-", "var(--border-color)", ] @pytest.mark.parametrize("css_file", CSS_FILES_REQUIRING_THEME_VARS) def test_css_file_uses_theme_vars(self, css_file): """Check that critical CSS files use theme variables.""" file_path = CSS_DIR / css_file if not file_path.exists(): pytest.skip(f"{css_file} not found") content = file_path.read_text() missing_vars = [] for var_pattern in self.REQUIRED_VAR_USAGE: if var_pattern not in content: missing_vars.append(var_pattern) assert not missing_vars, ( f"{css_file} is missing theme variable usage: {missing_vars}" ) class TestSemanticColorUsage: """Test that semantic colors (success, warning, error) are used correctly.""" @pytest.fixture def all_css_content(self): """Load all CSS files content.""" content = {} for css_file in CSS_DIR.rglob("*.css"): content[css_file.name] = css_file.read_text() return content def test_success_uses_success_color(self, all_css_content): """Classes with 'success' should use --success-color.""" issues = [] for filename, content in all_css_content.items(): if filename == "themes.css": continue # Find .success or -success classes success_classes = re.findall( r"\.([\w-]*success[\w-]*)\s*\{([^}]+)\}", content ) for class_name, class_content in success_classes: if ( "color:" in class_content and "var(--success-color)" not in class_content ): # Check if it's using success-color-rgb variant if "var(--success-color-rgb)" not in class_content: issues.append(f"{filename}: .{class_name}") # Allow a few exceptions but flag if too many if len(issues) > 3: pytest.fail( f"Success classes not using --success-color: {issues[:5]}" ) def test_error_uses_error_color(self, all_css_content): """Classes with 'error' or 'danger' should use --error-color.""" issues = [] for filename, content in all_css_content.items(): if filename == "themes.css": continue # Find .error or .danger classes error_classes = re.findall( r"\.([\w-]*(?:error|danger)[\w-]*)\s*\{([^}]+)\}", content ) for class_name, class_content in error_classes: if "color:" in class_content: if ( "var(--error-color)" not in class_content and "var(--error-color-rgb)" not in class_content ): issues.append(f"{filename}: .{class_name}") if len(issues) > 3: pytest.fail( f"Error/danger classes not using --error-color: {issues[:5]}" ) def test_warning_uses_warning_color(self, all_css_content): """Classes with 'warning' should use --warning-color.""" issues = [] for filename, content in all_css_content.items(): if filename == "themes.css": continue warning_classes = re.findall( r"\.([\w-]*warning[\w-]*)\s*\{([^}]+)\}", content ) for class_name, class_content in warning_classes: if "color:" in class_content: if ( "var(--warning-color)" not in class_content and "var(--warning-color-rgb)" not in class_content ): issues.append(f"{filename}: .{class_name}") if len(issues) > 3: pytest.fail( f"Warning classes not using --warning-color: {issues[:5]}" ) class TestCSSVariableFallbacks: """Test that CSS variable fallbacks are appropriate.""" # Bootstrap colors that should NOT be used as fallbacks BOOTSTRAP_COLORS = [ "#007bff", # Bootstrap primary blue "#28a745", # Bootstrap success green "#dc3545", # Bootstrap danger red "#ffc107", # Bootstrap warning yellow "#17a2b8", # Bootstrap info "#6c757d", # Bootstrap secondary "#343a40", # Bootstrap dark "#f8f9fa", # Bootstrap light ] def test_no_bootstrap_fallbacks_in_css(self): """CSS files should not use Bootstrap colors as fallbacks.""" issues = [] fallback_pattern = re.compile(r"var\([^,]+,\s*(#[0-9a-fA-F]{6})\)") for css_file in CSS_DIR.rglob("*.css"): if css_file.name == "themes.css": continue content = css_file.read_text() for match in fallback_pattern.finditer(content): fallback_color = match.group(1).lower() if fallback_color in [c.lower() for c in self.BOOTSTRAP_COLORS]: issues.append( f"{css_file.name}: Bootstrap color {fallback_color} as fallback" ) if issues: pytest.fail( "Found Bootstrap colors used as fallbacks:\n" + "\n".join(issues[:10]) ) def test_no_bootstrap_fallbacks_in_js(self): """JS files should not use Bootstrap colors as fallbacks.""" issues = [] fallback_pattern = re.compile( r"var\([^,]+,\s*['\"]?(#[0-9a-fA-F]{6})['\"]?\)" ) for js_file in JS_DIR.rglob("*.js"): content = js_file.read_text() for match in fallback_pattern.finditer(content): fallback_color = match.group(1).lower() if fallback_color in [c.lower() for c in self.BOOTSTRAP_COLORS]: issues.append( f"{js_file.name}: Bootstrap color {fallback_color}" ) if issues: pytest.fail( "Found Bootstrap colors in JS fallbacks:\n" + "\n".join(issues[:10]) ) class TestGradientTheming: """Test that gradients use theme variables.""" def test_gradients_use_theme_vars(self): """Gradients should use theme variables, not hardcoded colors.""" issues = [] # Pattern for gradients with hardcoded colors (not var()) gradient_pattern = re.compile( r"(?:linear|radial)-gradient\s*\([^)]*#[0-9a-fA-F]{3,8}[^)]*\)" ) for css_file in CSS_DIR.rglob("*.css"): if css_file.name == "themes.css": continue content = css_file.read_text() for i, line in enumerate(content.split("\n"), 1): # Skip if using CSS variables if "var(--" in line: continue if gradient_pattern.search(line): issues.append(f"{css_file.name}:{i}") # Allow a small number for intentional gradients if len(issues) > 5: pytest.fail( f"Found {len(issues)} gradients with hardcoded colors:\n" + "\n".join(issues[:10]) ) class TestBorderColorTheming: """Test that border colors use theme variables.""" def test_borders_use_theme_colors(self): """Border declarations should use theme variables.""" issues = [] # Pattern for border with hardcoded color (not CSS custom property definitions) border_color_pattern = re.compile( r"border(?:-(?:top|right|bottom|left))?(?:-color)?:\s*[^;]*#[0-9a-fA-F]{3,8}" ) for css_file in CSS_DIR.rglob("*.css"): if css_file.name == "themes.css": continue content = css_file.read_text() for i, line in enumerate(content.split("\n"), 1): # Skip lines using CSS variables if "var(--" in line: continue # Skip CSS custom property definitions (theme-specific values) if line.strip().startswith("--"): continue if border_color_pattern.search(line): # Exclude black/white borders (often intentional) if ( "#000" not in line.lower() and "#fff" not in line.lower() ): issues.append( f"{css_file.name}:{i}: {line.strip()[:60]}" ) if len(issues) > 10: pytest.fail( f"Found {len(issues)} borders with hardcoded colors:\n" + "\n".join(issues[:15]) ) class TestJSThemeIntegration: """Test JavaScript files properly integrate with theme system.""" def test_js_uses_css_variables_for_colors(self): """JS that sets colors should use CSS variables.""" issues = [] # Patterns that indicate proper CSS variable usage good_patterns = [ "var(--", "getPropertyValue", "getComputedStyle", ] # Pattern for direct color assignment bad_pattern = re.compile( r'\.style\.\w*[Cc]olor\s*=\s*["\']#[0-9a-fA-F]{3,8}["\']' ) for js_file in JS_DIR.rglob("*.js"): if "details.js" in js_file.name: # Chart colors exception continue content = js_file.read_text() for i, line in enumerate(content.split("\n"), 1): if bad_pattern.search(line): # Check if the line also uses CSS variables if not any(p in line for p in good_patterns): issues.append(f"{js_file.name}:{i}") if issues: pytest.fail( f"Found {len(issues)} JS lines setting colors directly:\n" + "\n".join(issues[:10]) ) def test_js_reads_css_variables_correctly(self): """JS should read CSS variables using getComputedStyle.""" js_files_using_colors = [] for js_file in JS_DIR.rglob("*.js"): content = js_file.read_text() if "getPropertyValue" in content and "--" in content: js_files_using_colors.append(js_file.name) # We should have at least some files reading CSS variables assert len(js_files_using_colors) >= 2, ( "Expected at least 2 JS files to read CSS variables with getPropertyValue" ) class TestTemplateInlineStyles: """Extended tests for inline styles in templates.""" def test_no_inline_background_colors(self): """Templates should not have inline background-color with hex values.""" issues = [] pattern = re.compile( r'style\s*=\s*["\'][^"\']*background(?:-color)?:\s*#[0-9a-fA-F]{3,8}' ) for template in TEMPLATES_DIR.rglob("*.html"): content = template.read_text() for i, line in enumerate(content.split("\n"), 1): if "var(--" in line: continue if pattern.search(line): issues.append(f"{template.name}:{i}") if issues: pytest.fail( f"Found {len(issues)} inline background colors:\n" + "\n".join(issues[:10]) ) def test_no_inline_text_colors(self): """Templates should not have inline color with hex values.""" issues = [] # More specific pattern to avoid matching background-color pattern = re.compile( r'style\s*=\s*["\'][^"\']*(?= 20, ( f"Expected at least 20 data-theme themes, found {len(themes)}: {themes}" ) # Verify detected count matches ALL_THEMES assert len(ALL_THEMES) == len(themes), ( f"ALL_THEMES count ({len(ALL_THEMES)}) doesn't match CSS ({len(themes)})" ) class TestAdditionalRGBVariants: """Additional tests for RGB variant correctness.""" @pytest.fixture def themes_css_content(self): """Combined theme CSS from the registry (single source of truth). Generated from the theme registry rather than read from the gitignored, auto-generated ``static/css/themes.css`` (PR #3400), which is absent on a fresh checkout / in CI. """ return _generate_combined_css() def test_all_required_rgb_variants_exist(self, themes_css_content): """Every required variable should have an RGB variant.""" required_base_vars = [ "bg-primary", "bg-secondary", "bg-tertiary", "accent-primary", "accent-secondary", "accent-tertiary", "text-primary", "text-secondary", "text-muted", "border-color", "success-color", "warning-color", "error-color", ] for var in required_base_vars: rgb_var = f"--{var}-rgb" assert rgb_var in themes_css_content, ( f"Missing RGB variant: {rgb_var}" ) def test_rgb_format_is_correct(self, themes_css_content): """RGB values should be in 'R, G, B' format (no parentheses).""" rgb_pattern = re.compile(r"--([\w-]+)-rgb:\s*([^;]+);") for match in rgb_pattern.finditer(themes_css_content): var_name = match.group(1) value = match.group(2).strip() # Should be three numbers separated by commas parts = value.split(",") assert len(parts) == 3, ( f"--{var_name}-rgb should have 3 components: {value}" ) for part in parts: try: num = int(part.strip()) assert 0 <= num <= 255, ( f"--{var_name}-rgb component out of range: {num}" ) except ValueError: pytest.fail( f"--{var_name}-rgb has non-numeric component: {part}" ) class TestColorConsistency: """Test color value consistency across themes.""" @pytest.fixture def themes_css_content(self): """Combined theme CSS from the registry (single source of truth). Generated from the theme registry rather than read from the gitignored, auto-generated ``static/css/themes.css`` (PR #3400), which is absent on a fresh checkout / in CI. """ return _generate_combined_css() def test_success_color_is_greenish(self, themes_css_content): """Success colors should be in the green spectrum.""" # Extract success colors from all themes success_pattern = re.compile(r"--success-color:\s*(#[0-9a-fA-F]{6})") success_colors = success_pattern.findall(themes_css_content) for color in success_colors: # Green component should be relatively high r = int(color[1:3], 16) g = int(color[3:5], 16) # For success colors, green should generally be prominent # (allowing for some exceptions like high-contrast yellow-green) assert g >= r // 2 or g >= 100, ( f"Success color {color} doesn't look greenish enough" ) def test_error_color_is_reddish(self, themes_css_content): """Error colors should be in the red spectrum.""" error_pattern = re.compile(r"--error-color:\s*(#[0-9a-fA-F]{6})") error_colors = error_pattern.findall(themes_css_content) for color in error_colors: r = int(color[1:3], 16) g = int(color[3:5], 16) b = int(color[5:7], 16) # Red component should be prominent assert r >= 150 or (r > g and r > b), ( f"Error color {color} doesn't look reddish enough" ) def test_rgb_values_match_hex(self, themes_css_content): """RGB variants should match their hex counterparts.""" # Find pairs like --bg-primary: #121212 and --bg-primary-rgb: 18, 18, 18 hex_pattern = re.compile(r"--(\w+(?:-\w+)*):\s*(#[0-9a-fA-F]{6})") rgb_pattern = re.compile( r"--(\w+(?:-\w+)*)-rgb:\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)" ) hex_colors = {} rgb_colors = {} for match in hex_pattern.finditer(themes_css_content): var_name = match.group(1) hex_value = match.group(2) if var_name not in hex_colors: hex_colors[var_name] = [] hex_colors[var_name].append(hex_value) for match in rgb_pattern.finditer(themes_css_content): var_name = match.group(1) rgb_value = ( int(match.group(2)), int(match.group(3)), int(match.group(4)), ) if var_name not in rgb_colors: rgb_colors[var_name] = [] rgb_colors[var_name].append(rgb_value) # Check a sample of variables for var_name in ["bg-primary", "accent-primary", "success-color"]: if var_name in hex_colors and var_name in rgb_colors: for i, hex_val in enumerate( hex_colors[var_name][:3] ): # Check first 3 expected_r = int(hex_val[1:3], 16) expected_g = int(hex_val[3:5], 16) expected_b = int(hex_val[5:7], 16) if i < len(rgb_colors[var_name]): actual = rgb_colors[var_name][i] assert actual == (expected_r, expected_g, expected_b), ( f"RGB mismatch for {var_name}: " f"hex {hex_val} != rgb {actual}" ) class TestShadowTheming: """Test that box shadows use theme-appropriate colors.""" def test_shadows_use_rgba_variables(self): """Box shadows should use rgba with CSS variables, not hardcoded colors.""" issues = [] # Pattern for box-shadow with hardcoded rgba shadow_pattern = re.compile( r"box-shadow:\s*[^;]*rgba\s*\(\s*\d+\s*,\s*\d+\s*,\s*\d+", re.IGNORECASE, ) for css_file in CSS_DIR.rglob("*.css"): if css_file.name == "themes.css": continue content = css_file.read_text() for i, line in enumerate(content.split("\n"), 1): # Skip if using CSS variable for rgba if "rgba(var(--" in line: continue # Allow black shadows (common and intentional) if "rgba(0, 0, 0" in line or "rgba(0,0,0" in line: continue if shadow_pattern.search(line): issues.append(f"{css_file.name}:{i}") if len(issues) > 5: pytest.fail( f"Found {len(issues)} shadows with non-black hardcoded colors:\n" + "\n".join(issues[:10]) ) def test_no_hardcoded_shadow_colors_in_js(self): """JavaScript should not set hardcoded shadow colors.""" issues = [] shadow_pattern = re.compile( r"boxShadow\s*=\s*['\"].*?#[0-9a-fA-F]{3,8}", re.IGNORECASE ) for js_file in JS_DIR.rglob("*.js"): content = js_file.read_text() for i, line in enumerate(content.split("\n"), 1): if shadow_pattern.search(line): issues.append(f"{js_file.name}:{i}") if issues: pytest.fail( f"Found {len(issues)} hardcoded shadows in JS:\n" + "\n".join(issues[:10]) ) class TestFocusStateTheming: """Test that focus states use theme colors.""" def test_focus_outline_uses_theme_colors(self): """Focus outlines should use accent colors from theme.""" focus_uses_var = False focus_pattern = re.compile(r":focus[^{]*\{[^}]*outline", re.DOTALL) for css_file in CSS_DIR.rglob("*.css"): content = css_file.read_text() if focus_pattern.search(content): if "var(--accent-" in content or "var(--border-" in content: focus_uses_var = True break assert focus_uses_var, ( "Focus states should use theme variables for outline colors" ) def test_focus_visible_defined(self): """Should have :focus-visible styles for keyboard navigation.""" focus_visible_found = False for css_file in CSS_DIR.rglob("*.css"): content = css_file.read_text() if ":focus-visible" in content: focus_visible_found = True break assert focus_visible_found, ( "Should define :focus-visible styles for accessibility" ) class TestFormElementTheming: """Test that form elements use theme colors.""" def test_input_uses_theme_colors(self): """Input elements should use theme background and text colors.""" input_pattern = re.compile( r"input\s*(?:\[[^\]]*\])?\s*\{[^}]+\}", re.DOTALL ) for css_file in CSS_DIR.rglob("*.css"): content = css_file.read_text() matches = input_pattern.findall(content) for match in matches: # Should use theme vars, not hardcoded colors if "background" in match and "#" in match: if "var(--" not in match: pytest.fail( f"Input styles in {css_file.name} have hardcoded bg" ) def test_select_uses_theme_colors(self): """Select elements should use theme colors.""" for css_file in CSS_DIR.rglob("*.css"): if css_file.name in ["custom_dropdown.css", "settings.css"]: content = css_file.read_text() if "select" in content.lower(): assert "var(--" in content, ( f"{css_file.name} should use theme variables for selects" ) break class TestButtonTheming: """Test that buttons use theme colors appropriately.""" def test_primary_button_uses_accent(self): """Primary buttons should use accent colors.""" primary_btn_uses_accent = False for css_file in CSS_DIR.rglob("*.css"): content = css_file.read_text() # Look for primary button definitions if ".btn-primary" in content or ".ldr-btn-primary" in content: if "var(--accent-primary)" in content: primary_btn_uses_accent = True break assert primary_btn_uses_accent, ( "Primary buttons should use --accent-primary" ) def test_button_hover_states_themed(self): """Button hover states should use theme colors.""" hover_pattern = re.compile(r"\.btn[^{]*:hover\s*\{([^}]+)\}", re.DOTALL) for css_file in CSS_DIR.rglob("*.css"): content = css_file.read_text() matches = hover_pattern.findall(content) for match in matches: if "#" in match and "var(--" not in match: # Has hardcoded color without CSS variable # Check if it's a brand exception if not any( brand.lower() in match.lower() for brand in BRAND_COLOR_EXCEPTIONS ): pytest.fail( f"Button hover in {css_file.name} has hardcoded color" ) class TestModalTheming: """Test that modals and dialogs use theme colors.""" def test_modal_content_uses_theme_bg(self): """Modal content should use theme background colors.""" for template in TEMPLATES_DIR.rglob("*.html"): if "modal" in template.name.lower(): content = template.read_text() # Should not have hardcoded background colors if ( "background-color: #" in content or "background:#" in content ): if "var(--" not in content: pytest.fail( f"Modal {template.name} has hardcoded background" ) class TestLinkTheming: """Test that links use theme colors.""" def test_links_use_accent_colors(self): """Links should use accent or dedicated link colors.""" link_uses_theme = False for css_file in CSS_DIR.rglob("*.css"): content = css_file.read_text() # Find anchor styles if re.search(r"\ba\s*\{", content) or re.search( r"\ba:link", content ): if "var(--accent-" in content or "var(--link-" in content: link_uses_theme = True break assert link_uses_theme, "Links should use theme accent or link colors" class TestScrollbarTheming: """Test scrollbar theming for webkit browsers.""" def test_scrollbar_uses_theme_colors(self): """Custom scrollbars should use theme colors.""" scrollbar_uses_theme = False for css_file in CSS_DIR.rglob("*.css"): content = css_file.read_text() if "::-webkit-scrollbar" in content: # Check if it uses theme variables if "var(--" in content: scrollbar_uses_theme = True break # Scrollbar styling is optional but should use theme vars if present if not scrollbar_uses_theme: # Check if any scrollbar styling exists for css_file in CSS_DIR.rglob("*.css"): if "::-webkit-scrollbar" in css_file.read_text(): pytest.fail( "Scrollbar styles found but not using theme variables" ) class TestTransitionAndAnimation: """Test that color transitions work with theme switching.""" def test_theme_transition_defined(self): """Should have smooth transitions for theme changes.""" transition_found = False for css_file in CSS_DIR.rglob("*.css"): content = css_file.read_text() # Look for color/background transitions if re.search( r"transition[^;]*(?:color|background|border)[^;]*;", content ): transition_found = True break assert transition_found, ( "Should have CSS transitions for smooth theme switching" ) def test_no_color_flash_on_load(self): """Theme should be applied before content is visible.""" base_html = TEMPLATES_DIR / "base.html" if not base_html.exists(): pytest.skip("base.html not found") content = base_html.read_text() # Theme initialization should happen early (in head or early body) head_content = ( content.split("")[0] if "" in content else "" ) # Should have theme detection/setting in head theme_in_head = ( "data-theme" in head_content or "theme" in head_content.lower() or "localStorage" in head_content ) assert theme_in_head, ( "Theme should be initialized in to prevent flash" ) class TestCodeBlockTheming: """Test that code blocks and pre elements use theme colors.""" def test_code_blocks_themed(self): """Code blocks should use theme-appropriate colors.""" code_themed = False for css_file in CSS_DIR.rglob("*.css"): content = css_file.read_text() if re.search(r"\bcode\b|\bpre\b", content): if "var(--" in content: code_themed = True break assert code_themed, "Code blocks should use theme variables" class TestLoadingStateTheming: """Test that loading states use theme colors.""" def test_spinner_uses_theme_colors(self): """Loading spinners should use theme accent color.""" spinner_patterns = ["spinner", "loading", "loader"] spinner_themed = False for css_file in CSS_DIR.rglob("*.css"): content = css_file.read_text() for pattern in spinner_patterns: if pattern in content.lower(): if "var(--accent-" in content: spinner_themed = True break if spinner_themed: break assert spinner_themed, "Spinners should use theme accent colors" class TestNavTheming: """Test that navigation uses theme colors.""" def test_nav_uses_theme_colors(self): """Navigation should use theme colors.""" nav_themed = False for css_file in CSS_DIR.rglob("*.css"): content = css_file.read_text() if ".nav" in content or "nav " in content: if "var(--" in content: nav_themed = True break assert nav_themed, "Navigation should use theme variables" class TestHighContrastCompliance: """Test high contrast theme meets accessibility requirements.""" @pytest.fixture def themes_css_content(self): """Combined theme CSS from the theme registry (single source of truth). Must NOT read ``static/css/themes.css`` directly: that file is auto-generated and gitignored (PR #3400), so a fresh checkout / CI run does not have it — which made these tests error with FileNotFoundError (or fail extraction against a separately-built copy). Generating from the registry is what the app serves and what the TestThemeDefinitions fixture already does. """ return _generate_combined_css() def _get_luminance(self, hex_color: str) -> float: """Calculate relative luminance.""" r = int(hex_color[1:3], 16) / 255 g = int(hex_color[3:5], 16) / 255 b = int(hex_color[5:7], 16) / 255 return 0.299 * r + 0.587 * g + 0.114 * b def _contrast_ratio(self, l1: float, l2: float) -> float: """Calculate contrast ratio.""" lighter = max(l1, l2) darker = min(l1, l2) return (lighter + 0.05) / (darker + 0.05) def _extract_theme_color( self, themes_css_content: str, theme: str, var_name: str ) -> str | None: """Extract a color variable from a theme.""" if theme == "dark": pattern = rf":root[^{{]*\{{[^}}]*{var_name}:\s*(#[0-9a-fA-F]{{6}})" else: pattern = rf'\[data-theme="{theme}"\][^{{]*\{{[^}}]*{var_name}:\s*(#[0-9a-fA-F]{{6}})' match = re.search(pattern, themes_css_content, re.DOTALL) return match.group(1) if match else None def test_high_contrast_has_higher_ratio(self, themes_css_content): """High contrast theme should have higher contrast than regular dark.""" # Get dark theme contrast dark_bg = self._extract_theme_color( themes_css_content, "dark", "--bg-primary" ) dark_text = self._extract_theme_color( themes_css_content, "dark", "--text-primary" ) # Get high-contrast theme contrast hc_bg = self._extract_theme_color( themes_css_content, "high-contrast", "--bg-primary" ) hc_text = self._extract_theme_color( themes_css_content, "high-contrast", "--text-primary" ) if not all([dark_bg, dark_text, hc_bg, hc_text]): pytest.skip("Could not extract colors for comparison") dark_ratio = self._contrast_ratio( self._get_luminance(dark_bg), self._get_luminance(dark_text) ) hc_ratio = self._contrast_ratio( self._get_luminance(hc_bg), self._get_luminance(hc_text) ) assert hc_ratio >= dark_ratio, ( f"High contrast ({hc_ratio:.2f}) should have >= contrast than dark ({dark_ratio:.2f})" ) def test_high_contrast_meets_aaa(self, themes_css_content): """High contrast theme should meet WCAG AAA (7:1) for text.""" hc_bg = self._extract_theme_color( themes_css_content, "high-contrast", "--bg-primary" ) hc_text = self._extract_theme_color( themes_css_content, "high-contrast", "--text-primary" ) if not hc_bg or not hc_text: pytest.skip("Could not extract high-contrast colors") ratio = self._contrast_ratio( self._get_luminance(hc_bg), self._get_luminance(hc_text) ) assert ratio >= 7.0, ( f"High contrast should meet WCAG AAA (7:1), got {ratio:.2f}" ) class TestThemeLocalStorage: """Test theme persistence via localStorage.""" def test_theme_saved_to_localstorage(self): """JavaScript should save theme preference to localStorage.""" localstorage_theme_found = False for js_file in JS_DIR.rglob("*.js"): content = js_file.read_text() if "localStorage" in content and "theme" in content.lower(): localstorage_theme_found = True break assert localstorage_theme_found, ( "Theme should be saved to localStorage for persistence" ) def test_theme_loaded_from_localstorage(self): """Theme should be loaded from localStorage on page load.""" load_found = False for js_file in JS_DIR.rglob("*.js"): content = js_file.read_text() if "localStorage.getItem" in content and "theme" in content.lower(): load_found = True break # Also check templates if not load_found: for template in TEMPLATES_DIR.rglob("*.html"): content = template.read_text() if "localStorage" in content and "theme" in content.lower(): load_found = True break assert load_found, "Theme should be loaded from localStorage on init" class TestThemeCSSSyntax: """Test CSS syntax is valid in theme definitions.""" @pytest.fixture def themes_css_content(self): """Combined theme CSS from the registry (single source of truth). Generated from the theme registry rather than read from the gitignored, auto-generated ``static/css/themes.css`` (PR #3400), which is absent on a fresh checkout / in CI. """ return _generate_combined_css() def test_no_unclosed_braces(self, themes_css_content): """CSS should have balanced braces.""" open_braces = themes_css_content.count("{") close_braces = themes_css_content.count("}") assert open_braces == close_braces, ( f"Unbalanced braces: {open_braces} open, {close_braces} close" ) def test_no_duplicate_theme_definitions(self, themes_css_content): """Each theme's root definition should only appear once. Note: Additional selectors like [data-theme="x"] a { } for element overrides are allowed and expected - we only check for duplicate root theme variable definitions (selectors followed directly by {). """ # Pattern for root theme definitions only (not element-specific like # [data-theme="x"] a or [data-theme="x"] button) # This matches [data-theme="x"] { but not [data-theme="x"] a { theme_pattern = re.compile(r'\[data-theme="([^"]+)"\]\s*\{') themes = theme_pattern.findall(themes_css_content) # Count occurrences from collections import Counter counts = Counter(themes) duplicates = [t for t, c in counts.items() if c > 1] assert not duplicates, f"Duplicate root theme definitions: {duplicates}" def test_variables_have_values(self, themes_css_content): """CSS variables should have values, not be empty.""" empty_var_pattern = re.compile(r"--[\w-]+:\s*;") empty_vars = empty_var_pattern.findall(themes_css_content) assert not empty_vars, f"Empty variable definitions found: {empty_vars}" def test_hex_colors_are_valid(self, themes_css_content): """All hex colors should be valid 3, 6, or 8 character hex.""" hex_pattern = re.compile(r"#([0-9a-fA-F]+)\b") for match in hex_pattern.finditer(themes_css_content): hex_value = match.group(1) assert len(hex_value) in [3, 6, 8], ( f"Invalid hex color length: #{hex_value} ({len(hex_value)} chars)" ) class TestMediaQueryTheming: """Test media query handling for theme preferences.""" def test_prefers_color_scheme_support(self): """Should support prefers-color-scheme media query.""" prefers_found = False # Check CSS for css_file in CSS_DIR.rglob("*.css"): content = css_file.read_text() if "prefers-color-scheme" in content: prefers_found = True break # Check JS if not prefers_found: for js_file in JS_DIR.rglob("*.js"): content = js_file.read_text() if "prefers-color-scheme" in content: prefers_found = True break assert prefers_found, ( "Should support prefers-color-scheme for system theme detection" ) class TestResponsiveTheming: """Test that themes work across different screen sizes.""" def test_no_fixed_color_in_media_queries(self): """Media queries should not override with hardcoded colors.""" issues = [] media_pattern = re.compile( r"@media[^{]*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}", re.DOTALL ) for css_file in CSS_DIR.rglob("*.css"): if css_file.name == "themes.css": continue content = css_file.read_text() for match in media_pattern.finditer(content): media_content = match.group(1) # Check for hardcoded colors if re.search(r":\s*#[0-9a-fA-F]{3,8}\s*;", media_content): if "var(--" not in media_content: issues.append(css_file.name) break if len(issues) > 3: pytest.fail(f"Media queries with hardcoded colors: {issues[:5]}") if __name__ == "__main__": pytest.main([__file__, "-v"])