Skip to main content

qtcloud_devops_cli/
test.rs

1use std::path::{Path, PathBuf};
2
3use crate::contract;
4
5const TEST_SUMMARY_CACHE: &str = ".quanttide/devops/test-summary.json";
6
7/// 测试结果汇总。
8#[derive(Debug, Default, serde::Serialize, serde::Deserialize)]
9pub struct TestSummary {
10    pub total: u32,
11    pub passed: u32,
12    pub failed: u32,
13    pub skipped: u32,
14}
15
16/// 覆盖率数据。
17#[derive(Debug, Default)]
18pub struct Coverage {
19    pub percentage: f64,
20    pub threshold: f64,
21}
22
23impl Coverage {
24    pub fn met(&self) -> bool {
25        self.percentage >= self.threshold
26    }
27}
28
29/// 按 scope 输出测试状态(写 stdout 的便捷封装)。
30pub fn status(repo_path: &Path, c: &contract::Contract) {
31    let _ = status_to(&mut std::io::stdout(), repo_path, c);
32}
33
34/// 在 Docker 容器中运行测试和覆盖率。
35///
36/// 容器隔离编译环境,崩溃不影响宿主机。
37/// 覆盖率报告写入容器挂载目录,`test status` 可直接读取。
38/// 运行测试和覆盖率。
39///
40/// 在宿主机直接执行。如需容器隔离请使用 `scripts/test-in-container.sh`。
41pub fn run(repo_path: &Path) -> Result<(), String> {
42    println!("  运行测试...");
43    run_direct(repo_path)
44}
45
46/// 直接在宿主机运行测试 + 覆盖率。
47fn run_direct(repo_path: &Path) -> Result<(), String> {
48    let c = crate::contract::load(repo_path);
49    let cwd = std::env::current_dir().unwrap_or_else(|_| repo_path.to_path_buf());
50    let scopes: Vec<_> = c
51        .scopes
52        .iter()
53        .filter(|s| {
54            let scope_abs = repo_path.join(&s.dir);
55            cwd.starts_with(&scope_abs) || scope_abs.starts_with(&cwd)
56        })
57        .collect();
58
59    let run_scoped = |dir: &Path, lang: &contract::Language| -> Result<(), String> {
60        match run_coverage_for_lang(dir, lang) {
61            Ok(true) => Ok(()),
62            // 覆盖率失败但测试可能已通过,回退到只跑测试
63            Ok(false) => {
64                let summary = collect_test_summary_from_run(dir, lang)?;
65                save_test_summary(dir, &summary);
66                Ok(())
67            }
68            Err(e) => {
69                // cargo llvm-cov 可能测试通过但覆盖率生成失败
70                let summary = collect_test_summary_from_run(dir, lang).unwrap_or_default();
71                if summary.failed == 0 && summary.total > 0 {
72                    save_test_summary(dir, &summary);
73                    return Ok(());
74                }
75                Err(e)
76            }
77        }
78    };
79
80    if scopes.is_empty() {
81        let lang = crate::contract::detect_by_files(repo_path);
82        run_scoped(repo_path, &lang)?;
83    } else {
84        for scope in &scopes {
85            let scope_dir = repo_path.join(&scope.dir);
86            if !scope_dir.exists() {
87                println!("  [{}]     ⚠ 目录不存在,跳过", scope.name);
88                continue;
89            }
90            let lang = c.resolve_language(scope, &scope_dir);
91            println!("  [{}] 运行测试...", scope.name);
92            run_scoped(&scope_dir, &lang)?;
93        }
94    }
95    println!("  ✅ 测试通过");
96    Ok(())
97}
98
99/// 在 repo 树中向上查找 Dockerfile。
100#[allow(dead_code)]
101fn run_tests_for_lang(dir: &Path, lang: &contract::Language) -> Result<(), String> {
102    let Some((cmd, args)) = test_command(lang) else {
103        println!("  ⚠ 不支持的语言: {:?},跳过", lang);
104        return Ok(());
105    };
106    let status = std::process::Command::new(cmd)
107        .args(args)
108        .current_dir(dir)
109        .status()
110        .map_err(|e| format!("启动 {} 失败: {}", cmd, e))?;
111    if status.success() {
112        println!("  ✅ {} 测试通过", cmd);
113        Ok(())
114    } else {
115        Err(format!("{} 测试失败", cmd))
116    }
117}
118
119fn coverage_command(lang: &contract::Language) -> Option<(&'static str, &'static [&'static str])> {
120    match lang {
121        contract::Language::Rust => Some((
122            "cargo",
123            &[
124                "llvm-cov",
125                "--lcov",
126                "--output-path",
127                "target/coverage/lcov.info",
128            ],
129        )),
130        contract::Language::Python => Some(("coverage", &["xml"])),
131        contract::Language::Go => Some((
132            "go",
133            &["tool", "cover", "-html=coverage.out", "-o", "coverage.html"],
134        )),
135        contract::Language::Dart => Some(("flutter", &["test", "--coverage"])),
136        contract::Language::TypeScript => Some(("npx", &["nyc", "--reporter=lcov", "npm", "test"])),
137        contract::Language::Unknown(_) => None,
138    }
139}
140
141/// 从 /proc/meminfo 读取 MemAvailable (kB),计算安全的并行编译 job 数。
142/// 公式:jobs = max(1, min(CPU核数, MemAvailable_GB / 1.5))
143fn safe_parallel_jobs() -> usize {
144    let mem_kb = std::fs::read_to_string("/proc/meminfo")
145        .ok()
146        .and_then(|s| {
147            s.lines().find_map(|l| {
148                if l.starts_with("MemAvailable:") {
149                    l.split_whitespace().nth(1)?.parse::<usize>().ok()
150                } else {
151                    None
152                }
153            })
154        })
155        .unwrap_or(4_194_304);
156    let mem_gb = mem_kb as f64 / 1_048_576.0;
157    let jobs_from_mem = (mem_gb / 1.5).floor() as usize;
158    let cpus = std::thread::available_parallelism()
159        .map(|n| n.get())
160        .unwrap_or(4);
161    jobs_from_mem.max(1).min(cpus)
162}
163
164/// 为 Rust 构建 cargo llvm-cov 参数(含自动并行度限制)。
165fn rust_coverage_args(jobs: usize) -> Vec<String> {
166    let mut args = vec![
167        "llvm-cov".to_string(),
168        "--lcov".to_string(),
169        "--output-path".to_string(),
170        "target/coverage/lcov.info".to_string(),
171    ];
172    if jobs > 0 {
173        args.push("-j".to_string());
174        args.push(jobs.to_string());
175    }
176    args
177}
178
179/// 生成覆盖率。返回 Ok(true) 表示该命令已一并运行了测试(如 cargo llvm-cov),
180/// 调用方可跳过单独的 run_tests_for_lang。Err 表示测试/覆盖率执行失败。
181fn run_coverage_for_lang(dir: &Path, lang: &contract::Language) -> Result<bool, String> {
182    let (cmd, args): (&str, Vec<String>) = match lang {
183        contract::Language::Rust => ("cargo", rust_coverage_args(safe_parallel_jobs())),
184        _ => {
185            let Some((c, a)) = coverage_command(lang) else {
186                println!("  ⚠ {:?} 覆盖率不可用,跳过", lang);
187                return Ok(false);
188            };
189            (c, a.iter().map(|s| s.to_string()).collect())
190        }
191    };
192    let handles_tests = matches!(lang, contract::Language::Rust);
193    println!("  生成覆盖率 ({})...", cmd);
194    match std::process::Command::new(cmd)
195        .args(&args)
196        .current_dir(dir)
197        .status()
198    {
199        Ok(s) if s.success() => {
200            println!("  ✅ 覆盖率已更新");
201            Ok(handles_tests)
202        }
203        Ok(_) if handles_tests => Err(format!("{} 测试失败", cmd)),
204        Ok(_) => {
205            println!("  ⚠ 覆盖率生成失败(可忽略)");
206            Ok(false)
207        }
208        Err(e) => {
209            println!("  ⚠ 覆盖率工具不可用: {}(可忽略)", e);
210            Ok(false)
211        }
212    }
213}
214
215/// 按 scope 输出测试状态,写入任意 writer。
216pub fn status_to(
217    writer: &mut impl std::io::Write,
218    repo_path: &Path,
219    c: &contract::Contract,
220) -> std::io::Result<()> {
221    let cwd = std::env::current_dir().unwrap_or_else(|_| repo_path.to_path_buf());
222    let scopes: Vec<_> = c
223        .scopes
224        .iter()
225        .filter(|s| {
226            let scope_abs = repo_path.join(&s.dir);
227            cwd.starts_with(&scope_abs) || scope_abs.starts_with(&cwd)
228        })
229        .collect();
230
231    writeln!(writer, "测试状态")?;
232    writeln!(writer, "{}", "-".repeat(50))?;
233
234    if scopes.is_empty() {
235        let lang = contract::detect_by_files(repo_path);
236        let summary = collect_test_summary(repo_path, &lang);
237        let coverage = collect_coverage(repo_path, &lang, c.stages.test.threshold);
238        print_scope(writer, "(root)", &summary, &coverage)?;
239    } else {
240        for scope in &scopes {
241            let scope_dir = repo_path.join(&scope.dir);
242            if !scope_dir.exists() {
243                writeln!(writer, "  [{}]     ⚠ 目录不存在", scope.name)?;
244                continue;
245            }
246            let lang = c.resolve_language(scope, &scope_dir);
247            let summary = collect_test_summary(&scope_dir, &lang);
248            let threshold = c.scope_test_threshold(scope);
249            let coverage = collect_coverage(&scope_dir, &lang, threshold);
250            print_scope(writer, &scope.name, &summary, &coverage)?;
251        }
252    }
253
254    Ok(())
255}
256
257fn print_scope(
258    writer: &mut impl std::io::Write,
259    name: &str,
260    summary: &TestSummary,
261    coverage: &Coverage,
262) -> std::io::Result<()> {
263    let status_icon = if summary.failed > 0 {
264        "❌"
265    } else if summary.skipped > 0 {
266        "⚠"
267    } else if summary.total > 0 {
268        "✅"
269    } else {
270        "—"
271    };
272
273    let detail = if summary.total > 0 {
274        if summary.failed > 0 {
275            format!("{} / {} 失败", summary.failed, summary.total)
276        } else if summary.skipped > 0 {
277            format!(
278                "{} 通过 / {} 跳过 / {} 总计",
279                summary.passed, summary.skipped, summary.total
280            )
281        } else {
282            format!("{} ✅ 全部通过", summary.total)
283        }
284    } else {
285        "暂无测试".into()
286    };
287
288    writeln!(writer, "  [{:<12}] {}", name, status_icon)?;
289    writeln!(writer, "    测试数:       {}", detail)?;
290
291    let cov_icon = if coverage.met() {
292        "✅"
293    } else if coverage.percentage > 0.0 {
294        "⚠"
295    } else {
296        "—"
297    };
298    if coverage.percentage > 0.0 {
299        writeln!(
300            writer,
301            "    覆盖率:       {:.1}%{}(阈值 {}%)",
302            coverage.percentage, cov_icon, coverage.threshold,
303        )?;
304    } else {
305        writeln!(writer, "    覆盖率:       未检测到覆盖率报告")?;
306        writeln!(writer, "                  运行 `cargo llvm-cov --lcov --output-path target/coverage/lcov.info` 生成")?;
307    }
308
309    Ok(())
310}
311
312/// 返回语言对应的测试命令和标签,None 表示不支持。
313fn test_command(lang: &contract::Language) -> Option<(&'static str, &'static [&'static str])> {
314    match lang {
315        contract::Language::Rust => Some(("cargo", &["test"])),
316        contract::Language::Python => Some(("python", &["-m", "pytest"])),
317        contract::Language::Go => Some(("go", &["test", "./..."])),
318        contract::Language::Dart => Some(("flutter", &["test"])),
319        contract::Language::TypeScript => Some(("npm", &["test"])),
320        contract::Language::Unknown(_) => None,
321    }
322}
323
324/// 返回语言对应的清单文件名(存在验证用),None 表示不需要验证。
325fn test_manifest_file(lang: &contract::Language) -> Option<&'static str> {
326    match lang {
327        contract::Language::Rust => Some("Cargo.toml"),
328        contract::Language::Python => Some("pyproject.toml"),
329        contract::Language::Go => Some("go.mod"),
330        contract::Language::Dart => Some("pubspec.yaml"),
331        contract::Language::TypeScript => Some("package.json"),
332        contract::Language::Unknown(_) => None,
333    }
334}
335
336/// 读取缓存的测试摘要路径。
337fn cache_path(dir: &Path) -> PathBuf {
338    dir.join(TEST_SUMMARY_CACHE)
339}
340
341/// 收集已缓存的测试结果(不运行测试)。
342fn collect_test_summary(dir: &Path, _lang: &contract::Language) -> TestSummary {
343    let cache = cache_path(dir);
344    let content = match std::fs::read_to_string(&cache) {
345        Ok(c) => c,
346        Err(_) => return TestSummary::default(),
347    };
348    serde_json::from_str(&content).unwrap_or_default()
349}
350
351/// 运行测试并收集结果。
352fn collect_test_summary_from_run(
353    dir: &Path,
354    lang: &contract::Language,
355) -> Result<TestSummary, String> {
356    let (cmd, args) = match test_command(lang) {
357        Some(x) => x,
358        None => return Ok(TestSummary::default()),
359    };
360    if let Some(mf) = test_manifest_file(lang) {
361        if !dir.join(mf).exists() {
362            return Ok(TestSummary::default());
363        }
364    }
365    let output = std::process::Command::new(cmd)
366        .args(args)
367        .current_dir(dir)
368        .output()
369        .map_err(|e| format!("启动 {} 失败: {}", cmd, e))?;
370    let combined = format!(
371        "{}{}",
372        String::from_utf8_lossy(&output.stdout),
373        String::from_utf8_lossy(&output.stderr),
374    );
375    let summary = parse_test_summary(&combined);
376    if !output.status.success() {
377        return Err(format!("{} 测试失败", cmd));
378    }
379    Ok(summary)
380}
381
382/// 保存测试摘要到缓存文件。
383fn save_test_summary(dir: &Path, summary: &TestSummary) {
384    let cache = cache_path(dir);
385    if let Some(parent) = cache.parent() {
386        std::fs::create_dir_all(parent).ok();
387    }
388    if let Ok(content) = serde_json::to_string(summary) {
389        std::fs::write(&cache, &content).ok();
390    }
391}
392
393/// 清除缓存的测试摘要。
394pub fn clear_cache(dir: &Path) {
395    let cache = cache_path(dir);
396    std::fs::remove_file(&cache).ok();
397}
398
399fn parse_test_summary(content: &str) -> TestSummary {
400    let mut passed = 0u32;
401    let mut failed = 0u32;
402    let mut skipped = 0u32;
403
404    for line in content.lines() {
405        if line.contains("test result:") {
406            for part in line.split(';') {
407                let p = part.trim();
408                let words: Vec<&str> = p.split_whitespace().collect();
409                if words.len() < 2 {
410                    continue;
411                }
412                let kind = words[words.len() - 1];
413                if let Ok(n) = words[words.len() - 2].parse::<u32>() {
414                    match kind {
415                        "passed" => passed += n,
416                        "failed" => failed += n,
417                        "ignored" => skipped += n,
418                        _ => {}
419                    }
420                }
421            }
422        }
423    }
424    let total = passed + failed + skipped;
425    TestSummary {
426        total,
427        passed,
428        failed,
429        skipped,
430    }
431}
432
433/// 收集覆盖率数据。
434///
435/// 按语言读取对应的覆盖率报告。
436fn collect_coverage(dir: &Path, lang: &contract::Language, threshold: f64) -> Coverage {
437    let paths: &[std::path::PathBuf] = match lang {
438        contract::Language::Rust => &[
439            dir.join("target/coverage/lcov.info"),
440            dir.join("coverage/lcov.info"),
441        ],
442        contract::Language::Python => &[dir.join("coverage.xml"), dir.join("htmlcov/coverage.xml")],
443        _ => {
444            return Coverage {
445                percentage: 0.0,
446                threshold,
447            }
448        }
449    };
450    for path in paths {
451        if path.exists() {
452            let content = std::fs::read_to_string(path).unwrap_or_default();
453            if let Some(pct) = parse_lcov_coverage(&content) {
454                return Coverage {
455                    percentage: pct,
456                    threshold,
457                };
458            }
459            if let Some(pct) = parse_cobertura_coverage(&content) {
460                return Coverage {
461                    percentage: pct,
462                    threshold,
463                };
464            }
465        }
466    }
467    Coverage {
468        percentage: 0.0,
469        threshold,
470    }
471}
472
473/// 从 lcov.info 解析覆盖率百分比。
474///
475/// lcov 格式:
476/// ```text
477/// SF:src/lib.rs
478/// DA:1,1
479/// DA:2,0
480/// end_of_record
481/// ```
482/// 覆盖率 = 命中行数 / 总行数
483fn parse_lcov_coverage(content: &str) -> Option<f64> {
484    let mut total_lines = 0u32;
485    let mut hit_lines = 0u32;
486
487    for line in content.lines() {
488        if let Some(rest) = line.strip_prefix("DA:") {
489            if let Some(count_str) = rest.split(',').nth(1) {
490                total_lines += 1;
491                if let Ok(count) = count_str.trim().parse::<u32>() {
492                    if count > 0 {
493                        hit_lines += 1;
494                    }
495                }
496            }
497        }
498    }
499
500    if total_lines == 0 {
501        None
502    } else {
503        Some((hit_lines as f64 / total_lines as f64) * 100.0)
504    }
505}
506
507/// 从 Cobertura XML 解析覆盖率百分比。
508///
509/// 格式:<coverage line-rate="0.85" ...>
510fn parse_cobertura_coverage(content: &str) -> Option<f64> {
511    for line in content.lines() {
512        if let Some(rest) = line.trim().strip_prefix("<coverage") {
513            if let Some(attr) = rest.split("line-rate=\"").nth(1) {
514                let val_str = attr.split('"').next()?;
515                let rate: f64 = val_str.parse().ok()?;
516                return Some(rate * 100.0);
517            }
518        }
519    }
520    None
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn test_parse_test_summary_ok() {
529        let s = parse_test_summary(
530            "test result: ok. 10 passed; 0 failed; 2 ignored; 0 measured; 12 filtered out",
531        );
532        assert_eq!(s.passed, 10);
533        assert_eq!(s.failed, 0);
534        assert_eq!(s.skipped, 2);
535        assert_eq!(s.total, 12);
536    }
537
538    #[test]
539    fn test_parse_test_summary_failed() {
540        let s =
541            parse_test_summary("test result: FAILED. 8 passed; 3 failed; 1 ignored; 0 measured");
542        assert_eq!(s.passed, 8);
543        assert_eq!(s.failed, 3);
544        assert_eq!(s.skipped, 1);
545    }
546
547    #[test]
548    fn test_parse_lcov_empty() {
549        assert!(parse_lcov_coverage("").is_none());
550    }
551
552    #[test]
553    fn test_parse_lcov_simple() {
554        let content = "SF:src/lib.rs\nDA:1,1\nDA:2,0\nDA:3,1\nend_of_record\n";
555        let pct = parse_lcov_coverage(content).unwrap();
556        assert!((pct - 66.666).abs() < 0.01);
557    }
558
559    #[test]
560    fn test_print_scope_skipped() {
561        let mut buf = Vec::new();
562        let s = TestSummary {
563            total: 10,
564            passed: 8,
565            failed: 0,
566            skipped: 2,
567        };
568        let c = Coverage {
569            percentage: 0.0,
570            threshold: 70.0,
571        };
572        print_scope(&mut buf, "test", &s, &c).unwrap();
573        let out = String::from_utf8_lossy(&buf);
574        assert!(out.contains("⚠"), "跳过应有 ⚠");
575    }
576
577    #[test]
578    fn test_print_scope_no_tests() {
579        let mut buf = Vec::new();
580        let s = TestSummary::default();
581        let c = Coverage {
582            percentage: 0.0,
583            threshold: 70.0,
584        };
585        print_scope(&mut buf, "test", &s, &c).unwrap();
586        let out = String::from_utf8_lossy(&buf);
587        assert!(out.contains("—"), "无测试应有 —");
588        assert!(out.contains("暂无测试"));
589    }
590
591    #[test]
592    fn test_print_scope_coverage_warn() {
593        let mut buf = Vec::new();
594        let s = TestSummary {
595            total: 10,
596            passed: 10,
597            failed: 0,
598            skipped: 0,
599        };
600        let c = Coverage {
601            percentage: 50.0,
602            threshold: 70.0,
603        };
604        print_scope(&mut buf, "test", &s, &c).unwrap();
605        let out = String::from_utf8_lossy(&buf);
606        assert!(out.contains("⚠"), "低于阈值应有 ⚠");
607    }
608
609    #[test]
610    fn test_coverage_met() {
611        let c = Coverage {
612            percentage: 80.0,
613            threshold: 70.0,
614        };
615        assert!(c.met());
616    }
617
618    // ── 性能测试(大输入边界) ──────────────────────────────────
619
620    #[test]
621    fn test_parse_test_summary_large_output() {
622        // 模拟 1000 组测试结果
623        let mut content = String::new();
624        for i in 0..500 {
625            content.push_str(&format!(
626                "test test_{i} ... ok\ntest test_{i}_a ... FAILED\n"
627            ));
628        }
629        content.push_str("test result: FAILED. 500 passed; 500 failed; 0 ignored; 0 measured\n");
630        let s = parse_test_summary(&content);
631        assert_eq!(s.passed, 500);
632        assert_eq!(s.failed, 500);
633        assert_eq!(s.total, 1000);
634    }
635
636    #[test]
637    fn test_parse_lcov_large_input() {
638        // 10000 DA 行的 lcov 输出
639        let mut lines = vec!["SF:src/lib.rs".to_string()];
640        for i in 0..5000 {
641            lines.push(format!("DA:{},1", i + 1));
642            lines.push(format!("DA:{},0", i + 5001));
643        }
644        lines.push("end_of_record".to_string());
645        let content = lines.join("\n");
646        let pct = parse_lcov_coverage(&content).unwrap();
647        assert!((pct - 50.0).abs() < 0.01, "10000 行应正确解析为 50%");
648    }
649
650    #[test]
651    fn test_parse_test_summary_very_large_stdout() {
652        // 大量非测试行(CI 日志、warnings)中间夹杂测试结果
653        let mut lines: Vec<String> = (0..2000)
654            .map(|i| format!("  Compiling crate-{} v0.1.0", i))
655            .collect();
656        lines.push("test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured".into());
657        for i in 2000..4000 {
658            lines.push(format!("warning: unused variable `x` in crate-{}", i));
659        }
660        let content = lines.join("\n");
661        let start = std::time::Instant::now();
662        let s = parse_test_summary(&content);
663        let elapsed = start.elapsed();
664        assert_eq!(s.passed, 1);
665        assert!(
666            elapsed.as_millis() < 500,
667            "4000 行日志应在 500ms 内解析完成: {}ms",
668            elapsed.as_millis()
669        );
670    }
671
672    #[test]
673    fn test_parse_lcov_no_match() {
674        // 大量不相关行,没有 DA: 前缀
675        let mut lines = vec!["TN:".to_string()];
676        for i in 0..5000 {
677            lines.push(format!("SF:src/file_{i}.rs"));
678            lines.push("end_of_record".to_string());
679        }
680        let content = lines.join("\n");
681        assert!(parse_lcov_coverage(&content).is_none());
682    }
683
684    #[test]
685    fn test_parse_cobertura_simple() {
686        let content = r#"<coverage line-rate="0.85"></coverage>"#;
687        let pct = parse_cobertura_coverage(content).unwrap();
688        assert!((pct - 85.0).abs() < 0.01);
689    }
690
691    #[test]
692    fn test_coverage_not_met() {
693        let c = Coverage {
694            percentage: 60.0,
695            threshold: 70.0,
696        };
697        assert!(!c.met());
698    }
699
700    // ── test_command ──────────────────────────────────────────
701
702    #[test]
703    fn test_command_all_languages() {
704        assert_eq!(
705            test_command(&contract::Language::Rust),
706            Some(("cargo", &["test"][..]))
707        );
708        assert_eq!(
709            test_command(&contract::Language::Python),
710            Some(("python", &["-m", "pytest"][..]))
711        );
712        assert_eq!(
713            test_command(&contract::Language::Go),
714            Some(("go", &["test", "./..."][..]))
715        );
716        assert_eq!(
717            test_command(&contract::Language::Dart),
718            Some(("flutter", &["test"][..]))
719        );
720        assert_eq!(
721            test_command(&contract::Language::TypeScript),
722            Some(("npm", &["test"][..]))
723        );
724        assert_eq!(test_command(&contract::Language::Unknown("?".into())), None);
725    }
726
727    // ── coverage_command ──────────────────────────────────
728
729    #[test]
730    fn test_coverage_command_all_languages() {
731        assert_eq!(
732            coverage_command(&contract::Language::Rust).map(|(c, _)| c),
733            Some("cargo")
734        );
735        assert_eq!(
736            coverage_command(&contract::Language::Python).map(|(c, _)| c),
737            Some("coverage")
738        );
739        assert_eq!(
740            coverage_command(&contract::Language::Go).map(|(c, _)| c),
741            Some("go")
742        );
743        assert_eq!(
744            coverage_command(&contract::Language::Dart).map(|(c, _)| c),
745            Some("flutter")
746        );
747        assert_eq!(
748            coverage_command(&contract::Language::TypeScript).map(|(c, _)| c),
749            Some("npx")
750        );
751        assert!(coverage_command(&contract::Language::Unknown("auto".into())).is_none());
752    }
753
754    // ── test_manifest_file ────────────────────────────────────
755
756    #[test]
757    fn test_manifest_file_all_languages() {
758        assert_eq!(
759            test_manifest_file(&contract::Language::Rust),
760            Some("Cargo.toml")
761        );
762        assert_eq!(
763            test_manifest_file(&contract::Language::Python),
764            Some("pyproject.toml")
765        );
766        assert_eq!(test_manifest_file(&contract::Language::Go), Some("go.mod"));
767        assert_eq!(
768            test_manifest_file(&contract::Language::Dart),
769            Some("pubspec.yaml")
770        );
771        assert_eq!(
772            test_manifest_file(&contract::Language::TypeScript),
773            Some("package.json")
774        );
775        assert_eq!(
776            test_manifest_file(&contract::Language::Unknown("?".into())),
777            None
778        );
779    }
780
781    // ── cache_path ────────────────────────────────────────────
782
783    #[test]
784    fn test_cache_path_resolves_in_dir() {
785        let d = tempfile::tempdir().unwrap();
786        let p = cache_path(d.path());
787        assert!(p.ends_with(".quanttide/devops/test-summary.json"));
788    }
789
790    #[test]
791    fn test_cache_path_absolute() {
792        let p = cache_path(Path::new("/tmp/myproject"));
793        assert_eq!(
794            p,
795            Path::new("/tmp/myproject/.quanttide/devops/test-summary.json")
796        );
797    }
798
799    // ── save / collect / clear cache ─────────────────────────
800
801    #[test]
802    fn test_save_and_collect_cache_roundtrip() {
803        let d = tempfile::tempdir().unwrap();
804        let summary = TestSummary {
805            total: 42,
806            passed: 40,
807            failed: 1,
808            skipped: 1,
809        };
810        save_test_summary(d.path(), &summary);
811        let cached = collect_test_summary(d.path(), &contract::Language::Rust);
812        assert_eq!(cached.total, 42);
813        assert_eq!(cached.passed, 40);
814        assert_eq!(cached.failed, 1);
815        assert_eq!(cached.skipped, 1);
816    }
817
818    #[test]
819    fn test_collect_cache_nonexistent_returns_default() {
820        let d = tempfile::tempdir().unwrap();
821        let summary = collect_test_summary(d.path(), &contract::Language::Rust);
822        assert_eq!(summary.total, 0);
823        assert_eq!(summary.passed, 0);
824    }
825
826    #[test]
827    fn test_clear_cache_removes_file() {
828        let d = tempfile::tempdir().unwrap();
829        save_test_summary(
830            d.path(),
831            &TestSummary {
832                total: 5,
833                ..Default::default()
834            },
835        );
836        assert!(cache_path(d.path()).exists());
837        clear_cache(d.path());
838        assert!(!cache_path(d.path()).exists());
839    }
840
841    // ── parse_test_summary 边缘情况 ───────────────────────
842
843    #[test]
844    fn test_parse_test_summary_empty() {
845        let s = parse_test_summary("");
846        assert_eq!(s.total, 0);
847    }
848
849    #[test]
850    fn test_parse_test_summary_no_result_line() {
851        let s = parse_test_summary("Compiling foo ...\n   Compiling bar ...\n");
852        assert_eq!(s.total, 0);
853    }
854
855    #[test]
856    fn test_parse_test_summary_malformed_skips_bad_tokens() {
857        // 'abc' 不是合法数字,应跳过
858        let s = parse_test_summary("test result: ok. abc passed; 0 failed");
859        assert_eq!(s.passed, 0);
860        assert_eq!(s.failed, 0);
861    }
862
863    #[test]
864    fn test_parse_test_summary_multiple_result_lines() {
865        // 工作空间多 crate 场景:每行一个 test result
866        let content = "test result: ok. 5 passed; 0 failed; 1 ignored\n\
867                       test result: ok. 3 passed; 1 failed; 0 ignored\n";
868        let s = parse_test_summary(content);
869        assert_eq!(s.passed, 8);
870        assert_eq!(s.failed, 1);
871        assert_eq!(s.skipped, 1);
872        assert_eq!(s.total, 10);
873    }
874
875    // ── collect_coverage 不存在文件时的行为 ────────────────
876
877    #[test]
878    fn test_collect_coverage_no_file_rust() {
879        let d = tempfile::tempdir().unwrap();
880        let cov = collect_coverage(d.path(), &contract::Language::Rust, 70.0);
881        assert_eq!(cov.percentage, 0.0);
882        assert_eq!(cov.threshold, 70.0);
883        assert!(!cov.met());
884    }
885
886    #[test]
887    fn test_collect_coverage_unknown_lang_no_paths() {
888        let d = tempfile::tempdir().unwrap();
889        let cov = collect_coverage(d.path(), &contract::Language::Unknown("x".into()), 80.0);
890        assert_eq!(cov.percentage, 0.0);
891        assert_eq!(cov.threshold, 80.0);
892    }
893
894    #[test]
895    fn test_collect_coverage_rust_with_lcov_file() {
896        let d = tempfile::tempdir().unwrap();
897        let cov_dir = d.path().join("target/coverage");
898        std::fs::create_dir_all(&cov_dir).unwrap();
899        std::fs::write(
900            cov_dir.join("lcov.info"),
901            "SF:src/lib.rs\nDA:1,1\nDA:2,0\nDA:3,1\nend_of_record\n",
902        )
903        .unwrap();
904        let cov = collect_coverage(d.path(), &contract::Language::Rust, 70.0);
905        assert!((cov.percentage - 66.666).abs() < 0.01);
906        assert!(!cov.met());
907    }
908
909    #[test]
910    fn test_collect_coverage_python_with_cobertura() {
911        let d = tempfile::tempdir().unwrap();
912        std::fs::write(
913            d.path().join("coverage.xml"),
914            r#"<coverage line-rate="0.92"></coverage>"#,
915        )
916        .unwrap();
917        let cov = collect_coverage(d.path(), &contract::Language::Python, 80.0);
918        assert!((cov.percentage - 92.0).abs() < 0.01);
919        assert!(cov.met());
920    }
921
922    // ── TestSummary 序列化/反序列化 ────────────────────────
923
924    #[test]
925    fn test_test_summary_serde_roundtrip() {
926        let s = TestSummary {
927            total: 100,
928            passed: 90,
929            failed: 5,
930            skipped: 5,
931        };
932        let json = serde_json::to_string(&s).unwrap();
933        let back: TestSummary = serde_json::from_str(&json).unwrap();
934        assert_eq!(back.total, 100);
935        assert_eq!(back.passed, 90);
936        assert_eq!(back.failed, 5);
937        assert_eq!(back.skipped, 5);
938    }
939
940    #[test]
941    fn test_test_summary_serde_default_roundtrip() {
942        let s = TestSummary::default();
943        let json = serde_json::to_string(&s).unwrap();
944        let back: TestSummary = serde_json::from_str(&json).unwrap();
945        assert_eq!(back.total, 0);
946    }
947
948    // ── Coverage 方法 ─────────────────────────────────────────
949
950    #[test]
951    fn test_coverage_met_exact() {
952        let c = Coverage {
953            percentage: 70.0,
954            threshold: 70.0,
955        };
956        assert!(c.met());
957    }
958
959    #[test]
960    fn test_coverage_met_zero_threshold() {
961        let c = Coverage {
962            percentage: 0.0,
963            threshold: 0.0,
964        };
965        assert!(c.met());
966    }
967
968    // ── status_to ──────────────────────────────────────────────
969
970    // ── print_scope 更多变体 ───────────────────────────────
971
972    #[test]
973    fn test_print_scope_all_passed_no_coverage() {
974        let mut buf = Vec::new();
975        let s = TestSummary {
976            total: 5,
977            passed: 5,
978            failed: 0,
979            skipped: 0,
980        };
981        let c = Coverage {
982            percentage: 0.0,
983            threshold: 70.0,
984        };
985        print_scope(&mut buf, "core", &s, &c).unwrap();
986        let out = String::from_utf8_lossy(&buf);
987        assert!(out.contains("✅"), "全部通过应有 ✅");
988        assert!(out.contains("未检测到覆盖率报告"));
989    }
990
991    #[test]
992    fn test_print_scope_with_coverage_met() {
993        let mut buf = Vec::new();
994        let s = TestSummary {
995            total: 10,
996            passed: 10,
997            failed: 0,
998            skipped: 0,
999        };
1000        let c = Coverage {
1001            percentage: 85.0,
1002            threshold: 70.0,
1003        };
1004        print_scope(&mut buf, "lib", &s, &c).unwrap();
1005        let out = String::from_utf8_lossy(&buf);
1006        assert!(out.contains("✅"), "满足阈值应有 ✅");
1007        assert!(out.contains("85.0%"));
1008    }
1009
1010    #[test]
1011    fn test_print_scope_all_failed() {
1012        let mut buf = Vec::new();
1013        let s = TestSummary {
1014            total: 3,
1015            passed: 0,
1016            failed: 3,
1017            skipped: 0,
1018        };
1019        let c = Coverage::default();
1020        print_scope(&mut buf, "test", &s, &c).unwrap();
1021        let out = String::from_utf8_lossy(&buf);
1022        assert!(out.contains("❌"), "全部失败应有 ❌");
1023        assert!(out.contains("3 / 3"));
1024    }
1025
1026    #[test]
1027    fn test_print_scope_coverage_below_threshold() {
1028        let mut buf = Vec::new();
1029        let s = TestSummary {
1030            total: 1,
1031            passed: 1,
1032            failed: 0,
1033            skipped: 0,
1034        };
1035        let c = Coverage {
1036            percentage: 30.0,
1037            threshold: 70.0,
1038        };
1039        print_scope(&mut buf, "lib", &s, &c).unwrap();
1040        let out = String::from_utf8_lossy(&buf);
1041        assert!(out.contains("⚠"), "低于阈值应有 ⚠");
1042        assert!(out.contains("30.0%"));
1043    }
1044
1045    // ── parse_test_summary 更多变体 ─────────────────────────
1046
1047    #[test]
1048    fn test_parse_test_summary_filtered_out() {
1049        let s =
1050            parse_test_summary("test result: ok. 5 passed; 0 failed; 0 ignored; 50 filtered out");
1051        assert_eq!(s.total, 5);
1052        assert_eq!(s.passed, 5);
1053    }
1054
1055    #[test]
1056    fn test_parse_test_summary_with_measured() {
1057        let s = parse_test_summary("test result: ok. 3 passed; 1 failed; 0 ignored; 2 measured");
1058        assert_eq!(s.total, 4);
1059        assert_eq!(s.passed, 3);
1060        assert_eq!(s.failed, 1);
1061    }
1062
1063    #[test]
1064    fn test_parse_test_summary_zero_all() {
1065        let s = parse_test_summary("test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured");
1066        assert_eq!(s.total, 0);
1067    }
1068
1069    // ── parse_lcov_coverage 边缘 ─────────────────────────────
1070
1071    #[test]
1072    fn test_parse_lcov_da_with_non_numeric_count() {
1073        // count 不是数字,行仍计为 total_lines 但不计入 hit
1074        let content = "DA:1,abc\nDA:2,1\nend_of_record\n";
1075        let pct = parse_lcov_coverage(content).unwrap();
1076        assert!((pct - 50.0).abs() < 0.01);
1077    }
1078
1079    #[test]
1080    fn test_parse_lcov_multiple_records() {
1081        // 多个 end_of_record 块,只累计 DA 行
1082        let content = "DA:1,1\nend_of_record\nSF:other.rs\nDA:2,0\nend_of_record\n";
1083        let pct = parse_lcov_coverage(content).unwrap();
1084        assert!((pct - 50.0).abs() < 0.01);
1085    }
1086
1087    // ── parse_cobertura_coverage 边缘 ────────────────────────
1088
1089    #[test]
1090    fn test_parse_cobertura_no_match() {
1091        assert!(parse_cobertura_coverage("<html></html>").is_none());
1092    }
1093
1094    #[test]
1095    fn test_parse_cobertura_no_line_rate() {
1096        assert!(parse_cobertura_coverage(r#"<coverage branch-rate="0.5"></coverage>"#).is_none());
1097    }
1098
1099    #[test]
1100    fn test_parse_cobertura_bad_line_rate() {
1101        assert!(parse_cobertura_coverage(r#"<coverage line-rate="abc"></coverage>"#).is_none());
1102    }
1103
1104    #[test]
1105    fn test_parse_cobertura_large_xml() {
1106        use std::time::Instant;
1107        let mut lines = vec![r#"<coverage line-rate="0.85">"#.to_string()];
1108        for i in 0..5000 {
1109            lines.push(format!(r#"<package name="pkg-{i}" line-rate="0.9"><class name="Cls{i}" filename="src/file{i}.rs" line-rate="0.9"/></package>"#));
1110        }
1111        lines.push("</coverage>".to_string());
1112        let content = lines.join("\n");
1113        let start = Instant::now();
1114        let pct = parse_cobertura_coverage(&content);
1115        let elapsed = start.elapsed();
1116        assert!((pct.unwrap() - 85.0).abs() < 0.01);
1117        assert!(
1118            elapsed.as_micros() < 5000,
1119            "5000 行 Cobertura 应在 5ms 内解析,实际: {}μs",
1120            elapsed.as_micros()
1121        );
1122    }
1123
1124    // ── TestSummary serde 零值 ─────────────────────────────
1125
1126    #[test]
1127    fn test_test_summary_serde_all_zero() {
1128        let s = TestSummary {
1129            total: 0,
1130            passed: 0,
1131            failed: 0,
1132            skipped: 0,
1133        };
1134        let json = serde_json::to_string(&s).unwrap();
1135        let back: TestSummary = serde_json::from_str(&json).unwrap();
1136        assert_eq!(back.total, 0);
1137    }
1138
1139    // ── scope 过滤性能 ────────────────────────────────────────
1140
1141    #[test]
1142    fn test_scope_filter_large_contract() {
1143        use std::time::Instant;
1144        let repo_path = Path::new("/tmp/repo");
1145        let cwd = Path::new("/tmp/repo/packages/cli");
1146        let mut scopes = Vec::new();
1147        for i in 0..1000 {
1148            scopes.push(contract::Scope {
1149                name: format!("scope-{}", i),
1150                dir: format!("packages/scope-{}", i),
1151                language: contract::Language::Unknown("?".into()),
1152                framework: String::new(),
1153                build_tool: contract::BuildTool::Unknown("?".into()),
1154                registry: contract::Registry::None,
1155                release: contract::StageRelease::default(),
1156                test_threshold: None,
1157                ci_workflow: None,
1158            });
1159        }
1160        scopes.push(contract::Scope {
1161            name: "cli".into(),
1162            dir: "packages/cli".into(),
1163            language: contract::Language::Rust,
1164            framework: String::new(),
1165            build_tool: contract::BuildTool::Cargo,
1166            registry: contract::Registry::Crates,
1167            release: contract::StageRelease::default(),
1168            test_threshold: None,
1169            ci_workflow: None,
1170        });
1171        let start = Instant::now();
1172        let filtered: Vec<_> = scopes
1173            .iter()
1174            .filter(|s| {
1175                let scope_abs = repo_path.join(&s.dir);
1176                cwd.starts_with(&scope_abs) || scope_abs.starts_with(&cwd)
1177            })
1178            .collect();
1179        let elapsed = start.elapsed();
1180        assert_eq!(filtered.len(), 1, "应只匹配一个");
1181        assert_eq!(filtered[0].name, "cli");
1182        assert!(
1183            elapsed.as_micros() < 5000,
1184            "1000 scope 过滤应 < 5ms,实际: {}μs",
1185            elapsed.as_micros()
1186        );
1187    }
1188
1189    // ── status_to ──────────────────────────────────────────────
1190
1191    #[test]
1192    fn test_status_to_passing() {
1193        let d = tempfile::tempdir().unwrap();
1194        // 创建一个真实的 Rust 项目,使得 cargo test 能运行并通过
1195        std::fs::write(
1196            d.path().join("Cargo.toml"),
1197            "[package]\nname = \"test\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
1198        )
1199        .unwrap();
1200        std::fs::create_dir_all(d.path().join("src")).unwrap();
1201        std::fs::write(d.path().join("src/lib.rs"), "#[test]\nfn it_works() {}\n").unwrap();
1202
1203        let c = contract::Contract::default();
1204        let mut buf = Vec::new();
1205        status_to(&mut buf, d.path(), &c).unwrap();
1206        let out = String::from_utf8_lossy(&buf);
1207
1208        assert!(out.contains("测试状态"));
1209        assert!(out.contains("全部通过") || out.contains("暂无测试"));
1210    }
1211
1212    #[test]
1213    fn test_status_to_empty() {
1214        let d = tempfile::tempdir().unwrap();
1215        let c = contract::Contract::default();
1216        let mut buf = Vec::new();
1217        status_to(&mut buf, d.path(), &c).unwrap();
1218        let out = String::from_utf8_lossy(&buf);
1219
1220        assert!(out.contains("测试状态"));
1221    }
1222}