Files
wehub-resource-sync 7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

1714 lines
61 KiB
Python

"""
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*["\'][^"\']*(?<!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 text colors:\n"
+ "\n".join(issues[:10])
)
class TestThemeNamingConventions:
"""Test that theme names follow conventions."""
@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_theme_names_are_lowercase_kebab(self, themes_css_content):
"""Theme names should be lowercase with hyphens."""
theme_pattern = re.compile(r'\[data-theme="([^"]+)"\]')
themes = theme_pattern.findall(themes_css_content)
for theme in themes:
assert theme == theme.lower(), (
f"Theme '{theme}' should be lowercase"
)
assert "_" not in theme, (
f"Theme '{theme}' should use hyphens, not underscores"
)
assert " " not in theme, (
f"Theme '{theme}' should not contain spaces"
)
def test_theme_count(self, themes_css_content):
"""Should have at least 20 themes defined with data-theme attribute."""
theme_pattern = re.compile(r'\[data-theme="([^"]+)"\]')
themes = set(theme_pattern.findall(themes_css_content))
# Should have a reasonable number of themes (at least original 20)
assert len(themes) >= 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("</head>")[0] if "</head>" 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 <head> 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"])