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 =
227 "0000000000000000000000000000000000000000000000000000000000000000";
228const BENCH_ANCHOR_HI: &str =
229 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
230const BENCH_DEFAULT_DEPTH: u8 = 8;
234
235pub fn bench_depth(health: &HealthSnapshot) -> u8 {
241 let raw = health
242 .status
243 .as_ref()
244 .map(|s| s.storage_radius)
245 .unwrap_or(-1);
246 if raw <= 0 {
247 BENCH_DEFAULT_DEPTH
248 } else {
249 raw.min(255) as u8
250 }
251}
252
253pub struct Lottery {
254 client: Arc<ApiClient>,
255 health_rx: watch::Receiver<HealthSnapshot>,
256 lottery_rx: watch::Receiver<LotterySnapshot>,
257 health: HealthSnapshot,
258 lottery: LotterySnapshot,
259 bench: BenchState,
260 bench_tx: mpsc::UnboundedSender<Result<RCHashResponse, String>>,
261 bench_rx: mpsc::UnboundedReceiver<Result<RCHashResponse, String>>,
262}
263
264impl Lottery {
265 pub fn new(
266 client: Arc<ApiClient>,
267 health_rx: watch::Receiver<HealthSnapshot>,
268 lottery_rx: watch::Receiver<LotterySnapshot>,
269 ) -> Self {
270 let health = health_rx.borrow().clone();
271 let lottery = lottery_rx.borrow().clone();
272 let (bench_tx, bench_rx) = mpsc::unbounded_channel();
273 Self {
274 client,
275 health_rx,
276 lottery_rx,
277 health,
278 lottery,
279 bench: BenchState::Idle,
280 bench_tx,
281 bench_rx,
282 }
283 }
284
285 fn pull_latest(&mut self) {
286 self.health = self.health_rx.borrow().clone();
287 self.lottery = self.lottery_rx.borrow().clone();
288 }
289
290 fn drain_bench_results(&mut self) {
292 while let Ok(result) = self.bench_rx.try_recv() {
293 self.bench = match result {
294 Ok(resp) => BenchState::Done {
295 duration_seconds: resp.duration_seconds,
296 hash: resp.hash,
297 },
298 Err(e) => BenchState::Failed { error: e },
299 };
300 }
301 }
302
303 fn maybe_start_bench(&mut self) -> bool {
306 if matches!(self.bench, BenchState::Running) {
307 return false;
308 }
309 let depth = bench_depth(&self.health);
310 let client = self.client.clone();
311 let tx = self.bench_tx.clone();
312 tokio::spawn(async move {
313 let res = client
314 .bee()
315 .debug()
316 .r_chash(depth, BENCH_ANCHOR_LO, BENCH_ANCHOR_HI)
317 .await
318 .map_err(|e| e.to_string());
319 let _ = tx.send(res);
320 });
321 self.bench = BenchState::Running;
322 true
323 }
324
325 pub fn view_for(health: &HealthSnapshot, lottery: &LotterySnapshot) -> LotteryView {
328 let round = health.redistribution.as_ref().map(round_card_for);
329 let anchors = health
330 .redistribution
331 .as_ref()
332 .map(anchor_rows_for)
333 .unwrap_or_default();
334 let stake = stake_card_for(health.redistribution.as_ref(), lottery);
335 LotteryView {
336 round,
337 anchors,
338 stake,
339 }
340 }
341}
342
343fn round_card_for(r: &RedistributionState) -> RoundCard {
344 let block_of_round = r.block % BLOCKS_PER_ROUND;
345 let phase = Phase::from_api(&r.phase);
346
347 let segments = build_phase_segments(phase, block_of_round);
351
352 RoundCard {
353 round: r.round,
354 block: r.block,
355 block_of_round,
356 phase,
357 phase_label: phase.label(),
358 segments,
359 }
360}
361
362fn build_phase_segments(current: Phase, block_of_round: u64) -> Vec<PhaseSegment> {
363 let phases = [
364 (Phase::Commit, 0u64, BLOCKS_PER_PHASE),
365 (Phase::Reveal, BLOCKS_PER_PHASE, 2 * BLOCKS_PER_PHASE),
366 (Phase::Claim, 2 * BLOCKS_PER_PHASE, BLOCKS_PER_ROUND),
367 ];
368 phases
369 .iter()
370 .map(|&(p, start, end)| PhaseSegment {
371 phase: p,
372 state: phase_state_for(p, current, block_of_round, start, end),
373 start_block: start,
374 end_block: end,
375 })
376 .collect()
377}
378
379fn phase_state_for(
380 seg: Phase,
381 current: Phase,
382 block_of_round: u64,
383 start: u64,
384 end: u64,
385) -> PhaseState {
386 if current == seg {
390 return PhaseState::Active;
391 }
392 if matches!(current, Phase::Sample) {
393 return PhaseState::Done;
394 }
395 if block_of_round >= end {
396 PhaseState::Done
397 } else if block_of_round < start {
398 PhaseState::Pending
399 } else {
400 PhaseState::Active
404 }
405}
406
407fn anchor_rows_for(r: &RedistributionState) -> Vec<AnchorRow> {
408 let current = r.round;
409 let make = |label: &'static str, anchor: u64| AnchorRow {
410 label,
411 round: anchor,
412 delta: if anchor == 0 || anchor > current {
413 None
414 } else {
415 Some(current - anchor)
416 },
417 when: format_when(current, anchor),
418 };
419 vec![
420 make("last won", r.last_won_round),
421 make("last played", r.last_played_round),
422 make("last selected", r.last_selected_round),
423 make("last frozen", r.last_frozen_round),
424 ]
425}
426
427fn format_when(current: u64, anchor: u64) -> String {
428 if anchor == 0 {
429 return "never".into();
430 }
431 if anchor > current {
432 return format!("round {anchor} (future)");
433 }
434 let delta = current - anchor;
435 match delta {
436 0 => "this round".into(),
437 1 => "last round".into(),
438 n => format!("{n} rounds ago"),
439 }
440}
441
442fn stake_card_for(r: Option<&RedistributionState>, lottery: &LotterySnapshot) -> StakeCard {
443 let zero = BigInt::from(0);
444 let staked_bi = lottery.staked.as_ref();
445 let staked_str = staked_bi.map(format_plur).unwrap_or_else(|| "—".into());
446
447 let (minimum_gas, reward, fees, last_sample, status_inputs) = match r {
448 Some(r) => (
449 r.minimum_gas_funds
450 .as_ref()
451 .map(format_plur)
452 .unwrap_or_else(|| "—".into()),
453 r.reward
454 .as_ref()
455 .map(format_plur)
456 .unwrap_or_else(|| "—".into()),
457 r.fees
458 .as_ref()
459 .map(format_plur)
460 .unwrap_or_else(|| "—".into()),
461 (r.last_sample_duration_seconds > 0.0)
462 .then(|| format!("{:.1}s", r.last_sample_duration_seconds)),
463 Some((
464 r.is_frozen,
465 r.is_healthy,
466 r.has_sufficient_funds,
467 r.is_fully_synced,
468 r.last_frozen_round,
469 )),
470 ),
471 None => (
472 "—".into(),
473 "—".into(),
474 "—".into(),
475 None,
476 None,
477 ),
478 };
479
480 let (status, why) = match (lottery.last_error.as_deref(), staked_bi, status_inputs) {
481 (Some(e), _, _) => (StakeStatus::Unknown, Some(format!("/stake error: {e}"))),
482 (_, None, _) => (StakeStatus::Unknown, Some("/stake not loaded yet".into())),
483 (_, Some(s), Some((frozen, healthy, sufficient, synced, last_frozen)))
484 if s == &zero =>
485 {
486 let _ = (frozen, healthy, sufficient, synced, last_frozen);
487 (
488 StakeStatus::Unstaked,
489 Some("0 BZZ staked — node cannot participate in redistribution.".into()),
490 )
491 }
492 (_, Some(_), Some((true, _, _, _, last_frozen))) => (
493 StakeStatus::Frozen,
494 Some(format!(
495 "frozen out at round {last_frozen}; resumes after the freeze window."
496 )),
497 ),
498 (_, Some(_), Some((_, _, false, _, _))) => (
499 StakeStatus::InsufficientGas,
500 Some("operator wallet has too little native token to play a round.".into()),
501 ),
502 (_, Some(_), Some((_, false, _, _, _))) => (
503 StakeStatus::Unhealthy,
504 Some("redistribution worker reports unhealthy — see Health screen.".into()),
505 ),
506 (_, Some(_), Some((_, _, _, false, _))) => (
507 StakeStatus::Unhealthy,
508 Some("node is not fully synced — sampling will skip until it catches up.".into()),
509 ),
510 (_, Some(_), Some(_)) => (StakeStatus::Healthy, None),
511 (_, Some(s), None) if s == &zero => (
513 StakeStatus::Unstaked,
514 Some("0 BZZ staked — node cannot participate in redistribution.".into()),
515 ),
516 (_, Some(_), None) => (
517 StakeStatus::Unknown,
518 Some("redistribution state not loaded yet".into()),
519 ),
520 };
521
522 StakeCard {
523 status,
524 staked: staked_str,
525 minimum_gas,
526 reward,
527 fees,
528 last_sample,
529 why,
530 }
531}
532
533impl Component for Lottery {
534 fn update(&mut self, action: Action) -> Result<Option<Action>> {
535 if matches!(action, Action::Tick) {
536 self.pull_latest();
537 self.drain_bench_results();
538 }
539 Ok(None)
540 }
541
542 fn handle_key_event(&mut self, key: KeyEvent) -> Result<Option<Action>> {
543 if matches!(key.code, KeyCode::Char('r')) {
544 self.maybe_start_bench();
545 }
546 Ok(None)
547 }
548
549 fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
550 let chunks = Layout::vertical([
551 Constraint::Length(3), Constraint::Length(6), Constraint::Length(7), Constraint::Min(0), Constraint::Length(1), ])
557 .split(area);
558
559 let header_l1 = Line::from(vec![Span::styled(
561 "LOTTERY / REDISTRIBUTION",
562 Style::default().add_modifier(Modifier::BOLD),
563 )]);
564 let mut header_l2 = Vec::new();
565 let t = theme::active();
566 if let Some(err) = &self.lottery.last_error {
567 let (color, msg) = theme::classify_header_error(err);
568 header_l2.push(Span::styled(msg, Style::default().fg(color)));
569 } else if !self.lottery.is_loaded() {
570 header_l2.push(Span::styled(
571 "loading…",
572 Style::default().fg(t.dim),
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(t.dim),
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(t.dim)
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(t.dim)
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(t.dim)
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(t.dim)),
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(t.info)),
679 Span::styled(
680 " (deadline ≈ 95s for the 38-block commit window)",
681 Style::default().fg(t.dim),
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(t.dim)
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(t.dim),
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(t.dim),
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(t.info),
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(t.pass)
737 } else {
738 Style::default().fg(t.fail).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(t.dim)),
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(t.fail),
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 t = theme::active();
783 let mut out = vec![Span::raw(" ")];
784 for (i, s) in segs.iter().enumerate() {
785 let color = match s.state {
786 PhaseState::Done => t.dim,
787 PhaseState::Active => t.warn,
788 PhaseState::Pending => Color::White,
789 };
790 let modifier = if matches!(s.state, PhaseState::Active) {
791 Modifier::BOLD
792 } else {
793 Modifier::empty()
794 };
795 out.push(Span::styled(
796 format!(
797 " {} {}..{} ",
798 s.phase.label(),
799 s.start_block,
800 s.end_block
801 ),
802 Style::default().fg(color).add_modifier(modifier),
803 ));
804 if i + 1 < segs.len() {
805 out.push(Span::styled("│", Style::default().fg(t.dim)));
806 }
807 }
808 out
809}
810
811fn progress_bar_spans(rc: &RoundCard) -> Vec<Span<'static>> {
812 const WIDTH: usize = 24;
814 let filled = ((rc.block_of_round as usize) * WIDTH) / BLOCKS_PER_ROUND as usize;
815 let mut bar = String::with_capacity(WIDTH);
816 for _ in 0..filled.min(WIDTH) {
817 bar.push('▇');
818 }
819 for _ in filled.min(WIDTH)..WIDTH {
820 bar.push('░');
821 }
822 vec![
823 Span::raw(" "),
824 Span::styled(bar, Style::default().fg(theme::active().warn)),
825 Span::raw(format!(
826 " {}/{BLOCKS_PER_ROUND}",
827 rc.block_of_round
828 )),
829 ]
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835
836 #[test]
837 fn phase_from_api_known() {
838 assert_eq!(Phase::from_api("commit"), Phase::Commit);
839 assert_eq!(Phase::from_api("reveal"), Phase::Reveal);
840 assert_eq!(Phase::from_api("claim"), Phase::Claim);
841 assert_eq!(Phase::from_api("sample"), Phase::Sample);
842 }
843
844 #[test]
845 fn phase_from_api_unknown_falls_back() {
846 assert_eq!(Phase::from_api(""), Phase::Unknown);
847 assert_eq!(Phase::from_api("garbage"), Phase::Unknown);
848 }
849
850 #[test]
851 fn phase_segments_during_commit() {
852 let segs = build_phase_segments(Phase::Commit, 10);
853 assert_eq!(segs[0].state, PhaseState::Active);
854 assert_eq!(segs[1].state, PhaseState::Pending);
855 assert_eq!(segs[2].state, PhaseState::Pending);
856 }
857
858 #[test]
859 fn phase_segments_during_reveal() {
860 let segs = build_phase_segments(Phase::Reveal, 50);
861 assert_eq!(segs[0].state, PhaseState::Done);
862 assert_eq!(segs[1].state, PhaseState::Active);
863 assert_eq!(segs[2].state, PhaseState::Pending);
864 }
865
866 #[test]
867 fn phase_segments_during_claim() {
868 let segs = build_phase_segments(Phase::Claim, 100);
869 assert_eq!(segs[0].state, PhaseState::Done);
870 assert_eq!(segs[1].state, PhaseState::Done);
871 assert_eq!(segs[2].state, PhaseState::Active);
872 }
873
874 #[test]
875 fn phase_segments_during_sample() {
876 let segs = build_phase_segments(Phase::Sample, 0);
878 for s in &segs {
879 assert_eq!(s.state, PhaseState::Done);
880 }
881 }
882
883 #[test]
884 fn format_when_handles_zero() {
885 assert_eq!(format_when(100, 0), "never");
886 }
887
888 #[test]
889 fn format_when_handles_current() {
890 assert_eq!(format_when(100, 100), "this round");
891 }
892
893 #[test]
894 fn format_when_handles_n_ago() {
895 assert_eq!(format_when(100, 95), "5 rounds ago");
896 }
897
898 #[test]
899 fn bench_depth_falls_back_when_status_missing() {
900 assert_eq!(bench_depth(&HealthSnapshot::default()), BENCH_DEFAULT_DEPTH);
901 }
902
903 #[test]
904 fn bench_depth_falls_back_on_sentinel() {
905 let snap = HealthSnapshot {
907 status: Some(bee::debug::Status {
908 storage_radius: -1,
909 ..bee::debug::Status::default()
910 }),
911 ..HealthSnapshot::default()
912 };
913 assert_eq!(bench_depth(&snap), BENCH_DEFAULT_DEPTH);
914 }
915
916 #[test]
917 fn bench_depth_uses_storage_radius_when_present() {
918 let snap = HealthSnapshot {
919 status: Some(bee::debug::Status {
920 storage_radius: 12,
921 ..bee::debug::Status::default()
922 }),
923 ..HealthSnapshot::default()
924 };
925 assert_eq!(bench_depth(&snap), 12);
926 }
927}