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