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