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
70fn 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
98fn 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 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 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
155pub 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
169pub fn save_report(report: &crate::domain::reporting::Report) -> Result<(), Error> {
171 save_result(report)
173}
174
175pub 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
189pub 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#[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 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#[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 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#[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 return CHARS[3].to_string().repeat(values.len());
346 }
347 values
348 .iter()
349 .map(|v| {
350 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#[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 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#[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 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 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 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 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 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 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 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 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 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, "▄"); }
727
728 #[test]
729 fn test_sparkline_identical_values() {
730 let line = sparkline(&[50.0, 50.0, 50.0]);
731 assert_eq!(line, "▄▄▄"); }
733
734 #[test]
737 fn test_sparkline_ascii_increasing() {
738 let line = sparkline_ascii(&[10.0, 20.0, 30.0, 40.0, 50.0]);
739 assert_eq!(line.chars().count(), 5);
741 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); }
761
762 #[test]
763 fn test_sparkline_ascii_identical_values() {
764 let line = sparkline_ascii(&[50.0, 50.0, 50.0]);
765 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 #[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 #[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 #[test]
850 #[serial]
851 fn test_load_entries_valid_json() {
852 let (_temp, path) = temp_history_path();
853
854 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 let result = load_entries(std::path::Path::new("/nonexistent/file.json"));
909 assert!(result.is_err());
910 }
911
912 #[test]
915 fn test_get_history_path_returns_some() {
916 let path = get_history_path();
918 assert!(path.is_some());
919 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 #[test]
932 #[serial]
933 fn test_load_history_from_path_empty_file() {
934 let (_temp, path) = temp_history_path();
935
936 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 let result = load_history_from_path(std::path::Path::new("/nonexistent/path.json"));
962 assert!(result.is_ok()); }
964
965 #[test]
966 #[serial]
967 fn test_save_result_to_path_multiple_entries() {
968 let (_temp, path) = temp_history_path();
969
970 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 #[test]
988 #[serial]
989 fn test_format_comparison_with_insufficient_history() {
990 let result = format_comparison(50_000_000.0, 25_000_000.0, true);
993 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 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 let history = load_history_from_path(&path).unwrap();
1014 assert_eq!(history.len(), 5);
1015 assert_eq!(history[0].download, Some(100_000_000.0));
1017 assert_eq!(history[0].upload, Some(50_000_000.0));
1018 }
1019
1020 #[test]
1023 #[serial]
1024 fn test_save_result_no_history_path() {
1025 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 assert!(result.is_ok() || result.is_err());
1034 }
1035
1036 #[test]
1039 #[serial]
1040 fn test_load_empty_history() {
1041 let result = load();
1043 assert!(result.is_ok());
1045 }
1046
1047 #[test]
1050 #[serial]
1051 fn test_show_history_no_panic() {
1052 let result = show();
1054 assert!(result.is_ok());
1055 }
1056
1057 #[test]
1060 fn test_sparkline_exact_boundaries() {
1061 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}