Skip to main content

scope/cli/
monitor.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//!
29//! The monitor auto-selects a layout based on terminal dimensions (responsive
30//! breakpoints). Manual switching via `L`/`H` disables auto-selection until `A`.
31//!
32//! ## Features
33//!
34//! - Real-time price chart (line, candlestick, or volume profile) with sliding window
35//! - Volume bar chart
36//! - Buy/sell ratio gauge
37//! - Scrollable activity feed (transaction log)
38//! - Key metrics panel with sparkline and stats table
39//! - Config-driven widget visibility (toggle any widget on/off)
40//! - Four layout presets switchable at runtime
41//! - Responsive terminal sizing with auto-layout
42//! - Log/linear Y-axis scale toggle
43//! - Three color schemes (Green/Red, Blue/Orange, Monochrome)
44//! - On-chain holder count integration (when chain client is available)
45//! - Per-pair liquidity depth breakdown across DEXes
46//! - Configurable price alerts (min/max thresholds, whale detection, volume spikes)
47//! - CSV export mode (toggle with `E`, writes to `./scope-exports/`)
48//! - Auto-pause on user input (toggle with `Shift+P`)
49//!
50//! ## Keyboard Controls
51//!
52//! - `Q`/`Esc` quit, `R` refresh, `P`/`Space` pause
53//! - `Shift+P` toggle auto-pause on input
54//! - `E` toggle CSV export (REC indicator when active)
55//! - `L`/`H` cycle layout forward/backward
56//! - `W` + `1-5` toggle widget visibility
57//! - `A` re-enable auto layout
58//! - `C` toggle chart mode, `S` toggle log/linear scale, `/` cycle color scheme
59//! - `T`/`Tab` cycle time period, `1-6` select period
60//! - `J`/`K` scroll activity log, `+`/`-` adjust refresh speed
61
62use crate::chains::dex::{DexClient, DexDataSource, DexTokenData};
63use crate::chains::{ChainClient, ChainClientFactory};
64use crate::config::Config;
65use crate::error::{Result, ScopeError};
66use clap::Args;
67use crossterm::{
68    event::{DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
69    execute,
70};
71use ratatui::{
72    Frame,
73    layout::{Constraint, Direction, Layout, Rect},
74    style::{Color, Style},
75    symbols,
76    text::{Line, Span},
77    widgets::{
78        Axis, Bar, BarChart, BarGroup, Block, Borders, Chart, Dataset, GraphType, List, ListItem,
79        ListState, Paragraph, Row, Sparkline, Table, Tabs,
80        canvas::{Canvas, Line as CanvasLine, Rectangle},
81    },
82};
83use serde::{Deserialize, Serialize};
84use std::collections::VecDeque;
85use std::fs;
86use std::io::{self, BufWriter, Write as _};
87use std::path::PathBuf;
88use std::time::{Duration, Instant};
89
90use super::interactive::SessionContext;
91
92// ============================================================================
93// CLI Arguments
94// ============================================================================
95
96/// Arguments for the top-level `monitor` command.
97///
98/// Launches the live TUI dashboard directly from the command line,
99/// without requiring interactive mode.
100///
101/// # Examples
102///
103/// ```bash
104/// # Monitor by token address
105/// scope monitor 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
106///
107/// # Monitor by symbol on a specific chain
108/// scope monitor USDC --chain ethereum
109///
110/// # Short alias
111/// scope mon PEPE -c ethereum
112///
113/// # Custom layout and refresh rate
114/// scope monitor USDC --layout chart-focus --refresh 3
115/// ```
116#[derive(Debug, Args)]
117pub struct MonitorArgs {
118    /// Token address or symbol to monitor.
119    ///
120    /// Can be a contract address (0x...) or a token symbol/name.
121    /// If a name/symbol is provided, matching tokens will be searched
122    /// and you can select from the results.
123    pub token: String,
124
125    /// Target blockchain network.
126    ///
127    /// Determines which chain to query for token data.
128    #[arg(short, long, default_value = "ethereum")]
129    pub chain: String,
130
131    /// Layout preset for the TUI dashboard.
132    ///
133    /// Controls how widgets are arranged on screen.
134    /// Options: dashboard, chart-focus, feed, compact.
135    #[arg(short, long)]
136    pub layout: Option<LayoutPreset>,
137
138    /// Refresh interval in seconds.
139    ///
140    /// How often to fetch new data from the API.
141    /// Adjustable at runtime with +/- keys.
142    #[arg(short, long)]
143    pub refresh: Option<u64>,
144
145    /// Y-axis scale mode for price charts.
146    ///
147    /// Options: linear, log.
148    #[arg(short, long)]
149    pub scale: Option<ScaleMode>,
150
151    /// Color scheme for charts.
152    ///
153    /// Options: green-red, blue-orange, monochrome.
154    #[arg(long)]
155    pub color_scheme: Option<ColorScheme>,
156
157    /// Start CSV export immediately, writing to the given path.
158    #[arg(short, long, value_name = "PATH")]
159    pub export: Option<PathBuf>,
160}
161
162/// Maximum data retention: 24 hours.
163/// At 5-second intervals: 24 * 60 * 12 = 17,280 points max per history.
164/// With DataPoint at 24 bytes: ~415 KB per history, ~830 KB total.
165/// Data is persisted to OS temp folder for session continuity.
166const MAX_DATA_AGE_SECS: f64 = 24.0 * 3600.0; // 24 hours
167
168/// Cache file prefix in temp directory.
169const CACHE_FILE_PREFIX: &str = "bcc_monitor_";
170
171/// Default refresh interval in seconds.
172const DEFAULT_REFRESH_SECS: u64 = 5;
173
174/// Minimum refresh interval in seconds.
175const MIN_REFRESH_SECS: u64 = 1;
176
177/// Maximum refresh interval in seconds.
178const MAX_REFRESH_SECS: u64 = 60;
179
180/// A data point with timestamp, value, and whether it's real (from API) or synthetic.
181#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
182pub struct DataPoint {
183    /// Unix timestamp in seconds.
184    pub timestamp: f64,
185    /// Value (price or volume).
186    pub value: f64,
187    /// True if this is real data from API, false if synthetic/estimated.
188    pub is_real: bool,
189}
190
191/// OHLC (Open-High-Low-Close) candlestick data for a time period.
192#[derive(Debug, Clone, Copy)]
193pub struct OhlcCandle {
194    /// Start timestamp of this candle period.
195    pub timestamp: f64,
196    /// Opening price.
197    pub open: f64,
198    /// Highest price during the period.
199    pub high: f64,
200    /// Lowest price during the period.
201    pub low: f64,
202    /// Closing price.
203    pub close: f64,
204    /// Whether this candle is bullish (close >= open).
205    pub is_bullish: bool,
206}
207
208impl OhlcCandle {
209    /// Creates a new candle from a single price point.
210    pub fn new(timestamp: f64, price: f64) -> Self {
211        Self {
212            timestamp,
213            open: price,
214            high: price,
215            low: price,
216            close: price,
217            is_bullish: true,
218        }
219    }
220
221    /// Updates the candle with a new price.
222    pub fn update(&mut self, price: f64) {
223        self.high = self.high.max(price);
224        self.low = self.low.min(price);
225        self.close = price;
226        self.is_bullish = self.close >= self.open;
227    }
228}
229
230/// Cached monitor data that persists between sessions.
231#[derive(Debug, Serialize, Deserialize)]
232struct CachedMonitorData {
233    /// Token address this cache is for.
234    token_address: String,
235    /// Chain identifier.
236    chain: String,
237    /// Price history data points.
238    price_history: Vec<DataPoint>,
239    /// Volume history data points.
240    volume_history: Vec<DataPoint>,
241    /// Timestamp when cache was saved.
242    saved_at: f64,
243}
244
245/// Time period for chart display (limited to 24 hours of data retention).
246#[derive(Debug, Clone, Copy, PartialEq, Eq)]
247pub enum TimePeriod {
248    /// Last 1 minute
249    Min1,
250    /// Last 5 minutes
251    Min5,
252    /// Last 15 minutes
253    Min15,
254    /// Last 1 hour
255    Hour1,
256    /// Last 4 hours
257    Hour4,
258    /// Last 24 hours (1 day)
259    Day1,
260}
261
262impl TimePeriod {
263    /// Returns the duration in seconds for this period.
264    pub fn duration_secs(&self) -> i64 {
265        match self {
266            TimePeriod::Min1 => 60,
267            TimePeriod::Min5 => 5 * 60,
268            TimePeriod::Min15 => 15 * 60,
269            TimePeriod::Hour1 => 3600,
270            TimePeriod::Hour4 => 4 * 3600,
271            TimePeriod::Day1 => 24 * 3600,
272        }
273    }
274
275    /// Returns a display label for this period.
276    pub fn label(&self) -> &'static str {
277        match self {
278            TimePeriod::Min1 => "1m",
279            TimePeriod::Min5 => "5m",
280            TimePeriod::Min15 => "15m",
281            TimePeriod::Hour1 => "1h",
282            TimePeriod::Hour4 => "4h",
283            TimePeriod::Day1 => "1d",
284        }
285    }
286
287    /// Returns the zero-based index for this period (for Tabs widget).
288    pub fn index(&self) -> usize {
289        match self {
290            TimePeriod::Min1 => 0,
291            TimePeriod::Min5 => 1,
292            TimePeriod::Min15 => 2,
293            TimePeriod::Hour1 => 3,
294            TimePeriod::Hour4 => 4,
295            TimePeriod::Day1 => 5,
296        }
297    }
298
299    /// Cycles to the next time period.
300    pub fn next(&self) -> Self {
301        match self {
302            TimePeriod::Min1 => TimePeriod::Min5,
303            TimePeriod::Min5 => TimePeriod::Min15,
304            TimePeriod::Min15 => TimePeriod::Hour1,
305            TimePeriod::Hour1 => TimePeriod::Hour4,
306            TimePeriod::Hour4 => TimePeriod::Day1,
307            TimePeriod::Day1 => TimePeriod::Min1,
308        }
309    }
310}
311
312impl std::fmt::Display for TimePeriod {
313    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        write!(f, "{}", self.label())
315    }
316}
317
318/// Chart display mode.
319#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
320pub enum ChartMode {
321    /// Line chart showing price over time.
322    #[default]
323    Line,
324    /// Candlestick chart showing OHLC data.
325    Candlestick,
326    /// Volume profile showing volume distribution by price level.
327    VolumeProfile,
328}
329
330impl ChartMode {
331    /// Cycles to the next chart mode.
332    pub fn next(&self) -> Self {
333        match self {
334            ChartMode::Line => ChartMode::Candlestick,
335            ChartMode::Candlestick => ChartMode::VolumeProfile,
336            ChartMode::VolumeProfile => ChartMode::Line,
337        }
338    }
339
340    /// Returns a display label for this mode.
341    pub fn label(&self) -> &'static str {
342        match self {
343            ChartMode::Line => "Line",
344            ChartMode::Candlestick => "Candle",
345            ChartMode::VolumeProfile => "VolPro",
346        }
347    }
348}
349
350/// Color scheme for the monitor TUI.
351#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
352#[serde(rename_all = "kebab-case")]
353pub enum ColorScheme {
354    /// Classic green/red (default).
355    #[default]
356    GreenRed,
357    /// Blue/orange, better for certain color blindness.
358    BlueOrange,
359    /// Monochrome -- fully accessible grayscale.
360    Monochrome,
361}
362
363impl ColorScheme {
364    /// Cycles to the next color scheme.
365    pub fn next(&self) -> Self {
366        match self {
367            ColorScheme::GreenRed => ColorScheme::BlueOrange,
368            ColorScheme::BlueOrange => ColorScheme::Monochrome,
369            ColorScheme::Monochrome => ColorScheme::GreenRed,
370        }
371    }
372
373    /// Returns the named color palette for this scheme.
374    pub fn palette(&self) -> ColorPalette {
375        match self {
376            ColorScheme::GreenRed => ColorPalette {
377                up: Color::Green,
378                down: Color::Red,
379                neutral: Color::Gray,
380                header_fg: Color::White,
381                border: Color::DarkGray,
382                highlight: Color::Yellow,
383                volume_bar: Color::Blue,
384                sparkline: Color::Cyan,
385            },
386            ColorScheme::BlueOrange => ColorPalette {
387                up: Color::Blue,
388                down: Color::Rgb(255, 165, 0), // orange
389                neutral: Color::Gray,
390                header_fg: Color::White,
391                border: Color::DarkGray,
392                highlight: Color::Cyan,
393                volume_bar: Color::Magenta,
394                sparkline: Color::LightBlue,
395            },
396            ColorScheme::Monochrome => ColorPalette {
397                up: Color::White,
398                down: Color::DarkGray,
399                neutral: Color::Gray,
400                header_fg: Color::White,
401                border: Color::DarkGray,
402                highlight: Color::White,
403                volume_bar: Color::Gray,
404                sparkline: Color::White,
405            },
406        }
407    }
408
409    /// Returns a short display label.
410    pub fn label(&self) -> &'static str {
411        match self {
412            ColorScheme::GreenRed => "G/R",
413            ColorScheme::BlueOrange => "B/O",
414            ColorScheme::Monochrome => "Mono",
415        }
416    }
417}
418
419/// Named color palette derived from a ColorScheme.
420#[derive(Debug, Clone, Copy)]
421pub struct ColorPalette {
422    /// Color for price-up / bullish.
423    pub up: Color,
424    /// Color for price-down / bearish.
425    pub down: Color,
426    /// Neutral/secondary text color.
427    pub neutral: Color,
428    /// Header foreground.
429    pub header_fg: Color,
430    /// Border color.
431    pub border: Color,
432    /// Highlight/accent color.
433    pub highlight: Color,
434    /// Volume bar color.
435    pub volume_bar: Color,
436    /// Sparkline color.
437    pub sparkline: Color,
438}
439
440/// Y-axis scaling mode for price charts.
441#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
442#[serde(rename_all = "kebab-case")]
443pub enum ScaleMode {
444    /// Linear scale (default).
445    #[default]
446    Linear,
447    /// Logarithmic scale -- useful for tokens with very wide price ranges.
448    Log,
449}
450
451impl ScaleMode {
452    /// Toggles between Linear and Log.
453    pub fn toggle(&self) -> Self {
454        match self {
455            ScaleMode::Linear => ScaleMode::Log,
456            ScaleMode::Log => ScaleMode::Linear,
457        }
458    }
459
460    /// Returns a short display label.
461    pub fn label(&self) -> &'static str {
462        match self {
463            ScaleMode::Linear => "Lin",
464            ScaleMode::Log => "Log",
465        }
466    }
467}
468
469/// Alert configuration for price and whale detection.
470#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
471#[serde(default)]
472pub struct AlertConfig {
473    /// Minimum price threshold; alert fires when price drops below this.
474    pub price_min: Option<f64>,
475    /// Maximum price threshold; alert fires when price exceeds this.
476    pub price_max: Option<f64>,
477    /// Minimum USD value for whale transaction detection.
478    pub whale_min_usd: Option<f64>,
479    /// Volume spike threshold as a percentage increase from the rolling average.
480    pub volume_spike_threshold_pct: Option<f64>,
481}
482
483impl Default for AlertConfig {
484    #[allow(clippy::derivable_impls)]
485    fn default() -> Self {
486        Self {
487            price_min: None,
488            price_max: None,
489            whale_min_usd: None,
490            volume_spike_threshold_pct: None,
491        }
492    }
493}
494
495/// An active (currently firing) alert with a description.
496#[derive(Debug, Clone)]
497pub struct ActiveAlert {
498    /// Human-readable message describing the alert.
499    pub message: String,
500    /// When the alert was first triggered.
501    pub triggered_at: Instant,
502}
503
504/// CSV export configuration.
505#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
506#[serde(default)]
507pub struct ExportConfig {
508    /// Base directory for exports (default: `./scope-exports/`).
509    pub path: Option<String>,
510}
511
512impl Default for ExportConfig {
513    #[allow(clippy::derivable_impls)]
514    fn default() -> Self {
515        Self { path: None }
516    }
517}
518
519/// Layout preset for the monitor TUI.
520///
521/// Controls which widgets are shown and how they are arranged.
522/// Can be switched at runtime with keybindings or set via config.
523#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
524#[serde(rename_all = "kebab-case")]
525pub enum LayoutPreset {
526    /// Balanced 2x2 grid with all widgets visible.
527    #[default]
528    Dashboard,
529    /// Price chart takes ~85% of the screen; minimal stats overlay.
530    ChartFocus,
531    /// Transaction/activity feed prioritized; small price ticker.
532    Feed,
533    /// Minimal single-column sparkline view for small terminals.
534    Compact,
535}
536
537impl LayoutPreset {
538    /// Cycles to the next layout preset.
539    pub fn next(&self) -> Self {
540        match self {
541            LayoutPreset::Dashboard => LayoutPreset::ChartFocus,
542            LayoutPreset::ChartFocus => LayoutPreset::Feed,
543            LayoutPreset::Feed => LayoutPreset::Compact,
544            LayoutPreset::Compact => LayoutPreset::Dashboard,
545        }
546    }
547
548    /// Cycles to the previous layout preset.
549    pub fn prev(&self) -> Self {
550        match self {
551            LayoutPreset::Dashboard => LayoutPreset::Compact,
552            LayoutPreset::ChartFocus => LayoutPreset::Dashboard,
553            LayoutPreset::Feed => LayoutPreset::ChartFocus,
554            LayoutPreset::Compact => LayoutPreset::Feed,
555        }
556    }
557
558    /// Returns a display label for this preset.
559    pub fn label(&self) -> &'static str {
560        match self {
561            LayoutPreset::Dashboard => "Dashboard",
562            LayoutPreset::ChartFocus => "Chart",
563            LayoutPreset::Feed => "Feed",
564            LayoutPreset::Compact => "Compact",
565        }
566    }
567}
568
569/// Controls which widgets are visible in the monitor.
570///
571/// Individual widgets can be toggled on/off via keybindings or config.
572/// The layout functions use these flags to decide which `Rect` areas to allocate.
573#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
574#[serde(default)]
575pub struct WidgetVisibility {
576    /// Show the price chart (line or candlestick).
577    pub price_chart: bool,
578    /// Show the volume bar chart.
579    pub volume_chart: bool,
580    /// Show the buy/sell pressure gauge and activity log.
581    pub buy_sell_pressure: bool,
582    /// Show the metrics panel (sparkline + key metrics table).
583    pub metrics_panel: bool,
584    /// Show the activity log feed.
585    pub activity_log: bool,
586    /// Show the holder count in the metrics panel.
587    pub holder_count: bool,
588    /// Show the per-pair liquidity depth.
589    pub liquidity_depth: bool,
590}
591
592impl Default for WidgetVisibility {
593    fn default() -> Self {
594        Self {
595            price_chart: true,
596            volume_chart: true,
597            buy_sell_pressure: true,
598            metrics_panel: true,
599            activity_log: true,
600            holder_count: true,
601            liquidity_depth: true,
602        }
603    }
604}
605
606impl WidgetVisibility {
607    /// Returns the number of visible widgets.
608    pub fn visible_count(&self) -> usize {
609        [
610            self.price_chart,
611            self.volume_chart,
612            self.buy_sell_pressure,
613            self.metrics_panel,
614            self.activity_log,
615        ]
616        .iter()
617        .filter(|&&v| v)
618        .count()
619    }
620
621    /// Toggles a widget by index (1-based: 1=price_chart, 2=volume, 3=buy_sell, 4=metrics, 5=log).
622    pub fn toggle_by_index(&mut self, index: usize) {
623        match index {
624            1 => self.price_chart = !self.price_chart,
625            2 => self.volume_chart = !self.volume_chart,
626            3 => self.buy_sell_pressure = !self.buy_sell_pressure,
627            4 => self.metrics_panel = !self.metrics_panel,
628            5 => self.activity_log = !self.activity_log,
629            _ => {}
630        }
631    }
632}
633
634/// Monitor-specific configuration.
635///
636/// Loaded from the `monitor:` section of `~/.config/scope/config.yaml`.
637/// All fields have sensible defaults so the section is entirely optional.
638#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
639#[serde(default)]
640pub struct MonitorConfig {
641    /// Layout preset to use on startup.
642    pub layout: LayoutPreset,
643    /// Refresh interval in seconds.
644    pub refresh_seconds: u64,
645    /// Widget visibility toggles.
646    pub widgets: WidgetVisibility,
647    /// Y-axis scale mode for price charts.
648    pub scale: ScaleMode,
649    /// Color scheme.
650    pub color_scheme: ColorScheme,
651    /// Alert thresholds (price min/max, whale detection).
652    pub alerts: AlertConfig,
653    /// CSV export settings.
654    pub export: ExportConfig,
655    /// Whether to auto-pause data fetching when the user is interacting.
656    pub auto_pause_on_input: bool,
657}
658
659impl Default for MonitorConfig {
660    fn default() -> Self {
661        Self {
662            layout: LayoutPreset::Dashboard,
663            refresh_seconds: DEFAULT_REFRESH_SECS,
664            widgets: WidgetVisibility::default(),
665            scale: ScaleMode::Linear,
666            color_scheme: ColorScheme::GreenRed,
667            alerts: AlertConfig::default(),
668            export: ExportConfig::default(),
669            auto_pause_on_input: false,
670        }
671    }
672}
673
674/// State for the live token monitor.
675pub struct MonitorState {
676    /// Token contract address.
677    pub token_address: String,
678
679    /// Token symbol.
680    pub symbol: String,
681
682    /// Token name.
683    pub name: String,
684
685    /// Blockchain network.
686    pub chain: String,
687
688    /// Historical price data points with real/synthetic indicator.
689    pub price_history: VecDeque<DataPoint>,
690
691    /// Historical volume data points with real/synthetic indicator.
692    pub volume_history: VecDeque<DataPoint>,
693
694    /// Count of real (non-synthetic) data points.
695    pub real_data_count: usize,
696
697    /// Current price in USD.
698    pub current_price: f64,
699
700    /// 24-hour price change percentage.
701    pub price_change_24h: f64,
702
703    /// 6-hour price change percentage.
704    pub price_change_6h: f64,
705
706    /// 1-hour price change percentage.
707    pub price_change_1h: f64,
708
709    /// 5-minute price change percentage.
710    pub price_change_5m: f64,
711
712    /// Timestamp when the price last changed (Unix timestamp).
713    pub last_price_change_at: f64,
714
715    /// Previous price for change detection.
716    pub previous_price: f64,
717
718    /// Total buy transactions in 24 hours.
719    pub buys_24h: u64,
720
721    /// Total sell transactions in 24 hours.
722    pub sells_24h: u64,
723
724    /// Total liquidity in USD.
725    pub liquidity_usd: f64,
726
727    /// 24-hour volume in USD.
728    pub volume_24h: f64,
729
730    /// Market capitalization.
731    pub market_cap: Option<f64>,
732
733    /// Fully diluted valuation.
734    pub fdv: Option<f64>,
735
736    /// Last update timestamp.
737    pub last_update: Instant,
738
739    /// Refresh rate.
740    pub refresh_rate: Duration,
741
742    /// Whether monitoring is paused.
743    pub paused: bool,
744
745    /// Recent log messages.
746    pub log_messages: VecDeque<String>,
747
748    /// Scroll state for the activity log list widget.
749    pub log_list_state: ListState,
750
751    /// Error message to display (if any).
752    pub error_message: Option<String>,
753
754    /// Selected time period for chart display.
755    pub time_period: TimePeriod,
756
757    /// Chart display mode (line or candlestick).
758    pub chart_mode: ChartMode,
759
760    /// Y-axis scale mode for price charts (Linear or Log).
761    pub scale_mode: ScaleMode,
762
763    /// Active color scheme.
764    pub color_scheme: ColorScheme,
765
766    /// Holder count (fetched from chain client, if available).
767    pub holder_count: Option<u64>,
768
769    /// Per-pair liquidity data: (pair_name, liquidity_usd).
770    pub liquidity_pairs: Vec<(String, f64)>,
771
772    /// Counter to throttle holder count fetches.
773    pub holder_fetch_counter: u32,
774
775    /// Unix timestamp when monitoring started.
776    pub start_timestamp: i64,
777
778    /// Current layout preset.
779    pub layout: LayoutPreset,
780
781    /// Widget visibility toggles.
782    pub widgets: WidgetVisibility,
783
784    /// Whether responsive auto-layout is active (disabled by manual layout switch).
785    pub auto_layout: bool,
786
787    /// Whether the widget-toggle input mode is active (waiting for digit 1-5).
788    pub widget_toggle_mode: bool,
789
790    // ── Phase 7: Alert System ──
791    /// Alert configuration thresholds.
792    pub alerts: AlertConfig,
793
794    /// Currently firing alerts.
795    pub active_alerts: Vec<ActiveAlert>,
796
797    /// Visual flash timer for alert overlay.
798    pub alert_flash_until: Option<Instant>,
799
800    // ── Phase 8: CSV Export ──
801    /// Whether CSV export is currently active.
802    pub export_active: bool,
803
804    /// Path to the current export file.
805    pub export_path: Option<PathBuf>,
806
807    /// Rolling volume average for spike detection (simple moving average).
808    pub volume_avg: f64,
809
810    // ── Phase 9: Auto-Pause ──
811    /// Whether auto-pause on user input is enabled.
812    pub auto_pause_on_input: bool,
813
814    /// Timestamp of the last user key input.
815    pub last_input_at: Instant,
816
817    /// Duration after last input before auto-pause lifts (default 3s).
818    pub auto_pause_timeout: Duration,
819}
820
821impl MonitorState {
822    /// Creates a new monitor state from initial token data.
823    /// Attempts to load cached data from disk first.
824    pub fn new(token_data: &DexTokenData, chain: &str) -> Self {
825        let now = Instant::now();
826        let now_ts = chrono::Utc::now().timestamp() as f64;
827
828        // Try to load cached data first
829        let (price_history, volume_history, real_data_count) =
830            if let Some(cached) = Self::load_cache(&token_data.address, chain) {
831                // Filter out data older than 24 hours
832                let cutoff = now_ts - MAX_DATA_AGE_SECS;
833                let price_hist: VecDeque<DataPoint> = cached
834                    .price_history
835                    .into_iter()
836                    .filter(|p| p.timestamp >= cutoff)
837                    .collect();
838                let vol_hist: VecDeque<DataPoint> = cached
839                    .volume_history
840                    .into_iter()
841                    .filter(|p| p.timestamp >= cutoff)
842                    .collect();
843                let real_count = price_hist.iter().filter(|p| p.is_real).count();
844                (price_hist, vol_hist, real_count)
845            } else {
846                // Generate synthetic historical data from price change percentages
847                let price_hist = Self::generate_synthetic_price_history(
848                    token_data.price_usd,
849                    token_data.price_change_1h,
850                    token_data.price_change_6h,
851                    token_data.price_change_24h,
852                    now_ts,
853                );
854                let vol_hist = Self::generate_synthetic_volume_history(
855                    token_data.volume_24h,
856                    token_data.volume_6h,
857                    token_data.volume_1h,
858                    now_ts,
859                );
860                (price_hist, vol_hist, 0)
861            };
862
863        Self {
864            token_address: token_data.address.clone(),
865            symbol: token_data.symbol.clone(),
866            name: token_data.name.clone(),
867            chain: chain.to_string(),
868            price_history,
869            volume_history,
870            real_data_count,
871            current_price: token_data.price_usd,
872            price_change_24h: token_data.price_change_24h,
873            price_change_6h: token_data.price_change_6h,
874            price_change_1h: token_data.price_change_1h,
875            price_change_5m: token_data.price_change_5m,
876            last_price_change_at: now_ts, // Initialize to current time
877            previous_price: token_data.price_usd,
878            buys_24h: token_data.total_buys_24h,
879            sells_24h: token_data.total_sells_24h,
880            liquidity_usd: token_data.liquidity_usd,
881            volume_24h: token_data.volume_24h,
882            market_cap: token_data.market_cap,
883            fdv: token_data.fdv,
884            last_update: now,
885            refresh_rate: Duration::from_secs(DEFAULT_REFRESH_SECS),
886            paused: false,
887            log_messages: VecDeque::with_capacity(10),
888            log_list_state: ListState::default(),
889            error_message: None,
890            time_period: TimePeriod::Hour1, // Default to 1 hour view
891            chart_mode: ChartMode::Line,    // Default to line chart
892            scale_mode: ScaleMode::Linear,  // Default to linear scale
893            color_scheme: ColorScheme::GreenRed, // Default color scheme
894            holder_count: None,
895            liquidity_pairs: Vec::new(),
896            holder_fetch_counter: 0,
897            start_timestamp: now_ts as i64,
898            layout: LayoutPreset::Dashboard,
899            widgets: WidgetVisibility::default(),
900            auto_layout: true,
901            widget_toggle_mode: false,
902            // Phase 7: Alerts
903            alerts: AlertConfig::default(),
904            active_alerts: Vec::new(),
905            alert_flash_until: None,
906            // Phase 8: Export
907            export_active: false,
908            export_path: None,
909            volume_avg: token_data.volume_24h,
910            // Phase 9: Auto-Pause
911            auto_pause_on_input: false,
912            last_input_at: now,
913            auto_pause_timeout: Duration::from_secs(3),
914        }
915    }
916
917    /// Applies monitor config settings to this state.
918    pub fn apply_config(&mut self, config: &MonitorConfig) {
919        self.layout = config.layout;
920        self.widgets = config.widgets.clone();
921        self.refresh_rate = Duration::from_secs(config.refresh_seconds);
922        self.scale_mode = config.scale;
923        self.color_scheme = config.color_scheme;
924        self.alerts = config.alerts.clone();
925        self.auto_pause_on_input = config.auto_pause_on_input;
926    }
927
928    /// Toggles between line and candlestick chart modes.
929    /// Returns the current color palette based on the active color scheme.
930    pub fn palette(&self) -> ColorPalette {
931        self.color_scheme.palette()
932    }
933
934    pub fn toggle_chart_mode(&mut self) {
935        self.chart_mode = self.chart_mode.next();
936        self.log(format!("Chart mode: {}", self.chart_mode.label()));
937    }
938
939    /// Returns the path to the cache file for a token.
940    fn cache_path(token_address: &str, chain: &str) -> PathBuf {
941        let mut path = std::env::temp_dir();
942        // Create a safe filename from address (first 16 chars) and chain
943        let safe_addr = token_address
944            .chars()
945            .filter(|c| c.is_alphanumeric())
946            .take(16)
947            .collect::<String>()
948            .to_lowercase();
949        path.push(format!("{}{}_{}.json", CACHE_FILE_PREFIX, chain, safe_addr));
950        path
951    }
952
953    /// Loads cached monitor data from disk.
954    fn load_cache(token_address: &str, chain: &str) -> Option<CachedMonitorData> {
955        let path = Self::cache_path(token_address, chain);
956        if !path.exists() {
957            return None;
958        }
959
960        match fs::read_to_string(&path) {
961            Ok(contents) => {
962                match serde_json::from_str::<CachedMonitorData>(&contents) {
963                    Ok(cached) => {
964                        // Verify this is for the same token
965                        if cached.token_address.to_lowercase() == token_address.to_lowercase()
966                            && cached.chain.to_lowercase() == chain.to_lowercase()
967                        {
968                            Some(cached)
969                        } else {
970                            None
971                        }
972                    }
973                    Err(_) => None,
974                }
975            }
976            Err(_) => None,
977        }
978    }
979
980    /// Saves monitor data to cache file.
981    pub fn save_cache(&self) {
982        let cached = CachedMonitorData {
983            token_address: self.token_address.clone(),
984            chain: self.chain.clone(),
985            price_history: self.price_history.iter().copied().collect(),
986            volume_history: self.volume_history.iter().copied().collect(),
987            saved_at: chrono::Utc::now().timestamp() as f64,
988        };
989
990        let path = Self::cache_path(&self.token_address, &self.chain);
991        if let Ok(json) = serde_json::to_string(&cached) {
992            let _ = fs::write(&path, json);
993        }
994    }
995
996    /// Generates synthetic price history from percentage changes.
997    /// All generated points are marked as synthetic (is_real = false).
998    fn generate_synthetic_price_history(
999        current_price: f64,
1000        change_1h: f64,
1001        change_6h: f64,
1002        change_24h: f64,
1003        now_ts: f64,
1004    ) -> VecDeque<DataPoint> {
1005        let mut history = VecDeque::with_capacity(50);
1006
1007        // Calculate prices at known points (working backwards from current)
1008        let price_1h_ago = current_price / (1.0 + change_1h / 100.0);
1009        let price_6h_ago = current_price / (1.0 + change_6h / 100.0);
1010        let price_24h_ago = current_price / (1.0 + change_24h / 100.0);
1011
1012        // Generate points: 24h ago, 12h ago, 6h ago, 3h ago, 1h ago, 30m ago, now
1013        let points = [
1014            (now_ts - 24.0 * 3600.0, price_24h_ago),
1015            (now_ts - 12.0 * 3600.0, (price_24h_ago + price_6h_ago) / 2.0),
1016            (now_ts - 6.0 * 3600.0, price_6h_ago),
1017            (now_ts - 3.0 * 3600.0, (price_6h_ago + price_1h_ago) / 2.0),
1018            (now_ts - 1.0 * 3600.0, price_1h_ago),
1019            (now_ts - 0.5 * 3600.0, (price_1h_ago + current_price) / 2.0),
1020            (now_ts, current_price),
1021        ];
1022
1023        // Interpolate to create more points for smoother charts
1024        for i in 0..points.len() - 1 {
1025            let (t1, p1) = points[i];
1026            let (t2, p2) = points[i + 1];
1027            let steps = 4; // Number of interpolated points between each pair
1028
1029            for j in 0..steps {
1030                let frac = j as f64 / steps as f64;
1031                let t = t1 + (t2 - t1) * frac;
1032                let p = p1 + (p2 - p1) * frac;
1033                history.push_back(DataPoint {
1034                    timestamp: t,
1035                    value: p,
1036                    is_real: false, // Synthetic data
1037                });
1038            }
1039        }
1040        // Add the final point (also synthetic since it's estimated)
1041        history.push_back(DataPoint {
1042            timestamp: points[points.len() - 1].0,
1043            value: points[points.len() - 1].1,
1044            is_real: false,
1045        });
1046
1047        history
1048    }
1049
1050    /// Generates synthetic volume history from known data points.
1051    /// All generated points are marked as synthetic (is_real = false).
1052    fn generate_synthetic_volume_history(
1053        volume_24h: f64,
1054        volume_6h: f64,
1055        volume_1h: f64,
1056        now_ts: f64,
1057    ) -> VecDeque<DataPoint> {
1058        let mut history = VecDeque::with_capacity(24);
1059
1060        // Create hourly volume estimates
1061        let hourly_avg = volume_24h / 24.0;
1062
1063        for i in 0..24 {
1064            let hours_ago = 24 - i;
1065            let ts = now_ts - (hours_ago as f64) * 3600.0;
1066
1067            // Use more accurate data for recent hours
1068            let volume = if hours_ago <= 1 {
1069                volume_1h
1070            } else if hours_ago <= 6 {
1071                volume_6h / 6.0
1072            } else {
1073                // Estimate with some variation
1074                hourly_avg * (0.8 + (i as f64 / 24.0) * 0.4)
1075            };
1076
1077            history.push_back(DataPoint {
1078                timestamp: ts,
1079                value: volume,
1080                is_real: false, // Synthetic data
1081            });
1082        }
1083
1084        history
1085    }
1086
1087    /// Updates the state with new token data.
1088    /// New data points are marked as real (is_real = true).
1089    pub fn update(&mut self, token_data: &DexTokenData) {
1090        let now_ts = chrono::Utc::now().timestamp() as f64;
1091
1092        // Add new REAL data points
1093        self.price_history.push_back(DataPoint {
1094            timestamp: now_ts,
1095            value: token_data.price_usd,
1096            is_real: true,
1097        });
1098        self.volume_history.push_back(DataPoint {
1099            timestamp: now_ts,
1100            value: token_data.volume_24h,
1101            is_real: true,
1102        });
1103        self.real_data_count += 1;
1104
1105        // Trim data points older than 24 hours
1106        let cutoff = now_ts - MAX_DATA_AGE_SECS;
1107
1108        while let Some(point) = self.price_history.front() {
1109            if point.timestamp < cutoff {
1110                self.price_history.pop_front();
1111            } else {
1112                break;
1113            }
1114        }
1115        while let Some(point) = self.volume_history.front() {
1116            if point.timestamp < cutoff {
1117                self.volume_history.pop_front();
1118            } else {
1119                break;
1120            }
1121        }
1122
1123        // Track when price actually changes (using 8 decimal precision for stablecoins)
1124        let price_changed = (self.previous_price - token_data.price_usd).abs() > 0.00000001;
1125        if price_changed {
1126            self.last_price_change_at = now_ts;
1127            self.previous_price = token_data.price_usd;
1128        }
1129
1130        // Update current values
1131        self.current_price = token_data.price_usd;
1132        self.price_change_24h = token_data.price_change_24h;
1133        self.price_change_6h = token_data.price_change_6h;
1134        self.price_change_1h = token_data.price_change_1h;
1135        self.price_change_5m = token_data.price_change_5m;
1136        self.buys_24h = token_data.total_buys_24h;
1137        self.sells_24h = token_data.total_sells_24h;
1138        self.liquidity_usd = token_data.liquidity_usd;
1139        self.volume_24h = token_data.volume_24h;
1140        self.market_cap = token_data.market_cap;
1141        self.fdv = token_data.fdv;
1142
1143        // Extract per-pair liquidity data
1144        self.liquidity_pairs = token_data
1145            .pairs
1146            .iter()
1147            .map(|p| {
1148                let label = format!("{}/{} ({})", p.base_token, p.quote_token, p.dex_name);
1149                (label, p.liquidity_usd)
1150            })
1151            .collect();
1152
1153        self.last_update = Instant::now();
1154        self.error_message = None;
1155
1156        // ── Alert checks ──
1157        self.check_alerts(token_data);
1158
1159        // ── CSV export ──
1160        if self.export_active {
1161            self.write_export_row();
1162        }
1163
1164        // ── Volume average for spike detection ──
1165        // Exponential moving average: 10% weight on new value
1166        self.volume_avg = self.volume_avg * 0.9 + token_data.volume_24h * 0.1;
1167
1168        self.log(format!("Updated: ${:.6}", token_data.price_usd));
1169
1170        // Periodically save to cache (every 60 updates, ~5 minutes at 5s refresh)
1171        if self.real_data_count.is_multiple_of(60) {
1172            self.save_cache();
1173        }
1174    }
1175
1176    /// Checks alert thresholds and updates active_alerts.
1177    fn check_alerts(&mut self, token_data: &DexTokenData) {
1178        self.active_alerts.clear();
1179
1180        // Price min alert
1181        if let Some(min) = self.alerts.price_min
1182            && self.current_price < min
1183        {
1184            self.active_alerts.push(ActiveAlert {
1185                message: format!("⚠ Price ${:.6} below min ${:.6}", self.current_price, min),
1186                triggered_at: Instant::now(),
1187            });
1188        }
1189
1190        // Price max alert
1191        if let Some(max) = self.alerts.price_max
1192            && self.current_price > max
1193        {
1194            self.active_alerts.push(ActiveAlert {
1195                message: format!("⚠ Price ${:.6} above max ${:.6}", self.current_price, max),
1196                triggered_at: Instant::now(),
1197            });
1198        }
1199
1200        // Volume spike alert
1201        if let Some(threshold_pct) = self.alerts.volume_spike_threshold_pct
1202            && self.volume_avg > 0.0
1203        {
1204            let spike_pct = ((token_data.volume_24h - self.volume_avg) / self.volume_avg) * 100.0;
1205            if spike_pct > threshold_pct {
1206                self.active_alerts.push(ActiveAlert {
1207                    message: format!("⚠ Volume spike: +{:.1}% vs avg", spike_pct),
1208                    triggered_at: Instant::now(),
1209                });
1210            }
1211        }
1212
1213        // Whale detection — estimate from buy/sell changes
1214        if let Some(whale_min) = self.alerts.whale_min_usd {
1215            // Approximate single-transaction size from volume/tx count
1216            let total_txs = (token_data.total_buys_24h + token_data.total_sells_24h) as f64;
1217            if total_txs > 0.0 && token_data.volume_24h / total_txs >= whale_min {
1218                let avg_tx_size = token_data.volume_24h / total_txs;
1219                self.active_alerts.push(ActiveAlert {
1220                    message: format!(
1221                        "🐋 Avg tx size {} ≥ whale threshold {}",
1222                        format_usd(avg_tx_size),
1223                        format_usd(whale_min)
1224                    ),
1225                    triggered_at: Instant::now(),
1226                });
1227            }
1228        }
1229
1230        // Set flash timer if any alerts are active
1231        if !self.active_alerts.is_empty() {
1232            self.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
1233        }
1234    }
1235
1236    /// Writes a single CSV row to the export file.
1237    fn write_export_row(&mut self) {
1238        if let Some(ref path) = self.export_path {
1239            // Open file in append mode
1240            if let Ok(file) = fs::OpenOptions::new().append(true).open(path) {
1241                let mut writer = BufWriter::new(file);
1242                let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
1243                let market_cap_str = self
1244                    .market_cap
1245                    .map(|mc| format!("{:.2}", mc))
1246                    .unwrap_or_default();
1247                let row = format!(
1248                    "{},{:.8},{:.2},{:.2},{},{},{}\n",
1249                    timestamp,
1250                    self.current_price,
1251                    self.volume_24h,
1252                    self.liquidity_usd,
1253                    self.buys_24h,
1254                    self.sells_24h,
1255                    market_cap_str,
1256                );
1257                let _ = writer.write_all(row.as_bytes());
1258            }
1259        }
1260    }
1261
1262    /// Starts CSV export: creates the file and writes the header.
1263    pub fn start_export(&mut self) {
1264        let base_dir = PathBuf::from("./scope-exports");
1265        let _ = fs::create_dir_all(&base_dir);
1266        let date_str = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
1267        let filename = format!("{}_{}.csv", self.symbol, date_str);
1268        let path = base_dir.join(filename);
1269
1270        // Write CSV header
1271        if let Ok(mut file) = fs::File::create(&path) {
1272            let header =
1273                "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
1274            let _ = file.write_all(header.as_bytes());
1275        }
1276
1277        self.export_path = Some(path.clone());
1278        self.export_active = true;
1279        self.log(format!("Export started: {}", path.display()));
1280    }
1281
1282    /// Stops CSV export and closes the file.
1283    pub fn stop_export(&mut self) {
1284        if let Some(ref path) = self.export_path {
1285            self.log(format!("Export saved: {}", path.display()));
1286        }
1287        self.export_active = false;
1288        self.export_path = None;
1289    }
1290
1291    /// Toggles CSV export on/off.
1292    pub fn toggle_export(&mut self) {
1293        if self.export_active {
1294            self.stop_export();
1295        } else {
1296            self.start_export();
1297        }
1298    }
1299
1300    /// Returns data points filtered by the current time period.
1301    /// Returns tuples for chart compatibility, plus a separate vector of is_real flags.
1302    pub fn get_price_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
1303        let now_ts = chrono::Utc::now().timestamp() as f64;
1304        let cutoff = now_ts - self.time_period.duration_secs() as f64;
1305
1306        let filtered: Vec<&DataPoint> = self
1307            .price_history
1308            .iter()
1309            .filter(|p| p.timestamp >= cutoff)
1310            .collect();
1311
1312        let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
1313        let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
1314
1315        (data, is_real)
1316    }
1317
1318    /// Returns volume data filtered by the current time period.
1319    /// Returns tuples for chart compatibility, plus a separate vector of is_real flags.
1320    pub fn get_volume_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
1321        let now_ts = chrono::Utc::now().timestamp() as f64;
1322        let cutoff = now_ts - self.time_period.duration_secs() as f64;
1323
1324        let filtered: Vec<&DataPoint> = self
1325            .volume_history
1326            .iter()
1327            .filter(|p| p.timestamp >= cutoff)
1328            .collect();
1329
1330        let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
1331        let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
1332
1333        (data, is_real)
1334    }
1335
1336    /// Returns count of synthetic vs real data points in the current view.
1337    pub fn data_stats(&self) -> (usize, usize) {
1338        let now_ts = chrono::Utc::now().timestamp() as f64;
1339        let cutoff = now_ts - self.time_period.duration_secs() as f64;
1340
1341        let (synthetic, real) = self
1342            .price_history
1343            .iter()
1344            .filter(|p| p.timestamp >= cutoff)
1345            .fold(
1346                (0, 0),
1347                |(s, r), p| {
1348                    if p.is_real { (s, r + 1) } else { (s + 1, r) }
1349                },
1350            );
1351
1352        (synthetic, real)
1353    }
1354
1355    /// Estimates memory usage of stored data in bytes.
1356    pub fn memory_usage(&self) -> usize {
1357        // DataPoint is 24 bytes (f64 + f64 + bool + padding)
1358        let point_size = std::mem::size_of::<DataPoint>();
1359        (self.price_history.len() + self.volume_history.len()) * point_size
1360    }
1361
1362    /// Generates OHLC candles from price history for the current time period.
1363    ///
1364    /// The candle duration is automatically determined based on the selected time period:
1365    /// - 1m view: 5-second candles
1366    /// - 5m view: 15-second candles
1367    /// - 15m view: 1-minute candles
1368    /// - 1h view: 5-minute candles
1369    /// - 4h view: 15-minute candles
1370    /// - 1d view: 1-hour candles
1371    pub fn get_ohlc_candles(&self) -> Vec<OhlcCandle> {
1372        let (data, _) = self.get_price_data_for_period();
1373
1374        if data.is_empty() {
1375            return vec![];
1376        }
1377
1378        // Determine candle duration based on time period
1379        let candle_duration_secs = match self.time_period {
1380            TimePeriod::Min1 => 5.0,    // 5-second candles
1381            TimePeriod::Min5 => 15.0,   // 15-second candles
1382            TimePeriod::Min15 => 60.0,  // 1-minute candles
1383            TimePeriod::Hour1 => 300.0, // 5-minute candles
1384            TimePeriod::Hour4 => 900.0, // 15-minute candles
1385            TimePeriod::Day1 => 3600.0, // 1-hour candles
1386        };
1387
1388        let mut candles: Vec<OhlcCandle> = Vec::new();
1389
1390        for (timestamp, price) in data {
1391            // Determine which candle this point belongs to
1392            let candle_start = (timestamp / candle_duration_secs).floor() * candle_duration_secs;
1393
1394            if let Some(last_candle) = candles.last_mut() {
1395                if (last_candle.timestamp - candle_start).abs() < 0.001 {
1396                    // Same candle, update it
1397                    last_candle.update(price);
1398                } else {
1399                    // New candle
1400                    candles.push(OhlcCandle::new(candle_start, price));
1401                }
1402            } else {
1403                // First candle
1404                candles.push(OhlcCandle::new(candle_start, price));
1405            }
1406        }
1407
1408        candles
1409    }
1410
1411    /// Cycles to the next time period.
1412    pub fn cycle_time_period(&mut self) {
1413        self.time_period = self.time_period.next();
1414        self.log(format!("Time period: {}", self.time_period.label()));
1415    }
1416
1417    /// Sets a specific time period.
1418    pub fn set_time_period(&mut self, period: TimePeriod) {
1419        self.time_period = period;
1420        self.log(format!("Time period: {}", period.label()));
1421    }
1422
1423    /// Logs a message to the log panel.
1424    fn log(&mut self, message: String) {
1425        let timestamp = chrono::Local::now().format("%H:%M:%S").to_string();
1426        self.log_messages
1427            .push_back(format!("[{}] {}", timestamp, message));
1428        while self.log_messages.len() > 10 {
1429            self.log_messages.pop_front();
1430        }
1431    }
1432
1433    /// Returns whether a refresh is needed.
1434    /// Respects manual pause, auto-pause on input, and the refresh interval.
1435    pub fn should_refresh(&self) -> bool {
1436        if self.paused {
1437            return false;
1438        }
1439        // Auto-pause: if the user is actively interacting, defer refresh
1440        if self.auto_pause_on_input && self.last_input_at.elapsed() < self.auto_pause_timeout {
1441            return false;
1442        }
1443        self.last_update.elapsed() >= self.refresh_rate
1444    }
1445
1446    /// Returns true if auto-pause is currently suppressing refreshes.
1447    pub fn is_auto_paused(&self) -> bool {
1448        self.auto_pause_on_input && self.last_input_at.elapsed() < self.auto_pause_timeout
1449    }
1450
1451    /// Toggles pause state.
1452    pub fn toggle_pause(&mut self) {
1453        self.paused = !self.paused;
1454        self.log(if self.paused {
1455            "Paused".to_string()
1456        } else {
1457            "Resumed".to_string()
1458        });
1459    }
1460
1461    /// Forces an immediate refresh.
1462    pub fn force_refresh(&mut self) {
1463        self.paused = false;
1464        self.last_update = Instant::now() - self.refresh_rate;
1465    }
1466
1467    /// Increases refresh interval (slower updates).
1468    pub fn slower_refresh(&mut self) {
1469        let current_secs = self.refresh_rate.as_secs();
1470        let new_secs = (current_secs + 5).min(MAX_REFRESH_SECS);
1471        self.refresh_rate = Duration::from_secs(new_secs);
1472        self.log(format!("Refresh rate: {}s", new_secs));
1473    }
1474
1475    /// Decreases refresh interval (faster updates).
1476    pub fn faster_refresh(&mut self) {
1477        let current_secs = self.refresh_rate.as_secs();
1478        let new_secs = current_secs.saturating_sub(5).max(MIN_REFRESH_SECS);
1479        self.refresh_rate = Duration::from_secs(new_secs);
1480        self.log(format!("Refresh rate: {}s", new_secs));
1481    }
1482
1483    /// Scrolls the activity log down (newer messages).
1484    pub fn scroll_log_down(&mut self) {
1485        let len = self.log_messages.len();
1486        if len == 0 {
1487            return;
1488        }
1489        let i = self
1490            .log_list_state
1491            .selected()
1492            .map_or(0, |i| if i + 1 < len { i + 1 } else { i });
1493        self.log_list_state.select(Some(i));
1494    }
1495
1496    /// Scrolls the activity log up (older messages).
1497    pub fn scroll_log_up(&mut self) {
1498        let i = self
1499            .log_list_state
1500            .selected()
1501            .map_or(0, |i| i.saturating_sub(1));
1502        self.log_list_state.select(Some(i));
1503    }
1504
1505    /// Returns the current refresh rate in seconds.
1506    pub fn refresh_rate_secs(&self) -> u64 {
1507        self.refresh_rate.as_secs()
1508    }
1509
1510    /// Returns the buy/sell ratio as a percentage (0.0 to 1.0).
1511    pub fn buy_ratio(&self) -> f64 {
1512        let total = self.buys_24h + self.sells_24h;
1513        if total == 0 {
1514            0.5
1515        } else {
1516            self.buys_24h as f64 / total as f64
1517        }
1518    }
1519}
1520
1521/// Main monitor application, generic over terminal backend for testability.
1522///
1523/// The default type parameter (`CrosstermBackend<Stdout>`) is used in production.
1524/// Tests use `TestBackend` to avoid real terminal I/O.
1525pub struct MonitorApp<B: ratatui::backend::Backend = ratatui::backend::CrosstermBackend<io::Stdout>>
1526{
1527    /// Terminal backend.
1528    terminal: ratatui::Terminal<B>,
1529
1530    /// Monitor state.
1531    state: MonitorState,
1532
1533    /// DEX client for fetching data.
1534    dex_client: Box<dyn DexDataSource>,
1535
1536    /// Optional chain client for on-chain data (holder count, etc.).
1537    chain_client: Option<Box<dyn ChainClient>>,
1538
1539    /// Whether to exit the application.
1540    should_exit: bool,
1541
1542    /// Whether this app owns the real terminal (and should restore it on drop).
1543    /// False for test instances using `TestBackend`.
1544    owns_terminal: bool,
1545}
1546
1547/// Production constructor and terminal-specific methods.
1548impl MonitorApp {
1549    /// Creates a new monitor application with a real terminal and live DEX client.
1550    pub fn new(
1551        initial_data: DexTokenData,
1552        chain: &str,
1553        monitor_config: &MonitorConfig,
1554        chain_client: Option<Box<dyn ChainClient>>,
1555    ) -> Result<Self> {
1556        // Setup terminal using ratatui's simplified init
1557        let terminal = ratatui::init();
1558        // Enable mouse capture (not handled by ratatui::init)
1559        execute!(io::stdout(), EnableMouseCapture)
1560            .map_err(|e| ScopeError::Chain(format!("Failed to enable mouse capture: {}", e)))?;
1561
1562        let mut state = MonitorState::new(&initial_data, chain);
1563        state.apply_config(monitor_config);
1564
1565        Ok(Self {
1566            terminal,
1567            state,
1568            dex_client: Box::new(DexClient::new()),
1569            chain_client,
1570            should_exit: false,
1571            owns_terminal: true,
1572        })
1573    }
1574
1575    /// Runs the main event loop using async event stream.
1576    pub async fn run(&mut self) -> Result<()> {
1577        use futures::StreamExt;
1578
1579        let mut event_stream = crossterm::event::EventStream::new();
1580
1581        loop {
1582            // Render UI
1583            self.terminal.draw(|f| ui(f, &mut self.state))?;
1584
1585            // Calculate how long until next refresh
1586            let refresh_delay = if self.state.paused {
1587                Duration::from_millis(200) // Just check for events while paused
1588            } else {
1589                let elapsed = self.state.last_update.elapsed();
1590                self.state.refresh_rate.saturating_sub(elapsed)
1591            };
1592
1593            // Wait for either an event or the refresh timer
1594            tokio::select! {
1595                maybe_event = event_stream.next() => {
1596                    match maybe_event {
1597                        Some(Ok(Event::Key(key))) => {
1598                            self.handle_key_event(key);
1599                        }
1600                        Some(Ok(Event::Resize(_, _))) => {
1601                            // Terminal resized — ui() will pick up new size on next draw
1602                        }
1603                        _ => {}
1604                    }
1605                }
1606                _ = tokio::time::sleep(refresh_delay) => {
1607                    // Timer expired — check if refresh is needed
1608                }
1609            }
1610
1611            if self.should_exit {
1612                break;
1613            }
1614
1615            // Check if refresh needed
1616            if self.state.should_refresh() {
1617                self.fetch_data().await;
1618            }
1619        }
1620
1621        Ok(())
1622    }
1623}
1624
1625impl<B: ratatui::backend::Backend> Drop for MonitorApp<B> {
1626    fn drop(&mut self) {
1627        if self.owns_terminal {
1628            let _ = execute!(io::stdout(), DisableMouseCapture);
1629            ratatui::restore();
1630        }
1631    }
1632}
1633
1634/// Methods that work with any terminal backend (production or test).
1635/// Includes cleanup, key handling, and data fetching.
1636impl<B: ratatui::backend::Backend> MonitorApp<B> {
1637    /// Cleans up terminal state. Only performs real terminal restore when
1638    /// this app owns the terminal (production mode).
1639    pub fn cleanup(&mut self) -> Result<()> {
1640        // Save cache before exiting
1641        self.state.save_cache();
1642
1643        if self.owns_terminal {
1644            // Disable mouse capture (not handled by ratatui::restore)
1645            let _ = execute!(io::stdout(), DisableMouseCapture);
1646            // Restore terminal using ratatui's simplified cleanup
1647            ratatui::restore();
1648        }
1649        Ok(())
1650    }
1651
1652    /// Handles a single key event, updating state accordingly.
1653    /// Extracted from the event loop for testability.
1654    fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) {
1655        // Track last input time for auto-pause
1656        self.state.last_input_at = Instant::now();
1657
1658        // Widget toggle mode: waiting for digit 1-5
1659        if self.state.widget_toggle_mode {
1660            self.state.widget_toggle_mode = false;
1661            if let KeyCode::Char(c @ '1'..='5') = key.code {
1662                let idx = (c as u8 - b'0') as usize;
1663                self.state.widgets.toggle_by_index(idx);
1664                return;
1665            }
1666            // Any other key cancels the mode and falls through
1667        }
1668
1669        match key.code {
1670            KeyCode::Char('q') | KeyCode::Esc => {
1671                // Stop export before quitting if active
1672                if self.state.export_active {
1673                    self.state.stop_export();
1674                }
1675                self.should_exit = true;
1676            }
1677            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1678                if self.state.export_active {
1679                    self.state.stop_export();
1680                }
1681                self.should_exit = true;
1682            }
1683            KeyCode::Char('r') => {
1684                self.state.force_refresh();
1685            }
1686            // Shift+P toggles auto-pause on input
1687            KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::SHIFT) => {
1688                self.state.auto_pause_on_input = !self.state.auto_pause_on_input;
1689                self.state.log(format!(
1690                    "Auto-pause: {}",
1691                    if self.state.auto_pause_on_input {
1692                        "ON"
1693                    } else {
1694                        "OFF"
1695                    }
1696                ));
1697            }
1698            KeyCode::Char('p') | KeyCode::Char(' ') => {
1699                self.state.toggle_pause();
1700            }
1701            // Toggle CSV export
1702            KeyCode::Char('e') => {
1703                self.state.toggle_export();
1704            }
1705            // Increase refresh interval (slower)
1706            KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
1707                self.state.slower_refresh();
1708            }
1709            // Decrease refresh interval (faster)
1710            KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
1711                self.state.faster_refresh();
1712            }
1713            // Time period selection (1=1m, 2=5m, 3=15m, 4=1h, 5=4h, 6=1d)
1714            KeyCode::Char('1') => {
1715                self.state.set_time_period(TimePeriod::Min1);
1716            }
1717            KeyCode::Char('2') => {
1718                self.state.set_time_period(TimePeriod::Min5);
1719            }
1720            KeyCode::Char('3') => {
1721                self.state.set_time_period(TimePeriod::Min15);
1722            }
1723            KeyCode::Char('4') => {
1724                self.state.set_time_period(TimePeriod::Hour1);
1725            }
1726            KeyCode::Char('5') => {
1727                self.state.set_time_period(TimePeriod::Hour4);
1728            }
1729            KeyCode::Char('6') => {
1730                self.state.set_time_period(TimePeriod::Day1);
1731            }
1732            KeyCode::Char('t') | KeyCode::Tab => {
1733                self.state.cycle_time_period();
1734            }
1735            // Toggle chart mode (line/candlestick/volume-profile)
1736            KeyCode::Char('c') => {
1737                self.state.toggle_chart_mode();
1738            }
1739            // Toggle scale mode (linear/log)
1740            KeyCode::Char('s') => {
1741                self.state.scale_mode = self.state.scale_mode.toggle();
1742                self.state
1743                    .log(format!("Scale: {}", self.state.scale_mode.label()));
1744            }
1745            // Cycle color scheme
1746            KeyCode::Char('/') => {
1747                self.state.color_scheme = self.state.color_scheme.next();
1748                self.state
1749                    .log(format!("Colors: {}", self.state.color_scheme.label()));
1750            }
1751            // Scroll activity log
1752            KeyCode::Char('j') | KeyCode::Down => {
1753                self.state.scroll_log_down();
1754            }
1755            KeyCode::Char('k') | KeyCode::Up => {
1756                self.state.scroll_log_up();
1757            }
1758            // Layout cycling
1759            KeyCode::Char('l') => {
1760                self.state.layout = self.state.layout.next();
1761                self.state.auto_layout = false;
1762            }
1763            KeyCode::Char('h') => {
1764                self.state.layout = self.state.layout.prev();
1765                self.state.auto_layout = false;
1766            }
1767            // Widget toggle mode
1768            KeyCode::Char('w') => {
1769                self.state.widget_toggle_mode = true;
1770            }
1771            // Re-enable auto layout
1772            KeyCode::Char('a') => {
1773                self.state.auto_layout = true;
1774            }
1775            _ => {}
1776        }
1777    }
1778
1779    /// Fetches new data from the API.
1780    async fn fetch_data(&mut self) {
1781        match self
1782            .dex_client
1783            .get_token_data(&self.state.chain, &self.state.token_address)
1784            .await
1785        {
1786            Ok(data) => {
1787                self.state.update(&data);
1788            }
1789            Err(e) => {
1790                self.state.error_message = Some(format!("API Error: {}", e));
1791                self.state.last_update = Instant::now(); // Prevent rapid retries
1792            }
1793        }
1794
1795        // Periodically fetch holder count via chain client (~every 12th refresh ≈ 60s at 5s rate)
1796        self.state.holder_fetch_counter += 1;
1797        if self.state.holder_fetch_counter.is_multiple_of(12)
1798            && let Some(ref client) = self.chain_client
1799        {
1800            match client
1801                .get_token_holder_count(&self.state.token_address)
1802                .await
1803            {
1804                Ok(count) if count > 0 => {
1805                    self.state.holder_count = Some(count);
1806                }
1807                _ => {} // Keep previous value or None
1808            }
1809        }
1810    }
1811}
1812
1813/// Handles a key event by mutating state. Standalone version for testability.
1814/// Returns true if the application should exit.
1815#[cfg(test)]
1816fn handle_key_event_on_state(key: crossterm::event::KeyEvent, state: &mut MonitorState) -> bool {
1817    // Track last input time for auto-pause
1818    state.last_input_at = Instant::now();
1819
1820    // Widget toggle mode: waiting for digit 1-5
1821    if state.widget_toggle_mode {
1822        state.widget_toggle_mode = false;
1823        if let KeyCode::Char(c @ '1'..='5') = key.code {
1824            let idx = (c as u8 - b'0') as usize;
1825            state.widgets.toggle_by_index(idx);
1826            return false;
1827        }
1828        // Any other key cancels the mode and falls through
1829    }
1830
1831    match key.code {
1832        KeyCode::Char('q') | KeyCode::Esc => {
1833            if state.export_active {
1834                state.stop_export();
1835            }
1836            return true;
1837        }
1838        KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1839            if state.export_active {
1840                state.stop_export();
1841            }
1842            return true;
1843        }
1844        KeyCode::Char('r') => {
1845            state.force_refresh();
1846        }
1847        // Shift+P toggles auto-pause
1848        KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::SHIFT) => {
1849            state.auto_pause_on_input = !state.auto_pause_on_input;
1850            state.log(format!(
1851                "Auto-pause: {}",
1852                if state.auto_pause_on_input {
1853                    "ON"
1854                } else {
1855                    "OFF"
1856                }
1857            ));
1858        }
1859        KeyCode::Char('p') | KeyCode::Char(' ') => {
1860            state.toggle_pause();
1861        }
1862        // Toggle CSV export
1863        KeyCode::Char('e') => {
1864            state.toggle_export();
1865        }
1866        KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
1867            state.slower_refresh();
1868        }
1869        KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
1870            state.faster_refresh();
1871        }
1872        KeyCode::Char('1') => {
1873            state.set_time_period(TimePeriod::Min1);
1874        }
1875        KeyCode::Char('2') => {
1876            state.set_time_period(TimePeriod::Min5);
1877        }
1878        KeyCode::Char('3') => {
1879            state.set_time_period(TimePeriod::Min15);
1880        }
1881        KeyCode::Char('4') => {
1882            state.set_time_period(TimePeriod::Hour1);
1883        }
1884        KeyCode::Char('5') => {
1885            state.set_time_period(TimePeriod::Hour4);
1886        }
1887        KeyCode::Char('6') => {
1888            state.set_time_period(TimePeriod::Day1);
1889        }
1890        KeyCode::Char('t') | KeyCode::Tab => {
1891            state.cycle_time_period();
1892        }
1893        KeyCode::Char('c') => {
1894            state.toggle_chart_mode();
1895        }
1896        KeyCode::Char('s') => {
1897            state.scale_mode = state.scale_mode.toggle();
1898            state.log(format!("Scale: {}", state.scale_mode.label()));
1899        }
1900        KeyCode::Char('/') => {
1901            state.color_scheme = state.color_scheme.next();
1902            state.log(format!("Colors: {}", state.color_scheme.label()));
1903        }
1904        KeyCode::Char('j') | KeyCode::Down => {
1905            state.scroll_log_down();
1906        }
1907        KeyCode::Char('k') | KeyCode::Up => {
1908            state.scroll_log_up();
1909        }
1910        KeyCode::Char('l') => {
1911            state.layout = state.layout.next();
1912            state.auto_layout = false;
1913        }
1914        KeyCode::Char('h') => {
1915            state.layout = state.layout.prev();
1916            state.auto_layout = false;
1917        }
1918        KeyCode::Char('w') => {
1919            state.widget_toggle_mode = true;
1920        }
1921        KeyCode::Char('a') => {
1922            state.auto_layout = true;
1923        }
1924        _ => {}
1925    }
1926    false
1927}
1928
1929/// Renders the UI.
1930/// Computed layout areas for each widget. `None` means the widget is hidden.
1931struct LayoutAreas {
1932    price_chart: Option<Rect>,
1933    volume_chart: Option<Rect>,
1934    buy_sell_gauge: Option<Rect>,
1935    metrics_panel: Option<Rect>,
1936    activity_feed: Option<Rect>,
1937}
1938
1939/// Dashboard layout: charts top, gauges middle, transaction feed bottom.
1940///
1941/// ```text
1942/// ┌──────────────────────┬──────────────────────┐
1943/// │  Price Chart (60%)   │  Volume Chart (40%)  │  55%
1944/// ├──────────────────────┼──────────────────────┤
1945/// │  Buy/Sell (50%)      │  Metrics (50%)       │  20%
1946/// ├──────────────────────┴──────────────────────┤
1947/// │  Activity Feed                              │  25%
1948/// └─────────────────────────────────────────────┘
1949/// ```
1950fn layout_dashboard(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
1951    let rows = Layout::default()
1952        .direction(Direction::Vertical)
1953        .constraints([
1954            Constraint::Percentage(55),
1955            Constraint::Percentage(20),
1956            Constraint::Percentage(25),
1957        ])
1958        .split(area);
1959
1960    let top = Layout::default()
1961        .direction(Direction::Horizontal)
1962        .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
1963        .split(rows[0]);
1964
1965    let middle = Layout::default()
1966        .direction(Direction::Horizontal)
1967        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
1968        .split(rows[1]);
1969
1970    LayoutAreas {
1971        price_chart: if widgets.price_chart {
1972            Some(top[0])
1973        } else {
1974            None
1975        },
1976        volume_chart: if widgets.volume_chart {
1977            Some(top[1])
1978        } else {
1979            None
1980        },
1981        buy_sell_gauge: if widgets.buy_sell_pressure {
1982            Some(middle[0])
1983        } else {
1984            None
1985        },
1986        metrics_panel: if widgets.metrics_panel {
1987            Some(middle[1])
1988        } else {
1989            None
1990        },
1991        activity_feed: if widgets.activity_log {
1992            Some(rows[2])
1993        } else {
1994            None
1995        },
1996    }
1997}
1998
1999/// Chart-focus layout: full-width candles with minimal stats overlay.
2000///
2001/// ```text
2002/// ┌─────────────────────────────────────────────┐
2003/// │                                             │
2004/// │            Price Chart (~85%)                │
2005/// │                                             │
2006/// ├─────────────────────────────────────────────┤
2007/// │  Metrics (compact stats overlay)     ~15%   │
2008/// └─────────────────────────────────────────────┘
2009/// ```
2010fn layout_chart_focus(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2011    let vertical = Layout::default()
2012        .direction(Direction::Vertical)
2013        .constraints([Constraint::Percentage(85), Constraint::Percentage(15)])
2014        .split(area);
2015
2016    LayoutAreas {
2017        price_chart: if widgets.price_chart {
2018            Some(vertical[0])
2019        } else {
2020            None
2021        },
2022        volume_chart: None,   // Hidden in chart-focus
2023        buy_sell_gauge: None, // Hidden in chart-focus
2024        metrics_panel: if widgets.metrics_panel {
2025            Some(vertical[1])
2026        } else {
2027            None
2028        },
2029        activity_feed: None, // Hidden in chart-focus
2030    }
2031}
2032
2033/// Feed layout: transaction log takes priority, small price ticker on top.
2034///
2035/// ```text
2036/// ┌──────────────────────┬──────────────────────┐
2037/// │  Metrics (50%)       │  Buy/Sell (50%)      │  25%
2038/// ├──────────────────────┴──────────────────────┤
2039/// │                                             │
2040/// │            Activity Feed (~75%)             │
2041/// │                                             │
2042/// └─────────────────────────────────────────────┘
2043/// ```
2044fn layout_feed(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2045    let vertical = Layout::default()
2046        .direction(Direction::Vertical)
2047        .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
2048        .split(area);
2049
2050    let top = Layout::default()
2051        .direction(Direction::Horizontal)
2052        .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
2053        .split(vertical[0]);
2054
2055    LayoutAreas {
2056        price_chart: None,  // Hidden in feed mode
2057        volume_chart: None, // Hidden in feed mode
2058        metrics_panel: if widgets.metrics_panel {
2059            Some(top[0])
2060        } else {
2061            None
2062        },
2063        buy_sell_gauge: if widgets.buy_sell_pressure {
2064            Some(top[1])
2065        } else {
2066            None
2067        },
2068        activity_feed: if widgets.activity_log {
2069            Some(vertical[1])
2070        } else {
2071            None
2072        },
2073    }
2074}
2075
2076/// Compact layout: price sparkline and metrics only for small terminals.
2077///
2078/// ```text
2079/// ┌─────────────────────────────────────────────┐
2080/// │  Metrics Panel (sparkline + stats)    100%  │
2081/// └─────────────────────────────────────────────┘
2082/// ```
2083fn layout_compact(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2084    LayoutAreas {
2085        price_chart: None,    // Hidden in compact
2086        volume_chart: None,   // Hidden in compact
2087        buy_sell_gauge: None, // Hidden in compact
2088        metrics_panel: if widgets.metrics_panel {
2089            Some(area)
2090        } else {
2091            None
2092        },
2093        activity_feed: None, // Hidden in compact
2094    }
2095}
2096
2097/// Selects the best layout preset based on terminal size.
2098fn auto_select_layout(size: Rect) -> LayoutPreset {
2099    match (size.width, size.height) {
2100        (w, h) if w < 80 || h < 24 => LayoutPreset::Compact,
2101        (w, _) if w < 120 => LayoutPreset::Feed,
2102        (_, h) if h < 30 => LayoutPreset::ChartFocus,
2103        _ => LayoutPreset::Dashboard,
2104    }
2105}
2106
2107/// Renders the UI, dispatching to the active layout preset.
2108fn ui(f: &mut Frame, state: &mut MonitorState) {
2109    // Responsive breakpoint: auto-select layout for terminal size
2110    if state.auto_layout {
2111        let suggested = auto_select_layout(f.area());
2112        if suggested != state.layout {
2113            state.layout = suggested;
2114        }
2115    }
2116
2117    // Main layout: header, content, footer
2118    let chunks = Layout::default()
2119        .direction(Direction::Vertical)
2120        .constraints([
2121            Constraint::Length(4), // Header (token info + time period tabs)
2122            Constraint::Min(10),   // Content
2123            Constraint::Length(3), // Footer
2124        ])
2125        .split(f.area());
2126
2127    // Render header
2128    render_header(f, chunks[0], state);
2129
2130    // Calculate content areas based on active layout preset
2131    let areas = match state.layout {
2132        LayoutPreset::Dashboard => layout_dashboard(chunks[1], &state.widgets),
2133        LayoutPreset::ChartFocus => layout_chart_focus(chunks[1], &state.widgets),
2134        LayoutPreset::Feed => layout_feed(chunks[1], &state.widgets),
2135        LayoutPreset::Compact => layout_compact(chunks[1], &state.widgets),
2136    };
2137
2138    // Render each widget if its area is allocated
2139    if let Some(area) = areas.price_chart {
2140        match state.chart_mode {
2141            ChartMode::Line => render_price_chart(f, area, state),
2142            ChartMode::Candlestick => render_candlestick_chart(f, area, state),
2143            ChartMode::VolumeProfile => render_volume_profile_chart(f, area, state),
2144        }
2145    }
2146    if let Some(area) = areas.volume_chart {
2147        render_volume_chart(f, area, &*state);
2148    }
2149    if let Some(area) = areas.buy_sell_gauge {
2150        render_buy_sell_gauge(f, area, state);
2151    }
2152    if let Some(area) = areas.metrics_panel {
2153        // Split metrics area to show liquidity depth if enabled and data available
2154        if state.widgets.liquidity_depth && !state.liquidity_pairs.is_empty() {
2155            let split = Layout::default()
2156                .direction(Direction::Vertical)
2157                .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
2158                .split(area);
2159            render_metrics_panel(f, split[0], &*state);
2160            render_liquidity_depth(f, split[1], &*state);
2161        } else {
2162            render_metrics_panel(f, area, &*state);
2163        }
2164    }
2165    if let Some(area) = areas.activity_feed {
2166        render_activity_feed(f, area, state);
2167    }
2168
2169    // Render alert overlay on top of content area if alerts are active
2170    if !state.active_alerts.is_empty() {
2171        // Show alerts as a banner at the top of the content area (3 lines max)
2172        let alert_height = (state.active_alerts.len() as u16 + 2).min(5);
2173        let alert_area = Rect::new(
2174            chunks[1].x,
2175            chunks[1].y,
2176            chunks[1].width,
2177            alert_height.min(chunks[1].height),
2178        );
2179        render_alert_overlay(f, alert_area, state);
2180    }
2181
2182    // Render footer
2183    render_footer(f, chunks[2], state);
2184}
2185
2186/// Renders the header with token info and time period tabs.
2187fn render_header(f: &mut Frame, area: Rect, state: &MonitorState) {
2188    let price_color = if state.price_change_24h >= 0.0 {
2189        Color::Green
2190    } else {
2191        Color::Red
2192    };
2193
2194    // Use Unicode arrows for trend indication
2195    let trend_arrow = if state.price_change_24h > 0.5 {
2196        "▲"
2197    } else if state.price_change_24h < -0.5 {
2198        "▼"
2199    } else if state.price_change_24h >= 0.0 {
2200        "△"
2201    } else {
2202        "▽"
2203    };
2204
2205    let change_str = format!(
2206        "{}{:.2}%",
2207        if state.price_change_24h >= 0.0 {
2208            "+"
2209        } else {
2210            ""
2211        },
2212        state.price_change_24h
2213    );
2214
2215    let title = format!(
2216        " ◈ {} ({}) │ {} ",
2217        state.symbol,
2218        state.name,
2219        state.chain.to_uppercase(),
2220    );
2221
2222    let price_str = format_price_usd(state.current_price);
2223
2224    // Split header area: top row for token info, bottom row for tabs
2225    let header_chunks = Layout::default()
2226        .direction(Direction::Vertical)
2227        .constraints([Constraint::Length(3), Constraint::Length(1)])
2228        .split(area);
2229
2230    // Token info with price
2231    let header = Paragraph::new(Line::from(vec![
2232        Span::styled(price_str, Style::new().fg(price_color).bold()),
2233        Span::raw(" "),
2234        Span::styled(trend_arrow, Style::new().fg(price_color)),
2235        Span::styled(format!(" {}", change_str), Style::new().fg(price_color)),
2236    ]))
2237    .block(
2238        Block::default()
2239            .title(title)
2240            .borders(Borders::ALL)
2241            .border_style(Style::new().cyan()),
2242    );
2243
2244    f.render_widget(header, header_chunks[0]);
2245
2246    // Time period tabs
2247    let tab_titles = vec!["1m", "5m", "15m", "1h", "4h", "1d"];
2248    let chart_label = state.chart_mode.label();
2249    let tabs = Tabs::new(tab_titles)
2250        .select(state.time_period.index())
2251        .highlight_style(Style::new().cyan().bold())
2252        .divider("│")
2253        .padding(" ", " ");
2254    let tabs_line = Layout::default()
2255        .direction(Direction::Horizontal)
2256        .constraints([Constraint::Min(20), Constraint::Length(10)])
2257        .split(header_chunks[1]);
2258    f.render_widget(tabs, tabs_line[0]);
2259    f.render_widget(
2260        Paragraph::new(Span::styled(
2261            format!("⊞ {}", chart_label),
2262            Style::new().magenta(),
2263        )),
2264        tabs_line[1],
2265    );
2266}
2267
2268/// Renders the price chart with visual differentiation between real and synthetic data.
2269fn render_price_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2270    // Get data filtered by selected time period
2271    let (data, is_real) = state.get_price_data_for_period();
2272
2273    if data.is_empty() {
2274        let empty = Paragraph::new("No price data").block(
2275            Block::default()
2276                .title(" Price (USD) ")
2277                .borders(Borders::ALL),
2278        );
2279        f.render_widget(empty, area);
2280        return;
2281    }
2282
2283    // Calculate price statistics
2284    let current_price = state.current_price;
2285    let first_price = data.first().map(|(_, p)| *p).unwrap_or(current_price);
2286    let price_change = current_price - first_price;
2287    let price_change_pct = if first_price > 0.0 {
2288        (price_change / first_price) * 100.0
2289    } else {
2290        0.0
2291    };
2292
2293    // Determine if price is up or down for coloring
2294    let pal = state.palette();
2295    let is_price_up = price_change >= 0.0;
2296    let trend_color = if is_price_up { pal.up } else { pal.down };
2297    let trend_symbol = if is_price_up { "▲" } else { "▼" };
2298
2299    // Format current price based on magnitude
2300    let price_str = format_price_usd(current_price);
2301    let change_str = if price_change_pct.abs() < 0.01 {
2302        "0.00%".to_string()
2303    } else {
2304        format!(
2305            "{}{:.2}%",
2306            if is_price_up { "+" } else { "" },
2307            price_change_pct
2308        )
2309    };
2310
2311    // Build title with current price and change
2312    let chart_title = Line::from(vec![
2313        Span::raw(" ◆ "),
2314        Span::styled(
2315            format!("{} {} ", price_str, trend_symbol),
2316            Style::new().fg(trend_color).bold(),
2317        ),
2318        Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
2319        Span::styled(
2320            format!("│{}│ ", state.time_period.label()),
2321            Style::new().gray(),
2322        ),
2323    ]);
2324
2325    // Calculate bounds
2326    let (min_price, max_price) = data
2327        .iter()
2328        .fold((f64::MAX, f64::MIN), |(min, max), (_, p)| {
2329            (min.min(*p), max.max(*p))
2330        });
2331
2332    // Handle case where all prices are the same (e.g., stablecoins)
2333    let price_range = max_price - min_price;
2334    let (y_min, y_max) = if price_range < 0.0001 {
2335        // Add ±0.1% padding when prices are flat
2336        let padding = min_price * 0.001;
2337        (min_price - padding, max_price + padding)
2338    } else {
2339        (min_price - price_range * 0.1, max_price + price_range * 0.1)
2340    };
2341
2342    let x_min = data.first().map(|(t, _)| *t).unwrap_or(0.0);
2343    let x_max = data.last().map(|(t, _)| *t).unwrap_or(1.0);
2344    // Ensure x range is non-zero for proper rendering
2345    let x_max = if (x_max - x_min).abs() < 0.001 {
2346        x_min + 1.0
2347    } else {
2348        x_max
2349    };
2350
2351    // Apply scale transformation (log or linear)
2352    let apply_scale = |price: f64| -> f64 {
2353        match state.scale_mode {
2354            ScaleMode::Linear => price,
2355            ScaleMode::Log => {
2356                if price > 0.0 {
2357                    price.ln()
2358                } else {
2359                    0.0
2360                }
2361            }
2362        }
2363    };
2364
2365    let (y_min, y_max) = (apply_scale(y_min), apply_scale(y_max));
2366
2367    // Split data into synthetic and real datasets for visual differentiation
2368    let synthetic_data: Vec<(f64, f64)> = data
2369        .iter()
2370        .zip(&is_real)
2371        .filter(|(_, real)| !**real)
2372        .map(|((t, p), _)| (*t, apply_scale(*p)))
2373        .collect();
2374
2375    let real_data: Vec<(f64, f64)> = data
2376        .iter()
2377        .zip(&is_real)
2378        .filter(|(_, real)| **real)
2379        .map(|((t, p), _)| (*t, apply_scale(*p)))
2380        .collect();
2381
2382    // Create reference line at first price (horizontal line for comparison)
2383    let reference_line: Vec<(f64, f64)> = vec![
2384        (x_min, apply_scale(first_price)),
2385        (x_max, apply_scale(first_price)),
2386    ];
2387
2388    let mut datasets = Vec::new();
2389
2390    // Reference line (starting price) - dashed gray
2391    datasets.push(
2392        Dataset::default()
2393            .name("━Start")
2394            .marker(symbols::Marker::Braille)
2395            .graph_type(GraphType::Line)
2396            .style(Style::new().dark_gray())
2397            .data(&reference_line),
2398    );
2399
2400    // Synthetic data shown with Dot marker and dimmed color
2401    if !synthetic_data.is_empty() {
2402        datasets.push(
2403            Dataset::default()
2404                .name("◇Est")
2405                .marker(symbols::Marker::Braille)
2406                .graph_type(GraphType::Line)
2407                .style(Style::new().cyan())
2408                .data(&synthetic_data),
2409        );
2410    }
2411
2412    // Real data shown with Braille marker and trend color
2413    if !real_data.is_empty() {
2414        datasets.push(
2415            Dataset::default()
2416                .name("●Live")
2417                .marker(symbols::Marker::Braille)
2418                .graph_type(GraphType::Line)
2419                .style(Style::new().fg(trend_color))
2420                .data(&real_data),
2421        );
2422    }
2423
2424    // Create time labels based on period
2425    let time_label = format!("-{}", state.time_period.label());
2426
2427    // Calculate middle price for 3-point y-axis labels
2428    // In log mode, labels show original USD prices (exp of log values)
2429    let mid_y = (y_min + y_max) / 2.0;
2430    let y_label = |val: f64| -> String {
2431        match state.scale_mode {
2432            ScaleMode::Linear => format_price_usd(val),
2433            ScaleMode::Log => format_price_usd(val.exp()),
2434        }
2435    };
2436
2437    let scale_label = match state.scale_mode {
2438        ScaleMode::Linear => "USD",
2439        ScaleMode::Log => "USD (log)",
2440    };
2441
2442    let chart = Chart::new(datasets)
2443        .block(
2444            Block::default()
2445                .title(chart_title)
2446                .borders(Borders::ALL)
2447                .border_style(Style::new().fg(trend_color)),
2448        )
2449        .x_axis(
2450            Axis::default()
2451                .title(Span::styled("Time", Style::new().gray()))
2452                .style(Style::new().gray())
2453                .bounds([x_min, x_max])
2454                .labels(vec![Span::raw(time_label), Span::raw("now")]),
2455        )
2456        .y_axis(
2457            Axis::default()
2458                .title(Span::styled(scale_label, Style::new().gray()))
2459                .style(Style::new().gray())
2460                .bounds([y_min, y_max])
2461                .labels(vec![
2462                    Span::raw(y_label(y_min)),
2463                    Span::raw(y_label(mid_y)),
2464                    Span::raw(y_label(y_max)),
2465                ]),
2466        );
2467
2468    f.render_widget(chart, area);
2469}
2470
2471/// Checks if a price indicates a stablecoin (pegged around $1.00).
2472fn is_stablecoin_price(price: f64) -> bool {
2473    (0.95..=1.05).contains(&price)
2474}
2475
2476/// Formats a price in USD with appropriate precision.
2477/// Stablecoins get extra precision (6 decimals) to show micro-fluctuations.
2478fn format_price_usd(price: f64) -> String {
2479    if price >= 1000.0 {
2480        format!("${:.2}", price)
2481    } else if is_stablecoin_price(price) {
2482        // Stablecoins get 6 decimals to show micro-fluctuations
2483        format!("${:.6}", price)
2484    } else if price >= 1.0 {
2485        format!("${:.4}", price)
2486    } else if price >= 0.01 {
2487        format!("${:.6}", price)
2488    } else if price >= 0.0001 {
2489        format!("${:.8}", price)
2490    } else {
2491        format!("${:.10}", price)
2492    }
2493}
2494
2495/// Renders a candlestick chart using OHLC data.
2496fn render_candlestick_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2497    let candles = state.get_ohlc_candles();
2498
2499    if candles.is_empty() {
2500        let empty = Paragraph::new("No candle data (waiting for more data points)").block(
2501            Block::default()
2502                .title(" Candlestick (USD) ")
2503                .borders(Borders::ALL),
2504        );
2505        f.render_widget(empty, area);
2506        return;
2507    }
2508
2509    // Calculate price statistics
2510    let current_price = state.current_price;
2511    let first_candle = candles.first().unwrap();
2512    let last_candle = candles.last().unwrap();
2513    let price_change = last_candle.close - first_candle.open;
2514    let price_change_pct = if first_candle.open > 0.0 {
2515        (price_change / first_candle.open) * 100.0
2516    } else {
2517        0.0
2518    };
2519
2520    let pal = state.palette();
2521    let is_price_up = price_change >= 0.0;
2522    let trend_color = if is_price_up { pal.up } else { pal.down };
2523    let trend_symbol = if is_price_up { "▲" } else { "▼" };
2524
2525    let price_str = format_price_usd(current_price);
2526    let change_str = format!(
2527        "{}{:.2}%",
2528        if is_price_up { "+" } else { "" },
2529        price_change_pct
2530    );
2531
2532    // Calculate bounds from all candle high/low
2533    let (min_price, max_price) = candles.iter().fold((f64::MAX, f64::MIN), |(min, max), c| {
2534        (min.min(c.low), max.max(c.high))
2535    });
2536
2537    let price_range = max_price - min_price;
2538    let (y_min, y_max) = if price_range < 0.0001 {
2539        let padding = min_price * 0.001;
2540        (min_price - padding, max_price + padding)
2541    } else {
2542        (min_price - price_range * 0.1, max_price + price_range * 0.1)
2543    };
2544
2545    let x_min = candles.first().map(|c| c.timestamp).unwrap_or(0.0);
2546    let x_max = candles.last().map(|c| c.timestamp).unwrap_or(1.0);
2547    let x_range = x_max - x_min;
2548    let x_max = if x_range < 0.001 {
2549        x_min + 1.0
2550    } else {
2551        x_max + x_range * 0.05
2552    };
2553
2554    // Calculate candle width based on number of candles and area
2555    let candle_count = candles.len() as f64;
2556    let candle_spacing = x_range / candle_count.max(1.0);
2557    let candle_width = candle_spacing * 0.6; // 60% of spacing for body
2558
2559    let title = Line::from(vec![
2560        Span::raw(" ⬡ "),
2561        Span::styled(
2562            format!("{} {} ", price_str, trend_symbol),
2563            Style::new().fg(trend_color).bold(),
2564        ),
2565        Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
2566        Span::styled(
2567            format!("│{}│ ", state.time_period.label()),
2568            Style::new().gray(),
2569        ),
2570        Span::styled("⊞Candles ", Style::new().magenta()),
2571    ]);
2572
2573    // Apply scale transformation (log or linear)
2574    let apply_scale = |price: f64| -> f64 {
2575        match state.scale_mode {
2576            ScaleMode::Linear => price,
2577            ScaleMode::Log => {
2578                if price > 0.0 {
2579                    price.ln()
2580                } else {
2581                    0.0
2582                }
2583            }
2584        }
2585    };
2586    let scaled_y_min = apply_scale(y_min);
2587    let scaled_y_max = apply_scale(y_max);
2588    let scaled_price_range = scaled_y_max - scaled_y_min;
2589
2590    // Clone candles for the closure
2591    let candles_clone = candles.clone();
2592    let is_log = matches!(state.scale_mode, ScaleMode::Log);
2593    let pal_up = pal.up;
2594    let pal_down = pal.down;
2595
2596    let canvas = Canvas::default()
2597        .block(
2598            Block::default()
2599                .title(title)
2600                .borders(Borders::ALL)
2601                .border_style(Style::new().fg(trend_color)),
2602        )
2603        .x_bounds([x_min - candle_spacing, x_max])
2604        .y_bounds([scaled_y_min, scaled_y_max])
2605        .paint(move |ctx| {
2606            let scale_fn = |p: f64| -> f64 { if is_log && p > 0.0 { p.ln() } else { p } };
2607            for candle in &candles_clone {
2608                let color = if candle.is_bullish { pal_up } else { pal_down };
2609
2610                // Draw the wick (high-low line)
2611                ctx.draw(&CanvasLine {
2612                    x1: candle.timestamp,
2613                    y1: scale_fn(candle.low),
2614                    x2: candle.timestamp,
2615                    y2: scale_fn(candle.high),
2616                    color,
2617                });
2618
2619                // Draw the body (open-close rectangle)
2620                let body_top = scale_fn(candle.open.max(candle.close));
2621                let body_bottom = scale_fn(candle.open.min(candle.close));
2622                let body_height = (body_top - body_bottom).max(scaled_price_range * 0.002);
2623
2624                ctx.draw(&Rectangle {
2625                    x: candle.timestamp - candle_width / 2.0,
2626                    y: body_bottom,
2627                    width: candle_width,
2628                    height: body_height,
2629                    color,
2630                });
2631            }
2632        });
2633
2634    f.render_widget(canvas, area);
2635}
2636
2637/// Renders a volume profile chart showing volume distribution by price level.
2638///
2639/// Buckets the price+volume history into horizontal bars where each bar shows
2640/// the accumulated volume at that price level. Accuracy improves over longer
2641/// monitoring sessions as more data points are collected.
2642fn render_volume_profile_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2643    let pal = state.palette();
2644    let (price_data, _) = state.get_price_data_for_period();
2645    let (volume_data, _) = state.get_volume_data_for_period();
2646
2647    if price_data.len() < 2 || volume_data.is_empty() {
2648        let block = Block::default()
2649            .title(" ◨ Volume Profile (collecting data...) ")
2650            .borders(Borders::ALL)
2651            .border_style(Style::new().fg(Color::DarkGray));
2652        f.render_widget(block, area);
2653        return;
2654    }
2655
2656    // Find price range
2657    let min_price = price_data.iter().map(|(_, p)| *p).fold(f64::MAX, f64::min);
2658    let max_price = price_data.iter().map(|(_, p)| *p).fold(f64::MIN, f64::max);
2659
2660    if (max_price - min_price).abs() < f64::EPSILON {
2661        let block = Block::default()
2662            .title(" ◨ Volume Profile (no price range) ")
2663            .borders(Borders::ALL)
2664            .border_style(Style::new().fg(Color::DarkGray));
2665        f.render_widget(block, area);
2666        return;
2667    }
2668
2669    // Number of price buckets = available height minus borders
2670    let inner_height = area.height.saturating_sub(2) as usize;
2671    let num_buckets = inner_height.clamp(1, 30);
2672    let bucket_size = (max_price - min_price) / num_buckets as f64;
2673
2674    // Accumulate volume per price bucket
2675    let mut bucket_volumes = vec![0.0_f64; num_buckets];
2676
2677    // Pair price and volume data by index (they have same timestamps)
2678    let vol_iter: Vec<f64> = volume_data.iter().map(|(_, v)| *v).collect();
2679    for (i, (_, price)) in price_data.iter().enumerate() {
2680        let bucket_idx =
2681            (((price - min_price) / bucket_size).floor() as usize).min(num_buckets - 1);
2682        // Use volume delta if available, otherwise use a unit contribution
2683        let vol_contribution = if i < vol_iter.len() {
2684            // Use relative volume (delta from previous if possible)
2685            if i > 0 {
2686                (vol_iter[i] - vol_iter[i - 1]).abs().max(1.0)
2687            } else {
2688                1.0
2689            }
2690        } else {
2691            1.0
2692        };
2693        bucket_volumes[bucket_idx] += vol_contribution;
2694    }
2695
2696    let max_vol = bucket_volumes
2697        .iter()
2698        .cloned()
2699        .fold(0.0_f64, f64::max)
2700        .max(1.0);
2701
2702    // Find the bucket containing the current price
2703    let current_bucket = (((state.current_price - min_price) / bucket_size).floor() as usize)
2704        .min(num_buckets.saturating_sub(1));
2705
2706    // Build horizontal bars using Paragraph with spans
2707    let inner_width = area.width.saturating_sub(12) as usize; // leave room for price labels
2708
2709    let lines: Vec<Line> = (0..num_buckets)
2710        .rev() // top = highest price
2711        .map(|i| {
2712            let bar_width = ((bucket_volumes[i] / max_vol) * inner_width as f64).round() as usize;
2713            let price_mid = min_price + (i as f64 + 0.5) * bucket_size;
2714            let label = if price_mid >= 1.0 {
2715                format!("{:>8.2}", price_mid)
2716            } else {
2717                format!("{:>8.6}", price_mid)
2718            };
2719            let bar_str = "█".repeat(bar_width);
2720            let style = if i == current_bucket {
2721                Style::new().fg(pal.highlight).bold()
2722            } else {
2723                Style::new().fg(pal.sparkline)
2724            };
2725            Line::from(vec![
2726                Span::styled(label, Style::new().dark_gray()),
2727                Span::raw(" "),
2728                Span::styled(bar_str, style),
2729            ])
2730        })
2731        .collect();
2732
2733    let block = Block::default()
2734        .title(" ◨ Volume Profile (accuracy improves over time) ")
2735        .borders(Borders::ALL)
2736        .border_style(Style::new().fg(pal.sparkline));
2737
2738    let paragraph = Paragraph::new(lines).block(block);
2739    f.render_widget(paragraph, area);
2740}
2741
2742/// Renders the volume chart with visual differentiation between real and synthetic data.
2743fn render_volume_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2744    let pal = state.palette();
2745    // Get data filtered by selected time period
2746    let (data, is_real) = state.get_volume_data_for_period();
2747
2748    if data.is_empty() {
2749        let empty = Paragraph::new("No volume data")
2750            .block(Block::default().title(" 24h Volume ").borders(Borders::ALL));
2751        f.render_widget(empty, area);
2752        return;
2753    }
2754
2755    // Get current volume for display
2756    let current_volume = state.volume_24h;
2757    let volume_str = format_usd(current_volume);
2758
2759    // Count synthetic vs real points for the legend
2760    let has_synthetic = is_real.iter().any(|r| !r);
2761    let has_real = is_real.iter().any(|r| *r);
2762
2763    // Build title with current volume
2764    let data_indicator = if has_synthetic && has_real {
2765        "[◆ est │ ● live]"
2766    } else if has_synthetic {
2767        "[◆ estimated]"
2768    } else {
2769        "[● live]"
2770    };
2771
2772    let chart_title = Line::from(vec![
2773        Span::raw(" ▣ "),
2774        Span::styled(
2775            format!("24h Vol: {} ", volume_str),
2776            Style::new().fg(pal.volume_bar).bold(),
2777        ),
2778        Span::styled(
2779            format!("│{}│ ", state.time_period.label()),
2780            Style::new().gray(),
2781        ),
2782        Span::styled(data_indicator, Style::new().dark_gray()),
2783    ]);
2784
2785    // Build bars from data points — bucket into a reasonable number of bars
2786    // based on available width (each bar needs at least 3 chars)
2787    let inner_width = area.width.saturating_sub(2) as usize; // account for block borders
2788    let max_bars = (inner_width / 3).max(1).min(data.len());
2789    let bucket_size = data.len().div_ceil(max_bars);
2790
2791    let bars: Vec<Bar> = data
2792        .chunks(bucket_size)
2793        .zip(is_real.chunks(bucket_size))
2794        .enumerate()
2795        .map(|(i, (chunk, real_chunk))| {
2796            let avg_vol = chunk.iter().map(|(_, v)| v).sum::<f64>() / chunk.len() as f64;
2797            let any_real = real_chunk.iter().any(|r| *r);
2798            let bar_color = if any_real {
2799                pal.volume_bar
2800            } else {
2801                pal.neutral
2802            };
2803            // Show time labels at start, middle, and end
2804            let label = if i == 0 || i == max_bars.saturating_sub(1) || i == max_bars / 2 {
2805                format_number(avg_vol)
2806            } else {
2807                String::new()
2808            };
2809            Bar::default()
2810                .value(avg_vol as u64)
2811                .label(Line::from(label))
2812                .style(Style::new().fg(bar_color))
2813        })
2814        .collect();
2815
2816    // Calculate dynamic bar width based on available space
2817    let bar_width = if !bars.is_empty() {
2818        let total_bars = bars.len() as u16;
2819        // Each bar gets: bar_width + 1 gap, minus 1 gap for the last bar
2820        ((inner_width as u16).saturating_sub(total_bars.saturating_sub(1))) / total_bars
2821    } else {
2822        1
2823    }
2824    .max(1);
2825
2826    let barchart = BarChart::default()
2827        .data(BarGroup::default().bars(&bars))
2828        .block(
2829            Block::default()
2830                .title(chart_title)
2831                .borders(Borders::ALL)
2832                .border_style(Style::new().blue()),
2833        )
2834        .bar_width(bar_width)
2835        .bar_gap(1)
2836        .value_style(Style::new().dark_gray());
2837
2838    f.render_widget(barchart, area);
2839}
2840
2841/// Renders the buy/sell ratio gauge (pressure bar only, no activity log).
2842fn render_buy_sell_gauge(f: &mut Frame, area: Rect, state: &mut MonitorState) {
2843    let pal = state.palette();
2844    // Buy/Sell ratio bar
2845    let ratio = state.buy_ratio();
2846    let border_color = if ratio > 0.5 { pal.up } else { pal.down };
2847
2848    let block = Block::default()
2849        .title(" ◐ Buy/Sell Ratio (24h) ")
2850        .borders(Borders::ALL)
2851        .border_style(Style::new().fg(border_color));
2852
2853    let inner = block.inner(area);
2854    f.render_widget(block, area);
2855
2856    if inner.width > 0 && inner.height > 0 {
2857        let buy_width = ((ratio * inner.width as f64).round() as u16).min(inner.width);
2858        let sell_width = inner.width.saturating_sub(buy_width);
2859
2860        let buy_indicator = if ratio > 0.5 { "▶" } else { "▷" };
2861        let sell_indicator = if ratio < 0.5 { "◀" } else { "◁" };
2862        let label = format!(
2863            "{}Buys: {} │ Sells: {}{} ({:.1}%)",
2864            buy_indicator,
2865            state.buys_24h,
2866            state.sells_24h,
2867            sell_indicator,
2868            ratio * 100.0
2869        );
2870
2871        let buy_bar = "█".repeat(buy_width as usize);
2872        let sell_bar = "█".repeat(sell_width as usize);
2873        let bar_line = Line::from(vec![
2874            Span::styled(buy_bar, Style::new().fg(pal.up)),
2875            Span::styled(sell_bar, Style::new().fg(pal.down)),
2876        ]);
2877        f.render_widget(Paragraph::new(bar_line), inner);
2878
2879        // Center the label on top of the bar
2880        let label_len = label.len() as u16;
2881        if label_len <= inner.width {
2882            let x_offset = (inner.width.saturating_sub(label_len)) / 2;
2883            let label_area = Rect::new(inner.x + x_offset, inner.y, label_len, 1);
2884            let label_widget =
2885                Paragraph::new(Span::styled(label, Style::new().fg(Color::White).bold()));
2886            f.render_widget(label_widget, label_area);
2887        }
2888    }
2889}
2890
2891/// Renders the scrollable activity log feed.
2892fn render_activity_feed(f: &mut Frame, area: Rect, state: &mut MonitorState) {
2893    let log_len = state.log_messages.len();
2894    let log_title = if log_len > 0 {
2895        let selected = state.log_list_state.selected().unwrap_or(0);
2896        format!(" ◷ Activity Log [{}/{}] ", selected + 1, log_len)
2897    } else {
2898        " ◷ Activity Log ".to_string()
2899    };
2900
2901    let items: Vec<ListItem> = state
2902        .log_messages
2903        .iter()
2904        .rev()
2905        .map(|msg| ListItem::new(msg.as_str()).style(Style::new().gray()))
2906        .collect();
2907
2908    let log_list = List::new(items)
2909        .block(
2910            Block::default()
2911                .title(log_title)
2912                .borders(Borders::ALL)
2913                .border_style(Style::new().dark_gray()),
2914        )
2915        .highlight_style(Style::new().white().bold())
2916        .highlight_symbol("▸ ");
2917
2918    f.render_stateful_widget(log_list, area, &mut state.log_list_state);
2919}
2920
2921/// Renders a flashing alert overlay when alerts are active.
2922fn render_alert_overlay(f: &mut Frame, area: Rect, state: &MonitorState) {
2923    if state.active_alerts.is_empty() {
2924        return;
2925    }
2926
2927    let is_flash_on = state
2928        .alert_flash_until
2929        .map(|deadline| {
2930            if Instant::now() < deadline {
2931                // Flash with ~500ms period
2932                (Instant::now().elapsed().subsec_millis() / 500).is_multiple_of(2)
2933            } else {
2934                false
2935            }
2936        })
2937        .unwrap_or(false);
2938
2939    let border_color = if is_flash_on {
2940        Color::Red
2941    } else {
2942        Color::Yellow
2943    };
2944
2945    let alert_lines: Vec<Line> = state
2946        .active_alerts
2947        .iter()
2948        .map(|a| Line::from(Span::styled(&a.message, Style::new().fg(Color::Red).bold())))
2949        .collect();
2950
2951    let alert_widget = Paragraph::new(alert_lines).block(
2952        Block::default()
2953            .title(" ⚠ ALERTS ")
2954            .borders(Borders::ALL)
2955            .border_style(Style::new().fg(border_color).bold()),
2956    );
2957
2958    f.render_widget(alert_widget, area);
2959}
2960
2961/// Renders a horizontal stacked bar chart of per-pair liquidity.
2962fn render_liquidity_depth(f: &mut Frame, area: Rect, state: &MonitorState) {
2963    let pal = state.palette();
2964
2965    if state.liquidity_pairs.is_empty() {
2966        let block = Block::default()
2967            .title(" ◫ Liquidity Depth (no data) ")
2968            .borders(Borders::ALL)
2969            .border_style(Style::new().fg(Color::DarkGray));
2970        f.render_widget(block, area);
2971        return;
2972    }
2973
2974    let max_liquidity = state
2975        .liquidity_pairs
2976        .iter()
2977        .map(|(_, liq)| *liq)
2978        .fold(0.0_f64, f64::max)
2979        .max(1.0);
2980
2981    let inner_width = area.width.saturating_sub(2) as usize;
2982
2983    let lines: Vec<Line> = state
2984        .liquidity_pairs
2985        .iter()
2986        .take(area.height.saturating_sub(2) as usize) // limit to available rows
2987        .map(|(name, liq)| {
2988            let bar_width = ((liq / max_liquidity) * inner_width as f64 * 0.6).round() as usize;
2989            let bar_str = "█".repeat(bar_width);
2990            let label = format!(" {} {}", format_usd(*liq), name);
2991            Line::from(vec![
2992                Span::styled(bar_str, Style::new().fg(pal.volume_bar)),
2993                Span::styled(label, Style::new().fg(pal.neutral)),
2994            ])
2995        })
2996        .collect();
2997
2998    let block = Block::default()
2999        .title(format!(
3000            " ◫ Liquidity Depth ({} pairs) ",
3001            state.liquidity_pairs.len()
3002        ))
3003        .borders(Borders::ALL)
3004        .border_style(Style::new().fg(pal.border));
3005
3006    let paragraph = Paragraph::new(lines).block(block);
3007    f.render_widget(paragraph, area);
3008}
3009
3010/// Renders the key metrics panel.
3011fn render_metrics_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3012    let pal = state.palette();
3013    // Split panel: top sparkline (2 rows), bottom table
3014    let chunks = Layout::default()
3015        .direction(Direction::Vertical)
3016        .constraints([Constraint::Length(3), Constraint::Min(0)])
3017        .split(area);
3018
3019    // --- Sparkline: recent price trend ---
3020    let sparkline_data: Vec<u64> = {
3021        // Take the last N price points and normalize to u64 for Sparkline
3022        let points: Vec<f64> = state.price_history.iter().map(|dp| dp.value).collect();
3023        if points.len() < 2 {
3024            vec![0; chunks[0].width.saturating_sub(2) as usize]
3025        } else {
3026            let min_p = points.iter().cloned().fold(f64::MAX, f64::min);
3027            let max_p = points.iter().cloned().fold(f64::MIN, f64::max);
3028            let range = (max_p - min_p).max(0.0001);
3029            points
3030                .iter()
3031                .map(|p| (((*p - min_p) / range) * 100.0) as u64)
3032                .collect()
3033        }
3034    };
3035
3036    let trend_color = if state.price_change_5m >= 0.0 {
3037        pal.up
3038    } else {
3039        pal.down
3040    };
3041
3042    let sparkline = Sparkline::default()
3043        .block(
3044            Block::default()
3045                .title(" ◉ Price Trend ")
3046                .borders(Borders::ALL)
3047                .border_style(Style::new().fg(pal.sparkline)),
3048        )
3049        .data(&sparkline_data)
3050        .style(Style::new().fg(trend_color));
3051
3052    f.render_widget(sparkline, chunks[0]);
3053
3054    // --- Table: key metrics ---
3055    let change_5m_str = if state.price_change_5m.abs() < 0.0001 {
3056        "0.00%".to_string()
3057    } else {
3058        format!("{:+.4}%", state.price_change_5m)
3059    };
3060    let change_5m_color = if state.price_change_5m > 0.0 {
3061        pal.up
3062    } else if state.price_change_5m < 0.0 {
3063        pal.down
3064    } else {
3065        pal.neutral
3066    };
3067
3068    let now_ts = chrono::Utc::now().timestamp() as f64;
3069    let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
3070    let last_change_str = if secs_since_change < 60 {
3071        format!("{}s ago", secs_since_change)
3072    } else if secs_since_change < 3600 {
3073        format!("{}m ago", secs_since_change / 60)
3074    } else {
3075        format!("{}h ago", secs_since_change / 3600)
3076    };
3077    let last_change_color = if secs_since_change < 60 {
3078        pal.up
3079    } else {
3080        pal.highlight
3081    };
3082
3083    let change_24h_str = format!(
3084        "{}{:.2}%",
3085        if state.price_change_24h >= 0.0 {
3086            "+"
3087        } else {
3088            ""
3089        },
3090        state.price_change_24h
3091    );
3092
3093    let market_cap_str = state
3094        .market_cap
3095        .map(format_usd)
3096        .unwrap_or_else(|| "N/A".to_string());
3097
3098    let mut rows = vec![
3099        Row::new(vec![
3100            Span::styled("Price", Style::new().gray()),
3101            Span::styled(format_price_usd(state.current_price), Style::new().bold()),
3102        ]),
3103        Row::new(vec![
3104            Span::styled("5m Chg", Style::new().gray()),
3105            Span::styled(change_5m_str, Style::new().fg(change_5m_color)),
3106        ]),
3107        Row::new(vec![
3108            Span::styled("Last Δ", Style::new().gray()),
3109            Span::styled(last_change_str, Style::new().fg(last_change_color)),
3110        ]),
3111        Row::new(vec![
3112            Span::styled("24h Chg", Style::new().gray()),
3113            Span::raw(change_24h_str),
3114        ]),
3115        Row::new(vec![
3116            Span::styled("Liq", Style::new().gray()),
3117            Span::raw(format_usd(state.liquidity_usd)),
3118        ]),
3119        Row::new(vec![
3120            Span::styled("Vol 24h", Style::new().gray()),
3121            Span::raw(format_usd(state.volume_24h)),
3122        ]),
3123        Row::new(vec![
3124            Span::styled("Mkt Cap", Style::new().gray()),
3125            Span::raw(market_cap_str),
3126        ]),
3127        Row::new(vec![
3128            Span::styled("Buys", Style::new().gray()),
3129            Span::styled(format!("{}", state.buys_24h), Style::new().fg(pal.up)),
3130        ]),
3131        Row::new(vec![
3132            Span::styled("Sells", Style::new().gray()),
3133            Span::styled(format!("{}", state.sells_24h), Style::new().fg(pal.down)),
3134        ]),
3135    ];
3136
3137    // Add holder count if available and the widget is enabled
3138    if state.widgets.holder_count
3139        && let Some(count) = state.holder_count
3140    {
3141        rows.push(Row::new(vec![
3142            Span::styled("Holders", Style::new().gray()),
3143            Span::styled(format_number(count as f64), Style::new().fg(pal.highlight)),
3144        ]));
3145    }
3146
3147    let table = Table::new(rows, [Constraint::Length(8), Constraint::Min(10)]).block(
3148        Block::default()
3149            .title(" ◉ Key Metrics ")
3150            .borders(Borders::ALL)
3151            .border_style(Style::new().magenta()),
3152    );
3153
3154    f.render_widget(table, chunks[1]);
3155}
3156
3157/// Renders the footer with status and controls.
3158fn render_footer(f: &mut Frame, area: Rect, state: &MonitorState) {
3159    let elapsed = state.last_update.elapsed().as_secs();
3160
3161    // Calculate time since last price change
3162    let now_ts = chrono::Utc::now().timestamp() as f64;
3163    let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
3164    let price_change_str = if secs_since_change < 60 {
3165        format!("{}s", secs_since_change)
3166    } else if secs_since_change < 3600 {
3167        format!("{}m", secs_since_change / 60)
3168    } else {
3169        format!("{}h", secs_since_change / 3600)
3170    };
3171
3172    // Get data stats
3173    let (synthetic_count, real_count) = state.data_stats();
3174    let memory_bytes = state.memory_usage();
3175    let memory_str = if memory_bytes >= 1024 * 1024 {
3176        format!("{:.1}MB", memory_bytes as f64 / (1024.0 * 1024.0))
3177    } else if memory_bytes >= 1024 {
3178        format!("{:.1}KB", memory_bytes as f64 / 1024.0)
3179    } else {
3180        format!("{}B", memory_bytes)
3181    };
3182
3183    let status = if let Some(ref err) = state.error_message {
3184        Span::styled(format!("⚠ {}", err), Style::new().red())
3185    } else if state.paused {
3186        Span::styled("⏸ PAUSED", Style::new().fg(Color::Yellow).bold())
3187    } else if state.is_auto_paused() {
3188        Span::styled("⏸ AUTO-PAUSED", Style::new().fg(Color::Cyan).bold())
3189    } else {
3190        Span::styled(
3191            format!(
3192                "↻ {}s │ Δ {} │ {} pts │ {}",
3193                elapsed,
3194                price_change_str,
3195                synthetic_count + real_count,
3196                memory_str
3197            ),
3198            Style::new().gray(),
3199        )
3200    };
3201
3202    let widget_hint = if state.widget_toggle_mode {
3203        Span::styled("W:1-5?", Style::new().fg(Color::Yellow).bold())
3204    } else {
3205        Span::styled("W", Style::new().fg(Color::Cyan).bold())
3206    };
3207
3208    let mut spans = vec![status, Span::raw(" ║ ")];
3209
3210    // REC indicator when CSV export is active
3211    if state.export_active {
3212        spans.push(Span::styled("● REC ", Style::new().fg(Color::Red).bold()));
3213    }
3214
3215    spans.extend([
3216        Span::styled("Q", Style::new().red().bold()),
3217        Span::raw("uit "),
3218        Span::styled("R", Style::new().fg(Color::Green).bold()),
3219        Span::raw("efresh "),
3220        Span::styled("P", Style::new().fg(Color::Yellow).bold()),
3221        Span::raw("ause "),
3222        Span::styled("E", Style::new().fg(Color::LightRed).bold()),
3223        Span::raw("xport "),
3224        Span::styled("L", Style::new().fg(Color::Cyan).bold()),
3225        Span::raw(format!(":{} ", state.layout.label())),
3226        widget_hint,
3227        Span::raw("idget "),
3228        Span::styled("C", Style::new().fg(Color::LightBlue).bold()),
3229        Span::raw(format!("hart:{} ", state.chart_mode.label())),
3230        Span::styled("S", Style::new().fg(Color::LightGreen).bold()),
3231        Span::raw(format!("cale:{} ", state.scale_mode.label())),
3232        Span::styled("/", Style::new().fg(Color::LightRed).bold()),
3233        Span::raw(format!(":{} ", state.color_scheme.label())),
3234        Span::styled("T", Style::new().fg(Color::Magenta).bold()),
3235        Span::raw("ime "),
3236    ]);
3237
3238    let footer = Paragraph::new(Line::from(spans)).block(Block::default().borders(Borders::ALL));
3239
3240    f.render_widget(footer, area);
3241}
3242
3243/// Formats a number with K/M/B suffixes.
3244fn format_number(n: f64) -> String {
3245    if n >= 1_000_000_000.0 {
3246        format!("{:.2}B", n / 1_000_000_000.0)
3247    } else if n >= 1_000_000.0 {
3248        format!("{:.2}M", n / 1_000_000.0)
3249    } else if n >= 1_000.0 {
3250        format!("{:.2}K", n / 1_000.0)
3251    } else {
3252        format!("{:.2}", n)
3253    }
3254}
3255
3256/// Formats a USD amount with appropriate suffix.
3257fn format_usd(n: f64) -> String {
3258    if n >= 1_000_000_000.0 {
3259        format!("${:.2}B", n / 1_000_000_000.0)
3260    } else if n >= 1_000_000.0 {
3261        format!("${:.2}M", n / 1_000_000.0)
3262    } else if n >= 1_000.0 {
3263        format!("${:.2}K", n / 1_000.0)
3264    } else {
3265        format!("${:.2}", n)
3266    }
3267}
3268
3269/// Entry point for the top-level `scope monitor` command.
3270///
3271/// Creates a `SessionContext` from CLI args and delegates to [`run`].
3272/// Applies CLI-provided overrides (layout, refresh, scale, etc.) on top
3273/// of the config-file defaults.
3274pub async fn run_direct(
3275    args: MonitorArgs,
3276    config: &Config,
3277    clients: &dyn ChainClientFactory,
3278) -> Result<()> {
3279    // Build a SessionContext from the CLI args (no interactive session needed)
3280    let ctx = SessionContext {
3281        chain: args.chain,
3282        chain_explicit: true,
3283        ..SessionContext::default()
3284    };
3285
3286    // Build a MonitorConfig from config-file defaults + CLI overrides
3287    let mut monitor_config = config.monitor.clone();
3288    if let Some(layout) = args.layout {
3289        monitor_config.layout = layout;
3290    }
3291    if let Some(refresh) = args.refresh {
3292        monitor_config.refresh_seconds = refresh;
3293    }
3294    if let Some(scale) = args.scale {
3295        monitor_config.scale = scale;
3296    }
3297    if let Some(color_scheme) = args.color_scheme {
3298        monitor_config.color_scheme = color_scheme;
3299    }
3300    if let Some(ref path) = args.export {
3301        monitor_config.export.path = Some(path.to_string_lossy().into_owned());
3302    }
3303
3304    // Use a temporary Config with the CLI-overridden monitor settings
3305    let mut effective_config = config.clone();
3306    effective_config.monitor = monitor_config;
3307
3308    run(Some(args.token), &ctx, &effective_config, clients).await
3309}
3310
3311/// Entry point for the monitor command from interactive mode.
3312pub async fn run(
3313    token: Option<String>,
3314    ctx: &SessionContext,
3315    config: &Config,
3316    clients: &dyn ChainClientFactory,
3317) -> Result<()> {
3318    let token_input = match token {
3319        Some(t) => t,
3320        None => {
3321            return Err(ScopeError::Chain(
3322                "Token address or symbol required. Usage: monitor <token>".to_string(),
3323            ));
3324        }
3325    };
3326
3327    println!("Starting live monitor for {}...", token_input);
3328    println!("Fetching initial data...");
3329
3330    // Resolve token address
3331    let dex_client = clients.create_dex_client();
3332    let token_address =
3333        resolve_token_address(&token_input, &ctx.chain, config, dex_client.as_ref()).await?;
3334
3335    // Fetch initial data
3336    let initial_data = dex_client
3337        .get_token_data(&ctx.chain, &token_address)
3338        .await?;
3339
3340    println!(
3341        "Monitoring {} ({}) on {}",
3342        initial_data.symbol, initial_data.name, ctx.chain
3343    );
3344    println!("Press Q to quit, R to refresh, P to pause...\n");
3345
3346    // Small delay to let user read the message
3347    tokio::time::sleep(Duration::from_millis(500)).await;
3348
3349    // Create optional chain client for on-chain data (holder count, etc.)
3350    let chain_client = clients.create_chain_client(&ctx.chain).ok();
3351
3352    // Create and run the app
3353    let mut app = MonitorApp::new(initial_data, &ctx.chain, &config.monitor, chain_client)?;
3354    let result = app.run().await;
3355
3356    // Cleanup is handled by Drop, but we do it explicitly for error handling
3357    if let Err(e) = app.cleanup() {
3358        eprintln!("Warning: Failed to cleanup terminal: {}", e);
3359    }
3360
3361    result
3362}
3363
3364/// Resolves a token input (address or symbol) to an address.
3365async fn resolve_token_address(
3366    input: &str,
3367    chain: &str,
3368    _config: &Config,
3369    dex_client: &dyn DexDataSource,
3370) -> Result<String> {
3371    // Check if it's already an address (EVM, Solana, Tron)
3372    if crate::tokens::TokenAliases::is_address(input) {
3373        return Ok(input.to_string());
3374    }
3375
3376    // Check saved aliases
3377    let aliases = crate::tokens::TokenAliases::load();
3378    if let Some(alias) = aliases.get(input, Some(chain)) {
3379        return Ok(alias.address.clone());
3380    }
3381
3382    // Search by name/symbol
3383    let results = dex_client.search_tokens(input, Some(chain)).await?;
3384
3385    if results.is_empty() {
3386        return Err(ScopeError::NotFound(format!(
3387            "No token found matching '{}' on {}",
3388            input, chain
3389        )));
3390    }
3391
3392    // If only one result, use it directly
3393    if results.len() == 1 {
3394        let token = &results[0];
3395        println!(
3396            "Found: {} ({}) - ${:.6}",
3397            token.symbol,
3398            token.name,
3399            token.price_usd.unwrap_or(0.0)
3400        );
3401        return Ok(token.address.clone());
3402    }
3403
3404    // Multiple results — prompt user to select
3405    let selected = select_token_interactive(&results)?;
3406    Ok(selected.address.clone())
3407}
3408
3409/// Abbreviates a blockchain address for display (e.g. "0x1234...abcd").
3410fn abbreviate_address(addr: &str) -> String {
3411    if addr.len() > 16 {
3412        format!("{}...{}", &addr[..8], &addr[addr.len() - 6..])
3413    } else {
3414        addr.to_string()
3415    }
3416}
3417
3418/// Displays token search results and prompts the user to select one.
3419fn select_token_interactive(
3420    results: &[crate::chains::dex::TokenSearchResult],
3421) -> Result<&crate::chains::dex::TokenSearchResult> {
3422    let stdin = io::stdin();
3423    let stdout = io::stdout();
3424    select_token_impl(results, &mut stdin.lock(), &mut stdout.lock())
3425}
3426
3427/// Testable implementation of token selection with injected I/O.
3428fn select_token_impl<'a>(
3429    results: &'a [crate::chains::dex::TokenSearchResult],
3430    reader: &mut impl io::BufRead,
3431    writer: &mut impl io::Write,
3432) -> Result<&'a crate::chains::dex::TokenSearchResult> {
3433    writeln!(
3434        writer,
3435        "\nFound {} tokens matching your query:\n",
3436        results.len()
3437    )
3438    .map_err(|e| ScopeError::Io(e.to_string()))?;
3439
3440    writeln!(
3441        writer,
3442        "{:>3}  {:>8}  {:<22}  {:<16}  {:>12}  {:>12}",
3443        "#", "Symbol", "Name", "Address", "Price", "Liquidity"
3444    )
3445    .map_err(|e| ScopeError::Io(e.to_string()))?;
3446
3447    writeln!(writer, "{}", "─".repeat(82)).map_err(|e| ScopeError::Io(e.to_string()))?;
3448
3449    for (i, token) in results.iter().enumerate() {
3450        let price = token
3451            .price_usd
3452            .map(|p| format!("${:.6}", p))
3453            .unwrap_or_else(|| "N/A".to_string());
3454
3455        let liquidity = format_monitor_number(token.liquidity_usd);
3456        let addr = abbreviate_address(&token.address);
3457
3458        // Truncate name if too long
3459        let name = if token.name.len() > 20 {
3460            format!("{}...", &token.name[..17])
3461        } else {
3462            token.name.clone()
3463        };
3464
3465        writeln!(
3466            writer,
3467            "{:>3}  {:>8}  {:<22}  {:<16}  {:>12}  {:>12}",
3468            i + 1,
3469            token.symbol,
3470            name,
3471            addr,
3472            price,
3473            liquidity
3474        )
3475        .map_err(|e| ScopeError::Io(e.to_string()))?;
3476    }
3477
3478    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
3479    write!(writer, "Select token (1-{}): ", results.len())
3480        .map_err(|e| ScopeError::Io(e.to_string()))?;
3481    writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
3482
3483    let mut input = String::new();
3484    reader
3485        .read_line(&mut input)
3486        .map_err(|e| ScopeError::Io(e.to_string()))?;
3487
3488    let selection: usize = input
3489        .trim()
3490        .parse()
3491        .map_err(|_| ScopeError::Api("Invalid selection".to_string()))?;
3492
3493    if selection < 1 || selection > results.len() {
3494        return Err(ScopeError::Api(format!(
3495            "Selection must be between 1 and {}",
3496            results.len()
3497        )));
3498    }
3499
3500    let selected = &results[selection - 1];
3501    writeln!(
3502        writer,
3503        "Selected: {} ({}) at {}",
3504        selected.symbol, selected.name, selected.address
3505    )
3506    .map_err(|e| ScopeError::Io(e.to_string()))?;
3507
3508    Ok(selected)
3509}
3510
3511/// Format a number for the monitor selection table.
3512fn format_monitor_number(value: f64) -> String {
3513    if value >= 1_000_000_000.0 {
3514        format!("${:.2}B", value / 1_000_000_000.0)
3515    } else if value >= 1_000_000.0 {
3516        format!("${:.2}M", value / 1_000_000.0)
3517    } else if value >= 1_000.0 {
3518        format!("${:.2}K", value / 1_000.0)
3519    } else {
3520        format!("${:.2}", value)
3521    }
3522}
3523
3524// ============================================================================
3525// Unit Tests
3526// ============================================================================
3527
3528#[cfg(test)]
3529mod tests {
3530    use super::*;
3531
3532    fn create_test_token_data() -> DexTokenData {
3533        DexTokenData {
3534            address: "0x1234".to_string(),
3535            symbol: "TEST".to_string(),
3536            name: "Test Token".to_string(),
3537            price_usd: 1.0,
3538            price_change_24h: 5.0,
3539            price_change_6h: 2.0,
3540            price_change_1h: 0.5,
3541            price_change_5m: 0.1,
3542            volume_24h: 1_000_000.0,
3543            volume_6h: 250_000.0,
3544            volume_1h: 50_000.0,
3545            liquidity_usd: 500_000.0,
3546            market_cap: Some(10_000_000.0),
3547            fdv: Some(100_000_000.0),
3548            pairs: vec![],
3549            price_history: vec![],
3550            volume_history: vec![],
3551            total_buys_24h: 100,
3552            total_sells_24h: 50,
3553            total_buys_6h: 25,
3554            total_sells_6h: 12,
3555            total_buys_1h: 5,
3556            total_sells_1h: 3,
3557            earliest_pair_created_at: Some(1700000000000),
3558            image_url: None,
3559            websites: vec![],
3560            socials: vec![],
3561            dexscreener_url: None,
3562        }
3563    }
3564
3565    #[test]
3566    fn test_monitor_state_new() {
3567        let token_data = create_test_token_data();
3568        let state = MonitorState::new(&token_data, "ethereum");
3569
3570        assert_eq!(state.symbol, "TEST");
3571        assert_eq!(state.chain, "ethereum");
3572        assert_eq!(state.current_price, 1.0);
3573        assert_eq!(state.buys_24h, 100);
3574        assert_eq!(state.sells_24h, 50);
3575        assert!(!state.paused);
3576    }
3577
3578    #[test]
3579    fn test_monitor_state_buy_ratio() {
3580        let token_data = create_test_token_data();
3581        let state = MonitorState::new(&token_data, "ethereum");
3582
3583        let ratio = state.buy_ratio();
3584        assert!((ratio - 0.6666).abs() < 0.01); // 100 / 150 ≈ 0.667
3585    }
3586
3587    #[test]
3588    fn test_monitor_state_buy_ratio_zero() {
3589        let mut token_data = create_test_token_data();
3590        token_data.total_buys_24h = 0;
3591        token_data.total_sells_24h = 0;
3592        let state = MonitorState::new(&token_data, "ethereum");
3593
3594        assert_eq!(state.buy_ratio(), 0.5); // Default to 50/50 when no data
3595    }
3596
3597    #[test]
3598    fn test_monitor_state_toggle_pause() {
3599        let token_data = create_test_token_data();
3600        let mut state = MonitorState::new(&token_data, "ethereum");
3601
3602        assert!(!state.paused);
3603        state.toggle_pause();
3604        assert!(state.paused);
3605        state.toggle_pause();
3606        assert!(!state.paused);
3607    }
3608
3609    #[test]
3610    fn test_monitor_state_should_refresh() {
3611        let token_data = create_test_token_data();
3612        let mut state = MonitorState::new(&token_data, "ethereum");
3613        state.refresh_rate = Duration::from_secs(60);
3614
3615        // Just created, should not need refresh (60s refresh rate)
3616        assert!(!state.should_refresh());
3617
3618        // Simulate time passing well beyond refresh rate
3619        state.last_update = Instant::now() - Duration::from_secs(120);
3620        assert!(state.should_refresh());
3621
3622        // Pause should prevent refresh
3623        state.paused = true;
3624        assert!(!state.should_refresh());
3625    }
3626
3627    #[test]
3628    fn test_format_number() {
3629        assert_eq!(format_number(500.0), "500.00");
3630        assert_eq!(format_number(1_500.0), "1.50K");
3631        assert_eq!(format_number(1_500_000.0), "1.50M");
3632        assert_eq!(format_number(1_500_000_000.0), "1.50B");
3633    }
3634
3635    #[test]
3636    fn test_format_usd() {
3637        assert_eq!(format_usd(500.0), "$500.00");
3638        assert_eq!(format_usd(1_500.0), "$1.50K");
3639        assert_eq!(format_usd(1_500_000.0), "$1.50M");
3640        assert_eq!(format_usd(1_500_000_000.0), "$1.50B");
3641    }
3642
3643    #[test]
3644    fn test_monitor_state_update() {
3645        let token_data = create_test_token_data();
3646        let mut state = MonitorState::new(&token_data, "ethereum");
3647
3648        let initial_len = state.price_history.len();
3649
3650        let mut updated_data = token_data.clone();
3651        updated_data.price_usd = 1.5;
3652        updated_data.total_buys_24h = 150;
3653
3654        state.update(&updated_data);
3655
3656        assert_eq!(state.current_price, 1.5);
3657        assert_eq!(state.buys_24h, 150);
3658        // Should have one more point after update
3659        assert_eq!(state.price_history.len(), initial_len + 1);
3660    }
3661
3662    #[test]
3663    fn test_monitor_state_refresh_rate_adjustment() {
3664        let token_data = create_test_token_data();
3665        let mut state = MonitorState::new(&token_data, "ethereum");
3666
3667        // Default is 5 seconds
3668        assert_eq!(state.refresh_rate_secs(), 5);
3669
3670        // Slow down (+5s)
3671        state.slower_refresh();
3672        assert_eq!(state.refresh_rate_secs(), 10);
3673
3674        // Speed up (-5s)
3675        state.faster_refresh();
3676        assert_eq!(state.refresh_rate_secs(), 5);
3677
3678        // Speed up again (should hit minimum of 1s)
3679        state.faster_refresh();
3680        assert_eq!(state.refresh_rate_secs(), 1);
3681
3682        // Can't go below 1s
3683        state.faster_refresh();
3684        assert_eq!(state.refresh_rate_secs(), 1);
3685
3686        // Slow down to max (60s)
3687        for _ in 0..20 {
3688            state.slower_refresh();
3689        }
3690        assert_eq!(state.refresh_rate_secs(), 60);
3691    }
3692
3693    #[test]
3694    fn test_time_period() {
3695        assert_eq!(TimePeriod::Min1.label(), "1m");
3696        assert_eq!(TimePeriod::Min5.label(), "5m");
3697        assert_eq!(TimePeriod::Min15.label(), "15m");
3698        assert_eq!(TimePeriod::Hour1.label(), "1h");
3699        assert_eq!(TimePeriod::Hour4.label(), "4h");
3700        assert_eq!(TimePeriod::Day1.label(), "1d");
3701
3702        assert_eq!(TimePeriod::Min1.duration_secs(), 60);
3703        assert_eq!(TimePeriod::Min5.duration_secs(), 300);
3704        assert_eq!(TimePeriod::Min15.duration_secs(), 15 * 60);
3705        assert_eq!(TimePeriod::Hour1.duration_secs(), 3600);
3706        assert_eq!(TimePeriod::Hour4.duration_secs(), 4 * 3600);
3707        assert_eq!(TimePeriod::Day1.duration_secs(), 24 * 3600);
3708
3709        // Test cycling
3710        assert_eq!(TimePeriod::Min1.next(), TimePeriod::Min5);
3711        assert_eq!(TimePeriod::Min5.next(), TimePeriod::Min15);
3712        assert_eq!(TimePeriod::Min15.next(), TimePeriod::Hour1);
3713        assert_eq!(TimePeriod::Hour1.next(), TimePeriod::Hour4);
3714        assert_eq!(TimePeriod::Hour4.next(), TimePeriod::Day1);
3715        assert_eq!(TimePeriod::Day1.next(), TimePeriod::Min1);
3716    }
3717
3718    #[test]
3719    fn test_monitor_state_time_period() {
3720        let token_data = create_test_token_data();
3721        let mut state = MonitorState::new(&token_data, "ethereum");
3722
3723        // Default is 1 hour
3724        assert_eq!(state.time_period, TimePeriod::Hour1);
3725
3726        // Cycle through periods
3727        state.cycle_time_period();
3728        assert_eq!(state.time_period, TimePeriod::Hour4);
3729
3730        state.set_time_period(TimePeriod::Day1);
3731        assert_eq!(state.time_period, TimePeriod::Day1);
3732    }
3733
3734    #[test]
3735    fn test_synthetic_history_generation() {
3736        let token_data = create_test_token_data();
3737        let state = MonitorState::new(&token_data, "ethereum");
3738
3739        // Should have generated history (synthetic or cached real)
3740        assert!(state.price_history.len() > 1);
3741        assert!(state.volume_history.len() > 1);
3742
3743        // Price history should span some time range
3744        if let (Some(first), Some(last)) = (state.price_history.front(), state.price_history.back())
3745        {
3746            let span = last.timestamp - first.timestamp;
3747            assert!(span > 0.0); // History should span some time
3748        }
3749    }
3750
3751    #[test]
3752    fn test_real_data_marking() {
3753        let token_data = create_test_token_data();
3754        let mut state = MonitorState::new(&token_data, "ethereum");
3755
3756        // Initially all synthetic
3757        let (synthetic, real) = state.data_stats();
3758        assert!(synthetic > 0);
3759        assert_eq!(real, 0);
3760
3761        // After update, should have real data
3762        let mut updated_data = token_data.clone();
3763        updated_data.price_usd = 1.5;
3764        state.update(&updated_data);
3765
3766        let (synthetic2, real2) = state.data_stats();
3767        assert!(synthetic2 > 0);
3768        assert_eq!(real2, 1);
3769        assert_eq!(state.real_data_count, 1);
3770
3771        // The last point should be real
3772        assert!(
3773            state
3774                .price_history
3775                .back()
3776                .map(|p| p.is_real)
3777                .unwrap_or(false)
3778        );
3779    }
3780
3781    #[test]
3782    fn test_memory_usage() {
3783        let token_data = create_test_token_data();
3784        let state = MonitorState::new(&token_data, "ethereum");
3785
3786        let mem = state.memory_usage();
3787        // DataPoint is 24 bytes, should have some data points
3788        assert!(mem > 0);
3789
3790        // Each DataPoint is 24 bytes (f64 + f64 + bool + padding)
3791        let expected_point_size = std::mem::size_of::<DataPoint>();
3792        assert_eq!(expected_point_size, 24);
3793    }
3794
3795    #[test]
3796    fn test_get_data_for_period_returns_flags() {
3797        let token_data = create_test_token_data();
3798        let mut state = MonitorState::new(&token_data, "ethereum");
3799
3800        // Get initial data (may contain cached real data or synthetic)
3801        let (data, is_real) = state.get_price_data_for_period();
3802        assert_eq!(data.len(), is_real.len());
3803
3804        // Add real data point
3805        state.update(&token_data);
3806
3807        let (_data2, is_real2) = state.get_price_data_for_period();
3808        // Should have at least one real point now
3809        assert!(is_real2.iter().any(|r| *r));
3810    }
3811
3812    #[test]
3813    fn test_cache_path_generation() {
3814        let path =
3815            MonitorState::cache_path("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum");
3816        assert!(path.to_string_lossy().contains("bcc_monitor_"));
3817        assert!(path.to_string_lossy().contains("ethereum"));
3818        // Should be in temp directory
3819        let temp_dir = std::env::temp_dir();
3820        assert!(path.starts_with(temp_dir));
3821    }
3822
3823    #[test]
3824    fn test_cache_save_and_load() {
3825        let token_data = create_test_token_data();
3826        let mut state = MonitorState::new(&token_data, "test_chain");
3827
3828        // Add some real data
3829        state.update(&token_data);
3830        state.update(&token_data);
3831
3832        // Save cache
3833        state.save_cache();
3834
3835        // Verify cache file exists
3836        let path = MonitorState::cache_path(&state.token_address, &state.chain);
3837        assert!(path.exists(), "Cache file should exist after save");
3838
3839        // Load cache
3840        let loaded = MonitorState::load_cache(&state.token_address, &state.chain);
3841        assert!(loaded.is_some(), "Should be able to load saved cache");
3842
3843        let cached = loaded.unwrap();
3844        assert_eq!(cached.token_address, state.token_address);
3845        assert_eq!(cached.chain, state.chain);
3846        assert!(!cached.price_history.is_empty());
3847
3848        // Cleanup
3849        let _ = std::fs::remove_file(path);
3850    }
3851
3852    // ========================================================================
3853    // Price formatting tests
3854    // ========================================================================
3855
3856    #[test]
3857    fn test_format_price_usd_high() {
3858        let formatted = format_price_usd(2500.50);
3859        assert!(formatted.starts_with("$2500.50"));
3860    }
3861
3862    #[test]
3863    fn test_format_price_usd_stablecoin() {
3864        let formatted = format_price_usd(1.0001);
3865        assert!(formatted.contains("1.000100"));
3866        assert!(is_stablecoin_price(1.0001));
3867    }
3868
3869    #[test]
3870    fn test_format_price_usd_medium() {
3871        let formatted = format_price_usd(5.1234);
3872        assert!(formatted.starts_with("$5.1234"));
3873    }
3874
3875    #[test]
3876    fn test_format_price_usd_small() {
3877        let formatted = format_price_usd(0.05);
3878        assert!(formatted.starts_with("$0.0500"));
3879    }
3880
3881    #[test]
3882    fn test_format_price_usd_micro() {
3883        let formatted = format_price_usd(0.001);
3884        assert!(formatted.starts_with("$0.0010"));
3885    }
3886
3887    #[test]
3888    fn test_format_price_usd_nano() {
3889        let formatted = format_price_usd(0.00001);
3890        assert!(formatted.contains("0.0000100"));
3891    }
3892
3893    #[test]
3894    fn test_is_stablecoin_price() {
3895        assert!(is_stablecoin_price(1.0));
3896        assert!(is_stablecoin_price(0.999));
3897        assert!(is_stablecoin_price(1.001));
3898        assert!(is_stablecoin_price(0.95));
3899        assert!(is_stablecoin_price(1.05));
3900        assert!(!is_stablecoin_price(0.94));
3901        assert!(!is_stablecoin_price(1.06));
3902        assert!(!is_stablecoin_price(100.0));
3903    }
3904
3905    // ========================================================================
3906    // OHLC candle tests
3907    // ========================================================================
3908
3909    #[test]
3910    fn test_ohlc_candle_new() {
3911        let candle = OhlcCandle::new(1000.0, 50.0);
3912        assert_eq!(candle.open, 50.0);
3913        assert_eq!(candle.high, 50.0);
3914        assert_eq!(candle.low, 50.0);
3915        assert_eq!(candle.close, 50.0);
3916        assert!(candle.is_bullish);
3917    }
3918
3919    #[test]
3920    fn test_ohlc_candle_update() {
3921        let mut candle = OhlcCandle::new(1000.0, 50.0);
3922        candle.update(55.0);
3923        assert_eq!(candle.high, 55.0);
3924        assert_eq!(candle.close, 55.0);
3925        assert!(candle.is_bullish);
3926
3927        candle.update(45.0);
3928        assert_eq!(candle.low, 45.0);
3929        assert_eq!(candle.close, 45.0);
3930        assert!(!candle.is_bullish); // close < open
3931    }
3932
3933    #[test]
3934    fn test_get_ohlc_candles() {
3935        let token_data = create_test_token_data();
3936        let mut state = MonitorState::new(&token_data, "ethereum");
3937        // Add several data points
3938        for i in 0..20 {
3939            let mut data = token_data.clone();
3940            data.price_usd = 1.0 + (i as f64 * 0.01);
3941            state.update(&data);
3942        }
3943        let candles = state.get_ohlc_candles();
3944        // Should have some candles
3945        assert!(!candles.is_empty());
3946    }
3947
3948    // ========================================================================
3949    // ChartMode tests
3950    // ========================================================================
3951
3952    #[test]
3953    fn test_chart_mode_cycle() {
3954        let mode = ChartMode::Line;
3955        assert_eq!(mode.next(), ChartMode::Candlestick);
3956        assert_eq!(ChartMode::Candlestick.next(), ChartMode::VolumeProfile);
3957        assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
3958    }
3959
3960    #[test]
3961    fn test_chart_mode_label() {
3962        assert_eq!(ChartMode::Line.label(), "Line");
3963        assert_eq!(ChartMode::Candlestick.label(), "Candle");
3964        assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
3965    }
3966
3967    // ========================================================================
3968    // TUI rendering tests (headless TestBackend)
3969    // ========================================================================
3970
3971    use ratatui::Terminal;
3972    use ratatui::backend::TestBackend;
3973
3974    fn create_test_terminal() -> Terminal<TestBackend> {
3975        let backend = TestBackend::new(120, 40);
3976        Terminal::new(backend).unwrap()
3977    }
3978
3979    fn create_populated_state() -> MonitorState {
3980        let token_data = create_test_token_data();
3981        let mut state = MonitorState::new(&token_data, "ethereum");
3982        // Add real data points so charts have content
3983        for i in 0..30 {
3984            let mut data = token_data.clone();
3985            data.price_usd = 1.0 + (i as f64 * 0.01);
3986            data.volume_24h = 1_000_000.0 + (i as f64 * 10_000.0);
3987            state.update(&data);
3988        }
3989        state
3990    }
3991
3992    #[test]
3993    fn test_render_header_no_panic() {
3994        let mut terminal = create_test_terminal();
3995        let state = create_populated_state();
3996        terminal
3997            .draw(|f| render_header(f, f.area(), &state))
3998            .unwrap();
3999    }
4000
4001    #[test]
4002    fn test_render_price_chart_no_panic() {
4003        let mut terminal = create_test_terminal();
4004        let state = create_populated_state();
4005        terminal
4006            .draw(|f| render_price_chart(f, f.area(), &state))
4007            .unwrap();
4008    }
4009
4010    #[test]
4011    fn test_render_price_chart_line_mode() {
4012        let mut terminal = create_test_terminal();
4013        let mut state = create_populated_state();
4014        state.chart_mode = ChartMode::Line;
4015        terminal
4016            .draw(|f| render_price_chart(f, f.area(), &state))
4017            .unwrap();
4018    }
4019
4020    #[test]
4021    fn test_render_candlestick_chart_no_panic() {
4022        let mut terminal = create_test_terminal();
4023        let state = create_populated_state();
4024        terminal
4025            .draw(|f| render_candlestick_chart(f, f.area(), &state))
4026            .unwrap();
4027    }
4028
4029    #[test]
4030    fn test_render_candlestick_chart_empty() {
4031        let mut terminal = create_test_terminal();
4032        let token_data = create_test_token_data();
4033        let state = MonitorState::new(&token_data, "ethereum");
4034        terminal
4035            .draw(|f| render_candlestick_chart(f, f.area(), &state))
4036            .unwrap();
4037    }
4038
4039    #[test]
4040    fn test_render_volume_chart_no_panic() {
4041        let mut terminal = create_test_terminal();
4042        let state = create_populated_state();
4043        terminal
4044            .draw(|f| render_volume_chart(f, f.area(), &state))
4045            .unwrap();
4046    }
4047
4048    #[test]
4049    fn test_render_volume_chart_empty() {
4050        let mut terminal = create_test_terminal();
4051        let token_data = create_test_token_data();
4052        let state = MonitorState::new(&token_data, "ethereum");
4053        terminal
4054            .draw(|f| render_volume_chart(f, f.area(), &state))
4055            .unwrap();
4056    }
4057
4058    #[test]
4059    fn test_render_buy_sell_gauge_no_panic() {
4060        let mut terminal = create_test_terminal();
4061        let mut state = create_populated_state();
4062        terminal
4063            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
4064            .unwrap();
4065    }
4066
4067    #[test]
4068    fn test_render_buy_sell_gauge_balanced() {
4069        let mut terminal = create_test_terminal();
4070        let mut token_data = create_test_token_data();
4071        token_data.total_buys_24h = 100;
4072        token_data.total_sells_24h = 100;
4073        let mut state = MonitorState::new(&token_data, "ethereum");
4074        terminal
4075            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
4076            .unwrap();
4077    }
4078
4079    #[test]
4080    fn test_render_metrics_panel_no_panic() {
4081        let mut terminal = create_test_terminal();
4082        let state = create_populated_state();
4083        terminal
4084            .draw(|f| render_metrics_panel(f, f.area(), &state))
4085            .unwrap();
4086    }
4087
4088    #[test]
4089    fn test_render_metrics_panel_no_market_cap() {
4090        let mut terminal = create_test_terminal();
4091        let mut token_data = create_test_token_data();
4092        token_data.market_cap = None;
4093        token_data.fdv = None;
4094        let state = MonitorState::new(&token_data, "ethereum");
4095        terminal
4096            .draw(|f| render_metrics_panel(f, f.area(), &state))
4097            .unwrap();
4098    }
4099
4100    #[test]
4101    fn test_render_footer_no_panic() {
4102        let mut terminal = create_test_terminal();
4103        let state = create_populated_state();
4104        terminal
4105            .draw(|f| render_footer(f, f.area(), &state))
4106            .unwrap();
4107    }
4108
4109    #[test]
4110    fn test_render_footer_paused() {
4111        let mut terminal = create_test_terminal();
4112        let token_data = create_test_token_data();
4113        let mut state = MonitorState::new(&token_data, "ethereum");
4114        state.paused = true;
4115        terminal
4116            .draw(|f| render_footer(f, f.area(), &state))
4117            .unwrap();
4118    }
4119
4120    #[test]
4121    fn test_render_all_components() {
4122        // Exercise the full draw_ui layout path
4123        let mut terminal = create_test_terminal();
4124        let mut state = create_populated_state();
4125        terminal
4126            .draw(|f| {
4127                let area = f.area();
4128                let chunks = Layout::default()
4129                    .direction(Direction::Vertical)
4130                    .constraints([
4131                        Constraint::Length(3),
4132                        Constraint::Min(10),
4133                        Constraint::Length(5),
4134                        Constraint::Length(3),
4135                        Constraint::Length(3),
4136                    ])
4137                    .split(area);
4138                render_header(f, chunks[0], &state);
4139                render_price_chart(f, chunks[1], &state);
4140                render_volume_chart(f, chunks[2], &state);
4141                render_buy_sell_gauge(f, chunks[3], &mut state);
4142                render_footer(f, chunks[4], &state);
4143            })
4144            .unwrap();
4145    }
4146
4147    #[test]
4148    fn test_render_candlestick_mode() {
4149        let mut terminal = create_test_terminal();
4150        let mut state = create_populated_state();
4151        state.chart_mode = ChartMode::Candlestick;
4152        terminal
4153            .draw(|f| {
4154                let area = f.area();
4155                let chunks = Layout::default()
4156                    .direction(Direction::Vertical)
4157                    .constraints([Constraint::Length(3), Constraint::Min(10)])
4158                    .split(area);
4159                render_header(f, chunks[0], &state);
4160                render_candlestick_chart(f, chunks[1], &state);
4161            })
4162            .unwrap();
4163    }
4164
4165    #[test]
4166    fn test_render_with_different_time_periods() {
4167        let mut terminal = create_test_terminal();
4168        let mut state = create_populated_state();
4169
4170        for period in [
4171            TimePeriod::Min1,
4172            TimePeriod::Min5,
4173            TimePeriod::Min15,
4174            TimePeriod::Hour1,
4175            TimePeriod::Hour4,
4176            TimePeriod::Day1,
4177        ] {
4178            state.time_period = period;
4179            terminal
4180                .draw(|f| render_price_chart(f, f.area(), &state))
4181                .unwrap();
4182        }
4183    }
4184
4185    #[test]
4186    fn test_render_metrics_with_stablecoin() {
4187        let mut terminal = create_test_terminal();
4188        let mut token_data = create_test_token_data();
4189        token_data.price_usd = 0.999;
4190        token_data.symbol = "USDC".to_string();
4191        let state = MonitorState::new(&token_data, "ethereum");
4192        terminal
4193            .draw(|f| render_metrics_panel(f, f.area(), &state))
4194            .unwrap();
4195    }
4196
4197    #[test]
4198    fn test_render_header_with_negative_change() {
4199        let mut terminal = create_test_terminal();
4200        let mut token_data = create_test_token_data();
4201        token_data.price_change_24h = -15.5;
4202        token_data.price_change_1h = -2.3;
4203        let state = MonitorState::new(&token_data, "ethereum");
4204        terminal
4205            .draw(|f| render_header(f, f.area(), &state))
4206            .unwrap();
4207    }
4208
4209    // ========================================================================
4210    // MonitorState method tests
4211    // ========================================================================
4212
4213    #[test]
4214    fn test_toggle_chart_mode_roundtrip() {
4215        let token_data = create_test_token_data();
4216        let mut state = MonitorState::new(&token_data, "ethereum");
4217        assert_eq!(state.chart_mode, ChartMode::Line);
4218        state.toggle_chart_mode();
4219        assert_eq!(state.chart_mode, ChartMode::Candlestick);
4220        state.toggle_chart_mode();
4221        assert_eq!(state.chart_mode, ChartMode::VolumeProfile);
4222        state.toggle_chart_mode();
4223        assert_eq!(state.chart_mode, ChartMode::Line);
4224    }
4225
4226    #[test]
4227    fn test_cycle_all_time_periods() {
4228        let token_data = create_test_token_data();
4229        let mut state = MonitorState::new(&token_data, "ethereum");
4230        assert_eq!(state.time_period, TimePeriod::Hour1);
4231        state.cycle_time_period();
4232        assert_eq!(state.time_period, TimePeriod::Hour4);
4233        state.cycle_time_period();
4234        assert_eq!(state.time_period, TimePeriod::Day1);
4235        state.cycle_time_period();
4236        assert_eq!(state.time_period, TimePeriod::Min1);
4237        state.cycle_time_period();
4238        assert_eq!(state.time_period, TimePeriod::Min5);
4239        state.cycle_time_period();
4240        assert_eq!(state.time_period, TimePeriod::Min15);
4241        state.cycle_time_period();
4242        assert_eq!(state.time_period, TimePeriod::Hour1);
4243    }
4244
4245    #[test]
4246    fn test_set_specific_time_period() {
4247        let token_data = create_test_token_data();
4248        let mut state = MonitorState::new(&token_data, "ethereum");
4249        state.set_time_period(TimePeriod::Day1);
4250        assert_eq!(state.time_period, TimePeriod::Day1);
4251    }
4252
4253    #[test]
4254    fn test_pause_resume_roundtrip() {
4255        let token_data = create_test_token_data();
4256        let mut state = MonitorState::new(&token_data, "ethereum");
4257        assert!(!state.paused);
4258        state.toggle_pause();
4259        assert!(state.paused);
4260        state.toggle_pause();
4261        assert!(!state.paused);
4262    }
4263
4264    #[test]
4265    fn test_force_refresh_unpauses() {
4266        let token_data = create_test_token_data();
4267        let mut state = MonitorState::new(&token_data, "ethereum");
4268        state.paused = true;
4269        state.force_refresh();
4270        assert!(!state.paused);
4271        assert!(state.should_refresh());
4272    }
4273
4274    #[test]
4275    fn test_refresh_rate_adjust() {
4276        let token_data = create_test_token_data();
4277        let mut state = MonitorState::new(&token_data, "ethereum");
4278        assert_eq!(state.refresh_rate_secs(), 5);
4279
4280        state.slower_refresh();
4281        assert_eq!(state.refresh_rate_secs(), 10);
4282
4283        state.faster_refresh();
4284        assert_eq!(state.refresh_rate_secs(), 5);
4285    }
4286
4287    #[test]
4288    fn test_faster_refresh_clamped_min() {
4289        let token_data = create_test_token_data();
4290        let mut state = MonitorState::new(&token_data, "ethereum");
4291        for _ in 0..10 {
4292            state.faster_refresh();
4293        }
4294        assert!(state.refresh_rate_secs() >= 1);
4295    }
4296
4297    #[test]
4298    fn test_slower_refresh_clamped_max() {
4299        let token_data = create_test_token_data();
4300        let mut state = MonitorState::new(&token_data, "ethereum");
4301        for _ in 0..20 {
4302            state.slower_refresh();
4303        }
4304        assert!(state.refresh_rate_secs() <= 60);
4305    }
4306
4307    #[test]
4308    fn test_buy_ratio_balanced() {
4309        let mut token_data = create_test_token_data();
4310        token_data.total_buys_24h = 100;
4311        token_data.total_sells_24h = 100;
4312        let state = MonitorState::new(&token_data, "ethereum");
4313        assert!((state.buy_ratio() - 0.5).abs() < 0.01);
4314    }
4315
4316    #[test]
4317    fn test_buy_ratio_no_trades() {
4318        let mut token_data = create_test_token_data();
4319        token_data.total_buys_24h = 0;
4320        token_data.total_sells_24h = 0;
4321        let state = MonitorState::new(&token_data, "ethereum");
4322        assert!((state.buy_ratio() - 0.5).abs() < 0.01);
4323    }
4324
4325    #[test]
4326    fn test_data_stats_initial() {
4327        let token_data = create_test_token_data();
4328        let state = MonitorState::new(&token_data, "ethereum");
4329        let (synthetic, real) = state.data_stats();
4330        assert!(synthetic > 0 || real == 0);
4331    }
4332
4333    #[test]
4334    fn test_memory_usage_nonzero() {
4335        let token_data = create_test_token_data();
4336        let state = MonitorState::new(&token_data, "ethereum");
4337        let usage = state.memory_usage();
4338        assert!(usage > 0);
4339    }
4340
4341    #[test]
4342    fn test_price_data_for_period() {
4343        let token_data = create_test_token_data();
4344        let state = MonitorState::new(&token_data, "ethereum");
4345        let (data, is_real) = state.get_price_data_for_period();
4346        assert_eq!(data.len(), is_real.len());
4347    }
4348
4349    #[test]
4350    fn test_volume_data_for_period() {
4351        let token_data = create_test_token_data();
4352        let state = MonitorState::new(&token_data, "ethereum");
4353        let (data, is_real) = state.get_volume_data_for_period();
4354        assert_eq!(data.len(), is_real.len());
4355    }
4356
4357    #[test]
4358    fn test_ohlc_candles_generation() {
4359        let token_data = create_test_token_data();
4360        let state = MonitorState::new(&token_data, "ethereum");
4361        let candles = state.get_ohlc_candles();
4362        for candle in &candles {
4363            assert!(candle.high >= candle.low);
4364        }
4365    }
4366
4367    #[test]
4368    fn test_state_update_with_new_data() {
4369        let token_data = create_test_token_data();
4370        let mut state = MonitorState::new(&token_data, "ethereum");
4371        let initial_count = state.real_data_count;
4372
4373        let mut updated_data = create_test_token_data();
4374        updated_data.price_usd = 2.0;
4375        updated_data.volume_24h = 2_000_000.0;
4376
4377        state.update(&updated_data);
4378        assert_eq!(state.current_price, 2.0);
4379        assert_eq!(state.real_data_count, initial_count + 1);
4380        assert!(state.error_message.is_none());
4381    }
4382
4383    #[test]
4384    fn test_cache_roundtrip_save_load() {
4385        let token_data = create_test_token_data();
4386        let state = MonitorState::new(&token_data, "ethereum");
4387
4388        state.save_cache();
4389
4390        let cache_path = MonitorState::cache_path(&token_data.address, "ethereum");
4391        assert!(cache_path.exists());
4392
4393        let cached = MonitorState::load_cache(&token_data.address, "ethereum");
4394        assert!(cached.is_some());
4395
4396        let _ = std::fs::remove_file(cache_path);
4397    }
4398
4399    #[test]
4400    fn test_should_refresh_when_paused() {
4401        let token_data = create_test_token_data();
4402        let mut state = MonitorState::new(&token_data, "ethereum");
4403        assert!(!state.should_refresh());
4404        state.paused = true;
4405        assert!(!state.should_refresh());
4406    }
4407
4408    #[test]
4409    fn test_ohlc_candle_lifecycle() {
4410        let mut candle = OhlcCandle::new(1700000000.0, 100.0);
4411        assert_eq!(candle.open, 100.0);
4412        assert!(candle.is_bullish);
4413        candle.update(110.0);
4414        assert_eq!(candle.high, 110.0);
4415        assert!(candle.is_bullish);
4416        candle.update(90.0);
4417        assert_eq!(candle.low, 90.0);
4418        assert!(!candle.is_bullish);
4419    }
4420
4421    #[test]
4422    fn test_time_period_display_impl() {
4423        assert_eq!(format!("{}", TimePeriod::Min1), "1m");
4424        assert_eq!(format!("{}", TimePeriod::Min15), "15m");
4425        assert_eq!(format!("{}", TimePeriod::Day1), "1d");
4426    }
4427
4428    #[test]
4429    fn test_log_messages_accumulate() {
4430        let token_data = create_test_token_data();
4431        let mut state = MonitorState::new(&token_data, "ethereum");
4432        // Trigger actions that log
4433        state.toggle_pause();
4434        state.toggle_pause();
4435        state.cycle_time_period();
4436        state.toggle_chart_mode();
4437        assert!(!state.log_messages.is_empty());
4438    }
4439
4440    #[test]
4441    fn test_ui_function_full_render() {
4442        // Test the main ui() function which orchestrates all rendering
4443        let mut terminal = create_test_terminal();
4444        let mut state = create_populated_state();
4445        terminal.draw(|f| ui(f, &mut state)).unwrap();
4446    }
4447
4448    #[test]
4449    fn test_ui_function_candlestick_mode() {
4450        let mut terminal = create_test_terminal();
4451        let mut state = create_populated_state();
4452        state.chart_mode = ChartMode::Candlestick;
4453        terminal.draw(|f| ui(f, &mut state)).unwrap();
4454    }
4455
4456    #[test]
4457    fn test_ui_function_with_error_message() {
4458        let mut terminal = create_test_terminal();
4459        let mut state = create_populated_state();
4460        state.error_message = Some("Test error".to_string());
4461        terminal.draw(|f| ui(f, &mut state)).unwrap();
4462    }
4463
4464    #[test]
4465    fn test_render_header_with_small_positive_change() {
4466        let mut terminal = create_test_terminal();
4467        let mut state = create_populated_state();
4468        state.price_change_24h = 0.3; // Between 0 and 0.5 -> △
4469        terminal
4470            .draw(|f| render_header(f, f.area(), &state))
4471            .unwrap();
4472    }
4473
4474    #[test]
4475    fn test_render_header_with_small_negative_change() {
4476        let mut terminal = create_test_terminal();
4477        let mut state = create_populated_state();
4478        state.price_change_24h = -0.3; // Between -0.5 and 0 -> ▽
4479        terminal
4480            .draw(|f| render_header(f, f.area(), &state))
4481            .unwrap();
4482    }
4483
4484    #[test]
4485    fn test_render_buy_sell_gauge_high_buy_ratio() {
4486        let mut terminal = create_test_terminal();
4487        let token_data = create_test_token_data();
4488        let mut state = MonitorState::new(&token_data, "ethereum");
4489        state.buys_24h = 100;
4490        state.sells_24h = 10;
4491        terminal
4492            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
4493            .unwrap();
4494    }
4495
4496    #[test]
4497    fn test_render_buy_sell_gauge_zero_total() {
4498        let mut terminal = create_test_terminal();
4499        let token_data = create_test_token_data();
4500        let mut state = MonitorState::new(&token_data, "ethereum");
4501        state.buys_24h = 0;
4502        state.sells_24h = 0;
4503        terminal
4504            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
4505            .unwrap();
4506    }
4507
4508    #[test]
4509    fn test_render_metrics_with_market_cap() {
4510        let mut terminal = create_test_terminal();
4511        let token_data = create_test_token_data();
4512        let mut state = MonitorState::new(&token_data, "ethereum");
4513        state.market_cap = Some(1_000_000_000.0);
4514        state.fdv = Some(2_000_000_000.0);
4515        terminal
4516            .draw(|f| render_metrics_panel(f, f.area(), &state))
4517            .unwrap();
4518    }
4519
4520    #[test]
4521    fn test_render_footer_with_error() {
4522        let mut terminal = create_test_terminal();
4523        let mut state = create_populated_state();
4524        state.error_message = Some("Connection failed".to_string());
4525        terminal
4526            .draw(|f| render_footer(f, f.area(), &state))
4527            .unwrap();
4528    }
4529
4530    #[test]
4531    fn test_format_price_usd_various() {
4532        // Test format_price_usd with various magnitudes
4533        assert!(!format_price_usd(0.0000001).is_empty());
4534        assert!(!format_price_usd(0.001).is_empty());
4535        assert!(!format_price_usd(1.0).is_empty());
4536        assert!(!format_price_usd(100.0).is_empty());
4537        assert!(!format_price_usd(10000.0).is_empty());
4538        assert!(!format_price_usd(1000000.0).is_empty());
4539    }
4540
4541    #[test]
4542    fn test_format_usd_various() {
4543        assert!(!format_usd(0.0).is_empty());
4544        assert!(!format_usd(999.0).is_empty());
4545        assert!(!format_usd(1500.0).is_empty());
4546        assert!(!format_usd(1_500_000.0).is_empty());
4547        assert!(!format_usd(1_500_000_000.0).is_empty());
4548        assert!(!format_usd(1_500_000_000_000.0).is_empty());
4549    }
4550
4551    #[test]
4552    fn test_format_number_various() {
4553        assert!(!format_number(0.0).is_empty());
4554        assert!(!format_number(999.0).is_empty());
4555        assert!(!format_number(1500.0).is_empty());
4556        assert!(!format_number(1_500_000.0).is_empty());
4557        assert!(!format_number(1_500_000_000.0).is_empty());
4558    }
4559
4560    #[test]
4561    fn test_render_with_min15_period() {
4562        let mut terminal = create_test_terminal();
4563        let mut state = create_populated_state();
4564        state.set_time_period(TimePeriod::Min15);
4565        terminal.draw(|f| ui(f, &mut state)).unwrap();
4566    }
4567
4568    #[test]
4569    fn test_render_with_hour6_period() {
4570        let mut terminal = create_test_terminal();
4571        let mut state = create_populated_state();
4572        state.set_time_period(TimePeriod::Hour4);
4573        terminal.draw(|f| ui(f, &mut state)).unwrap();
4574    }
4575
4576    #[test]
4577    fn test_ui_with_fresh_state_no_real_data() {
4578        let mut terminal = create_test_terminal();
4579        let token_data = create_test_token_data();
4580        let mut state = MonitorState::new(&token_data, "ethereum");
4581        // Fresh state with only synthetic data
4582        terminal.draw(|f| ui(f, &mut state)).unwrap();
4583    }
4584
4585    #[test]
4586    fn test_ui_with_paused_state() {
4587        let mut terminal = create_test_terminal();
4588        let mut state = create_populated_state();
4589        state.toggle_pause();
4590        terminal.draw(|f| ui(f, &mut state)).unwrap();
4591    }
4592
4593    #[test]
4594    fn test_render_all_with_different_time_periods_and_modes() {
4595        let mut terminal = create_test_terminal();
4596        let mut state = create_populated_state();
4597
4598        // Test all combinations of time period + chart mode
4599        for period in &[
4600            TimePeriod::Min1,
4601            TimePeriod::Min5,
4602            TimePeriod::Min15,
4603            TimePeriod::Hour1,
4604            TimePeriod::Hour4,
4605            TimePeriod::Day1,
4606        ] {
4607            for mode in &[
4608                ChartMode::Line,
4609                ChartMode::Candlestick,
4610                ChartMode::VolumeProfile,
4611            ] {
4612                state.set_time_period(*period);
4613                state.chart_mode = *mode;
4614                terminal.draw(|f| ui(f, &mut state)).unwrap();
4615            }
4616        }
4617    }
4618
4619    #[test]
4620    fn test_render_metrics_with_large_values() {
4621        let mut terminal = create_test_terminal();
4622        let mut state = create_populated_state();
4623        state.market_cap = Some(50_000_000_000.0); // 50B
4624        state.fdv = Some(100_000_000_000.0); // 100B
4625        state.volume_24h = 5_000_000_000.0; // 5B
4626        state.liquidity_usd = 500_000_000.0; // 500M
4627        terminal
4628            .draw(|f| render_metrics_panel(f, f.area(), &state))
4629            .unwrap();
4630    }
4631
4632    #[test]
4633    fn test_render_header_large_positive_change() {
4634        let mut terminal = create_test_terminal();
4635        let mut state = create_populated_state();
4636        state.price_change_24h = 50.0; // >0.5 -> ▲
4637        terminal
4638            .draw(|f| render_header(f, f.area(), &state))
4639            .unwrap();
4640    }
4641
4642    #[test]
4643    fn test_render_header_large_negative_change() {
4644        let mut terminal = create_test_terminal();
4645        let mut state = create_populated_state();
4646        state.price_change_24h = -50.0; // <-0.5 -> ▼
4647        terminal
4648            .draw(|f| render_header(f, f.area(), &state))
4649            .unwrap();
4650    }
4651
4652    #[test]
4653    fn test_render_price_chart_empty_data() {
4654        let mut terminal = create_test_terminal();
4655        let token_data = create_test_token_data();
4656        // Create state with no price history data
4657        let mut state = MonitorState::new(&token_data, "ethereum");
4658        state.price_history.clear();
4659        terminal
4660            .draw(|f| render_price_chart(f, f.area(), &state))
4661            .unwrap();
4662    }
4663
4664    #[test]
4665    fn test_render_price_chart_price_down() {
4666        let mut terminal = create_test_terminal();
4667        let mut state = create_populated_state();
4668        // Force price down scenario
4669        state.price_change_24h = -15.0;
4670        state.current_price = 0.5; // Below initial
4671        terminal
4672            .draw(|f| render_price_chart(f, f.area(), &state))
4673            .unwrap();
4674    }
4675
4676    #[test]
4677    fn test_render_price_chart_zero_first_price() {
4678        let mut terminal = create_test_terminal();
4679        let mut token_data = create_test_token_data();
4680        token_data.price_usd = 0.0;
4681        let state = MonitorState::new(&token_data, "ethereum");
4682        terminal
4683            .draw(|f| render_price_chart(f, f.area(), &state))
4684            .unwrap();
4685    }
4686
4687    #[test]
4688    fn test_render_metrics_panel_zero_5m_change() {
4689        let mut terminal = create_test_terminal();
4690        let mut state = create_populated_state();
4691        state.price_change_5m = 0.0; // Exactly zero
4692        terminal
4693            .draw(|f| render_metrics_panel(f, f.area(), &state))
4694            .unwrap();
4695    }
4696
4697    #[test]
4698    fn test_render_metrics_panel_positive_5m_change() {
4699        let mut terminal = create_test_terminal();
4700        let mut state = create_populated_state();
4701        state.price_change_5m = 5.0; // Positive
4702        terminal
4703            .draw(|f| render_metrics_panel(f, f.area(), &state))
4704            .unwrap();
4705    }
4706
4707    #[test]
4708    fn test_render_metrics_panel_negative_5m_change() {
4709        let mut terminal = create_test_terminal();
4710        let mut state = create_populated_state();
4711        state.price_change_5m = -3.0; // Negative
4712        terminal
4713            .draw(|f| render_metrics_panel(f, f.area(), &state))
4714            .unwrap();
4715    }
4716
4717    #[test]
4718    fn test_render_metrics_panel_negative_24h_change() {
4719        let mut terminal = create_test_terminal();
4720        let mut state = create_populated_state();
4721        state.price_change_24h = -10.0;
4722        terminal
4723            .draw(|f| render_metrics_panel(f, f.area(), &state))
4724            .unwrap();
4725    }
4726
4727    #[test]
4728    fn test_render_metrics_panel_old_last_change() {
4729        let mut terminal = create_test_terminal();
4730        let mut state = create_populated_state();
4731        // Set last_price_change_at to over an hour ago
4732        state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 7200.0; // 2h ago
4733        terminal
4734            .draw(|f| render_metrics_panel(f, f.area(), &state))
4735            .unwrap();
4736    }
4737
4738    #[test]
4739    fn test_render_metrics_panel_minutes_ago_change() {
4740        let mut terminal = create_test_terminal();
4741        let mut state = create_populated_state();
4742        // Set last_price_change_at to minutes ago
4743        state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 300.0; // 5 min ago
4744        terminal
4745            .draw(|f| render_metrics_panel(f, f.area(), &state))
4746            .unwrap();
4747    }
4748
4749    #[test]
4750    fn test_render_candlestick_empty_fresh_state() {
4751        let mut terminal = create_test_terminal();
4752        let token_data = create_test_token_data();
4753        let mut state = MonitorState::new(&token_data, "ethereum");
4754        state.price_history.clear();
4755        state.chart_mode = ChartMode::Candlestick;
4756        terminal
4757            .draw(|f| render_candlestick_chart(f, f.area(), &state))
4758            .unwrap();
4759    }
4760
4761    #[test]
4762    fn test_render_candlestick_price_down() {
4763        let mut terminal = create_test_terminal();
4764        let token_data = create_test_token_data();
4765        let mut state = MonitorState::new(&token_data, "ethereum");
4766        // Add data going down
4767        for i in 0..20 {
4768            let mut data = token_data.clone();
4769            data.price_usd = 2.0 - (i as f64 * 0.05);
4770            state.update(&data);
4771        }
4772        state.chart_mode = ChartMode::Candlestick;
4773        terminal
4774            .draw(|f| render_candlestick_chart(f, f.area(), &state))
4775            .unwrap();
4776    }
4777
4778    #[test]
4779    fn test_render_volume_chart_with_many_points() {
4780        let mut terminal = create_test_terminal();
4781        let token_data = create_test_token_data();
4782        let mut state = MonitorState::new(&token_data, "ethereum");
4783        // Add lots of data points
4784        for i in 0..100 {
4785            let mut data = token_data.clone();
4786            data.volume_24h = 1_000_000.0 + (i as f64 * 50_000.0);
4787            data.price_usd = 1.0 + (i as f64 * 0.001);
4788            state.update(&data);
4789        }
4790        terminal
4791            .draw(|f| render_volume_chart(f, f.area(), &state))
4792            .unwrap();
4793    }
4794
4795    // ========================================================================
4796    // Key event handler tests
4797    // ========================================================================
4798
4799    fn make_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
4800        crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)
4801    }
4802
4803    fn make_ctrl_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
4804        crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
4805    }
4806
4807    #[test]
4808    fn test_handle_key_quit_q() {
4809        let token_data = create_test_token_data();
4810        let mut state = MonitorState::new(&token_data, "ethereum");
4811        assert!(handle_key_event_on_state(
4812            make_key_event(KeyCode::Char('q')),
4813            &mut state
4814        ));
4815    }
4816
4817    #[test]
4818    fn test_handle_key_quit_esc() {
4819        let token_data = create_test_token_data();
4820        let mut state = MonitorState::new(&token_data, "ethereum");
4821        assert!(handle_key_event_on_state(
4822            make_key_event(KeyCode::Esc),
4823            &mut state
4824        ));
4825    }
4826
4827    #[test]
4828    fn test_handle_key_quit_ctrl_c() {
4829        let token_data = create_test_token_data();
4830        let mut state = MonitorState::new(&token_data, "ethereum");
4831        assert!(handle_key_event_on_state(
4832            make_ctrl_key_event(KeyCode::Char('c')),
4833            &mut state
4834        ));
4835    }
4836
4837    #[test]
4838    fn test_handle_key_refresh() {
4839        let token_data = create_test_token_data();
4840        let mut state = MonitorState::new(&token_data, "ethereum");
4841        state.refresh_rate = Duration::from_secs(60);
4842        // Set last_update in the past so should_refresh was false
4843        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('r')), &mut state);
4844        assert!(!exit);
4845        // force_refresh sets last_update to epoch, so should_refresh() should be true
4846        assert!(state.should_refresh());
4847    }
4848
4849    #[test]
4850    fn test_handle_key_pause_toggle() {
4851        let token_data = create_test_token_data();
4852        let mut state = MonitorState::new(&token_data, "ethereum");
4853        assert!(!state.paused);
4854
4855        handle_key_event_on_state(make_key_event(KeyCode::Char('p')), &mut state);
4856        assert!(state.paused);
4857
4858        handle_key_event_on_state(make_key_event(KeyCode::Char(' ')), &mut state);
4859        assert!(!state.paused);
4860    }
4861
4862    #[test]
4863    fn test_handle_key_slower_refresh() {
4864        let token_data = create_test_token_data();
4865        let mut state = MonitorState::new(&token_data, "ethereum");
4866        let initial = state.refresh_rate;
4867
4868        handle_key_event_on_state(make_key_event(KeyCode::Char('+')), &mut state);
4869        assert!(state.refresh_rate > initial);
4870
4871        state.refresh_rate = initial;
4872        handle_key_event_on_state(make_key_event(KeyCode::Char('=')), &mut state);
4873        assert!(state.refresh_rate > initial);
4874
4875        state.refresh_rate = initial;
4876        handle_key_event_on_state(make_key_event(KeyCode::Char(']')), &mut state);
4877        assert!(state.refresh_rate > initial);
4878    }
4879
4880    #[test]
4881    fn test_handle_key_faster_refresh() {
4882        let token_data = create_test_token_data();
4883        let mut state = MonitorState::new(&token_data, "ethereum");
4884        // First make it slower so there's room to go faster
4885        state.refresh_rate = Duration::from_secs(30);
4886        let initial = state.refresh_rate;
4887
4888        handle_key_event_on_state(make_key_event(KeyCode::Char('-')), &mut state);
4889        assert!(state.refresh_rate < initial);
4890
4891        state.refresh_rate = initial;
4892        handle_key_event_on_state(make_key_event(KeyCode::Char('_')), &mut state);
4893        assert!(state.refresh_rate < initial);
4894
4895        state.refresh_rate = initial;
4896        handle_key_event_on_state(make_key_event(KeyCode::Char('[')), &mut state);
4897        assert!(state.refresh_rate < initial);
4898    }
4899
4900    #[test]
4901    fn test_handle_key_time_periods() {
4902        let token_data = create_test_token_data();
4903        let mut state = MonitorState::new(&token_data, "ethereum");
4904
4905        handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
4906        assert!(matches!(state.time_period, TimePeriod::Min1));
4907
4908        handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
4909        assert!(matches!(state.time_period, TimePeriod::Min5));
4910
4911        handle_key_event_on_state(make_key_event(KeyCode::Char('3')), &mut state);
4912        assert!(matches!(state.time_period, TimePeriod::Min15));
4913
4914        handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
4915        assert!(matches!(state.time_period, TimePeriod::Hour1));
4916
4917        handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
4918        assert!(matches!(state.time_period, TimePeriod::Hour4));
4919
4920        handle_key_event_on_state(make_key_event(KeyCode::Char('6')), &mut state);
4921        assert!(matches!(state.time_period, TimePeriod::Day1));
4922    }
4923
4924    #[test]
4925    fn test_handle_key_cycle_time_period() {
4926        let token_data = create_test_token_data();
4927        let mut state = MonitorState::new(&token_data, "ethereum");
4928
4929        handle_key_event_on_state(make_key_event(KeyCode::Char('t')), &mut state);
4930        // Should cycle from default
4931        let first = state.time_period;
4932
4933        handle_key_event_on_state(make_key_event(KeyCode::Tab), &mut state);
4934        // Should have cycled again
4935        // Verify it cycled (no panic is the main check)
4936        let _ = state.time_period;
4937        let _ = first;
4938    }
4939
4940    #[test]
4941    fn test_handle_key_toggle_chart_mode() {
4942        let token_data = create_test_token_data();
4943        let mut state = MonitorState::new(&token_data, "ethereum");
4944        let initial_mode = state.chart_mode;
4945
4946        handle_key_event_on_state(make_key_event(KeyCode::Char('c')), &mut state);
4947        assert!(state.chart_mode != initial_mode);
4948    }
4949
4950    #[test]
4951    fn test_handle_key_unknown_no_op() {
4952        let token_data = create_test_token_data();
4953        let mut state = MonitorState::new(&token_data, "ethereum");
4954        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
4955        assert!(!exit);
4956    }
4957
4958    // ========================================================================
4959    // Cache save/load tests
4960    // ========================================================================
4961
4962    #[test]
4963    fn test_save_and_load_cache() {
4964        let token_data = create_test_token_data();
4965        let mut state = MonitorState::new(&token_data, "ethereum");
4966        state.price_history.push_back(DataPoint {
4967            timestamp: 1.0,
4968            value: 100.0,
4969            is_real: true,
4970        });
4971        state.price_history.push_back(DataPoint {
4972            timestamp: 2.0,
4973            value: 101.0,
4974            is_real: true,
4975        });
4976        state.volume_history.push_back(DataPoint {
4977            timestamp: 1.0,
4978            value: 5000.0,
4979            is_real: true,
4980        });
4981
4982        // save_cache uses dirs::cache_dir() which we can't redirect easily
4983        // but we can test the load_cache path with a real write
4984        state.save_cache();
4985        let cached = MonitorState::load_cache(&state.token_address, &state.chain);
4986        // Cache may or may not exist depending on system - just verify no panic
4987        if let Some(c) = cached {
4988            assert_eq!(
4989                c.token_address.to_lowercase(),
4990                state.token_address.to_lowercase()
4991            );
4992        }
4993    }
4994
4995    #[test]
4996    fn test_load_cache_nonexistent_token() {
4997        let cached = MonitorState::load_cache("0xNONEXISTENT_TOKEN_ADDR", "nonexistent_chain");
4998        assert!(cached.is_none());
4999    }
5000
5001    // ========================================================================
5002    // New widget tests: BarChart (volume), Table+Sparkline (metrics), scroll
5003    // ========================================================================
5004
5005    #[test]
5006    fn test_render_volume_barchart_with_populated_data() {
5007        // Verify the BarChart-based volume chart renders without panic
5008        // when state has many volume data points across different time periods
5009        let mut terminal = create_test_terminal();
5010        let mut state = create_populated_state();
5011        for period in [
5012            TimePeriod::Min1,
5013            TimePeriod::Min5,
5014            TimePeriod::Min15,
5015            TimePeriod::Hour1,
5016            TimePeriod::Hour4,
5017            TimePeriod::Day1,
5018        ] {
5019            state.set_time_period(period);
5020            terminal
5021                .draw(|f| render_volume_chart(f, f.area(), &state))
5022                .unwrap();
5023        }
5024    }
5025
5026    #[test]
5027    fn test_render_volume_barchart_narrow_terminal() {
5028        // BarChart with very narrow width should still render without panic
5029        let backend = TestBackend::new(20, 10);
5030        let mut terminal = Terminal::new(backend).unwrap();
5031        let state = create_populated_state();
5032        terminal
5033            .draw(|f| render_volume_chart(f, f.area(), &state))
5034            .unwrap();
5035    }
5036
5037    #[test]
5038    fn test_render_metrics_table_sparkline_no_panic() {
5039        // Verify the Table+Sparkline metrics panel renders without panic
5040        let mut terminal = create_test_terminal();
5041        let state = create_populated_state();
5042        terminal
5043            .draw(|f| render_metrics_panel(f, f.area(), &state))
5044            .unwrap();
5045    }
5046
5047    #[test]
5048    fn test_render_metrics_table_sparkline_all_periods() {
5049        // Ensure metrics panel renders correctly for every time period
5050        let mut terminal = create_test_terminal();
5051        let mut state = create_populated_state();
5052        for period in [
5053            TimePeriod::Min1,
5054            TimePeriod::Min5,
5055            TimePeriod::Min15,
5056            TimePeriod::Hour1,
5057            TimePeriod::Hour4,
5058            TimePeriod::Day1,
5059        ] {
5060            state.set_time_period(period);
5061            terminal
5062                .draw(|f| render_metrics_panel(f, f.area(), &state))
5063                .unwrap();
5064        }
5065    }
5066
5067    #[test]
5068    fn test_render_metrics_sparkline_trend_direction() {
5069        // When 5m change is negative, sparkline should still render
5070        let mut terminal = create_test_terminal();
5071        let mut state = create_populated_state();
5072        state.price_change_5m = -3.5;
5073        terminal
5074            .draw(|f| render_metrics_panel(f, f.area(), &state))
5075            .unwrap();
5076
5077        // When 5m change is positive
5078        state.price_change_5m = 2.0;
5079        terminal
5080            .draw(|f| render_metrics_panel(f, f.area(), &state))
5081            .unwrap();
5082
5083        // When 5m change is zero
5084        state.price_change_5m = 0.0;
5085        terminal
5086            .draw(|f| render_metrics_panel(f, f.area(), &state))
5087            .unwrap();
5088    }
5089
5090    #[test]
5091    fn test_render_tabs_time_period() {
5092        // Verify the Tabs widget in the header renders for each period
5093        let mut terminal = create_test_terminal();
5094        let mut state = create_populated_state();
5095        for period in [
5096            TimePeriod::Min1,
5097            TimePeriod::Min5,
5098            TimePeriod::Min15,
5099            TimePeriod::Hour1,
5100            TimePeriod::Hour4,
5101            TimePeriod::Day1,
5102        ] {
5103            state.set_time_period(period);
5104            terminal
5105                .draw(|f| render_header(f, f.area(), &state))
5106                .unwrap();
5107        }
5108    }
5109
5110    #[test]
5111    fn test_time_period_index() {
5112        assert_eq!(TimePeriod::Min1.index(), 0);
5113        assert_eq!(TimePeriod::Min5.index(), 1);
5114        assert_eq!(TimePeriod::Min15.index(), 2);
5115        assert_eq!(TimePeriod::Hour1.index(), 3);
5116        assert_eq!(TimePeriod::Hour4.index(), 4);
5117        assert_eq!(TimePeriod::Day1.index(), 5);
5118    }
5119
5120    #[test]
5121    fn test_scroll_log_down_from_start() {
5122        let token_data = create_test_token_data();
5123        let mut state = MonitorState::new(&token_data, "ethereum");
5124        state.log_messages.push_back("msg 1".to_string());
5125        state.log_messages.push_back("msg 2".to_string());
5126        state.log_messages.push_back("msg 3".to_string());
5127
5128        // Initially no selection
5129        assert_eq!(state.log_list_state.selected(), None);
5130
5131        // First scroll down selects item 0
5132        state.scroll_log_down();
5133        assert_eq!(state.log_list_state.selected(), Some(0));
5134
5135        // Second scroll moves to item 1
5136        state.scroll_log_down();
5137        assert_eq!(state.log_list_state.selected(), Some(1));
5138
5139        // Third scroll moves to item 2
5140        state.scroll_log_down();
5141        assert_eq!(state.log_list_state.selected(), Some(2));
5142
5143        // Fourth scroll stays at last item (bounds check)
5144        state.scroll_log_down();
5145        assert_eq!(state.log_list_state.selected(), Some(2));
5146    }
5147
5148    #[test]
5149    fn test_scroll_log_up_from_start() {
5150        let token_data = create_test_token_data();
5151        let mut state = MonitorState::new(&token_data, "ethereum");
5152        state.log_messages.push_back("msg 1".to_string());
5153        state.log_messages.push_back("msg 2".to_string());
5154        state.log_messages.push_back("msg 3".to_string());
5155
5156        // Scroll up from no selection goes to 0
5157        state.scroll_log_up();
5158        assert_eq!(state.log_list_state.selected(), Some(0));
5159
5160        // Can't go below 0
5161        state.scroll_log_up();
5162        assert_eq!(state.log_list_state.selected(), Some(0));
5163    }
5164
5165    #[test]
5166    fn test_scroll_log_up_down_roundtrip() {
5167        let token_data = create_test_token_data();
5168        let mut state = MonitorState::new(&token_data, "ethereum");
5169        for i in 0..10 {
5170            state.log_messages.push_back(format!("msg {}", i));
5171        }
5172
5173        // Scroll down 5 times
5174        for _ in 0..5 {
5175            state.scroll_log_down();
5176        }
5177        assert_eq!(state.log_list_state.selected(), Some(4));
5178
5179        // Scroll up 3 times
5180        for _ in 0..3 {
5181            state.scroll_log_up();
5182        }
5183        assert_eq!(state.log_list_state.selected(), Some(1));
5184    }
5185
5186    #[test]
5187    fn test_scroll_log_empty_no_panic() {
5188        let token_data = create_test_token_data();
5189        let mut state = MonitorState::new(&token_data, "ethereum");
5190        // With no log messages, scrolling should not panic
5191        state.scroll_log_down();
5192        state.scroll_log_up();
5193        assert!(
5194            state.log_list_state.selected().is_none() || state.log_list_state.selected() == Some(0)
5195        );
5196    }
5197
5198    #[test]
5199    fn test_render_scrollable_activity_log() {
5200        // Ensure the stateful activity log renders without panic
5201        let mut terminal = create_test_terminal();
5202        let mut state = create_populated_state();
5203        for i in 0..20 {
5204            state
5205                .log_messages
5206                .push_back(format!("Activity event #{}", i));
5207        }
5208        // Scroll down a few items
5209        state.scroll_log_down();
5210        state.scroll_log_down();
5211        state.scroll_log_down();
5212
5213        terminal
5214            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5215            .unwrap();
5216    }
5217
5218    #[test]
5219    fn test_handle_key_scroll_log_j_k() {
5220        let token_data = create_test_token_data();
5221        let mut state = MonitorState::new(&token_data, "ethereum");
5222        state.log_messages.push_back("line 1".to_string());
5223        state.log_messages.push_back("line 2".to_string());
5224
5225        // j scrolls down
5226        handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
5227        assert_eq!(state.log_list_state.selected(), Some(0));
5228
5229        handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
5230        assert_eq!(state.log_list_state.selected(), Some(1));
5231
5232        // k scrolls up
5233        handle_key_event_on_state(make_key_event(KeyCode::Char('k')), &mut state);
5234        assert_eq!(state.log_list_state.selected(), Some(0));
5235    }
5236
5237    #[test]
5238    fn test_handle_key_scroll_log_arrow_keys() {
5239        let token_data = create_test_token_data();
5240        let mut state = MonitorState::new(&token_data, "ethereum");
5241        state.log_messages.push_back("line 1".to_string());
5242        state.log_messages.push_back("line 2".to_string());
5243        state.log_messages.push_back("line 3".to_string());
5244
5245        // Down arrow scrolls down
5246        handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
5247        assert_eq!(state.log_list_state.selected(), Some(0));
5248
5249        handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
5250        assert_eq!(state.log_list_state.selected(), Some(1));
5251
5252        // Up arrow scrolls up
5253        handle_key_event_on_state(make_key_event(KeyCode::Up), &mut state);
5254        assert_eq!(state.log_list_state.selected(), Some(0));
5255    }
5256
5257    #[test]
5258    fn test_render_ui_with_scrolled_log() {
5259        // Full UI render with a scrolled activity log position
5260        let mut terminal = create_test_terminal();
5261        let mut state = create_populated_state();
5262        for i in 0..15 {
5263            state.log_messages.push_back(format!("Log entry {}", i));
5264        }
5265        state.scroll_log_down();
5266        state.scroll_log_down();
5267        state.scroll_log_down();
5268        state.scroll_log_down();
5269        state.scroll_log_down();
5270
5271        terminal.draw(|f| ui(f, &mut state)).unwrap();
5272    }
5273
5274    // ========================================================================
5275    // Token selection / resolve tests
5276    // ========================================================================
5277
5278    fn make_monitor_search_results() -> Vec<crate::chains::dex::TokenSearchResult> {
5279        vec![
5280            crate::chains::dex::TokenSearchResult {
5281                symbol: "USDC".to_string(),
5282                name: "USD Coin".to_string(),
5283                address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
5284                chain: "ethereum".to_string(),
5285                price_usd: Some(1.0),
5286                volume_24h: 1_000_000.0,
5287                liquidity_usd: 500_000_000.0,
5288                market_cap: Some(30_000_000_000.0),
5289            },
5290            crate::chains::dex::TokenSearchResult {
5291                symbol: "USDC".to_string(),
5292                name: "Bridged USD Coin".to_string(),
5293                address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
5294                chain: "ethereum".to_string(),
5295                price_usd: Some(0.9998),
5296                volume_24h: 500_000.0,
5297                liquidity_usd: 100_000_000.0,
5298                market_cap: None,
5299            },
5300            crate::chains::dex::TokenSearchResult {
5301                symbol: "USDC".to_string(),
5302                name: "A Very Long Token Name That Exceeds The Limit".to_string(),
5303                address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
5304                chain: "ethereum".to_string(),
5305                price_usd: None,
5306                volume_24h: 0.0,
5307                liquidity_usd: 50_000.0,
5308                market_cap: None,
5309            },
5310        ]
5311    }
5312
5313    #[test]
5314    fn test_abbreviate_address_long() {
5315        let addr = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
5316        let abbr = abbreviate_address(addr);
5317        assert_eq!(abbr, "0xA0b869...06eB48");
5318        assert!(abbr.contains("..."));
5319    }
5320
5321    #[test]
5322    fn test_abbreviate_address_short() {
5323        let addr = "0x1234abcd";
5324        let abbr = abbreviate_address(addr);
5325        // Short addresses are not abbreviated
5326        assert_eq!(abbr, "0x1234abcd");
5327    }
5328
5329    #[test]
5330    fn test_select_token_impl_first() {
5331        let results = make_monitor_search_results();
5332        let input = b"1\n";
5333        let mut reader = std::io::Cursor::new(&input[..]);
5334        let mut writer = Vec::new();
5335
5336        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5337        assert_eq!(selected.name, "USD Coin");
5338        assert_eq!(
5339            selected.address,
5340            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
5341        );
5342
5343        let output = String::from_utf8(writer).unwrap();
5344        assert!(output.contains("Found 3 tokens"));
5345        assert!(output.contains("USDC"));
5346        assert!(output.contains("0xA0b869...06eB48"));
5347        assert!(output.contains("Selected:"));
5348    }
5349
5350    #[test]
5351    fn test_select_token_impl_second() {
5352        let results = make_monitor_search_results();
5353        let input = b"2\n";
5354        let mut reader = std::io::Cursor::new(&input[..]);
5355        let mut writer = Vec::new();
5356
5357        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5358        assert_eq!(selected.name, "Bridged USD Coin");
5359        assert_eq!(
5360            selected.address,
5361            "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
5362        );
5363    }
5364
5365    #[test]
5366    fn test_select_token_impl_shows_address_column() {
5367        let results = make_monitor_search_results();
5368        let input = b"1\n";
5369        let mut reader = std::io::Cursor::new(&input[..]);
5370        let mut writer = Vec::new();
5371
5372        select_token_impl(&results, &mut reader, &mut writer).unwrap();
5373        let output = String::from_utf8(writer).unwrap();
5374
5375        // Table header should include Address column
5376        assert!(output.contains("Address"));
5377        // All three abbreviated addresses should appear
5378        assert!(output.contains("0xA0b869...06eB48"));
5379        assert!(output.contains("0x2791Bc...a84174"));
5380        assert!(output.contains("0x123456...345678"));
5381    }
5382
5383    #[test]
5384    fn test_select_token_impl_truncates_long_name() {
5385        let results = make_monitor_search_results();
5386        let input = b"3\n";
5387        let mut reader = std::io::Cursor::new(&input[..]);
5388        let mut writer = Vec::new();
5389
5390        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5391        assert_eq!(
5392            selected.address,
5393            "0x1234567890abcdef1234567890abcdef12345678"
5394        );
5395
5396        let output = String::from_utf8(writer).unwrap();
5397        assert!(output.contains("A Very Long Token..."));
5398    }
5399
5400    #[test]
5401    fn test_select_token_impl_invalid_input() {
5402        let results = make_monitor_search_results();
5403        let input = b"xyz\n";
5404        let mut reader = std::io::Cursor::new(&input[..]);
5405        let mut writer = Vec::new();
5406
5407        let result = select_token_impl(&results, &mut reader, &mut writer);
5408        assert!(result.is_err());
5409        assert!(
5410            result
5411                .unwrap_err()
5412                .to_string()
5413                .contains("Invalid selection")
5414        );
5415    }
5416
5417    #[test]
5418    fn test_select_token_impl_out_of_range_zero() {
5419        let results = make_monitor_search_results();
5420        let input = b"0\n";
5421        let mut reader = std::io::Cursor::new(&input[..]);
5422        let mut writer = Vec::new();
5423
5424        let result = select_token_impl(&results, &mut reader, &mut writer);
5425        assert!(result.is_err());
5426        assert!(
5427            result
5428                .unwrap_err()
5429                .to_string()
5430                .contains("Selection must be between")
5431        );
5432    }
5433
5434    #[test]
5435    fn test_select_token_impl_out_of_range_high() {
5436        let results = make_monitor_search_results();
5437        let input = b"99\n";
5438        let mut reader = std::io::Cursor::new(&input[..]);
5439        let mut writer = Vec::new();
5440
5441        let result = select_token_impl(&results, &mut reader, &mut writer);
5442        assert!(result.is_err());
5443    }
5444
5445    #[test]
5446    fn test_format_monitor_number() {
5447        assert_eq!(format_monitor_number(1_500_000_000.0), "$1.50B");
5448        assert_eq!(format_monitor_number(250_000_000.0), "$250.00M");
5449        assert_eq!(format_monitor_number(75_000.0), "$75.00K");
5450        assert_eq!(format_monitor_number(42.5), "$42.50");
5451    }
5452
5453    // ============================
5454    // Phase 4: Layout system tests
5455    // ============================
5456
5457    #[test]
5458    fn test_monitor_config_defaults() {
5459        let config = MonitorConfig::default();
5460        assert_eq!(config.layout, LayoutPreset::Dashboard);
5461        assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
5462        assert!(config.widgets.price_chart);
5463        assert!(config.widgets.volume_chart);
5464        assert!(config.widgets.buy_sell_pressure);
5465        assert!(config.widgets.metrics_panel);
5466        assert!(config.widgets.activity_log);
5467    }
5468
5469    #[test]
5470    fn test_layout_preset_next_cycles() {
5471        assert_eq!(LayoutPreset::Dashboard.next(), LayoutPreset::ChartFocus);
5472        assert_eq!(LayoutPreset::ChartFocus.next(), LayoutPreset::Feed);
5473        assert_eq!(LayoutPreset::Feed.next(), LayoutPreset::Compact);
5474        assert_eq!(LayoutPreset::Compact.next(), LayoutPreset::Dashboard);
5475    }
5476
5477    #[test]
5478    fn test_layout_preset_prev_cycles() {
5479        assert_eq!(LayoutPreset::Dashboard.prev(), LayoutPreset::Compact);
5480        assert_eq!(LayoutPreset::Compact.prev(), LayoutPreset::Feed);
5481        assert_eq!(LayoutPreset::Feed.prev(), LayoutPreset::ChartFocus);
5482        assert_eq!(LayoutPreset::ChartFocus.prev(), LayoutPreset::Dashboard);
5483    }
5484
5485    #[test]
5486    fn test_layout_preset_full_cycle() {
5487        let start = LayoutPreset::Dashboard;
5488        let mut preset = start;
5489        for _ in 0..4 {
5490            preset = preset.next();
5491        }
5492        assert_eq!(preset, start);
5493    }
5494
5495    #[test]
5496    fn test_layout_preset_labels() {
5497        assert_eq!(LayoutPreset::Dashboard.label(), "Dashboard");
5498        assert_eq!(LayoutPreset::ChartFocus.label(), "Chart");
5499        assert_eq!(LayoutPreset::Feed.label(), "Feed");
5500        assert_eq!(LayoutPreset::Compact.label(), "Compact");
5501    }
5502
5503    #[test]
5504    fn test_widget_visibility_default_all_visible() {
5505        let vis = WidgetVisibility::default();
5506        assert_eq!(vis.visible_count(), 5);
5507    }
5508
5509    #[test]
5510    fn test_widget_visibility_toggle_by_index() {
5511        let mut vis = WidgetVisibility::default();
5512        vis.toggle_by_index(1);
5513        assert!(!vis.price_chart);
5514        assert_eq!(vis.visible_count(), 4);
5515
5516        vis.toggle_by_index(2);
5517        assert!(!vis.volume_chart);
5518        assert_eq!(vis.visible_count(), 3);
5519
5520        vis.toggle_by_index(3);
5521        assert!(!vis.buy_sell_pressure);
5522        assert_eq!(vis.visible_count(), 2);
5523
5524        vis.toggle_by_index(4);
5525        assert!(!vis.metrics_panel);
5526        assert_eq!(vis.visible_count(), 1);
5527
5528        vis.toggle_by_index(5);
5529        assert!(!vis.activity_log);
5530        assert_eq!(vis.visible_count(), 0);
5531
5532        // Toggle back
5533        vis.toggle_by_index(1);
5534        assert!(vis.price_chart);
5535        assert_eq!(vis.visible_count(), 1);
5536    }
5537
5538    #[test]
5539    fn test_widget_visibility_toggle_invalid_index() {
5540        let mut vis = WidgetVisibility::default();
5541        vis.toggle_by_index(0);
5542        vis.toggle_by_index(6);
5543        vis.toggle_by_index(100);
5544        assert_eq!(vis.visible_count(), 5); // unchanged
5545    }
5546
5547    #[test]
5548    fn test_auto_select_layout_small_terminal() {
5549        let size = Rect::new(0, 0, 60, 20);
5550        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
5551    }
5552
5553    #[test]
5554    fn test_auto_select_layout_narrow_terminal() {
5555        let size = Rect::new(0, 0, 100, 40);
5556        assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
5557    }
5558
5559    #[test]
5560    fn test_auto_select_layout_short_terminal() {
5561        let size = Rect::new(0, 0, 140, 28);
5562        assert_eq!(auto_select_layout(size), LayoutPreset::ChartFocus);
5563    }
5564
5565    #[test]
5566    fn test_auto_select_layout_large_terminal() {
5567        let size = Rect::new(0, 0, 160, 50);
5568        assert_eq!(auto_select_layout(size), LayoutPreset::Dashboard);
5569    }
5570
5571    #[test]
5572    fn test_auto_select_layout_edge_80x24() {
5573        // Exactly at the threshold: width>=80 and height>=24, but width<120
5574        let size = Rect::new(0, 0, 80, 24);
5575        assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
5576    }
5577
5578    #[test]
5579    fn test_auto_select_layout_edge_79() {
5580        let size = Rect::new(0, 0, 79, 50);
5581        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
5582    }
5583
5584    #[test]
5585    fn test_auto_select_layout_edge_23_height() {
5586        let size = Rect::new(0, 0, 160, 23);
5587        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
5588    }
5589
5590    #[test]
5591    fn test_layout_dashboard_all_visible() {
5592        let area = Rect::new(0, 0, 120, 40);
5593        let vis = WidgetVisibility::default();
5594        let areas = layout_dashboard(area, &vis);
5595        assert!(areas.price_chart.is_some());
5596        assert!(areas.volume_chart.is_some());
5597        assert!(areas.buy_sell_gauge.is_some());
5598        assert!(areas.metrics_panel.is_some());
5599        assert!(areas.activity_feed.is_some());
5600    }
5601
5602    #[test]
5603    fn test_layout_dashboard_hidden_widget() {
5604        let area = Rect::new(0, 0, 120, 40);
5605        let vis = WidgetVisibility {
5606            price_chart: false,
5607            ..WidgetVisibility::default()
5608        };
5609        let areas = layout_dashboard(area, &vis);
5610        assert!(areas.price_chart.is_none());
5611        assert!(areas.volume_chart.is_some());
5612    }
5613
5614    #[test]
5615    fn test_layout_chart_focus_minimal_overlay() {
5616        let area = Rect::new(0, 0, 120, 40);
5617        let vis = WidgetVisibility::default();
5618        let areas = layout_chart_focus(area, &vis);
5619        assert!(areas.price_chart.is_some());
5620        assert!(areas.volume_chart.is_none()); // Hidden in chart-focus
5621        assert!(areas.buy_sell_gauge.is_none()); // Hidden in chart-focus
5622        assert!(areas.metrics_panel.is_some()); // Minimal stats overlay
5623        assert!(areas.activity_feed.is_none()); // Hidden in chart-focus
5624    }
5625
5626    #[test]
5627    fn test_layout_feed_activity_priority() {
5628        let area = Rect::new(0, 0, 120, 40);
5629        let vis = WidgetVisibility::default();
5630        let areas = layout_feed(area, &vis);
5631        assert!(areas.price_chart.is_none()); // Hidden in feed
5632        assert!(areas.volume_chart.is_none()); // Hidden in feed
5633        assert!(areas.buy_sell_gauge.is_some()); // Top row
5634        assert!(areas.metrics_panel.is_some()); // Top row
5635        assert!(areas.activity_feed.is_some()); // Dominates bottom 75%
5636    }
5637
5638    #[test]
5639    fn test_layout_compact_metrics_only() {
5640        let area = Rect::new(0, 0, 60, 20);
5641        let vis = WidgetVisibility::default();
5642        let areas = layout_compact(area, &vis);
5643        assert!(areas.price_chart.is_none()); // Hidden in compact
5644        assert!(areas.volume_chart.is_none()); // Hidden in compact
5645        assert!(areas.buy_sell_gauge.is_none()); // Hidden in compact
5646        assert!(areas.metrics_panel.is_some()); // Full area
5647        assert!(areas.activity_feed.is_none()); // Hidden in compact
5648    }
5649
5650    #[test]
5651    fn test_ui_render_all_layouts_no_panic() {
5652        let presets = [
5653            LayoutPreset::Dashboard,
5654            LayoutPreset::ChartFocus,
5655            LayoutPreset::Feed,
5656            LayoutPreset::Compact,
5657        ];
5658        for preset in &presets {
5659            let mut terminal = create_test_terminal();
5660            let mut state = create_populated_state();
5661            state.layout = *preset;
5662            state.auto_layout = false; // Don't override during render
5663            terminal.draw(|f| ui(f, &mut state)).unwrap();
5664        }
5665    }
5666
5667    #[test]
5668    fn test_ui_render_compact_small_terminal() {
5669        let backend = TestBackend::new(60, 20);
5670        let mut terminal = Terminal::new(backend).unwrap();
5671        let mut state = create_populated_state();
5672        state.layout = LayoutPreset::Compact;
5673        state.auto_layout = false;
5674        terminal.draw(|f| ui(f, &mut state)).unwrap();
5675    }
5676
5677    #[test]
5678    fn test_ui_auto_layout_selects_compact_for_small() {
5679        let backend = TestBackend::new(60, 20);
5680        let mut terminal = Terminal::new(backend).unwrap();
5681        let mut state = create_populated_state();
5682        state.layout = LayoutPreset::Dashboard;
5683        state.auto_layout = true;
5684        terminal.draw(|f| ui(f, &mut state)).unwrap();
5685        assert_eq!(state.layout, LayoutPreset::Compact);
5686    }
5687
5688    #[test]
5689    fn test_ui_auto_layout_disabled_keeps_preset() {
5690        let backend = TestBackend::new(60, 20);
5691        let mut terminal = Terminal::new(backend).unwrap();
5692        let mut state = create_populated_state();
5693        state.layout = LayoutPreset::Dashboard;
5694        state.auto_layout = false;
5695        terminal.draw(|f| ui(f, &mut state)).unwrap();
5696        assert_eq!(state.layout, LayoutPreset::Dashboard); // Not changed
5697    }
5698
5699    #[test]
5700    fn test_keybinding_l_cycles_layout_forward() {
5701        let mut state = create_populated_state();
5702        state.layout = LayoutPreset::Dashboard;
5703        state.auto_layout = true;
5704
5705        handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
5706        assert_eq!(state.layout, LayoutPreset::ChartFocus);
5707        assert!(!state.auto_layout); // Manual switch disables auto
5708
5709        handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
5710        assert_eq!(state.layout, LayoutPreset::Feed);
5711    }
5712
5713    #[test]
5714    fn test_keybinding_h_cycles_layout_backward() {
5715        let mut state = create_populated_state();
5716        state.layout = LayoutPreset::Dashboard;
5717        state.auto_layout = true;
5718
5719        handle_key_event_on_state(make_key_event(KeyCode::Char('h')), &mut state);
5720        assert_eq!(state.layout, LayoutPreset::Compact);
5721        assert!(!state.auto_layout);
5722    }
5723
5724    #[test]
5725    fn test_keybinding_a_enables_auto_layout() {
5726        let mut state = create_populated_state();
5727        state.auto_layout = false;
5728
5729        handle_key_event_on_state(make_key_event(KeyCode::Char('a')), &mut state);
5730        assert!(state.auto_layout);
5731    }
5732
5733    #[test]
5734    fn test_keybinding_w_widget_toggle_mode() {
5735        let mut state = create_populated_state();
5736        assert!(!state.widget_toggle_mode);
5737
5738        // Press w to enter toggle mode
5739        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
5740        assert!(state.widget_toggle_mode);
5741
5742        // Press 1 to toggle price_chart off
5743        handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
5744        assert!(!state.widget_toggle_mode);
5745        assert!(!state.widgets.price_chart);
5746    }
5747
5748    #[test]
5749    fn test_keybinding_w_cancel_with_non_digit() {
5750        let mut state = create_populated_state();
5751
5752        // Enter widget toggle mode
5753        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
5754        assert!(state.widget_toggle_mode);
5755
5756        // Press 'x' to cancel — should also process 'x' as a normal key (no-op)
5757        handle_key_event_on_state(make_key_event(KeyCode::Char('x')), &mut state);
5758        assert!(!state.widget_toggle_mode);
5759        assert!(state.widgets.price_chart); // unchanged
5760    }
5761
5762    #[test]
5763    fn test_keybinding_w_toggle_multiple_widgets() {
5764        let mut state = create_populated_state();
5765
5766        // Toggle widget 2 (volume_chart)
5767        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
5768        handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
5769        assert!(!state.widgets.volume_chart);
5770
5771        // Toggle widget 4 (metrics_panel)
5772        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
5773        handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
5774        assert!(!state.widgets.metrics_panel);
5775
5776        // Toggle widget 5 (activity_log)
5777        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
5778        handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
5779        assert!(!state.widgets.activity_log);
5780    }
5781
5782    #[test]
5783    fn test_monitor_config_serde_roundtrip() {
5784        let config = MonitorConfig {
5785            layout: LayoutPreset::ChartFocus,
5786            refresh_seconds: 5,
5787            widgets: WidgetVisibility {
5788                price_chart: true,
5789                volume_chart: false,
5790                buy_sell_pressure: true,
5791                metrics_panel: false,
5792                activity_log: true,
5793                holder_count: true,
5794                liquidity_depth: true,
5795            },
5796            scale: ScaleMode::Log,
5797            color_scheme: ColorScheme::BlueOrange,
5798            alerts: AlertConfig::default(),
5799            export: ExportConfig::default(),
5800            auto_pause_on_input: false,
5801        };
5802
5803        let yaml = serde_yaml::to_string(&config).unwrap();
5804        let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
5805        assert_eq!(parsed.layout, LayoutPreset::ChartFocus);
5806        assert_eq!(parsed.refresh_seconds, 5);
5807        assert!(parsed.widgets.price_chart);
5808        assert!(!parsed.widgets.volume_chart);
5809        assert!(parsed.widgets.buy_sell_pressure);
5810        assert!(!parsed.widgets.metrics_panel);
5811        assert!(parsed.widgets.activity_log);
5812    }
5813
5814    #[test]
5815    fn test_monitor_config_serde_kebab_case() {
5816        let yaml = r#"
5817layout: chart-focus
5818refresh_seconds: 15
5819widgets:
5820  price_chart: true
5821  volume_chart: true
5822  buy_sell_pressure: false
5823  metrics_panel: true
5824  activity_log: false
5825"#;
5826        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
5827        assert_eq!(config.layout, LayoutPreset::ChartFocus);
5828        assert_eq!(config.refresh_seconds, 15);
5829        assert!(!config.widgets.buy_sell_pressure);
5830        assert!(!config.widgets.activity_log);
5831    }
5832
5833    #[test]
5834    fn test_monitor_config_serde_default_missing_fields() {
5835        let yaml = "layout: feed\n";
5836        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
5837        assert_eq!(config.layout, LayoutPreset::Feed);
5838        assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
5839        assert!(config.widgets.price_chart); // defaults
5840    }
5841
5842    #[test]
5843    fn test_state_apply_config() {
5844        let mut state = create_populated_state();
5845        let config = MonitorConfig {
5846            layout: LayoutPreset::Feed,
5847            refresh_seconds: 5,
5848            widgets: WidgetVisibility {
5849                price_chart: false,
5850                volume_chart: true,
5851                buy_sell_pressure: true,
5852                metrics_panel: false,
5853                activity_log: true,
5854                holder_count: true,
5855                liquidity_depth: true,
5856            },
5857            scale: ScaleMode::Log,
5858            color_scheme: ColorScheme::Monochrome,
5859            alerts: AlertConfig::default(),
5860            export: ExportConfig::default(),
5861            auto_pause_on_input: false,
5862        };
5863        state.apply_config(&config);
5864        assert_eq!(state.layout, LayoutPreset::Feed);
5865        assert!(!state.widgets.price_chart);
5866        assert!(!state.widgets.metrics_panel);
5867        assert_eq!(state.refresh_rate, Duration::from_secs(5));
5868    }
5869
5870    #[test]
5871    fn test_layout_all_widgets_hidden_dashboard() {
5872        let area = Rect::new(0, 0, 120, 40);
5873        let vis = WidgetVisibility {
5874            price_chart: false,
5875            volume_chart: false,
5876            buy_sell_pressure: false,
5877            metrics_panel: false,
5878            activity_log: false,
5879            holder_count: false,
5880            liquidity_depth: false,
5881        };
5882        let areas = layout_dashboard(area, &vis);
5883        assert!(areas.price_chart.is_none());
5884        assert!(areas.volume_chart.is_none());
5885        assert!(areas.buy_sell_gauge.is_none());
5886        assert!(areas.metrics_panel.is_none());
5887        assert!(areas.activity_feed.is_none());
5888    }
5889
5890    #[test]
5891    fn test_ui_render_with_hidden_widgets() {
5892        let mut terminal = create_test_terminal();
5893        let mut state = create_populated_state();
5894        state.auto_layout = false;
5895        state.widgets.price_chart = false;
5896        state.widgets.volume_chart = false;
5897        terminal.draw(|f| ui(f, &mut state)).unwrap();
5898    }
5899
5900    #[test]
5901    fn test_ui_render_widget_toggle_mode_footer() {
5902        let mut terminal = create_test_terminal();
5903        let mut state = create_populated_state();
5904        state.auto_layout = false;
5905        state.widget_toggle_mode = true;
5906        terminal.draw(|f| ui(f, &mut state)).unwrap();
5907    }
5908
5909    #[test]
5910    fn test_monitor_state_new_has_layout_fields() {
5911        let token_data = create_test_token_data();
5912        let state = MonitorState::new(&token_data, "ethereum");
5913        assert_eq!(state.layout, LayoutPreset::Dashboard);
5914        assert!(state.auto_layout);
5915        assert!(!state.widget_toggle_mode);
5916        assert_eq!(state.widgets.visible_count(), 5);
5917    }
5918
5919    // ========================================================================
5920    // Phase 6: Data Source Integration tests
5921    // ========================================================================
5922
5923    #[test]
5924    fn test_monitor_state_has_holder_count_field() {
5925        let token_data = create_test_token_data();
5926        let state = MonitorState::new(&token_data, "ethereum");
5927        assert_eq!(state.holder_count, None);
5928        assert!(state.liquidity_pairs.is_empty());
5929        assert_eq!(state.holder_fetch_counter, 0);
5930    }
5931
5932    #[test]
5933    fn test_liquidity_pairs_extracted_on_update() {
5934        let mut token_data = create_test_token_data();
5935        token_data.pairs = vec![
5936            crate::chains::DexPair {
5937                dex_name: "Uniswap V3".to_string(),
5938                pair_address: "0xpair1".to_string(),
5939                base_token: "TEST".to_string(),
5940                quote_token: "WETH".to_string(),
5941                price_usd: 1.0,
5942                volume_24h: 500_000.0,
5943                liquidity_usd: 250_000.0,
5944                price_change_24h: 5.0,
5945                buys_24h: 50,
5946                sells_24h: 25,
5947                buys_6h: 10,
5948                sells_6h: 5,
5949                buys_1h: 3,
5950                sells_1h: 2,
5951                pair_created_at: None,
5952                url: None,
5953            },
5954            crate::chains::DexPair {
5955                dex_name: "SushiSwap".to_string(),
5956                pair_address: "0xpair2".to_string(),
5957                base_token: "TEST".to_string(),
5958                quote_token: "USDC".to_string(),
5959                price_usd: 1.0,
5960                volume_24h: 300_000.0,
5961                liquidity_usd: 150_000.0,
5962                price_change_24h: 3.0,
5963                buys_24h: 30,
5964                sells_24h: 15,
5965                buys_6h: 8,
5966                sells_6h: 4,
5967                buys_1h: 2,
5968                sells_1h: 1,
5969                pair_created_at: None,
5970                url: None,
5971            },
5972        ];
5973
5974        let mut state = MonitorState::new(&token_data, "ethereum");
5975        state.update(&token_data);
5976
5977        assert_eq!(state.liquidity_pairs.len(), 2);
5978        assert!(state.liquidity_pairs[0].0.contains("Uniswap V3"));
5979        assert!((state.liquidity_pairs[0].1 - 250_000.0).abs() < 0.01);
5980    }
5981
5982    #[test]
5983    fn test_render_liquidity_depth_no_panic() {
5984        let mut terminal = create_test_terminal();
5985        let mut state = create_populated_state();
5986        state.liquidity_pairs = vec![
5987            ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
5988            ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
5989        ];
5990        terminal
5991            .draw(|f| render_liquidity_depth(f, f.area(), &state))
5992            .unwrap();
5993    }
5994
5995    #[test]
5996    fn test_render_liquidity_depth_empty() {
5997        let mut terminal = create_test_terminal();
5998        let state = create_populated_state();
5999        terminal
6000            .draw(|f| render_liquidity_depth(f, f.area(), &state))
6001            .unwrap();
6002    }
6003
6004    #[test]
6005    fn test_render_metrics_with_holder_count() {
6006        let mut terminal = create_test_terminal();
6007        let mut state = create_populated_state();
6008        state.holder_count = Some(42_000);
6009        terminal
6010            .draw(|f| render_metrics_panel(f, f.area(), &state))
6011            .unwrap();
6012    }
6013
6014    // ========================================================================
6015    // Phase 7: Alert System tests
6016    // ========================================================================
6017
6018    #[test]
6019    fn test_alert_config_default() {
6020        let config = AlertConfig::default();
6021        assert!(config.price_min.is_none());
6022        assert!(config.price_max.is_none());
6023        assert!(config.whale_min_usd.is_none());
6024        assert!(config.volume_spike_threshold_pct.is_none());
6025    }
6026
6027    #[test]
6028    fn test_alert_price_min_triggers() {
6029        let token_data = create_test_token_data();
6030        let mut state = MonitorState::new(&token_data, "ethereum");
6031        state.alerts.price_min = Some(2.0); // Price is 1.0, below min of 2.0
6032        state.update(&token_data);
6033        assert!(
6034            !state.active_alerts.is_empty(),
6035            "Should have price-min alert"
6036        );
6037        assert!(state.active_alerts[0].message.contains("below min"));
6038    }
6039
6040    #[test]
6041    fn test_alert_price_max_triggers() {
6042        let mut token_data = create_test_token_data();
6043        token_data.price_usd = 100.0;
6044        let mut state = MonitorState::new(&token_data, "ethereum");
6045        state.alerts.price_max = Some(50.0); // Price 100.0 above max of 50.0
6046        state.update(&token_data);
6047        assert!(
6048            !state.active_alerts.is_empty(),
6049            "Should have price-max alert"
6050        );
6051        assert!(state.active_alerts[0].message.contains("above max"));
6052    }
6053
6054    #[test]
6055    fn test_alert_no_trigger_within_bounds() {
6056        let token_data = create_test_token_data();
6057        let mut state = MonitorState::new(&token_data, "ethereum");
6058        state.alerts.price_min = Some(0.5); // Price 1.0 is above min
6059        state.alerts.price_max = Some(2.0); // Price 1.0 is below max
6060        state.update(&token_data);
6061        assert!(
6062            state.active_alerts.is_empty(),
6063            "Should have no alerts when price is within bounds"
6064        );
6065    }
6066
6067    #[test]
6068    fn test_alert_volume_spike_triggers() {
6069        let token_data = create_test_token_data();
6070        let mut state = MonitorState::new(&token_data, "ethereum");
6071        state.alerts.volume_spike_threshold_pct = Some(10.0);
6072        state.volume_avg = 500_000.0; // Average volume is 500K
6073
6074        // Token data has volume_24h of 1M, which is +100% vs avg — should trigger
6075        state.update(&token_data);
6076        let spike_alerts: Vec<_> = state
6077            .active_alerts
6078            .iter()
6079            .filter(|a| a.message.contains("spike"))
6080            .collect();
6081        assert!(!spike_alerts.is_empty(), "Should have volume spike alert");
6082    }
6083
6084    #[test]
6085    fn test_alert_flash_timer_set() {
6086        let token_data = create_test_token_data();
6087        let mut state = MonitorState::new(&token_data, "ethereum");
6088        state.alerts.price_min = Some(2.0);
6089        state.update(&token_data);
6090        assert!(state.alert_flash_until.is_some());
6091    }
6092
6093    #[test]
6094    fn test_render_alert_overlay_no_panic() {
6095        let mut terminal = create_test_terminal();
6096        let mut state = create_populated_state();
6097        state.active_alerts.push(ActiveAlert {
6098            message: "⚠ Test alert".to_string(),
6099            triggered_at: Instant::now(),
6100        });
6101        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
6102        terminal
6103            .draw(|f| render_alert_overlay(f, f.area(), &state))
6104            .unwrap();
6105    }
6106
6107    #[test]
6108    fn test_render_alert_overlay_empty() {
6109        let mut terminal = create_test_terminal();
6110        let state = create_populated_state();
6111        terminal
6112            .draw(|f| render_alert_overlay(f, f.area(), &state))
6113            .unwrap();
6114    }
6115
6116    #[test]
6117    fn test_alert_config_serde_roundtrip() {
6118        let config = AlertConfig {
6119            price_min: Some(0.5),
6120            price_max: Some(2.0),
6121            whale_min_usd: Some(10_000.0),
6122            volume_spike_threshold_pct: Some(50.0),
6123        };
6124        let yaml = serde_yaml::to_string(&config).unwrap();
6125        let parsed: AlertConfig = serde_yaml::from_str(&yaml).unwrap();
6126        assert_eq!(parsed.price_min, Some(0.5));
6127        assert_eq!(parsed.price_max, Some(2.0));
6128        assert_eq!(parsed.whale_min_usd, Some(10_000.0));
6129        assert_eq!(parsed.volume_spike_threshold_pct, Some(50.0));
6130    }
6131
6132    #[test]
6133    fn test_ui_with_active_alerts() {
6134        let mut terminal = create_test_terminal();
6135        let mut state = create_populated_state();
6136        state.active_alerts.push(ActiveAlert {
6137            message: "⚠ Price below min".to_string(),
6138            triggered_at: Instant::now(),
6139        });
6140        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
6141        terminal.draw(|f| ui(f, &mut state)).unwrap();
6142    }
6143
6144    // ========================================================================
6145    // Phase 8: CSV Export tests
6146    // ========================================================================
6147
6148    #[test]
6149    fn test_export_config_default() {
6150        let config = ExportConfig::default();
6151        assert!(config.path.is_none());
6152    }
6153
6154    /// Helper to create an export in a temp directory, avoiding race conditions.
6155    fn start_export_in_temp(state: &mut MonitorState) -> PathBuf {
6156        use std::sync::atomic::{AtomicU64, Ordering};
6157        static COUNTER: AtomicU64 = AtomicU64::new(0);
6158        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
6159        let dir =
6160            std::env::temp_dir().join(format!("scope_test_export_{}_{}", std::process::id(), id));
6161        let _ = fs::create_dir_all(&dir);
6162        let filename = format!("{}_test_{}.csv", state.symbol, id);
6163        let path = dir.join(filename);
6164
6165        let mut file = fs::File::create(&path).expect("failed to create export test file");
6166        let header = "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
6167        file.write_all(header.as_bytes())
6168            .expect("failed to write header");
6169        drop(file); // Ensure file is flushed and closed
6170
6171        state.export_path = Some(path.clone());
6172        state.export_active = true;
6173        path
6174    }
6175
6176    #[test]
6177    fn test_export_start_creates_file() {
6178        let token_data = create_test_token_data();
6179        let mut state = MonitorState::new(&token_data, "ethereum");
6180        let path = start_export_in_temp(&mut state);
6181
6182        assert!(state.export_active);
6183        assert!(state.export_path.is_some());
6184        assert!(path.exists(), "Export file should exist");
6185
6186        // Cleanup
6187        let _ = std::fs::remove_file(&path);
6188    }
6189
6190    #[test]
6191    fn test_export_stop() {
6192        let token_data = create_test_token_data();
6193        let mut state = MonitorState::new(&token_data, "ethereum");
6194        let path = start_export_in_temp(&mut state);
6195        state.stop_export();
6196
6197        assert!(!state.export_active);
6198        assert!(state.export_path.is_none());
6199
6200        // Cleanup
6201        let _ = std::fs::remove_file(&path);
6202    }
6203
6204    #[test]
6205    fn test_export_toggle() {
6206        let token_data = create_test_token_data();
6207        let mut state = MonitorState::new(&token_data, "ethereum");
6208
6209        state.toggle_export();
6210        assert!(state.export_active);
6211        let path = state.export_path.clone().unwrap();
6212
6213        state.toggle_export();
6214        assert!(!state.export_active);
6215
6216        // Cleanup
6217        let _ = std::fs::remove_file(path);
6218    }
6219
6220    #[test]
6221    fn test_export_writes_csv_rows() {
6222        let token_data = create_test_token_data();
6223        let mut state = MonitorState::new(&token_data, "ethereum");
6224        let path = start_export_in_temp(&mut state);
6225
6226        // Simulate a few updates
6227        state.update(&token_data);
6228        state.update(&token_data);
6229
6230        let contents = std::fs::read_to_string(&path).unwrap();
6231        let lines: Vec<&str> = contents.lines().collect();
6232
6233        assert!(
6234            lines.len() >= 3,
6235            "Should have header + 2 data rows, got {}",
6236            lines.len()
6237        );
6238        assert!(lines[0].starts_with("timestamp,price_usd"));
6239
6240        // Cleanup
6241        state.stop_export();
6242        let _ = std::fs::remove_file(path);
6243    }
6244
6245    #[test]
6246    fn test_keybinding_e_toggles_export() {
6247        let token_data = create_test_token_data();
6248        let mut state = MonitorState::new(&token_data, "ethereum");
6249
6250        handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
6251        assert!(state.export_active);
6252        let path = state.export_path.clone().unwrap();
6253
6254        handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
6255        assert!(!state.export_active);
6256
6257        // Cleanup
6258        let _ = std::fs::remove_file(path);
6259    }
6260
6261    #[test]
6262    fn test_render_footer_with_export_active() {
6263        let mut terminal = create_test_terminal();
6264        let mut state = create_populated_state();
6265        state.export_active = true;
6266        terminal
6267            .draw(|f| render_footer(f, f.area(), &state))
6268            .unwrap();
6269    }
6270
6271    #[test]
6272    fn test_export_config_serde_roundtrip() {
6273        let config = ExportConfig {
6274            path: Some("./my-exports".to_string()),
6275        };
6276        let yaml = serde_yaml::to_string(&config).unwrap();
6277        let parsed: ExportConfig = serde_yaml::from_str(&yaml).unwrap();
6278        assert_eq!(parsed.path, Some("./my-exports".to_string()));
6279    }
6280
6281    // ========================================================================
6282    // Phase 9: Auto-Pause tests
6283    // ========================================================================
6284
6285    #[test]
6286    fn test_auto_pause_default_disabled() {
6287        let token_data = create_test_token_data();
6288        let state = MonitorState::new(&token_data, "ethereum");
6289        assert!(!state.auto_pause_on_input);
6290        assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
6291    }
6292
6293    #[test]
6294    fn test_auto_pause_blocks_refresh() {
6295        let token_data = create_test_token_data();
6296        let mut state = MonitorState::new(&token_data, "ethereum");
6297        state.auto_pause_on_input = true;
6298        state.refresh_rate = Duration::from_secs(1);
6299
6300        // Simulate fresh input
6301        state.last_input_at = Instant::now();
6302        state.last_update = Instant::now() - Duration::from_secs(10); // Long overdue
6303
6304        // Auto-pause should block refresh since we just had input
6305        assert!(!state.should_refresh());
6306    }
6307
6308    #[test]
6309    fn test_auto_pause_allows_refresh_after_timeout() {
6310        let token_data = create_test_token_data();
6311        let mut state = MonitorState::new(&token_data, "ethereum");
6312        state.auto_pause_on_input = true;
6313        state.refresh_rate = Duration::from_secs(1);
6314        state.auto_pause_timeout = Duration::from_millis(1); // Very short timeout
6315
6316        // Simulate old input (long ago)
6317        state.last_input_at = Instant::now() - Duration::from_secs(10);
6318        state.last_update = Instant::now() - Duration::from_secs(10);
6319
6320        // Should allow refresh since input was long ago
6321        assert!(state.should_refresh());
6322    }
6323
6324    #[test]
6325    fn test_auto_pause_disabled_does_not_block() {
6326        let token_data = create_test_token_data();
6327        let mut state = MonitorState::new(&token_data, "ethereum");
6328        state.auto_pause_on_input = false;
6329        state.refresh_rate = Duration::from_secs(1);
6330
6331        state.last_input_at = Instant::now(); // Fresh input
6332        state.last_update = Instant::now() - Duration::from_secs(10);
6333
6334        // Should still refresh because auto-pause is disabled
6335        assert!(state.should_refresh());
6336    }
6337
6338    #[test]
6339    fn test_is_auto_paused() {
6340        let token_data = create_test_token_data();
6341        let mut state = MonitorState::new(&token_data, "ethereum");
6342
6343        // Not auto-paused when disabled
6344        state.auto_pause_on_input = false;
6345        state.last_input_at = Instant::now();
6346        assert!(!state.is_auto_paused());
6347
6348        // Auto-paused when enabled and input is recent
6349        state.auto_pause_on_input = true;
6350        state.last_input_at = Instant::now();
6351        assert!(state.is_auto_paused());
6352
6353        // Not auto-paused when input is old
6354        state.last_input_at = Instant::now() - Duration::from_secs(10);
6355        assert!(!state.is_auto_paused());
6356    }
6357
6358    #[test]
6359    fn test_keybinding_shift_p_toggles_auto_pause() {
6360        let token_data = create_test_token_data();
6361        let mut state = MonitorState::new(&token_data, "ethereum");
6362        assert!(!state.auto_pause_on_input);
6363
6364        let shift_p = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
6365        handle_key_event_on_state(shift_p, &mut state);
6366        assert!(state.auto_pause_on_input);
6367
6368        handle_key_event_on_state(shift_p, &mut state);
6369        assert!(!state.auto_pause_on_input);
6370    }
6371
6372    #[test]
6373    fn test_keybinding_updates_last_input_at() {
6374        let token_data = create_test_token_data();
6375        let mut state = MonitorState::new(&token_data, "ethereum");
6376
6377        // Set last_input_at to the past
6378        state.last_input_at = Instant::now() - Duration::from_secs(60);
6379        let old_input = state.last_input_at;
6380
6381        // Any key event should update last_input_at
6382        handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
6383        assert!(state.last_input_at > old_input);
6384    }
6385
6386    #[test]
6387    fn test_render_footer_auto_paused() {
6388        let mut terminal = create_test_terminal();
6389        let mut state = create_populated_state();
6390        state.auto_pause_on_input = true;
6391        state.last_input_at = Instant::now(); // Recent input -> auto-paused
6392        terminal
6393            .draw(|f| render_footer(f, f.area(), &state))
6394            .unwrap();
6395    }
6396
6397    #[test]
6398    fn test_config_auto_pause_applied() {
6399        let mut state = create_populated_state();
6400        let config = MonitorConfig {
6401            auto_pause_on_input: true,
6402            ..MonitorConfig::default()
6403        };
6404        state.apply_config(&config);
6405        assert!(state.auto_pause_on_input);
6406    }
6407
6408    // ========================================================================
6409    // Combined full-UI tests for new features
6410    // ========================================================================
6411
6412    #[test]
6413    fn test_ui_render_all_layouts_with_alerts_and_export() {
6414        for preset in &[
6415            LayoutPreset::Dashboard,
6416            LayoutPreset::ChartFocus,
6417            LayoutPreset::Feed,
6418            LayoutPreset::Compact,
6419        ] {
6420            let mut terminal = create_test_terminal();
6421            let mut state = create_populated_state();
6422            state.layout = *preset;
6423            state.auto_layout = false;
6424            state.export_active = true;
6425            state.active_alerts.push(ActiveAlert {
6426                message: "⚠ Test alert".to_string(),
6427                triggered_at: Instant::now(),
6428            });
6429            terminal.draw(|f| ui(f, &mut state)).unwrap();
6430        }
6431    }
6432
6433    #[test]
6434    fn test_ui_render_with_liquidity_data() {
6435        let mut terminal = create_test_terminal();
6436        let mut state = create_populated_state();
6437        state.liquidity_pairs = vec![
6438            ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
6439            ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
6440        ];
6441        terminal.draw(|f| ui(f, &mut state)).unwrap();
6442    }
6443
6444    #[test]
6445    fn test_monitor_config_full_serde_roundtrip() {
6446        let config = MonitorConfig {
6447            layout: LayoutPreset::Dashboard,
6448            refresh_seconds: 10,
6449            widgets: WidgetVisibility::default(),
6450            scale: ScaleMode::Log,
6451            color_scheme: ColorScheme::BlueOrange,
6452            alerts: AlertConfig {
6453                price_min: Some(0.5),
6454                price_max: Some(10.0),
6455                whale_min_usd: Some(50_000.0),
6456                volume_spike_threshold_pct: Some(100.0),
6457            },
6458            export: ExportConfig {
6459                path: Some("./exports".to_string()),
6460            },
6461            auto_pause_on_input: true,
6462        };
6463
6464        let yaml = serde_yaml::to_string(&config).unwrap();
6465        let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
6466        assert_eq!(parsed.layout, LayoutPreset::Dashboard);
6467        assert_eq!(parsed.refresh_seconds, 10);
6468        assert_eq!(parsed.alerts.price_min, Some(0.5));
6469        assert_eq!(parsed.alerts.price_max, Some(10.0));
6470        assert_eq!(parsed.export.path, Some("./exports".to_string()));
6471        assert!(parsed.auto_pause_on_input);
6472    }
6473
6474    #[test]
6475    fn test_monitor_config_serde_defaults_for_new_fields() {
6476        // Only specify old fields — new fields should default
6477        let yaml = r#"
6478layout: dashboard
6479refresh_seconds: 5
6480"#;
6481        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
6482        assert!(config.alerts.price_min.is_none());
6483        assert!(config.export.path.is_none());
6484        assert!(!config.auto_pause_on_input);
6485    }
6486
6487    #[test]
6488    fn test_quit_stops_export() {
6489        let token_data = create_test_token_data();
6490        let mut state = MonitorState::new(&token_data, "ethereum");
6491        let path = start_export_in_temp(&mut state);
6492
6493        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('q')), &mut state);
6494        assert!(exit);
6495        assert!(!state.export_active);
6496
6497        // Cleanup
6498        let _ = std::fs::remove_file(path);
6499    }
6500
6501    #[test]
6502    fn test_monitor_state_new_has_alert_export_autopause_fields() {
6503        let token_data = create_test_token_data();
6504        let state = MonitorState::new(&token_data, "ethereum");
6505
6506        // Alert fields
6507        assert!(state.active_alerts.is_empty());
6508        assert!(state.alert_flash_until.is_none());
6509        assert!(state.alerts.price_min.is_none());
6510
6511        // Export fields
6512        assert!(!state.export_active);
6513        assert!(state.export_path.is_none());
6514
6515        // Auto-pause fields
6516        assert!(!state.auto_pause_on_input);
6517        assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
6518    }
6519
6520    // ========================================================================
6521    // Coverage gap: Serde round-trip tests for enums
6522    // ========================================================================
6523
6524    #[test]
6525    fn test_scale_mode_serde_roundtrip() {
6526        for mode in &[ScaleMode::Linear, ScaleMode::Log] {
6527            let yaml = serde_yaml::to_string(mode).unwrap();
6528            let parsed: ScaleMode = serde_yaml::from_str(&yaml).unwrap();
6529            assert_eq!(&parsed, mode);
6530        }
6531    }
6532
6533    #[test]
6534    fn test_scale_mode_serde_kebab_case() {
6535        let parsed: ScaleMode = serde_yaml::from_str("linear").unwrap();
6536        assert_eq!(parsed, ScaleMode::Linear);
6537        let parsed: ScaleMode = serde_yaml::from_str("log").unwrap();
6538        assert_eq!(parsed, ScaleMode::Log);
6539    }
6540
6541    #[test]
6542    fn test_scale_mode_toggle() {
6543        assert_eq!(ScaleMode::Linear.toggle(), ScaleMode::Log);
6544        assert_eq!(ScaleMode::Log.toggle(), ScaleMode::Linear);
6545    }
6546
6547    #[test]
6548    fn test_scale_mode_label() {
6549        assert_eq!(ScaleMode::Linear.label(), "Lin");
6550        assert_eq!(ScaleMode::Log.label(), "Log");
6551    }
6552
6553    #[test]
6554    fn test_color_scheme_serde_roundtrip() {
6555        for scheme in &[
6556            ColorScheme::GreenRed,
6557            ColorScheme::BlueOrange,
6558            ColorScheme::Monochrome,
6559        ] {
6560            let yaml = serde_yaml::to_string(scheme).unwrap();
6561            let parsed: ColorScheme = serde_yaml::from_str(&yaml).unwrap();
6562            assert_eq!(&parsed, scheme);
6563        }
6564    }
6565
6566    #[test]
6567    fn test_color_scheme_serde_kebab_case() {
6568        let parsed: ColorScheme = serde_yaml::from_str("green-red").unwrap();
6569        assert_eq!(parsed, ColorScheme::GreenRed);
6570        let parsed: ColorScheme = serde_yaml::from_str("blue-orange").unwrap();
6571        assert_eq!(parsed, ColorScheme::BlueOrange);
6572        let parsed: ColorScheme = serde_yaml::from_str("monochrome").unwrap();
6573        assert_eq!(parsed, ColorScheme::Monochrome);
6574    }
6575
6576    #[test]
6577    fn test_color_scheme_cycle() {
6578        assert_eq!(ColorScheme::GreenRed.next(), ColorScheme::BlueOrange);
6579        assert_eq!(ColorScheme::BlueOrange.next(), ColorScheme::Monochrome);
6580        assert_eq!(ColorScheme::Monochrome.next(), ColorScheme::GreenRed);
6581    }
6582
6583    #[test]
6584    fn test_color_scheme_label() {
6585        assert_eq!(ColorScheme::GreenRed.label(), "G/R");
6586        assert_eq!(ColorScheme::BlueOrange.label(), "B/O");
6587        assert_eq!(ColorScheme::Monochrome.label(), "Mono");
6588    }
6589
6590    #[test]
6591    fn test_color_palette_fields_populated() {
6592        // Verify each palette has distinct meaningful values
6593        for scheme in &[
6594            ColorScheme::GreenRed,
6595            ColorScheme::BlueOrange,
6596            ColorScheme::Monochrome,
6597        ] {
6598            let pal = scheme.palette();
6599            // up and down colors should differ (visually distinct)
6600            assert_ne!(
6601                format!("{:?}", pal.up),
6602                format!("{:?}", pal.down),
6603                "Up/down should differ for {:?}",
6604                scheme
6605            );
6606        }
6607    }
6608
6609    #[test]
6610    fn test_layout_preset_serde_roundtrip() {
6611        for preset in &[
6612            LayoutPreset::Dashboard,
6613            LayoutPreset::ChartFocus,
6614            LayoutPreset::Feed,
6615            LayoutPreset::Compact,
6616        ] {
6617            let yaml = serde_yaml::to_string(preset).unwrap();
6618            let parsed: LayoutPreset = serde_yaml::from_str(&yaml).unwrap();
6619            assert_eq!(&parsed, preset);
6620        }
6621    }
6622
6623    #[test]
6624    fn test_layout_preset_serde_kebab_case() {
6625        let parsed: LayoutPreset = serde_yaml::from_str("dashboard").unwrap();
6626        assert_eq!(parsed, LayoutPreset::Dashboard);
6627        let parsed: LayoutPreset = serde_yaml::from_str("chart-focus").unwrap();
6628        assert_eq!(parsed, LayoutPreset::ChartFocus);
6629        let parsed: LayoutPreset = serde_yaml::from_str("feed").unwrap();
6630        assert_eq!(parsed, LayoutPreset::Feed);
6631        let parsed: LayoutPreset = serde_yaml::from_str("compact").unwrap();
6632        assert_eq!(parsed, LayoutPreset::Compact);
6633    }
6634
6635    #[test]
6636    fn test_widget_visibility_serde_roundtrip() {
6637        let vis = WidgetVisibility {
6638            price_chart: false,
6639            volume_chart: true,
6640            buy_sell_pressure: false,
6641            metrics_panel: true,
6642            activity_log: false,
6643            holder_count: false,
6644            liquidity_depth: true,
6645        };
6646        let yaml = serde_yaml::to_string(&vis).unwrap();
6647        let parsed: WidgetVisibility = serde_yaml::from_str(&yaml).unwrap();
6648        assert!(!parsed.price_chart);
6649        assert!(parsed.volume_chart);
6650        assert!(!parsed.buy_sell_pressure);
6651        assert!(parsed.metrics_panel);
6652        assert!(!parsed.activity_log);
6653        assert!(!parsed.holder_count);
6654        assert!(parsed.liquidity_depth);
6655    }
6656
6657    #[test]
6658    fn test_data_point_serde_roundtrip() {
6659        let dp = DataPoint {
6660            timestamp: 1700000000.5,
6661            value: 42.123456,
6662            is_real: true,
6663        };
6664        let json = serde_json::to_string(&dp).unwrap();
6665        let parsed: DataPoint = serde_json::from_str(&json).unwrap();
6666        assert!((parsed.timestamp - dp.timestamp).abs() < 0.001);
6667        assert!((parsed.value - dp.value).abs() < 0.001);
6668        assert_eq!(parsed.is_real, dp.is_real);
6669    }
6670
6671    // ========================================================================
6672    // Coverage gap: Key handler tests for scale and color scheme
6673    // ========================================================================
6674
6675    #[test]
6676    fn test_handle_key_scale_toggle_s() {
6677        let token_data = create_test_token_data();
6678        let mut state = MonitorState::new(&token_data, "ethereum");
6679        assert_eq!(state.scale_mode, ScaleMode::Linear);
6680
6681        handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
6682        assert_eq!(state.scale_mode, ScaleMode::Log);
6683
6684        handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
6685        assert_eq!(state.scale_mode, ScaleMode::Linear);
6686    }
6687
6688    #[test]
6689    fn test_handle_key_color_scheme_cycle_slash() {
6690        let token_data = create_test_token_data();
6691        let mut state = MonitorState::new(&token_data, "ethereum");
6692        assert_eq!(state.color_scheme, ColorScheme::GreenRed);
6693
6694        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
6695        assert_eq!(state.color_scheme, ColorScheme::BlueOrange);
6696
6697        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
6698        assert_eq!(state.color_scheme, ColorScheme::Monochrome);
6699
6700        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
6701        assert_eq!(state.color_scheme, ColorScheme::GreenRed);
6702    }
6703
6704    // ========================================================================
6705    // Coverage gap: Volume profile chart render tests
6706    // ========================================================================
6707
6708    #[test]
6709    fn test_render_volume_profile_chart_no_panic() {
6710        let mut terminal = create_test_terminal();
6711        let state = create_populated_state();
6712        terminal
6713            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
6714            .unwrap();
6715    }
6716
6717    #[test]
6718    fn test_render_volume_profile_chart_empty_data() {
6719        let mut terminal = create_test_terminal();
6720        let token_data = create_test_token_data();
6721        let mut state = MonitorState::new(&token_data, "ethereum");
6722        state.price_history.clear();
6723        state.volume_history.clear();
6724        terminal
6725            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
6726            .unwrap();
6727    }
6728
6729    #[test]
6730    fn test_render_volume_profile_chart_single_price() {
6731        // When all prices are identical, there's no range -> "no price range" path
6732        let mut terminal = create_test_terminal();
6733        let mut token_data = create_test_token_data();
6734        token_data.price_usd = 1.0;
6735        let mut state = MonitorState::new(&token_data, "ethereum");
6736        // Clear and add identical-price data points
6737        state.price_history.clear();
6738        state.volume_history.clear();
6739        let now = chrono::Utc::now().timestamp() as f64;
6740        for i in 0..5 {
6741            state.price_history.push_back(DataPoint {
6742                timestamp: now - (5.0 - i as f64) * 60.0,
6743                value: 1.0, // all same price
6744                is_real: true,
6745            });
6746            state.volume_history.push_back(DataPoint {
6747                timestamp: now - (5.0 - i as f64) * 60.0,
6748                value: 1000.0,
6749                is_real: true,
6750            });
6751        }
6752        terminal
6753            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
6754            .unwrap();
6755    }
6756
6757    #[test]
6758    fn test_render_volume_profile_chart_narrow_terminal() {
6759        let backend = TestBackend::new(30, 15);
6760        let mut terminal = Terminal::new(backend).unwrap();
6761        let state = create_populated_state();
6762        terminal
6763            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
6764            .unwrap();
6765    }
6766
6767    // ========================================================================
6768    // Coverage gap: Log scale rendering tests
6769    // ========================================================================
6770
6771    #[test]
6772    fn test_render_price_chart_log_scale() {
6773        let mut terminal = create_test_terminal();
6774        let mut state = create_populated_state();
6775        state.scale_mode = ScaleMode::Log;
6776        terminal
6777            .draw(|f| render_price_chart(f, f.area(), &state))
6778            .unwrap();
6779    }
6780
6781    #[test]
6782    fn test_render_candlestick_chart_log_scale() {
6783        let mut terminal = create_test_terminal();
6784        let mut state = create_populated_state();
6785        state.scale_mode = ScaleMode::Log;
6786        state.chart_mode = ChartMode::Candlestick;
6787        terminal
6788            .draw(|f| render_candlestick_chart(f, f.area(), &state))
6789            .unwrap();
6790    }
6791
6792    #[test]
6793    fn test_render_price_chart_log_scale_zero_price() {
6794        // Verify log scale handles zero/near-zero prices safely
6795        let mut terminal = create_test_terminal();
6796        let mut token_data = create_test_token_data();
6797        token_data.price_usd = 0.0001;
6798        let mut state = MonitorState::new(&token_data, "ethereum");
6799        state.scale_mode = ScaleMode::Log;
6800        for i in 0..10 {
6801            let mut data = token_data.clone();
6802            data.price_usd = 0.0001 + (i as f64 * 0.00001);
6803            state.update(&data);
6804        }
6805        terminal
6806            .draw(|f| render_price_chart(f, f.area(), &state))
6807            .unwrap();
6808    }
6809
6810    // ========================================================================
6811    // Coverage gap: Color scheme rendering tests
6812    // ========================================================================
6813
6814    #[test]
6815    fn test_render_ui_with_all_color_schemes() {
6816        for scheme in &[
6817            ColorScheme::GreenRed,
6818            ColorScheme::BlueOrange,
6819            ColorScheme::Monochrome,
6820        ] {
6821            let mut terminal = create_test_terminal();
6822            let mut state = create_populated_state();
6823            state.color_scheme = *scheme;
6824            terminal.draw(|f| ui(f, &mut state)).unwrap();
6825        }
6826    }
6827
6828    #[test]
6829    fn test_render_volume_chart_all_color_schemes() {
6830        for scheme in &[
6831            ColorScheme::GreenRed,
6832            ColorScheme::BlueOrange,
6833            ColorScheme::Monochrome,
6834        ] {
6835            let mut terminal = create_test_terminal();
6836            let mut state = create_populated_state();
6837            state.color_scheme = *scheme;
6838            terminal
6839                .draw(|f| render_volume_chart(f, f.area(), &state))
6840                .unwrap();
6841        }
6842    }
6843
6844    // ========================================================================
6845    // Coverage gap: Activity feed dedicated render tests
6846    // ========================================================================
6847
6848    #[test]
6849    fn test_render_activity_feed_no_panic() {
6850        let mut terminal = create_test_terminal();
6851        let mut state = create_populated_state();
6852        for i in 0..5 {
6853            state.log_messages.push_back(format!("Event {}", i));
6854        }
6855        terminal
6856            .draw(|f| render_activity_feed(f, f.area(), &mut state))
6857            .unwrap();
6858    }
6859
6860    #[test]
6861    fn test_render_activity_feed_empty_log() {
6862        let mut terminal = create_test_terminal();
6863        let token_data = create_test_token_data();
6864        let mut state = MonitorState::new(&token_data, "ethereum");
6865        state.log_messages.clear();
6866        terminal
6867            .draw(|f| render_activity_feed(f, f.area(), &mut state))
6868            .unwrap();
6869    }
6870
6871    #[test]
6872    fn test_render_activity_feed_with_selection() {
6873        let mut terminal = create_test_terminal();
6874        let mut state = create_populated_state();
6875        for i in 0..10 {
6876            state.log_messages.push_back(format!("Event {}", i));
6877        }
6878        state.scroll_log_down();
6879        state.scroll_log_down();
6880        state.scroll_log_down();
6881        terminal
6882            .draw(|f| render_activity_feed(f, f.area(), &mut state))
6883            .unwrap();
6884    }
6885
6886    // ========================================================================
6887    // Coverage gap: Alert edge cases
6888    // ========================================================================
6889
6890    #[test]
6891    fn test_alert_whale_zero_transactions() {
6892        let mut token_data = create_test_token_data();
6893        token_data.total_buys_24h = 0;
6894        token_data.total_sells_24h = 0;
6895        let mut state = MonitorState::new(&token_data, "ethereum");
6896        state.alerts.whale_min_usd = Some(100.0);
6897        state.update(&token_data);
6898        // With zero total txs, whale detection should NOT fire
6899        let whale_alerts: Vec<_> = state
6900            .active_alerts
6901            .iter()
6902            .filter(|a| a.message.contains("whale") || a.message.contains("🐋"))
6903            .collect();
6904        assert!(
6905            whale_alerts.is_empty(),
6906            "Whale alert should not fire with zero transactions"
6907        );
6908    }
6909
6910    #[test]
6911    fn test_alert_multiple_simultaneous() {
6912        let mut token_data = create_test_token_data();
6913        token_data.price_usd = 0.1; // below min
6914        let mut state = MonitorState::new(&token_data, "ethereum");
6915        state.alerts.price_min = Some(0.5); // will fire: price 0.1 < 0.5
6916        state.alerts.price_max = Some(0.05); // will fire: price 0.1 > 0.05
6917        state.alerts.volume_spike_threshold_pct = Some(1.0);
6918        state.volume_avg = 100.0; // volume_24h 1M vs avg 100 => huge spike
6919
6920        state.update(&token_data);
6921        // Should have multiple alerts
6922        assert!(
6923            state.active_alerts.len() >= 2,
6924            "Expected multiple alerts, got {}",
6925            state.active_alerts.len()
6926        );
6927    }
6928
6929    #[test]
6930    fn test_alert_clears_on_next_update() {
6931        let token_data = create_test_token_data();
6932        let mut state = MonitorState::new(&token_data, "ethereum");
6933        state.alerts.price_min = Some(2.0); // price 1.0 < 2.0 -> fires
6934        state.update(&token_data);
6935        assert!(!state.active_alerts.is_empty());
6936
6937        // Update with price above min -> should clear
6938        let mut above_min = token_data.clone();
6939        above_min.price_usd = 3.0;
6940        state.alerts.price_min = Some(2.0);
6941        state.update(&above_min);
6942        // check_alerts clears alerts each time and re-evaluates
6943        let price_min_alerts: Vec<_> = state
6944            .active_alerts
6945            .iter()
6946            .filter(|a| a.message.contains("below min"))
6947            .collect();
6948        assert!(
6949            price_min_alerts.is_empty(),
6950            "Price-min alert should clear when price goes above min"
6951        );
6952    }
6953
6954    #[test]
6955    fn test_render_alert_overlay_multiple_alerts() {
6956        let mut terminal = create_test_terminal();
6957        let mut state = create_populated_state();
6958        state.active_alerts.push(ActiveAlert {
6959            message: "⚠ Price below min".to_string(),
6960            triggered_at: Instant::now(),
6961        });
6962        state.active_alerts.push(ActiveAlert {
6963            message: "🐋 Whale detected".to_string(),
6964            triggered_at: Instant::now(),
6965        });
6966        state.active_alerts.push(ActiveAlert {
6967            message: "⚠ Volume spike".to_string(),
6968            triggered_at: Instant::now(),
6969        });
6970        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
6971        terminal
6972            .draw(|f| render_alert_overlay(f, f.area(), &state))
6973            .unwrap();
6974    }
6975
6976    #[test]
6977    fn test_render_alert_overlay_flash_expired() {
6978        let mut terminal = create_test_terminal();
6979        let mut state = create_populated_state();
6980        state.active_alerts.push(ActiveAlert {
6981            message: "⚠ Test".to_string(),
6982            triggered_at: Instant::now(),
6983        });
6984        // Flash timer already expired
6985        state.alert_flash_until = Some(Instant::now() - Duration::from_secs(5));
6986        terminal
6987            .draw(|f| render_alert_overlay(f, f.area(), &state))
6988            .unwrap();
6989    }
6990
6991    // ========================================================================
6992    // Coverage gap: Liquidity depth edge cases
6993    // ========================================================================
6994
6995    #[test]
6996    fn test_render_liquidity_depth_many_pairs() {
6997        let mut terminal = create_test_terminal();
6998        let mut state = create_populated_state();
6999        // Add many pairs to test height-limiting
7000        for i in 0..20 {
7001            state.liquidity_pairs.push((
7002                format!("TEST/TOKEN{} (DEX{})", i, i),
7003                (100_000.0 + i as f64 * 50_000.0),
7004            ));
7005        }
7006        terminal
7007            .draw(|f| render_liquidity_depth(f, f.area(), &state))
7008            .unwrap();
7009    }
7010
7011    #[test]
7012    fn test_render_liquidity_depth_narrow_terminal() {
7013        let backend = TestBackend::new(30, 10);
7014        let mut terminal = Terminal::new(backend).unwrap();
7015        let mut state = create_populated_state();
7016        state.liquidity_pairs = vec![
7017            ("TEST/WETH (Uniswap)".to_string(), 500_000.0),
7018            ("TEST/USDC (Sushi)".to_string(), 100_000.0),
7019        ];
7020        terminal
7021            .draw(|f| render_liquidity_depth(f, f.area(), &state))
7022            .unwrap();
7023    }
7024
7025    // ========================================================================
7026    // Coverage gap: Metrics panel edge cases
7027    // ========================================================================
7028
7029    #[test]
7030    fn test_render_metrics_panel_holder_count_disabled() {
7031        let mut terminal = create_test_terminal();
7032        let mut state = create_populated_state();
7033        state.holder_count = Some(42_000);
7034        state.widgets.holder_count = false; // disabled
7035        terminal
7036            .draw(|f| render_metrics_panel(f, f.area(), &state))
7037            .unwrap();
7038    }
7039
7040    #[test]
7041    fn test_render_metrics_panel_sparkline_single_point() {
7042        let mut terminal = create_test_terminal();
7043        let mut token_data = create_test_token_data();
7044        token_data.price_usd = 1.0;
7045        let mut state = MonitorState::new(&token_data, "ethereum");
7046        state.price_history.clear();
7047        state.price_history.push_back(DataPoint {
7048            timestamp: 1.0,
7049            value: 1.0,
7050            is_real: true,
7051        });
7052        terminal
7053            .draw(|f| render_metrics_panel(f, f.area(), &state))
7054            .unwrap();
7055    }
7056
7057    // ========================================================================
7058    // Coverage gap: Buy/sell gauge edge cases
7059    // ========================================================================
7060
7061    #[test]
7062    fn test_render_buy_sell_gauge_tiny_area() {
7063        // Render in a very small area to test zero width/height paths
7064        let backend = TestBackend::new(5, 3);
7065        let mut terminal = Terminal::new(backend).unwrap();
7066        let mut state = create_populated_state();
7067        terminal
7068            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
7069            .unwrap();
7070    }
7071
7072    // ========================================================================
7073    // Coverage gap: Log message queue overflow
7074    // ========================================================================
7075
7076    #[test]
7077    fn test_log_message_queue_overflow() {
7078        let token_data = create_test_token_data();
7079        let mut state = MonitorState::new(&token_data, "ethereum");
7080        // Add more than 10 messages (queue capacity)
7081        for i in 0..20 {
7082            state.toggle_pause(); // each toggle logs a message
7083            let _ = i;
7084        }
7085        assert!(
7086            state.log_messages.len() <= 10,
7087            "Log queue should cap at 10, got {}",
7088            state.log_messages.len()
7089        );
7090    }
7091
7092    // ========================================================================
7093    // Coverage gap: Export CSV row content verification
7094    // ========================================================================
7095
7096    #[test]
7097    fn test_export_writes_csv_row_content_format() {
7098        let token_data = create_test_token_data();
7099        let mut state = MonitorState::new(&token_data, "ethereum");
7100        let path = start_export_in_temp(&mut state);
7101
7102        state.update(&token_data);
7103
7104        let contents = std::fs::read_to_string(&path).unwrap();
7105        let lines: Vec<&str> = contents.lines().collect();
7106        assert!(lines.len() >= 2);
7107
7108        // Verify header
7109        assert_eq!(
7110            lines[0],
7111            "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap"
7112        );
7113
7114        // Verify data row has correct number of columns
7115        let data_cols: Vec<&str> = lines[1].split(',').collect();
7116        assert_eq!(
7117            data_cols.len(),
7118            7,
7119            "Expected 7 CSV columns, got {}",
7120            data_cols.len()
7121        );
7122
7123        // Verify timestamp format (ISO 8601)
7124        assert!(data_cols[0].contains('T'));
7125        assert!(data_cols[0].ends_with('Z'));
7126
7127        // Cleanup
7128        state.stop_export();
7129        let _ = std::fs::remove_file(path);
7130    }
7131
7132    #[test]
7133    fn test_export_writes_csv_row_market_cap_none() {
7134        let mut token_data = create_test_token_data();
7135        token_data.market_cap = None;
7136        let mut state = MonitorState::new(&token_data, "ethereum");
7137        let path = start_export_in_temp(&mut state);
7138
7139        state.update(&token_data);
7140
7141        let contents = std::fs::read_to_string(&path).unwrap();
7142        let lines: Vec<&str> = contents.lines().collect();
7143        assert!(lines.len() >= 2);
7144
7145        // Last column should be empty when market_cap is None
7146        let data_cols: Vec<&str> = lines[1].split(',').collect();
7147        assert_eq!(data_cols.len(), 7);
7148        assert!(
7149            data_cols[6].is_empty(),
7150            "Market cap column should be empty when None"
7151        );
7152
7153        // Cleanup
7154        state.stop_export();
7155        let _ = std::fs::remove_file(path);
7156    }
7157
7158    // ========================================================================
7159    // Coverage gap: Full UI render with log scale + all chart modes
7160    // ========================================================================
7161
7162    #[test]
7163    fn test_ui_render_log_scale_all_chart_modes() {
7164        for mode in &[
7165            ChartMode::Line,
7166            ChartMode::Candlestick,
7167            ChartMode::VolumeProfile,
7168        ] {
7169            let mut terminal = create_test_terminal();
7170            let mut state = create_populated_state();
7171            state.scale_mode = ScaleMode::Log;
7172            state.chart_mode = *mode;
7173            terminal.draw(|f| ui(f, &mut state)).unwrap();
7174        }
7175    }
7176
7177    // ========================================================================
7178    // Coverage gap: Footer rendering edge cases
7179    // ========================================================================
7180
7181    #[test]
7182    fn test_render_footer_widget_toggle_mode_active() {
7183        let mut terminal = create_test_terminal();
7184        let mut state = create_populated_state();
7185        state.widget_toggle_mode = true;
7186        terminal
7187            .draw(|f| render_footer(f, f.area(), &state))
7188            .unwrap();
7189    }
7190
7191    #[test]
7192    fn test_render_footer_all_status_indicators() {
7193        // Test with export + auto-pause + alerts simultaneously
7194        let mut terminal = create_test_terminal();
7195        let mut state = create_populated_state();
7196        state.export_active = true;
7197        state.auto_pause_on_input = true;
7198        state.last_input_at = Instant::now(); // triggers auto-paused display
7199        terminal
7200            .draw(|f| render_footer(f, f.area(), &state))
7201            .unwrap();
7202    }
7203
7204    // ========================================================================
7205    // Coverage gap: Synthetic data generation edge cases
7206    // ========================================================================
7207
7208    #[test]
7209    fn test_generate_synthetic_price_history_zero_price() {
7210        let mut token_data = create_test_token_data();
7211        token_data.price_usd = 0.0;
7212        token_data.price_change_1h = 0.0;
7213        token_data.price_change_6h = 0.0;
7214        token_data.price_change_24h = 0.0;
7215        let state = MonitorState::new(&token_data, "ethereum");
7216        // Should not panic with zero prices
7217        assert!(!state.price_history.is_empty());
7218    }
7219
7220    #[test]
7221    fn test_generate_synthetic_volume_history_zero_volume() {
7222        let mut token_data = create_test_token_data();
7223        token_data.volume_24h = 0.0;
7224        token_data.volume_6h = 0.0;
7225        token_data.volume_1h = 0.0;
7226        let state = MonitorState::new(&token_data, "ethereum");
7227        assert!(!state.volume_history.is_empty());
7228    }
7229
7230    // ========================================================================
7231    // Coverage gap: Auto-pause with custom timeout
7232    // ========================================================================
7233
7234    #[test]
7235    fn test_auto_pause_custom_timeout() {
7236        let token_data = create_test_token_data();
7237        let mut state = MonitorState::new(&token_data, "ethereum");
7238        state.auto_pause_on_input = true;
7239        state.auto_pause_timeout = Duration::from_secs(10);
7240        state.refresh_rate = Duration::from_secs(1);
7241
7242        // Fresh input with long timeout -> still auto-paused
7243        state.last_input_at = Instant::now();
7244        state.last_update = Instant::now() - Duration::from_secs(5);
7245        assert!(!state.should_refresh()); // within 10s timeout
7246        assert!(state.is_auto_paused());
7247    }
7248
7249    // ========================================================================
7250    // Coverage gap: Price chart with stablecoin flat range
7251    // ========================================================================
7252
7253    #[test]
7254    fn test_render_price_chart_stablecoin_flat_range() {
7255        let mut terminal = create_test_terminal();
7256        let mut token_data = create_test_token_data();
7257        token_data.price_usd = 1.0;
7258        let mut state = MonitorState::new(&token_data, "ethereum");
7259        // Add many points at nearly identical prices (stablecoin)
7260        for i in 0..20 {
7261            let mut data = token_data.clone();
7262            data.price_usd = 1.0 + (i as f64 * 0.000001); // micro variation
7263            state.update(&data);
7264        }
7265        terminal
7266            .draw(|f| render_price_chart(f, f.area(), &state))
7267            .unwrap();
7268    }
7269
7270    // ========================================================================
7271    // Coverage gap: Cache load edge cases
7272    // ========================================================================
7273
7274    #[test]
7275    fn test_load_cache_corrupted_json() {
7276        let path = MonitorState::cache_path("0xCORRUPTED_TEST", "test_chain");
7277        // Write invalid JSON
7278        let _ = std::fs::write(&path, "not valid json {{{");
7279        let cached = MonitorState::load_cache("0xCORRUPTED_TEST", "test_chain");
7280        assert!(cached.is_none(), "Corrupted JSON should return None");
7281        let _ = std::fs::remove_file(path);
7282    }
7283
7284    #[test]
7285    fn test_load_cache_wrong_token() {
7286        let token_data = create_test_token_data();
7287        let state = MonitorState::new(&token_data, "ethereum");
7288        state.save_cache();
7289
7290        // Try to load with different token address
7291        let cached = MonitorState::load_cache("0xDIFFERENT_ADDRESS", "ethereum");
7292        assert!(
7293            cached.is_none(),
7294            "Loading cache with wrong token address should return None"
7295        );
7296
7297        // Cleanup
7298        let path = MonitorState::cache_path(&token_data.address, "ethereum");
7299        let _ = std::fs::remove_file(path);
7300    }
7301
7302    // ========================================================================
7303    // Integration tests: Mock types and MonitorApp constructor
7304    // ========================================================================
7305
7306    use crate::chains::dex::TokenSearchResult;
7307
7308    /// Mock DEX data source for integration testing.
7309    struct MockDexDataSource {
7310        /// Data returned by `get_token_data`. If `Err`, simulates an API failure.
7311        token_data_result: std::sync::Mutex<Result<DexTokenData>>,
7312    }
7313
7314    impl MockDexDataSource {
7315        fn new(data: DexTokenData) -> Self {
7316            Self {
7317                token_data_result: std::sync::Mutex::new(Ok(data)),
7318            }
7319        }
7320
7321        fn failing(msg: &str) -> Self {
7322            Self {
7323                token_data_result: std::sync::Mutex::new(Err(ScopeError::Api(msg.to_string()))),
7324            }
7325        }
7326    }
7327
7328    #[async_trait::async_trait]
7329    impl DexDataSource for MockDexDataSource {
7330        async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
7331            self.token_data_result
7332                .lock()
7333                .unwrap()
7334                .as_ref()
7335                .ok()
7336                .map(|d| d.price_usd)
7337        }
7338
7339        async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
7340            Some(2000.0)
7341        }
7342
7343        async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
7344            let guard = self.token_data_result.lock().unwrap();
7345            match &*guard {
7346                Ok(data) => Ok(data.clone()),
7347                Err(e) => Err(ScopeError::Api(e.to_string())),
7348            }
7349        }
7350
7351        async fn search_tokens(
7352            &self,
7353            _query: &str,
7354            _chain: Option<&str>,
7355        ) -> Result<Vec<TokenSearchResult>> {
7356            Ok(vec![])
7357        }
7358    }
7359
7360    /// Mock chain client for integration testing.
7361    struct MockChainClient {
7362        holder_count: u64,
7363    }
7364
7365    impl MockChainClient {
7366        fn new(holder_count: u64) -> Self {
7367            Self { holder_count }
7368        }
7369    }
7370
7371    #[async_trait::async_trait]
7372    impl ChainClient for MockChainClient {
7373        fn chain_name(&self) -> &str {
7374            "ethereum"
7375        }
7376        fn native_token_symbol(&self) -> &str {
7377            "ETH"
7378        }
7379        async fn get_balance(&self, _address: &str) -> Result<crate::chains::Balance> {
7380            unimplemented!("not needed for monitor tests")
7381        }
7382        async fn enrich_balance_usd(&self, _balance: &mut crate::chains::Balance) {}
7383        async fn get_transaction(&self, _hash: &str) -> Result<crate::chains::Transaction> {
7384            unimplemented!("not needed for monitor tests")
7385        }
7386        async fn get_transactions(
7387            &self,
7388            _address: &str,
7389            _limit: u32,
7390        ) -> Result<Vec<crate::chains::Transaction>> {
7391            Ok(vec![])
7392        }
7393        async fn get_block_number(&self) -> Result<u64> {
7394            Ok(1000000)
7395        }
7396        async fn get_token_balances(
7397            &self,
7398            _address: &str,
7399        ) -> Result<Vec<crate::chains::TokenBalance>> {
7400            Ok(vec![])
7401        }
7402        async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
7403            Ok(self.holder_count)
7404        }
7405    }
7406
7407    /// Creates a `MonitorApp<TestBackend>` with mock dependencies.
7408    fn create_test_app(
7409        dex: Box<dyn DexDataSource>,
7410        chain_client: Option<Box<dyn ChainClient>>,
7411    ) -> MonitorApp<TestBackend> {
7412        let token_data = create_test_token_data();
7413        let state = MonitorState::new(&token_data, "ethereum");
7414        let backend = TestBackend::new(120, 40);
7415        let terminal = ratatui::Terminal::new(backend).unwrap();
7416        MonitorApp {
7417            terminal,
7418            state,
7419            dex_client: dex,
7420            chain_client,
7421            should_exit: false,
7422            owns_terminal: false,
7423        }
7424    }
7425
7426    fn create_test_app_with_state(
7427        state: MonitorState,
7428        dex: Box<dyn DexDataSource>,
7429        chain_client: Option<Box<dyn ChainClient>>,
7430    ) -> MonitorApp<TestBackend> {
7431        let backend = TestBackend::new(120, 40);
7432        let terminal = ratatui::Terminal::new(backend).unwrap();
7433        MonitorApp {
7434            terminal,
7435            state,
7436            dex_client: dex,
7437            chain_client,
7438            should_exit: false,
7439            owns_terminal: false,
7440        }
7441    }
7442
7443    // ========================================================================
7444    // Integration tests: MonitorApp::handle_key_event
7445    // ========================================================================
7446
7447    #[test]
7448    fn test_app_handle_key_quit_q() {
7449        let data = create_test_token_data();
7450        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7451        assert!(!app.should_exit);
7452        app.handle_key_event(make_key_event(KeyCode::Char('q')));
7453        assert!(app.should_exit);
7454    }
7455
7456    #[test]
7457    fn test_app_handle_key_quit_esc() {
7458        let data = create_test_token_data();
7459        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7460        app.handle_key_event(make_key_event(KeyCode::Esc));
7461        assert!(app.should_exit);
7462    }
7463
7464    #[test]
7465    fn test_app_handle_key_quit_ctrl_c() {
7466        let data = create_test_token_data();
7467        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7468        let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
7469        app.handle_key_event(key);
7470        assert!(app.should_exit);
7471    }
7472
7473    #[test]
7474    fn test_app_handle_key_quit_stops_active_export() {
7475        let data = create_test_token_data();
7476        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7477        let path = start_export_in_temp(&mut app.state);
7478        assert!(app.state.export_active);
7479
7480        app.handle_key_event(make_key_event(KeyCode::Char('q')));
7481        assert!(app.should_exit);
7482        assert!(!app.state.export_active);
7483        let _ = std::fs::remove_file(path);
7484    }
7485
7486    #[test]
7487    fn test_app_handle_key_ctrl_c_stops_active_export() {
7488        let data = create_test_token_data();
7489        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7490        let path = start_export_in_temp(&mut app.state);
7491        assert!(app.state.export_active);
7492
7493        let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
7494        app.handle_key_event(key);
7495        assert!(app.should_exit);
7496        assert!(!app.state.export_active);
7497        let _ = std::fs::remove_file(path);
7498    }
7499
7500    #[test]
7501    fn test_app_handle_key_updates_last_input_time() {
7502        let data = create_test_token_data();
7503        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7504        let before = Instant::now();
7505        app.handle_key_event(make_key_event(KeyCode::Char('p')));
7506        assert!(app.state.last_input_at >= before);
7507    }
7508
7509    #[test]
7510    fn test_app_handle_key_widget_toggle_mode() {
7511        let data = create_test_token_data();
7512        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7513        assert!(app.state.widgets.price_chart);
7514
7515        // Enter widget toggle mode
7516        app.handle_key_event(make_key_event(KeyCode::Char('w')));
7517        assert!(app.state.widget_toggle_mode);
7518
7519        // Toggle widget 1 (price chart)
7520        app.handle_key_event(make_key_event(KeyCode::Char('1')));
7521        assert!(!app.state.widget_toggle_mode);
7522        assert!(!app.state.widgets.price_chart);
7523    }
7524
7525    #[test]
7526    fn test_app_handle_key_widget_toggle_mode_cancel() {
7527        let data = create_test_token_data();
7528        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7529
7530        // Enter widget toggle mode
7531        app.handle_key_event(make_key_event(KeyCode::Char('w')));
7532        assert!(app.state.widget_toggle_mode);
7533
7534        // Any non-digit key cancels widget toggle mode
7535        app.handle_key_event(make_key_event(KeyCode::Char('x')));
7536        assert!(!app.state.widget_toggle_mode);
7537    }
7538
7539    #[test]
7540    fn test_app_handle_key_all_keybindings() {
7541        let data = create_test_token_data();
7542        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7543
7544        // r = force refresh
7545        app.handle_key_event(make_key_event(KeyCode::Char('r')));
7546        assert!(!app.should_exit);
7547
7548        // Shift+P = toggle auto-pause
7549        let key = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
7550        app.handle_key_event(key);
7551        assert!(app.state.auto_pause_on_input);
7552        app.handle_key_event(key);
7553        assert!(!app.state.auto_pause_on_input);
7554
7555        // p = toggle pause
7556        app.handle_key_event(make_key_event(KeyCode::Char('p')));
7557        assert!(app.state.paused);
7558
7559        // space = toggle pause
7560        app.handle_key_event(make_key_event(KeyCode::Char(' ')));
7561        assert!(!app.state.paused);
7562
7563        // e = toggle export
7564        app.handle_key_event(make_key_event(KeyCode::Char('e')));
7565        assert!(app.state.export_active);
7566        // Stop export to avoid file handles
7567        app.state.stop_export();
7568
7569        // + = slower refresh
7570        let before_rate = app.state.refresh_rate;
7571        app.handle_key_event(make_key_event(KeyCode::Char('+')));
7572        assert!(app.state.refresh_rate >= before_rate);
7573
7574        // - = faster refresh
7575        let before_rate = app.state.refresh_rate;
7576        app.handle_key_event(make_key_event(KeyCode::Char('-')));
7577        assert!(app.state.refresh_rate <= before_rate);
7578
7579        // 1-6 = time periods
7580        app.handle_key_event(make_key_event(KeyCode::Char('1')));
7581        assert_eq!(app.state.time_period, TimePeriod::Min1);
7582        app.handle_key_event(make_key_event(KeyCode::Char('2')));
7583        assert_eq!(app.state.time_period, TimePeriod::Min5);
7584        app.handle_key_event(make_key_event(KeyCode::Char('3')));
7585        assert_eq!(app.state.time_period, TimePeriod::Min15);
7586        app.handle_key_event(make_key_event(KeyCode::Char('4')));
7587        assert_eq!(app.state.time_period, TimePeriod::Hour1);
7588        app.handle_key_event(make_key_event(KeyCode::Char('5')));
7589        assert_eq!(app.state.time_period, TimePeriod::Hour4);
7590        app.handle_key_event(make_key_event(KeyCode::Char('6')));
7591        assert_eq!(app.state.time_period, TimePeriod::Day1);
7592
7593        // t = cycle time period
7594        app.handle_key_event(make_key_event(KeyCode::Char('t')));
7595        assert_eq!(app.state.time_period, TimePeriod::Min1); // wraps from Day1
7596
7597        // c = toggle chart mode
7598        app.handle_key_event(make_key_event(KeyCode::Char('c')));
7599        assert_eq!(app.state.chart_mode, ChartMode::Candlestick);
7600
7601        // s = toggle scale
7602        app.handle_key_event(make_key_event(KeyCode::Char('s')));
7603        assert_eq!(app.state.scale_mode, ScaleMode::Log);
7604
7605        // / = cycle color scheme
7606        app.handle_key_event(make_key_event(KeyCode::Char('/')));
7607        assert_eq!(app.state.color_scheme, ColorScheme::BlueOrange);
7608
7609        // j = scroll log down
7610        app.handle_key_event(make_key_event(KeyCode::Char('j')));
7611
7612        // k = scroll log up
7613        app.handle_key_event(make_key_event(KeyCode::Char('k')));
7614
7615        // l = next layout
7616        app.handle_key_event(make_key_event(KeyCode::Char('l')));
7617        assert!(!app.state.auto_layout);
7618
7619        // h = prev layout
7620        app.handle_key_event(make_key_event(KeyCode::Char('h')));
7621
7622        // a = re-enable auto layout
7623        app.handle_key_event(make_key_event(KeyCode::Char('a')));
7624        assert!(app.state.auto_layout);
7625
7626        // w = widget toggle mode
7627        app.handle_key_event(make_key_event(KeyCode::Char('w')));
7628        assert!(app.state.widget_toggle_mode);
7629        // Cancel it
7630        app.handle_key_event(make_key_event(KeyCode::Char('z')));
7631
7632        // Unknown key is a no-op
7633        app.handle_key_event(make_key_event(KeyCode::F(12)));
7634        assert!(!app.should_exit);
7635    }
7636
7637    // ========================================================================
7638    // Integration tests: MonitorApp::fetch_data
7639    // ========================================================================
7640
7641    #[tokio::test]
7642    async fn test_app_fetch_data_success() {
7643        let data = create_test_token_data();
7644        let initial_price = data.price_usd;
7645        let mut updated = data.clone();
7646        updated.price_usd = 2.5;
7647        let mut app = create_test_app(Box::new(MockDexDataSource::new(updated)), None);
7648
7649        assert!((app.state.current_price - initial_price).abs() < 0.001);
7650        app.fetch_data().await;
7651        assert!((app.state.current_price - 2.5).abs() < 0.001);
7652        assert!(app.state.error_message.is_none());
7653    }
7654
7655    #[tokio::test]
7656    async fn test_app_fetch_data_api_error() {
7657        let mut app = create_test_app(Box::new(MockDexDataSource::failing("rate limited")), None);
7658
7659        app.fetch_data().await;
7660        assert!(app.state.error_message.is_some());
7661        assert!(
7662            app.state
7663                .error_message
7664                .as_ref()
7665                .unwrap()
7666                .contains("API Error")
7667        );
7668    }
7669
7670    #[tokio::test]
7671    async fn test_app_fetch_data_holder_count_on_12th_tick() {
7672        let data = create_test_token_data();
7673        let mock_chain = MockChainClient::new(42_000);
7674        let mut app = create_test_app(
7675            Box::new(MockDexDataSource::new(data)),
7676            Some(Box::new(mock_chain)),
7677        );
7678
7679        // First 11 fetches should not update holder count
7680        for _ in 0..11 {
7681            app.fetch_data().await;
7682        }
7683        assert!(app.state.holder_count.is_none());
7684
7685        // 12th fetch triggers holder count lookup
7686        app.fetch_data().await;
7687        assert_eq!(app.state.holder_count, Some(42_000));
7688    }
7689
7690    #[tokio::test]
7691    async fn test_app_fetch_data_holder_count_zero_not_stored() {
7692        let data = create_test_token_data();
7693        let mock_chain = MockChainClient::new(0); // returns zero
7694        let mut app = create_test_app(
7695            Box::new(MockDexDataSource::new(data)),
7696            Some(Box::new(mock_chain)),
7697        );
7698
7699        // Skip to 12th tick
7700        app.state.holder_fetch_counter = 11;
7701        app.fetch_data().await;
7702        // Zero holder count should NOT be stored
7703        assert!(app.state.holder_count.is_none());
7704    }
7705
7706    #[tokio::test]
7707    async fn test_app_fetch_data_no_chain_client_skips_holders() {
7708        let data = create_test_token_data();
7709        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7710
7711        // Skip to 12th tick
7712        app.state.holder_fetch_counter = 11;
7713        app.fetch_data().await;
7714        // Without a chain client, holder count stays None
7715        assert!(app.state.holder_count.is_none());
7716    }
7717
7718    #[tokio::test]
7719    async fn test_app_fetch_data_preserves_holder_on_subsequent_failure() {
7720        let data = create_test_token_data();
7721        let mock_chain = MockChainClient::new(42_000);
7722        let mut app = create_test_app(
7723            Box::new(MockDexDataSource::new(data)),
7724            Some(Box::new(mock_chain)),
7725        );
7726
7727        // Fetch holder count on 12th tick
7728        app.state.holder_fetch_counter = 11;
7729        app.fetch_data().await;
7730        assert_eq!(app.state.holder_count, Some(42_000));
7731
7732        // Replace chain client with one returning 0
7733        app.chain_client = Some(Box::new(MockChainClient::new(0)));
7734        // 24th tick
7735        app.state.holder_fetch_counter = 23;
7736        app.fetch_data().await;
7737        // Previous value should be preserved (zero is ignored)
7738        assert_eq!(app.state.holder_count, Some(42_000));
7739    }
7740
7741    // ========================================================================
7742    // Integration tests: MonitorApp::cleanup
7743    // ========================================================================
7744
7745    #[test]
7746    fn test_app_cleanup_does_not_panic_test_backend() {
7747        let data = create_test_token_data();
7748        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7749        // cleanup() with owns_terminal=false should not attempt terminal restore
7750        let result = app.cleanup();
7751        assert!(result.is_ok());
7752    }
7753
7754    // ========================================================================
7755    // Integration tests: MonitorApp draw renders without panic
7756    // ========================================================================
7757
7758    #[test]
7759    fn test_app_draw_renders_ui() {
7760        let data = create_test_token_data();
7761        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7762        // Verify we can render UI through the MonitorApp terminal
7763        app.terminal
7764            .draw(|f| ui(f, &mut app.state))
7765            .expect("should render without panic");
7766    }
7767
7768    // ========================================================================
7769    // Integration tests: select_token_impl
7770    // ========================================================================
7771
7772    fn make_search_results() -> Vec<TokenSearchResult> {
7773        vec![
7774            TokenSearchResult {
7775                address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
7776                symbol: "USDC".to_string(),
7777                name: "USD Coin".to_string(),
7778                chain: "ethereum".to_string(),
7779                price_usd: Some(1.0),
7780                volume_24h: 5_000_000_000.0,
7781                liquidity_usd: 2_000_000_000.0,
7782                market_cap: Some(32_000_000_000.0),
7783            },
7784            TokenSearchResult {
7785                address: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(),
7786                symbol: "USDT".to_string(),
7787                name: "Tether USD".to_string(),
7788                chain: "ethereum".to_string(),
7789                price_usd: Some(1.0),
7790                volume_24h: 6_000_000_000.0,
7791                liquidity_usd: 3_000_000_000.0,
7792                market_cap: Some(83_000_000_000.0),
7793            },
7794            TokenSearchResult {
7795                address: "0x6B175474E89094C44Da98b954EedeAC495271d0F".to_string(),
7796                symbol: "DAI".to_string(),
7797                name: "Dai Stablecoin".to_string(),
7798                chain: "ethereum".to_string(),
7799                price_usd: Some(1.0),
7800                volume_24h: 200_000_000.0,
7801                liquidity_usd: 500_000_000.0,
7802                market_cap: Some(5_000_000_000.0),
7803            },
7804        ]
7805    }
7806
7807    #[test]
7808    fn test_select_token_impl_valid_first() {
7809        let results = make_search_results();
7810        let mut reader = io::Cursor::new(b"1\n");
7811        let mut writer = Vec::new();
7812        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
7813        assert_eq!(selected.symbol, "USDC");
7814        assert_eq!(selected.address, results[0].address);
7815        let output = String::from_utf8(writer).unwrap();
7816        assert!(output.contains("Found 3 tokens"));
7817        assert!(output.contains("Selected: USDC"));
7818    }
7819
7820    #[test]
7821    fn test_select_token_impl_valid_last() {
7822        let results = make_search_results();
7823        let mut reader = io::Cursor::new(b"3\n");
7824        let mut writer = Vec::new();
7825        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
7826        assert_eq!(selected.symbol, "DAI");
7827    }
7828
7829    #[test]
7830    fn test_select_token_impl_valid_middle() {
7831        let results = make_search_results();
7832        let mut reader = io::Cursor::new(b"2\n");
7833        let mut writer = Vec::new();
7834        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
7835        assert_eq!(selected.symbol, "USDT");
7836    }
7837
7838    #[test]
7839    fn test_select_token_impl_out_of_bounds_zero() {
7840        let results = make_search_results();
7841        let mut reader = io::Cursor::new(b"0\n");
7842        let mut writer = Vec::new();
7843        let result = select_token_impl(&results, &mut reader, &mut writer);
7844        assert!(result.is_err());
7845        let err = result.unwrap_err().to_string();
7846        assert!(err.contains("Selection must be between 1 and 3"));
7847    }
7848
7849    #[test]
7850    fn test_select_token_impl_out_of_bounds_high() {
7851        let results = make_search_results();
7852        let mut reader = io::Cursor::new(b"99\n");
7853        let mut writer = Vec::new();
7854        let result = select_token_impl(&results, &mut reader, &mut writer);
7855        assert!(result.is_err());
7856    }
7857
7858    #[test]
7859    fn test_select_token_impl_non_numeric_input() {
7860        let results = make_search_results();
7861        let mut reader = io::Cursor::new(b"abc\n");
7862        let mut writer = Vec::new();
7863        let result = select_token_impl(&results, &mut reader, &mut writer);
7864        assert!(result.is_err());
7865        let err = result.unwrap_err().to_string();
7866        assert!(err.contains("Invalid selection"));
7867    }
7868
7869    #[test]
7870    fn test_select_token_impl_empty_input() {
7871        let results = make_search_results();
7872        let mut reader = io::Cursor::new(b"\n");
7873        let mut writer = Vec::new();
7874        let result = select_token_impl(&results, &mut reader, &mut writer);
7875        assert!(result.is_err());
7876    }
7877
7878    #[test]
7879    fn test_select_token_impl_long_name_truncation() {
7880        let results = vec![TokenSearchResult {
7881            address: "0xABCDEF1234567890ABCDEF1234567890ABCDEF12".to_string(),
7882            symbol: "LONG".to_string(),
7883            name: "A Very Long Token Name That Exceeds Twenty Characters".to_string(),
7884            chain: "ethereum".to_string(),
7885            price_usd: None,
7886            volume_24h: 100.0,
7887            liquidity_usd: 50.0,
7888            market_cap: None,
7889        }];
7890        let mut reader = io::Cursor::new(b"1\n");
7891        let mut writer = Vec::new();
7892        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
7893        assert_eq!(selected.symbol, "LONG");
7894        let output = String::from_utf8(writer).unwrap();
7895        // Should have truncated name
7896        assert!(output.contains("A Very Long Token..."));
7897        // Should show N/A for price
7898        assert!(output.contains("N/A"));
7899    }
7900
7901    #[test]
7902    fn test_select_token_impl_output_format() {
7903        let results = make_search_results();
7904        let mut reader = io::Cursor::new(b"1\n");
7905        let mut writer = Vec::new();
7906        let _ = select_token_impl(&results, &mut reader, &mut writer).unwrap();
7907        let output = String::from_utf8(writer).unwrap();
7908
7909        // Verify table header
7910        assert!(output.contains("#"));
7911        assert!(output.contains("Symbol"));
7912        assert!(output.contains("Name"));
7913        assert!(output.contains("Address"));
7914        assert!(output.contains("Price"));
7915        assert!(output.contains("Liquidity"));
7916        // Verify separator line
7917        assert!(output.contains("─"));
7918        // Verify prompt
7919        assert!(output.contains("Select token (1-3):"));
7920    }
7921
7922    // ========================================================================
7923    // Integration tests: format_monitor_number
7924    // ========================================================================
7925
7926    #[test]
7927    fn test_format_monitor_number_billions() {
7928        assert_eq!(format_monitor_number(5_000_000_000.0), "$5.00B");
7929        assert_eq!(format_monitor_number(1_234_567_890.0), "$1.23B");
7930    }
7931
7932    #[test]
7933    fn test_format_monitor_number_millions() {
7934        assert_eq!(format_monitor_number(5_000_000.0), "$5.00M");
7935        assert_eq!(format_monitor_number(42_500_000.0), "$42.50M");
7936    }
7937
7938    #[test]
7939    fn test_format_monitor_number_thousands() {
7940        assert_eq!(format_monitor_number(5_000.0), "$5.00K");
7941        assert_eq!(format_monitor_number(999_999.0), "$1000.00K");
7942    }
7943
7944    #[test]
7945    fn test_format_monitor_number_small() {
7946        assert_eq!(format_monitor_number(42.0), "$42.00");
7947        assert_eq!(format_monitor_number(0.5), "$0.50");
7948        assert_eq!(format_monitor_number(0.0), "$0.00");
7949    }
7950
7951    // ========================================================================
7952    // Integration tests: abbreviate_address edge cases
7953    // ========================================================================
7954
7955    #[test]
7956    fn test_abbreviate_address_exactly_16_chars() {
7957        let addr = "0123456789ABCDEF"; // exactly 16 chars
7958        assert_eq!(abbreviate_address(addr), addr);
7959    }
7960
7961    #[test]
7962    fn test_abbreviate_address_17_chars() {
7963        let addr = "0123456789ABCDEFG"; // 17 chars -> abbreviated
7964        assert_eq!(abbreviate_address(addr), "01234567...BCDEFG");
7965    }
7966
7967    // ========================================================================
7968    // Integration tests: MonitorApp with state + fetch combined scenario
7969    // ========================================================================
7970
7971    #[tokio::test]
7972    async fn test_app_full_scenario_fetch_render_quit() {
7973        let data = create_test_token_data();
7974        let mut updated = data.clone();
7975        updated.price_usd = 3.0;
7976        let mock_chain = MockChainClient::new(10_000);
7977        let state = MonitorState::new(&data, "ethereum");
7978        let mut app = create_test_app_with_state(
7979            state,
7980            Box::new(MockDexDataSource::new(updated)),
7981            Some(Box::new(mock_chain)),
7982        );
7983
7984        // 1. Fetch new data
7985        app.fetch_data().await;
7986        assert!((app.state.current_price - 3.0).abs() < 0.001);
7987
7988        // 2. Render UI
7989        app.terminal
7990            .draw(|f| ui(f, &mut app.state))
7991            .expect("render");
7992
7993        // 3. Start export
7994        app.handle_key_event(make_key_event(KeyCode::Char('e')));
7995        assert!(app.state.export_active);
7996
7997        // 4. Quit (should stop export)
7998        app.handle_key_event(make_key_event(KeyCode::Char('q')));
7999        assert!(app.should_exit);
8000        assert!(!app.state.export_active);
8001    }
8002
8003    #[tokio::test]
8004    async fn test_app_fetch_data_error_then_recovery() {
8005        let mut app = create_test_app(Box::new(MockDexDataSource::failing("server down")), None);
8006
8007        // First fetch fails
8008        app.fetch_data().await;
8009        assert!(app.state.error_message.is_some());
8010
8011        // Replace with working mock
8012        let mut recovered = create_test_token_data();
8013        recovered.price_usd = 5.0;
8014        app.dex_client = Box::new(MockDexDataSource::new(recovered));
8015
8016        // Second fetch succeeds
8017        app.fetch_data().await;
8018        assert!((app.state.current_price - 5.0).abs() < 0.001);
8019        // Error message is cleared by state.update()
8020    }
8021
8022    // ========================================================================
8023    // Integration tests: MonitorArgs parsing and run_direct config merging
8024    // ========================================================================
8025
8026    #[test]
8027    fn test_monitor_args_defaults() {
8028        use super::super::Cli;
8029        use clap::Parser;
8030        // Simulate: scope monitor USDC
8031        let cli = Cli::try_parse_from(["scope", "monitor", "USDC"]).unwrap();
8032        if let super::super::Commands::Monitor(args) = cli.command {
8033            assert_eq!(args.token, "USDC");
8034            assert_eq!(args.chain, "ethereum");
8035            assert!(args.layout.is_none());
8036            assert!(args.refresh.is_none());
8037            assert!(args.scale.is_none());
8038            assert!(args.color_scheme.is_none());
8039            assert!(args.export.is_none());
8040        } else {
8041            panic!("Expected Monitor command");
8042        }
8043    }
8044
8045    #[test]
8046    fn test_monitor_args_all_flags() {
8047        use super::super::Cli;
8048        use clap::Parser;
8049        let cli = Cli::try_parse_from([
8050            "scope",
8051            "monitor",
8052            "PEPE",
8053            "--chain",
8054            "solana",
8055            "--layout",
8056            "feed",
8057            "--refresh",
8058            "2",
8059            "--scale",
8060            "log",
8061            "--color-scheme",
8062            "monochrome",
8063            "--export",
8064            "/tmp/data.csv",
8065        ])
8066        .unwrap();
8067        if let super::super::Commands::Monitor(args) = cli.command {
8068            assert_eq!(args.token, "PEPE");
8069            assert_eq!(args.chain, "solana");
8070            assert_eq!(args.layout, Some(LayoutPreset::Feed));
8071            assert_eq!(args.refresh, Some(2));
8072            assert_eq!(args.scale, Some(ScaleMode::Log));
8073            assert_eq!(args.color_scheme, Some(ColorScheme::Monochrome));
8074            assert_eq!(args.export, Some(PathBuf::from("/tmp/data.csv")));
8075        } else {
8076            panic!("Expected Monitor command");
8077        }
8078    }
8079
8080    #[test]
8081    fn test_run_direct_config_override_layout() {
8082        // Verify that run_direct properly applies CLI overrides to config
8083        let config = Config::default();
8084        assert_eq!(config.monitor.layout, LayoutPreset::Dashboard);
8085
8086        let args = MonitorArgs {
8087            token: "USDC".to_string(),
8088            chain: "ethereum".to_string(),
8089            layout: Some(LayoutPreset::ChartFocus),
8090            refresh: None,
8091            scale: None,
8092            color_scheme: None,
8093            export: None,
8094        };
8095
8096        // Build the effective config the same way run_direct does
8097        let mut monitor_config = config.monitor.clone();
8098        if let Some(layout) = args.layout {
8099            monitor_config.layout = layout;
8100        }
8101        assert_eq!(monitor_config.layout, LayoutPreset::ChartFocus);
8102    }
8103
8104    #[test]
8105    fn test_run_direct_config_override_all_fields() {
8106        let config = Config::default();
8107        let args = MonitorArgs {
8108            token: "PEPE".to_string(),
8109            chain: "solana".to_string(),
8110            layout: Some(LayoutPreset::Compact),
8111            refresh: Some(2),
8112            scale: Some(ScaleMode::Log),
8113            color_scheme: Some(ColorScheme::BlueOrange),
8114            export: Some(PathBuf::from("/tmp/test.csv")),
8115        };
8116
8117        let mut mc = config.monitor.clone();
8118        if let Some(layout) = args.layout {
8119            mc.layout = layout;
8120        }
8121        if let Some(refresh) = args.refresh {
8122            mc.refresh_seconds = refresh;
8123        }
8124        if let Some(scale) = args.scale {
8125            mc.scale = scale;
8126        }
8127        if let Some(color_scheme) = args.color_scheme {
8128            mc.color_scheme = color_scheme;
8129        }
8130        if let Some(ref path) = args.export {
8131            mc.export.path = Some(path.to_string_lossy().into_owned());
8132        }
8133
8134        assert_eq!(mc.layout, LayoutPreset::Compact);
8135        assert_eq!(mc.refresh_seconds, 2);
8136        assert_eq!(mc.scale, ScaleMode::Log);
8137        assert_eq!(mc.color_scheme, ColorScheme::BlueOrange);
8138        assert_eq!(mc.export.path, Some("/tmp/test.csv".to_string()));
8139    }
8140
8141    #[test]
8142    fn test_run_direct_config_no_overrides_preserves_defaults() {
8143        let config = Config::default();
8144        let args = MonitorArgs {
8145            token: "USDC".to_string(),
8146            chain: "ethereum".to_string(),
8147            layout: None,
8148            refresh: None,
8149            scale: None,
8150            color_scheme: None,
8151            export: None,
8152        };
8153
8154        let mut mc = config.monitor.clone();
8155        if let Some(layout) = args.layout {
8156            mc.layout = layout;
8157        }
8158        if let Some(refresh) = args.refresh {
8159            mc.refresh_seconds = refresh;
8160        }
8161        if let Some(scale) = args.scale {
8162            mc.scale = scale;
8163        }
8164        if let Some(color_scheme) = args.color_scheme {
8165            mc.color_scheme = color_scheme;
8166        }
8167
8168        // All should remain at defaults
8169        assert_eq!(mc.layout, LayoutPreset::Dashboard);
8170        assert_eq!(mc.refresh_seconds, DEFAULT_REFRESH_SECS);
8171        assert_eq!(mc.scale, ScaleMode::Linear);
8172        assert_eq!(mc.color_scheme, ColorScheme::GreenRed);
8173        assert!(mc.export.path.is_none());
8174    }
8175}