Skip to main content

netrunner_core/
history.rs

1//! History Storage - Using Redb Embedded Database
2//!
3//! A robust, fast, and efficient history storage system using the redb embedded database.
4//! Features:
5//! - ACID transactions
6//! - Type-safe table definitions
7//! - Crash recovery
8//! - Compact storage
9
10use chrono::{DateTime, Utc};
11use redb::{ReadableDatabase, ReadableTable, ReadableTableMetadata, TableDefinition};
12use serde::{Deserialize, Serialize};
13use std::path::PathBuf;
14
15use crate::types::SpeedTestResult;
16
17const DB_NAME: &str = "netrunner_history.db";
18const RETENTION_DAYS: i64 = 30;
19
20const RESULTS_TABLE: TableDefinition<&[u8], &[u8]> = TableDefinition::new("test_results");
21const STATS_TABLE: TableDefinition<&[u8], &[u8]> = TableDefinition::new("statistics");
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct TestStatistics {
25    pub test_count: usize,
26    pub avg_download_mbps: f64,
27    pub max_download_mbps: f64,
28    pub min_download_mbps: f64,
29    pub avg_upload_mbps: f64,
30    pub max_upload_mbps: f64,
31    pub min_upload_mbps: f64,
32    pub avg_ping_ms: f64,
33    pub min_ping_ms: f64,
34    pub max_ping_ms: f64,
35    pub total_data_downloaded_gb: f64,
36    pub total_data_uploaded_gb: f64,
37    pub first_test: DateTime<Utc>,
38    pub last_test: DateTime<Utc>,
39}
40
41impl Default for TestStatistics {
42    fn default() -> Self {
43        Self {
44            test_count: 0,
45            avg_download_mbps: 0.0,
46            max_download_mbps: 0.0,
47            min_download_mbps: f64::MAX,
48            avg_upload_mbps: 0.0,
49            max_upload_mbps: 0.0,
50            min_upload_mbps: f64::MAX,
51            avg_ping_ms: 0.0,
52            min_ping_ms: f64::MAX,
53            max_ping_ms: 0.0,
54            total_data_downloaded_gb: 0.0,
55            total_data_uploaded_gb: 0.0,
56            first_test: Utc::now(),
57            last_test: Utc::now(),
58        }
59    }
60}
61
62pub struct HistoryStorage {
63    db: redb::Database,
64}
65
66#[allow(dead_code)]
67impl HistoryStorage {
68    /// Create a new history storage instance
69    pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
70        let db_path = Self::get_db_path()?;
71        let db = redb::Database::create(db_path)?;
72
73        Ok(Self { db })
74    }
75
76    /// Create a new history storage instance with custom path (for testing)
77    #[cfg(test)]
78    fn new_with_path(path: PathBuf) -> Result<Self, Box<dyn std::error::Error>> {
79        let db = redb::Database::create(path)?;
80        Ok(Self { db })
81    }
82
83    /// Get the database path
84    fn get_db_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
85        let config_dir = dirs::config_dir()
86            .ok_or("Failed to find config directory")?
87            .join("netrunner");
88
89        std::fs::create_dir_all(&config_dir)?;
90        Ok(config_dir.join(DB_NAME))
91    }
92
93    /// Save a test result
94    pub fn save_result(&self, result: &SpeedTestResult) -> Result<(), Box<dyn std::error::Error>> {
95        // Use timestamp as key (nanoseconds since epoch for uniqueness)
96        let key = result
97            .timestamp
98            .timestamp_nanos_opt()
99            .unwrap_or_default()
100            .to_be_bytes();
101
102        // Serialize result
103        let value = postcard::to_stdvec(result)?;
104
105        // Store in database
106        let txn = self.db.begin_write()?;
107        {
108            let mut table = txn.open_table(RESULTS_TABLE)?;
109            table.insert(key.as_slice(), value.as_slice())?;
110        }
111        txn.commit()?;
112
113        // Update statistics
114        self.update_statistics(result)?;
115
116        // Clean up old records (older than 30 days)
117        self.cleanup_old_records()?;
118
119        Ok(())
120    }
121
122    /// Get recent test results
123    pub fn get_recent_results(
124        &self,
125        limit: usize,
126    ) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
127        let txn = self.db.begin_read()?;
128        let table = txn.open_table(RESULTS_TABLE)?;
129
130        let mut results = Vec::new();
131
132        // Iterate in reverse (newest first) — skip any records that cannot be
133        // decoded (e.g. stale bytes written by an older version).
134        for item in table.iter()?.rev() {
135            if results.len() >= limit {
136                break;
137            }
138            let (_, value) = item?;
139            if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
140                results.push(result);
141            }
142        }
143
144        Ok(results)
145    }
146
147    /// Get all test results
148    pub fn get_all_results(&self) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
149        let txn = self.db.begin_read()?;
150        let table = txn.open_table(RESULTS_TABLE)?;
151
152        let mut results = Vec::new();
153
154        for item in table.iter()?.rev() {
155            let (_, value) = item?;
156            if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
157                results.push(result);
158            }
159        }
160
161        Ok(results)
162    }
163
164    /// Get results within a date range
165    pub fn get_results_by_date_range(
166        &self,
167        start: DateTime<Utc>,
168        end: DateTime<Utc>,
169    ) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
170        let txn = self.db.begin_read()?;
171        let table = txn.open_table(RESULTS_TABLE)?;
172
173        let start_key = start
174            .timestamp_nanos_opt()
175            .unwrap_or_default()
176            .to_be_bytes();
177        let end_key = end.timestamp_nanos_opt().unwrap_or_default().to_be_bytes();
178
179        let mut results = Vec::new();
180
181        let start_slice: &[u8] = start_key.as_slice();
182        let end_slice: &[u8] = end_key.as_slice();
183
184        for item in table.range(start_slice..=end_slice)? {
185            let (_, value) = item?;
186            if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
187                results.push(result);
188            }
189        }
190
191        Ok(results)
192    }
193
194    /// Get results filtered by quality
195    pub fn get_results_by_quality(
196        &self,
197        quality: crate::types::ConnectionQuality,
198    ) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
199        let all_results = self.get_all_results()?;
200
201        Ok(all_results
202            .into_iter()
203            .filter(|r| r.quality == quality)
204            .collect())
205    }
206
207    /// Get results by server location
208    pub fn get_results_by_server(
209        &self,
210        server_location: &str,
211    ) -> Result<Vec<SpeedTestResult>, Box<dyn std::error::Error>> {
212        let all_results = self.get_all_results()?;
213
214        Ok(all_results
215            .into_iter()
216            .filter(|r| r.server_location.contains(server_location))
217            .collect())
218    }
219
220    /// Update statistics
221    fn update_statistics(
222        &self,
223        result: &SpeedTestResult,
224    ) -> Result<(), Box<dyn std::error::Error>> {
225        let mut stats = self.get_statistics_internal()?;
226
227        // Update counts
228        stats.test_count += 1;
229
230        // Update download stats
231        stats.avg_download_mbps = (stats.avg_download_mbps * (stats.test_count - 1) as f64
232            + result.download_mbps)
233            / stats.test_count as f64;
234        stats.max_download_mbps = stats.max_download_mbps.max(result.download_mbps);
235        stats.min_download_mbps = stats.min_download_mbps.min(result.download_mbps);
236
237        // Update upload stats
238        stats.avg_upload_mbps = (stats.avg_upload_mbps * (stats.test_count - 1) as f64
239            + result.upload_mbps)
240            / stats.test_count as f64;
241        stats.max_upload_mbps = stats.max_upload_mbps.max(result.upload_mbps);
242        stats.min_upload_mbps = stats.min_upload_mbps.min(result.upload_mbps);
243
244        // Update ping stats
245        stats.avg_ping_ms = (stats.avg_ping_ms * (stats.test_count - 1) as f64 + result.ping_ms)
246            / stats.test_count as f64;
247        stats.min_ping_ms = stats.min_ping_ms.min(result.ping_ms);
248        stats.max_ping_ms = stats.max_ping_ms.max(result.ping_ms);
249
250        // Estimate data transferred (rough calculation based on test duration and speed)
251        let test_duration_hours = result.test_duration_seconds / 3600.0;
252        stats.total_data_downloaded_gb += result.download_mbps * test_duration_hours / 8.0 / 1000.0;
253        stats.total_data_uploaded_gb += result.upload_mbps * test_duration_hours / 8.0 / 1000.0;
254
255        // Update timestamps
256        stats.last_test = result.timestamp;
257        if stats.test_count == 1 {
258            stats.first_test = result.timestamp;
259        }
260
261        // Save updated statistics
262        let value = postcard::to_stdvec(&stats)?;
263        let txn = self.db.begin_write()?;
264        {
265            let mut table = txn.open_table(STATS_TABLE)?;
266            table.insert(b"global".as_slice(), value.as_slice())?;
267        }
268        txn.commit()?;
269
270        Ok(())
271    }
272
273    /// Get statistics
274    pub fn get_statistics(&self) -> Result<TestStatistics, Box<dyn std::error::Error>> {
275        self.get_statistics_internal()
276    }
277
278    fn get_statistics_internal(&self) -> Result<TestStatistics, Box<dyn std::error::Error>> {
279        let txn = self.db.begin_read()?;
280        let table = match txn.open_table(STATS_TABLE) {
281            Ok(t) => t,
282            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(TestStatistics::default()),
283            Err(e) => return Err(e.into()),
284        };
285
286        match table.get(b"global".as_slice())? {
287            // Fall back to default if the stored bytes cannot be decoded
288            // (e.g. stale bytes written by an older version).
289            Some(value) => Ok(postcard::from_bytes(value.value()).unwrap_or_default()),
290            None => Ok(TestStatistics::default()),
291        }
292    }
293
294    /// Get statistics for a specific date range
295    pub fn get_statistics_by_date_range(
296        &self,
297        start: DateTime<Utc>,
298        end: DateTime<Utc>,
299    ) -> Result<TestStatistics, Box<dyn std::error::Error>> {
300        let results = self.get_results_by_date_range(start, end)?;
301
302        if results.is_empty() {
303            return Ok(TestStatistics::default());
304        }
305
306        let mut stats = TestStatistics {
307            test_count: results.len(),
308            max_download_mbps: 0.0,
309            min_download_mbps: f64::MAX,
310            max_upload_mbps: 0.0,
311            min_upload_mbps: f64::MAX,
312            ..Default::default()
313        };
314
315        // Calculate statistics
316        let mut total_download = 0.0;
317        let mut total_upload = 0.0;
318        let mut total_ping = 0.0;
319        stats.max_ping_ms = 0.0;
320        stats.min_ping_ms = f64::MAX;
321
322        for result in &results {
323            total_download += result.download_mbps;
324            total_upload += result.upload_mbps;
325            total_ping += result.ping_ms;
326
327            stats.max_download_mbps = stats.max_download_mbps.max(result.download_mbps);
328            stats.min_download_mbps = stats.min_download_mbps.min(result.download_mbps);
329            stats.max_upload_mbps = stats.max_upload_mbps.max(result.upload_mbps);
330            stats.min_upload_mbps = stats.min_upload_mbps.min(result.upload_mbps);
331            stats.max_ping_ms = stats.max_ping_ms.max(result.ping_ms);
332            stats.min_ping_ms = stats.min_ping_ms.min(result.ping_ms);
333
334            // Estimate data transferred
335            let test_duration_hours = result.test_duration_seconds / 3600.0;
336            stats.total_data_downloaded_gb +=
337                result.download_mbps * test_duration_hours / 8.0 / 1000.0;
338            stats.total_data_uploaded_gb += result.upload_mbps * test_duration_hours / 8.0 / 1000.0;
339        }
340
341        stats.avg_download_mbps = total_download / results.len() as f64;
342        stats.avg_upload_mbps = total_upload / results.len() as f64;
343        stats.avg_ping_ms = total_ping / results.len() as f64;
344
345        if let Some(first) = results.last() {
346            stats.first_test = first.timestamp;
347        }
348        if let Some(last) = results.first() {
349            stats.last_test = last.timestamp;
350        }
351
352        Ok(stats)
353    }
354
355    /// Get the number of stored results
356    pub fn count(&self) -> Result<usize, Box<dyn std::error::Error>> {
357        let txn = self.db.begin_read()?;
358        let table = match txn.open_table(RESULTS_TABLE) {
359            Ok(t) => t,
360            Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
361            Err(e) => return Err(e.into()),
362        };
363        Ok(table.len()? as usize)
364    }
365
366    /// Delete a specific result
367    pub fn delete_result(
368        &self,
369        timestamp: DateTime<Utc>,
370    ) -> Result<(), Box<dyn std::error::Error>> {
371        let key = timestamp
372            .timestamp_nanos_opt()
373            .unwrap_or_default()
374            .to_be_bytes();
375
376        let txn = self.db.begin_write()?;
377        {
378            let mut table = txn.open_table(RESULTS_TABLE)?;
379            table.remove(key.as_slice())?;
380        }
381        txn.commit()?;
382
383        // Recalculate statistics
384        self.recalculate_statistics()?;
385
386        Ok(())
387    }
388
389    /// Clear all history
390    pub fn clear_history(&self) -> Result<(), Box<dyn std::error::Error>> {
391        let txn = self.db.begin_write()?;
392        txn.delete_table(RESULTS_TABLE)?;
393        txn.delete_table(STATS_TABLE)?;
394        txn.commit()?;
395
396        Ok(())
397    }
398
399    /// Recalculate all statistics from scratch
400    fn recalculate_statistics(&self) -> Result<(), Box<dyn std::error::Error>> {
401        // Clear stats table
402        let txn = self.db.begin_write()?;
403        txn.delete_table(STATS_TABLE)?;
404        txn.commit()?;
405
406        let results = self.get_all_results()?;
407
408        for result in results {
409            self.update_statistics(&result)?;
410        }
411
412        Ok(())
413    }
414
415    /// Clean up records older than the retention period (30 days)
416    fn cleanup_old_records(&self) -> Result<(), Box<dyn std::error::Error>> {
417        // Calculate cutoff timestamp (30 days ago)
418        let cutoff = Utc::now() - chrono::Duration::days(RETENTION_DAYS);
419        let cutoff_nanos = cutoff.timestamp_nanos_opt().unwrap_or_default();
420
421        // Collect keys to delete
422        let mut keys_to_delete = Vec::new();
423
424        {
425            let txn = self.db.begin_read()?;
426            let table = match txn.open_table(RESULTS_TABLE) {
427                Ok(t) => t,
428                Err(redb::TableError::TableDoesNotExist(_)) => return Ok(()),
429                Err(e) => return Err(e.into()),
430            };
431
432            for item in table.iter()? {
433                let (_, value) = item?;
434
435                // Deserialize to check timestamp
436                if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
437                    let result_nanos = result.timestamp.timestamp_nanos_opt().unwrap_or_default();
438
439                    if result_nanos < cutoff_nanos {
440                        keys_to_delete.push(
441                            result
442                                .timestamp
443                                .timestamp_nanos_opt()
444                                .unwrap_or_default()
445                                .to_be_bytes(),
446                        );
447                    }
448                }
449            }
450        }
451
452        let deleted_count = keys_to_delete.len();
453
454        // Delete old records
455        if deleted_count > 0 {
456            let txn = self.db.begin_write()?;
457            {
458                let mut table = txn.open_table(RESULTS_TABLE)?;
459                for key in &keys_to_delete {
460                    table.remove(key.as_slice())?;
461                }
462            }
463            txn.commit()?;
464
465            // Recalculate statistics
466            self.recalculate_statistics()?;
467        }
468
469        Ok(())
470    }
471
472    /// Export history to JSON
473    pub fn export_to_json(&self, path: &str) -> Result<(), Box<dyn std::error::Error>> {
474        let results = self.get_all_results()?;
475        let json = serde_json::to_string_pretty(&results)?;
476        std::fs::write(path, json)?;
477        Ok(())
478    }
479
480    /// Import history from JSON
481    pub fn import_from_json(&self, path: &str) -> Result<usize, Box<dyn std::error::Error>> {
482        let json = std::fs::read_to_string(path)?;
483        let results: Vec<SpeedTestResult> = serde_json::from_str(&json)?;
484
485        let count = results.len();
486
487        for result in results {
488            self.save_result(&result)?;
489        }
490
491        Ok(count)
492    }
493
494    /// Get database statistics
495    pub fn get_db_stats(&self) -> Result<DbStats, Box<dyn std::error::Error>> {
496        let db_path = Self::get_db_path()?;
497        let size_on_disk = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
498        let results_count = self.count()?;
499
500        Ok(DbStats {
501            size_on_disk,
502            results_count,
503            db_path: db_path.to_string_lossy().to_string(),
504        })
505    }
506
507    /// Optimize database
508    /// Note: redb's compact() requires &mut self which is not available through &self.
509    /// The database already manages its storage efficiently with ACID transactions.
510    pub fn optimize(&self) -> Result<(), Box<dyn std::error::Error>> {
511        // redb handles storage management internally; no explicit optimization needed.
512        Ok(())
513    }
514
515    /// Get fastest recorded download speed
516    pub fn get_fastest_download(
517        &self,
518    ) -> Result<Option<SpeedTestResult>, Box<dyn std::error::Error>> {
519        let results = self.get_all_results()?;
520        Ok(results.into_iter().max_by(|a, b| {
521            a.download_mbps
522                .partial_cmp(&b.download_mbps)
523                .unwrap_or(std::cmp::Ordering::Equal)
524        }))
525    }
526
527    /// Get fastest recorded upload speed
528    pub fn get_fastest_upload(
529        &self,
530    ) -> Result<Option<SpeedTestResult>, Box<dyn std::error::Error>> {
531        let results = self.get_all_results()?;
532        Ok(results.into_iter().max_by(|a, b| {
533            a.upload_mbps
534                .partial_cmp(&b.upload_mbps)
535                .unwrap_or(std::cmp::Ordering::Equal)
536        }))
537    }
538
539    /// Get lowest recorded ping
540    pub fn get_lowest_ping(&self) -> Result<Option<SpeedTestResult>, Box<dyn std::error::Error>> {
541        let results = self.get_all_results()?;
542        Ok(results.into_iter().min_by(|a, b| {
543            a.ping_ms
544                .partial_cmp(&b.ping_ms)
545                .unwrap_or(std::cmp::Ordering::Equal)
546        }))
547    }
548
549    /// Manually cleanup old records (older than retention period)
550    /// Returns the number of records deleted
551    pub fn cleanup_old_records_manual(&self) -> Result<usize, Box<dyn std::error::Error>> {
552        // Calculate cutoff timestamp (30 days ago)
553        let cutoff = Utc::now() - chrono::Duration::days(RETENTION_DAYS);
554        let cutoff_nanos = cutoff.timestamp_nanos_opt().unwrap_or_default();
555
556        // Collect keys to delete
557        let mut keys_to_delete = Vec::new();
558
559        {
560            let txn = self.db.begin_read()?;
561            let table = match txn.open_table(RESULTS_TABLE) {
562                Ok(t) => t,
563                Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
564                Err(e) => return Err(e.into()),
565            };
566
567            for item in table.iter()? {
568                let (_, value) = item?;
569
570                // Deserialize to check timestamp
571                if let Ok(result) = postcard::from_bytes::<SpeedTestResult>(value.value()) {
572                    let result_nanos = result.timestamp.timestamp_nanos_opt().unwrap_or_default();
573
574                    if result_nanos < cutoff_nanos {
575                        keys_to_delete.push(
576                            result
577                                .timestamp
578                                .timestamp_nanos_opt()
579                                .unwrap_or_default()
580                                .to_be_bytes(),
581                        );
582                    }
583                }
584            }
585        }
586
587        let deleted_count = keys_to_delete.len();
588
589        // Delete old records
590        if deleted_count > 0 {
591            let txn = self.db.begin_write()?;
592            {
593                let mut table = txn.open_table(RESULTS_TABLE)?;
594                for key in &keys_to_delete {
595                    table.remove(key.as_slice())?;
596                }
597            }
598            txn.commit()?;
599
600            // Recalculate statistics
601            self.recalculate_statistics()?;
602        }
603
604        Ok(deleted_count)
605    }
606
607    /// Get the retention period in days
608    pub const fn get_retention_days() -> i64 {
609        RETENTION_DAYS
610    }
611
612    /// Get speed trends (compares recent results to historical average)
613    pub fn get_speed_trends(&self) -> Result<SpeedTrends, Box<dyn std::error::Error>> {
614        let all_stats = self.get_statistics()?;
615        let recent_results = self.get_recent_results(10)?;
616
617        if recent_results.is_empty() {
618            return Ok(SpeedTrends::default());
619        }
620
621        let recent_avg_download = recent_results.iter().map(|r| r.download_mbps).sum::<f64>()
622            / recent_results.len() as f64;
623        let recent_avg_upload =
624            recent_results.iter().map(|r| r.upload_mbps).sum::<f64>() / recent_results.len() as f64;
625        let recent_avg_ping =
626            recent_results.iter().map(|r| r.ping_ms).sum::<f64>() / recent_results.len() as f64;
627
628        let download_trend = if all_stats.avg_download_mbps > 0.0 {
629            ((recent_avg_download - all_stats.avg_download_mbps) / all_stats.avg_download_mbps)
630                * 100.0
631        } else {
632            0.0
633        };
634
635        let upload_trend = if all_stats.avg_upload_mbps > 0.0 {
636            ((recent_avg_upload - all_stats.avg_upload_mbps) / all_stats.avg_upload_mbps) * 100.0
637        } else {
638            0.0
639        };
640
641        let ping_trend = if all_stats.avg_ping_ms > 0.0 {
642            ((recent_avg_ping - all_stats.avg_ping_ms) / all_stats.avg_ping_ms) * 100.0
643        } else {
644            0.0
645        };
646
647        Ok(SpeedTrends {
648            download_trend_percent: download_trend,
649            upload_trend_percent: upload_trend,
650            ping_trend_percent: ping_trend,
651            improving: download_trend > 0.0 && upload_trend > 0.0 && ping_trend < 0.0,
652        })
653    }
654}
655
656#[derive(Debug, Clone, Serialize, Deserialize)]
657#[allow(dead_code)]
658pub struct DbStats {
659    pub size_on_disk: u64,
660    pub results_count: usize,
661    pub db_path: String,
662}
663
664#[derive(Debug, Clone, Serialize, Deserialize, Default)]
665#[allow(dead_code)]
666pub struct SpeedTrends {
667    pub download_trend_percent: f64,
668    pub upload_trend_percent: f64,
669    pub ping_trend_percent: f64,
670    pub improving: bool,
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use crate::types::ConnectionQuality;
677    use tempfile::tempdir;
678
679    #[test]
680    fn test_storage_creation() {
681        let temp_dir = tempdir().unwrap();
682        let db_path = temp_dir.path().join("test_db");
683        let storage = HistoryStorage::new_with_path(db_path);
684        assert!(storage.is_ok());
685    }
686
687    #[test]
688    fn test_save_and_retrieve() {
689        let temp_dir = tempdir().unwrap();
690        let db_path = temp_dir.path().join("test_db");
691        let storage = HistoryStorage::new_with_path(db_path).unwrap();
692
693        let result = SpeedTestResult {
694            timestamp: Utc::now(),
695            download_mbps: 100.0,
696            upload_mbps: 50.0,
697            ping_ms: 10.0,
698            jitter_ms: 1.0,
699            packet_loss_percent: 0.0,
700            server_location: "Test Server".to_string(),
701            server_ip: None,
702            client_ip: None,
703            quality: ConnectionQuality::Excellent,
704            test_duration_seconds: 10.0,
705            isp: None,
706        };
707
708        assert!(storage.save_result(&result).is_ok());
709
710        let results = storage.get_recent_results(1).unwrap();
711        assert_eq!(results.len(), 1);
712        assert_eq!(results[0].download_mbps, 100.0);
713    }
714
715    #[test]
716    fn test_statistics() {
717        let temp_dir = tempdir().unwrap();
718        let db_path = temp_dir.path().join("test_db");
719        let storage = HistoryStorage::new_with_path(db_path).unwrap();
720
721        let stats = storage.get_statistics();
722        assert!(stats.is_ok());
723    }
724}