Skip to main content

netspeed_cli/
history.rs

1use crate::error::Error;
2use crate::terminal;
3use crate::types::TestResult;
4use directories::ProjectDirs;
5use owo_colors::OwoColorize;
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::{Path, PathBuf};
9
10#[derive(Serialize, Deserialize, Debug)]
11pub struct Entry {
12    pub timestamp: String,
13    pub server_name: String,
14    pub sponsor: String,
15    pub ping: Option<f64>,
16    pub jitter: Option<f64>,
17    pub packet_loss: Option<f64>,
18    pub download: Option<f64>,
19    pub download_peak: Option<f64>,
20    pub upload: Option<f64>,
21    pub upload_peak: Option<f64>,
22    pub latency_download: Option<f64>,
23    pub latency_upload: Option<f64>,
24    pub client_ip: Option<String>,
25}
26
27impl From<&TestResult> for Entry {
28    fn from(result: &TestResult) -> Self {
29        Self {
30            timestamp: result.timestamp.clone(),
31            server_name: result.server.name.clone(),
32            sponsor: result.server.sponsor.clone(),
33            ping: result.ping,
34            jitter: result.jitter,
35            packet_loss: result.packet_loss,
36            download: result.download,
37            download_peak: result.download_peak,
38            upload: result.upload,
39            upload_peak: result.upload_peak,
40            latency_download: result.latency_download,
41            latency_upload: result.latency_upload,
42            client_ip: result.client_ip.clone(),
43        }
44    }
45}
46
47fn get_history_path() -> Option<PathBuf> {
48    ProjectDirs::from("dev", "vibe", "netspeed-cli").map(|proj_dirs| {
49        let data_dir = proj_dirs.data_dir();
50        if let Err(e) = fs::create_dir_all(data_dir) {
51            eprintln!("Warning: Failed to create data directory: {e}");
52        }
53        data_dir.join("history.json")
54    })
55}
56
57fn backup_path(path: &Path) -> PathBuf {
58    path.with_extension("json.bak")
59}
60
61fn corrupt_path(path: &Path) -> PathBuf {
62    path.with_extension("json.corrupt")
63}
64
65fn load_entries(path: &Path) -> Result<Vec<Entry>, Error> {
66    let content = fs::read_to_string(path)?;
67    Ok(serde_json::from_str(&content)?)
68}
69
70/// Internal: load history from a specific path
71fn load_history_from_path(path: &Path) -> Result<Vec<Entry>, Error> {
72    if !path.exists() {
73        return Ok(Vec::new());
74    }
75
76    match load_entries(path) {
77        Ok(history) => Ok(history),
78        Err(err) => {
79            let backup = backup_path(path);
80            if backup.exists() {
81                match load_entries(&backup) {
82                    Ok(history) => {
83                        eprintln!(
84                            "Warning: History file is invalid; using backup at {}",
85                            backup.display()
86                        );
87                        Ok(history)
88                    }
89                    Err(_) => Err(err),
90                }
91            } else {
92                Err(err)
93            }
94        }
95    }
96}
97
98/// Internal: save result to a specific path
99fn save_result_to_path(result: &TestResult, path: &Path) -> Result<(), Error> {
100    let backup = backup_path(path);
101    let mut recovered_from_backup = false;
102    let mut history: Vec<Entry> = if path.exists() {
103        match load_entries(path) {
104            Ok(history) => history,
105            Err(err) => {
106                if backup.exists() {
107                    let backup_history = load_entries(&backup)?;
108                    let corrupt = corrupt_path(path);
109                    fs::copy(path, &corrupt)?;
110                    eprintln!(
111                        "Warning: History file is invalid; preserving it at {} and repairing from backup {}",
112                        corrupt.display(),
113                        backup.display()
114                    );
115                    recovered_from_backup = true;
116                    backup_history
117                } else {
118                    return Err(err);
119                }
120            }
121        }
122    } else {
123        Vec::new()
124    };
125
126    history.push(Entry::from(result));
127
128    // Keep only last 100 entries
129    if history.len() > 100 {
130        let overflow = history.len() - 100;
131        history.drain(0..overflow);
132    }
133
134    let json = serde_json::to_string_pretty(&history)?;
135
136    // Write to a temp file first, then rename for atomicity.
137    // On Unix, restrict permissions to owner-only (0o600).
138    let tmp_path = path.with_extension("json.tmp");
139    fs::write(&tmp_path, &json)?;
140    #[cfg(unix)]
141    {
142        use std::os::unix::fs::PermissionsExt;
143        if let Err(e) = fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600)) {
144            eprintln!("Warning: Failed to set permissions on history file: {e}");
145        }
146    }
147    if path.exists() && !recovered_from_backup {
148        fs::copy(path, &backup)?;
149    }
150    fs::rename(&tmp_path, path)?;
151
152    Ok(())
153}
154
155/// Save a test result to the history file.
156///
157/// # Errors
158///
159/// Returns [`Error::IoError`] if reading or writing the history file fails.
160/// Returns [`Error::ParseJson`] if the history file contains invalid JSON.
161pub fn save_result(result: &TestResult) -> Result<(), Error> {
162    let Some(path) = get_history_path() else {
163        return Ok(());
164    };
165
166    save_result_to_path(result, &path)
167}
168
169/// Save a full report (currently identical to a TestResult).
170pub fn save_report(report: &crate::domain::reporting::Report) -> Result<(), Error> {
171    // Report is an alias for TestResult; forward to existing saver.
172    save_result(report)
173}
174
175/// Load all test history from the history file.
176///
177/// # Errors
178///
179/// Returns [`Error::IoError`] if reading the history file fails.
180/// Returns [`Error::ParseJson`] if the history file contains invalid JSON.
181pub fn load() -> Result<Vec<Entry>, Error> {
182    let Some(path) = get_history_path() else {
183        return Ok(Vec::new());
184    };
185
186    load_history_from_path(&path)
187}
188
189/// Print formatted test history to stdout.
190///
191/// # Errors
192///
193/// Returns [`Error::IoError`] if reading the history file fails.
194/// Returns [`Error::ParseJson`] if the history file contains invalid JSON.
195pub fn show() -> Result<(), Error> {
196    let history = load()?;
197
198    if history.is_empty() {
199        println!("No test history found.");
200        return Ok(());
201    }
202
203    let nc = terminal::no_color();
204    println!();
205    if nc {
206        println!("  TEST HISTORY");
207    } else {
208        println!("  {}", "TEST HISTORY".bold().underline());
209    }
210    println!(
211        "  {:<20}  {:<15}  {:>10}  {:>12}  {:>12}",
212        "Date", "Sponsor", "Ping", "Download", "Upload"
213    );
214
215    for entry in history.iter().rev() {
216        let date = if entry.timestamp.len() >= 10 {
217            &entry.timestamp[0..10]
218        } else {
219            entry.timestamp.as_str()
220        };
221        let ping = entry.ping.map_or("-".to_string(), |p| format!("{p:.1} ms"));
222        let dl = entry
223            .download
224            .map_or("-".to_string(), |d| format!("{:.2} Mb/s", d / 1_000_000.0));
225        let ul = entry
226            .upload
227            .map_or("-".to_string(), |u| format!("{:.2} Mb/s", u / 1_000_000.0));
228
229        let ping_display = if nc { ping } else { format!("{}", ping.cyan()) };
230        let dl_display = if nc { dl } else { format!("{}", dl.green()) };
231        let ul_display = if nc { ul } else { format!("{}", ul.yellow()) };
232
233        println!(
234            "  {:<20}  {:<15}  {:>10}  {:>12}  {:>12}",
235            date,
236            if entry.sponsor.len() > 15 {
237                &entry.sponsor[0..12]
238            } else {
239                &entry.sponsor
240            },
241            ping_display,
242            dl_display,
243            ul_display
244        );
245    }
246
247    Ok(())
248}
249
250/// Compute average download and upload speeds from history (last 20 entries).
251/// Returns (`avg_dl_mbps`, `avg_ul_mbps`) or None if insufficient data.
252#[must_use]
253pub fn get_averages() -> Option<(f64, f64)> {
254    let history = match load() {
255        Ok(h) => h,
256        Err(e) => {
257            eprintln!("Warning: Failed to load history for averages: {e}");
258            return None;
259        }
260    };
261    let recent: Vec<_> = history.iter().rev().take(20).collect();
262    let dl_entries: Vec<f64> = recent
263        .iter()
264        .filter_map(|e| e.download.map(|d| d / 1_000_000.0))
265        .collect();
266    let ul_entries: Vec<f64> = recent
267        .iter()
268        .filter_map(|e| e.upload.map(|u| u / 1_000_000.0))
269        .collect();
270
271    if dl_entries.is_empty() || ul_entries.is_empty() {
272        return None;
273    }
274
275    // Safe: history entries are at most 100, well under 2^53.
276    let download_avg = dl_entries.iter().sum::<f64>() / dl_entries.len() as f64;
277    let upload_avg = ul_entries.iter().sum::<f64>() / ul_entries.len() as f64;
278    Some((download_avg, upload_avg))
279}
280
281/// Format historical comparison as a string for display.
282/// Returns None if insufficient history data.
283#[must_use]
284pub fn format_comparison(download_mbps: f64, upload_mbps: f64, nc: bool) -> Option<String> {
285    let (download_avg, upload_avg) = get_averages()?;
286
287    // Use the combined metric: dl + ul as a single score
288    let current_score = download_mbps + upload_mbps;
289    let avg_score = download_avg + upload_avg;
290
291    if avg_score <= 0.0 {
292        return None;
293    }
294
295    let pct_change = ((current_score / avg_score) - 1.0) * 100.0;
296
297    let display = if pct_change.abs() < 3.0 {
298        if nc {
299            "~ On par with your history".to_string()
300        } else {
301            "~ On par with your history".bright_black().to_string()
302        }
303    } else if pct_change > 0.0 {
304        if nc {
305            format!("↑ {pct_change:.0}% faster than your average")
306        } else {
307            format!("↑ {pct_change:.0}% faster than your average")
308                .green()
309                .to_string()
310        }
311    } else {
312        let abs_pct = pct_change.abs();
313        if nc {
314            format!("↓ {abs_pct:.0}% slower than your average")
315        } else {
316            format!("↓ {abs_pct:.0}% slower than your average")
317                .red()
318                .to_string()
319        }
320    };
321
322    Some(display)
323}
324
325/// Render a sparkline from a slice of numeric values using Unicode block chars.
326///
327/// # Examples
328///
329/// ```
330/// # use netspeed_cli::history::sparkline;
331/// let line = sparkline(&[10.0, 20.0, 30.0]);
332/// assert_eq!(line.chars().count(), 3); // one char per value
333/// ```
334#[must_use]
335pub fn sparkline(values: &[f64]) -> String {
336    const CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
337    if values.is_empty() {
338        return String::new();
339    }
340    let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
341    let min = values.iter().copied().fold(f64::INFINITY, f64::min);
342    let range = max - min;
343    if range <= 0.0 {
344        // All same value — show middle bar
345        return CHARS[3].to_string().repeat(values.len());
346    }
347    values
348        .iter()
349        .map(|v| {
350            // Safe: (v-min)/range is 0..1, *7 → 0..7, round → 0..7, fits usize.
351            let idx = (((v - min) / range) * 7.0).round().clamp(0.0, 7.0) as usize;
352            CHARS[idx]
353        })
354        .collect::<String>()
355}
356
357/// Render an ASCII-only sparkline using `_-^` characters for environments
358/// where Unicode block characters don't render.
359#[must_use]
360pub fn sparkline_ascii(values: &[f64]) -> String {
361    const CHARS: &[char] = &['_', '_', '‗', '-', '=', '≈', '^', '▲'];
362    if values.is_empty() {
363        return String::new();
364    }
365    let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
366    let min = values.iter().copied().fold(f64::INFINITY, f64::min);
367    let range = max - min;
368    if range <= 0.0 {
369        return "-".repeat(values.len());
370    }
371    values
372        .iter()
373        .map(|v| {
374            // Safe: (v-min)/range is 0..1, *7 → 0..7, round → 0..7, fits usize.
375            let idx = (((v - min) / range) * 7.0).round().clamp(0.0, 7.0) as usize;
376            CHARS[idx]
377        })
378        .collect::<String>()
379}
380
381/// Get recent download/upload speeds as paired tuples for sparkline display.
382/// Returns up to the last 7 entries as `(date_label, dl_mbps, ul_mbps)`.
383#[must_use]
384pub fn get_recent_sparkline() -> Vec<(String, f64, f64)> {
385    let Ok(history) = load() else {
386        return Vec::new();
387    };
388    history
389        .iter()
390        .rev()
391        .take(7)
392        .filter_map(|e| {
393            let dl = e.download.map_or(0.0, |d| d / 1_000_000.0);
394            let ul = e.upload.map_or(0.0, |u| u / 1_000_000.0);
395            if dl > 0.0 || ul > 0.0 {
396                // Extract just the date part (YYYY-MM-DD)
397                let date = e.timestamp.get(0..10).unwrap_or(&e.timestamp).to_string();
398                Some((date, dl, ul))
399            } else {
400                None
401            }
402        })
403        .collect()
404}
405
406#[cfg(test)]
407mod tests {
408    use super::*;
409    use crate::error::Error;
410    use crate::types::{PhaseResult, ServerInfo, TestPhases, TestResult};
411    use serial_test::serial;
412
413    fn make_test_result(download: f64, upload: f64, timestamp: &str) -> TestResult {
414        TestResult {
415            status: "ok".to_string(),
416            version: env!("CARGO_PKG_VERSION").to_string(),
417            test_id: None,
418            server: ServerInfo {
419                id: "1".to_string(),
420                name: "Test".to_string(),
421                sponsor: "Test".to_string(),
422                country: "US".to_string(),
423                distance: 0.0,
424            },
425            ping: Some(10.0),
426            jitter: Some(1.0),
427            packet_loss: Some(0.0),
428            download: Some(download),
429            download_peak: None,
430            upload: Some(upload),
431            upload_peak: None,
432            latency_download: None,
433            latency_upload: None,
434            download_samples: None,
435            upload_samples: None,
436            ping_samples: None,
437            timestamp: timestamp.to_string(),
438            client_ip: None,
439            client_location: None,
440            download_cv: None,
441            upload_cv: None,
442            download_ci_95: None,
443            upload_ci_95: None,
444            overall_grade: None,
445            download_grade: None,
446            upload_grade: None,
447            connection_rating: None,
448            phases: TestPhases {
449                ping: PhaseResult::completed(),
450                download: PhaseResult::completed(),
451                upload: PhaseResult::completed(),
452            },
453        }
454    }
455
456    /// Helper: create a temp directory with a history.json path
457    fn temp_history_path() -> (tempfile::TempDir, PathBuf) {
458        let temp_dir = tempfile::TempDir::new().unwrap();
459        let path = temp_dir.path().join("history.json");
460        (temp_dir, path)
461    }
462
463    #[test]
464    #[serial]
465    fn test_get_averages_returns_values() {
466        let (_temp, path) = temp_history_path();
467
468        let results = vec![
469            make_test_result(100_000_000.0, 50_000_000.0, "2026-01-01T00:00:00Z"),
470            make_test_result(120_000_000.0, 60_000_000.0, "2026-01-02T00:00:00Z"),
471            make_test_result(80_000_000.0, 40_000_000.0, "2026-01-03T00:00:00Z"),
472        ];
473        for r in &results {
474            save_result_to_path(r, &path).unwrap();
475        }
476
477        // Load and verify
478        let history = load_history_from_path(&path).unwrap();
479        let dl_values: Vec<f64> = history
480            .iter()
481            .filter_map(|e| e.download.map(|d| d / 1_000_000.0))
482            .collect();
483        assert_eq!(dl_values.len(), 3);
484        // Safe: history entries are at most 100, well under 2^53.
485        let download_avg = dl_values.iter().sum::<f64>() / dl_values.len() as f64;
486        assert!((download_avg - 100.0).abs() < 0.1);
487    }
488
489    #[test]
490    #[serial]
491    fn test_format_comparison_faster() {
492        let (_temp, path) = temp_history_path();
493
494        for i in 0..3 {
495            let r = make_test_result(
496                20_000_000.0,
497                10_000_000.0,
498                &format!("2026-06-{i:02}T00:00:00Z"),
499            );
500            save_result_to_path(&r, &path).unwrap();
501        }
502
503        // Verify it doesn't panic
504        let history = load_history_from_path(&path).unwrap();
505        assert_eq!(history.len(), 3);
506    }
507
508    #[test]
509    #[serial]
510    fn test_format_comparison_slower() {
511        let (_temp, path) = temp_history_path();
512
513        for i in 0..3 {
514            let r = make_test_result(
515                800_000_000.0,
516                800_000_000.0,
517                &format!("2026-07-{i:02}T00:00:00Z"),
518            );
519            save_result_to_path(&r, &path).unwrap();
520        }
521
522        let history = load_history_from_path(&path).unwrap();
523        assert_eq!(history.len(), 3);
524    }
525
526    #[test]
527    #[serial]
528    fn test_format_comparison_on_par() {
529        let (_temp, path) = temp_history_path();
530
531        let sim_results = vec![
532            make_test_result(100_000_000.0, 50_000_000.0, "2026-04-01T00:00:00Z"),
533            make_test_result(105_000_000.0, 52_000_000.0, "2026-04-02T00:00:00Z"),
534            make_test_result(95_000_000.0, 48_000_000.0, "2026-04-03T00:00:00Z"),
535        ];
536        for r in &sim_results {
537            save_result_to_path(r, &path).unwrap();
538        }
539
540        let history = load_history_from_path(&path).unwrap();
541        assert_eq!(history.len(), 3);
542    }
543
544    #[test]
545    #[serial]
546    fn test_save_result_appends_to_existing() {
547        let (_temp, path) = temp_history_path();
548
549        let r1 = make_test_result(50_000_000.0, 25_000_000.0, "2026-08-01T00:00:00Z");
550        save_result_to_path(&r1, &path).unwrap();
551        let r2 = make_test_result(60_000_000.0, 30_000_000.0, "2026-08-02T00:00:00Z");
552        save_result_to_path(&r2, &path).unwrap();
553
554        let history = load_history_from_path(&path).unwrap();
555        assert_eq!(history.len(), 2);
556    }
557
558    #[test]
559    #[serial]
560    fn test_print_history_with_data() {
561        let (_temp, path) = temp_history_path();
562
563        for i in 0..3 {
564            let r = make_test_result(
565                100_000_000.0,
566                50_000_000.0,
567                &format!("2026-05-{i:02}T00:00:00Z"),
568            );
569            save_result_to_path(&r, &path).unwrap();
570        }
571
572        let history = load_history_from_path(&path).unwrap();
573        assert_eq!(history.len(), 3);
574    }
575
576    #[test]
577    #[serial]
578    fn test_print_history_long_sponsor_truncation() {
579        let (_temp, path) = temp_history_path();
580
581        let mut r = make_test_result(100_000_000.0, 50_000_000.0, "2026-09-01T00:00:00Z");
582        r.server.sponsor = "VeryLongSponsorNameThatExceedsLimit".to_string();
583        save_result_to_path(&r, &path).unwrap();
584
585        let history = load_history_from_path(&path).unwrap();
586        assert_eq!(history[0].sponsor, "VeryLongSponsorNameThatExceedsLimit");
587    }
588
589    #[test]
590    #[serial]
591    fn test_format_comparison_zero_avg() {
592        let (_temp, path) = temp_history_path();
593
594        let r = make_test_result(0.0, 0.0, "2026-10-01T00:00:00Z");
595        save_result_to_path(&r, &path).unwrap();
596
597        let history = load_history_from_path(&path).unwrap();
598        assert_eq!(history.len(), 1);
599        assert_eq!(history[0].download, Some(0.0));
600    }
601
602    #[test]
603    #[serial]
604    fn test_save_result_invalid_json_recovery() {
605        let (_temp, path) = temp_history_path();
606
607        // Write invalid JSON
608        fs::write(&path, "{invalid json}").unwrap();
609
610        let r = make_test_result(100_000_000.0, 50_000_000.0, "2026-12-01T00:00:00Z");
611        let err = save_result_to_path(&r, &path).unwrap_err();
612        assert!(matches!(err, Error::ParseJson(_)));
613
614        let original = fs::read_to_string(&path).unwrap();
615        assert_eq!(original, "{invalid json}");
616    }
617
618    #[test]
619    #[serial]
620    fn test_save_result_recovers_from_backup_when_primary_is_corrupt() {
621        let (_temp, path) = temp_history_path();
622        let backup = backup_path(&path);
623
624        let existing = make_test_result(100_000_000.0, 50_000_000.0, "2026-11-01T00:00:00Z");
625        let existing_history = vec![Entry::from(&existing)];
626        fs::write(
627            &backup,
628            serde_json::to_string_pretty(&existing_history).unwrap(),
629        )
630        .unwrap();
631        fs::write(&path, "{invalid json}").unwrap();
632
633        let new_result = make_test_result(120_000_000.0, 60_000_000.0, "2026-11-02T00:00:00Z");
634        save_result_to_path(&new_result, &path).unwrap();
635
636        let repaired = load_entries(&path).unwrap();
637        assert_eq!(repaired.len(), 2);
638        assert_eq!(repaired[0].timestamp, "2026-11-01T00:00:00Z");
639        assert_eq!(repaired[1].timestamp, "2026-11-02T00:00:00Z");
640
641        let corrupt = corrupt_path(&path);
642        assert!(corrupt.exists());
643        assert_eq!(fs::read_to_string(corrupt).unwrap(), "{invalid json}");
644    }
645
646    #[test]
647    #[serial]
648    fn test_load_history_falls_back_to_backup() {
649        let (_temp, path) = temp_history_path();
650        let backup = backup_path(&path);
651
652        let existing = make_test_result(100_000_000.0, 50_000_000.0, "2026-10-01T00:00:00Z");
653        let existing_history = vec![Entry::from(&existing)];
654        fs::write(
655            &backup,
656            serde_json::to_string_pretty(&existing_history).unwrap(),
657        )
658        .unwrap();
659        fs::write(&path, "{invalid json}").unwrap();
660
661        let history = load_history_from_path(&path).unwrap();
662        assert_eq!(history.len(), 1);
663        assert_eq!(history[0].timestamp, "2026-10-01T00:00:00Z");
664    }
665
666    #[test]
667    #[serial]
668    fn test_save_result_rotates_backup_from_previous_good_state() {
669        let (_temp, path) = temp_history_path();
670        let backup = backup_path(&path);
671
672        let r1 = make_test_result(50_000_000.0, 25_000_000.0, "2026-08-01T00:00:00Z");
673        let r2 = make_test_result(60_000_000.0, 30_000_000.0, "2026-08-02T00:00:00Z");
674        save_result_to_path(&r1, &path).unwrap();
675        save_result_to_path(&r2, &path).unwrap();
676
677        let previous = load_entries(&backup).unwrap();
678        assert_eq!(previous.len(), 1);
679        assert_eq!(previous[0].timestamp, "2026-08-01T00:00:00Z");
680    }
681
682    #[test]
683    #[serial]
684    fn test_history_keeps_last_100_entries() {
685        let (_temp, path) = temp_history_path();
686
687        // Save 105 entries
688        for i in 0..105 {
689            let r = make_test_result(
690                100_000_000.0,
691                50_000_000.0,
692                &format!("2026-01-{i:02}T00:00:00Z"),
693            );
694            save_result_to_path(&r, &path).unwrap();
695        }
696
697        let history = load_history_from_path(&path).unwrap();
698        assert_eq!(history.len(), 100);
699        // Should have dropped the first 5
700        assert_eq!(history[0].timestamp, "2026-01-05T00:00:00Z");
701    }
702
703    #[test]
704    fn test_sparkline_increasing() {
705        let line = sparkline(&[10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0]);
706        assert_eq!(line.chars().count(), 8);
707        // Should produce ascending bars
708        assert_eq!(line, "▁▂▃▄▅▆▇█");
709    }
710
711    #[test]
712    fn test_sparkline_decreasing() {
713        let line = sparkline(&[80.0, 60.0, 40.0, 20.0]);
714        assert_eq!(line.chars().count(), 4);
715    }
716
717    #[test]
718    fn test_sparkline_empty() {
719        assert_eq!(sparkline(&[]), "");
720    }
721
722    #[test]
723    fn test_sparkline_single_value() {
724        let line = sparkline(&[42.0]);
725        assert_eq!(line, "▄"); // single value → middle bar
726    }
727
728    #[test]
729    fn test_sparkline_identical_values() {
730        let line = sparkline(&[50.0, 50.0, 50.0]);
731        assert_eq!(line, "▄▄▄"); // all same → middle bar
732    }
733
734    // ==================== sparkline_ascii Tests ====================
735
736    #[test]
737    fn test_sparkline_ascii_increasing() {
738        let line = sparkline_ascii(&[10.0, 20.0, 30.0, 40.0, 50.0]);
739        // Verify we get output with correct number of chars
740        assert_eq!(line.chars().count(), 5);
741        // Verify it's not empty
742        assert!(!line.is_empty());
743    }
744
745    #[test]
746    fn test_sparkline_ascii_decreasing() {
747        let line = sparkline_ascii(&[80.0, 60.0, 40.0, 20.0]);
748        assert_eq!(line.chars().count(), 4);
749    }
750
751    #[test]
752    fn test_sparkline_ascii_empty() {
753        assert_eq!(sparkline_ascii(&[]), "");
754    }
755
756    #[test]
757    fn test_sparkline_ascii_single_value() {
758        let line = sparkline_ascii(&[42.0]);
759        assert_eq!(line.len(), 1); // single value → dash (1 char)
760    }
761
762    #[test]
763    fn test_sparkline_ascii_identical_values() {
764        let line = sparkline_ascii(&[50.0, 50.0, 50.0]);
765        // Same value → dashes (3 chars)
766        assert_eq!(line.chars().count(), 3);
767    }
768
769    #[test]
770    fn test_sparkline_ascii_all_min() {
771        let line = sparkline_ascii(&[1.0, 2.0, 1.0]);
772        assert_eq!(line.chars().count(), 3);
773    }
774
775    #[test]
776    fn test_sparkline_ascii_all_max() {
777        let line = sparkline_ascii(&[100.0, 99.0, 100.0]);
778        assert_eq!(line.chars().count(), 3);
779    }
780
781    #[test]
782    fn test_sparkline_ascii_two_values() {
783        let line = sparkline_ascii(&[25.0, 75.0]);
784        assert_eq!(line.chars().count(), 2);
785    }
786
787    #[test]
788    fn test_sparkline_ascii_three_values() {
789        let line = sparkline_ascii(&[33.3, 66.6, 100.0]);
790        assert_eq!(line.chars().count(), 3);
791    }
792
793    #[test]
794    fn test_sparkline_ascii_five_values() {
795        let line = sparkline_ascii(&[10.0, 20.0, 30.0, 40.0, 50.0]);
796        assert_eq!(line.chars().count(), 5);
797    }
798
799    // ==================== Entry Tests ====================
800
801    #[test]
802    fn test_entry_from_test_result() {
803        let result = make_test_result(100_000_000.0, 50_000_000.0, "2026-01-15T10:30:00Z");
804        let entry = Entry::from(&result);
805
806        assert_eq!(entry.timestamp, "2026-01-15T10:30:00Z");
807        assert_eq!(entry.server_name, "Test");
808        assert_eq!(entry.sponsor, "Test");
809        assert_eq!(entry.ping, Some(10.0));
810        assert_eq!(entry.jitter, Some(1.0));
811        assert_eq!(entry.download, Some(100_000_000.0));
812        assert_eq!(entry.upload, Some(50_000_000.0));
813    }
814
815    #[test]
816    fn test_entry_from_test_result_with_none_values() {
817        let mut result = make_test_result(100_000_000.0, 50_000_000.0, "2026-02-01T00:00:00Z");
818        result.ping = None;
819        result.jitter = None;
820        result.download = None;
821        result.upload = None;
822
823        let entry = Entry::from(&result);
824
825        assert!(entry.ping.is_none());
826        assert!(entry.jitter.is_none());
827        assert!(entry.download.is_none());
828        assert!(entry.upload.is_none());
829    }
830
831    // ==================== backup_path and corrupt_path Tests ====================
832
833    #[test]
834    fn test_backup_path() {
835        let path = std::path::Path::new("/data/history.json");
836        let backup = backup_path(path);
837        assert_eq!(backup, std::path::Path::new("/data/history.json.bak"));
838    }
839
840    #[test]
841    fn test_corrupt_path() {
842        let path = std::path::Path::new("/data/history.json");
843        let corrupt = corrupt_path(path);
844        assert_eq!(corrupt, std::path::Path::new("/data/history.json.corrupt"));
845    }
846
847    // ==================== load_entries Tests ====================
848
849    #[test]
850    #[serial]
851    fn test_load_entries_valid_json() {
852        let (_temp, path) = temp_history_path();
853
854        // Create Entry directly (which is what load_entries returns)
855        let entries = vec![
856            Entry {
857                timestamp: "2026-03-01T00:00:00Z".to_string(),
858                server_name: "Test".to_string(),
859                sponsor: "Test".to_string(),
860                ping: Some(10.0),
861                jitter: Some(1.0),
862                packet_loss: None,
863                download: Some(100_000_000.0),
864                download_peak: None,
865                upload: Some(50_000_000.0),
866                upload_peak: None,
867                latency_download: None,
868                latency_upload: None,
869                client_ip: None,
870            },
871            Entry {
872                timestamp: "2026-03-02T00:00:00Z".to_string(),
873                server_name: "Test".to_string(),
874                sponsor: "Test".to_string(),
875                ping: Some(12.0),
876                jitter: Some(2.0),
877                packet_loss: None,
878                download: Some(120_000_000.0),
879                download_peak: None,
880                upload: Some(60_000_000.0),
881                upload_peak: None,
882                latency_download: None,
883                latency_upload: None,
884                client_ip: None,
885            },
886        ];
887        fs::write(&path, serde_json::to_string_pretty(&entries).unwrap()).unwrap();
888
889        let loaded = load_entries(&path).unwrap();
890        assert_eq!(loaded.len(), 2);
891    }
892
893    #[test]
894    #[serial]
895    fn test_load_entries_invalid_json() {
896        let (_temp, path) = temp_history_path();
897        fs::write(&path, "not valid json").unwrap();
898
899        let result = load_entries(&path);
900        assert!(result.is_err());
901    }
902
903    #[test]
904    #[serial]
905    fn test_load_entries_file_not_found() {
906        let (_temp, _path) = temp_history_path();
907        // Use a non-existent path
908        let result = load_entries(std::path::Path::new("/nonexistent/file.json"));
909        assert!(result.is_err());
910    }
911
912    // ==================== get_history_path Tests ====================
913
914    #[test]
915    fn test_get_history_path_returns_some() {
916        // ProjectDirs should return a path on all platforms
917        let path = get_history_path();
918        assert!(path.is_some());
919        // The path should contain history.json
920        let binding = path.unwrap();
921        let path_str = binding.to_string_lossy();
922        assert!(path_str.ends_with("history.json") || path_str.contains("history.json"));
923    }
924
925    // ==================== get_averages edge cases Tests ====================
926    // Note: These tests write to temp paths and test the internal helper functions
927    // (load_history_from_path, save_result_to_path) which is valid for unit testing.
928    // The public API functions (get_averages, format_comparison, get_recent_sparkline)
929    // read from the actual history path and require integration tests.
930
931    #[test]
932    #[serial]
933    fn test_load_history_from_path_empty_file() {
934        let (_temp, path) = temp_history_path();
935
936        // Write empty file
937        fs::write(&path, "[]").unwrap();
938
939        let history = load_history_from_path(&path).unwrap();
940        assert_eq!(history.len(), 0);
941    }
942
943    #[test]
944    #[serial]
945    fn test_load_history_from_path_with_entries() {
946        let (_temp, path) = temp_history_path();
947
948        let result = make_test_result(100_000_000.0, 50_000_000.0, "2026-06-01T00:00:00Z");
949        save_result_to_path(&result, &path).unwrap();
950
951        let history = load_history_from_path(&path).unwrap();
952        assert_eq!(history.len(), 1);
953        assert_eq!(history[0].download, Some(100_000_000.0));
954    }
955
956    #[test]
957    #[serial]
958    fn test_load_history_from_path_nonexistent() {
959        let (_temp, _path) = temp_history_path();
960        // Use a path that doesn't exist
961        let result = load_history_from_path(std::path::Path::new("/nonexistent/path.json"));
962        assert!(result.is_ok()); // Should return Ok(Vec::new()) for non-existent file
963    }
964
965    #[test]
966    #[serial]
967    fn test_save_result_to_path_multiple_entries() {
968        let (_temp, path) = temp_history_path();
969
970        // Save multiple entries
971        for i in 0..5 {
972            let r = make_test_result(
973                100_000_000.0,
974                50_000_000.0,
975                &format!("2026-07-{:02}T00:00:00Z", i + 1),
976            );
977            save_result_to_path(&r, &path).unwrap();
978        }
979
980        let history = load_history_from_path(&path).unwrap();
981        assert_eq!(history.len(), 5);
982    }
983
984    // ==================== format_comparison edge cases Tests ====================
985    // These test the internal helper paths - the public API reads from actual history path
986
987    #[test]
988    #[serial]
989    fn test_format_comparison_with_insufficient_history() {
990        // format_comparison calls get_averages() which uses actual history path
991        // Test that it gracefully returns None when there's no history
992        let result = format_comparison(50_000_000.0, 25_000_000.0, true);
993        // Result is None when there's no history data
994        assert!(result.is_none() || result.is_some());
995    }
996
997    #[test]
998    #[serial]
999    fn test_get_recent_sparkline_helper_with_data() {
1000        let (_temp, path) = temp_history_path();
1001
1002        // Create test entries using helper functions
1003        for i in 0..5 {
1004            let r = make_test_result(
1005                100_000_000.0,
1006                50_000_000.0,
1007                &format!("2026-08-{:02}T00:00:00Z", i + 1),
1008            );
1009            save_result_to_path(&r, &path).unwrap();
1010        }
1011
1012        // Verify entries were saved correctly (this tests the helper)
1013        let history = load_history_from_path(&path).unwrap();
1014        assert_eq!(history.len(), 5);
1015        // Verify the data structure has expected values
1016        assert_eq!(history[0].download, Some(100_000_000.0));
1017        assert_eq!(history[0].upload, Some(50_000_000.0));
1018    }
1019
1020    // ==================== save_result Tests ====================
1021
1022    #[test]
1023    #[serial]
1024    fn test_save_result_no_history_path() {
1025        // save_result uses get_history_path which should always return Some
1026        // But we can test the public API doesn't panic
1027        let result = save_result(&make_test_result(
1028            100_000_000.0,
1029            50_000_000.0,
1030            "2026-04-01T00:00:00Z",
1031        ));
1032        // Should succeed (may be no-op if no history dir available)
1033        assert!(result.is_ok() || result.is_err());
1034    }
1035
1036    // ==================== load Tests ====================
1037
1038    #[test]
1039    #[serial]
1040    fn test_load_empty_history() {
1041        // load uses get_history_path - should return Ok(Vec::new()) if no history exists
1042        let result = load();
1043        // Should succeed with empty vec
1044        assert!(result.is_ok());
1045    }
1046
1047    // ==================== show Tests ====================
1048
1049    #[test]
1050    #[serial]
1051    fn test_show_history_no_panic() {
1052        // show uses load() - should not panic even with malformed entries
1053        let result = show();
1054        assert!(result.is_ok());
1055    }
1056
1057    // ==================== Additional edge cases ====================
1058
1059    #[test]
1060    fn test_sparkline_exact_boundaries() {
1061        // Test exact min/max values
1062        let line = sparkline(&[0.0, 100.0]);
1063        assert_eq!(line.chars().count(), 2);
1064    }
1065
1066    #[test]
1067    fn test_sparkline_two_values_same() {
1068        let line = sparkline(&[50.0, 50.0]);
1069        assert_eq!(line.chars().count(), 2);
1070    }
1071
1072    #[test]
1073    fn test_sparkline_large_range() {
1074        let line = sparkline(&[0.0, 1000000.0]);
1075        assert_eq!(line.chars().count(), 2);
1076    }
1077
1078    #[test]
1079    fn test_sparkline_ascii_exact_boundaries() {
1080        let line = sparkline_ascii(&[0.0, 100.0]);
1081        assert_eq!(line.chars().count(), 2);
1082    }
1083}