Coverage for openhcs/pyqt_gui/shared/config_validator.py: 0.0%
138 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
1"""
2Configuration Validator for OpenHCS PyQt6 Color Schemes
4Validates color scheme JSON configurations for WCAG compliance, proper format,
5and semantic correctness. Provides hot-reload capability and error reporting.
6"""
8import json
9import logging
10from typing import Dict, List, Tuple, Optional, Any
11from pathlib import Path
12from dataclasses import fields
13from openhcs.pyqt_gui.shared.color_scheme import PyQt6ColorScheme
15logger = logging.getLogger(__name__)
18class ColorSchemeValidator:
19 """
20 Validates PyQt6 color scheme configurations.
22 Provides comprehensive validation including format checking, WCAG compliance,
23 and semantic correctness validation for color scheme JSON files.
24 """
26 def __init__(self):
27 """Initialize the validator."""
28 self.validation_errors = []
29 self.validation_warnings = []
31 def validate_config_file(self, config_path: str) -> bool:
32 """
33 Validate a color scheme configuration file.
35 Args:
36 config_path: Path to JSON configuration file
38 Returns:
39 bool: True if validation passes, False otherwise
40 """
41 self.validation_errors.clear()
42 self.validation_warnings.clear()
44 try:
45 # Check file exists
46 if not Path(config_path).exists():
47 self.validation_errors.append(f"Configuration file not found: {config_path}")
48 return False
50 # Load and parse JSON
51 with open(config_path, 'r') as f:
52 config = json.load(f)
54 # Validate structure
55 if not self._validate_structure(config):
56 return False
58 # Validate colors
59 if not self._validate_colors(config):
60 return False
62 # Validate WCAG compliance
63 if not self._validate_wcag_compliance(config):
64 return False
66 # Validate theme variants
67 if not self._validate_theme_variants(config):
68 return False
70 logger.info(f"Color scheme configuration validated successfully: {config_path}")
71 return True
73 except json.JSONDecodeError as e:
74 self.validation_errors.append(f"Invalid JSON format: {e}")
75 return False
76 except Exception as e:
77 self.validation_errors.append(f"Validation error: {e}")
78 return False
80 def _validate_structure(self, config: Dict[str, Any]) -> bool:
81 """
82 Validate the basic structure of the configuration.
84 Args:
85 config: Parsed JSON configuration
87 Returns:
88 bool: True if structure is valid
89 """
90 required_sections = [
91 "base_ui_colors",
92 "text_colors",
93 "interactive_elements",
94 "selection_and_highlighting",
95 "status_colors",
96 "log_highlighting",
97 "python_syntax"
98 ]
100 for section in required_sections:
101 if section not in config:
102 self.validation_errors.append(f"Missing required section: {section}")
103 return False
105 # Validate metadata
106 if "_schema_version" not in config:
107 self.validation_warnings.append("Missing schema version")
109 return True
111 def _validate_colors(self, config: Dict[str, Any]) -> bool:
112 """
113 Validate color format and values.
115 Args:
116 config: Parsed JSON configuration
118 Returns:
119 bool: True if colors are valid
120 """
121 valid = True
123 def validate_color_section(section_name: str, section_data: Dict[str, Any]):
124 nonlocal valid
125 for color_name, color_value in section_data.items():
126 if color_name.startswith("_"):
127 continue # Skip metadata
129 if not self._validate_color_value(color_name, color_value):
130 valid = False
132 # Validate all color sections
133 for section_name, section_data in config.items():
134 if isinstance(section_data, dict) and not section_name.startswith("_"):
135 if section_name not in ["theme_variants", "accessibility", "metadata"]:
136 validate_color_section(section_name, section_data)
138 return valid
140 def _validate_color_value(self, color_name: str, color_value: Any) -> bool:
141 """
142 Validate a single color value.
144 Args:
145 color_name: Name of the color
146 color_value: Color value to validate
148 Returns:
149 bool: True if color value is valid
150 """
151 if not isinstance(color_value, list):
152 self.validation_errors.append(f"Color {color_name} must be a list, got {type(color_value)}")
153 return False
155 # Check RGB or RGBA format
156 if len(color_value) not in [3, 4]:
157 self.validation_errors.append(f"Color {color_name} must have 3 (RGB) or 4 (RGBA) values")
158 return False
160 # Validate color component values
161 for i, component in enumerate(color_value):
162 if not isinstance(component, int):
163 self.validation_errors.append(f"Color {color_name} component {i} must be integer")
164 return False
166 max_value = 255 if i < 3 else 255 # RGB: 0-255, Alpha: 0-255
167 if not (0 <= component <= max_value):
168 self.validation_errors.append(f"Color {color_name} component {i} out of range (0-{max_value})")
169 return False
171 return True
173 def _validate_wcag_compliance(self, config: Dict[str, Any]) -> bool:
174 """
175 Validate WCAG contrast ratio compliance.
177 Args:
178 config: Parsed JSON configuration
180 Returns:
181 bool: True if WCAG compliance is satisfied
182 """
183 # Create temporary color scheme for validation
184 try:
185 color_scheme = self._create_color_scheme_from_config(config)
187 # Define critical color combinations to validate
188 critical_combinations = [
189 (color_scheme.text_primary, color_scheme.window_bg, "Primary text on window"),
190 (color_scheme.text_secondary, color_scheme.window_bg, "Secondary text on window"),
191 (color_scheme.button_text, color_scheme.button_normal_bg, "Button text on button"),
192 (color_scheme.input_text, color_scheme.input_bg, "Input text on input field"),
193 (color_scheme.selection_text, color_scheme.selection_bg, "Selected text on selection"),
194 ]
196 min_ratio = 4.5 # WCAG AA standard
197 all_valid = True
199 for fg, bg, description in critical_combinations:
200 if not color_scheme.validate_wcag_contrast(fg, bg, min_ratio):
201 contrast_ratio = self._calculate_contrast_ratio(fg, bg)
202 self.validation_errors.append(
203 f"WCAG compliance failure: {description} "
204 f"(contrast ratio: {contrast_ratio:.2f}, required: {min_ratio})"
205 )
206 all_valid = False
208 return all_valid
210 except Exception as e:
211 self.validation_errors.append(f"WCAG validation error: {e}")
212 return False
214 def _validate_theme_variants(self, config: Dict[str, Any]) -> bool:
215 """
216 Validate theme variant configurations.
218 Args:
219 config: Parsed JSON configuration
221 Returns:
222 bool: True if theme variants are valid
223 """
224 if "theme_variants" not in config:
225 return True # Optional section
227 variants = config["theme_variants"]
229 for variant_name, variant_config in variants.items():
230 if variant_name.startswith("_"):
231 continue # Skip metadata
233 # Validate variant colors
234 for color_name, color_value in variant_config.items():
235 if color_name.startswith("_"):
236 continue # Skip metadata
238 if not self._validate_color_value(f"{variant_name}.{color_name}", color_value):
239 return False
241 return True
243 def _create_color_scheme_from_config(self, config: Dict[str, Any]) -> PyQt6ColorScheme:
244 """
245 Create a PyQt6ColorScheme from configuration for validation.
247 Args:
248 config: Parsed JSON configuration
250 Returns:
251 PyQt6ColorScheme: Color scheme instance
252 """
253 # Flatten all color sections into a single dict
254 all_colors = {}
256 for section_name, section_data in config.items():
257 if isinstance(section_data, dict) and not section_name.startswith("_"):
258 if section_name not in ["theme_variants", "accessibility", "metadata"]:
259 for color_name, color_value in section_data.items():
260 if not color_name.startswith("_"):
261 all_colors[color_name] = tuple(color_value)
263 # Create color scheme with available colors
264 valid_fields = {f.name for f in fields(PyQt6ColorScheme)}
265 scheme_kwargs = {k: v for k, v in all_colors.items() if k in valid_fields}
267 return PyQt6ColorScheme(**scheme_kwargs)
269 def _calculate_contrast_ratio(self, fg: Tuple[int, int, int], bg: Tuple[int, int, int]) -> float:
270 """
271 Calculate contrast ratio between two colors.
273 Args:
274 fg: Foreground color RGB tuple
275 bg: Background color RGB tuple
277 Returns:
278 float: Contrast ratio
279 """
280 def relative_luminance(color: Tuple[int, int, int]) -> float:
281 r, g, b = [c / 255.0 for c in color]
283 def gamma_correct(c):
284 return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
286 r, g, b = map(gamma_correct, [r, g, b])
287 return 0.2126 * r + 0.7152 * g + 0.0722 * b
289 l1 = relative_luminance(fg)
290 l2 = relative_luminance(bg)
292 if l1 < l2:
293 l1, l2 = l2, l1
295 return (l1 + 0.05) / (l2 + 0.05)
297 def get_validation_report(self) -> Dict[str, List[str]]:
298 """
299 Get the validation report with errors and warnings.
301 Returns:
302 Dict[str, List[str]]: Dictionary with 'errors' and 'warnings' lists
303 """
304 return {
305 "errors": self.validation_errors.copy(),
306 "warnings": self.validation_warnings.copy()
307 }
309 def print_validation_report(self):
310 """Print the validation report to console."""
311 if self.validation_errors:
312 print("❌ Validation Errors:")
313 for error in self.validation_errors:
314 print(f" • {error}")
316 if self.validation_warnings:
317 print("⚠️ Validation Warnings:")
318 for warning in self.validation_warnings:
319 print(f" • {warning}")
321 if not self.validation_errors and not self.validation_warnings:
322 print("✅ Validation passed with no errors or warnings")