Skip to main content

scope_cli/cli/monitor/
mod.rs

1//! # Live Token Monitor
2//!
3//! This module implements a real-time terminal UI for monitoring token metrics.
4//! It displays live-updating charts for price, volume, transactions, and liquidity
5//! across four switchable layout presets with responsive terminal sizing.
6//!
7//! ## Usage
8//!
9//! Directly from the command line (no interactive mode required):
10//! ```text
11//! scope monitor USDC
12//! scope mon PEPE --chain ethereum --layout chart-focus --refresh 3
13//! scope monitor 0x1234... -c solana -s log --color-scheme blue-orange
14//! ```
15//!
16//! Or from interactive mode:
17//! ```text
18//! scope> monitor USDC
19//! scope> mon 0x1234...
20//! ```
21//!
22//! ## Layout Presets
23//!
24//! - **Dashboard** -- Charts top, gauges middle, transaction feed bottom (default)
25//! - **ChartFocus** -- Full-width candles (~85%), minimal stats overlay below
26//! - **Feed** -- Transaction log takes priority (~75%), small metrics + buy/sell on top
27//! - **Compact** -- Price sparkline and metrics only, for small terminals (<80x24)
28//! - **Exchange** -- Order book + chart + market info (exchange-style view)
29//!
30//! The monitor auto-selects a layout based on terminal dimensions (responsive
31//! breakpoints). Manual switching via `L`/`H` disables auto-selection until `A`.
32//!
33//! ## Features
34//!
35//! - Real-time price chart (line, candlestick, or volume profile) with sliding window
36//! - Volume bar chart
37//! - Buy/sell ratio gauge
38//! - Scrollable activity feed (transaction log)
39//! - Key metrics panel with sparkline and stats table
40//! - Config-driven widget visibility (toggle any widget on/off)
41//! - Four layout presets switchable at runtime
42//! - Responsive terminal sizing with auto-layout
43//! - Log/linear Y-axis scale toggle
44//! - Three color schemes (Green/Red, Blue/Orange, Monochrome)
45//! - On-chain holder count integration (when chain client is available)
46//! - Per-pair liquidity depth breakdown across DEXes
47//! - Configurable price alerts (min/max thresholds, whale detection, volume spikes)
48//! - CSV export mode (toggle with `E`, writes to `./scope-exports/`)
49//! - Auto-pause on user input (toggle with `Shift+P`)
50//!
51//! ## Keyboard Controls
52//!
53//! - `Q`/`Esc` quit, `R` refresh, `P`/`Space` pause
54//! - `Shift+P` toggle auto-pause on input
55//! - `E` toggle CSV export (REC indicator when active)
56//! - `L`/`H` cycle layout forward/backward
57//! - `W` + `1-5` toggle widget visibility
58//! - `A` re-enable auto layout
59//! - `C` toggle chart mode, `S` toggle log/linear scale, `/` cycle color scheme
60//! - `T`/`Tab` cycle time period, `1-6` select period
61//! - `J`/`K` scroll activity log, `+`/`-` adjust refresh speed
62
63pub mod config;
64pub mod input;
65pub mod state;
66pub mod widgets;
67
68// Re-export key types for external use
69pub use config::{
70    AlertConfig, ChartMode, ColorScheme, ExportConfig, LayoutPreset, MonitorConfig, ScaleMode,
71    TimePeriod, WidgetVisibility,
72};
73pub use input::MonitorApp;
74pub use state::MonitorState;
75
76use clap::Args;
77use scope::chains::ChainClientFactory;
78use scope::chains::dex::{DexDataSource, DexTokenData};
79use scope::config::Config;
80use scope::error::{Result, ScopeError};
81use std::io;
82use std::path::PathBuf;
83use std::time::Duration;
84
85use super::interactive::SessionContext;
86
87// ============================================================================
88// CLI Arguments
89// ============================================================================
90
91/// Arguments for the top-level `monitor` command.
92///
93/// Launches the live TUI dashboard directly from the command line,
94/// without requiring interactive mode.
95///
96/// # Examples
97///
98/// ```bash
99/// # Monitor by token address
100/// scope monitor 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
101///
102/// # Monitor by symbol on a specific chain
103/// scope monitor USDC --chain ethereum
104///
105/// # Short alias
106/// scope mon PEPE -c ethereum
107///
108/// # Custom layout and refresh rate
109/// scope monitor USDC --layout chart-focus --refresh 3
110/// ```
111#[derive(Debug, Args)]
112#[command(after_help = "\x1b[1mExamples:\x1b[0m
113  scope monitor USDC
114  scope monitor @dai-token                                \x1b[2m# address book shortcut\x1b[0m
115  scope monitor PEPE --chain ethereum --layout chart-focus
116  scope monitor DAI --venue binance --pair DAI_USDT
117  scope monitor BTC --venue binance --refresh 5 --scale log")]
118pub struct MonitorArgs {
119    /// Token address or symbol to monitor.
120    ///
121    /// Can be a contract address (0x...) or a token symbol/name.
122    /// If a name/symbol is provided, matching tokens will be searched
123    /// and you can select from the results.
124    /// Use @label to resolve from the address book (e.g., @dai-token).
125    pub token: String,
126
127    /// Target blockchain network.
128    ///
129    /// Determines which chain to query for token data.
130    #[arg(short, long, default_value = "ethereum")]
131    pub chain: String,
132
133    /// Layout preset for the TUI dashboard.
134    ///
135    /// Controls how widgets are arranged on screen.
136    /// Options: dashboard, chart-focus, feed, compact, exchange.
137    #[arg(short, long)]
138    pub layout: Option<LayoutPreset>,
139
140    /// Refresh interval in seconds.
141    ///
142    /// How often to fetch new data from the API.
143    /// Adjustable at runtime with +/- keys.
144    #[arg(short, long)]
145    pub refresh: Option<u64>,
146
147    /// Y-axis scale mode for price charts.
148    ///
149    /// Options: linear, log.
150    #[arg(short, long)]
151    pub scale: Option<ScaleMode>,
152
153    /// Color scheme for charts.
154    ///
155    /// Options: green-red, blue-orange, monochrome.
156    #[arg(long)]
157    pub color_scheme: Option<ColorScheme>,
158
159    /// Start CSV export immediately, writing to the given path.
160    #[arg(short, long, value_name = "PATH")]
161    pub export: Option<PathBuf>,
162
163    /// Exchange venue for real OHLC candle data (e.g., binance, mexc).
164    ///
165    /// When specified, the monitor fetches real candlestick data from the
166    /// exchange API instead of generating synthetic candles from price history.
167    #[arg(long, value_name = "VENUE")]
168    pub venue: Option<String>,
169
170    /// Direct trading pair for exchange-only mode (e.g., DAI_USDT).
171    ///
172    /// Bypasses DexScreener token resolution entirely and uses the exchange
173    /// ticker as the data source. Requires `--venue` to be specified.
174    /// Use this when the token is not listed on DexScreener.
175    #[arg(long, value_name = "PAIR")]
176    pub pair: Option<String>,
177}
178/// Entry point for the top-level `scope monitor` command.
179///
180/// Creates a `SessionContext` from CLI args and delegates to [`run`].
181/// Applies CLI-provided overrides (layout, refresh, scale, etc.) on top
182/// of the config-file defaults.
183pub async fn run_direct(
184    mut args: MonitorArgs,
185    config: &Config,
186    clients: &dyn ChainClientFactory,
187) -> Result<()> {
188    // Resolve address book label → address + chain
189    if let Some((address, chain)) =
190        crate::cli::address_book::resolve_address_book_input(&args.token, config)?
191    {
192        args.token = address;
193        if args.chain == "ethereum" {
194            args.chain = chain;
195        }
196    }
197
198    // Build a SessionContext from the CLI args (no interactive session needed)
199    let ctx = SessionContext {
200        chain: args.chain,
201        ..SessionContext::default()
202    };
203
204    // Build a MonitorConfig from config-file defaults + CLI overrides
205    let mut monitor_config = config.monitor.clone();
206    if let Some(layout) = args.layout {
207        monitor_config.layout = layout;
208    }
209    if let Some(refresh) = args.refresh {
210        monitor_config.refresh_seconds = refresh;
211    }
212    if let Some(scale) = args.scale {
213        monitor_config.scale = scale;
214    }
215    if let Some(color_scheme) = args.color_scheme {
216        monitor_config.color_scheme = color_scheme;
217    }
218    if let Some(ref path) = args.export {
219        monitor_config.export.path = Some(path.to_string_lossy().into_owned());
220    }
221    if let Some(ref venue) = args.venue {
222        monitor_config.venue = Some(venue.clone());
223    }
224
225    // Use a temporary Config with the CLI-overridden monitor settings
226    let mut effective_config = config.clone();
227    effective_config.monitor = monitor_config;
228
229    run(
230        Some(args.token),
231        args.pair,
232        &ctx,
233        &effective_config,
234        clients,
235    )
236    .await
237}
238
239/// Entry point for the monitor command from interactive mode.
240///
241/// When `explicit_pair` is `Some`, token resolution is bypassed and the
242/// exchange ticker is used as the primary data source.  This enables
243/// monitoring tokens that are not indexed by DexScreener.
244pub async fn run(
245    token: Option<String>,
246    explicit_pair: Option<String>,
247    ctx: &SessionContext,
248    config: &Config,
249    clients: &dyn ChainClientFactory,
250) -> Result<()> {
251    let token_input = match token {
252        Some(t) => t,
253        None => {
254            return Err(ScopeError::Chain(
255                "Token address or symbol required. Usage: monitor <token>".to_string(),
256            ));
257        }
258    };
259
260    eprintln!("  Starting live monitor for {}...", token_input);
261    eprintln!("  Fetching initial data...");
262
263    // Create exchange client if a venue is configured (from MonitorConfig or CLI)
264    let exchange_client = config.monitor.venue.as_ref().and_then(|venue_id| {
265        scope::market::VenueRegistry::load()
266            .ok()
267            .and_then(|r| r.get(venue_id).cloned())
268            .map(|desc| scope::market::ExchangeClient::from_descriptor(&desc))
269    });
270
271    // ---- Exchange-only mode (--pair + --venue) ----
272    // Bypass DexScreener entirely when the user provides a direct pair.
273    let initial_data = if let Some(ref pair_str) = explicit_pair {
274        let ex = exchange_client.as_ref().ok_or_else(|| {
275            ScopeError::Chain("--pair requires --venue to be specified".to_string())
276        })?;
277
278        // Fetch the ticker to get current price data
279        let ticker = ex.fetch_ticker(pair_str).await.map_err(|e| {
280            ScopeError::Chain(format!("Failed to fetch ticker for {}: {}", pair_str, e))
281        })?;
282
283        // Extract base symbol from pair label (e.g., "DAI/USDT" → "DAI")
284        let base_symbol = ticker
285            .pair
286            .split('/')
287            .next()
288            .unwrap_or(&token_input)
289            .to_string();
290
291        eprintln!(
292            "  Exchange-only mode: {} @ ${:.6}",
293            pair_str,
294            ticker.last_price.unwrap_or(0.0)
295        );
296
297        // Build a minimal DexTokenData from the exchange ticker
298        build_exchange_token_data(&base_symbol, pair_str, &ticker)
299    } else {
300        // ---- Normal DexScreener mode ----
301        let dex_client = clients.create_dex_client();
302        let token_address =
303            resolve_token_address(&token_input, &ctx.chain, config, dex_client.as_ref()).await?;
304
305        dex_client
306            .get_token_data(&ctx.chain, &token_address)
307            .await?
308    };
309
310    println!(
311        "Monitoring {} ({}) on {}",
312        initial_data.symbol, initial_data.name, ctx.chain
313    );
314    println!("Press Q to quit, R to refresh, P to pause...\n");
315
316    // Small delay to let user read the message
317    tokio::time::sleep(Duration::from_millis(500)).await;
318
319    // Create optional chain client for on-chain data (holder count, etc.)
320    let chain_client = clients.create_chain_client(&ctx.chain).ok();
321
322    // Create and run the app — override venue_pair when using explicit pair mode
323    let mut app = MonitorApp::new(
324        initial_data,
325        &ctx.chain,
326        &config.monitor,
327        chain_client,
328        exchange_client,
329    )?;
330
331    // In explicit-pair mode, override the auto-formatted pair with the user's exact pair
332    if let Some(ref pair_str) = explicit_pair {
333        app.state.venue_pair = Some(pair_str.clone());
334    }
335
336    let result = app.run().await;
337
338    // Cleanup is handled by Drop, but we do it explicitly for error handling
339    if let Err(e) = app.cleanup() {
340        eprintln!("Warning: Failed to cleanup terminal: {}", e);
341    }
342
343    result
344}
345
346/// Builds a minimal [`DexTokenData`] from an exchange ticker.
347///
348/// Used in exchange-only mode (--pair) when DexScreener is not available
349/// for the token.
350fn build_exchange_token_data(
351    symbol: &str,
352    pair_label: &str,
353    ticker: &scope::market::Ticker,
354) -> DexTokenData {
355    let price = ticker.last_price.unwrap_or(0.0);
356    DexTokenData {
357        address: format!("exchange:{}", pair_label),
358        symbol: symbol.to_string(),
359        name: symbol.to_string(),
360        price_usd: price,
361        price_change_24h: 0.0,
362        price_change_6h: 0.0,
363        price_change_1h: 0.0,
364        price_change_5m: 0.0,
365        volume_24h: ticker.volume_24h.unwrap_or(0.0),
366        volume_6h: 0.0,
367        volume_1h: 0.0,
368        liquidity_usd: 0.0,
369        market_cap: None,
370        fdv: None,
371        pairs: vec![],
372        price_history: vec![],
373        volume_history: vec![],
374        total_buys_24h: 0,
375        total_sells_24h: 0,
376        total_buys_6h: 0,
377        total_sells_6h: 0,
378        total_buys_1h: 0,
379        total_sells_1h: 0,
380        earliest_pair_created_at: None,
381        image_url: None,
382        websites: vec![],
383        socials: vec![],
384        dexscreener_url: None,
385    }
386}
387
388/// Resolves a token input (address or symbol) to an address.
389///
390/// Uses the same chain filter logic as the crawl command: when the chain is
391/// "ethereum" (the default), searches ALL chains so that exact symbol matches
392/// on other chains rank above substring matches on ethereum.  Includes a CEX
393/// ticker fallback when DexScreener returns no results.
394async fn resolve_token_address(
395    input: &str,
396    chain: &str,
397    _config: &Config,
398    dex_client: &dyn DexDataSource,
399) -> Result<String> {
400    // Check if it's already an address (EVM, Solana, Tron)
401    if scope::tokens::TokenAliases::is_address(input) {
402        return Ok(input.to_string());
403    }
404
405    // Check saved aliases — use chain filter only when explicitly overridden
406    let chain_filter = if chain != "ethereum" {
407        Some(chain)
408    } else {
409        None
410    };
411    let aliases = scope::tokens::TokenAliases::load();
412    if let Some(alias) = aliases.get(input, chain_filter) {
413        return Ok(alias.address.clone());
414    }
415
416    // Search by name/symbol — same filter logic as crawl:
417    // "ethereum" (default) → None (all chains), explicit chain → Some(chain)
418    let mut results = dex_client.search_tokens(input, chain_filter).await?;
419
420    // CEX fallback: if DexScreener has no results, try exchange ticker
421    if results.is_empty()
422        && let Some(fallback) = try_cex_fallback(input, chain).await
423    {
424        eprintln!(
425            "  Not found on DexScreener; found {} on {} (CEX)",
426            fallback.symbol, fallback.chain
427        );
428        results.push(fallback);
429    }
430
431    if results.is_empty() {
432        return Err(ScopeError::NotFound(format!(
433            "No token found matching '{}' on {} (checked DexScreener and CEX venues)",
434            input, chain
435        )));
436    }
437
438    // If only one result, use it directly
439    if results.len() == 1 {
440        let token = &results[0];
441        println!(
442            "Found: {} ({}) - ${:.6}",
443            token.symbol,
444            token.name,
445            token.price_usd.unwrap_or(0.0)
446        );
447        return Ok(token.address.clone());
448    }
449
450    // Multiple results — prompt user to select
451    let selected = select_token_interactive(&results)?;
452    Ok(selected.address.clone())
453}
454
455/// CEX venue ticker fallback for token resolution.
456///
457/// When DexScreener returns no results, tries to find the token on a
458/// centralized exchange (Binance) as a fallback. Returns a synthetic
459/// `TokenSearchResult` from the ticker data.
460async fn try_cex_fallback(symbol: &str, chain: &str) -> Option<scope::chains::TokenSearchResult> {
461    let registry = scope::market::VenueRegistry::load().ok()?;
462    let descriptor = registry.get("binance")?;
463    let client = scope::market::ExchangeClient::from_descriptor(&descriptor.clone());
464    let pair = client.format_pair(&format!("{}USDT", symbol.to_uppercase()));
465    let ticker = client.fetch_ticker(&pair).await.ok()?;
466    let price = ticker.last_price.unwrap_or(0.0);
467    Some(scope::chains::TokenSearchResult {
468        address: String::new(),
469        symbol: symbol.to_uppercase(),
470        name: symbol.to_uppercase(),
471        chain: chain.to_string(),
472        price_usd: Some(price),
473        volume_24h: ticker.volume_24h.unwrap_or(0.0),
474        liquidity_usd: 0.0,
475        market_cap: None,
476    })
477}
478
479/// Abbreviates a blockchain address for display (e.g. "0x1234...abcd").
480fn abbreviate_address(addr: &str) -> String {
481    if addr.len() > 16 {
482        format!("{}...{}", &addr[..8], &addr[addr.len() - 6..])
483    } else {
484        addr.to_string()
485    }
486}
487
488/// Displays token search results and prompts the user to select one.
489fn select_token_interactive(
490    results: &[scope::chains::dex::TokenSearchResult],
491) -> Result<&scope::chains::dex::TokenSearchResult> {
492    let stdin = io::stdin();
493    let stdout = io::stdout();
494    select_token_impl(results, &mut stdin.lock(), &mut stdout.lock())
495}
496
497/// Testable implementation of token selection with injected I/O.
498fn select_token_impl<'a>(
499    results: &'a [scope::chains::dex::TokenSearchResult],
500    reader: &mut impl io::BufRead,
501    writer: &mut impl io::Write,
502) -> Result<&'a scope::chains::dex::TokenSearchResult> {
503    writeln!(
504        writer,
505        "\nFound {} tokens matching your query:\n",
506        results.len()
507    )
508    .map_err(|e| ScopeError::Io(e.to_string()))?;
509
510    writeln!(
511        writer,
512        "{:>3}  {:>8}  {:<22}  {:<16}  {:>12}  {:>12}",
513        "#", "Symbol", "Name", "Address", "Price", "Liquidity"
514    )
515    .map_err(|e| ScopeError::Io(e.to_string()))?;
516
517    writeln!(writer, "{}", "─".repeat(82)).map_err(|e| ScopeError::Io(e.to_string()))?;
518
519    for (i, token) in results.iter().enumerate() {
520        let price = token
521            .price_usd
522            .map(|p| format!("${:.6}", p))
523            .unwrap_or_else(|| "N/A".to_string());
524
525        let liquidity = format_monitor_number(token.liquidity_usd);
526        let addr = abbreviate_address(&token.address);
527
528        // Truncate name if too long
529        let name = if token.name.len() > 20 {
530            format!("{}...", &token.name[..17])
531        } else {
532            token.name.clone()
533        };
534
535        writeln!(
536            writer,
537            "{:>3}  {:>8}  {:<22}  {:<16}  {:>12}  {:>12}",
538            i + 1,
539            token.symbol,
540            name,
541            addr,
542            price,
543            liquidity
544        )
545        .map_err(|e| ScopeError::Io(e.to_string()))?;
546    }
547
548    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
549    write!(writer, "Select token (1-{}): ", results.len())
550        .map_err(|e| ScopeError::Io(e.to_string()))?;
551    writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
552
553    let mut input = String::new();
554    reader
555        .read_line(&mut input)
556        .map_err(|e| ScopeError::Io(e.to_string()))?;
557
558    let selection: usize = input
559        .trim()
560        .parse()
561        .map_err(|_| ScopeError::Api("Invalid selection".to_string()))?;
562
563    if selection < 1 || selection > results.len() {
564        return Err(ScopeError::Api(format!(
565            "Selection must be between 1 and {}",
566            results.len()
567        )));
568    }
569
570    let selected = &results[selection - 1];
571    writeln!(
572        writer,
573        "Selected: {} ({}) at {}",
574        selected.symbol, selected.name, selected.address
575    )
576    .map_err(|e| ScopeError::Io(e.to_string()))?;
577
578    Ok(selected)
579}
580
581/// Format a number for the monitor selection table.
582fn format_monitor_number(value: f64) -> String {
583    if value >= 1_000_000_000.0 {
584        format!("${:.2}B", value / 1_000_000_000.0)
585    } else if value >= 1_000_000.0 {
586        format!("${:.2}M", value / 1_000_000.0)
587    } else if value >= 1_000.0 {
588        format!("${:.2}K", value / 1_000.0)
589    } else {
590        format!("${:.2}", value)
591    }
592}
593// ============================================================================
594// Unit Tests
595// ============================================================================
596
597#[cfg(test)]
598mod tests {
599    use super::config::*;
600    use super::input::*;
601    use super::state::*;
602    use super::widgets::*;
603    use super::*;
604
605    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
606    use ratatui::layout::{Constraint, Direction, Layout, Rect};
607    use scope::chains::ChainClient;
608    use scope::market::{Trade, TradeSide};
609    use std::fs;
610    use std::io::Write as _;
611    use std::time::Instant;
612
613    fn create_test_token_data() -> DexTokenData {
614        DexTokenData {
615            address: "0x1234".to_string(),
616            symbol: "TEST".to_string(),
617            name: "Test Token".to_string(),
618            price_usd: 1.0,
619            price_change_24h: 5.0,
620            price_change_6h: 2.0,
621            price_change_1h: 0.5,
622            price_change_5m: 0.1,
623            volume_24h: 1_000_000.0,
624            volume_6h: 250_000.0,
625            volume_1h: 50_000.0,
626            liquidity_usd: 500_000.0,
627            market_cap: Some(10_000_000.0),
628            fdv: Some(100_000_000.0),
629            pairs: vec![],
630            price_history: vec![],
631            volume_history: vec![],
632            total_buys_24h: 100,
633            total_sells_24h: 50,
634            total_buys_6h: 25,
635            total_sells_6h: 12,
636            total_buys_1h: 5,
637            total_sells_1h: 3,
638            earliest_pair_created_at: Some(1700000000000),
639            image_url: None,
640            websites: vec![],
641            socials: vec![],
642            dexscreener_url: None,
643        }
644    }
645
646    #[test]
647    fn test_monitor_state_new() {
648        let token_data = create_test_token_data();
649        let state = MonitorState::new(&token_data, "ethereum");
650
651        assert_eq!(state.symbol, "TEST");
652        assert_eq!(state.chain, "ethereum");
653        assert_eq!(state.current_price, 1.0);
654        assert_eq!(state.buys_24h, 100);
655        assert_eq!(state.sells_24h, 50);
656        assert!(!state.paused);
657    }
658
659    #[test]
660    fn test_monitor_state_buy_ratio() {
661        let token_data = create_test_token_data();
662        let state = MonitorState::new(&token_data, "ethereum");
663
664        let ratio = state.buy_ratio();
665        assert!((ratio - 0.6666).abs() < 0.01); // 100 / 150 ≈ 0.667
666    }
667
668    #[test]
669    fn test_monitor_state_buy_ratio_zero() {
670        let mut token_data = create_test_token_data();
671        token_data.total_buys_24h = 0;
672        token_data.total_sells_24h = 0;
673        let state = MonitorState::new(&token_data, "ethereum");
674
675        assert_eq!(state.buy_ratio(), 0.5); // Default to 50/50 when no data
676    }
677
678    #[test]
679    fn test_monitor_state_toggle_pause() {
680        let token_data = create_test_token_data();
681        let mut state = MonitorState::new(&token_data, "ethereum");
682
683        assert!(!state.paused);
684        state.toggle_pause();
685        assert!(state.paused);
686        state.toggle_pause();
687        assert!(!state.paused);
688    }
689
690    #[test]
691    fn test_monitor_state_should_refresh() {
692        let token_data = create_test_token_data();
693        let mut state = MonitorState::new(&token_data, "ethereum");
694        state.refresh_rate = Duration::from_secs(60);
695
696        // Just created, should not need refresh (60s refresh rate)
697        assert!(!state.should_refresh());
698
699        // Simulate time passing well beyond refresh rate
700        state.last_update = Instant::now() - Duration::from_secs(120);
701        assert!(state.should_refresh());
702
703        // Pause should prevent refresh
704        state.paused = true;
705        assert!(!state.should_refresh());
706    }
707
708    #[test]
709    fn test_format_number() {
710        assert_eq!(format_number(500.0), "500.00");
711        assert_eq!(format_number(1_500.0), "1.50K");
712        assert_eq!(format_number(1_500_000.0), "1.50M");
713        assert_eq!(format_number(1_500_000_000.0), "1.50B");
714    }
715
716    #[test]
717    fn test_format_usd() {
718        assert_eq!(scope::display::format_usd(500.0), "$500.00");
719        assert_eq!(scope::display::format_usd(1_500.0), "$1.50K");
720        assert_eq!(scope::display::format_usd(1_500_000.0), "$1.50M");
721        assert_eq!(scope::display::format_usd(1_500_000_000.0), "$1.50B");
722    }
723
724    #[test]
725    fn test_monitor_state_update() {
726        let token_data = create_test_token_data();
727        let mut state = MonitorState::new(&token_data, "ethereum");
728
729        let initial_len = state.price_history.len();
730
731        let mut updated_data = token_data.clone();
732        updated_data.price_usd = 1.5;
733        updated_data.total_buys_24h = 150;
734
735        state.update(&updated_data);
736
737        assert_eq!(state.current_price, 1.5);
738        assert_eq!(state.buys_24h, 150);
739        // Should have one more point after update
740        assert_eq!(state.price_history.len(), initial_len + 1);
741    }
742
743    #[test]
744    fn test_monitor_state_refresh_rate_adjustment() {
745        let token_data = create_test_token_data();
746        let mut state = MonitorState::new(&token_data, "ethereum");
747
748        // Default is 5 seconds
749        assert_eq!(state.refresh_rate_secs(), 5);
750
751        // Slow down (+5s)
752        state.slower_refresh();
753        assert_eq!(state.refresh_rate_secs(), 10);
754
755        // Speed up (-5s)
756        state.faster_refresh();
757        assert_eq!(state.refresh_rate_secs(), 5);
758
759        // Speed up again (should hit minimum of 1s)
760        state.faster_refresh();
761        assert_eq!(state.refresh_rate_secs(), 1);
762
763        // Can't go below 1s
764        state.faster_refresh();
765        assert_eq!(state.refresh_rate_secs(), 1);
766
767        // Slow down to max (60s)
768        for _ in 0..20 {
769            state.slower_refresh();
770        }
771        assert_eq!(state.refresh_rate_secs(), 60);
772    }
773
774    #[test]
775    fn test_time_period() {
776        assert_eq!(TimePeriod::Min1.label(), "1m");
777        assert_eq!(TimePeriod::Min5.label(), "5m");
778        assert_eq!(TimePeriod::Min15.label(), "15m");
779        assert_eq!(TimePeriod::Hour1.label(), "1h");
780        assert_eq!(TimePeriod::Hour4.label(), "4h");
781        assert_eq!(TimePeriod::Day1.label(), "1d");
782
783        assert_eq!(TimePeriod::Min1.duration_secs(), 60);
784        assert_eq!(TimePeriod::Min5.duration_secs(), 300);
785        assert_eq!(TimePeriod::Min15.duration_secs(), 15 * 60);
786        assert_eq!(TimePeriod::Hour1.duration_secs(), 3600);
787        assert_eq!(TimePeriod::Hour4.duration_secs(), 4 * 3600);
788        assert_eq!(TimePeriod::Day1.duration_secs(), 24 * 3600);
789
790        // Test cycling
791        assert_eq!(TimePeriod::Min1.next(), TimePeriod::Min5);
792        assert_eq!(TimePeriod::Min5.next(), TimePeriod::Min15);
793        assert_eq!(TimePeriod::Min15.next(), TimePeriod::Hour1);
794        assert_eq!(TimePeriod::Hour1.next(), TimePeriod::Hour4);
795        assert_eq!(TimePeriod::Hour4.next(), TimePeriod::Day1);
796        assert_eq!(TimePeriod::Day1.next(), TimePeriod::Min1);
797    }
798
799    #[test]
800    fn test_time_period_exchange_interval() {
801        assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
802        assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
803        assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
804        assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
805        assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
806        assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
807    }
808
809    #[test]
810    fn test_monitor_state_time_period() {
811        let token_data = create_test_token_data();
812        let mut state = MonitorState::new(&token_data, "ethereum");
813
814        // Default is 1 hour
815        assert_eq!(state.time_period, TimePeriod::Hour1);
816
817        // Cycle through periods
818        state.cycle_time_period();
819        assert_eq!(state.time_period, TimePeriod::Hour4);
820
821        state.set_time_period(TimePeriod::Day1);
822        assert_eq!(state.time_period, TimePeriod::Day1);
823    }
824
825    #[test]
826    fn test_synthetic_history_generation() {
827        let token_data = create_test_token_data();
828        let state = MonitorState::new(&token_data, "ethereum");
829
830        // Should have generated history (synthetic or cached real)
831        assert!(state.price_history.len() > 1);
832        assert!(state.volume_history.len() > 1);
833
834        // Price history should span some time range
835        if let (Some(first), Some(last)) = (state.price_history.front(), state.price_history.back())
836        {
837            let span = last.timestamp - first.timestamp;
838            assert!(span > 0.0); // History should span some time
839        }
840    }
841
842    #[test]
843    fn test_real_data_marking() {
844        let token_data = create_test_token_data();
845        let mut state = MonitorState::new(&token_data, "ethereum");
846
847        // Initially all synthetic
848        let (synthetic, real) = state.data_stats();
849        assert!(synthetic > 0);
850        assert_eq!(real, 0);
851
852        // After update, should have real data
853        let mut updated_data = token_data.clone();
854        updated_data.price_usd = 1.5;
855        state.update(&updated_data);
856
857        let (synthetic2, real2) = state.data_stats();
858        assert!(synthetic2 > 0);
859        assert_eq!(real2, 1);
860        assert_eq!(state.real_data_count, 1);
861
862        // The last point should be real
863        assert!(
864            state
865                .price_history
866                .back()
867                .map(|p| p.is_real)
868                .unwrap_or(false)
869        );
870    }
871
872    #[test]
873    fn test_memory_usage() {
874        let token_data = create_test_token_data();
875        let state = MonitorState::new(&token_data, "ethereum");
876
877        let mem = state.memory_usage();
878        // DataPoint is 24 bytes, should have some data points
879        assert!(mem > 0);
880
881        // Each DataPoint is 24 bytes (f64 + f64 + bool + padding)
882        let expected_point_size = std::mem::size_of::<DataPoint>();
883        assert_eq!(expected_point_size, 24);
884    }
885
886    #[test]
887    fn test_get_data_for_period_returns_flags() {
888        let token_data = create_test_token_data();
889        let mut state = MonitorState::new(&token_data, "ethereum");
890
891        // Get initial data (may contain cached real data or synthetic)
892        let (data, is_real) = state.get_price_data_for_period();
893        assert_eq!(data.len(), is_real.len());
894
895        // Add real data point
896        state.update(&token_data);
897
898        let (_data2, is_real2) = state.get_price_data_for_period();
899        // Should have at least one real point now
900        assert!(is_real2.iter().any(|r| *r));
901    }
902
903    #[test]
904    fn test_cache_path_generation() {
905        let path =
906            MonitorState::cache_path("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum");
907        assert!(path.to_string_lossy().contains("bcc_monitor_"));
908        assert!(path.to_string_lossy().contains("ethereum"));
909        // Should be in temp directory
910        let temp_dir = std::env::temp_dir();
911        assert!(path.starts_with(temp_dir));
912    }
913
914    #[test]
915    fn test_cache_save_and_load() {
916        let token_data = create_test_token_data();
917        let mut state = MonitorState::new(&token_data, "test_chain");
918
919        // Add some real data
920        state.update(&token_data);
921        state.update(&token_data);
922
923        // Save cache
924        state.save_cache();
925
926        // Verify cache file exists
927        let path = MonitorState::cache_path(&state.token_address, &state.chain);
928        assert!(path.exists(), "Cache file should exist after save");
929
930        // Load cache
931        let loaded = MonitorState::load_cache(&state.token_address, &state.chain);
932        assert!(loaded.is_some(), "Should be able to load saved cache");
933
934        let cached = loaded.unwrap();
935        assert_eq!(cached.token_address, state.token_address);
936        assert_eq!(cached.chain, state.chain);
937        assert!(!cached.price_history.is_empty());
938
939        // Cleanup
940        let _ = std::fs::remove_file(path);
941    }
942
943    // ========================================================================
944    // Price formatting tests
945    // ========================================================================
946
947    #[test]
948    fn test_format_price_usd_high() {
949        let formatted = format_price_usd(2500.50);
950        assert!(formatted.starts_with("$2500.50"));
951    }
952
953    #[test]
954    fn test_format_price_usd_stablecoin() {
955        let formatted = format_price_usd(1.0001);
956        assert!(formatted.contains("1.000100"));
957        assert!(is_stablecoin_price(1.0001));
958    }
959
960    #[test]
961    fn test_format_price_usd_medium() {
962        let formatted = format_price_usd(5.1234);
963        assert!(formatted.starts_with("$5.1234"));
964    }
965
966    #[test]
967    fn test_format_price_usd_small() {
968        let formatted = format_price_usd(0.05);
969        assert!(formatted.starts_with("$0.0500"));
970    }
971
972    #[test]
973    fn test_format_price_usd_micro() {
974        let formatted = format_price_usd(0.001);
975        assert!(formatted.starts_with("$0.0010"));
976    }
977
978    #[test]
979    fn test_format_price_usd_nano() {
980        let formatted = format_price_usd(0.00001);
981        assert!(formatted.contains("0.0000100"));
982    }
983
984    #[test]
985    fn test_is_stablecoin_price() {
986        assert!(is_stablecoin_price(1.0));
987        assert!(is_stablecoin_price(0.999));
988        assert!(is_stablecoin_price(1.001));
989        assert!(is_stablecoin_price(0.95));
990        assert!(is_stablecoin_price(1.05));
991        assert!(!is_stablecoin_price(0.94));
992        assert!(!is_stablecoin_price(1.06));
993        assert!(!is_stablecoin_price(100.0));
994    }
995
996    // ========================================================================
997    // OHLC candle tests
998    // ========================================================================
999
1000    #[test]
1001    fn test_ohlc_candle_new() {
1002        let candle = OhlcCandle::new(1000.0, 50.0);
1003        assert_eq!(candle.open, 50.0);
1004        assert_eq!(candle.high, 50.0);
1005        assert_eq!(candle.low, 50.0);
1006        assert_eq!(candle.close, 50.0);
1007        assert!(candle.is_bullish);
1008    }
1009
1010    #[test]
1011    fn test_ohlc_candle_update() {
1012        let mut candle = OhlcCandle::new(1000.0, 50.0);
1013        candle.update(55.0);
1014        assert_eq!(candle.high, 55.0);
1015        assert_eq!(candle.close, 55.0);
1016        assert!(candle.is_bullish);
1017
1018        candle.update(45.0);
1019        assert_eq!(candle.low, 45.0);
1020        assert_eq!(candle.close, 45.0);
1021        assert!(!candle.is_bullish); // close < open
1022    }
1023
1024    #[test]
1025    fn test_get_ohlc_candles() {
1026        let token_data = create_test_token_data();
1027        let mut state = MonitorState::new(&token_data, "ethereum");
1028        // Add several data points
1029        for i in 0..20 {
1030            let mut data = token_data.clone();
1031            data.price_usd = 1.0 + (i as f64 * 0.01);
1032            state.update(&data);
1033        }
1034        let candles = state.get_ohlc_candles();
1035        // Should have some candles
1036        assert!(!candles.is_empty());
1037    }
1038
1039    #[test]
1040    fn test_get_ohlc_candles_returns_exchange_ohlc_when_populated() {
1041        let token_data = create_test_token_data();
1042        let mut state = MonitorState::new(&token_data, "ethereum");
1043        // Populate exchange OHLC
1044        let exchange_candles = vec![
1045            OhlcCandle::new(1700000000.0, 100.0),
1046            OhlcCandle::new(1700003600.0, 101.0),
1047        ];
1048        state.exchange_ohlc = exchange_candles.clone();
1049        let candles = state.get_ohlc_candles();
1050        assert_eq!(candles.len(), 2);
1051        assert_eq!(candles[0].timestamp, 1700000000.0);
1052        assert_eq!(candles[0].open, 100.0);
1053        assert_eq!(candles[1].timestamp, 1700003600.0);
1054        assert_eq!(candles[1].open, 101.0);
1055    }
1056
1057    // ========================================================================
1058    // ChartMode tests
1059    // ========================================================================
1060
1061    #[test]
1062    fn test_chart_mode_cycle() {
1063        let mode = ChartMode::Line;
1064        assert_eq!(mode.next(), ChartMode::Candlestick);
1065        assert_eq!(ChartMode::Candlestick.next(), ChartMode::VolumeProfile);
1066        assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
1067    }
1068
1069    #[test]
1070    fn test_chart_mode_label() {
1071        assert_eq!(ChartMode::Line.label(), "Line");
1072        assert_eq!(ChartMode::Candlestick.label(), "Candle");
1073        assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
1074    }
1075
1076    // ========================================================================
1077    // TUI rendering tests (headless TestBackend)
1078    // ========================================================================
1079
1080    use ratatui::Terminal;
1081    use ratatui::backend::TestBackend;
1082
1083    fn create_test_terminal() -> Terminal<TestBackend> {
1084        let backend = TestBackend::new(120, 40);
1085        Terminal::new(backend).unwrap()
1086    }
1087
1088    fn create_populated_state() -> MonitorState {
1089        let token_data = create_test_token_data();
1090        let mut state = MonitorState::new(&token_data, "ethereum");
1091        // Add real data points so charts have content
1092        for i in 0..30 {
1093            let mut data = token_data.clone();
1094            data.price_usd = 1.0 + (i as f64 * 0.01);
1095            data.volume_24h = 1_000_000.0 + (i as f64 * 10_000.0);
1096            state.update(&data);
1097        }
1098        state
1099    }
1100
1101    #[test]
1102    fn test_render_header_no_panic() {
1103        let mut terminal = create_test_terminal();
1104        let state = create_populated_state();
1105        terminal
1106            .draw(|f| render_header(f, f.area(), &state))
1107            .unwrap();
1108    }
1109
1110    #[test]
1111    fn test_render_price_chart_no_panic() {
1112        let mut terminal = create_test_terminal();
1113        let state = create_populated_state();
1114        terminal
1115            .draw(|f| render_price_chart(f, f.area(), &state))
1116            .unwrap();
1117    }
1118
1119    #[test]
1120    fn test_render_price_chart_line_mode() {
1121        let mut terminal = create_test_terminal();
1122        let mut state = create_populated_state();
1123        state.chart_mode = ChartMode::Line;
1124        terminal
1125            .draw(|f| render_price_chart(f, f.area(), &state))
1126            .unwrap();
1127    }
1128
1129    #[test]
1130    fn test_render_candlestick_chart_no_panic() {
1131        let mut terminal = create_test_terminal();
1132        let state = create_populated_state();
1133        terminal
1134            .draw(|f| render_candlestick_chart(f, f.area(), &state))
1135            .unwrap();
1136    }
1137
1138    #[test]
1139    fn test_render_candlestick_chart_empty() {
1140        let mut terminal = create_test_terminal();
1141        let token_data = create_test_token_data();
1142        let state = MonitorState::new(&token_data, "ethereum");
1143        terminal
1144            .draw(|f| render_candlestick_chart(f, f.area(), &state))
1145            .unwrap();
1146    }
1147
1148    #[test]
1149    fn test_render_volume_chart_no_panic() {
1150        let mut terminal = create_test_terminal();
1151        let state = create_populated_state();
1152        terminal
1153            .draw(|f| render_volume_chart(f, f.area(), &state))
1154            .unwrap();
1155    }
1156
1157    #[test]
1158    fn test_render_volume_chart_empty() {
1159        let mut terminal = create_test_terminal();
1160        let token_data = create_test_token_data();
1161        let state = MonitorState::new(&token_data, "ethereum");
1162        terminal
1163            .draw(|f| render_volume_chart(f, f.area(), &state))
1164            .unwrap();
1165    }
1166
1167    #[test]
1168    fn test_render_buy_sell_gauge_no_panic() {
1169        let mut terminal = create_test_terminal();
1170        let mut state = create_populated_state();
1171        terminal
1172            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
1173            .unwrap();
1174    }
1175
1176    #[test]
1177    fn test_render_buy_sell_gauge_balanced() {
1178        let mut terminal = create_test_terminal();
1179        let mut token_data = create_test_token_data();
1180        token_data.total_buys_24h = 100;
1181        token_data.total_sells_24h = 100;
1182        let mut state = MonitorState::new(&token_data, "ethereum");
1183        terminal
1184            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
1185            .unwrap();
1186    }
1187
1188    #[test]
1189    fn test_render_metrics_panel_no_panic() {
1190        let mut terminal = create_test_terminal();
1191        let state = create_populated_state();
1192        terminal
1193            .draw(|f| render_metrics_panel(f, f.area(), &state))
1194            .unwrap();
1195    }
1196
1197    #[test]
1198    fn test_render_metrics_panel_no_market_cap() {
1199        let mut terminal = create_test_terminal();
1200        let mut token_data = create_test_token_data();
1201        token_data.market_cap = None;
1202        token_data.fdv = None;
1203        let state = MonitorState::new(&token_data, "ethereum");
1204        terminal
1205            .draw(|f| render_metrics_panel(f, f.area(), &state))
1206            .unwrap();
1207    }
1208
1209    #[test]
1210    fn test_render_footer_no_panic() {
1211        let mut terminal = create_test_terminal();
1212        let state = create_populated_state();
1213        terminal
1214            .draw(|f| render_footer(f, f.area(), &state))
1215            .unwrap();
1216    }
1217
1218    #[test]
1219    fn test_render_footer_paused() {
1220        let mut terminal = create_test_terminal();
1221        let token_data = create_test_token_data();
1222        let mut state = MonitorState::new(&token_data, "ethereum");
1223        state.paused = true;
1224        terminal
1225            .draw(|f| render_footer(f, f.area(), &state))
1226            .unwrap();
1227    }
1228
1229    #[test]
1230    fn test_render_all_components() {
1231        // Exercise the full draw_ui layout path
1232        let mut terminal = create_test_terminal();
1233        let mut state = create_populated_state();
1234        terminal
1235            .draw(|f| {
1236                let area = f.area();
1237                let chunks = Layout::default()
1238                    .direction(Direction::Vertical)
1239                    .constraints([
1240                        Constraint::Length(3),
1241                        Constraint::Min(10),
1242                        Constraint::Length(5),
1243                        Constraint::Length(3),
1244                        Constraint::Length(3),
1245                    ])
1246                    .split(area);
1247                render_header(f, chunks[0], &state);
1248                render_price_chart(f, chunks[1], &state);
1249                render_volume_chart(f, chunks[2], &state);
1250                render_buy_sell_gauge(f, chunks[3], &mut state);
1251                render_footer(f, chunks[4], &state);
1252            })
1253            .unwrap();
1254    }
1255
1256    #[test]
1257    fn test_render_candlestick_mode() {
1258        let mut terminal = create_test_terminal();
1259        let mut state = create_populated_state();
1260        state.chart_mode = ChartMode::Candlestick;
1261        terminal
1262            .draw(|f| {
1263                let area = f.area();
1264                let chunks = Layout::default()
1265                    .direction(Direction::Vertical)
1266                    .constraints([Constraint::Length(3), Constraint::Min(10)])
1267                    .split(area);
1268                render_header(f, chunks[0], &state);
1269                render_candlestick_chart(f, chunks[1], &state);
1270            })
1271            .unwrap();
1272    }
1273
1274    #[test]
1275    fn test_render_with_different_time_periods() {
1276        let mut terminal = create_test_terminal();
1277        let mut state = create_populated_state();
1278
1279        for period in [
1280            TimePeriod::Min1,
1281            TimePeriod::Min5,
1282            TimePeriod::Min15,
1283            TimePeriod::Hour1,
1284            TimePeriod::Hour4,
1285            TimePeriod::Day1,
1286        ] {
1287            state.time_period = period;
1288            terminal
1289                .draw(|f| render_price_chart(f, f.area(), &state))
1290                .unwrap();
1291        }
1292    }
1293
1294    #[test]
1295    fn test_render_metrics_with_stablecoin() {
1296        let mut terminal = create_test_terminal();
1297        let mut token_data = create_test_token_data();
1298        token_data.price_usd = 0.999;
1299        token_data.symbol = "USDC".to_string();
1300        let state = MonitorState::new(&token_data, "ethereum");
1301        terminal
1302            .draw(|f| render_metrics_panel(f, f.area(), &state))
1303            .unwrap();
1304    }
1305
1306    #[test]
1307    fn test_render_header_with_negative_change() {
1308        let mut terminal = create_test_terminal();
1309        let mut token_data = create_test_token_data();
1310        token_data.price_change_24h = -15.5;
1311        token_data.price_change_1h = -2.3;
1312        let state = MonitorState::new(&token_data, "ethereum");
1313        terminal
1314            .draw(|f| render_header(f, f.area(), &state))
1315            .unwrap();
1316    }
1317
1318    // ========================================================================
1319    // MonitorState method tests
1320    // ========================================================================
1321
1322    #[test]
1323    fn test_toggle_chart_mode_roundtrip() {
1324        let token_data = create_test_token_data();
1325        let mut state = MonitorState::new(&token_data, "ethereum");
1326        assert_eq!(state.chart_mode, ChartMode::Line);
1327        state.toggle_chart_mode();
1328        assert_eq!(state.chart_mode, ChartMode::Candlestick);
1329        state.toggle_chart_mode();
1330        assert_eq!(state.chart_mode, ChartMode::VolumeProfile);
1331        state.toggle_chart_mode();
1332        assert_eq!(state.chart_mode, ChartMode::Line);
1333    }
1334
1335    #[test]
1336    fn test_cycle_all_time_periods() {
1337        let token_data = create_test_token_data();
1338        let mut state = MonitorState::new(&token_data, "ethereum");
1339        assert_eq!(state.time_period, TimePeriod::Hour1);
1340        state.cycle_time_period();
1341        assert_eq!(state.time_period, TimePeriod::Hour4);
1342        state.cycle_time_period();
1343        assert_eq!(state.time_period, TimePeriod::Day1);
1344        state.cycle_time_period();
1345        assert_eq!(state.time_period, TimePeriod::Min1);
1346        state.cycle_time_period();
1347        assert_eq!(state.time_period, TimePeriod::Min5);
1348        state.cycle_time_period();
1349        assert_eq!(state.time_period, TimePeriod::Min15);
1350        state.cycle_time_period();
1351        assert_eq!(state.time_period, TimePeriod::Hour1);
1352    }
1353
1354    #[test]
1355    fn test_set_specific_time_period() {
1356        let token_data = create_test_token_data();
1357        let mut state = MonitorState::new(&token_data, "ethereum");
1358        state.set_time_period(TimePeriod::Day1);
1359        assert_eq!(state.time_period, TimePeriod::Day1);
1360    }
1361
1362    #[test]
1363    fn test_pause_resume_roundtrip() {
1364        let token_data = create_test_token_data();
1365        let mut state = MonitorState::new(&token_data, "ethereum");
1366        assert!(!state.paused);
1367        state.toggle_pause();
1368        assert!(state.paused);
1369        state.toggle_pause();
1370        assert!(!state.paused);
1371    }
1372
1373    #[test]
1374    fn test_force_refresh_unpauses() {
1375        let token_data = create_test_token_data();
1376        let mut state = MonitorState::new(&token_data, "ethereum");
1377        state.paused = true;
1378        state.force_refresh();
1379        assert!(!state.paused);
1380        assert!(state.should_refresh());
1381    }
1382
1383    #[test]
1384    fn test_refresh_rate_adjust() {
1385        let token_data = create_test_token_data();
1386        let mut state = MonitorState::new(&token_data, "ethereum");
1387        assert_eq!(state.refresh_rate_secs(), 5);
1388
1389        state.slower_refresh();
1390        assert_eq!(state.refresh_rate_secs(), 10);
1391
1392        state.faster_refresh();
1393        assert_eq!(state.refresh_rate_secs(), 5);
1394    }
1395
1396    #[test]
1397    fn test_faster_refresh_clamped_min() {
1398        let token_data = create_test_token_data();
1399        let mut state = MonitorState::new(&token_data, "ethereum");
1400        for _ in 0..10 {
1401            state.faster_refresh();
1402        }
1403        assert!(state.refresh_rate_secs() >= 1);
1404    }
1405
1406    #[test]
1407    fn test_slower_refresh_clamped_max() {
1408        let token_data = create_test_token_data();
1409        let mut state = MonitorState::new(&token_data, "ethereum");
1410        for _ in 0..20 {
1411            state.slower_refresh();
1412        }
1413        assert!(state.refresh_rate_secs() <= 60);
1414    }
1415
1416    #[test]
1417    fn test_buy_ratio_balanced() {
1418        let mut token_data = create_test_token_data();
1419        token_data.total_buys_24h = 100;
1420        token_data.total_sells_24h = 100;
1421        let state = MonitorState::new(&token_data, "ethereum");
1422        assert!((state.buy_ratio() - 0.5).abs() < 0.01);
1423    }
1424
1425    #[test]
1426    fn test_buy_ratio_no_trades() {
1427        let mut token_data = create_test_token_data();
1428        token_data.total_buys_24h = 0;
1429        token_data.total_sells_24h = 0;
1430        let state = MonitorState::new(&token_data, "ethereum");
1431        assert!((state.buy_ratio() - 0.5).abs() < 0.01);
1432    }
1433
1434    #[test]
1435    fn test_data_stats_initial() {
1436        let token_data = create_test_token_data();
1437        let state = MonitorState::new(&token_data, "ethereum");
1438        let (synthetic, real) = state.data_stats();
1439        assert!(synthetic > 0 || real == 0);
1440    }
1441
1442    #[test]
1443    fn test_memory_usage_nonzero() {
1444        let token_data = create_test_token_data();
1445        let state = MonitorState::new(&token_data, "ethereum");
1446        let usage = state.memory_usage();
1447        assert!(usage > 0);
1448    }
1449
1450    #[test]
1451    fn test_price_data_for_period() {
1452        let token_data = create_test_token_data();
1453        let state = MonitorState::new(&token_data, "ethereum");
1454        let (data, is_real) = state.get_price_data_for_period();
1455        assert_eq!(data.len(), is_real.len());
1456    }
1457
1458    #[test]
1459    fn test_volume_data_for_period() {
1460        let token_data = create_test_token_data();
1461        let state = MonitorState::new(&token_data, "ethereum");
1462        let (data, is_real) = state.get_volume_data_for_period();
1463        assert_eq!(data.len(), is_real.len());
1464    }
1465
1466    #[test]
1467    fn test_ohlc_candles_generation() {
1468        let token_data = create_test_token_data();
1469        let state = MonitorState::new(&token_data, "ethereum");
1470        let candles = state.get_ohlc_candles();
1471        for candle in &candles {
1472            assert!(candle.high >= candle.low);
1473        }
1474    }
1475
1476    #[test]
1477    fn test_state_update_with_new_data() {
1478        let token_data = create_test_token_data();
1479        let mut state = MonitorState::new(&token_data, "ethereum");
1480        let initial_count = state.real_data_count;
1481
1482        let mut updated_data = create_test_token_data();
1483        updated_data.price_usd = 2.0;
1484        updated_data.volume_24h = 2_000_000.0;
1485
1486        state.update(&updated_data);
1487        assert_eq!(state.current_price, 2.0);
1488        assert_eq!(state.real_data_count, initial_count + 1);
1489        assert!(state.error_message.is_none());
1490    }
1491
1492    #[test]
1493    fn test_cache_roundtrip_save_load() {
1494        let token_data = create_test_token_data();
1495        let state = MonitorState::new(&token_data, "ethereum");
1496
1497        state.save_cache();
1498
1499        let cache_path = MonitorState::cache_path(&token_data.address, "ethereum");
1500        assert!(cache_path.exists());
1501
1502        let cached = MonitorState::load_cache(&token_data.address, "ethereum");
1503        assert!(cached.is_some());
1504
1505        let _ = std::fs::remove_file(cache_path);
1506    }
1507
1508    #[test]
1509    fn test_should_refresh_when_paused() {
1510        let token_data = create_test_token_data();
1511        let mut state = MonitorState::new(&token_data, "ethereum");
1512        assert!(!state.should_refresh());
1513        state.paused = true;
1514        assert!(!state.should_refresh());
1515    }
1516
1517    #[test]
1518    fn test_ohlc_candle_lifecycle() {
1519        let mut candle = OhlcCandle::new(1700000000.0, 100.0);
1520        assert_eq!(candle.open, 100.0);
1521        assert!(candle.is_bullish);
1522        candle.update(110.0);
1523        assert_eq!(candle.high, 110.0);
1524        assert!(candle.is_bullish);
1525        candle.update(90.0);
1526        assert_eq!(candle.low, 90.0);
1527        assert!(!candle.is_bullish);
1528    }
1529
1530    #[test]
1531    fn test_time_period_display_impl() {
1532        assert_eq!(format!("{}", TimePeriod::Min1), "1m");
1533        assert_eq!(format!("{}", TimePeriod::Min15), "15m");
1534        assert_eq!(format!("{}", TimePeriod::Day1), "1d");
1535    }
1536
1537    #[test]
1538    fn test_log_messages_accumulate() {
1539        let token_data = create_test_token_data();
1540        let mut state = MonitorState::new(&token_data, "ethereum");
1541        // Trigger actions that log
1542        state.toggle_pause();
1543        state.toggle_pause();
1544        state.cycle_time_period();
1545        state.toggle_chart_mode();
1546        assert!(!state.log_messages.is_empty());
1547    }
1548
1549    #[test]
1550    fn test_ui_function_full_render() {
1551        // Test the main ui() function which orchestrates all rendering
1552        let mut terminal = create_test_terminal();
1553        let mut state = create_populated_state();
1554        terminal.draw(|f| ui(f, &mut state)).unwrap();
1555    }
1556
1557    #[test]
1558    fn test_ui_function_candlestick_mode() {
1559        let mut terminal = create_test_terminal();
1560        let mut state = create_populated_state();
1561        state.chart_mode = ChartMode::Candlestick;
1562        terminal.draw(|f| ui(f, &mut state)).unwrap();
1563    }
1564
1565    #[test]
1566    fn test_ui_function_with_error_message() {
1567        let mut terminal = create_test_terminal();
1568        let mut state = create_populated_state();
1569        state.error_message = Some("Test error".to_string());
1570        terminal.draw(|f| ui(f, &mut state)).unwrap();
1571    }
1572
1573    #[test]
1574    fn test_render_header_with_small_positive_change() {
1575        let mut terminal = create_test_terminal();
1576        let mut state = create_populated_state();
1577        state.price_change_24h = 0.3; // Between 0 and 0.5 -> △
1578        terminal
1579            .draw(|f| render_header(f, f.area(), &state))
1580            .unwrap();
1581    }
1582
1583    #[test]
1584    fn test_render_header_with_small_negative_change() {
1585        let mut terminal = create_test_terminal();
1586        let mut state = create_populated_state();
1587        state.price_change_24h = -0.3; // Between -0.5 and 0 -> ▽
1588        terminal
1589            .draw(|f| render_header(f, f.area(), &state))
1590            .unwrap();
1591    }
1592
1593    #[test]
1594    fn test_render_buy_sell_gauge_high_buy_ratio() {
1595        let mut terminal = create_test_terminal();
1596        let token_data = create_test_token_data();
1597        let mut state = MonitorState::new(&token_data, "ethereum");
1598        state.buys_24h = 100;
1599        state.sells_24h = 10;
1600        terminal
1601            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
1602            .unwrap();
1603    }
1604
1605    #[test]
1606    fn test_render_buy_sell_gauge_zero_total() {
1607        let mut terminal = create_test_terminal();
1608        let token_data = create_test_token_data();
1609        let mut state = MonitorState::new(&token_data, "ethereum");
1610        state.buys_24h = 0;
1611        state.sells_24h = 0;
1612        terminal
1613            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
1614            .unwrap();
1615    }
1616
1617    #[test]
1618    fn test_render_metrics_with_market_cap() {
1619        let mut terminal = create_test_terminal();
1620        let token_data = create_test_token_data();
1621        let mut state = MonitorState::new(&token_data, "ethereum");
1622        state.market_cap = Some(1_000_000_000.0);
1623        state.fdv = Some(2_000_000_000.0);
1624        terminal
1625            .draw(|f| render_metrics_panel(f, f.area(), &state))
1626            .unwrap();
1627    }
1628
1629    #[test]
1630    fn test_render_footer_with_error() {
1631        let mut terminal = create_test_terminal();
1632        let mut state = create_populated_state();
1633        state.error_message = Some("Connection failed".to_string());
1634        terminal
1635            .draw(|f| render_footer(f, f.area(), &state))
1636            .unwrap();
1637    }
1638
1639    #[test]
1640    fn test_format_price_usd_various() {
1641        // Test format_price_usd with various magnitudes
1642        assert!(!format_price_usd(0.0000001).is_empty());
1643        assert!(!format_price_usd(0.001).is_empty());
1644        assert!(!format_price_usd(1.0).is_empty());
1645        assert!(!format_price_usd(100.0).is_empty());
1646        assert!(!format_price_usd(10000.0).is_empty());
1647        assert!(!format_price_usd(1000000.0).is_empty());
1648    }
1649
1650    #[test]
1651    fn test_format_usd_various() {
1652        assert!(!scope::display::format_usd(0.0).is_empty());
1653        assert!(!scope::display::format_usd(999.0).is_empty());
1654        assert!(!scope::display::format_usd(1500.0).is_empty());
1655        assert!(!scope::display::format_usd(1_500_000.0).is_empty());
1656        assert!(!scope::display::format_usd(1_500_000_000.0).is_empty());
1657        assert!(!scope::display::format_usd(1_500_000_000_000.0).is_empty());
1658    }
1659
1660    #[test]
1661    fn test_format_number_various() {
1662        assert!(!format_number(0.0).is_empty());
1663        assert!(!format_number(999.0).is_empty());
1664        assert!(!format_number(1500.0).is_empty());
1665        assert!(!format_number(1_500_000.0).is_empty());
1666        assert!(!format_number(1_500_000_000.0).is_empty());
1667    }
1668
1669    #[test]
1670    fn test_render_with_min15_period() {
1671        let mut terminal = create_test_terminal();
1672        let mut state = create_populated_state();
1673        state.set_time_period(TimePeriod::Min15);
1674        terminal.draw(|f| ui(f, &mut state)).unwrap();
1675    }
1676
1677    #[test]
1678    fn test_render_with_hour6_period() {
1679        let mut terminal = create_test_terminal();
1680        let mut state = create_populated_state();
1681        state.set_time_period(TimePeriod::Hour4);
1682        terminal.draw(|f| ui(f, &mut state)).unwrap();
1683    }
1684
1685    #[test]
1686    fn test_ui_with_fresh_state_no_real_data() {
1687        let mut terminal = create_test_terminal();
1688        let token_data = create_test_token_data();
1689        let mut state = MonitorState::new(&token_data, "ethereum");
1690        // Fresh state with only synthetic data
1691        terminal.draw(|f| ui(f, &mut state)).unwrap();
1692    }
1693
1694    #[test]
1695    fn test_ui_with_paused_state() {
1696        let mut terminal = create_test_terminal();
1697        let mut state = create_populated_state();
1698        state.toggle_pause();
1699        terminal.draw(|f| ui(f, &mut state)).unwrap();
1700    }
1701
1702    #[test]
1703    fn test_render_all_with_different_time_periods_and_modes() {
1704        let mut terminal = create_test_terminal();
1705        let mut state = create_populated_state();
1706
1707        // Test all combinations of time period + chart mode
1708        for period in &[
1709            TimePeriod::Min1,
1710            TimePeriod::Min5,
1711            TimePeriod::Min15,
1712            TimePeriod::Hour1,
1713            TimePeriod::Hour4,
1714            TimePeriod::Day1,
1715        ] {
1716            for mode in &[
1717                ChartMode::Line,
1718                ChartMode::Candlestick,
1719                ChartMode::VolumeProfile,
1720            ] {
1721                state.set_time_period(*period);
1722                state.chart_mode = *mode;
1723                terminal.draw(|f| ui(f, &mut state)).unwrap();
1724            }
1725        }
1726    }
1727
1728    #[test]
1729    fn test_render_metrics_with_large_values() {
1730        let mut terminal = create_test_terminal();
1731        let mut state = create_populated_state();
1732        state.market_cap = Some(50_000_000_000.0); // 50B
1733        state.fdv = Some(100_000_000_000.0); // 100B
1734        state.volume_24h = 5_000_000_000.0; // 5B
1735        state.liquidity_usd = 500_000_000.0; // 500M
1736        terminal
1737            .draw(|f| render_metrics_panel(f, f.area(), &state))
1738            .unwrap();
1739    }
1740
1741    #[test]
1742    fn test_render_header_large_positive_change() {
1743        let mut terminal = create_test_terminal();
1744        let mut state = create_populated_state();
1745        state.price_change_24h = 50.0; // >0.5 -> ▲
1746        terminal
1747            .draw(|f| render_header(f, f.area(), &state))
1748            .unwrap();
1749    }
1750
1751    #[test]
1752    fn test_render_header_large_negative_change() {
1753        let mut terminal = create_test_terminal();
1754        let mut state = create_populated_state();
1755        state.price_change_24h = -50.0; // <-0.5 -> ▼
1756        terminal
1757            .draw(|f| render_header(f, f.area(), &state))
1758            .unwrap();
1759    }
1760
1761    #[test]
1762    fn test_render_price_chart_empty_data() {
1763        let mut terminal = create_test_terminal();
1764        let token_data = create_test_token_data();
1765        // Create state with no price history data
1766        let mut state = MonitorState::new(&token_data, "ethereum");
1767        state.price_history.clear();
1768        terminal
1769            .draw(|f| render_price_chart(f, f.area(), &state))
1770            .unwrap();
1771    }
1772
1773    #[test]
1774    fn test_render_price_chart_price_down() {
1775        let mut terminal = create_test_terminal();
1776        let mut state = create_populated_state();
1777        // Force price down scenario
1778        state.price_change_24h = -15.0;
1779        state.current_price = 0.5; // Below initial
1780        terminal
1781            .draw(|f| render_price_chart(f, f.area(), &state))
1782            .unwrap();
1783    }
1784
1785    #[test]
1786    fn test_render_price_chart_zero_first_price() {
1787        let mut terminal = create_test_terminal();
1788        let mut token_data = create_test_token_data();
1789        token_data.price_usd = 0.0;
1790        let state = MonitorState::new(&token_data, "ethereum");
1791        terminal
1792            .draw(|f| render_price_chart(f, f.area(), &state))
1793            .unwrap();
1794    }
1795
1796    #[test]
1797    fn test_render_metrics_panel_zero_5m_change() {
1798        let mut terminal = create_test_terminal();
1799        let mut state = create_populated_state();
1800        state.price_change_5m = 0.0; // Exactly zero
1801        terminal
1802            .draw(|f| render_metrics_panel(f, f.area(), &state))
1803            .unwrap();
1804    }
1805
1806    #[test]
1807    fn test_render_metrics_panel_positive_5m_change() {
1808        let mut terminal = create_test_terminal();
1809        let mut state = create_populated_state();
1810        state.price_change_5m = 5.0; // Positive
1811        terminal
1812            .draw(|f| render_metrics_panel(f, f.area(), &state))
1813            .unwrap();
1814    }
1815
1816    #[test]
1817    fn test_render_metrics_panel_negative_5m_change() {
1818        let mut terminal = create_test_terminal();
1819        let mut state = create_populated_state();
1820        state.price_change_5m = -3.0; // Negative
1821        terminal
1822            .draw(|f| render_metrics_panel(f, f.area(), &state))
1823            .unwrap();
1824    }
1825
1826    #[test]
1827    fn test_render_metrics_panel_negative_24h_change() {
1828        let mut terminal = create_test_terminal();
1829        let mut state = create_populated_state();
1830        state.price_change_24h = -10.0;
1831        terminal
1832            .draw(|f| render_metrics_panel(f, f.area(), &state))
1833            .unwrap();
1834    }
1835
1836    #[test]
1837    fn test_render_metrics_panel_old_last_change() {
1838        let mut terminal = create_test_terminal();
1839        let mut state = create_populated_state();
1840        // Set last_price_change_at to over an hour ago
1841        state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 7200.0; // 2h ago
1842        terminal
1843            .draw(|f| render_metrics_panel(f, f.area(), &state))
1844            .unwrap();
1845    }
1846
1847    #[test]
1848    fn test_render_metrics_panel_minutes_ago_change() {
1849        let mut terminal = create_test_terminal();
1850        let mut state = create_populated_state();
1851        // Set last_price_change_at to minutes ago
1852        state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 300.0; // 5 min ago
1853        terminal
1854            .draw(|f| render_metrics_panel(f, f.area(), &state))
1855            .unwrap();
1856    }
1857
1858    #[test]
1859    fn test_render_candlestick_empty_fresh_state() {
1860        let mut terminal = create_test_terminal();
1861        let token_data = create_test_token_data();
1862        let mut state = MonitorState::new(&token_data, "ethereum");
1863        state.price_history.clear();
1864        state.chart_mode = ChartMode::Candlestick;
1865        terminal
1866            .draw(|f| render_candlestick_chart(f, f.area(), &state))
1867            .unwrap();
1868    }
1869
1870    #[test]
1871    fn test_render_candlestick_price_down() {
1872        let mut terminal = create_test_terminal();
1873        let token_data = create_test_token_data();
1874        let mut state = MonitorState::new(&token_data, "ethereum");
1875        // Add data going down
1876        for i in 0..20 {
1877            let mut data = token_data.clone();
1878            data.price_usd = 2.0 - (i as f64 * 0.05);
1879            state.update(&data);
1880        }
1881        state.chart_mode = ChartMode::Candlestick;
1882        terminal
1883            .draw(|f| render_candlestick_chart(f, f.area(), &state))
1884            .unwrap();
1885    }
1886
1887    #[test]
1888    fn test_render_volume_chart_with_many_points() {
1889        let mut terminal = create_test_terminal();
1890        let token_data = create_test_token_data();
1891        let mut state = MonitorState::new(&token_data, "ethereum");
1892        // Add lots of data points
1893        for i in 0..100 {
1894            let mut data = token_data.clone();
1895            data.volume_24h = 1_000_000.0 + (i as f64 * 50_000.0);
1896            data.price_usd = 1.0 + (i as f64 * 0.001);
1897            state.update(&data);
1898        }
1899        terminal
1900            .draw(|f| render_volume_chart(f, f.area(), &state))
1901            .unwrap();
1902    }
1903
1904    // ========================================================================
1905    // Key event handler tests
1906    // ========================================================================
1907
1908    fn make_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
1909        crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)
1910    }
1911
1912    fn make_ctrl_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
1913        crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
1914    }
1915
1916    #[test]
1917    fn test_handle_key_quit_q() {
1918        let token_data = create_test_token_data();
1919        let mut state = MonitorState::new(&token_data, "ethereum");
1920        assert!(handle_key_event_on_state(
1921            make_key_event(KeyCode::Char('q')),
1922            &mut state
1923        ));
1924    }
1925
1926    #[test]
1927    fn test_handle_key_quit_esc() {
1928        let token_data = create_test_token_data();
1929        let mut state = MonitorState::new(&token_data, "ethereum");
1930        assert!(handle_key_event_on_state(
1931            make_key_event(KeyCode::Esc),
1932            &mut state
1933        ));
1934    }
1935
1936    #[test]
1937    fn test_handle_key_quit_ctrl_c() {
1938        let token_data = create_test_token_data();
1939        let mut state = MonitorState::new(&token_data, "ethereum");
1940        assert!(handle_key_event_on_state(
1941            make_ctrl_key_event(KeyCode::Char('c')),
1942            &mut state
1943        ));
1944    }
1945
1946    #[test]
1947    fn test_handle_key_refresh() {
1948        let token_data = create_test_token_data();
1949        let mut state = MonitorState::new(&token_data, "ethereum");
1950        state.refresh_rate = Duration::from_secs(60);
1951        // Set last_update in the past so should_refresh was false
1952        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('r')), &mut state);
1953        assert!(!exit);
1954        // force_refresh sets last_update to epoch, so should_refresh() should be true
1955        assert!(state.should_refresh());
1956    }
1957
1958    #[test]
1959    fn test_handle_key_pause_toggle() {
1960        let token_data = create_test_token_data();
1961        let mut state = MonitorState::new(&token_data, "ethereum");
1962        assert!(!state.paused);
1963
1964        handle_key_event_on_state(make_key_event(KeyCode::Char('p')), &mut state);
1965        assert!(state.paused);
1966
1967        handle_key_event_on_state(make_key_event(KeyCode::Char(' ')), &mut state);
1968        assert!(!state.paused);
1969    }
1970
1971    #[test]
1972    fn test_handle_key_slower_refresh() {
1973        let token_data = create_test_token_data();
1974        let mut state = MonitorState::new(&token_data, "ethereum");
1975        let initial = state.refresh_rate;
1976
1977        handle_key_event_on_state(make_key_event(KeyCode::Char('+')), &mut state);
1978        assert!(state.refresh_rate > initial);
1979
1980        state.refresh_rate = initial;
1981        handle_key_event_on_state(make_key_event(KeyCode::Char('=')), &mut state);
1982        assert!(state.refresh_rate > initial);
1983
1984        state.refresh_rate = initial;
1985        handle_key_event_on_state(make_key_event(KeyCode::Char(']')), &mut state);
1986        assert!(state.refresh_rate > initial);
1987    }
1988
1989    #[test]
1990    fn test_handle_key_faster_refresh() {
1991        let token_data = create_test_token_data();
1992        let mut state = MonitorState::new(&token_data, "ethereum");
1993        // First make it slower so there's room to go faster
1994        state.refresh_rate = Duration::from_secs(30);
1995        let initial = state.refresh_rate;
1996
1997        handle_key_event_on_state(make_key_event(KeyCode::Char('-')), &mut state);
1998        assert!(state.refresh_rate < initial);
1999
2000        state.refresh_rate = initial;
2001        handle_key_event_on_state(make_key_event(KeyCode::Char('_')), &mut state);
2002        assert!(state.refresh_rate < initial);
2003
2004        state.refresh_rate = initial;
2005        handle_key_event_on_state(make_key_event(KeyCode::Char('[')), &mut state);
2006        assert!(state.refresh_rate < initial);
2007    }
2008
2009    #[test]
2010    fn test_handle_key_time_periods() {
2011        let token_data = create_test_token_data();
2012        let mut state = MonitorState::new(&token_data, "ethereum");
2013
2014        handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
2015        assert!(matches!(state.time_period, TimePeriod::Min1));
2016
2017        handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
2018        assert!(matches!(state.time_period, TimePeriod::Min5));
2019
2020        handle_key_event_on_state(make_key_event(KeyCode::Char('3')), &mut state);
2021        assert!(matches!(state.time_period, TimePeriod::Min15));
2022
2023        handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
2024        assert!(matches!(state.time_period, TimePeriod::Hour1));
2025
2026        handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
2027        assert!(matches!(state.time_period, TimePeriod::Hour4));
2028
2029        handle_key_event_on_state(make_key_event(KeyCode::Char('6')), &mut state);
2030        assert!(matches!(state.time_period, TimePeriod::Day1));
2031    }
2032
2033    #[test]
2034    fn test_handle_key_cycle_time_period() {
2035        let token_data = create_test_token_data();
2036        let mut state = MonitorState::new(&token_data, "ethereum");
2037
2038        handle_key_event_on_state(make_key_event(KeyCode::Char('t')), &mut state);
2039        // Should cycle from default
2040        let first = state.time_period;
2041
2042        handle_key_event_on_state(make_key_event(KeyCode::Tab), &mut state);
2043        // Should have cycled again
2044        // Verify it cycled (no panic is the main check)
2045        let _ = state.time_period;
2046        let _ = first;
2047    }
2048
2049    #[test]
2050    fn test_handle_key_toggle_chart_mode() {
2051        let token_data = create_test_token_data();
2052        let mut state = MonitorState::new(&token_data, "ethereum");
2053        let initial_mode = state.chart_mode;
2054
2055        handle_key_event_on_state(make_key_event(KeyCode::Char('c')), &mut state);
2056        assert!(state.chart_mode != initial_mode);
2057    }
2058
2059    #[test]
2060    fn test_handle_key_unknown_no_op() {
2061        let token_data = create_test_token_data();
2062        let mut state = MonitorState::new(&token_data, "ethereum");
2063        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
2064        assert!(!exit);
2065    }
2066
2067    // ========================================================================
2068    // Cache save/load tests
2069    // ========================================================================
2070
2071    #[test]
2072    fn test_save_and_load_cache() {
2073        let token_data = create_test_token_data();
2074        let mut state = MonitorState::new(&token_data, "ethereum");
2075        state.price_history.push_back(DataPoint {
2076            timestamp: 1.0,
2077            value: 100.0,
2078            is_real: true,
2079        });
2080        state.price_history.push_back(DataPoint {
2081            timestamp: 2.0,
2082            value: 101.0,
2083            is_real: true,
2084        });
2085        state.volume_history.push_back(DataPoint {
2086            timestamp: 1.0,
2087            value: 5000.0,
2088            is_real: true,
2089        });
2090
2091        // save_cache uses dirs::cache_dir() which we can't redirect easily
2092        // but we can test the load_cache path with a real write
2093        state.save_cache();
2094        let cached = MonitorState::load_cache(&state.token_address, &state.chain);
2095        // Cache may or may not exist depending on system - just verify no panic
2096        if let Some(c) = cached {
2097            assert_eq!(
2098                c.token_address.to_lowercase(),
2099                state.token_address.to_lowercase()
2100            );
2101        }
2102    }
2103
2104    #[test]
2105    fn test_load_cache_nonexistent_token() {
2106        let cached = MonitorState::load_cache("0xNONEXISTENT_TOKEN_ADDR", "nonexistent_chain");
2107        assert!(cached.is_none());
2108    }
2109
2110    // ========================================================================
2111    // New widget tests: BarChart (volume), Table+Sparkline (metrics), scroll
2112    // ========================================================================
2113
2114    #[test]
2115    fn test_render_volume_barchart_with_populated_data() {
2116        // Verify the BarChart-based volume chart renders without panic
2117        // when state has many volume data points across different time periods
2118        let mut terminal = create_test_terminal();
2119        let mut state = create_populated_state();
2120        for period in [
2121            TimePeriod::Min1,
2122            TimePeriod::Min5,
2123            TimePeriod::Min15,
2124            TimePeriod::Hour1,
2125            TimePeriod::Hour4,
2126            TimePeriod::Day1,
2127        ] {
2128            state.set_time_period(period);
2129            terminal
2130                .draw(|f| render_volume_chart(f, f.area(), &state))
2131                .unwrap();
2132        }
2133    }
2134
2135    #[test]
2136    fn test_render_volume_barchart_narrow_terminal() {
2137        // BarChart with very narrow width should still render without panic
2138        let backend = TestBackend::new(20, 10);
2139        let mut terminal = Terminal::new(backend).unwrap();
2140        let state = create_populated_state();
2141        terminal
2142            .draw(|f| render_volume_chart(f, f.area(), &state))
2143            .unwrap();
2144    }
2145
2146    #[test]
2147    fn test_render_metrics_table_sparkline_no_panic() {
2148        // Verify the Table+Sparkline metrics panel renders without panic
2149        let mut terminal = create_test_terminal();
2150        let state = create_populated_state();
2151        terminal
2152            .draw(|f| render_metrics_panel(f, f.area(), &state))
2153            .unwrap();
2154    }
2155
2156    #[test]
2157    fn test_render_metrics_table_sparkline_all_periods() {
2158        // Ensure metrics panel renders correctly for every time period
2159        let mut terminal = create_test_terminal();
2160        let mut state = create_populated_state();
2161        for period in [
2162            TimePeriod::Min1,
2163            TimePeriod::Min5,
2164            TimePeriod::Min15,
2165            TimePeriod::Hour1,
2166            TimePeriod::Hour4,
2167            TimePeriod::Day1,
2168        ] {
2169            state.set_time_period(period);
2170            terminal
2171                .draw(|f| render_metrics_panel(f, f.area(), &state))
2172                .unwrap();
2173        }
2174    }
2175
2176    #[test]
2177    fn test_render_metrics_sparkline_trend_direction() {
2178        // When 5m change is negative, sparkline should still render
2179        let mut terminal = create_test_terminal();
2180        let mut state = create_populated_state();
2181        state.price_change_5m = -3.5;
2182        terminal
2183            .draw(|f| render_metrics_panel(f, f.area(), &state))
2184            .unwrap();
2185
2186        // When 5m change is positive
2187        state.price_change_5m = 2.0;
2188        terminal
2189            .draw(|f| render_metrics_panel(f, f.area(), &state))
2190            .unwrap();
2191
2192        // When 5m change is zero
2193        state.price_change_5m = 0.0;
2194        terminal
2195            .draw(|f| render_metrics_panel(f, f.area(), &state))
2196            .unwrap();
2197    }
2198
2199    #[test]
2200    fn test_render_tabs_time_period() {
2201        // Verify the Tabs widget in the header renders for each period
2202        let mut terminal = create_test_terminal();
2203        let mut state = create_populated_state();
2204        for period in [
2205            TimePeriod::Min1,
2206            TimePeriod::Min5,
2207            TimePeriod::Min15,
2208            TimePeriod::Hour1,
2209            TimePeriod::Hour4,
2210            TimePeriod::Day1,
2211        ] {
2212            state.set_time_period(period);
2213            terminal
2214                .draw(|f| render_header(f, f.area(), &state))
2215                .unwrap();
2216        }
2217    }
2218
2219    #[test]
2220    fn test_time_period_index() {
2221        assert_eq!(TimePeriod::Min1.index(), 0);
2222        assert_eq!(TimePeriod::Min5.index(), 1);
2223        assert_eq!(TimePeriod::Min15.index(), 2);
2224        assert_eq!(TimePeriod::Hour1.index(), 3);
2225        assert_eq!(TimePeriod::Hour4.index(), 4);
2226        assert_eq!(TimePeriod::Day1.index(), 5);
2227    }
2228
2229    #[test]
2230    fn test_scroll_log_down_from_start() {
2231        let token_data = create_test_token_data();
2232        let mut state = MonitorState::new(&token_data, "ethereum");
2233        state.log_messages.push_back("msg 1".to_string());
2234        state.log_messages.push_back("msg 2".to_string());
2235        state.log_messages.push_back("msg 3".to_string());
2236
2237        // Initially no selection
2238        assert_eq!(state.log_list_state.selected(), None);
2239
2240        // First scroll down selects item 0
2241        state.scroll_log_down();
2242        assert_eq!(state.log_list_state.selected(), Some(0));
2243
2244        // Second scroll moves to item 1
2245        state.scroll_log_down();
2246        assert_eq!(state.log_list_state.selected(), Some(1));
2247
2248        // Third scroll moves to item 2
2249        state.scroll_log_down();
2250        assert_eq!(state.log_list_state.selected(), Some(2));
2251
2252        // Fourth scroll stays at last item (bounds check)
2253        state.scroll_log_down();
2254        assert_eq!(state.log_list_state.selected(), Some(2));
2255    }
2256
2257    #[test]
2258    fn test_scroll_log_up_from_start() {
2259        let token_data = create_test_token_data();
2260        let mut state = MonitorState::new(&token_data, "ethereum");
2261        state.log_messages.push_back("msg 1".to_string());
2262        state.log_messages.push_back("msg 2".to_string());
2263        state.log_messages.push_back("msg 3".to_string());
2264
2265        // Scroll up from no selection goes to 0
2266        state.scroll_log_up();
2267        assert_eq!(state.log_list_state.selected(), Some(0));
2268
2269        // Can't go below 0
2270        state.scroll_log_up();
2271        assert_eq!(state.log_list_state.selected(), Some(0));
2272    }
2273
2274    #[test]
2275    fn test_scroll_log_up_down_roundtrip() {
2276        let token_data = create_test_token_data();
2277        let mut state = MonitorState::new(&token_data, "ethereum");
2278        for i in 0..10 {
2279            state.log_messages.push_back(format!("msg {}", i));
2280        }
2281
2282        // Scroll down 5 times
2283        for _ in 0..5 {
2284            state.scroll_log_down();
2285        }
2286        assert_eq!(state.log_list_state.selected(), Some(4));
2287
2288        // Scroll up 3 times
2289        for _ in 0..3 {
2290            state.scroll_log_up();
2291        }
2292        assert_eq!(state.log_list_state.selected(), Some(1));
2293    }
2294
2295    #[test]
2296    fn test_scroll_log_empty_no_panic() {
2297        let token_data = create_test_token_data();
2298        let mut state = MonitorState::new(&token_data, "ethereum");
2299        // With no log messages, scrolling should not panic
2300        state.scroll_log_down();
2301        state.scroll_log_up();
2302        assert!(
2303            state.log_list_state.selected().is_none() || state.log_list_state.selected() == Some(0)
2304        );
2305    }
2306
2307    #[test]
2308    fn test_render_scrollable_activity_log() {
2309        // Ensure the stateful activity log renders without panic
2310        let mut terminal = create_test_terminal();
2311        let mut state = create_populated_state();
2312        for i in 0..20 {
2313            state
2314                .log_messages
2315                .push_back(format!("Activity event #{}", i));
2316        }
2317        // Scroll down a few items
2318        state.scroll_log_down();
2319        state.scroll_log_down();
2320        state.scroll_log_down();
2321
2322        terminal
2323            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
2324            .unwrap();
2325    }
2326
2327    #[test]
2328    fn test_handle_key_scroll_log_j_k() {
2329        let token_data = create_test_token_data();
2330        let mut state = MonitorState::new(&token_data, "ethereum");
2331        state.log_messages.push_back("line 1".to_string());
2332        state.log_messages.push_back("line 2".to_string());
2333
2334        // j scrolls down
2335        handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
2336        assert_eq!(state.log_list_state.selected(), Some(0));
2337
2338        handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
2339        assert_eq!(state.log_list_state.selected(), Some(1));
2340
2341        // k scrolls up
2342        handle_key_event_on_state(make_key_event(KeyCode::Char('k')), &mut state);
2343        assert_eq!(state.log_list_state.selected(), Some(0));
2344    }
2345
2346    #[test]
2347    fn test_handle_key_scroll_log_arrow_keys() {
2348        let token_data = create_test_token_data();
2349        let mut state = MonitorState::new(&token_data, "ethereum");
2350        state.log_messages.push_back("line 1".to_string());
2351        state.log_messages.push_back("line 2".to_string());
2352        state.log_messages.push_back("line 3".to_string());
2353
2354        // Down arrow scrolls down
2355        handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
2356        assert_eq!(state.log_list_state.selected(), Some(0));
2357
2358        handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
2359        assert_eq!(state.log_list_state.selected(), Some(1));
2360
2361        // Up arrow scrolls up
2362        handle_key_event_on_state(make_key_event(KeyCode::Up), &mut state);
2363        assert_eq!(state.log_list_state.selected(), Some(0));
2364    }
2365
2366    #[test]
2367    fn test_render_ui_with_scrolled_log() {
2368        // Full UI render with a scrolled activity log position
2369        let mut terminal = create_test_terminal();
2370        let mut state = create_populated_state();
2371        for i in 0..15 {
2372            state.log_messages.push_back(format!("Log entry {}", i));
2373        }
2374        state.scroll_log_down();
2375        state.scroll_log_down();
2376        state.scroll_log_down();
2377        state.scroll_log_down();
2378        state.scroll_log_down();
2379
2380        terminal.draw(|f| ui(f, &mut state)).unwrap();
2381    }
2382
2383    // ========================================================================
2384    // Token selection / resolve tests
2385    // ========================================================================
2386
2387    fn make_monitor_search_results() -> Vec<scope::chains::dex::TokenSearchResult> {
2388        vec![
2389            scope::chains::dex::TokenSearchResult {
2390                symbol: "USDC".to_string(),
2391                name: "USD Coin".to_string(),
2392                address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
2393                chain: "ethereum".to_string(),
2394                price_usd: Some(1.0),
2395                volume_24h: 1_000_000.0,
2396                liquidity_usd: 500_000_000.0,
2397                market_cap: Some(30_000_000_000.0),
2398            },
2399            scope::chains::dex::TokenSearchResult {
2400                symbol: "USDC".to_string(),
2401                name: "Bridged USD Coin".to_string(),
2402                address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
2403                chain: "ethereum".to_string(),
2404                price_usd: Some(0.9998),
2405                volume_24h: 500_000.0,
2406                liquidity_usd: 100_000_000.0,
2407                market_cap: None,
2408            },
2409            scope::chains::dex::TokenSearchResult {
2410                symbol: "USDC".to_string(),
2411                name: "A Very Long Token Name That Exceeds The Limit".to_string(),
2412                address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
2413                chain: "ethereum".to_string(),
2414                price_usd: None,
2415                volume_24h: 0.0,
2416                liquidity_usd: 50_000.0,
2417                market_cap: None,
2418            },
2419        ]
2420    }
2421
2422    #[test]
2423    fn test_abbreviate_address_long() {
2424        let addr = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
2425        let abbr = abbreviate_address(addr);
2426        assert_eq!(abbr, "0xA0b869...06eB48");
2427        assert!(abbr.contains("..."));
2428    }
2429
2430    #[test]
2431    fn test_abbreviate_address_short() {
2432        let addr = "0x1234abcd";
2433        let abbr = abbreviate_address(addr);
2434        // Short addresses are not abbreviated
2435        assert_eq!(abbr, "0x1234abcd");
2436    }
2437
2438    #[test]
2439    fn test_select_token_impl_first() {
2440        let results = make_monitor_search_results();
2441        let input = b"1\n";
2442        let mut reader = std::io::Cursor::new(&input[..]);
2443        let mut writer = Vec::new();
2444
2445        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
2446        assert_eq!(selected.name, "USD Coin");
2447        assert_eq!(
2448            selected.address,
2449            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
2450        );
2451
2452        let output = String::from_utf8(writer).unwrap();
2453        assert!(output.contains("Found 3 tokens"));
2454        assert!(output.contains("USDC"));
2455        assert!(output.contains("0xA0b869...06eB48"));
2456        assert!(output.contains("Selected:"));
2457    }
2458
2459    #[test]
2460    fn test_select_token_impl_second() {
2461        let results = make_monitor_search_results();
2462        let input = b"2\n";
2463        let mut reader = std::io::Cursor::new(&input[..]);
2464        let mut writer = Vec::new();
2465
2466        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
2467        assert_eq!(selected.name, "Bridged USD Coin");
2468        assert_eq!(
2469            selected.address,
2470            "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
2471        );
2472    }
2473
2474    #[test]
2475    fn test_select_token_impl_shows_address_column() {
2476        let results = make_monitor_search_results();
2477        let input = b"1\n";
2478        let mut reader = std::io::Cursor::new(&input[..]);
2479        let mut writer = Vec::new();
2480
2481        select_token_impl(&results, &mut reader, &mut writer).unwrap();
2482        let output = String::from_utf8(writer).unwrap();
2483
2484        // Table header should include Address column
2485        assert!(output.contains("Address"));
2486        // All three abbreviated addresses should appear
2487        assert!(output.contains("0xA0b869...06eB48"));
2488        assert!(output.contains("0x2791Bc...a84174"));
2489        assert!(output.contains("0x123456...345678"));
2490    }
2491
2492    #[test]
2493    fn test_select_token_impl_truncates_long_name() {
2494        let results = make_monitor_search_results();
2495        let input = b"3\n";
2496        let mut reader = std::io::Cursor::new(&input[..]);
2497        let mut writer = Vec::new();
2498
2499        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
2500        assert_eq!(
2501            selected.address,
2502            "0x1234567890abcdef1234567890abcdef12345678"
2503        );
2504
2505        let output = String::from_utf8(writer).unwrap();
2506        assert!(output.contains("A Very Long Token..."));
2507    }
2508
2509    #[test]
2510    fn test_select_token_impl_invalid_input() {
2511        let results = make_monitor_search_results();
2512        let input = b"xyz\n";
2513        let mut reader = std::io::Cursor::new(&input[..]);
2514        let mut writer = Vec::new();
2515
2516        let result = select_token_impl(&results, &mut reader, &mut writer);
2517        assert!(result.is_err());
2518        assert!(
2519            result
2520                .unwrap_err()
2521                .to_string()
2522                .contains("Invalid selection")
2523        );
2524    }
2525
2526    #[test]
2527    fn test_select_token_impl_out_of_range_zero() {
2528        let results = make_monitor_search_results();
2529        let input = b"0\n";
2530        let mut reader = std::io::Cursor::new(&input[..]);
2531        let mut writer = Vec::new();
2532
2533        let result = select_token_impl(&results, &mut reader, &mut writer);
2534        assert!(result.is_err());
2535        assert!(
2536            result
2537                .unwrap_err()
2538                .to_string()
2539                .contains("Selection must be between")
2540        );
2541    }
2542
2543    #[test]
2544    fn test_select_token_impl_out_of_range_high() {
2545        let results = make_monitor_search_results();
2546        let input = b"99\n";
2547        let mut reader = std::io::Cursor::new(&input[..]);
2548        let mut writer = Vec::new();
2549
2550        let result = select_token_impl(&results, &mut reader, &mut writer);
2551        assert!(result.is_err());
2552    }
2553
2554    #[test]
2555    fn test_format_monitor_number() {
2556        assert_eq!(format_monitor_number(1_500_000_000.0), "$1.50B");
2557        assert_eq!(format_monitor_number(250_000_000.0), "$250.00M");
2558        assert_eq!(format_monitor_number(75_000.0), "$75.00K");
2559        assert_eq!(format_monitor_number(42.5), "$42.50");
2560    }
2561
2562    // ============================
2563    // Phase 4: Layout system tests
2564    // ============================
2565
2566    #[test]
2567    fn test_monitor_config_defaults() {
2568        let config = MonitorConfig::default();
2569        assert_eq!(config.layout, LayoutPreset::Dashboard);
2570        assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
2571        assert!(config.widgets.price_chart);
2572        assert!(config.widgets.volume_chart);
2573        assert!(config.widgets.buy_sell_pressure);
2574        assert!(config.widgets.metrics_panel);
2575        assert!(config.widgets.activity_log);
2576    }
2577
2578    #[test]
2579    fn test_layout_preset_next_cycles() {
2580        assert_eq!(LayoutPreset::Dashboard.next(), LayoutPreset::ChartFocus);
2581        assert_eq!(LayoutPreset::ChartFocus.next(), LayoutPreset::Feed);
2582        assert_eq!(LayoutPreset::Feed.next(), LayoutPreset::Compact);
2583        assert_eq!(LayoutPreset::Compact.next(), LayoutPreset::Exchange);
2584        assert_eq!(LayoutPreset::Exchange.next(), LayoutPreset::Dashboard);
2585    }
2586
2587    #[test]
2588    fn test_layout_preset_prev_cycles() {
2589        assert_eq!(LayoutPreset::Dashboard.prev(), LayoutPreset::Exchange);
2590        assert_eq!(LayoutPreset::Exchange.prev(), LayoutPreset::Compact);
2591        assert_eq!(LayoutPreset::Compact.prev(), LayoutPreset::Feed);
2592        assert_eq!(LayoutPreset::Feed.prev(), LayoutPreset::ChartFocus);
2593        assert_eq!(LayoutPreset::ChartFocus.prev(), LayoutPreset::Dashboard);
2594    }
2595
2596    #[test]
2597    fn test_layout_preset_full_cycle() {
2598        let start = LayoutPreset::Dashboard;
2599        let mut preset = start;
2600        for _ in 0..5 {
2601            preset = preset.next();
2602        }
2603        assert_eq!(preset, start);
2604    }
2605
2606    #[test]
2607    fn test_layout_preset_labels() {
2608        assert_eq!(LayoutPreset::Dashboard.label(), "Dashboard");
2609        assert_eq!(LayoutPreset::ChartFocus.label(), "Chart");
2610        assert_eq!(LayoutPreset::Feed.label(), "Feed");
2611        assert_eq!(LayoutPreset::Compact.label(), "Compact");
2612        assert_eq!(LayoutPreset::Exchange.label(), "Exchange");
2613    }
2614
2615    #[test]
2616    fn test_widget_visibility_default_all_visible() {
2617        let vis = WidgetVisibility::default();
2618        assert_eq!(vis.visible_count(), 5);
2619    }
2620
2621    #[test]
2622    fn test_widget_visibility_toggle_by_index() {
2623        let mut vis = WidgetVisibility::default();
2624        vis.toggle_by_index(1);
2625        assert!(!vis.price_chart);
2626        assert_eq!(vis.visible_count(), 4);
2627
2628        vis.toggle_by_index(2);
2629        assert!(!vis.volume_chart);
2630        assert_eq!(vis.visible_count(), 3);
2631
2632        vis.toggle_by_index(3);
2633        assert!(!vis.buy_sell_pressure);
2634        assert_eq!(vis.visible_count(), 2);
2635
2636        vis.toggle_by_index(4);
2637        assert!(!vis.metrics_panel);
2638        assert_eq!(vis.visible_count(), 1);
2639
2640        vis.toggle_by_index(5);
2641        assert!(!vis.activity_log);
2642        assert_eq!(vis.visible_count(), 0);
2643
2644        // Toggle back
2645        vis.toggle_by_index(1);
2646        assert!(vis.price_chart);
2647        assert_eq!(vis.visible_count(), 1);
2648    }
2649
2650    #[test]
2651    fn test_widget_visibility_toggle_invalid_index() {
2652        let mut vis = WidgetVisibility::default();
2653        vis.toggle_by_index(0);
2654        vis.toggle_by_index(6);
2655        vis.toggle_by_index(100);
2656        assert_eq!(vis.visible_count(), 5); // unchanged
2657    }
2658
2659    #[test]
2660    fn test_auto_select_layout_small_terminal() {
2661        let size = Rect::new(0, 0, 60, 20);
2662        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
2663    }
2664
2665    #[test]
2666    fn test_auto_select_layout_narrow_terminal() {
2667        let size = Rect::new(0, 0, 100, 40);
2668        assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
2669    }
2670
2671    #[test]
2672    fn test_auto_select_layout_short_terminal() {
2673        let size = Rect::new(0, 0, 140, 28);
2674        assert_eq!(auto_select_layout(size), LayoutPreset::ChartFocus);
2675    }
2676
2677    #[test]
2678    fn test_auto_select_layout_large_terminal() {
2679        let size = Rect::new(0, 0, 160, 50);
2680        assert_eq!(auto_select_layout(size), LayoutPreset::Dashboard);
2681    }
2682
2683    #[test]
2684    fn test_auto_select_layout_edge_80x24() {
2685        // Exactly at the threshold: width>=80 and height>=24, but width<120
2686        let size = Rect::new(0, 0, 80, 24);
2687        assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
2688    }
2689
2690    #[test]
2691    fn test_auto_select_layout_edge_79() {
2692        let size = Rect::new(0, 0, 79, 50);
2693        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
2694    }
2695
2696    #[test]
2697    fn test_auto_select_layout_edge_23_height() {
2698        let size = Rect::new(0, 0, 160, 23);
2699        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
2700    }
2701
2702    #[test]
2703    fn test_layout_dashboard_all_visible() {
2704        let area = Rect::new(0, 0, 120, 40);
2705        let vis = WidgetVisibility::default();
2706        let areas = layout_dashboard(area, &vis);
2707        assert!(areas.price_chart.is_some());
2708        assert!(areas.volume_chart.is_some());
2709        assert!(areas.buy_sell_gauge.is_some());
2710        assert!(areas.metrics_panel.is_some());
2711        assert!(areas.activity_feed.is_some());
2712    }
2713
2714    #[test]
2715    fn test_layout_dashboard_hidden_widget() {
2716        let area = Rect::new(0, 0, 120, 40);
2717        let vis = WidgetVisibility {
2718            price_chart: false,
2719            ..WidgetVisibility::default()
2720        };
2721        let areas = layout_dashboard(area, &vis);
2722        assert!(areas.price_chart.is_none());
2723        assert!(areas.volume_chart.is_some());
2724    }
2725
2726    #[test]
2727    fn test_layout_chart_focus_minimal_overlay() {
2728        let area = Rect::new(0, 0, 120, 40);
2729        let vis = WidgetVisibility::default();
2730        let areas = layout_chart_focus(area, &vis);
2731        assert!(areas.price_chart.is_some());
2732        assert!(areas.volume_chart.is_none()); // Hidden in chart-focus
2733        assert!(areas.buy_sell_gauge.is_none()); // Hidden in chart-focus
2734        assert!(areas.metrics_panel.is_some()); // Minimal stats overlay
2735        assert!(areas.activity_feed.is_none()); // Hidden in chart-focus
2736    }
2737
2738    #[test]
2739    fn test_layout_feed_activity_priority() {
2740        let area = Rect::new(0, 0, 120, 40);
2741        let vis = WidgetVisibility::default();
2742        let areas = layout_feed(area, &vis);
2743        assert!(areas.price_chart.is_none()); // Hidden in feed
2744        assert!(areas.volume_chart.is_none()); // Hidden in feed
2745        assert!(areas.buy_sell_gauge.is_some()); // Top row
2746        assert!(areas.metrics_panel.is_some()); // Top row
2747        assert!(areas.activity_feed.is_some()); // Dominates bottom 75%
2748    }
2749
2750    #[test]
2751    fn test_layout_compact_metrics_only() {
2752        let area = Rect::new(0, 0, 60, 20);
2753        let vis = WidgetVisibility::default();
2754        let areas = layout_compact(area, &vis);
2755        assert!(areas.price_chart.is_none()); // Hidden in compact
2756        assert!(areas.volume_chart.is_none()); // Hidden in compact
2757        assert!(areas.buy_sell_gauge.is_none()); // Hidden in compact
2758        assert!(areas.metrics_panel.is_some()); // Full area
2759        assert!(areas.activity_feed.is_none()); // Hidden in compact
2760    }
2761
2762    #[test]
2763    fn test_layout_exchange_has_order_book_and_market_info() {
2764        let area = Rect::new(0, 0, 160, 50);
2765        let vis = WidgetVisibility::default();
2766        let areas = layout_exchange(area, &vis);
2767        assert!(areas.order_book.is_some());
2768        assert!(areas.market_info.is_some());
2769        assert!(areas.price_chart.is_some());
2770        assert!(areas.buy_sell_gauge.is_some());
2771        assert!(areas.volume_chart.is_none()); // Not in exchange layout
2772        assert!(areas.metrics_panel.is_none()); // Not in exchange layout
2773        assert!(areas.activity_feed.is_none()); // Not in exchange layout
2774    }
2775
2776    #[test]
2777    fn test_ui_render_all_layouts_no_panic() {
2778        let presets = [
2779            LayoutPreset::Dashboard,
2780            LayoutPreset::ChartFocus,
2781            LayoutPreset::Feed,
2782            LayoutPreset::Compact,
2783            LayoutPreset::Exchange,
2784        ];
2785        for preset in &presets {
2786            let mut terminal = create_test_terminal();
2787            let mut state = create_populated_state();
2788            state.layout = *preset;
2789            state.auto_layout = false; // Don't override during render
2790            terminal.draw(|f| ui(f, &mut state)).unwrap();
2791        }
2792    }
2793
2794    #[test]
2795    fn test_ui_render_compact_small_terminal() {
2796        let backend = TestBackend::new(60, 20);
2797        let mut terminal = Terminal::new(backend).unwrap();
2798        let mut state = create_populated_state();
2799        state.layout = LayoutPreset::Compact;
2800        state.auto_layout = false;
2801        terminal.draw(|f| ui(f, &mut state)).unwrap();
2802    }
2803
2804    #[test]
2805    fn test_ui_auto_layout_selects_compact_for_small() {
2806        let backend = TestBackend::new(60, 20);
2807        let mut terminal = Terminal::new(backend).unwrap();
2808        let mut state = create_populated_state();
2809        state.layout = LayoutPreset::Dashboard;
2810        state.auto_layout = true;
2811        terminal.draw(|f| ui(f, &mut state)).unwrap();
2812        assert_eq!(state.layout, LayoutPreset::Compact);
2813    }
2814
2815    #[test]
2816    fn test_ui_auto_layout_disabled_keeps_preset() {
2817        let backend = TestBackend::new(60, 20);
2818        let mut terminal = Terminal::new(backend).unwrap();
2819        let mut state = create_populated_state();
2820        state.layout = LayoutPreset::Dashboard;
2821        state.auto_layout = false;
2822        terminal.draw(|f| ui(f, &mut state)).unwrap();
2823        assert_eq!(state.layout, LayoutPreset::Dashboard); // Not changed
2824    }
2825
2826    #[test]
2827    fn test_keybinding_l_cycles_layout_forward() {
2828        let mut state = create_populated_state();
2829        state.layout = LayoutPreset::Dashboard;
2830        state.auto_layout = true;
2831
2832        handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
2833        assert_eq!(state.layout, LayoutPreset::ChartFocus);
2834        assert!(!state.auto_layout); // Manual switch disables auto
2835
2836        handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
2837        assert_eq!(state.layout, LayoutPreset::Feed);
2838    }
2839
2840    #[test]
2841    fn test_keybinding_h_cycles_layout_backward() {
2842        let mut state = create_populated_state();
2843        state.layout = LayoutPreset::Dashboard;
2844        state.auto_layout = true;
2845
2846        handle_key_event_on_state(make_key_event(KeyCode::Char('h')), &mut state);
2847        assert_eq!(state.layout, LayoutPreset::Exchange);
2848        assert!(!state.auto_layout);
2849    }
2850
2851    #[test]
2852    fn test_keybinding_a_enables_auto_layout() {
2853        let mut state = create_populated_state();
2854        state.auto_layout = false;
2855
2856        handle_key_event_on_state(make_key_event(KeyCode::Char('a')), &mut state);
2857        assert!(state.auto_layout);
2858    }
2859
2860    #[test]
2861    fn test_keybinding_w_widget_toggle_mode() {
2862        let mut state = create_populated_state();
2863        assert!(!state.widget_toggle_mode);
2864
2865        // Press w to enter toggle mode
2866        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2867        assert!(state.widget_toggle_mode);
2868
2869        // Press 1 to toggle price_chart off
2870        handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
2871        assert!(!state.widget_toggle_mode);
2872        assert!(!state.widgets.price_chart);
2873    }
2874
2875    #[test]
2876    fn test_keybinding_w_cancel_with_non_digit() {
2877        let mut state = create_populated_state();
2878
2879        // Enter widget toggle mode
2880        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2881        assert!(state.widget_toggle_mode);
2882
2883        // Press 'x' to cancel — should also process 'x' as a normal key (no-op)
2884        handle_key_event_on_state(make_key_event(KeyCode::Char('x')), &mut state);
2885        assert!(!state.widget_toggle_mode);
2886        assert!(state.widgets.price_chart); // unchanged
2887    }
2888
2889    #[test]
2890    fn test_keybinding_w_toggle_multiple_widgets() {
2891        let mut state = create_populated_state();
2892
2893        // Toggle widget 2 (volume_chart)
2894        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2895        handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
2896        assert!(!state.widgets.volume_chart);
2897
2898        // Toggle widget 4 (metrics_panel)
2899        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2900        handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
2901        assert!(!state.widgets.metrics_panel);
2902
2903        // Toggle widget 5 (activity_log)
2904        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2905        handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
2906        assert!(!state.widgets.activity_log);
2907    }
2908
2909    #[test]
2910    fn test_monitor_config_serde_roundtrip() {
2911        let config = MonitorConfig {
2912            layout: LayoutPreset::ChartFocus,
2913            refresh_seconds: 5,
2914            widgets: WidgetVisibility {
2915                price_chart: true,
2916                volume_chart: false,
2917                buy_sell_pressure: true,
2918                metrics_panel: false,
2919                activity_log: true,
2920                holder_count: true,
2921                liquidity_depth: true,
2922            },
2923            scale: ScaleMode::Log,
2924            color_scheme: ColorScheme::BlueOrange,
2925            alerts: AlertConfig::default(),
2926            export: ExportConfig::default(),
2927            auto_pause_on_input: false,
2928            venue: None,
2929        };
2930
2931        let yaml = serde_yaml::to_string(&config).unwrap();
2932        let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
2933        assert_eq!(parsed.layout, LayoutPreset::ChartFocus);
2934        assert_eq!(parsed.refresh_seconds, 5);
2935        assert!(parsed.widgets.price_chart);
2936        assert!(!parsed.widgets.volume_chart);
2937        assert!(parsed.widgets.buy_sell_pressure);
2938        assert!(!parsed.widgets.metrics_panel);
2939        assert!(parsed.widgets.activity_log);
2940    }
2941
2942    #[test]
2943    fn test_monitor_config_serde_kebab_case() {
2944        let yaml = r#"
2945layout: chart-focus
2946refresh_seconds: 15
2947widgets:
2948  price_chart: true
2949  volume_chart: true
2950  buy_sell_pressure: false
2951  metrics_panel: true
2952  activity_log: false
2953"#;
2954        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
2955        assert_eq!(config.layout, LayoutPreset::ChartFocus);
2956        assert_eq!(config.refresh_seconds, 15);
2957        assert!(!config.widgets.buy_sell_pressure);
2958        assert!(!config.widgets.activity_log);
2959    }
2960
2961    #[test]
2962    fn test_monitor_config_serde_default_missing_fields() {
2963        let yaml = "layout: feed\n";
2964        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
2965        assert_eq!(config.layout, LayoutPreset::Feed);
2966        assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
2967        assert!(config.widgets.price_chart); // defaults
2968    }
2969
2970    #[test]
2971    fn test_state_apply_config() {
2972        let mut state = create_populated_state();
2973        let config = MonitorConfig {
2974            layout: LayoutPreset::Feed,
2975            refresh_seconds: 5,
2976            widgets: WidgetVisibility {
2977                price_chart: false,
2978                volume_chart: true,
2979                buy_sell_pressure: true,
2980                metrics_panel: false,
2981                activity_log: true,
2982                holder_count: true,
2983                liquidity_depth: true,
2984            },
2985            scale: ScaleMode::Log,
2986            color_scheme: ColorScheme::Monochrome,
2987            alerts: AlertConfig::default(),
2988            export: ExportConfig::default(),
2989            auto_pause_on_input: false,
2990            venue: None,
2991        };
2992        state.apply_config(&config);
2993        assert_eq!(state.layout, LayoutPreset::Feed);
2994        assert!(!state.widgets.price_chart);
2995        assert!(!state.widgets.metrics_panel);
2996        assert_eq!(state.refresh_rate, Duration::from_secs(5));
2997    }
2998
2999    #[test]
3000    fn test_layout_all_widgets_hidden_dashboard() {
3001        let area = Rect::new(0, 0, 120, 40);
3002        let vis = WidgetVisibility {
3003            price_chart: false,
3004            volume_chart: false,
3005            buy_sell_pressure: false,
3006            metrics_panel: false,
3007            activity_log: false,
3008            holder_count: false,
3009            liquidity_depth: false,
3010        };
3011        let areas = layout_dashboard(area, &vis);
3012        assert!(areas.price_chart.is_none());
3013        assert!(areas.volume_chart.is_none());
3014        assert!(areas.buy_sell_gauge.is_none());
3015        assert!(areas.metrics_panel.is_none());
3016        assert!(areas.activity_feed.is_none());
3017    }
3018
3019    #[test]
3020    fn test_ui_render_with_hidden_widgets() {
3021        let mut terminal = create_test_terminal();
3022        let mut state = create_populated_state();
3023        state.auto_layout = false;
3024        state.widgets.price_chart = false;
3025        state.widgets.volume_chart = false;
3026        terminal.draw(|f| ui(f, &mut state)).unwrap();
3027    }
3028
3029    #[test]
3030    fn test_ui_render_widget_toggle_mode_footer() {
3031        let mut terminal = create_test_terminal();
3032        let mut state = create_populated_state();
3033        state.auto_layout = false;
3034        state.widget_toggle_mode = true;
3035        terminal.draw(|f| ui(f, &mut state)).unwrap();
3036    }
3037
3038    #[test]
3039    fn test_monitor_state_new_has_layout_fields() {
3040        let token_data = create_test_token_data();
3041        let state = MonitorState::new(&token_data, "ethereum");
3042        assert_eq!(state.layout, LayoutPreset::Dashboard);
3043        assert!(state.auto_layout);
3044        assert!(!state.widget_toggle_mode);
3045        assert_eq!(state.widgets.visible_count(), 5);
3046    }
3047
3048    // ========================================================================
3049    // Phase 6: Data Source Integration tests
3050    // ========================================================================
3051
3052    #[test]
3053    fn test_monitor_state_has_holder_count_field() {
3054        let token_data = create_test_token_data();
3055        let state = MonitorState::new(&token_data, "ethereum");
3056        assert_eq!(state.holder_count, None);
3057        assert!(state.liquidity_pairs.is_empty());
3058        assert_eq!(state.holder_fetch_counter, 0);
3059    }
3060
3061    #[test]
3062    fn test_liquidity_pairs_extracted_on_update() {
3063        let mut token_data = create_test_token_data();
3064        token_data.pairs = vec![
3065            scope::chains::DexPair {
3066                dex_name: "Uniswap V3".to_string(),
3067                pair_address: "0xpair1".to_string(),
3068                base_token: "TEST".to_string(),
3069                quote_token: "WETH".to_string(),
3070                price_usd: 1.0,
3071                volume_24h: 500_000.0,
3072                liquidity_usd: 250_000.0,
3073                price_change_24h: 5.0,
3074                buys_24h: 50,
3075                sells_24h: 25,
3076                buys_6h: 10,
3077                sells_6h: 5,
3078                buys_1h: 3,
3079                sells_1h: 2,
3080                pair_created_at: None,
3081                url: None,
3082            },
3083            scope::chains::DexPair {
3084                dex_name: "SushiSwap".to_string(),
3085                pair_address: "0xpair2".to_string(),
3086                base_token: "TEST".to_string(),
3087                quote_token: "USDC".to_string(),
3088                price_usd: 1.0,
3089                volume_24h: 300_000.0,
3090                liquidity_usd: 150_000.0,
3091                price_change_24h: 3.0,
3092                buys_24h: 30,
3093                sells_24h: 15,
3094                buys_6h: 8,
3095                sells_6h: 4,
3096                buys_1h: 2,
3097                sells_1h: 1,
3098                pair_created_at: None,
3099                url: None,
3100            },
3101        ];
3102
3103        let mut state = MonitorState::new(&token_data, "ethereum");
3104        state.update(&token_data);
3105
3106        assert_eq!(state.liquidity_pairs.len(), 2);
3107        assert!(state.liquidity_pairs[0].0.contains("Uniswap V3"));
3108        assert!((state.liquidity_pairs[0].1 - 250_000.0).abs() < 0.01);
3109    }
3110
3111    #[test]
3112    fn test_render_liquidity_depth_no_panic() {
3113        let mut terminal = create_test_terminal();
3114        let mut state = create_populated_state();
3115        state.liquidity_pairs = vec![
3116            ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
3117            ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
3118        ];
3119        terminal
3120            .draw(|f| render_liquidity_depth(f, f.area(), &state))
3121            .unwrap();
3122    }
3123
3124    #[test]
3125    fn test_render_liquidity_depth_empty() {
3126        let mut terminal = create_test_terminal();
3127        let state = create_populated_state();
3128        terminal
3129            .draw(|f| render_liquidity_depth(f, f.area(), &state))
3130            .unwrap();
3131    }
3132
3133    #[test]
3134    fn test_render_metrics_with_holder_count() {
3135        let mut terminal = create_test_terminal();
3136        let mut state = create_populated_state();
3137        state.holder_count = Some(42_000);
3138        terminal
3139            .draw(|f| render_metrics_panel(f, f.area(), &state))
3140            .unwrap();
3141    }
3142
3143    // ========================================================================
3144    // Phase 7: Alert System tests
3145    // ========================================================================
3146
3147    #[test]
3148    fn test_alert_config_default() {
3149        let config = AlertConfig::default();
3150        assert!(config.price_min.is_none());
3151        assert!(config.price_max.is_none());
3152        assert!(config.whale_min_usd.is_none());
3153        assert!(config.volume_spike_threshold_pct.is_none());
3154    }
3155
3156    #[test]
3157    fn test_alert_price_min_triggers() {
3158        let token_data = create_test_token_data();
3159        let mut state = MonitorState::new(&token_data, "ethereum");
3160        state.alerts.price_min = Some(2.0); // Price is 1.0, below min of 2.0
3161        state.update(&token_data);
3162        assert!(
3163            !state.active_alerts.is_empty(),
3164            "Should have price-min alert"
3165        );
3166        assert!(state.active_alerts[0].message.contains("below min"));
3167    }
3168
3169    #[test]
3170    fn test_alert_price_max_triggers() {
3171        let mut token_data = create_test_token_data();
3172        token_data.price_usd = 100.0;
3173        let mut state = MonitorState::new(&token_data, "ethereum");
3174        state.alerts.price_max = Some(50.0); // Price 100.0 above max of 50.0
3175        state.update(&token_data);
3176        assert!(
3177            !state.active_alerts.is_empty(),
3178            "Should have price-max alert"
3179        );
3180        assert!(state.active_alerts[0].message.contains("above max"));
3181    }
3182
3183    #[test]
3184    fn test_alert_no_trigger_within_bounds() {
3185        let token_data = create_test_token_data();
3186        let mut state = MonitorState::new(&token_data, "ethereum");
3187        state.alerts.price_min = Some(0.5); // Price 1.0 is above min
3188        state.alerts.price_max = Some(2.0); // Price 1.0 is below max
3189        state.update(&token_data);
3190        assert!(
3191            state.active_alerts.is_empty(),
3192            "Should have no alerts when price is within bounds"
3193        );
3194    }
3195
3196    #[test]
3197    fn test_alert_volume_spike_triggers() {
3198        let token_data = create_test_token_data();
3199        let mut state = MonitorState::new(&token_data, "ethereum");
3200        state.alerts.volume_spike_threshold_pct = Some(10.0);
3201        state.volume_avg = 500_000.0; // Average volume is 500K
3202
3203        // Token data has volume_24h of 1M, which is +100% vs avg — should trigger
3204        state.update(&token_data);
3205        let spike_alerts: Vec<_> = state
3206            .active_alerts
3207            .iter()
3208            .filter(|a| a.message.contains("spike"))
3209            .collect();
3210        assert!(!spike_alerts.is_empty(), "Should have volume spike alert");
3211    }
3212
3213    #[test]
3214    fn test_alert_flash_timer_set() {
3215        let token_data = create_test_token_data();
3216        let mut state = MonitorState::new(&token_data, "ethereum");
3217        state.alerts.price_min = Some(2.0);
3218        state.update(&token_data);
3219        assert!(state.alert_flash_until.is_some());
3220    }
3221
3222    #[test]
3223    fn test_render_alert_overlay_no_panic() {
3224        let mut terminal = create_test_terminal();
3225        let mut state = create_populated_state();
3226        state.active_alerts.push(ActiveAlert {
3227            message: "⚠ Test alert".to_string(),
3228            triggered_at: Instant::now(),
3229        });
3230        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
3231        terminal
3232            .draw(|f| render_alert_overlay(f, f.area(), &state))
3233            .unwrap();
3234    }
3235
3236    #[test]
3237    fn test_render_alert_overlay_empty() {
3238        let mut terminal = create_test_terminal();
3239        let state = create_populated_state();
3240        terminal
3241            .draw(|f| render_alert_overlay(f, f.area(), &state))
3242            .unwrap();
3243    }
3244
3245    #[test]
3246    fn test_alert_config_serde_roundtrip() {
3247        let config = AlertConfig {
3248            price_min: Some(0.5),
3249            price_max: Some(2.0),
3250            whale_min_usd: Some(10_000.0),
3251            volume_spike_threshold_pct: Some(50.0),
3252        };
3253        let yaml = serde_yaml::to_string(&config).unwrap();
3254        let parsed: AlertConfig = serde_yaml::from_str(&yaml).unwrap();
3255        assert_eq!(parsed.price_min, Some(0.5));
3256        assert_eq!(parsed.price_max, Some(2.0));
3257        assert_eq!(parsed.whale_min_usd, Some(10_000.0));
3258        assert_eq!(parsed.volume_spike_threshold_pct, Some(50.0));
3259    }
3260
3261    #[test]
3262    fn test_ui_with_active_alerts() {
3263        let mut terminal = create_test_terminal();
3264        let mut state = create_populated_state();
3265        state.active_alerts.push(ActiveAlert {
3266            message: "⚠ Price below min".to_string(),
3267            triggered_at: Instant::now(),
3268        });
3269        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
3270        terminal.draw(|f| ui(f, &mut state)).unwrap();
3271    }
3272
3273    // ========================================================================
3274    // Phase 8: CSV Export tests
3275    // ========================================================================
3276
3277    #[test]
3278    fn test_export_config_default() {
3279        let config = ExportConfig::default();
3280        assert!(config.path.is_none());
3281    }
3282
3283    /// Helper to create an export in a temp directory, avoiding race conditions.
3284    fn start_export_in_temp(state: &mut MonitorState) -> PathBuf {
3285        use std::sync::atomic::{AtomicU64, Ordering};
3286        static COUNTER: AtomicU64 = AtomicU64::new(0);
3287        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
3288        let dir =
3289            std::env::temp_dir().join(format!("scope_test_export_{}_{}", std::process::id(), id));
3290        let _ = fs::create_dir_all(&dir);
3291        let filename = format!("{}_test_{}.csv", state.symbol, id);
3292        let path = dir.join(filename);
3293
3294        let mut file = fs::File::create(&path).expect("failed to create export test file");
3295        let header = "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
3296        file.write_all(header.as_bytes())
3297            .expect("failed to write header");
3298        drop(file); // Ensure file is flushed and closed
3299
3300        state.export_path = Some(path.clone());
3301        state.export_active = true;
3302        path
3303    }
3304
3305    #[test]
3306    fn test_export_start_creates_file() {
3307        let token_data = create_test_token_data();
3308        let mut state = MonitorState::new(&token_data, "ethereum");
3309        let path = start_export_in_temp(&mut state);
3310
3311        assert!(state.export_active);
3312        assert!(state.export_path.is_some());
3313        assert!(path.exists(), "Export file should exist");
3314
3315        // Cleanup
3316        let _ = std::fs::remove_file(&path);
3317    }
3318
3319    #[test]
3320    fn test_export_stop() {
3321        let token_data = create_test_token_data();
3322        let mut state = MonitorState::new(&token_data, "ethereum");
3323        let path = start_export_in_temp(&mut state);
3324        state.stop_export();
3325
3326        assert!(!state.export_active);
3327        assert!(state.export_path.is_none());
3328
3329        // Cleanup
3330        let _ = std::fs::remove_file(&path);
3331    }
3332
3333    #[test]
3334    fn test_export_toggle() {
3335        let token_data = create_test_token_data();
3336        let mut state = MonitorState::new(&token_data, "ethereum");
3337
3338        state.toggle_export();
3339        assert!(state.export_active);
3340        let path = state.export_path.clone().unwrap();
3341
3342        state.toggle_export();
3343        assert!(!state.export_active);
3344
3345        // Cleanup
3346        let _ = std::fs::remove_file(path);
3347    }
3348
3349    #[test]
3350    fn test_export_writes_csv_rows() {
3351        let token_data = create_test_token_data();
3352        let mut state = MonitorState::new(&token_data, "ethereum");
3353        let path = start_export_in_temp(&mut state);
3354
3355        // Simulate a few updates
3356        state.update(&token_data);
3357        state.update(&token_data);
3358
3359        let contents = std::fs::read_to_string(&path).unwrap();
3360        let lines: Vec<&str> = contents.lines().collect();
3361
3362        assert!(
3363            lines.len() >= 3,
3364            "Should have header + 2 data rows, got {}",
3365            lines.len()
3366        );
3367        assert!(lines[0].starts_with("timestamp,price_usd"));
3368
3369        // Cleanup
3370        state.stop_export();
3371        let _ = std::fs::remove_file(path);
3372    }
3373
3374    #[test]
3375    fn test_keybinding_e_toggles_export() {
3376        let token_data = create_test_token_data();
3377        let mut state = MonitorState::new(&token_data, "ethereum");
3378
3379        handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
3380        assert!(state.export_active);
3381        let path = state.export_path.clone().unwrap();
3382
3383        handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
3384        assert!(!state.export_active);
3385
3386        // Cleanup
3387        let _ = std::fs::remove_file(path);
3388    }
3389
3390    #[test]
3391    fn test_render_footer_with_export_active() {
3392        let mut terminal = create_test_terminal();
3393        let mut state = create_populated_state();
3394        state.export_active = true;
3395        terminal
3396            .draw(|f| render_footer(f, f.area(), &state))
3397            .unwrap();
3398    }
3399
3400    #[test]
3401    fn test_export_config_serde_roundtrip() {
3402        let config = ExportConfig {
3403            path: Some("./my-exports".to_string()),
3404        };
3405        let yaml = serde_yaml::to_string(&config).unwrap();
3406        let parsed: ExportConfig = serde_yaml::from_str(&yaml).unwrap();
3407        assert_eq!(parsed.path, Some("./my-exports".to_string()));
3408    }
3409
3410    // ========================================================================
3411    // Phase 9: Auto-Pause tests
3412    // ========================================================================
3413
3414    #[test]
3415    fn test_auto_pause_default_disabled() {
3416        let token_data = create_test_token_data();
3417        let state = MonitorState::new(&token_data, "ethereum");
3418        assert!(!state.auto_pause_on_input);
3419        assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
3420    }
3421
3422    #[test]
3423    fn test_auto_pause_blocks_refresh() {
3424        let token_data = create_test_token_data();
3425        let mut state = MonitorState::new(&token_data, "ethereum");
3426        state.auto_pause_on_input = true;
3427        state.refresh_rate = Duration::from_secs(1);
3428
3429        // Simulate fresh input
3430        state.last_input_at = Instant::now();
3431        state.last_update = Instant::now() - Duration::from_secs(10); // Long overdue
3432
3433        // Auto-pause should block refresh since we just had input
3434        assert!(!state.should_refresh());
3435    }
3436
3437    #[test]
3438    fn test_auto_pause_allows_refresh_after_timeout() {
3439        let token_data = create_test_token_data();
3440        let mut state = MonitorState::new(&token_data, "ethereum");
3441        state.auto_pause_on_input = true;
3442        state.refresh_rate = Duration::from_secs(1);
3443        state.auto_pause_timeout = Duration::from_millis(1); // Very short timeout
3444
3445        // Simulate old input (long ago)
3446        state.last_input_at = Instant::now() - Duration::from_secs(10);
3447        state.last_update = Instant::now() - Duration::from_secs(10);
3448
3449        // Should allow refresh since input was long ago
3450        assert!(state.should_refresh());
3451    }
3452
3453    #[test]
3454    fn test_auto_pause_disabled_does_not_block() {
3455        let token_data = create_test_token_data();
3456        let mut state = MonitorState::new(&token_data, "ethereum");
3457        state.auto_pause_on_input = false;
3458        state.refresh_rate = Duration::from_secs(1);
3459
3460        state.last_input_at = Instant::now(); // Fresh input
3461        state.last_update = Instant::now() - Duration::from_secs(10);
3462
3463        // Should still refresh because auto-pause is disabled
3464        assert!(state.should_refresh());
3465    }
3466
3467    #[test]
3468    fn test_is_auto_paused() {
3469        let token_data = create_test_token_data();
3470        let mut state = MonitorState::new(&token_data, "ethereum");
3471
3472        // Not auto-paused when disabled
3473        state.auto_pause_on_input = false;
3474        state.last_input_at = Instant::now();
3475        assert!(!state.is_auto_paused());
3476
3477        // Auto-paused when enabled and input is recent
3478        state.auto_pause_on_input = true;
3479        state.last_input_at = Instant::now();
3480        assert!(state.is_auto_paused());
3481
3482        // Not auto-paused when input is old
3483        state.last_input_at = Instant::now() - Duration::from_secs(10);
3484        assert!(!state.is_auto_paused());
3485    }
3486
3487    #[test]
3488    fn test_keybinding_shift_p_toggles_auto_pause() {
3489        let token_data = create_test_token_data();
3490        let mut state = MonitorState::new(&token_data, "ethereum");
3491        assert!(!state.auto_pause_on_input);
3492
3493        let shift_p = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
3494        handle_key_event_on_state(shift_p, &mut state);
3495        assert!(state.auto_pause_on_input);
3496
3497        handle_key_event_on_state(shift_p, &mut state);
3498        assert!(!state.auto_pause_on_input);
3499    }
3500
3501    #[test]
3502    fn test_keybinding_updates_last_input_at() {
3503        let token_data = create_test_token_data();
3504        let mut state = MonitorState::new(&token_data, "ethereum");
3505
3506        // Set last_input_at to the past
3507        state.last_input_at = Instant::now() - Duration::from_secs(60);
3508        let old_input = state.last_input_at;
3509
3510        // Any key event should update last_input_at
3511        handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
3512        assert!(state.last_input_at > old_input);
3513    }
3514
3515    #[test]
3516    fn test_render_footer_auto_paused() {
3517        let mut terminal = create_test_terminal();
3518        let mut state = create_populated_state();
3519        state.auto_pause_on_input = true;
3520        state.last_input_at = Instant::now(); // Recent input -> auto-paused
3521        terminal
3522            .draw(|f| render_footer(f, f.area(), &state))
3523            .unwrap();
3524    }
3525
3526    #[test]
3527    fn test_config_auto_pause_applied() {
3528        let mut state = create_populated_state();
3529        let config = MonitorConfig {
3530            auto_pause_on_input: true,
3531            ..MonitorConfig::default()
3532        };
3533        state.apply_config(&config);
3534        assert!(state.auto_pause_on_input);
3535    }
3536
3537    // ========================================================================
3538    // Combined full-UI tests for new features
3539    // ========================================================================
3540
3541    #[test]
3542    fn test_ui_render_all_layouts_with_alerts_and_export() {
3543        for preset in &[
3544            LayoutPreset::Dashboard,
3545            LayoutPreset::ChartFocus,
3546            LayoutPreset::Feed,
3547            LayoutPreset::Compact,
3548        ] {
3549            let mut terminal = create_test_terminal();
3550            let mut state = create_populated_state();
3551            state.layout = *preset;
3552            state.auto_layout = false;
3553            state.export_active = true;
3554            state.active_alerts.push(ActiveAlert {
3555                message: "⚠ Test alert".to_string(),
3556                triggered_at: Instant::now(),
3557            });
3558            terminal.draw(|f| ui(f, &mut state)).unwrap();
3559        }
3560    }
3561
3562    #[test]
3563    fn test_ui_render_with_liquidity_data() {
3564        let mut terminal = create_test_terminal();
3565        let mut state = create_populated_state();
3566        state.liquidity_pairs = vec![
3567            ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
3568            ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
3569        ];
3570        terminal.draw(|f| ui(f, &mut state)).unwrap();
3571    }
3572
3573    #[test]
3574    fn test_monitor_config_full_serde_roundtrip() {
3575        let config = MonitorConfig {
3576            layout: LayoutPreset::Dashboard,
3577            refresh_seconds: 10,
3578            widgets: WidgetVisibility::default(),
3579            scale: ScaleMode::Log,
3580            color_scheme: ColorScheme::BlueOrange,
3581            alerts: AlertConfig {
3582                price_min: Some(0.5),
3583                price_max: Some(10.0),
3584                whale_min_usd: Some(50_000.0),
3585                volume_spike_threshold_pct: Some(100.0),
3586            },
3587            export: ExportConfig {
3588                path: Some("./exports".to_string()),
3589            },
3590            auto_pause_on_input: true,
3591            venue: Some("binance".to_string()),
3592        };
3593
3594        let yaml = serde_yaml::to_string(&config).unwrap();
3595        let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
3596        assert_eq!(parsed.layout, LayoutPreset::Dashboard);
3597        assert_eq!(parsed.refresh_seconds, 10);
3598        assert_eq!(parsed.venue, Some("binance".to_string()));
3599        assert_eq!(parsed.alerts.price_min, Some(0.5));
3600        assert_eq!(parsed.alerts.price_max, Some(10.0));
3601        assert_eq!(parsed.export.path, Some("./exports".to_string()));
3602        assert!(parsed.auto_pause_on_input);
3603    }
3604
3605    #[test]
3606    fn test_monitor_config_serde_defaults_for_new_fields() {
3607        // Only specify old fields — new fields should default
3608        let yaml = r#"
3609layout: dashboard
3610refresh_seconds: 5
3611"#;
3612        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
3613        assert!(config.alerts.price_min.is_none());
3614        assert!(config.export.path.is_none());
3615        assert!(!config.auto_pause_on_input);
3616    }
3617
3618    #[test]
3619    fn test_quit_stops_export() {
3620        let token_data = create_test_token_data();
3621        let mut state = MonitorState::new(&token_data, "ethereum");
3622        let path = start_export_in_temp(&mut state);
3623
3624        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('q')), &mut state);
3625        assert!(exit);
3626        assert!(!state.export_active);
3627
3628        // Cleanup
3629        let _ = std::fs::remove_file(path);
3630    }
3631
3632    #[test]
3633    fn test_monitor_state_new_has_alert_export_autopause_fields() {
3634        let token_data = create_test_token_data();
3635        let state = MonitorState::new(&token_data, "ethereum");
3636
3637        // Alert fields
3638        assert!(state.active_alerts.is_empty());
3639        assert!(state.alert_flash_until.is_none());
3640        assert!(state.alerts.price_min.is_none());
3641
3642        // Export fields
3643        assert!(!state.export_active);
3644        assert!(state.export_path.is_none());
3645
3646        // Auto-pause fields
3647        assert!(!state.auto_pause_on_input);
3648        assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
3649    }
3650
3651    // ========================================================================
3652    // Coverage gap: Serde round-trip tests for enums
3653    // ========================================================================
3654
3655    #[test]
3656    fn test_scale_mode_serde_roundtrip() {
3657        for mode in &[ScaleMode::Linear, ScaleMode::Log] {
3658            let yaml = serde_yaml::to_string(mode).unwrap();
3659            let parsed: ScaleMode = serde_yaml::from_str(&yaml).unwrap();
3660            assert_eq!(&parsed, mode);
3661        }
3662    }
3663
3664    #[test]
3665    fn test_scale_mode_serde_kebab_case() {
3666        let parsed: ScaleMode = serde_yaml::from_str("linear").unwrap();
3667        assert_eq!(parsed, ScaleMode::Linear);
3668        let parsed: ScaleMode = serde_yaml::from_str("log").unwrap();
3669        assert_eq!(parsed, ScaleMode::Log);
3670    }
3671
3672    #[test]
3673    fn test_scale_mode_toggle() {
3674        assert_eq!(ScaleMode::Linear.toggle(), ScaleMode::Log);
3675        assert_eq!(ScaleMode::Log.toggle(), ScaleMode::Linear);
3676    }
3677
3678    #[test]
3679    fn test_scale_mode_label() {
3680        assert_eq!(ScaleMode::Linear.label(), "Lin");
3681        assert_eq!(ScaleMode::Log.label(), "Log");
3682    }
3683
3684    #[test]
3685    fn test_color_scheme_serde_roundtrip() {
3686        for scheme in &[
3687            ColorScheme::GreenRed,
3688            ColorScheme::BlueOrange,
3689            ColorScheme::Monochrome,
3690        ] {
3691            let yaml = serde_yaml::to_string(scheme).unwrap();
3692            let parsed: ColorScheme = serde_yaml::from_str(&yaml).unwrap();
3693            assert_eq!(&parsed, scheme);
3694        }
3695    }
3696
3697    #[test]
3698    fn test_color_scheme_serde_kebab_case() {
3699        let parsed: ColorScheme = serde_yaml::from_str("green-red").unwrap();
3700        assert_eq!(parsed, ColorScheme::GreenRed);
3701        let parsed: ColorScheme = serde_yaml::from_str("blue-orange").unwrap();
3702        assert_eq!(parsed, ColorScheme::BlueOrange);
3703        let parsed: ColorScheme = serde_yaml::from_str("monochrome").unwrap();
3704        assert_eq!(parsed, ColorScheme::Monochrome);
3705    }
3706
3707    #[test]
3708    fn test_color_scheme_cycle() {
3709        assert_eq!(ColorScheme::GreenRed.next(), ColorScheme::BlueOrange);
3710        assert_eq!(ColorScheme::BlueOrange.next(), ColorScheme::Monochrome);
3711        assert_eq!(ColorScheme::Monochrome.next(), ColorScheme::GreenRed);
3712    }
3713
3714    #[test]
3715    fn test_color_scheme_label() {
3716        assert_eq!(ColorScheme::GreenRed.label(), "G/R");
3717        assert_eq!(ColorScheme::BlueOrange.label(), "B/O");
3718        assert_eq!(ColorScheme::Monochrome.label(), "Mono");
3719    }
3720
3721    #[test]
3722    fn test_color_palette_fields_populated() {
3723        // Verify each palette has distinct meaningful values
3724        for scheme in &[
3725            ColorScheme::GreenRed,
3726            ColorScheme::BlueOrange,
3727            ColorScheme::Monochrome,
3728        ] {
3729            let pal = scheme.palette();
3730            // up and down colors should differ (visually distinct)
3731            assert_ne!(
3732                format!("{:?}", pal.up),
3733                format!("{:?}", pal.down),
3734                "Up/down should differ for {:?}",
3735                scheme
3736            );
3737        }
3738    }
3739
3740    #[test]
3741    fn test_layout_preset_serde_roundtrip() {
3742        for preset in &[
3743            LayoutPreset::Dashboard,
3744            LayoutPreset::ChartFocus,
3745            LayoutPreset::Feed,
3746            LayoutPreset::Compact,
3747        ] {
3748            let yaml = serde_yaml::to_string(preset).unwrap();
3749            let parsed: LayoutPreset = serde_yaml::from_str(&yaml).unwrap();
3750            assert_eq!(&parsed, preset);
3751        }
3752    }
3753
3754    #[test]
3755    fn test_layout_preset_serde_kebab_case() {
3756        let parsed: LayoutPreset = serde_yaml::from_str("dashboard").unwrap();
3757        assert_eq!(parsed, LayoutPreset::Dashboard);
3758        let parsed: LayoutPreset = serde_yaml::from_str("chart-focus").unwrap();
3759        assert_eq!(parsed, LayoutPreset::ChartFocus);
3760        let parsed: LayoutPreset = serde_yaml::from_str("feed").unwrap();
3761        assert_eq!(parsed, LayoutPreset::Feed);
3762        let parsed: LayoutPreset = serde_yaml::from_str("compact").unwrap();
3763        assert_eq!(parsed, LayoutPreset::Compact);
3764    }
3765
3766    #[test]
3767    fn test_widget_visibility_serde_roundtrip() {
3768        let vis = WidgetVisibility {
3769            price_chart: false,
3770            volume_chart: true,
3771            buy_sell_pressure: false,
3772            metrics_panel: true,
3773            activity_log: false,
3774            holder_count: false,
3775            liquidity_depth: true,
3776        };
3777        let yaml = serde_yaml::to_string(&vis).unwrap();
3778        let parsed: WidgetVisibility = serde_yaml::from_str(&yaml).unwrap();
3779        assert!(!parsed.price_chart);
3780        assert!(parsed.volume_chart);
3781        assert!(!parsed.buy_sell_pressure);
3782        assert!(parsed.metrics_panel);
3783        assert!(!parsed.activity_log);
3784        assert!(!parsed.holder_count);
3785        assert!(parsed.liquidity_depth);
3786    }
3787
3788    #[test]
3789    fn test_data_point_serde_roundtrip() {
3790        let dp = DataPoint {
3791            timestamp: 1700000000.5,
3792            value: 42.123456,
3793            is_real: true,
3794        };
3795        let json = serde_json::to_string(&dp).unwrap();
3796        let parsed: DataPoint = serde_json::from_str(&json).unwrap();
3797        assert!((parsed.timestamp - dp.timestamp).abs() < 0.001);
3798        assert!((parsed.value - dp.value).abs() < 0.001);
3799        assert_eq!(parsed.is_real, dp.is_real);
3800    }
3801
3802    // ========================================================================
3803    // Coverage gap: Key handler tests for scale and color scheme
3804    // ========================================================================
3805
3806    #[test]
3807    fn test_handle_key_scale_toggle_s() {
3808        let token_data = create_test_token_data();
3809        let mut state = MonitorState::new(&token_data, "ethereum");
3810        assert_eq!(state.scale_mode, ScaleMode::Linear);
3811
3812        handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
3813        assert_eq!(state.scale_mode, ScaleMode::Log);
3814
3815        handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
3816        assert_eq!(state.scale_mode, ScaleMode::Linear);
3817    }
3818
3819    #[test]
3820    fn test_handle_key_color_scheme_cycle_slash() {
3821        let token_data = create_test_token_data();
3822        let mut state = MonitorState::new(&token_data, "ethereum");
3823        assert_eq!(state.color_scheme, ColorScheme::GreenRed);
3824
3825        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
3826        assert_eq!(state.color_scheme, ColorScheme::BlueOrange);
3827
3828        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
3829        assert_eq!(state.color_scheme, ColorScheme::Monochrome);
3830
3831        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
3832        assert_eq!(state.color_scheme, ColorScheme::GreenRed);
3833    }
3834
3835    // ========================================================================
3836    // Coverage gap: Volume profile chart render tests
3837    // ========================================================================
3838
3839    #[test]
3840    fn test_render_volume_profile_chart_no_panic() {
3841        let mut terminal = create_test_terminal();
3842        let state = create_populated_state();
3843        terminal
3844            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
3845            .unwrap();
3846    }
3847
3848    #[test]
3849    fn test_render_volume_profile_chart_empty_data() {
3850        let mut terminal = create_test_terminal();
3851        let token_data = create_test_token_data();
3852        let mut state = MonitorState::new(&token_data, "ethereum");
3853        state.price_history.clear();
3854        state.volume_history.clear();
3855        terminal
3856            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
3857            .unwrap();
3858    }
3859
3860    #[test]
3861    fn test_render_volume_profile_chart_single_price() {
3862        // When all prices are identical, there's no range -> "no price range" path
3863        let mut terminal = create_test_terminal();
3864        let mut token_data = create_test_token_data();
3865        token_data.price_usd = 1.0;
3866        let mut state = MonitorState::new(&token_data, "ethereum");
3867        // Clear and add identical-price data points
3868        state.price_history.clear();
3869        state.volume_history.clear();
3870        let now = chrono::Utc::now().timestamp() as f64;
3871        for i in 0..5 {
3872            state.price_history.push_back(DataPoint {
3873                timestamp: now - (5.0 - i as f64) * 60.0,
3874                value: 1.0, // all same price
3875                is_real: true,
3876            });
3877            state.volume_history.push_back(DataPoint {
3878                timestamp: now - (5.0 - i as f64) * 60.0,
3879                value: 1000.0,
3880                is_real: true,
3881            });
3882        }
3883        terminal
3884            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
3885            .unwrap();
3886    }
3887
3888    #[test]
3889    fn test_render_volume_profile_chart_narrow_terminal() {
3890        let backend = TestBackend::new(30, 15);
3891        let mut terminal = Terminal::new(backend).unwrap();
3892        let state = create_populated_state();
3893        terminal
3894            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
3895            .unwrap();
3896    }
3897
3898    // ========================================================================
3899    // Coverage gap: Log scale rendering tests
3900    // ========================================================================
3901
3902    #[test]
3903    fn test_render_price_chart_log_scale() {
3904        let mut terminal = create_test_terminal();
3905        let mut state = create_populated_state();
3906        state.scale_mode = ScaleMode::Log;
3907        terminal
3908            .draw(|f| render_price_chart(f, f.area(), &state))
3909            .unwrap();
3910    }
3911
3912    #[test]
3913    fn test_render_candlestick_chart_log_scale() {
3914        let mut terminal = create_test_terminal();
3915        let mut state = create_populated_state();
3916        state.scale_mode = ScaleMode::Log;
3917        state.chart_mode = ChartMode::Candlestick;
3918        terminal
3919            .draw(|f| render_candlestick_chart(f, f.area(), &state))
3920            .unwrap();
3921    }
3922
3923    #[test]
3924    fn test_render_price_chart_log_scale_zero_price() {
3925        // Verify log scale handles zero/near-zero prices safely
3926        let mut terminal = create_test_terminal();
3927        let mut token_data = create_test_token_data();
3928        token_data.price_usd = 0.0001;
3929        let mut state = MonitorState::new(&token_data, "ethereum");
3930        state.scale_mode = ScaleMode::Log;
3931        for i in 0..10 {
3932            let mut data = token_data.clone();
3933            data.price_usd = 0.0001 + (i as f64 * 0.00001);
3934            state.update(&data);
3935        }
3936        terminal
3937            .draw(|f| render_price_chart(f, f.area(), &state))
3938            .unwrap();
3939    }
3940
3941    // ========================================================================
3942    // Coverage gap: Color scheme rendering tests
3943    // ========================================================================
3944
3945    #[test]
3946    fn test_render_ui_with_all_color_schemes() {
3947        for scheme in &[
3948            ColorScheme::GreenRed,
3949            ColorScheme::BlueOrange,
3950            ColorScheme::Monochrome,
3951        ] {
3952            let mut terminal = create_test_terminal();
3953            let mut state = create_populated_state();
3954            state.color_scheme = *scheme;
3955            terminal.draw(|f| ui(f, &mut state)).unwrap();
3956        }
3957    }
3958
3959    #[test]
3960    fn test_render_volume_chart_all_color_schemes() {
3961        for scheme in &[
3962            ColorScheme::GreenRed,
3963            ColorScheme::BlueOrange,
3964            ColorScheme::Monochrome,
3965        ] {
3966            let mut terminal = create_test_terminal();
3967            let mut state = create_populated_state();
3968            state.color_scheme = *scheme;
3969            terminal
3970                .draw(|f| render_volume_chart(f, f.area(), &state))
3971                .unwrap();
3972        }
3973    }
3974
3975    // ========================================================================
3976    // Coverage gap: Activity feed dedicated render tests
3977    // ========================================================================
3978
3979    #[test]
3980    fn test_render_activity_feed_no_panic() {
3981        let mut terminal = create_test_terminal();
3982        let mut state = create_populated_state();
3983        for i in 0..5 {
3984            state.log_messages.push_back(format!("Event {}", i));
3985        }
3986        terminal
3987            .draw(|f| render_activity_feed(f, f.area(), &mut state))
3988            .unwrap();
3989    }
3990
3991    #[test]
3992    fn test_render_activity_feed_empty_log() {
3993        let mut terminal = create_test_terminal();
3994        let token_data = create_test_token_data();
3995        let mut state = MonitorState::new(&token_data, "ethereum");
3996        state.log_messages.clear();
3997        terminal
3998            .draw(|f| render_activity_feed(f, f.area(), &mut state))
3999            .unwrap();
4000    }
4001
4002    #[test]
4003    fn test_render_activity_feed_with_selection() {
4004        let mut terminal = create_test_terminal();
4005        let mut state = create_populated_state();
4006        for i in 0..10 {
4007            state.log_messages.push_back(format!("Event {}", i));
4008        }
4009        state.scroll_log_down();
4010        state.scroll_log_down();
4011        state.scroll_log_down();
4012        terminal
4013            .draw(|f| render_activity_feed(f, f.area(), &mut state))
4014            .unwrap();
4015    }
4016
4017    // ========================================================================
4018    // Coverage gap: Alert edge cases
4019    // ========================================================================
4020
4021    #[test]
4022    fn test_alert_whale_zero_transactions() {
4023        let mut token_data = create_test_token_data();
4024        token_data.total_buys_24h = 0;
4025        token_data.total_sells_24h = 0;
4026        let mut state = MonitorState::new(&token_data, "ethereum");
4027        state.alerts.whale_min_usd = Some(100.0);
4028        state.update(&token_data);
4029        // With zero total txs, whale detection should NOT fire
4030        let whale_alerts: Vec<_> = state
4031            .active_alerts
4032            .iter()
4033            .filter(|a| a.message.contains("whale") || a.message.contains("🐋"))
4034            .collect();
4035        assert!(
4036            whale_alerts.is_empty(),
4037            "Whale alert should not fire with zero transactions"
4038        );
4039    }
4040
4041    #[test]
4042    fn test_alert_multiple_simultaneous() {
4043        let mut token_data = create_test_token_data();
4044        token_data.price_usd = 0.1; // below min
4045        let mut state = MonitorState::new(&token_data, "ethereum");
4046        state.alerts.price_min = Some(0.5); // will fire: price 0.1 < 0.5
4047        state.alerts.price_max = Some(0.05); // will fire: price 0.1 > 0.05
4048        state.alerts.volume_spike_threshold_pct = Some(1.0);
4049        state.volume_avg = 100.0; // volume_24h 1M vs avg 100 => huge spike
4050
4051        state.update(&token_data);
4052        // Should have multiple alerts
4053        assert!(
4054            state.active_alerts.len() >= 2,
4055            "Expected multiple alerts, got {}",
4056            state.active_alerts.len()
4057        );
4058    }
4059
4060    #[test]
4061    fn test_alert_clears_on_next_update() {
4062        let token_data = create_test_token_data();
4063        let mut state = MonitorState::new(&token_data, "ethereum");
4064        state.alerts.price_min = Some(2.0); // price 1.0 < 2.0 -> fires
4065        state.update(&token_data);
4066        assert!(!state.active_alerts.is_empty());
4067
4068        // Update with price above min -> should clear
4069        let mut above_min = token_data.clone();
4070        above_min.price_usd = 3.0;
4071        state.alerts.price_min = Some(2.0);
4072        state.update(&above_min);
4073        // check_alerts clears alerts each time and re-evaluates
4074        let price_min_alerts: Vec<_> = state
4075            .active_alerts
4076            .iter()
4077            .filter(|a| a.message.contains("below min"))
4078            .collect();
4079        assert!(
4080            price_min_alerts.is_empty(),
4081            "Price-min alert should clear when price goes above min"
4082        );
4083    }
4084
4085    #[test]
4086    fn test_render_alert_overlay_multiple_alerts() {
4087        let mut terminal = create_test_terminal();
4088        let mut state = create_populated_state();
4089        state.active_alerts.push(ActiveAlert {
4090            message: "⚠ Price below min".to_string(),
4091            triggered_at: Instant::now(),
4092        });
4093        state.active_alerts.push(ActiveAlert {
4094            message: "🐋 Whale detected".to_string(),
4095            triggered_at: Instant::now(),
4096        });
4097        state.active_alerts.push(ActiveAlert {
4098            message: "⚠ Volume spike".to_string(),
4099            triggered_at: Instant::now(),
4100        });
4101        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
4102        terminal
4103            .draw(|f| render_alert_overlay(f, f.area(), &state))
4104            .unwrap();
4105    }
4106
4107    #[test]
4108    fn test_render_alert_overlay_flash_expired() {
4109        let mut terminal = create_test_terminal();
4110        let mut state = create_populated_state();
4111        state.active_alerts.push(ActiveAlert {
4112            message: "⚠ Test".to_string(),
4113            triggered_at: Instant::now(),
4114        });
4115        // Flash timer already expired
4116        state.alert_flash_until = Some(Instant::now() - Duration::from_secs(5));
4117        terminal
4118            .draw(|f| render_alert_overlay(f, f.area(), &state))
4119            .unwrap();
4120    }
4121
4122    // ========================================================================
4123    // Coverage gap: Liquidity depth edge cases
4124    // ========================================================================
4125
4126    #[test]
4127    fn test_render_liquidity_depth_many_pairs() {
4128        let mut terminal = create_test_terminal();
4129        let mut state = create_populated_state();
4130        // Add many pairs to test height-limiting
4131        for i in 0..20 {
4132            state.liquidity_pairs.push((
4133                format!("TEST/TOKEN{} (DEX{})", i, i),
4134                (100_000.0 + i as f64 * 50_000.0),
4135            ));
4136        }
4137        terminal
4138            .draw(|f| render_liquidity_depth(f, f.area(), &state))
4139            .unwrap();
4140    }
4141
4142    #[test]
4143    fn test_render_liquidity_depth_narrow_terminal() {
4144        let backend = TestBackend::new(30, 10);
4145        let mut terminal = Terminal::new(backend).unwrap();
4146        let mut state = create_populated_state();
4147        state.liquidity_pairs = vec![
4148            ("TEST/WETH (Uniswap)".to_string(), 500_000.0),
4149            ("TEST/USDC (Sushi)".to_string(), 100_000.0),
4150        ];
4151        terminal
4152            .draw(|f| render_liquidity_depth(f, f.area(), &state))
4153            .unwrap();
4154    }
4155
4156    // ========================================================================
4157    // Coverage gap: Metrics panel edge cases
4158    // ========================================================================
4159
4160    #[test]
4161    fn test_render_metrics_panel_holder_count_disabled() {
4162        let mut terminal = create_test_terminal();
4163        let mut state = create_populated_state();
4164        state.holder_count = Some(42_000);
4165        state.widgets.holder_count = false; // disabled
4166        terminal
4167            .draw(|f| render_metrics_panel(f, f.area(), &state))
4168            .unwrap();
4169    }
4170
4171    #[test]
4172    fn test_render_metrics_panel_sparkline_single_point() {
4173        let mut terminal = create_test_terminal();
4174        let mut token_data = create_test_token_data();
4175        token_data.price_usd = 1.0;
4176        let mut state = MonitorState::new(&token_data, "ethereum");
4177        state.price_history.clear();
4178        state.price_history.push_back(DataPoint {
4179            timestamp: 1.0,
4180            value: 1.0,
4181            is_real: true,
4182        });
4183        terminal
4184            .draw(|f| render_metrics_panel(f, f.area(), &state))
4185            .unwrap();
4186    }
4187
4188    // ========================================================================
4189    // Coverage gap: Buy/sell gauge edge cases
4190    // ========================================================================
4191
4192    #[test]
4193    fn test_render_buy_sell_gauge_tiny_area() {
4194        // Render in a very small area to test zero width/height paths
4195        let backend = TestBackend::new(5, 3);
4196        let mut terminal = Terminal::new(backend).unwrap();
4197        let mut state = create_populated_state();
4198        terminal
4199            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
4200            .unwrap();
4201    }
4202
4203    // ========================================================================
4204    // Coverage gap: Log message queue overflow
4205    // ========================================================================
4206
4207    #[test]
4208    fn test_log_message_queue_overflow() {
4209        let token_data = create_test_token_data();
4210        let mut state = MonitorState::new(&token_data, "ethereum");
4211        // Add more than 10 messages (queue capacity)
4212        for i in 0..20 {
4213            state.toggle_pause(); // each toggle logs a message
4214            let _ = i;
4215        }
4216        assert!(
4217            state.log_messages.len() <= 10,
4218            "Log queue should cap at 10, got {}",
4219            state.log_messages.len()
4220        );
4221    }
4222
4223    // ========================================================================
4224    // Coverage gap: Export CSV row content verification
4225    // ========================================================================
4226
4227    #[test]
4228    fn test_export_writes_csv_row_content_format() {
4229        let token_data = create_test_token_data();
4230        let mut state = MonitorState::new(&token_data, "ethereum");
4231        let path = start_export_in_temp(&mut state);
4232
4233        state.update(&token_data);
4234
4235        let contents = std::fs::read_to_string(&path).unwrap();
4236        let lines: Vec<&str> = contents.lines().collect();
4237        assert!(lines.len() >= 2);
4238
4239        // Verify header
4240        assert_eq!(
4241            lines[0],
4242            "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap"
4243        );
4244
4245        // Verify data row has correct number of columns
4246        let data_cols: Vec<&str> = lines[1].split(',').collect();
4247        assert_eq!(
4248            data_cols.len(),
4249            7,
4250            "Expected 7 CSV columns, got {}",
4251            data_cols.len()
4252        );
4253
4254        // Verify timestamp format (ISO 8601)
4255        assert!(data_cols[0].contains('T'));
4256        assert!(data_cols[0].ends_with('Z'));
4257
4258        // Cleanup
4259        state.stop_export();
4260        let _ = std::fs::remove_file(path);
4261    }
4262
4263    #[test]
4264    fn test_export_writes_csv_row_market_cap_none() {
4265        let mut token_data = create_test_token_data();
4266        token_data.market_cap = None;
4267        let mut state = MonitorState::new(&token_data, "ethereum");
4268        let path = start_export_in_temp(&mut state);
4269
4270        state.update(&token_data);
4271
4272        let contents = std::fs::read_to_string(&path).unwrap();
4273        let lines: Vec<&str> = contents.lines().collect();
4274        assert!(lines.len() >= 2);
4275
4276        // Last column should be empty when market_cap is None
4277        let data_cols: Vec<&str> = lines[1].split(',').collect();
4278        assert_eq!(data_cols.len(), 7);
4279        assert!(
4280            data_cols[6].is_empty(),
4281            "Market cap column should be empty when None"
4282        );
4283
4284        // Cleanup
4285        state.stop_export();
4286        let _ = std::fs::remove_file(path);
4287    }
4288
4289    // ========================================================================
4290    // Coverage gap: Full UI render with log scale + all chart modes
4291    // ========================================================================
4292
4293    #[test]
4294    fn test_ui_render_log_scale_all_chart_modes() {
4295        for mode in &[
4296            ChartMode::Line,
4297            ChartMode::Candlestick,
4298            ChartMode::VolumeProfile,
4299        ] {
4300            let mut terminal = create_test_terminal();
4301            let mut state = create_populated_state();
4302            state.scale_mode = ScaleMode::Log;
4303            state.chart_mode = *mode;
4304            terminal.draw(|f| ui(f, &mut state)).unwrap();
4305        }
4306    }
4307
4308    // ========================================================================
4309    // Coverage gap: Footer rendering edge cases
4310    // ========================================================================
4311
4312    #[test]
4313    fn test_render_footer_widget_toggle_mode_active() {
4314        let mut terminal = create_test_terminal();
4315        let mut state = create_populated_state();
4316        state.widget_toggle_mode = true;
4317        terminal
4318            .draw(|f| render_footer(f, f.area(), &state))
4319            .unwrap();
4320    }
4321
4322    #[test]
4323    fn test_render_footer_all_status_indicators() {
4324        // Test with export + auto-pause + alerts simultaneously
4325        let mut terminal = create_test_terminal();
4326        let mut state = create_populated_state();
4327        state.export_active = true;
4328        state.auto_pause_on_input = true;
4329        state.last_input_at = Instant::now(); // triggers auto-paused display
4330        terminal
4331            .draw(|f| render_footer(f, f.area(), &state))
4332            .unwrap();
4333    }
4334
4335    // ========================================================================
4336    // Coverage gap: Synthetic data generation edge cases
4337    // ========================================================================
4338
4339    #[test]
4340    fn test_generate_synthetic_price_history_zero_price() {
4341        let mut token_data = create_test_token_data();
4342        token_data.price_usd = 0.0;
4343        token_data.price_change_1h = 0.0;
4344        token_data.price_change_6h = 0.0;
4345        token_data.price_change_24h = 0.0;
4346        let state = MonitorState::new(&token_data, "ethereum");
4347        // Should not panic with zero prices
4348        assert!(!state.price_history.is_empty());
4349    }
4350
4351    #[test]
4352    fn test_generate_synthetic_volume_history_zero_volume() {
4353        let mut token_data = create_test_token_data();
4354        token_data.volume_24h = 0.0;
4355        token_data.volume_6h = 0.0;
4356        token_data.volume_1h = 0.0;
4357        let state = MonitorState::new(&token_data, "ethereum");
4358        assert!(!state.volume_history.is_empty());
4359    }
4360
4361    #[test]
4362    fn test_generate_synthetic_order_book() {
4363        let pairs = vec![scope::chains::DexPair {
4364            dex_name: "Uniswap V3".to_string(),
4365            pair_address: "0xabc".to_string(),
4366            base_token: "DAI".to_string(),
4367            quote_token: "USDT".to_string(),
4368            price_usd: 1.0,
4369            volume_24h: 50_000.0,
4370            liquidity_usd: 200_000.0,
4371            price_change_24h: 0.1,
4372            buys_24h: 100,
4373            sells_24h: 90,
4374            buys_6h: 30,
4375            sells_6h: 25,
4376            buys_1h: 10,
4377            sells_1h: 8,
4378            pair_created_at: None,
4379            url: None,
4380        }];
4381        let book = MonitorState::generate_synthetic_order_book(&pairs, "DAI", 1.0, 200_000.0);
4382        assert!(book.is_some());
4383        let book = book.unwrap();
4384        assert_eq!(book.pair, "DAI/USDT");
4385        assert!(!book.asks.is_empty());
4386        assert!(!book.bids.is_empty());
4387        // Asks should be ascending
4388        for w in book.asks.windows(2) {
4389            assert!(w[0].price <= w[1].price);
4390        }
4391        // Bids should be descending
4392        for w in book.bids.windows(2) {
4393            assert!(w[0].price >= w[1].price);
4394        }
4395    }
4396
4397    #[test]
4398    fn test_generate_synthetic_order_book_zero_price() {
4399        let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 0.0, 100_000.0);
4400        assert!(book.is_none());
4401    }
4402
4403    #[test]
4404    fn test_generate_synthetic_order_book_zero_liquidity() {
4405        let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 1.0, 0.0);
4406        assert!(book.is_none());
4407    }
4408
4409    // ========================================================================
4410    // Coverage gap: Auto-pause with custom timeout
4411    // ========================================================================
4412
4413    #[test]
4414    fn test_auto_pause_custom_timeout() {
4415        let token_data = create_test_token_data();
4416        let mut state = MonitorState::new(&token_data, "ethereum");
4417        state.auto_pause_on_input = true;
4418        state.auto_pause_timeout = Duration::from_secs(10);
4419        state.refresh_rate = Duration::from_secs(1);
4420
4421        // Fresh input with long timeout -> still auto-paused
4422        state.last_input_at = Instant::now();
4423        state.last_update = Instant::now() - Duration::from_secs(5);
4424        assert!(!state.should_refresh()); // within 10s timeout
4425        assert!(state.is_auto_paused());
4426    }
4427
4428    // ========================================================================
4429    // Coverage gap: Price chart with stablecoin flat range
4430    // ========================================================================
4431
4432    #[test]
4433    fn test_render_price_chart_stablecoin_flat_range() {
4434        let mut terminal = create_test_terminal();
4435        let mut token_data = create_test_token_data();
4436        token_data.price_usd = 1.0;
4437        let mut state = MonitorState::new(&token_data, "ethereum");
4438        // Add many points at nearly identical prices (stablecoin)
4439        for i in 0..20 {
4440            let mut data = token_data.clone();
4441            data.price_usd = 1.0 + (i as f64 * 0.000001); // micro variation
4442            state.update(&data);
4443        }
4444        terminal
4445            .draw(|f| render_price_chart(f, f.area(), &state))
4446            .unwrap();
4447    }
4448
4449    // ========================================================================
4450    // Coverage gap: Cache load edge cases
4451    // ========================================================================
4452
4453    #[test]
4454    fn test_load_cache_corrupted_json() {
4455        let path = MonitorState::cache_path("0xCORRUPTED_TEST", "test_chain");
4456        // Write invalid JSON
4457        let _ = std::fs::write(&path, "not valid json {{{");
4458        let cached = MonitorState::load_cache("0xCORRUPTED_TEST", "test_chain");
4459        assert!(cached.is_none(), "Corrupted JSON should return None");
4460        let _ = std::fs::remove_file(path);
4461    }
4462
4463    #[test]
4464    fn test_load_cache_wrong_token() {
4465        let token_data = create_test_token_data();
4466        let state = MonitorState::new(&token_data, "ethereum");
4467        state.save_cache();
4468
4469        // Try to load with different token address
4470        let cached = MonitorState::load_cache("0xDIFFERENT_ADDRESS", "ethereum");
4471        assert!(
4472            cached.is_none(),
4473            "Loading cache with wrong token address should return None"
4474        );
4475
4476        // Cleanup
4477        let path = MonitorState::cache_path(&token_data.address, "ethereum");
4478        let _ = std::fs::remove_file(path);
4479    }
4480
4481    // ========================================================================
4482    // Integration tests: Mock types and MonitorApp constructor
4483    // ========================================================================
4484
4485    use scope::chains::dex::TokenSearchResult;
4486
4487    /// Mock DEX data source for integration testing.
4488    struct MockDexDataSource {
4489        /// Data returned by `get_token_data`. If `Err`, simulates an API failure.
4490        token_data_result: std::sync::Mutex<Result<DexTokenData>>,
4491    }
4492
4493    impl MockDexDataSource {
4494        fn new(data: DexTokenData) -> Self {
4495            Self {
4496                token_data_result: std::sync::Mutex::new(Ok(data)),
4497            }
4498        }
4499
4500        fn failing(msg: &str) -> Self {
4501            Self {
4502                token_data_result: std::sync::Mutex::new(Err(ScopeError::Api(msg.to_string()))),
4503            }
4504        }
4505    }
4506
4507    #[async_trait::async_trait]
4508    impl DexDataSource for MockDexDataSource {
4509        async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
4510            self.token_data_result
4511                .lock()
4512                .unwrap()
4513                .as_ref()
4514                .ok()
4515                .map(|d| d.price_usd)
4516        }
4517
4518        async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
4519            Some(2000.0)
4520        }
4521
4522        async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
4523            let guard = self.token_data_result.lock().unwrap();
4524            match &*guard {
4525                Ok(data) => Ok(data.clone()),
4526                Err(e) => Err(ScopeError::Api(e.to_string())),
4527            }
4528        }
4529
4530        async fn search_tokens(
4531            &self,
4532            _query: &str,
4533            _chain: Option<&str>,
4534        ) -> Result<Vec<TokenSearchResult>> {
4535            Ok(vec![])
4536        }
4537    }
4538
4539    /// Mock chain client for integration testing.
4540    struct MockChainClient {
4541        holder_count: u64,
4542    }
4543
4544    impl MockChainClient {
4545        fn new(holder_count: u64) -> Self {
4546            Self { holder_count }
4547        }
4548    }
4549
4550    #[async_trait::async_trait]
4551    impl ChainClient for MockChainClient {
4552        fn chain_name(&self) -> &str {
4553            "ethereum"
4554        }
4555        fn native_token_symbol(&self) -> &str {
4556            "ETH"
4557        }
4558        async fn get_balance(&self, _address: &str) -> Result<scope::chains::Balance> {
4559            unimplemented!("not needed for monitor tests")
4560        }
4561        async fn enrich_balance_usd(&self, _balance: &mut scope::chains::Balance) {}
4562        async fn get_transaction(&self, _hash: &str) -> Result<scope::chains::Transaction> {
4563            unimplemented!("not needed for monitor tests")
4564        }
4565        async fn get_transactions(
4566            &self,
4567            _address: &str,
4568            _limit: u32,
4569        ) -> Result<Vec<scope::chains::Transaction>> {
4570            Ok(vec![])
4571        }
4572        async fn get_block_number(&self) -> Result<u64> {
4573            Ok(1000000)
4574        }
4575        async fn get_token_balances(
4576            &self,
4577            _address: &str,
4578        ) -> Result<Vec<scope::chains::TokenBalance>> {
4579            Ok(vec![])
4580        }
4581        async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
4582            Ok(self.holder_count)
4583        }
4584    }
4585
4586    /// Creates a `MonitorApp<TestBackend>` with mock dependencies.
4587    fn create_test_app(
4588        dex: Box<dyn DexDataSource>,
4589        chain_client: Option<Box<dyn ChainClient>>,
4590    ) -> MonitorApp<TestBackend> {
4591        let token_data = create_test_token_data();
4592        let state = MonitorState::new(&token_data, "ethereum");
4593        let backend = TestBackend::new(120, 40);
4594        let terminal = ratatui::Terminal::new(backend).unwrap();
4595        MonitorApp {
4596            terminal,
4597            state,
4598            dex_client: dex,
4599            chain_client,
4600            exchange_client: None,
4601            should_exit: false,
4602            owns_terminal: false,
4603        }
4604    }
4605
4606    fn create_test_app_with_state(
4607        state: MonitorState,
4608        dex: Box<dyn DexDataSource>,
4609        chain_client: Option<Box<dyn ChainClient>>,
4610    ) -> MonitorApp<TestBackend> {
4611        let backend = TestBackend::new(120, 40);
4612        let terminal = ratatui::Terminal::new(backend).unwrap();
4613        MonitorApp {
4614            terminal,
4615            state,
4616            dex_client: dex,
4617            chain_client,
4618            exchange_client: None,
4619            should_exit: false,
4620            owns_terminal: false,
4621        }
4622    }
4623
4624    // ========================================================================
4625    // Integration tests: MonitorApp::handle_key_event
4626    // ========================================================================
4627
4628    #[test]
4629    fn test_app_handle_key_quit_q() {
4630        let data = create_test_token_data();
4631        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4632        assert!(!app.should_exit);
4633        app.handle_key_event(make_key_event(KeyCode::Char('q')));
4634        assert!(app.should_exit);
4635    }
4636
4637    #[test]
4638    fn test_app_handle_key_quit_esc() {
4639        let data = create_test_token_data();
4640        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4641        app.handle_key_event(make_key_event(KeyCode::Esc));
4642        assert!(app.should_exit);
4643    }
4644
4645    #[test]
4646    fn test_app_handle_key_quit_ctrl_c() {
4647        let data = create_test_token_data();
4648        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4649        let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
4650        app.handle_key_event(key);
4651        assert!(app.should_exit);
4652    }
4653
4654    #[test]
4655    fn test_app_handle_key_quit_stops_active_export() {
4656        let data = create_test_token_data();
4657        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4658        let path = start_export_in_temp(&mut app.state);
4659        assert!(app.state.export_active);
4660
4661        app.handle_key_event(make_key_event(KeyCode::Char('q')));
4662        assert!(app.should_exit);
4663        assert!(!app.state.export_active);
4664        let _ = std::fs::remove_file(path);
4665    }
4666
4667    #[test]
4668    fn test_app_handle_key_ctrl_c_stops_active_export() {
4669        let data = create_test_token_data();
4670        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4671        let path = start_export_in_temp(&mut app.state);
4672        assert!(app.state.export_active);
4673
4674        let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
4675        app.handle_key_event(key);
4676        assert!(app.should_exit);
4677        assert!(!app.state.export_active);
4678        let _ = std::fs::remove_file(path);
4679    }
4680
4681    #[test]
4682    fn test_app_handle_key_updates_last_input_time() {
4683        let data = create_test_token_data();
4684        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4685        let before = Instant::now();
4686        app.handle_key_event(make_key_event(KeyCode::Char('p')));
4687        assert!(app.state.last_input_at >= before);
4688    }
4689
4690    #[test]
4691    fn test_app_handle_key_widget_toggle_mode() {
4692        let data = create_test_token_data();
4693        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4694        assert!(app.state.widgets.price_chart);
4695
4696        // Enter widget toggle mode
4697        app.handle_key_event(make_key_event(KeyCode::Char('w')));
4698        assert!(app.state.widget_toggle_mode);
4699
4700        // Toggle widget 1 (price chart)
4701        app.handle_key_event(make_key_event(KeyCode::Char('1')));
4702        assert!(!app.state.widget_toggle_mode);
4703        assert!(!app.state.widgets.price_chart);
4704    }
4705
4706    #[test]
4707    fn test_app_handle_key_widget_toggle_mode_cancel() {
4708        let data = create_test_token_data();
4709        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4710
4711        // Enter widget toggle mode
4712        app.handle_key_event(make_key_event(KeyCode::Char('w')));
4713        assert!(app.state.widget_toggle_mode);
4714
4715        // Any non-digit key cancels widget toggle mode
4716        app.handle_key_event(make_key_event(KeyCode::Char('x')));
4717        assert!(!app.state.widget_toggle_mode);
4718    }
4719
4720    #[test]
4721    fn test_app_handle_key_all_keybindings() {
4722        let data = create_test_token_data();
4723        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4724
4725        // r = force refresh
4726        app.handle_key_event(make_key_event(KeyCode::Char('r')));
4727        assert!(!app.should_exit);
4728
4729        // Shift+P = toggle auto-pause
4730        let key = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
4731        app.handle_key_event(key);
4732        assert!(app.state.auto_pause_on_input);
4733        app.handle_key_event(key);
4734        assert!(!app.state.auto_pause_on_input);
4735
4736        // p = toggle pause
4737        app.handle_key_event(make_key_event(KeyCode::Char('p')));
4738        assert!(app.state.paused);
4739
4740        // space = toggle pause
4741        app.handle_key_event(make_key_event(KeyCode::Char(' ')));
4742        assert!(!app.state.paused);
4743
4744        // e = toggle export
4745        app.handle_key_event(make_key_event(KeyCode::Char('e')));
4746        assert!(app.state.export_active);
4747        // Stop export to avoid file handles
4748        app.state.stop_export();
4749
4750        // + = slower refresh
4751        let before_rate = app.state.refresh_rate;
4752        app.handle_key_event(make_key_event(KeyCode::Char('+')));
4753        assert!(app.state.refresh_rate >= before_rate);
4754
4755        // - = faster refresh
4756        let before_rate = app.state.refresh_rate;
4757        app.handle_key_event(make_key_event(KeyCode::Char('-')));
4758        assert!(app.state.refresh_rate <= before_rate);
4759
4760        // 1-6 = time periods
4761        app.handle_key_event(make_key_event(KeyCode::Char('1')));
4762        assert_eq!(app.state.time_period, TimePeriod::Min1);
4763        app.handle_key_event(make_key_event(KeyCode::Char('2')));
4764        assert_eq!(app.state.time_period, TimePeriod::Min5);
4765        app.handle_key_event(make_key_event(KeyCode::Char('3')));
4766        assert_eq!(app.state.time_period, TimePeriod::Min15);
4767        app.handle_key_event(make_key_event(KeyCode::Char('4')));
4768        assert_eq!(app.state.time_period, TimePeriod::Hour1);
4769        app.handle_key_event(make_key_event(KeyCode::Char('5')));
4770        assert_eq!(app.state.time_period, TimePeriod::Hour4);
4771        app.handle_key_event(make_key_event(KeyCode::Char('6')));
4772        assert_eq!(app.state.time_period, TimePeriod::Day1);
4773
4774        // t = cycle time period
4775        app.handle_key_event(make_key_event(KeyCode::Char('t')));
4776        assert_eq!(app.state.time_period, TimePeriod::Min1); // wraps from Day1
4777
4778        // c = toggle chart mode
4779        app.handle_key_event(make_key_event(KeyCode::Char('c')));
4780        assert_eq!(app.state.chart_mode, ChartMode::Candlestick);
4781
4782        // s = toggle scale
4783        app.handle_key_event(make_key_event(KeyCode::Char('s')));
4784        assert_eq!(app.state.scale_mode, ScaleMode::Log);
4785
4786        // / = cycle color scheme
4787        app.handle_key_event(make_key_event(KeyCode::Char('/')));
4788        assert_eq!(app.state.color_scheme, ColorScheme::BlueOrange);
4789
4790        // j = scroll log down
4791        app.handle_key_event(make_key_event(KeyCode::Char('j')));
4792
4793        // k = scroll log up
4794        app.handle_key_event(make_key_event(KeyCode::Char('k')));
4795
4796        // l = next layout
4797        app.handle_key_event(make_key_event(KeyCode::Char('l')));
4798        assert!(!app.state.auto_layout);
4799
4800        // h = prev layout
4801        app.handle_key_event(make_key_event(KeyCode::Char('h')));
4802
4803        // a = re-enable auto layout
4804        app.handle_key_event(make_key_event(KeyCode::Char('a')));
4805        assert!(app.state.auto_layout);
4806
4807        // w = widget toggle mode
4808        app.handle_key_event(make_key_event(KeyCode::Char('w')));
4809        assert!(app.state.widget_toggle_mode);
4810        // Cancel it
4811        app.handle_key_event(make_key_event(KeyCode::Char('z')));
4812
4813        // Unknown key is a no-op
4814        app.handle_key_event(make_key_event(KeyCode::F(12)));
4815        assert!(!app.should_exit);
4816    }
4817
4818    // ========================================================================
4819    // Integration tests: MonitorApp::fetch_data
4820    // ========================================================================
4821
4822    #[tokio::test]
4823    async fn test_app_fetch_data_success() {
4824        let data = create_test_token_data();
4825        let initial_price = data.price_usd;
4826        let mut updated = data.clone();
4827        updated.price_usd = 2.5;
4828        let mut app = create_test_app(Box::new(MockDexDataSource::new(updated)), None);
4829
4830        assert!((app.state.current_price - initial_price).abs() < 0.001);
4831        app.fetch_data().await;
4832        assert!((app.state.current_price - 2.5).abs() < 0.001);
4833        assert!(app.state.error_message.is_none());
4834    }
4835
4836    #[tokio::test]
4837    async fn test_app_fetch_data_api_error() {
4838        let mut app = create_test_app(Box::new(MockDexDataSource::failing("rate limited")), None);
4839
4840        app.fetch_data().await;
4841        assert!(app.state.error_message.is_some());
4842        assert!(
4843            app.state
4844                .error_message
4845                .as_ref()
4846                .unwrap()
4847                .contains("API Error")
4848        );
4849    }
4850
4851    #[tokio::test]
4852    async fn test_app_fetch_data_holder_count_on_12th_tick() {
4853        let data = create_test_token_data();
4854        let mock_chain = MockChainClient::new(42_000);
4855        let mut app = create_test_app(
4856            Box::new(MockDexDataSource::new(data)),
4857            Some(Box::new(mock_chain)),
4858        );
4859
4860        // First 11 fetches should not update holder count
4861        for _ in 0..11 {
4862            app.fetch_data().await;
4863        }
4864        assert!(app.state.holder_count.is_none());
4865
4866        // 12th fetch triggers holder count lookup
4867        app.fetch_data().await;
4868        assert_eq!(app.state.holder_count, Some(42_000));
4869    }
4870
4871    #[tokio::test]
4872    async fn test_app_fetch_data_holder_count_zero_not_stored() {
4873        let data = create_test_token_data();
4874        let mock_chain = MockChainClient::new(0); // returns zero
4875        let mut app = create_test_app(
4876            Box::new(MockDexDataSource::new(data)),
4877            Some(Box::new(mock_chain)),
4878        );
4879
4880        // Skip to 12th tick
4881        app.state.holder_fetch_counter = 11;
4882        app.fetch_data().await;
4883        // Zero holder count should NOT be stored
4884        assert!(app.state.holder_count.is_none());
4885    }
4886
4887    #[tokio::test]
4888    async fn test_app_fetch_data_no_chain_client_skips_holders() {
4889        let data = create_test_token_data();
4890        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4891
4892        // Skip to 12th tick
4893        app.state.holder_fetch_counter = 11;
4894        app.fetch_data().await;
4895        // Without a chain client, holder count stays None
4896        assert!(app.state.holder_count.is_none());
4897    }
4898
4899    #[tokio::test]
4900    async fn test_app_fetch_data_preserves_holder_on_subsequent_failure() {
4901        let data = create_test_token_data();
4902        let mock_chain = MockChainClient::new(42_000);
4903        let mut app = create_test_app(
4904            Box::new(MockDexDataSource::new(data)),
4905            Some(Box::new(mock_chain)),
4906        );
4907
4908        // Fetch holder count on 12th tick
4909        app.state.holder_fetch_counter = 11;
4910        app.fetch_data().await;
4911        assert_eq!(app.state.holder_count, Some(42_000));
4912
4913        // Replace chain client with one returning 0
4914        app.chain_client = Some(Box::new(MockChainClient::new(0)));
4915        // 24th tick
4916        app.state.holder_fetch_counter = 23;
4917        app.fetch_data().await;
4918        // Previous value should be preserved (zero is ignored)
4919        assert_eq!(app.state.holder_count, Some(42_000));
4920    }
4921
4922    // ========================================================================
4923    // Integration tests: MonitorApp::cleanup
4924    // ========================================================================
4925
4926    #[test]
4927    fn test_app_cleanup_does_not_panic_test_backend() {
4928        let data = create_test_token_data();
4929        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4930        // cleanup() with owns_terminal=false should not attempt terminal restore
4931        let result = app.cleanup();
4932        assert!(result.is_ok());
4933    }
4934
4935    // ========================================================================
4936    // Integration tests: MonitorApp draw renders without panic
4937    // ========================================================================
4938
4939    #[test]
4940    fn test_app_draw_renders_ui() {
4941        let data = create_test_token_data();
4942        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4943        // Verify we can render UI through the MonitorApp terminal
4944        app.terminal
4945            .draw(|f| ui(f, &mut app.state))
4946            .expect("should render without panic");
4947    }
4948
4949    // ========================================================================
4950    // Integration tests: select_token_impl
4951    // ========================================================================
4952
4953    fn make_search_results() -> Vec<TokenSearchResult> {
4954        vec![
4955            TokenSearchResult {
4956                address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
4957                symbol: "USDC".to_string(),
4958                name: "USD Coin".to_string(),
4959                chain: "ethereum".to_string(),
4960                price_usd: Some(1.0),
4961                volume_24h: 5_000_000_000.0,
4962                liquidity_usd: 2_000_000_000.0,
4963                market_cap: Some(32_000_000_000.0),
4964            },
4965            TokenSearchResult {
4966                address: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(),
4967                symbol: "USDT".to_string(),
4968                name: "Tether USD".to_string(),
4969                chain: "ethereum".to_string(),
4970                price_usd: Some(1.0),
4971                volume_24h: 6_000_000_000.0,
4972                liquidity_usd: 3_000_000_000.0,
4973                market_cap: Some(83_000_000_000.0),
4974            },
4975            TokenSearchResult {
4976                address: "0x6B175474E89094C44Da98b954EedeAC495271d0F".to_string(),
4977                symbol: "DAI".to_string(),
4978                name: "Dai Stablecoin".to_string(),
4979                chain: "ethereum".to_string(),
4980                price_usd: Some(1.0),
4981                volume_24h: 200_000_000.0,
4982                liquidity_usd: 500_000_000.0,
4983                market_cap: Some(5_000_000_000.0),
4984            },
4985        ]
4986    }
4987
4988    #[test]
4989    fn test_select_token_impl_valid_first() {
4990        let results = make_search_results();
4991        let mut reader = io::Cursor::new(b"1\n");
4992        let mut writer = Vec::new();
4993        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
4994        assert_eq!(selected.symbol, "USDC");
4995        assert_eq!(selected.address, results[0].address);
4996        let output = String::from_utf8(writer).unwrap();
4997        assert!(output.contains("Found 3 tokens"));
4998        assert!(output.contains("Selected: USDC"));
4999    }
5000
5001    #[test]
5002    fn test_select_token_impl_valid_last() {
5003        let results = make_search_results();
5004        let mut reader = io::Cursor::new(b"3\n");
5005        let mut writer = Vec::new();
5006        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5007        assert_eq!(selected.symbol, "DAI");
5008    }
5009
5010    #[test]
5011    fn test_select_token_impl_valid_middle() {
5012        let results = make_search_results();
5013        let mut reader = io::Cursor::new(b"2\n");
5014        let mut writer = Vec::new();
5015        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5016        assert_eq!(selected.symbol, "USDT");
5017    }
5018
5019    #[test]
5020    fn test_select_token_impl_out_of_bounds_zero() {
5021        let results = make_search_results();
5022        let mut reader = io::Cursor::new(b"0\n");
5023        let mut writer = Vec::new();
5024        let result = select_token_impl(&results, &mut reader, &mut writer);
5025        assert!(result.is_err());
5026        let err = result.unwrap_err().to_string();
5027        assert!(err.contains("Selection must be between 1 and 3"));
5028    }
5029
5030    #[test]
5031    fn test_select_token_impl_out_of_bounds_high() {
5032        let results = make_search_results();
5033        let mut reader = io::Cursor::new(b"99\n");
5034        let mut writer = Vec::new();
5035        let result = select_token_impl(&results, &mut reader, &mut writer);
5036        assert!(result.is_err());
5037    }
5038
5039    #[test]
5040    fn test_select_token_impl_non_numeric_input() {
5041        let results = make_search_results();
5042        let mut reader = io::Cursor::new(b"abc\n");
5043        let mut writer = Vec::new();
5044        let result = select_token_impl(&results, &mut reader, &mut writer);
5045        assert!(result.is_err());
5046        let err = result.unwrap_err().to_string();
5047        assert!(err.contains("Invalid selection"));
5048    }
5049
5050    #[test]
5051    fn test_select_token_impl_empty_input() {
5052        let results = make_search_results();
5053        let mut reader = io::Cursor::new(b"\n");
5054        let mut writer = Vec::new();
5055        let result = select_token_impl(&results, &mut reader, &mut writer);
5056        assert!(result.is_err());
5057    }
5058
5059    #[test]
5060    fn test_select_token_impl_long_name_truncation() {
5061        let results = vec![TokenSearchResult {
5062            address: "0xABCDEF1234567890ABCDEF1234567890ABCDEF12".to_string(),
5063            symbol: "LONG".to_string(),
5064            name: "A Very Long Token Name That Exceeds Twenty Characters".to_string(),
5065            chain: "ethereum".to_string(),
5066            price_usd: None,
5067            volume_24h: 100.0,
5068            liquidity_usd: 50.0,
5069            market_cap: None,
5070        }];
5071        let mut reader = io::Cursor::new(b"1\n");
5072        let mut writer = Vec::new();
5073        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5074        assert_eq!(selected.symbol, "LONG");
5075        let output = String::from_utf8(writer).unwrap();
5076        // Should have truncated name
5077        assert!(output.contains("A Very Long Token..."));
5078        // Should show N/A for price
5079        assert!(output.contains("N/A"));
5080    }
5081
5082    #[test]
5083    fn test_select_token_impl_output_format() {
5084        let results = make_search_results();
5085        let mut reader = io::Cursor::new(b"1\n");
5086        let mut writer = Vec::new();
5087        let _ = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5088        let output = String::from_utf8(writer).unwrap();
5089
5090        // Verify table header
5091        assert!(output.contains("#"));
5092        assert!(output.contains("Symbol"));
5093        assert!(output.contains("Name"));
5094        assert!(output.contains("Address"));
5095        assert!(output.contains("Price"));
5096        assert!(output.contains("Liquidity"));
5097        // Verify separator line
5098        assert!(output.contains("─"));
5099        // Verify prompt
5100        assert!(output.contains("Select token (1-3):"));
5101    }
5102
5103    // ========================================================================
5104    // Integration tests: format_monitor_number
5105    // ========================================================================
5106
5107    #[test]
5108    fn test_format_monitor_number_billions() {
5109        assert_eq!(format_monitor_number(5_000_000_000.0), "$5.00B");
5110        assert_eq!(format_monitor_number(1_234_567_890.0), "$1.23B");
5111    }
5112
5113    #[test]
5114    fn test_format_monitor_number_millions() {
5115        assert_eq!(format_monitor_number(5_000_000.0), "$5.00M");
5116        assert_eq!(format_monitor_number(42_500_000.0), "$42.50M");
5117    }
5118
5119    #[test]
5120    fn test_format_monitor_number_thousands() {
5121        assert_eq!(format_monitor_number(5_000.0), "$5.00K");
5122        assert_eq!(format_monitor_number(999_999.0), "$1000.00K");
5123    }
5124
5125    #[test]
5126    fn test_format_monitor_number_small() {
5127        assert_eq!(format_monitor_number(42.0), "$42.00");
5128        assert_eq!(format_monitor_number(0.5), "$0.50");
5129        assert_eq!(format_monitor_number(0.0), "$0.00");
5130    }
5131
5132    // ========================================================================
5133    // Integration tests: abbreviate_address edge cases
5134    // ========================================================================
5135
5136    #[test]
5137    fn test_abbreviate_address_exactly_16_chars() {
5138        let addr = "0123456789ABCDEF"; // exactly 16 chars
5139        assert_eq!(abbreviate_address(addr), addr);
5140    }
5141
5142    #[test]
5143    fn test_abbreviate_address_17_chars() {
5144        let addr = "0123456789ABCDEFG"; // 17 chars -> abbreviated
5145        assert_eq!(abbreviate_address(addr), "01234567...BCDEFG");
5146    }
5147
5148    // ========================================================================
5149    // Integration tests: MonitorApp with state + fetch combined scenario
5150    // ========================================================================
5151
5152    #[tokio::test]
5153    async fn test_app_full_scenario_fetch_render_quit() {
5154        let data = create_test_token_data();
5155        let mut updated = data.clone();
5156        updated.price_usd = 3.0;
5157        let mock_chain = MockChainClient::new(10_000);
5158        let state = MonitorState::new(&data, "ethereum");
5159        let mut app = create_test_app_with_state(
5160            state,
5161            Box::new(MockDexDataSource::new(updated)),
5162            Some(Box::new(mock_chain)),
5163        );
5164
5165        // 1. Fetch new data
5166        app.fetch_data().await;
5167        assert!((app.state.current_price - 3.0).abs() < 0.001);
5168
5169        // 2. Render UI
5170        app.terminal
5171            .draw(|f| ui(f, &mut app.state))
5172            .expect("render");
5173
5174        // 3. Start export
5175        app.handle_key_event(make_key_event(KeyCode::Char('e')));
5176        assert!(app.state.export_active);
5177
5178        // 4. Quit (should stop export)
5179        app.handle_key_event(make_key_event(KeyCode::Char('q')));
5180        assert!(app.should_exit);
5181        assert!(!app.state.export_active);
5182    }
5183
5184    #[tokio::test]
5185    async fn test_app_fetch_data_error_then_recovery() {
5186        let mut app = create_test_app(Box::new(MockDexDataSource::failing("server down")), None);
5187
5188        // First fetch fails
5189        app.fetch_data().await;
5190        assert!(app.state.error_message.is_some());
5191
5192        // Replace with working mock
5193        let mut recovered = create_test_token_data();
5194        recovered.price_usd = 5.0;
5195        app.dex_client = Box::new(MockDexDataSource::new(recovered));
5196
5197        // Second fetch succeeds
5198        app.fetch_data().await;
5199        assert!((app.state.current_price - 5.0).abs() < 0.001);
5200        // Error message is cleared by state.update()
5201    }
5202
5203    // ========================================================================
5204    // Integration tests: MonitorArgs parsing and run_direct config merging
5205    // ========================================================================
5206
5207    #[test]
5208    fn test_monitor_args_defaults() {
5209        use super::super::Cli;
5210        use clap::Parser;
5211        // Simulate: scope monitor USDC
5212        let cli = Cli::try_parse_from(["scope", "monitor", "USDC"]).unwrap();
5213        if let super::super::Commands::Monitor(args) = cli.command {
5214            assert_eq!(args.token, "USDC");
5215            assert_eq!(args.chain, "ethereum");
5216            assert!(args.layout.is_none());
5217            assert!(args.refresh.is_none());
5218            assert!(args.scale.is_none());
5219            assert!(args.color_scheme.is_none());
5220            assert!(args.export.is_none());
5221        } else {
5222            panic!("Expected Monitor command");
5223        }
5224    }
5225
5226    #[test]
5227    fn test_monitor_args_all_flags() {
5228        use super::super::Cli;
5229        use clap::Parser;
5230        let cli = Cli::try_parse_from([
5231            "scope",
5232            "monitor",
5233            "PEPE",
5234            "--chain",
5235            "solana",
5236            "--layout",
5237            "feed",
5238            "--refresh",
5239            "2",
5240            "--scale",
5241            "log",
5242            "--color-scheme",
5243            "monochrome",
5244            "--export",
5245            "/tmp/data.csv",
5246        ])
5247        .unwrap();
5248        if let super::super::Commands::Monitor(args) = cli.command {
5249            assert_eq!(args.token, "PEPE");
5250            assert_eq!(args.chain, "solana");
5251            assert_eq!(args.layout, Some(LayoutPreset::Feed));
5252            assert_eq!(args.refresh, Some(2));
5253            assert_eq!(args.scale, Some(ScaleMode::Log));
5254            assert_eq!(args.color_scheme, Some(ColorScheme::Monochrome));
5255            assert_eq!(args.export, Some(PathBuf::from("/tmp/data.csv")));
5256        } else {
5257            panic!("Expected Monitor command");
5258        }
5259    }
5260
5261    #[test]
5262    fn test_run_direct_config_override_layout() {
5263        // Verify that run_direct properly applies CLI overrides to config
5264        let config = Config::default();
5265        assert_eq!(config.monitor.layout, LayoutPreset::Dashboard);
5266
5267        let args = MonitorArgs {
5268            token: "USDC".to_string(),
5269            chain: "ethereum".to_string(),
5270            layout: Some(LayoutPreset::ChartFocus),
5271            refresh: None,
5272            scale: None,
5273            color_scheme: None,
5274            export: None,
5275            venue: None,
5276            pair: None,
5277        };
5278
5279        // Build the effective config the same way run_direct does
5280        let mut monitor_config = config.monitor.clone();
5281        if let Some(layout) = args.layout {
5282            monitor_config.layout = layout;
5283        }
5284        assert_eq!(monitor_config.layout, LayoutPreset::ChartFocus);
5285    }
5286
5287    #[test]
5288    fn test_run_direct_config_override_all_fields() {
5289        let config = Config::default();
5290        let args = MonitorArgs {
5291            token: "PEPE".to_string(),
5292            chain: "solana".to_string(),
5293            layout: Some(LayoutPreset::Compact),
5294            refresh: Some(2),
5295            scale: Some(ScaleMode::Log),
5296            color_scheme: Some(ColorScheme::BlueOrange),
5297            export: Some(PathBuf::from("/tmp/test.csv")),
5298            venue: None,
5299            pair: None,
5300        };
5301
5302        let mut mc = config.monitor.clone();
5303        if let Some(layout) = args.layout {
5304            mc.layout = layout;
5305        }
5306        if let Some(refresh) = args.refresh {
5307            mc.refresh_seconds = refresh;
5308        }
5309        if let Some(scale) = args.scale {
5310            mc.scale = scale;
5311        }
5312        if let Some(color_scheme) = args.color_scheme {
5313            mc.color_scheme = color_scheme;
5314        }
5315        if let Some(ref path) = args.export {
5316            mc.export.path = Some(path.to_string_lossy().into_owned());
5317        }
5318
5319        assert_eq!(mc.layout, LayoutPreset::Compact);
5320        assert_eq!(mc.refresh_seconds, 2);
5321        assert_eq!(mc.scale, ScaleMode::Log);
5322        assert_eq!(mc.color_scheme, ColorScheme::BlueOrange);
5323        assert_eq!(mc.export.path, Some("/tmp/test.csv".to_string()));
5324    }
5325
5326    #[test]
5327    fn test_run_direct_config_no_overrides_preserves_defaults() {
5328        let config = Config::default();
5329        let args = MonitorArgs {
5330            token: "USDC".to_string(),
5331            chain: "ethereum".to_string(),
5332            layout: None,
5333            refresh: None,
5334            scale: None,
5335            color_scheme: None,
5336            export: None,
5337            venue: None,
5338            pair: None,
5339        };
5340
5341        let mut mc = config.monitor.clone();
5342        if let Some(layout) = args.layout {
5343            mc.layout = layout;
5344        }
5345        if let Some(refresh) = args.refresh {
5346            mc.refresh_seconds = refresh;
5347        }
5348        if let Some(scale) = args.scale {
5349            mc.scale = scale;
5350        }
5351        if let Some(color_scheme) = args.color_scheme {
5352            mc.color_scheme = color_scheme;
5353        }
5354
5355        // All should remain at defaults
5356        assert_eq!(mc.layout, LayoutPreset::Dashboard);
5357        assert_eq!(mc.refresh_seconds, DEFAULT_REFRESH_SECS);
5358        assert_eq!(mc.scale, ScaleMode::Linear);
5359        assert_eq!(mc.color_scheme, ColorScheme::GreenRed);
5360        assert!(mc.export.path.is_none());
5361    }
5362
5363    // =================================================================
5364    // OHLC / exchange_interval tests
5365    // =================================================================
5366
5367    #[test]
5368    fn test_exchange_interval_mapping() {
5369        assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
5370        assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
5371        assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
5372        assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
5373        assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
5374        assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
5375    }
5376
5377    #[test]
5378    fn test_monitor_state_exchange_ohlc_default_empty() {
5379        let token_data = create_test_token_data();
5380        let state = MonitorState::new(&token_data, "ethereum");
5381        assert!(state.exchange_ohlc.is_empty());
5382        assert!(state.venue_pair.is_none());
5383    }
5384
5385    #[test]
5386    fn test_get_ohlc_candles_prefers_exchange_data() {
5387        let token_data = create_test_token_data();
5388        let mut state = MonitorState::new(&token_data, "ethereum");
5389
5390        // Add some synthetic candles from price history
5391        let now = std::time::SystemTime::now()
5392            .duration_since(std::time::UNIX_EPOCH)
5393            .unwrap()
5394            .as_secs_f64();
5395        state.price_history.push_back(DataPoint {
5396            timestamp: now,
5397            value: 1.0,
5398            is_real: true,
5399        });
5400        state.price_history.push_back(DataPoint {
5401            timestamp: now + 5.0,
5402            value: 1.01,
5403            is_real: true,
5404        });
5405
5406        // Without exchange OHLC, should get synthetic candles
5407        let candles_before = state.get_ohlc_candles();
5408
5409        // Now add exchange OHLC data
5410        state.exchange_ohlc = vec![
5411            OhlcCandle {
5412                timestamp: 1700000000.0,
5413                open: 50000.0,
5414                high: 50500.0,
5415                low: 49800.0,
5416                close: 50200.0,
5417                is_bullish: true,
5418            },
5419            OhlcCandle {
5420                timestamp: 1700003600.0,
5421                open: 50200.0,
5422                high: 50800.0,
5423                low: 50100.0,
5424                close: 50700.0,
5425                is_bullish: true,
5426            },
5427        ];
5428
5429        let candles_after = state.get_ohlc_candles();
5430        assert_eq!(candles_after.len(), 2);
5431        assert_eq!(candles_after[0].open, 50000.0);
5432        assert_eq!(candles_after[1].close, 50700.0);
5433
5434        // Verify exchange data is preferred over synthetic
5435        if !candles_before.is_empty() {
5436            assert_ne!(candles_after[0].open, candles_before[0].open);
5437        }
5438    }
5439
5440    #[test]
5441    fn test_monitor_args_with_venue() {
5442        let args = MonitorArgs {
5443            token: "BTC".to_string(),
5444            chain: "ethereum".to_string(),
5445            refresh: None,
5446            layout: None,
5447            scale: None,
5448            color_scheme: None,
5449            export: None,
5450            venue: Some("binance".to_string()),
5451            pair: None,
5452        };
5453        assert_eq!(args.venue, Some("binance".to_string()));
5454    }
5455
5456    #[test]
5457    fn test_ohlc_candle_is_bullish_calculation() {
5458        let bullish = OhlcCandle {
5459            timestamp: 1700000000.0,
5460            open: 100.0,
5461            high: 110.0,
5462            low: 95.0,
5463            close: 105.0,
5464            is_bullish: true,
5465        };
5466        assert!(bullish.is_bullish);
5467
5468        let bearish = OhlcCandle {
5469            timestamp: 1700000000.0,
5470            open: 100.0,
5471            high: 105.0,
5472            low: 90.0,
5473            close: 95.0,
5474            is_bullish: false,
5475        };
5476        assert!(!bearish.is_bullish);
5477    }
5478
5479    #[test]
5480    fn test_build_exchange_token_data_from_ticker() {
5481        let ticker = scope::market::Ticker {
5482            pair: "DAI/USDT".to_string(),
5483            last_price: Some(1.001),
5484            high_24h: Some(1.005),
5485            low_24h: Some(0.998),
5486            volume_24h: Some(500_000.0),
5487            quote_volume_24h: Some(500_500.0),
5488            best_bid: Some(1.0005),
5489            best_ask: Some(1.0015),
5490        };
5491
5492        let data = build_exchange_token_data("DAI", "DAI_USDT", &ticker);
5493
5494        assert_eq!(data.symbol, "DAI");
5495        assert_eq!(data.name, "DAI");
5496        assert_eq!(data.price_usd, 1.001);
5497        assert_eq!(data.volume_24h, 500_000.0);
5498        assert!(data.address.contains("exchange:"));
5499        assert!(data.pairs.is_empty());
5500        assert!(data.price_history.is_empty());
5501        assert!(data.dexscreener_url.is_none());
5502    }
5503
5504    #[test]
5505    fn test_build_exchange_token_data_missing_price() {
5506        let ticker = scope::market::Ticker {
5507            pair: "FOO/USDT".to_string(),
5508            last_price: None,
5509            high_24h: None,
5510            low_24h: None,
5511            volume_24h: None,
5512            quote_volume_24h: None,
5513            best_bid: None,
5514            best_ask: None,
5515        };
5516
5517        let data = build_exchange_token_data("FOO", "FOO_USDT", &ticker);
5518        assert_eq!(data.price_usd, 0.0);
5519        assert_eq!(data.volume_24h, 0.0);
5520    }
5521
5522    #[test]
5523    fn test_monitor_args_with_pair() {
5524        let args = MonitorArgs {
5525            token: "DAI".to_string(),
5526            chain: "ethereum".to_string(),
5527            refresh: None,
5528            layout: None,
5529            scale: None,
5530            color_scheme: None,
5531            export: None,
5532            venue: Some("biconomy".to_string()),
5533            pair: Some("DAI_USDT".to_string()),
5534        };
5535        assert_eq!(args.pair, Some("DAI_USDT".to_string()));
5536        assert_eq!(args.venue, Some("biconomy".to_string()));
5537    }
5538
5539    #[test]
5540    fn test_monitor_args_pair_none_by_default() {
5541        let args = MonitorArgs {
5542            token: "BTC".to_string(),
5543            chain: "ethereum".to_string(),
5544            refresh: None,
5545            layout: None,
5546            scale: None,
5547            color_scheme: None,
5548            export: None,
5549            venue: None,
5550            pair: None,
5551        };
5552        assert!(args.pair.is_none());
5553    }
5554
5555    #[test]
5556    fn test_build_exchange_token_data_extracts_base_symbol() {
5557        let ticker = scope::market::Ticker {
5558            pair: "DOGE/USDT".to_string(),
5559            last_price: Some(0.123),
5560            high_24h: Some(0.13),
5561            low_24h: Some(0.12),
5562            volume_24h: Some(1_000_000.0),
5563            quote_volume_24h: Some(123_000.0),
5564            best_bid: Some(0.1225),
5565            best_ask: Some(0.1235),
5566        };
5567
5568        let data = build_exchange_token_data("DOGE", "DOGE_USDT", &ticker);
5569        assert_eq!(data.symbol, "DOGE");
5570        assert_eq!(data.name, "DOGE");
5571        assert_eq!(data.price_usd, 0.123);
5572        assert_eq!(data.volume_24h, 1_000_000.0);
5573        // Verify all DEX-specific fields are zeroed/empty
5574        assert_eq!(data.price_change_24h, 0.0);
5575        assert_eq!(data.price_change_6h, 0.0);
5576        assert_eq!(data.price_change_1h, 0.0);
5577        assert_eq!(data.price_change_5m, 0.0);
5578        assert_eq!(data.volume_6h, 0.0);
5579        assert_eq!(data.volume_1h, 0.0);
5580        assert_eq!(data.liquidity_usd, 0.0);
5581        assert!(data.market_cap.is_none());
5582        assert!(data.fdv.is_none());
5583        assert!(data.earliest_pair_created_at.is_none());
5584        assert!(data.image_url.is_none());
5585        assert!(data.websites.is_empty());
5586        assert!(data.socials.is_empty());
5587        assert_eq!(data.total_buys_24h, 0);
5588        assert_eq!(data.total_sells_24h, 0);
5589    }
5590
5591    #[test]
5592    fn test_build_exchange_token_data_address_format() {
5593        let ticker = scope::market::Ticker {
5594            pair: "X/Y".to_string(),
5595            last_price: Some(1.0),
5596            high_24h: None,
5597            low_24h: None,
5598            volume_24h: None,
5599            quote_volume_24h: None,
5600            best_bid: None,
5601            best_ask: None,
5602        };
5603
5604        let data = build_exchange_token_data("X", "X_Y", &ticker);
5605        assert_eq!(data.address, "exchange:X_Y");
5606    }
5607
5608    #[test]
5609    fn test_monitor_args_pair_requires_venue_conceptually() {
5610        // When --pair is set, --venue should also be set.
5611        // This is a structural test; the runtime validation is in run().
5612        let args = MonitorArgs {
5613            token: "DAI".to_string(),
5614            chain: "ethereum".to_string(),
5615            refresh: None,
5616            layout: None,
5617            scale: None,
5618            color_scheme: None,
5619            export: None,
5620            venue: Some("biconomy".to_string()),
5621            pair: Some("DAI_USDT".to_string()),
5622        };
5623        assert!(args.venue.is_some());
5624        assert!(args.pair.is_some());
5625    }
5626
5627    #[test]
5628    fn test_run_direct_config_pair_passthrough() {
5629        // Verify that run_direct properly propagates the pair field
5630        let config = Config::default();
5631        let args = MonitorArgs {
5632            token: "DAI".to_string(),
5633            chain: "ethereum".to_string(),
5634            layout: None,
5635            refresh: None,
5636            scale: None,
5637            color_scheme: None,
5638            export: None,
5639            venue: Some("biconomy".to_string()),
5640            pair: Some("DAI_USDT".to_string()),
5641        };
5642
5643        // Simulate the config override path from run_direct
5644        let mut mc = config.monitor.clone();
5645        if let Some(ref venue) = args.venue {
5646            mc.venue = Some(venue.clone());
5647        }
5648        assert_eq!(mc.venue, Some("biconomy".to_string()));
5649        // pair is passed directly to run(), not stored in MonitorConfig
5650        assert_eq!(args.pair, Some("DAI_USDT".to_string()));
5651    }
5652
5653    // =========================================================================
5654    // resolve_token_address chain filter tests
5655    // =========================================================================
5656
5657    #[test]
5658    fn test_chain_filter_logic_ethereum_default() {
5659        // When chain is "ethereum" (default), chain_filter should be None
5660        // so we search ALL chains and exact matches on any chain sort first.
5661        let chain = "ethereum";
5662        let chain_filter: Option<&str> = if chain != "ethereum" {
5663            Some(chain)
5664        } else {
5665            None
5666        };
5667        assert!(chain_filter.is_none());
5668    }
5669
5670    #[test]
5671    fn test_chain_filter_logic_explicit_chain() {
5672        // When chain is explicitly set (not "ethereum"), filter to that chain.
5673        let chain = "solana";
5674        let chain_filter: Option<&str> = if chain != "ethereum" {
5675            Some(chain)
5676        } else {
5677            None
5678        };
5679        assert_eq!(chain_filter, Some("solana"));
5680    }
5681
5682    #[test]
5683    fn test_chain_filter_logic_bsc() {
5684        let chain = "bsc";
5685        let chain_filter: Option<&str> = if chain != "ethereum" {
5686            Some(chain)
5687        } else {
5688            None
5689        };
5690        assert_eq!(chain_filter, Some("bsc"));
5691    }
5692
5693    #[tokio::test]
5694    async fn test_resolve_token_address_with_address_input() {
5695        use scope::chains::dex::DexClient;
5696        let config = Config::default();
5697        let dex = DexClient::new();
5698        // EVM address should be returned directly, no DexScreener query
5699        let result = resolve_token_address(
5700            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
5701            "ethereum",
5702            &config,
5703            &dex,
5704        )
5705        .await
5706        .unwrap();
5707        assert_eq!(result, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
5708    }
5709
5710    #[tokio::test]
5711    async fn test_resolve_token_address_solana_address() {
5712        use scope::chains::dex::DexClient;
5713        let config = Config::default();
5714        let dex = DexClient::new();
5715        // Solana address (base58, 32+ chars) should be returned directly
5716        let addr = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
5717        let result = resolve_token_address(addr, "solana", &config, &dex)
5718            .await
5719            .unwrap();
5720        assert_eq!(result, addr);
5721    }
5722
5723    #[test]
5724    fn test_try_cex_fallback_returns_correct_structure() {
5725        // Verify the fallback constructs results with the right shape
5726        // (can't easily test async CEX call without mocking, so test the structure)
5727        let result = scope::chains::TokenSearchResult {
5728            address: String::new(),
5729            symbol: "TEST".to_string(),
5730            name: "TEST".to_string(),
5731            chain: "ethereum".to_string(),
5732            price_usd: Some(1.0),
5733            volume_24h: 100.0,
5734            liquidity_usd: 0.0,
5735            market_cap: None,
5736        };
5737        assert_eq!(result.symbol, "TEST");
5738        assert!(result.address.is_empty());
5739        assert_eq!(result.liquidity_usd, 0.0);
5740    }
5741
5742    #[test]
5743    fn test_select_token_impl_single_result_pick() {
5744        let results = vec![scope::chains::dex::TokenSearchResult {
5745            address: "0xabc".to_string(),
5746            symbol: "TEST".to_string(),
5747            name: "Test Token".to_string(),
5748            chain: "ethereum".to_string(),
5749            price_usd: Some(1.0),
5750            volume_24h: 100.0,
5751            liquidity_usd: 1000.0,
5752            market_cap: None,
5753        }];
5754        let input = b"1\n";
5755        let mut output = Vec::new();
5756        let result = select_token_impl(&results, &mut &input[..], &mut output);
5757        assert!(result.is_ok());
5758        assert_eq!(result.unwrap().symbol, "TEST");
5759    }
5760
5761    #[test]
5762    fn test_select_token_impl_out_of_range() {
5763        let results = vec![scope::chains::dex::TokenSearchResult {
5764            address: "0xabc".to_string(),
5765            symbol: "TEST".to_string(),
5766            name: "Test Token".to_string(),
5767            chain: "ethereum".to_string(),
5768            price_usd: Some(1.0),
5769            volume_24h: 100.0,
5770            liquidity_usd: 1000.0,
5771            market_cap: None,
5772        }];
5773        let input = b"0\n";
5774        let mut output = Vec::new();
5775        let result = select_token_impl(&results, &mut &input[..], &mut output);
5776        assert!(result.is_err());
5777    }
5778
5779    #[test]
5780    fn test_select_token_impl_second_of_two() {
5781        let results = vec![
5782            scope::chains::dex::TokenSearchResult {
5783                address: "0xabc".to_string(),
5784                symbol: "TOKEN1".to_string(),
5785                name: "Token One".to_string(),
5786                chain: "ethereum".to_string(),
5787                price_usd: Some(1.0),
5788                volume_24h: 100.0,
5789                liquidity_usd: 1000.0,
5790                market_cap: None,
5791            },
5792            scope::chains::dex::TokenSearchResult {
5793                address: "0xdef".to_string(),
5794                symbol: "TOKEN2".to_string(),
5795                name: "Token Two".to_string(),
5796                chain: "solana".to_string(),
5797                price_usd: Some(2.0),
5798                volume_24h: 200.0,
5799                liquidity_usd: 2000.0,
5800                market_cap: None,
5801            },
5802        ];
5803        let input = b"2\n";
5804        let mut output = Vec::new();
5805        let result = select_token_impl(&results, &mut &input[..], &mut output);
5806        assert!(result.is_ok());
5807        assert_eq!(result.unwrap().symbol, "TOKEN2");
5808    }
5809
5810    // ========================================================================
5811    // Key event tests
5812    // ========================================================================
5813
5814    #[test]
5815    fn test_handle_key_event_quit() {
5816        let token_data = create_test_token_data();
5817        let mut state = MonitorState::new(&token_data, "ethereum");
5818        let key = crossterm::event::KeyEvent::new(
5819            crossterm::event::KeyCode::Char('q'),
5820            crossterm::event::KeyModifiers::NONE,
5821        );
5822        assert!(handle_key_event_on_state(key, &mut state));
5823    }
5824
5825    #[test]
5826    fn test_handle_key_event_pause() {
5827        let token_data = create_test_token_data();
5828        let mut state = MonitorState::new(&token_data, "ethereum");
5829        let key = crossterm::event::KeyEvent::new(
5830            crossterm::event::KeyCode::Char(' '),
5831            crossterm::event::KeyModifiers::NONE,
5832        );
5833        assert!(!state.paused);
5834        assert!(!handle_key_event_on_state(key, &mut state));
5835        assert!(state.paused);
5836    }
5837
5838    #[test]
5839    fn test_handle_key_event_chart_cycle() {
5840        let token_data = create_test_token_data();
5841        let mut state = MonitorState::new(&token_data, "ethereum");
5842        let initial_mode = state.chart_mode;
5843        let key = crossterm::event::KeyEvent::new(
5844            crossterm::event::KeyCode::Char('c'),
5845            crossterm::event::KeyModifiers::NONE,
5846        );
5847        handle_key_event_on_state(key, &mut state);
5848        assert_ne!(state.chart_mode, initial_mode);
5849    }
5850
5851    #[test]
5852    fn test_handle_key_event_slower_faster() {
5853        let token_data = create_test_token_data();
5854        let mut state = MonitorState::new(&token_data, "ethereum");
5855        let initial_rate = state.refresh_rate_secs();
5856
5857        // '+' slows down (increases interval)
5858        let key_plus = crossterm::event::KeyEvent::new(
5859            crossterm::event::KeyCode::Char('+'),
5860            crossterm::event::KeyModifiers::NONE,
5861        );
5862        handle_key_event_on_state(key_plus, &mut state);
5863        assert!(state.refresh_rate_secs() > initial_rate);
5864
5865        // '-' speeds up (decreases interval)
5866        let key_minus = crossterm::event::KeyEvent::new(
5867            crossterm::event::KeyCode::Char('-'),
5868            crossterm::event::KeyModifiers::NONE,
5869        );
5870        handle_key_event_on_state(key_minus, &mut state);
5871        assert_eq!(state.refresh_rate_secs(), initial_rate);
5872    }
5873
5874    #[test]
5875    fn test_handle_key_event_time_period() {
5876        let token_data = create_test_token_data();
5877        let mut state = MonitorState::new(&token_data, "ethereum");
5878        let initial_period = state.time_period;
5879        let key = crossterm::event::KeyEvent::new(
5880            crossterm::event::KeyCode::Char('t'),
5881            crossterm::event::KeyModifiers::NONE,
5882        );
5883        handle_key_event_on_state(key, &mut state);
5884        assert_ne!(state.time_period, initial_period);
5885    }
5886
5887    #[test]
5888    fn test_handle_key_event_escape() {
5889        let token_data = create_test_token_data();
5890        let mut state = MonitorState::new(&token_data, "ethereum");
5891        let key = crossterm::event::KeyEvent::new(
5892            crossterm::event::KeyCode::Esc,
5893            crossterm::event::KeyModifiers::NONE,
5894        );
5895        assert!(handle_key_event_on_state(key, &mut state));
5896    }
5897
5898    // ========================================================================
5899    // Coverage gap: Exchange layout render functions (order book, trades, market info)
5900    // ========================================================================
5901
5902    #[test]
5903    fn test_render_order_book_panel_no_data() {
5904        let mut terminal = create_test_terminal();
5905        let mut state = create_populated_state();
5906        state.order_book = None;
5907        let area = Rect::new(0, 0, 50, 20);
5908        terminal
5909            .draw(|f| render_order_book_panel(f, area, &state))
5910            .unwrap();
5911    }
5912
5913    #[test]
5914    fn test_render_order_book_panel_with_data() {
5915        let mut terminal = create_test_terminal();
5916        let state = create_populated_state();
5917        let area = Rect::new(0, 0, 60, 25);
5918        terminal
5919            .draw(|f| render_order_book_panel(f, area, &state))
5920            .unwrap();
5921    }
5922
5923    #[test]
5924    fn test_render_order_book_panel_narrow_height() {
5925        let mut terminal = create_test_terminal();
5926        let state = create_populated_state();
5927        let area = Rect::new(0, 0, 50, 3);
5928        terminal
5929            .draw(|f| render_order_book_panel(f, area, &state))
5930            .unwrap();
5931    }
5932
5933    #[test]
5934    fn test_render_recent_trades_panel_empty() {
5935        let mut terminal = create_test_terminal();
5936        let mut state = create_populated_state();
5937        state.recent_trades.clear();
5938        let area = Rect::new(0, 0, 60, 15);
5939        terminal
5940            .draw(|f| render_recent_trades_panel(f, area, &state))
5941            .unwrap();
5942    }
5943
5944    #[test]
5945    fn test_render_recent_trades_panel_with_trades() {
5946        let mut terminal = create_test_terminal();
5947        let mut state = create_populated_state();
5948        state.recent_trades.push_back(Trade {
5949            price: 1.5,
5950            quantity: 100.0,
5951            quote_quantity: None,
5952            timestamp_ms: 1700000000000,
5953            side: TradeSide::Buy,
5954            id: None,
5955        });
5956        state.recent_trades.push_back(Trade {
5957            price: 1.49,
5958            quantity: 50.0,
5959            quote_quantity: None,
5960            timestamp_ms: 1700000001000,
5961            side: TradeSide::Sell,
5962            id: None,
5963        });
5964        let area = Rect::new(0, 0, 60, 20);
5965        terminal
5966            .draw(|f| render_recent_trades_panel(f, area, &state))
5967            .unwrap();
5968    }
5969
5970    #[test]
5971    fn test_render_recent_trades_panel_high_price_trade() {
5972        let mut terminal = create_test_terminal();
5973        let mut state = create_populated_state();
5974        state.recent_trades.push_back(Trade {
5975            price: 50_000.0,
5976            quantity: 0.5,
5977            quote_quantity: None,
5978            timestamp_ms: 1700000000000,
5979            side: TradeSide::Buy,
5980            id: None,
5981        });
5982        let area = Rect::new(0, 0, 60, 15);
5983        terminal
5984            .draw(|f| render_recent_trades_panel(f, area, &state))
5985            .unwrap();
5986    }
5987
5988    #[test]
5989    fn test_render_market_info_panel_no_panic() {
5990        let mut terminal = create_test_terminal();
5991        let state = create_populated_state();
5992        let area = Rect::new(0, 0, 80, 30);
5993        terminal
5994            .draw(|f| render_market_info_panel(f, area, &state))
5995            .unwrap();
5996    }
5997
5998    #[test]
5999    fn test_render_market_info_panel_with_dex_pairs() {
6000        let mut terminal = create_test_terminal();
6001        let mut state = create_populated_state();
6002        state.dex_pairs = vec![
6003            scope::chains::DexPair {
6004                dex_name: "Uniswap V3".to_string(),
6005                pair_address: "0xabc".to_string(),
6006                base_token: "TEST".to_string(),
6007                quote_token: "USDT".to_string(),
6008                price_usd: 1.0,
6009                volume_24h: 100_000.0,
6010                liquidity_usd: 250_000.0,
6011                price_change_24h: 2.5,
6012                buys_24h: 50,
6013                sells_24h: 40,
6014                buys_6h: 15,
6015                sells_6h: 12,
6016                buys_1h: 5,
6017                sells_1h: 4,
6018                pair_created_at: None,
6019                url: None,
6020            },
6021            scope::chains::DexPair {
6022                dex_name: "SushiSwap".to_string(),
6023                pair_address: "0xdef".to_string(),
6024                base_token: "TEST".to_string(),
6025                quote_token: "WETH".to_string(),
6026                price_usd: 1.01,
6027                volume_24h: 25_000.0,
6028                liquidity_usd: 80_000.0,
6029                price_change_24h: -1.2,
6030                buys_24h: 20,
6031                sells_24h: 25,
6032                buys_6h: 8,
6033                sells_6h: 10,
6034                buys_1h: 3,
6035                sells_1h: 4,
6036                pair_created_at: None,
6037                url: None,
6038            },
6039        ];
6040        let area = Rect::new(0, 0, 100, 35);
6041        terminal
6042            .draw(|f| render_market_info_panel(f, area, &state))
6043            .unwrap();
6044    }
6045
6046    #[test]
6047    fn test_ohlc_candle_update_bearish() {
6048        let mut candle = OhlcCandle::new(1700000000.0, 10.0);
6049        assert!(candle.is_bullish);
6050        candle.update(9.5);
6051        assert!(!candle.is_bullish);
6052        assert_eq!(candle.high, 10.0);
6053        assert_eq!(candle.low, 9.5);
6054        assert_eq!(candle.close, 9.5);
6055    }
6056
6057    #[test]
6058    fn test_time_period_index_all() {
6059        assert_eq!(TimePeriod::Min1.index(), 0);
6060        assert_eq!(TimePeriod::Min5.index(), 1);
6061        assert_eq!(TimePeriod::Min15.index(), 2);
6062        assert_eq!(TimePeriod::Hour1.index(), 3);
6063        assert_eq!(TimePeriod::Hour4.index(), 4);
6064        assert_eq!(TimePeriod::Day1.index(), 5);
6065    }
6066
6067    #[test]
6068    fn test_format_number_boundary_values() {
6069        assert_eq!(format_number(0.5), "0.50");
6070        assert_eq!(format_number(999.99), "999.99");
6071        assert_eq!(format_number(1000.0), "1.00K");
6072        assert_eq!(format_number(999_999.0), "1000.00K"); // 999999/1000 = 999.999 -> rounds to 1000.00
6073        assert_eq!(format_number(1_000_000.0), "1.00M");
6074        assert_eq!(format_number(999_999_999.0), "1000.00M");
6075        assert_eq!(format_number(1_000_000_000.0), "1.00B");
6076    }
6077
6078    #[test]
6079    fn test_chart_mode_volume_profile_label() {
6080        assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
6081        assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
6082    }
6083
6084    #[test]
6085    fn test_layout_exchange_ui_full_render() {
6086        let mut terminal = create_test_terminal();
6087        let mut state = create_populated_state();
6088        state.layout = LayoutPreset::Exchange;
6089        state.auto_layout = false;
6090        state.dex_pairs = vec![scope::chains::DexPair {
6091            dex_name: "Uniswap".to_string(),
6092            pair_address: "0xabc".to_string(),
6093            base_token: "TEST".to_string(),
6094            quote_token: "USDT".to_string(),
6095            price_usd: 1.0,
6096            volume_24h: 50_000.0,
6097            liquidity_usd: 100_000.0,
6098            price_change_24h: 0.5,
6099            buys_24h: 100,
6100            sells_24h: 80,
6101            buys_6h: 30,
6102            sells_6h: 25,
6103            buys_1h: 10,
6104            sells_1h: 8,
6105            pair_created_at: None,
6106            url: None,
6107        }];
6108        state.order_book = MonitorState::generate_synthetic_order_book(
6109            &state.dex_pairs,
6110            &state.symbol,
6111            state.current_price,
6112            state.liquidity_usd,
6113        );
6114        state.recent_trades.push_back(Trade {
6115            price: 1.0,
6116            quantity: 500.0,
6117            quote_quantity: None,
6118            timestamp_ms: 1700000000000,
6119            side: TradeSide::Buy,
6120            id: None,
6121        });
6122        terminal.draw(|f| ui(f, &mut state)).unwrap();
6123    }
6124
6125    // ========================================================================
6126    // MonitorState::update and synthetic order book tests
6127    // ========================================================================
6128
6129    fn make_dex_pair(
6130        dex: &str,
6131        base: &str,
6132        quote: &str,
6133        price: f64,
6134        vol: f64,
6135        liq: f64,
6136    ) -> scope::chains::DexPair {
6137        scope::chains::DexPair {
6138            dex_name: dex.to_string(),
6139            base_token: base.to_string(),
6140            quote_token: quote.to_string(),
6141            price_usd: price,
6142            volume_24h: vol,
6143            liquidity_usd: liq,
6144            price_change_24h: 5.0,
6145            pair_address: format!("0x{}", dex),
6146            buys_24h: 100,
6147            sells_24h: 50,
6148            buys_6h: 25,
6149            sells_6h: 12,
6150            buys_1h: 5,
6151            sells_1h: 3,
6152            pair_created_at: None,
6153            url: None,
6154        }
6155    }
6156
6157    fn create_test_token_data_with_pairs() -> DexTokenData {
6158        let mut data = create_test_token_data();
6159        data.pairs = vec![
6160            make_dex_pair("uniswap", "TEST", "USDC", 1.0, 500_000.0, 300_000.0),
6161            make_dex_pair("sushiswap", "TEST", "WETH", 1.01, 200_000.0, 150_000.0),
6162        ];
6163        data
6164    }
6165
6166    #[test]
6167    fn test_generate_synthetic_order_book_with_pairs() {
6168        let data = create_test_token_data_with_pairs();
6169        let book = MonitorState::generate_synthetic_order_book(
6170            &data.pairs,
6171            &data.symbol,
6172            data.price_usd,
6173            data.liquidity_usd,
6174        );
6175        assert!(book.is_some());
6176        let book = book.unwrap();
6177        assert!(!book.bids.is_empty());
6178        assert!(!book.asks.is_empty());
6179        assert!(book.bids[0].price < data.price_usd);
6180        assert!(book.asks[0].price > data.price_usd);
6181    }
6182
6183    #[test]
6184    fn test_generate_synthetic_order_book_empty_pairs() {
6185        let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 1.0, 500_000.0);
6186        // Empty pairs with positive price/liquidity still generates a book
6187        assert!(book.is_some());
6188    }
6189
6190    #[test]
6191    fn test_generate_synthetic_order_book_zero_mid_price() {
6192        let pairs = vec![make_dex_pair("uniswap", "TEST", "USDC", 0.0, 100.0, 100.0)];
6193        let book = MonitorState::generate_synthetic_order_book(&pairs, "TEST", 0.0, 100.0);
6194        assert!(book.is_none());
6195    }
6196
6197    #[test]
6198    fn test_generate_synthetic_order_book_high_liquidity() {
6199        let pairs = vec![make_dex_pair(
6200            "uniswap",
6201            "WETH",
6202            "USDC",
6203            3500.0,
6204            50_000_000.0,
6205            5_000_000.0,
6206        )];
6207        let book = MonitorState::generate_synthetic_order_book(&pairs, "WETH", 3500.0, 5_000_000.0);
6208        assert!(book.is_some());
6209        let book = book.unwrap();
6210        assert!(book.bids.len() > 5);
6211        assert!(book.asks.len() > 5);
6212    }
6213
6214    #[test]
6215    fn test_monitor_state_update_with_token_data() {
6216        let initial_data = create_test_token_data_with_pairs();
6217        let mut state = MonitorState::new(&initial_data, "ethereum");
6218        assert_eq!(state.current_price, 1.0);
6219        assert_eq!(state.real_data_count, 0);
6220
6221        // Update with new price
6222        let mut updated_data = initial_data.clone();
6223        updated_data.price_usd = 1.05;
6224        updated_data.volume_24h = 1_200_000.0;
6225        state.update(&updated_data);
6226
6227        assert_eq!(state.current_price, 1.05);
6228        assert_eq!(state.real_data_count, 1);
6229        assert!(!state.price_history.is_empty());
6230        assert!(!state.volume_history.is_empty());
6231    }
6232
6233    #[test]
6234    fn test_monitor_state_update_price_unchanged() {
6235        let data = create_test_token_data();
6236        let mut state = MonitorState::new(&data, "ethereum");
6237        state.update(&data);
6238        assert_eq!(state.real_data_count, 1);
6239        // Price didn't change, so last_price_change_at should not update
6240        assert_eq!(state.current_price, 1.0);
6241    }
6242
6243    #[test]
6244    fn test_monitor_state_update_generates_trades() {
6245        let data = create_test_token_data_with_pairs();
6246        let mut state = MonitorState::new(&data, "ethereum");
6247
6248        // Update with changed price to generate a trade
6249        let mut new_data = data.clone();
6250        new_data.price_usd = 1.10;
6251        state.update(&new_data);
6252
6253        // recent_trades should have at least one trade from the update
6254        assert!(!state.recent_trades.is_empty());
6255    }
6256
6257    #[test]
6258    fn test_monitor_state_update_liquidity_pairs() {
6259        let data = create_test_token_data_with_pairs();
6260        let mut state = MonitorState::new(&data, "ethereum");
6261        state.update(&data);
6262        assert_eq!(state.liquidity_pairs.len(), 2);
6263        assert!(state.liquidity_pairs[0].0.contains("TEST/USDC"));
6264        assert!(state.liquidity_pairs[1].0.contains("TEST/WETH"));
6265    }
6266
6267    #[test]
6268    fn test_monitor_state_update_dex_pairs_and_order_book() {
6269        let data = create_test_token_data_with_pairs();
6270        let mut state = MonitorState::new(&data, "ethereum");
6271        state.update(&data);
6272        assert_eq!(state.dex_pairs.len(), 2);
6273        assert!(state.order_book.is_some());
6274    }
6275
6276    #[test]
6277    fn test_monitor_state_update_sells_more_than_buys() {
6278        let mut data = create_test_token_data_with_pairs();
6279        data.total_buys_24h = 10;
6280        data.total_sells_24h = 50;
6281        let mut state = MonitorState::new(&data, "ethereum");
6282        state.update(&data);
6283        // Should produce Sell side trade when sells > buys and price unchanged
6284        assert!(!state.recent_trades.is_empty());
6285    }
6286
6287    #[test]
6288    fn test_monitor_state_update_zero_volume() {
6289        let mut data = create_test_token_data_with_pairs();
6290        data.volume_24h = 0.0;
6291        let mut state = MonitorState::new(&data, "ethereum");
6292        state.update(&data);
6293        // Should still produce a trade with quantity 1.0
6294        if let Some(trade) = state.recent_trades.back() {
6295            assert_eq!(trade.quantity, 1.0);
6296        }
6297    }
6298}