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                        crate::display::format_usd(avg_tx_size),
1223                        crate::display::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 = crate::display::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!(" {} {}", crate::display::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(crate::display::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(crate::display::format_usd(state.liquidity_usd)),
3118        ]),
3119        Row::new(vec![
3120            Span::styled("Vol 24h", Style::new().gray()),
3121            Span::raw(crate::display::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/// Entry point for the top-level `scope monitor` command.
3257///
3258/// Creates a `SessionContext` from CLI args and delegates to [`run`].
3259/// Applies CLI-provided overrides (layout, refresh, scale, etc.) on top
3260/// of the config-file defaults.
3261pub async fn run_direct(
3262    args: MonitorArgs,
3263    config: &Config,
3264    clients: &dyn ChainClientFactory,
3265) -> Result<()> {
3266    // Build a SessionContext from the CLI args (no interactive session needed)
3267    let ctx = SessionContext {
3268        chain: args.chain,
3269        chain_explicit: true,
3270        ..SessionContext::default()
3271    };
3272
3273    // Build a MonitorConfig from config-file defaults + CLI overrides
3274    let mut monitor_config = config.monitor.clone();
3275    if let Some(layout) = args.layout {
3276        monitor_config.layout = layout;
3277    }
3278    if let Some(refresh) = args.refresh {
3279        monitor_config.refresh_seconds = refresh;
3280    }
3281    if let Some(scale) = args.scale {
3282        monitor_config.scale = scale;
3283    }
3284    if let Some(color_scheme) = args.color_scheme {
3285        monitor_config.color_scheme = color_scheme;
3286    }
3287    if let Some(ref path) = args.export {
3288        monitor_config.export.path = Some(path.to_string_lossy().into_owned());
3289    }
3290
3291    // Use a temporary Config with the CLI-overridden monitor settings
3292    let mut effective_config = config.clone();
3293    effective_config.monitor = monitor_config;
3294
3295    run(Some(args.token), &ctx, &effective_config, clients).await
3296}
3297
3298/// Entry point for the monitor command from interactive mode.
3299pub async fn run(
3300    token: Option<String>,
3301    ctx: &SessionContext,
3302    config: &Config,
3303    clients: &dyn ChainClientFactory,
3304) -> Result<()> {
3305    let token_input = match token {
3306        Some(t) => t,
3307        None => {
3308            return Err(ScopeError::Chain(
3309                "Token address or symbol required. Usage: monitor <token>".to_string(),
3310            ));
3311        }
3312    };
3313
3314    println!("Starting live monitor for {}...", token_input);
3315    println!("Fetching initial data...");
3316
3317    // Resolve token address
3318    let dex_client = clients.create_dex_client();
3319    let token_address =
3320        resolve_token_address(&token_input, &ctx.chain, config, dex_client.as_ref()).await?;
3321
3322    // Fetch initial data
3323    let initial_data = dex_client
3324        .get_token_data(&ctx.chain, &token_address)
3325        .await?;
3326
3327    println!(
3328        "Monitoring {} ({}) on {}",
3329        initial_data.symbol, initial_data.name, ctx.chain
3330    );
3331    println!("Press Q to quit, R to refresh, P to pause...\n");
3332
3333    // Small delay to let user read the message
3334    tokio::time::sleep(Duration::from_millis(500)).await;
3335
3336    // Create optional chain client for on-chain data (holder count, etc.)
3337    let chain_client = clients.create_chain_client(&ctx.chain).ok();
3338
3339    // Create and run the app
3340    let mut app = MonitorApp::new(initial_data, &ctx.chain, &config.monitor, chain_client)?;
3341    let result = app.run().await;
3342
3343    // Cleanup is handled by Drop, but we do it explicitly for error handling
3344    if let Err(e) = app.cleanup() {
3345        eprintln!("Warning: Failed to cleanup terminal: {}", e);
3346    }
3347
3348    result
3349}
3350
3351/// Resolves a token input (address or symbol) to an address.
3352async fn resolve_token_address(
3353    input: &str,
3354    chain: &str,
3355    _config: &Config,
3356    dex_client: &dyn DexDataSource,
3357) -> Result<String> {
3358    // Check if it's already an address (EVM, Solana, Tron)
3359    if crate::tokens::TokenAliases::is_address(input) {
3360        return Ok(input.to_string());
3361    }
3362
3363    // Check saved aliases
3364    let aliases = crate::tokens::TokenAliases::load();
3365    if let Some(alias) = aliases.get(input, Some(chain)) {
3366        return Ok(alias.address.clone());
3367    }
3368
3369    // Search by name/symbol
3370    let results = dex_client.search_tokens(input, Some(chain)).await?;
3371
3372    if results.is_empty() {
3373        return Err(ScopeError::NotFound(format!(
3374            "No token found matching '{}' on {}",
3375            input, chain
3376        )));
3377    }
3378
3379    // If only one result, use it directly
3380    if results.len() == 1 {
3381        let token = &results[0];
3382        println!(
3383            "Found: {} ({}) - ${:.6}",
3384            token.symbol,
3385            token.name,
3386            token.price_usd.unwrap_or(0.0)
3387        );
3388        return Ok(token.address.clone());
3389    }
3390
3391    // Multiple results — prompt user to select
3392    let selected = select_token_interactive(&results)?;
3393    Ok(selected.address.clone())
3394}
3395
3396/// Abbreviates a blockchain address for display (e.g. "0x1234...abcd").
3397fn abbreviate_address(addr: &str) -> String {
3398    if addr.len() > 16 {
3399        format!("{}...{}", &addr[..8], &addr[addr.len() - 6..])
3400    } else {
3401        addr.to_string()
3402    }
3403}
3404
3405/// Displays token search results and prompts the user to select one.
3406fn select_token_interactive(
3407    results: &[crate::chains::dex::TokenSearchResult],
3408) -> Result<&crate::chains::dex::TokenSearchResult> {
3409    let stdin = io::stdin();
3410    let stdout = io::stdout();
3411    select_token_impl(results, &mut stdin.lock(), &mut stdout.lock())
3412}
3413
3414/// Testable implementation of token selection with injected I/O.
3415fn select_token_impl<'a>(
3416    results: &'a [crate::chains::dex::TokenSearchResult],
3417    reader: &mut impl io::BufRead,
3418    writer: &mut impl io::Write,
3419) -> Result<&'a crate::chains::dex::TokenSearchResult> {
3420    writeln!(
3421        writer,
3422        "\nFound {} tokens matching your query:\n",
3423        results.len()
3424    )
3425    .map_err(|e| ScopeError::Io(e.to_string()))?;
3426
3427    writeln!(
3428        writer,
3429        "{:>3}  {:>8}  {:<22}  {:<16}  {:>12}  {:>12}",
3430        "#", "Symbol", "Name", "Address", "Price", "Liquidity"
3431    )
3432    .map_err(|e| ScopeError::Io(e.to_string()))?;
3433
3434    writeln!(writer, "{}", "─".repeat(82)).map_err(|e| ScopeError::Io(e.to_string()))?;
3435
3436    for (i, token) in results.iter().enumerate() {
3437        let price = token
3438            .price_usd
3439            .map(|p| format!("${:.6}", p))
3440            .unwrap_or_else(|| "N/A".to_string());
3441
3442        let liquidity = format_monitor_number(token.liquidity_usd);
3443        let addr = abbreviate_address(&token.address);
3444
3445        // Truncate name if too long
3446        let name = if token.name.len() > 20 {
3447            format!("{}...", &token.name[..17])
3448        } else {
3449            token.name.clone()
3450        };
3451
3452        writeln!(
3453            writer,
3454            "{:>3}  {:>8}  {:<22}  {:<16}  {:>12}  {:>12}",
3455            i + 1,
3456            token.symbol,
3457            name,
3458            addr,
3459            price,
3460            liquidity
3461        )
3462        .map_err(|e| ScopeError::Io(e.to_string()))?;
3463    }
3464
3465    writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
3466    write!(writer, "Select token (1-{}): ", results.len())
3467        .map_err(|e| ScopeError::Io(e.to_string()))?;
3468    writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
3469
3470    let mut input = String::new();
3471    reader
3472        .read_line(&mut input)
3473        .map_err(|e| ScopeError::Io(e.to_string()))?;
3474
3475    let selection: usize = input
3476        .trim()
3477        .parse()
3478        .map_err(|_| ScopeError::Api("Invalid selection".to_string()))?;
3479
3480    if selection < 1 || selection > results.len() {
3481        return Err(ScopeError::Api(format!(
3482            "Selection must be between 1 and {}",
3483            results.len()
3484        )));
3485    }
3486
3487    let selected = &results[selection - 1];
3488    writeln!(
3489        writer,
3490        "Selected: {} ({}) at {}",
3491        selected.symbol, selected.name, selected.address
3492    )
3493    .map_err(|e| ScopeError::Io(e.to_string()))?;
3494
3495    Ok(selected)
3496}
3497
3498/// Format a number for the monitor selection table.
3499fn format_monitor_number(value: f64) -> String {
3500    if value >= 1_000_000_000.0 {
3501        format!("${:.2}B", value / 1_000_000_000.0)
3502    } else if value >= 1_000_000.0 {
3503        format!("${:.2}M", value / 1_000_000.0)
3504    } else if value >= 1_000.0 {
3505        format!("${:.2}K", value / 1_000.0)
3506    } else {
3507        format!("${:.2}", value)
3508    }
3509}
3510
3511// ============================================================================
3512// Unit Tests
3513// ============================================================================
3514
3515#[cfg(test)]
3516mod tests {
3517    use super::*;
3518
3519    fn create_test_token_data() -> DexTokenData {
3520        DexTokenData {
3521            address: "0x1234".to_string(),
3522            symbol: "TEST".to_string(),
3523            name: "Test Token".to_string(),
3524            price_usd: 1.0,
3525            price_change_24h: 5.0,
3526            price_change_6h: 2.0,
3527            price_change_1h: 0.5,
3528            price_change_5m: 0.1,
3529            volume_24h: 1_000_000.0,
3530            volume_6h: 250_000.0,
3531            volume_1h: 50_000.0,
3532            liquidity_usd: 500_000.0,
3533            market_cap: Some(10_000_000.0),
3534            fdv: Some(100_000_000.0),
3535            pairs: vec![],
3536            price_history: vec![],
3537            volume_history: vec![],
3538            total_buys_24h: 100,
3539            total_sells_24h: 50,
3540            total_buys_6h: 25,
3541            total_sells_6h: 12,
3542            total_buys_1h: 5,
3543            total_sells_1h: 3,
3544            earliest_pair_created_at: Some(1700000000000),
3545            image_url: None,
3546            websites: vec![],
3547            socials: vec![],
3548            dexscreener_url: None,
3549        }
3550    }
3551
3552    #[test]
3553    fn test_monitor_state_new() {
3554        let token_data = create_test_token_data();
3555        let state = MonitorState::new(&token_data, "ethereum");
3556
3557        assert_eq!(state.symbol, "TEST");
3558        assert_eq!(state.chain, "ethereum");
3559        assert_eq!(state.current_price, 1.0);
3560        assert_eq!(state.buys_24h, 100);
3561        assert_eq!(state.sells_24h, 50);
3562        assert!(!state.paused);
3563    }
3564
3565    #[test]
3566    fn test_monitor_state_buy_ratio() {
3567        let token_data = create_test_token_data();
3568        let state = MonitorState::new(&token_data, "ethereum");
3569
3570        let ratio = state.buy_ratio();
3571        assert!((ratio - 0.6666).abs() < 0.01); // 100 / 150 ≈ 0.667
3572    }
3573
3574    #[test]
3575    fn test_monitor_state_buy_ratio_zero() {
3576        let mut token_data = create_test_token_data();
3577        token_data.total_buys_24h = 0;
3578        token_data.total_sells_24h = 0;
3579        let state = MonitorState::new(&token_data, "ethereum");
3580
3581        assert_eq!(state.buy_ratio(), 0.5); // Default to 50/50 when no data
3582    }
3583
3584    #[test]
3585    fn test_monitor_state_toggle_pause() {
3586        let token_data = create_test_token_data();
3587        let mut state = MonitorState::new(&token_data, "ethereum");
3588
3589        assert!(!state.paused);
3590        state.toggle_pause();
3591        assert!(state.paused);
3592        state.toggle_pause();
3593        assert!(!state.paused);
3594    }
3595
3596    #[test]
3597    fn test_monitor_state_should_refresh() {
3598        let token_data = create_test_token_data();
3599        let mut state = MonitorState::new(&token_data, "ethereum");
3600        state.refresh_rate = Duration::from_secs(60);
3601
3602        // Just created, should not need refresh (60s refresh rate)
3603        assert!(!state.should_refresh());
3604
3605        // Simulate time passing well beyond refresh rate
3606        state.last_update = Instant::now() - Duration::from_secs(120);
3607        assert!(state.should_refresh());
3608
3609        // Pause should prevent refresh
3610        state.paused = true;
3611        assert!(!state.should_refresh());
3612    }
3613
3614    #[test]
3615    fn test_format_number() {
3616        assert_eq!(format_number(500.0), "500.00");
3617        assert_eq!(format_number(1_500.0), "1.50K");
3618        assert_eq!(format_number(1_500_000.0), "1.50M");
3619        assert_eq!(format_number(1_500_000_000.0), "1.50B");
3620    }
3621
3622    #[test]
3623    fn test_format_usd() {
3624        assert_eq!(crate::display::format_usd(500.0), "$500.00");
3625        assert_eq!(crate::display::format_usd(1_500.0), "$1.50K");
3626        assert_eq!(crate::display::format_usd(1_500_000.0), "$1.50M");
3627        assert_eq!(crate::display::format_usd(1_500_000_000.0), "$1.50B");
3628    }
3629
3630    #[test]
3631    fn test_monitor_state_update() {
3632        let token_data = create_test_token_data();
3633        let mut state = MonitorState::new(&token_data, "ethereum");
3634
3635        let initial_len = state.price_history.len();
3636
3637        let mut updated_data = token_data.clone();
3638        updated_data.price_usd = 1.5;
3639        updated_data.total_buys_24h = 150;
3640
3641        state.update(&updated_data);
3642
3643        assert_eq!(state.current_price, 1.5);
3644        assert_eq!(state.buys_24h, 150);
3645        // Should have one more point after update
3646        assert_eq!(state.price_history.len(), initial_len + 1);
3647    }
3648
3649    #[test]
3650    fn test_monitor_state_refresh_rate_adjustment() {
3651        let token_data = create_test_token_data();
3652        let mut state = MonitorState::new(&token_data, "ethereum");
3653
3654        // Default is 5 seconds
3655        assert_eq!(state.refresh_rate_secs(), 5);
3656
3657        // Slow down (+5s)
3658        state.slower_refresh();
3659        assert_eq!(state.refresh_rate_secs(), 10);
3660
3661        // Speed up (-5s)
3662        state.faster_refresh();
3663        assert_eq!(state.refresh_rate_secs(), 5);
3664
3665        // Speed up again (should hit minimum of 1s)
3666        state.faster_refresh();
3667        assert_eq!(state.refresh_rate_secs(), 1);
3668
3669        // Can't go below 1s
3670        state.faster_refresh();
3671        assert_eq!(state.refresh_rate_secs(), 1);
3672
3673        // Slow down to max (60s)
3674        for _ in 0..20 {
3675            state.slower_refresh();
3676        }
3677        assert_eq!(state.refresh_rate_secs(), 60);
3678    }
3679
3680    #[test]
3681    fn test_time_period() {
3682        assert_eq!(TimePeriod::Min1.label(), "1m");
3683        assert_eq!(TimePeriod::Min5.label(), "5m");
3684        assert_eq!(TimePeriod::Min15.label(), "15m");
3685        assert_eq!(TimePeriod::Hour1.label(), "1h");
3686        assert_eq!(TimePeriod::Hour4.label(), "4h");
3687        assert_eq!(TimePeriod::Day1.label(), "1d");
3688
3689        assert_eq!(TimePeriod::Min1.duration_secs(), 60);
3690        assert_eq!(TimePeriod::Min5.duration_secs(), 300);
3691        assert_eq!(TimePeriod::Min15.duration_secs(), 15 * 60);
3692        assert_eq!(TimePeriod::Hour1.duration_secs(), 3600);
3693        assert_eq!(TimePeriod::Hour4.duration_secs(), 4 * 3600);
3694        assert_eq!(TimePeriod::Day1.duration_secs(), 24 * 3600);
3695
3696        // Test cycling
3697        assert_eq!(TimePeriod::Min1.next(), TimePeriod::Min5);
3698        assert_eq!(TimePeriod::Min5.next(), TimePeriod::Min15);
3699        assert_eq!(TimePeriod::Min15.next(), TimePeriod::Hour1);
3700        assert_eq!(TimePeriod::Hour1.next(), TimePeriod::Hour4);
3701        assert_eq!(TimePeriod::Hour4.next(), TimePeriod::Day1);
3702        assert_eq!(TimePeriod::Day1.next(), TimePeriod::Min1);
3703    }
3704
3705    #[test]
3706    fn test_monitor_state_time_period() {
3707        let token_data = create_test_token_data();
3708        let mut state = MonitorState::new(&token_data, "ethereum");
3709
3710        // Default is 1 hour
3711        assert_eq!(state.time_period, TimePeriod::Hour1);
3712
3713        // Cycle through periods
3714        state.cycle_time_period();
3715        assert_eq!(state.time_period, TimePeriod::Hour4);
3716
3717        state.set_time_period(TimePeriod::Day1);
3718        assert_eq!(state.time_period, TimePeriod::Day1);
3719    }
3720
3721    #[test]
3722    fn test_synthetic_history_generation() {
3723        let token_data = create_test_token_data();
3724        let state = MonitorState::new(&token_data, "ethereum");
3725
3726        // Should have generated history (synthetic or cached real)
3727        assert!(state.price_history.len() > 1);
3728        assert!(state.volume_history.len() > 1);
3729
3730        // Price history should span some time range
3731        if let (Some(first), Some(last)) = (state.price_history.front(), state.price_history.back())
3732        {
3733            let span = last.timestamp - first.timestamp;
3734            assert!(span > 0.0); // History should span some time
3735        }
3736    }
3737
3738    #[test]
3739    fn test_real_data_marking() {
3740        let token_data = create_test_token_data();
3741        let mut state = MonitorState::new(&token_data, "ethereum");
3742
3743        // Initially all synthetic
3744        let (synthetic, real) = state.data_stats();
3745        assert!(synthetic > 0);
3746        assert_eq!(real, 0);
3747
3748        // After update, should have real data
3749        let mut updated_data = token_data.clone();
3750        updated_data.price_usd = 1.5;
3751        state.update(&updated_data);
3752
3753        let (synthetic2, real2) = state.data_stats();
3754        assert!(synthetic2 > 0);
3755        assert_eq!(real2, 1);
3756        assert_eq!(state.real_data_count, 1);
3757
3758        // The last point should be real
3759        assert!(
3760            state
3761                .price_history
3762                .back()
3763                .map(|p| p.is_real)
3764                .unwrap_or(false)
3765        );
3766    }
3767
3768    #[test]
3769    fn test_memory_usage() {
3770        let token_data = create_test_token_data();
3771        let state = MonitorState::new(&token_data, "ethereum");
3772
3773        let mem = state.memory_usage();
3774        // DataPoint is 24 bytes, should have some data points
3775        assert!(mem > 0);
3776
3777        // Each DataPoint is 24 bytes (f64 + f64 + bool + padding)
3778        let expected_point_size = std::mem::size_of::<DataPoint>();
3779        assert_eq!(expected_point_size, 24);
3780    }
3781
3782    #[test]
3783    fn test_get_data_for_period_returns_flags() {
3784        let token_data = create_test_token_data();
3785        let mut state = MonitorState::new(&token_data, "ethereum");
3786
3787        // Get initial data (may contain cached real data or synthetic)
3788        let (data, is_real) = state.get_price_data_for_period();
3789        assert_eq!(data.len(), is_real.len());
3790
3791        // Add real data point
3792        state.update(&token_data);
3793
3794        let (_data2, is_real2) = state.get_price_data_for_period();
3795        // Should have at least one real point now
3796        assert!(is_real2.iter().any(|r| *r));
3797    }
3798
3799    #[test]
3800    fn test_cache_path_generation() {
3801        let path =
3802            MonitorState::cache_path("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum");
3803        assert!(path.to_string_lossy().contains("bcc_monitor_"));
3804        assert!(path.to_string_lossy().contains("ethereum"));
3805        // Should be in temp directory
3806        let temp_dir = std::env::temp_dir();
3807        assert!(path.starts_with(temp_dir));
3808    }
3809
3810    #[test]
3811    fn test_cache_save_and_load() {
3812        let token_data = create_test_token_data();
3813        let mut state = MonitorState::new(&token_data, "test_chain");
3814
3815        // Add some real data
3816        state.update(&token_data);
3817        state.update(&token_data);
3818
3819        // Save cache
3820        state.save_cache();
3821
3822        // Verify cache file exists
3823        let path = MonitorState::cache_path(&state.token_address, &state.chain);
3824        assert!(path.exists(), "Cache file should exist after save");
3825
3826        // Load cache
3827        let loaded = MonitorState::load_cache(&state.token_address, &state.chain);
3828        assert!(loaded.is_some(), "Should be able to load saved cache");
3829
3830        let cached = loaded.unwrap();
3831        assert_eq!(cached.token_address, state.token_address);
3832        assert_eq!(cached.chain, state.chain);
3833        assert!(!cached.price_history.is_empty());
3834
3835        // Cleanup
3836        let _ = std::fs::remove_file(path);
3837    }
3838
3839    // ========================================================================
3840    // Price formatting tests
3841    // ========================================================================
3842
3843    #[test]
3844    fn test_format_price_usd_high() {
3845        let formatted = format_price_usd(2500.50);
3846        assert!(formatted.starts_with("$2500.50"));
3847    }
3848
3849    #[test]
3850    fn test_format_price_usd_stablecoin() {
3851        let formatted = format_price_usd(1.0001);
3852        assert!(formatted.contains("1.000100"));
3853        assert!(is_stablecoin_price(1.0001));
3854    }
3855
3856    #[test]
3857    fn test_format_price_usd_medium() {
3858        let formatted = format_price_usd(5.1234);
3859        assert!(formatted.starts_with("$5.1234"));
3860    }
3861
3862    #[test]
3863    fn test_format_price_usd_small() {
3864        let formatted = format_price_usd(0.05);
3865        assert!(formatted.starts_with("$0.0500"));
3866    }
3867
3868    #[test]
3869    fn test_format_price_usd_micro() {
3870        let formatted = format_price_usd(0.001);
3871        assert!(formatted.starts_with("$0.0010"));
3872    }
3873
3874    #[test]
3875    fn test_format_price_usd_nano() {
3876        let formatted = format_price_usd(0.00001);
3877        assert!(formatted.contains("0.0000100"));
3878    }
3879
3880    #[test]
3881    fn test_is_stablecoin_price() {
3882        assert!(is_stablecoin_price(1.0));
3883        assert!(is_stablecoin_price(0.999));
3884        assert!(is_stablecoin_price(1.001));
3885        assert!(is_stablecoin_price(0.95));
3886        assert!(is_stablecoin_price(1.05));
3887        assert!(!is_stablecoin_price(0.94));
3888        assert!(!is_stablecoin_price(1.06));
3889        assert!(!is_stablecoin_price(100.0));
3890    }
3891
3892    // ========================================================================
3893    // OHLC candle tests
3894    // ========================================================================
3895
3896    #[test]
3897    fn test_ohlc_candle_new() {
3898        let candle = OhlcCandle::new(1000.0, 50.0);
3899        assert_eq!(candle.open, 50.0);
3900        assert_eq!(candle.high, 50.0);
3901        assert_eq!(candle.low, 50.0);
3902        assert_eq!(candle.close, 50.0);
3903        assert!(candle.is_bullish);
3904    }
3905
3906    #[test]
3907    fn test_ohlc_candle_update() {
3908        let mut candle = OhlcCandle::new(1000.0, 50.0);
3909        candle.update(55.0);
3910        assert_eq!(candle.high, 55.0);
3911        assert_eq!(candle.close, 55.0);
3912        assert!(candle.is_bullish);
3913
3914        candle.update(45.0);
3915        assert_eq!(candle.low, 45.0);
3916        assert_eq!(candle.close, 45.0);
3917        assert!(!candle.is_bullish); // close < open
3918    }
3919
3920    #[test]
3921    fn test_get_ohlc_candles() {
3922        let token_data = create_test_token_data();
3923        let mut state = MonitorState::new(&token_data, "ethereum");
3924        // Add several data points
3925        for i in 0..20 {
3926            let mut data = token_data.clone();
3927            data.price_usd = 1.0 + (i as f64 * 0.01);
3928            state.update(&data);
3929        }
3930        let candles = state.get_ohlc_candles();
3931        // Should have some candles
3932        assert!(!candles.is_empty());
3933    }
3934
3935    // ========================================================================
3936    // ChartMode tests
3937    // ========================================================================
3938
3939    #[test]
3940    fn test_chart_mode_cycle() {
3941        let mode = ChartMode::Line;
3942        assert_eq!(mode.next(), ChartMode::Candlestick);
3943        assert_eq!(ChartMode::Candlestick.next(), ChartMode::VolumeProfile);
3944        assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
3945    }
3946
3947    #[test]
3948    fn test_chart_mode_label() {
3949        assert_eq!(ChartMode::Line.label(), "Line");
3950        assert_eq!(ChartMode::Candlestick.label(), "Candle");
3951        assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
3952    }
3953
3954    // ========================================================================
3955    // TUI rendering tests (headless TestBackend)
3956    // ========================================================================
3957
3958    use ratatui::Terminal;
3959    use ratatui::backend::TestBackend;
3960
3961    fn create_test_terminal() -> Terminal<TestBackend> {
3962        let backend = TestBackend::new(120, 40);
3963        Terminal::new(backend).unwrap()
3964    }
3965
3966    fn create_populated_state() -> MonitorState {
3967        let token_data = create_test_token_data();
3968        let mut state = MonitorState::new(&token_data, "ethereum");
3969        // Add real data points so charts have content
3970        for i in 0..30 {
3971            let mut data = token_data.clone();
3972            data.price_usd = 1.0 + (i as f64 * 0.01);
3973            data.volume_24h = 1_000_000.0 + (i as f64 * 10_000.0);
3974            state.update(&data);
3975        }
3976        state
3977    }
3978
3979    #[test]
3980    fn test_render_header_no_panic() {
3981        let mut terminal = create_test_terminal();
3982        let state = create_populated_state();
3983        terminal
3984            .draw(|f| render_header(f, f.area(), &state))
3985            .unwrap();
3986    }
3987
3988    #[test]
3989    fn test_render_price_chart_no_panic() {
3990        let mut terminal = create_test_terminal();
3991        let state = create_populated_state();
3992        terminal
3993            .draw(|f| render_price_chart(f, f.area(), &state))
3994            .unwrap();
3995    }
3996
3997    #[test]
3998    fn test_render_price_chart_line_mode() {
3999        let mut terminal = create_test_terminal();
4000        let mut state = create_populated_state();
4001        state.chart_mode = ChartMode::Line;
4002        terminal
4003            .draw(|f| render_price_chart(f, f.area(), &state))
4004            .unwrap();
4005    }
4006
4007    #[test]
4008    fn test_render_candlestick_chart_no_panic() {
4009        let mut terminal = create_test_terminal();
4010        let state = create_populated_state();
4011        terminal
4012            .draw(|f| render_candlestick_chart(f, f.area(), &state))
4013            .unwrap();
4014    }
4015
4016    #[test]
4017    fn test_render_candlestick_chart_empty() {
4018        let mut terminal = create_test_terminal();
4019        let token_data = create_test_token_data();
4020        let state = MonitorState::new(&token_data, "ethereum");
4021        terminal
4022            .draw(|f| render_candlestick_chart(f, f.area(), &state))
4023            .unwrap();
4024    }
4025
4026    #[test]
4027    fn test_render_volume_chart_no_panic() {
4028        let mut terminal = create_test_terminal();
4029        let state = create_populated_state();
4030        terminal
4031            .draw(|f| render_volume_chart(f, f.area(), &state))
4032            .unwrap();
4033    }
4034
4035    #[test]
4036    fn test_render_volume_chart_empty() {
4037        let mut terminal = create_test_terminal();
4038        let token_data = create_test_token_data();
4039        let state = MonitorState::new(&token_data, "ethereum");
4040        terminal
4041            .draw(|f| render_volume_chart(f, f.area(), &state))
4042            .unwrap();
4043    }
4044
4045    #[test]
4046    fn test_render_buy_sell_gauge_no_panic() {
4047        let mut terminal = create_test_terminal();
4048        let mut state = create_populated_state();
4049        terminal
4050            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
4051            .unwrap();
4052    }
4053
4054    #[test]
4055    fn test_render_buy_sell_gauge_balanced() {
4056        let mut terminal = create_test_terminal();
4057        let mut token_data = create_test_token_data();
4058        token_data.total_buys_24h = 100;
4059        token_data.total_sells_24h = 100;
4060        let mut state = MonitorState::new(&token_data, "ethereum");
4061        terminal
4062            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
4063            .unwrap();
4064    }
4065
4066    #[test]
4067    fn test_render_metrics_panel_no_panic() {
4068        let mut terminal = create_test_terminal();
4069        let state = create_populated_state();
4070        terminal
4071            .draw(|f| render_metrics_panel(f, f.area(), &state))
4072            .unwrap();
4073    }
4074
4075    #[test]
4076    fn test_render_metrics_panel_no_market_cap() {
4077        let mut terminal = create_test_terminal();
4078        let mut token_data = create_test_token_data();
4079        token_data.market_cap = None;
4080        token_data.fdv = None;
4081        let state = MonitorState::new(&token_data, "ethereum");
4082        terminal
4083            .draw(|f| render_metrics_panel(f, f.area(), &state))
4084            .unwrap();
4085    }
4086
4087    #[test]
4088    fn test_render_footer_no_panic() {
4089        let mut terminal = create_test_terminal();
4090        let state = create_populated_state();
4091        terminal
4092            .draw(|f| render_footer(f, f.area(), &state))
4093            .unwrap();
4094    }
4095
4096    #[test]
4097    fn test_render_footer_paused() {
4098        let mut terminal = create_test_terminal();
4099        let token_data = create_test_token_data();
4100        let mut state = MonitorState::new(&token_data, "ethereum");
4101        state.paused = true;
4102        terminal
4103            .draw(|f| render_footer(f, f.area(), &state))
4104            .unwrap();
4105    }
4106
4107    #[test]
4108    fn test_render_all_components() {
4109        // Exercise the full draw_ui layout path
4110        let mut terminal = create_test_terminal();
4111        let mut state = create_populated_state();
4112        terminal
4113            .draw(|f| {
4114                let area = f.area();
4115                let chunks = Layout::default()
4116                    .direction(Direction::Vertical)
4117                    .constraints([
4118                        Constraint::Length(3),
4119                        Constraint::Min(10),
4120                        Constraint::Length(5),
4121                        Constraint::Length(3),
4122                        Constraint::Length(3),
4123                    ])
4124                    .split(area);
4125                render_header(f, chunks[0], &state);
4126                render_price_chart(f, chunks[1], &state);
4127                render_volume_chart(f, chunks[2], &state);
4128                render_buy_sell_gauge(f, chunks[3], &mut state);
4129                render_footer(f, chunks[4], &state);
4130            })
4131            .unwrap();
4132    }
4133
4134    #[test]
4135    fn test_render_candlestick_mode() {
4136        let mut terminal = create_test_terminal();
4137        let mut state = create_populated_state();
4138        state.chart_mode = ChartMode::Candlestick;
4139        terminal
4140            .draw(|f| {
4141                let area = f.area();
4142                let chunks = Layout::default()
4143                    .direction(Direction::Vertical)
4144                    .constraints([Constraint::Length(3), Constraint::Min(10)])
4145                    .split(area);
4146                render_header(f, chunks[0], &state);
4147                render_candlestick_chart(f, chunks[1], &state);
4148            })
4149            .unwrap();
4150    }
4151
4152    #[test]
4153    fn test_render_with_different_time_periods() {
4154        let mut terminal = create_test_terminal();
4155        let mut state = create_populated_state();
4156
4157        for period in [
4158            TimePeriod::Min1,
4159            TimePeriod::Min5,
4160            TimePeriod::Min15,
4161            TimePeriod::Hour1,
4162            TimePeriod::Hour4,
4163            TimePeriod::Day1,
4164        ] {
4165            state.time_period = period;
4166            terminal
4167                .draw(|f| render_price_chart(f, f.area(), &state))
4168                .unwrap();
4169        }
4170    }
4171
4172    #[test]
4173    fn test_render_metrics_with_stablecoin() {
4174        let mut terminal = create_test_terminal();
4175        let mut token_data = create_test_token_data();
4176        token_data.price_usd = 0.999;
4177        token_data.symbol = "USDC".to_string();
4178        let state = MonitorState::new(&token_data, "ethereum");
4179        terminal
4180            .draw(|f| render_metrics_panel(f, f.area(), &state))
4181            .unwrap();
4182    }
4183
4184    #[test]
4185    fn test_render_header_with_negative_change() {
4186        let mut terminal = create_test_terminal();
4187        let mut token_data = create_test_token_data();
4188        token_data.price_change_24h = -15.5;
4189        token_data.price_change_1h = -2.3;
4190        let state = MonitorState::new(&token_data, "ethereum");
4191        terminal
4192            .draw(|f| render_header(f, f.area(), &state))
4193            .unwrap();
4194    }
4195
4196    // ========================================================================
4197    // MonitorState method tests
4198    // ========================================================================
4199
4200    #[test]
4201    fn test_toggle_chart_mode_roundtrip() {
4202        let token_data = create_test_token_data();
4203        let mut state = MonitorState::new(&token_data, "ethereum");
4204        assert_eq!(state.chart_mode, ChartMode::Line);
4205        state.toggle_chart_mode();
4206        assert_eq!(state.chart_mode, ChartMode::Candlestick);
4207        state.toggle_chart_mode();
4208        assert_eq!(state.chart_mode, ChartMode::VolumeProfile);
4209        state.toggle_chart_mode();
4210        assert_eq!(state.chart_mode, ChartMode::Line);
4211    }
4212
4213    #[test]
4214    fn test_cycle_all_time_periods() {
4215        let token_data = create_test_token_data();
4216        let mut state = MonitorState::new(&token_data, "ethereum");
4217        assert_eq!(state.time_period, TimePeriod::Hour1);
4218        state.cycle_time_period();
4219        assert_eq!(state.time_period, TimePeriod::Hour4);
4220        state.cycle_time_period();
4221        assert_eq!(state.time_period, TimePeriod::Day1);
4222        state.cycle_time_period();
4223        assert_eq!(state.time_period, TimePeriod::Min1);
4224        state.cycle_time_period();
4225        assert_eq!(state.time_period, TimePeriod::Min5);
4226        state.cycle_time_period();
4227        assert_eq!(state.time_period, TimePeriod::Min15);
4228        state.cycle_time_period();
4229        assert_eq!(state.time_period, TimePeriod::Hour1);
4230    }
4231
4232    #[test]
4233    fn test_set_specific_time_period() {
4234        let token_data = create_test_token_data();
4235        let mut state = MonitorState::new(&token_data, "ethereum");
4236        state.set_time_period(TimePeriod::Day1);
4237        assert_eq!(state.time_period, TimePeriod::Day1);
4238    }
4239
4240    #[test]
4241    fn test_pause_resume_roundtrip() {
4242        let token_data = create_test_token_data();
4243        let mut state = MonitorState::new(&token_data, "ethereum");
4244        assert!(!state.paused);
4245        state.toggle_pause();
4246        assert!(state.paused);
4247        state.toggle_pause();
4248        assert!(!state.paused);
4249    }
4250
4251    #[test]
4252    fn test_force_refresh_unpauses() {
4253        let token_data = create_test_token_data();
4254        let mut state = MonitorState::new(&token_data, "ethereum");
4255        state.paused = true;
4256        state.force_refresh();
4257        assert!(!state.paused);
4258        assert!(state.should_refresh());
4259    }
4260
4261    #[test]
4262    fn test_refresh_rate_adjust() {
4263        let token_data = create_test_token_data();
4264        let mut state = MonitorState::new(&token_data, "ethereum");
4265        assert_eq!(state.refresh_rate_secs(), 5);
4266
4267        state.slower_refresh();
4268        assert_eq!(state.refresh_rate_secs(), 10);
4269
4270        state.faster_refresh();
4271        assert_eq!(state.refresh_rate_secs(), 5);
4272    }
4273
4274    #[test]
4275    fn test_faster_refresh_clamped_min() {
4276        let token_data = create_test_token_data();
4277        let mut state = MonitorState::new(&token_data, "ethereum");
4278        for _ in 0..10 {
4279            state.faster_refresh();
4280        }
4281        assert!(state.refresh_rate_secs() >= 1);
4282    }
4283
4284    #[test]
4285    fn test_slower_refresh_clamped_max() {
4286        let token_data = create_test_token_data();
4287        let mut state = MonitorState::new(&token_data, "ethereum");
4288        for _ in 0..20 {
4289            state.slower_refresh();
4290        }
4291        assert!(state.refresh_rate_secs() <= 60);
4292    }
4293
4294    #[test]
4295    fn test_buy_ratio_balanced() {
4296        let mut token_data = create_test_token_data();
4297        token_data.total_buys_24h = 100;
4298        token_data.total_sells_24h = 100;
4299        let state = MonitorState::new(&token_data, "ethereum");
4300        assert!((state.buy_ratio() - 0.5).abs() < 0.01);
4301    }
4302
4303    #[test]
4304    fn test_buy_ratio_no_trades() {
4305        let mut token_data = create_test_token_data();
4306        token_data.total_buys_24h = 0;
4307        token_data.total_sells_24h = 0;
4308        let state = MonitorState::new(&token_data, "ethereum");
4309        assert!((state.buy_ratio() - 0.5).abs() < 0.01);
4310    }
4311
4312    #[test]
4313    fn test_data_stats_initial() {
4314        let token_data = create_test_token_data();
4315        let state = MonitorState::new(&token_data, "ethereum");
4316        let (synthetic, real) = state.data_stats();
4317        assert!(synthetic > 0 || real == 0);
4318    }
4319
4320    #[test]
4321    fn test_memory_usage_nonzero() {
4322        let token_data = create_test_token_data();
4323        let state = MonitorState::new(&token_data, "ethereum");
4324        let usage = state.memory_usage();
4325        assert!(usage > 0);
4326    }
4327
4328    #[test]
4329    fn test_price_data_for_period() {
4330        let token_data = create_test_token_data();
4331        let state = MonitorState::new(&token_data, "ethereum");
4332        let (data, is_real) = state.get_price_data_for_period();
4333        assert_eq!(data.len(), is_real.len());
4334    }
4335
4336    #[test]
4337    fn test_volume_data_for_period() {
4338        let token_data = create_test_token_data();
4339        let state = MonitorState::new(&token_data, "ethereum");
4340        let (data, is_real) = state.get_volume_data_for_period();
4341        assert_eq!(data.len(), is_real.len());
4342    }
4343
4344    #[test]
4345    fn test_ohlc_candles_generation() {
4346        let token_data = create_test_token_data();
4347        let state = MonitorState::new(&token_data, "ethereum");
4348        let candles = state.get_ohlc_candles();
4349        for candle in &candles {
4350            assert!(candle.high >= candle.low);
4351        }
4352    }
4353
4354    #[test]
4355    fn test_state_update_with_new_data() {
4356        let token_data = create_test_token_data();
4357        let mut state = MonitorState::new(&token_data, "ethereum");
4358        let initial_count = state.real_data_count;
4359
4360        let mut updated_data = create_test_token_data();
4361        updated_data.price_usd = 2.0;
4362        updated_data.volume_24h = 2_000_000.0;
4363
4364        state.update(&updated_data);
4365        assert_eq!(state.current_price, 2.0);
4366        assert_eq!(state.real_data_count, initial_count + 1);
4367        assert!(state.error_message.is_none());
4368    }
4369
4370    #[test]
4371    fn test_cache_roundtrip_save_load() {
4372        let token_data = create_test_token_data();
4373        let state = MonitorState::new(&token_data, "ethereum");
4374
4375        state.save_cache();
4376
4377        let cache_path = MonitorState::cache_path(&token_data.address, "ethereum");
4378        assert!(cache_path.exists());
4379
4380        let cached = MonitorState::load_cache(&token_data.address, "ethereum");
4381        assert!(cached.is_some());
4382
4383        let _ = std::fs::remove_file(cache_path);
4384    }
4385
4386    #[test]
4387    fn test_should_refresh_when_paused() {
4388        let token_data = create_test_token_data();
4389        let mut state = MonitorState::new(&token_data, "ethereum");
4390        assert!(!state.should_refresh());
4391        state.paused = true;
4392        assert!(!state.should_refresh());
4393    }
4394
4395    #[test]
4396    fn test_ohlc_candle_lifecycle() {
4397        let mut candle = OhlcCandle::new(1700000000.0, 100.0);
4398        assert_eq!(candle.open, 100.0);
4399        assert!(candle.is_bullish);
4400        candle.update(110.0);
4401        assert_eq!(candle.high, 110.0);
4402        assert!(candle.is_bullish);
4403        candle.update(90.0);
4404        assert_eq!(candle.low, 90.0);
4405        assert!(!candle.is_bullish);
4406    }
4407
4408    #[test]
4409    fn test_time_period_display_impl() {
4410        assert_eq!(format!("{}", TimePeriod::Min1), "1m");
4411        assert_eq!(format!("{}", TimePeriod::Min15), "15m");
4412        assert_eq!(format!("{}", TimePeriod::Day1), "1d");
4413    }
4414
4415    #[test]
4416    fn test_log_messages_accumulate() {
4417        let token_data = create_test_token_data();
4418        let mut state = MonitorState::new(&token_data, "ethereum");
4419        // Trigger actions that log
4420        state.toggle_pause();
4421        state.toggle_pause();
4422        state.cycle_time_period();
4423        state.toggle_chart_mode();
4424        assert!(!state.log_messages.is_empty());
4425    }
4426
4427    #[test]
4428    fn test_ui_function_full_render() {
4429        // Test the main ui() function which orchestrates all rendering
4430        let mut terminal = create_test_terminal();
4431        let mut state = create_populated_state();
4432        terminal.draw(|f| ui(f, &mut state)).unwrap();
4433    }
4434
4435    #[test]
4436    fn test_ui_function_candlestick_mode() {
4437        let mut terminal = create_test_terminal();
4438        let mut state = create_populated_state();
4439        state.chart_mode = ChartMode::Candlestick;
4440        terminal.draw(|f| ui(f, &mut state)).unwrap();
4441    }
4442
4443    #[test]
4444    fn test_ui_function_with_error_message() {
4445        let mut terminal = create_test_terminal();
4446        let mut state = create_populated_state();
4447        state.error_message = Some("Test error".to_string());
4448        terminal.draw(|f| ui(f, &mut state)).unwrap();
4449    }
4450
4451    #[test]
4452    fn test_render_header_with_small_positive_change() {
4453        let mut terminal = create_test_terminal();
4454        let mut state = create_populated_state();
4455        state.price_change_24h = 0.3; // Between 0 and 0.5 -> △
4456        terminal
4457            .draw(|f| render_header(f, f.area(), &state))
4458            .unwrap();
4459    }
4460
4461    #[test]
4462    fn test_render_header_with_small_negative_change() {
4463        let mut terminal = create_test_terminal();
4464        let mut state = create_populated_state();
4465        state.price_change_24h = -0.3; // Between -0.5 and 0 -> ▽
4466        terminal
4467            .draw(|f| render_header(f, f.area(), &state))
4468            .unwrap();
4469    }
4470
4471    #[test]
4472    fn test_render_buy_sell_gauge_high_buy_ratio() {
4473        let mut terminal = create_test_terminal();
4474        let token_data = create_test_token_data();
4475        let mut state = MonitorState::new(&token_data, "ethereum");
4476        state.buys_24h = 100;
4477        state.sells_24h = 10;
4478        terminal
4479            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
4480            .unwrap();
4481    }
4482
4483    #[test]
4484    fn test_render_buy_sell_gauge_zero_total() {
4485        let mut terminal = create_test_terminal();
4486        let token_data = create_test_token_data();
4487        let mut state = MonitorState::new(&token_data, "ethereum");
4488        state.buys_24h = 0;
4489        state.sells_24h = 0;
4490        terminal
4491            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
4492            .unwrap();
4493    }
4494
4495    #[test]
4496    fn test_render_metrics_with_market_cap() {
4497        let mut terminal = create_test_terminal();
4498        let token_data = create_test_token_data();
4499        let mut state = MonitorState::new(&token_data, "ethereum");
4500        state.market_cap = Some(1_000_000_000.0);
4501        state.fdv = Some(2_000_000_000.0);
4502        terminal
4503            .draw(|f| render_metrics_panel(f, f.area(), &state))
4504            .unwrap();
4505    }
4506
4507    #[test]
4508    fn test_render_footer_with_error() {
4509        let mut terminal = create_test_terminal();
4510        let mut state = create_populated_state();
4511        state.error_message = Some("Connection failed".to_string());
4512        terminal
4513            .draw(|f| render_footer(f, f.area(), &state))
4514            .unwrap();
4515    }
4516
4517    #[test]
4518    fn test_format_price_usd_various() {
4519        // Test format_price_usd with various magnitudes
4520        assert!(!format_price_usd(0.0000001).is_empty());
4521        assert!(!format_price_usd(0.001).is_empty());
4522        assert!(!format_price_usd(1.0).is_empty());
4523        assert!(!format_price_usd(100.0).is_empty());
4524        assert!(!format_price_usd(10000.0).is_empty());
4525        assert!(!format_price_usd(1000000.0).is_empty());
4526    }
4527
4528    #[test]
4529    fn test_format_usd_various() {
4530        assert!(!crate::display::format_usd(0.0).is_empty());
4531        assert!(!crate::display::format_usd(999.0).is_empty());
4532        assert!(!crate::display::format_usd(1500.0).is_empty());
4533        assert!(!crate::display::format_usd(1_500_000.0).is_empty());
4534        assert!(!crate::display::format_usd(1_500_000_000.0).is_empty());
4535        assert!(!crate::display::format_usd(1_500_000_000_000.0).is_empty());
4536    }
4537
4538    #[test]
4539    fn test_format_number_various() {
4540        assert!(!format_number(0.0).is_empty());
4541        assert!(!format_number(999.0).is_empty());
4542        assert!(!format_number(1500.0).is_empty());
4543        assert!(!format_number(1_500_000.0).is_empty());
4544        assert!(!format_number(1_500_000_000.0).is_empty());
4545    }
4546
4547    #[test]
4548    fn test_render_with_min15_period() {
4549        let mut terminal = create_test_terminal();
4550        let mut state = create_populated_state();
4551        state.set_time_period(TimePeriod::Min15);
4552        terminal.draw(|f| ui(f, &mut state)).unwrap();
4553    }
4554
4555    #[test]
4556    fn test_render_with_hour6_period() {
4557        let mut terminal = create_test_terminal();
4558        let mut state = create_populated_state();
4559        state.set_time_period(TimePeriod::Hour4);
4560        terminal.draw(|f| ui(f, &mut state)).unwrap();
4561    }
4562
4563    #[test]
4564    fn test_ui_with_fresh_state_no_real_data() {
4565        let mut terminal = create_test_terminal();
4566        let token_data = create_test_token_data();
4567        let mut state = MonitorState::new(&token_data, "ethereum");
4568        // Fresh state with only synthetic data
4569        terminal.draw(|f| ui(f, &mut state)).unwrap();
4570    }
4571
4572    #[test]
4573    fn test_ui_with_paused_state() {
4574        let mut terminal = create_test_terminal();
4575        let mut state = create_populated_state();
4576        state.toggle_pause();
4577        terminal.draw(|f| ui(f, &mut state)).unwrap();
4578    }
4579
4580    #[test]
4581    fn test_render_all_with_different_time_periods_and_modes() {
4582        let mut terminal = create_test_terminal();
4583        let mut state = create_populated_state();
4584
4585        // Test all combinations of time period + chart mode
4586        for period in &[
4587            TimePeriod::Min1,
4588            TimePeriod::Min5,
4589            TimePeriod::Min15,
4590            TimePeriod::Hour1,
4591            TimePeriod::Hour4,
4592            TimePeriod::Day1,
4593        ] {
4594            for mode in &[
4595                ChartMode::Line,
4596                ChartMode::Candlestick,
4597                ChartMode::VolumeProfile,
4598            ] {
4599                state.set_time_period(*period);
4600                state.chart_mode = *mode;
4601                terminal.draw(|f| ui(f, &mut state)).unwrap();
4602            }
4603        }
4604    }
4605
4606    #[test]
4607    fn test_render_metrics_with_large_values() {
4608        let mut terminal = create_test_terminal();
4609        let mut state = create_populated_state();
4610        state.market_cap = Some(50_000_000_000.0); // 50B
4611        state.fdv = Some(100_000_000_000.0); // 100B
4612        state.volume_24h = 5_000_000_000.0; // 5B
4613        state.liquidity_usd = 500_000_000.0; // 500M
4614        terminal
4615            .draw(|f| render_metrics_panel(f, f.area(), &state))
4616            .unwrap();
4617    }
4618
4619    #[test]
4620    fn test_render_header_large_positive_change() {
4621        let mut terminal = create_test_terminal();
4622        let mut state = create_populated_state();
4623        state.price_change_24h = 50.0; // >0.5 -> ▲
4624        terminal
4625            .draw(|f| render_header(f, f.area(), &state))
4626            .unwrap();
4627    }
4628
4629    #[test]
4630    fn test_render_header_large_negative_change() {
4631        let mut terminal = create_test_terminal();
4632        let mut state = create_populated_state();
4633        state.price_change_24h = -50.0; // <-0.5 -> ▼
4634        terminal
4635            .draw(|f| render_header(f, f.area(), &state))
4636            .unwrap();
4637    }
4638
4639    #[test]
4640    fn test_render_price_chart_empty_data() {
4641        let mut terminal = create_test_terminal();
4642        let token_data = create_test_token_data();
4643        // Create state with no price history data
4644        let mut state = MonitorState::new(&token_data, "ethereum");
4645        state.price_history.clear();
4646        terminal
4647            .draw(|f| render_price_chart(f, f.area(), &state))
4648            .unwrap();
4649    }
4650
4651    #[test]
4652    fn test_render_price_chart_price_down() {
4653        let mut terminal = create_test_terminal();
4654        let mut state = create_populated_state();
4655        // Force price down scenario
4656        state.price_change_24h = -15.0;
4657        state.current_price = 0.5; // Below initial
4658        terminal
4659            .draw(|f| render_price_chart(f, f.area(), &state))
4660            .unwrap();
4661    }
4662
4663    #[test]
4664    fn test_render_price_chart_zero_first_price() {
4665        let mut terminal = create_test_terminal();
4666        let mut token_data = create_test_token_data();
4667        token_data.price_usd = 0.0;
4668        let state = MonitorState::new(&token_data, "ethereum");
4669        terminal
4670            .draw(|f| render_price_chart(f, f.area(), &state))
4671            .unwrap();
4672    }
4673
4674    #[test]
4675    fn test_render_metrics_panel_zero_5m_change() {
4676        let mut terminal = create_test_terminal();
4677        let mut state = create_populated_state();
4678        state.price_change_5m = 0.0; // Exactly zero
4679        terminal
4680            .draw(|f| render_metrics_panel(f, f.area(), &state))
4681            .unwrap();
4682    }
4683
4684    #[test]
4685    fn test_render_metrics_panel_positive_5m_change() {
4686        let mut terminal = create_test_terminal();
4687        let mut state = create_populated_state();
4688        state.price_change_5m = 5.0; // Positive
4689        terminal
4690            .draw(|f| render_metrics_panel(f, f.area(), &state))
4691            .unwrap();
4692    }
4693
4694    #[test]
4695    fn test_render_metrics_panel_negative_5m_change() {
4696        let mut terminal = create_test_terminal();
4697        let mut state = create_populated_state();
4698        state.price_change_5m = -3.0; // Negative
4699        terminal
4700            .draw(|f| render_metrics_panel(f, f.area(), &state))
4701            .unwrap();
4702    }
4703
4704    #[test]
4705    fn test_render_metrics_panel_negative_24h_change() {
4706        let mut terminal = create_test_terminal();
4707        let mut state = create_populated_state();
4708        state.price_change_24h = -10.0;
4709        terminal
4710            .draw(|f| render_metrics_panel(f, f.area(), &state))
4711            .unwrap();
4712    }
4713
4714    #[test]
4715    fn test_render_metrics_panel_old_last_change() {
4716        let mut terminal = create_test_terminal();
4717        let mut state = create_populated_state();
4718        // Set last_price_change_at to over an hour ago
4719        state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 7200.0; // 2h ago
4720        terminal
4721            .draw(|f| render_metrics_panel(f, f.area(), &state))
4722            .unwrap();
4723    }
4724
4725    #[test]
4726    fn test_render_metrics_panel_minutes_ago_change() {
4727        let mut terminal = create_test_terminal();
4728        let mut state = create_populated_state();
4729        // Set last_price_change_at to minutes ago
4730        state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 300.0; // 5 min ago
4731        terminal
4732            .draw(|f| render_metrics_panel(f, f.area(), &state))
4733            .unwrap();
4734    }
4735
4736    #[test]
4737    fn test_render_candlestick_empty_fresh_state() {
4738        let mut terminal = create_test_terminal();
4739        let token_data = create_test_token_data();
4740        let mut state = MonitorState::new(&token_data, "ethereum");
4741        state.price_history.clear();
4742        state.chart_mode = ChartMode::Candlestick;
4743        terminal
4744            .draw(|f| render_candlestick_chart(f, f.area(), &state))
4745            .unwrap();
4746    }
4747
4748    #[test]
4749    fn test_render_candlestick_price_down() {
4750        let mut terminal = create_test_terminal();
4751        let token_data = create_test_token_data();
4752        let mut state = MonitorState::new(&token_data, "ethereum");
4753        // Add data going down
4754        for i in 0..20 {
4755            let mut data = token_data.clone();
4756            data.price_usd = 2.0 - (i as f64 * 0.05);
4757            state.update(&data);
4758        }
4759        state.chart_mode = ChartMode::Candlestick;
4760        terminal
4761            .draw(|f| render_candlestick_chart(f, f.area(), &state))
4762            .unwrap();
4763    }
4764
4765    #[test]
4766    fn test_render_volume_chart_with_many_points() {
4767        let mut terminal = create_test_terminal();
4768        let token_data = create_test_token_data();
4769        let mut state = MonitorState::new(&token_data, "ethereum");
4770        // Add lots of data points
4771        for i in 0..100 {
4772            let mut data = token_data.clone();
4773            data.volume_24h = 1_000_000.0 + (i as f64 * 50_000.0);
4774            data.price_usd = 1.0 + (i as f64 * 0.001);
4775            state.update(&data);
4776        }
4777        terminal
4778            .draw(|f| render_volume_chart(f, f.area(), &state))
4779            .unwrap();
4780    }
4781
4782    // ========================================================================
4783    // Key event handler tests
4784    // ========================================================================
4785
4786    fn make_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
4787        crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)
4788    }
4789
4790    fn make_ctrl_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
4791        crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
4792    }
4793
4794    #[test]
4795    fn test_handle_key_quit_q() {
4796        let token_data = create_test_token_data();
4797        let mut state = MonitorState::new(&token_data, "ethereum");
4798        assert!(handle_key_event_on_state(
4799            make_key_event(KeyCode::Char('q')),
4800            &mut state
4801        ));
4802    }
4803
4804    #[test]
4805    fn test_handle_key_quit_esc() {
4806        let token_data = create_test_token_data();
4807        let mut state = MonitorState::new(&token_data, "ethereum");
4808        assert!(handle_key_event_on_state(
4809            make_key_event(KeyCode::Esc),
4810            &mut state
4811        ));
4812    }
4813
4814    #[test]
4815    fn test_handle_key_quit_ctrl_c() {
4816        let token_data = create_test_token_data();
4817        let mut state = MonitorState::new(&token_data, "ethereum");
4818        assert!(handle_key_event_on_state(
4819            make_ctrl_key_event(KeyCode::Char('c')),
4820            &mut state
4821        ));
4822    }
4823
4824    #[test]
4825    fn test_handle_key_refresh() {
4826        let token_data = create_test_token_data();
4827        let mut state = MonitorState::new(&token_data, "ethereum");
4828        state.refresh_rate = Duration::from_secs(60);
4829        // Set last_update in the past so should_refresh was false
4830        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('r')), &mut state);
4831        assert!(!exit);
4832        // force_refresh sets last_update to epoch, so should_refresh() should be true
4833        assert!(state.should_refresh());
4834    }
4835
4836    #[test]
4837    fn test_handle_key_pause_toggle() {
4838        let token_data = create_test_token_data();
4839        let mut state = MonitorState::new(&token_data, "ethereum");
4840        assert!(!state.paused);
4841
4842        handle_key_event_on_state(make_key_event(KeyCode::Char('p')), &mut state);
4843        assert!(state.paused);
4844
4845        handle_key_event_on_state(make_key_event(KeyCode::Char(' ')), &mut state);
4846        assert!(!state.paused);
4847    }
4848
4849    #[test]
4850    fn test_handle_key_slower_refresh() {
4851        let token_data = create_test_token_data();
4852        let mut state = MonitorState::new(&token_data, "ethereum");
4853        let initial = state.refresh_rate;
4854
4855        handle_key_event_on_state(make_key_event(KeyCode::Char('+')), &mut state);
4856        assert!(state.refresh_rate > initial);
4857
4858        state.refresh_rate = initial;
4859        handle_key_event_on_state(make_key_event(KeyCode::Char('=')), &mut state);
4860        assert!(state.refresh_rate > initial);
4861
4862        state.refresh_rate = initial;
4863        handle_key_event_on_state(make_key_event(KeyCode::Char(']')), &mut state);
4864        assert!(state.refresh_rate > initial);
4865    }
4866
4867    #[test]
4868    fn test_handle_key_faster_refresh() {
4869        let token_data = create_test_token_data();
4870        let mut state = MonitorState::new(&token_data, "ethereum");
4871        // First make it slower so there's room to go faster
4872        state.refresh_rate = Duration::from_secs(30);
4873        let initial = state.refresh_rate;
4874
4875        handle_key_event_on_state(make_key_event(KeyCode::Char('-')), &mut state);
4876        assert!(state.refresh_rate < initial);
4877
4878        state.refresh_rate = initial;
4879        handle_key_event_on_state(make_key_event(KeyCode::Char('_')), &mut state);
4880        assert!(state.refresh_rate < initial);
4881
4882        state.refresh_rate = initial;
4883        handle_key_event_on_state(make_key_event(KeyCode::Char('[')), &mut state);
4884        assert!(state.refresh_rate < initial);
4885    }
4886
4887    #[test]
4888    fn test_handle_key_time_periods() {
4889        let token_data = create_test_token_data();
4890        let mut state = MonitorState::new(&token_data, "ethereum");
4891
4892        handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
4893        assert!(matches!(state.time_period, TimePeriod::Min1));
4894
4895        handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
4896        assert!(matches!(state.time_period, TimePeriod::Min5));
4897
4898        handle_key_event_on_state(make_key_event(KeyCode::Char('3')), &mut state);
4899        assert!(matches!(state.time_period, TimePeriod::Min15));
4900
4901        handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
4902        assert!(matches!(state.time_period, TimePeriod::Hour1));
4903
4904        handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
4905        assert!(matches!(state.time_period, TimePeriod::Hour4));
4906
4907        handle_key_event_on_state(make_key_event(KeyCode::Char('6')), &mut state);
4908        assert!(matches!(state.time_period, TimePeriod::Day1));
4909    }
4910
4911    #[test]
4912    fn test_handle_key_cycle_time_period() {
4913        let token_data = create_test_token_data();
4914        let mut state = MonitorState::new(&token_data, "ethereum");
4915
4916        handle_key_event_on_state(make_key_event(KeyCode::Char('t')), &mut state);
4917        // Should cycle from default
4918        let first = state.time_period;
4919
4920        handle_key_event_on_state(make_key_event(KeyCode::Tab), &mut state);
4921        // Should have cycled again
4922        // Verify it cycled (no panic is the main check)
4923        let _ = state.time_period;
4924        let _ = first;
4925    }
4926
4927    #[test]
4928    fn test_handle_key_toggle_chart_mode() {
4929        let token_data = create_test_token_data();
4930        let mut state = MonitorState::new(&token_data, "ethereum");
4931        let initial_mode = state.chart_mode;
4932
4933        handle_key_event_on_state(make_key_event(KeyCode::Char('c')), &mut state);
4934        assert!(state.chart_mode != initial_mode);
4935    }
4936
4937    #[test]
4938    fn test_handle_key_unknown_no_op() {
4939        let token_data = create_test_token_data();
4940        let mut state = MonitorState::new(&token_data, "ethereum");
4941        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
4942        assert!(!exit);
4943    }
4944
4945    // ========================================================================
4946    // Cache save/load tests
4947    // ========================================================================
4948
4949    #[test]
4950    fn test_save_and_load_cache() {
4951        let token_data = create_test_token_data();
4952        let mut state = MonitorState::new(&token_data, "ethereum");
4953        state.price_history.push_back(DataPoint {
4954            timestamp: 1.0,
4955            value: 100.0,
4956            is_real: true,
4957        });
4958        state.price_history.push_back(DataPoint {
4959            timestamp: 2.0,
4960            value: 101.0,
4961            is_real: true,
4962        });
4963        state.volume_history.push_back(DataPoint {
4964            timestamp: 1.0,
4965            value: 5000.0,
4966            is_real: true,
4967        });
4968
4969        // save_cache uses dirs::cache_dir() which we can't redirect easily
4970        // but we can test the load_cache path with a real write
4971        state.save_cache();
4972        let cached = MonitorState::load_cache(&state.token_address, &state.chain);
4973        // Cache may or may not exist depending on system - just verify no panic
4974        if let Some(c) = cached {
4975            assert_eq!(
4976                c.token_address.to_lowercase(),
4977                state.token_address.to_lowercase()
4978            );
4979        }
4980    }
4981
4982    #[test]
4983    fn test_load_cache_nonexistent_token() {
4984        let cached = MonitorState::load_cache("0xNONEXISTENT_TOKEN_ADDR", "nonexistent_chain");
4985        assert!(cached.is_none());
4986    }
4987
4988    // ========================================================================
4989    // New widget tests: BarChart (volume), Table+Sparkline (metrics), scroll
4990    // ========================================================================
4991
4992    #[test]
4993    fn test_render_volume_barchart_with_populated_data() {
4994        // Verify the BarChart-based volume chart renders without panic
4995        // when state has many volume data points across different time periods
4996        let mut terminal = create_test_terminal();
4997        let mut state = create_populated_state();
4998        for period in [
4999            TimePeriod::Min1,
5000            TimePeriod::Min5,
5001            TimePeriod::Min15,
5002            TimePeriod::Hour1,
5003            TimePeriod::Hour4,
5004            TimePeriod::Day1,
5005        ] {
5006            state.set_time_period(period);
5007            terminal
5008                .draw(|f| render_volume_chart(f, f.area(), &state))
5009                .unwrap();
5010        }
5011    }
5012
5013    #[test]
5014    fn test_render_volume_barchart_narrow_terminal() {
5015        // BarChart with very narrow width should still render without panic
5016        let backend = TestBackend::new(20, 10);
5017        let mut terminal = Terminal::new(backend).unwrap();
5018        let state = create_populated_state();
5019        terminal
5020            .draw(|f| render_volume_chart(f, f.area(), &state))
5021            .unwrap();
5022    }
5023
5024    #[test]
5025    fn test_render_metrics_table_sparkline_no_panic() {
5026        // Verify the Table+Sparkline metrics panel renders without panic
5027        let mut terminal = create_test_terminal();
5028        let state = create_populated_state();
5029        terminal
5030            .draw(|f| render_metrics_panel(f, f.area(), &state))
5031            .unwrap();
5032    }
5033
5034    #[test]
5035    fn test_render_metrics_table_sparkline_all_periods() {
5036        // Ensure metrics panel renders correctly for every time period
5037        let mut terminal = create_test_terminal();
5038        let mut state = create_populated_state();
5039        for period in [
5040            TimePeriod::Min1,
5041            TimePeriod::Min5,
5042            TimePeriod::Min15,
5043            TimePeriod::Hour1,
5044            TimePeriod::Hour4,
5045            TimePeriod::Day1,
5046        ] {
5047            state.set_time_period(period);
5048            terminal
5049                .draw(|f| render_metrics_panel(f, f.area(), &state))
5050                .unwrap();
5051        }
5052    }
5053
5054    #[test]
5055    fn test_render_metrics_sparkline_trend_direction() {
5056        // When 5m change is negative, sparkline should still render
5057        let mut terminal = create_test_terminal();
5058        let mut state = create_populated_state();
5059        state.price_change_5m = -3.5;
5060        terminal
5061            .draw(|f| render_metrics_panel(f, f.area(), &state))
5062            .unwrap();
5063
5064        // When 5m change is positive
5065        state.price_change_5m = 2.0;
5066        terminal
5067            .draw(|f| render_metrics_panel(f, f.area(), &state))
5068            .unwrap();
5069
5070        // When 5m change is zero
5071        state.price_change_5m = 0.0;
5072        terminal
5073            .draw(|f| render_metrics_panel(f, f.area(), &state))
5074            .unwrap();
5075    }
5076
5077    #[test]
5078    fn test_render_tabs_time_period() {
5079        // Verify the Tabs widget in the header renders for each period
5080        let mut terminal = create_test_terminal();
5081        let mut state = create_populated_state();
5082        for period in [
5083            TimePeriod::Min1,
5084            TimePeriod::Min5,
5085            TimePeriod::Min15,
5086            TimePeriod::Hour1,
5087            TimePeriod::Hour4,
5088            TimePeriod::Day1,
5089        ] {
5090            state.set_time_period(period);
5091            terminal
5092                .draw(|f| render_header(f, f.area(), &state))
5093                .unwrap();
5094        }
5095    }
5096
5097    #[test]
5098    fn test_time_period_index() {
5099        assert_eq!(TimePeriod::Min1.index(), 0);
5100        assert_eq!(TimePeriod::Min5.index(), 1);
5101        assert_eq!(TimePeriod::Min15.index(), 2);
5102        assert_eq!(TimePeriod::Hour1.index(), 3);
5103        assert_eq!(TimePeriod::Hour4.index(), 4);
5104        assert_eq!(TimePeriod::Day1.index(), 5);
5105    }
5106
5107    #[test]
5108    fn test_scroll_log_down_from_start() {
5109        let token_data = create_test_token_data();
5110        let mut state = MonitorState::new(&token_data, "ethereum");
5111        state.log_messages.push_back("msg 1".to_string());
5112        state.log_messages.push_back("msg 2".to_string());
5113        state.log_messages.push_back("msg 3".to_string());
5114
5115        // Initially no selection
5116        assert_eq!(state.log_list_state.selected(), None);
5117
5118        // First scroll down selects item 0
5119        state.scroll_log_down();
5120        assert_eq!(state.log_list_state.selected(), Some(0));
5121
5122        // Second scroll moves to item 1
5123        state.scroll_log_down();
5124        assert_eq!(state.log_list_state.selected(), Some(1));
5125
5126        // Third scroll moves to item 2
5127        state.scroll_log_down();
5128        assert_eq!(state.log_list_state.selected(), Some(2));
5129
5130        // Fourth scroll stays at last item (bounds check)
5131        state.scroll_log_down();
5132        assert_eq!(state.log_list_state.selected(), Some(2));
5133    }
5134
5135    #[test]
5136    fn test_scroll_log_up_from_start() {
5137        let token_data = create_test_token_data();
5138        let mut state = MonitorState::new(&token_data, "ethereum");
5139        state.log_messages.push_back("msg 1".to_string());
5140        state.log_messages.push_back("msg 2".to_string());
5141        state.log_messages.push_back("msg 3".to_string());
5142
5143        // Scroll up from no selection goes to 0
5144        state.scroll_log_up();
5145        assert_eq!(state.log_list_state.selected(), Some(0));
5146
5147        // Can't go below 0
5148        state.scroll_log_up();
5149        assert_eq!(state.log_list_state.selected(), Some(0));
5150    }
5151
5152    #[test]
5153    fn test_scroll_log_up_down_roundtrip() {
5154        let token_data = create_test_token_data();
5155        let mut state = MonitorState::new(&token_data, "ethereum");
5156        for i in 0..10 {
5157            state.log_messages.push_back(format!("msg {}", i));
5158        }
5159
5160        // Scroll down 5 times
5161        for _ in 0..5 {
5162            state.scroll_log_down();
5163        }
5164        assert_eq!(state.log_list_state.selected(), Some(4));
5165
5166        // Scroll up 3 times
5167        for _ in 0..3 {
5168            state.scroll_log_up();
5169        }
5170        assert_eq!(state.log_list_state.selected(), Some(1));
5171    }
5172
5173    #[test]
5174    fn test_scroll_log_empty_no_panic() {
5175        let token_data = create_test_token_data();
5176        let mut state = MonitorState::new(&token_data, "ethereum");
5177        // With no log messages, scrolling should not panic
5178        state.scroll_log_down();
5179        state.scroll_log_up();
5180        assert!(
5181            state.log_list_state.selected().is_none() || state.log_list_state.selected() == Some(0)
5182        );
5183    }
5184
5185    #[test]
5186    fn test_render_scrollable_activity_log() {
5187        // Ensure the stateful activity log renders without panic
5188        let mut terminal = create_test_terminal();
5189        let mut state = create_populated_state();
5190        for i in 0..20 {
5191            state
5192                .log_messages
5193                .push_back(format!("Activity event #{}", i));
5194        }
5195        // Scroll down a few items
5196        state.scroll_log_down();
5197        state.scroll_log_down();
5198        state.scroll_log_down();
5199
5200        terminal
5201            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5202            .unwrap();
5203    }
5204
5205    #[test]
5206    fn test_handle_key_scroll_log_j_k() {
5207        let token_data = create_test_token_data();
5208        let mut state = MonitorState::new(&token_data, "ethereum");
5209        state.log_messages.push_back("line 1".to_string());
5210        state.log_messages.push_back("line 2".to_string());
5211
5212        // j scrolls down
5213        handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
5214        assert_eq!(state.log_list_state.selected(), Some(0));
5215
5216        handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
5217        assert_eq!(state.log_list_state.selected(), Some(1));
5218
5219        // k scrolls up
5220        handle_key_event_on_state(make_key_event(KeyCode::Char('k')), &mut state);
5221        assert_eq!(state.log_list_state.selected(), Some(0));
5222    }
5223
5224    #[test]
5225    fn test_handle_key_scroll_log_arrow_keys() {
5226        let token_data = create_test_token_data();
5227        let mut state = MonitorState::new(&token_data, "ethereum");
5228        state.log_messages.push_back("line 1".to_string());
5229        state.log_messages.push_back("line 2".to_string());
5230        state.log_messages.push_back("line 3".to_string());
5231
5232        // Down arrow scrolls down
5233        handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
5234        assert_eq!(state.log_list_state.selected(), Some(0));
5235
5236        handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
5237        assert_eq!(state.log_list_state.selected(), Some(1));
5238
5239        // Up arrow scrolls up
5240        handle_key_event_on_state(make_key_event(KeyCode::Up), &mut state);
5241        assert_eq!(state.log_list_state.selected(), Some(0));
5242    }
5243
5244    #[test]
5245    fn test_render_ui_with_scrolled_log() {
5246        // Full UI render with a scrolled activity log position
5247        let mut terminal = create_test_terminal();
5248        let mut state = create_populated_state();
5249        for i in 0..15 {
5250            state.log_messages.push_back(format!("Log entry {}", i));
5251        }
5252        state.scroll_log_down();
5253        state.scroll_log_down();
5254        state.scroll_log_down();
5255        state.scroll_log_down();
5256        state.scroll_log_down();
5257
5258        terminal.draw(|f| ui(f, &mut state)).unwrap();
5259    }
5260
5261    // ========================================================================
5262    // Token selection / resolve tests
5263    // ========================================================================
5264
5265    fn make_monitor_search_results() -> Vec<crate::chains::dex::TokenSearchResult> {
5266        vec![
5267            crate::chains::dex::TokenSearchResult {
5268                symbol: "USDC".to_string(),
5269                name: "USD Coin".to_string(),
5270                address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
5271                chain: "ethereum".to_string(),
5272                price_usd: Some(1.0),
5273                volume_24h: 1_000_000.0,
5274                liquidity_usd: 500_000_000.0,
5275                market_cap: Some(30_000_000_000.0),
5276            },
5277            crate::chains::dex::TokenSearchResult {
5278                symbol: "USDC".to_string(),
5279                name: "Bridged USD Coin".to_string(),
5280                address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
5281                chain: "ethereum".to_string(),
5282                price_usd: Some(0.9998),
5283                volume_24h: 500_000.0,
5284                liquidity_usd: 100_000_000.0,
5285                market_cap: None,
5286            },
5287            crate::chains::dex::TokenSearchResult {
5288                symbol: "USDC".to_string(),
5289                name: "A Very Long Token Name That Exceeds The Limit".to_string(),
5290                address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
5291                chain: "ethereum".to_string(),
5292                price_usd: None,
5293                volume_24h: 0.0,
5294                liquidity_usd: 50_000.0,
5295                market_cap: None,
5296            },
5297        ]
5298    }
5299
5300    #[test]
5301    fn test_abbreviate_address_long() {
5302        let addr = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
5303        let abbr = abbreviate_address(addr);
5304        assert_eq!(abbr, "0xA0b869...06eB48");
5305        assert!(abbr.contains("..."));
5306    }
5307
5308    #[test]
5309    fn test_abbreviate_address_short() {
5310        let addr = "0x1234abcd";
5311        let abbr = abbreviate_address(addr);
5312        // Short addresses are not abbreviated
5313        assert_eq!(abbr, "0x1234abcd");
5314    }
5315
5316    #[test]
5317    fn test_select_token_impl_first() {
5318        let results = make_monitor_search_results();
5319        let input = b"1\n";
5320        let mut reader = std::io::Cursor::new(&input[..]);
5321        let mut writer = Vec::new();
5322
5323        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5324        assert_eq!(selected.name, "USD Coin");
5325        assert_eq!(
5326            selected.address,
5327            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
5328        );
5329
5330        let output = String::from_utf8(writer).unwrap();
5331        assert!(output.contains("Found 3 tokens"));
5332        assert!(output.contains("USDC"));
5333        assert!(output.contains("0xA0b869...06eB48"));
5334        assert!(output.contains("Selected:"));
5335    }
5336
5337    #[test]
5338    fn test_select_token_impl_second() {
5339        let results = make_monitor_search_results();
5340        let input = b"2\n";
5341        let mut reader = std::io::Cursor::new(&input[..]);
5342        let mut writer = Vec::new();
5343
5344        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5345        assert_eq!(selected.name, "Bridged USD Coin");
5346        assert_eq!(
5347            selected.address,
5348            "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
5349        );
5350    }
5351
5352    #[test]
5353    fn test_select_token_impl_shows_address_column() {
5354        let results = make_monitor_search_results();
5355        let input = b"1\n";
5356        let mut reader = std::io::Cursor::new(&input[..]);
5357        let mut writer = Vec::new();
5358
5359        select_token_impl(&results, &mut reader, &mut writer).unwrap();
5360        let output = String::from_utf8(writer).unwrap();
5361
5362        // Table header should include Address column
5363        assert!(output.contains("Address"));
5364        // All three abbreviated addresses should appear
5365        assert!(output.contains("0xA0b869...06eB48"));
5366        assert!(output.contains("0x2791Bc...a84174"));
5367        assert!(output.contains("0x123456...345678"));
5368    }
5369
5370    #[test]
5371    fn test_select_token_impl_truncates_long_name() {
5372        let results = make_monitor_search_results();
5373        let input = b"3\n";
5374        let mut reader = std::io::Cursor::new(&input[..]);
5375        let mut writer = Vec::new();
5376
5377        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5378        assert_eq!(
5379            selected.address,
5380            "0x1234567890abcdef1234567890abcdef12345678"
5381        );
5382
5383        let output = String::from_utf8(writer).unwrap();
5384        assert!(output.contains("A Very Long Token..."));
5385    }
5386
5387    #[test]
5388    fn test_select_token_impl_invalid_input() {
5389        let results = make_monitor_search_results();
5390        let input = b"xyz\n";
5391        let mut reader = std::io::Cursor::new(&input[..]);
5392        let mut writer = Vec::new();
5393
5394        let result = select_token_impl(&results, &mut reader, &mut writer);
5395        assert!(result.is_err());
5396        assert!(
5397            result
5398                .unwrap_err()
5399                .to_string()
5400                .contains("Invalid selection")
5401        );
5402    }
5403
5404    #[test]
5405    fn test_select_token_impl_out_of_range_zero() {
5406        let results = make_monitor_search_results();
5407        let input = b"0\n";
5408        let mut reader = std::io::Cursor::new(&input[..]);
5409        let mut writer = Vec::new();
5410
5411        let result = select_token_impl(&results, &mut reader, &mut writer);
5412        assert!(result.is_err());
5413        assert!(
5414            result
5415                .unwrap_err()
5416                .to_string()
5417                .contains("Selection must be between")
5418        );
5419    }
5420
5421    #[test]
5422    fn test_select_token_impl_out_of_range_high() {
5423        let results = make_monitor_search_results();
5424        let input = b"99\n";
5425        let mut reader = std::io::Cursor::new(&input[..]);
5426        let mut writer = Vec::new();
5427
5428        let result = select_token_impl(&results, &mut reader, &mut writer);
5429        assert!(result.is_err());
5430    }
5431
5432    #[test]
5433    fn test_format_monitor_number() {
5434        assert_eq!(format_monitor_number(1_500_000_000.0), "$1.50B");
5435        assert_eq!(format_monitor_number(250_000_000.0), "$250.00M");
5436        assert_eq!(format_monitor_number(75_000.0), "$75.00K");
5437        assert_eq!(format_monitor_number(42.5), "$42.50");
5438    }
5439
5440    // ============================
5441    // Phase 4: Layout system tests
5442    // ============================
5443
5444    #[test]
5445    fn test_monitor_config_defaults() {
5446        let config = MonitorConfig::default();
5447        assert_eq!(config.layout, LayoutPreset::Dashboard);
5448        assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
5449        assert!(config.widgets.price_chart);
5450        assert!(config.widgets.volume_chart);
5451        assert!(config.widgets.buy_sell_pressure);
5452        assert!(config.widgets.metrics_panel);
5453        assert!(config.widgets.activity_log);
5454    }
5455
5456    #[test]
5457    fn test_layout_preset_next_cycles() {
5458        assert_eq!(LayoutPreset::Dashboard.next(), LayoutPreset::ChartFocus);
5459        assert_eq!(LayoutPreset::ChartFocus.next(), LayoutPreset::Feed);
5460        assert_eq!(LayoutPreset::Feed.next(), LayoutPreset::Compact);
5461        assert_eq!(LayoutPreset::Compact.next(), LayoutPreset::Dashboard);
5462    }
5463
5464    #[test]
5465    fn test_layout_preset_prev_cycles() {
5466        assert_eq!(LayoutPreset::Dashboard.prev(), LayoutPreset::Compact);
5467        assert_eq!(LayoutPreset::Compact.prev(), LayoutPreset::Feed);
5468        assert_eq!(LayoutPreset::Feed.prev(), LayoutPreset::ChartFocus);
5469        assert_eq!(LayoutPreset::ChartFocus.prev(), LayoutPreset::Dashboard);
5470    }
5471
5472    #[test]
5473    fn test_layout_preset_full_cycle() {
5474        let start = LayoutPreset::Dashboard;
5475        let mut preset = start;
5476        for _ in 0..4 {
5477            preset = preset.next();
5478        }
5479        assert_eq!(preset, start);
5480    }
5481
5482    #[test]
5483    fn test_layout_preset_labels() {
5484        assert_eq!(LayoutPreset::Dashboard.label(), "Dashboard");
5485        assert_eq!(LayoutPreset::ChartFocus.label(), "Chart");
5486        assert_eq!(LayoutPreset::Feed.label(), "Feed");
5487        assert_eq!(LayoutPreset::Compact.label(), "Compact");
5488    }
5489
5490    #[test]
5491    fn test_widget_visibility_default_all_visible() {
5492        let vis = WidgetVisibility::default();
5493        assert_eq!(vis.visible_count(), 5);
5494    }
5495
5496    #[test]
5497    fn test_widget_visibility_toggle_by_index() {
5498        let mut vis = WidgetVisibility::default();
5499        vis.toggle_by_index(1);
5500        assert!(!vis.price_chart);
5501        assert_eq!(vis.visible_count(), 4);
5502
5503        vis.toggle_by_index(2);
5504        assert!(!vis.volume_chart);
5505        assert_eq!(vis.visible_count(), 3);
5506
5507        vis.toggle_by_index(3);
5508        assert!(!vis.buy_sell_pressure);
5509        assert_eq!(vis.visible_count(), 2);
5510
5511        vis.toggle_by_index(4);
5512        assert!(!vis.metrics_panel);
5513        assert_eq!(vis.visible_count(), 1);
5514
5515        vis.toggle_by_index(5);
5516        assert!(!vis.activity_log);
5517        assert_eq!(vis.visible_count(), 0);
5518
5519        // Toggle back
5520        vis.toggle_by_index(1);
5521        assert!(vis.price_chart);
5522        assert_eq!(vis.visible_count(), 1);
5523    }
5524
5525    #[test]
5526    fn test_widget_visibility_toggle_invalid_index() {
5527        let mut vis = WidgetVisibility::default();
5528        vis.toggle_by_index(0);
5529        vis.toggle_by_index(6);
5530        vis.toggle_by_index(100);
5531        assert_eq!(vis.visible_count(), 5); // unchanged
5532    }
5533
5534    #[test]
5535    fn test_auto_select_layout_small_terminal() {
5536        let size = Rect::new(0, 0, 60, 20);
5537        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
5538    }
5539
5540    #[test]
5541    fn test_auto_select_layout_narrow_terminal() {
5542        let size = Rect::new(0, 0, 100, 40);
5543        assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
5544    }
5545
5546    #[test]
5547    fn test_auto_select_layout_short_terminal() {
5548        let size = Rect::new(0, 0, 140, 28);
5549        assert_eq!(auto_select_layout(size), LayoutPreset::ChartFocus);
5550    }
5551
5552    #[test]
5553    fn test_auto_select_layout_large_terminal() {
5554        let size = Rect::new(0, 0, 160, 50);
5555        assert_eq!(auto_select_layout(size), LayoutPreset::Dashboard);
5556    }
5557
5558    #[test]
5559    fn test_auto_select_layout_edge_80x24() {
5560        // Exactly at the threshold: width>=80 and height>=24, but width<120
5561        let size = Rect::new(0, 0, 80, 24);
5562        assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
5563    }
5564
5565    #[test]
5566    fn test_auto_select_layout_edge_79() {
5567        let size = Rect::new(0, 0, 79, 50);
5568        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
5569    }
5570
5571    #[test]
5572    fn test_auto_select_layout_edge_23_height() {
5573        let size = Rect::new(0, 0, 160, 23);
5574        assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
5575    }
5576
5577    #[test]
5578    fn test_layout_dashboard_all_visible() {
5579        let area = Rect::new(0, 0, 120, 40);
5580        let vis = WidgetVisibility::default();
5581        let areas = layout_dashboard(area, &vis);
5582        assert!(areas.price_chart.is_some());
5583        assert!(areas.volume_chart.is_some());
5584        assert!(areas.buy_sell_gauge.is_some());
5585        assert!(areas.metrics_panel.is_some());
5586        assert!(areas.activity_feed.is_some());
5587    }
5588
5589    #[test]
5590    fn test_layout_dashboard_hidden_widget() {
5591        let area = Rect::new(0, 0, 120, 40);
5592        let vis = WidgetVisibility {
5593            price_chart: false,
5594            ..WidgetVisibility::default()
5595        };
5596        let areas = layout_dashboard(area, &vis);
5597        assert!(areas.price_chart.is_none());
5598        assert!(areas.volume_chart.is_some());
5599    }
5600
5601    #[test]
5602    fn test_layout_chart_focus_minimal_overlay() {
5603        let area = Rect::new(0, 0, 120, 40);
5604        let vis = WidgetVisibility::default();
5605        let areas = layout_chart_focus(area, &vis);
5606        assert!(areas.price_chart.is_some());
5607        assert!(areas.volume_chart.is_none()); // Hidden in chart-focus
5608        assert!(areas.buy_sell_gauge.is_none()); // Hidden in chart-focus
5609        assert!(areas.metrics_panel.is_some()); // Minimal stats overlay
5610        assert!(areas.activity_feed.is_none()); // Hidden in chart-focus
5611    }
5612
5613    #[test]
5614    fn test_layout_feed_activity_priority() {
5615        let area = Rect::new(0, 0, 120, 40);
5616        let vis = WidgetVisibility::default();
5617        let areas = layout_feed(area, &vis);
5618        assert!(areas.price_chart.is_none()); // Hidden in feed
5619        assert!(areas.volume_chart.is_none()); // Hidden in feed
5620        assert!(areas.buy_sell_gauge.is_some()); // Top row
5621        assert!(areas.metrics_panel.is_some()); // Top row
5622        assert!(areas.activity_feed.is_some()); // Dominates bottom 75%
5623    }
5624
5625    #[test]
5626    fn test_layout_compact_metrics_only() {
5627        let area = Rect::new(0, 0, 60, 20);
5628        let vis = WidgetVisibility::default();
5629        let areas = layout_compact(area, &vis);
5630        assert!(areas.price_chart.is_none()); // Hidden in compact
5631        assert!(areas.volume_chart.is_none()); // Hidden in compact
5632        assert!(areas.buy_sell_gauge.is_none()); // Hidden in compact
5633        assert!(areas.metrics_panel.is_some()); // Full area
5634        assert!(areas.activity_feed.is_none()); // Hidden in compact
5635    }
5636
5637    #[test]
5638    fn test_ui_render_all_layouts_no_panic() {
5639        let presets = [
5640            LayoutPreset::Dashboard,
5641            LayoutPreset::ChartFocus,
5642            LayoutPreset::Feed,
5643            LayoutPreset::Compact,
5644        ];
5645        for preset in &presets {
5646            let mut terminal = create_test_terminal();
5647            let mut state = create_populated_state();
5648            state.layout = *preset;
5649            state.auto_layout = false; // Don't override during render
5650            terminal.draw(|f| ui(f, &mut state)).unwrap();
5651        }
5652    }
5653
5654    #[test]
5655    fn test_ui_render_compact_small_terminal() {
5656        let backend = TestBackend::new(60, 20);
5657        let mut terminal = Terminal::new(backend).unwrap();
5658        let mut state = create_populated_state();
5659        state.layout = LayoutPreset::Compact;
5660        state.auto_layout = false;
5661        terminal.draw(|f| ui(f, &mut state)).unwrap();
5662    }
5663
5664    #[test]
5665    fn test_ui_auto_layout_selects_compact_for_small() {
5666        let backend = TestBackend::new(60, 20);
5667        let mut terminal = Terminal::new(backend).unwrap();
5668        let mut state = create_populated_state();
5669        state.layout = LayoutPreset::Dashboard;
5670        state.auto_layout = true;
5671        terminal.draw(|f| ui(f, &mut state)).unwrap();
5672        assert_eq!(state.layout, LayoutPreset::Compact);
5673    }
5674
5675    #[test]
5676    fn test_ui_auto_layout_disabled_keeps_preset() {
5677        let backend = TestBackend::new(60, 20);
5678        let mut terminal = Terminal::new(backend).unwrap();
5679        let mut state = create_populated_state();
5680        state.layout = LayoutPreset::Dashboard;
5681        state.auto_layout = false;
5682        terminal.draw(|f| ui(f, &mut state)).unwrap();
5683        assert_eq!(state.layout, LayoutPreset::Dashboard); // Not changed
5684    }
5685
5686    #[test]
5687    fn test_keybinding_l_cycles_layout_forward() {
5688        let mut state = create_populated_state();
5689        state.layout = LayoutPreset::Dashboard;
5690        state.auto_layout = true;
5691
5692        handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
5693        assert_eq!(state.layout, LayoutPreset::ChartFocus);
5694        assert!(!state.auto_layout); // Manual switch disables auto
5695
5696        handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
5697        assert_eq!(state.layout, LayoutPreset::Feed);
5698    }
5699
5700    #[test]
5701    fn test_keybinding_h_cycles_layout_backward() {
5702        let mut state = create_populated_state();
5703        state.layout = LayoutPreset::Dashboard;
5704        state.auto_layout = true;
5705
5706        handle_key_event_on_state(make_key_event(KeyCode::Char('h')), &mut state);
5707        assert_eq!(state.layout, LayoutPreset::Compact);
5708        assert!(!state.auto_layout);
5709    }
5710
5711    #[test]
5712    fn test_keybinding_a_enables_auto_layout() {
5713        let mut state = create_populated_state();
5714        state.auto_layout = false;
5715
5716        handle_key_event_on_state(make_key_event(KeyCode::Char('a')), &mut state);
5717        assert!(state.auto_layout);
5718    }
5719
5720    #[test]
5721    fn test_keybinding_w_widget_toggle_mode() {
5722        let mut state = create_populated_state();
5723        assert!(!state.widget_toggle_mode);
5724
5725        // Press w to enter toggle mode
5726        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
5727        assert!(state.widget_toggle_mode);
5728
5729        // Press 1 to toggle price_chart off
5730        handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
5731        assert!(!state.widget_toggle_mode);
5732        assert!(!state.widgets.price_chart);
5733    }
5734
5735    #[test]
5736    fn test_keybinding_w_cancel_with_non_digit() {
5737        let mut state = create_populated_state();
5738
5739        // Enter widget toggle mode
5740        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
5741        assert!(state.widget_toggle_mode);
5742
5743        // Press 'x' to cancel — should also process 'x' as a normal key (no-op)
5744        handle_key_event_on_state(make_key_event(KeyCode::Char('x')), &mut state);
5745        assert!(!state.widget_toggle_mode);
5746        assert!(state.widgets.price_chart); // unchanged
5747    }
5748
5749    #[test]
5750    fn test_keybinding_w_toggle_multiple_widgets() {
5751        let mut state = create_populated_state();
5752
5753        // Toggle widget 2 (volume_chart)
5754        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
5755        handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
5756        assert!(!state.widgets.volume_chart);
5757
5758        // Toggle widget 4 (metrics_panel)
5759        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
5760        handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
5761        assert!(!state.widgets.metrics_panel);
5762
5763        // Toggle widget 5 (activity_log)
5764        handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
5765        handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
5766        assert!(!state.widgets.activity_log);
5767    }
5768
5769    #[test]
5770    fn test_monitor_config_serde_roundtrip() {
5771        let config = MonitorConfig {
5772            layout: LayoutPreset::ChartFocus,
5773            refresh_seconds: 5,
5774            widgets: WidgetVisibility {
5775                price_chart: true,
5776                volume_chart: false,
5777                buy_sell_pressure: true,
5778                metrics_panel: false,
5779                activity_log: true,
5780                holder_count: true,
5781                liquidity_depth: true,
5782            },
5783            scale: ScaleMode::Log,
5784            color_scheme: ColorScheme::BlueOrange,
5785            alerts: AlertConfig::default(),
5786            export: ExportConfig::default(),
5787            auto_pause_on_input: false,
5788        };
5789
5790        let yaml = serde_yaml::to_string(&config).unwrap();
5791        let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
5792        assert_eq!(parsed.layout, LayoutPreset::ChartFocus);
5793        assert_eq!(parsed.refresh_seconds, 5);
5794        assert!(parsed.widgets.price_chart);
5795        assert!(!parsed.widgets.volume_chart);
5796        assert!(parsed.widgets.buy_sell_pressure);
5797        assert!(!parsed.widgets.metrics_panel);
5798        assert!(parsed.widgets.activity_log);
5799    }
5800
5801    #[test]
5802    fn test_monitor_config_serde_kebab_case() {
5803        let yaml = r#"
5804layout: chart-focus
5805refresh_seconds: 15
5806widgets:
5807  price_chart: true
5808  volume_chart: true
5809  buy_sell_pressure: false
5810  metrics_panel: true
5811  activity_log: false
5812"#;
5813        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
5814        assert_eq!(config.layout, LayoutPreset::ChartFocus);
5815        assert_eq!(config.refresh_seconds, 15);
5816        assert!(!config.widgets.buy_sell_pressure);
5817        assert!(!config.widgets.activity_log);
5818    }
5819
5820    #[test]
5821    fn test_monitor_config_serde_default_missing_fields() {
5822        let yaml = "layout: feed\n";
5823        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
5824        assert_eq!(config.layout, LayoutPreset::Feed);
5825        assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
5826        assert!(config.widgets.price_chart); // defaults
5827    }
5828
5829    #[test]
5830    fn test_state_apply_config() {
5831        let mut state = create_populated_state();
5832        let config = MonitorConfig {
5833            layout: LayoutPreset::Feed,
5834            refresh_seconds: 5,
5835            widgets: WidgetVisibility {
5836                price_chart: false,
5837                volume_chart: true,
5838                buy_sell_pressure: true,
5839                metrics_panel: false,
5840                activity_log: true,
5841                holder_count: true,
5842                liquidity_depth: true,
5843            },
5844            scale: ScaleMode::Log,
5845            color_scheme: ColorScheme::Monochrome,
5846            alerts: AlertConfig::default(),
5847            export: ExportConfig::default(),
5848            auto_pause_on_input: false,
5849        };
5850        state.apply_config(&config);
5851        assert_eq!(state.layout, LayoutPreset::Feed);
5852        assert!(!state.widgets.price_chart);
5853        assert!(!state.widgets.metrics_panel);
5854        assert_eq!(state.refresh_rate, Duration::from_secs(5));
5855    }
5856
5857    #[test]
5858    fn test_layout_all_widgets_hidden_dashboard() {
5859        let area = Rect::new(0, 0, 120, 40);
5860        let vis = WidgetVisibility {
5861            price_chart: false,
5862            volume_chart: false,
5863            buy_sell_pressure: false,
5864            metrics_panel: false,
5865            activity_log: false,
5866            holder_count: false,
5867            liquidity_depth: false,
5868        };
5869        let areas = layout_dashboard(area, &vis);
5870        assert!(areas.price_chart.is_none());
5871        assert!(areas.volume_chart.is_none());
5872        assert!(areas.buy_sell_gauge.is_none());
5873        assert!(areas.metrics_panel.is_none());
5874        assert!(areas.activity_feed.is_none());
5875    }
5876
5877    #[test]
5878    fn test_ui_render_with_hidden_widgets() {
5879        let mut terminal = create_test_terminal();
5880        let mut state = create_populated_state();
5881        state.auto_layout = false;
5882        state.widgets.price_chart = false;
5883        state.widgets.volume_chart = false;
5884        terminal.draw(|f| ui(f, &mut state)).unwrap();
5885    }
5886
5887    #[test]
5888    fn test_ui_render_widget_toggle_mode_footer() {
5889        let mut terminal = create_test_terminal();
5890        let mut state = create_populated_state();
5891        state.auto_layout = false;
5892        state.widget_toggle_mode = true;
5893        terminal.draw(|f| ui(f, &mut state)).unwrap();
5894    }
5895
5896    #[test]
5897    fn test_monitor_state_new_has_layout_fields() {
5898        let token_data = create_test_token_data();
5899        let state = MonitorState::new(&token_data, "ethereum");
5900        assert_eq!(state.layout, LayoutPreset::Dashboard);
5901        assert!(state.auto_layout);
5902        assert!(!state.widget_toggle_mode);
5903        assert_eq!(state.widgets.visible_count(), 5);
5904    }
5905
5906    // ========================================================================
5907    // Phase 6: Data Source Integration tests
5908    // ========================================================================
5909
5910    #[test]
5911    fn test_monitor_state_has_holder_count_field() {
5912        let token_data = create_test_token_data();
5913        let state = MonitorState::new(&token_data, "ethereum");
5914        assert_eq!(state.holder_count, None);
5915        assert!(state.liquidity_pairs.is_empty());
5916        assert_eq!(state.holder_fetch_counter, 0);
5917    }
5918
5919    #[test]
5920    fn test_liquidity_pairs_extracted_on_update() {
5921        let mut token_data = create_test_token_data();
5922        token_data.pairs = vec![
5923            crate::chains::DexPair {
5924                dex_name: "Uniswap V3".to_string(),
5925                pair_address: "0xpair1".to_string(),
5926                base_token: "TEST".to_string(),
5927                quote_token: "WETH".to_string(),
5928                price_usd: 1.0,
5929                volume_24h: 500_000.0,
5930                liquidity_usd: 250_000.0,
5931                price_change_24h: 5.0,
5932                buys_24h: 50,
5933                sells_24h: 25,
5934                buys_6h: 10,
5935                sells_6h: 5,
5936                buys_1h: 3,
5937                sells_1h: 2,
5938                pair_created_at: None,
5939                url: None,
5940            },
5941            crate::chains::DexPair {
5942                dex_name: "SushiSwap".to_string(),
5943                pair_address: "0xpair2".to_string(),
5944                base_token: "TEST".to_string(),
5945                quote_token: "USDC".to_string(),
5946                price_usd: 1.0,
5947                volume_24h: 300_000.0,
5948                liquidity_usd: 150_000.0,
5949                price_change_24h: 3.0,
5950                buys_24h: 30,
5951                sells_24h: 15,
5952                buys_6h: 8,
5953                sells_6h: 4,
5954                buys_1h: 2,
5955                sells_1h: 1,
5956                pair_created_at: None,
5957                url: None,
5958            },
5959        ];
5960
5961        let mut state = MonitorState::new(&token_data, "ethereum");
5962        state.update(&token_data);
5963
5964        assert_eq!(state.liquidity_pairs.len(), 2);
5965        assert!(state.liquidity_pairs[0].0.contains("Uniswap V3"));
5966        assert!((state.liquidity_pairs[0].1 - 250_000.0).abs() < 0.01);
5967    }
5968
5969    #[test]
5970    fn test_render_liquidity_depth_no_panic() {
5971        let mut terminal = create_test_terminal();
5972        let mut state = create_populated_state();
5973        state.liquidity_pairs = vec![
5974            ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
5975            ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
5976        ];
5977        terminal
5978            .draw(|f| render_liquidity_depth(f, f.area(), &state))
5979            .unwrap();
5980    }
5981
5982    #[test]
5983    fn test_render_liquidity_depth_empty() {
5984        let mut terminal = create_test_terminal();
5985        let state = create_populated_state();
5986        terminal
5987            .draw(|f| render_liquidity_depth(f, f.area(), &state))
5988            .unwrap();
5989    }
5990
5991    #[test]
5992    fn test_render_metrics_with_holder_count() {
5993        let mut terminal = create_test_terminal();
5994        let mut state = create_populated_state();
5995        state.holder_count = Some(42_000);
5996        terminal
5997            .draw(|f| render_metrics_panel(f, f.area(), &state))
5998            .unwrap();
5999    }
6000
6001    // ========================================================================
6002    // Phase 7: Alert System tests
6003    // ========================================================================
6004
6005    #[test]
6006    fn test_alert_config_default() {
6007        let config = AlertConfig::default();
6008        assert!(config.price_min.is_none());
6009        assert!(config.price_max.is_none());
6010        assert!(config.whale_min_usd.is_none());
6011        assert!(config.volume_spike_threshold_pct.is_none());
6012    }
6013
6014    #[test]
6015    fn test_alert_price_min_triggers() {
6016        let token_data = create_test_token_data();
6017        let mut state = MonitorState::new(&token_data, "ethereum");
6018        state.alerts.price_min = Some(2.0); // Price is 1.0, below min of 2.0
6019        state.update(&token_data);
6020        assert!(
6021            !state.active_alerts.is_empty(),
6022            "Should have price-min alert"
6023        );
6024        assert!(state.active_alerts[0].message.contains("below min"));
6025    }
6026
6027    #[test]
6028    fn test_alert_price_max_triggers() {
6029        let mut token_data = create_test_token_data();
6030        token_data.price_usd = 100.0;
6031        let mut state = MonitorState::new(&token_data, "ethereum");
6032        state.alerts.price_max = Some(50.0); // Price 100.0 above max of 50.0
6033        state.update(&token_data);
6034        assert!(
6035            !state.active_alerts.is_empty(),
6036            "Should have price-max alert"
6037        );
6038        assert!(state.active_alerts[0].message.contains("above max"));
6039    }
6040
6041    #[test]
6042    fn test_alert_no_trigger_within_bounds() {
6043        let token_data = create_test_token_data();
6044        let mut state = MonitorState::new(&token_data, "ethereum");
6045        state.alerts.price_min = Some(0.5); // Price 1.0 is above min
6046        state.alerts.price_max = Some(2.0); // Price 1.0 is below max
6047        state.update(&token_data);
6048        assert!(
6049            state.active_alerts.is_empty(),
6050            "Should have no alerts when price is within bounds"
6051        );
6052    }
6053
6054    #[test]
6055    fn test_alert_volume_spike_triggers() {
6056        let token_data = create_test_token_data();
6057        let mut state = MonitorState::new(&token_data, "ethereum");
6058        state.alerts.volume_spike_threshold_pct = Some(10.0);
6059        state.volume_avg = 500_000.0; // Average volume is 500K
6060
6061        // Token data has volume_24h of 1M, which is +100% vs avg — should trigger
6062        state.update(&token_data);
6063        let spike_alerts: Vec<_> = state
6064            .active_alerts
6065            .iter()
6066            .filter(|a| a.message.contains("spike"))
6067            .collect();
6068        assert!(!spike_alerts.is_empty(), "Should have volume spike alert");
6069    }
6070
6071    #[test]
6072    fn test_alert_flash_timer_set() {
6073        let token_data = create_test_token_data();
6074        let mut state = MonitorState::new(&token_data, "ethereum");
6075        state.alerts.price_min = Some(2.0);
6076        state.update(&token_data);
6077        assert!(state.alert_flash_until.is_some());
6078    }
6079
6080    #[test]
6081    fn test_render_alert_overlay_no_panic() {
6082        let mut terminal = create_test_terminal();
6083        let mut state = create_populated_state();
6084        state.active_alerts.push(ActiveAlert {
6085            message: "⚠ Test alert".to_string(),
6086            triggered_at: Instant::now(),
6087        });
6088        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
6089        terminal
6090            .draw(|f| render_alert_overlay(f, f.area(), &state))
6091            .unwrap();
6092    }
6093
6094    #[test]
6095    fn test_render_alert_overlay_empty() {
6096        let mut terminal = create_test_terminal();
6097        let state = create_populated_state();
6098        terminal
6099            .draw(|f| render_alert_overlay(f, f.area(), &state))
6100            .unwrap();
6101    }
6102
6103    #[test]
6104    fn test_alert_config_serde_roundtrip() {
6105        let config = AlertConfig {
6106            price_min: Some(0.5),
6107            price_max: Some(2.0),
6108            whale_min_usd: Some(10_000.0),
6109            volume_spike_threshold_pct: Some(50.0),
6110        };
6111        let yaml = serde_yaml::to_string(&config).unwrap();
6112        let parsed: AlertConfig = serde_yaml::from_str(&yaml).unwrap();
6113        assert_eq!(parsed.price_min, Some(0.5));
6114        assert_eq!(parsed.price_max, Some(2.0));
6115        assert_eq!(parsed.whale_min_usd, Some(10_000.0));
6116        assert_eq!(parsed.volume_spike_threshold_pct, Some(50.0));
6117    }
6118
6119    #[test]
6120    fn test_ui_with_active_alerts() {
6121        let mut terminal = create_test_terminal();
6122        let mut state = create_populated_state();
6123        state.active_alerts.push(ActiveAlert {
6124            message: "⚠ Price below min".to_string(),
6125            triggered_at: Instant::now(),
6126        });
6127        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
6128        terminal.draw(|f| ui(f, &mut state)).unwrap();
6129    }
6130
6131    // ========================================================================
6132    // Phase 8: CSV Export tests
6133    // ========================================================================
6134
6135    #[test]
6136    fn test_export_config_default() {
6137        let config = ExportConfig::default();
6138        assert!(config.path.is_none());
6139    }
6140
6141    /// Helper to create an export in a temp directory, avoiding race conditions.
6142    fn start_export_in_temp(state: &mut MonitorState) -> PathBuf {
6143        use std::sync::atomic::{AtomicU64, Ordering};
6144        static COUNTER: AtomicU64 = AtomicU64::new(0);
6145        let id = COUNTER.fetch_add(1, Ordering::Relaxed);
6146        let dir =
6147            std::env::temp_dir().join(format!("scope_test_export_{}_{}", std::process::id(), id));
6148        let _ = fs::create_dir_all(&dir);
6149        let filename = format!("{}_test_{}.csv", state.symbol, id);
6150        let path = dir.join(filename);
6151
6152        let mut file = fs::File::create(&path).expect("failed to create export test file");
6153        let header = "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
6154        file.write_all(header.as_bytes())
6155            .expect("failed to write header");
6156        drop(file); // Ensure file is flushed and closed
6157
6158        state.export_path = Some(path.clone());
6159        state.export_active = true;
6160        path
6161    }
6162
6163    #[test]
6164    fn test_export_start_creates_file() {
6165        let token_data = create_test_token_data();
6166        let mut state = MonitorState::new(&token_data, "ethereum");
6167        let path = start_export_in_temp(&mut state);
6168
6169        assert!(state.export_active);
6170        assert!(state.export_path.is_some());
6171        assert!(path.exists(), "Export file should exist");
6172
6173        // Cleanup
6174        let _ = std::fs::remove_file(&path);
6175    }
6176
6177    #[test]
6178    fn test_export_stop() {
6179        let token_data = create_test_token_data();
6180        let mut state = MonitorState::new(&token_data, "ethereum");
6181        let path = start_export_in_temp(&mut state);
6182        state.stop_export();
6183
6184        assert!(!state.export_active);
6185        assert!(state.export_path.is_none());
6186
6187        // Cleanup
6188        let _ = std::fs::remove_file(&path);
6189    }
6190
6191    #[test]
6192    fn test_export_toggle() {
6193        let token_data = create_test_token_data();
6194        let mut state = MonitorState::new(&token_data, "ethereum");
6195
6196        state.toggle_export();
6197        assert!(state.export_active);
6198        let path = state.export_path.clone().unwrap();
6199
6200        state.toggle_export();
6201        assert!(!state.export_active);
6202
6203        // Cleanup
6204        let _ = std::fs::remove_file(path);
6205    }
6206
6207    #[test]
6208    fn test_export_writes_csv_rows() {
6209        let token_data = create_test_token_data();
6210        let mut state = MonitorState::new(&token_data, "ethereum");
6211        let path = start_export_in_temp(&mut state);
6212
6213        // Simulate a few updates
6214        state.update(&token_data);
6215        state.update(&token_data);
6216
6217        let contents = std::fs::read_to_string(&path).unwrap();
6218        let lines: Vec<&str> = contents.lines().collect();
6219
6220        assert!(
6221            lines.len() >= 3,
6222            "Should have header + 2 data rows, got {}",
6223            lines.len()
6224        );
6225        assert!(lines[0].starts_with("timestamp,price_usd"));
6226
6227        // Cleanup
6228        state.stop_export();
6229        let _ = std::fs::remove_file(path);
6230    }
6231
6232    #[test]
6233    fn test_keybinding_e_toggles_export() {
6234        let token_data = create_test_token_data();
6235        let mut state = MonitorState::new(&token_data, "ethereum");
6236
6237        handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
6238        assert!(state.export_active);
6239        let path = state.export_path.clone().unwrap();
6240
6241        handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
6242        assert!(!state.export_active);
6243
6244        // Cleanup
6245        let _ = std::fs::remove_file(path);
6246    }
6247
6248    #[test]
6249    fn test_render_footer_with_export_active() {
6250        let mut terminal = create_test_terminal();
6251        let mut state = create_populated_state();
6252        state.export_active = true;
6253        terminal
6254            .draw(|f| render_footer(f, f.area(), &state))
6255            .unwrap();
6256    }
6257
6258    #[test]
6259    fn test_export_config_serde_roundtrip() {
6260        let config = ExportConfig {
6261            path: Some("./my-exports".to_string()),
6262        };
6263        let yaml = serde_yaml::to_string(&config).unwrap();
6264        let parsed: ExportConfig = serde_yaml::from_str(&yaml).unwrap();
6265        assert_eq!(parsed.path, Some("./my-exports".to_string()));
6266    }
6267
6268    // ========================================================================
6269    // Phase 9: Auto-Pause tests
6270    // ========================================================================
6271
6272    #[test]
6273    fn test_auto_pause_default_disabled() {
6274        let token_data = create_test_token_data();
6275        let state = MonitorState::new(&token_data, "ethereum");
6276        assert!(!state.auto_pause_on_input);
6277        assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
6278    }
6279
6280    #[test]
6281    fn test_auto_pause_blocks_refresh() {
6282        let token_data = create_test_token_data();
6283        let mut state = MonitorState::new(&token_data, "ethereum");
6284        state.auto_pause_on_input = true;
6285        state.refresh_rate = Duration::from_secs(1);
6286
6287        // Simulate fresh input
6288        state.last_input_at = Instant::now();
6289        state.last_update = Instant::now() - Duration::from_secs(10); // Long overdue
6290
6291        // Auto-pause should block refresh since we just had input
6292        assert!(!state.should_refresh());
6293    }
6294
6295    #[test]
6296    fn test_auto_pause_allows_refresh_after_timeout() {
6297        let token_data = create_test_token_data();
6298        let mut state = MonitorState::new(&token_data, "ethereum");
6299        state.auto_pause_on_input = true;
6300        state.refresh_rate = Duration::from_secs(1);
6301        state.auto_pause_timeout = Duration::from_millis(1); // Very short timeout
6302
6303        // Simulate old input (long ago)
6304        state.last_input_at = Instant::now() - Duration::from_secs(10);
6305        state.last_update = Instant::now() - Duration::from_secs(10);
6306
6307        // Should allow refresh since input was long ago
6308        assert!(state.should_refresh());
6309    }
6310
6311    #[test]
6312    fn test_auto_pause_disabled_does_not_block() {
6313        let token_data = create_test_token_data();
6314        let mut state = MonitorState::new(&token_data, "ethereum");
6315        state.auto_pause_on_input = false;
6316        state.refresh_rate = Duration::from_secs(1);
6317
6318        state.last_input_at = Instant::now(); // Fresh input
6319        state.last_update = Instant::now() - Duration::from_secs(10);
6320
6321        // Should still refresh because auto-pause is disabled
6322        assert!(state.should_refresh());
6323    }
6324
6325    #[test]
6326    fn test_is_auto_paused() {
6327        let token_data = create_test_token_data();
6328        let mut state = MonitorState::new(&token_data, "ethereum");
6329
6330        // Not auto-paused when disabled
6331        state.auto_pause_on_input = false;
6332        state.last_input_at = Instant::now();
6333        assert!(!state.is_auto_paused());
6334
6335        // Auto-paused when enabled and input is recent
6336        state.auto_pause_on_input = true;
6337        state.last_input_at = Instant::now();
6338        assert!(state.is_auto_paused());
6339
6340        // Not auto-paused when input is old
6341        state.last_input_at = Instant::now() - Duration::from_secs(10);
6342        assert!(!state.is_auto_paused());
6343    }
6344
6345    #[test]
6346    fn test_keybinding_shift_p_toggles_auto_pause() {
6347        let token_data = create_test_token_data();
6348        let mut state = MonitorState::new(&token_data, "ethereum");
6349        assert!(!state.auto_pause_on_input);
6350
6351        let shift_p = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
6352        handle_key_event_on_state(shift_p, &mut state);
6353        assert!(state.auto_pause_on_input);
6354
6355        handle_key_event_on_state(shift_p, &mut state);
6356        assert!(!state.auto_pause_on_input);
6357    }
6358
6359    #[test]
6360    fn test_keybinding_updates_last_input_at() {
6361        let token_data = create_test_token_data();
6362        let mut state = MonitorState::new(&token_data, "ethereum");
6363
6364        // Set last_input_at to the past
6365        state.last_input_at = Instant::now() - Duration::from_secs(60);
6366        let old_input = state.last_input_at;
6367
6368        // Any key event should update last_input_at
6369        handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
6370        assert!(state.last_input_at > old_input);
6371    }
6372
6373    #[test]
6374    fn test_render_footer_auto_paused() {
6375        let mut terminal = create_test_terminal();
6376        let mut state = create_populated_state();
6377        state.auto_pause_on_input = true;
6378        state.last_input_at = Instant::now(); // Recent input -> auto-paused
6379        terminal
6380            .draw(|f| render_footer(f, f.area(), &state))
6381            .unwrap();
6382    }
6383
6384    #[test]
6385    fn test_config_auto_pause_applied() {
6386        let mut state = create_populated_state();
6387        let config = MonitorConfig {
6388            auto_pause_on_input: true,
6389            ..MonitorConfig::default()
6390        };
6391        state.apply_config(&config);
6392        assert!(state.auto_pause_on_input);
6393    }
6394
6395    // ========================================================================
6396    // Combined full-UI tests for new features
6397    // ========================================================================
6398
6399    #[test]
6400    fn test_ui_render_all_layouts_with_alerts_and_export() {
6401        for preset in &[
6402            LayoutPreset::Dashboard,
6403            LayoutPreset::ChartFocus,
6404            LayoutPreset::Feed,
6405            LayoutPreset::Compact,
6406        ] {
6407            let mut terminal = create_test_terminal();
6408            let mut state = create_populated_state();
6409            state.layout = *preset;
6410            state.auto_layout = false;
6411            state.export_active = true;
6412            state.active_alerts.push(ActiveAlert {
6413                message: "⚠ Test alert".to_string(),
6414                triggered_at: Instant::now(),
6415            });
6416            terminal.draw(|f| ui(f, &mut state)).unwrap();
6417        }
6418    }
6419
6420    #[test]
6421    fn test_ui_render_with_liquidity_data() {
6422        let mut terminal = create_test_terminal();
6423        let mut state = create_populated_state();
6424        state.liquidity_pairs = vec![
6425            ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
6426            ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
6427        ];
6428        terminal.draw(|f| ui(f, &mut state)).unwrap();
6429    }
6430
6431    #[test]
6432    fn test_monitor_config_full_serde_roundtrip() {
6433        let config = MonitorConfig {
6434            layout: LayoutPreset::Dashboard,
6435            refresh_seconds: 10,
6436            widgets: WidgetVisibility::default(),
6437            scale: ScaleMode::Log,
6438            color_scheme: ColorScheme::BlueOrange,
6439            alerts: AlertConfig {
6440                price_min: Some(0.5),
6441                price_max: Some(10.0),
6442                whale_min_usd: Some(50_000.0),
6443                volume_spike_threshold_pct: Some(100.0),
6444            },
6445            export: ExportConfig {
6446                path: Some("./exports".to_string()),
6447            },
6448            auto_pause_on_input: true,
6449        };
6450
6451        let yaml = serde_yaml::to_string(&config).unwrap();
6452        let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
6453        assert_eq!(parsed.layout, LayoutPreset::Dashboard);
6454        assert_eq!(parsed.refresh_seconds, 10);
6455        assert_eq!(parsed.alerts.price_min, Some(0.5));
6456        assert_eq!(parsed.alerts.price_max, Some(10.0));
6457        assert_eq!(parsed.export.path, Some("./exports".to_string()));
6458        assert!(parsed.auto_pause_on_input);
6459    }
6460
6461    #[test]
6462    fn test_monitor_config_serde_defaults_for_new_fields() {
6463        // Only specify old fields — new fields should default
6464        let yaml = r#"
6465layout: dashboard
6466refresh_seconds: 5
6467"#;
6468        let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
6469        assert!(config.alerts.price_min.is_none());
6470        assert!(config.export.path.is_none());
6471        assert!(!config.auto_pause_on_input);
6472    }
6473
6474    #[test]
6475    fn test_quit_stops_export() {
6476        let token_data = create_test_token_data();
6477        let mut state = MonitorState::new(&token_data, "ethereum");
6478        let path = start_export_in_temp(&mut state);
6479
6480        let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('q')), &mut state);
6481        assert!(exit);
6482        assert!(!state.export_active);
6483
6484        // Cleanup
6485        let _ = std::fs::remove_file(path);
6486    }
6487
6488    #[test]
6489    fn test_monitor_state_new_has_alert_export_autopause_fields() {
6490        let token_data = create_test_token_data();
6491        let state = MonitorState::new(&token_data, "ethereum");
6492
6493        // Alert fields
6494        assert!(state.active_alerts.is_empty());
6495        assert!(state.alert_flash_until.is_none());
6496        assert!(state.alerts.price_min.is_none());
6497
6498        // Export fields
6499        assert!(!state.export_active);
6500        assert!(state.export_path.is_none());
6501
6502        // Auto-pause fields
6503        assert!(!state.auto_pause_on_input);
6504        assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
6505    }
6506
6507    // ========================================================================
6508    // Coverage gap: Serde round-trip tests for enums
6509    // ========================================================================
6510
6511    #[test]
6512    fn test_scale_mode_serde_roundtrip() {
6513        for mode in &[ScaleMode::Linear, ScaleMode::Log] {
6514            let yaml = serde_yaml::to_string(mode).unwrap();
6515            let parsed: ScaleMode = serde_yaml::from_str(&yaml).unwrap();
6516            assert_eq!(&parsed, mode);
6517        }
6518    }
6519
6520    #[test]
6521    fn test_scale_mode_serde_kebab_case() {
6522        let parsed: ScaleMode = serde_yaml::from_str("linear").unwrap();
6523        assert_eq!(parsed, ScaleMode::Linear);
6524        let parsed: ScaleMode = serde_yaml::from_str("log").unwrap();
6525        assert_eq!(parsed, ScaleMode::Log);
6526    }
6527
6528    #[test]
6529    fn test_scale_mode_toggle() {
6530        assert_eq!(ScaleMode::Linear.toggle(), ScaleMode::Log);
6531        assert_eq!(ScaleMode::Log.toggle(), ScaleMode::Linear);
6532    }
6533
6534    #[test]
6535    fn test_scale_mode_label() {
6536        assert_eq!(ScaleMode::Linear.label(), "Lin");
6537        assert_eq!(ScaleMode::Log.label(), "Log");
6538    }
6539
6540    #[test]
6541    fn test_color_scheme_serde_roundtrip() {
6542        for scheme in &[
6543            ColorScheme::GreenRed,
6544            ColorScheme::BlueOrange,
6545            ColorScheme::Monochrome,
6546        ] {
6547            let yaml = serde_yaml::to_string(scheme).unwrap();
6548            let parsed: ColorScheme = serde_yaml::from_str(&yaml).unwrap();
6549            assert_eq!(&parsed, scheme);
6550        }
6551    }
6552
6553    #[test]
6554    fn test_color_scheme_serde_kebab_case() {
6555        let parsed: ColorScheme = serde_yaml::from_str("green-red").unwrap();
6556        assert_eq!(parsed, ColorScheme::GreenRed);
6557        let parsed: ColorScheme = serde_yaml::from_str("blue-orange").unwrap();
6558        assert_eq!(parsed, ColorScheme::BlueOrange);
6559        let parsed: ColorScheme = serde_yaml::from_str("monochrome").unwrap();
6560        assert_eq!(parsed, ColorScheme::Monochrome);
6561    }
6562
6563    #[test]
6564    fn test_color_scheme_cycle() {
6565        assert_eq!(ColorScheme::GreenRed.next(), ColorScheme::BlueOrange);
6566        assert_eq!(ColorScheme::BlueOrange.next(), ColorScheme::Monochrome);
6567        assert_eq!(ColorScheme::Monochrome.next(), ColorScheme::GreenRed);
6568    }
6569
6570    #[test]
6571    fn test_color_scheme_label() {
6572        assert_eq!(ColorScheme::GreenRed.label(), "G/R");
6573        assert_eq!(ColorScheme::BlueOrange.label(), "B/O");
6574        assert_eq!(ColorScheme::Monochrome.label(), "Mono");
6575    }
6576
6577    #[test]
6578    fn test_color_palette_fields_populated() {
6579        // Verify each palette has distinct meaningful values
6580        for scheme in &[
6581            ColorScheme::GreenRed,
6582            ColorScheme::BlueOrange,
6583            ColorScheme::Monochrome,
6584        ] {
6585            let pal = scheme.palette();
6586            // up and down colors should differ (visually distinct)
6587            assert_ne!(
6588                format!("{:?}", pal.up),
6589                format!("{:?}", pal.down),
6590                "Up/down should differ for {:?}",
6591                scheme
6592            );
6593        }
6594    }
6595
6596    #[test]
6597    fn test_layout_preset_serde_roundtrip() {
6598        for preset in &[
6599            LayoutPreset::Dashboard,
6600            LayoutPreset::ChartFocus,
6601            LayoutPreset::Feed,
6602            LayoutPreset::Compact,
6603        ] {
6604            let yaml = serde_yaml::to_string(preset).unwrap();
6605            let parsed: LayoutPreset = serde_yaml::from_str(&yaml).unwrap();
6606            assert_eq!(&parsed, preset);
6607        }
6608    }
6609
6610    #[test]
6611    fn test_layout_preset_serde_kebab_case() {
6612        let parsed: LayoutPreset = serde_yaml::from_str("dashboard").unwrap();
6613        assert_eq!(parsed, LayoutPreset::Dashboard);
6614        let parsed: LayoutPreset = serde_yaml::from_str("chart-focus").unwrap();
6615        assert_eq!(parsed, LayoutPreset::ChartFocus);
6616        let parsed: LayoutPreset = serde_yaml::from_str("feed").unwrap();
6617        assert_eq!(parsed, LayoutPreset::Feed);
6618        let parsed: LayoutPreset = serde_yaml::from_str("compact").unwrap();
6619        assert_eq!(parsed, LayoutPreset::Compact);
6620    }
6621
6622    #[test]
6623    fn test_widget_visibility_serde_roundtrip() {
6624        let vis = WidgetVisibility {
6625            price_chart: false,
6626            volume_chart: true,
6627            buy_sell_pressure: false,
6628            metrics_panel: true,
6629            activity_log: false,
6630            holder_count: false,
6631            liquidity_depth: true,
6632        };
6633        let yaml = serde_yaml::to_string(&vis).unwrap();
6634        let parsed: WidgetVisibility = serde_yaml::from_str(&yaml).unwrap();
6635        assert!(!parsed.price_chart);
6636        assert!(parsed.volume_chart);
6637        assert!(!parsed.buy_sell_pressure);
6638        assert!(parsed.metrics_panel);
6639        assert!(!parsed.activity_log);
6640        assert!(!parsed.holder_count);
6641        assert!(parsed.liquidity_depth);
6642    }
6643
6644    #[test]
6645    fn test_data_point_serde_roundtrip() {
6646        let dp = DataPoint {
6647            timestamp: 1700000000.5,
6648            value: 42.123456,
6649            is_real: true,
6650        };
6651        let json = serde_json::to_string(&dp).unwrap();
6652        let parsed: DataPoint = serde_json::from_str(&json).unwrap();
6653        assert!((parsed.timestamp - dp.timestamp).abs() < 0.001);
6654        assert!((parsed.value - dp.value).abs() < 0.001);
6655        assert_eq!(parsed.is_real, dp.is_real);
6656    }
6657
6658    // ========================================================================
6659    // Coverage gap: Key handler tests for scale and color scheme
6660    // ========================================================================
6661
6662    #[test]
6663    fn test_handle_key_scale_toggle_s() {
6664        let token_data = create_test_token_data();
6665        let mut state = MonitorState::new(&token_data, "ethereum");
6666        assert_eq!(state.scale_mode, ScaleMode::Linear);
6667
6668        handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
6669        assert_eq!(state.scale_mode, ScaleMode::Log);
6670
6671        handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
6672        assert_eq!(state.scale_mode, ScaleMode::Linear);
6673    }
6674
6675    #[test]
6676    fn test_handle_key_color_scheme_cycle_slash() {
6677        let token_data = create_test_token_data();
6678        let mut state = MonitorState::new(&token_data, "ethereum");
6679        assert_eq!(state.color_scheme, ColorScheme::GreenRed);
6680
6681        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
6682        assert_eq!(state.color_scheme, ColorScheme::BlueOrange);
6683
6684        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
6685        assert_eq!(state.color_scheme, ColorScheme::Monochrome);
6686
6687        handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
6688        assert_eq!(state.color_scheme, ColorScheme::GreenRed);
6689    }
6690
6691    // ========================================================================
6692    // Coverage gap: Volume profile chart render tests
6693    // ========================================================================
6694
6695    #[test]
6696    fn test_render_volume_profile_chart_no_panic() {
6697        let mut terminal = create_test_terminal();
6698        let state = create_populated_state();
6699        terminal
6700            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
6701            .unwrap();
6702    }
6703
6704    #[test]
6705    fn test_render_volume_profile_chart_empty_data() {
6706        let mut terminal = create_test_terminal();
6707        let token_data = create_test_token_data();
6708        let mut state = MonitorState::new(&token_data, "ethereum");
6709        state.price_history.clear();
6710        state.volume_history.clear();
6711        terminal
6712            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
6713            .unwrap();
6714    }
6715
6716    #[test]
6717    fn test_render_volume_profile_chart_single_price() {
6718        // When all prices are identical, there's no range -> "no price range" path
6719        let mut terminal = create_test_terminal();
6720        let mut token_data = create_test_token_data();
6721        token_data.price_usd = 1.0;
6722        let mut state = MonitorState::new(&token_data, "ethereum");
6723        // Clear and add identical-price data points
6724        state.price_history.clear();
6725        state.volume_history.clear();
6726        let now = chrono::Utc::now().timestamp() as f64;
6727        for i in 0..5 {
6728            state.price_history.push_back(DataPoint {
6729                timestamp: now - (5.0 - i as f64) * 60.0,
6730                value: 1.0, // all same price
6731                is_real: true,
6732            });
6733            state.volume_history.push_back(DataPoint {
6734                timestamp: now - (5.0 - i as f64) * 60.0,
6735                value: 1000.0,
6736                is_real: true,
6737            });
6738        }
6739        terminal
6740            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
6741            .unwrap();
6742    }
6743
6744    #[test]
6745    fn test_render_volume_profile_chart_narrow_terminal() {
6746        let backend = TestBackend::new(30, 15);
6747        let mut terminal = Terminal::new(backend).unwrap();
6748        let state = create_populated_state();
6749        terminal
6750            .draw(|f| render_volume_profile_chart(f, f.area(), &state))
6751            .unwrap();
6752    }
6753
6754    // ========================================================================
6755    // Coverage gap: Log scale rendering tests
6756    // ========================================================================
6757
6758    #[test]
6759    fn test_render_price_chart_log_scale() {
6760        let mut terminal = create_test_terminal();
6761        let mut state = create_populated_state();
6762        state.scale_mode = ScaleMode::Log;
6763        terminal
6764            .draw(|f| render_price_chart(f, f.area(), &state))
6765            .unwrap();
6766    }
6767
6768    #[test]
6769    fn test_render_candlestick_chart_log_scale() {
6770        let mut terminal = create_test_terminal();
6771        let mut state = create_populated_state();
6772        state.scale_mode = ScaleMode::Log;
6773        state.chart_mode = ChartMode::Candlestick;
6774        terminal
6775            .draw(|f| render_candlestick_chart(f, f.area(), &state))
6776            .unwrap();
6777    }
6778
6779    #[test]
6780    fn test_render_price_chart_log_scale_zero_price() {
6781        // Verify log scale handles zero/near-zero prices safely
6782        let mut terminal = create_test_terminal();
6783        let mut token_data = create_test_token_data();
6784        token_data.price_usd = 0.0001;
6785        let mut state = MonitorState::new(&token_data, "ethereum");
6786        state.scale_mode = ScaleMode::Log;
6787        for i in 0..10 {
6788            let mut data = token_data.clone();
6789            data.price_usd = 0.0001 + (i as f64 * 0.00001);
6790            state.update(&data);
6791        }
6792        terminal
6793            .draw(|f| render_price_chart(f, f.area(), &state))
6794            .unwrap();
6795    }
6796
6797    // ========================================================================
6798    // Coverage gap: Color scheme rendering tests
6799    // ========================================================================
6800
6801    #[test]
6802    fn test_render_ui_with_all_color_schemes() {
6803        for scheme in &[
6804            ColorScheme::GreenRed,
6805            ColorScheme::BlueOrange,
6806            ColorScheme::Monochrome,
6807        ] {
6808            let mut terminal = create_test_terminal();
6809            let mut state = create_populated_state();
6810            state.color_scheme = *scheme;
6811            terminal.draw(|f| ui(f, &mut state)).unwrap();
6812        }
6813    }
6814
6815    #[test]
6816    fn test_render_volume_chart_all_color_schemes() {
6817        for scheme in &[
6818            ColorScheme::GreenRed,
6819            ColorScheme::BlueOrange,
6820            ColorScheme::Monochrome,
6821        ] {
6822            let mut terminal = create_test_terminal();
6823            let mut state = create_populated_state();
6824            state.color_scheme = *scheme;
6825            terminal
6826                .draw(|f| render_volume_chart(f, f.area(), &state))
6827                .unwrap();
6828        }
6829    }
6830
6831    // ========================================================================
6832    // Coverage gap: Activity feed dedicated render tests
6833    // ========================================================================
6834
6835    #[test]
6836    fn test_render_activity_feed_no_panic() {
6837        let mut terminal = create_test_terminal();
6838        let mut state = create_populated_state();
6839        for i in 0..5 {
6840            state.log_messages.push_back(format!("Event {}", i));
6841        }
6842        terminal
6843            .draw(|f| render_activity_feed(f, f.area(), &mut state))
6844            .unwrap();
6845    }
6846
6847    #[test]
6848    fn test_render_activity_feed_empty_log() {
6849        let mut terminal = create_test_terminal();
6850        let token_data = create_test_token_data();
6851        let mut state = MonitorState::new(&token_data, "ethereum");
6852        state.log_messages.clear();
6853        terminal
6854            .draw(|f| render_activity_feed(f, f.area(), &mut state))
6855            .unwrap();
6856    }
6857
6858    #[test]
6859    fn test_render_activity_feed_with_selection() {
6860        let mut terminal = create_test_terminal();
6861        let mut state = create_populated_state();
6862        for i in 0..10 {
6863            state.log_messages.push_back(format!("Event {}", i));
6864        }
6865        state.scroll_log_down();
6866        state.scroll_log_down();
6867        state.scroll_log_down();
6868        terminal
6869            .draw(|f| render_activity_feed(f, f.area(), &mut state))
6870            .unwrap();
6871    }
6872
6873    // ========================================================================
6874    // Coverage gap: Alert edge cases
6875    // ========================================================================
6876
6877    #[test]
6878    fn test_alert_whale_zero_transactions() {
6879        let mut token_data = create_test_token_data();
6880        token_data.total_buys_24h = 0;
6881        token_data.total_sells_24h = 0;
6882        let mut state = MonitorState::new(&token_data, "ethereum");
6883        state.alerts.whale_min_usd = Some(100.0);
6884        state.update(&token_data);
6885        // With zero total txs, whale detection should NOT fire
6886        let whale_alerts: Vec<_> = state
6887            .active_alerts
6888            .iter()
6889            .filter(|a| a.message.contains("whale") || a.message.contains("🐋"))
6890            .collect();
6891        assert!(
6892            whale_alerts.is_empty(),
6893            "Whale alert should not fire with zero transactions"
6894        );
6895    }
6896
6897    #[test]
6898    fn test_alert_multiple_simultaneous() {
6899        let mut token_data = create_test_token_data();
6900        token_data.price_usd = 0.1; // below min
6901        let mut state = MonitorState::new(&token_data, "ethereum");
6902        state.alerts.price_min = Some(0.5); // will fire: price 0.1 < 0.5
6903        state.alerts.price_max = Some(0.05); // will fire: price 0.1 > 0.05
6904        state.alerts.volume_spike_threshold_pct = Some(1.0);
6905        state.volume_avg = 100.0; // volume_24h 1M vs avg 100 => huge spike
6906
6907        state.update(&token_data);
6908        // Should have multiple alerts
6909        assert!(
6910            state.active_alerts.len() >= 2,
6911            "Expected multiple alerts, got {}",
6912            state.active_alerts.len()
6913        );
6914    }
6915
6916    #[test]
6917    fn test_alert_clears_on_next_update() {
6918        let token_data = create_test_token_data();
6919        let mut state = MonitorState::new(&token_data, "ethereum");
6920        state.alerts.price_min = Some(2.0); // price 1.0 < 2.0 -> fires
6921        state.update(&token_data);
6922        assert!(!state.active_alerts.is_empty());
6923
6924        // Update with price above min -> should clear
6925        let mut above_min = token_data.clone();
6926        above_min.price_usd = 3.0;
6927        state.alerts.price_min = Some(2.0);
6928        state.update(&above_min);
6929        // check_alerts clears alerts each time and re-evaluates
6930        let price_min_alerts: Vec<_> = state
6931            .active_alerts
6932            .iter()
6933            .filter(|a| a.message.contains("below min"))
6934            .collect();
6935        assert!(
6936            price_min_alerts.is_empty(),
6937            "Price-min alert should clear when price goes above min"
6938        );
6939    }
6940
6941    #[test]
6942    fn test_render_alert_overlay_multiple_alerts() {
6943        let mut terminal = create_test_terminal();
6944        let mut state = create_populated_state();
6945        state.active_alerts.push(ActiveAlert {
6946            message: "⚠ Price below min".to_string(),
6947            triggered_at: Instant::now(),
6948        });
6949        state.active_alerts.push(ActiveAlert {
6950            message: "🐋 Whale detected".to_string(),
6951            triggered_at: Instant::now(),
6952        });
6953        state.active_alerts.push(ActiveAlert {
6954            message: "⚠ Volume spike".to_string(),
6955            triggered_at: Instant::now(),
6956        });
6957        state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
6958        terminal
6959            .draw(|f| render_alert_overlay(f, f.area(), &state))
6960            .unwrap();
6961    }
6962
6963    #[test]
6964    fn test_render_alert_overlay_flash_expired() {
6965        let mut terminal = create_test_terminal();
6966        let mut state = create_populated_state();
6967        state.active_alerts.push(ActiveAlert {
6968            message: "⚠ Test".to_string(),
6969            triggered_at: Instant::now(),
6970        });
6971        // Flash timer already expired
6972        state.alert_flash_until = Some(Instant::now() - Duration::from_secs(5));
6973        terminal
6974            .draw(|f| render_alert_overlay(f, f.area(), &state))
6975            .unwrap();
6976    }
6977
6978    // ========================================================================
6979    // Coverage gap: Liquidity depth edge cases
6980    // ========================================================================
6981
6982    #[test]
6983    fn test_render_liquidity_depth_many_pairs() {
6984        let mut terminal = create_test_terminal();
6985        let mut state = create_populated_state();
6986        // Add many pairs to test height-limiting
6987        for i in 0..20 {
6988            state.liquidity_pairs.push((
6989                format!("TEST/TOKEN{} (DEX{})", i, i),
6990                (100_000.0 + i as f64 * 50_000.0),
6991            ));
6992        }
6993        terminal
6994            .draw(|f| render_liquidity_depth(f, f.area(), &state))
6995            .unwrap();
6996    }
6997
6998    #[test]
6999    fn test_render_liquidity_depth_narrow_terminal() {
7000        let backend = TestBackend::new(30, 10);
7001        let mut terminal = Terminal::new(backend).unwrap();
7002        let mut state = create_populated_state();
7003        state.liquidity_pairs = vec![
7004            ("TEST/WETH (Uniswap)".to_string(), 500_000.0),
7005            ("TEST/USDC (Sushi)".to_string(), 100_000.0),
7006        ];
7007        terminal
7008            .draw(|f| render_liquidity_depth(f, f.area(), &state))
7009            .unwrap();
7010    }
7011
7012    // ========================================================================
7013    // Coverage gap: Metrics panel edge cases
7014    // ========================================================================
7015
7016    #[test]
7017    fn test_render_metrics_panel_holder_count_disabled() {
7018        let mut terminal = create_test_terminal();
7019        let mut state = create_populated_state();
7020        state.holder_count = Some(42_000);
7021        state.widgets.holder_count = false; // disabled
7022        terminal
7023            .draw(|f| render_metrics_panel(f, f.area(), &state))
7024            .unwrap();
7025    }
7026
7027    #[test]
7028    fn test_render_metrics_panel_sparkline_single_point() {
7029        let mut terminal = create_test_terminal();
7030        let mut token_data = create_test_token_data();
7031        token_data.price_usd = 1.0;
7032        let mut state = MonitorState::new(&token_data, "ethereum");
7033        state.price_history.clear();
7034        state.price_history.push_back(DataPoint {
7035            timestamp: 1.0,
7036            value: 1.0,
7037            is_real: true,
7038        });
7039        terminal
7040            .draw(|f| render_metrics_panel(f, f.area(), &state))
7041            .unwrap();
7042    }
7043
7044    // ========================================================================
7045    // Coverage gap: Buy/sell gauge edge cases
7046    // ========================================================================
7047
7048    #[test]
7049    fn test_render_buy_sell_gauge_tiny_area() {
7050        // Render in a very small area to test zero width/height paths
7051        let backend = TestBackend::new(5, 3);
7052        let mut terminal = Terminal::new(backend).unwrap();
7053        let mut state = create_populated_state();
7054        terminal
7055            .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
7056            .unwrap();
7057    }
7058
7059    // ========================================================================
7060    // Coverage gap: Log message queue overflow
7061    // ========================================================================
7062
7063    #[test]
7064    fn test_log_message_queue_overflow() {
7065        let token_data = create_test_token_data();
7066        let mut state = MonitorState::new(&token_data, "ethereum");
7067        // Add more than 10 messages (queue capacity)
7068        for i in 0..20 {
7069            state.toggle_pause(); // each toggle logs a message
7070            let _ = i;
7071        }
7072        assert!(
7073            state.log_messages.len() <= 10,
7074            "Log queue should cap at 10, got {}",
7075            state.log_messages.len()
7076        );
7077    }
7078
7079    // ========================================================================
7080    // Coverage gap: Export CSV row content verification
7081    // ========================================================================
7082
7083    #[test]
7084    fn test_export_writes_csv_row_content_format() {
7085        let token_data = create_test_token_data();
7086        let mut state = MonitorState::new(&token_data, "ethereum");
7087        let path = start_export_in_temp(&mut state);
7088
7089        state.update(&token_data);
7090
7091        let contents = std::fs::read_to_string(&path).unwrap();
7092        let lines: Vec<&str> = contents.lines().collect();
7093        assert!(lines.len() >= 2);
7094
7095        // Verify header
7096        assert_eq!(
7097            lines[0],
7098            "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap"
7099        );
7100
7101        // Verify data row has correct number of columns
7102        let data_cols: Vec<&str> = lines[1].split(',').collect();
7103        assert_eq!(
7104            data_cols.len(),
7105            7,
7106            "Expected 7 CSV columns, got {}",
7107            data_cols.len()
7108        );
7109
7110        // Verify timestamp format (ISO 8601)
7111        assert!(data_cols[0].contains('T'));
7112        assert!(data_cols[0].ends_with('Z'));
7113
7114        // Cleanup
7115        state.stop_export();
7116        let _ = std::fs::remove_file(path);
7117    }
7118
7119    #[test]
7120    fn test_export_writes_csv_row_market_cap_none() {
7121        let mut token_data = create_test_token_data();
7122        token_data.market_cap = None;
7123        let mut state = MonitorState::new(&token_data, "ethereum");
7124        let path = start_export_in_temp(&mut state);
7125
7126        state.update(&token_data);
7127
7128        let contents = std::fs::read_to_string(&path).unwrap();
7129        let lines: Vec<&str> = contents.lines().collect();
7130        assert!(lines.len() >= 2);
7131
7132        // Last column should be empty when market_cap is None
7133        let data_cols: Vec<&str> = lines[1].split(',').collect();
7134        assert_eq!(data_cols.len(), 7);
7135        assert!(
7136            data_cols[6].is_empty(),
7137            "Market cap column should be empty when None"
7138        );
7139
7140        // Cleanup
7141        state.stop_export();
7142        let _ = std::fs::remove_file(path);
7143    }
7144
7145    // ========================================================================
7146    // Coverage gap: Full UI render with log scale + all chart modes
7147    // ========================================================================
7148
7149    #[test]
7150    fn test_ui_render_log_scale_all_chart_modes() {
7151        for mode in &[
7152            ChartMode::Line,
7153            ChartMode::Candlestick,
7154            ChartMode::VolumeProfile,
7155        ] {
7156            let mut terminal = create_test_terminal();
7157            let mut state = create_populated_state();
7158            state.scale_mode = ScaleMode::Log;
7159            state.chart_mode = *mode;
7160            terminal.draw(|f| ui(f, &mut state)).unwrap();
7161        }
7162    }
7163
7164    // ========================================================================
7165    // Coverage gap: Footer rendering edge cases
7166    // ========================================================================
7167
7168    #[test]
7169    fn test_render_footer_widget_toggle_mode_active() {
7170        let mut terminal = create_test_terminal();
7171        let mut state = create_populated_state();
7172        state.widget_toggle_mode = true;
7173        terminal
7174            .draw(|f| render_footer(f, f.area(), &state))
7175            .unwrap();
7176    }
7177
7178    #[test]
7179    fn test_render_footer_all_status_indicators() {
7180        // Test with export + auto-pause + alerts simultaneously
7181        let mut terminal = create_test_terminal();
7182        let mut state = create_populated_state();
7183        state.export_active = true;
7184        state.auto_pause_on_input = true;
7185        state.last_input_at = Instant::now(); // triggers auto-paused display
7186        terminal
7187            .draw(|f| render_footer(f, f.area(), &state))
7188            .unwrap();
7189    }
7190
7191    // ========================================================================
7192    // Coverage gap: Synthetic data generation edge cases
7193    // ========================================================================
7194
7195    #[test]
7196    fn test_generate_synthetic_price_history_zero_price() {
7197        let mut token_data = create_test_token_data();
7198        token_data.price_usd = 0.0;
7199        token_data.price_change_1h = 0.0;
7200        token_data.price_change_6h = 0.0;
7201        token_data.price_change_24h = 0.0;
7202        let state = MonitorState::new(&token_data, "ethereum");
7203        // Should not panic with zero prices
7204        assert!(!state.price_history.is_empty());
7205    }
7206
7207    #[test]
7208    fn test_generate_synthetic_volume_history_zero_volume() {
7209        let mut token_data = create_test_token_data();
7210        token_data.volume_24h = 0.0;
7211        token_data.volume_6h = 0.0;
7212        token_data.volume_1h = 0.0;
7213        let state = MonitorState::new(&token_data, "ethereum");
7214        assert!(!state.volume_history.is_empty());
7215    }
7216
7217    // ========================================================================
7218    // Coverage gap: Auto-pause with custom timeout
7219    // ========================================================================
7220
7221    #[test]
7222    fn test_auto_pause_custom_timeout() {
7223        let token_data = create_test_token_data();
7224        let mut state = MonitorState::new(&token_data, "ethereum");
7225        state.auto_pause_on_input = true;
7226        state.auto_pause_timeout = Duration::from_secs(10);
7227        state.refresh_rate = Duration::from_secs(1);
7228
7229        // Fresh input with long timeout -> still auto-paused
7230        state.last_input_at = Instant::now();
7231        state.last_update = Instant::now() - Duration::from_secs(5);
7232        assert!(!state.should_refresh()); // within 10s timeout
7233        assert!(state.is_auto_paused());
7234    }
7235
7236    // ========================================================================
7237    // Coverage gap: Price chart with stablecoin flat range
7238    // ========================================================================
7239
7240    #[test]
7241    fn test_render_price_chart_stablecoin_flat_range() {
7242        let mut terminal = create_test_terminal();
7243        let mut token_data = create_test_token_data();
7244        token_data.price_usd = 1.0;
7245        let mut state = MonitorState::new(&token_data, "ethereum");
7246        // Add many points at nearly identical prices (stablecoin)
7247        for i in 0..20 {
7248            let mut data = token_data.clone();
7249            data.price_usd = 1.0 + (i as f64 * 0.000001); // micro variation
7250            state.update(&data);
7251        }
7252        terminal
7253            .draw(|f| render_price_chart(f, f.area(), &state))
7254            .unwrap();
7255    }
7256
7257    // ========================================================================
7258    // Coverage gap: Cache load edge cases
7259    // ========================================================================
7260
7261    #[test]
7262    fn test_load_cache_corrupted_json() {
7263        let path = MonitorState::cache_path("0xCORRUPTED_TEST", "test_chain");
7264        // Write invalid JSON
7265        let _ = std::fs::write(&path, "not valid json {{{");
7266        let cached = MonitorState::load_cache("0xCORRUPTED_TEST", "test_chain");
7267        assert!(cached.is_none(), "Corrupted JSON should return None");
7268        let _ = std::fs::remove_file(path);
7269    }
7270
7271    #[test]
7272    fn test_load_cache_wrong_token() {
7273        let token_data = create_test_token_data();
7274        let state = MonitorState::new(&token_data, "ethereum");
7275        state.save_cache();
7276
7277        // Try to load with different token address
7278        let cached = MonitorState::load_cache("0xDIFFERENT_ADDRESS", "ethereum");
7279        assert!(
7280            cached.is_none(),
7281            "Loading cache with wrong token address should return None"
7282        );
7283
7284        // Cleanup
7285        let path = MonitorState::cache_path(&token_data.address, "ethereum");
7286        let _ = std::fs::remove_file(path);
7287    }
7288
7289    // ========================================================================
7290    // Integration tests: Mock types and MonitorApp constructor
7291    // ========================================================================
7292
7293    use crate::chains::dex::TokenSearchResult;
7294
7295    /// Mock DEX data source for integration testing.
7296    struct MockDexDataSource {
7297        /// Data returned by `get_token_data`. If `Err`, simulates an API failure.
7298        token_data_result: std::sync::Mutex<Result<DexTokenData>>,
7299    }
7300
7301    impl MockDexDataSource {
7302        fn new(data: DexTokenData) -> Self {
7303            Self {
7304                token_data_result: std::sync::Mutex::new(Ok(data)),
7305            }
7306        }
7307
7308        fn failing(msg: &str) -> Self {
7309            Self {
7310                token_data_result: std::sync::Mutex::new(Err(ScopeError::Api(msg.to_string()))),
7311            }
7312        }
7313    }
7314
7315    #[async_trait::async_trait]
7316    impl DexDataSource for MockDexDataSource {
7317        async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
7318            self.token_data_result
7319                .lock()
7320                .unwrap()
7321                .as_ref()
7322                .ok()
7323                .map(|d| d.price_usd)
7324        }
7325
7326        async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
7327            Some(2000.0)
7328        }
7329
7330        async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
7331            let guard = self.token_data_result.lock().unwrap();
7332            match &*guard {
7333                Ok(data) => Ok(data.clone()),
7334                Err(e) => Err(ScopeError::Api(e.to_string())),
7335            }
7336        }
7337
7338        async fn search_tokens(
7339            &self,
7340            _query: &str,
7341            _chain: Option<&str>,
7342        ) -> Result<Vec<TokenSearchResult>> {
7343            Ok(vec![])
7344        }
7345    }
7346
7347    /// Mock chain client for integration testing.
7348    struct MockChainClient {
7349        holder_count: u64,
7350    }
7351
7352    impl MockChainClient {
7353        fn new(holder_count: u64) -> Self {
7354            Self { holder_count }
7355        }
7356    }
7357
7358    #[async_trait::async_trait]
7359    impl ChainClient for MockChainClient {
7360        fn chain_name(&self) -> &str {
7361            "ethereum"
7362        }
7363        fn native_token_symbol(&self) -> &str {
7364            "ETH"
7365        }
7366        async fn get_balance(&self, _address: &str) -> Result<crate::chains::Balance> {
7367            unimplemented!("not needed for monitor tests")
7368        }
7369        async fn enrich_balance_usd(&self, _balance: &mut crate::chains::Balance) {}
7370        async fn get_transaction(&self, _hash: &str) -> Result<crate::chains::Transaction> {
7371            unimplemented!("not needed for monitor tests")
7372        }
7373        async fn get_transactions(
7374            &self,
7375            _address: &str,
7376            _limit: u32,
7377        ) -> Result<Vec<crate::chains::Transaction>> {
7378            Ok(vec![])
7379        }
7380        async fn get_block_number(&self) -> Result<u64> {
7381            Ok(1000000)
7382        }
7383        async fn get_token_balances(
7384            &self,
7385            _address: &str,
7386        ) -> Result<Vec<crate::chains::TokenBalance>> {
7387            Ok(vec![])
7388        }
7389        async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
7390            Ok(self.holder_count)
7391        }
7392    }
7393
7394    /// Creates a `MonitorApp<TestBackend>` with mock dependencies.
7395    fn create_test_app(
7396        dex: Box<dyn DexDataSource>,
7397        chain_client: Option<Box<dyn ChainClient>>,
7398    ) -> MonitorApp<TestBackend> {
7399        let token_data = create_test_token_data();
7400        let state = MonitorState::new(&token_data, "ethereum");
7401        let backend = TestBackend::new(120, 40);
7402        let terminal = ratatui::Terminal::new(backend).unwrap();
7403        MonitorApp {
7404            terminal,
7405            state,
7406            dex_client: dex,
7407            chain_client,
7408            should_exit: false,
7409            owns_terminal: false,
7410        }
7411    }
7412
7413    fn create_test_app_with_state(
7414        state: MonitorState,
7415        dex: Box<dyn DexDataSource>,
7416        chain_client: Option<Box<dyn ChainClient>>,
7417    ) -> MonitorApp<TestBackend> {
7418        let backend = TestBackend::new(120, 40);
7419        let terminal = ratatui::Terminal::new(backend).unwrap();
7420        MonitorApp {
7421            terminal,
7422            state,
7423            dex_client: dex,
7424            chain_client,
7425            should_exit: false,
7426            owns_terminal: false,
7427        }
7428    }
7429
7430    // ========================================================================
7431    // Integration tests: MonitorApp::handle_key_event
7432    // ========================================================================
7433
7434    #[test]
7435    fn test_app_handle_key_quit_q() {
7436        let data = create_test_token_data();
7437        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7438        assert!(!app.should_exit);
7439        app.handle_key_event(make_key_event(KeyCode::Char('q')));
7440        assert!(app.should_exit);
7441    }
7442
7443    #[test]
7444    fn test_app_handle_key_quit_esc() {
7445        let data = create_test_token_data();
7446        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7447        app.handle_key_event(make_key_event(KeyCode::Esc));
7448        assert!(app.should_exit);
7449    }
7450
7451    #[test]
7452    fn test_app_handle_key_quit_ctrl_c() {
7453        let data = create_test_token_data();
7454        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7455        let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
7456        app.handle_key_event(key);
7457        assert!(app.should_exit);
7458    }
7459
7460    #[test]
7461    fn test_app_handle_key_quit_stops_active_export() {
7462        let data = create_test_token_data();
7463        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7464        let path = start_export_in_temp(&mut app.state);
7465        assert!(app.state.export_active);
7466
7467        app.handle_key_event(make_key_event(KeyCode::Char('q')));
7468        assert!(app.should_exit);
7469        assert!(!app.state.export_active);
7470        let _ = std::fs::remove_file(path);
7471    }
7472
7473    #[test]
7474    fn test_app_handle_key_ctrl_c_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        let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
7481        app.handle_key_event(key);
7482        assert!(app.should_exit);
7483        assert!(!app.state.export_active);
7484        let _ = std::fs::remove_file(path);
7485    }
7486
7487    #[test]
7488    fn test_app_handle_key_updates_last_input_time() {
7489        let data = create_test_token_data();
7490        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7491        let before = Instant::now();
7492        app.handle_key_event(make_key_event(KeyCode::Char('p')));
7493        assert!(app.state.last_input_at >= before);
7494    }
7495
7496    #[test]
7497    fn test_app_handle_key_widget_toggle_mode() {
7498        let data = create_test_token_data();
7499        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7500        assert!(app.state.widgets.price_chart);
7501
7502        // Enter widget toggle mode
7503        app.handle_key_event(make_key_event(KeyCode::Char('w')));
7504        assert!(app.state.widget_toggle_mode);
7505
7506        // Toggle widget 1 (price chart)
7507        app.handle_key_event(make_key_event(KeyCode::Char('1')));
7508        assert!(!app.state.widget_toggle_mode);
7509        assert!(!app.state.widgets.price_chart);
7510    }
7511
7512    #[test]
7513    fn test_app_handle_key_widget_toggle_mode_cancel() {
7514        let data = create_test_token_data();
7515        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7516
7517        // Enter widget toggle mode
7518        app.handle_key_event(make_key_event(KeyCode::Char('w')));
7519        assert!(app.state.widget_toggle_mode);
7520
7521        // Any non-digit key cancels widget toggle mode
7522        app.handle_key_event(make_key_event(KeyCode::Char('x')));
7523        assert!(!app.state.widget_toggle_mode);
7524    }
7525
7526    #[test]
7527    fn test_app_handle_key_all_keybindings() {
7528        let data = create_test_token_data();
7529        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7530
7531        // r = force refresh
7532        app.handle_key_event(make_key_event(KeyCode::Char('r')));
7533        assert!(!app.should_exit);
7534
7535        // Shift+P = toggle auto-pause
7536        let key = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
7537        app.handle_key_event(key);
7538        assert!(app.state.auto_pause_on_input);
7539        app.handle_key_event(key);
7540        assert!(!app.state.auto_pause_on_input);
7541
7542        // p = toggle pause
7543        app.handle_key_event(make_key_event(KeyCode::Char('p')));
7544        assert!(app.state.paused);
7545
7546        // space = toggle pause
7547        app.handle_key_event(make_key_event(KeyCode::Char(' ')));
7548        assert!(!app.state.paused);
7549
7550        // e = toggle export
7551        app.handle_key_event(make_key_event(KeyCode::Char('e')));
7552        assert!(app.state.export_active);
7553        // Stop export to avoid file handles
7554        app.state.stop_export();
7555
7556        // + = slower refresh
7557        let before_rate = app.state.refresh_rate;
7558        app.handle_key_event(make_key_event(KeyCode::Char('+')));
7559        assert!(app.state.refresh_rate >= before_rate);
7560
7561        // - = faster refresh
7562        let before_rate = app.state.refresh_rate;
7563        app.handle_key_event(make_key_event(KeyCode::Char('-')));
7564        assert!(app.state.refresh_rate <= before_rate);
7565
7566        // 1-6 = time periods
7567        app.handle_key_event(make_key_event(KeyCode::Char('1')));
7568        assert_eq!(app.state.time_period, TimePeriod::Min1);
7569        app.handle_key_event(make_key_event(KeyCode::Char('2')));
7570        assert_eq!(app.state.time_period, TimePeriod::Min5);
7571        app.handle_key_event(make_key_event(KeyCode::Char('3')));
7572        assert_eq!(app.state.time_period, TimePeriod::Min15);
7573        app.handle_key_event(make_key_event(KeyCode::Char('4')));
7574        assert_eq!(app.state.time_period, TimePeriod::Hour1);
7575        app.handle_key_event(make_key_event(KeyCode::Char('5')));
7576        assert_eq!(app.state.time_period, TimePeriod::Hour4);
7577        app.handle_key_event(make_key_event(KeyCode::Char('6')));
7578        assert_eq!(app.state.time_period, TimePeriod::Day1);
7579
7580        // t = cycle time period
7581        app.handle_key_event(make_key_event(KeyCode::Char('t')));
7582        assert_eq!(app.state.time_period, TimePeriod::Min1); // wraps from Day1
7583
7584        // c = toggle chart mode
7585        app.handle_key_event(make_key_event(KeyCode::Char('c')));
7586        assert_eq!(app.state.chart_mode, ChartMode::Candlestick);
7587
7588        // s = toggle scale
7589        app.handle_key_event(make_key_event(KeyCode::Char('s')));
7590        assert_eq!(app.state.scale_mode, ScaleMode::Log);
7591
7592        // / = cycle color scheme
7593        app.handle_key_event(make_key_event(KeyCode::Char('/')));
7594        assert_eq!(app.state.color_scheme, ColorScheme::BlueOrange);
7595
7596        // j = scroll log down
7597        app.handle_key_event(make_key_event(KeyCode::Char('j')));
7598
7599        // k = scroll log up
7600        app.handle_key_event(make_key_event(KeyCode::Char('k')));
7601
7602        // l = next layout
7603        app.handle_key_event(make_key_event(KeyCode::Char('l')));
7604        assert!(!app.state.auto_layout);
7605
7606        // h = prev layout
7607        app.handle_key_event(make_key_event(KeyCode::Char('h')));
7608
7609        // a = re-enable auto layout
7610        app.handle_key_event(make_key_event(KeyCode::Char('a')));
7611        assert!(app.state.auto_layout);
7612
7613        // w = widget toggle mode
7614        app.handle_key_event(make_key_event(KeyCode::Char('w')));
7615        assert!(app.state.widget_toggle_mode);
7616        // Cancel it
7617        app.handle_key_event(make_key_event(KeyCode::Char('z')));
7618
7619        // Unknown key is a no-op
7620        app.handle_key_event(make_key_event(KeyCode::F(12)));
7621        assert!(!app.should_exit);
7622    }
7623
7624    // ========================================================================
7625    // Integration tests: MonitorApp::fetch_data
7626    // ========================================================================
7627
7628    #[tokio::test]
7629    async fn test_app_fetch_data_success() {
7630        let data = create_test_token_data();
7631        let initial_price = data.price_usd;
7632        let mut updated = data.clone();
7633        updated.price_usd = 2.5;
7634        let mut app = create_test_app(Box::new(MockDexDataSource::new(updated)), None);
7635
7636        assert!((app.state.current_price - initial_price).abs() < 0.001);
7637        app.fetch_data().await;
7638        assert!((app.state.current_price - 2.5).abs() < 0.001);
7639        assert!(app.state.error_message.is_none());
7640    }
7641
7642    #[tokio::test]
7643    async fn test_app_fetch_data_api_error() {
7644        let mut app = create_test_app(Box::new(MockDexDataSource::failing("rate limited")), None);
7645
7646        app.fetch_data().await;
7647        assert!(app.state.error_message.is_some());
7648        assert!(
7649            app.state
7650                .error_message
7651                .as_ref()
7652                .unwrap()
7653                .contains("API Error")
7654        );
7655    }
7656
7657    #[tokio::test]
7658    async fn test_app_fetch_data_holder_count_on_12th_tick() {
7659        let data = create_test_token_data();
7660        let mock_chain = MockChainClient::new(42_000);
7661        let mut app = create_test_app(
7662            Box::new(MockDexDataSource::new(data)),
7663            Some(Box::new(mock_chain)),
7664        );
7665
7666        // First 11 fetches should not update holder count
7667        for _ in 0..11 {
7668            app.fetch_data().await;
7669        }
7670        assert!(app.state.holder_count.is_none());
7671
7672        // 12th fetch triggers holder count lookup
7673        app.fetch_data().await;
7674        assert_eq!(app.state.holder_count, Some(42_000));
7675    }
7676
7677    #[tokio::test]
7678    async fn test_app_fetch_data_holder_count_zero_not_stored() {
7679        let data = create_test_token_data();
7680        let mock_chain = MockChainClient::new(0); // returns zero
7681        let mut app = create_test_app(
7682            Box::new(MockDexDataSource::new(data)),
7683            Some(Box::new(mock_chain)),
7684        );
7685
7686        // Skip to 12th tick
7687        app.state.holder_fetch_counter = 11;
7688        app.fetch_data().await;
7689        // Zero holder count should NOT be stored
7690        assert!(app.state.holder_count.is_none());
7691    }
7692
7693    #[tokio::test]
7694    async fn test_app_fetch_data_no_chain_client_skips_holders() {
7695        let data = create_test_token_data();
7696        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7697
7698        // Skip to 12th tick
7699        app.state.holder_fetch_counter = 11;
7700        app.fetch_data().await;
7701        // Without a chain client, holder count stays None
7702        assert!(app.state.holder_count.is_none());
7703    }
7704
7705    #[tokio::test]
7706    async fn test_app_fetch_data_preserves_holder_on_subsequent_failure() {
7707        let data = create_test_token_data();
7708        let mock_chain = MockChainClient::new(42_000);
7709        let mut app = create_test_app(
7710            Box::new(MockDexDataSource::new(data)),
7711            Some(Box::new(mock_chain)),
7712        );
7713
7714        // Fetch holder count on 12th tick
7715        app.state.holder_fetch_counter = 11;
7716        app.fetch_data().await;
7717        assert_eq!(app.state.holder_count, Some(42_000));
7718
7719        // Replace chain client with one returning 0
7720        app.chain_client = Some(Box::new(MockChainClient::new(0)));
7721        // 24th tick
7722        app.state.holder_fetch_counter = 23;
7723        app.fetch_data().await;
7724        // Previous value should be preserved (zero is ignored)
7725        assert_eq!(app.state.holder_count, Some(42_000));
7726    }
7727
7728    // ========================================================================
7729    // Integration tests: MonitorApp::cleanup
7730    // ========================================================================
7731
7732    #[test]
7733    fn test_app_cleanup_does_not_panic_test_backend() {
7734        let data = create_test_token_data();
7735        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7736        // cleanup() with owns_terminal=false should not attempt terminal restore
7737        let result = app.cleanup();
7738        assert!(result.is_ok());
7739    }
7740
7741    // ========================================================================
7742    // Integration tests: MonitorApp draw renders without panic
7743    // ========================================================================
7744
7745    #[test]
7746    fn test_app_draw_renders_ui() {
7747        let data = create_test_token_data();
7748        let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
7749        // Verify we can render UI through the MonitorApp terminal
7750        app.terminal
7751            .draw(|f| ui(f, &mut app.state))
7752            .expect("should render without panic");
7753    }
7754
7755    // ========================================================================
7756    // Integration tests: select_token_impl
7757    // ========================================================================
7758
7759    fn make_search_results() -> Vec<TokenSearchResult> {
7760        vec![
7761            TokenSearchResult {
7762                address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
7763                symbol: "USDC".to_string(),
7764                name: "USD Coin".to_string(),
7765                chain: "ethereum".to_string(),
7766                price_usd: Some(1.0),
7767                volume_24h: 5_000_000_000.0,
7768                liquidity_usd: 2_000_000_000.0,
7769                market_cap: Some(32_000_000_000.0),
7770            },
7771            TokenSearchResult {
7772                address: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(),
7773                symbol: "USDT".to_string(),
7774                name: "Tether USD".to_string(),
7775                chain: "ethereum".to_string(),
7776                price_usd: Some(1.0),
7777                volume_24h: 6_000_000_000.0,
7778                liquidity_usd: 3_000_000_000.0,
7779                market_cap: Some(83_000_000_000.0),
7780            },
7781            TokenSearchResult {
7782                address: "0x6B175474E89094C44Da98b954EedeAC495271d0F".to_string(),
7783                symbol: "DAI".to_string(),
7784                name: "Dai Stablecoin".to_string(),
7785                chain: "ethereum".to_string(),
7786                price_usd: Some(1.0),
7787                volume_24h: 200_000_000.0,
7788                liquidity_usd: 500_000_000.0,
7789                market_cap: Some(5_000_000_000.0),
7790            },
7791        ]
7792    }
7793
7794    #[test]
7795    fn test_select_token_impl_valid_first() {
7796        let results = make_search_results();
7797        let mut reader = io::Cursor::new(b"1\n");
7798        let mut writer = Vec::new();
7799        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
7800        assert_eq!(selected.symbol, "USDC");
7801        assert_eq!(selected.address, results[0].address);
7802        let output = String::from_utf8(writer).unwrap();
7803        assert!(output.contains("Found 3 tokens"));
7804        assert!(output.contains("Selected: USDC"));
7805    }
7806
7807    #[test]
7808    fn test_select_token_impl_valid_last() {
7809        let results = make_search_results();
7810        let mut reader = io::Cursor::new(b"3\n");
7811        let mut writer = Vec::new();
7812        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
7813        assert_eq!(selected.symbol, "DAI");
7814    }
7815
7816    #[test]
7817    fn test_select_token_impl_valid_middle() {
7818        let results = make_search_results();
7819        let mut reader = io::Cursor::new(b"2\n");
7820        let mut writer = Vec::new();
7821        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
7822        assert_eq!(selected.symbol, "USDT");
7823    }
7824
7825    #[test]
7826    fn test_select_token_impl_out_of_bounds_zero() {
7827        let results = make_search_results();
7828        let mut reader = io::Cursor::new(b"0\n");
7829        let mut writer = Vec::new();
7830        let result = select_token_impl(&results, &mut reader, &mut writer);
7831        assert!(result.is_err());
7832        let err = result.unwrap_err().to_string();
7833        assert!(err.contains("Selection must be between 1 and 3"));
7834    }
7835
7836    #[test]
7837    fn test_select_token_impl_out_of_bounds_high() {
7838        let results = make_search_results();
7839        let mut reader = io::Cursor::new(b"99\n");
7840        let mut writer = Vec::new();
7841        let result = select_token_impl(&results, &mut reader, &mut writer);
7842        assert!(result.is_err());
7843    }
7844
7845    #[test]
7846    fn test_select_token_impl_non_numeric_input() {
7847        let results = make_search_results();
7848        let mut reader = io::Cursor::new(b"abc\n");
7849        let mut writer = Vec::new();
7850        let result = select_token_impl(&results, &mut reader, &mut writer);
7851        assert!(result.is_err());
7852        let err = result.unwrap_err().to_string();
7853        assert!(err.contains("Invalid selection"));
7854    }
7855
7856    #[test]
7857    fn test_select_token_impl_empty_input() {
7858        let results = make_search_results();
7859        let mut reader = io::Cursor::new(b"\n");
7860        let mut writer = Vec::new();
7861        let result = select_token_impl(&results, &mut reader, &mut writer);
7862        assert!(result.is_err());
7863    }
7864
7865    #[test]
7866    fn test_select_token_impl_long_name_truncation() {
7867        let results = vec![TokenSearchResult {
7868            address: "0xABCDEF1234567890ABCDEF1234567890ABCDEF12".to_string(),
7869            symbol: "LONG".to_string(),
7870            name: "A Very Long Token Name That Exceeds Twenty Characters".to_string(),
7871            chain: "ethereum".to_string(),
7872            price_usd: None,
7873            volume_24h: 100.0,
7874            liquidity_usd: 50.0,
7875            market_cap: None,
7876        }];
7877        let mut reader = io::Cursor::new(b"1\n");
7878        let mut writer = Vec::new();
7879        let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
7880        assert_eq!(selected.symbol, "LONG");
7881        let output = String::from_utf8(writer).unwrap();
7882        // Should have truncated name
7883        assert!(output.contains("A Very Long Token..."));
7884        // Should show N/A for price
7885        assert!(output.contains("N/A"));
7886    }
7887
7888    #[test]
7889    fn test_select_token_impl_output_format() {
7890        let results = make_search_results();
7891        let mut reader = io::Cursor::new(b"1\n");
7892        let mut writer = Vec::new();
7893        let _ = select_token_impl(&results, &mut reader, &mut writer).unwrap();
7894        let output = String::from_utf8(writer).unwrap();
7895
7896        // Verify table header
7897        assert!(output.contains("#"));
7898        assert!(output.contains("Symbol"));
7899        assert!(output.contains("Name"));
7900        assert!(output.contains("Address"));
7901        assert!(output.contains("Price"));
7902        assert!(output.contains("Liquidity"));
7903        // Verify separator line
7904        assert!(output.contains("─"));
7905        // Verify prompt
7906        assert!(output.contains("Select token (1-3):"));
7907    }
7908
7909    // ========================================================================
7910    // Integration tests: format_monitor_number
7911    // ========================================================================
7912
7913    #[test]
7914    fn test_format_monitor_number_billions() {
7915        assert_eq!(format_monitor_number(5_000_000_000.0), "$5.00B");
7916        assert_eq!(format_monitor_number(1_234_567_890.0), "$1.23B");
7917    }
7918
7919    #[test]
7920    fn test_format_monitor_number_millions() {
7921        assert_eq!(format_monitor_number(5_000_000.0), "$5.00M");
7922        assert_eq!(format_monitor_number(42_500_000.0), "$42.50M");
7923    }
7924
7925    #[test]
7926    fn test_format_monitor_number_thousands() {
7927        assert_eq!(format_monitor_number(5_000.0), "$5.00K");
7928        assert_eq!(format_monitor_number(999_999.0), "$1000.00K");
7929    }
7930
7931    #[test]
7932    fn test_format_monitor_number_small() {
7933        assert_eq!(format_monitor_number(42.0), "$42.00");
7934        assert_eq!(format_monitor_number(0.5), "$0.50");
7935        assert_eq!(format_monitor_number(0.0), "$0.00");
7936    }
7937
7938    // ========================================================================
7939    // Integration tests: abbreviate_address edge cases
7940    // ========================================================================
7941
7942    #[test]
7943    fn test_abbreviate_address_exactly_16_chars() {
7944        let addr = "0123456789ABCDEF"; // exactly 16 chars
7945        assert_eq!(abbreviate_address(addr), addr);
7946    }
7947
7948    #[test]
7949    fn test_abbreviate_address_17_chars() {
7950        let addr = "0123456789ABCDEFG"; // 17 chars -> abbreviated
7951        assert_eq!(abbreviate_address(addr), "01234567...BCDEFG");
7952    }
7953
7954    // ========================================================================
7955    // Integration tests: MonitorApp with state + fetch combined scenario
7956    // ========================================================================
7957
7958    #[tokio::test]
7959    async fn test_app_full_scenario_fetch_render_quit() {
7960        let data = create_test_token_data();
7961        let mut updated = data.clone();
7962        updated.price_usd = 3.0;
7963        let mock_chain = MockChainClient::new(10_000);
7964        let state = MonitorState::new(&data, "ethereum");
7965        let mut app = create_test_app_with_state(
7966            state,
7967            Box::new(MockDexDataSource::new(updated)),
7968            Some(Box::new(mock_chain)),
7969        );
7970
7971        // 1. Fetch new data
7972        app.fetch_data().await;
7973        assert!((app.state.current_price - 3.0).abs() < 0.001);
7974
7975        // 2. Render UI
7976        app.terminal
7977            .draw(|f| ui(f, &mut app.state))
7978            .expect("render");
7979
7980        // 3. Start export
7981        app.handle_key_event(make_key_event(KeyCode::Char('e')));
7982        assert!(app.state.export_active);
7983
7984        // 4. Quit (should stop export)
7985        app.handle_key_event(make_key_event(KeyCode::Char('q')));
7986        assert!(app.should_exit);
7987        assert!(!app.state.export_active);
7988    }
7989
7990    #[tokio::test]
7991    async fn test_app_fetch_data_error_then_recovery() {
7992        let mut app = create_test_app(Box::new(MockDexDataSource::failing("server down")), None);
7993
7994        // First fetch fails
7995        app.fetch_data().await;
7996        assert!(app.state.error_message.is_some());
7997
7998        // Replace with working mock
7999        let mut recovered = create_test_token_data();
8000        recovered.price_usd = 5.0;
8001        app.dex_client = Box::new(MockDexDataSource::new(recovered));
8002
8003        // Second fetch succeeds
8004        app.fetch_data().await;
8005        assert!((app.state.current_price - 5.0).abs() < 0.001);
8006        // Error message is cleared by state.update()
8007    }
8008
8009    // ========================================================================
8010    // Integration tests: MonitorArgs parsing and run_direct config merging
8011    // ========================================================================
8012
8013    #[test]
8014    fn test_monitor_args_defaults() {
8015        use super::super::Cli;
8016        use clap::Parser;
8017        // Simulate: scope monitor USDC
8018        let cli = Cli::try_parse_from(["scope", "monitor", "USDC"]).unwrap();
8019        if let super::super::Commands::Monitor(args) = cli.command {
8020            assert_eq!(args.token, "USDC");
8021            assert_eq!(args.chain, "ethereum");
8022            assert!(args.layout.is_none());
8023            assert!(args.refresh.is_none());
8024            assert!(args.scale.is_none());
8025            assert!(args.color_scheme.is_none());
8026            assert!(args.export.is_none());
8027        } else {
8028            panic!("Expected Monitor command");
8029        }
8030    }
8031
8032    #[test]
8033    fn test_monitor_args_all_flags() {
8034        use super::super::Cli;
8035        use clap::Parser;
8036        let cli = Cli::try_parse_from([
8037            "scope",
8038            "monitor",
8039            "PEPE",
8040            "--chain",
8041            "solana",
8042            "--layout",
8043            "feed",
8044            "--refresh",
8045            "2",
8046            "--scale",
8047            "log",
8048            "--color-scheme",
8049            "monochrome",
8050            "--export",
8051            "/tmp/data.csv",
8052        ])
8053        .unwrap();
8054        if let super::super::Commands::Monitor(args) = cli.command {
8055            assert_eq!(args.token, "PEPE");
8056            assert_eq!(args.chain, "solana");
8057            assert_eq!(args.layout, Some(LayoutPreset::Feed));
8058            assert_eq!(args.refresh, Some(2));
8059            assert_eq!(args.scale, Some(ScaleMode::Log));
8060            assert_eq!(args.color_scheme, Some(ColorScheme::Monochrome));
8061            assert_eq!(args.export, Some(PathBuf::from("/tmp/data.csv")));
8062        } else {
8063            panic!("Expected Monitor command");
8064        }
8065    }
8066
8067    #[test]
8068    fn test_run_direct_config_override_layout() {
8069        // Verify that run_direct properly applies CLI overrides to config
8070        let config = Config::default();
8071        assert_eq!(config.monitor.layout, LayoutPreset::Dashboard);
8072
8073        let args = MonitorArgs {
8074            token: "USDC".to_string(),
8075            chain: "ethereum".to_string(),
8076            layout: Some(LayoutPreset::ChartFocus),
8077            refresh: None,
8078            scale: None,
8079            color_scheme: None,
8080            export: None,
8081        };
8082
8083        // Build the effective config the same way run_direct does
8084        let mut monitor_config = config.monitor.clone();
8085        if let Some(layout) = args.layout {
8086            monitor_config.layout = layout;
8087        }
8088        assert_eq!(monitor_config.layout, LayoutPreset::ChartFocus);
8089    }
8090
8091    #[test]
8092    fn test_run_direct_config_override_all_fields() {
8093        let config = Config::default();
8094        let args = MonitorArgs {
8095            token: "PEPE".to_string(),
8096            chain: "solana".to_string(),
8097            layout: Some(LayoutPreset::Compact),
8098            refresh: Some(2),
8099            scale: Some(ScaleMode::Log),
8100            color_scheme: Some(ColorScheme::BlueOrange),
8101            export: Some(PathBuf::from("/tmp/test.csv")),
8102        };
8103
8104        let mut mc = config.monitor.clone();
8105        if let Some(layout) = args.layout {
8106            mc.layout = layout;
8107        }
8108        if let Some(refresh) = args.refresh {
8109            mc.refresh_seconds = refresh;
8110        }
8111        if let Some(scale) = args.scale {
8112            mc.scale = scale;
8113        }
8114        if let Some(color_scheme) = args.color_scheme {
8115            mc.color_scheme = color_scheme;
8116        }
8117        if let Some(ref path) = args.export {
8118            mc.export.path = Some(path.to_string_lossy().into_owned());
8119        }
8120
8121        assert_eq!(mc.layout, LayoutPreset::Compact);
8122        assert_eq!(mc.refresh_seconds, 2);
8123        assert_eq!(mc.scale, ScaleMode::Log);
8124        assert_eq!(mc.color_scheme, ColorScheme::BlueOrange);
8125        assert_eq!(mc.export.path, Some("/tmp/test.csv".to_string()));
8126    }
8127
8128    #[test]
8129    fn test_run_direct_config_no_overrides_preserves_defaults() {
8130        let config = Config::default();
8131        let args = MonitorArgs {
8132            token: "USDC".to_string(),
8133            chain: "ethereum".to_string(),
8134            layout: None,
8135            refresh: None,
8136            scale: None,
8137            color_scheme: None,
8138            export: None,
8139        };
8140
8141        let mut mc = config.monitor.clone();
8142        if let Some(layout) = args.layout {
8143            mc.layout = layout;
8144        }
8145        if let Some(refresh) = args.refresh {
8146            mc.refresh_seconds = refresh;
8147        }
8148        if let Some(scale) = args.scale {
8149            mc.scale = scale;
8150        }
8151        if let Some(color_scheme) = args.color_scheme {
8152            mc.color_scheme = color_scheme;
8153        }
8154
8155        // All should remain at defaults
8156        assert_eq!(mc.layout, LayoutPreset::Dashboard);
8157        assert_eq!(mc.refresh_seconds, DEFAULT_REFRESH_SECS);
8158        assert_eq!(mc.scale, ScaleMode::Linear);
8159        assert_eq!(mc.color_scheme, ColorScheme::GreenRed);
8160        assert!(mc.export.path.is_none());
8161    }
8162}