oparry_validators/
testing.rs1use crate::Validator;
4use oparry_core::{Issue, IssueLevel, Result, ValidationResult};
5use oparry_parser::{ParsedCode, Language};
6use regex::Regex;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
11pub struct TestingConfig {
12 pub min_coverage: usize,
14 pub require_test_files: bool,
16 pub block_skip_tests: bool,
18 pub require_assertions: bool,
20 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
41pub 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