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