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