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}