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