1use std::sync::Arc;
23
24use color_eyre::Result;
25use crossterm::event::{KeyCode, KeyEvent};
26use ratatui::{
27 Frame,
28 layout::{Constraint, Layout, Rect},
29 style::{Color, Modifier, Style},
30 text::{Line, Span},
31 widgets::{Block, Borders, Paragraph},
32};
33use tokio::sync::{mpsc, watch};
34
35use super::Component;
36use crate::action::Action;
37use crate::api::ApiClient;
38use crate::theme;
39use crate::watch::StampsSnapshot;
40
41use bee::postage::{BatchBucket, PostageBatch, PostageBatchBuckets};
42use bee::swarm::BatchId;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum StampStatus {
47 Pending,
49 Expired,
51 Critical,
54 Skewed,
56 Healthy,
58}
59
60impl StampStatus {
61 fn color(self) -> Color {
62 let t = theme::active();
63 match self {
64 Self::Pending => t.info,
65 Self::Expired => t.fail,
66 Self::Critical => t.fail,
67 Self::Skewed => t.warn,
68 Self::Healthy => t.pass,
69 }
70 }
71 fn label(self) -> &'static str {
72 match self {
73 Self::Pending => "⏳ pending",
74 Self::Expired => "✗ expired",
75 Self::Critical => "✗ critical",
76 Self::Skewed => "⚠ skewed",
77 Self::Healthy => "✓",
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
84pub struct StampRow {
85 pub label: String,
86 pub batch_id_short: String,
87 pub volume: String,
91 pub worst_bucket_pct: u32,
94 pub worst_bucket_raw: String,
96 pub ttl: String,
98 pub immutable: bool,
101 pub status: StampStatus,
102 pub why: Option<String>,
104}
105
106pub const FILL_BIN_LABELS: &[&str] = &[
110 "0 %",
111 "1 – 19 %",
112 "20 – 49 %",
113 "50 – 79 %",
114 "80 – 99 %",
115 "100 %",
116];
117
118#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct StampDrillView {
122 pub depth: u8,
123 pub bucket_depth: u8,
124 pub upper_bound: u32,
125 pub total_chunks: u64,
128 pub theoretical_capacity: u128,
132 pub fill_distribution: [u32; 6],
136 pub worst_buckets: Vec<WorstBucket>,
139 pub worst_pct: u32,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub struct WorstBucket {
146 pub bucket_id: u32,
147 pub collisions: u32,
148 pub pct: u32,
149}
150
151#[derive(Debug, Clone)]
154pub enum DrillState {
155 Idle,
156 Loading {
157 batch_id: BatchId,
158 },
159 Loaded {
160 batch_id: BatchId,
161 view: StampDrillView,
162 },
163 Failed {
164 batch_id: BatchId,
165 error: String,
166 },
167}
168
169type DrillFetchResult = (BatchId, std::result::Result<PostageBatchBuckets, String>);
170
171pub struct Stamps {
172 client: Arc<ApiClient>,
173 rx: watch::Receiver<StampsSnapshot>,
174 snapshot: StampsSnapshot,
175 selected: usize,
176 scroll_offset: usize,
181 drill: DrillState,
182 fetch_tx: mpsc::UnboundedSender<DrillFetchResult>,
183 fetch_rx: mpsc::UnboundedReceiver<DrillFetchResult>,
184}
185
186impl Stamps {
187 pub fn new(client: Arc<ApiClient>, rx: watch::Receiver<StampsSnapshot>) -> Self {
188 let snapshot = rx.borrow().clone();
189 let (fetch_tx, fetch_rx) = mpsc::unbounded_channel();
190 Self {
191 client,
192 rx,
193 snapshot,
194 selected: 0,
195 scroll_offset: 0,
196 drill: DrillState::Idle,
197 fetch_tx,
198 fetch_rx,
199 }
200 }
201
202 fn pull_latest(&mut self) {
203 self.snapshot = self.rx.borrow().clone();
204 let n = self.snapshot.batches.len();
207 if n == 0 {
208 self.selected = 0;
209 } else if self.selected >= n {
210 self.selected = n - 1;
211 }
212 }
213
214 fn drain_fetches(&mut self) {
219 while let Ok((batch_id, result)) = self.fetch_rx.try_recv() {
220 match &self.drill {
221 DrillState::Loading { batch_id: pending } if *pending == batch_id => {}
222 _ => continue, }
224 self.drill = match result {
225 Ok(buckets) => DrillState::Loaded {
226 batch_id,
227 view: Self::compute_drill_view(&buckets),
228 },
229 Err(error) => DrillState::Failed { batch_id, error },
230 };
231 }
232 }
233
234 pub fn rows_for(snap: &StampsSnapshot) -> Vec<StampRow> {
237 snap.batches.iter().map(row_from_batch).collect()
238 }
239
240 pub fn compute_drill_view(buckets: &PostageBatchBuckets) -> StampDrillView {
244 let upper_bound = buckets.bucket_upper_bound.max(1);
245 let mut fill_distribution = [0u32; 6];
246 let mut total_chunks: u64 = 0;
247 for b in &buckets.buckets {
248 total_chunks += u64::from(b.collisions);
249 let bin = bucket_fill_bin(b.collisions, upper_bound);
250 fill_distribution[bin] += 1;
251 }
252 let mut sorted: Vec<&BatchBucket> = buckets.buckets.iter().collect();
253 sorted.sort_by(|a, b| {
256 b.collisions
257 .cmp(&a.collisions)
258 .then_with(|| a.bucket_id.cmp(&b.bucket_id))
259 });
260 let worst_buckets: Vec<WorstBucket> = sorted
261 .iter()
262 .take(10)
263 .map(|b| WorstBucket {
264 bucket_id: b.bucket_id,
265 collisions: b.collisions,
266 pct: pct_of(b.collisions, upper_bound),
267 })
268 .collect();
269 let worst_pct = worst_buckets.first().map(|w| w.pct).unwrap_or(0);
270 let theoretical_capacity = (1u128 << buckets.bucket_depth) * u128::from(upper_bound);
271 StampDrillView {
272 depth: buckets.depth,
273 bucket_depth: buckets.bucket_depth,
274 upper_bound,
275 total_chunks,
276 theoretical_capacity,
277 fill_distribution,
278 worst_buckets,
279 worst_pct,
280 }
281 }
282
283 fn maybe_start_drill(&mut self) {
287 if self.snapshot.batches.is_empty() {
288 return;
289 }
290 let i = self.selected.min(self.snapshot.batches.len() - 1);
291 let batch_id = self.snapshot.batches[i].batch_id;
292 if let DrillState::Loading { batch_id: pending } = &self.drill {
293 if *pending == batch_id {
294 return; }
296 }
297 let client = self.client.clone();
298 let tx = self.fetch_tx.clone();
299 tokio::spawn(async move {
300 let res = client
301 .bee()
302 .postage()
303 .get_postage_batch_buckets(&batch_id)
304 .await
305 .map_err(|e| e.to_string());
306 let _ = tx.send((batch_id, res));
307 });
308 self.drill = DrillState::Loading { batch_id };
309 }
310}
311
312fn bucket_fill_bin(collisions: u32, upper_bound: u32) -> usize {
313 if collisions == 0 {
314 return 0;
315 }
316 if collisions >= upper_bound {
317 return 5; }
319 let pct = pct_of(collisions, upper_bound);
320 match pct {
321 0 => 0, 1..=19 => 1,
323 20..=49 => 2,
324 50..=79 => 3,
325 80..=99 => 4,
326 _ => 5,
327 }
328}
329
330fn pct_of(collisions: u32, upper_bound: u32) -> u32 {
331 if upper_bound == 0 {
332 return 0;
333 }
334 let pct = (u64::from(collisions) * 100) / u64::from(upper_bound);
335 pct.min(100) as u32
336}
337
338fn row_from_batch(b: &PostageBatch) -> StampRow {
339 let label = if b.label.is_empty() {
340 "(unlabeled)".to_string()
341 } else {
342 b.label.clone()
343 };
344 let batch_hex = b.batch_id.to_hex();
345 let batch_id_short = if batch_hex.len() > 8 {
346 format!("{}…", &batch_hex[..8])
347 } else {
348 batch_hex
349 };
350 let theoretical_bytes: u128 = (1u128 << b.depth) * 4096;
351 let volume = format_bytes(theoretical_bytes);
352 let worst_bucket_pct = worst_bucket_pct(b);
353 let upper_bound = 1u32 << b.depth.saturating_sub(b.bucket_depth);
354 let worst_bucket_raw = format!("{}/{}", b.utilization, upper_bound);
355 let ttl = format_ttl_seconds(b.batch_ttl);
356
357 let (status, why) = if !b.usable {
358 (
359 StampStatus::Pending,
360 Some("waiting on chain confirmation (~10 blocks).".into()),
361 )
362 } else if b.batch_ttl <= 0 {
363 (
364 StampStatus::Expired,
365 Some("paid balance exhausted; topup or stop using.".into()),
366 )
367 } else if worst_bucket_pct >= 95 {
368 (
369 StampStatus::Critical,
370 Some(if b.immutable {
371 "immutable batch will REJECT next upload at this bucket.".into()
372 } else {
373 "mutable batch will silently overwrite oldest chunks.".into()
374 }),
375 )
376 } else if worst_bucket_pct >= 80 {
377 (
378 StampStatus::Skewed,
379 Some(format!(
380 "worst bucket {worst_bucket_pct}% > safe headroom — dilute or stop using."
381 )),
382 )
383 } else {
384 (StampStatus::Healthy, None)
385 };
386
387 StampRow {
388 label,
389 batch_id_short,
390 volume,
391 worst_bucket_pct,
392 worst_bucket_raw,
393 ttl,
394 immutable: b.immutable,
395 status,
396 why,
397 }
398}
399
400fn worst_bucket_pct(b: &PostageBatch) -> u32 {
403 let upper_bound: u32 = 1u32 << b.depth.saturating_sub(b.bucket_depth);
404 if upper_bound == 0 {
405 0
406 } else {
407 let pct = (u64::from(b.utilization) * 100) / u64::from(upper_bound);
408 pct.min(100) as u32
409 }
410}
411
412fn format_bytes(bytes: u128) -> String {
414 const K: u128 = 1024;
415 const M: u128 = K * 1024;
416 const G: u128 = M * 1024;
417 const T: u128 = G * 1024;
418 if bytes >= T {
419 format!("{:.1} TiB", bytes as f64 / T as f64)
420 } else if bytes >= G {
421 format!("{:.1} GiB", bytes as f64 / G as f64)
422 } else if bytes >= M {
423 format!("{:.1} MiB", bytes as f64 / M as f64)
424 } else if bytes >= K {
425 format!("{:.1} KiB", bytes as f64 / K as f64)
426 } else {
427 format!("{bytes} B")
428 }
429}
430
431fn format_ttl_seconds(secs: i64) -> String {
432 if secs <= 0 {
433 return "expired".into();
434 }
435 let days = secs / 86_400;
436 let hours = (secs % 86_400) / 3_600;
437 if days >= 1 {
438 format!("{days}d {hours:>2}h")
439 } else {
440 let minutes = (secs % 3_600) / 60;
441 format!("{hours}h {minutes:>2}m")
442 }
443}
444
445fn fill_bar(pct: u32, width: usize) -> String {
447 let filled = ((pct as usize) * width) / 100;
448 let mut bar = String::with_capacity(width);
449 for _ in 0..filled.min(width) {
450 bar.push('▇');
451 }
452 for _ in filled.min(width)..width {
453 bar.push('░');
454 }
455 bar
456}
457
458impl Component for Stamps {
459 fn update(&mut self, action: Action) -> Result<Option<Action>> {
460 if matches!(action, Action::Tick) {
461 self.pull_latest();
462 self.drain_fetches();
463 }
464 Ok(None)
465 }
466
467 fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
468 if matches!(self.drill, DrillState::Loaded { .. } | DrillState::Loading { .. } | DrillState::Failed { .. })
472 && matches!(key.code, KeyCode::Esc)
473 {
474 self.drill = DrillState::Idle;
475 return Ok(None);
476 }
477 match key.code {
478 KeyCode::Char('j') | KeyCode::Down => {
479 let n = self.snapshot.batches.len();
480 if n > 0 && self.selected + 1 < n {
481 self.selected += 1;
482 }
483 }
484 KeyCode::Char('k') | KeyCode::Up => {
485 self.selected = self.selected.saturating_sub(1);
486 }
487 KeyCode::Enter => {
488 self.maybe_start_drill();
489 }
490 _ => {}
491 }
492 Ok(None)
493 }
494
495 fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
496 let chunks = Layout::vertical([
497 Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ])
501 .split(area);
502
503 let count = self.snapshot.batches.len();
505 let mut header_l1 = vec![
506 Span::styled("STAMPS", Style::default().add_modifier(Modifier::BOLD)),
507 Span::raw(format!(" {count} batch(es)")),
508 ];
509 if let DrillState::Loaded { batch_id, .. }
510 | DrillState::Loading { batch_id }
511 | DrillState::Failed { batch_id, .. } = &self.drill
512 {
513 let hex = batch_id.to_hex();
514 let short = if hex.len() > 8 { &hex[..8] } else { &hex };
515 header_l1.push(Span::raw(format!(" · drill {short}…")));
516 }
517 let header_l1 = Line::from(header_l1);
518 let mut header_l2 = Vec::new();
519 let t = theme::active();
520 if let Some(err) = &self.snapshot.last_error {
521 let (color, msg) = theme::classify_header_error(err);
522 header_l2.push(Span::styled(msg, Style::default().fg(color)));
523 } else if !self.snapshot.is_loaded() {
524 header_l2.push(Span::styled(
525 "loading…",
526 Style::default().fg(t.dim),
527 ));
528 }
529 frame.render_widget(
530 Paragraph::new(vec![header_l1, Line::from(header_l2)])
531 .block(Block::default().borders(Borders::BOTTOM)),
532 chunks[0],
533 );
534
535 match &self.drill {
537 DrillState::Idle => self.draw_table(frame, chunks[1]),
538 DrillState::Loading { .. } => {
539 let msg = Line::from(Span::styled(
540 " fetching /stamps/<id>/buckets… (Esc cancel)",
541 Style::default().fg(t.dim),
542 ));
543 frame.render_widget(Paragraph::new(msg), chunks[1]);
544 }
545 DrillState::Failed { error, .. } => {
546 let msg = Line::from(vec![
547 Span::raw(" drill failed: "),
548 Span::styled(error.clone(), Style::default().fg(t.fail)),
549 Span::raw(" (Esc to dismiss)"),
550 ]);
551 frame.render_widget(Paragraph::new(msg), chunks[1]);
552 }
553 DrillState::Loaded { view, .. } => self.draw_drill(frame, chunks[1], view),
554 }
555
556 let footer = match &self.drill {
558 DrillState::Idle => Line::from(vec![
559 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
560 Span::raw(" switch screen "),
561 Span::styled(" ↑↓/jk ", Style::default().fg(Color::Black).bg(Color::White)),
562 Span::raw(" select "),
563 Span::styled(" ↵ ", Style::default().fg(Color::Black).bg(Color::White)),
564 Span::raw(" drill "),
565 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
566 Span::raw(" quit "),
567 Span::styled(" I/M ", Style::default().fg(t.dim)),
568 Span::raw(" immutable / mutable "),
569 ]),
570 _ => Line::from(vec![
571 Span::styled(" Esc ", Style::default().fg(Color::Black).bg(Color::White)),
572 Span::raw(" close drill "),
573 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
574 Span::raw(" switch screen "),
575 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
576 Span::raw(" quit "),
577 ]),
578 };
579 frame.render_widget(Paragraph::new(footer), chunks[2]);
580
581 Ok(())
582 }
583}
584
585impl Stamps {
586 fn draw_table(&mut self, frame: &mut Frame, area: Rect) {
587 use ratatui::layout::{Constraint, Layout};
588
589 let t = theme::active();
590
591 let table_chunks = Layout::vertical([
594 Constraint::Length(1),
595 Constraint::Min(0),
596 ])
597 .split(area);
598 frame.render_widget(
599 Paragraph::new(Line::from(Span::styled(
600 " LABEL BATCH VOLUME WORST BUCKET TTL STATUS",
601 Style::default()
602 .fg(t.dim)
603 .add_modifier(Modifier::BOLD),
604 ))),
605 table_chunks[0],
606 );
607
608 if self.snapshot.batches.is_empty() {
609 frame.render_widget(
610 Paragraph::new(Line::from(Span::styled(
611 " (no batches yet — buy one with swarm-cli or `bee stamps buy`)",
612 Style::default()
613 .fg(t.dim)
614 .add_modifier(Modifier::ITALIC),
615 ))),
616 table_chunks[1],
617 );
618 return;
619 }
620
621 let mut lines: Vec<Line> = Vec::new();
622 let mut row_starts: Vec<usize> = Vec::new();
628 for (i, r) in Self::rows_for(&self.snapshot).into_iter().enumerate() {
629 row_starts.push(lines.len());
630 let bar = fill_bar(r.worst_bucket_pct, 8);
631 let immut_glyph = if r.immutable { "I" } else { "M" };
632 let cursor = if i == self.selected {
633 format!("{} ", t.glyphs.cursor)
634 } else {
635 " ".to_string()
636 };
637 lines.push(Line::from(vec![
638 Span::styled(
639 cursor,
640 Style::default()
641 .fg(if i == self.selected { t.accent } else { t.dim })
642 .add_modifier(Modifier::BOLD),
643 ),
644 Span::styled(
645 format!("{:<20}", truncate(&r.label, 20)),
646 Style::default().add_modifier(Modifier::BOLD),
647 ),
648 Span::raw(format!("{:<13}", r.batch_id_short)),
649 Span::raw(format!("{:<12}", r.volume)),
650 Span::styled(
651 format!("{bar} {:>3}% ({})", r.worst_bucket_pct, r.worst_bucket_raw),
652 Style::default().fg(bucket_color(r.worst_bucket_pct)),
653 ),
654 Span::raw(" "),
655 Span::raw(format!("{:<10} ", r.ttl)),
656 Span::styled(immut_glyph, Style::default().fg(t.dim)),
657 Span::raw(" "),
658 Span::styled(
659 r.status.label(),
660 Style::default()
661 .fg(r.status.color())
662 .add_modifier(Modifier::BOLD),
663 ),
664 ]));
665 if let Some(why) = r.why {
666 lines.push(Line::from(vec![
667 Span::raw(format!(" {} ", t.glyphs.continuation)),
668 Span::styled(
669 why,
670 Style::default()
671 .fg(t.dim)
672 .add_modifier(Modifier::ITALIC),
673 ),
674 ]));
675 }
676 }
677
678 let visual_cursor = row_starts.get(self.selected).copied().unwrap_or(0);
683 let body = table_chunks[1];
684 let visible_rows = body.height as usize;
685 self.scroll_offset = super::scroll::clamp_scroll(
686 visual_cursor,
687 self.scroll_offset,
688 visible_rows,
689 lines.len(),
690 );
691 frame.render_widget(
692 Paragraph::new(lines.clone()).scroll((self.scroll_offset as u16, 0)),
693 body,
694 );
695 super::scroll::render_scrollbar(
696 frame,
697 body,
698 self.scroll_offset,
699 visible_rows,
700 lines.len(),
701 );
702 }
703
704 fn draw_drill(&self, frame: &mut Frame, area: Rect, view: &StampDrillView) {
705 let t = theme::active();
706 let mut lines: Vec<Line> = Vec::new();
707 let total_buckets: u32 = view.fill_distribution.iter().sum();
709 lines.push(Line::from(vec![
710 Span::raw(" depth "),
711 Span::styled(
712 format!("{}", view.depth),
713 Style::default().add_modifier(Modifier::BOLD),
714 ),
715 Span::raw(" bucket-depth "),
716 Span::styled(
717 format!("{}", view.bucket_depth),
718 Style::default().add_modifier(Modifier::BOLD),
719 ),
720 Span::raw(" per-bucket cap "),
721 Span::styled(
722 format!("{}", view.upper_bound),
723 Style::default().add_modifier(Modifier::BOLD),
724 ),
725 Span::raw(" "),
726 Span::styled(
727 format!("{} buckets", total_buckets),
728 Style::default().fg(t.dim),
729 ),
730 ]));
731 lines.push(Line::from(vec![
732 Span::raw(" total chunks "),
733 Span::styled(
734 format!("{}", view.total_chunks),
735 Style::default().add_modifier(Modifier::BOLD),
736 ),
737 Span::raw(" / "),
738 Span::styled(
739 format!("{}", view.theoretical_capacity),
740 Style::default().fg(t.dim),
741 ),
742 Span::raw(" worst bucket "),
743 Span::styled(
744 format!("{}%", view.worst_pct),
745 Style::default()
746 .fg(bucket_color(view.worst_pct))
747 .add_modifier(Modifier::BOLD),
748 ),
749 ]));
750 lines.push(Line::from(""));
751
752 lines.push(Line::from(Span::styled(
754 " FILL % COUNT DISTRIBUTION",
755 Style::default()
756 .fg(t.dim)
757 .add_modifier(Modifier::BOLD),
758 )));
759 let max_bin = view.fill_distribution.iter().copied().max().unwrap_or(1).max(1);
760 for (idx, count) in view.fill_distribution.iter().enumerate() {
761 let label = FILL_BIN_LABELS[idx];
762 let bar_width = ((u64::from(*count) * 30) / u64::from(max_bin)) as usize;
763 let bar: String = std::iter::repeat_n('▇', bar_width).collect();
764 let bin_color = match idx {
768 5 => t.fail,
769 4 => t.warn,
770 _ => t.pass,
771 };
772 lines.push(Line::from(vec![
773 Span::raw(" "),
774 Span::raw(format!("{label:<10} ")),
775 Span::styled(
776 format!("{count:>5} "),
777 Style::default().add_modifier(Modifier::BOLD),
778 ),
779 Span::styled(bar, Style::default().fg(bin_color)),
780 ]));
781 }
782 lines.push(Line::from(""));
783
784 if !view.worst_buckets.is_empty() {
786 lines.push(Line::from(Span::styled(
787 " WORST BUCKETS",
788 Style::default()
789 .fg(t.dim)
790 .add_modifier(Modifier::BOLD),
791 )));
792 for w in &view.worst_buckets {
793 if w.collisions == 0 {
794 break;
797 }
798 lines.push(Line::from(vec![
799 Span::raw(" "),
800 Span::raw(format!("#{:<8}", w.bucket_id)),
801 Span::raw(format!(
802 "{:>4} / {} ",
803 w.collisions, view.upper_bound
804 )),
805 Span::styled(
806 format!("{}%", w.pct),
807 Style::default()
808 .fg(bucket_color(w.pct))
809 .add_modifier(Modifier::BOLD),
810 ),
811 ]));
812 }
813 }
814
815 frame.render_widget(Paragraph::new(lines), area);
816 }
817}
818
819fn truncate(s: &str, max: usize) -> String {
820 if s.chars().count() <= max {
821 s.to_string()
822 } else {
823 let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
824 out.push('…');
825 out
826 }
827}
828
829fn bucket_color(pct: u32) -> Color {
830 let t = theme::active();
831 if pct >= 95 {
832 t.fail
833 } else if pct >= 80 {
834 t.warn
835 } else {
836 t.pass
837 }
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843
844 fn buckets_with(counts: &[(u32, u32)], depth: u8, bucket_depth: u8) -> PostageBatchBuckets {
845 let upper_bound = 1u32 << (depth - bucket_depth);
846 let buckets = counts
847 .iter()
848 .map(|(id, c)| BatchBucket {
849 bucket_id: *id,
850 collisions: *c,
851 })
852 .collect();
853 PostageBatchBuckets {
854 depth,
855 bucket_depth,
856 bucket_upper_bound: upper_bound,
857 buckets,
858 }
859 }
860
861 #[test]
862 fn fill_bar_clamps_to_width() {
863 assert_eq!(fill_bar(0, 8), "░░░░░░░░");
864 assert_eq!(fill_bar(50, 8), "▇▇▇▇░░░░");
865 assert_eq!(fill_bar(100, 8), "▇▇▇▇▇▇▇▇");
866 assert_eq!(fill_bar(150, 8), "▇▇▇▇▇▇▇▇"); }
868
869 #[test]
870 fn format_bytes_iec() {
871 assert_eq!(format_bytes(0), "0 B");
872 assert_eq!(format_bytes(1024), "1.0 KiB");
873 assert_eq!(format_bytes(1024 * 1024), "1.0 MiB");
874 assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GiB");
875 assert_eq!(format_bytes(16 * 1024 * 1024 * 1024), "16.0 GiB");
876 }
877
878 #[test]
879 fn format_ttl_zero_is_expired() {
880 assert_eq!(format_ttl_seconds(0), "expired");
881 assert_eq!(format_ttl_seconds(-5), "expired");
882 }
883
884 #[test]
885 fn format_ttl_days_and_hours() {
886 assert_eq!(format_ttl_seconds(47 * 86_400 + 12 * 3_600), "47d 12h");
888 }
889
890 #[test]
891 fn format_ttl_under_a_day_uses_hours_minutes() {
892 assert_eq!(format_ttl_seconds(2 * 3_600 + 30 * 60), "2h 30m");
893 }
894
895 #[test]
896 fn drill_view_bins_and_worst_n() {
897 let buckets = buckets_with(
901 &[
902 (0, 64), (1, 60), (2, 40), (3, 20), (4, 1), (5, 0), ],
909 22,
910 16,
911 );
912 let view = Stamps::compute_drill_view(&buckets);
913 assert_eq!(view.depth, 22);
914 assert_eq!(view.bucket_depth, 16);
915 assert_eq!(view.upper_bound, 64);
916 assert_eq!(view.total_chunks, 64 + 60 + 40 + 20 + 1);
917 assert_eq!(view.fill_distribution, [1, 1, 1, 1, 1, 1]);
918 assert_eq!(view.worst_pct, 100);
919 assert_eq!(view.worst_buckets.len(), 6);
925 assert_eq!(view.worst_buckets[0].bucket_id, 0);
926 assert_eq!(view.worst_buckets[0].pct, 100);
927 assert_eq!(view.worst_buckets[1].bucket_id, 1);
928 assert_eq!(view.worst_buckets[1].pct, 93);
929 }
930
931 #[test]
932 fn drill_view_handles_empty_buckets() {
933 let buckets = buckets_with(&[], 22, 16);
934 let view = Stamps::compute_drill_view(&buckets);
935 assert_eq!(view.total_chunks, 0);
936 assert_eq!(view.fill_distribution, [0; 6]);
937 assert_eq!(view.worst_pct, 0);
938 assert!(view.worst_buckets.is_empty());
939 }
940
941 #[test]
942 fn drill_view_caps_worst_at_ten() {
943 let entries: Vec<(u32, u32)> = (0..12).map(|i| (i, 1)).collect();
946 let buckets = buckets_with(&entries, 22, 16);
947 let view = Stamps::compute_drill_view(&buckets);
948 assert_eq!(view.worst_buckets.len(), 10);
949 }
950
951 #[test]
952 fn drill_view_breaks_ties_by_bucket_id() {
953 let buckets = buckets_with(&[(7, 5), (3, 5), (10, 5)], 22, 16);
954 let view = Stamps::compute_drill_view(&buckets);
955 assert_eq!(
957 view.worst_buckets
958 .iter()
959 .map(|w| w.bucket_id)
960 .collect::<Vec<_>>(),
961 vec![3, 7, 10],
962 );
963 }
964
965 #[test]
966 fn fill_bin_handles_overflow_collisions() {
967 assert_eq!(bucket_fill_bin(70, 64), 5);
970 }
971}