1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
4#[serde(rename_all = "snake_case")]
5pub enum PhaseState {
6 Completed,
7 Skipped,
8}
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct PhaseResult {
12 pub state: PhaseState,
13 #[serde(skip_serializing_if = "Option::is_none")]
14 pub reason: Option<String>,
15}
16
17impl PhaseResult {
18 #[must_use]
19 pub fn completed() -> Self {
20 Self {
21 state: PhaseState::Completed,
22 reason: None,
23 }
24 }
25
26 #[must_use]
27 pub fn skipped(reason: impl Into<String>) -> Self {
28 Self {
29 state: PhaseState::Skipped,
30 reason: Some(reason.into()),
31 }
32 }
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36pub struct TestPhases {
37 pub ping: PhaseResult,
38 pub download: PhaseResult,
39 pub upload: PhaseResult,
40}
41
42#[derive(Debug, Clone, Deserialize, Serialize)]
43pub struct Server {
44 #[serde(rename = "@id")]
45 pub id: String,
46 #[serde(rename = "@url")]
47 pub url: String,
48 #[serde(rename = "@name")]
49 pub name: String,
50 #[serde(rename = "@sponsor")]
51 pub sponsor: String,
52 #[serde(rename = "@country")]
53 pub country: String,
54 #[serde(rename = "@lat")]
55 pub lat: f64,
56 #[serde(rename = "@lon")]
57 pub lon: f64,
58 #[serde(skip)]
59 pub distance: f64,
60}
61
62#[derive(Debug, Clone, Serialize)]
63pub struct TestResult {
64 pub status: String,
65 pub version: String, #[serde(skip_serializing_if = "Option::is_none")]
67 pub test_id: Option<String>, pub server: ServerInfo,
69 pub ping: Option<f64>,
70 pub jitter: Option<f64>,
71 pub packet_loss: Option<f64>,
72 pub download: Option<f64>,
73 pub download_peak: Option<f64>,
74 pub download_cv: Option<f64>, pub upload: Option<f64>,
76 pub upload_peak: Option<f64>,
77 pub upload_cv: Option<f64>, pub download_ci_95: Option<(f64, f64)>, pub upload_ci_95: Option<(f64, f64)>, pub latency_download: Option<f64>,
81 pub latency_upload: Option<f64>,
82 pub download_samples: Option<Vec<f64>>,
83 pub upload_samples: Option<Vec<f64>>,
84 pub ping_samples: Option<Vec<f64>>,
85 pub timestamp: String,
86 pub client_ip: Option<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 pub client_location: Option<ClientLocation>,
89 pub overall_grade: Option<String>,
91 pub download_grade: Option<String>,
92 pub upload_grade: Option<String>,
93 pub connection_rating: Option<String>,
94 pub phases: TestPhases,
95}
96
97pub struct TestResultBuilder {
102 server: ServerInfo,
103 ping: Option<f64>,
104 jitter: Option<f64>,
105 packet_loss: Option<f64>,
106 ping_samples: Vec<f64>,
107 download_avg_bps: f64,
108 download_peak_bps: f64,
109 download_samples: Vec<f64>,
110 download_latency: Option<f64>,
111 upload_avg_bps: f64,
112 upload_peak_bps: f64,
113 upload_samples: Vec<f64>,
114 upload_latency: Option<f64>,
115 client_ip: Option<String>,
116 client_location: Option<ClientLocation>,
117}
118
119impl TestResultBuilder {
120 pub fn new(server: ServerInfo) -> Self {
121 Self {
122 server,
123 ping: None,
124 jitter: None,
125 packet_loss: None,
126 ping_samples: Vec::new(),
127 download_avg_bps: 0.0,
128 download_peak_bps: 0.0,
129 download_samples: Vec::new(),
130 download_latency: None,
131 upload_avg_bps: 0.0,
132 upload_peak_bps: 0.0,
133 upload_samples: Vec::new(),
134 upload_latency: None,
135 client_ip: None,
136 client_location: None,
137 }
138 }
139
140 pub fn ping(mut self, ping: f64) -> Self {
141 self.ping = Some(ping);
142 self
143 }
144
145 pub fn jitter(mut self, jitter: f64) -> Self {
146 self.jitter = Some(jitter);
147 self
148 }
149
150 pub fn packet_loss(mut self, loss: f64) -> Self {
151 self.packet_loss = Some(loss);
152 self
153 }
154
155 pub fn ping_samples(mut self, samples: &[f64]) -> Self {
156 self.ping_samples = samples.to_vec();
157 self
158 }
159
160 pub fn download_stats(
161 mut self,
162 avg_bps: f64,
163 peak_bps: f64,
164 samples: &[f64],
165 latency_under_load: Option<f64>,
166 ) -> Self {
167 self.download_avg_bps = avg_bps;
168 self.download_peak_bps = peak_bps;
169 self.download_samples = samples.to_vec();
170 self.download_latency = latency_under_load;
171 self
172 }
173
174 pub fn upload_stats(
175 mut self,
176 avg_bps: f64,
177 peak_bps: f64,
178 samples: &[f64],
179 latency_under_load: Option<f64>,
180 ) -> Self {
181 self.upload_avg_bps = avg_bps;
182 self.upload_peak_bps = peak_bps;
183 self.upload_samples = samples.to_vec();
184 self.upload_latency = latency_under_load;
185 self
186 }
187
188 pub fn client_ip(mut self, ip: impl Into<String>) -> Self {
189 self.client_ip = Some(ip.into());
190 self
191 }
192
193 pub fn client_location(mut self, location: ClientLocation) -> Self {
194 self.client_location = Some(location);
195 self
196 }
197
198 pub fn build(self, stats: &dyn StatsService) -> TestResult {
200 fn opt_samples(v: &[f64]) -> Option<Vec<f64>> {
201 if v.is_empty() { None } else { Some(v.to_vec()) }
202 }
203 fn opt_positive(v: f64) -> Option<f64> {
204 if v > 0.0 { Some(v) } else { None }
205 }
206
207 TestResult {
208 status: "ok".to_string(),
209 version: env!("CARGO_PKG_VERSION").to_string(),
210 test_id: Some(uuid_v4()),
211 server: self.server,
212 ping: self.ping,
213 jitter: self.jitter,
214 packet_loss: self.packet_loss,
215 download: opt_positive(self.download_avg_bps),
216 download_peak: opt_positive(self.download_peak_bps),
217 download_cv: stats.coefficient_of_variation(&self.download_samples),
218 upload: opt_positive(self.upload_avg_bps),
219 upload_peak: opt_positive(self.upload_peak_bps),
220 upload_cv: stats.coefficient_of_variation(&self.upload_samples),
221 download_ci_95: stats.confidence_interval_95(&self.download_samples, 1_000_000.0),
222 upload_ci_95: stats.confidence_interval_95(&self.upload_samples, 1_000_000.0),
223 latency_download: self.download_latency,
224 latency_upload: self.upload_latency,
225 download_samples: opt_samples(&self.download_samples),
226 upload_samples: opt_samples(&self.upload_samples),
227 ping_samples: opt_samples(&self.ping_samples),
228 timestamp: chrono::Utc::now().to_rfc3339(),
229 client_ip: self.client_ip,
230 client_location: self.client_location,
231 overall_grade: None,
232 download_grade: None,
233 upload_grade: None,
234 connection_rating: None,
235 phases: TestPhases {
236 ping: PhaseResult::completed(),
237 download: PhaseResult::completed(),
238 upload: PhaseResult::completed(),
239 },
240 }
241 }
242}
243
244impl TestResult {
245 #[allow(clippy::too_many_arguments)]
248 #[must_use]
249 pub fn from_test_runs(
250 server: ServerInfo,
251 ping: Option<f64>,
252 jitter: Option<f64>,
253 packet_loss: Option<f64>,
254 ping_samples: &[f64],
255 dl: &crate::task_runner::TestRunResult,
256 ul: &crate::task_runner::TestRunResult,
257 client_ip: Option<String>,
258 client_location: Option<ClientLocation>,
259 ) -> Self {
260 let mut builder = TestResultBuilder::new(server)
261 .ping_samples(ping_samples)
262 .download_stats(
263 dl.avg_bps,
264 dl.peak_bps,
265 &dl.speed_samples,
266 dl.latency_under_load,
267 )
268 .upload_stats(
269 ul.avg_bps,
270 ul.peak_bps,
271 &ul.speed_samples,
272 ul.latency_under_load,
273 );
274
275 if let Some(p) = ping {
276 builder = builder.ping(p);
277 }
278 if let Some(j) = jitter {
279 builder = builder.jitter(j);
280 }
281 if let Some(pl) = packet_loss {
282 builder = builder.packet_loss(pl);
283 }
284 if let Some(ip) = client_ip {
285 builder = builder.client_ip(ip);
286 }
287 if let Some(loc) = client_location {
288 builder = builder.client_location(loc);
289 }
290
291 builder.build(&DefaultStats)
292 }
293}
294
295fn compute_cv(samples: &[f64]) -> Option<f64> {
298 if samples.len() < 2 {
299 return None;
300 }
301 let mean: f64 = samples.iter().sum::<f64>() / samples.len() as f64;
303 if mean == 0.0 {
304 return None;
305 }
306 let variance: f64 =
308 samples.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / (samples.len() - 1) as f64;
309 let std_dev = variance.sqrt();
310 Some(std_dev / mean)
311}
312
313fn compute_ci_95(samples: &[f64], scale: f64) -> Option<(f64, f64)> {
316 let n = samples.len();
317 if n < 2 {
318 return None;
319 }
320 let mean: f64 = samples.iter().sum::<f64>() / n as f64;
322 if n < 30 {
323 let variance: f64 =
326 samples.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
327 let std_err = variance.sqrt() / (n as f64).sqrt();
328 let margin = 2.045 * std_err;
329 Some(((mean - margin) / scale, (mean + margin) / scale))
330 } else {
331 let variance: f64 =
334 samples.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
335 let std_err = variance.sqrt() / (n as f64).sqrt();
336 let margin = 1.96 * std_err;
337 Some(((mean - margin) / scale, (mean + margin) / scale))
338 }
339}
340
341pub trait StatsService: Send + Sync {
343 fn coefficient_of_variation(&self, samples: &[f64]) -> Option<f64>;
344 fn confidence_interval_95(&self, samples: &[f64], scale: f64) -> Option<(f64, f64)>;
345}
346
347pub struct DefaultStats;
349
350impl DefaultStats {
351 pub fn new() -> Self {
352 Self
353 }
354}
355
356impl Default for DefaultStats {
357 fn default() -> Self {
358 Self::new()
359 }
360}
361
362impl StatsService for DefaultStats {
363 fn coefficient_of_variation(&self, samples: &[f64]) -> Option<f64> {
364 compute_cv(samples)
365 }
366
367 fn confidence_interval_95(&self, samples: &[f64], scale: f64) -> Option<(f64, f64)> {
368 compute_ci_95(samples, scale)
369 }
370}
371
372#[derive(Debug, Clone, Serialize)]
373pub struct ServerInfo {
374 pub id: String,
375 pub name: String,
376 pub sponsor: String,
377 pub country: String,
378 pub distance: f64,
379}
380
381#[derive(Debug, Clone, Serialize, Default)]
383pub struct ClientLocation {
384 pub lat: f64,
385 pub lon: f64,
386 #[serde(skip_serializing_if = "Option::is_none")]
387 pub city: Option<String>,
388 #[serde(skip_serializing_if = "Option::is_none")]
389 pub country: Option<String>,
390}
391
392#[derive(Debug, Clone, Serialize)]
393pub struct CsvOutput {
394 pub server_id: String,
395 pub sponsor: String,
396 pub server_name: String,
397 pub timestamp: String,
398 pub distance: f64,
399 pub ping: f64,
400 pub jitter: f64,
401 pub packet_loss: f64,
402 pub download: f64,
403 pub download_peak: f64,
404 pub upload: f64,
405 pub upload_peak: f64,
406 pub ip_address: String,
407}
408
409#[cfg(test)]
410#[allow(clippy::items_after_test_module)]
411mod tests {
412 use super::*;
413
414 #[test]
415 fn test_server_serialization() {
416 let server = Server {
417 id: "1234".to_string(),
418 url: "http://example.com".to_string(),
419 name: "Test Server".to_string(),
420 sponsor: "Test ISP".to_string(),
421 country: "US".to_string(),
422 lat: 40.7128,
423 lon: -74.0060,
424 distance: 100.5,
425 };
426
427 let json = serde_json::to_string(&server).unwrap();
428 assert!(json.contains("\"1234\""));
430 assert!(json.contains("\"Test Server\""));
431 }
432
433 #[test]
434 fn test_test_result_serialization() {
435 let result = TestResult {
436 status: "ok".to_string(),
437 version: env!("CARGO_PKG_VERSION").to_string(),
438 test_id: None,
439 server: ServerInfo {
440 id: "1234".to_string(),
441 name: "Test Server".to_string(),
442 sponsor: "Test ISP".to_string(),
443 country: "US".to_string(),
444 distance: 100.5,
445 },
446 ping: Some(15.234),
447 jitter: Some(1.2),
448 packet_loss: Some(0.0),
449 download: Some(150_000_000.0),
450 download_peak: Some(180_000_000.0),
451 upload: Some(50_000_000.0),
452 upload_peak: Some(60_000_000.0),
453 latency_download: Some(25.0),
454 latency_upload: Some(30.0),
455 download_samples: None,
456 upload_samples: None,
457 ping_samples: None,
458 timestamp: "2026-04-04T12:00:00Z".to_string(),
459 client_ip: Some("192.168.1.1".to_string()),
460 client_location: None,
461 download_cv: None,
462 upload_cv: None,
463 download_ci_95: None,
464 upload_ci_95: None,
465 overall_grade: None,
466 download_grade: None,
467 upload_grade: None,
468 connection_rating: None,
469 phases: TestPhases {
470 ping: PhaseResult::completed(),
471 download: PhaseResult::completed(),
472 upload: PhaseResult::completed(),
473 },
474 };
475
476 let json = serde_json::to_string(&result).unwrap();
477 assert!(json.contains("\"status\":\"ok\""));
478 assert!(json.contains("\"ping\":15.234"));
479 assert!(json.contains("\"jitter\":1.2"));
480 assert!(json.contains("\"download\":150000000.0"));
481 assert!(json.contains("\"phases\""));
482 }
483
484 #[test]
485 fn test_csv_output_serialization() {
486 let csv = CsvOutput {
487 server_id: "1234".to_string(),
488 sponsor: "Test ISP".to_string(),
489 server_name: "Test Server".to_string(),
490 timestamp: "2026-04-04T12:00:00Z".to_string(),
491 distance: 100.5,
492 ping: 15.234,
493 jitter: 1.2,
494 packet_loss: 0.0,
495 download: 150_000_000.0,
496 download_peak: 180_000_000.0,
497 upload: 50_000_000.0,
498 upload_peak: 60_000_000.0,
499 ip_address: "192.168.1.1".to_string(),
500 };
501
502 let json = serde_json::to_string(&csv).unwrap();
503 assert!(json.contains("\"server_id\":\"1234\""));
504 assert!(json.contains("\"ping\":15.234"));
505 assert!(json.contains("\"jitter\":1.2"));
506 }
507
508 #[test]
509 fn test_server_clone() {
510 let server = Server {
511 id: "1234".to_string(),
512 url: "http://example.com".to_string(),
513 name: "Test".to_string(),
514 sponsor: "ISP".to_string(),
515 country: "US".to_string(),
516 lat: 40.0,
517 lon: -74.0,
518 distance: 0.0,
519 };
520
521 let cloned = server.clone();
522 assert_eq!(cloned.id, server.id);
523 assert_eq!(cloned.name, server.name);
524 }
525
526 #[test]
527 fn test_phase_result_skipped_serialization() {
528 let phase = PhaseResult::skipped("disabled by user");
529 let json = serde_json::to_string(&phase).unwrap();
530 assert!(json.contains("\"state\":\"skipped\""));
531 assert!(json.contains("\"reason\":\"disabled by user\""));
532 }
533
534 #[test]
535 fn test_uuid_v4_format() {
536 let id = uuid_v4();
537 assert_eq!(id.len(), 36);
539 assert!(id.chars().all(|c| c.is_ascii_hexdigit() || c == '-'));
540 assert_eq!(&id[14..15], "4"); }
542
543 #[test]
544 fn test_uuid_v4_unique() {
545 let id1 = uuid_v4();
546 let id2 = uuid_v4();
547 assert_ne!(id1, id2);
548 }
549
550 #[test]
551 fn test_client_location_serialization() {
552 let loc = ClientLocation {
553 lat: 40.7128,
554 lon: -74.0060,
555 city: Some("New York".to_string()),
556 country: Some("US".to_string()),
557 };
558 let json = serde_json::to_string(&loc).unwrap();
559 assert!(json.contains("\"lat\":40.7128"));
560 assert!(json.contains("\"lon\":-74.006"));
561 assert!(json.contains("\"city\":\"New York\""));
562 }
563
564 #[test]
565 fn test_client_location_minimal() {
566 let loc = ClientLocation {
567 lat: 0.0,
568 lon: 0.0,
569 city: None,
570 country: None,
571 };
572 let json = serde_json::to_string(&loc).unwrap();
573 assert!(!json.contains("city"));
574 assert!(!json.contains("country"));
575 }
576
577 #[test]
578 fn test_result_builder_full() {
579 let server = ServerInfo {
580 id: "1".to_string(),
581 name: "Server".to_string(),
582 sponsor: "ISP".to_string(),
583 country: "US".to_string(),
584 distance: 50.0,
585 };
586 let result = TestResultBuilder::new(server)
587 .ping(10.0)
588 .jitter(2.0)
589 .packet_loss(0.5)
590 .ping_samples(&[10.0, 12.0])
591 .download_stats(
592 100_000_000.0,
593 120_000_000.0,
594 &[90_000_000.0, 110_000_000.0],
595 Some(25.0),
596 )
597 .upload_stats(
598 50_000_000.0,
599 60_000_000.0,
600 &[45_000_000.0, 55_000_000.0],
601 Some(30.0),
602 )
603 .client_ip("1.2.3.4")
604 .client_location(ClientLocation {
605 lat: 40.0,
606 lon: -74.0,
607 city: Some("NYC".to_string()),
608 country: Some("US".to_string()),
609 })
610 .build(&DefaultStats);
611
612 assert_eq!(result.status, "ok");
613 assert_eq!(result.ping, Some(10.0));
614 assert_eq!(result.jitter, Some(2.0));
615 assert_eq!(result.packet_loss, Some(0.5));
616 assert_eq!(result.download, Some(100_000_000.0));
617 assert_eq!(result.download_peak, Some(120_000_000.0));
618 assert_eq!(result.upload, Some(50_000_000.0));
619 assert_eq!(result.client_ip, Some("1.2.3.4".to_string()));
620 assert!(result.download_samples.is_some());
621 assert!(result.ping_samples.is_some());
622 assert!(result.download_cv.is_some());
623 assert!(result.download_ci_95.is_some());
624 assert!(result.test_id.is_some());
625 }
626
627 #[test]
628 fn test_result_builder_minimal() {
629 let server = ServerInfo {
630 id: "0".to_string(),
631 name: "".to_string(),
632 sponsor: "".to_string(),
633 country: "".to_string(),
634 distance: 0.0,
635 };
636 let result = TestResultBuilder::new(server).build(&DefaultStats);
637
638 assert_eq!(result.status, "ok");
639 assert!(result.ping.is_none());
640 assert!(result.jitter.is_none());
641 assert!(result.download.is_none());
642 assert!(result.upload.is_none());
643 assert!(result.client_ip.is_none());
644 assert!(result.download_samples.is_none());
645 assert!(result.ping_samples.is_none());
646 assert!(result.download_cv.is_none());
647 assert!(result.upload_cv.is_none());
648 }
649
650 #[test]
651 fn test_stats_service_cv_known_data() {
652 let stats = DefaultStats::new();
653 let cv = stats.coefficient_of_variation(&[10.0, 12.0]);
654 assert!(cv.is_some());
655 let val = cv.unwrap();
656 assert!(val > 0.0);
657 }
658
659 #[test]
660 fn test_stats_service_cv_empty() {
661 let stats = DefaultStats::new();
662 assert!(stats.coefficient_of_variation(&[]).is_none());
663 }
664
665 #[test]
666 fn test_stats_service_cv_single() {
667 let stats = DefaultStats::new();
668 assert!(stats.coefficient_of_variation(&[42.0]).is_none());
669 }
670
671 #[test]
672 fn test_stats_service_cv_zero_mean() {
673 let stats = DefaultStats::new();
674 assert!(stats.coefficient_of_variation(&[0.0, 0.0, 0.0]).is_none());
675 }
676
677 #[test]
678 fn test_stats_service_ci95_known_data() {
679 let stats = DefaultStats::new();
680 let ci = stats.confidence_interval_95(&[100.0, 102.0, 98.0, 101.0, 99.0], 1.0);
681 assert!(ci.is_some());
682 let (lo, hi) = ci.unwrap();
683 assert!(lo < hi);
684 let mid = (lo + hi) / 2.0;
685 assert!((mid - 100.0).abs() < 2.0);
686 }
687
688 #[test]
689 fn test_stats_service_ci95_too_few_samples() {
690 let stats = DefaultStats::new();
691 assert!(stats.confidence_interval_95(&[50.0], 1.0).is_none());
692 }
693}
694
695fn uuid_v4() -> String {
698 use std::time::{SystemTime, UNIX_EPOCH};
699 let timestamp = SystemTime::now()
700 .duration_since(UNIX_EPOCH)
701 .unwrap_or_default()
702 .as_nanos();
703 let random: u64 = rand_simple();
704 format!(
706 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
707 (timestamp as u64) & 0xFFFFFFFF,
708 ((random >> 48) & 0xFFFF) as u16,
709 ((random >> 32) & 0xFFF) as u16,
710 ((random >> 16) & 0xFFFF) as u16,
711 random & 0xFFFFFFFFFFFF
712 )
713}
714
715fn rand_simple() -> u64 {
718 use std::sync::atomic::{AtomicU64, Ordering};
719 static STATE: AtomicU64 = AtomicU64::new(0x123456789ABCDEF0);
720 let mut state = STATE.load(Ordering::Relaxed);
721 if state == 0 {
722 state = 0x123456789ABCDEF0;
723 }
724 state ^= state << 13;
725 state ^= state >> 7;
726 state ^= state << 17;
727 STATE.store(state, Ordering::Relaxed);
728 state
729}