1use super::layout::{LayoutEngine, LayoutError, GRID_SIZE};
6use super::palette::{Color, MaterialPalette, FORBIDDEN_PAIRINGS};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
11pub enum LintSeverity {
12 Info,
14 Warning,
16 Error,
18}
19
20impl std::fmt::Display for LintSeverity {
21 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22 match self {
23 Self::Error => write!(f, "ERROR"),
24 Self::Warning => write!(f, "WARNING"),
25 Self::Info => write!(f, "INFO"),
26 }
27 }
28}
29
30#[derive(Debug, Clone)]
32pub struct LintViolation {
33 pub rule: LintRule,
35 pub severity: LintSeverity,
37 pub message: String,
39 pub element_id: Option<String>,
41}
42
43impl LintViolation {
44 fn new(
46 rule: LintRule,
47 severity: LintSeverity,
48 message: String,
49 element_id: Option<&str>,
50 ) -> Self {
51 Self { rule, severity, message, element_id: element_id.map(str::to_string) }
52 }
53}
54
55impl std::fmt::Display for LintViolation {
56 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 if let Some(id) = &self.element_id {
58 write!(f, "[{}] {}: {} (element: {})", self.severity, self.rule, self.message, id)
59 } else {
60 write!(f, "[{}] {}: {}", self.severity, self.rule, self.message)
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum LintRule {
68 NoOverlap,
70 MaterialColors,
72 GridAlignment,
74 FileSize,
76 WithinBounds,
78 ContrastRatio,
80 StrokeConsistency,
82 MinTextSize,
84 MinStrokeWidth,
86 InternalPadding,
88 BlockGap,
90 ForbiddenPairing,
92}
93
94impl std::fmt::Display for LintRule {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 match self {
97 Self::NoOverlap => write!(f, "NO_OVERLAP"),
98 Self::MaterialColors => write!(f, "MATERIAL_COLORS"),
99 Self::GridAlignment => write!(f, "GRID_ALIGNMENT"),
100 Self::FileSize => write!(f, "FILE_SIZE"),
101 Self::WithinBounds => write!(f, "WITHIN_BOUNDS"),
102 Self::ContrastRatio => write!(f, "CONTRAST_RATIO"),
103 Self::StrokeConsistency => write!(f, "STROKE_CONSISTENCY"),
104 Self::MinTextSize => write!(f, "MIN_TEXT_SIZE"),
105 Self::MinStrokeWidth => write!(f, "MIN_STROKE_WIDTH"),
106 Self::InternalPadding => write!(f, "INTERNAL_PADDING"),
107 Self::BlockGap => write!(f, "BLOCK_GAP"),
108 Self::ForbiddenPairing => write!(f, "FORBIDDEN_PAIRING"),
109 }
110 }
111}
112
113#[derive(Debug, Clone)]
115pub struct LintConfig {
116 pub max_file_size: usize,
118 pub grid_size: f32,
120 pub min_text_size: f32,
122 pub min_contrast_ratio: f32,
124 pub check_material_colors: bool,
126 pub check_grid_alignment: bool,
128 pub min_stroke_width: f32,
130 pub min_internal_padding: f32,
132 pub min_block_gap: f32,
134 pub check_forbidden_pairings: bool,
136}
137
138impl LintConfig {
139 pub fn video_mode() -> Self {
141 Self {
142 max_file_size: 100_000,
143 grid_size: GRID_SIZE,
144 min_text_size: 18.0,
145 min_contrast_ratio: 4.5,
146 check_material_colors: false, check_grid_alignment: false, min_stroke_width: 2.0,
149 min_internal_padding: 20.0,
150 min_block_gap: 20.0,
151 check_forbidden_pairings: true,
152 }
153 }
154}
155
156impl Default for LintConfig {
157 fn default() -> Self {
158 Self {
159 max_file_size: 100_000, grid_size: GRID_SIZE,
161 min_text_size: 11.0, min_contrast_ratio: 4.5, check_material_colors: true,
164 check_grid_alignment: true,
165 min_stroke_width: 1.0,
166 min_internal_padding: 0.0,
167 min_block_gap: 0.0,
168 check_forbidden_pairings: false,
169 }
170 }
171}
172
173#[derive(Debug)]
175pub struct SvgLinter {
176 config: LintConfig,
177 palette: MaterialPalette,
178}
179
180impl SvgLinter {
181 pub fn new() -> Self {
183 Self { config: LintConfig::default(), palette: MaterialPalette::light() }
184 }
185
186 pub fn with_config(config: LintConfig) -> Self {
188 Self { config, palette: MaterialPalette::light() }
189 }
190
191 pub fn with_palette(mut self, palette: MaterialPalette) -> Self {
193 self.palette = palette;
194 self
195 }
196
197 pub fn lint_layout(&self, layout: &LayoutEngine) -> Vec<LintViolation> {
199 let mut violations = Vec::new();
200
201 for error in layout.validate() {
202 let violation = match error {
203 LayoutError::Overlap { id1, id2 } => LintViolation {
204 rule: LintRule::NoOverlap,
205 severity: LintSeverity::Error,
206 message: format!("Elements '{}' and '{}' overlap", id1, id2),
207 element_id: Some(id1),
208 },
209 LayoutError::OutOfBounds { id } => LintViolation {
210 rule: LintRule::WithinBounds,
211 severity: LintSeverity::Error,
212 message: "Element is outside viewport bounds".to_string(),
213 element_id: Some(id),
214 },
215 LayoutError::NotAligned { id } => {
216 if self.config.check_grid_alignment {
217 LintViolation {
218 rule: LintRule::GridAlignment,
219 severity: LintSeverity::Warning,
220 message: format!(
221 "Element is not aligned to {}px grid",
222 self.config.grid_size
223 ),
224 element_id: Some(id),
225 }
226 } else {
227 continue;
228 }
229 }
230 };
231 violations.push(violation);
232 }
233
234 violations
235 }
236
237 pub fn lint_color(&self, color: &Color, element_id: Option<&str>) -> Option<LintViolation> {
239 if !self.config.check_material_colors {
240 return None;
241 }
242
243 if !self.palette.is_valid_color(color) {
244 Some(LintViolation::new(
245 LintRule::MaterialColors,
246 LintSeverity::Warning,
247 format!("Color {} is not in the Material palette", color.to_css_hex()),
248 element_id,
249 ))
250 } else {
251 None
252 }
253 }
254
255 pub fn lint_file_size(&self, svg_content: &str) -> Option<LintViolation> {
257 if svg_content.len() > self.config.max_file_size {
258 Some(LintViolation {
259 rule: LintRule::FileSize,
260 severity: LintSeverity::Error,
261 message: format!(
262 "File size {} bytes exceeds maximum {} bytes",
263 svg_content.len(),
264 self.config.max_file_size
265 ),
266 element_id: None,
267 })
268 } else {
269 None
270 }
271 }
272
273 pub fn lint_text_size(&self, size: f32, element_id: Option<&str>) -> Option<LintViolation> {
275 if size < self.config.min_text_size {
276 Some(LintViolation::new(
277 LintRule::MinTextSize,
278 LintSeverity::Warning,
279 format!("Text size {}px is below minimum {}px", size, self.config.min_text_size),
280 element_id,
281 ))
282 } else {
283 None
284 }
285 }
286
287 fn relative_luminance(color: &Color) -> f64 {
289 fn channel_luminance(c: u8) -> f64 {
290 let c = c as f64 / 255.0;
291 if c <= 0.03928 {
292 c / 12.92
293 } else {
294 ((c + 0.055) / 1.055).powf(2.4)
295 }
296 }
297
298 let r = channel_luminance(color.r);
299 let g = channel_luminance(color.g);
300 let b = channel_luminance(color.b);
301
302 0.2126 * r + 0.7152 * g + 0.0722 * b
303 }
304
305 pub fn contrast_ratio(color1: &Color, color2: &Color) -> f64 {
307 let l1 = Self::relative_luminance(color1);
308 let l2 = Self::relative_luminance(color2);
309
310 let lighter = l1.max(l2);
311 let darker = l1.min(l2);
312
313 (lighter + 0.05) / (darker + 0.05)
314 }
315
316 pub fn lint_contrast(
318 &self,
319 foreground: &Color,
320 background: &Color,
321 element_id: Option<&str>,
322 ) -> Option<LintViolation> {
323 let ratio = Self::contrast_ratio(foreground, background);
324
325 if ratio < self.config.min_contrast_ratio as f64 {
326 Some(LintViolation::new(
327 LintRule::ContrastRatio,
328 LintSeverity::Warning,
329 format!(
330 "Contrast ratio {:.2}:1 is below minimum {:.1}:1 (WCAG AA)",
331 ratio, self.config.min_contrast_ratio
332 ),
333 element_id,
334 ))
335 } else {
336 None
337 }
338 }
339
340 pub fn lint_stroke_width(&self, width: f32, element_id: Option<&str>) -> Option<LintViolation> {
342 if width < self.config.min_stroke_width {
343 Some(LintViolation::new(
344 LintRule::MinStrokeWidth,
345 LintSeverity::Warning,
346 format!(
347 "Stroke width {}px is below minimum {}px",
348 width, self.config.min_stroke_width
349 ),
350 element_id,
351 ))
352 } else {
353 None
354 }
355 }
356
357 pub fn lint_internal_padding(
359 &self,
360 padding: f32,
361 element_id: Option<&str>,
362 ) -> Option<LintViolation> {
363 if self.config.min_internal_padding > 0.0 && padding < self.config.min_internal_padding {
364 Some(LintViolation::new(
365 LintRule::InternalPadding,
366 LintSeverity::Warning,
367 format!(
368 "Internal padding {}px is below minimum {}px",
369 padding, self.config.min_internal_padding
370 ),
371 element_id,
372 ))
373 } else {
374 None
375 }
376 }
377
378 pub fn lint_block_gap(&self, gap: f32, element_id: Option<&str>) -> Option<LintViolation> {
380 if self.config.min_block_gap > 0.0 && gap < self.config.min_block_gap {
381 Some(LintViolation::new(
382 LintRule::BlockGap,
383 LintSeverity::Warning,
384 format!("Block gap {}px is below minimum {}px", gap, self.config.min_block_gap),
385 element_id,
386 ))
387 } else {
388 None
389 }
390 }
391
392 pub fn lint_forbidden_pairing(
394 &self,
395 text: &Color,
396 bg: &Color,
397 element_id: Option<&str>,
398 ) -> Option<LintViolation> {
399 if !self.config.check_forbidden_pairings {
400 return None;
401 }
402
403 let text_hex = text.to_css_hex().to_lowercase();
404 let bg_hex = bg.to_css_hex().to_lowercase();
405
406 for (forbidden_text, forbidden_bg) in FORBIDDEN_PAIRINGS {
407 if text_hex == *forbidden_text && bg_hex == *forbidden_bg {
408 return Some(LintViolation::new(
409 LintRule::ForbiddenPairing,
410 LintSeverity::Error,
411 format!(
412 "Forbidden color pairing: {} on {} fails WCAG AA contrast",
413 text_hex, bg_hex
414 ),
415 element_id,
416 ));
417 }
418 }
419
420 None
421 }
422
423 pub fn lint_all(
425 &self,
426 layout: &LayoutEngine,
427 svg_content: &str,
428 colors: &[(&str, Color)],
429 text_sizes: &[(&str, f32)],
430 ) -> LintResult {
431 let mut violations = Vec::new();
432
433 violations.extend(self.lint_layout(layout));
435
436 if let Some(v) = self.lint_file_size(svg_content) {
438 violations.push(v);
439 }
440
441 for (id, color) in colors {
443 if let Some(v) = self.lint_color(color, Some(id)) {
444 violations.push(v);
445 }
446 }
447
448 for (id, size) in text_sizes {
450 if let Some(v) = self.lint_text_size(*size, Some(id)) {
451 violations.push(v);
452 }
453 }
454
455 LintResult::new(violations)
456 }
457}
458
459impl Default for SvgLinter {
460 fn default() -> Self {
461 Self::new()
462 }
463}
464
465#[derive(Debug)]
467pub struct LintResult {
468 pub violations: Vec<LintViolation>,
470}
471
472impl LintResult {
473 pub fn new(violations: Vec<LintViolation>) -> Self {
475 Self { violations }
476 }
477
478 pub fn has_errors(&self) -> bool {
480 self.violations.iter().any(|v| v.severity == LintSeverity::Error)
481 }
482
483 pub fn has_warnings(&self) -> bool {
485 self.violations.iter().any(|v| v.severity == LintSeverity::Warning)
486 }
487
488 pub fn passed(&self) -> bool {
490 !self.has_errors()
491 }
492
493 pub fn error_count(&self) -> usize {
495 self.violations.iter().filter(|v| v.severity == LintSeverity::Error).count()
496 }
497
498 pub fn warning_count(&self) -> usize {
500 self.violations.iter().filter(|v| v.severity == LintSeverity::Warning).count()
501 }
502
503 pub fn by_severity(&self, severity: LintSeverity) -> Vec<&LintViolation> {
505 self.violations.iter().filter(|v| v.severity == severity).collect()
506 }
507
508 pub fn by_rule(&self, rule: LintRule) -> Vec<&LintViolation> {
510 self.violations.iter().filter(|v| v.rule == rule).collect()
511 }
512}
513
514impl std::fmt::Display for LintResult {
515 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516 if self.violations.is_empty() {
517 return writeln!(f, "Lint passed: no violations");
518 }
519
520 writeln!(
521 f,
522 "Lint result: {} error(s), {} warning(s)",
523 self.error_count(),
524 self.warning_count()
525 )?;
526
527 for violation in &self.violations {
528 writeln!(f, " {}", violation)?;
529 }
530
531 Ok(())
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use crate::oracle::svg::layout::Viewport;
539 use crate::oracle::svg::shapes::Rect;
540
541 #[test]
542 fn test_lint_severity_order() {
543 assert!(LintSeverity::Error > LintSeverity::Warning);
544 assert!(LintSeverity::Warning > LintSeverity::Info);
545 }
546
547 #[test]
548 fn test_linter_creation() {
549 let linter = SvgLinter::new();
550 assert_eq!(linter.config.max_file_size, 100_000);
551 assert_eq!(linter.config.grid_size, 8.0);
552 }
553
554 #[test]
555 fn test_lint_color_valid() {
556 let linter = SvgLinter::new();
557 let palette = MaterialPalette::light();
558
559 let violation = linter.lint_color(&palette.primary, Some("test"));
561 assert!(violation.is_none());
562 }
563
564 #[test]
565 fn test_lint_color_invalid() {
566 let linter = SvgLinter::new();
567
568 let violation = linter.lint_color(&Color::rgb(1, 2, 3), Some("test"));
570 assert!(violation.is_some());
571 assert_eq!(violation.expect("unexpected failure").rule, LintRule::MaterialColors);
572 }
573
574 #[test]
575 fn test_lint_file_size_ok() {
576 let linter = SvgLinter::new();
577 let small_svg = "<svg></svg>";
578
579 let violation = linter.lint_file_size(small_svg);
580 assert!(violation.is_none());
581 }
582
583 #[test]
584 fn test_lint_file_size_too_large() {
585 let config = LintConfig { max_file_size: 10, ..Default::default() };
586 let linter = SvgLinter::with_config(config);
587
588 let violation = linter.lint_file_size("This is longer than 10 bytes");
589 assert!(violation.is_some());
590 assert_eq!(violation.expect("unexpected failure").rule, LintRule::FileSize);
591 }
592
593 #[test]
594 fn test_lint_text_size() {
595 let linter = SvgLinter::new();
596
597 let violation = linter.lint_text_size(8.0, Some("text1"));
599 assert!(violation.is_some());
600 assert_eq!(violation.expect("unexpected failure").rule, LintRule::MinTextSize);
601
602 let violation = linter.lint_text_size(14.0, Some("text2"));
604 assert!(violation.is_none());
605 }
606
607 #[test]
608 fn test_contrast_ratio_calculation() {
609 let ratio = SvgLinter::contrast_ratio(&Color::rgb(0, 0, 0), &Color::rgb(255, 255, 255));
611 assert!(ratio > 20.0 && ratio < 22.0);
612
613 let ratio =
615 SvgLinter::contrast_ratio(&Color::rgb(128, 128, 128), &Color::rgb(128, 128, 128));
616 assert!((ratio - 1.0).abs() < 0.01);
617 }
618
619 #[test]
620 fn test_lint_contrast() {
621 let linter = SvgLinter::new();
622
623 let violation =
625 linter.lint_contrast(&Color::rgb(0, 0, 0), &Color::rgb(255, 255, 255), Some("text"));
626 assert!(violation.is_none());
627
628 let violation = linter.lint_contrast(
630 &Color::rgb(200, 200, 200),
631 &Color::rgb(255, 255, 255),
632 Some("text"),
633 );
634 assert!(violation.is_some());
635 }
636
637 #[test]
638 fn test_lint_layout_overlap() {
639 let mut layout = LayoutEngine::new(Viewport::new(200.0, 200.0).with_padding(0.0));
640
641 layout.elements.insert(
643 "r1".to_string(),
644 super::super::layout::LayoutRect::new("r1", Rect::new(0.0, 0.0, 50.0, 50.0)),
645 );
646 layout.elements.insert(
647 "r2".to_string(),
648 super::super::layout::LayoutRect::new("r2", Rect::new(25.0, 25.0, 50.0, 50.0)),
649 );
650
651 let linter = SvgLinter::new();
652 let violations = linter.lint_layout(&layout);
653
654 assert!(violations.iter().any(|v| v.rule == LintRule::NoOverlap));
655 }
656
657 #[test]
658 fn test_lint_result() {
659 let violations = vec![
660 LintViolation {
661 rule: LintRule::NoOverlap,
662 severity: LintSeverity::Error,
663 message: "Overlap".to_string(),
664 element_id: Some("r1".to_string()),
665 },
666 LintViolation {
667 rule: LintRule::MaterialColors,
668 severity: LintSeverity::Warning,
669 message: "Bad color".to_string(),
670 element_id: Some("r2".to_string()),
671 },
672 ];
673
674 let result = LintResult::new(violations);
675
676 assert!(result.has_errors());
677 assert!(result.has_warnings());
678 assert!(!result.passed());
679 assert_eq!(result.error_count(), 1);
680 assert_eq!(result.warning_count(), 1);
681 }
682
683 #[test]
684 fn test_lint_result_passed() {
685 let result = LintResult::new(vec![]);
686 assert!(result.passed());
687 assert!(!result.has_errors());
688 }
689
690 #[test]
691 fn test_lint_result_display() {
692 let result = LintResult::new(vec![]);
693 let output = format!("{}", result);
694 assert!(output.contains("no violations"));
695 }
696
697 #[test]
698 fn test_lint_severity_display() {
699 assert_eq!(format!("{}", LintSeverity::Error), "ERROR");
700 assert_eq!(format!("{}", LintSeverity::Warning), "WARNING");
701 assert_eq!(format!("{}", LintSeverity::Info), "INFO");
702 }
703
704 #[test]
705 fn test_lint_rule_display() {
706 assert_eq!(format!("{}", LintRule::NoOverlap), "NO_OVERLAP");
707 assert_eq!(format!("{}", LintRule::MaterialColors), "MATERIAL_COLORS");
708 assert_eq!(format!("{}", LintRule::GridAlignment), "GRID_ALIGNMENT");
709 assert_eq!(format!("{}", LintRule::FileSize), "FILE_SIZE");
710 assert_eq!(format!("{}", LintRule::WithinBounds), "WITHIN_BOUNDS");
711 assert_eq!(format!("{}", LintRule::ContrastRatio), "CONTRAST_RATIO");
712 assert_eq!(format!("{}", LintRule::StrokeConsistency), "STROKE_CONSISTENCY");
713 assert_eq!(format!("{}", LintRule::MinTextSize), "MIN_TEXT_SIZE");
714 }
715
716 #[test]
717 fn test_lint_violation_display() {
718 let violation = LintViolation {
719 rule: LintRule::NoOverlap,
720 severity: LintSeverity::Error,
721 message: "Test message".to_string(),
722 element_id: Some("elem1".to_string()),
723 };
724 let output = format!("{}", violation);
725 assert!(output.contains("ERROR"));
726 assert!(output.contains("NO_OVERLAP"));
727 assert!(output.contains("Test message"));
728 assert!(output.contains("elem1"));
729 }
730
731 #[test]
732 fn test_lint_violation_display_no_element_id() {
733 let violation = LintViolation {
734 rule: LintRule::FileSize,
735 severity: LintSeverity::Warning,
736 message: "File too large".to_string(),
737 element_id: None,
738 };
739 let output = format!("{}", violation);
740 assert!(output.contains("FILE_SIZE"));
741 assert!(!output.contains("element:"));
742 }
743
744 #[test]
745 fn test_linter_with_palette() {
746 let linter = SvgLinter::new().with_palette(MaterialPalette::dark());
747 let dark_primary = MaterialPalette::dark().primary;
748 let violation = linter.lint_color(&dark_primary, None);
749 assert!(violation.is_none());
750 }
751
752 #[test]
753 fn test_lint_config_default_values() {
754 let config = LintConfig::default();
755 assert_eq!(config.max_file_size, 100_000);
756 assert_eq!(config.grid_size, 8.0);
757 assert_eq!(config.min_text_size, 11.0);
758 assert_eq!(config.min_contrast_ratio, 4.5);
759 assert!(config.check_material_colors);
760 assert!(config.check_grid_alignment);
761 }
762
763 #[test]
764 fn test_lint_result_by_severity() {
765 let violations = vec![
766 LintViolation {
767 rule: LintRule::NoOverlap,
768 severity: LintSeverity::Error,
769 message: "Error 1".to_string(),
770 element_id: None,
771 },
772 LintViolation {
773 rule: LintRule::MaterialColors,
774 severity: LintSeverity::Warning,
775 message: "Warn 1".to_string(),
776 element_id: None,
777 },
778 LintViolation {
779 rule: LintRule::FileSize,
780 severity: LintSeverity::Error,
781 message: "Error 2".to_string(),
782 element_id: None,
783 },
784 ];
785 let result = LintResult::new(violations);
786 let errors = result.by_severity(LintSeverity::Error);
787 let warnings = result.by_severity(LintSeverity::Warning);
788 assert_eq!(errors.len(), 2);
789 assert_eq!(warnings.len(), 1);
790 }
791
792 #[test]
793 fn test_lint_result_by_rule() {
794 let violations = vec![
795 LintViolation {
796 rule: LintRule::NoOverlap,
797 severity: LintSeverity::Error,
798 message: "Overlap 1".to_string(),
799 element_id: None,
800 },
801 LintViolation {
802 rule: LintRule::NoOverlap,
803 severity: LintSeverity::Error,
804 message: "Overlap 2".to_string(),
805 element_id: None,
806 },
807 LintViolation {
808 rule: LintRule::FileSize,
809 severity: LintSeverity::Error,
810 message: "Size".to_string(),
811 element_id: None,
812 },
813 ];
814 let result = LintResult::new(violations);
815 let overlaps = result.by_rule(LintRule::NoOverlap);
816 let sizes = result.by_rule(LintRule::FileSize);
817 assert_eq!(overlaps.len(), 2);
818 assert_eq!(sizes.len(), 1);
819 }
820
821 #[test]
822 fn test_linter_default() {
823 let linter = SvgLinter::default();
824 assert_eq!(linter.config.max_file_size, 100_000);
825 }
826
827 #[test]
828 fn test_lint_color_disabled() {
829 let config = LintConfig { check_material_colors: false, ..Default::default() };
830 let linter = SvgLinter::with_config(config);
831 let violation = linter.lint_color(&Color::rgb(1, 2, 3), Some("test"));
832 assert!(violation.is_none());
833 }
834
835 #[test]
836 fn test_lint_result_display_with_violations() {
837 let violations = vec![LintViolation {
838 rule: LintRule::NoOverlap,
839 severity: LintSeverity::Error,
840 message: "Overlap".to_string(),
841 element_id: None,
842 }];
843 let result = LintResult::new(violations);
844 let output = format!("{}", result);
845 assert!(output.contains("1 error(s)"));
846 }
847
848 #[test]
853 fn test_lint_all_clean() {
854 let linter = SvgLinter::new();
855 let layout = LayoutEngine::new(Viewport::new(800.0, 600.0).with_padding(16.0));
856 let palette = MaterialPalette::light();
857 let colors: Vec<(&str, Color)> = vec![("bg", palette.surface)];
858 let text_sizes: Vec<(&str, f32)> = vec![("title", 24.0)];
859
860 let result = linter.lint_all(&layout, "<svg></svg>", &colors, &text_sizes);
861 assert!(result.passed());
862 }
863
864 #[test]
865 fn test_lint_all_with_violations() {
866 let config = LintConfig {
867 max_file_size: 5, ..Default::default()
869 };
870 let linter = SvgLinter::with_config(config);
871 let layout = LayoutEngine::new(Viewport::new(800.0, 600.0).with_padding(16.0));
872 let colors: Vec<(&str, Color)> = vec![("bad", Color::rgb(1, 2, 3))];
873 let text_sizes: Vec<(&str, f32)> = vec![("tiny", 5.0)];
874
875 let result = linter.lint_all(&layout, "<svg>large content</svg>", &colors, &text_sizes);
876
877 assert!(!result.passed());
878 assert!(result.has_errors(), "Should have file size error");
880 assert!(result.has_warnings(), "Should have color + text size warnings");
881 }
882
883 #[test]
884 fn test_lint_all_empty_inputs() {
885 let linter = SvgLinter::new();
886 let layout = LayoutEngine::new(Viewport::new(800.0, 600.0).with_padding(16.0));
887 let colors: Vec<(&str, Color)> = vec![];
888 let text_sizes: Vec<(&str, f32)> = vec![];
889
890 let result = linter.lint_all(&layout, "", &colors, &text_sizes);
891 assert!(result.passed());
892 }
893
894 #[test]
899 fn test_lint_config_video_mode() {
900 let config = LintConfig::video_mode();
901 assert_eq!(config.min_text_size, 18.0);
902 assert_eq!(config.min_stroke_width, 2.0);
903 assert_eq!(config.min_contrast_ratio, 4.5);
904 assert_eq!(config.min_internal_padding, 20.0);
905 assert_eq!(config.min_block_gap, 20.0);
906 assert!(config.check_forbidden_pairings);
907 assert!(!config.check_material_colors);
908 assert!(!config.check_grid_alignment);
909 }
910
911 #[test]
912 fn test_lint_stroke_width_ok() {
913 let linter = SvgLinter::with_config(LintConfig::video_mode());
914 assert!(linter.lint_stroke_width(2.0, Some("rect1")).is_none());
915 assert!(linter.lint_stroke_width(3.0, None).is_none());
916 }
917
918 #[test]
919 fn test_lint_stroke_width_too_thin() {
920 let linter = SvgLinter::with_config(LintConfig::video_mode());
921 let violation = linter.lint_stroke_width(1.0, Some("rect1"));
922 assert!(violation.is_some());
923 assert_eq!(violation.expect("unexpected failure").rule, LintRule::MinStrokeWidth);
924 }
925
926 #[test]
927 fn test_lint_internal_padding_ok() {
928 let linter = SvgLinter::with_config(LintConfig::video_mode());
929 assert!(linter.lint_internal_padding(20.0, Some("box1")).is_none());
930 assert!(linter.lint_internal_padding(25.0, None).is_none());
931 }
932
933 #[test]
934 fn test_lint_internal_padding_too_small() {
935 let linter = SvgLinter::with_config(LintConfig::video_mode());
936 let violation = linter.lint_internal_padding(15.0, Some("box1"));
937 assert!(violation.is_some());
938 assert_eq!(violation.expect("unexpected failure").rule, LintRule::InternalPadding);
939 }
940
941 #[test]
942 fn test_lint_internal_padding_disabled() {
943 let linter = SvgLinter::new(); assert!(linter.lint_internal_padding(5.0, None).is_none());
945 }
946
947 #[test]
948 fn test_lint_block_gap_ok() {
949 let linter = SvgLinter::with_config(LintConfig::video_mode());
950 assert!(linter.lint_block_gap(20.0, Some("gap")).is_none());
951 assert!(linter.lint_block_gap(30.0, None).is_none());
952 }
953
954 #[test]
955 fn test_lint_block_gap_too_small() {
956 let linter = SvgLinter::with_config(LintConfig::video_mode());
957 let violation = linter.lint_block_gap(10.0, Some("gap"));
958 assert!(violation.is_some());
959 assert_eq!(violation.expect("unexpected failure").rule, LintRule::BlockGap);
960 }
961
962 #[test]
963 fn test_lint_block_gap_disabled() {
964 let linter = SvgLinter::new(); assert!(linter.lint_block_gap(5.0, None).is_none());
966 }
967
968 #[test]
969 fn test_lint_forbidden_pairing_detected() {
970 let linter = SvgLinter::with_config(LintConfig::video_mode());
971 let text = Color::from_hex("#64748b").expect("unexpected failure");
972 let bg = Color::from_hex("#0f172a").expect("unexpected failure");
973 let violation = linter.lint_forbidden_pairing(&text, &bg, Some("text1"));
974 assert!(violation.is_some());
975 assert_eq!(violation.expect("unexpected failure").rule, LintRule::ForbiddenPairing);
976 }
977
978 #[test]
979 fn test_lint_forbidden_pairing_all_forbidden() {
980 let linter = SvgLinter::with_config(LintConfig::video_mode());
981 for (text_hex, bg_hex) in super::super::palette::FORBIDDEN_PAIRINGS {
982 let text = Color::from_hex(text_hex).expect("unexpected failure");
983 let bg = Color::from_hex(bg_hex).expect("unexpected failure");
984 assert!(
985 linter.lint_forbidden_pairing(&text, &bg, None).is_some(),
986 "Expected forbidden pairing {} on {} to be detected",
987 text_hex,
988 bg_hex
989 );
990 }
991 }
992
993 #[test]
994 fn test_lint_forbidden_pairing_good_combo() {
995 let linter = SvgLinter::with_config(LintConfig::video_mode());
996 let text = Color::from_hex("#f1f5f9").expect("unexpected failure");
997 let bg = Color::from_hex("#0f172a").expect("unexpected failure");
998 assert!(linter.lint_forbidden_pairing(&text, &bg, None).is_none());
999 }
1000
1001 #[test]
1002 fn test_lint_forbidden_pairing_disabled() {
1003 let linter = SvgLinter::new(); let text = Color::from_hex("#64748b").expect("unexpected failure");
1005 let bg = Color::from_hex("#0f172a").expect("unexpected failure");
1006 assert!(linter.lint_forbidden_pairing(&text, &bg, None).is_none());
1007 }
1008
1009 #[test]
1010 fn test_lint_rule_display_new_rules() {
1011 assert_eq!(format!("{}", LintRule::MinStrokeWidth), "MIN_STROKE_WIDTH");
1012 assert_eq!(format!("{}", LintRule::InternalPadding), "INTERNAL_PADDING");
1013 assert_eq!(format!("{}", LintRule::BlockGap), "BLOCK_GAP");
1014 assert_eq!(format!("{}", LintRule::ForbiddenPairing), "FORBIDDEN_PAIRING");
1015 }
1016
1017 #[test]
1018 fn test_lint_video_mode_text_size_18px() {
1019 let linter = SvgLinter::with_config(LintConfig::video_mode());
1020 assert!(linter.lint_text_size(18.0, Some("label")).is_none());
1022 let violation = linter.lint_text_size(17.0, Some("small"));
1024 assert!(violation.is_some());
1025 assert_eq!(violation.expect("unexpected failure").rule, LintRule::MinTextSize);
1026 }
1027}