1use core::time::Duration;
5
6use crate::model::MeasuredValue;
7use crate::units::{ByteUnits, Rate, format_byte_rate, format_bytes, format_history_offset};
8
9use super::{HistoricalSample, HistoryMetric, HistoryRing};
10
11pub const HISTORY_STEP_MULTIPLIER: usize = 10;
13
14pub const COMPARISON_LOOKBACK: Duration = Duration::from_secs(30);
16
17#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
25pub enum HistoryPosition {
26 #[default]
28 Live,
29 Selected {
31 absolute: u64,
34 },
35}
36
37#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
42pub enum SeekOutcome {
43 Moved,
45 ClampedAtOldest,
47 ClampedAtNewest,
53 Empty,
55}
56
57impl SeekOutcome {
58 #[must_use]
60 pub const fn was_clamped(self) -> bool {
61 matches!(self, Self::ClampedAtOldest | Self::ClampedAtNewest)
62 }
63}
64
65#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
67pub enum ComparisonBaseline {
68 PreviousSample,
70 Elapsed(Duration),
72}
73
74impl ComparisonBaseline {
75 pub const THIRTY_SECONDS_AGO: Self = Self::Elapsed(COMPARISON_LOOKBACK);
77}
78
79#[derive(Clone, Copy, Debug, PartialEq)]
81pub struct MetricComparison {
82 pub metric: HistoryMetric,
84 pub selected: MeasuredValue,
86 pub baseline: MeasuredValue,
88 pub baseline_age: Duration,
94 pub delta: f64,
98}
99
100impl MetricComparison {
101 #[must_use]
103 pub fn render_delta(&self, units: ByteUnits) -> String {
104 if self.metric.is_percentage() {
105 return format!("{:+.0} points", self.delta);
106 }
107 match self.metric {
108 HistoryMetric::LoadOne => format!("{:+.2}", self.delta),
109 HistoryMetric::SwapUsed => {
110 let magnitude = self.delta.abs();
111 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
114 let bytes = magnitude.min(u64::MAX as f64) as u64;
115 format!("{}{}", sign(self.delta), format_bytes(bytes, units))
116 }
117 _ => match Rate::new(self.delta.abs()) {
118 Some(rate) => format!("{}{}", sign(self.delta), format_byte_rate(rate, units)),
119 None => "n/a".to_owned(),
121 },
122 }
123 }
124}
125
126const fn sign(delta: f64) -> char {
128 if delta < 0.0 { '-' } else { '+' }
129}
130
131#[derive(Clone, Copy, Debug, PartialEq)]
133pub struct MetricComparisons {
134 pub previous_sample: Option<MetricComparison>,
136 pub thirty_seconds_ago: Option<MetricComparison>,
141}
142
143#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
149pub struct HistoryView {
150 position: HistoryPosition,
151}
152
153impl HistoryView {
154 #[must_use]
156 pub const fn live() -> Self {
157 Self {
158 position: HistoryPosition::Live,
159 }
160 }
161
162 #[must_use]
164 pub const fn position(self) -> HistoryPosition {
165 self.position
166 }
167
168 #[must_use]
170 pub const fn is_live(self) -> bool {
171 matches!(self.position, HistoryPosition::Live)
172 }
173
174 #[must_use]
181 pub const fn allows_process_actions(self) -> bool {
182 self.is_live()
183 }
184
185 pub const fn return_live(&mut self) {
187 self.position = HistoryPosition::Live;
188 }
189
190 #[must_use]
196 pub fn selected_absolute(self, ring: &HistoryRing) -> Option<u64> {
197 let newest = ring.newest_absolute()?;
198 Some(match self.position {
199 HistoryPosition::Live => newest,
200 HistoryPosition::Selected { absolute } => {
201 absolute.max(ring.first_absolute()).min(newest)
202 }
203 })
204 }
205
206 #[must_use]
208 pub fn selected(self, ring: &HistoryRing) -> Option<&HistoricalSample> {
209 ring.get_absolute(self.selected_absolute(ring)?)
210 }
211
212 pub fn step_back(&mut self, ring: &HistoryRing, steps: usize) -> SeekOutcome {
215 let Some(current) = self.selected_absolute(ring) else {
216 return SeekOutcome::Empty;
217 };
218 let floor = ring.first_absolute();
219 let available = current.saturating_sub(floor);
220 let wanted = u64::try_from(steps).unwrap_or(u64::MAX);
221 let target = current.saturating_sub(wanted.min(available));
222 self.position = HistoryPosition::Selected { absolute: target };
223 if wanted > available {
224 SeekOutcome::ClampedAtOldest
225 } else {
226 SeekOutcome::Moved
227 }
228 }
229
230 pub fn step_forward(&mut self, ring: &HistoryRing, steps: usize) -> SeekOutcome {
232 let Some(current) = self.selected_absolute(ring) else {
233 return SeekOutcome::Empty;
234 };
235 let Some(ceiling) = ring.newest_absolute() else {
236 return SeekOutcome::Empty;
237 };
238 let requested = current.saturating_add(u64::try_from(steps).unwrap_or(u64::MAX));
239 let target = requested.min(ceiling);
240 self.position = HistoryPosition::Selected { absolute: target };
241 if requested > ceiling {
242 SeekOutcome::ClampedAtNewest
243 } else {
244 SeekOutcome::Moved
245 }
246 }
247
248 pub fn seek_to_offset(&mut self, ring: &HistoryRing, offset: Duration) -> SeekOutcome {
254 let (Some(newest), Some(oldest)) = (ring.newest(), ring.oldest()) else {
255 return SeekOutcome::Empty;
256 };
257 let (index, clamped) = match newest.monotonic_offset.checked_sub(offset) {
261 Some(target) if target >= oldest.monotonic_offset => {
262 match ring.index_at_or_before_offset(target) {
263 Some(index) => (index, false),
264 None => (0, true),
265 }
266 }
267 _ => (0, true),
268 };
269 let absolute = ring
270 .first_absolute()
271 .saturating_add(u64::try_from(index).unwrap_or(u64::MAX));
272 self.position = HistoryPosition::Selected { absolute };
273 if clamped {
274 SeekOutcome::ClampedAtOldest
275 } else {
276 SeekOutcome::Moved
277 }
278 }
279
280 #[must_use]
285 pub fn offset_from_live(self, ring: &HistoryRing) -> Duration {
286 match (self.selected(ring), ring.newest()) {
287 (Some(selected), Some(newest)) => newest
288 .monotonic_offset
289 .saturating_sub(selected.monotonic_offset),
290 _ => Duration::ZERO,
291 }
292 }
293
294 #[must_use]
300 pub fn format_offset(self, ring: &HistoryRing) -> String {
301 format_history_offset(self.offset_from_live(ring))
302 }
303
304 #[must_use]
311 pub fn compare(
312 self,
313 ring: &HistoryRing,
314 metric: HistoryMetric,
315 baseline: ComparisonBaseline,
316 ) -> Option<MetricComparison> {
317 let selected_absolute = self.selected_absolute(ring)?;
318 let selected = ring.get_absolute(selected_absolute)?;
319 let earlier = match baseline {
320 ComparisonBaseline::PreviousSample => {
321 ring.get_absolute(selected_absolute.checked_sub(1)?)?
322 }
323 ComparisonBaseline::Elapsed(lookback) => {
324 let target = selected.monotonic_offset.checked_sub(lookback)?;
327 let sample = ring.get(ring.index_at_or_before_offset(target)?)?;
328 if sample.sequence >= selected.sequence {
329 return None;
330 }
331 sample
332 }
333 };
334
335 let selected_scalar = selected.system.scalar(metric)?;
336 let earlier_scalar = earlier.system.scalar(metric)?;
337 let selected_state = selected.system.measurement(metric);
338 let earlier_state = earlier.system.measurement(metric);
339
340 Some(MetricComparison {
341 metric,
342 selected: *selected_state.fresh()?,
343 baseline: *earlier_state.fresh()?,
344 baseline_age: selected
345 .monotonic_offset
346 .saturating_sub(earlier.monotonic_offset),
347 delta: selected_scalar - earlier_scalar,
348 })
349 }
350
351 #[must_use]
353 pub fn comparisons(self, ring: &HistoryRing, metric: HistoryMetric) -> MetricComparisons {
354 MetricComparisons {
355 previous_sample: self.compare(ring, metric, ComparisonBaseline::PreviousSample),
356 thirty_seconds_ago: self.compare(ring, metric, ComparisonBaseline::THIRTY_SECONDS_AGO),
357 }
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::history::{HistoryConfig, HistoryLimits};
365 use crate::model::{CpuUsage, MetricState, SystemSnapshot, UnavailableReason};
366 use crate::units::Percent;
367 use std::time::{Instant, SystemTime};
368
369 fn ring_with(capacity_seconds: u64, cpu: &[Option<f32>]) -> HistoryRing {
375 let start = Instant::now();
376 let interval = Duration::from_secs(1);
377 let mut ring = HistoryRing::new(
378 HistoryLimits::resolve(HistoryConfig {
379 interval,
380 duration: Duration::from_secs(capacity_seconds),
381 memory_budget_bytes: crate::history::MAX_MEMORY_BUDGET_BYTES,
382 ..HistoryConfig::default()
383 }),
384 start,
385 );
386 for (index, busy) in cpu.iter().enumerate() {
387 let sequence = u64::try_from(index).unwrap_or(0);
388 let captured_at = start + interval.saturating_mul(u32::try_from(index).unwrap_or(0));
389 let mut snapshot = SystemSnapshot::warming_up(
390 captured_at,
391 SystemTime::UNIX_EPOCH + Duration::from_secs(sequence),
392 8,
393 );
394 snapshot.sequence = sequence;
395 snapshot.elapsed = interval;
396 snapshot.cpu.total = match busy {
397 Some(value) => MetricState::Available(CpuUsage::plain(
398 Percent::new(*value).expect("valid percent"),
399 )),
400 None => MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset),
401 };
402 ring.record(&snapshot);
403 }
404 ring
405 }
406
407 fn steady(count: usize) -> Vec<Option<f32>> {
408 (0..count)
409 .map(|index| Some(f32::from(u8::try_from(index % 100).unwrap_or(0))))
410 .collect()
411 }
412
413 #[test]
414 fn a_new_view_is_live_and_shows_the_newest_sample() {
415 let ring = ring_with(60, &steady(10));
416 let view = HistoryView::live();
417
418 assert!(view.is_live());
419 assert_eq!(view.position(), HistoryPosition::Live);
420 assert_eq!(view.selected(&ring).map(|s| s.sequence), Some(9));
421 assert_eq!(view.offset_from_live(&ring), Duration::ZERO);
422 assert_eq!(view.format_offset(&ring), "LIVE");
423 }
424
425 #[test]
426 fn the_default_view_is_live() {
427 assert_eq!(HistoryView::default(), HistoryView::live());
428 }
429
430 #[test]
431 fn process_actions_are_only_allowed_at_live() {
432 let ring = ring_with(60, &steady(10));
433 let mut view = HistoryView::live();
434 assert!(view.allows_process_actions());
435
436 view.step_back(&ring, 1);
437 assert!(
438 !view.allows_process_actions(),
439 "§15.1 disables actions in history"
440 );
441
442 view.return_live();
443 assert!(view.allows_process_actions());
444 }
445
446 #[test]
447 fn stepping_back_reports_the_offset_from_live() {
448 let ring = ring_with(600, &steady(120));
449 let mut view = HistoryView::live();
450
451 assert_eq!(view.step_back(&ring, 37), SeekOutcome::Moved);
452 assert_eq!(view.offset_from_live(&ring), Duration::from_secs(37));
453 assert_eq!(
454 view.format_offset(&ring),
455 "-00:37",
456 "the §2.1 header reads HISTORY -00:37"
457 );
458 assert_eq!(view.selected(&ring).map(|s| s.sequence), Some(119 - 37));
459 }
460
461 #[test]
462 fn the_shift_multiplier_moves_ten_samples() {
463 let ring = ring_with(600, &steady(120));
464 let mut single = HistoryView::live();
465 let mut multiple = HistoryView::live();
466
467 for _ in 0..HISTORY_STEP_MULTIPLIER {
468 single.step_back(&ring, 1);
469 }
470 multiple.step_back(&ring, HISTORY_STEP_MULTIPLIER);
471 assert_eq!(single, multiple);
472 assert_eq!(single.offset_from_live(&ring), Duration::from_secs(10));
473 }
474
475 #[test]
476 fn seeking_clamps_at_the_oldest_sample() {
477 let ring = ring_with(60, &steady(10));
478 let mut view = HistoryView::live();
479
480 assert_eq!(view.step_back(&ring, 100), SeekOutcome::ClampedAtOldest);
481 assert!(view.step_back(&ring, 100).was_clamped());
482 assert_eq!(view.selected(&ring).map(|s| s.sequence), Some(0));
483 assert_eq!(view.offset_from_live(&ring), Duration::from_secs(9));
484 }
485
486 #[test]
487 fn seeking_clamps_at_the_newest_sample_without_returning_to_live() {
488 let ring = ring_with(60, &steady(10));
489 let mut view = HistoryView::live();
490 view.step_back(&ring, 5);
491
492 assert_eq!(view.step_forward(&ring, 100), SeekOutcome::ClampedAtNewest);
493 assert_eq!(view.selected(&ring).map(|s| s.sequence), Some(9));
494 assert!(
495 !view.is_live(),
496 "§2.1 makes returning to live an explicit action, not a side effect"
497 );
498 assert_eq!(view.offset_from_live(&ring), Duration::ZERO);
499 }
500
501 #[test]
502 fn stepping_forward_from_the_middle_moves_the_full_distance() {
503 let ring = ring_with(60, &steady(20));
504 let mut view = HistoryView::live();
505 view.step_back(&ring, 15);
506 assert_eq!(view.step_forward(&ring, 5), SeekOutcome::Moved);
507 assert_eq!(view.offset_from_live(&ring), Duration::from_secs(10));
508 }
509
510 #[test]
511 fn seeking_an_empty_ring_reports_that_there_is_nothing_to_select() {
512 let ring = ring_with(60, &[]);
513 let mut view = HistoryView::live();
514
515 assert_eq!(view.step_back(&ring, 1), SeekOutcome::Empty);
516 assert_eq!(view.step_forward(&ring, 1), SeekOutcome::Empty);
517 assert_eq!(
518 view.seek_to_offset(&ring, Duration::from_secs(1)),
519 SeekOutcome::Empty
520 );
521 assert!(view.is_live(), "an empty ring leaves the view at live");
522 assert!(view.selected(&ring).is_none());
523 assert_eq!(view.format_offset(&ring), "LIVE");
524 }
525
526 #[test]
527 fn seeking_to_an_offset_selects_the_sample_at_or_before_it() {
528 let ring = ring_with(600, &steady(120));
529 let mut view = HistoryView::live();
530
531 assert_eq!(
532 view.seek_to_offset(&ring, Duration::from_secs(37)),
533 SeekOutcome::Moved
534 );
535 assert_eq!(view.offset_from_live(&ring), Duration::from_secs(37));
536
537 assert_eq!(
538 view.seek_to_offset(&ring, Duration::from_millis(37_500)),
539 SeekOutcome::Moved
540 );
541 assert_eq!(
542 view.offset_from_live(&ring),
543 Duration::from_secs(38),
544 "an offset between samples resolves to the older one"
545 );
546 }
547
548 #[test]
549 fn seeking_to_an_offset_beyond_history_clamps_at_the_oldest_sample() {
550 let ring = ring_with(60, &steady(10));
551 let mut view = HistoryView::live();
552 assert_eq!(
553 view.seek_to_offset(&ring, Duration::from_secs(600)),
554 SeekOutcome::ClampedAtOldest
555 );
556 assert_eq!(view.selected(&ring).map(|s| s.sequence), Some(0));
557 }
558
559 #[test]
560 fn seeking_to_zero_selects_the_newest_sample_but_stays_paused() {
561 let ring = ring_with(60, &steady(10));
562 let mut view = HistoryView::live();
563 assert_eq!(
564 view.seek_to_offset(&ring, Duration::ZERO),
565 SeekOutcome::Moved
566 );
567 assert_eq!(view.selected(&ring).map(|s| s.sequence), Some(9));
568 assert!(!view.is_live());
569 }
570
571 #[test]
572 fn a_selection_that_was_evicted_resolves_to_the_oldest_retained_sample() {
573 let mut ring = ring_with(30, &steady(30));
574 let mut view = HistoryView::live();
575 view.step_back(&ring, 29);
576 assert_eq!(view.selected(&ring).map(|s| s.sequence), Some(0));
577
578 let refilled = steady(45);
580 ring = ring_with(30, &refilled);
581 assert_eq!(view.selected(&ring).map(|s| s.sequence), Some(15));
582 assert!(!view.is_live());
583 }
584
585 #[test]
586 fn selection_is_stable_as_new_samples_arrive() {
587 let start = Instant::now();
589 let interval = Duration::from_secs(1);
590 let mut ring = HistoryRing::new(HistoryLimits::default(), start);
591 let push = |ring: &mut HistoryRing, sequence: u64| {
592 let mut snapshot = SystemSnapshot::warming_up(
593 start + interval.saturating_mul(u32::try_from(sequence).unwrap_or(0)),
594 SystemTime::UNIX_EPOCH,
595 8,
596 );
597 snapshot.sequence = sequence;
598 ring.record(&snapshot);
599 };
600 for sequence in 0..10 {
601 push(&mut ring, sequence);
602 }
603
604 let mut view = HistoryView::live();
605 view.step_back(&ring, 3);
606 assert_eq!(view.selected(&ring).map(|s| s.sequence), Some(6));
607 assert_eq!(view.offset_from_live(&ring), Duration::from_secs(3));
608
609 for sequence in 10..15 {
610 push(&mut ring, sequence);
611 }
612 assert_eq!(
613 view.selected(&ring).map(|s| s.sequence),
614 Some(6),
615 "the cursor must stay on the same sample"
616 );
617 assert_eq!(view.offset_from_live(&ring), Duration::from_secs(8));
618 }
619
620 #[test]
621 fn seeking_a_large_ring_lands_on_the_expected_sample() {
622 let ring = ring_with(3_600, &steady(3_000));
625 let mut view = HistoryView::live();
626
627 assert_eq!(view.step_back(&ring, 2_999), SeekOutcome::Moved);
628 assert_eq!(view.selected(&ring).map(|s| s.sequence), Some(0));
629 assert_eq!(view.step_back(&ring, 1), SeekOutcome::ClampedAtOldest);
630 assert_eq!(view.step_forward(&ring, 2_999), SeekOutcome::Moved);
631 assert_eq!(view.selected(&ring).map(|s| s.sequence), Some(2_999));
632 }
633
634 #[test]
635 fn a_comparison_against_the_previous_sample_is_a_signed_delta() {
636 let ring = ring_with(60, &[Some(20.0), Some(74.0)]);
637 let view = HistoryView::live();
638
639 let comparison = view
640 .compare(
641 &ring,
642 HistoryMetric::CpuBusy,
643 ComparisonBaseline::PreviousSample,
644 )
645 .expect("one sample ago exists");
646 assert!((comparison.delta - 54.0).abs() < 0.01, "{comparison:?}");
647 assert_eq!(comparison.baseline_age, Duration::from_secs(1));
648 assert_eq!(comparison.render_delta(ByteUnits::Iec), "+54 points");
649 }
650
651 #[test]
652 fn the_oldest_sample_has_nothing_to_compare_against() {
653 let ring = ring_with(60, &[Some(20.0), Some(74.0)]);
654 let mut view = HistoryView::live();
655 view.step_back(&ring, 1);
656 assert!(
657 view.compare(
658 &ring,
659 HistoryMetric::CpuBusy,
660 ComparisonBaseline::PreviousSample
661 )
662 .is_none()
663 );
664 }
665
666 #[test]
667 fn the_thirty_second_comparison_is_none_until_history_reaches_back() {
668 let short = ring_with(60, &steady(10));
669 let view = HistoryView::live();
670 assert!(
671 view.compare(
672 &short,
673 HistoryMetric::CpuBusy,
674 ComparisonBaseline::THIRTY_SECONDS_AGO
675 )
676 .is_none(),
677 "§2.5 permits the comparison only when history permits; zero would be a lie"
678 );
679
680 let long = ring_with(300, &steady(60));
681 let comparison = view
682 .compare(
683 &long,
684 HistoryMetric::CpuBusy,
685 ComparisonBaseline::THIRTY_SECONDS_AGO,
686 )
687 .expect("history reaches back 30s");
688 assert_eq!(comparison.baseline_age, Duration::from_secs(30));
689 }
690
691 #[test]
692 fn the_thirty_second_comparison_is_none_when_the_ring_evicted_that_far_back() {
693 let ring = ring_with(10, &steady(200));
696 let view = HistoryView::live();
697 assert!(
698 view.compare(
699 &ring,
700 HistoryMetric::CpuBusy,
701 ComparisonBaseline::THIRTY_SECONDS_AGO
702 )
703 .is_none()
704 );
705 }
706
707 #[test]
708 fn both_comparisons_resolve_together() {
709 let ring = ring_with(300, &steady(90));
710 let view = HistoryView::live();
711 let comparisons = view.comparisons(&ring, HistoryMetric::CpuBusy);
712 assert!(comparisons.previous_sample.is_some());
713 assert!(comparisons.thirty_seconds_ago.is_some());
714 }
715
716 #[test]
717 fn an_unavailable_input_yields_no_comparison_rather_than_a_spike() {
718 let ring = ring_with(60, &[Some(20.0), Some(21.0), None]);
721 let view = HistoryView::live();
722
723 assert!(
724 view.compare(
725 &ring,
726 HistoryMetric::CpuBusy,
727 ComparisonBaseline::PreviousSample
728 )
729 .is_none(),
730 "an unavailable selected value must not become a delta"
731 );
732
733 let ring = ring_with(60, &[None, Some(90.0)]);
735 assert!(
736 view.compare(
737 &ring,
738 HistoryMetric::CpuBusy,
739 ComparisonBaseline::PreviousSample
740 )
741 .is_none()
742 );
743 }
744
745 #[test]
746 fn comparing_an_empty_ring_returns_nothing() {
747 let ring = ring_with(60, &[]);
748 let view = HistoryView::live();
749 for baseline in [
750 ComparisonBaseline::PreviousSample,
751 ComparisonBaseline::THIRTY_SECONDS_AGO,
752 ] {
753 assert!(
754 view.compare(&ring, HistoryMetric::CpuBusy, baseline)
755 .is_none()
756 );
757 }
758 }
759
760 #[test]
761 fn a_comparison_of_an_unsupported_metric_is_absent_not_zero() {
762 let ring = ring_with(60, &steady(60));
763 let view = HistoryView::live();
764 assert!(
766 view.compare(
767 &ring,
768 HistoryMetric::DiskRead,
769 ComparisonBaseline::PreviousSample
770 )
771 .is_none()
772 );
773 }
774
775 #[test]
776 fn deltas_render_in_the_metrics_own_unit() {
777 let comparison = MetricComparison {
778 metric: HistoryMetric::SwapUsed,
779 selected: MeasuredValue::Bytes(0),
780 baseline: MeasuredValue::Bytes(4 * 1024 * 1024),
781 baseline_age: Duration::from_secs(1),
782 delta: -4.0 * 1024.0 * 1024.0,
783 };
784 assert_eq!(comparison.render_delta(ByteUnits::Iec), "-4.0 MiB");
785
786 let comparison = MetricComparison {
787 metric: HistoryMetric::LoadOne,
788 selected: MeasuredValue::Load(4.5),
789 baseline: MeasuredValue::Load(1.5),
790 baseline_age: Duration::from_secs(1),
791 delta: 3.0,
792 };
793 assert_eq!(comparison.render_delta(ByteUnits::Iec), "+3.00");
794
795 let comparison = MetricComparison {
796 metric: HistoryMetric::NetworkRx,
797 selected: MeasuredValue::ByteRate(Rate::new(0.0).expect("valid")),
798 baseline: MeasuredValue::ByteRate(Rate::new(1_048_576.0).expect("valid")),
799 baseline_age: Duration::from_secs(1),
800 delta: -1_048_576.0,
801 };
802 assert_eq!(comparison.render_delta(ByteUnits::Iec), "-1.0M/s");
803 }
804
805 #[test]
806 fn a_non_finite_delta_renders_as_unavailable_rather_than_panicking() {
807 let comparison = MetricComparison {
808 metric: HistoryMetric::DiskRead,
809 selected: MeasuredValue::ByteRate(Rate::ZERO),
810 baseline: MeasuredValue::ByteRate(Rate::ZERO),
811 baseline_age: Duration::from_secs(1),
812 delta: f64::NAN,
813 };
814 assert_eq!(comparison.render_delta(ByteUnits::Iec), "n/a");
815 }
816}