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
1714 lines
61 KiB
Python
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"])
|