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

1""" 

2Configuration Validator for OpenHCS PyQt6 Color Schemes 

3 

4Validates color scheme JSON configurations for WCAG compliance, proper format, 

5and semantic correctness. Provides hot-reload capability and error reporting. 

6""" 

7 

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 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18class ColorSchemeValidator: 

19 """ 

20 Validates PyQt6 color scheme configurations. 

21  

22 Provides comprehensive validation including format checking, WCAG compliance, 

23 and semantic correctness validation for color scheme JSON files. 

24 """ 

25 

26 def __init__(self): 

27 """Initialize the validator.""" 

28 self.validation_errors = [] 

29 self.validation_warnings = [] 

30 

31 def validate_config_file(self, config_path: str) -> bool: 

32 """ 

33 Validate a color scheme configuration file. 

34  

35 Args: 

36 config_path: Path to JSON configuration file 

37  

38 Returns: 

39 bool: True if validation passes, False otherwise 

40 """ 

41 self.validation_errors.clear() 

42 self.validation_warnings.clear() 

43 

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 

49 

50 # Load and parse JSON 

51 with open(config_path, 'r') as f: 

52 config = json.load(f) 

53 

54 # Validate structure 

55 if not self._validate_structure(config): 

56 return False 

57 

58 # Validate colors 

59 if not self._validate_colors(config): 

60 return False 

61 

62 # Validate WCAG compliance 

63 if not self._validate_wcag_compliance(config): 

64 return False 

65 

66 # Validate theme variants 

67 if not self._validate_theme_variants(config): 

68 return False 

69 

70 logger.info(f"Color scheme configuration validated successfully: {config_path}") 

71 return True 

72 

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 

79 

80 def _validate_structure(self, config: Dict[str, Any]) -> bool: 

81 """ 

82 Validate the basic structure of the configuration. 

83  

84 Args: 

85 config: Parsed JSON configuration 

86  

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 ] 

99 

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 

104 

105 # Validate metadata 

106 if "_schema_version" not in config: 

107 self.validation_warnings.append("Missing schema version") 

108 

109 return True 

110 

111 def _validate_colors(self, config: Dict[str, Any]) -> bool: 

112 """ 

113 Validate color format and values. 

114  

115 Args: 

116 config: Parsed JSON configuration 

117  

118 Returns: 

119 bool: True if colors are valid 

120 """ 

121 valid = True 

122 

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 

128 

129 if not self._validate_color_value(color_name, color_value): 

130 valid = False 

131 

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) 

137 

138 return valid 

139 

140 def _validate_color_value(self, color_name: str, color_value: Any) -> bool: 

141 """ 

142 Validate a single color value. 

143  

144 Args: 

145 color_name: Name of the color 

146 color_value: Color value to validate 

147  

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 

154 

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 

159 

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 

165 

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 

170 

171 return True 

172 

173 def _validate_wcag_compliance(self, config: Dict[str, Any]) -> bool: 

174 """ 

175 Validate WCAG contrast ratio compliance. 

176  

177 Args: 

178 config: Parsed JSON configuration 

179  

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) 

186 

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 ] 

195 

196 min_ratio = 4.5 # WCAG AA standard 

197 all_valid = True 

198 

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 

207 

208 return all_valid 

209 

210 except Exception as e: 

211 self.validation_errors.append(f"WCAG validation error: {e}") 

212 return False 

213 

214 def _validate_theme_variants(self, config: Dict[str, Any]) -> bool: 

215 """ 

216 Validate theme variant configurations. 

217  

218 Args: 

219 config: Parsed JSON configuration 

220  

221 Returns: 

222 bool: True if theme variants are valid 

223 """ 

224 if "theme_variants" not in config: 

225 return True # Optional section 

226 

227 variants = config["theme_variants"] 

228 

229 for variant_name, variant_config in variants.items(): 

230 if variant_name.startswith("_"): 

231 continue # Skip metadata 

232 

233 # Validate variant colors 

234 for color_name, color_value in variant_config.items(): 

235 if color_name.startswith("_"): 

236 continue # Skip metadata 

237 

238 if not self._validate_color_value(f"{variant_name}.{color_name}", color_value): 

239 return False 

240 

241 return True 

242 

243 def _create_color_scheme_from_config(self, config: Dict[str, Any]) -> PyQt6ColorScheme: 

244 """ 

245 Create a PyQt6ColorScheme from configuration for validation. 

246  

247 Args: 

248 config: Parsed JSON configuration 

249  

250 Returns: 

251 PyQt6ColorScheme: Color scheme instance 

252 """ 

253 # Flatten all color sections into a single dict 

254 all_colors = {} 

255 

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) 

262 

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} 

266 

267 return PyQt6ColorScheme(**scheme_kwargs) 

268 

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. 

272  

273 Args: 

274 fg: Foreground color RGB tuple 

275 bg: Background color RGB tuple 

276  

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] 

282 

283 def gamma_correct(c): 

284 return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 

285 

286 r, g, b = map(gamma_correct, [r, g, b]) 

287 return 0.2126 * r + 0.7152 * g + 0.0722 * b 

288 

289 l1 = relative_luminance(fg) 

290 l2 = relative_luminance(bg) 

291 

292 if l1 < l2: 

293 l1, l2 = l2, l1 

294 

295 return (l1 + 0.05) / (l2 + 0.05) 

296 

297 def get_validation_report(self) -> Dict[str, List[str]]: 

298 """ 

299 Get the validation report with errors and warnings. 

300  

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 } 

308 

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}") 

315 

316 if self.validation_warnings: 

317 print("⚠️ Validation Warnings:") 

318 for warning in self.validation_warnings: 

319 print(f"{warning}") 

320 

321 if not self.validation_errors and not self.validation_warnings: 

322 print("✅ Validation passed with no errors or warnings")