Skip to main content

oparry_validators/
testing.rs

1//! Testing validator - Test coverage and quality checks
2
3use crate::Validator;
4use oparry_core::{Issue, IssueLevel, Result, ValidationResult};
5use oparry_parser::{ParsedCode, Language};
6use regex::Regex;
7use std::path::{Path, PathBuf};
8
9/// Testing validation configuration
10#[derive(Debug, Clone)]
11pub struct TestingConfig {
12    /// Minimum test coverage percentage
13    pub min_coverage: usize,
14    /// Require test files for source files
15    pub require_test_files: bool,
16    /// Block skipping tests
17    pub block_skip_tests: bool,
18    /// Require assertions in tests
19    pub require_assertions: bool,
20    /// Test file patterns
21    pub test_file_patterns: Vec<String>,
22}
23
24impl Default for TestingConfig {
25    fn default() -> Self {
26        Self {
27            min_coverage: 80,
28            require_test_files: true,
29            block_skip_tests: true,
30            require_assertions: true,
31            test_file_patterns: vec![
32                "*.test.ts".to_string(),
33                "*.test.tsx".to_string(),
34                "*.spec.ts".to_string(),
35                "_test.rs".to_string(),
36            ],
37        }
38    }
39}
40
41/// Testing validator
42pub struct TestingValidator {
43    config: TestingConfig,
44    skip_regex: Regex,
45    assertion_regex: Regex,
46    test_fn_regex: Regex,
47}
48
49impl TestingValidator {
50    pub fn new(config: TestingConfig) -> Self {
51        Self {
52            config,
53            skip_regex: Regex::new(r"(skip|describe\.skip|test\.skip|it\.skip)").unwrap(),
54            assertion_regex: Regex::new(r"(expect|assert|assertEq)").unwrap(),
55            test_fn_regex: Regex::new(r#"(test|it|describe)\s*\(['"]"#).unwrap(),
56        }
57    }
58
59    pub fn default_config() -> Self {
60        Self::new(TestingConfig::default())
61    }
62
63    fn check_skipped_tests(&self, source: &str, file: &str) -> Vec<Issue> {
64        let mut issues = Vec::new();
65        if !self.config.block_skip_tests {
66            return issues;
67        }
68
69        for (idx, line) in source.lines().enumerate() {
70            if self.skip_regex.is_match(line) {
71                issues.push(Issue::warning(
72                    "test-skipped",
73                    "Skipped test detected",
74                )
75                .with_file(file)
76                .with_line(idx + 1)
77                .with_suggestion("Remove skip or fix the test"));
78            }
79        }
80        issues
81    }
82
83    fn check_missing_assertions(&self, source: &str, file: &str) -> Vec<Issue> {
84        let mut issues = Vec::new();
85        if !self.config.require_assertions {
86            return issues;
87        }
88
89        let lines: Vec<&str> = source.lines().collect();
90        let mut in_test = false;
91        let mut test_has_assertion = false;
92
93        for (idx, line) in lines.iter().enumerate() {
94            if self.test_fn_regex.is_match(line) {
95                in_test = true;
96                test_has_assertion = false;
97            } else if in_test {
98                if self.assertion_regex.is_match(line) {
99                    test_has_assertion = true;
100                }
101
102                if line.contains("});") || line.contains(");") {
103                    if !test_has_assertion {
104                        issues.push(Issue::warning(
105                            "test-no-assertion",
106                            "Test without any assertion detected",
107                        )
108                        .with_file(file)
109                        .with_line(idx + 1)
110                        .with_suggestion("Add expect/assert to verify test behavior"));
111                    }
112                    in_test = false;
113                }
114            }
115        }
116        issues
117    }
118
119    fn is_test_file(&self, path: &str) -> bool {
120        path.contains(".test.") || path.contains(".spec.") || path.contains("_test.")
121    }
122}
123
124impl Validator for TestingValidator {
125    fn name(&self) -> &str {
126        "Testing"
127    }
128
129    fn supports(&self, _language: Language) -> bool {
130        true
131    }
132
133    fn validate_parsed(&self, code: &ParsedCode, file: &Path) -> Result<ValidationResult> {
134        let mut result = ValidationResult::new();
135        let source = code.source();
136        let file_str = file.to_string_lossy().to_string();
137
138        if self.is_test_file(&file_str) {
139            for issue in self.check_skipped_tests(source, &file_str) {
140                result.add_issue(issue);
141            }
142            for issue in self.check_missing_assertions(source, &file_str) {
143                result.add_issue(issue);
144            }
145        }
146
147        Ok(result)
148    }
149
150    fn validate_raw(&self, source: &str, file: &Path) -> Result<ValidationResult> {
151        let parsed = ParsedCode::Generic(source.to_string());
152        self.validate_parsed(&parsed, file)
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_testing_validator_valid() {
162        let validator = TestingValidator::default_config();
163        let code = r#"test("adds numbers", () => { expect(add(1, 2)).toBe(3); });"#;
164        let result = validator.validate_raw(code, Path::new("sum.test.ts")).unwrap();
165        assert!(result.passed);
166    }
167
168    #[test]
169    fn test_testing_skipped() {
170        let validator = TestingValidator::default_config();
171        let code = r#"test.skip("skipped test", () => { });"#;
172        let result = validator.validate_raw(code, Path::new("test.test.ts")).unwrap();
173        assert!(!result.passed || result.warning_count() >= 1);
174    }
175
176    #[test]
177    fn test_testing_config_default() {
178        let config = TestingConfig::default();
179        assert_eq!(config.min_coverage, 80);
180        assert!(config.require_test_files);
181    }
182
183    #[test]
184    fn test_testing_validator_supports() {
185        let validator = TestingValidator::default_config();
186        assert!(validator.supports(Language::TypeScript));
187        assert!(validator.supports(Language::Rust));
188    }
189}
190