1use crate::common;
2use crate::error::Error;
3use crate::terminal;
4use crate::theme::{Colors, Theme};
5use crate::types::TestResult;
6use directories::ProjectDirs;
7use owo_colors::OwoColorize;
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::{Path, PathBuf};
11
12#[derive(Serialize, Deserialize, Debug)]
13pub struct Entry {
14 pub timestamp: String,
15 pub server_name: String,
16 pub sponsor: String,
17 pub ping: Option<f64>,
18 pub jitter: Option<f64>,
19 pub packet_loss: Option<f64>,
20 pub download: Option<f64>,
21 pub download_peak: Option<f64>,
22 pub upload: Option<f64>,
23 pub upload_peak: Option<f64>,
24 pub latency_download: Option<f64>,
25 pub latency_upload: Option<f64>,
26 pub client_ip: Option<String>,
27}
28
29impl From<&TestResult> for Entry {
30 fn from(result: &TestResult) -> Self {
31 Self {
32 timestamp: result.timestamp.clone(),
33 server_name: result.server.name.clone(),
34 sponsor: result.server.sponsor.clone(),
35 ping: result.ping,
36 jitter: result.jitter,
37 packet_loss: result.packet_loss,
38 download: result.download,
39 download_peak: result.download_peak,
40 upload: result.upload,
41 upload_peak: result.upload_peak,
42 latency_download: result.latency_download,
43 latency_upload: result.latency_upload,
44 client_ip: result.client_ip.clone(),
45 }
46 }
47}
48
49fn get_history_path() -> Option<PathBuf> {
50 ProjectDirs::from("dev", "vibe", "netspeed-cli").map(|proj_dirs| {
51 let data_dir = proj_dirs.data_dir();
52 if let Err(e) = fs::create_dir_all(data_dir) {
53 eprintln!("Warning: Failed to create data directory: {e}");
54 }
55 data_dir.join("history.json")
56 })
57}
58
59fn backup_path(path: &Path) -> PathBuf {
60 path.with_extension("json.bak")
61}
62
63fn corrupt_path(path: &Path) -> PathBuf {
64 path.with_extension("json.corrupt")
65}
66
67fn load_entries(path: &Path) -> Result<Vec<Entry>, Error> {
68 let content = fs::read_to_string(path)?;
69 Ok(serde_json::from_str(&content)?)
70}
71
72fn load_history_from_path(path: &Path) -> Result<Vec<Entry>, Error> {
74 if !path.exists() {
75 return Ok(Vec::new());
76 }
77
78 match load_entries(path) {
79 Ok(history) => Ok(history),
80 Err(err) => {
81 let backup = backup_path(path);
82 if backup.exists() {
83 match load_entries(&backup) {
84 Ok(history) => {
85 eprintln!(
86 "Warning: History file is invalid; using backup at {}",
87 backup.display()
88 );
89 Ok(history)
90 }
91 Err(_) => Err(err),
92 }
93 } else {
94 Err(err)
95 }
96 }
97 }
98}
99
100fn save_result_to_path(result: &TestResult, path: &Path) -> Result<(), Error> {
102 let backup = backup_path(path);
103 let mut recovered_from_backup = false;
104 let mut history: Vec<Entry> = if path.exists() {
105 match load_entries(path) {
106 Ok(history) => history,
107 Err(err) => {
108 if backup.exists() {
109 let backup_history = load_entries(&backup)?;
110 let corrupt = corrupt_path(path);
111 fs::copy(path, &corrupt)?;
112 eprintln!(
113 "Warning: History file is invalid; preserving it at {} and repairing from backup {}",
114 corrupt.display(),
115 backup.display()
116 );
117 recovered_from_backup = true;
118 backup_history
119 } else {
120 return Err(err);
121 }
122 }
123 }
124 } else {
125 Vec::new()
126 };
127
128 history.push(Entry::from(result));
129
130 if history.len() > 100 {
132 let overflow = history.len() - 100;
133 history.drain(0..overflow);
134 }
135
136 let json = serde_json::to_string_pretty(&history)?;
137
138 let tmp_path = path.with_extension("json.tmp");
141 fs::write(&tmp_path, &json)?;
142 #[cfg(unix)]
143 {
144 use std::os::unix::fs::PermissionsExt;
145 if let Err(e) = fs::set_permissions(&tmp_path, fs::Permissions::from_mode(0o600)) {
146 eprintln!("Warning: Failed to set permissions on history file: {e}");
147 }
148 }
149 if path.exists() && !recovered_from_backup {
150 fs::copy(path, &backup)?;
151 }
152 fs::rename(&tmp_path, path)?;
153
154 Ok(())
155}
156
157pub fn save_result(result: &TestResult) -> Result<(), Error> {
164 let Some(path) = get_history_path() else {
165 return Ok(());
166 };
167
168 save_result_to_path(result, &path)
169}
170
171pub fn save_report(report: &crate::domain::reporting::Report) -> Result<(), Error> {
173 save_result(report)
175}
176
177pub fn load() -> Result<Vec<Entry>, Error> {
184 let Some(path) = get_history_path() else {
185 return Ok(Vec::new());
186 };
187
188 load_history_from_path(&path)
189}
190
191pub fn show(theme: Theme) -> Result<(), Error> {
198 let history = load()?;
199
200 if history.is_empty() {
201 println!("No test history found.");
202 return Ok(());
203 }
204
205 let nc = terminal::no_color() || theme == Theme::Monochrome;
206 let term_w = common::get_terminal_width().unwrap_or(90) as usize;
207 let box_w = term_w.min(80);
208 let inner_w = box_w.saturating_sub(4); let content_w = inner_w.saturating_sub(4); let count = history.len();
212 let left_text = "◉ TEST HISTORY";
213 let right_text = format!("{count} entries");
214 let pad = content_w.saturating_sub(left_text.chars().count() + right_text.chars().count());
215 let spaces = " ".repeat(pad);
216
217 let top_border = format!(" ┌{}┐", "─".repeat(inner_w));
218 let mid_border = format!(" └{}┘", "─".repeat(inner_w));
219
220 println!();
221 println!("{top_border}");
222 if nc {
223 println!(" │ {left_text}{spaces}{right_text} │");
224 } else {
225 let left_col = format!(
226 "{} {}",
227 Colors::muted("◉", theme),
228 Colors::header("TEST HISTORY", theme)
229 );
230 let right_col = Colors::muted(&right_text, theme);
231 println!(" │ {left_col}{spaces}{right_col} │");
232 }
233 println!("{mid_border}");
234 println!();
235
236 const DATE_W: usize = 10;
238 const DL_W: usize = 12;
239 const UL_W: usize = 12;
240 const PING_W: usize = 9;
241 const SERVER_W: usize = 18;
242
243 let h_date = format!("{:<DATE_W$}", "Date");
245 let h_dl = format!("{:>DL_W$}", "↓ Download");
246 let h_ul = format!("{:>UL_W$}", "↑ Upload");
247 let h_ping = format!("{:>PING_W$}", "Ping");
248 let h_server = format!("{:<SERVER_W$}", "Server");
249 if nc {
250 println!(" {h_date} {h_dl} {h_ul} {h_ping} {h_server}");
251 } else {
252 println!(
253 " {} {} {} {} {}",
254 Colors::muted(&h_date, theme),
255 Colors::muted(&h_dl, theme),
256 Colors::muted(&h_ul, theme),
257 Colors::muted(&h_ping, theme),
258 Colors::muted(&h_server, theme),
259 );
260 }
261
262 let sep_len = DATE_W + 2 + DL_W + 2 + UL_W + 2 + PING_W + 2 + SERVER_W;
264 let sep = format!(" {}", "╌".repeat(sep_len));
265 if nc {
266 println!("{sep}");
267 } else {
268 println!("{}", sep.dimmed());
269 }
270
271 for entry in history.iter().rev() {
273 let date_plain = if entry.timestamp.len() >= 10 {
274 entry.timestamp[0..10].to_string()
275 } else {
276 entry.timestamp.clone()
277 };
278
279 let dl_mbps = entry.download.map(|d| d / 1_000_000.0);
280 let ul_mbps = entry.upload.map(|u| u / 1_000_000.0);
281
282 let dl_plain = dl_mbps.map_or("-".to_string(), |d| format!("{d:.1} Mb/s"));
283 let ul_plain = ul_mbps.map_or("-".to_string(), |u| format!("{u:.1} Mb/s"));
284 let ping_plain = entry.ping.map_or("-".to_string(), |p| format!("{p:.0} ms"));
285
286 let sponsor_truncated = if entry.sponsor.chars().count() > SERVER_W {
287 let truncated: String = entry.sponsor.chars().take(SERVER_W - 1).collect();
288 format!("{truncated}…")
289 } else {
290 entry.sponsor.clone()
291 };
292
293 let date_col = format!("{date_plain:<DATE_W$}");
295 let dl_col_plain = format!("{dl_plain:>DL_W$}");
296 let ul_col_plain = format!("{ul_plain:>UL_W$}");
297 let ping_col_plain = format!("{ping_plain:>PING_W$}");
298 let server_col = format!("{sponsor_truncated:<SERVER_W$}");
299
300 if nc {
301 println!(
302 " {date_col} {dl_col_plain} {ul_col_plain} {ping_col_plain} {server_col}"
303 );
304 } else {
305 let date_colored = Colors::muted(&date_col, theme);
306 let dl_colored = color_speed(&dl_col_plain, dl_mbps, theme);
307 let ul_colored = color_speed(&ul_col_plain, ul_mbps, theme);
308 let ping_colored = color_ping(&ping_col_plain, entry.ping, theme);
309 let server_colored = server_col.dimmed().to_string();
310 println!(
311 " {date_colored} {dl_colored} {ul_colored} {ping_colored} {server_colored}"
312 );
313 }
314 }
315
316 let spark_start = if history.len() > 20 {
318 history.len() - 20
319 } else {
320 0
321 };
322 let spark_slice = &history[spark_start..];
323 let dl_vals: Vec<f64> = spark_slice
324 .iter()
325 .filter_map(|e| e.download.map(|d| d / 1_000_000.0))
326 .collect();
327 let ul_vals: Vec<f64> = spark_slice
328 .iter()
329 .filter_map(|e| e.upload.map(|u| u / 1_000_000.0))
330 .collect();
331
332 if !dl_vals.is_empty() || !ul_vals.is_empty() {
333 let n = spark_slice.len();
334 if nc {
335 println!("{sep}");
336 } else {
337 println!("{}", sep.dimmed());
338 }
339 if !dl_vals.is_empty() {
340 let dl_spark = sparkline(&dl_vals);
341 if nc {
342 println!(" ↓ {dl_spark} Download trend ({n} tests)");
343 } else {
344 println!(
345 " {} {} {}",
346 Colors::muted("↓", theme),
347 Colors::info(&dl_spark, theme),
348 Colors::muted(&format!("Download trend ({n} tests)"), theme),
349 );
350 }
351 }
352 if !ul_vals.is_empty() {
353 let ul_spark = sparkline(&ul_vals);
354 if nc {
355 println!(" ↑ {ul_spark} Upload trend");
356 } else {
357 println!(
358 " {} {} {}",
359 Colors::muted("↑", theme),
360 Colors::good(&ul_spark, theme),
361 Colors::muted("Upload trend", theme),
362 );
363 }
364 }
365 }
366
367 println!();
368 Ok(())
369}
370
371fn color_speed(col: &str, mbps: Option<f64>, theme: Theme) -> String {
372 match mbps {
373 None => col.dimmed().to_string(),
374 Some(v) if v >= 100.0 => Colors::good(col, theme),
375 Some(v) if v >= 25.0 => Colors::info(col, theme),
376 Some(v) if v >= 5.0 => Colors::warn(col, theme),
377 Some(_) => Colors::bad(col, theme),
378 }
379}
380
381fn color_ping(col: &str, ping: Option<f64>, theme: Theme) -> String {
382 match ping {
383 None => col.dimmed().to_string(),
384 Some(v) if v <= 20.0 => Colors::good(col, theme),
385 Some(v) if v <= 50.0 => Colors::warn(col, theme),
386 Some(_) => Colors::bad(col, theme),
387 }
388}
389
390#[must_use]
393pub fn get_averages() -> Option<(f64, f64)> {
394 let history = match load() {
395 Ok(h) => h,
396 Err(e) => {
397 eprintln!("Warning: Failed to load history for averages: {e}");
398 return None;
399 }
400 };
401 let recent: Vec<_> = history.iter().rev().take(20).collect();
402 let dl_entries: Vec<f64> = recent
403 .iter()
404 .filter_map(|e| e.download.map(|d| d / 1_000_000.0))
405 .collect();
406 let ul_entries: Vec<f64> = recent
407 .iter()
408 .filter_map(|e| e.upload.map(|u| u / 1_000_000.0))
409 .collect();
410
411 if dl_entries.is_empty() || ul_entries.is_empty() {
412 return None;
413 }
414
415 let download_avg = dl_entries.iter().sum::<f64>() / dl_entries.len() as f64;
417 let upload_avg = ul_entries.iter().sum::<f64>() / ul_entries.len() as f64;
418 Some((download_avg, upload_avg))
419}
420
421#[must_use]
424pub fn format_comparison(
425 download_mbps: f64,
426 upload_mbps: f64,
427 nc: bool,
428 theme: Theme,
429) -> Option<String> {
430 let (download_avg, upload_avg) = get_averages()?;
431
432 let current_score = download_mbps + upload_mbps;
434 let avg_score = download_avg + upload_avg;
435
436 if avg_score <= 0.0 {
437 return None;
438 }
439
440 let pct_change = ((current_score / avg_score) - 1.0) * 100.0;
441
442 let display = if pct_change.abs() < 3.0 {
443 if nc {
444 "~ On par with your history".to_string()
445 } else {
446 Colors::muted("~ On par with your history", theme)
447 }
448 } else if pct_change > 0.0 {
449 if nc {
450 format!("↑ {pct_change:.0}% faster than your average")
451 } else {
452 Colors::good(
453 &format!("↑ {pct_change:.0}% faster than your average"),
454 theme,
455 )
456 }
457 } else {
458 let abs_pct = pct_change.abs();
459 if nc {
460 format!("↓ {abs_pct:.0}% slower than your average")
461 } else {
462 Colors::bad(&format!("↓ {abs_pct:.0}% slower than your average"), theme)
463 }
464 };
465
466 Some(display)
467}
468
469#[must_use]
479pub fn sparkline(values: &[f64]) -> String {
480 const CHARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
481 if values.is_empty() {
482 return String::new();
483 }
484 let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
485 let min = values.iter().copied().fold(f64::INFINITY, f64::min);
486 let range = max - min;
487 if range <= 0.0 {
488 return CHARS[3].to_string().repeat(values.len());
490 }
491 values
492 .iter()
493 .map(|v| {
494 let idx = (((v - min) / range) * 7.0).round().clamp(0.0, 7.0) as usize;
496 CHARS[idx]
497 })
498 .collect::<String>()
499}
500
501#[must_use]
504pub fn sparkline_ascii(values: &[f64]) -> String {
505 const CHARS: &[char] = &['_', '_', '‗', '-', '=', '≈', '^', '▲'];
506 if values.is_empty() {
507 return String::new();
508 }
509 let max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
510 let min = values.iter().copied().fold(f64::INFINITY, f64::min);
511 let range = max - min;
512 if range <= 0.0 {
513 return "-".repeat(values.len());
514 }
515 values
516 .iter()
517 .map(|v| {
518 let idx = (((v - min) / range) * 7.0).round().clamp(0.0, 7.0) as usize;
520 CHARS[idx]
521 })
522 .collect::<String>()
523}
524
525#[must_use]
528pub fn get_recent_sparkline() -> Vec<(String, f64, f64)> {
529 let Ok(history) = load() else {
530 return Vec::new();
531 };
532 history
533 .iter()
534 .rev()
535 .take(7)
536 .filter_map(|e| {
537 let dl = e.download.map_or(0.0, |d| d / 1_000_000.0);
538 let ul = e.upload.map_or(0.0, |u| u / 1_000_000.0);
539 if dl > 0.0 || ul > 0.0 {
540 let date = e.timestamp.get(0..10).unwrap_or(&e.timestamp).to_string();
542 Some((date, dl, ul))
543 } else {
544 None
545 }
546 })
547 .collect()
548}
549
550#[cfg(test)]
551mod tests {
552 use super::*;
553 use crate::error::Error;
554 use crate::types::{PhaseResult, ServerInfo, TestPhases, TestResult};
555 use serial_test::serial;
556
557 fn make_test_result(download: f64, upload: f64, timestamp: &str) -> TestResult {
558 TestResult {
559 status: "ok".to_string(),
560 version: env!("CARGO_PKG_VERSION").to_string(),
561 test_id: None,
562 server: ServerInfo {
563 id: "1".to_string(),
564 name: "Test".to_string(),
565 sponsor: "Test".to_string(),
566 country: "US".to_string(),
567 distance: 0.0,
568 },
569 ping: Some(10.0),
570 jitter: Some(1.0),
571 packet_loss: Some(0.0),
572 download: Some(download),
573 download_peak: None,
574 upload: Some(upload),
575 upload_peak: None,
576 latency_download: None,
577 latency_upload: None,
578 download_samples: None,
579 upload_samples: None,
580 ping_samples: None,
581 timestamp: timestamp.to_string(),
582 client_ip: None,
583 client_location: None,
584 download_cv: None,
585 upload_cv: None,
586 download_ci_95: None,
587 upload_ci_95: None,
588 overall_grade: None,
589 download_grade: None,
590 upload_grade: None,
591 connection_rating: None,
592 phases: TestPhases {
593 ping: PhaseResult::completed(),
594 download: PhaseResult::completed(),
595 upload: PhaseResult::completed(),
596 },
597 }
598 }
599
600 fn temp_history_path() -> (tempfile::TempDir, PathBuf) {
602 let temp_dir = tempfile::TempDir::new().unwrap();
603 let path = temp_dir.path().join("history.json");
604 (temp_dir, path)
605 }
606
607 #[test]
608 #[serial]
609 fn test_get_averages_returns_values() {
610 let (_temp, path) = temp_history_path();
611
612 let results = vec![
613 make_test_result(100_000_000.0, 50_000_000.0, "2026-01-01T00:00:00Z"),
614 make_test_result(120_000_000.0, 60_000_000.0, "2026-01-02T00:00:00Z"),
615 make_test_result(80_000_000.0, 40_000_000.0, "2026-01-03T00:00:00Z"),
616 ];
617 for r in &results {
618 save_result_to_path(r, &path).unwrap();
619 }
620
621 let history = load_history_from_path(&path).unwrap();
623 let dl_values: Vec<f64> = history
624 .iter()
625 .filter_map(|e| e.download.map(|d| d / 1_000_000.0))
626 .collect();
627 assert_eq!(dl_values.len(), 3);
628 let download_avg = dl_values.iter().sum::<f64>() / dl_values.len() as f64;
630 assert!((download_avg - 100.0).abs() < 0.1);
631 }
632
633 #[test]
634 #[serial]
635 fn test_format_comparison_faster() {
636 let (_temp, path) = temp_history_path();
637
638 for i in 0..3 {
639 let r = make_test_result(
640 20_000_000.0,
641 10_000_000.0,
642 &format!("2026-06-{i:02}T00:00:00Z"),
643 );
644 save_result_to_path(&r, &path).unwrap();
645 }
646
647 let history = load_history_from_path(&path).unwrap();
649 assert_eq!(history.len(), 3);
650 }
651
652 #[test]
653 #[serial]
654 fn test_format_comparison_slower() {
655 let (_temp, path) = temp_history_path();
656
657 for i in 0..3 {
658 let r = make_test_result(
659 800_000_000.0,
660 800_000_000.0,
661 &format!("2026-07-{i:02}T00:00:00Z"),
662 );
663 save_result_to_path(&r, &path).unwrap();
664 }
665
666 let history = load_history_from_path(&path).unwrap();
667 assert_eq!(history.len(), 3);
668 }
669
670 #[test]
671 #[serial]
672 fn test_format_comparison_on_par() {
673 let (_temp, path) = temp_history_path();
674
675 let sim_results = vec![
676 make_test_result(100_000_000.0, 50_000_000.0, "2026-04-01T00:00:00Z"),
677 make_test_result(105_000_000.0, 52_000_000.0, "2026-04-02T00:00:00Z"),
678 make_test_result(95_000_000.0, 48_000_000.0, "2026-04-03T00:00:00Z"),
679 ];
680 for r in &sim_results {
681 save_result_to_path(r, &path).unwrap();
682 }
683
684 let history = load_history_from_path(&path).unwrap();
685 assert_eq!(history.len(), 3);
686 }
687
688 #[test]
689 #[serial]
690 fn test_save_result_appends_to_existing() {
691 let (_temp, path) = temp_history_path();
692
693 let r1 = make_test_result(50_000_000.0, 25_000_000.0, "2026-08-01T00:00:00Z");
694 save_result_to_path(&r1, &path).unwrap();
695 let r2 = make_test_result(60_000_000.0, 30_000_000.0, "2026-08-02T00:00:00Z");
696 save_result_to_path(&r2, &path).unwrap();
697
698 let history = load_history_from_path(&path).unwrap();
699 assert_eq!(history.len(), 2);
700 }
701
702 #[test]
703 #[serial]
704 fn test_print_history_with_data() {
705 let (_temp, path) = temp_history_path();
706
707 for i in 0..3 {
708 let r = make_test_result(
709 100_000_000.0,
710 50_000_000.0,
711 &format!("2026-05-{i:02}T00:00:00Z"),
712 );
713 save_result_to_path(&r, &path).unwrap();
714 }
715
716 let history = load_history_from_path(&path).unwrap();
717 assert_eq!(history.len(), 3);
718 }
719
720 #[test]
721 #[serial]
722 fn test_print_history_long_sponsor_truncation() {
723 let (_temp, path) = temp_history_path();
724
725 let mut r = make_test_result(100_000_000.0, 50_000_000.0, "2026-09-01T00:00:00Z");
726 r.server.sponsor = "VeryLongSponsorNameThatExceedsLimit".to_string();
727 save_result_to_path(&r, &path).unwrap();
728
729 let history = load_history_from_path(&path).unwrap();
730 assert_eq!(history[0].sponsor, "VeryLongSponsorNameThatExceedsLimit");
731 }
732
733 #[test]
734 #[serial]
735 fn test_format_comparison_zero_avg() {
736 let (_temp, path) = temp_history_path();
737
738 let r = make_test_result(0.0, 0.0, "2026-10-01T00:00:00Z");
739 save_result_to_path(&r, &path).unwrap();
740
741 let history = load_history_from_path(&path).unwrap();
742 assert_eq!(history.len(), 1);
743 assert_eq!(history[0].download, Some(0.0));
744 }
745
746 #[test]
747 #[serial]
748 fn test_save_result_invalid_json_recovery() {
749 let (_temp, path) = temp_history_path();
750
751 fs::write(&path, "{invalid json}").unwrap();
753
754 let r = make_test_result(100_000_000.0, 50_000_000.0, "2026-12-01T00:00:00Z");
755 let err = save_result_to_path(&r, &path).unwrap_err();
756 assert!(matches!(err, Error::ParseJson(_)));
757
758 let original = fs::read_to_string(&path).unwrap();
759 assert_eq!(original, "{invalid json}");
760 }
761
762 #[test]
763 #[serial]
764 fn test_save_result_recovers_from_backup_when_primary_is_corrupt() {
765 let (_temp, path) = temp_history_path();
766 let backup = backup_path(&path);
767
768 let existing = make_test_result(100_000_000.0, 50_000_000.0, "2026-11-01T00:00:00Z");
769 let existing_history = vec![Entry::from(&existing)];
770 fs::write(
771 &backup,
772 serde_json::to_string_pretty(&existing_history).unwrap(),
773 )
774 .unwrap();
775 fs::write(&path, "{invalid json}").unwrap();
776
777 let new_result = make_test_result(120_000_000.0, 60_000_000.0, "2026-11-02T00:00:00Z");
778 save_result_to_path(&new_result, &path).unwrap();
779
780 let repaired = load_entries(&path).unwrap();
781 assert_eq!(repaired.len(), 2);
782 assert_eq!(repaired[0].timestamp, "2026-11-01T00:00:00Z");
783 assert_eq!(repaired[1].timestamp, "2026-11-02T00:00:00Z");
784
785 let corrupt = corrupt_path(&path);
786 assert!(corrupt.exists());
787 assert_eq!(fs::read_to_string(corrupt).unwrap(), "{invalid json}");
788 }
789
790 #[test]
791 #[serial]
792 fn test_load_history_falls_back_to_backup() {
793 let (_temp, path) = temp_history_path();
794 let backup = backup_path(&path);
795
796 let existing = make_test_result(100_000_000.0, 50_000_000.0, "2026-10-01T00:00:00Z");
797 let existing_history = vec![Entry::from(&existing)];
798 fs::write(
799 &backup,
800 serde_json::to_string_pretty(&existing_history).unwrap(),
801 )
802 .unwrap();
803 fs::write(&path, "{invalid json}").unwrap();
804
805 let history = load_history_from_path(&path).unwrap();
806 assert_eq!(history.len(), 1);
807 assert_eq!(history[0].timestamp, "2026-10-01T00:00:00Z");
808 }
809
810 #[test]
811 #[serial]
812 fn test_save_result_rotates_backup_from_previous_good_state() {
813 let (_temp, path) = temp_history_path();
814 let backup = backup_path(&path);
815
816 let r1 = make_test_result(50_000_000.0, 25_000_000.0, "2026-08-01T00:00:00Z");
817 let r2 = make_test_result(60_000_000.0, 30_000_000.0, "2026-08-02T00:00:00Z");
818 save_result_to_path(&r1, &path).unwrap();
819 save_result_to_path(&r2, &path).unwrap();
820
821 let previous = load_entries(&backup).unwrap();
822 assert_eq!(previous.len(), 1);
823 assert_eq!(previous[0].timestamp, "2026-08-01T00:00:00Z");
824 }
825
826 #[test]
827 #[serial]
828 fn test_history_keeps_last_100_entries() {
829 let (_temp, path) = temp_history_path();
830
831 for i in 0..105 {
833 let r = make_test_result(
834 100_000_000.0,
835 50_000_000.0,
836 &format!("2026-01-{i:02}T00:00:00Z"),
837 );
838 save_result_to_path(&r, &path).unwrap();
839 }
840
841 let history = load_history_from_path(&path).unwrap();
842 assert_eq!(history.len(), 100);
843 assert_eq!(history[0].timestamp, "2026-01-05T00:00:00Z");
845 }
846
847 #[test]
848 fn test_sparkline_increasing() {
849 let line = sparkline(&[10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0]);
850 assert_eq!(line.chars().count(), 8);
851 assert_eq!(line, "▁▂▃▄▅▆▇█");
853 }
854
855 #[test]
856 fn test_sparkline_decreasing() {
857 let line = sparkline(&[80.0, 60.0, 40.0, 20.0]);
858 assert_eq!(line.chars().count(), 4);
859 }
860
861 #[test]
862 fn test_sparkline_empty() {
863 assert_eq!(sparkline(&[]), "");
864 }
865
866 #[test]
867 fn test_sparkline_single_value() {
868 let line = sparkline(&[42.0]);
869 assert_eq!(line, "▄"); }
871
872 #[test]
873 fn test_sparkline_identical_values() {
874 let line = sparkline(&[50.0, 50.0, 50.0]);
875 assert_eq!(line, "▄▄▄"); }
877
878 #[test]
881 fn test_sparkline_ascii_increasing() {
882 let line = sparkline_ascii(&[10.0, 20.0, 30.0, 40.0, 50.0]);
883 assert_eq!(line.chars().count(), 5);
885 assert!(!line.is_empty());
887 }
888
889 #[test]
890 fn test_sparkline_ascii_decreasing() {
891 let line = sparkline_ascii(&[80.0, 60.0, 40.0, 20.0]);
892 assert_eq!(line.chars().count(), 4);
893 }
894
895 #[test]
896 fn test_sparkline_ascii_empty() {
897 assert_eq!(sparkline_ascii(&[]), "");
898 }
899
900 #[test]
901 fn test_sparkline_ascii_single_value() {
902 let line = sparkline_ascii(&[42.0]);
903 assert_eq!(line.len(), 1); }
905
906 #[test]
907 fn test_sparkline_ascii_identical_values() {
908 let line = sparkline_ascii(&[50.0, 50.0, 50.0]);
909 assert_eq!(line.chars().count(), 3);
911 }
912
913 #[test]
914 fn test_sparkline_ascii_all_min() {
915 let line = sparkline_ascii(&[1.0, 2.0, 1.0]);
916 assert_eq!(line.chars().count(), 3);
917 }
918
919 #[test]
920 fn test_sparkline_ascii_all_max() {
921 let line = sparkline_ascii(&[100.0, 99.0, 100.0]);
922 assert_eq!(line.chars().count(), 3);
923 }
924
925 #[test]
926 fn test_sparkline_ascii_two_values() {
927 let line = sparkline_ascii(&[25.0, 75.0]);
928 assert_eq!(line.chars().count(), 2);
929 }
930
931 #[test]
932 fn test_sparkline_ascii_three_values() {
933 let line = sparkline_ascii(&[33.3, 66.6, 100.0]);
934 assert_eq!(line.chars().count(), 3);
935 }
936
937 #[test]
938 fn test_sparkline_ascii_five_values() {
939 let line = sparkline_ascii(&[10.0, 20.0, 30.0, 40.0, 50.0]);
940 assert_eq!(line.chars().count(), 5);
941 }
942
943 #[test]
946 fn test_entry_from_test_result() {
947 let result = make_test_result(100_000_000.0, 50_000_000.0, "2026-01-15T10:30:00Z");
948 let entry = Entry::from(&result);
949
950 assert_eq!(entry.timestamp, "2026-01-15T10:30:00Z");
951 assert_eq!(entry.server_name, "Test");
952 assert_eq!(entry.sponsor, "Test");
953 assert_eq!(entry.ping, Some(10.0));
954 assert_eq!(entry.jitter, Some(1.0));
955 assert_eq!(entry.download, Some(100_000_000.0));
956 assert_eq!(entry.upload, Some(50_000_000.0));
957 }
958
959 #[test]
960 fn test_entry_from_test_result_with_none_values() {
961 let mut result = make_test_result(100_000_000.0, 50_000_000.0, "2026-02-01T00:00:00Z");
962 result.ping = None;
963 result.jitter = None;
964 result.download = None;
965 result.upload = None;
966
967 let entry = Entry::from(&result);
968
969 assert!(entry.ping.is_none());
970 assert!(entry.jitter.is_none());
971 assert!(entry.download.is_none());
972 assert!(entry.upload.is_none());
973 }
974
975 #[test]
978 fn test_backup_path() {
979 let path = std::path::Path::new("/data/history.json");
980 let backup = backup_path(path);
981 assert_eq!(backup, std::path::Path::new("/data/history.json.bak"));
982 }
983
984 #[test]
985 fn test_corrupt_path() {
986 let path = std::path::Path::new("/data/history.json");
987 let corrupt = corrupt_path(path);
988 assert_eq!(corrupt, std::path::Path::new("/data/history.json.corrupt"));
989 }
990
991 #[test]
994 #[serial]
995 fn test_load_entries_valid_json() {
996 let (_temp, path) = temp_history_path();
997
998 let entries = vec![
1000 Entry {
1001 timestamp: "2026-03-01T00:00:00Z".to_string(),
1002 server_name: "Test".to_string(),
1003 sponsor: "Test".to_string(),
1004 ping: Some(10.0),
1005 jitter: Some(1.0),
1006 packet_loss: None,
1007 download: Some(100_000_000.0),
1008 download_peak: None,
1009 upload: Some(50_000_000.0),
1010 upload_peak: None,
1011 latency_download: None,
1012 latency_upload: None,
1013 client_ip: None,
1014 },
1015 Entry {
1016 timestamp: "2026-03-02T00:00:00Z".to_string(),
1017 server_name: "Test".to_string(),
1018 sponsor: "Test".to_string(),
1019 ping: Some(12.0),
1020 jitter: Some(2.0),
1021 packet_loss: None,
1022 download: Some(120_000_000.0),
1023 download_peak: None,
1024 upload: Some(60_000_000.0),
1025 upload_peak: None,
1026 latency_download: None,
1027 latency_upload: None,
1028 client_ip: None,
1029 },
1030 ];
1031 fs::write(&path, serde_json::to_string_pretty(&entries).unwrap()).unwrap();
1032
1033 let loaded = load_entries(&path).unwrap();
1034 assert_eq!(loaded.len(), 2);
1035 }
1036
1037 #[test]
1038 #[serial]
1039 fn test_load_entries_invalid_json() {
1040 let (_temp, path) = temp_history_path();
1041 fs::write(&path, "not valid json").unwrap();
1042
1043 let result = load_entries(&path);
1044 assert!(result.is_err());
1045 }
1046
1047 #[test]
1048 #[serial]
1049 fn test_load_entries_file_not_found() {
1050 let (_temp, _path) = temp_history_path();
1051 let result = load_entries(std::path::Path::new("/nonexistent/file.json"));
1053 assert!(result.is_err());
1054 }
1055
1056 #[test]
1059 fn test_get_history_path_returns_some() {
1060 let path = get_history_path();
1062 assert!(path.is_some());
1063 let binding = path.unwrap();
1065 let path_str = binding.to_string_lossy();
1066 assert!(path_str.ends_with("history.json") || path_str.contains("history.json"));
1067 }
1068
1069 #[test]
1076 #[serial]
1077 fn test_load_history_from_path_empty_file() {
1078 let (_temp, path) = temp_history_path();
1079
1080 fs::write(&path, "[]").unwrap();
1082
1083 let history = load_history_from_path(&path).unwrap();
1084 assert_eq!(history.len(), 0);
1085 }
1086
1087 #[test]
1088 #[serial]
1089 fn test_load_history_from_path_with_entries() {
1090 let (_temp, path) = temp_history_path();
1091
1092 let result = make_test_result(100_000_000.0, 50_000_000.0, "2026-06-01T00:00:00Z");
1093 save_result_to_path(&result, &path).unwrap();
1094
1095 let history = load_history_from_path(&path).unwrap();
1096 assert_eq!(history.len(), 1);
1097 assert_eq!(history[0].download, Some(100_000_000.0));
1098 }
1099
1100 #[test]
1101 #[serial]
1102 fn test_load_history_from_path_nonexistent() {
1103 let (_temp, _path) = temp_history_path();
1104 let result = load_history_from_path(std::path::Path::new("/nonexistent/path.json"));
1106 assert!(result.is_ok()); }
1108
1109 #[test]
1110 #[serial]
1111 fn test_save_result_to_path_multiple_entries() {
1112 let (_temp, path) = temp_history_path();
1113
1114 for i in 0..5 {
1116 let r = make_test_result(
1117 100_000_000.0,
1118 50_000_000.0,
1119 &format!("2026-07-{:02}T00:00:00Z", i + 1),
1120 );
1121 save_result_to_path(&r, &path).unwrap();
1122 }
1123
1124 let history = load_history_from_path(&path).unwrap();
1125 assert_eq!(history.len(), 5);
1126 }
1127
1128 #[test]
1132 #[serial]
1133 fn test_format_comparison_with_insufficient_history() {
1134 let result = format_comparison(50_000_000.0, 25_000_000.0, true, crate::theme::Theme::Dark);
1137 assert!(result.is_none() || result.is_some());
1139 }
1140
1141 #[test]
1142 #[serial]
1143 fn test_get_recent_sparkline_helper_with_data() {
1144 let (_temp, path) = temp_history_path();
1145
1146 for i in 0..5 {
1148 let r = make_test_result(
1149 100_000_000.0,
1150 50_000_000.0,
1151 &format!("2026-08-{:02}T00:00:00Z", i + 1),
1152 );
1153 save_result_to_path(&r, &path).unwrap();
1154 }
1155
1156 let history = load_history_from_path(&path).unwrap();
1158 assert_eq!(history.len(), 5);
1159 assert_eq!(history[0].download, Some(100_000_000.0));
1161 assert_eq!(history[0].upload, Some(50_000_000.0));
1162 }
1163
1164 #[test]
1167 #[serial]
1168 fn test_save_result_no_history_path() {
1169 let result = save_result(&make_test_result(
1172 100_000_000.0,
1173 50_000_000.0,
1174 "2026-04-01T00:00:00Z",
1175 ));
1176 assert!(result.is_ok() || result.is_err());
1178 }
1179
1180 #[test]
1183 #[serial]
1184 fn test_load_empty_history() {
1185 let result = load();
1187 assert!(result.is_ok());
1189 }
1190
1191 #[test]
1194 #[serial]
1195 fn test_show_history_no_panic() {
1196 let result = show(crate::theme::Theme::Dark);
1198 assert!(result.is_ok());
1199 }
1200
1201 #[test]
1204 fn test_sparkline_exact_boundaries() {
1205 let line = sparkline(&[0.0, 100.0]);
1207 assert_eq!(line.chars().count(), 2);
1208 }
1209
1210 #[test]
1211 fn test_sparkline_two_values_same() {
1212 let line = sparkline(&[50.0, 50.0]);
1213 assert_eq!(line.chars().count(), 2);
1214 }
1215
1216 #[test]
1217 fn test_sparkline_large_range() {
1218 let line = sparkline(&[0.0, 1000000.0]);
1219 assert_eq!(line.chars().count(), 2);
1220 }
1221
1222 #[test]
1223 fn test_sparkline_ascii_exact_boundaries() {
1224 let line = sparkline_ascii(&[0.0, 100.0]);
1225 assert_eq!(line.chars().count(), 2);
1226 }
1227}