1use 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#[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 pub token: String,
133
134 #[arg(short, long, default_value = "ethereum")]
138 pub chain: String,
139
140 #[arg(short, long)]
145 pub layout: Option<LayoutPreset>,
146
147 #[arg(short, long)]
152 pub refresh: Option<u64>,
153
154 #[arg(short, long)]
158 pub scale: Option<ScaleMode>,
159
160 #[arg(long)]
164 pub color_scheme: Option<ColorScheme>,
165
166 #[arg(short, long, value_name = "PATH")]
168 pub export: Option<PathBuf>,
169
170 #[arg(long, value_name = "VENUE")]
175 pub venue: Option<String>,
176
177 #[arg(long, value_name = "PAIR")]
183 pub pair: Option<String>,
184}
185
186const MAX_DATA_AGE_SECS: f64 = 24.0 * 3600.0; const CACHE_FILE_PREFIX: &str = "bcc_monitor_";
194
195const DEFAULT_REFRESH_SECS: u64 = 5;
197
198const MIN_REFRESH_SECS: u64 = 1;
200
201const MAX_REFRESH_SECS: u64 = 60;
203
204#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
206pub struct DataPoint {
207 pub timestamp: f64,
209 pub value: f64,
211 pub is_real: bool,
213}
214
215#[derive(Debug, Clone, Copy)]
217pub struct OhlcCandle {
218 pub timestamp: f64,
220 pub open: f64,
222 pub high: f64,
224 pub low: f64,
226 pub close: f64,
228 pub is_bullish: bool,
230}
231
232impl OhlcCandle {
233 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 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#[derive(Debug, Serialize, Deserialize)]
256struct CachedMonitorData {
257 token_address: String,
259 chain: String,
261 price_history: Vec<DataPoint>,
263 volume_history: Vec<DataPoint>,
265 saved_at: f64,
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum TimePeriod {
272 Min1,
274 Min5,
276 Min15,
278 Hour1,
280 Hour4,
282 Day1,
284}
285
286impl TimePeriod {
287 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
356pub enum ChartMode {
357 #[default]
359 Line,
360 Candlestick,
362 VolumeProfile,
364}
365
366impl ChartMode {
367 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
388#[serde(rename_all = "kebab-case")]
389pub enum ColorScheme {
390 #[default]
392 GreenRed,
393 BlueOrange,
395 Monochrome,
397}
398
399impl ColorScheme {
400 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 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), 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 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#[derive(Debug, Clone, Copy)]
457pub struct ColorPalette {
458 pub up: Color,
460 pub down: Color,
462 pub neutral: Color,
464 pub header_fg: Color,
466 pub border: Color,
468 pub highlight: Color,
470 pub volume_bar: Color,
472 pub sparkline: Color,
474}
475
476#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
478#[serde(rename_all = "kebab-case")]
479pub enum ScaleMode {
480 #[default]
482 Linear,
483 Log,
485}
486
487impl ScaleMode {
488 pub fn toggle(&self) -> Self {
490 match self {
491 ScaleMode::Linear => ScaleMode::Log,
492 ScaleMode::Log => ScaleMode::Linear,
493 }
494 }
495
496 pub fn label(&self) -> &'static str {
498 match self {
499 ScaleMode::Linear => "Lin",
500 ScaleMode::Log => "Log",
501 }
502 }
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
507#[serde(default)]
508pub struct AlertConfig {
509 pub price_min: Option<f64>,
511 pub price_max: Option<f64>,
513 pub whale_min_usd: Option<f64>,
515 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#[derive(Debug, Clone)]
533pub struct ActiveAlert {
534 pub message: String,
536 pub triggered_at: Instant,
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
542#[serde(default)]
543pub struct ExportConfig {
544 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
560#[serde(rename_all = "kebab-case")]
561pub enum LayoutPreset {
562 #[default]
564 Dashboard,
565 ChartFocus,
567 Feed,
569 Compact,
571 Exchange,
573}
574
575impl LayoutPreset {
576 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
615#[serde(default)]
616pub struct WidgetVisibility {
617 pub price_chart: bool,
619 pub volume_chart: bool,
621 pub buy_sell_pressure: bool,
623 pub metrics_panel: bool,
625 pub activity_log: bool,
627 pub holder_count: bool,
629 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 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 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
680#[serde(default)]
681pub struct MonitorConfig {
682 pub layout: LayoutPreset,
684 pub refresh_seconds: u64,
686 pub widgets: WidgetVisibility,
688 pub scale: ScaleMode,
690 pub color_scheme: ColorScheme,
692 pub alerts: AlertConfig,
694 pub export: ExportConfig,
696 pub auto_pause_on_input: bool,
698 #[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
719pub struct MonitorState {
721 pub token_address: String,
723
724 pub symbol: String,
726
727 pub name: String,
729
730 pub chain: String,
732
733 pub price_history: VecDeque<DataPoint>,
735
736 pub volume_history: VecDeque<DataPoint>,
738
739 pub real_data_count: usize,
741
742 pub current_price: f64,
744
745 pub price_change_24h: f64,
747
748 pub price_change_6h: f64,
750
751 pub price_change_1h: f64,
753
754 pub price_change_5m: f64,
756
757 pub last_price_change_at: f64,
759
760 pub previous_price: f64,
762
763 pub buys_24h: u64,
765
766 pub sells_24h: u64,
768
769 pub liquidity_usd: f64,
771
772 pub volume_24h: f64,
774
775 pub market_cap: Option<f64>,
777
778 pub fdv: Option<f64>,
780
781 pub last_update: Instant,
783
784 pub refresh_rate: Duration,
786
787 pub paused: bool,
789
790 pub log_messages: VecDeque<String>,
792
793 pub log_list_state: ListState,
795
796 pub error_message: Option<String>,
798
799 pub time_period: TimePeriod,
801
802 pub chart_mode: ChartMode,
804
805 pub scale_mode: ScaleMode,
807
808 pub color_scheme: ColorScheme,
810
811 pub holder_count: Option<u64>,
813
814 pub liquidity_pairs: Vec<(String, f64)>,
816
817 pub order_book: Option<OrderBook>,
819
820 pub recent_trades: VecDeque<Trade>,
822
823 pub dex_pairs: Vec<DexPair>,
825
826 pub websites: Vec<String>,
828
829 pub socials: Vec<(String, String)>,
831
832 pub earliest_pair_created_at: Option<i64>,
834
835 pub dexscreener_url: Option<String>,
837
838 pub holder_fetch_counter: u32,
840
841 pub start_timestamp: i64,
843
844 pub layout: LayoutPreset,
846
847 pub widgets: WidgetVisibility,
849
850 pub auto_layout: bool,
852
853 pub widget_toggle_mode: bool,
855
856 pub alerts: AlertConfig,
859
860 pub active_alerts: Vec<ActiveAlert>,
862
863 pub alert_flash_until: Option<Instant>,
865
866 pub export_active: bool,
869
870 pub export_path: Option<PathBuf>,
872
873 pub volume_avg: f64,
875
876 pub auto_pause_on_input: bool,
879
880 pub last_input_at: Instant,
882
883 pub auto_pause_timeout: Duration,
885
886 pub exchange_ohlc: Vec<OhlcCandle>,
890
891 pub venue_pair: Option<String>,
893}
894
895impl MonitorState {
896 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 let (price_history, volume_history, real_data_count) =
904 if let Some(cached) = Self::load_cache(&token_data.address, chain) {
905 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 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, 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, chart_mode: ChartMode::Line, scale_mode: ScaleMode::Linear, color_scheme: ColorScheme::GreenRed, 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 alerts: AlertConfig::default(),
989 active_alerts: Vec::new(),
990 alert_flash_until: None,
991 export_active: false,
993 export_path: None,
994 volume_avg: token_data.volume_24h,
995 auto_pause_on_input: false,
997 last_input_at: now,
998 auto_pause_timeout: Duration::from_secs(3),
999 exchange_ohlc: Vec::new(),
1001 venue_pair: None,
1002 }
1003 }
1004
1005 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 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 fn cache_path(token_address: &str, chain: &str) -> PathBuf {
1029 let mut path = std::env::temp_dir();
1030 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 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 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 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 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 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 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 for i in 0..points.len() - 1 {
1113 let (t1, p1) = points[i];
1114 let (t2, p2) = points[i + 1];
1115 let steps = 4; 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, });
1126 }
1127 }
1128 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 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 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 let volume = if hours_ago <= 1 {
1157 volume_1h
1158 } else if hours_ago <= 6 {
1159 volume_6h / 6.0
1160 } else {
1161 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, });
1170 }
1171
1172 history
1173 }
1174
1175 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 let base_spread_bps = if total_liquidity > 1_000_000.0 {
1192 5.0 } else if total_liquidity > 100_000.0 {
1194 15.0 } else {
1196 50.0 };
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 let mut asks = Vec::with_capacity(num_levels);
1205 for i in 0..num_levels {
1206 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 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 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 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 pub fn update(&mut self, token_data: &DexTokenData) {
1256 let now_ts = chrono::Utc::now().timestamp() as f64;
1257
1258 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 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 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 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 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 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 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 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 let qty = if token_data.volume_24h > 0.0 && token_data.price_usd > 0.0 {
1345 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 while self.recent_trades.len() > 200 {
1362 self.recent_trades.pop_front();
1363 }
1364 }
1365
1366 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 self.check_alerts(token_data);
1381
1382 if self.export_active {
1384 self.write_export_row();
1385 }
1386
1387 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 if self.real_data_count.is_multiple_of(60) {
1395 self.save_cache();
1396 }
1397 }
1398
1399 fn check_alerts(&mut self, token_data: &DexTokenData) {
1401 self.active_alerts.clear();
1402
1403 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 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 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 if let Some(whale_min) = self.alerts.whale_min_usd {
1438 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 if !self.active_alerts.is_empty() {
1455 self.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
1456 }
1457 }
1458
1459 fn write_export_row(&mut self) {
1461 if let Some(ref path) = self.export_path {
1462 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 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 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 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 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 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 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 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 pub fn memory_usage(&self) -> usize {
1580 let point_size = std::mem::size_of::<DataPoint>();
1582 (self.price_history.len() + self.volume_history.len()) * point_size
1583 }
1584
1585 pub fn get_ohlc_candles(&self) -> Vec<OhlcCandle> {
1595 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 let candle_duration_secs = match self.time_period {
1608 TimePeriod::Min1 => 5.0, TimePeriod::Min5 => 15.0, TimePeriod::Min15 => 60.0, TimePeriod::Hour1 => 300.0, TimePeriod::Hour4 => 900.0, TimePeriod::Day1 => 3600.0, };
1615
1616 let mut candles: Vec<OhlcCandle> = Vec::new();
1617
1618 for (timestamp, price) in data {
1619 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 last_candle.update(price);
1626 } else {
1627 candles.push(OhlcCandle::new(candle_start, price));
1629 }
1630 } else {
1631 candles.push(OhlcCandle::new(candle_start, price));
1633 }
1634 }
1635
1636 candles
1637 }
1638
1639 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 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 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 pub fn should_refresh(&self) -> bool {
1664 if self.paused {
1665 return false;
1666 }
1667 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 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 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 pub fn force_refresh(&mut self) {
1691 self.paused = false;
1692 self.last_update = Instant::now() - self.refresh_rate;
1693 }
1694
1695 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 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 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 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 pub fn refresh_rate_secs(&self) -> u64 {
1735 self.refresh_rate.as_secs()
1736 }
1737
1738 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
1749pub struct MonitorApp<B: ratatui::backend::Backend = ratatui::backend::CrosstermBackend<io::Stdout>>
1754{
1755 terminal: ratatui::Terminal<B>,
1757
1758 state: MonitorState,
1760
1761 dex_client: Box<dyn DexDataSource>,
1763
1764 chain_client: Option<Box<dyn ChainClient>>,
1766
1767 exchange_client: Option<crate::market::ExchangeClient>,
1769
1770 should_exit: bool,
1772
1773 owns_terminal: bool,
1776}
1777
1778impl MonitorApp {
1780 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 let terminal = ratatui::init();
1790 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 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 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 self.terminal.draw(|f| ui(f, &mut self.state))?;
1823
1824 let refresh_delay = if self.state.paused {
1826 Duration::from_millis(200) } else {
1828 let elapsed = self.state.last_update.elapsed();
1829 self.state.refresh_rate.saturating_sub(elapsed)
1830 };
1831
1832 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 }
1842 _ => {}
1843 }
1844 }
1845 _ = tokio::time::sleep(refresh_delay) => {
1846 }
1848 }
1849
1850 if self.should_exit {
1851 break;
1852 }
1853
1854 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
1873impl<B: ratatui::backend::Backend> MonitorApp<B> {
1876 pub fn cleanup(&mut self) -> Result<()> {
1879 self.state.save_cache();
1881
1882 if self.owns_terminal {
1883 let _ = execute!(io::stdout(), DisableMouseCapture);
1885 ratatui::restore();
1887 }
1888 Ok(())
1889 }
1890
1891 fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) {
1894 self.state.last_input_at = Instant::now();
1896
1897 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 }
1907
1908 match key.code {
1909 KeyCode::Char('q') | KeyCode::Esc => {
1910 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 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 KeyCode::Char('e') => {
1942 self.state.toggle_export();
1943 }
1944 KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
1946 self.state.slower_refresh();
1947 }
1948 KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
1950 self.state.faster_refresh();
1951 }
1952 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 KeyCode::Char('c') => {
1976 self.state.toggle_chart_mode();
1977 }
1978 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 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 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 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 KeyCode::Char('w') => {
2008 self.state.widget_toggle_mode = true;
2009 }
2010 KeyCode::Char('a') => {
2012 self.state.auto_layout = true;
2013 }
2014 _ => {}
2015 }
2016 }
2017
2018 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(); }
2032 }
2033
2034 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 }
2058 }
2059 }
2060
2061 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 _ => {} }
2075 }
2076 }
2077}
2078
2079#[cfg(test)]
2082fn handle_key_event_on_state(key: crossterm::event::KeyEvent, state: &mut MonitorState) -> bool {
2083 state.last_input_at = Instant::now();
2085
2086 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 }
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 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 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
2195struct 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: Option<Rect>,
2205 market_info: Option<Rect>,
2207 trade_history: Option<Rect>,
2209}
2210
2211fn 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
2274fn 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, buy_sell_gauge: None, metrics_panel: if widgets.metrics_panel {
2300 Some(vertical[1])
2301 } else {
2302 None
2303 },
2304 activity_feed: None, order_book: None,
2306 market_info: None,
2307 trade_history: None,
2308 }
2309}
2310
2311fn 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, volume_chart: None, 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
2357fn layout_compact(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2365 LayoutAreas {
2366 price_chart: None, volume_chart: None, buy_sell_gauge: None, metrics_panel: if widgets.metrics_panel {
2370 Some(area)
2371 } else {
2372 None
2373 },
2374 activity_feed: None, order_book: None,
2376 market_info: None,
2377 trade_history: None,
2378 }
2379}
2380
2381fn 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
2426fn 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
2436fn ui(f: &mut Frame, state: &mut MonitorState) {
2438 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 let chunks = Layout::default()
2448 .direction(Direction::Vertical)
2449 .constraints([
2450 Constraint::Length(4), Constraint::Min(10), Constraint::Length(3), ])
2454 .split(f.area());
2455
2456 render_header(f, chunks[0], state);
2458
2459 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 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 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 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 if !state.active_alerts.is_empty() {
2511 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(f, chunks[2], state);
2524}
2525
2526fn 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 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 let header_chunks = Layout::default()
2566 .direction(Direction::Vertical)
2567 .constraints([Constraint::Length(3), Constraint::Length(1)])
2568 .split(area);
2569
2570 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 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
2608fn render_price_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2610 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 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 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 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 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 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 let price_range = max_price - min_price;
2674 let (y_min, y_max) = if price_range < 0.0001 {
2675 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 let x_max = if (x_max - x_min).abs() < 0.001 {
2686 x_min + 1.0
2687 } else {
2688 x_max
2689 };
2690
2691 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 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 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 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 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 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 let time_label = format!("-{}", state.time_period.label());
2766
2767 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
2811fn is_stablecoin_price(price: f64) -> bool {
2813 (0.95..=1.05).contains(&price)
2814}
2815
2816fn format_price_usd(price: f64) -> String {
2819 if price >= 1000.0 {
2820 format!("${:.2}", price)
2821 } else if is_stablecoin_price(price) {
2822 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
2835fn 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 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 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 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; 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 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 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 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 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
2977fn 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 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 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 let mut bucket_volumes = vec![0.0_f64; num_buckets];
3016
3017 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 let vol_contribution = if i < vol_iter.len() {
3024 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 let current_bucket = (((state.current_price - min_price) / bucket_size).floor() as usize)
3044 .min(num_buckets.saturating_sub(1));
3045
3046 let inner_width = area.width.saturating_sub(12) as usize; let lines: Vec<Line> = (0..num_buckets)
3050 .rev() .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
3082fn render_volume_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
3084 let pal = state.palette();
3085 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 let current_volume = state.volume_24h;
3097 let volume_str = crate::display::format_usd(current_volume);
3098
3099 let has_synthetic = is_real.iter().any(|r| !r);
3101 let has_real = is_real.iter().any(|r| *r);
3102
3103 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 let inner_width = area.width.saturating_sub(2) as usize; 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 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 let bar_width = if !bars.is_empty() {
3158 let total_bars = bars.len() as u16;
3159 ((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
3181fn render_buy_sell_gauge(f: &mut Frame, area: Rect, state: &mut MonitorState) {
3183 let pal = state.palette();
3184 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 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
3231fn 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
3261fn 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 (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
3301fn 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) .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
3350fn render_metrics_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3352 let pal = state.palette();
3353 let chunks = Layout::default()
3355 .direction(Direction::Vertical)
3356 .constraints([Constraint::Length(3), Constraint::Min(0)])
3357 .split(area);
3358
3359 let sparkline_data: Vec<u64> = {
3361 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 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 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
3497fn 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; if inner_height < 3 {
3517 return;
3518 }
3519
3520 let ask_rows = (inner_height.saturating_sub(1)) / 2;
3522 let bid_rows = inner_height.saturating_sub(ask_rows).saturating_sub(1);
3523
3524 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 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 let visible_asks: Vec<_> = book.asks.iter().take(ask_rows).collect();
3541 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); 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 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 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
3625fn 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 let time_width = 8; let side_width = 4; let price_width = inner_width
3648 .saturating_sub(time_width)
3649 .saturating_sub(side_width)
3650 .saturating_sub(3) / 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 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 let visible_count = inner_height.saturating_sub(1); 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 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
3741fn render_market_info_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3746 let pal = state.palette();
3747
3748 let cols = Layout::default()
3750 .direction(Direction::Horizontal)
3751 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
3752 .split(area);
3753
3754 {
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) .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 {
3803 let mut info_lines: Vec<Line> = Vec::new();
3804
3805 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 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 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 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 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 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 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 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 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
3955fn render_footer(f: &mut Frame, area: Rect, state: &MonitorState) {
3957 let elapsed = state.last_update.elapsed().as_secs();
3958
3959 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 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 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
4041fn 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
4054pub async fn run_direct(
4060 mut args: MonitorArgs,
4061 config: &Config,
4062 clients: &dyn ChainClientFactory,
4063) -> Result<()> {
4064 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 let ctx = SessionContext {
4076 chain: args.chain,
4077 ..SessionContext::default()
4078 };
4079
4080 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 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
4115pub 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 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 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 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 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_exchange_token_data(&base_symbol, pair_str, &ticker)
4175 } else {
4176 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 tokio::time::sleep(Duration::from_millis(500)).await;
4194
4195 let chain_client = clients.create_chain_client(&ctx.chain).ok();
4197
4198 let mut app = MonitorApp::new(
4200 initial_data,
4201 &ctx.chain,
4202 &config.monitor,
4203 chain_client,
4204 exchange_client,
4205 )?;
4206
4207 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 if let Err(e) = app.cleanup() {
4216 eprintln!("Warning: Failed to cleanup terminal: {}", e);
4217 }
4218
4219 result
4220}
4221
4222fn 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
4264async fn resolve_token_address(
4271 input: &str,
4272 chain: &str,
4273 _config: &Config,
4274 dex_client: &dyn DexDataSource,
4275) -> Result<String> {
4276 if crate::tokens::TokenAliases::is_address(input) {
4278 return Ok(input.to_string());
4279 }
4280
4281 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 let mut results = dex_client.search_tokens(input, chain_filter).await?;
4295
4296 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 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 let selected = select_token_interactive(&results)?;
4328 Ok(selected.address.clone())
4329}
4330
4331async 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
4355fn 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
4364fn 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
4373fn 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 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
4457fn 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#[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); }
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); }
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 assert!(!state.should_refresh());
4563
4564 state.last_update = Instant::now() - Duration::from_secs(120);
4566 assert!(state.should_refresh());
4567
4568 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 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 assert_eq!(state.refresh_rate_secs(), 5);
4615
4616 state.slower_refresh();
4618 assert_eq!(state.refresh_rate_secs(), 10);
4619
4620 state.faster_refresh();
4622 assert_eq!(state.refresh_rate_secs(), 5);
4623
4624 state.faster_refresh();
4626 assert_eq!(state.refresh_rate_secs(), 1);
4627
4628 state.faster_refresh();
4630 assert_eq!(state.refresh_rate_secs(), 1);
4631
4632 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 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 assert_eq!(state.time_period, TimePeriod::Hour1);
4681
4682 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 assert!(state.price_history.len() > 1);
4697 assert!(state.volume_history.len() > 1);
4698
4699 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); }
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 let (synthetic, real) = state.data_stats();
4714 assert!(synthetic > 0);
4715 assert_eq!(real, 0);
4716
4717 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 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 assert!(mem > 0);
4745
4746 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 let (data, is_real) = state.get_price_data_for_period();
4758 assert_eq!(data.len(), is_real.len());
4759
4760 state.update(&token_data);
4762
4763 let (_data2, is_real2) = state.get_price_data_for_period();
4764 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 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 state.update(&token_data);
4786 state.update(&token_data);
4787
4788 state.save_cache();
4790
4791 let path = MonitorState::cache_path(&state.token_address, &state.chain);
4793 assert!(path.exists(), "Cache file should exist after save");
4794
4795 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 let _ = std::fs::remove_file(path);
4806 }
4807
4808 #[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 #[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); }
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 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 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 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 #[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 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 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 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 #[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 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 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; 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; 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 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 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 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); state.fdv = Some(100_000_000_000.0); state.volume_24h = 5_000_000_000.0; state.liquidity_usd = 500_000_000.0; 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; 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; 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 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 state.price_change_24h = -15.0;
5644 state.current_price = 0.5; 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; 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; 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; 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 state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 7200.0; 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 state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 300.0; 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 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 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 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 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('r')), &mut state);
5818 assert!(!exit);
5819 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 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 let first = state.time_period;
5906
5907 handle_key_event_on_state(make_key_event(KeyCode::Tab), &mut state);
5908 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 #[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 state.save_cache();
5959 let cached = MonitorState::load_cache(&state.token_address, &state.chain);
5960 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 #[test]
5980 fn test_render_volume_barchart_with_populated_data() {
5981 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 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 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 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 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 state.price_change_5m = 2.0;
6053 terminal
6054 .draw(|f| render_metrics_panel(f, f.area(), &state))
6055 .unwrap();
6056
6057 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 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 assert_eq!(state.log_list_state.selected(), None);
6104
6105 state.scroll_log_down();
6107 assert_eq!(state.log_list_state.selected(), Some(0));
6108
6109 state.scroll_log_down();
6111 assert_eq!(state.log_list_state.selected(), Some(1));
6112
6113 state.scroll_log_down();
6115 assert_eq!(state.log_list_state.selected(), Some(2));
6116
6117 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 state.scroll_log_up();
6132 assert_eq!(state.log_list_state.selected(), Some(0));
6133
6134 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 for _ in 0..5 {
6149 state.scroll_log_down();
6150 }
6151 assert_eq!(state.log_list_state.selected(), Some(4));
6152
6153 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 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 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 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 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 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 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 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 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 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 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 assert!(output.contains("Address"));
6351 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 #[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 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); }
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 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()); assert!(areas.buy_sell_gauge.is_none()); assert!(areas.metrics_panel.is_some()); assert!(areas.activity_feed.is_none()); }
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()); assert!(areas.volume_chart.is_none()); assert!(areas.buy_sell_gauge.is_some()); assert!(areas.metrics_panel.is_some()); assert!(areas.activity_feed.is_some()); }
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()); assert!(areas.volume_chart.is_none()); assert!(areas.buy_sell_gauge.is_none()); assert!(areas.metrics_panel.is_some()); assert!(areas.activity_feed.is_none()); }
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()); assert!(areas.metrics_panel.is_none()); assert!(areas.activity_feed.is_none()); }
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; 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); }
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); 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 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6732 assert!(state.widget_toggle_mode);
6733
6734 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 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6746 assert!(state.widget_toggle_mode);
6747
6748 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); }
6753
6754 #[test]
6755 fn test_keybinding_w_toggle_multiple_widgets() {
6756 let mut state = create_populated_state();
6757
6758 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 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 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); }
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 #[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 #[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); 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); 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); state.alerts.price_max = Some(2.0); 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; 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 #[test]
7143 fn test_export_config_default() {
7144 let config = ExportConfig::default();
7145 assert!(config.path.is_none());
7146 }
7147
7148 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); 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 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 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 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 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 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 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 #[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 state.last_input_at = Instant::now();
7296 state.last_update = Instant::now() - Duration::from_secs(10); 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); state.last_input_at = Instant::now() - Duration::from_secs(10);
7312 state.last_update = Instant::now() - Duration::from_secs(10);
7313
7314 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(); state.last_update = Instant::now() - Duration::from_secs(10);
7327
7328 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 state.auto_pause_on_input = false;
7339 state.last_input_at = Instant::now();
7340 assert!(!state.is_auto_paused());
7341
7342 state.auto_pause_on_input = true;
7344 state.last_input_at = Instant::now();
7345 assert!(state.is_auto_paused());
7346
7347 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 state.last_input_at = Instant::now() - Duration::from_secs(60);
7373 let old_input = state.last_input_at;
7374
7375 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(); 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 #[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 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 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 assert!(state.active_alerts.is_empty());
7504 assert!(state.alert_flash_until.is_none());
7505 assert!(state.alerts.price_min.is_none());
7506
7507 assert!(!state.export_active);
7509 assert!(state.export_path.is_none());
7510
7511 assert!(!state.auto_pause_on_input);
7513 assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
7514 }
7515
7516 #[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 for scheme in &[
7590 ColorScheme::GreenRed,
7591 ColorScheme::BlueOrange,
7592 ColorScheme::Monochrome,
7593 ] {
7594 let pal = scheme.palette();
7595 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 #[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 #[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 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 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, 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 #[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 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 #[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 #[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 #[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 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; let mut state = MonitorState::new(&token_data, "ethereum");
7911 state.alerts.price_min = Some(0.5); state.alerts.price_max = Some(0.05); state.alerts.volume_spike_threshold_pct = Some(1.0);
7914 state.volume_avg = 100.0; state.update(&token_data);
7917 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); state.update(&token_data);
7931 assert!(!state.active_alerts.is_empty());
7932
7933 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 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 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 #[test]
7992 fn test_render_liquidity_depth_many_pairs() {
7993 let mut terminal = create_test_terminal();
7994 let mut state = create_populated_state();
7995 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 #[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; 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 #[test]
8058 fn test_render_buy_sell_gauge_tiny_area() {
8059 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 #[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 for i in 0..20 {
8078 state.toggle_pause(); 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 #[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 assert_eq!(
8106 lines[0],
8107 "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap"
8108 );
8109
8110 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 assert!(data_cols[0].contains('T'));
8121 assert!(data_cols[0].ends_with('Z'));
8122
8123 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 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 state.stop_export();
8151 let _ = std::fs::remove_file(path);
8152 }
8153
8154 #[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 #[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 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(); terminal
8196 .draw(|f| render_footer(f, f.area(), &state))
8197 .unwrap();
8198 }
8199
8200 #[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 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 for w in book.asks.windows(2) {
8254 assert!(w[0].price <= w[1].price);
8255 }
8256 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 #[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 state.last_input_at = Instant::now();
8288 state.last_update = Instant::now() - Duration::from_secs(5);
8289 assert!(!state.should_refresh()); assert!(state.is_auto_paused());
8291 }
8292
8293 #[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 for i in 0..20 {
8305 let mut data = token_data.clone();
8306 data.price_usd = 1.0 + (i as f64 * 0.000001); state.update(&data);
8308 }
8309 terminal
8310 .draw(|f| render_price_chart(f, f.area(), &state))
8311 .unwrap();
8312 }
8313
8314 #[test]
8319 fn test_load_cache_corrupted_json() {
8320 let path = MonitorState::cache_path("0xCORRUPTED_TEST", "test_chain");
8321 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 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 let path = MonitorState::cache_path(&token_data.address, "ethereum");
8343 let _ = std::fs::remove_file(path);
8344 }
8345
8346 use crate::chains::dex::TokenSearchResult;
8351
8352 struct MockDexDataSource {
8354 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 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 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 #[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 app.handle_key_event(make_key_event(KeyCode::Char('w')));
8563 assert!(app.state.widget_toggle_mode);
8564
8565 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 app.handle_key_event(make_key_event(KeyCode::Char('w')));
8578 assert!(app.state.widget_toggle_mode);
8579
8580 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 app.handle_key_event(make_key_event(KeyCode::Char('r')));
8592 assert!(!app.should_exit);
8593
8594 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 app.handle_key_event(make_key_event(KeyCode::Char('p')));
8603 assert!(app.state.paused);
8604
8605 app.handle_key_event(make_key_event(KeyCode::Char(' ')));
8607 assert!(!app.state.paused);
8608
8609 app.handle_key_event(make_key_event(KeyCode::Char('e')));
8611 assert!(app.state.export_active);
8612 app.state.stop_export();
8614
8615 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 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 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 app.handle_key_event(make_key_event(KeyCode::Char('t')));
8641 assert_eq!(app.state.time_period, TimePeriod::Min1); app.handle_key_event(make_key_event(KeyCode::Char('c')));
8645 assert_eq!(app.state.chart_mode, ChartMode::Candlestick);
8646
8647 app.handle_key_event(make_key_event(KeyCode::Char('s')));
8649 assert_eq!(app.state.scale_mode, ScaleMode::Log);
8650
8651 app.handle_key_event(make_key_event(KeyCode::Char('/')));
8653 assert_eq!(app.state.color_scheme, ColorScheme::BlueOrange);
8654
8655 app.handle_key_event(make_key_event(KeyCode::Char('j')));
8657
8658 app.handle_key_event(make_key_event(KeyCode::Char('k')));
8660
8661 app.handle_key_event(make_key_event(KeyCode::Char('l')));
8663 assert!(!app.state.auto_layout);
8664
8665 app.handle_key_event(make_key_event(KeyCode::Char('h')));
8667
8668 app.handle_key_event(make_key_event(KeyCode::Char('a')));
8670 assert!(app.state.auto_layout);
8671
8672 app.handle_key_event(make_key_event(KeyCode::Char('w')));
8674 assert!(app.state.widget_toggle_mode);
8675 app.handle_key_event(make_key_event(KeyCode::Char('z')));
8677
8678 app.handle_key_event(make_key_event(KeyCode::F(12)));
8680 assert!(!app.should_exit);
8681 }
8682
8683 #[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 for _ in 0..11 {
8727 app.fetch_data().await;
8728 }
8729 assert!(app.state.holder_count.is_none());
8730
8731 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); let mut app = create_test_app(
8741 Box::new(MockDexDataSource::new(data)),
8742 Some(Box::new(mock_chain)),
8743 );
8744
8745 app.state.holder_fetch_counter = 11;
8747 app.fetch_data().await;
8748 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 app.state.holder_fetch_counter = 11;
8759 app.fetch_data().await;
8760 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 app.state.holder_fetch_counter = 11;
8775 app.fetch_data().await;
8776 assert_eq!(app.state.holder_count, Some(42_000));
8777
8778 app.chain_client = Some(Box::new(MockChainClient::new(0)));
8780 app.state.holder_fetch_counter = 23;
8782 app.fetch_data().await;
8783 assert_eq!(app.state.holder_count, Some(42_000));
8785 }
8786
8787 #[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 let result = app.cleanup();
8797 assert!(result.is_ok());
8798 }
8799
8800 #[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 app.terminal
8810 .draw(|f| ui(f, &mut app.state))
8811 .expect("should render without panic");
8812 }
8813
8814 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 assert!(output.contains("A Very Long Token..."));
8943 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 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 assert!(output.contains("─"));
8964 assert!(output.contains("Select token (1-3):"));
8966 }
8967
8968 #[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 #[test]
9002 fn test_abbreviate_address_exactly_16_chars() {
9003 let addr = "0123456789ABCDEF"; assert_eq!(abbreviate_address(addr), addr);
9005 }
9006
9007 #[test]
9008 fn test_abbreviate_address_17_chars() {
9009 let addr = "0123456789ABCDEFG"; assert_eq!(abbreviate_address(addr), "01234567...BCDEFG");
9011 }
9012
9013 #[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 app.fetch_data().await;
9032 assert!((app.state.current_price - 3.0).abs() < 0.001);
9033
9034 app.terminal
9036 .draw(|f| ui(f, &mut app.state))
9037 .expect("render");
9038
9039 app.handle_key_event(make_key_event(KeyCode::Char('e')));
9041 assert!(app.state.export_active);
9042
9043 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 app.fetch_data().await;
9055 assert!(app.state.error_message.is_some());
9056
9057 let mut recovered = create_test_token_data();
9059 recovered.price_usd = 5.0;
9060 app.dex_client = Box::new(MockDexDataSource::new(recovered));
9061
9062 app.fetch_data().await;
9064 assert!((app.state.current_price - 5.0).abs() < 0.001);
9065 }
9067
9068 #[test]
9073 fn test_monitor_args_defaults() {
9074 use super::super::Cli;
9075 use clap::Parser;
9076 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 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 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 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 #[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 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 let candles_before = state.get_ohlc_candles();
9273
9274 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 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 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 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 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 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 assert_eq!(args.pair, Some("DAI_USDT".to_string()));
9516 }
9517
9518 #[test]
9523 fn test_chain_filter_logic_ethereum_default() {
9524 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 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 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 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 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 #[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 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 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 #[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"); 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 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 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 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 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 let mut new_data = data.clone();
10115 new_data.price_usd = 1.10;
10116 state.update(&new_data);
10117
10118 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 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 if let Some(trade) = state.recent_trades.back() {
10160 assert_eq!(trade.quantity, 1.0);
10161 }
10162 }
10163}