Skip to main content

pristine/tui/
moving.rs

1//! What is moving on the screen right now, and why each thing is allowed to.
2//!
3//! # Motion is information wearing a costume
4//!
5//! Nothing here spins to prove the program is running. Every moving thing below is a fact the
6//! view already holds, drawn *over time* rather than all at once, so the rate of a change is
7//! legible as well as its result:
8//!
9//! - a rolled-up total that climbs shows the rate claims are arriving at, which is the one
10//!   thing a count of directories cannot say;
11//! - a row that is lit is a directory the walk found since the last frame;
12//! - a shimmer through a dash is a claim a pricing thread is inside **at this instant** — the
13//!   pool has N threads, so exactly N rows shimmer, and that is honest rather than decorative;
14//! - a mark running up the ancestors is the subtree operation that just happened, shown
15//!   instead of inferred;
16//! - a row emptying is bytes leaving the disk.
17//!
18//! The rule that follows from it, and the one worth keeping: **if an effect cannot be derived
19//! from something true, it does not move.** There is no spinner in this file and no place to
20//! put one.
21//!
22//! # Whimsy before the point of no return, gravity after it
23//!
24//! Everything above belongs to finding and waiting. Past the confirmation the only thing that
25//! moves is the pair of counters in [`Chase`] — reclaimable going down, freed coming up — and
26//! that is the whole payoff. A deletion is a thing that might have been a mistake, so it gets
27//! no celebration.
28//!
29//! # It is bounded by the viewport, not by the tree
30//!
31//! [`Moving::advance`] is handed the rows that are actually drawn, and it forgets every entry
32//! it was not handed this frame. So the per-frame cost is the height of the pane whatever the
33//! tree is doing — one real home directory is 22,765 directories and 16,013 claims, and none
34//! of that is touched here. A row scrolled away and back has no state to inherit, and starts
35//! showing the truth immediately, which is right: nobody watched it change.
36
37use std::collections::{HashMap, HashSet};
38use std::time::{Duration, Instant};
39
40use crate::tree::NodeId;
41
42/// Roughly how long a number takes to reach the one behind it.
43///
44/// Long enough to read as movement, short enough that a reader who looks at a row and then
45/// acts on it is acting on the true figure. See [`Chase::advance`] for what "roughly" means.
46pub const COUNT_UP: Duration = Duration::from_millis(200);
47
48/// How long a newly arrived row stays lit.
49///
50/// About a second, because the eye has to be *drawn* to it rather than merely able to find it,
51/// and because the alternative — a scrolling log of what was found — is the thing a tree
52/// exists to avoid.
53pub const ARRIVAL: Duration = Duration::from_millis(900);
54
55/// How long one rung of a mark cascade stays lit.
56pub const FLASH: Duration = Duration::from_millis(160);
57
58/// How much later each rung above the marked row lights up.
59///
60/// The stagger is the whole message: the mark is seen *travelling* outwards, which is what
61/// makes "this took everything underneath" a thing the screen said rather than a thing the
62/// reader worked out.
63pub const RUNG: Duration = Duration::from_millis(45);
64
65/// How long an emptied row stays on screen, dimmed, before it collapses away.
66///
67/// The *emptying* before this has no duration of its own — it takes exactly as long as the
68/// deleter takes, because it is driven by the bytes the deleter reports leaving the disk. This
69/// is only the beat after the number reaches zero, so the row is seen to have emptied rather
70/// than vanishing on the same frame as its last byte.
71pub const DIM: Duration = Duration::from_millis(200);
72
73/// How long the pricing shimmer takes to cross its column once.
74pub const SHIMMER: Duration = Duration::from_millis(700);
75
76/// One number on its way to another.
77///
78/// Exponential rather than linear, for a reason that is about streaming rather than about
79/// taste: the target moves. A claim lands, then another, then a price — a linear tween would
80/// have to be restarted on each one and would visibly stutter, where an approach simply has a
81/// new gap to close and keeps its speed continuous.
82#[derive(Clone, Copy, Debug)]
83pub struct Chase {
84    shown: f64,
85    /// The frame this was last advanced on. Doubles as the mark that keeps it alive: see
86    /// [`Moving::advance`].
87    at: Instant,
88    settled: bool,
89}
90
91impl Chase {
92    /// A number that is already where it belongs.
93    #[must_use]
94    pub fn new(value: u64, now: Instant) -> Self {
95        Self {
96            #[expect(
97                clippy::cast_precision_loss,
98                reason = "a byte count large enough to lose precision here is 4 petabytes, and \
99                          the value is on its way to a display rounded to one decimal place"
100            )]
101            shown: value as f64,
102            at: now,
103            settled: true,
104        }
105    }
106
107    /// Moves toward `target` by however much time has passed, and says where it got to.
108    ///
109    /// The time constant is a third of [`COUNT_UP`], so about 95% of the gap is closed in that
110    /// long — which is what "roughly 200ms" means for a curve that never formally arrives.
111    /// Formally never arriving is also why the snap below is not optional.
112    pub fn advance(&mut self, target: u64, now: Instant) -> u64 {
113        #[expect(
114            clippy::cast_precision_loss,
115            reason = "as in `new`: the display this is bound for has one decimal place"
116        )]
117        let target = target as f64;
118        let elapsed = now.saturating_duration_since(self.at).as_secs_f64();
119        self.at = now;
120        let closed = 1.0 - (-elapsed * 3.0 / COUNT_UP.as_secs_f64()).exp();
121        self.shown += (target - self.shown) * closed;
122        // Snapped once the remaining gap is below what the column can print — a tenth of a
123        // percent is a whole digit of `1023.9 GiB`, so half of that is invisible by
124        // construction. Without it the value approaches forever and the view never reports
125        // itself still, which is what the frame rate is chosen from.
126        if (target - self.shown).abs() <= (target.abs() * 0.0005).max(1.0) {
127            self.shown = target;
128            self.settled = true;
129        } else {
130            self.settled = false;
131        }
132        self.value()
133    }
134
135    /// Puts the number somewhere without easing toward it.
136    ///
137    /// For a value that is already being interpolated by something else — a row emptying on a
138    /// ramp — so that the chase takes over seamlessly when that ends rather than resuming from
139    /// wherever it was left standing.
140    pub fn jam(&mut self, value: u64, now: Instant) {
141        *self = Self::new(value, now);
142    }
143
144    /// What to draw.
145    #[must_use]
146    pub fn value(&self) -> u64 {
147        #[expect(
148            clippy::cast_possible_truncation,
149            clippy::cast_sign_loss,
150            reason = "the chase only ever runs between two byte counts, so it is bounded by \
151                      them; a negative is arithmetically unreachable and saturates to zero \
152                      rather than wrapping"
153        )]
154        let value = self.shown.max(0.0).round() as u64;
155        value
156    }
157
158    /// Whether it has arrived, which is what "nothing is moving" is made of.
159    #[must_use]
160    pub fn settled(&self) -> bool {
161        self.settled
162    }
163}
164
165/// Everything the view is in the middle of showing.
166#[derive(Debug)]
167pub struct Moving {
168    /// One chase per row that was drawn last frame. Bounded by the pane.
169    rows: HashMap<NodeId, Chase>,
170    /// When each row appeared in the tree, while that is still recent.
171    arrived: HashMap<NodeId, Instant>,
172    /// When each rung of the last mark cascade lights up. Ancestors only, so it is bounded by
173    /// the depth of the tree — ten, on a real home directory.
174    cascade: HashMap<NodeId, Instant>,
175    /// Targets the deleter is part way through, and the bytes it says have gone from each so
176    /// far. Cumulative, straight off [`crate::delete::Freeing`] — nothing here interpolates
177    /// toward a guess, because the guess is not needed once the real figure is arriving.
178    freeing: HashMap<NodeId, u64>,
179    /// Targets the deleter has finished with, spending their last moment dimmed. The view
180    /// reads the deadline off this and takes the row out of the tree when it passes.
181    spent: HashMap<NodeId, Instant>,
182    /// Bytes from targets of the running batch whose rows have already collapsed away.
183    ///
184    /// They cannot stay in `freeing`, because that map is what "a row is still emptying" is
185    /// read from and a row that has gone is not. They cannot be dropped either: the batch has
186    /// not reported its own total yet, and a counter that fell back by what it had already
187    /// given back would be the one number a reader came back for, going the wrong way.
188    settled: u64,
189    /// Claims a pricing thread is inside at this instant. Exactly as many as the pool has
190    /// threads, which is the fact the shimmer is drawing.
191    hot: HashSet<NodeId>,
192    /// What the session has given back.
193    ///
194    /// Not a [`Chase`], deliberately. It moves on the deleter's own progress reports, which is
195    /// the same source and the same instant as the fall on every row above the target — so
196    /// easing it would put the two counters that are meant to move against each other a
197    /// fifth of a second out of step, for no gain over a figure that is already true.
198    freed: u64,
199    now: Instant,
200}
201
202impl Moving {
203    /// Nothing moving, as of `now`.
204    #[must_use]
205    pub fn new(now: Instant) -> Self {
206        Self {
207            rows: HashMap::new(),
208            arrived: HashMap::new(),
209            cascade: HashMap::new(),
210            freeing: HashMap::new(),
211            spent: HashMap::new(),
212            settled: 0,
213            hot: HashSet::new(),
214            freed: 0,
215            now,
216        }
217    }
218
219    /// Moves the clock on, before anything asks a question whose answer depends on it.
220    ///
221    /// Split out from [`Moving::advance`] because the caller has work to do *between* the two
222    /// — working out what each row is worth given what is draining away under it, which is a
223    /// question about this frame's instant and not the last one's.
224    pub fn tick(&mut self, now: Instant) {
225        self.now = now;
226    }
227
228    /// Advances every drawn row toward what it is really worth, and forgets the rest.
229    ///
230    /// `rows` is the viewport's worth of `(row, what it is worth now, is that exact)`, so a row
231    /// that scrolled off loses its state and a row that scrolls back on starts at the truth.
232    /// That is the whole of the cost story: this is O(rows on screen), never O(tree).
233    ///
234    /// **Exact** means the caller is already interpolating that value itself and the chase must
235    /// not add a second, slower opinion on top — which is what a row emptying is. Jammed rather
236    /// than skipped, so that when the drain ends the chase carries on from where the ramp left
237    /// off instead of from wherever it was standing when the drain began.
238    pub fn advance(&mut self, now: Instant, rows: &[(NodeId, u64, bool)], freed: u64) {
239        self.now = now;
240        for &(id, target, exact) in rows {
241            let chase = self
242                .rows
243                .entry(id)
244                .or_insert_with(|| Chase::new(target, now));
245            if exact {
246                chase.jam(target, now);
247            } else {
248                chase.advance(target, now);
249            }
250        }
251        // A chase stamped with any earlier frame belongs to a row nobody is drawing.
252        self.rows.retain(|_, chase| chase.at == now);
253        self.freed = freed;
254        self.arrived
255            .retain(|_, at| now.saturating_duration_since(*at) < ARRIVAL);
256        // The stamp is when a rung *lights*, which for the outer ones is still in the future —
257        // and `saturating_duration_since` reads a future instant as no time at all, so a rung
258        // waiting its turn is kept by the same condition that keeps a lit one.
259        self.cascade
260            .retain(|_, at| now.saturating_duration_since(*at) < FLASH);
261    }
262
263    /// What a row draws, which is the truth once it has caught up with it.
264    #[must_use]
265    pub fn shown(&self, id: NodeId, truth: u64) -> u64 {
266        self.rows.get(&id).map_or(truth, Chase::value)
267    }
268
269    /// What the session has given back so far.
270    #[must_use]
271    pub fn freed(&self) -> u64 {
272        self.freed
273    }
274
275    /// Notes a directory that has just appeared in the tree.
276    pub fn arrived(&mut self, id: NodeId, now: Instant) {
277        self.arrived.insert(id, now);
278    }
279
280    /// How lit a newly arrived row is: 1.0 the moment it lands, 0.0 once it is old news.
281    #[must_use]
282    pub fn freshness(&self, id: NodeId) -> f64 {
283        let Some(at) = self.arrived.get(&id) else {
284            return 0.0;
285        };
286        let elapsed = self.now.saturating_duration_since(*at).as_secs_f64();
287        (1.0 - elapsed / ARRIVAL.as_secs_f64()).clamp(0.0, 1.0)
288    }
289
290    /// Lights a mark running outwards through `ancestors`, nearest first.
291    pub fn cascade(&mut self, ancestors: &[NodeId], now: Instant) {
292        for (rung, &id) in ancestors.iter().enumerate() {
293            self.cascade
294                .insert(id, now + RUNG * u32::try_from(rung).unwrap_or(u32::MAX));
295        }
296    }
297
298    /// Whether this row's rung of the cascade is lit at this instant.
299    ///
300    /// A rung that has not come round yet is not lit either, which is what makes the mark
301    /// travel rather than all of it flashing at once.
302    #[must_use]
303    pub fn is_cascading(&self, id: NodeId) -> bool {
304        self.cascade
305            .get(&id)
306            .is_some_and(|at| *at <= self.now && self.now.saturating_duration_since(*at) < FLASH)
307    }
308
309    /// Notes that a pricing thread has gone into this claim.
310    pub fn heats(&mut self, id: NodeId) {
311        self.hot.insert(id);
312    }
313
314    /// Notes that it has come back out, with a price or without one.
315    pub fn cools(&mut self, id: NodeId) {
316        self.hot.remove(&id);
317    }
318
319    /// Forgets every claim that was being priced, for the end of the walk: a pool that has
320    /// stopped leaves nothing hot behind, and a row shimmering for a thread that no longer
321    /// exists would be the one moving thing here that says nothing.
322    pub fn cooled(&mut self) {
323        self.hot.clear();
324    }
325
326    /// Whether a pricing thread is inside this claim right now.
327    #[must_use]
328    pub fn is_hot(&self, id: NodeId) -> bool {
329        self.hot.contains(&id)
330    }
331
332    /// Every claim currently being priced, so the view can drop the ones that have since gone.
333    pub fn hot(&self) -> impl Iterator<Item = NodeId> + '_ {
334        self.hot.iter().copied()
335    }
336
337    /// Which cell of a `width`-wide shimmer is lit.
338    ///
339    /// One phase for the whole screen rather than one per row: the reader is being told how
340    /// many rows are hot, and rows that pulse together are countable at a glance where rows
341    /// each doing their own thing are not.
342    #[must_use]
343    pub fn shimmer(&self, width: usize, epoch: Instant) -> usize {
344        if width == 0 {
345            return 0;
346        }
347        let step = SHIMMER.as_millis().max(1) / width as u128;
348        let elapsed = self.now.saturating_duration_since(epoch).as_millis();
349        usize::try_from(elapsed / step.max(1) % width as u128).unwrap_or(0)
350    }
351
352    /// Records how much of a target the deleter says has gone so far.
353    ///
354    /// Taken as the total rather than added to, because that is what the event carries: a
355    /// report that arrives out of order behind a later one is discarded rather than winding
356    /// the row backwards, which the pool makes possible and nothing else would catch.
357    pub fn frees(&mut self, id: NodeId, bytes: u64) {
358        let freed = self.freeing.entry(id).or_insert(0);
359        *freed = (*freed).max(bytes);
360    }
361
362    /// Records that the deleter has finished with a target, and starts its dimmed beat.
363    pub fn spends(&mut self, id: NodeId, bytes: u64, now: Instant) {
364        self.frees(id, bytes);
365        self.spent.entry(id).or_insert(now);
366    }
367
368    /// Whether bytes are leaving this target right now.
369    #[must_use]
370    pub fn is_freeing(&self, id: NodeId) -> bool {
371        self.freeing.contains_key(&id) && !self.spent.contains_key(&id)
372    }
373
374    /// Whether this row has emptied and is spending its last moment on screen.
375    #[must_use]
376    pub fn is_spent(&self, id: NodeId) -> bool {
377        self.spent.contains_key(&id)
378    }
379
380    /// Whether the deleter has touched this row at all — either phase.
381    ///
382    /// The one predicate the batch, the marks and `space` all read, so "a directory the
383    /// deleter is part way through is not a directory to delete again" is stated once rather
384    /// than in the three places that could drift.
385    #[must_use]
386    pub fn is_leaving(&self, id: NodeId) -> bool {
387        self.is_freeing(id) || self.is_spent(id)
388    }
389
390    /// Every row the running removal is still on screen for, with the bytes it has given
391    /// back so far.
392    ///
393    /// What every ancestor subtracts, and half of what the freed counter adds up. Spent
394    /// targets are in here too until their row collapses, because their bytes are just as
395    /// gone and the rows above them have to say so.
396    pub fn leaving(&self) -> impl Iterator<Item = (NodeId, u64)> + '_ {
397        self.freeing.iter().map(|(&id, &bytes)| (id, bytes))
398    }
399
400    /// What the deleter has given back from this row so far.
401    #[must_use]
402    pub fn freed_from(&self, id: NodeId) -> u64 {
403        self.freeing.get(&id).copied().unwrap_or(0)
404    }
405
406    /// What the running batch has given back in total, rows still on screen and rows already
407    /// collapsed alike.
408    #[must_use]
409    pub fn freed_so_far(&self) -> u64 {
410        self.settled + self.freeing.values().sum::<u64>()
411    }
412
413    /// The rows whose dimmed beat is over, which the view then takes out of the tree for real.
414    /// Forgotten here in the same breath, so each is handed over exactly once.
415    pub fn collapsed(&mut self, now: Instant) -> Vec<NodeId> {
416        let due: Vec<NodeId> = self
417            .spent
418            .iter()
419            .filter(|(_, at)| now.saturating_duration_since(**at) >= DIM)
420            .map(|(&id, _)| id)
421            .collect();
422        for id in &due {
423            self.spent.remove(id);
424            // Out of the per-row map and into the batch's running total. Left where it was it
425            // would keep the view reporting itself in motion for a row nobody can see.
426            self.settled += self.freeing.remove(id).unwrap_or(0);
427        }
428        due
429    }
430
431    /// Hands the running total over to the caller's own, when the batch has reported one.
432    ///
433    /// The per-target figures and the [`crate::delete::Removal`] are the same arithmetic from
434    /// the same accounting, so keeping both would count every byte twice. The dimmed rows stay
435    /// where they are: what they are worth on screen is zero either way, and it is the *tree*
436    /// that still has to lose them.
437    ///
438    /// **Everything dropped here is transient, which is a constraint on the caller as much as
439    /// a description.** A target the sweep finished with is on its way out of the tree anyway;
440    /// a target it could not finish is *staying*, and the only record that it is smaller than
441    /// it was is the figure about to be cleared. So an incomplete target's reduction has to be
442    /// made durable before this runs, or its row and every total above it spring back to what
443    /// they were worth before the deletion — see [`super::state::View::deleted`], which is the
444    /// one caller, and [`crate::tree::Tree::shrink`], which is where the bytes go.
445    pub fn banked(&mut self) {
446        self.freeing.clear();
447        self.settled = 0;
448    }
449
450    /// Whether anything at all is still in motion.
451    ///
452    /// What the event loop reads to decide how often to repaint: a view with something moving
453    /// in it earns a smooth frame rate, and a view a reader is sitting and thinking in front
454    /// of does not.
455    #[must_use]
456    pub fn is_moving(&self) -> bool {
457        !self.hot.is_empty()
458            || !self.freeing.is_empty()
459            || !self.spent.is_empty()
460            || !self.cascade.is_empty()
461            || !self.arrived.is_empty()
462            || self.rows.values().any(|chase| !chase.settled())
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::{ARRIVAL, COUNT_UP, Chase, DIM, FLASH, Moving, RUNG};
469    use std::time::{Duration, Instant};
470
471    #[test]
472    fn a_chase_climbs_toward_its_target_and_arrives_at_it_exactly() {
473        let start = Instant::now();
474        let mut chase = Chase::new(0, start);
475
476        // Part way there is genuinely part way: the point of the effect is that the reader
477        // sees the number move rather than appear.
478        let half = chase.advance(1_000_000, start + COUNT_UP / 2);
479        assert!(half > 0 && half < 1_000_000, "{half}");
480        assert!(!chase.settled());
481
482        // …and it lands on the true number rather than approaching it forever, which is what
483        // lets the view report itself still.
484        let landed = chase.advance(1_000_000, start + COUNT_UP * 4);
485        assert_eq!(landed, 1_000_000);
486        assert!(chase.settled());
487    }
488
489    #[test]
490    fn a_chase_runs_downwards_as_readily_as_up() {
491        let start = Instant::now();
492        let mut chase = Chase::new(1_000_000, start);
493        // Which is what a deletion is: the same mechanism, and no second one to keep in step
494        // with this one.
495        let draining = chase.advance(0, start + COUNT_UP / 2);
496        assert!(draining > 0 && draining < 1_000_000, "{draining}");
497        // Zero is the one target an approach takes a while over, because the snap is the
498        // absolute byte the number is finally within rather than a share of a target that is
499        // itself nothing. It is also the reason a *row* emptying is a ramp and not one of
500        // these — see [`Moving::draining_share`].
501        assert_eq!(chase.advance(0, start + COUNT_UP * 8), 0);
502    }
503
504    #[test]
505    fn a_target_that_moves_mid_flight_is_chased_rather_than_restarted() {
506        let start = Instant::now();
507        let mut chase = Chase::new(0, start);
508        let first = chase.advance(100, start + COUNT_UP / 4);
509        // A claim lands while the previous one is still being counted up to. Nothing resets:
510        // the gap is simply bigger now, which is what makes a stream of arrivals read as one
511        // continuous climb rather than as a stutter per claim.
512        let second = chase.advance(200, start + COUNT_UP / 2);
513        assert!(second > first, "{first} -> {second}");
514        assert!(second < 200);
515    }
516
517    #[test]
518    fn a_row_that_scrolled_off_the_screen_is_forgotten_rather_than_animated() {
519        let start = Instant::now();
520        let mut moving = Moving::new(start);
521        moving.advance(start, &[(1, 100, false), (2, 200, false)], 0);
522        moving.advance(start + COUNT_UP, &[(1, 100, false)], 0);
523
524        // The cost story: one entry per row the pane drew, whatever the tree is doing.
525        assert_eq!(
526            moving.shown(2, 999),
527            999,
528            "a row nobody drew kept its state"
529        );
530        assert_eq!(moving.shown(1, 100), 100);
531    }
532
533    #[test]
534    fn a_newly_arrived_row_is_lit_and_the_light_decays() {
535        let start = Instant::now();
536        let mut moving = Moving::new(start);
537        moving.arrived(7, start);
538
539        moving.advance(start, &[], 0);
540        assert!((moving.freshness(7) - 1.0).abs() < f64::EPSILON);
541        moving.advance(start + ARRIVAL / 2, &[], 0);
542        assert!(
543            (0.4..0.6).contains(&moving.freshness(7)),
544            "{}",
545            moving.freshness(7)
546        );
547        moving.advance(start + ARRIVAL * 2, &[], 0);
548        assert!(moving.freshness(7).abs() < f64::EPSILON);
549        assert!(
550            !moving.is_moving(),
551            "a light nobody can see is still animating"
552        );
553    }
554
555    #[test]
556    fn a_cascade_lights_each_rung_later_than_the_one_below_it() {
557        let start = Instant::now();
558        let mut moving = Moving::new(start);
559        // The chain a mark on a deep row runs through: the row, then its parent, then the root.
560        moving.cascade(&[10, 11, 12], start);
561
562        moving.advance(start, &[], 0);
563        assert!(moving.is_cascading(10));
564        assert!(!moving.is_cascading(12), "the whole chain flashed at once");
565
566        moving.advance(start + RUNG * 2, &[], 0);
567        assert!(moving.is_cascading(12), "the mark never reached the root");
568
569        moving.advance(start + RUNG * 2 + FLASH, &[], 0);
570        assert!(!moving.is_cascading(12));
571        assert!(!moving.is_moving());
572    }
573
574    #[test]
575    fn a_row_stays_until_its_dimmed_beat_is_over_and_is_handed_back_once() {
576        let start = Instant::now();
577        let mut moving = Moving::new(start);
578        moving.frees(3, 40);
579        assert!(moving.is_freeing(3));
580        assert!(!moving.is_spent(3), "dimmed while it is still emptying");
581        assert_eq!(moving.leaving().collect::<Vec<_>>(), [(3, 40)]);
582
583        moving.spends(3, 100, start);
584        assert!(moving.is_spent(3));
585        assert!(!moving.is_freeing(3));
586        // The finished total supersedes the last progress report rather than adding to it.
587        assert_eq!(moving.leaving().collect::<Vec<_>>(), [(3, 100)]);
588
589        assert!(moving.collapsed(start + DIM / 2).is_empty());
590        assert_eq!(moving.collapsed(start + DIM), vec![3]);
591        // Handed over twice, the view would try to remove the same claim from the tree twice —
592        // and the second removal would be refused, silently, which is the shape of bug this
593        // whole file has to avoid.
594        assert!(moving.collapsed(start + DIM * 2).is_empty());
595        assert!(!moving.is_spent(3));
596    }
597
598    #[test]
599    fn a_progress_report_that_arrives_behind_a_later_one_does_not_wind_the_row_backwards() {
600        let start = Instant::now();
601        let mut moving = Moving::new(start);
602        // The pool calls the sink from several threads, so two reports about one target can
603        // reach the channel in either order. Each is a total, so the newest is the largest —
604        // and taking the maximum is what makes that true of what is drawn as well.
605        moving.frees(3, 900);
606        moving.frees(3, 400);
607        assert_eq!(moving.leaving().collect::<Vec<_>>(), [(3, 900)]);
608    }
609
610    #[test]
611    fn banking_a_batch_leaves_nothing_for_the_counter_to_count_twice() {
612        let start = Instant::now();
613        let mut moving = Moving::new(start);
614        moving.spends(3, 100, start);
615
616        moving.banked();
617
618        // The batch report carries the same bytes, so the running figures have to go — but
619        // the row itself is still dimmed, and it is the tree that has yet to lose it.
620        assert_eq!(moving.leaving().count(), 0);
621        assert!(moving.is_spent(3));
622        assert_eq!(moving.collapsed(start + DIM), vec![3]);
623    }
624
625    #[test]
626    fn the_shimmer_travels_and_comes_round() {
627        let start = Instant::now();
628        let mut moving = Moving::new(start);
629        moving.advance(start, &[], 0);
630        let first = moving.shimmer(5, start);
631        moving.advance(start + super::SHIMMER / 5, &[], 0);
632        let second = moving.shimmer(5, start);
633        assert_ne!(first, second, "the shimmer stood still");
634        moving.advance(start + super::SHIMMER, &[], 0);
635        assert_eq!(moving.shimmer(5, start), first, "it never came round");
636    }
637
638    #[test]
639    fn a_view_with_nothing_happening_in_it_reports_itself_still() {
640        let start = Instant::now();
641        let mut moving = Moving::new(start);
642        moving.advance(start, &[(1, 100, false)], 0);
643        assert!(!moving.is_moving());
644
645        // A claim being priced is the one kind of motion with no clock on it: it runs until
646        // the pool comes back, however long that takes.
647        moving.heats(1);
648        assert!(moving.is_moving());
649        moving.cools(1);
650        assert!(!moving.is_moving());
651
652        moving.advance(start + Duration::from_millis(1), &[(1, 100_000, false)], 0);
653        assert!(moving.is_moving(), "a number in flight is motion");
654    }
655}