1use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use tokio::sync::Mutex;
7
8use crate::config::Config;
9use crate::diagnostics::DiagnosticResults;
10use crate::render::color;
11
12use super::action::{Action, ActionOutcome, DiagnosticKey, Risk};
13use super::triage::{Attempts, Effectiveness, HardBlock};
14use super::vpn::DisabledVpn;
15
16pub const WALL_CLOCK_CAP: Duration = Duration::from_secs(240);
20
21pub const DEFAULT_ITERATION_DELAY: Duration = Duration::from_secs(2);
25
26#[derive(Debug, Clone)]
29pub enum FinalOutcome {
30 Fixed,
32 Partial(Vec<DiagnosticKey>),
34 Exhausted(Vec<DiagnosticKey>),
36 HardBlock(HardBlock),
38 Timeout(Vec<DiagnosticKey>),
40 UserDeclined(Vec<DiagnosticKey>),
42 PreflightFailed(String),
44 Interrupted(Vec<DiagnosticKey>),
50}
51
52impl FinalOutcome {
53 pub fn exit_code(&self) -> i32 {
54 match self {
55 FinalOutcome::Fixed => 0,
56 FinalOutcome::Partial(_) => 1,
57 FinalOutcome::Exhausted(_) => 2,
58 FinalOutcome::HardBlock(_) => 1,
59 FinalOutcome::Timeout(_) => 2,
60 FinalOutcome::UserDeclined(_) => 1,
61 FinalOutcome::PreflightFailed(_) => 2,
62 FinalOutcome::Interrupted(_) => 130,
64 }
65 }
66}
67
68const RESTORE_OP_TIMEOUT: Duration = Duration::from_secs(30);
74
75#[derive(Debug, Clone)]
83pub enum RestoreOp {
84 ReEnableInterface { iface: String },
86 ReEnableVpn(Arc<DisabledVpn>),
88 #[cfg(target_os = "macos")]
92 RecreateMacosService {
93 iface: String,
94 service: String,
95 snapshot: super::stages::MacosNetworkSnapshot,
96 },
97}
98
99impl RestoreOp {
100 fn label(&self) -> String {
102 match self {
103 RestoreOp::ReEnableInterface { iface } => {
104 format!("re-enable network adapter {}", iface)
105 }
106 RestoreOp::ReEnableVpn(vpn) => format!("re-enable VPN {}", vpn.name),
107 #[cfg(target_os = "macos")]
108 RestoreOp::RecreateMacosService { service, .. } => {
109 format!("recreate macOS network service {}", service)
110 }
111 }
112 }
113}
114
115async fn restore_op(op: &RestoreOp) -> Result<(), String> {
118 match op {
119 RestoreOp::ReEnableInterface { iface } => super::stages::enable_interface(iface).await,
120 RestoreOp::ReEnableVpn(vpn) => {
121 if super::vpn::reenable_vpn(vpn).await {
122 Ok(())
123 } else {
124 Err(format!("could not re-enable VPN {}", vpn.name))
125 }
126 }
127 #[cfg(target_os = "macos")]
128 RestoreOp::RecreateMacosService {
129 iface,
130 service,
131 snapshot,
132 } => super::stages::recreate_and_restore_macos_service(iface, service, snapshot).await,
133 }
134}
135
136#[derive(Debug, Clone)]
139struct RegisteredOp {
140 op: RestoreOp,
141 resolved: bool,
142}
143
144pub type RestoreToken = usize;
147
148#[derive(Clone, Default)]
157pub struct RestoreRegistry {
158 inner: Arc<Mutex<Vec<RegisteredOp>>>,
159}
160
161impl RestoreRegistry {
162 pub fn new() -> Self {
163 Self {
164 inner: Arc::new(Mutex::new(Vec::new())),
165 }
166 }
167
168 pub async fn register(&self, op: RestoreOp) -> RestoreToken {
171 let mut guard = self.inner.lock().await;
172 guard.push(RegisteredOp {
173 op,
174 resolved: false,
175 });
176 guard.len() - 1
177 }
178
179 pub async fn mark_resolved(&self, token: RestoreToken) {
182 let mut guard = self.inner.lock().await;
183 if let Some(entry) = guard.get_mut(token) {
184 entry.resolved = true;
185 }
186 }
187
188 pub async fn drain(&self) -> Vec<String> {
197 let pending: Vec<(usize, RestoreOp)> = {
200 let guard = self.inner.lock().await;
201 guard
202 .iter()
203 .enumerate()
204 .filter(|(_, e)| !e.resolved)
205 .map(|(i, e)| (i, e.op.clone()))
206 .collect()
207 };
208
209 let mut failures = Vec::new();
210 for (idx, op) in pending {
211 let result = match tokio::time::timeout(RESTORE_OP_TIMEOUT, restore_op(&op)).await {
212 Ok(Ok(())) => Ok(()),
213 Ok(Err(e)) => Err(e),
214 Err(_) => Err(format!("timed out after {}s", RESTORE_OP_TIMEOUT.as_secs())),
215 };
216 match result {
217 Ok(()) => {
218 let mut guard = self.inner.lock().await;
220 if let Some(entry) = guard.get_mut(idx) {
221 entry.resolved = true;
222 }
223 }
224 Err(reason) => {
225 failures.push(format!("Could not {}: {}", op.label(), reason));
226 }
227 }
228 }
229 failures
230 }
231
232 #[cfg(test)]
234 pub(crate) async fn pending_count(&self) -> usize {
235 self.inner
236 .lock()
237 .await
238 .iter()
239 .filter(|e| !e.resolved)
240 .count()
241 }
242}
243
244#[derive(Debug, Clone)]
247pub struct ActionRecord {
248 pub action_id: super::action::ActionId,
249 pub label: &'static str,
250 pub targets: &'static [DiagnosticKey],
254 pub outcome: ActionOutcome,
255 pub duration: Duration,
256 pub iteration: u8,
257 pub user_declined: bool,
259 pub skipped_no_interaction: bool,
262}
263
264#[derive(Debug)]
265pub struct IterationSnapshot {
266 pub iteration: u8,
267 pub results: DiagnosticResults,
268}
269
270#[derive(Debug)]
271pub struct Session {
272 pub started_at: Instant,
273 pub baseline: Option<DiagnosticResults>,
274 pub baseline_confirmation: Option<DiagnosticResults>,
278 pub intermittent: std::collections::HashSet<DiagnosticKey>,
281 pub snapshots: Vec<IterationSnapshot>,
282 pub action_log: Vec<ActionRecord>,
283 pub attempts: Attempts,
284 pub effectiveness: Effectiveness,
285 pub vpn_names: Vec<String>,
286 pub final_outcome: Option<FinalOutcome>,
287}
288
289impl Session {
290 pub fn new() -> Self {
291 Self {
292 started_at: Instant::now(),
293 baseline: None,
294 baseline_confirmation: None,
295 intermittent: std::collections::HashSet::new(),
296 snapshots: Vec::new(),
297 action_log: Vec::new(),
298 attempts: Attempts::new(),
299 effectiveness: Effectiveness::new(),
300 vpn_names: Vec::new(),
301 final_outcome: None,
302 }
303 }
304
305 pub fn elapsed(&self) -> Duration {
306 self.started_at.elapsed()
307 }
308
309 pub fn wall_clock_exhausted(&self) -> bool {
310 self.elapsed() >= WALL_CLOCK_CAP
311 }
312
313 pub fn record_baseline(&mut self, results: DiagnosticResults) {
314 self.baseline = Some(results.clone());
315 self.snapshots.push(IterationSnapshot {
316 iteration: 0,
317 results,
318 });
319 }
320
321 pub fn record_iteration(&mut self, iteration: u8, results: DiagnosticResults) {
322 self.snapshots
323 .push(IterationSnapshot { iteration, results });
324 }
325
326 pub fn record_confirmation(
329 &mut self,
330 results: DiagnosticResults,
331 intermittent: std::collections::HashSet<DiagnosticKey>,
332 ) {
333 self.baseline_confirmation = Some(results);
334 self.intermittent = intermittent;
335 }
336
337 pub fn record_action(
338 &mut self,
339 iteration: u8,
340 action: &Action,
341 outcome: ActionOutcome,
342 duration: Duration,
343 user_declined: bool,
344 skipped_no_interaction: bool,
345 ) {
346 let entry = self.attempts.entry(action.id).or_insert(0);
347 if !skipped_no_interaction && !user_declined {
348 *entry = entry.saturating_add(1);
349 }
350 self.action_log.push(ActionRecord {
351 action_id: action.id,
352 label: action.label,
353 targets: action.targets,
354 outcome,
355 duration,
356 iteration,
357 user_declined,
358 skipped_no_interaction,
359 });
360 }
361
362 pub fn update_effectiveness(
368 &mut self,
369 iteration: u8,
370 prior_failures: &std::collections::HashSet<DiagnosticKey>,
371 current_failures: &std::collections::HashSet<DiagnosticKey>,
372 ) {
373 let cleared: std::collections::HashSet<DiagnosticKey> = prior_failures
374 .difference(current_failures)
375 .copied()
376 .collect();
377 for key in cleared {
378 if self.intermittent.contains(&key) {
379 continue;
380 }
381 if let Some(record) = self
382 .action_log
383 .iter()
384 .rev()
385 .take_while(|r| r.iteration == iteration)
386 .find(|r| r.outcome.ok && r.targets.contains(&key))
387 {
388 self.effectiveness.insert((record.action_id, key), true);
389 }
390 }
391 }
392}
393
394impl Default for Session {
395 fn default() -> Self {
396 Self::new()
397 }
398}
399
400pub struct Reporter<'a> {
405 pub config: &'a Config,
406}
407
408impl<'a> Reporter<'a> {
409 pub fn new(config: &'a Config) -> Self {
410 Self { config }
411 }
412
413 pub fn header(&self) {
414 println!();
415 println!(
416 " {} {}",
417 color::cyan("nd300 fix", self.config),
418 color::dim("— diagnostic-driven recovery", self.config),
419 );
420 }
421
422 pub fn iteration_header(&self, iteration: u8) {
423 println!();
424 println!(
425 " {} {}",
426 color::cyan(&format!("Iteration {}", iteration), self.config),
427 color::dim(
428 "— running diagnostics, then applying targeted fixes",
429 self.config,
430 ),
431 );
432 }
433
434 pub fn baseline_summary(&self, failure_count: usize) {
435 if failure_count == 0 {
436 println!(
437 " {} {}",
438 color::green("✓", self.config),
439 color::green("All diagnostics passing — nothing to fix.", self.config),
440 );
441 } else {
442 println!(
443 " {} found {} failing area{}",
444 color::yellow("→", self.config),
445 failure_count,
446 if failure_count == 1 { "" } else { "s" },
447 );
448 }
449 }
450
451 pub fn confirmation_pass(&self) {
453 println!(
454 " {} {}",
455 color::dim("→", self.config),
456 color::dim(
457 "Double-checking the failures with a second diagnostic pass...",
458 self.config,
459 ),
460 );
461 }
462
463 pub fn confirmation_result(&self, confirmed: usize, transient: usize) {
465 if confirmed == 0 && transient > 0 {
466 println!(
467 " {} {}",
468 color::green("✓", self.config),
469 color::green(
470 "The failures did not reproduce on the second pass — they were transient.",
471 self.config,
472 ),
473 );
474 } else if transient > 0 {
475 println!(
476 " {} {} confirmed, {} transient (will not be acted on)",
477 color::yellow("→", self.config),
478 confirmed,
479 transient,
480 );
481 } else {
482 println!(
483 " {} {} failure{} confirmed on both passes",
484 color::yellow("→", self.config),
485 confirmed,
486 if confirmed == 1 { "" } else { "s" },
487 );
488 }
489 }
490
491 pub fn announce_action(&self, action: &Action) {
492 println!();
493 println!(
494 " {} {}",
495 color::dim("•", self.config),
496 color::dim(action.one_line_why, self.config),
497 );
498 print!(" {} {} ", color::cyan("→", self.config), action.label,);
499 use std::io::Write;
500 let _ = std::io::stdout().flush();
501 }
502
503 pub fn finish_action(&self, outcome: &ActionOutcome, duration: Duration) {
504 if outcome.ok {
505 println!(
506 "{} {}",
507 color::green("✓", self.config),
508 color::dim(
509 &format!("({:.1}s) {}", duration.as_secs_f64(), outcome.message),
510 self.config,
511 ),
512 );
513 } else {
514 println!(
515 "{} {}",
516 color::red("✗", self.config),
517 color::red(&outcome.message, self.config),
518 );
519 }
520 }
521
522 pub fn finish_action_skipped(&self, reason: &str) {
523 println!(
524 "{} {}",
525 color::yellow("·", self.config),
526 color::yellow(reason, self.config),
527 );
528 }
529
530 pub fn confirmation_prompt(&self, action: &Action) -> bool {
533 println!();
534 println!(
535 " {} {}",
536 color::yellow("Confirm:", self.config),
537 color::yellow(action.label, self.config),
538 );
539 println!(" {}", color::dim(action.one_line_why, self.config));
540 println!(
541 " {}",
542 color::dim("This changes live network settings. Use --yes to auto-confirm this class of action.", self.config),
543 );
544
545 use std::io::Write;
546 print!(
547 " {} ",
548 color::yellow(
549 "Continue? Type 'y' to proceed, anything else to skip:",
550 self.config
551 ),
552 );
553 let _ = std::io::stdout().flush();
554
555 let mut input = String::new();
556 if std::io::stdin().read_line(&mut input).is_err() {
557 return false;
558 }
559 matches!(input.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
560 }
561
562 pub fn high_risk_prompt(&self, action: &Action) -> bool {
566 let exp = match &action.risk {
567 Risk::High(e) => e,
568 _ => return true, };
570
571 let header = format!("Escalating: {}", exp.what);
572 let bar = if self.config.use_unicode { '─' } else { '-' };
573 let rule: String = std::iter::repeat_n(bar, 76).collect();
574
575 println!();
576 println!(
577 " {}",
578 color::yellow(&format!("┌─ {} ", header), self.config)
579 );
580 println!(" {}", color::dim(&rule, self.config));
581 println!(" {}", color::bold("Why I want to do this:", self.config));
582 for line in wrap_text(exp.why, 70) {
583 println!(" {}", line);
584 }
585 println!();
586 println!(" {}", color::bold("What will happen:", self.config));
587 for bullet in exp.side_effects {
588 println!(" • {}", bullet);
589 }
590 println!();
591 println!(
592 " {} {}",
593 color::bold("Reversible:", self.config),
594 exp.reversible.label(),
595 );
596 println!(
597 " {} {}",
598 color::bold("Typical duration:", self.config),
599 exp.typical_duration,
600 );
601 println!(" {}", color::dim(&rule, self.config));
602
603 use std::io::Write;
605 print!(
606 " {} ",
607 color::yellow(
608 "Continue? Type 'y' to proceed, anything else to skip:",
609 self.config
610 ),
611 );
612 let _ = std::io::stdout().flush();
613
614 let mut input = String::new();
615 if std::io::stdin().read_line(&mut input).is_err() {
616 return false;
617 }
618 matches!(input.trim(), "y" | "Y" | "yes" | "Yes" | "YES")
619 }
620
621 pub fn high_risk_skipped_no_tty(&self, action: &Action) {
622 println!(
623 " {} {}",
624 color::yellow("·", self.config),
625 color::yellow(
626 &format!(
627 "Skipped {}: this action requires interactive confirmation. Re-run `nd300 fix` in a terminal to attempt it.",
628 action.label,
629 ),
630 self.config,
631 ),
632 );
633 }
634
635 pub fn high_risk_declined(&self, action: &Action) {
636 self.confirmation_declined(action);
637 }
638
639 pub fn confirmation_declined(&self, action: &Action) {
640 println!(
641 " {} {}",
642 color::yellow("·", self.config),
643 color::dim(
644 &format!("Skipped {} (you declined the prompt).", action.label),
645 self.config,
646 ),
647 );
648 }
649
650 pub fn final_verdict(&self, outcome: &FinalOutcome, report_path: Option<&std::path::Path>) {
651 let bar = if self.config.use_unicode { '─' } else { '-' };
652 let rule: String = std::iter::repeat_n(bar, 50).collect();
653
654 println!();
655 println!(
656 " {} {}",
657 color::bold("Result", self.config),
658 color::dim(&rule, self.config),
659 );
660
661 match outcome {
662 FinalOutcome::Fixed => {
663 println!(
664 " {} {}",
665 color::green("✓ Fixed", self.config),
666 color::green(
667 "Connectivity is healthy now. The actions above resolved the failures.",
668 self.config,
669 ),
670 );
671 }
672 FinalOutcome::Partial(remaining) => {
673 println!(
674 " {} {}",
675 color::yellow("⚠ Partially fixed", self.config),
676 color::yellow(
677 &format!(
678 "{} remain{}",
679 describe_keys(remaining),
680 if remaining.len() == 1 { "s" } else { "" }
681 ),
682 self.config,
683 ),
684 );
685 self.suggestions_for(remaining);
686 }
687 FinalOutcome::Exhausted(remaining) => {
688 println!(
689 " {} {}",
690 color::red("✗ Couldn't fix", self.config),
691 color::red(
692 &format!(
693 "Tried every applicable action; {} still failing",
694 describe_keys(remaining)
695 ),
696 self.config,
697 ),
698 );
699 self.suggestions_for(remaining);
700 }
701 FinalOutcome::HardBlock(reason) => {
702 println!(
703 " {} {}",
704 color::yellow("⚠ Cannot fix from here", self.config),
705 color::yellow(&reason.user_message(), self.config),
706 );
707 }
708 FinalOutcome::Timeout(remaining) => {
709 println!(
710 " {} {}",
711 color::red("✗ Timed out", self.config),
712 color::red(
713 &format!(
714 "Loop hit its safety cap; {} still failing",
715 describe_keys(remaining)
716 ),
717 self.config,
718 ),
719 );
720 self.suggestions_for(remaining);
721 }
722 FinalOutcome::UserDeclined(remaining) => {
723 println!(
724 " {} {}",
725 color::yellow("⚠ Stopped at your request", self.config),
726 color::yellow(
727 &format!(
728 "You declined a confirmation prompt; {} still failing",
729 describe_keys(remaining)
730 ),
731 self.config,
732 ),
733 );
734 self.suggestions_for(remaining);
735 }
736 FinalOutcome::PreflightFailed(reason) => {
737 println!(
738 " {} {}",
739 color::red("✗ Could not start", self.config),
740 color::red(reason, self.config),
741 );
742 }
743 FinalOutcome::Interrupted(_) => {
744 println!(
745 " {} {}",
746 color::yellow("⚠ Interrupted", self.config),
747 color::yellow(
748 "The fix was stopped before it finished. nd300 attempted to restore any network state it had changed — see below for anything that needs manual recovery.",
749 self.config,
750 ),
751 );
752 }
753 }
754
755 if let Some(path) = report_path {
756 println!(
757 " {} {}",
758 color::dim("Full report:", self.config),
759 color::dim(&path.display().to_string(), self.config),
760 );
761 }
762 }
763
764 fn suggestions_for(&self, remaining: &[DiagnosticKey]) {
765 if remaining.is_empty() {
766 return;
767 }
768 println!();
769 println!(" {}", color::bold("What to try next:", self.config));
770 for s in suggestions_for_keys(remaining) {
771 println!(" • {}", s);
772 }
773 }
774}
775
776fn describe_keys(keys: &[DiagnosticKey]) -> String {
777 let names: Vec<&str> = keys.iter().map(|k| key_label(*k)).collect();
778 names.join(", ")
779}
780
781fn key_label(k: DiagnosticKey) -> &'static str {
782 match k {
783 DiagnosticKey::Adapters => "network adapter",
784 DiagnosticKey::Interfaces => "network interface",
785 DiagnosticKey::Gateway => "gateway / router",
786 DiagnosticKey::Dns => "DNS",
787 DiagnosticKey::PublicIp => "public IP",
788 DiagnosticKey::Latency => "latency",
789 DiagnosticKey::Ports => "outbound ports",
790 DiagnosticKey::Speed => "speed test",
791 }
792}
793
794pub fn suggestions_for_keys(remaining: &[DiagnosticKey]) -> Vec<String> {
796 use DiagnosticKey::*;
797 let mut out: Vec<String> = Vec::new();
798
799 let has = |k: DiagnosticKey| remaining.contains(&k);
800
801 if has(Adapters) || has(Interfaces) {
802 out.push(
803 "Reboot your computer — a deeper hardware/driver state may need a clean start."
804 .to_string(),
805 );
806 out.push(
807 "Check for network driver updates from your hardware vendor (Intel, Realtek, etc.)."
808 .to_string(),
809 );
810 }
811 if has(Gateway) {
812 out.push(
813 "Power-cycle your router / modem (unplug for 30 seconds, plug back in).".to_string(),
814 );
815 out.push("Try a different cable or Wi-Fi network if available.".to_string());
816 }
817 if has(Dns) {
818 out.push(
819 "Try `nd300 fix` again, then choose `Switch DNS to Cloudflare` if it offers it."
820 .to_string(),
821 );
822 out.push(
823 "Check your router admin page for a custom DNS setting and remove it.".to_string(),
824 );
825 }
826 if has(PublicIp) {
827 out.push("Check your ISP's status page — there may be an outage in your area.".to_string());
828 out.push(
829 "Disconnect any VPN you have running (including work VPNs) and re-test.".to_string(),
830 );
831 }
832 if has(Latency) {
833 out.push("If on Wi-Fi: move closer to your router or try a 5 GHz network.".to_string());
834 out.push(
835 "Run a speed test from another device on the same network to compare.".to_string(),
836 );
837 }
838 if has(Ports) {
839 out.push(
840 "Outbound ports may be blocked by a firewall (work network or AV software). Contact IT or check your firewall rules.".to_string(),
841 );
842 }
843
844 if out.is_empty() {
845 out.push("Reboot the machine and try again.".to_string());
846 out.push(
847 "Run `nd300 -t` for the full technician report and share it with support.".to_string(),
848 );
849 }
850 out
851}
852
853fn wrap_text(text: &str, width: usize) -> Vec<String> {
855 let mut lines = Vec::new();
856 let mut current = String::new();
857 for word in text.split_whitespace() {
858 if current.len() + word.len() + 1 > width && !current.is_empty() {
859 lines.push(std::mem::take(&mut current));
860 }
861 if !current.is_empty() {
862 current.push(' ');
863 }
864 current.push_str(word);
865 }
866 if !current.is_empty() {
867 lines.push(current);
868 }
869 lines
870}
871
872#[cfg(test)]
873mod restore_registry_tests {
874 use super::*;
875 use crate::actions::fix::vpn::{DisableMethod, DisabledVpn};
876
877 fn failing_vpn_op(name: &str) -> RestoreOp {
881 RestoreOp::ReEnableVpn(Arc::new(DisabledVpn {
882 name: name.to_string(),
883 method: DisableMethod::VendorCli(
884 "nd300-nonexistent-vpn-binary".to_string(),
885 vec!["connect".to_string()],
886 ),
887 }))
888 }
889
890 #[tokio::test]
891 async fn empty_registry_drains_to_no_failures() {
892 let reg = RestoreRegistry::new();
893 assert_eq!(reg.pending_count().await, 0);
894 assert!(reg.drain().await.is_empty());
895 }
896
897 #[tokio::test]
898 async fn register_then_resolve_clears_pending() {
899 let reg = RestoreRegistry::new();
900 let t1 = reg.register(failing_vpn_op("VpnA")).await;
901 let _t2 = reg.register(failing_vpn_op("VpnB")).await;
902 assert_eq!(reg.pending_count().await, 2);
903
904 reg.mark_resolved(t1).await;
905 assert_eq!(reg.pending_count().await, 1);
906 }
907
908 #[tokio::test]
909 async fn drain_skips_resolved_and_reports_unresolved_failures() {
910 let reg = RestoreRegistry::new();
911 let resolved = reg.register(failing_vpn_op("ResolvedVpn")).await;
912 let _pending = reg.register(failing_vpn_op("PendingVpn")).await;
913
914 reg.mark_resolved(resolved).await;
917
918 let failures = reg.drain().await;
919
920 assert_eq!(failures.len(), 1, "failures: {:?}", failures);
923 assert!(
924 failures[0].contains("PendingVpn"),
925 "unexpected failure text: {}",
926 failures[0]
927 );
928 assert!(
929 !failures[0].contains("ResolvedVpn"),
930 "resolved op should not have been drained: {}",
931 failures[0]
932 );
933
934 let second = reg.drain().await;
938 assert_eq!(second.len(), 1);
939 }
940
941 #[test]
942 fn interrupted_exit_code_is_130() {
943 assert_eq!(FinalOutcome::Interrupted(Vec::new()).exit_code(), 130);
944 }
945}
946
947#[cfg(test)]
948mod effectiveness_tests {
949 use super::*;
950 use crate::actions::fix::action::ActionId;
951 use std::collections::HashSet;
952
953 fn record(
954 action_id: ActionId,
955 targets: &'static [DiagnosticKey],
956 ok: bool,
957 iteration: u8,
958 ) -> ActionRecord {
959 ActionRecord {
960 action_id,
961 label: "test action",
962 targets,
963 outcome: if ok {
964 ActionOutcome::ok("ok")
965 } else {
966 ActionOutcome::fail("fail")
967 },
968 duration: Duration::from_millis(1),
969 iteration,
970 user_declined: false,
971 skipped_no_interaction: false,
972 }
973 }
974
975 fn keys(ks: &[DiagnosticKey]) -> HashSet<DiagnosticKey> {
976 ks.iter().copied().collect()
977 }
978
979 #[test]
982 fn credit_requires_matching_targets() {
983 let mut s = Session::new();
984 s.action_log.push(record(
985 ActionId::FlushArp,
986 &[DiagnosticKey::Gateway],
987 true,
988 1,
989 ));
990 s.action_log
991 .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
992
993 s.update_effectiveness(1, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
994
995 assert_eq!(
996 s.effectiveness
997 .get(&(ActionId::FlushDns, DiagnosticKey::Dns)),
998 Some(&true)
999 );
1000 assert!(
1001 !s.effectiveness
1002 .contains_key(&(ActionId::FlushArp, DiagnosticKey::Dns)),
1003 "non-targeting action must not be credited"
1004 );
1005 }
1006
1007 #[test]
1008 fn most_recent_targeting_action_wins() {
1009 let mut s = Session::new();
1010 s.action_log
1011 .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1012 s.action_log.push(record(
1013 ActionId::SetDnsAutomatic,
1014 &[DiagnosticKey::Dns],
1015 true,
1016 1,
1017 ));
1018
1019 s.update_effectiveness(1, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1020
1021 assert!(s
1022 .effectiveness
1023 .contains_key(&(ActionId::SetDnsAutomatic, DiagnosticKey::Dns)));
1024 assert!(
1025 !s.effectiveness
1026 .contains_key(&(ActionId::FlushDns, DiagnosticKey::Dns)),
1027 "only the most recent targeting action is credited"
1028 );
1029 }
1030
1031 #[test]
1032 fn failed_action_earns_no_credit() {
1033 let mut s = Session::new();
1034 s.action_log
1035 .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], false, 1));
1036
1037 s.update_effectiveness(1, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1038
1039 assert!(s.effectiveness.is_empty());
1040 }
1041
1042 #[test]
1043 fn prior_iteration_records_never_credited() {
1044 let mut s = Session::new();
1045 s.action_log
1046 .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1047
1048 s.update_effectiveness(2, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1049
1050 assert!(s.effectiveness.is_empty());
1051 }
1052
1053 #[test]
1054 fn nothing_cleared_changes_nothing() {
1055 let mut s = Session::new();
1056 s.action_log
1057 .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1058
1059 s.update_effectiveness(
1060 1,
1061 &keys(&[DiagnosticKey::Dns]),
1062 &keys(&[DiagnosticKey::Dns]),
1063 );
1064
1065 assert!(s.effectiveness.is_empty());
1066 }
1067
1068 #[test]
1072 fn intermittent_key_earns_no_credit() {
1073 let mut s = Session::new();
1074 s.intermittent.insert(DiagnosticKey::Dns);
1075 s.action_log
1076 .push(record(ActionId::FlushDns, &[DiagnosticKey::Dns], true, 1));
1077
1078 s.update_effectiveness(1, &keys(&[DiagnosticKey::Dns]), &keys(&[]));
1079
1080 assert!(s.effectiveness.is_empty());
1081 }
1082}