diff --git a/.gitignore b/.gitignore index 68942e3..32b68c2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ -/target -./src/snapshots +/target/ +src/snapshots/ +config/config.toml diff --git a/Cargo.lock b/Cargo.lock index 9c3d324..124d572 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -763,7 +763,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -783,12 +782,6 @@ dependencies = [ "futures-util", ] -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - [[package]] name = "futures-sink" version = "0.3.32" @@ -808,10 +801,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", - "futures-io", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] @@ -2153,9 +2143,7 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", - "futures-channel", "futures-core", - "futures-util", "h2", "http", "http-body", diff --git a/Cargo.toml b/Cargo.toml index d680fc5..a1cd321 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,7 @@ unicode-width = "0.2" p2poolv2_config = { git = "https://github.com/p2poolv2/p2poolv2", package = "p2poolv2_config" } bitcoin = "0.32.5" toml_edit = "0.22" -reqwest = { version = "0.12", features = ["json", "rustls-tls", "blocking"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } [dev-dependencies] diff --git a/config/config.sample.toml b/config/config.sample.toml new file mode 100644 index 0000000..8d74355 --- /dev/null +++ b/config/config.sample.toml @@ -0,0 +1,5 @@ +[api] +host = "127.0.0.1" +port = 46884 +auth_user = "p2pool" +auth_pass = "p2pool" diff --git a/dummy.toml b/dummy.toml new file mode 100644 index 0000000..bd5642b --- /dev/null +++ b/dummy.toml @@ -0,0 +1,49 @@ + +[network] +listen_address = "/ip4/127.0.0.1/tcp/6884" +dial_peers = [] +max_pending_incoming = 10 +max_pending_outgoing = 10 +max_established_incoming = 50 +max_established_outgoing = 50 +max_established_per_peer = 1 +max_workbase_per_second = 10 +max_userworkbase_per_second = 10 +max_miningshare_per_second = 100 +max_inventory_per_second = 100 +max_transaction_per_second = 100 +max_requests_per_second = 100 +dial_timeout_secs = 30 + +[store] +path = "./store.db" +background_task_frequency_hours = 24 +pplns_ttl_days = 7 + +[stratum] +hostname = "pool.example.com" +port = 3333 +start_difficulty = 10000 +minimum_difficulty = 100 +solo_address = "tb1qyazxde6558qj6z3d9np5e6msmrspwpf6k0qggk" +bootstrap_address = "tb1qyazxde6558qj6z3d9np5e6msmrspwpf6k0qggk" +zmqpubhashblock = "tcp://127.0.0.1:28332" +network = "signet" +version_mask = "1fffe000" +difficulty_multiplier = 1.0 +pool_signature = "P2Poolv2" + +[bitcoinrpc] +url = "http://127.0.0.1:38332" +username = "p2pool" +password = "p2pool" + +[logging] +file = "./logs/p2pool.log" +console = true +level = "info" +stats_dir = "./logs/stats" + +[api] +hostname = "127.0.0.1" +port = 46884 diff --git a/src/app.rs b/src/app.rs index e7d7550..3a6a6de 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,7 @@ use crate::bitcoin_config::ConfigEntry as BitcoinEntry; use crate::components::bitcoin_config_view::BitcoinConfigView; use crate::components::file_explorer::FileExplorer; -use crate::components::p2pool_client::{ChainInfo, P2PoolClient}; +use crate::components::p2pool_client::{ChainInfo, P2PoolClient, PeerInfo}; use crate::components::p2pool_config_view::P2PoolConfigView; use crate::components::settings_view::SettingsView; use crate::settings::Settings; @@ -34,7 +34,7 @@ pub const BITCOIN_STATUS_TABS: &[&str] = &["Chain Info", "System", "Logs", "Peer pub const MAX_BITCOIN_STATUS_TAB: usize = BITCOIN_STATUS_TABS.len() - 1; /// Tab labels for the P2Pool Status view -pub const P2POOL_STATUS_TABS: &[&str] = &["Chain Info"]; +pub const P2POOL_STATUS_TABS: &[&str] = &["Chain Info", "Peers Info"]; pub const MAX_P2POOL_STATUS_TAB: usize = P2POOL_STATUS_TABS.len() - 1; @@ -113,15 +113,21 @@ pub struct App { pub p2pool_status_tab: usize, pub chain_info: Option, pub p2pool_chain_info_error: Option, - // async channel to receive chain info updates from the background task that fetches it when the P2Pool Status screen is opened + pub peer_info: Option>, + pub p2pool_peer_info_error: Option, + // async channel to receive chain info updates from the background task that + // fetches it when the P2Pool Status screen is opened. pub chain_info_tx: mpsc::UnboundedSender>, pub chain_info_rx: mpsc::UnboundedReceiver>, + pub peer_info_tx: mpsc::UnboundedSender>>, + pub peer_info_rx: mpsc::UnboundedReceiver>>, } impl App { #[must_use] pub fn new() -> App { - let (tx, rx) = mpsc::unbounded_channel(); + let (chain_info_tx, chain_info_rx) = mpsc::unbounded_channel(); + let (peer_info_tx, peer_info_rx) = mpsc::unbounded_channel(); App { current_screen: CurrentScreen::Home, sidebar_index: 0, @@ -142,8 +148,12 @@ impl App { p2pool_status_tab: 0, chain_info: None, p2pool_chain_info_error: None, - chain_info_tx: tx, - chain_info_rx: rx, + peer_info: None, + p2pool_peer_info_error: None, + chain_info_tx, + chain_info_rx, + peer_info_tx, + peer_info_rx, } } @@ -170,6 +180,21 @@ impl App { } } + pub fn poll_peer_info(&mut self) { + while let Ok(result) = self.peer_info_rx.try_recv() { + match result { + Ok(info) => { + self.peer_info = Some(info); + self.p2pool_peer_info_error = None; + } + Err(e) => { + self.peer_info = None; + self.p2pool_peer_info_error = Some(e.to_string()); + } + } + } + } + // Logic to switch between sidebar items pub fn toggle_menu(&mut self) { if self.current_screen == CurrentScreen::BitcoinConfig { @@ -187,13 +212,20 @@ impl App { if let Some(&(_, screen)) = SIDEBAR_ITEMS.get(self.sidebar_index) { self.current_screen = screen; if self.current_screen == CurrentScreen::P2PoolStatus { - let client = self.p2pool_client.clone(); - let tx = self.chain_info_tx.clone(); + let chain_client = self.p2pool_client.clone(); + let chain_tx = self.chain_info_tx.clone(); + let peer_client = self.p2pool_client.clone(); + let peer_tx = self.peer_info_tx.clone(); if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn(async move { - let res = client.fetch_chain_info().await; - let _ = tx.send(res.map_err(anyhow::Error::from)); + let res = chain_client.fetch_chain_info().await; + let _ = chain_tx.send(res.map_err(anyhow::Error::from)); + }); + + handle.spawn(async move { + let res = peer_client.fetch_peer_info().await; + let _ = peer_tx.send(res.map_err(anyhow::Error::from)); }); } } diff --git a/src/components/p2pool_client.rs b/src/components/p2pool_client.rs index af89d13..85fe225 100644 --- a/src/components/p2pool_client.rs +++ b/src/components/p2pool_client.rs @@ -24,6 +24,12 @@ pub struct ChainInfo { pub chain_tip_blockhash: Option, } +#[derive(Debug, Clone, Deserialize)] +pub struct PeerInfo { + pub peer_id: String, + pub status: Option, +} + fn build_client() -> Client { Client::builder() .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECONDS)) @@ -81,6 +87,20 @@ impl P2PoolClient { Ok(data) } + + pub async fn fetch_peer_info(&self) -> Result, reqwest::Error> { + let url = format!("{}/peers", self.base_url); + let mut request = self.client.get(url); + + if let Some((user, pass)) = &self.auth_credentials { + request = request.basic_auth(user, Some(pass)); + } + + let response = request.send().await?.error_for_status()?; + let data = response.json::>().await?; + + Ok(data) + } } impl Default for P2PoolClient { @@ -152,6 +172,73 @@ mod tests { mock.assert(); } + #[tokio::test] + async fn test_fetch_peer_info_success() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/peers") + .with_status(200) + .with_header("content-type", "application/json") + .with_body( + json!([ + { + "peer_id": "12D3KooWPeerOne", + "status": "Connected" + } + ]) + .to_string(), + ) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let result = client.fetch_peer_info().await.unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].peer_id, "12D3KooWPeerOne"); + assert_eq!(result[0].status.as_deref(), Some("Connected")); + mock.assert(); + } + + #[tokio::test] + async fn test_fetch_peer_info_accepts_missing_status() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/peers") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!([{ "peer_id": "12D3KooWPeerOne" }]).to_string()) + .create(); + + let client = P2PoolClient::with_base_url(server.url()); + let result = client.fetch_peer_info().await.unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].peer_id, "12D3KooWPeerOne"); + assert_eq!(result[0].status, None); + mock.assert(); + } + + #[tokio::test] + async fn test_fetch_peer_info_sends_basic_auth() { + let mut server = Server::new_async().await; + + let mock = server + .mock("GET", "/peers") + .match_header("authorization", "Basic dXNlcjpwYXNzd29yZA==") + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!([]).to_string()) + .create(); + + let client = + P2PoolClient::with_base_url(server.url()).with_auth("user".into(), "password".into()); + + client.fetch_peer_info().await.unwrap(); + mock.assert(); + } + #[tokio::test] async fn test_fetch_chain_info_errors_on_http_500() { let mut server = Server::new_async().await; diff --git a/src/components/p2pool_status_view.rs b/src/components/p2pool_status_view.rs index 5b3ef43..d249375 100644 --- a/src/components/p2pool_status_view.rs +++ b/src/components/p2pool_status_view.rs @@ -36,6 +36,7 @@ impl P2PoolStatusView { match app.p2pool_status_tab { 0 => Self::render_chain_info(f, app, outer[1]), + 1 => Self::render_peer_info(f, app, outer[1]), _ => {} } } @@ -49,7 +50,8 @@ impl P2PoolStatusView { )), Line::from(format!( "Chain Tip Height : {}", - info.chain_tip_height.unwrap_or(0) + info.chain_tip_height + .map_or_else(|| "-".to_string(), |h| h.to_string()) )), Line::from(format!( "Chain Tip Blockhash : {}", @@ -75,6 +77,50 @@ impl P2PoolStatusView { f.render_widget(paragraph, area); } + + fn render_peer_info(f: &mut Frame, app: &App, area: Rect) { + let text = if let Some(peers) = &app.peer_info { + if peers.is_empty() { + vec![Line::from(Span::styled( + "No connected peers", + Style::default().fg(Color::DarkGray), + ))] + } else { + let mut lines = Vec::with_capacity(peers.len() + 2); + lines.push(Line::from(format!( + "Connected Peers : {}", + peers.len() + ))); + lines.push(Line::from("")); + + for peer in peers { + lines.push(Line::from(format!( + "{} ({})", + peer.peer_id, + peer.status.as_deref().unwrap_or("Connected") + ))); + } + + lines + } + } else if let Some(err) = &app.p2pool_peer_info_error { + vec![Line::from(Span::styled( + format!("Failed to fetch peer info: {err}"), + Style::default().fg(Color::Red), + ))] + } else { + vec![Line::from(Span::styled( + "Loading peer info...", + Style::default().fg(Color::DarkGray), + ))] + }; + + let paragraph = Paragraph::new(text) + .block(Block::default().borders(Borders::ALL).title(" Peers Info ")) + .wrap(Wrap { trim: true }); + + f.render_widget(paragraph, area); + } } impl Default for P2PoolStatusView { @@ -82,3 +128,179 @@ impl Default for P2PoolStatusView { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::components::p2pool_client::{ChainInfo, PeerInfo}; + use ratatui::{Terminal, backend::TestBackend, prelude::Rect}; + + fn render_view(app: &App) -> String { + let backend = TestBackend::new(100, 25); + let mut terminal = Terminal::new(backend).unwrap(); + let area = Rect::new(0, 0, 100, 25); + + terminal + .draw(|f| P2PoolStatusView::render(f, app, area)) + .unwrap(); + + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect() + } + + #[test] + fn render_dispatches_chain_info_for_tab_zero() { + let app = App::new(); + + let output = render_view(&app); + + assert!(output.contains("Loading chain info...")); + } + + #[test] + fn render_dispatches_peer_info_for_tab_one() { + let mut app = App::new(); + app.p2pool_status_tab = 1; + + let output = render_view(&app); + + assert!(output.contains("Loading peer info...")); + } + + #[test] + fn render_with_unknown_tab_only_renders_tabs() { + let mut app = App::new(); + app.p2pool_status_tab = 5; + + let output = render_view(&app); + + assert!(output.contains("Info")); + assert!(!output.contains("Loading chain info...")); + assert!(!output.contains("Genesis Blockhash")); + assert!(!output.contains("Loading peer info...")); + assert!(!output.contains("No connected peers")); + assert!(!output.contains("Connected Peers")); + } + + #[test] + fn render_chain_info_shows_available_values() { + let mut app = App::new(); + app.chain_info = Some(ChainInfo { + genesis_blockhash: Some("genesis-hash".to_string()), + chain_tip_height: Some(850_000), + chain_tip_blockhash: Some("tip-hash".to_string()), + total_work: "ffff".to_string(), + }); + + let output = render_view(&app); + + assert!(output.contains("Genesis Blockhash : genesis-hash")); + assert!(output.contains("Chain Tip Height : 850000")); + assert!(output.contains("Chain Tip Blockhash : tip-hash")); + assert!(output.contains("Total Work : ffff")); + } + + #[test] + fn render_chain_info_shows_dash_for_missing_optional_values() { + let mut app = App::new(); + app.chain_info = Some(ChainInfo { + genesis_blockhash: None, + chain_tip_height: None, + chain_tip_blockhash: None, + total_work: "0".to_string(), + }); + + let output = render_view(&app); + + assert!(output.contains("Genesis Blockhash : -")); + assert!(output.contains("Chain Tip Height : -")); + assert!(output.contains("Chain Tip Blockhash : -")); + assert!(output.contains("Total Work : 0")); + } + + #[test] + fn render_chain_info_shows_error_when_fetch_failed() { + let mut app = App::new(); + app.p2pool_chain_info_error = Some("connection refused".to_string()); + + let output = render_view(&app); + + assert!(output.contains("Failed to fetch chain info: connection refused")); + assert!(!output.contains("Loading chain info...")); + } + + #[test] + fn render_peer_info_shows_loading_state_with_no_data() { + let mut app = App::new(); + app.p2pool_status_tab = 1; + + let output = render_view(&app); + + assert!(output.contains("Loading peer info...")); + } + + #[test] + fn render_peer_info_shows_error_when_fetch_failed() { + let mut app = App::new(); + app.p2pool_status_tab = 1; + app.p2pool_peer_info_error = Some("request timed out".to_string()); + + let output = render_view(&app); + + assert!(output.contains("Failed to fetch peer info: request timed out")); + assert!(!output.contains("Loading peer info...")); + } + + #[test] + fn render_peer_info_shows_empty_state_when_no_peers_are_connected() { + let mut app = App::new(); + app.p2pool_status_tab = 1; + app.peer_info = Some(Vec::new()); + + let output = render_view(&app); + + assert!(output.contains("No connected peers")); + } + + #[test] + fn render_peer_info_lists_connected_peers_with_statuses() { + let mut app = App::new(); + app.p2pool_status_tab = 1; + app.peer_info = Some(vec![ + PeerInfo { + peer_id: "12D3KooWPeerOne".to_string(), + status: Some("Connected".to_string()), + }, + PeerInfo { + peer_id: "12D3KooWPeerTwo".to_string(), + status: Some("Syncing".to_string()), + }, + ]); + + let output = render_view(&app); + + assert!(output.contains("Connected Peers : 2")); + assert!(output.contains("12D3KooWPeerOne (Connected)")); + assert!(output.contains("12D3KooWPeerTwo (Syncing)")); + } + + #[test] + fn render_peer_info_defaults_missing_status_to_connected() { + let mut app = App::new(); + app.p2pool_status_tab = 1; + app.peer_info = Some(vec![PeerInfo { + peer_id: "12D3KooWNoStatus".to_string(), + status: None, + }]); + + let output = render_view(&app); + + assert!(output.contains("Connected Peers : 1")); + assert!(output.contains("12D3KooWNoStatus (Connected)")); + } +} diff --git a/src/components/status_bar.rs b/src/components/status_bar.rs index 268346d..775d6f6 100644 --- a/src/components/status_bar.rs +++ b/src/components/status_bar.rs @@ -108,7 +108,7 @@ impl StatusBar { spans.extend(hint("Esc", "Back")); } } - CurrentScreen::BitcoinStatus => { + CurrentScreen::BitcoinStatus | CurrentScreen::P2PoolStatus => { spans.extend(hint("↑↓", "Navigate sidebar")); spans.extend(hint("←→", "Switch tab")); spans.extend(hint("q", "Quit")); diff --git a/src/config.rs b/src/config.rs index fd56aa1..c7e5966 100644 --- a/src/config.rs +++ b/src/config.rs @@ -20,7 +20,7 @@ pub fn load_api_config() -> Result { .build()?; let host: String = settings.get("api.host").unwrap_or("127.0.0.1".into()); - let port: u16 = settings.get("api.port").unwrap_or(9332); + let port: u16 = settings.get("api.port").unwrap_or(46884); let auth_user: Option = settings.get("api.auth_user").ok(); let auth_pass: Option = settings.get("api.auth_pass").ok(); diff --git a/src/main.rs b/src/main.rs index 76b9060..54125af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,7 +4,8 @@ use p2poolv2_config::Config as P2PoolConfig; use pdm::app::{ - App, AppAction, CurrentScreen, ExplorerTrigger, MAX_BITCOIN_STATUS_TAB, MAX_SIDEBAR_INDEX, + App, AppAction, CurrentScreen, ExplorerTrigger, MAX_BITCOIN_STATUS_TAB, MAX_P2POOL_STATUS_TAB, + MAX_SIDEBAR_INDEX, }; use pdm::bitcoin_config::{ parse_config as parse_bitcoin_config, save_config as save_bitcoin_config, @@ -23,32 +24,37 @@ use crossterm::{ }; use ratatui::{Terminal, backend::Backend, backend::CrosstermBackend}; use std::io; +use std::time::Duration; #[tokio::main] async fn main() -> Result<()> { - // Setup Terminal + let mut app = App::new(); + app.settings = load_settings(); + bootstrap_from_settings(&mut app); + + let res = run_terminal_session(&mut app); + + if let Err(err) = &res { + eprintln!("Error: {err:#}"); + } + + Ok(()) +} + +fn run_terminal_session(app: &mut App) -> Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; - // Run App - let mut app = App::new(); - app.settings = load_settings(); - bootstrap_from_settings(&mut app); - let res = run_app(&mut terminal, &mut app); + let res = run_app(&mut terminal, app); - // Restore Terminal disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; - if let Err(err) = res { - eprintln!("Error: {err:#}"); - } - - Ok(()) + res } fn sidebar_nav(key: KeyCode, app: &mut App) -> AppAction { @@ -65,133 +71,167 @@ fn sidebar_nav(key: KeyCode, app: &mut App) -> AppAction { } } +enum KeyOutcome { + Ignored, + Exit, + Action(AppAction), +} + fn run_app(terminal: &mut Terminal, app: &mut App) -> Result<()> where ::Error: Send + Sync + 'static, { loop { app.poll_chain_info(); + app.poll_peer_info(); terminal.draw(|f| ui::ui(f, app))?; - if let Event::Key(key) = event::read()? { - if key.kind != KeyEventKind::Press { - continue; + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + match dispatch_key(key, app) { + KeyOutcome::Ignored => {} + KeyOutcome::Exit => return Ok(()), + KeyOutcome::Action(action) => { + if handle_action(action, app)?.is_break() { + return Ok(()); + } + } + } } + } + } +} - // Ctrl-C is always a hard exit. - // 'q' is suppressed while a text-input field is active. - let text_input_active = (app.current_screen == CurrentScreen::BitcoinConfig - && !app.bitcoin_config_view.sidebar_focused - && app.bitcoin_config_view.editing) - || (app.current_screen == CurrentScreen::P2PoolConfig - && !app.p2pool_config_view.sidebar_focused - && app.p2pool_config_view.editing); - - if (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c')) - || (!text_input_active && key.code == KeyCode::Char('q')) - { - return Ok(()); - } +#[allow(clippy::too_many_lines)] // Mirrors the top-level key dispatch table. +fn dispatch_key(key: event::KeyEvent, app: &mut App) -> KeyOutcome { + if key.kind != KeyEventKind::Press { + return KeyOutcome::Ignored; + } - let action = match app.current_screen { - CurrentScreen::FileExplorer => app.explorer.handle_input(key), + // Ctrl-C is always a hard exit. + // 'q' is suppressed while a text-input field is active. + let text_input_active = (app.current_screen == CurrentScreen::BitcoinConfig + && !app.bitcoin_config_view.sidebar_focused + && app.bitcoin_config_view.editing) + || (app.current_screen == CurrentScreen::P2PoolConfig + && !app.p2pool_config_view.sidebar_focused + && app.p2pool_config_view.editing); - CurrentScreen::BitcoinStatus => match key.code { - KeyCode::Left => { - if app.bitcoin_status_tab > 0 { - app.bitcoin_status_tab -= 1; + if (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c')) + || (!text_input_active && key.code == KeyCode::Char('q')) + { + return KeyOutcome::Exit; + } + + let action = match app.current_screen { + CurrentScreen::FileExplorer => app.explorer.handle_input(key), + + CurrentScreen::BitcoinStatus => match key.code { + KeyCode::Left => { + if app.bitcoin_status_tab > 0 { + app.bitcoin_status_tab -= 1; + } + AppAction::None + } + KeyCode::Right => { + if app.bitcoin_status_tab < MAX_BITCOIN_STATUS_TAB { + app.bitcoin_status_tab += 1; + } + AppAction::None + } + k => sidebar_nav(k, app), + }, + + CurrentScreen::P2PoolStatus => match key.code { + KeyCode::Left => { + if app.p2pool_status_tab > 0 { + app.p2pool_status_tab -= 1; + } + AppAction::None + } + KeyCode::Right => { + if app.p2pool_status_tab < MAX_P2POOL_STATUS_TAB { + app.p2pool_status_tab += 1; + } + AppAction::None + } + k => sidebar_nav(k, app), + }, + + CurrentScreen::BitcoinConfig => { + if app.bitcoin_conf_path.is_some() { + if app.bitcoin_config_view.sidebar_focused { + match key.code { + KeyCode::Enter => { + app.bitcoin_config_view.sidebar_focused = false; + AppAction::None } - AppAction::None + k => sidebar_nav(k, app), } - KeyCode::Right => { - if app.bitcoin_status_tab < MAX_BITCOIN_STATUS_TAB { - app.bitcoin_status_tab += 1; - } - AppAction::None + } else { + let entries = &app.bitcoin_data; + app.bitcoin_config_view.handle_input(key, entries) + } + } else { + match key.code { + KeyCode::Enter => { + app.bitcoin_config_view.warning_message = None; + AppAction::OpenExplorer(ExplorerTrigger::BitcoinConfig) } + KeyCode::Esc => AppAction::CloseModal, k => sidebar_nav(k, app), - }, - - CurrentScreen::BitcoinConfig => { - if app.bitcoin_conf_path.is_some() { - if app.bitcoin_config_view.sidebar_focused { - match key.code { - KeyCode::Enter => { - app.bitcoin_config_view.sidebar_focused = false; - AppAction::None - } - k => sidebar_nav(k, app), - } - } else { - let entries = &app.bitcoin_data; - app.bitcoin_config_view.handle_input(key, entries) - } - } else { - match key.code { - KeyCode::Enter => { - app.bitcoin_config_view.warning_message = None; - AppAction::OpenExplorer(ExplorerTrigger::BitcoinConfig) - } - KeyCode::Esc => AppAction::CloseModal, - k => sidebar_nav(k, app), - } - } } + } + } - // P2Pool config - CurrentScreen::P2PoolConfig => { - if app.p2pool_conf_path.is_some() { - if app.p2pool_config_view.sidebar_focused { - match key.code { - KeyCode::Enter => { - app.p2pool_config_view.sidebar_focused = false; - AppAction::None - } - k => sidebar_nav(k, app), - } - } else { - // Build flat entry list and delegate to the view - let entries = app - .p2pool_config - .as_ref() - .map(|cfg| flatten_config(cfg)) - .unwrap_or_default(); - app.p2pool_config_view.handle_input(key, &entries) - } - } else { - match key.code { - KeyCode::Enter => { - app.p2pool_config_view.warning_message = None; - AppAction::OpenExplorer(ExplorerTrigger::P2PoolConfig) - } - KeyCode::Esc => AppAction::CloseModal, - k => sidebar_nav(k, app), + CurrentScreen::P2PoolConfig => { + if app.p2pool_conf_path.is_some() { + if app.p2pool_config_view.sidebar_focused { + match key.code { + KeyCode::Enter => { + app.p2pool_config_view.sidebar_focused = false; + AppAction::None } + k => sidebar_nav(k, app), } + } else { + let entries = app + .p2pool_config + .as_ref() + .map(|cfg| flatten_config(cfg)) + .unwrap_or_default(); + app.p2pool_config_view.handle_input(key, &entries) } - - CurrentScreen::Settings => { - if app.settings_view.sidebar_focused { - match key.code { - KeyCode::Enter => { - app.settings_view.sidebar_focused = false; - AppAction::None - } - k => sidebar_nav(k, app), - } - } else { - app.settings_view.handle_input(key) + } else { + match key.code { + KeyCode::Enter => { + app.p2pool_config_view.warning_message = None; + AppAction::OpenExplorer(ExplorerTrigger::P2PoolConfig) } + KeyCode::Esc => AppAction::CloseModal, + k => sidebar_nav(k, app), } + } + } - _ => sidebar_nav(key.code, app), - }; - - if handle_action(action, app)?.is_break() { - return Ok(()); + CurrentScreen::Settings => { + if app.settings_view.sidebar_focused { + match key.code { + KeyCode::Enter => { + app.settings_view.sidebar_focused = false; + AppAction::None + } + k => sidebar_nav(k, app), + } + } else { + app.settings_view.handle_input(key) } } - } + + _ => sidebar_nav(key.code, app), + }; + + KeyOutcome::Action(action) } /// Pre-populate app state from `app.settings`. Called once at startup after @@ -598,6 +638,14 @@ mod tests { let _ = handle_action(action, app).unwrap(); } + fn press(code: KeyCode) -> crossterm::event::KeyEvent { + crossterm::event::KeyEvent::new(code, KeyModifiers::empty()) + } + + fn press_with(code: KeyCode, modifiers: KeyModifiers) -> crossterm::event::KeyEvent { + crossterm::event::KeyEvent::new(code, modifiers) + } + /// Write a p2pool TOML to `path`. fn write_valid_p2pool_toml(path: &std::path::Path) { std::fs::write( @@ -714,6 +762,475 @@ port = 46884 .unwrap(); } + #[test] + fn dispatch_key_ignores_non_press_events() { + use crossterm::event::{KeyEvent, KeyEventKind}; + + let mut app = App::new(); + let mut release = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::empty()); + release.kind = KeyEventKind::Release; + + assert!(matches!( + dispatch_key(release, &mut app), + KeyOutcome::Ignored + )); + } + + #[test] + fn dispatch_key_ctrl_c_always_exits() { + let mut app = App::new(); + let key = press_with(KeyCode::Char('c'), KeyModifiers::CONTROL); + + assert!(matches!(dispatch_key(key, &mut app), KeyOutcome::Exit)); + } + + #[test] + fn dispatch_key_q_exits_when_not_text_input_active() { + let mut app = App::new(); + + assert!(matches!( + dispatch_key(press(KeyCode::Char('q')), &mut app), + KeyOutcome::Exit + )); + } + + #[test] + fn dispatch_key_q_suppressed_while_editing_bitcoin_config() { + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinConfig; + app.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_config_view.sidebar_focused = false; + app.bitcoin_config_view.editing = true; + + let outcome = dispatch_key(press(KeyCode::Char('q')), &mut app); + + assert!(!matches!(outcome, KeyOutcome::Exit)); + } + + #[test] + fn dispatch_key_q_suppressed_while_editing_p2pool_config() { + let mut app = App::new(); + app.current_screen = CurrentScreen::P2PoolConfig; + app.p2pool_config_view.sidebar_focused = false; + app.p2pool_config_view.editing = true; + + let outcome = dispatch_key(press(KeyCode::Char('q')), &mut app); + + assert!(!matches!(outcome, KeyOutcome::Exit)); + } + + #[test] + fn dispatch_key_q_exits_on_bitcoin_config_when_sidebar_focused() { + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinConfig; + app.bitcoin_config_view.sidebar_focused = true; + app.bitcoin_config_view.editing = true; + + assert!(matches!( + dispatch_key(press(KeyCode::Char('q')), &mut app), + KeyOutcome::Exit + )); + } + + #[test] + fn dispatch_key_bitcoin_status_left_decrements_above_zero() { + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinStatus; + app.bitcoin_status_tab = 1; + + dispatch_key(press(KeyCode::Left), &mut app); + + assert_eq!(app.bitcoin_status_tab, 0); + } + + #[test] + fn dispatch_key_bitcoin_status_left_floors_at_zero() { + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinStatus; + app.bitcoin_status_tab = 0; + + dispatch_key(press(KeyCode::Left), &mut app); + + assert_eq!(app.bitcoin_status_tab, 0); + } + + #[test] + fn dispatch_key_bitcoin_status_right_increments_below_max() { + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinStatus; + app.bitcoin_status_tab = 0; + + dispatch_key(press(KeyCode::Right), &mut app); + + assert_eq!(app.bitcoin_status_tab, 1); + } + + #[test] + fn dispatch_key_bitcoin_status_right_ceils_at_max() { + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinStatus; + app.bitcoin_status_tab = MAX_BITCOIN_STATUS_TAB; + + dispatch_key(press(KeyCode::Right), &mut app); + + assert_eq!(app.bitcoin_status_tab, MAX_BITCOIN_STATUS_TAB); + } + + #[test] + fn dispatch_key_bitcoin_status_other_key_falls_back_to_sidebar_nav() { + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinStatus; + app.sidebar_index = 0; + + let outcome = dispatch_key(press(KeyCode::Down), &mut app); + + assert_eq!(app.sidebar_index, 1); + assert!(matches!(outcome, KeyOutcome::Action(AppAction::ToggleMenu))); + } + + #[test] + fn dispatch_key_p2pool_status_left_decrements_above_zero() { + let mut app = App::new(); + app.current_screen = CurrentScreen::P2PoolStatus; + app.p2pool_status_tab = 1; + + dispatch_key(press(KeyCode::Left), &mut app); + + assert_eq!(app.p2pool_status_tab, 0); + } + + #[test] + fn dispatch_key_p2pool_status_left_floors_at_zero() { + let mut app = App::new(); + app.current_screen = CurrentScreen::P2PoolStatus; + app.p2pool_status_tab = 0; + + dispatch_key(press(KeyCode::Left), &mut app); + + assert_eq!(app.p2pool_status_tab, 0); + } + + #[test] + fn dispatch_key_p2pool_status_right_increments_below_max() { + let mut app = App::new(); + app.current_screen = CurrentScreen::P2PoolStatus; + app.p2pool_status_tab = 0; + + dispatch_key(press(KeyCode::Right), &mut app); + + assert_eq!(app.p2pool_status_tab, 1); + } + + #[test] + fn dispatch_key_p2pool_status_right_ceils_at_max() { + let mut app = App::new(); + app.current_screen = CurrentScreen::P2PoolStatus; + app.p2pool_status_tab = MAX_P2POOL_STATUS_TAB; + + dispatch_key(press(KeyCode::Right), &mut app); + + assert_eq!(app.p2pool_status_tab, MAX_P2POOL_STATUS_TAB); + } + + #[test] + fn dispatch_key_file_explorer_delegates_to_explorer() { + let mut app = App::new(); + app.current_screen = CurrentScreen::FileExplorer; + app.explorer.load_directory(); + + let outcome = dispatch_key(press(KeyCode::Down), &mut app); + + assert!(matches!(outcome, KeyOutcome::Action(_))); + } + + #[test] + fn dispatch_key_bitcoin_config_sidebar_focused_enter_unfocuses() { + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinConfig; + app.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_config_view.sidebar_focused = true; + + let outcome = dispatch_key(press(KeyCode::Enter), &mut app); + + assert!(!app.bitcoin_config_view.sidebar_focused); + assert!(matches!(outcome, KeyOutcome::Action(AppAction::None))); + } + + #[test] + fn dispatch_key_bitcoin_config_sidebar_focused_other_key_navigates_sidebar() { + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinConfig; + app.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_config_view.sidebar_focused = true; + app.sidebar_index = 1; + + dispatch_key(press(KeyCode::Down), &mut app); + + assert_eq!(app.sidebar_index, 2); + } + + #[test] + fn dispatch_key_bitcoin_config_not_focused_delegates_to_view() { + use pdm::bitcoin_config::ConfigEntry; + + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinConfig; + app.bitcoin_conf_path = Some(std::path::PathBuf::from("/tmp/bitcoin.conf")); + app.bitcoin_config_view.sidebar_focused = false; + app.bitcoin_data = vec![ConfigEntry { + key: "rpcuser".to_string(), + value: "old".to_string(), + enabled: true, + schema: None, + section: None, + }]; + + dispatch_key(press(KeyCode::Esc), &mut app); + + assert!(app.bitcoin_config_view.sidebar_focused); + } + + #[test] + fn dispatch_key_bitcoin_config_no_path_enter_opens_explorer() { + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinConfig; + app.bitcoin_conf_path = None; + app.bitcoin_config_view.warning_message = Some("stale".to_string()); + + let outcome = dispatch_key(press(KeyCode::Enter), &mut app); + + assert!(app.bitcoin_config_view.warning_message.is_none()); + assert!(matches!( + outcome, + KeyOutcome::Action(AppAction::OpenExplorer(ExplorerTrigger::BitcoinConfig)) + )); + } + + #[test] + fn dispatch_key_bitcoin_config_no_path_esc_closes_modal() { + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinConfig; + app.bitcoin_conf_path = None; + + let outcome = dispatch_key(press(KeyCode::Esc), &mut app); + + assert!(matches!(outcome, KeyOutcome::Action(AppAction::CloseModal))); + } + + #[test] + fn dispatch_key_bitcoin_config_no_path_other_key_navigates_sidebar() { + let mut app = App::new(); + app.current_screen = CurrentScreen::BitcoinConfig; + app.bitcoin_conf_path = None; + app.sidebar_index = 1; + + dispatch_key(press(KeyCode::Up), &mut app); + + assert_eq!(app.sidebar_index, 0); + } + + #[test] + fn dispatch_key_p2pool_config_sidebar_focused_enter_unfocuses() { + let mut app = App::new(); + app.current_screen = CurrentScreen::P2PoolConfig; + app.p2pool_conf_path = Some(std::path::PathBuf::from("/tmp/p2pool.toml")); + app.p2pool_config_view.sidebar_focused = true; + + dispatch_key(press(KeyCode::Enter), &mut app); + + assert!(!app.p2pool_config_view.sidebar_focused); + } + + #[test] + fn dispatch_key_p2pool_config_not_focused_uses_flattened_entries() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("p2pool.toml"); + write_valid_p2pool_toml(&file); + let cfg = P2PoolConfig::load(file.to_str().unwrap()).unwrap(); + + let mut app = App::new(); + app.current_screen = CurrentScreen::P2PoolConfig; + app.p2pool_conf_path = Some(file); + app.p2pool_config = Some(cfg); + app.p2pool_config_view.sidebar_focused = false; + + let outcome = dispatch_key(press(KeyCode::Down), &mut app); + + assert!(matches!(outcome, KeyOutcome::Action(_))); + } + + #[test] + fn dispatch_key_p2pool_config_no_path_enter_opens_explorer() { + let mut app = App::new(); + app.current_screen = CurrentScreen::P2PoolConfig; + app.p2pool_conf_path = None; + app.p2pool_config_view.warning_message = Some("stale".to_string()); + + let outcome = dispatch_key(press(KeyCode::Enter), &mut app); + + assert!(app.p2pool_config_view.warning_message.is_none()); + assert!(matches!( + outcome, + KeyOutcome::Action(AppAction::OpenExplorer(ExplorerTrigger::P2PoolConfig)) + )); + } + + #[test] + fn dispatch_key_p2pool_config_no_path_esc_closes_modal() { + let mut app = App::new(); + app.current_screen = CurrentScreen::P2PoolConfig; + app.p2pool_conf_path = None; + + let outcome = dispatch_key(press(KeyCode::Esc), &mut app); + + assert!(matches!(outcome, KeyOutcome::Action(AppAction::CloseModal))); + } + + #[test] + fn dispatch_key_p2pool_config_no_path_other_key_navigates_sidebar() { + let mut app = App::new(); + app.current_screen = CurrentScreen::P2PoolConfig; + app.p2pool_conf_path = None; + app.sidebar_index = 1; + + dispatch_key(press(KeyCode::Down), &mut app); + + assert_eq!(app.sidebar_index, 2); + } + + #[test] + fn dispatch_key_settings_sidebar_focused_enter_unfocuses() { + let mut app = App::new(); + app.current_screen = CurrentScreen::Settings; + app.settings_view.sidebar_focused = true; + + let outcome = dispatch_key(press(KeyCode::Enter), &mut app); + + assert!(!app.settings_view.sidebar_focused); + assert!(matches!(outcome, KeyOutcome::Action(AppAction::None))); + } + + #[test] + fn dispatch_key_settings_sidebar_focused_respects_max_index() { + let mut app = App::new(); + app.current_screen = CurrentScreen::Settings; + app.settings_view.sidebar_focused = true; + app.sidebar_index = MAX_SIDEBAR_INDEX; + + dispatch_key(press(KeyCode::Down), &mut app); + + assert_eq!(app.sidebar_index, MAX_SIDEBAR_INDEX); + } + + #[test] + fn dispatch_key_settings_not_focused_delegates_to_settings_view() { + let mut app = App::new(); + app.current_screen = CurrentScreen::Settings; + app.settings_view.sidebar_focused = false; + + let outcome = dispatch_key(press(KeyCode::Down), &mut app); + + assert!(matches!(outcome, KeyOutcome::Action(_))); + } + + #[test] + fn dispatch_key_default_screen_falls_back_to_sidebar_nav() { + let mut app = App::new(); + app.sidebar_index = 0; + + let outcome = dispatch_key(press(KeyCode::Down), &mut app); + + assert_eq!(app.sidebar_index, 1); + assert!(matches!(outcome, KeyOutcome::Action(AppAction::ToggleMenu))); + } + + #[test] + #[serial] + fn file_selected_for_settings_field_0_invalid_bitcoin_config_sets_error() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + redirect_saves_to(&dir); + let path = dir.path().join("not_a_config.conf"); + std::fs::write(&path, "unknownkey=somevalue\n").unwrap(); + + let mut app = App::new(); + app.explorer_trigger = Some(ExplorerTrigger::Settings(0)); + + run(AppAction::FileSelected(path), &mut app); + + assert_eq!( + app.settings_view.save_error.as_deref(), + Some("File does not appear to be a Bitcoin config.") + ); + assert!(app.settings.bitcoin_conf_path.is_none()); + assert_eq!(app.current_screen, CurrentScreen::Settings); + } + + #[test] + #[serial] + fn file_selected_for_settings_field_0_missing_path_sets_invalid_config_error() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + redirect_saves_to(&dir); + let missing_path = dir.path().join("does_not_exist.conf"); + + let mut app = App::new(); + app.explorer_trigger = Some(ExplorerTrigger::Settings(0)); + + run(AppAction::FileSelected(missing_path), &mut app); + + assert_eq!( + app.settings_view.save_error.as_deref(), + Some("File does not appear to be a Bitcoin config.") + ); + assert!(app.settings.bitcoin_conf_path.is_none()); + } + + #[test] + #[serial] + fn file_selected_for_settings_field_1_invalid_hostname_sets_error() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + redirect_saves_to(&dir); + let path = dir.path().join("p2pool.toml"); + write_empty_hostname_toml(&path); + + let mut app = App::new(); + app.explorer_trigger = Some(ExplorerTrigger::Settings(1)); + + run(AppAction::FileSelected(path), &mut app); + + assert_eq!( + app.settings_view.save_error.as_deref(), + Some("Config appears invalid: stratum.hostname is empty.") + ); + assert!(app.settings.p2pool_conf_path.is_none()); + assert!(app.p2pool_config.is_none()); + } + + #[test] + #[serial] + fn file_selected_for_settings_field_1_load_failure_sets_error() { + use tempfile::tempdir; + + let dir = tempdir().unwrap(); + redirect_saves_to(&dir); + let path = dir.path().join("bad.toml"); + std::fs::write(&path, "invalid === toml").unwrap(); + + let mut app = App::new(); + app.explorer_trigger = Some(ExplorerTrigger::Settings(1)); + + run(AppAction::FileSelected(path), &mut app); + + let err = app.settings_view.save_error.expect("expected a save error"); + assert!(err.starts_with("Failed to load P2Pool config:")); + assert!(app.settings.p2pool_conf_path.is_none()); + } + #[test] fn test_app_integration_smoke_test() { let backend = TestBackend::new(80, 25); @@ -1227,17 +1744,13 @@ port = 46884 let dir = tempdir().unwrap(); let path = dir.path().join("p2pool.toml"); - // Write a minimal but syntactically valid TOML file; P2PoolConfig::load - // may fail to parse it, but bootstrap_from_settings should at least set - // app.p2pool_conf_path regardless of whether the config is parseable. - let cfg = write_valid_p2pool_toml(&path); + write_valid_p2pool_toml(&path); let mut app = App::new(); app.settings.p2pool_conf_path = Some(path.clone()); bootstrap_from_settings(&mut app); - // The path must always be set, even if the config fails to parse. assert_eq!(app.p2pool_conf_path, Some(path)); } @@ -1270,7 +1783,7 @@ port = 46884 let dir = tempdir().unwrap(); redirect_saves_to(&dir); let path = dir.path().join("p2pool.toml"); - let cfg = write_valid_p2pool_toml(&path); + write_valid_p2pool_toml(&path); let mut app = App::new(); app.explorer_trigger = Some(ExplorerTrigger::Settings(1)); diff --git a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap index 77cbcf1..9614b70 100644 --- a/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap +++ b/src/snapshots/pdm__ui__tests__p2pool_status_screen_render.snap @@ -7,7 +7,7 @@ TestBackend { area: Rect { x: 0, y: 0, width: 80, height: 24 }, content: [ "┌ PDM ──────────────────┐┌ Info ───────────────────────────────────────────────┐", - "│Home ││ Chain Info │", + "│Home ││ Chain Info │ Peers Info │", "│Bitcoin Config ││ │", "│Bitcoin Status │└─────────────────────────────────────────────────────┘", "│P2Pool Config │┌ Chain Info ─────────────────────────────────────────┐", @@ -29,7 +29,7 @@ TestBackend { "│ ││ │", "│ ││ │", "└───────────────────────┘└─────────────────────────────────────────────────────┘", - " ↑↓ Navigate sidebar Enter Select q Quit ", + " ↑↓ Navigate sidebar ←→ Switch tab q Quit ", ], styles: [ x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE, @@ -42,10 +42,10 @@ TestBackend { x: 0, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, x: 4, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, x: 23, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 30, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 39, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, - x: 42, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, - x: 49, y: 23, fg: Reset, bg: Black, underline: Reset, modifier: NONE, + x: 27, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 40, y: 23, fg: White, bg: DarkGray, underline: Reset, modifier: NONE, + x: 43, y: 23, fg: DarkGray, bg: Black, underline: Reset, modifier: NONE, + x: 50, y: 23, fg: Reset, bg: Black, underline: Reset, modifier: NONE, ] }, scrollback: Buffer { diff --git a/src/ui.rs b/src/ui.rs index e9fbede..f14d3e8 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -112,8 +112,6 @@ pub fn ui(f: &mut Frame, app: &mut App) { mod tests { use super::*; use crate::app::App; - use crate::components::p2pool_client::P2PoolClient; - use mockito::Server; use ratatui::Terminal; use ratatui::backend::TestBackend; @@ -194,28 +192,14 @@ mod tests { #[test] fn test_p2pool_status_screen_render() { - let mut server = Server::new(); - let _mock = server - .mock("GET", "/chain_info") - .with_status(200) - .with_header("content-type", "application/json") - .with_body( - serde_json::json!({ - "genesis_blockhash": null, - "chain_tip_height": 1, - "total_work": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffff", - "chain_tip_blockhash": null - }) - .to_string(), - ) - .create(); - - let client = P2PoolClient::with_base_url(server.url()); - let mut app = App::new_with_client(client); let mut terminal = make_terminal(); + let mut app = App::new(); + app.sidebar_index = 4; app.toggle_menu(); + terminal.draw(|f| ui(f, &mut app)).unwrap(); + insta::assert_debug_snapshot!(terminal.backend()); }