Skip to main content

bee_tui/components/
lottery.rs

1//! S4 — Lottery / redistribution screen (`docs/PLAN.md` § 8.S4).
2//!
3//! Three panes driven by [`crate::watch::HealthSnapshot`] (for
4//! `/redistributionstate` at 2 s cadence) and
5//! [`crate::watch::LotterySnapshot`] (for `/stake` at 30 s):
6//!
7//! 1. **Round timeline** — current round number plus a three-segment
8//!    bar for the commit (0..38) / reveal (38..76) / claim (76..152)
9//!    phases, with the active phase highlighted and a within-phase
10//!    progress bar. Block-of-round comes from `block % 152` per the
11//!    upstream `DefaultBlocksPerRound` constant in
12//!    `bee-go/pkg/storageincentives/agent.go`.
13//! 2. **Anchor summary** — last *won*, *played*, *frozen*, *selected*
14//!    rounds, each with a `Δ` to the current round so operators see
15//!    "last won 4 rounds ago" without doing arithmetic. The PLAN's
16//!    "last 20 rounds with skip reasons" view needs an upstream
17//!    `RoundData[]` port and is deferred.
18//! 3. **Stake card** — staked BZZ vs the minimum needed to play, with
19//!    the "frozen / unhealthy / insufficient funds" reasons that
20//!    Bee's API exposes only in scattered booleans surfaced as a
21//!    single human sentence.
22//!
23//! Render path delegates to the pure [`Lottery::view_for`] function so
24//! `tests/s4_lottery_view.rs` can pin all state derivations
25//! (phase math, skip-reason reconstruction, stake-status ladder)
26//! without a TUI.
27
28use 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
51/// `pkg/storageincentives/agent.go:36` — round length in blocks.
52pub const BLOCKS_PER_ROUND: u64 = 152;
53/// One-quarter of the round (152 / 4); commit and reveal each take this many
54/// blocks, claim takes the remaining 76.
55pub const BLOCKS_PER_PHASE: u64 = 38;
56
57/// Discrete phase enum derived from the API's `phase: String`. Falls
58/// back to [`Phase::Unknown`] if Bee returns a value we don't model.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Phase {
61    Commit,
62    Reveal,
63    Claim,
64    /// In-between rounds — the agent is sampling chunks for the next
65    /// commit. Bee surfaces this as `phase: "sample"`.
66    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/// Tri-state per-phase outcome shown in the timeline ribbon.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum PhaseState {
95    /// Phase already completed in the current round.
96    Done,
97    /// Currently in this phase.
98    Active,
99    /// Phase hasn't started yet.
100    Pending,
101}
102
103/// One segment of the timeline ribbon.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct PhaseSegment {
106    pub phase: Phase,
107    pub state: PhaseState,
108    /// Inclusive lower bound of this phase in block-of-round.
109    pub start_block: u64,
110    /// Exclusive upper bound.
111    pub end_block: u64,
112}
113
114/// Top pane.
115#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct RoundCard {
117    pub round: u64,
118    pub block: u64,
119    /// `block % BLOCKS_PER_ROUND` for quick visual reference.
120    pub block_of_round: u64,
121    /// Current phase as enum + label.
122    pub phase: Phase,
123    pub phase_label: &'static str,
124    pub segments: Vec<PhaseSegment>,
125}
126
127/// One row of the anchor-summary pane.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct AnchorRow {
130    pub label: &'static str,
131    pub round: u64,
132    /// `current_round - round`. `None` if the anchor round is 0
133    /// (never reached) or in the future.
134    pub delta: Option<u64>,
135    /// Human description: `"never"`, `"4 rounds ago"`, etc.
136    pub when: String,
137}
138
139/// Tri-state stake card status.
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum StakeStatus {
142    /// `staked == 0` — node can't participate.
143    Unstaked,
144    /// 0 < staked but `has_sufficient_funds == false` — operator funded
145    /// the contract but the wallet doesn't have gas, so the node skips.
146    InsufficientGas,
147    /// Frozen out of redistribution by a previous bad commit.
148    Frozen,
149    /// Worker is unhealthy (storage radius drift, sample failures, etc.).
150    Unhealthy,
151    /// `staked > 0`, `has_sufficient_funds`, not frozen, healthy.
152    Healthy,
153    /// `/stake` failed.
154    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/// Bottom pane.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct StakeCard {
183    pub status: StakeStatus,
184    /// Pre-formatted `BZZ x.xxxx` or `—`.
185    pub staked: String,
186    pub minimum_gas: String,
187    /// Cumulative reward (`BZZ x.xxxx` or `—`).
188    pub reward: String,
189    pub fees: String,
190    /// Last sample duration in seconds, formatted to one decimal.
191    pub last_sample: Option<String>,
192    /// Tooltip explaining the current state when not Healthy.
193    pub why: Option<String>,
194}
195
196/// Aggregated view fed to both renderer and snapshot tests.
197#[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/// Lifecycle of the on-demand rchash benchmark. Operator hits `r` to
205/// run; the component owns the request lifecycle via an internal mpsc
206/// channel so the App's action pipeline doesn't grow a one-off
207/// rchash-result variant.
208#[derive(Debug, Clone, PartialEq)]
209pub enum BenchState {
210    Idle,
211    Running,
212    Done {
213        duration_seconds: f64,
214        /// Reserve commitment hash (8-char prefix is what the screen
215        /// shows; full hash is kept here for the sake of completeness).
216        hash: String,
217    },
218    Failed {
219        error: String,
220    },
221}
222
223/// Fixed anchor pair used for the benchmark. Real-round anchors come
224/// off-chain; for a deterministic local sample we use 0x00…00 vs
225/// 0xff…ff so repeat measurements compare cleanly.
226const BENCH_ANCHOR_LO: &str = "0000000000000000000000000000000000000000000000000000000000000000";
227const BENCH_ANCHOR_HI: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
228/// Fallback depth when `/status` hasn't reported a `storage_radius`
229/// yet. 8 is a typical mainnet radius, so the sample size is
230/// representative.
231const BENCH_DEFAULT_DEPTH: u8 = 8;
232
233/// Pick a depth for the benchmark that mirrors what the agent would
234/// actually sample at: the node's current `storage_radius`. Falls back
235/// to [`BENCH_DEFAULT_DEPTH`] if `/status` hasn't loaded yet. Status's
236/// `storage_radius` is `i64` because the API can return a sentinel
237/// `-1`; we clamp to `0..=255` for the rchash path.
238pub 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    /// Drain any benchmark results that arrived since the last tick.
289    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    /// Kick off a benchmark unless one is already in flight. Returns
302    /// `true` if a new request was spawned.
303    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    /// Pure, snapshot-driven view computation. Exposed for snapshot
324    /// tests in `tests/s4_lottery_view.rs`.
325    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    // Build the three on-chain phase segments. (Sample is between rounds and
346    // doesn't get a segment — when phase==Sample, the ribbon shows all three
347    // segments as Done.)
348    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    // Trust the API's `phase` field for the active marker, but fall back to
385    // block-range arithmetic when it reports `sample` (between rounds —
386    // every commit/reveal/claim is Done) or `unknown`.
387    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        // Block-of-round is inside our segment but the API claims a different
399        // phase — surface as Active anyway, the API is authoritative for the
400        // current marker.
401        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        // Stake known but no redistribution data yet.
502        (_, 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), // header
542            Constraint::Length(6), // round timeline
543            Constraint::Length(7), // anchors
544            Constraint::Min(0),    // stake card
545            Constraint::Length(1), // footer
546        ])
547        .split(area);
548
549        // Header
550        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        // Round timeline
574        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        // Anchors
603        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        // Stake card
636        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        // Bench card: rchash benchmark line. Always rendered so the
678        // operator knows the keybinding exists.
679        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        // Footer
740        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    // 24-cell ASCII bar over 0..152.
785    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        // Sample sits between rounds — every on-chain phase reads as Done.
846        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        // Bee returns storage_radius = -1 before warmup completes.
875        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}