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