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