Skip to main content

qtcloud_devops_cli/
test.rs

1use std::path::Path;
2
3use crate::contract;
4
5/// 测试结果汇总。
6#[derive(Debug, Default)]
7pub struct TestSummary {
8    pub total: u32,
9    pub passed: u32,
10    pub failed: u32,
11    pub skipped: u32,
12}
13
14/// 覆盖率数据。
15#[derive(Debug, Default)]
16pub struct Coverage {
17    pub percentage: f64,
18    pub threshold: f64,
19}
20
21impl Coverage {
22    pub fn met(&self) -> bool {
23        self.percentage >= self.threshold
24    }
25}
26
27/// 按 scope 输出测试状态。
28pub fn status(repo_path: &Path, c: &contract::Contract) {
29    let scopes = contract::load_scopes(repo_path);
30
31    println!("测试状态");
32    println!("{}", "-".repeat(50));
33
34    if scopes.is_empty() {
35        let lang = contract::detect_by_files(repo_path);
36        let summary = collect_test_summary(repo_path, &lang);
37        let coverage = collect_coverage(repo_path, &lang, c.stages.test.threshold);
38        print_scope("(root)", &summary, &coverage);
39    } else {
40        for scope in &scopes {
41            let scope_dir = repo_path.join(&scope.dir);
42            if !scope_dir.exists() {
43                println!("  [{}]     ⚠ 目录不存在", scope.name);
44                continue;
45            }
46            let lang = c.resolve_language(scope, &scope_dir);
47            let summary = collect_test_summary(&scope_dir, &lang);
48            let threshold = c.scope_test_threshold(scope);
49            let coverage = collect_coverage(&scope_dir, &lang, threshold);
50            print_scope(&scope.name, &summary, &coverage);
51        }
52    }
53}
54
55fn print_scope(name: &str, summary: &TestSummary, coverage: &Coverage) {
56    let status_icon = if summary.failed > 0 {
57        "❌"
58    } else if summary.skipped > 0 {
59        "⚠"
60    } else if summary.total > 0 {
61        "✅"
62    } else {
63        "—"
64    };
65
66    let detail = if summary.total > 0 {
67        if summary.failed > 0 {
68            format!("{} / {} 失败", summary.failed, summary.total)
69        } else if summary.skipped > 0 {
70            format!(
71                "{} 通过 / {} 跳过 / {} 总计",
72                summary.passed, summary.skipped, summary.total
73            )
74        } else {
75            format!("{} ✅ 全部通过", summary.total)
76        }
77    } else {
78        "暂无测试".into()
79    };
80
81    println!("  [{:<12}] {}", name, status_icon);
82    println!("    测试数:       {}", detail);
83
84    let cov_icon = if coverage.met() {
85        "✅"
86    } else if coverage.percentage > 0.0 {
87        "⚠"
88    } else {
89        "—"
90    };
91    if coverage.percentage > 0.0 {
92        println!(
93            "    覆盖率:       {:.1}%{}(阈值 {}%)",
94            coverage.percentage, cov_icon, coverage.threshold,
95        );
96    } else {
97        println!("    覆盖率:       未检测到覆盖率报告");
98    }
99}
100
101/// 返回语言对应的测试命令和标签,None 表示不支持。
102fn test_command(lang: &contract::Language) -> Option<(&'static str, &'static [&'static str])> {
103    match lang {
104        contract::Language::Rust => Some(("cargo", &["test"])),
105        contract::Language::Python => Some(("python", &["-m", "pytest"])),
106        contract::Language::Go => Some(("go", &["test", "./..."])),
107        contract::Language::Dart => Some(("flutter", &["test"])),
108        contract::Language::TypeScript => Some(("npm", &["test"])),
109        contract::Language::Unknown(_) => None,
110    }
111}
112
113/// 返回语言对应的清单文件名(存在验证用),None 表示不需要验证。
114fn test_manifest_file(lang: &contract::Language) -> Option<&'static str> {
115    match lang {
116        contract::Language::Rust => Some("Cargo.toml"),
117        contract::Language::Python => Some("pyproject.toml"),
118        contract::Language::Go => Some("go.mod"),
119        contract::Language::Dart => Some("pubspec.yaml"),
120        contract::Language::TypeScript => Some("package.json"),
121        contract::Language::Unknown(_) => None,
122    }
123}
124
125/// 收集测试结果。
126///
127/// 按语言运行对应的测试命令,解析输出。
128fn collect_test_summary(dir: &Path, lang: &contract::Language) -> TestSummary {
129    let (cmd, args) = match test_command(lang) {
130        Some(x) => x,
131        None => return TestSummary::default(),
132    };
133    if let Some(mf) = test_manifest_file(lang) {
134        if !dir.join(mf).exists() {
135            return TestSummary::default();
136        }
137    }
138    let result = std::process::Command::new(cmd)
139        .args(args)
140        .current_dir(dir)
141        .output();
142    match result {
143        Ok(o) => {
144            let output = String::from_utf8_lossy(&o.stdout);
145            let errors = String::from_utf8_lossy(&o.stderr);
146            // Rust 的输出在 stdout,pytest 的输出在 stderr
147            let combined = format!("{}{}", output, errors);
148            parse_test_summary(&combined)
149        }
150        Err(_) => TestSummary::default(),
151    }
152}
153
154fn parse_test_summary(content: &str) -> TestSummary {
155    let mut passed = 0u32;
156    let mut failed = 0u32;
157    let mut skipped = 0u32;
158
159    for line in content.lines() {
160        if line.contains("test result:") {
161            for part in line.split(';') {
162                let p = part.trim();
163                let words: Vec<&str> = p.split_whitespace().collect();
164                if words.len() < 2 {
165                    continue;
166                }
167                let kind = words[words.len() - 1];
168                if let Ok(n) = words[words.len() - 2].parse::<u32>() {
169                    match kind {
170                        "passed" => passed += n,
171                        "failed" => failed += n,
172                        "ignored" => skipped += n,
173                        _ => {}
174                    }
175                }
176            }
177        }
178    }
179    let total = passed + failed + skipped;
180    TestSummary {
181        total,
182        passed,
183        failed,
184        skipped,
185    }
186}
187
188/// 收集覆盖率数据。
189///
190/// 按语言读取对应的覆盖率报告。
191fn collect_coverage(dir: &Path, lang: &contract::Language, threshold: f64) -> Coverage {
192    let paths: &[std::path::PathBuf] = match lang {
193        contract::Language::Rust => &[
194            dir.join("target/coverage/lcov.info"),
195            dir.join("coverage/lcov.info"),
196        ],
197        contract::Language::Python => &[dir.join("coverage.xml"), dir.join("htmlcov/coverage.xml")],
198        _ => {
199            return Coverage {
200                percentage: 0.0,
201                threshold,
202            }
203        }
204    };
205    for path in paths {
206        if path.exists() {
207            let content = std::fs::read_to_string(path).unwrap_or_default();
208            if let Some(pct) = parse_lcov_coverage(&content) {
209                return Coverage {
210                    percentage: pct,
211                    threshold,
212                };
213            }
214            if let Some(pct) = parse_cobertura_coverage(&content) {
215                return Coverage {
216                    percentage: pct,
217                    threshold,
218                };
219            }
220        }
221    }
222    Coverage {
223        percentage: 0.0,
224        threshold,
225    }
226}
227
228/// 从 lcov.info 解析覆盖率百分比。
229///
230/// lcov 格式:
231/// ```text
232/// SF:src/lib.rs
233/// DA:1,1
234/// DA:2,0
235/// end_of_record
236/// ```
237/// 覆盖率 = 命中行数 / 总行数
238fn parse_lcov_coverage(content: &str) -> Option<f64> {
239    let mut total_lines = 0u32;
240    let mut hit_lines = 0u32;
241
242    for line in content.lines() {
243        if let Some(rest) = line.strip_prefix("DA:") {
244            if let Some(count_str) = rest.split(',').nth(1) {
245                total_lines += 1;
246                if let Ok(count) = count_str.trim().parse::<u32>() {
247                    if count > 0 {
248                        hit_lines += 1;
249                    }
250                }
251            }
252        }
253    }
254
255    if total_lines == 0 {
256        None
257    } else {
258        Some((hit_lines as f64 / total_lines as f64) * 100.0)
259    }
260}
261
262/// 从 Cobertura XML 解析覆盖率百分比。
263///
264/// 格式:<coverage line-rate="0.85" ...>
265fn parse_cobertura_coverage(content: &str) -> Option<f64> {
266    for line in content.lines() {
267        if let Some(rest) = line.trim().strip_prefix("<coverage") {
268            if let Some(attr) = rest.split("line-rate=\"").nth(1) {
269                let val_str = attr.split('"').next()?;
270                let rate: f64 = val_str.parse().ok()?;
271                return Some(rate * 100.0);
272            }
273        }
274    }
275    None
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn test_parse_test_summary_ok() {
284        let s = parse_test_summary(
285            "test result: ok. 10 passed; 0 failed; 2 ignored; 0 measured; 12 filtered out",
286        );
287        assert_eq!(s.passed, 10);
288        assert_eq!(s.failed, 0);
289        assert_eq!(s.skipped, 2);
290        assert_eq!(s.total, 12);
291    }
292
293    #[test]
294    fn test_parse_test_summary_failed() {
295        let s =
296            parse_test_summary("test result: FAILED. 8 passed; 3 failed; 1 ignored; 0 measured");
297        assert_eq!(s.passed, 8);
298        assert_eq!(s.failed, 3);
299        assert_eq!(s.skipped, 1);
300    }
301
302    #[test]
303    fn test_parse_lcov_empty() {
304        assert!(parse_lcov_coverage("").is_none());
305    }
306
307    #[test]
308    fn test_parse_lcov_simple() {
309        let content = "SF:src/lib.rs\nDA:1,1\nDA:2,0\nDA:3,1\nend_of_record\n";
310        let pct = parse_lcov_coverage(content).unwrap();
311        assert!((pct - 66.666).abs() < 0.01);
312    }
313
314    #[test]
315    fn test_coverage_met() {
316        let c = Coverage {
317            percentage: 80.0,
318            threshold: 70.0,
319        };
320        assert!(c.met());
321    }
322
323    #[test]
324    fn test_parse_cobertura_simple() {
325        let content = r#"<coverage line-rate="0.85"></coverage>"#;
326        let pct = parse_cobertura_coverage(content).unwrap();
327        assert!((pct - 85.0).abs() < 0.01);
328    }
329
330    #[test]
331    fn test_coverage_not_met() {
332        let c = Coverage {
333            percentage: 60.0,
334            threshold: 70.0,
335        };
336        assert!(!c.met());
337    }
338
339    // ── test_command ──────────────────────────────────────────
340
341    #[test]
342    fn test_command_all_languages() {
343        assert_eq!(
344            test_command(&contract::Language::Rust),
345            Some(("cargo", &["test"][..]))
346        );
347        assert_eq!(
348            test_command(&contract::Language::Python),
349            Some(("python", &["-m", "pytest"][..]))
350        );
351        assert_eq!(
352            test_command(&contract::Language::Go),
353            Some(("go", &["test", "./..."][..]))
354        );
355        assert_eq!(
356            test_command(&contract::Language::Dart),
357            Some(("flutter", &["test"][..]))
358        );
359        assert_eq!(
360            test_command(&contract::Language::TypeScript),
361            Some(("npm", &["test"][..]))
362        );
363        assert_eq!(test_command(&contract::Language::Unknown("?".into())), None);
364    }
365
366    // ── test_manifest_file ────────────────────────────────────
367
368    #[test]
369    fn test_manifest_file_all_languages() {
370        assert_eq!(
371            test_manifest_file(&contract::Language::Rust),
372            Some("Cargo.toml")
373        );
374        assert_eq!(
375            test_manifest_file(&contract::Language::Python),
376            Some("pyproject.toml")
377        );
378        assert_eq!(test_manifest_file(&contract::Language::Go), Some("go.mod"));
379        assert_eq!(
380            test_manifest_file(&contract::Language::Dart),
381            Some("pubspec.yaml")
382        );
383        assert_eq!(
384            test_manifest_file(&contract::Language::TypeScript),
385            Some("package.json")
386        );
387        assert_eq!(
388            test_manifest_file(&contract::Language::Unknown("?".into())),
389            None
390        );
391    }
392}