1use std::collections::BTreeMap;
4use std::fmt::Write as _;
5
6use fallow_engine::module_graph::ExportTestReach;
7use fallow_engine::mutation::{MutantDisposition, MutationPlan, PlannedMutant, PruneReason};
8use fallow_output::{
9 MutantLocation, MutantResult, MutantStatus, MutantTestReach, MutationBaselineFailure,
10 MutationCompletion, MutationFunctionResult, MutationIncompleteReason, MutationPlanCountsOutput,
11 MutationPlanDisposition, MutationPlanFile, MutationPlanMutant, MutationReport,
12 MutationRunIssue, MutationSkippedFile, MutationStatusCounts, MutationSummary, TestReachClass,
13};
14use fallow_types::envelope::ToolVersion;
15use rustc_hash::FxHashSet;
16
17use super::scheduler::ScheduleOutcome;
18use crate::ProgrammaticError;
19
20fn kebab<T: serde::Serialize>(value: &T) -> String {
22 serde_json::to_value(value)
23 .ok()
24 .and_then(|value| value.as_str().map(str::to_owned))
25 .unwrap_or_default()
26}
27
28pub fn tool_version() -> ToolVersion {
29 ToolVersion(env!("CARGO_PKG_VERSION").to_owned())
30}
31
32fn location(mutant: &PlannedMutant) -> MutantLocation {
33 MutantLocation {
34 mutant_id: mutant.mutant.id.as_str().to_owned(),
35 file: mutant.file.clone(),
36 line: mutant.mutant.line,
37 column: mutant.mutant.column + 1,
38 span: mutant.mutant.span,
39 operator: mutant.mutant.operator.as_str().to_owned(),
40 function: mutant
41 .function
42 .as_ref()
43 .and_then(|function| function.name.clone()),
44 function_line: mutant.function.as_ref().map(|function| function.line),
45 rank: mutant.rank,
46 }
47}
48
49fn prune_reason(mutant: &PlannedMutant) -> Option<String> {
50 match mutant.disposition {
51 MutantDisposition::Pruned(reason) => Some(kebab::<PruneReason>(&reason)),
52 _ => None,
53 }
54}
55
56fn test_reach(mutant: &PlannedMutant) -> Option<MutantTestReach> {
58 let reach = mutant.test_reach.as_ref()?;
59 Some(MutantTestReach {
60 reach: match reach.reach {
61 ExportTestReach::Direct => TestReachClass::Direct,
62 ExportTestReach::Transitive => TestReachClass::Transitive,
63 ExportTestReach::MockedOnly => TestReachClass::MockedOnly,
64 ExportTestReach::None => TestReachClass::None,
65 ExportTestReach::NoneFile => TestReachClass::NoneFile,
66 },
67 export_name: reach.export_name.clone(),
68 tests: reach.tests.clone(),
69 mocked_targets: reach.mocked_targets.clone(),
70 })
71}
72
73pub fn skipped_files(plan: &MutationPlan) -> Vec<MutationSkippedFile> {
75 plan.skipped_files
76 .iter()
77 .map(|file| MutationSkippedFile {
78 path: file.path.clone(),
79 reason: kebab(&file.reason),
80 })
81 .collect()
82}
83
84pub fn plan_mutants(plan: &MutationPlan) -> Vec<MutationPlanMutant> {
86 plan.mutants
87 .iter()
88 .map(|mutant| MutationPlanMutant {
89 location: location(mutant),
90 disposition: match mutant.disposition {
91 MutantDisposition::Planned => MutationPlanDisposition::Planned,
92 MutantDisposition::Pending => MutationPlanDisposition::Pending,
93 MutantDisposition::NoCoverage => MutationPlanDisposition::NoCoverage,
94 MutantDisposition::Pruned(_) => MutationPlanDisposition::Pruned,
95 },
96 prune_reason: prune_reason(mutant),
97 test_reach: (mutant.disposition == MutantDisposition::NoCoverage)
98 .then(|| test_reach(mutant))
99 .flatten(),
100 })
101 .collect()
102}
103
104pub const fn plan_counts(plan: &MutationPlan) -> MutationPlanCountsOutput {
106 MutationPlanCountsOutput {
107 total: plan.counts.total,
108 planned: plan.counts.planned,
109 pending: plan.counts.pending,
110 no_coverage: plan.counts.no_coverage,
111 pruned_dead_code: plan.counts.pruned.dead_code,
112 pruned_generated: plan.counts.pruned.generated,
113 pruned_side_effect_sink: plan.counts.pruned.side_effect_sink,
114 pruned_framework_config: plan.counts.pruned.framework_config,
115 }
116}
117
118pub fn plan_files(plan: &MutationPlan) -> Vec<MutationPlanFile> {
120 plan.files
121 .iter()
122 .map(|file| MutationPlanFile {
123 path: file.path.clone(),
124 source_sha256: file.source_sha256.clone(),
125 static_test_files: file.static_test_files.clone(),
126 })
127 .collect()
128}
129
130pub fn mutant_results(plan: &MutationPlan, outcome: Option<&ScheduleOutcome>) -> Vec<MutantResult> {
139 plan.mutants
140 .iter()
141 .map(|mutant| {
142 let mut result = mutant_result(mutant, outcome);
143 if result.status == MutantStatus::NoCoverage {
144 result.test_reach = test_reach(mutant);
145 }
146 result
147 })
148 .collect()
149}
150
151fn mutant_result(mutant: &PlannedMutant, outcome: Option<&ScheduleOutcome>) -> MutantResult {
152 let base = MutantResult {
153 location: location(mutant),
154 status: MutantStatus::Pending,
155 prune_reason: prune_reason(mutant),
156 killed_by: None,
157 covering_tests: Vec::new(),
158 static_mutant: false,
159 duration_ms: None,
160 test_reach: None,
161 retried: false,
162 cached: false,
163 type_error_codes: Vec::new(),
164 };
165 let scheduled = outcome.and_then(|outcome| outcome.results.get(mutant.mutant.id.as_str()));
166 match (mutant.disposition, scheduled) {
167 (MutantDisposition::Pruned(_), _) => MutantResult {
168 status: MutantStatus::Pruned,
169 ..base
170 },
171 (
172 MutantDisposition::Planned | MutantDisposition::Pending | MutantDisposition::NoCoverage,
173 Some(result),
174 ) => MutantResult {
175 status: result.status,
176 killed_by: result.killed_by.clone(),
177 covering_tests: result.covering_tests.clone(),
178 static_mutant: result.static_mutant,
179 duration_ms: result.duration_ms,
180 retried: result.retried,
181 cached: result.cached,
182 ..base
183 },
184 (MutantDisposition::NoCoverage, None) => MutantResult {
185 status: MutantStatus::NoCoverage,
186 ..base
187 },
188 (MutantDisposition::Planned | MutantDisposition::Pending, _) => base,
189 }
190}
191
192pub fn summary(results: &[MutantResult]) -> MutationSummary {
194 MutationSummary::from_results(results)
195}
196
197type FunctionKey = (String, Option<u32>, Option<(u32, u32)>);
200
201pub fn function_results(
204 plan: &MutationPlan,
205 results: &[MutantResult],
206) -> Vec<MutationFunctionResult> {
207 let mut groups: BTreeMap<FunctionKey, MutationFunctionResult> = BTreeMap::new();
208 for (mutant, result) in plan.mutants.iter().zip(results) {
209 let function = mutant.function.as_ref();
210 let key = (
211 mutant.file.clone(),
212 function.map(|function| function.line),
213 function.map(|function| function.span),
214 );
215 let group = groups.entry(key).or_insert_with(|| MutationFunctionResult {
216 file: mutant.file.clone(),
217 name: function.and_then(|function| function.name.clone()),
218 line: function.map(|function| function.line),
219 end_line: function.map(|function| function.end_line),
220 in_diff: function.is_some_and(|function| function.in_diff),
221 cyclomatic: function.and_then(|function| function.cyclomatic),
222 counts: MutationStatusCounts::default(),
223 score: None,
224 survived: Vec::new(),
225 });
226 group.counts.add(result.status);
227 if result.status == MutantStatus::Survived {
228 group.survived.push(result.location.mutant_id.clone());
229 }
230 }
231 groups
232 .into_values()
233 .map(|mut group| {
234 group.score = group.counts.score();
235 group
236 })
237 .collect()
238}
239
240pub struct CompletionFacts<'a> {
242 pub plan: &'a MutationPlan,
243 pub results: &'a [MutantResult],
244 pub baseline_failures: &'a [MutationBaselineFailure],
245 pub worktree_changed: bool,
246 pub worktree_unchecked: bool,
248 pub unretried: bool,
250 pub unloaded_paths: &'a [String],
252 pub typecheck_failed: bool,
254 pub promoted: &'a [String],
257}
258
259pub fn completion(facts: &CompletionFacts<'_>) -> MutationCompletion {
262 let mut reasons = Vec::new();
263 if facts
264 .results
265 .iter()
266 .any(|result| result.status == MutantStatus::RuntimeError)
267 {
268 reasons.push(MutationIncompleteReason::RuntimeError);
269 }
270 let promoted: FxHashSet<&str> = facts.promoted.iter().map(String::as_str).collect();
271 let mut limit_pending = false;
272 let mut budget_pending = false;
273 for (mutant, result) in facts.plan.mutants.iter().zip(facts.results) {
274 if result.status != MutantStatus::Pending {
275 continue;
276 }
277 if is_scheduled(mutant, &promoted) {
278 budget_pending = true;
279 } else if mutant.disposition == MutantDisposition::Pending {
280 limit_pending = true;
281 }
282 }
283 if limit_pending {
284 reasons.push(MutationIncompleteReason::MutantLimit);
285 }
286 if budget_pending || facts.unretried {
287 reasons.push(MutationIncompleteReason::TimeBudget);
288 }
289 if !facts.plan.skipped_files.is_empty() {
290 reasons.push(MutationIncompleteReason::SkippedFiles);
291 }
292 if has_unloaded_reached_file(facts.plan, facts.unloaded_paths, &promoted) {
293 reasons.push(MutationIncompleteReason::UnloadedFiles);
294 }
295 if !facts.baseline_failures.is_empty() {
296 reasons.push(MutationIncompleteReason::BaselineFailure);
297 }
298 if facts.worktree_changed {
299 reasons.push(MutationIncompleteReason::WorktreeChanged);
300 }
301 if facts.worktree_unchecked {
302 reasons.push(MutationIncompleteReason::WorktreeUnchecked);
303 }
304 if facts.typecheck_failed {
305 reasons.push(MutationIncompleteReason::TypecheckFailed);
306 }
307 MutationCompletion::from_reasons(reasons)
308}
309
310fn is_scheduled(mutant: &PlannedMutant, promoted: &FxHashSet<&str>) -> bool {
314 match mutant.disposition {
315 MutantDisposition::Planned => true,
316 MutantDisposition::Pending => promoted.contains(mutant.mutant.id.as_str()),
317 MutantDisposition::NoCoverage | MutantDisposition::Pruned(_) => false,
318 }
319}
320
321fn has_unloaded_reached_file(
326 plan: &MutationPlan,
327 unloaded_paths: &[String],
328 promoted: &FxHashSet<&str>,
329) -> bool {
330 if unloaded_paths.is_empty() {
331 return false;
332 }
333 let unloaded: FxHashSet<&str> = unloaded_paths.iter().map(String::as_str).collect();
334 plan.files.iter().any(|file| {
335 unloaded.contains(file.path.as_str())
336 && !file.static_test_files.is_empty()
337 && plan
338 .mutants
339 .iter()
340 .any(|mutant| mutant.file == file.path && is_scheduled(mutant, promoted))
341 })
342}
343
344#[must_use]
359pub fn baseline_blocked_run_error(report: &MutationReport) -> Option<ProgrammaticError> {
360 let counts = &report.summary.counts;
361 if has_test_result(counts) || counts.pending > 0 {
362 return None;
363 }
364 let first = report.baseline_failures.first()?;
365 let name = first
366 .test_id
367 .as_deref()
368 .or(first.file.as_deref())
369 .unwrap_or("a test file");
370 let mut message = format!(
371 "no mutant has a test result, because the test suite fails with no active mutant: {name}"
372 );
373 if let Some(line) = first.message.as_deref().and_then(first_message_line) {
374 message.push_str(": ");
375 message.push_str(line);
376 }
377 let others = report.baseline_failures.len() - 1;
378 if others > 0 {
379 let noun = if others == 1 { "failure" } else { "failures" };
380 let _ = write!(
381 message,
382 " ({others} more {noun}, see `baseline_failures` in the JSON report)"
383 );
384 }
385 Some(
386 ProgrammaticError::new(message, 2)
387 .with_code("FALLOW_MUTATION_BASELINE_FAILED")
388 .with_help(
389 "run the tests with the runner directly and make them pass, for example with the environment variables that the package test script sets, then run fallow mutation again",
390 ),
391 )
392}
393
394const fn has_test_result(counts: &MutationStatusCounts) -> bool {
397 counts.killed + counts.timeout + counts.survived + counts.flaky > 0
398}
399
400fn first_message_line(message: &str) -> Option<&str> {
402 message.lines().map(str::trim).find(|line| !line.is_empty())
403}
404
405pub fn baseline_failures(outcome: Option<&ScheduleOutcome>) -> Vec<MutationBaselineFailure> {
407 outcome
408 .map(|outcome| {
409 outcome
410 .baseline_failures
411 .iter()
412 .map(|failure| MutationBaselineFailure {
413 file: failure.file.clone(),
414 test_id: failure.test_id.clone(),
415 message: failure.message.clone(),
416 mutant_ids: failure.mutant_ids.clone(),
417 })
418 .collect()
419 })
420 .unwrap_or_default()
421}
422
423pub fn issues(outcome: Option<&ScheduleOutcome>) -> Vec<MutationRunIssue> {
425 outcome
426 .map(|outcome| {
427 outcome
428 .issues
429 .iter()
430 .map(|issue| MutationRunIssue {
431 code: issue.code.to_owned(),
432 message: issue.message.clone(),
433 mutant_id: issue.mutant_id.clone(),
434 stderr_tail: issue.stderr_tail.clone(),
435 })
436 .collect()
437 })
438 .unwrap_or_default()
439}
440
441#[cfg(test)]
442mod tests {
443 use super::super::scheduler::{MutantOutcome, ScheduleTimings};
444 use super::*;
445 use fallow_engine::mutation::{
446 FunctionFacts, Mutant, MutantId, MutationPlanCounts, Operator, PlannedFile, PruneCounts,
447 SkipReason, SkippedFile,
448 };
449 use rustc_hash::FxHashMap;
450
451 fn function(name: &str, line: u32) -> FunctionFacts {
452 FunctionFacts {
453 name: Some(name.to_owned()),
454 span: (line * 100, line * 100 + 50),
455 line,
456 end_line: line + 3,
457 cyclomatic: Some(2),
458 in_diff: true,
459 }
460 }
461
462 fn planned(
463 id: &str,
464 rank: Option<u32>,
465 disposition: MutantDisposition,
466 function: Option<FunctionFacts>,
467 ) -> PlannedMutant {
468 PlannedMutant {
469 mutant: Mutant {
470 id: MutantId(id.to_owned()),
471 operator: Operator::ArithmeticOperator,
472 span: (10, 15),
473 line: 2,
474 column: 9,
475 original: "a + b".to_owned(),
476 replacement: "a - b".to_owned(),
477 enclosing_function: None,
478 },
479 file: "src/a.ts".to_owned(),
480 rank,
481 disposition,
482 function,
483 test_reach: Some(fallow_engine::mutation::MutantTestReach {
484 reach: ExportTestReach::MockedOnly,
485 export_name: Some("add".to_owned()),
486 tests: vec!["test/a.test.ts".to_owned()],
487 mocked_targets: vec!["src/a.ts".to_owned()],
488 }),
489 }
490 }
491
492 fn plan(mutants: Vec<PlannedMutant>, pending: usize) -> MutationPlan {
493 MutationPlan {
494 files: vec![PlannedFile {
495 path: "src/a.ts".to_owned(),
496 source_sha256: "ab".repeat(32),
497 instrumented_code: "SECRET SOURCE".to_owned(),
498 static_test_files: vec!["test/a.test.ts".to_owned()],
499 }],
500 counts: MutationPlanCounts {
501 total: mutants.len(),
502 pending,
503 pruned: PruneCounts::default(),
504 ..MutationPlanCounts::default()
505 },
506 mutants,
507 test_files: vec!["test/a.test.ts".to_owned()],
508 reach_files: Vec::new(),
509 test_file_reach: BTreeMap::new(),
510 support_files: Vec::new(),
511 no_test_files: false,
512 framework_config_checked: false,
513 skipped_files: Vec::new(),
514 unmatched_paths: Vec::new(),
515 }
516 }
517
518 fn outcome(results: &[(&str, MutantStatus)]) -> ScheduleOutcome {
519 ScheduleOutcome {
520 ready: None,
521 results: results
522 .iter()
523 .map(|(id, status)| {
524 (
525 (*id).to_owned(),
526 MutantOutcome {
527 status: *status,
528 killed_by: (*status == MutantStatus::Killed)
529 .then(|| "test/a.test.ts::t1".to_owned()),
530 covering_tests: vec!["test/a.test.ts::t1".to_owned()],
531 static_mutant: false,
532 duration_ms: Some(4),
533 retried: matches!(
534 *status,
535 MutantStatus::Survived | MutantStatus::Timeout
536 ),
537 cached: false,
538 },
539 )
540 })
541 .collect::<FxHashMap<_, _>>(),
542 baseline_failures: Vec::new(),
543 unloaded_paths: Vec::new(),
544 issues: Vec::new(),
545 unretried: Vec::new(),
546 timings: ScheduleTimings::default(),
547 workers: 1,
548 cache_keys: FxHashMap::default(),
549 promoted: Vec::new(),
550 }
551 }
552
553 #[test]
554 fn a_pending_mutant_that_the_dry_run_never_hit_is_no_coverage() {
555 let plan = plan(
556 vec![
557 planned("m1", Some(1), MutantDisposition::Planned, None),
558 planned("m2", Some(2), MutantDisposition::Pending, None),
559 planned("m3", Some(3), MutantDisposition::Pending, None),
560 ],
561 2,
562 );
563 let mut outcome = outcome(&[
564 ("m1", MutantStatus::Killed),
565 ("m3", MutantStatus::NoCoverage),
566 ]);
567 if let Some(result) = outcome.results.get_mut("m3") {
568 result.covering_tests.clear();
569 }
570 let results = mutant_results(&plan, Some(&outcome));
571 let statuses: Vec<MutantStatus> = results.iter().map(|result| result.status).collect();
572 assert_eq!(
573 statuses,
574 vec![
575 MutantStatus::Killed,
576 MutantStatus::Pending,
577 MutantStatus::NoCoverage,
578 ]
579 );
580 let summary = summary(&results);
581 assert_eq!(summary.coverage, Some(2.0 / 3.0));
582 }
583
584 #[test]
585 fn a_promoted_pending_mutant_reports_its_run_and_the_limit_cuts_nothing() {
586 let plan = plan(
587 vec![
588 planned("m1", Some(1), MutantDisposition::Planned, None),
589 planned("m2", Some(2), MutantDisposition::Pending, None),
590 planned("m3", Some(3), MutantDisposition::Pending, None),
591 ],
592 2,
593 );
594 let reasons = |m2: MutantStatus| {
595 let mut outcome = outcome(&[
596 ("m1", MutantStatus::NoCoverage),
597 ("m2", m2),
598 ("m3", MutantStatus::NoCoverage),
599 ]);
600 outcome.promoted = vec!["m2".to_owned()];
601 let results = mutant_results(&plan, Some(&outcome));
602 let statuses: Vec<MutantStatus> = results.iter().map(|result| result.status).collect();
603 assert_eq!(
604 statuses,
605 vec![MutantStatus::NoCoverage, m2, MutantStatus::NoCoverage]
606 );
607 completion(&CompletionFacts {
608 plan: &plan,
609 results: &results,
610 baseline_failures: &[],
611 worktree_changed: false,
612 worktree_unchecked: false,
613 unretried: false,
614 unloaded_paths: &[],
615 typecheck_failed: false,
616 promoted: &outcome.promoted,
617 })
618 .reasons
619 };
620 assert_eq!(
621 reasons(MutantStatus::Killed),
622 Vec::<MutationIncompleteReason>::new(),
623 "each covered mutant ran, so the limit cut nothing"
624 );
625 assert_eq!(
626 reasons(MutantStatus::Pending),
627 vec![MutationIncompleteReason::TimeBudget],
628 "a promoted mutant that did not run before the deadline is a time budget miss"
629 );
630 }
631
632 #[test]
633 fn only_a_no_coverage_mutant_carries_its_test_reach() {
634 let plan = plan(
635 vec![
636 planned("m1", Some(1), MutantDisposition::Planned, None),
637 planned("m2", Some(2), MutantDisposition::Pending, None),
638 planned("m3", None, MutantDisposition::NoCoverage, None),
639 planned("m4", Some(3), MutantDisposition::Pending, None),
640 ],
641 2,
642 );
643 let mut outcome = outcome(&[
644 ("m1", MutantStatus::Killed),
645 ("m2", MutantStatus::NoCoverage),
646 ]);
647 if let Some(result) = outcome.results.get_mut("m2") {
648 result.covering_tests.clear();
649 }
650 let reach = |results: &[MutantResult]| -> Vec<(MutantStatus, Option<TestReachClass>)> {
651 results
652 .iter()
653 .map(|result| {
654 (
655 result.status,
656 result.test_reach.as_ref().map(|reach| reach.reach),
657 )
658 })
659 .collect()
660 };
661 let results = mutant_results(&plan, Some(&outcome));
662 let mocked = Some(TestReachClass::MockedOnly);
663 assert_eq!(
664 reach(&results),
665 vec![
666 (MutantStatus::Killed, None),
667 (MutantStatus::NoCoverage, mocked),
668 (MutantStatus::NoCoverage, mocked),
669 (MutantStatus::Pending, None),
670 ]
671 );
672 let entry = results[2].test_reach.as_ref().expect("a test reach");
673 assert_eq!(entry.export_name.as_deref(), Some("add"));
674 assert_eq!(entry.mocked_targets, vec!["src/a.ts"]);
675 let value = serde_json::to_value(&results[2]).expect("serializes");
676 assert_eq!(value["test_reach"]["reach"], "mocked-only");
677 assert!(serde_json::to_value(&results[0]).expect("serializes")["test_reach"].is_null());
678
679 let entries = plan_mutants(&plan);
680 let plan_reach: Vec<Option<TestReachClass>> = entries
681 .iter()
682 .map(|entry| entry.test_reach.as_ref().map(|reach| reach.reach))
683 .collect();
684 assert_eq!(plan_reach, vec![None, None, mocked, None]);
685 }
686
687 #[test]
688 fn plan_dispositions_and_schedule_outcomes_merge_into_one_status_each() {
689 let plan = plan(
690 vec![
691 planned(
692 "m1",
693 Some(1),
694 MutantDisposition::Planned,
695 Some(function("add", 1)),
696 ),
697 planned(
698 "m2",
699 Some(2),
700 MutantDisposition::Planned,
701 Some(function("add", 1)),
702 ),
703 planned("m3", Some(3), MutantDisposition::Planned, None),
704 planned("m4", Some(4), MutantDisposition::Pending, None),
705 planned(
706 "m5",
707 None,
708 MutantDisposition::NoCoverage,
709 Some(function("sub", 9)),
710 ),
711 planned(
712 "m6",
713 None,
714 MutantDisposition::Pruned(PruneReason::SideEffectSink),
715 Some(function("sub", 9)),
716 ),
717 ],
718 1,
719 );
720 let outcome = outcome(&[("m1", MutantStatus::Killed), ("m2", MutantStatus::Survived)]);
721 let results = mutant_results(&plan, Some(&outcome));
722 let statuses: Vec<MutantStatus> = results.iter().map(|result| result.status).collect();
723 assert_eq!(
724 statuses,
725 vec![
726 MutantStatus::Killed,
727 MutantStatus::Survived,
728 MutantStatus::Pending,
729 MutantStatus::Pending,
730 MutantStatus::NoCoverage,
731 MutantStatus::Pruned,
732 ]
733 );
734 assert_eq!(results[0].killed_by.as_deref(), Some("test/a.test.ts::t1"));
735 assert_eq!(results[0].location.column, 10, "the column is 1-based");
736 assert_eq!(results[5].prune_reason.as_deref(), Some("side-effect-sink"));
737 assert_eq!(
738 results
739 .iter()
740 .map(|result| result.retried)
741 .collect::<Vec<_>>(),
742 vec![false, true, false, false, false, false],
743 "only the retried survivor carries the flag"
744 );
745
746 let summary = summary(&results);
747 assert_eq!(summary.counts.total, 6);
748 assert_eq!(
749 summary.score,
750 Some(0.5),
751 "killed / (killed + survived); no_coverage stays out"
752 );
753 assert_eq!(
754 summary.coverage,
755 Some(0.8),
756 "covered / (covered + no_coverage); the pending mutants count as covered"
757 );
758
759 let completion = completion(&CompletionFacts {
760 plan: &plan,
761 results: &results,
762 baseline_failures: &[],
763 worktree_changed: false,
764 worktree_unchecked: false,
765 unretried: false,
766 unloaded_paths: &[],
767 typecheck_failed: false,
768 promoted: &[],
769 });
770 assert_eq!(
771 completion.reasons,
772 vec![
773 MutationIncompleteReason::MutantLimit,
774 MutationIncompleteReason::TimeBudget
775 ],
776 "m3 is planned with no outcome, m4 is after the limit"
777 );
778 }
779
780 #[test]
781 fn functions_roll_up_by_file_and_span_with_their_survivors() {
782 let plan = plan(
783 vec![
784 planned(
785 "m1",
786 Some(1),
787 MutantDisposition::Planned,
788 Some(function("add", 1)),
789 ),
790 planned(
791 "m2",
792 Some(2),
793 MutantDisposition::Planned,
794 Some(function("add", 1)),
795 ),
796 planned("m3", Some(3), MutantDisposition::Planned, None),
797 planned(
798 "m4",
799 Some(4),
800 MutantDisposition::Planned,
801 Some(function("sub", 9)),
802 ),
803 ],
804 0,
805 );
806 let outcome = outcome(&[
807 ("m1", MutantStatus::Killed),
808 ("m2", MutantStatus::Survived),
809 ("m3", MutantStatus::Timeout),
810 ("m4", MutantStatus::Survived),
811 ]);
812 let results = mutant_results(&plan, Some(&outcome));
813 let functions = function_results(&plan, &results);
814 type Shape<'a> = (
815 Option<&'a str>,
816 Option<u32>,
817 usize,
818 Option<f64>,
819 Vec<&'a str>,
820 );
821 let shape: Vec<Shape<'_>> = functions
822 .iter()
823 .map(|function| {
824 (
825 function.name.as_deref(),
826 function.line,
827 function.counts.total,
828 function.score,
829 function.survived.iter().map(String::as_str).collect(),
830 )
831 })
832 .collect();
833 assert_eq!(
834 shape,
835 vec![
836 (None, None, 1, Some(1.0), vec![]),
837 (Some("add"), Some(1), 2, Some(0.5), vec!["m2"]),
838 (Some("sub"), Some(9), 1, Some(0.0), vec!["m4"]),
839 ]
840 );
841 assert!(functions[1].in_diff);
842 assert_eq!(functions[1].end_line, Some(4));
843 assert_eq!(
844 completion(&CompletionFacts {
845 plan: &plan,
846 results: &results,
847 baseline_failures: &[],
848 worktree_changed: false,
849 worktree_unchecked: false,
850 unretried: false,
851 unloaded_paths: &[],
852 typecheck_failed: false,
853 promoted: &[],
854 })
855 .reasons,
856 Vec::<MutationIncompleteReason>::new()
857 );
858 }
859
860 #[test]
861 fn runtime_errors_skipped_files_baseline_failures_and_worktree_changes_make_a_run_partial() {
862 let mut plan = plan(
863 vec![planned("m1", Some(1), MutantDisposition::Planned, None)],
864 0,
865 );
866 plan.skipped_files.push(SkippedFile {
867 path: "src/big.ts".to_owned(),
868 reason: SkipReason::NotAnalyzed,
869 });
870 let outcome = outcome(&[("m1", MutantStatus::RuntimeError)]);
871 let results = mutant_results(&plan, Some(&outcome));
872 let failure = MutationBaselineFailure {
873 file: Some("test/a.test.ts".to_owned()),
874 test_id: None,
875 message: Some("boom".to_owned()),
876 mutant_ids: Vec::new(),
877 };
878 let completion = completion(&CompletionFacts {
879 plan: &plan,
880 results: &results,
881 baseline_failures: &[failure],
882 worktree_changed: true,
883 worktree_unchecked: false,
884 unretried: false,
885 unloaded_paths: &[],
886 typecheck_failed: false,
887 promoted: &[],
888 });
889 assert_eq!(
890 completion.reasons,
891 vec![
892 MutationIncompleteReason::RuntimeError,
893 MutationIncompleteReason::SkippedFiles,
894 MutationIncompleteReason::BaselineFailure,
895 MutationIncompleteReason::WorktreeChanged,
896 ]
897 );
898 assert_eq!(skipped_files(&plan)[0].reason, "not-analyzed");
899 }
900
901 #[test]
902 fn an_unretried_mutant_and_an_unchecked_worktree_make_a_run_partial() {
903 let plan = plan(
904 vec![planned("m1", Some(1), MutantDisposition::Planned, None)],
905 0,
906 );
907 let outcome = outcome(&[("m1", MutantStatus::Survived)]);
908 let results = mutant_results(&plan, Some(&outcome));
909 let completion = completion(&CompletionFacts {
910 plan: &plan,
911 results: &results,
912 baseline_failures: &[],
913 worktree_changed: false,
914 worktree_unchecked: true,
915 unretried: true,
916 unloaded_paths: &[],
917 typecheck_failed: false,
918 promoted: &[],
919 });
920 assert_eq!(
921 completion.reasons,
922 vec![
923 MutationIncompleteReason::TimeBudget,
924 MutationIncompleteReason::WorktreeUnchecked,
925 ]
926 );
927 }
928
929 #[test]
930 fn an_unloaded_file_that_a_dry_run_test_reaches_makes_a_run_partial() {
931 let mut plan = plan(
932 vec![planned("m1", Some(1), MutantDisposition::Planned, None)],
933 0,
934 );
935 plan.files.push(PlannedFile {
936 path: "src/b.ts".to_owned(),
937 source_sha256: "cd".repeat(32),
938 instrumented_code: String::new(),
939 static_test_files: Vec::new(),
940 });
941 let results = mutant_results(&plan, Some(&outcome(&[("m1", MutantStatus::NoCoverage)])));
942 let reasons = |unloaded: &[String]| {
943 completion(&CompletionFacts {
944 plan: &plan,
945 results: &results,
946 baseline_failures: &[],
947 worktree_changed: false,
948 worktree_unchecked: false,
949 unretried: false,
950 unloaded_paths: unloaded,
951 typecheck_failed: false,
952 promoted: &[],
953 })
954 .reasons
955 };
956 assert_eq!(
957 reasons(&["src/a.ts".to_owned()]),
958 vec![MutationIncompleteReason::UnloadedFiles],
959 "a static test reaches src/a.ts, so the runner had to load it"
960 );
961 assert_eq!(
962 reasons(&["src/b.ts".to_owned()]),
963 Vec::new(),
964 "no static test reaches src/b.ts, so it is a fact, not a fault"
965 );
966 }
967
968 #[test]
969 fn a_static_map_miss_takes_the_schedule_outcome() {
970 let plan = plan(
971 vec![
972 planned("m1", None, MutantDisposition::NoCoverage, None),
973 planned("m2", None, MutantDisposition::NoCoverage, None),
974 ],
975 0,
976 );
977 let results = mutant_results(&plan, Some(&outcome(&[("m1", MutantStatus::Killed)])));
978 assert_eq!(results[0].status, MutantStatus::Killed);
979 assert_eq!(results[0].killed_by.as_deref(), Some("test/a.test.ts::t1"));
980 assert_eq!(results[1].status, MutantStatus::NoCoverage);
981 }
982
983 #[test]
984 fn a_cached_result_is_marked_in_the_report_and_counts_like_a_run() {
985 let plan = plan(
986 vec![
987 planned("m1", Some(1), MutantDisposition::Planned, None),
988 planned("m2", Some(2), MutantDisposition::Planned, None),
989 ],
990 0,
991 );
992 let mut outcome = outcome(&[("m1", MutantStatus::Killed), ("m2", MutantStatus::Survived)]);
993 if let Some(result) = outcome.results.get_mut("m1") {
994 result.cached = true;
995 }
996 let results = mutant_results(&plan, Some(&outcome));
997 assert!(results[0].cached);
998 assert!(!results[1].cached);
999 assert_eq!(summary(&results).score, Some(0.5));
1000 }
1001
1002 #[test]
1003 fn plan_output_parts_carry_no_source_text() {
1004 let plan = plan(
1005 vec![planned(
1006 "m1",
1007 Some(1),
1008 MutantDisposition::Pruned(PruneReason::FrameworkConfig),
1009 None,
1010 )],
1011 0,
1012 );
1013 let files = serde_json::to_string(&plan_files(&plan)).expect("json");
1014 let mutants = serde_json::to_string(&plan_mutants(&plan)).expect("json");
1015 for text in [&files, &mutants] {
1016 assert!(!text.contains("SECRET SOURCE"));
1017 assert!(!text.contains("a + b"));
1018 assert!(!text.contains("a - b"));
1019 }
1020 assert!(mutants.contains(r#""disposition":"pruned""#), "{mutants}");
1021 assert!(
1022 mutants.contains(r#""prune_reason":"framework-config""#),
1023 "{mutants}"
1024 );
1025 assert!(files.contains("test/a.test.ts"), "{files}");
1026 }
1027}