1use futures::StreamExt;
4use gpui::{Context, SharedString};
5use netrunner_core::{SpeedTestResult, TestConfig, TestEvent};
6
7use crate::engine::spawn_speed_test;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Phase {
12 Idle,
13 Locating,
14 Servers,
15 Latency,
16 Download,
17 Upload,
18 Done,
19}
20
21impl Phase {
22 pub fn label(self) -> &'static str {
23 match self {
24 Phase::Idle => "Ready",
25 Phase::Locating => "Detecting location…",
26 Phase::Servers => "Selecting servers…",
27 Phase::Latency => "Measuring latency…",
28 Phase::Download => "Testing download…",
29 Phase::Upload => "Testing upload…",
30 Phase::Done => "Complete",
31 }
32 }
33}
34
35pub const MAX_SAMPLES: usize = 120;
37
38pub struct SpeedApp {
40 pub config: TestConfig,
41 pub status: SharedString,
42 pub phase: Phase,
43 pub running: bool,
44
45 pub download_samples: Vec<f32>,
46 pub upload_samples: Vec<f32>,
47
48 pub download_mbps: f32,
49 pub upload_mbps: f32,
50 pub peak_download: f32,
51 pub peak_upload: f32,
52 pub ping_ms: f32,
53
54 pub location: Option<SharedString>,
55 pub isp: Option<SharedString>,
56 pub server: Option<SharedString>,
57 pub result: Option<SpeedTestResult>,
58}
59
60impl SpeedApp {
61 pub fn new() -> Self {
62 Self {
63 config: TestConfig::default(),
64 status: "Ready to jack in.".into(),
65 phase: Phase::Idle,
66 running: false,
67 download_samples: Vec::new(),
68 upload_samples: Vec::new(),
69 download_mbps: 0.0,
70 upload_mbps: 0.0,
71 peak_download: 0.0,
72 peak_upload: 0.0,
73 ping_ms: 0.0,
74 location: None,
75 isp: None,
76 server: None,
77 result: None,
78 }
79 }
80
81 pub fn start(&mut self, cx: &mut Context<Self>) {
83 if self.running {
84 return;
85 }
86
87 self.running = true;
89 self.phase = Phase::Locating;
90 self.status = "Initialising neural interface…".into();
91 self.download_samples.clear();
92 self.upload_samples.clear();
93 self.download_mbps = 0.0;
94 self.upload_mbps = 0.0;
95 self.peak_download = 0.0;
96 self.peak_upload = 0.0;
97 self.ping_ms = 0.0;
98 self.location = None;
99 self.isp = None;
100 self.server = None;
101 self.result = None;
102
103 let rx = spawn_speed_test(self.config.clone());
104
105 cx.spawn(async move |this, cx| {
106 let mut rx = rx;
107 while let Some(event) = rx.next().await {
108 let keep_going = this
109 .update(cx, |app, cx| {
110 app.apply(event);
111 cx.notify();
112 })
113 .is_ok();
114 if !keep_going {
115 break;
116 }
117 }
118 let _ = this.update(cx, |app, cx| {
119 app.running = false;
120 if app.phase != Phase::Done {
121 app.phase = Phase::Done;
122 }
123 cx.notify();
124 });
125 })
126 .detach();
127
128 cx.notify();
129 }
130
131 pub fn apply(&mut self, event: TestEvent) {
133 match event {
134 TestEvent::Status(msg) => self.status = msg.into(),
135 TestEvent::LocationDetected {
136 city, country, isp, ..
137 } => {
138 self.location = Some(format!("{city}, {country}").into());
139 self.isp = isp.map(Into::into);
140 self.phase = Phase::Locating;
141 }
142 TestEvent::ServerPoolBuilt { .. } | TestEvent::NearbyServersFound { .. } => {
143 self.phase = Phase::Servers;
144 }
145 TestEvent::ServersSelected { .. } => self.phase = Phase::Servers,
146 TestEvent::PrimarySelected { name, location, .. } => {
147 self.server = Some(format!("{name} · {location}").into());
148 self.phase = Phase::Servers;
149 }
150 TestEvent::PhaseStarted(phase) => {
151 use netrunner_core::Phase as CorePhase;
152 self.phase = match phase {
153 CorePhase::Locating => Phase::Locating,
154 CorePhase::BuildingServers | CorePhase::SelectingServers => Phase::Servers,
155 CorePhase::Latency => Phase::Latency,
156 CorePhase::Download => Phase::Download,
157 CorePhase::Upload => Phase::Upload,
158 CorePhase::Jitter => self.phase,
159 };
160 self.status = phase.title().to_string().into();
161 }
162 TestEvent::DownloadSample {
163 mbps, peak_mbps, ..
164 } => {
165 self.download_mbps = mbps as f32;
166 self.peak_download = peak_mbps as f32;
167 push_sample(&mut self.download_samples, mbps as f32);
168 }
169 TestEvent::UploadSample {
170 mbps, peak_mbps, ..
171 } => {
172 self.upload_mbps = mbps as f32;
173 self.peak_upload = peak_mbps as f32;
174 push_sample(&mut self.upload_samples, mbps as f32);
175 }
176 TestEvent::DownloadComplete { mbps } => self.download_mbps = mbps as f32,
177 TestEvent::UploadComplete { mbps } => self.upload_mbps = mbps as f32,
178 TestEvent::LatencyProgress { avg_ms } | TestEvent::LatencyComplete { avg_ms } => {
179 self.ping_ms = avg_ms as f32;
180 }
181 TestEvent::Completed(result) => {
182 self.download_mbps = result.download_mbps as f32;
183 self.upload_mbps = result.upload_mbps as f32;
184 self.ping_ms = result.ping_ms as f32;
185 self.phase = Phase::Done;
186 self.status = "Test complete.".into();
187 self.result = Some(*result);
188 }
189 TestEvent::JitterComplete { .. } | TestEvent::DiagnosticsComplete(_) => {}
190 }
191 }
192}
193
194impl Default for SpeedApp {
195 fn default() -> Self {
196 Self::new()
197 }
198}
199
200fn push_sample(buf: &mut Vec<f32>, value: f32) {
201 buf.push(value);
202 if buf.len() > MAX_SAMPLES {
203 let overflow = buf.len() - MAX_SAMPLES;
204 buf.drain(0..overflow);
205 }
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211 use netrunner_core::Phase as CorePhase;
212
213 #[test]
214 fn apply_download_sample_tracks_current_and_peak() {
215 let mut app = SpeedApp::new();
216 app.apply(TestEvent::PhaseStarted(CorePhase::Download));
217 assert_eq!(app.phase, Phase::Download);
218
219 app.apply(TestEvent::DownloadSample {
220 mbps: 120.0,
221 peak_mbps: 120.0,
222 elapsed_secs: 1.0,
223 });
224 app.apply(TestEvent::DownloadSample {
225 mbps: 80.0,
226 peak_mbps: 120.0,
227 elapsed_secs: 2.0,
228 });
229
230 assert_eq!(app.download_samples.len(), 2);
231 assert_eq!(app.download_mbps, 80.0);
232 assert_eq!(app.peak_download, 120.0);
233 }
234
235 #[test]
236 fn sample_buffer_is_capped() {
237 let mut app = SpeedApp::new();
238 for i in 0..(MAX_SAMPLES + 50) {
239 app.apply(TestEvent::UploadSample {
240 mbps: i as f64,
241 peak_mbps: i as f64,
242 elapsed_secs: 0.0,
243 });
244 }
245 assert_eq!(app.upload_samples.len(), MAX_SAMPLES);
246 assert_eq!(
248 *app.upload_samples.last().unwrap(),
249 (MAX_SAMPLES + 49) as f32
250 );
251 }
252
253 #[test]
254 fn completed_populates_result_and_metrics() {
255 let mut app = SpeedApp::new();
256 let result = netrunner_core::SpeedTestResult {
257 download_mbps: 250.0,
258 upload_mbps: 40.0,
259 ping_ms: 12.0,
260 ..Default::default()
261 };
262
263 app.apply(TestEvent::Completed(Box::new(result)));
264
265 assert_eq!(app.phase, Phase::Done);
266 assert!(app.result.is_some());
267 assert_eq!(app.download_mbps, 250.0);
268 assert_eq!(app.upload_mbps, 40.0);
269 assert_eq!(app.ping_ms, 12.0);
270 }
271
272 #[test]
273 fn location_event_is_formatted() {
274 let mut app = SpeedApp::new();
275 app.apply(TestEvent::LocationDetected {
276 city: "Berlin".into(),
277 country: "Germany".into(),
278 isp: Some("Example ISP".into()),
279 source: "ipapi.co".into(),
280 });
281 assert_eq!(
282 app.location.as_ref().map(|s| s.to_string()),
283 Some("Berlin, Germany".to_string())
284 );
285 assert_eq!(
286 app.isp.as_ref().map(|s| s.to_string()),
287 Some("Example ISP".to_string())
288 );
289 }
290}