Skip to main content

monitrs_core/history/
view.rs

1//! Selection and comparison over a [`HistoryRing`]: the Time Lens cursor (§2.1)
2//! and the comparison values of §2.5.
3
4use 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
11/// How many samples `Shift+[` and `Shift+]` move (§5.6's `Shift+[/] x10`).
12pub const HISTORY_STEP_MULTIPLIER: usize = 10;
13
14/// The look-back §2.5 asks for: "30 seconds ago when history permits".
15pub const COMPARISON_LOOKBACK: Duration = Duration::from_secs(30);
16
17/// Where the Time Lens cursor is.
18///
19/// The selected sample is named by its *absolute* index rather than by its
20/// distance from the newest sample, so a paused view keeps showing the same
21/// sample as new ones arrive instead of drifting backwards under the cursor
22/// (§2.1). Resolving an absolute index is index arithmetic, which is what makes
23/// seeking constant time (§21 M4).
24#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
25pub enum HistoryPosition {
26    /// Following the newest sample.
27    #[default]
28    Live,
29    /// Pinned to one recorded sample.
30    Selected {
31        /// The sample's absolute index, as counted by
32        /// [`HistoryRing::total_recorded`].
33        absolute: u64,
34    },
35}
36
37/// What a seek did.
38///
39/// Clamping is reported rather than silently absorbed so the UI can signal that
40/// the end of history was reached instead of appearing to ignore the key.
41#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
42pub enum SeekOutcome {
43    /// The cursor moved the full requested distance.
44    Moved,
45    /// The cursor stopped at the oldest retained sample.
46    ClampedAtOldest,
47    /// The cursor stopped at the newest retained sample.
48    ///
49    /// The view stays in history rather than snapping back to live: §2.1 makes
50    /// returning to live one explicit action, so stepping forward must not do it
51    /// as a side effect.
52    ClampedAtNewest,
53    /// Nothing is recorded yet, so there is nothing to select.
54    Empty,
55}
56
57impl SeekOutcome {
58    /// Whether the requested distance was reduced.
59    #[must_use]
60    pub const fn was_clamped(self) -> bool {
61        matches!(self, Self::ClampedAtOldest | Self::ClampedAtNewest)
62    }
63}
64
65/// Which earlier sample a comparison is made against (§2.5).
66#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
67pub enum ComparisonBaseline {
68    /// The immediately preceding retained sample.
69    PreviousSample,
70    /// The newest sample at least this much older than the selected one.
71    Elapsed(Duration),
72}
73
74impl ComparisonBaseline {
75    /// The 30-second look-back §2.5 names.
76    pub const THIRTY_SECONDS_AGO: Self = Self::Elapsed(COMPARISON_LOOKBACK);
77}
78
79/// One resolved comparison between the selected sample and an earlier one.
80#[derive(Clone, Copy, Debug, PartialEq)]
81pub struct MetricComparison {
82    /// Which metric was compared.
83    pub metric: HistoryMetric,
84    /// The selected sample's measurement.
85    pub selected: MeasuredValue,
86    /// The baseline sample's measurement.
87    pub baseline: MeasuredValue,
88    /// How much older the baseline sample is than the selected one.
89    ///
90    /// The *actual* distance, not the requested one: §8.1 forbids assuming a
91    /// fixed interval, so "30 seconds ago" resolves to a real sample that may be
92    /// 31 seconds back.
93    pub baseline_age: Duration,
94    /// `selected - baseline` in the metric's natural unit.
95    ///
96    /// Percentage metrics yield percentage *points* (§5.6's `+54 points vs now`).
97    pub delta: f64,
98}
99
100impl MetricComparison {
101    /// Renders the delta with an explicit sign in the metric's unit.
102    #[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                // A byte count is integral (§10.4); the delta is only floating
112                // point because it is signed, so floor it back for display.
113                #[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                // Unreachable for a difference of two validated finite rates.
120                None => "n/a".to_owned(),
121            },
122        }
123    }
124}
125
126/// The sign prefix for a rendered delta.
127const fn sign(delta: f64) -> char {
128    if delta < 0.0 { '-' } else { '+' }
129}
130
131/// Both comparisons §2.5 asks for, resolved together.
132#[derive(Clone, Copy, Debug, PartialEq)]
133pub struct MetricComparisons {
134    /// Against the immediately preceding sample.
135    pub previous_sample: Option<MetricComparison>,
136    /// Against roughly 30 seconds earlier.
137    ///
138    /// `None` when history does not reach back that far, or when either sample's
139    /// input was unavailable. §26 forbids reporting that as a zero change.
140    pub thirty_seconds_ago: Option<MetricComparison>,
141}
142
143/// The Time Lens cursor over a ring (§2.1).
144///
145/// Holds no reference to the ring, so it can live in application state next to
146/// one and be copied freely. Every method takes the ring it should be resolved
147/// against.
148#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
149pub struct HistoryView {
150    position: HistoryPosition,
151}
152
153impl HistoryView {
154    /// A view following the newest sample.
155    #[must_use]
156    pub const fn live() -> Self {
157        Self {
158            position: HistoryPosition::Live,
159        }
160    }
161
162    /// Where the cursor is.
163    #[must_use]
164    pub const fn position(self) -> HistoryPosition {
165        self.position
166    }
167
168    /// Whether the view is following live data.
169    #[must_use]
170    pub const fn is_live(self) -> bool {
171        matches!(self.position, HistoryPosition::Live)
172    }
173
174    /// Whether process-control actions may be offered.
175    ///
176    /// §2.1 and §15.1 both require process actions to be disabled while
177    /// inspecting history: the process shown may not exist any more, and its PID
178    /// may since have been reused. Encoding the rule here keeps every call site
179    /// from having to remember it.
180    #[must_use]
181    pub const fn allows_process_actions(self) -> bool {
182        self.is_live()
183    }
184
185    /// Returns to live, the single explicit action §2.1 specifies for `L`.
186    pub const fn return_live(&mut self) {
187        self.position = HistoryPosition::Live;
188    }
189
190    /// The absolute index the cursor resolves to, clamped into what is retained.
191    ///
192    /// Constant time. A selection whose sample has since been evicted resolves to
193    /// the oldest retained sample rather than to nothing, so the panel does not
194    /// blank out while the user is reading it.
195    #[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    /// The selected sample, or the newest one when live.
207    #[must_use]
208    pub fn selected(self, ring: &HistoryRing) -> Option<&HistoricalSample> {
209        ring.get_absolute(self.selected_absolute(ring)?)
210    }
211
212    /// Moves `steps` samples towards the past (`[`, or `Shift+[` with
213    /// [`HISTORY_STEP_MULTIPLIER`]).
214    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    /// Moves `steps` samples towards the present (`]`, or `Shift+]`).
231    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    /// Selects the newest sample at least `offset` behind the newest one.
249    ///
250    /// Effectively constant time: sample offsets increase monotonically, so this
251    /// is a binary search rather than a scan (§21 M4). An `offset` reaching past
252    /// the oldest retained sample clamps there and says so.
253    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        // An offset reaching past the oldest retained sample is a clamp even
258        // though a sample is still selected, so the UI can say "that is as far
259        // back as history goes" instead of appearing to ignore the request.
260        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    /// How far behind live the selected sample is.
281    ///
282    /// Computed from monotonic offsets, so a wall-clock change cannot make the
283    /// header count backwards (§8.1). Zero when live.
284    #[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    /// The header offset text: `LIVE`, or `-00:37` (§2.1, §5.6).
295    ///
296    /// Reports the *offset* only. §2.1's third header state, `PAUSED`, is a UI
297    /// state — a paused view that has not been scrubbed sits at offset zero — and
298    /// is distinguished with [`Self::is_live`].
299    #[must_use]
300    pub fn format_offset(self, ring: &HistoryRing) -> String {
301        format_history_offset(self.offset_from_live(ring))
302    }
303
304    /// Compares the selected sample's `metric` against an earlier sample (§2.5).
305    ///
306    /// Returns `None`, never a zero delta, when the baseline does not exist or
307    /// when either sample's input was unavailable. That is what stops a counter
308    /// reset from being rendered as a spike (§21 M4) and what honours §26's
309    /// "unavailable is not zero".
310    #[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                // `checked_sub` is the "when history permits" test: a selected
325                // sample younger than the look-back has nothing to compare to.
326                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    /// Both comparisons §2.5 requires, in one call.
352    #[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    /// A ring of one-second samples whose CPU busy percentage is `cpu[i]`.
370    ///
371    /// The memory budget is set to its maximum so that capacity is exactly
372    /// `capacity_seconds`: these tests are about seeking, and the budget clamp has
373    /// its own tests in [`super::super::ring`].
374    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        // Push the selected sample out of the ring.
579        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        // §2.1: a paused view must keep showing the sample the user selected.
588        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        // Correctness at both ends of a ring far larger than any UI will show;
623        // resolution is index arithmetic, so cost does not grow with length.
624        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        // A ring only 10s deep can never satisfy a 30s look-back, even once it
694        // has been running for minutes.
695        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        // §21 M4: counter resets do not create false spikes. The newest sample's
719        // CPU reading is a typed reset, so no delta can be computed from it.
720        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        // And the reverse: an unavailable *baseline* is equally unusable.
734        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        // No disks were recorded, so disk throughput is unsupported throughout.
765        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}