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