1use std::path::{Path, PathBuf};
2
3use crate::contract;
4
5const TEST_SUMMARY_CACHE: &str = ".quanttide/devops/test-summary.json";
6
7#[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#[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
29pub fn status(repo_path: &Path, c: &contract::Contract) {
31 let _ = status_to(&mut std::io::stdout(), repo_path, c);
32}
33
34pub fn run(repo_path: &Path) -> Result<(), String> {
42 println!(" 运行测试...");
43 run_direct(repo_path)
44}
45
46fn 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 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 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#[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
141fn 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
164fn 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
179fn 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
215pub 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
312fn 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
324fn 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
336fn cache_path(dir: &Path) -> PathBuf {
338 dir.join(TEST_SUMMARY_CACHE)
339}
340
341fn 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
351fn 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
382fn 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
393pub 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
433fn 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
473fn 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
507fn 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 #[test]
621 fn test_parse_test_summary_large_output() {
622 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 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 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 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]
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 #[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]
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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[test]
1072 fn test_parse_lcov_da_with_non_numeric_count() {
1073 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 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 #[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 #[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 #[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 #[test]
1192 fn test_status_to_passing() {
1193 let d = tempfile::tempdir().unwrap();
1194 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}