Skip to main content

netrunner_core/
speed_test.rs

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