Skip to main content

batuta/oracle/svg/
lint.rs

1//! SVG Linting and Validation
2//!
3//! Rules for validating SVG diagrams against Material Design 3 guidelines.
4
5use super::layout::{LayoutEngine, LayoutError, GRID_SIZE};
6use super::palette::{Color, MaterialPalette, FORBIDDEN_PAIRINGS};
7
8/// Lint severity level
9/// Ordered from least to most severe for comparison purposes
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
11pub enum LintSeverity {
12    /// Info - suggestion
13    Info,
14    /// Warning - should fix
15    Warning,
16    /// Error - must fix
17    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/// A lint violation
31#[derive(Debug, Clone)]
32pub struct LintViolation {
33    /// Rule that was violated
34    pub rule: LintRule,
35    /// Severity
36    pub severity: LintSeverity,
37    /// Human-readable message
38    pub message: String,
39    /// Element ID if applicable
40    pub element_id: Option<String>,
41}
42
43impl LintViolation {
44    /// Create a violation with an optional element ID reference.
45    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/// Lint rule identifiers
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum LintRule {
68    /// No overlapping elements on same layer
69    NoOverlap,
70    /// All colors from Material palette
71    MaterialColors,
72    /// 8px grid alignment
73    GridAlignment,
74    /// File size under 100KB
75    FileSize,
76    /// Elements within viewport
77    WithinBounds,
78    /// Minimum contrast ratio
79    ContrastRatio,
80    /// Consistent stroke widths
81    StrokeConsistency,
82    /// Text minimum size
83    MinTextSize,
84    /// Minimum stroke width (video mode: >= 2px)
85    MinStrokeWidth,
86    /// Internal padding (video mode: >= 20px from box edge to content)
87    InternalPadding,
88    /// Block gap (video mode: >= 20px between stroked/filtered boxes)
89    BlockGap,
90    /// Forbidden color pairings that fail WCAG AA contrast
91    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/// SVG linter configuration
114#[derive(Debug, Clone)]
115pub struct LintConfig {
116    /// Maximum file size in bytes
117    pub max_file_size: usize,
118    /// Grid size for alignment checks
119    pub grid_size: f32,
120    /// Minimum text size in pixels
121    pub min_text_size: f32,
122    /// Minimum contrast ratio (WCAG AA is 4.5:1)
123    pub min_contrast_ratio: f32,
124    /// Check material colors
125    pub check_material_colors: bool,
126    /// Check grid alignment
127    pub check_grid_alignment: bool,
128    /// Minimum stroke width in pixels (video mode)
129    pub min_stroke_width: f32,
130    /// Minimum internal padding in pixels (video mode)
131    pub min_internal_padding: f32,
132    /// Minimum gap between blocks in pixels (video mode)
133    pub min_block_gap: f32,
134    /// Check forbidden color pairings (video mode)
135    pub check_forbidden_pairings: bool,
136}
137
138impl LintConfig {
139    /// Video-mode lint configuration with stricter rules for 1080p.
140    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, // Video uses VideoPalette
147            check_grid_alignment: false,  // Grid protocol handles alignment
148            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, // 100KB
160            grid_size: GRID_SIZE,
161            min_text_size: 11.0,     // Label small
162            min_contrast_ratio: 4.5, // WCAG AA
163            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/// SVG Linter
174#[derive(Debug)]
175pub struct SvgLinter {
176    config: LintConfig,
177    palette: MaterialPalette,
178}
179
180impl SvgLinter {
181    /// Create a new linter with default config
182    pub fn new() -> Self {
183        Self { config: LintConfig::default(), palette: MaterialPalette::light() }
184    }
185
186    /// Create with custom config
187    pub fn with_config(config: LintConfig) -> Self {
188        Self { config, palette: MaterialPalette::light() }
189    }
190
191    /// Set the palette to validate against
192    pub fn with_palette(mut self, palette: MaterialPalette) -> Self {
193        self.palette = palette;
194        self
195    }
196
197    /// Lint the layout engine for overlap and bounds issues
198    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    /// Check if a color is valid (in the material palette)
238    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    /// Check file size
256    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    /// Check text size
274    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    /// Calculate luminance for contrast ratio
288    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    /// Calculate contrast ratio between two colors
306    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    /// Check contrast ratio between foreground and background
317    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    /// Check stroke width (video mode: >= 2px).
341    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    /// Check internal padding (video mode: >= 20px).
358    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    /// Check gap between blocks (video mode: >= 20px).
379    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    /// Check if a text/background color pairing is in the forbidden list.
393    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    /// Run all lint checks and return violations
424    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        // Layout checks
434        violations.extend(self.lint_layout(layout));
435
436        // File size check
437        if let Some(v) = self.lint_file_size(svg_content) {
438            violations.push(v);
439        }
440
441        // Color checks
442        for (id, color) in colors {
443            if let Some(v) = self.lint_color(color, Some(id)) {
444                violations.push(v);
445            }
446        }
447
448        // Text size checks
449        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/// Result of linting
466#[derive(Debug)]
467pub struct LintResult {
468    /// All violations found
469    pub violations: Vec<LintViolation>,
470}
471
472impl LintResult {
473    /// Create a new lint result
474    pub fn new(violations: Vec<LintViolation>) -> Self {
475        Self { violations }
476    }
477
478    /// Check if there are any errors
479    pub fn has_errors(&self) -> bool {
480        self.violations.iter().any(|v| v.severity == LintSeverity::Error)
481    }
482
483    /// Check if there are any warnings
484    pub fn has_warnings(&self) -> bool {
485        self.violations.iter().any(|v| v.severity == LintSeverity::Warning)
486    }
487
488    /// Check if lint passed (no errors)
489    pub fn passed(&self) -> bool {
490        !self.has_errors()
491    }
492
493    /// Get error count
494    pub fn error_count(&self) -> usize {
495        self.violations.iter().filter(|v| v.severity == LintSeverity::Error).count()
496    }
497
498    /// Get warning count
499    pub fn warning_count(&self) -> usize {
500        self.violations.iter().filter(|v| v.severity == LintSeverity::Warning).count()
501    }
502
503    /// Get violations by severity
504    pub fn by_severity(&self, severity: LintSeverity) -> Vec<&LintViolation> {
505        self.violations.iter().filter(|v| v.severity == severity).collect()
506    }
507
508    /// Get violations by rule
509    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        // Valid color
560        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        // Invalid color (not in palette)
569        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        // Too small
598        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        // OK
603        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        // Black on white should be ~21:1
610        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        // Same colors should be 1:1
614        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        // Good contrast (black on white)
624        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        // Poor contrast (light gray on white)
629        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        // Add overlapping elements directly to test validation
642        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    // =========================================================================
849    // Coverage Gap Tests — lint_all
850    // =========================================================================
851
852    #[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, // tiny limit to trigger file size error
868            ..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        // Should have file size error + color warning + text size warning
879        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    // =========================================================================
895    // Video-Mode Lint Rule Tests
896    // =========================================================================
897
898    #[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(); // default has min_internal_padding = 0
944        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(); // default has min_block_gap = 0
965        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(); // default has check_forbidden_pairings = false
1004        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        // 18px should pass
1021        assert!(linter.lint_text_size(18.0, Some("label")).is_none());
1022        // 17px should fail
1023        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}