""" Unit tests for the theme module components. These tests cover: 1. TOML frontmatter parsing (loader.py) 2. ThemeMetadata dataclass (schema.py) 3. ThemeRegistry functionality (__init__.py) 4. Invalid theme file handling """ import pytest from local_deep_research.web.themes import ( ThemeMetadata, ThemeRegistry, get_theme_metadata, get_themes, get_themes_json, theme_registry, ) from local_deep_research.web.themes.loader import ThemeLoader from local_deep_research.web.themes.schema import ( REQUIRED_RGB_VARIANTS, REQUIRED_VARIABLES, ) class TestFrontmatterParsing: """Test TOML frontmatter parsing from CSS files.""" @pytest.fixture def loader(self, tmp_path): """Create a ThemeLoader with temporary directory.""" return ThemeLoader(tmp_path) def test_parse_valid_frontmatter(self, loader): """Valid TOML frontmatter should be parsed correctly.""" css = """/*--- name = "Test Theme" label = "Test Theme" icon = "fa-star" group = "core" type = "dark" description = "A test theme" ---*/ [data-theme="test"] { --bg-primary: #121212; } """ result = loader.parse_frontmatter(css) assert result["name"] == "Test Theme" assert result["label"] == "Test Theme" assert result["icon"] == "fa-star" assert result["group"] == "core" assert result["type"] == "dark" assert result["description"] == "A test theme" def test_parse_missing_frontmatter(self, loader): """CSS without frontmatter should return empty dict.""" css = """[data-theme="test"] { --bg-primary: #121212; } """ result = loader.parse_frontmatter(css) assert result == {} def test_parse_malformed_frontmatter(self, loader): """Malformed TOML should return empty dict without crashing.""" css = """/*--- name = "Missing closing quote icon = fa-star # missing quotes ---*/ [data-theme="test"] { --bg-primary: #121212; } """ result = loader.parse_frontmatter(css) assert result == {} def test_parse_empty_frontmatter(self, loader): """Empty frontmatter should return empty dict.""" css = """/*--- ---*/ [data-theme="test"] { --bg-primary: #121212; } """ result = loader.parse_frontmatter(css) assert result == {} class TestThemeIdExtraction: """Test theme ID extraction from CSS selectors.""" @pytest.fixture def loader(self, tmp_path): """Create a ThemeLoader with temporary directory.""" return ThemeLoader(tmp_path) def test_extract_theme_id(self, loader): """Theme ID should be extracted from data-theme selector.""" css = """[data-theme="nord"] { --bg-primary: #2e3440; } """ result = loader.extract_theme_id(css) assert result == "nord" def test_extract_theme_id_with_root(self, loader): """Theme ID should be extracted when combined with :root.""" css = """:root, [data-theme="hashed"] { --bg-primary: #121212; } """ result = loader.extract_theme_id(css) assert result == "hashed" def test_extract_theme_id_missing(self, loader): """Should return None when no data-theme selector present.""" css = """:root { --bg-primary: #121212; } """ result = loader.extract_theme_id(css) assert result is None class TestCSSVariableValidation: """Test validation of required CSS variables.""" @pytest.fixture def loader(self, tmp_path): """Create a ThemeLoader with temporary directory.""" return ThemeLoader(tmp_path) def test_validate_complete_theme(self, loader): """Theme with all required variables should pass validation.""" # Build CSS with all required variables vars_css = "\n".join( [f" {var}: #123456;" for var in REQUIRED_VARIABLES] ) rgb_css = "\n".join( [f" {var}: 18, 52, 86;" for var in REQUIRED_RGB_VARIANTS] ) css = f"""[data-theme="complete"] {{ {vars_css} {rgb_css} }} """ missing_base, missing_rgb = loader.validate_css_variables( css, "complete" ) assert missing_base == [] assert missing_rgb == [] def test_validate_missing_variables(self, loader): """Theme missing required variables should be flagged.""" css = """[data-theme="incomplete"] { --bg-primary: #121212; --text-primary: #ffffff; } """ missing_base, missing_rgb = loader.validate_css_variables( css, "incomplete" ) assert "--bg-secondary" in missing_base assert "--accent-primary" in missing_base assert "--bg-primary-rgb" in missing_rgb class TestThemeLoading: """Test loading themes from files.""" @pytest.fixture def themes_dir(self, tmp_path): """Create a temporary themes directory with test themes.""" core_dir = tmp_path / "core" core_dir.mkdir() # Create a valid theme file valid_theme = core_dir / "test-valid.css" valid_theme.write_text("""/*--- name = "Test Valid" label = "Test Valid" icon = "fa-check" group = "core" type = "dark" ---*/ [data-theme="test-valid"] { --bg-primary: #121212; --bg-secondary: #1e1e1e; --bg-tertiary: #2d2d2d; --accent-primary: #bb86fc; --accent-secondary: #03dac6; --accent-tertiary: #cf6679; --text-primary: #ffffff; --text-secondary: #b3b3b3; --text-muted: #666666; --border-color: #333333; --success-color: #4caf50; --warning-color: #ff9800; --error-color: #f44336; --bg-primary-rgb: 18, 18, 18; --bg-secondary-rgb: 30, 30, 30; --bg-tertiary-rgb: 45, 45, 45; --accent-primary-rgb: 187, 134, 252; --accent-secondary-rgb: 3, 218, 198; --accent-tertiary-rgb: 207, 102, 121; --text-primary-rgb: 255, 255, 255; --text-secondary-rgb: 179, 179, 179; --text-muted-rgb: 102, 102, 102; --border-color-rgb: 51, 51, 51; --success-color-rgb: 76, 175, 80; --warning-color-rgb: 255, 152, 0; --error-color-rgb: 244, 67, 54; } """) return tmp_path def test_load_valid_theme(self, themes_dir): """Valid theme file should be loaded correctly.""" loader = ThemeLoader(themes_dir) theme = loader.load_theme(themes_dir / "core" / "test-valid.css") assert theme is not None assert theme.id == "test-valid" assert theme.label == "Test Valid" assert theme.icon == "fa-check" assert theme.group == "core" assert theme.type == "dark" def test_load_nonexistent_file(self, themes_dir): """Loading nonexistent file should return None.""" loader = ThemeLoader(themes_dir) theme = loader.load_theme(themes_dir / "nonexistent.css") assert theme is None def test_load_all_themes_includes_system(self, themes_dir): """load_all_themes should include virtual 'system' theme.""" loader = ThemeLoader(themes_dir) themes = loader.load_all_themes() assert "system" in themes assert themes["system"].css_path is None assert themes["system"].group == "system" class TestThemeRegistry: """Test ThemeRegistry singleton and methods.""" def test_singleton_pattern(self): """ThemeRegistry should be a singleton.""" registry1 = ThemeRegistry() registry2 = ThemeRegistry() assert registry1 is registry2 def test_registry_loads_themes(self): """Registry should load themes from themes directory.""" assert len(theme_registry.themes) > 0 assert "system" in theme_registry.themes def test_get_theme_ids(self): """get_theme_ids should return sorted list of theme IDs.""" ids = theme_registry.get_theme_ids() assert isinstance(ids, list) assert len(ids) > 0 assert ids == sorted(ids) def test_get_theme_valid(self): """get_theme should return ThemeMetadata for valid ID.""" theme = theme_registry.get_theme("system") assert theme is not None assert theme.id == "system" def test_get_theme_invalid(self): """get_theme should return None for invalid ID.""" theme = theme_registry.get_theme("nonexistent-theme-xyz") assert theme is None def test_is_valid_theme(self): """is_valid_theme should correctly validate theme IDs.""" assert theme_registry.is_valid_theme("system") is True assert theme_registry.is_valid_theme("nonexistent") is False def test_get_combined_css(self): """get_combined_css should return valid CSS string.""" css = theme_registry.get_combined_css() assert isinstance(css, str) assert "Auto-generated theme CSS" in css assert "[data-theme=" in css def test_get_themes_by_group(self): """get_themes_by_group should organize themes correctly.""" grouped = theme_registry.get_themes_by_group() assert isinstance(grouped, dict) assert "system" in grouped assert len(grouped["system"]) == 1 assert grouped["system"][0].id == "system" class TestThemeMetadata: """Test ThemeMetadata dataclass.""" def test_to_dict(self): """to_dict should return serializable dictionary.""" theme = ThemeMetadata( id="test", label="Test Theme", icon="fa-star", group="core", type="dark", description="A test theme", ) result = theme.to_dict() assert result["label"] == "Test Theme" assert result["icon"] == "fa-star" assert result["group"] == "core" assert result["type"] == "dark" # id, description, author, css_path should NOT be in to_dict assert "id" not in result assert "description" not in result def test_default_values(self): """ThemeMetadata should have sensible defaults.""" theme = ThemeMetadata( id="minimal", label="Minimal", icon="fa-circle", group="core", ) assert theme.type == "dark" assert theme.description == "" assert theme.author == "" assert theme.css_path is None class TestFlaskIntegrationFunctions: """Test Flask integration helper functions.""" def test_get_themes(self): """get_themes should return list of theme IDs.""" themes = get_themes() assert isinstance(themes, list) assert "system" in themes def test_get_themes_json(self): """get_themes_json should return Markup-safe JSON.""" json_str = get_themes_json() # Should be a valid JSON array assert json_str.startswith("[") assert json_str.endswith("]") assert '"system"' in json_str def test_get_theme_metadata(self): """get_theme_metadata should return Markup-safe JSON object.""" json_str = get_theme_metadata() # Should be a valid JSON object assert json_str.startswith("{") assert json_str.endswith("}") assert '"system"' in json_str class TestSettingsOptions: """Test settings options generation.""" def test_get_settings_options_format(self): """get_settings_options should return correct format.""" options = theme_registry.get_settings_options() assert isinstance(options, list) assert len(options) > 0 # Each option should have label and value for opt in options: assert "label" in opt assert "value" in opt assert isinstance(opt["label"], str) assert isinstance(opt["value"], str) def test_get_settings_options_sorted(self): """get_settings_options should be sorted by label.""" options = theme_registry.get_settings_options() labels = [opt["label"] for opt in options] assert labels == sorted(labels) def test_get_grouped_settings_options(self): """get_grouped_settings_options should include group info.""" options = theme_registry.get_grouped_settings_options() assert isinstance(options, list) for opt in options: assert "label" in opt assert "value" in opt assert "group" in opt class TestEdgeCases: """Test edge cases and error handling.""" def test_theme_with_special_characters_in_description(self, tmp_path): """Themes with special characters should be handled.""" core_dir = tmp_path / "core" core_dir.mkdir() theme_file = core_dir / "special.css" theme_file.write_text("""/*--- name = "Special \\"Theme\\"" label = "Special Theme" icon = "fa-star" group = "core" description = "Theme with \\"quotes\\" and 'apostrophes'" ---*/ [data-theme="special"] { --bg-primary: #121212; } """) loader = ThemeLoader(tmp_path) theme = loader.load_theme(theme_file) assert theme is not None assert "quotes" in theme.description def test_auto_generated_metadata(self, tmp_path): """Themes without frontmatter should get auto-generated metadata.""" core_dir = tmp_path / "core" core_dir.mkdir() theme_file = core_dir / "auto-theme.css" theme_file.write_text("""[data-theme="auto-theme"] { --bg-primary: #121212; } """) loader = ThemeLoader(tmp_path) theme = loader.load_theme(theme_file) assert theme is not None assert theme.id == "auto-theme" assert theme.label == "Auto Theme" # Auto-generated from filename assert theme.icon == "fa-palette" # Default icon assert theme.group == "core" # From parent directory def test_theme_type_validation(self, tmp_path): """Invalid theme type should default to 'dark'.""" core_dir = tmp_path / "core" core_dir.mkdir() theme_file = core_dir / "bad-type.css" theme_file.write_text("""/*--- name = "Bad Type" label = "Bad Type" icon = "fa-star" group = "core" type = "invalid" ---*/ [data-theme="bad-type"] { --bg-primary: #121212; } """) loader = ThemeLoader(tmp_path) theme = loader.load_theme(theme_file) assert theme is not None assert theme.type == "dark" # Should default to dark class TestCSSContentGeneration: """Test CSS content generation and stripping.""" def test_get_css_content_strips_frontmatter(self, tmp_path): """get_css_content should strip TOML frontmatter.""" core_dir = tmp_path / "core" core_dir.mkdir() theme_file = core_dir / "strip-test.css" theme_file.write_text("""/*--- name = "Strip Test" label = "Strip Test" icon = "fa-star" group = "core" ---*/ [data-theme="strip-test"] { --bg-primary: #121212; } """) loader = ThemeLoader(tmp_path) theme = loader.load_theme(theme_file) css = loader.get_css_content(theme) assert "/*---" not in css assert "---*/" not in css assert "name = " not in css assert "[data-theme=" in css assert "--bg-primary" in css if __name__ == "__main__": pytest.main([__file__, "-v"])