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_uuid_v4_unique_concurrent() {
552 use std::collections::HashSet;
553 use std::sync::{Arc, Mutex};
554 use std::thread;
555
556 let ids: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
557 let threads: Vec<_> = (0..32)
558 .map(|_| {
559 let ids = Arc::clone(&ids);
560 thread::spawn(move || {
561 let id = uuid_v4();
562 ids.lock().unwrap().insert(id);
563 })
564 })
565 .collect();
566 for t in threads {
567 t.join().unwrap();
568 }
569 assert_eq!(
570 ids.lock().unwrap().len(),
571 32,
572 "concurrent uuid_v4 produced duplicates"
573 );
574 }
575
576 #[test]
577 fn test_client_location_serialization() {
578 let loc = ClientLocation {
579 lat: 40.7128,
580 lon: -74.0060,
581 city: Some("New York".to_string()),
582 country: Some("US".to_string()),
583 };
584 let json = serde_json::to_string(&loc).unwrap();
585 assert!(json.contains("\"lat\":40.7128"));
586 assert!(json.contains("\"lon\":-74.006"));
587 assert!(json.contains("\"city\":\"New York\""));
588 }
589
590 #[test]
591 fn test_client_location_minimal() {
592 let loc = ClientLocation {
593 lat: 0.0,
594 lon: 0.0,
595 city: None,
596 country: None,
597 };
598 let json = serde_json::to_string(&loc).unwrap();
599 assert!(!json.contains("city"));
600 assert!(!json.contains("country"));
601 }
602
603 #[test]
604 fn test_result_builder_full() {
605 let server = ServerInfo {
606 id: "1".to_string(),
607 name: "Server".to_string(),
608 sponsor: "ISP".to_string(),
609 country: "US".to_string(),
610 distance: 50.0,
611 };
612 let result = TestResultBuilder::new(server)
613 .ping(10.0)
614 .jitter(2.0)
615 .packet_loss(0.5)
616 .ping_samples(&[10.0, 12.0])
617 .download_stats(
618 100_000_000.0,
619 120_000_000.0,
620 &[90_000_000.0, 110_000_000.0],
621 Some(25.0),
622 )
623 .upload_stats(
624 50_000_000.0,
625 60_000_000.0,
626 &[45_000_000.0, 55_000_000.0],
627 Some(30.0),
628 )
629 .client_ip("1.2.3.4")
630 .client_location(ClientLocation {
631 lat: 40.0,
632 lon: -74.0,
633 city: Some("NYC".to_string()),
634 country: Some("US".to_string()),
635 })
636 .build(&DefaultStats);
637
638 assert_eq!(result.status, "ok");
639 assert_eq!(result.ping, Some(10.0));
640 assert_eq!(result.jitter, Some(2.0));
641 assert_eq!(result.packet_loss, Some(0.5));
642 assert_eq!(result.download, Some(100_000_000.0));
643 assert_eq!(result.download_peak, Some(120_000_000.0));
644 assert_eq!(result.upload, Some(50_000_000.0));
645 assert_eq!(result.client_ip, Some("1.2.3.4".to_string()));
646 assert!(result.download_samples.is_some());
647 assert!(result.ping_samples.is_some());
648 assert!(result.download_cv.is_some());
649 assert!(result.download_ci_95.is_some());
650 assert!(result.test_id.is_some());
651 }
652
653 #[test]
654 fn test_result_builder_minimal() {
655 let server = ServerInfo {
656 id: "0".to_string(),
657 name: "".to_string(),
658 sponsor: "".to_string(),
659 country: "".to_string(),
660 distance: 0.0,
661 };
662 let result = TestResultBuilder::new(server).build(&DefaultStats);
663
664 assert_eq!(result.status, "ok");
665 assert!(result.ping.is_none());
666 assert!(result.jitter.is_none());
667 assert!(result.download.is_none());
668 assert!(result.upload.is_none());
669 assert!(result.client_ip.is_none());
670 assert!(result.download_samples.is_none());
671 assert!(result.ping_samples.is_none());
672 assert!(result.download_cv.is_none());
673 assert!(result.upload_cv.is_none());
674 }
675
676 #[test]
677 fn test_stats_service_cv_known_data() {
678 let stats = DefaultStats::new();
679 let cv = stats.coefficient_of_variation(&[10.0, 12.0]);
680 assert!(cv.is_some());
681 let val = cv.unwrap();
682 assert!(val > 0.0);
683 }
684
685 #[test]
686 fn test_stats_service_cv_empty() {
687 let stats = DefaultStats::new();
688 assert!(stats.coefficient_of_variation(&[]).is_none());
689 }
690
691 #[test]
692 fn test_stats_service_cv_single() {
693 let stats = DefaultStats::new();
694 assert!(stats.coefficient_of_variation(&[42.0]).is_none());
695 }
696
697 #[test]
698 fn test_stats_service_cv_zero_mean() {
699 let stats = DefaultStats::new();
700 assert!(stats.coefficient_of_variation(&[0.0, 0.0, 0.0]).is_none());
701 }
702
703 #[test]
704 fn test_stats_service_ci95_known_data() {
705 let stats = DefaultStats::new();
706 let ci = stats.confidence_interval_95(&[100.0, 102.0, 98.0, 101.0, 99.0], 1.0);
707 assert!(ci.is_some());
708 let (lo, hi) = ci.unwrap();
709 assert!(lo < hi);
710 let mid = (lo + hi) / 2.0;
711 assert!((mid - 100.0).abs() < 2.0);
712 }
713
714 #[test]
715 fn test_stats_service_ci95_too_few_samples() {
716 let stats = DefaultStats::new();
717 assert!(stats.confidence_interval_95(&[50.0], 1.0).is_none());
718 }
719}
720
721fn uuid_v4() -> String {
724 use std::time::{SystemTime, UNIX_EPOCH};
725 let timestamp = SystemTime::now()
726 .duration_since(UNIX_EPOCH)
727 .unwrap_or_default()
728 .as_nanos();
729 let random: u64 = rand_simple();
730 format!(
732 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
733 (timestamp as u64) & 0xFFFFFFFF,
734 ((random >> 48) & 0xFFFF) as u16,
735 ((random >> 32) & 0xFFF) as u16,
736 ((random >> 16) & 0xFFFF) as u16,
737 random & 0xFFFFFFFFFFFF
738 )
739}
740
741fn rand_simple() -> u64 {
744 use std::sync::atomic::{AtomicU64, Ordering};
745 static STATE: AtomicU64 = AtomicU64::new(0x123456789ABCDEF0);
746 loop {
747 let prev = STATE.load(Ordering::Relaxed);
748 let mut next = if prev == 0 { 0x123456789ABCDEF0 } else { prev };
749 next ^= next << 13;
750 next ^= next >> 7;
751 next ^= next << 17;
752 if STATE
753 .compare_exchange_weak(prev, next, Ordering::Relaxed, Ordering::Relaxed)
754 .is_ok()
755 {
756 return next;
757 }
758 }
759}