Skip to main content

netrunner_core/
speed_test.rs

1//! Speed Test Module
2//!
3//! A robust, high-performance speed-test implementation tuned for accuracy
4//! (results comparable to speedtest.net):
5//! - Throughput is measured against a reliable anycast backend
6//!   (`speed.cloudflare.com`, which routes to the nearest edge and actually
7//!   implements the download/upload protocol) rather than a mix of discovered
8//!   servers that may not serve the endpoint.
9//! - 50 parallel connections to saturate fast links.
10//! - Lock-free (atomic) byte counting, so the measurement itself is not the
11//!   bottleneck at gigabit speeds.
12//! - Excludes the TCP slow-start warmup window and reports a trimmed,
13//!   steady-state *sustained* throughput.
14//! - Geolocation-based server selection is still used for latency/ping.
15//! - Support for speeds up to 10 Gbps, with graceful failure (0 Mbps ->
16//!   `ConnectionQuality::Failed`) instead of a misleading floor.
17
18use chrono::Utc;
19use futures::stream::{FuturesUnordered, StreamExt};
20use reqwest::Client;
21use serde::{Deserialize, Serialize};
22use std::net::IpAddr;
23use std::sync::atomic::{AtomicU64, Ordering};
24use std::sync::Arc;
25use std::time::{Duration, Instant};
26use tokio::sync::RwLock;
27
28use crate::events::{emit, EventSender, Phase, SelectedServer, TestEvent};
29use crate::types::{
30    ConnectionQuality, ServerCapabilities, ServerProvider, SpeedTestResult, TestConfig, TestServer,
31};
32
33const PARALLEL_CONNECTIONS: usize = 50;
34const SERVER_SELECTION_COUNT: usize = 3;
35/// Total wall-clock duration of a throughput phase.
36const TEST_SECS: u64 = 15;
37/// Initial window excluded from the final throughput calc (TCP slow-start).
38const WARMUP_SECS: f64 = 3.0;
39/// Reliable, anycast throughput backend: Cloudflare's speed-test endpoints,
40/// which route to the nearest edge and actually implement `/__down` + `/__up`.
41/// Measuring against one well-provisioned backend — rather than a mix of
42/// discovered servers that may not implement the download protocol — is what
43/// makes the numbers comparable to speedtest.net. (The previous code appended
44/// `/__down` to every server, but only Cloudflare serves it, so half the
45/// connections transferred nothing and the result was badly under-reported.)
46const DOWNLOAD_ENDPOINT: &str = "https://speed.cloudflare.com/__down?bytes=100000000";
47const UPLOAD_ENDPOINT: &str = "https://speed.cloudflare.com/__up";
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct GeoLocation {
51    pub country: String,
52    pub city: String,
53    pub latitude: f64,
54    pub longitude: f64,
55    pub isp: Option<String>,
56}
57
58#[derive(Debug, Clone)]
59#[allow(dead_code)]
60pub struct ServerPerformance {
61    pub server: TestServer,
62    pub latency_ms: f64,
63    pub jitter_ms: f64,
64    pub packet_loss: f64,
65    pub download_score: f64,
66    pub upload_score: f64,
67    pub overall_score: f64,
68}
69
70pub struct SpeedTest {
71    config: TestConfig,
72    client: Client,
73    events: Option<EventSender>,
74    geo_location: Arc<RwLock<Option<GeoLocation>>>,
75    server_pool: Arc<RwLock<Vec<TestServer>>>,
76}
77
78impl SpeedTest {
79    pub fn new(config: TestConfig) -> Result<Self, Box<dyn std::error::Error>> {
80        Self::with_events(config, None)
81    }
82
83    /// Create a speed test that reports progress through an [`EventSender`].
84    ///
85    /// Front-ends (CLI, GUI) pass a channel here to receive live
86    /// [`TestEvent`]s; passing `None` runs silently.
87    pub fn with_events(
88        config: TestConfig,
89        events: Option<EventSender>,
90    ) -> Result<Self, Box<dyn std::error::Error>> {
91        let client = Client::builder()
92            .timeout(Duration::from_secs(30))
93            .pool_max_idle_per_host(100)
94            .pool_idle_timeout(Duration::from_secs(120))
95            .tcp_keepalive(Duration::from_secs(10))
96            .http2_keep_alive_interval(Duration::from_secs(10))
97            .http2_adaptive_window(true)
98            .http2_initial_stream_window_size(1024 * 1024) // 1MB
99            .http2_initial_connection_window_size(2 * 1024 * 1024) // 2MB
100            .danger_accept_invalid_certs(false)
101            .build()?;
102
103        Ok(Self {
104            config,
105            client,
106            events,
107            geo_location: Arc::new(RwLock::new(None)),
108            server_pool: Arc::new(RwLock::new(Vec::new())),
109        })
110    }
111
112    /// Attach or replace the progress event channel.
113    pub fn set_events(&mut self, events: Option<EventSender>) {
114        self.events = events;
115    }
116
117    /// Run the complete speed test with intelligent server selection
118    pub async fn run_full_test(&self) -> Result<SpeedTestResult, Box<dyn std::error::Error>> {
119        let start = Instant::now();
120
121        // Phase 1: Detect location
122        emit(&self.events, TestEvent::PhaseStarted(Phase::Locating));
123        let geo = self.detect_location().await?;
124        *self.geo_location.write().await = Some(geo.clone());
125
126        // Phase 2: Build server pool
127        emit(
128            &self.events,
129            TestEvent::PhaseStarted(Phase::BuildingServers),
130        );
131        self.build_server_pool(&geo).await?;
132
133        // Phase 3: Select best servers
134        emit(
135            &self.events,
136            TestEvent::PhaseStarted(Phase::SelectingServers),
137        );
138        let best_servers = self.select_best_servers().await?;
139
140        emit(
141            &self.events,
142            TestEvent::PrimarySelected {
143                name: best_servers[0].name.clone(),
144                location: best_servers[0].location.clone(),
145                distance_km: best_servers[0].distance_km.unwrap_or(0.0),
146            },
147        );
148
149        // Phase 4: Measure latency
150        let ping_ms = self.measure_latency(&best_servers[0]).await?;
151
152        // Phase 5: Download test (progressive)
153        let download_mbps = self.progressive_download_test().await?;
154
155        // Phase 6: Upload test (progressive)
156        let upload_mbps = self.progressive_upload_test().await?;
157
158        // Phase 7: Calculate statistics
159        emit(&self.events, TestEvent::PhaseStarted(Phase::Jitter));
160        let (jitter_ms, packet_loss) = self.measure_jitter_and_loss(&best_servers[0]).await?;
161        emit(
162            &self.events,
163            TestEvent::JitterComplete {
164                jitter_ms,
165                packet_loss_percent: packet_loss,
166            },
167        );
168
169        let quality = ConnectionQuality::from_speed_and_ping(download_mbps, upload_mbps, ping_ms);
170        let test_duration = start.elapsed().as_secs_f64();
171
172        let result = SpeedTestResult {
173            timestamp: Utc::now(),
174            download_mbps,
175            upload_mbps,
176            ping_ms,
177            jitter_ms,
178            packet_loss_percent: packet_loss,
179            server_location: best_servers[0].location.clone(),
180            server_ip: self.resolve_server_ip(&best_servers[0].url).await,
181            client_ip: self.get_client_ip().await,
182            quality,
183            test_duration_seconds: test_duration,
184            isp: geo.isp.clone(),
185        };
186
187        emit(&self.events, TestEvent::Completed(Box::new(result.clone())));
188
189        Ok(result)
190    }
191
192    /// Detect user's geolocation using multiple services
193    async fn detect_location(&self) -> Result<GeoLocation, Box<dyn std::error::Error>> {
194        emit(
195            &self.events,
196            TestEvent::Status("Detecting your location...".to_string()),
197        );
198
199        // Try multiple geolocation services sequentially (first success wins)
200        // Try ipapi.co
201        match self.try_ipapi_co().await {
202            Ok(geo) => {
203                self.emit_location(&geo, "ipapi.co");
204                return Ok(geo);
205            }
206            Err(e) => {
207                // Log error at trace level for debugging
208                if std::env::var("NETRUNNER_DEBUG").is_ok() {
209                    eprintln!("[TRACE] ipapi.co geolocation failed: {}", e);
210                }
211            }
212        }
213
214        // Try ip-api.com
215        match self.try_ip_api_com().await {
216            Ok(geo) => {
217                self.emit_location(&geo, "ip-api.com");
218                return Ok(geo);
219            }
220            Err(e) => {
221                // Log error at trace level for debugging
222                if std::env::var("NETRUNNER_DEBUG").is_ok() {
223                    eprintln!("[TRACE] ip-api.com geolocation failed: {}", e);
224                }
225            }
226        }
227
228        // Try ipinfo.io
229        match self.try_ipinfo_io().await {
230            Ok(geo) => {
231                self.emit_location(&geo, "ipinfo.io");
232                return Ok(geo);
233            }
234            Err(e) => {
235                // Log error at trace level for debugging
236                if std::env::var("NETRUNNER_DEBUG").is_ok() {
237                    eprintln!("[TRACE] ipinfo.io geolocation failed: {}", e);
238                }
239            }
240        }
241
242        // Try freegeoip.app
243        match self.try_freegeoip_app().await {
244            Ok(geo) => {
245                self.emit_location(&geo, "freegeoip.app");
246                return Ok(geo);
247            }
248            Err(e) => {
249                // Log error at trace level for debugging
250                if std::env::var("NETRUNNER_DEBUG").is_ok() {
251                    eprintln!("[TRACE] freegeoip.app geolocation failed: {}", e);
252                }
253            }
254        }
255
256        // Try ipwhois.app
257        match self.try_ipwhois_app().await {
258            Ok(geo) => {
259                self.emit_location(&geo, "ipwhois.app");
260                return Ok(geo);
261            }
262            Err(e) => {
263                // Log error at trace level for debugging
264                if std::env::var("NETRUNNER_DEBUG").is_ok() {
265                    eprintln!("[TRACE] ipwhois.app geolocation failed: {}", e);
266                }
267            }
268        }
269
270        // Fallback: Use a default location (USA central) if all services fail
271        emit(
272            &self.events,
273            TestEvent::Status(
274                "Using default location (USA Central) - all geolocation services failed"
275                    .to_string(),
276            ),
277        );
278
279        Ok(GeoLocation {
280            country: "United States".to_string(),
281            city: "Kansas City".to_string(),
282            latitude: 39.0997,
283            longitude: -94.5786,
284            isp: None,
285        })
286    }
287
288    /// Emit a [`TestEvent::LocationDetected`] for a resolved location.
289    fn emit_location(&self, geo: &GeoLocation, source: &str) {
290        emit(
291            &self.events,
292            TestEvent::LocationDetected {
293                city: geo.city.clone(),
294                country: geo.country.clone(),
295                isp: geo.isp.clone(),
296                source: source.to_string(),
297            },
298        );
299    }
300
301    /// Fetch and parse a geolocation provider's JSON response (5s timeout).
302    async fn fetch_geo_json(
303        &self,
304        url: &str,
305    ) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
306        let response = self
307            .client
308            .get(url)
309            .timeout(Duration::from_secs(5))
310            .send()
311            .await?;
312        if !response.status().is_success() {
313            return Err(format!("HTTP error: {}", response.status()).into());
314        }
315        Ok(response.json().await?)
316    }
317
318    async fn try_ipapi_co(&self) -> Result<GeoLocation, Box<dyn std::error::Error>> {
319        let json = self.fetch_geo_json("https://ipapi.co/json/").await?;
320        if json.get("error").is_some() {
321            return Err(format!(
322                "API error: {}",
323                json["reason"].as_str().unwrap_or("Unknown")
324            )
325            .into());
326        }
327        build_geo(
328            geo_str_field(&json, "country_name")?,
329            geo_str_field(&json, "city")?,
330            json["latitude"].as_f64().ok_or("Invalid latitude")?,
331            json["longitude"].as_f64().ok_or("Invalid longitude")?,
332            json["org"].as_str().map(String::from),
333        )
334    }
335
336    async fn try_ip_api_com(&self) -> Result<GeoLocation, Box<dyn std::error::Error>> {
337        let json = self
338            .fetch_geo_json(
339                "http://ip-api.com/json/?fields=status,message,country,city,lat,lon,isp",
340            )
341            .await?;
342        if json["status"].as_str() != Some("success") {
343            return Err(format!(
344                "API error: {}",
345                json["message"].as_str().unwrap_or("Unknown")
346            )
347            .into());
348        }
349        build_geo(
350            geo_str_field(&json, "country")?,
351            geo_str_field(&json, "city")?,
352            json["lat"].as_f64().ok_or("Invalid latitude")?,
353            json["lon"].as_f64().ok_or("Invalid longitude")?,
354            json["isp"].as_str().map(String::from),
355        )
356    }
357
358    async fn try_ipinfo_io(&self) -> Result<GeoLocation, Box<dyn std::error::Error>> {
359        let json = self.fetch_geo_json("https://ipinfo.io/json").await?;
360        // ipinfo.io returns "lat,lon" in the "loc" field.
361        let (latitude, longitude) =
362            parse_latlon_pair(json["loc"].as_str().ok_or("Invalid location")?)?;
363        build_geo(
364            geo_str_field(&json, "country")?,
365            geo_str_field(&json, "city")?,
366            latitude,
367            longitude,
368            json["org"].as_str().map(String::from),
369        )
370    }
371
372    async fn try_freegeoip_app(&self) -> Result<GeoLocation, Box<dyn std::error::Error>> {
373        let json = self.fetch_geo_json("https://freegeoip.app/json/").await?;
374        build_geo(
375            geo_str_field(&json, "country_name")?,
376            geo_str_field(&json, "city")?,
377            json["latitude"].as_f64().ok_or("Invalid latitude")?,
378            json["longitude"].as_f64().ok_or("Invalid longitude")?,
379            None,
380        )
381    }
382
383    async fn try_ipwhois_app(&self) -> Result<GeoLocation, Box<dyn std::error::Error>> {
384        let json = self.fetch_geo_json("https://ipwho.is/").await?;
385        if !json["success"].as_bool().unwrap_or(false) {
386            return Err(format!(
387                "API error: {}",
388                json["message"].as_str().unwrap_or("Unknown")
389            )
390            .into());
391        }
392        build_geo(
393            geo_str_field(&json, "country")?,
394            geo_str_field(&json, "city")?,
395            json["latitude"].as_f64().ok_or("Invalid latitude")?,
396            json["longitude"].as_f64().ok_or("Invalid longitude")?,
397            json["connection"]["isp"].as_str().map(String::from),
398        )
399    }
400
401    /// Build a comprehensive server pool based on location
402    async fn build_server_pool(&self, geo: &GeoLocation) -> Result<(), Box<dyn std::error::Error>> {
403        emit(
404            &self.events,
405            TestEvent::Status("Building server pool...".to_string()),
406        );
407
408        let mut servers = Vec::new();
409
410        // Try dynamic server discovery first
411        servers.extend(self.discover_nearby_servers(geo).await);
412
413        // Add global CDN endpoints as fallback
414        servers.extend(self.get_global_cdn_servers());
415
416        // Calculate distances for servers that don't have them
417        for server in &mut servers {
418            if server.distance_km.is_none() {
419                server.distance_km = Some(self.estimate_distance(geo, server));
420            }
421        }
422
423        // Sort by distance (nearest first)
424        servers.sort_by(|a, b| {
425            a.distance_km
426                .unwrap_or(f64::MAX)
427                .partial_cmp(&b.distance_km.unwrap_or(f64::MAX))
428                .unwrap_or(std::cmp::Ordering::Equal)
429        });
430
431        // Keep only the best servers
432        servers.truncate(20);
433
434        let server_count = servers.len();
435        *self.server_pool.write().await = servers;
436
437        emit(
438            &self.events,
439            TestEvent::ServerPoolBuilt {
440                count: server_count,
441            },
442        );
443
444        Ok(())
445    }
446
447    fn get_global_cdn_servers(&self) -> Vec<TestServer> {
448        // Global fallback servers - used with low priority
449        vec![
450            TestServer {
451                name: "Cloudflare Global".to_string(),
452                url: "https://speed.cloudflare.com".to_string(),
453                location: "Global CDN".to_string(),
454                distance_km: Some(5000.0), // Lower priority than regional servers
455                latency_ms: None,
456                provider: ServerProvider::Cloudflare,
457                capabilities: ServerCapabilities {
458                    supports_download: true,
459                    supports_upload: true,
460                    supports_latency: true,
461                    max_test_size_mb: 2000,
462                    geographic_weight: 0.5, // Medium weight for global anycast
463                },
464                quality_score: None,
465                country_code: None,
466                city: None,
467                is_backup: true,
468            },
469            TestServer {
470                name: "Google Global".to_string(),
471                url: "https://www.google.com".to_string(),
472                location: "Global CDN".to_string(),
473                distance_km: Some(5000.0),
474                latency_ms: None,
475                provider: ServerProvider::Google,
476                capabilities: ServerCapabilities {
477                    supports_download: true,
478                    supports_upload: false,
479                    supports_latency: true,
480                    max_test_size_mb: 100,
481                    geographic_weight: 0.4,
482                },
483                quality_score: None,
484                country_code: None,
485                city: None,
486                is_backup: true,
487            },
488        ]
489    }
490
491    /// Dynamically discover nearby speed test servers based on user location
492    async fn discover_nearby_servers(&self, geo: &GeoLocation) -> Vec<TestServer> {
493        let mut servers = Vec::new();
494
495        emit(
496            &self.events,
497            TestEvent::Status("Discovering nearby speed test servers...".to_string()),
498        );
499
500        // Try to fetch speedtest.net server list
501        if let Ok(speedtest_servers) = self.fetch_speedtest_net_servers(geo).await {
502            servers.extend(speedtest_servers);
503        }
504
505        // Add continent-based CDN servers
506        servers.extend(self.get_continent_servers(geo));
507
508        // Add country-specific servers
509        servers.extend(self.get_country_servers(geo));
510
511        emit(
512            &self.events,
513            TestEvent::NearbyServersFound {
514                count: servers.len(),
515            },
516        );
517
518        servers
519    }
520
521    /// Fetch real speedtest.net server list based on location
522    async fn fetch_speedtest_net_servers(
523        &self,
524        geo: &GeoLocation,
525    ) -> Result<Vec<TestServer>, Box<dyn std::error::Error>> {
526        // Speedtest.net uses a JSON API to get nearby servers
527        let url = "https://www.speedtest.net/api/js/servers?engine=js&limit=10";
528
529        if let Ok(response) = self.client.get(url).send().await {
530            if let Ok(text) = response.text().await {
531                // Parse the response and create TestServer objects
532                if let Ok(servers) = self.parse_speedtest_servers(&text, geo) {
533                    return Ok(servers);
534                }
535            }
536        }
537
538        // Fallback: Use Open Speed Test servers
539        self.get_open_speedtest_servers(geo).await
540    }
541
542    fn parse_speedtest_servers(
543        &self,
544        json: &str,
545        geo: &GeoLocation,
546    ) -> Result<Vec<TestServer>, Box<dyn std::error::Error>> {
547        // Simple JSON parsing for speedtest.net format
548        // Format: [{"id":123,"host":"server.host.com","lat":40.7,"lon":-74.0,"name":"New York","country":"US","sponsor":"ISP Name"}]
549
550        let mut servers = Vec::new();
551
552        // Use serde_json to parse
553        if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(json) {
554            if let Some(array) = parsed.as_array() {
555                for server in array.iter().take(10) {
556                    if let (Some(host), Some(name), Some(country), Some(lat), Some(lon)) = (
557                        server.get("host").and_then(|v| v.as_str()),
558                        server.get("name").and_then(|v| v.as_str()),
559                        server.get("country").and_then(|v| v.as_str()),
560                        server.get("lat").and_then(|v| v.as_f64()),
561                        server.get("lon").and_then(|v| v.as_f64()),
562                    ) {
563                        let distance =
564                            self.calculate_distance(geo.latitude, geo.longitude, lat, lon);
565
566                        servers.push(TestServer {
567                            name: format!("{}, {}", name, country),
568                            url: format!("https://{}", host),
569                            location: format!("{}, {}", name, country),
570                            distance_km: Some(distance),
571                            latency_ms: None,
572                            provider: ServerProvider::Custom(
573                                host.split('.').next().unwrap_or("speedtest").to_string(),
574                            ),
575                            capabilities: ServerCapabilities {
576                                supports_download: true,
577                                supports_upload: true,
578                                supports_latency: true,
579                                max_test_size_mb: 1000,
580                                geographic_weight: 1.0,
581                            },
582                            quality_score: None,
583                            country_code: Some(country.to_string()),
584                            city: Some(name.to_string()),
585                            is_backup: false,
586                        });
587                    }
588                }
589            }
590        }
591
592        if servers.is_empty() {
593            Err("No servers parsed".into())
594        } else {
595            Ok(servers)
596        }
597    }
598
599    async fn get_open_speedtest_servers(
600        &self,
601        geo: &GeoLocation,
602    ) -> Result<Vec<TestServer>, Box<dyn std::error::Error>> {
603        // Fallback to manually curated list of high-performance servers
604        let mut servers = Vec::new();
605
606        // Major internet exchanges and data centers
607        let endpoints = vec![
608            (
609                "Cloudflare (Anycast)",
610                "https://speed.cloudflare.com",
611                0.0,
612                0.0,
613                "Global",
614            ),
615            (
616                "LibreSpeed DE-IX",
617                "https://frankfurt.speedtest.wtnet.de",
618                50.1109,
619                8.6821,
620                "Frankfurt, Germany",
621            ),
622            (
623                "LibreSpeed AMS-IX",
624                "https://ams.speedtest.wtnet.de",
625                52.3676,
626                4.9041,
627                "Amsterdam, Netherlands",
628            ),
629            (
630                "LibreSpeed Singapore",
631                "https://sg.speedtest.wtnet.de",
632                1.3521,
633                103.8198,
634                "Singapore",
635            ),
636            (
637                "LibreSpeed New York",
638                "https://nyc.speedtest.wtnet.de",
639                40.7128,
640                -74.0060,
641                "New York, USA",
642            ),
643            (
644                "LibreSpeed Los Angeles",
645                "https://la.speedtest.wtnet.de",
646                34.0522,
647                -118.2437,
648                "Los Angeles, USA",
649            ),
650            (
651                "LibreSpeed Tokyo",
652                "https://tyo.speedtest.wtnet.de",
653                35.6762,
654                139.6503,
655                "Tokyo, Japan",
656            ),
657            (
658                "LibreSpeed London",
659                "https://lon.speedtest.wtnet.de",
660                51.5074,
661                -0.1278,
662                "London, UK",
663            ),
664            (
665                "LibreSpeed Sydney",
666                "https://syd.speedtest.wtnet.de",
667                -33.8688,
668                151.2093,
669                "Sydney, Australia",
670            ),
671        ];
672
673        for (name, url, lat, lon, location) in endpoints {
674            let distance = if lat == 0.0 && lon == 0.0 {
675                999999.0 // Global anycast
676            } else {
677                self.calculate_distance(geo.latitude, geo.longitude, lat, lon)
678            };
679
680            servers.push(TestServer {
681                name: name.to_string(),
682                url: url.to_string(),
683                location: location.to_string(),
684                distance_km: Some(distance),
685                latency_ms: None,
686                provider: ServerProvider::Custom("LibreSpeed".to_string()),
687                capabilities: ServerCapabilities {
688                    supports_download: true,
689                    supports_upload: true,
690                    supports_latency: true,
691                    max_test_size_mb: 2000,
692                    geographic_weight: 0.9,
693                },
694                quality_score: None,
695                country_code: Some(location.split(", ").last().unwrap_or("").to_string()),
696                city: Some(location.split(", ").next().unwrap_or(location).to_string()),
697                is_backup: false,
698            });
699        }
700
701        Ok(servers)
702    }
703
704    fn get_continent_servers(&self, geo: &GeoLocation) -> Vec<TestServer> {
705        let mut servers = Vec::new();
706
707        // Determine continent based on coordinates
708        let continent = self.determine_continent(geo.latitude, geo.longitude);
709
710        match continent.as_str() {
711            "North America" => {
712                servers.push(self.create_server_with_coords(
713                    geo,
714                    "US East Coast Hub",
715                    "https://ash.speedtest.wtnet.de",
716                    "Ashburn, USA",
717                    Some("US".to_string()),
718                    39.0438,
719                    -77.4874,
720                ));
721                servers.push(self.create_server_with_coords(
722                    geo,
723                    "US West Coast Hub",
724                    "https://lax.speedtest.wtnet.de",
725                    "Los Angeles, USA",
726                    Some("US".to_string()),
727                    34.0522,
728                    -118.2437,
729                ));
730            }
731            "Europe" => {
732                servers.push(self.create_server_with_coords(
733                    geo,
734                    "Europe Central Hub",
735                    "https://frankfurt.speedtest.wtnet.de",
736                    "Frankfurt, Germany",
737                    Some("DE".to_string()),
738                    50.1109,
739                    8.6821,
740                ));
741                servers.push(self.create_server_with_coords(
742                    geo,
743                    "Europe West Hub",
744                    "https://lon.speedtest.wtnet.de",
745                    "London, UK",
746                    Some("GB".to_string()),
747                    51.5074,
748                    -0.1278,
749                ));
750            }
751            "Asia" => {
752                servers.push(self.create_server_with_coords(
753                    geo,
754                    "Asia Pacific Hub",
755                    "https://sg.speedtest.wtnet.de",
756                    "Singapore",
757                    Some("SG".to_string()),
758                    1.3521,
759                    103.8198,
760                ));
761                servers.push(self.create_server_with_coords(
762                    geo,
763                    "Asia East Hub",
764                    "https://tokyo.speedtest.wtnet.de",
765                    "Tokyo, Japan",
766                    Some("JP".to_string()),
767                    35.6762,
768                    139.6503,
769                ));
770            }
771            "South America" => {
772                servers.push(self.create_server_with_coords(
773                    geo,
774                    "South America Hub",
775                    "https://saopaulo.speedtest.wtnet.de",
776                    "São Paulo, Brazil",
777                    Some("BR".to_string()),
778                    -23.5505,
779                    -46.6333,
780                ));
781            }
782            "Africa" => {
783                servers.push(self.create_server_with_coords(
784                    geo,
785                    "Africa Hub",
786                    "https://capetown.speedtest.wtnet.de",
787                    "Cape Town, South Africa",
788                    Some("ZA".to_string()),
789                    -33.9249,
790                    18.4241,
791                ));
792            }
793            "Oceania" => {
794                servers.push(self.create_server_with_coords(
795                    geo,
796                    "Oceania Hub",
797                    "https://syd.speedtest.wtnet.de",
798                    "Sydney, Australia",
799                    Some("AU".to_string()),
800                    -33.8688,
801                    151.2093,
802                ));
803            }
804            _ => {}
805        }
806
807        servers
808    }
809
810    fn determine_continent(&self, lat: f64, lon: f64) -> String {
811        // Simple continent determination based on coordinates
812        if lat > 15.0 && lon > -130.0 && lon < -50.0 {
813            "North America".to_string()
814        } else if lat < 15.0 && lat > -60.0 && lon > -85.0 && lon < -30.0 {
815            "South America".to_string()
816        } else if lat > 35.0 && lon > -15.0 && lon < 60.0 {
817            "Europe".to_string()
818        } else if lat > -40.0 && lat < 40.0 && lon > -20.0 && lon < 55.0 {
819            "Africa".to_string()
820        } else if lat > -15.0 && lon > 60.0 && lon < 180.0 {
821            "Asia".to_string()
822        } else if lat < -10.0 && lon > 110.0 && lon < 180.0 {
823            "Oceania".to_string()
824        } else {
825            "Unknown".to_string()
826        }
827    }
828
829    fn get_country_servers(&self, geo: &GeoLocation) -> Vec<TestServer> {
830        let mut servers = Vec::new();
831
832        // Add country-specific servers based on common countries
833        match geo.country.as_str() {
834            "United States" | "US" => {
835                servers.push(self.create_server(
836                    "US Central",
837                    "https://dal.speedtest.wtnet.de",
838                    "Dallas, USA",
839                    Some("US".to_string()),
840                ));
841            }
842            "United Kingdom" | "GB" | "UK" => {
843                servers.push(self.create_server(
844                    "UK Primary",
845                    "https://lon.speedtest.wtnet.de",
846                    "London, UK",
847                    Some("GB".to_string()),
848                ));
849            }
850            "Germany" | "DE" => {
851                servers.push(self.create_server(
852                    "DE Primary",
853                    "https://frankfurt.speedtest.wtnet.de",
854                    "Frankfurt, Germany",
855                    Some("DE".to_string()),
856                ));
857            }
858            "France" | "FR" => {
859                servers.push(self.create_server(
860                    "FR Primary",
861                    "https://paris.speedtest.wtnet.de",
862                    "Paris, France",
863                    Some("FR".to_string()),
864                ));
865            }
866            "Japan" | "JP" => {
867                servers.push(self.create_server(
868                    "JP Primary",
869                    "https://tyo.speedtest.wtnet.de",
870                    "Tokyo, Japan",
871                    Some("JP".to_string()),
872                ));
873            }
874            "Australia" | "AU" => {
875                servers.push(self.create_server(
876                    "AU Primary",
877                    "https://syd.speedtest.wtnet.de",
878                    "Sydney, Australia",
879                    Some("AU".to_string()),
880                ));
881            }
882            "Canada" | "CA" => {
883                servers.push(self.create_server(
884                    "CA Primary",
885                    "https://tor.speedtest.wtnet.de",
886                    "Toronto, Canada",
887                    Some("CA".to_string()),
888                ));
889            }
890            _ => {}
891        }
892
893        servers
894    }
895
896    fn calculate_distance(&self, lat1: f64, lon1: f64, lat2: f64, lon2: f64) -> f64 {
897        // Haversine formula for distance calculation
898        let r = 6371.0; // Earth's radius in km
899        let d_lat = (lat2 - lat1).to_radians();
900        let d_lon = (lon2 - lon1).to_radians();
901        let lat1 = lat1.to_radians();
902        let lat2 = lat2.to_radians();
903
904        let a = (d_lat / 2.0).sin() * (d_lat / 2.0).sin()
905            + lat1.cos() * lat2.cos() * (d_lon / 2.0).sin() * (d_lon / 2.0).sin();
906        let c = 2.0 * a.sqrt().atan2((1.0 - a).sqrt());
907
908        r * c
909    }
910
911    #[allow(clippy::too_many_arguments)]
912    fn create_server_with_coords(
913        &self,
914        geo: &GeoLocation,
915        name: &str,
916        url: &str,
917        location: &str,
918        country_code: Option<String>,
919        lat: f64,
920        lon: f64,
921    ) -> TestServer {
922        let distance = self.calculate_distance(geo.latitude, geo.longitude, lat, lon);
923
924        TestServer {
925            name: name.to_string(),
926            url: url.to_string(),
927            location: location.to_string(),
928            distance_km: Some(distance),
929            latency_ms: None,
930            provider: ServerProvider::Custom("LibreSpeed".to_string()),
931            capabilities: ServerCapabilities {
932                supports_download: true,
933                supports_upload: true,
934                supports_latency: true,
935                max_test_size_mb: 2000,
936                geographic_weight: 1.0,
937            },
938            quality_score: None,
939            country_code,
940            city: Some(location.split(", ").next().unwrap_or(location).to_string()),
941            is_backup: false,
942        }
943    }
944
945    fn create_server(
946        &self,
947        name: &str,
948        url: &str,
949        location: &str,
950        country_code: Option<String>,
951    ) -> TestServer {
952        TestServer {
953            name: name.to_string(),
954            url: url.to_string(),
955            location: location.to_string(),
956            distance_km: None,
957            latency_ms: None,
958            provider: ServerProvider::Cloudflare,
959            capabilities: ServerCapabilities {
960                supports_download: true,
961                supports_upload: true,
962                supports_latency: true,
963                max_test_size_mb: 1000,
964                geographic_weight: 1.2,
965            },
966            quality_score: None,
967            country_code,
968            city: Some(location.split(',').next().unwrap_or("").trim().to_string()),
969            is_backup: false,
970        }
971    }
972
973    fn determine_region(&self, country: &str) -> String {
974        match country {
975            "United States" | "Canada" | "Mexico" => "North America".to_string(),
976            "United Kingdom" | "Germany" | "France" | "Spain" | "Italy" | "Netherlands"
977            | "Belgium" | "Switzerland" | "Austria" | "Poland" => "Europe".to_string(),
978            "Japan" | "China" | "South Korea" | "Singapore" | "Australia" | "New Zealand"
979            | "India" => "Asia Pacific".to_string(),
980            "Brazil" | "Argentina" | "Chile" => "South America".to_string(),
981            _ => "Other".to_string(),
982        }
983    }
984
985    fn estimate_distance(&self, geo: &GeoLocation, server: &TestServer) -> f64 {
986        // Simplified distance estimation based on region
987        // In production, use actual server coordinates
988        let region = self.determine_region(&geo.country);
989
990        if let Some(city) = &server.city {
991            if city.contains(&geo.city) {
992                return 10.0; // Same city
993            }
994        }
995
996        match (region.as_str(), server.location.as_str()) {
997            ("North America", loc) if loc.contains("USA") || loc.contains("Canada") => 500.0,
998            ("Europe", loc) if loc.contains("Europe") || loc.contains("UK") => 300.0,
999            ("Asia Pacific", loc) if loc.contains("Asia") || loc.contains("Japan") => 400.0,
1000            _ => 5000.0, // Cross-region
1001        }
1002    }
1003
1004    /// Select the best servers by testing them concurrently
1005    async fn select_best_servers(&self) -> Result<Vec<TestServer>, Box<dyn std::error::Error>> {
1006        emit(
1007            &self.events,
1008            TestEvent::Status("Testing server performance...".to_string()),
1009        );
1010
1011        let servers = self.server_pool.read().await.clone();
1012
1013        if servers.is_empty() {
1014            return Err("No servers in pool".into());
1015        }
1016
1017        let mut test_results = Vec::new();
1018
1019        // Test servers concurrently - test up to 15 servers
1020        let mut futures = FuturesUnordered::new();
1021
1022        for server in servers.into_iter().take(15) {
1023            let client = self.client.clone();
1024            futures.push(async move { Self::quick_latency_test(&client, &server).await });
1025        }
1026
1027        while let Some(result) = futures.next().await {
1028            if let Ok(mut server) = result {
1029                if let Some(latency) = server.latency_ms {
1030                    let distance = server.distance_km.unwrap_or(1000.0);
1031                    let geographic_weight = server.capabilities.geographic_weight;
1032
1033                    // Calculate quality score considering latency, distance, and geographic weight
1034                    // Lower latency and distance = higher score
1035                    // Formula: base_score * geographic_weight / (latency_penalty + distance_penalty)
1036                    let latency_penalty = latency.max(1.0); // Avoid division by near-zero
1037                    let distance_penalty = (distance / 100.0).max(1.0);
1038                    server.quality_score =
1039                        Some((10000.0 * geographic_weight) / (latency_penalty + distance_penalty));
1040
1041                    test_results.push(server);
1042                }
1043            }
1044        }
1045
1046        if test_results.is_empty() {
1047            return Err("No servers responded to latency tests".into());
1048        }
1049
1050        // Sort by quality score (highest first)
1051        test_results.sort_by(|a, b| {
1052            b.quality_score
1053                .unwrap_or(0.0)
1054                .partial_cmp(&a.quality_score.unwrap_or(0.0))
1055                .unwrap_or(std::cmp::Ordering::Equal)
1056        });
1057
1058        let selected = test_results
1059            .into_iter()
1060            .take(SERVER_SELECTION_COUNT)
1061            .collect::<Vec<_>>();
1062
1063        if !self.config.json_output {
1064            emit(
1065                &self.events,
1066                TestEvent::ServersSelected {
1067                    servers: selected
1068                        .iter()
1069                        .map(|server| SelectedServer {
1070                            name: server.name.clone(),
1071                            location: server.location.clone(),
1072                            latency_ms: server.latency_ms.unwrap_or(0.0),
1073                            distance_km: server.distance_km.unwrap_or(0.0),
1074                        })
1075                        .collect(),
1076                },
1077            );
1078        }
1079
1080        Ok(selected)
1081    }
1082
1083    async fn quick_latency_test(
1084        client: &Client,
1085        server: &TestServer,
1086    ) -> Result<TestServer, Box<dyn std::error::Error>> {
1087        let mut latencies = Vec::new();
1088        let mut server = server.clone();
1089
1090        for _ in 0..3 {
1091            let start = Instant::now();
1092            match client
1093                .head(&server.url)
1094                .timeout(Duration::from_secs(2))
1095                .send()
1096                .await
1097            {
1098                Ok(resp) if resp.status().is_success() || resp.status().is_redirection() => {
1099                    latencies.push(start.elapsed().as_millis() as f64);
1100                }
1101                _ => {}
1102            }
1103        }
1104
1105        if !latencies.is_empty() {
1106            server.latency_ms = Some(latencies.iter().sum::<f64>() / latencies.len() as f64);
1107        }
1108
1109        Ok(server)
1110    }
1111
1112    /// Progressive download test against a reliable anycast backend.
1113    ///
1114    /// Fixes vs the old implementation: uses a real speed-test endpoint (so
1115    /// every connection transfers data), a lock-free counter, and a
1116    /// warmup-excluded, outlier-trimmed *sustained* throughput — which is what
1117    /// speedtest.net reports.
1118    async fn progressive_download_test(&self) -> Result<f64, Box<dyn std::error::Error>> {
1119        emit(&self.events, TestEvent::PhaseStarted(Phase::Download));
1120
1121        // Lock-free shared byte counter: at gigabit speeds a Mutex locked on
1122        // every chunk becomes the bottleneck and under-reports throughput.
1123        let total_bytes = Arc::new(AtomicU64::new(0));
1124        let start = Instant::now();
1125        let test_duration = Duration::from_secs(TEST_SECS);
1126
1127        let mut handles = Vec::new();
1128        for _ in 0..PARALLEL_CONNECTIONS {
1129            let url = DOWNLOAD_ENDPOINT.to_string();
1130            let client = self.client.clone();
1131            let total_bytes = Arc::clone(&total_bytes);
1132            let test_start = start;
1133
1134            let handle = tokio::spawn(async move {
1135                let end_time = test_start + test_duration;
1136                while Instant::now() < end_time {
1137                    match client.get(&url).send().await {
1138                        Ok(response) => {
1139                            let mut stream = response.bytes_stream();
1140                            while let Some(chunk_result) = stream.next().await {
1141                                if Instant::now() >= end_time {
1142                                    break;
1143                                }
1144                                if let Ok(chunk) = chunk_result {
1145                                    total_bytes.fetch_add(chunk.len() as u64, Ordering::Relaxed);
1146                                }
1147                            }
1148                        }
1149                        Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
1150                    }
1151                    if Instant::now() >= end_time {
1152                        break;
1153                    }
1154                }
1155            });
1156            handles.push(handle);
1157        }
1158
1159        // Monitor emits live samples and returns them for the final calc.
1160        let events = self.events.clone();
1161        let total_bytes_monitor = Arc::clone(&total_bytes);
1162        let monitor_handle = tokio::spawn(async move {
1163            collect_throughput_samples(total_bytes_monitor, start, test_duration, events, false)
1164                .await
1165        });
1166
1167        for handle in handles {
1168            let _ = handle.await;
1169        }
1170        let samples = monitor_handle.await.unwrap_or_default();
1171
1172        let mbps = sustained_mbps(&samples, WARMUP_SECS).clamp(0.0, 10_000.0);
1173        emit(&self.events, TestEvent::DownloadComplete { mbps });
1174        Ok(mbps)
1175    }
1176
1177    /// Progressive upload test against the reliable anycast backend.
1178    async fn progressive_upload_test(&self) -> Result<f64, Box<dyn std::error::Error>> {
1179        emit(&self.events, TestEvent::PhaseStarted(Phase::Upload));
1180
1181        let total_bytes = Arc::new(AtomicU64::new(0));
1182        let start = Instant::now();
1183        let test_duration = Duration::from_secs(TEST_SECS);
1184
1185        // 5 MB chunks streamed repeatedly for the duration of the test.
1186        let chunk_size = 5 * 1024 * 1024;
1187        let test_data = vec![0u8; chunk_size];
1188
1189        let mut handles = Vec::new();
1190        for _ in 0..10 {
1191            let url = UPLOAD_ENDPOINT.to_string();
1192            let client = self.client.clone();
1193            let total_bytes = Arc::clone(&total_bytes);
1194            let data = test_data.clone();
1195            let test_start = start;
1196
1197            let handle = tokio::spawn(async move {
1198                let end_time = test_start + test_duration;
1199                while Instant::now() < end_time {
1200                    match client
1201                        .post(&url)
1202                        .body(data.clone())
1203                        .timeout(Duration::from_secs(10))
1204                        .send()
1205                        .await
1206                    {
1207                        Ok(_) => {
1208                            total_bytes.fetch_add(data.len() as u64, Ordering::Relaxed);
1209                        }
1210                        Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
1211                    }
1212                }
1213            });
1214            handles.push(handle);
1215        }
1216
1217        let events = self.events.clone();
1218        let total_bytes_monitor = Arc::clone(&total_bytes);
1219        let monitor_handle = tokio::spawn(async move {
1220            collect_throughput_samples(total_bytes_monitor, start, test_duration, events, true)
1221                .await
1222        });
1223
1224        for handle in handles {
1225            let _ = handle.await;
1226        }
1227        let samples = monitor_handle.await.unwrap_or_default();
1228
1229        let mbps = sustained_mbps(&samples, WARMUP_SECS).clamp(0.0, 10_000.0);
1230        emit(&self.events, TestEvent::UploadComplete { mbps });
1231        Ok(mbps)
1232    }
1233
1234    async fn measure_latency(
1235        &self,
1236        server: &TestServer,
1237    ) -> Result<f64, Box<dyn std::error::Error>> {
1238        emit(&self.events, TestEvent::PhaseStarted(Phase::Latency));
1239
1240        let mut latencies = Vec::new();
1241
1242        for _i in 0..10 {
1243            let start = Instant::now();
1244            match self
1245                .client
1246                .head(&server.url)
1247                .timeout(Duration::from_secs(2))
1248                .send()
1249                .await
1250            {
1251                Ok(resp) if resp.status().is_success() || resp.status().is_redirection() => {
1252                    let latency = start.elapsed().as_millis() as f64;
1253                    latencies.push(latency);
1254
1255                    let current_avg = latencies.iter().sum::<f64>() / latencies.len() as f64;
1256                    emit(
1257                        &self.events,
1258                        TestEvent::LatencyProgress {
1259                            avg_ms: current_avg,
1260                        },
1261                    );
1262                }
1263                _ => {}
1264            }
1265
1266            tokio::time::sleep(Duration::from_millis(100)).await;
1267        }
1268
1269        let avg_latency = if !latencies.is_empty() {
1270            latencies.iter().sum::<f64>() / latencies.len() as f64
1271        } else {
1272            50.0
1273        };
1274
1275        emit(
1276            &self.events,
1277            TestEvent::LatencyComplete {
1278                avg_ms: avg_latency,
1279            },
1280        );
1281
1282        Ok(avg_latency)
1283    }
1284
1285    async fn measure_jitter_and_loss(
1286        &self,
1287        server: &TestServer,
1288    ) -> Result<(f64, f64), Box<dyn std::error::Error>> {
1289        let mut latencies = Vec::new();
1290        let mut lost = 0;
1291        let total = 20;
1292
1293        for _ in 0..total {
1294            let start = Instant::now();
1295            match self
1296                .client
1297                .head(&server.url)
1298                .timeout(Duration::from_secs(1))
1299                .send()
1300                .await
1301            {
1302                Ok(resp) if resp.status().is_success() || resp.status().is_redirection() => {
1303                    latencies.push(start.elapsed().as_millis() as f64);
1304                }
1305                _ => {
1306                    lost += 1;
1307                }
1308            }
1309            tokio::time::sleep(Duration::from_millis(50)).await;
1310        }
1311
1312        let jitter = if latencies.len() > 1 {
1313            let mean = latencies.iter().sum::<f64>() / latencies.len() as f64;
1314            let variance =
1315                latencies.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / latencies.len() as f64;
1316            variance.sqrt()
1317        } else {
1318            0.0
1319        };
1320
1321        let packet_loss = (lost as f64 / total as f64) * 100.0;
1322
1323        Ok((jitter, packet_loss))
1324    }
1325
1326    async fn get_client_ip(&self) -> Option<IpAddr> {
1327        if let Ok(response) = self
1328            .client
1329            .get("https://api.ipify.org?format=json")
1330            .timeout(Duration::from_secs(3))
1331            .send()
1332            .await
1333        {
1334            if let Ok(json) = response.json::<serde_json::Value>().await {
1335                return json["ip"].as_str().and_then(|s| s.parse::<IpAddr>().ok());
1336            }
1337        }
1338        None
1339    }
1340
1341    async fn resolve_server_ip(&self, url: &str) -> Option<IpAddr> {
1342        if let Ok(parsed) = url.parse::<reqwest::Url>() {
1343            if let Some(host) = parsed.host_str() {
1344                if let Ok(addrs) = tokio::net::lookup_host(format!("{}:443", host)).await {
1345                    return addrs.into_iter().next().map(|addr| addr.ip());
1346                }
1347            }
1348        }
1349        None
1350    }
1351}
1352
1353/// Extract a required, non-empty geolocation string field (rejects "Unknown").
1354fn geo_str_field(
1355    json: &serde_json::Value,
1356    key: &str,
1357) -> Result<String, Box<dyn std::error::Error>> {
1358    json[key]
1359        .as_str()
1360        .filter(|s| !s.is_empty() && *s != "Unknown")
1361        .map(str::to_string)
1362        .ok_or_else(|| format!("Invalid {key}").into())
1363}
1364
1365/// Parse a `"lat,lon"` pair (as returned by ipinfo.io's `loc` field).
1366fn parse_latlon_pair(loc: &str) -> Result<(f64, f64), Box<dyn std::error::Error>> {
1367    let coords: Vec<&str> = loc.split(',').collect();
1368    if coords.len() != 2 {
1369        return Err("Invalid coordinates format".into());
1370    }
1371    let lat = coords[0].trim().parse().map_err(|_| "Invalid latitude")?;
1372    let lon = coords[1].trim().parse().map_err(|_| "Invalid longitude")?;
1373    Ok((lat, lon))
1374}
1375
1376/// Validate coordinates and assemble a [`GeoLocation`].
1377///
1378/// `(0, 0)` is treated as invalid (a common "unknown location" sentinel).
1379fn build_geo(
1380    country: String,
1381    city: String,
1382    latitude: f64,
1383    longitude: f64,
1384    isp: Option<String>,
1385) -> Result<GeoLocation, Box<dyn std::error::Error>> {
1386    if latitude == 0.0 && longitude == 0.0 {
1387        return Err("Invalid coordinates".into());
1388    }
1389    Ok(GeoLocation {
1390        country,
1391        city,
1392        latitude,
1393        longitude,
1394        isp,
1395    })
1396}
1397
1398/// Poll a shared byte counter every 200 ms, emit live throughput samples, and
1399/// return every `(elapsed_secs, mbps)` sample for the final calculation.
1400///
1401/// Shared by the download and upload phases (previously duplicated).
1402async fn collect_throughput_samples(
1403    total_bytes: Arc<AtomicU64>,
1404    start: Instant,
1405    test_duration: Duration,
1406    events: Option<EventSender>,
1407    is_upload: bool,
1408) -> Vec<(f64, f64)> {
1409    let mut samples = Vec::new();
1410    let mut last_bytes = 0u64;
1411    let mut last_time = Instant::now();
1412    let mut peak = 0.0f64;
1413    let end_time = start + test_duration;
1414
1415    while Instant::now() < end_time {
1416        tokio::time::sleep(Duration::from_millis(200)).await;
1417
1418        let bytes = total_bytes.load(Ordering::Relaxed);
1419        let time_diff = last_time.elapsed().as_secs_f64();
1420        if time_diff >= 0.2 {
1421            let bytes_diff = bytes.saturating_sub(last_bytes);
1422            let mbps = (bytes_diff as f64 * 8.0) / (time_diff * 1_000_000.0);
1423            peak = peak.max(mbps);
1424            let elapsed = start.elapsed().as_secs_f64();
1425            samples.push((elapsed, mbps));
1426
1427            let event = if is_upload {
1428                TestEvent::UploadSample {
1429                    mbps,
1430                    peak_mbps: peak,
1431                    elapsed_secs: elapsed,
1432                }
1433            } else {
1434                TestEvent::DownloadSample {
1435                    mbps,
1436                    peak_mbps: peak,
1437                    elapsed_secs: elapsed,
1438                }
1439            };
1440            emit(&events, event);
1441
1442            last_bytes = bytes;
1443            last_time = Instant::now();
1444        }
1445    }
1446    samples
1447}
1448
1449/// Compute sustained throughput (Mbps) from per-interval `(elapsed_s, mbps)`
1450/// samples the way speedtest.net does: drop the TCP slow-start warmup window,
1451/// then trim the slowest/fastest 10% (transient dips and spikes) and average
1452/// the steady-state middle. Returns `0.0` when there is no usable data (a
1453/// genuine failure, which maps to `ConnectionQuality::Failed`).
1454fn sustained_mbps(samples: &[(f64, f64)], warmup_secs: f64) -> f64 {
1455    let mut steady: Vec<f64> = samples
1456        .iter()
1457        .filter(|(t, mbps)| *t >= warmup_secs && *mbps > 0.0)
1458        .map(|(_, mbps)| *mbps)
1459        .collect();
1460
1461    // If the whole test was shorter than the warmup, fall back to any samples.
1462    if steady.is_empty() {
1463        steady = samples
1464            .iter()
1465            .map(|(_, mbps)| *mbps)
1466            .filter(|mbps| *mbps > 0.0)
1467            .collect();
1468    }
1469    if steady.is_empty() {
1470        return 0.0;
1471    }
1472
1473    steady.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1474    let trim = steady.len() / 10;
1475    let slice = &steady[trim..steady.len() - trim];
1476    let slice = if slice.is_empty() { &steady[..] } else { slice };
1477    slice.iter().sum::<f64>() / slice.len() as f64
1478}
1479
1480#[cfg(test)]
1481mod tests {
1482    use super::*;
1483
1484    #[test]
1485    fn sustained_mbps_excludes_warmup() {
1486        // Ramp during warmup (<3s) is slow; steady state is ~100.
1487        let samples = vec![
1488            (0.2, 5.0),
1489            (1.0, 20.0),
1490            (2.0, 60.0),
1491            (3.2, 100.0),
1492            (4.0, 101.0),
1493            (5.0, 99.0),
1494            (6.0, 100.0),
1495        ];
1496        let mbps = sustained_mbps(&samples, WARMUP_SECS);
1497        assert!(
1498            (mbps - 100.0).abs() < 5.0,
1499            "expected ~100 sustained, got {mbps}"
1500        );
1501    }
1502
1503    #[test]
1504    fn sustained_mbps_trims_outliers() {
1505        // One huge spike and one deep dip in steady state should be trimmed.
1506        let mut samples = vec![(3.5, 0.1), (3.6, 5000.0)];
1507        for i in 0..20 {
1508            samples.push((4.0 + i as f64 * 0.1, 500.0));
1509        }
1510        let mbps = sustained_mbps(&samples, WARMUP_SECS);
1511        assert!((mbps - 500.0).abs() < 20.0, "expected ~500, got {mbps}");
1512    }
1513
1514    #[test]
1515    fn sustained_mbps_zero_when_no_data() {
1516        assert_eq!(sustained_mbps(&[], WARMUP_SECS), 0.0);
1517        assert_eq!(sustained_mbps(&[(4.0, 0.0), (5.0, 0.0)], WARMUP_SECS), 0.0);
1518    }
1519
1520    #[test]
1521    fn sustained_mbps_falls_back_for_short_tests() {
1522        // All samples within warmup window -> still returns a positive estimate.
1523        let samples = vec![(0.5, 40.0), (1.0, 60.0), (1.5, 50.0)];
1524        let mbps = sustained_mbps(&samples, WARMUP_SECS);
1525        assert!(mbps > 0.0, "short test should still estimate, got {mbps}");
1526    }
1527
1528    #[test]
1529    fn geo_str_field_extracts_and_rejects() {
1530        let json = serde_json::json!({ "city": "Berlin", "empty": "", "unk": "Unknown" });
1531        assert_eq!(geo_str_field(&json, "city").unwrap(), "Berlin");
1532        assert!(geo_str_field(&json, "empty").is_err());
1533        assert!(geo_str_field(&json, "unk").is_err());
1534        assert!(geo_str_field(&json, "missing").is_err());
1535    }
1536
1537    #[test]
1538    fn parse_latlon_pair_ok_and_err() {
1539        assert_eq!(parse_latlon_pair("52.52,13.40").unwrap(), (52.52, 13.40));
1540        assert_eq!(parse_latlon_pair(" 1.0 , 2.0 ").unwrap(), (1.0, 2.0));
1541        assert!(parse_latlon_pair("52.52").is_err());
1542        assert!(parse_latlon_pair("a,b").is_err());
1543    }
1544
1545    #[test]
1546    fn build_geo_rejects_zero_coords() {
1547        assert!(build_geo("C".into(), "City".into(), 0.0, 0.0, None).is_err());
1548        let g = build_geo("C".into(), "City".into(), 1.0, 2.0, Some("ISP".into())).unwrap();
1549        assert_eq!(g.latitude, 1.0);
1550        assert_eq!(g.isp.as_deref(), Some("ISP"));
1551    }
1552
1553    #[test]
1554    fn test_region_determination() {
1555        // Install the ring crypto provider (reqwest needs a TLS backend even for unit tests)
1556        let _ = rustls::crypto::ring::default_provider().install_default();
1557        let config = TestConfig::default();
1558        let speed_test = SpeedTest::new(config).unwrap();
1559
1560        assert_eq!(
1561            speed_test.determine_region("United States"),
1562            "North America"
1563        );
1564        assert_eq!(speed_test.determine_region("Germany"), "Europe");
1565        assert_eq!(speed_test.determine_region("Japan"), "Asia Pacific");
1566    }
1567}