1use std::sync::Arc;
29
30use color_eyre::Result;
31use crossterm::event::{KeyCode, KeyEvent};
32use num_bigint::BigInt;
33use ratatui::{
34 Frame,
35 layout::{Constraint, Layout, Rect},
36 style::{Color, Modifier, Style},
37 text::{Line, Span},
38 widgets::{Block, Borders, Paragraph},
39};
40use tokio::sync::{mpsc, watch};
41
42use super::Component;
43use super::swap::format_plur;
44use crate::action::Action;
45use crate::api::ApiClient;
46use crate::theme;
47use crate::watch::{HealthSnapshot, LotterySnapshot};
48
49use bee::debug::{RCHashResponse, RedistributionState};
50
51pub const BLOCKS_PER_ROUND: u64 = 152;
53pub const BLOCKS_PER_PHASE: u64 = 38;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Phase {
61 Commit,
62 Reveal,
63 Claim,
64 Sample,
67 Unknown,
68}
69
70impl Phase {
71 fn from_api(s: &str) -> Self {
72 match s {
73 "commit" => Self::Commit,
74 "reveal" => Self::Reveal,
75 "claim" => Self::Claim,
76 "sample" => Self::Sample,
77 _ => Self::Unknown,
78 }
79 }
80
81 fn label(self) -> &'static str {
82 match self {
83 Self::Commit => "commit",
84 Self::Reveal => "reveal",
85 Self::Claim => "claim",
86 Self::Sample => "sample",
87 Self::Unknown => "?",
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum PhaseState {
95 Done,
97 Active,
99 Pending,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct PhaseSegment {
106 pub phase: Phase,
107 pub state: PhaseState,
108 pub start_block: u64,
110 pub end_block: u64,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct RoundCard {
117 pub round: u64,
118 pub block: u64,
119 pub block_of_round: u64,
121 pub phase: Phase,
123 pub phase_label: &'static str,
124 pub segments: Vec<PhaseSegment>,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct AnchorRow {
130 pub label: &'static str,
131 pub round: u64,
132 pub delta: Option<u64>,
135 pub when: String,
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum StakeStatus {
142 Unstaked,
144 InsufficientGas,
147 Frozen,
149 Unhealthy,
151 Healthy,
153 Unknown,
155}
156
157impl StakeStatus {
158 fn color(self) -> Color {
159 match self {
160 Self::Unstaked => theme::active().fail,
161 Self::InsufficientGas => theme::active().warn,
162 Self::Frozen => theme::active().fail,
163 Self::Unhealthy => theme::active().warn,
164 Self::Healthy => theme::active().pass,
165 Self::Unknown => theme::active().dim,
166 }
167 }
168 fn label(self) -> &'static str {
169 match self {
170 Self::Unstaked => "✗ unstaked",
171 Self::InsufficientGas => "⚠ low gas",
172 Self::Frozen => "✗ frozen",
173 Self::Unhealthy => "⚠ unhealthy",
174 Self::Healthy => "✓ healthy",
175 Self::Unknown => "? unknown",
176 }
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct StakeCard {
183 pub status: StakeStatus,
184 pub staked: String,
186 pub minimum_gas: String,
187 pub reward: String,
189 pub fees: String,
190 pub last_sample: Option<String>,
192 pub why: Option<String>,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct LotteryView {
199 pub round: Option<RoundCard>,
200 pub anchors: Vec<AnchorRow>,
201 pub stake: StakeCard,
202}
203
204#[derive(Debug, Clone, PartialEq)]
209pub enum BenchState {
210 Idle,
211 Running,
212 Done {
213 duration_seconds: f64,
214 hash: String,
217 },
218 Failed {
219 error: String,
220 },
221}
222
223const BENCH_ANCHOR_LO: &str = "0000000000000000000000000000000000000000000000000000000000000000";
227const BENCH_ANCHOR_HI: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
228const BENCH_DEFAULT_DEPTH: u8 = 8;
232
233pub fn bench_depth(health: &HealthSnapshot) -> u8 {
239 let raw = health
240 .status
241 .as_ref()
242 .map(|s| s.storage_radius)
243 .unwrap_or(-1);
244 if raw <= 0 {
245 BENCH_DEFAULT_DEPTH
246 } else {
247 raw.min(255) as u8
248 }
249}
250
251pub struct Lottery {
252 client: Arc<ApiClient>,
253 health_rx: watch::Receiver<HealthSnapshot>,
254 lottery_rx: watch::Receiver<LotterySnapshot>,
255 health: HealthSnapshot,
256 lottery: LotterySnapshot,
257 bench: BenchState,
258 bench_tx: mpsc::UnboundedSender<Result<RCHashResponse, String>>,
259 bench_rx: mpsc::UnboundedReceiver<Result<RCHashResponse, String>>,
260}
261
262impl Lottery {
263 pub fn new(
264 client: Arc<ApiClient>,
265 health_rx: watch::Receiver<HealthSnapshot>,
266 lottery_rx: watch::Receiver<LotterySnapshot>,
267 ) -> Self {
268 let health = health_rx.borrow().clone();
269 let lottery = lottery_rx.borrow().clone();
270 let (bench_tx, bench_rx) = mpsc::unbounded_channel();
271 Self {
272 client,
273 health_rx,
274 lottery_rx,
275 health,
276 lottery,
277 bench: BenchState::Idle,
278 bench_tx,
279 bench_rx,
280 }
281 }
282
283 fn pull_latest(&mut self) {
284 self.health = self.health_rx.borrow().clone();
285 self.lottery = self.lottery_rx.borrow().clone();
286 }
287
288 fn drain_bench_results(&mut self) {
290 while let Ok(result) = self.bench_rx.try_recv() {
291 self.bench = match result {
292 Ok(resp) => BenchState::Done {
293 duration_seconds: resp.duration_seconds,
294 hash: resp.hash,
295 },
296 Err(e) => BenchState::Failed { error: e },
297 };
298 }
299 }
300
301 fn maybe_start_bench(&mut self) -> bool {
304 if matches!(self.bench, BenchState::Running) {
305 return false;
306 }
307 let depth = bench_depth(&self.health);
308 let client = self.client.clone();
309 let tx = self.bench_tx.clone();
310 tokio::spawn(async move {
311 let res = client
312 .bee()
313 .debug()
314 .r_chash(depth, BENCH_ANCHOR_LO, BENCH_ANCHOR_HI)
315 .await
316 .map_err(|e| e.to_string());
317 let _ = tx.send(res);
318 });
319 self.bench = BenchState::Running;
320 true
321 }
322
323 pub fn view_for(health: &HealthSnapshot, lottery: &LotterySnapshot) -> LotteryView {
326 let round = health.redistribution.as_ref().map(round_card_for);
327 let anchors = health
328 .redistribution
329 .as_ref()
330 .map(anchor_rows_for)
331 .unwrap_or_default();
332 let stake = stake_card_for(health.redistribution.as_ref(), lottery);
333 LotteryView {
334 round,
335 anchors,
336 stake,
337 }
338 }
339}
340
341fn round_card_for(r: &RedistributionState) -> RoundCard {
342 let block_of_round = r.block % BLOCKS_PER_ROUND;
343 let phase = Phase::from_api(&r.phase);
344
345 let segments = build_phase_segments(phase, block_of_round);
349
350 RoundCard {
351 round: r.round,
352 block: r.block,
353 block_of_round,
354 phase,
355 phase_label: phase.label(),
356 segments,
357 }
358}
359
360fn build_phase_segments(current: Phase, block_of_round: u64) -> Vec<PhaseSegment> {
361 let phases = [
362 (Phase::Commit, 0u64, BLOCKS_PER_PHASE),
363 (Phase::Reveal, BLOCKS_PER_PHASE, 2 * BLOCKS_PER_PHASE),
364 (Phase::Claim, 2 * BLOCKS_PER_PHASE, BLOCKS_PER_ROUND),
365 ];
366 phases
367 .iter()
368 .map(|&(p, start, end)| PhaseSegment {
369 phase: p,
370 state: phase_state_for(p, current, block_of_round, start, end),
371 start_block: start,
372 end_block: end,
373 })
374 .collect()
375}
376
377fn phase_state_for(
378 seg: Phase,
379 current: Phase,
380 block_of_round: u64,
381 start: u64,
382 end: u64,
383) -> PhaseState {
384 if current == seg {
388 return PhaseState::Active;
389 }
390 if matches!(current, Phase::Sample) {
391 return PhaseState::Done;
392 }
393 if block_of_round >= end {
394 PhaseState::Done
395 } else if block_of_round < start {
396 PhaseState::Pending
397 } else {
398 PhaseState::Active
402 }
403}
404
405fn anchor_rows_for(r: &RedistributionState) -> Vec<AnchorRow> {
406 let current = r.round;
407 let make = |label: &'static str, anchor: u64| AnchorRow {
408 label,
409 round: anchor,
410 delta: if anchor == 0 || anchor > current {
411 None
412 } else {
413 Some(current - anchor)
414 },
415 when: format_when(current, anchor),
416 };
417 vec![
418 make("last won", r.last_won_round),
419 make("last played", r.last_played_round),
420 make("last selected", r.last_selected_round),
421 make("last frozen", r.last_frozen_round),
422 ]
423}
424
425fn format_when(current: u64, anchor: u64) -> String {
426 if anchor == 0 {
427 return "never".into();
428 }
429 if anchor > current {
430 return format!("round {anchor} (future)");
431 }
432 let delta = current - anchor;
433 match delta {
434 0 => "this round".into(),
435 1 => "last round".into(),
436 n => format!("{n} rounds ago"),
437 }
438}
439
440fn stake_card_for(r: Option<&RedistributionState>, lottery: &LotterySnapshot) -> StakeCard {
441 let zero = BigInt::from(0);
442 let staked_bi = lottery.staked.as_ref();
443 let staked_str = staked_bi.map(format_plur).unwrap_or_else(|| "—".into());
444
445 let (minimum_gas, reward, fees, last_sample, status_inputs) = match r {
446 Some(r) => (
447 r.minimum_gas_funds
448 .as_ref()
449 .map(format_plur)
450 .unwrap_or_else(|| "—".into()),
451 r.reward
452 .as_ref()
453 .map(format_plur)
454 .unwrap_or_else(|| "—".into()),
455 r.fees
456 .as_ref()
457 .map(format_plur)
458 .unwrap_or_else(|| "—".into()),
459 (r.last_sample_duration_seconds > 0.0)
460 .then(|| format!("{:.1}s", r.last_sample_duration_seconds)),
461 Some((
462 r.is_frozen,
463 r.is_healthy,
464 r.has_sufficient_funds,
465 r.is_fully_synced,
466 r.last_frozen_round,
467 )),
468 ),
469 None => ("—".into(), "—".into(), "—".into(), None, None),
470 };
471
472 let (status, why) = match (lottery.last_error.as_deref(), staked_bi, status_inputs) {
473 (Some(e), _, _) => (StakeStatus::Unknown, Some(format!("/stake error: {e}"))),
474 (_, None, _) => (StakeStatus::Unknown, Some("/stake not loaded yet".into())),
475 (_, Some(s), Some((frozen, healthy, sufficient, synced, last_frozen))) if s == &zero => {
476 let _ = (frozen, healthy, sufficient, synced, last_frozen);
477 (
478 StakeStatus::Unstaked,
479 Some("0 BZZ staked — node cannot participate in redistribution.".into()),
480 )
481 }
482 (_, Some(_), Some((true, _, _, _, last_frozen))) => (
483 StakeStatus::Frozen,
484 Some(format!(
485 "frozen out at round {last_frozen}; resumes after the freeze window."
486 )),
487 ),
488 (_, Some(_), Some((_, _, false, _, _))) => (
489 StakeStatus::InsufficientGas,
490 Some("operator wallet has too little native token to play a round.".into()),
491 ),
492 (_, Some(_), Some((_, false, _, _, _))) => (
493 StakeStatus::Unhealthy,
494 Some("redistribution worker reports unhealthy — see Health screen.".into()),
495 ),
496 (_, Some(_), Some((_, _, _, false, _))) => (
497 StakeStatus::Unhealthy,
498 Some("node is not fully synced — sampling will skip until it catches up.".into()),
499 ),
500 (_, Some(_), Some(_)) => (StakeStatus::Healthy, None),
501 (_, Some(s), None) if s == &zero => (
503 StakeStatus::Unstaked,
504 Some("0 BZZ staked — node cannot participate in redistribution.".into()),
505 ),
506 (_, Some(_), None) => (
507 StakeStatus::Unknown,
508 Some("redistribution state not loaded yet".into()),
509 ),
510 };
511
512 StakeCard {
513 status,
514 staked: staked_str,
515 minimum_gas,
516 reward,
517 fees,
518 last_sample,
519 why,
520 }
521}
522
523impl Component for Lottery {
524 fn update(&mut self, action: Action) -> Result<Option<Action>> {
525 if matches!(action, Action::Tick) {
526 self.pull_latest();
527 self.drain_bench_results();
528 }
529 Ok(None)
530 }
531
532 fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
533 if matches!(key.code, KeyCode::Char('r')) {
534 self.maybe_start_bench();
535 }
536 Ok(None)
537 }
538
539 fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
540 let chunks = Layout::vertical([
541 Constraint::Length(3), Constraint::Length(6), Constraint::Length(7), Constraint::Min(0), Constraint::Length(1), ])
547 .split(area);
548
549 let header_l1 = Line::from(vec![Span::styled(
551 "LOTTERY / REDISTRIBUTION",
552 Style::default().add_modifier(Modifier::BOLD),
553 )]);
554 let mut header_l2 = Vec::new();
555 let t = theme::active();
556 if let Some(err) = &self.lottery.last_error {
557 let (color, msg) = theme::classify_header_error(err);
558 header_l2.push(Span::styled(msg, Style::default().fg(color)));
559 } else if !self.lottery.is_loaded() {
560 header_l2.push(Span::styled(
561 format!("{} loading…", theme::spinner_glyph()),
562 Style::default().fg(t.dim),
563 ));
564 }
565 frame.render_widget(
566 Paragraph::new(vec![header_l1, Line::from(header_l2)])
567 .block(Block::default().borders(Borders::BOTTOM)),
568 chunks[0],
569 );
570
571 let view = Self::view_for(&self.health, &self.lottery);
572
573 let mut round_lines: Vec<Line> = Vec::new();
575 if let Some(rc) = &view.round {
576 round_lines.push(Line::from(vec![
577 Span::styled(
578 format!(" Round {} ", rc.round),
579 Style::default().add_modifier(Modifier::BOLD),
580 ),
581 Span::styled(
582 format!(
583 "· phase {} · block-of-round {}/{BLOCKS_PER_ROUND}",
584 rc.phase_label, rc.block_of_round
585 ),
586 Style::default().fg(t.dim),
587 ),
588 ]));
589 round_lines.push(Line::from(segment_spans(&rc.segments)));
590 round_lines.push(Line::from(progress_bar_spans(rc)));
591 } else {
592 round_lines.push(Line::from(Span::styled(
593 " (redistribution state not loaded yet)",
594 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
595 )));
596 }
597 frame.render_widget(
598 Paragraph::new(round_lines).block(Block::default().borders(Borders::BOTTOM)),
599 chunks[1],
600 );
601
602 let mut anchor_lines: Vec<Line> = vec![Line::from(Span::styled(
604 " ANCHORS ROUND WHEN",
605 Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
606 ))];
607 if view.anchors.is_empty() {
608 anchor_lines.push(Line::from(Span::styled(
609 " (no anchor data)",
610 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
611 )));
612 } else {
613 for a in &view.anchors {
614 let round_str = if a.round == 0 {
615 "—".to_string()
616 } else {
617 a.round.to_string()
618 };
619 anchor_lines.push(Line::from(vec![
620 Span::raw(" "),
621 Span::styled(
622 format!("{:<14} ", a.label),
623 Style::default().add_modifier(Modifier::BOLD),
624 ),
625 Span::raw(format!("{round_str:<11} ")),
626 Span::styled(a.when.clone(), Style::default().fg(t.dim)),
627 ]));
628 }
629 }
630 frame.render_widget(
631 Paragraph::new(anchor_lines).block(Block::default().borders(Borders::BOTTOM)),
632 chunks[2],
633 );
634
635 let stake = &view.stake;
637 let mut stake_lines = vec![
638 Line::from(vec![
639 Span::styled(" Stake ", Style::default().add_modifier(Modifier::BOLD)),
640 Span::styled(
641 stake.status.label(),
642 Style::default()
643 .fg(stake.status.color())
644 .add_modifier(Modifier::BOLD),
645 ),
646 ]),
647 Line::from(vec![
648 Span::raw(format!(" staked {}", stake.staked)),
649 Span::raw(" "),
650 Span::raw(format!("min gas funds {}", stake.minimum_gas)),
651 ]),
652 Line::from(vec![
653 Span::raw(format!(" reward {}", stake.reward)),
654 Span::raw(" "),
655 Span::raw(format!("fees {}", stake.fees)),
656 ]),
657 ];
658 if let Some(sample) = &stake.last_sample {
659 stake_lines.push(Line::from(vec![
660 Span::raw(" last sample "),
661 Span::styled(sample.clone(), Style::default().fg(t.info)),
662 Span::styled(
663 " (deadline ≈ 95s for the 38-block commit window)",
664 Style::default().fg(t.dim),
665 ),
666 ]));
667 }
668 if let Some(why) = &stake.why {
669 stake_lines.push(Line::from(vec![
670 Span::raw(" └─ "),
671 Span::styled(
672 why.clone(),
673 Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
674 ),
675 ]));
676 }
677 let depth = bench_depth(&self.health);
680 stake_lines.push(Line::from(""));
681 stake_lines.push(Line::from(vec![
682 Span::styled(
683 " rchash bench ",
684 Style::default().add_modifier(Modifier::BOLD),
685 ),
686 Span::styled(
687 format!("(depth {depth}, deterministic anchors)"),
688 Style::default().fg(t.dim),
689 ),
690 ]));
691 match &self.bench {
692 BenchState::Idle => {
693 stake_lines.push(Line::from(vec![
694 Span::raw(" "),
695 Span::styled("press 'r' to run a sample", Style::default().fg(t.dim)),
696 ]));
697 }
698 BenchState::Running => {
699 stake_lines.push(Line::from(vec![
700 Span::raw(" "),
701 Span::styled(
702 "running… (this can take seconds-to-minutes on a busy reserve)",
703 Style::default().fg(t.info),
704 ),
705 ]));
706 }
707 BenchState::Done {
708 duration_seconds,
709 hash,
710 } => {
711 let prefix: String = hash.chars().take(8).collect();
712 let safe = *duration_seconds < 95.0;
713 let style = if safe {
714 Style::default().fg(t.pass)
715 } else {
716 Style::default().fg(t.fail).add_modifier(Modifier::BOLD)
717 };
718 let verdict = if safe {
719 "safe — fits inside the 95 s commit window"
720 } else {
721 "OVER 95 s commit window — sampler will time out!"
722 };
723 stake_lines.push(Line::from(vec![
724 Span::raw(" "),
725 Span::styled(format!("{duration_seconds:.1}s"), style),
726 Span::raw(format!(" hash {prefix}… ")),
727 Span::styled(verdict, Style::default().fg(t.dim)),
728 ]));
729 }
730 BenchState::Failed { error } => {
731 stake_lines.push(Line::from(vec![
732 Span::raw(" "),
733 Span::styled(format!("error: {error}"), Style::default().fg(t.fail)),
734 ]));
735 }
736 }
737 frame.render_widget(Paragraph::new(stake_lines), chunks[3]);
738
739 frame.render_widget(
741 Paragraph::new(Line::from(vec![
742 Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
743 Span::raw(" switch screen "),
744 Span::styled(" r ", Style::default().fg(Color::Black).bg(Color::White)),
745 Span::raw(" run rchash benchmark "),
746 Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
747 Span::raw(" help "),
748 Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
749 Span::raw(" quit "),
750 ])),
751 chunks[4],
752 );
753
754 Ok(())
755 }
756}
757
758fn segment_spans(segs: &[PhaseSegment]) -> Vec<Span<'static>> {
759 let t = theme::active();
760 let mut out = vec![Span::raw(" ")];
761 for (i, s) in segs.iter().enumerate() {
762 let color = match s.state {
763 PhaseState::Done => t.dim,
764 PhaseState::Active => t.warn,
765 PhaseState::Pending => Color::White,
766 };
767 let modifier = if matches!(s.state, PhaseState::Active) {
768 Modifier::BOLD
769 } else {
770 Modifier::empty()
771 };
772 out.push(Span::styled(
773 format!(" {} {}..{} ", s.phase.label(), s.start_block, s.end_block),
774 Style::default().fg(color).add_modifier(modifier),
775 ));
776 if i + 1 < segs.len() {
777 out.push(Span::styled("│", Style::default().fg(t.dim)));
778 }
779 }
780 out
781}
782
783fn progress_bar_spans(rc: &RoundCard) -> Vec<Span<'static>> {
784 const WIDTH: usize = 24;
786 let filled = ((rc.block_of_round as usize) * WIDTH) / BLOCKS_PER_ROUND as usize;
787 let mut bar = String::with_capacity(WIDTH);
788 for _ in 0..filled.min(WIDTH) {
789 bar.push('▇');
790 }
791 for _ in filled.min(WIDTH)..WIDTH {
792 bar.push('░');
793 }
794 vec![
795 Span::raw(" "),
796 Span::styled(bar, Style::default().fg(theme::active().warn)),
797 Span::raw(format!(" {}/{BLOCKS_PER_ROUND}", rc.block_of_round)),
798 ]
799}
800
801#[cfg(test)]
802mod tests {
803 use super::*;
804
805 #[test]
806 fn phase_from_api_known() {
807 assert_eq!(Phase::from_api("commit"), Phase::Commit);
808 assert_eq!(Phase::from_api("reveal"), Phase::Reveal);
809 assert_eq!(Phase::from_api("claim"), Phase::Claim);
810 assert_eq!(Phase::from_api("sample"), Phase::Sample);
811 }
812
813 #[test]
814 fn phase_from_api_unknown_falls_back() {
815 assert_eq!(Phase::from_api(""), Phase::Unknown);
816 assert_eq!(Phase::from_api("garbage"), Phase::Unknown);
817 }
818
819 #[test]
820 fn phase_segments_during_commit() {
821 let segs = build_phase_segments(Phase::Commit, 10);
822 assert_eq!(segs[0].state, PhaseState::Active);
823 assert_eq!(segs[1].state, PhaseState::Pending);
824 assert_eq!(segs[2].state, PhaseState::Pending);
825 }
826
827 #[test]
828 fn phase_segments_during_reveal() {
829 let segs = build_phase_segments(Phase::Reveal, 50);
830 assert_eq!(segs[0].state, PhaseState::Done);
831 assert_eq!(segs[1].state, PhaseState::Active);
832 assert_eq!(segs[2].state, PhaseState::Pending);
833 }
834
835 #[test]
836 fn phase_segments_during_claim() {
837 let segs = build_phase_segments(Phase::Claim, 100);
838 assert_eq!(segs[0].state, PhaseState::Done);
839 assert_eq!(segs[1].state, PhaseState::Done);
840 assert_eq!(segs[2].state, PhaseState::Active);
841 }
842
843 #[test]
844 fn phase_segments_during_sample() {
845 let segs = build_phase_segments(Phase::Sample, 0);
847 for s in &segs {
848 assert_eq!(s.state, PhaseState::Done);
849 }
850 }
851
852 #[test]
853 fn format_when_handles_zero() {
854 assert_eq!(format_when(100, 0), "never");
855 }
856
857 #[test]
858 fn format_when_handles_current() {
859 assert_eq!(format_when(100, 100), "this round");
860 }
861
862 #[test]
863 fn format_when_handles_n_ago() {
864 assert_eq!(format_when(100, 95), "5 rounds ago");
865 }
866
867 #[test]
868 fn bench_depth_falls_back_when_status_missing() {
869 assert_eq!(bench_depth(&HealthSnapshot::default()), BENCH_DEFAULT_DEPTH);
870 }
871
872 #[test]
873 fn bench_depth_falls_back_on_sentinel() {
874 let snap = HealthSnapshot {
876 status: Some(bee::debug::Status {
877 storage_radius: -1,
878 ..bee::debug::Status::default()
879 }),
880 ..HealthSnapshot::default()
881 };
882 assert_eq!(bench_depth(&snap), BENCH_DEFAULT_DEPTH);
883 }
884
885 #[test]
886 fn bench_depth_uses_storage_radius_when_present() {
887 let snap = HealthSnapshot {
888 status: Some(bee::debug::Status {
889 storage_radius: 12,
890 ..bee::debug::Status::default()
891 }),
892 ..HealthSnapshot::default()
893 };
894 assert_eq!(bench_depth(&snap), 12);
895 }
896}