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 = contract::resolve_language(scope, &scope_dir);
47            let summary = collect_test_summary(&scope_dir, &lang);
48            let threshold = contract::scope_test_threshold(c, 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/// 收集测试结果。
102///
103/// 按语言运行对应的测试命令,解析输出。
104fn collect_test_summary(dir: &Path, lang: &contract::Language) -> TestSummary {
105    let result = match lang {
106        contract::Language::Rust => {
107            if !dir.join("Cargo.toml").exists() {
108                return TestSummary::default();
109            }
110            std::process::Command::new("cargo")
111                .args(["test"])
112                .current_dir(dir)
113                .output()
114        }
115        contract::Language::Python => {
116            if !dir.join("pyproject.toml").exists() {
117                return TestSummary::default();
118            }
119            std::process::Command::new("python")
120                .args(["-m", "pytest"])
121                .current_dir(dir)
122                .output()
123        }
124        contract::Language::Go => {
125            if !dir.join("go.mod").exists() {
126                return TestSummary::default();
127            }
128            std::process::Command::new("go")
129                .args(["test", "./..."])
130                .current_dir(dir)
131                .output()
132        }
133        contract::Language::Dart => {
134            if !dir.join("pubspec.yaml").exists() {
135                return TestSummary::default();
136            }
137            std::process::Command::new("flutter")
138                .args(["test"])
139                .current_dir(dir)
140                .output()
141        }
142        contract::Language::TypeScript => {
143            if !dir.join("package.json").exists() {
144                return TestSummary::default();
145            }
146            std::process::Command::new("npm")
147                .args(["test"])
148                .current_dir(dir)
149                .output()
150        }
151        contract::Language::Unknown(_) => return TestSummary::default(),
152    };
153    match result {
154        Ok(o) => {
155            let output = String::from_utf8_lossy(&o.stdout);
156            let errors = String::from_utf8_lossy(&o.stderr);
157            // Rust 的输出在 stdout,pytest 的输出在 stderr
158            let combined = format!("{}{}", output, errors);
159            parse_test_summary(&combined)
160        }
161        Err(_) => TestSummary::default(),
162    }
163}
164
165fn parse_test_summary(content: &str) -> TestSummary {
166    let mut passed = 0u32;
167    let mut failed = 0u32;
168    let mut skipped = 0u32;
169
170    for line in content.lines() {
171        if line.contains("test result:") {
172            for part in line.split(';') {
173                let p = part.trim();
174                let words: Vec<&str> = p.split_whitespace().collect();
175                if words.len() < 2 {
176                    continue;
177                }
178                let kind = words[words.len() - 1];
179                if let Ok(n) = words[words.len() - 2].parse::<u32>() {
180                    match kind {
181                        "passed" => passed = n,
182                        "failed" => failed = n,
183                        "ignored" => skipped = n,
184                        _ => {}
185                    }
186                }
187            }
188        }
189    }
190    let total = passed + failed + skipped;
191    TestSummary {
192        total,
193        passed,
194        failed,
195        skipped,
196    }
197}
198
199/// 收集覆盖率数据。
200///
201/// 按语言读取对应的覆盖率报告。
202fn collect_coverage(dir: &Path, lang: &contract::Language, threshold: f64) -> Coverage {
203    let paths: &[std::path::PathBuf] = match lang {
204        contract::Language::Rust => &[
205            dir.join("target/coverage/lcov.info"),
206            dir.join("coverage/lcov.info"),
207        ],
208        contract::Language::Python => &[dir.join("coverage.xml"), dir.join("htmlcov/coverage.xml")],
209        _ => {
210            return Coverage {
211                percentage: 0.0,
212                threshold,
213            }
214        }
215    };
216    for path in paths {
217        if path.exists() {
218            let content = std::fs::read_to_string(path).unwrap_or_default();
219            if let Some(pct) = parse_lcov_coverage(&content) {
220                return Coverage {
221                    percentage: pct,
222                    threshold,
223                };
224            }
225            if let Some(pct) = parse_cobertura_coverage(&content) {
226                return Coverage {
227                    percentage: pct,
228                    threshold,
229                };
230            }
231        }
232    }
233    Coverage {
234        percentage: 0.0,
235        threshold,
236    }
237}
238
239/// 从 lcov.info 解析覆盖率百分比。
240///
241/// lcov 格式:
242/// ```text
243/// SF:src/lib.rs
244/// DA:1,1
245/// DA:2,0
246/// end_of_record
247/// ```
248/// 覆盖率 = 命中行数 / 总行数
249fn parse_lcov_coverage(content: &str) -> Option<f64> {
250    let mut total_lines = 0u32;
251    let mut hit_lines = 0u32;
252
253    for line in content.lines() {
254        if let Some(rest) = line.strip_prefix("DA:") {
255            if let Some(count_str) = rest.split(',').nth(1) {
256                total_lines += 1;
257                if let Ok(count) = count_str.trim().parse::<u32>() {
258                    if count > 0 {
259                        hit_lines += 1;
260                    }
261                }
262            }
263        }
264    }
265
266    if total_lines == 0 {
267        None
268    } else {
269        Some((hit_lines as f64 / total_lines as f64) * 100.0)
270    }
271}
272
273/// 从 Cobertura XML 解析覆盖率百分比。
274///
275/// 格式:<coverage line-rate="0.85" ...>
276fn parse_cobertura_coverage(content: &str) -> Option<f64> {
277    for line in content.lines() {
278        if let Some(rest) = line.trim().strip_prefix("<coverage") {
279            if let Some(attr) = rest.split("line-rate=\"").nth(1) {
280                let val_str = attr.split('"').next()?;
281                let rate: f64 = val_str.parse().ok()?;
282                return Some(rate * 100.0);
283            }
284        }
285    }
286    None
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    #[test]
294    fn test_parse_test_summary_ok() {
295        let s = parse_test_summary(
296            "test result: ok. 10 passed; 0 failed; 2 ignored; 0 measured; 12 filtered out",
297        );
298        assert_eq!(s.passed, 10);
299        assert_eq!(s.failed, 0);
300        assert_eq!(s.skipped, 2);
301        assert_eq!(s.total, 12);
302    }
303
304    #[test]
305    fn test_parse_test_summary_failed() {
306        let s =
307            parse_test_summary("test result: FAILED. 8 passed; 3 failed; 1 ignored; 0 measured");
308        assert_eq!(s.passed, 8);
309        assert_eq!(s.failed, 3);
310        assert_eq!(s.skipped, 1);
311    }
312
313    #[test]
314    fn test_parse_lcov_empty() {
315        assert!(parse_lcov_coverage("").is_none());
316    }
317
318    #[test]
319    fn test_parse_lcov_simple() {
320        let content = "SF:src/lib.rs\nDA:1,1\nDA:2,0\nDA:3,1\nend_of_record\n";
321        let pct = parse_lcov_coverage(content).unwrap();
322        assert!((pct - 66.666).abs() < 0.01);
323    }
324
325    #[test]
326    fn test_coverage_met() {
327        let c = Coverage {
328            percentage: 80.0,
329            threshold: 70.0,
330        };
331        assert!(c.met());
332    }
333
334    #[test]
335    fn test_parse_cobertura_simple() {
336        let content = r#"<coverage line-rate="0.85"></coverage>"#;
337        let pct = parse_cobertura_coverage(content).unwrap();
338        assert!((pct - 85.0).abs() < 0.01);
339    }
340
341    #[test]
342    fn test_coverage_not_met() {
343        let c = Coverage {
344            percentage: 60.0,
345            threshold: 70.0,
346        };
347        assert!(!c.met());
348    }
349}