Skip to main content

bee_tui/components/
swap.rs

1//! S3 — SWAP / cheques screen (`docs/PLAN.md` § 8.S3).
2//!
3//! Three stacked panes driven by [`crate::watch::SwapSnapshot`]:
4//!
5//! 1. **Chequebook balance card** — total + available, with the
6//!    headroom (`available / total`) called out so operators notice
7//!    when they're approaching cashout pressure.
8//! 2. **Last received cheques** (per peer) — sorted by cumulative
9//!    payout descending so the most lucrative peers float to the top.
10//!    A peer with no cheque yet is rendered as a quiet "—" row instead
11//!    of being hidden, because "no cheques" is itself useful signal.
12//! 3. **Per-peer settlements table** — received and sent PLUR side by
13//!    side, with the net (received − sent) flagged. SWAP keeps these
14//!    near zero in steady state; a large net delta means cheques are
15//!    being issued faster than they're being cashed.
16//!
17//! Render path delegates to the pure [`Swap::view_for`] function so
18//! `tests/s3_swap_view.rs` can pin all the formatting (PLUR scaling,
19//! sort order, net-balance highlighting) without launching a TUI.
20
21use color_eyre::Result;
22use num_bigint::BigInt;
23use ratatui::{
24    Frame,
25    layout::{Constraint, Layout, Rect},
26    style::{Color, Modifier, Style},
27    text::{Line, Span},
28    widgets::{Block, Borders, Paragraph},
29};
30use tokio::sync::watch;
31
32use super::Component;
33use crate::action::Action;
34use crate::theme;
35use crate::watch::SwapSnapshot;
36
37use bee::debug::{ChequebookBalance, LastCheque, Settlement, Settlements};
38
39/// Tri-state outcome for the chequebook balance card.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum SwapStatus {
42    /// `total_balance` is zero — chequebook hasn't been funded yet.
43    Empty,
44    /// Available balance is healthy (>20 % of total).
45    Healthy,
46    /// Available drops below 20 % of total — most funds are tied up
47    /// in unsettled debt and cashing out is getting close.
48    Tight,
49    /// `/chequebook/balance` failed; nothing to render.
50    Unknown,
51}
52
53impl SwapStatus {
54    fn color(self) -> Color {
55        match self {
56            Self::Empty => theme::active().warn,
57            Self::Healthy => theme::active().pass,
58            Self::Tight => theme::active().warn,
59            Self::Unknown => theme::active().dim,
60        }
61    }
62    fn label(self) -> &'static str {
63        match self {
64            Self::Empty => "○ unfunded",
65            Self::Healthy => "✓ healthy",
66            Self::Tight => "⚠ tight",
67            Self::Unknown => "? unknown",
68        }
69    }
70}
71
72/// Snapshot-friendly view of the chequebook card.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct ChequebookCard {
75    pub status: SwapStatus,
76    /// PLUR formatted as `BZZ x.xxxx` (BZZ has 16 decimals).
77    pub total: String,
78    pub available: String,
79    /// `available / total` as 0..=100. `0` if total is zero.
80    pub available_pct: u32,
81    pub why: Option<String>,
82}
83
84/// One row of the "last received cheques" pane.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct CheckRow {
87    pub peer_short: String,
88    /// Pre-formatted payout (`BZZ x.xxxx` or `—` if no cheque yet).
89    pub payout: String,
90    /// `true` if this peer has not sent us any cheque yet.
91    pub never: bool,
92}
93
94/// One row of the per-peer settlements pane.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct SettlementRow {
97    pub peer_short: String,
98    pub received: String,
99    pub sent: String,
100    /// Sign-prefixed net (`+x` if we're owed, `-x` if we owe).
101    pub net: String,
102    /// `true` when |net| > 0.5 BZZ — flagged in red.
103    pub net_flagged: bool,
104}
105
106/// Aggregated view fed to both the renderer and snapshot tests.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct SwapView {
109    pub card: ChequebookCard,
110    /// On-chain chequebook contract address from
111    /// `/chequebook/address`. `None` when the endpoint hasn't
112    /// returned yet (cold start) — the header silently omits the
113    /// row in that case rather than rendering a placeholder.
114    pub chequebook_address: Option<String>,
115    pub cheques: Vec<CheckRow>,
116    pub settlements: Vec<SettlementRow>,
117    pub time_total_received: Option<String>,
118    pub time_total_sent: Option<String>,
119    /// Pre-rendered Market tile lines. `None` when the operator
120    /// hasn't enabled `[economics].enable_market_tile` — the
121    /// renderer skips the tile row entirely in that case.
122    pub market: Option<MarketTile>,
123}
124
125/// Snapshot of the Market tile lines. Pure data so snapshot tests
126/// can pin the formatted strings without poking the renderer.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct MarketTile {
129    /// `BZZ ≈ $0.4321` — `—` when price hasn't loaded yet.
130    pub price_line: String,
131    /// `gas: 1.20 base + 0.50 tip = 1.70 gwei` — `—` when not
132    /// configured or basefee fetch failed.
133    pub gas_line: String,
134    /// "stale: ..." line shown dim+italic under the tile when the
135    /// last poll surfaced an error. `None` when the latest poll
136    /// was clean, or when no poll has run yet (cold start shows a
137    /// `loading…` line via `cold_start` instead).
138    pub stale_why: Option<String>,
139    /// `true` while no poll has completed — drives the spinner
140    /// glyph in the tile so cold-start motion is visible.
141    pub cold_start: bool,
142}
143
144pub struct Swap {
145    rx: watch::Receiver<SwapSnapshot>,
146    snapshot: SwapSnapshot,
147    /// Optional cost-context feed driving the Market tile. `None`
148    /// when `[economics].enable_market_tile` is off — the tile is
149    /// suppressed entirely in that case so the layout doesn't waste
150    /// a row on placeholder text.
151    market_rx: Option<watch::Receiver<crate::economics_oracle::EconomicsSnapshot>>,
152    market: crate::economics_oracle::EconomicsSnapshot,
153}
154
155impl Swap {
156    pub fn new(rx: watch::Receiver<SwapSnapshot>) -> Self {
157        let snapshot = rx.borrow().clone();
158        Self {
159            rx,
160            snapshot,
161            market_rx: None,
162            market: crate::economics_oracle::EconomicsSnapshot::default(),
163        }
164    }
165
166    /// Builder-style attach for the cost-context feed. Call once
167    /// after `new` when `[economics].enable_market_tile` is true.
168    pub fn with_market_feed(
169        mut self,
170        rx: watch::Receiver<crate::economics_oracle::EconomicsSnapshot>,
171    ) -> Self {
172        self.market = rx.borrow().clone();
173        self.market_rx = Some(rx);
174        self
175    }
176
177    fn pull_latest(&mut self) {
178        self.snapshot = self.rx.borrow().clone();
179        if let Some(rx) = &self.market_rx {
180            self.market = rx.borrow().clone();
181        }
182    }
183
184    /// Pure, snapshot-driven view computation. Exposed for snapshot
185    /// tests in `tests/s3_swap_view.rs`. The `market` argument is
186    /// `None` when `[economics].enable_market_tile` is off — the
187    /// tile is suppressed entirely in that case so cold-start
188    /// renders identically to today.
189    pub fn view_for(
190        snap: &SwapSnapshot,
191        market: Option<&crate::economics_oracle::EconomicsSnapshot>,
192    ) -> SwapView {
193        let card = card_for(snap.chequebook.as_ref());
194        let cheques = cheque_rows_for(&snap.last_received);
195        let settlements = settlement_rows_for(snap.settlements.as_ref());
196        let time_total_received = snap
197            .time_settlements
198            .as_ref()
199            .and_then(|s| s.total_received.as_ref())
200            .map(format_plur);
201        let time_total_sent = snap
202            .time_settlements
203            .as_ref()
204            .and_then(|s| s.total_sent.as_ref())
205            .map(format_plur);
206        let market = market.map(market_tile_for);
207        SwapView {
208            card,
209            chequebook_address: snap.chequebook_address.clone(),
210            cheques,
211            settlements,
212            time_total_received,
213            time_total_sent,
214            market,
215        }
216    }
217
218    /// Convenience wrapper used by snapshot tests that don't care
219    /// about the optional Market tile. Equivalent to
220    /// `view_for(snap, None)`.
221    pub fn view_for_no_market(snap: &SwapSnapshot) -> SwapView {
222        Self::view_for(snap, None)
223    }
224}
225
226/// Pure formatter — exposed for tests. Builds the four tile lines
227/// from the latest poller snapshot.
228fn market_tile_for(m: &crate::economics_oracle::EconomicsSnapshot) -> MarketTile {
229    let price_line = match &m.price {
230        Some(p) => format!("BZZ ≈ ${:.4}", p.usd),
231        None => "BZZ ≈ —".to_string(),
232    };
233    let gas_line = match &m.gas {
234        Some(g) => match g.max_priority_fee_gwei {
235            Some(tip) => format!(
236                "gas: {:.2} base + {:.2} tip = {:.2} gwei",
237                g.base_fee_gwei,
238                tip,
239                g.total_gwei(),
240            ),
241            None => format!("gas: {:.2} gwei base", g.base_fee_gwei),
242        },
243        None => "gas: —".to_string(),
244    };
245    MarketTile {
246        price_line,
247        gas_line,
248        stale_why: m.last_error.clone(),
249        cold_start: m.last_polled.is_none(),
250    }
251}
252
253fn card_for(cb: Option<&ChequebookBalance>) -> ChequebookCard {
254    let Some(cb) = cb else {
255        return ChequebookCard {
256            status: SwapStatus::Unknown,
257            total: "—".into(),
258            available: "—".into(),
259            available_pct: 0,
260            why: Some("/chequebook/balance not available yet".into()),
261        };
262    };
263    let zero = BigInt::from(0);
264    let total = &cb.total_balance;
265    let avail = &cb.available_balance;
266    let total_str = format_plur(total);
267    let avail_str = format_plur(avail);
268    if total == &zero {
269        return ChequebookCard {
270            status: SwapStatus::Empty,
271            total: total_str,
272            available: avail_str,
273            available_pct: 0,
274            why: Some("chequebook holds 0 BZZ — fund it to send cheques.".into()),
275        };
276    }
277    let pct = pct_of(avail, total);
278    let (status, why) = if pct < 20 {
279        (
280            SwapStatus::Tight,
281            Some(format!(
282                "only {pct}% available — most BZZ is tied up in unsettled debt."
283            )),
284        )
285    } else {
286        (SwapStatus::Healthy, None)
287    };
288    ChequebookCard {
289        status,
290        total: total_str,
291        available: avail_str,
292        available_pct: pct,
293        why,
294    }
295}
296
297fn cheque_rows_for(last_received: &[LastCheque]) -> Vec<CheckRow> {
298    let mut rows: Vec<CheckRow> = last_received
299        .iter()
300        .map(|lc| {
301            let payout_bi = lc.last_received.as_ref().and_then(|c| c.payout.as_ref());
302            let (payout, never) = match payout_bi {
303                Some(p) => (format_plur(p), false),
304                None => ("—".into(), true),
305            };
306            CheckRow {
307                peer_short: short_peer(&lc.peer),
308                payout,
309                never,
310            }
311        })
312        .collect();
313    // Sort: peers with cheques first, by payout descending (string-sort is OK
314    // because format_plur produces fixed-decimal `BZZ x.xxxx` form). Peers with
315    // no cheque sink to the bottom but stay visible.
316    rows.sort_by(|a, b| match (a.never, b.never) {
317        (false, true) => std::cmp::Ordering::Less,
318        (true, false) => std::cmp::Ordering::Greater,
319        _ => b.payout.cmp(&a.payout),
320    });
321    rows
322}
323
324fn settlement_rows_for(s: Option<&Settlements>) -> Vec<SettlementRow> {
325    let Some(s) = s else { return Vec::new() };
326    // Sort the raw settlements by |received - sent| descending so the
327    // most out-of-balance peers float to the top — that's where cashout
328    // pressure shows up first.
329    let mut sorted: Vec<&Settlement> = s.settlements.iter().collect();
330    sorted.sort_by_key(|s| std::cmp::Reverse(abs_net(s)));
331    sorted.into_iter().map(settlement_row).collect()
332}
333
334fn abs_net(s: &Settlement) -> BigInt {
335    let zero = BigInt::from(0);
336    let recv = s.received.as_ref().unwrap_or(&zero);
337    let sent = s.sent.as_ref().unwrap_or(&zero);
338    let net = recv - sent;
339    if net < zero { -net } else { net }
340}
341
342fn settlement_row(s: &Settlement) -> SettlementRow {
343    let zero = BigInt::from(0);
344    let recv = s.received.as_ref().unwrap_or(&zero);
345    let sent = s.sent.as_ref().unwrap_or(&zero);
346    let net_bi = recv - sent;
347    let net = format_plur_signed(&net_bi);
348    // Flag peers >0.5 BZZ out of balance (5 * 10^15 PLUR).
349    let half_bzz = BigInt::from(5_000_000_000_000_000u64);
350    let abs = if net_bi < BigInt::from(0) {
351        -net_bi
352    } else {
353        net_bi
354    };
355    let net_flagged = abs > half_bzz;
356    SettlementRow {
357        peer_short: short_peer(&s.peer),
358        received: format_plur(recv),
359        sent: format_plur(sent),
360        net,
361        net_flagged,
362    }
363}
364
365/// Format a PLUR amount as `BZZ x.xxxx` with 4 fractional digits.
366/// Bee's PLUR has 16 decimals; we render the top 4 digits of the
367/// fractional part so 0.0001 BZZ is the smallest visible unit, which
368/// is finer than any realistic per-cheque amount.
369pub fn format_plur(plur: &BigInt) -> String {
370    format_plur_inner(plur, false)
371}
372
373fn format_plur_signed(plur: &BigInt) -> String {
374    format_plur_inner(plur, true)
375}
376
377fn format_plur_inner(plur: &BigInt, signed: bool) -> String {
378    let zero = BigInt::from(0);
379    let neg = plur < &zero;
380    let abs = if neg { -plur.clone() } else { plur.clone() };
381    let scale = BigInt::from(10u64).pow(16);
382    let whole = &abs / &scale;
383    let frac = &abs % &scale;
384    // Take top 4 digits: divide by 10^12.
385    let frac_4 = &frac / BigInt::from(10u64).pow(12);
386    let sign = if neg {
387        "-"
388    } else if signed {
389        "+"
390    } else {
391        ""
392    };
393    format!("{sign}BZZ {whole}.{frac_4:0>4}")
394}
395
396fn pct_of(num: &BigInt, denom: &BigInt) -> u32 {
397    let zero = BigInt::from(0);
398    if denom == &zero {
399        return 0;
400    }
401    // (num * 100) / denom, clamped to 100.
402    let scaled = num * BigInt::from(100);
403    let q = &scaled / denom;
404    // Convert to u32 best-effort; in practice 0..=100.
405    let q_str = q.to_string();
406    let q_u: u128 = q_str.parse().unwrap_or(0);
407    q_u.min(100) as u32
408}
409
410fn short_peer(p: &str) -> String {
411    let trimmed = p.trim_start_matches("0x");
412    if trimmed.len() > 10 {
413        format!("{}…{}", &trimmed[..6], &trimmed[trimmed.len() - 4..])
414    } else {
415        trimmed.to_string()
416    }
417}
418
419impl Component for Swap {
420    fn update(&mut self, action: Action) -> Result<Option<Action>> {
421        if matches!(action, Action::Tick) {
422            self.pull_latest();
423        }
424        Ok(None)
425    }
426
427    fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
428        let view = Self::view_for(&self.snapshot, self.market_rx.as_ref().map(|_| &self.market));
429
430        // Layout slots: header, optional market tile (3 rows when
431        // present), chequebook card, tables, footer. Computing slots
432        // up-front keeps the conditional in one place.
433        let mut constraints: Vec<Constraint> = vec![Constraint::Length(3)];
434        let market_present = view.market.is_some();
435        if market_present {
436            constraints.push(Constraint::Length(3));
437        }
438        constraints.push(Constraint::Length(5)); // chequebook card
439        constraints.push(Constraint::Min(0)); // tables
440        constraints.push(Constraint::Length(1)); // footer
441        let chunks = Layout::vertical(constraints).split(area);
442
443        let mut slot = 0usize;
444        let header_slot = chunks[slot];
445        slot += 1;
446        let market_slot = if market_present {
447            let s = chunks[slot];
448            slot += 1;
449            Some(s)
450        } else {
451            None
452        };
453        let card_slot = chunks[slot];
454        slot += 1;
455        let tables_slot = chunks[slot];
456        slot += 1;
457        let footer_slot = chunks[slot];
458
459        let t = theme::active();
460        // Header
461        let mut header_l1 = vec![Span::styled(
462            "SWAP / CHEQUES",
463            Style::default().add_modifier(Modifier::BOLD),
464        )];
465        if let Some(addr) = &self.snapshot.chequebook_address {
466            header_l1.push(Span::raw("   contract "));
467            header_l1.push(Span::styled(addr.clone(), Style::default().fg(t.dim)));
468        }
469        let header_l1 = Line::from(header_l1);
470        let mut header_l2 = Vec::new();
471        if let Some(err) = &self.snapshot.last_error {
472            let (color, msg) = theme::classify_header_error(err);
473            header_l2.push(Span::styled(msg, Style::default().fg(color)));
474        } else if !self.snapshot.is_loaded() {
475            header_l2.push(Span::styled(
476                format!("{} loading…", theme::spinner_glyph()),
477                Style::default().fg(t.dim),
478            ));
479        }
480        frame.render_widget(
481            Paragraph::new(vec![header_l1, Line::from(header_l2)])
482                .block(Block::default().borders(Borders::BOTTOM)),
483            header_slot,
484        );
485
486        // Market tile (xBZZ price + Gnosis basefee). Rendered only
487        // when [economics].enable_market_tile is on. Two-line tile:
488        // price + gas, with optional dim "stale" why-line under it.
489        if let (Some(rect), Some(tile)) = (market_slot, view.market.as_ref()) {
490            let prefix = if tile.cold_start {
491                format!("{} ", theme::spinner_glyph())
492            } else {
493                "  ".to_string()
494            };
495            let mut lines = vec![
496                Line::from(vec![
497                    Span::raw(prefix.clone()),
498                    Span::styled(
499                        "Market  ",
500                        Style::default().add_modifier(Modifier::BOLD),
501                    ),
502                    Span::raw(tile.price_line.clone()),
503                    Span::raw("    "),
504                    Span::raw(tile.gas_line.clone()),
505                ]),
506            ];
507            if let Some(why) = &tile.stale_why {
508                lines.push(Line::from(vec![
509                    Span::raw("    └─ "),
510                    Span::styled(
511                        format!("stale: {why}"),
512                        Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
513                    ),
514                ]));
515            }
516            frame.render_widget(
517                Paragraph::new(lines).block(Block::default().borders(Borders::BOTTOM)),
518                rect,
519            );
520        }
521
522        // Chequebook card
523        let card = &view.card;
524        let mut card_lines = vec![
525            Line::from(vec![
526                Span::styled(
527                    "  Chequebook  ",
528                    Style::default().add_modifier(Modifier::BOLD),
529                ),
530                Span::styled(
531                    card.status.label(),
532                    Style::default()
533                        .fg(card.status.color())
534                        .add_modifier(Modifier::BOLD),
535                ),
536            ]),
537            Line::from(vec![
538                Span::raw(format!("    total      {}", card.total)),
539                Span::raw("   "),
540                Span::raw(format!("available  {}", card.available)),
541                Span::raw("   "),
542                Span::styled(
543                    format!("({}% available)", card.available_pct),
544                    Style::default().fg(t.dim),
545                ),
546            ]),
547        ];
548        if let Some(why) = &card.why {
549            card_lines.push(Line::from(vec![
550                Span::raw("    └─ "),
551                Span::styled(
552                    why.clone(),
553                    Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
554                ),
555            ]));
556        }
557        frame.render_widget(
558            Paragraph::new(card_lines).block(Block::default().borders(Borders::BOTTOM)),
559            card_slot,
560        );
561
562        // Tables stacked: cheques (top) + settlements (bottom)
563        let table_chunks =
564            Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
565                .split(tables_slot);
566
567        // Cheques table
568        let mut cheque_lines: Vec<Line> = vec![Line::from(Span::styled(
569            "  PEER          LAST RECEIVED",
570            Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
571        ))];
572        if view.cheques.is_empty() {
573            cheque_lines.push(Line::from(Span::styled(
574                "  (no peer cheques known yet)",
575                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
576            )));
577        } else {
578            for r in &view.cheques {
579                let payout_style = if r.never {
580                    Style::default().fg(t.dim)
581                } else {
582                    Style::default().fg(t.pass)
583                };
584                cheque_lines.push(Line::from(vec![
585                    Span::raw("  "),
586                    Span::raw(format!("{:<14}", r.peer_short)),
587                    Span::styled(r.payout.clone(), payout_style),
588                ]));
589            }
590        }
591        frame.render_widget(
592            Paragraph::new(cheque_lines).block(Block::default().borders(Borders::BOTTOM).title(
593                Span::styled(
594                    " last cheques ",
595                    Style::default().add_modifier(Modifier::BOLD),
596                ),
597            )),
598            table_chunks[0],
599        );
600
601        // Settlements table
602        let mut settle_lines: Vec<Line> = vec![Line::from(Span::styled(
603            "  PEER          RECEIVED              SENT                 NET",
604            Style::default().fg(t.dim).add_modifier(Modifier::BOLD),
605        ))];
606        if let (Some(tr), Some(ts)) = (&view.time_total_received, &view.time_total_sent) {
607            settle_lines.push(Line::from(vec![Span::styled(
608                format!("  time-based totals — received {tr} · sent {ts}"),
609                Style::default().fg(t.dim),
610            )]));
611        }
612        if view.settlements.is_empty() {
613            settle_lines.push(Line::from(Span::styled(
614                "  (no peer settlements yet)",
615                Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
616            )));
617        } else {
618            for r in &view.settlements {
619                let net_style = if r.net_flagged {
620                    Style::default().fg(t.fail).add_modifier(Modifier::BOLD)
621                } else {
622                    Style::default().fg(t.dim)
623                };
624                settle_lines.push(Line::from(vec![
625                    Span::raw("  "),
626                    Span::raw(format!("{:<14}", r.peer_short)),
627                    Span::raw(format!("{:<22}", r.received)),
628                    Span::raw(format!("{:<21}", r.sent)),
629                    Span::styled(r.net.clone(), net_style),
630                ]));
631            }
632        }
633        frame.render_widget(
634            Paragraph::new(settle_lines).block(Block::default().title(Span::styled(
635                " settlements ",
636                Style::default().add_modifier(Modifier::BOLD),
637            ))),
638            table_chunks[1],
639        );
640
641        // Footer
642        frame.render_widget(
643            Paragraph::new(Line::from(vec![
644                Span::styled(" Tab ", Style::default().fg(Color::Black).bg(Color::White)),
645                Span::raw(" switch screen  "),
646                Span::styled(" ? ", Style::default().fg(Color::Black).bg(Color::White)),
647                Span::raw(" help  "),
648                Span::styled(" q ", Style::default().fg(Color::Black).bg(Color::White)),
649                Span::raw(" quit  "),
650                Span::styled(" net ", Style::default().fg(t.fail)),
651                Span::raw(" out-of-balance peer (>0.5 BZZ) "),
652            ])),
653            footer_slot,
654        );
655
656        Ok(())
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    #[test]
665    fn format_plur_zero() {
666        assert_eq!(format_plur(&BigInt::from(0)), "BZZ 0.0000");
667    }
668
669    #[test]
670    fn format_plur_one_bzz() {
671        let one = BigInt::from(10u64).pow(16);
672        assert_eq!(format_plur(&one), "BZZ 1.0000");
673    }
674
675    #[test]
676    fn format_plur_fractional() {
677        // 0.5 BZZ = 5 * 10^15 PLUR
678        let half = BigInt::from(5_000_000_000_000_000u64);
679        assert_eq!(format_plur(&half), "BZZ 0.5000");
680    }
681
682    #[test]
683    fn format_plur_signed_negative() {
684        let one = BigInt::from(10u64).pow(16);
685        assert_eq!(format_plur_signed(&-one), "-BZZ 1.0000");
686    }
687
688    #[test]
689    fn format_plur_signed_positive() {
690        let one = BigInt::from(10u64).pow(16);
691        assert_eq!(format_plur_signed(&one), "+BZZ 1.0000");
692    }
693
694    #[test]
695    fn pct_of_handles_zero_denom() {
696        assert_eq!(pct_of(&BigInt::from(10), &BigInt::from(0)), 0);
697    }
698
699    #[test]
700    fn pct_of_clamps_to_100() {
701        assert_eq!(pct_of(&BigInt::from(200), &BigInt::from(100)), 100);
702    }
703
704    #[test]
705    fn short_peer_truncates_long_overlay() {
706        let p = "0xabcdef0123456789abcdef0123456789";
707        let s = short_peer(p);
708        assert!(s.contains('…'));
709        assert!(s.starts_with("abcdef"));
710    }
711
712    #[test]
713    fn short_peer_passes_short_through() {
714        assert_eq!(short_peer("abcd"), "abcd");
715    }
716}