Skip to main content

netrunner_core/
events.rs

1//! UI-agnostic progress events emitted by the speed-test and diagnostics engines.
2//!
3//! The core crate contains **no UI code**. Instead, long-running operations
4//! report their progress by sending [`TestEvent`]s over a Tokio
5//! [`tokio::sync::mpsc::UnboundedSender`]. Front-ends (the Ratatui CLI and the GPUI desktop
6//! app) subscribe to these events and render them however they like — the CLI
7//! reproduces its cyberpunk bandwidth graphs, while the GUI feeds live
8//! download/upload charts.
9//!
10//! When no sender is supplied the engines run silently, which is exactly what
11//! `--json` output and library consumers want.
12
13use crate::types::{NetworkDiagnostics, SpeedTestResult};
14
15/// The channel used to stream [`TestEvent`]s from the engine to a front-end.
16pub type EventSender = tokio::sync::mpsc::UnboundedSender<TestEvent>;
17
18/// A single stage of a full speed test.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Phase {
21    /// Detecting the caller's geographic location.
22    Locating,
23    /// Building the candidate server pool.
24    BuildingServers,
25    /// Latency-probing the pool to pick the fastest servers.
26    SelectingServers,
27    /// Measuring round-trip latency.
28    Latency,
29    /// Measuring download throughput.
30    Download,
31    /// Measuring upload throughput.
32    Upload,
33    /// Measuring jitter and packet loss.
34    Jitter,
35}
36
37impl Phase {
38    /// Human-readable section title for this phase.
39    pub fn title(self) -> &'static str {
40        match self {
41            Phase::Locating => "Detecting Location",
42            Phase::BuildingServers => "Building Server Pool",
43            Phase::SelectingServers => "Selecting Best Servers",
44            Phase::Latency => "Testing Latency",
45            Phase::Download => "Testing Download Speed",
46            Phase::Upload => "Testing Upload Speed",
47            Phase::Jitter => "Measuring Jitter & Packet Loss",
48        }
49    }
50}
51
52/// A server chosen during selection, with its measured latency and distance.
53#[derive(Debug, Clone)]
54pub struct SelectedServer {
55    pub name: String,
56    pub location: String,
57    pub latency_ms: f64,
58    pub distance_km: f64,
59}
60
61/// A progress event emitted while running a test.
62#[derive(Debug, Clone)]
63pub enum TestEvent {
64    /// A generic informational status line.
65    Status(String),
66    /// The caller's location was resolved.
67    LocationDetected {
68        city: String,
69        country: String,
70        isp: Option<String>,
71        source: String,
72    },
73    /// The candidate server pool was built.
74    ServerPoolBuilt { count: usize },
75    /// Nearby servers were discovered.
76    NearbyServersFound { count: usize },
77    /// The best servers were selected (fastest first).
78    ServersSelected { servers: Vec<SelectedServer> },
79    /// The primary (fastest) server was chosen.
80    PrimarySelected {
81        name: String,
82        location: String,
83        distance_km: f64,
84    },
85    /// A new phase started.
86    PhaseStarted(Phase),
87    /// A live download-throughput sample (megabits per second).
88    DownloadSample {
89        mbps: f64,
90        peak_mbps: f64,
91        elapsed_secs: f64,
92    },
93    /// A live upload-throughput sample (megabits per second).
94    UploadSample {
95        mbps: f64,
96        peak_mbps: f64,
97        elapsed_secs: f64,
98    },
99    /// Final download throughput.
100    DownloadComplete { mbps: f64 },
101    /// Final upload throughput.
102    UploadComplete { mbps: f64 },
103    /// A running average latency update.
104    LatencyProgress { avg_ms: f64 },
105    /// Final measured latency.
106    LatencyComplete { avg_ms: f64 },
107    /// Final jitter and packet-loss.
108    JitterComplete {
109        jitter_ms: f64,
110        packet_loss_percent: f64,
111    },
112    /// Diagnostics finished.
113    DiagnosticsComplete(Box<NetworkDiagnostics>),
114    /// The whole speed test finished.
115    Completed(Box<SpeedTestResult>),
116}
117
118/// Send an event on an optional channel, ignoring send errors (a dropped
119/// receiver simply means nobody is listening).
120#[inline]
121pub fn emit(tx: &Option<EventSender>, event: TestEvent) {
122    if let Some(tx) = tx {
123        let _ = tx.send(event);
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn phase_titles_are_present() {
133        assert_eq!(Phase::Download.title(), "Testing Download Speed");
134        assert_eq!(Phase::Upload.title(), "Testing Upload Speed");
135        assert_eq!(Phase::Latency.title(), "Testing Latency");
136    }
137
138    #[test]
139    fn emit_is_a_noop_without_a_sender() {
140        // Should not panic when there is no receiver.
141        emit(&None, TestEvent::Status("hello".into()));
142    }
143
144    #[test]
145    fn emit_delivers_to_receiver() {
146        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
147        emit(&Some(tx), TestEvent::ServerPoolBuilt { count: 7 });
148        match rx.try_recv() {
149            Ok(TestEvent::ServerPoolBuilt { count }) => assert_eq!(count, 7),
150            other => panic!("unexpected event: {other:?}"),
151        }
152    }
153}