pristine-cli 0.1.0

A language-agnostic reclaimable-space finder and cleaner.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
//! What is moving on the screen right now, and why each thing is allowed to.
//!
//! # Motion is information wearing a costume
//!
//! Nothing here spins to prove the program is running. Every moving thing below is a fact the
//! view already holds, drawn *over time* rather than all at once, so the rate of a change is
//! legible as well as its result:
//!
//! - a rolled-up total that climbs shows the rate claims are arriving at, which is the one
//!   thing a count of directories cannot say;
//! - a row that is lit is a directory the walk found since the last frame;
//! - a shimmer through a dash is a claim a pricing thread is inside **at this instant** — the
//!   pool has N threads, so exactly N rows shimmer, and that is honest rather than decorative;
//! - a mark running up the ancestors is the subtree operation that just happened, shown
//!   instead of inferred;
//! - a row emptying is bytes leaving the disk.
//!
//! The rule that follows from it, and the one worth keeping: **if an effect cannot be derived
//! from something true, it does not move.** There is no spinner in this file and no place to
//! put one.
//!
//! # Whimsy before the point of no return, gravity after it
//!
//! Everything above belongs to finding and waiting. Past the confirmation the only thing that
//! moves is the pair of counters in [`Chase`] — reclaimable going down, freed coming up — and
//! that is the whole payoff. A deletion is a thing that might have been a mistake, so it gets
//! no celebration.
//!
//! # It is bounded by the viewport, not by the tree
//!
//! [`Moving::advance`] is handed the rows that are actually drawn, and it forgets every entry
//! it was not handed this frame. So the per-frame cost is the height of the pane whatever the
//! tree is doing — one real home directory is 22,765 directories and 16,013 claims, and none
//! of that is touched here. A row scrolled away and back has no state to inherit, and starts
//! showing the truth immediately, which is right: nobody watched it change.

use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};

use crate::tree::NodeId;

/// Roughly how long a number takes to reach the one behind it.
///
/// Long enough to read as movement, short enough that a reader who looks at a row and then
/// acts on it is acting on the true figure. See [`Chase::advance`] for what "roughly" means.
pub const COUNT_UP: Duration = Duration::from_millis(200);

/// How long a newly arrived row stays lit.
///
/// About a second, because the eye has to be *drawn* to it rather than merely able to find it,
/// and because the alternative — a scrolling log of what was found — is the thing a tree
/// exists to avoid.
pub const ARRIVAL: Duration = Duration::from_millis(900);

/// How long one rung of a mark cascade stays lit.
pub const FLASH: Duration = Duration::from_millis(160);

/// How much later each rung above the marked row lights up.
///
/// The stagger is the whole message: the mark is seen *travelling* outwards, which is what
/// makes "this took everything underneath" a thing the screen said rather than a thing the
/// reader worked out.
pub const RUNG: Duration = Duration::from_millis(45);

/// How long an emptied row stays on screen, dimmed, before it collapses away.
///
/// The *emptying* before this has no duration of its own — it takes exactly as long as the
/// deleter takes, because it is driven by the bytes the deleter reports leaving the disk. This
/// is only the beat after the number reaches zero, so the row is seen to have emptied rather
/// than vanishing on the same frame as its last byte.
pub const DIM: Duration = Duration::from_millis(200);

/// How long the pricing shimmer takes to cross its column once.
pub const SHIMMER: Duration = Duration::from_millis(700);

/// One number on its way to another.
///
/// Exponential rather than linear, for a reason that is about streaming rather than about
/// taste: the target moves. A claim lands, then another, then a price — a linear tween would
/// have to be restarted on each one and would visibly stutter, where an approach simply has a
/// new gap to close and keeps its speed continuous.
#[derive(Clone, Copy, Debug)]
pub struct Chase {
    shown: f64,
    /// The frame this was last advanced on. Doubles as the mark that keeps it alive: see
    /// [`Moving::advance`].
    at: Instant,
    settled: bool,
}

impl Chase {
    /// A number that is already where it belongs.
    #[must_use]
    pub fn new(value: u64, now: Instant) -> Self {
        Self {
            #[expect(
                clippy::cast_precision_loss,
                reason = "a byte count large enough to lose precision here is 4 petabytes, and \
                          the value is on its way to a display rounded to one decimal place"
            )]
            shown: value as f64,
            at: now,
            settled: true,
        }
    }

    /// Moves toward `target` by however much time has passed, and says where it got to.
    ///
    /// The time constant is a third of [`COUNT_UP`], so about 95% of the gap is closed in that
    /// long — which is what "roughly 200ms" means for a curve that never formally arrives.
    /// Formally never arriving is also why the snap below is not optional.
    pub fn advance(&mut self, target: u64, now: Instant) -> u64 {
        #[expect(
            clippy::cast_precision_loss,
            reason = "as in `new`: the display this is bound for has one decimal place"
        )]
        let target = target as f64;
        let elapsed = now.saturating_duration_since(self.at).as_secs_f64();
        self.at = now;
        let closed = 1.0 - (-elapsed * 3.0 / COUNT_UP.as_secs_f64()).exp();
        self.shown += (target - self.shown) * closed;
        // Snapped once the remaining gap is below what the column can print — a tenth of a
        // percent is a whole digit of `1023.9 GiB`, so half of that is invisible by
        // construction. Without it the value approaches forever and the view never reports
        // itself still, which is what the frame rate is chosen from.
        if (target - self.shown).abs() <= (target.abs() * 0.0005).max(1.0) {
            self.shown = target;
            self.settled = true;
        } else {
            self.settled = false;
        }
        self.value()
    }

    /// Puts the number somewhere without easing toward it.
    ///
    /// For a value that is already being interpolated by something else — a row emptying on a
    /// ramp — so that the chase takes over seamlessly when that ends rather than resuming from
    /// wherever it was left standing.
    pub fn jam(&mut self, value: u64, now: Instant) {
        *self = Self::new(value, now);
    }

    /// What to draw.
    #[must_use]
    pub fn value(&self) -> u64 {
        #[expect(
            clippy::cast_possible_truncation,
            clippy::cast_sign_loss,
            reason = "the chase only ever runs between two byte counts, so it is bounded by \
                      them; a negative is arithmetically unreachable and saturates to zero \
                      rather than wrapping"
        )]
        let value = self.shown.max(0.0).round() as u64;
        value
    }

    /// Whether it has arrived, which is what "nothing is moving" is made of.
    #[must_use]
    pub fn settled(&self) -> bool {
        self.settled
    }
}

/// Everything the view is in the middle of showing.
#[derive(Debug)]
pub struct Moving {
    /// One chase per row that was drawn last frame. Bounded by the pane.
    rows: HashMap<NodeId, Chase>,
    /// When each row appeared in the tree, while that is still recent.
    arrived: HashMap<NodeId, Instant>,
    /// When each rung of the last mark cascade lights up. Ancestors only, so it is bounded by
    /// the depth of the tree — ten, on a real home directory.
    cascade: HashMap<NodeId, Instant>,
    /// Targets the deleter is part way through, and the bytes it says have gone from each so
    /// far. Cumulative, straight off [`crate::delete::Freeing`] — nothing here interpolates
    /// toward a guess, because the guess is not needed once the real figure is arriving.
    freeing: HashMap<NodeId, u64>,
    /// Targets the deleter has finished with, spending their last moment dimmed. The view
    /// reads the deadline off this and takes the row out of the tree when it passes.
    spent: HashMap<NodeId, Instant>,
    /// Bytes from targets of the running batch whose rows have already collapsed away.
    ///
    /// They cannot stay in `freeing`, because that map is what "a row is still emptying" is
    /// read from and a row that has gone is not. They cannot be dropped either: the batch has
    /// not reported its own total yet, and a counter that fell back by what it had already
    /// given back would be the one number a reader came back for, going the wrong way.
    settled: u64,
    /// Claims a pricing thread is inside at this instant. Exactly as many as the pool has
    /// threads, which is the fact the shimmer is drawing.
    hot: HashSet<NodeId>,
    /// What the session has given back.
    ///
    /// Not a [`Chase`], deliberately. It moves on the deleter's own progress reports, which is
    /// the same source and the same instant as the fall on every row above the target — so
    /// easing it would put the two counters that are meant to move against each other a
    /// fifth of a second out of step, for no gain over a figure that is already true.
    freed: u64,
    now: Instant,
}

impl Moving {
    /// Nothing moving, as of `now`.
    #[must_use]
    pub fn new(now: Instant) -> Self {
        Self {
            rows: HashMap::new(),
            arrived: HashMap::new(),
            cascade: HashMap::new(),
            freeing: HashMap::new(),
            spent: HashMap::new(),
            settled: 0,
            hot: HashSet::new(),
            freed: 0,
            now,
        }
    }

    /// Moves the clock on, before anything asks a question whose answer depends on it.
    ///
    /// Split out from [`Moving::advance`] because the caller has work to do *between* the two
    /// — working out what each row is worth given what is draining away under it, which is a
    /// question about this frame's instant and not the last one's.
    pub fn tick(&mut self, now: Instant) {
        self.now = now;
    }

    /// Advances every drawn row toward what it is really worth, and forgets the rest.
    ///
    /// `rows` is the viewport's worth of `(row, what it is worth now, is that exact)`, so a row
    /// that scrolled off loses its state and a row that scrolls back on starts at the truth.
    /// That is the whole of the cost story: this is O(rows on screen), never O(tree).
    ///
    /// **Exact** means the caller is already interpolating that value itself and the chase must
    /// not add a second, slower opinion on top — which is what a row emptying is. Jammed rather
    /// than skipped, so that when the drain ends the chase carries on from where the ramp left
    /// off instead of from wherever it was standing when the drain began.
    pub fn advance(&mut self, now: Instant, rows: &[(NodeId, u64, bool)], freed: u64) {
        self.now = now;
        for &(id, target, exact) in rows {
            let chase = self
                .rows
                .entry(id)
                .or_insert_with(|| Chase::new(target, now));
            if exact {
                chase.jam(target, now);
            } else {
                chase.advance(target, now);
            }
        }
        // A chase stamped with any earlier frame belongs to a row nobody is drawing.
        self.rows.retain(|_, chase| chase.at == now);
        self.freed = freed;
        self.arrived
            .retain(|_, at| now.saturating_duration_since(*at) < ARRIVAL);
        // The stamp is when a rung *lights*, which for the outer ones is still in the future —
        // and `saturating_duration_since` reads a future instant as no time at all, so a rung
        // waiting its turn is kept by the same condition that keeps a lit one.
        self.cascade
            .retain(|_, at| now.saturating_duration_since(*at) < FLASH);
    }

    /// What a row draws, which is the truth once it has caught up with it.
    #[must_use]
    pub fn shown(&self, id: NodeId, truth: u64) -> u64 {
        self.rows.get(&id).map_or(truth, Chase::value)
    }

    /// What the session has given back so far.
    #[must_use]
    pub fn freed(&self) -> u64 {
        self.freed
    }

    /// Notes a directory that has just appeared in the tree.
    pub fn arrived(&mut self, id: NodeId, now: Instant) {
        self.arrived.insert(id, now);
    }

    /// How lit a newly arrived row is: 1.0 the moment it lands, 0.0 once it is old news.
    #[must_use]
    pub fn freshness(&self, id: NodeId) -> f64 {
        let Some(at) = self.arrived.get(&id) else {
            return 0.0;
        };
        let elapsed = self.now.saturating_duration_since(*at).as_secs_f64();
        (1.0 - elapsed / ARRIVAL.as_secs_f64()).clamp(0.0, 1.0)
    }

    /// Lights a mark running outwards through `ancestors`, nearest first.
    pub fn cascade(&mut self, ancestors: &[NodeId], now: Instant) {
        for (rung, &id) in ancestors.iter().enumerate() {
            self.cascade
                .insert(id, now + RUNG * u32::try_from(rung).unwrap_or(u32::MAX));
        }
    }

    /// Whether this row's rung of the cascade is lit at this instant.
    ///
    /// A rung that has not come round yet is not lit either, which is what makes the mark
    /// travel rather than all of it flashing at once.
    #[must_use]
    pub fn is_cascading(&self, id: NodeId) -> bool {
        self.cascade
            .get(&id)
            .is_some_and(|at| *at <= self.now && self.now.saturating_duration_since(*at) < FLASH)
    }

    /// Notes that a pricing thread has gone into this claim.
    pub fn heats(&mut self, id: NodeId) {
        self.hot.insert(id);
    }

    /// Notes that it has come back out, with a price or without one.
    pub fn cools(&mut self, id: NodeId) {
        self.hot.remove(&id);
    }

    /// Forgets every claim that was being priced, for the end of the walk: a pool that has
    /// stopped leaves nothing hot behind, and a row shimmering for a thread that no longer
    /// exists would be the one moving thing here that says nothing.
    pub fn cooled(&mut self) {
        self.hot.clear();
    }

    /// Whether a pricing thread is inside this claim right now.
    #[must_use]
    pub fn is_hot(&self, id: NodeId) -> bool {
        self.hot.contains(&id)
    }

    /// Every claim currently being priced, so the view can drop the ones that have since gone.
    pub fn hot(&self) -> impl Iterator<Item = NodeId> + '_ {
        self.hot.iter().copied()
    }

    /// Which cell of a `width`-wide shimmer is lit.
    ///
    /// One phase for the whole screen rather than one per row: the reader is being told how
    /// many rows are hot, and rows that pulse together are countable at a glance where rows
    /// each doing their own thing are not.
    #[must_use]
    pub fn shimmer(&self, width: usize, epoch: Instant) -> usize {
        if width == 0 {
            return 0;
        }
        let step = SHIMMER.as_millis().max(1) / width as u128;
        let elapsed = self.now.saturating_duration_since(epoch).as_millis();
        usize::try_from(elapsed / step.max(1) % width as u128).unwrap_or(0)
    }

    /// Records how much of a target the deleter says has gone so far.
    ///
    /// Taken as the total rather than added to, because that is what the event carries: a
    /// report that arrives out of order behind a later one is discarded rather than winding
    /// the row backwards, which the pool makes possible and nothing else would catch.
    pub fn frees(&mut self, id: NodeId, bytes: u64) {
        let freed = self.freeing.entry(id).or_insert(0);
        *freed = (*freed).max(bytes);
    }

    /// Records that the deleter has finished with a target, and starts its dimmed beat.
    pub fn spends(&mut self, id: NodeId, bytes: u64, now: Instant) {
        self.frees(id, bytes);
        self.spent.entry(id).or_insert(now);
    }

    /// Whether bytes are leaving this target right now.
    #[must_use]
    pub fn is_freeing(&self, id: NodeId) -> bool {
        self.freeing.contains_key(&id) && !self.spent.contains_key(&id)
    }

    /// Whether this row has emptied and is spending its last moment on screen.
    #[must_use]
    pub fn is_spent(&self, id: NodeId) -> bool {
        self.spent.contains_key(&id)
    }

    /// Whether the deleter has touched this row at all — either phase.
    ///
    /// The one predicate the batch, the marks and `space` all read, so "a directory the
    /// deleter is part way through is not a directory to delete again" is stated once rather
    /// than in the three places that could drift.
    #[must_use]
    pub fn is_leaving(&self, id: NodeId) -> bool {
        self.is_freeing(id) || self.is_spent(id)
    }

    /// Every row the running removal is still on screen for, with the bytes it has given
    /// back so far.
    ///
    /// What every ancestor subtracts, and half of what the freed counter adds up. Spent
    /// targets are in here too until their row collapses, because their bytes are just as
    /// gone and the rows above them have to say so.
    pub fn leaving(&self) -> impl Iterator<Item = (NodeId, u64)> + '_ {
        self.freeing.iter().map(|(&id, &bytes)| (id, bytes))
    }

    /// What the deleter has given back from this row so far.
    #[must_use]
    pub fn freed_from(&self, id: NodeId) -> u64 {
        self.freeing.get(&id).copied().unwrap_or(0)
    }

    /// What the running batch has given back in total, rows still on screen and rows already
    /// collapsed alike.
    #[must_use]
    pub fn freed_so_far(&self) -> u64 {
        self.settled + self.freeing.values().sum::<u64>()
    }

    /// The rows whose dimmed beat is over, which the view then takes out of the tree for real.
    /// Forgotten here in the same breath, so each is handed over exactly once.
    pub fn collapsed(&mut self, now: Instant) -> Vec<NodeId> {
        let due: Vec<NodeId> = self
            .spent
            .iter()
            .filter(|(_, at)| now.saturating_duration_since(**at) >= DIM)
            .map(|(&id, _)| id)
            .collect();
        for id in &due {
            self.spent.remove(id);
            // Out of the per-row map and into the batch's running total. Left where it was it
            // would keep the view reporting itself in motion for a row nobody can see.
            self.settled += self.freeing.remove(id).unwrap_or(0);
        }
        due
    }

    /// Hands the running total over to the caller's own, when the batch has reported one.
    ///
    /// The per-target figures and the [`crate::delete::Removal`] are the same arithmetic from
    /// the same accounting, so keeping both would count every byte twice. The dimmed rows stay
    /// where they are: what they are worth on screen is zero either way, and it is the *tree*
    /// that still has to lose them.
    ///
    /// **Everything dropped here is transient, which is a constraint on the caller as much as
    /// a description.** A target the sweep finished with is on its way out of the tree anyway;
    /// a target it could not finish is *staying*, and the only record that it is smaller than
    /// it was is the figure about to be cleared. So an incomplete target's reduction has to be
    /// made durable before this runs, or its row and every total above it spring back to what
    /// they were worth before the deletion — see [`super::state::View::deleted`], which is the
    /// one caller, and [`crate::tree::Tree::shrink`], which is where the bytes go.
    pub fn banked(&mut self) {
        self.freeing.clear();
        self.settled = 0;
    }

    /// Whether anything at all is still in motion.
    ///
    /// What the event loop reads to decide how often to repaint: a view with something moving
    /// in it earns a smooth frame rate, and a view a reader is sitting and thinking in front
    /// of does not.
    #[must_use]
    pub fn is_moving(&self) -> bool {
        !self.hot.is_empty()
            || !self.freeing.is_empty()
            || !self.spent.is_empty()
            || !self.cascade.is_empty()
            || !self.arrived.is_empty()
            || self.rows.values().any(|chase| !chase.settled())
    }
}

#[cfg(test)]
mod tests {
    use super::{ARRIVAL, COUNT_UP, Chase, DIM, FLASH, Moving, RUNG};
    use std::time::{Duration, Instant};

    #[test]
    fn a_chase_climbs_toward_its_target_and_arrives_at_it_exactly() {
        let start = Instant::now();
        let mut chase = Chase::new(0, start);

        // Part way there is genuinely part way: the point of the effect is that the reader
        // sees the number move rather than appear.
        let half = chase.advance(1_000_000, start + COUNT_UP / 2);
        assert!(half > 0 && half < 1_000_000, "{half}");
        assert!(!chase.settled());

        // …and it lands on the true number rather than approaching it forever, which is what
        // lets the view report itself still.
        let landed = chase.advance(1_000_000, start + COUNT_UP * 4);
        assert_eq!(landed, 1_000_000);
        assert!(chase.settled());
    }

    #[test]
    fn a_chase_runs_downwards_as_readily_as_up() {
        let start = Instant::now();
        let mut chase = Chase::new(1_000_000, start);
        // Which is what a deletion is: the same mechanism, and no second one to keep in step
        // with this one.
        let draining = chase.advance(0, start + COUNT_UP / 2);
        assert!(draining > 0 && draining < 1_000_000, "{draining}");
        // Zero is the one target an approach takes a while over, because the snap is the
        // absolute byte the number is finally within rather than a share of a target that is
        // itself nothing. It is also the reason a *row* emptying is a ramp and not one of
        // these — see [`Moving::draining_share`].
        assert_eq!(chase.advance(0, start + COUNT_UP * 8), 0);
    }

    #[test]
    fn a_target_that_moves_mid_flight_is_chased_rather_than_restarted() {
        let start = Instant::now();
        let mut chase = Chase::new(0, start);
        let first = chase.advance(100, start + COUNT_UP / 4);
        // A claim lands while the previous one is still being counted up to. Nothing resets:
        // the gap is simply bigger now, which is what makes a stream of arrivals read as one
        // continuous climb rather than as a stutter per claim.
        let second = chase.advance(200, start + COUNT_UP / 2);
        assert!(second > first, "{first} -> {second}");
        assert!(second < 200);
    }

    #[test]
    fn a_row_that_scrolled_off_the_screen_is_forgotten_rather_than_animated() {
        let start = Instant::now();
        let mut moving = Moving::new(start);
        moving.advance(start, &[(1, 100, false), (2, 200, false)], 0);
        moving.advance(start + COUNT_UP, &[(1, 100, false)], 0);

        // The cost story: one entry per row the pane drew, whatever the tree is doing.
        assert_eq!(
            moving.shown(2, 999),
            999,
            "a row nobody drew kept its state"
        );
        assert_eq!(moving.shown(1, 100), 100);
    }

    #[test]
    fn a_newly_arrived_row_is_lit_and_the_light_decays() {
        let start = Instant::now();
        let mut moving = Moving::new(start);
        moving.arrived(7, start);

        moving.advance(start, &[], 0);
        assert!((moving.freshness(7) - 1.0).abs() < f64::EPSILON);
        moving.advance(start + ARRIVAL / 2, &[], 0);
        assert!(
            (0.4..0.6).contains(&moving.freshness(7)),
            "{}",
            moving.freshness(7)
        );
        moving.advance(start + ARRIVAL * 2, &[], 0);
        assert!(moving.freshness(7).abs() < f64::EPSILON);
        assert!(
            !moving.is_moving(),
            "a light nobody can see is still animating"
        );
    }

    #[test]
    fn a_cascade_lights_each_rung_later_than_the_one_below_it() {
        let start = Instant::now();
        let mut moving = Moving::new(start);
        // The chain a mark on a deep row runs through: the row, then its parent, then the root.
        moving.cascade(&[10, 11, 12], start);

        moving.advance(start, &[], 0);
        assert!(moving.is_cascading(10));
        assert!(!moving.is_cascading(12), "the whole chain flashed at once");

        moving.advance(start + RUNG * 2, &[], 0);
        assert!(moving.is_cascading(12), "the mark never reached the root");

        moving.advance(start + RUNG * 2 + FLASH, &[], 0);
        assert!(!moving.is_cascading(12));
        assert!(!moving.is_moving());
    }

    #[test]
    fn a_row_stays_until_its_dimmed_beat_is_over_and_is_handed_back_once() {
        let start = Instant::now();
        let mut moving = Moving::new(start);
        moving.frees(3, 40);
        assert!(moving.is_freeing(3));
        assert!(!moving.is_spent(3), "dimmed while it is still emptying");
        assert_eq!(moving.leaving().collect::<Vec<_>>(), [(3, 40)]);

        moving.spends(3, 100, start);
        assert!(moving.is_spent(3));
        assert!(!moving.is_freeing(3));
        // The finished total supersedes the last progress report rather than adding to it.
        assert_eq!(moving.leaving().collect::<Vec<_>>(), [(3, 100)]);

        assert!(moving.collapsed(start + DIM / 2).is_empty());
        assert_eq!(moving.collapsed(start + DIM), vec![3]);
        // Handed over twice, the view would try to remove the same claim from the tree twice —
        // and the second removal would be refused, silently, which is the shape of bug this
        // whole file has to avoid.
        assert!(moving.collapsed(start + DIM * 2).is_empty());
        assert!(!moving.is_spent(3));
    }

    #[test]
    fn a_progress_report_that_arrives_behind_a_later_one_does_not_wind_the_row_backwards() {
        let start = Instant::now();
        let mut moving = Moving::new(start);
        // The pool calls the sink from several threads, so two reports about one target can
        // reach the channel in either order. Each is a total, so the newest is the largest —
        // and taking the maximum is what makes that true of what is drawn as well.
        moving.frees(3, 900);
        moving.frees(3, 400);
        assert_eq!(moving.leaving().collect::<Vec<_>>(), [(3, 900)]);
    }

    #[test]
    fn banking_a_batch_leaves_nothing_for_the_counter_to_count_twice() {
        let start = Instant::now();
        let mut moving = Moving::new(start);
        moving.spends(3, 100, start);

        moving.banked();

        // The batch report carries the same bytes, so the running figures have to go — but
        // the row itself is still dimmed, and it is the tree that has yet to lose it.
        assert_eq!(moving.leaving().count(), 0);
        assert!(moving.is_spent(3));
        assert_eq!(moving.collapsed(start + DIM), vec![3]);
    }

    #[test]
    fn the_shimmer_travels_and_comes_round() {
        let start = Instant::now();
        let mut moving = Moving::new(start);
        moving.advance(start, &[], 0);
        let first = moving.shimmer(5, start);
        moving.advance(start + super::SHIMMER / 5, &[], 0);
        let second = moving.shimmer(5, start);
        assert_ne!(first, second, "the shimmer stood still");
        moving.advance(start + super::SHIMMER, &[], 0);
        assert_eq!(moving.shimmer(5, start), first, "it never came round");
    }

    #[test]
    fn a_view_with_nothing_happening_in_it_reports_itself_still() {
        let start = Instant::now();
        let mut moving = Moving::new(start);
        moving.advance(start, &[(1, 100, false)], 0);
        assert!(!moving.is_moving());

        // A claim being priced is the one kind of motion with no clock on it: it runs until
        // the pool comes back, however long that takes.
        moving.heats(1);
        assert!(moving.is_moving());
        moving.cools(1);
        assert!(!moving.is_moving());

        moving.advance(start + Duration::from_millis(1), &[(1, 100_000, false)], 0);
        assert!(moving.is_moving(), "a number in flight is motion");
    }
}