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)]
119pub struct MonitorArgs {
120 pub token: String,
126
127 #[arg(short, long, default_value = "ethereum")]
131 pub chain: String,
132
133 #[arg(short, long)]
138 pub layout: Option<LayoutPreset>,
139
140 #[arg(short, long)]
145 pub refresh: Option<u64>,
146
147 #[arg(short, long)]
151 pub scale: Option<ScaleMode>,
152
153 #[arg(long)]
157 pub color_scheme: Option<ColorScheme>,
158
159 #[arg(short, long, value_name = "PATH")]
161 pub export: Option<PathBuf>,
162
163 #[arg(long, value_name = "VENUE")]
168 pub venue: Option<String>,
169}
170
171const MAX_DATA_AGE_SECS: f64 = 24.0 * 3600.0; const CACHE_FILE_PREFIX: &str = "bcc_monitor_";
179
180const DEFAULT_REFRESH_SECS: u64 = 5;
182
183const MIN_REFRESH_SECS: u64 = 1;
185
186const MAX_REFRESH_SECS: u64 = 60;
188
189#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
191pub struct DataPoint {
192 pub timestamp: f64,
194 pub value: f64,
196 pub is_real: bool,
198}
199
200#[derive(Debug, Clone, Copy)]
202pub struct OhlcCandle {
203 pub timestamp: f64,
205 pub open: f64,
207 pub high: f64,
209 pub low: f64,
211 pub close: f64,
213 pub is_bullish: bool,
215}
216
217impl OhlcCandle {
218 pub fn new(timestamp: f64, price: f64) -> Self {
220 Self {
221 timestamp,
222 open: price,
223 high: price,
224 low: price,
225 close: price,
226 is_bullish: true,
227 }
228 }
229
230 pub fn update(&mut self, price: f64) {
232 self.high = self.high.max(price);
233 self.low = self.low.min(price);
234 self.close = price;
235 self.is_bullish = self.close >= self.open;
236 }
237}
238
239#[derive(Debug, Serialize, Deserialize)]
241struct CachedMonitorData {
242 token_address: String,
244 chain: String,
246 price_history: Vec<DataPoint>,
248 volume_history: Vec<DataPoint>,
250 saved_at: f64,
252}
253
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
256pub enum TimePeriod {
257 Min1,
259 Min5,
261 Min15,
263 Hour1,
265 Hour4,
267 Day1,
269}
270
271impl TimePeriod {
272 pub fn duration_secs(&self) -> i64 {
274 match self {
275 TimePeriod::Min1 => 60,
276 TimePeriod::Min5 => 5 * 60,
277 TimePeriod::Min15 => 15 * 60,
278 TimePeriod::Hour1 => 3600,
279 TimePeriod::Hour4 => 4 * 3600,
280 TimePeriod::Day1 => 24 * 3600,
281 }
282 }
283
284 pub fn label(&self) -> &'static str {
286 match self {
287 TimePeriod::Min1 => "1m",
288 TimePeriod::Min5 => "5m",
289 TimePeriod::Min15 => "15m",
290 TimePeriod::Hour1 => "1h",
291 TimePeriod::Hour4 => "4h",
292 TimePeriod::Day1 => "1d",
293 }
294 }
295
296 pub fn index(&self) -> usize {
298 match self {
299 TimePeriod::Min1 => 0,
300 TimePeriod::Min5 => 1,
301 TimePeriod::Min15 => 2,
302 TimePeriod::Hour1 => 3,
303 TimePeriod::Hour4 => 4,
304 TimePeriod::Day1 => 5,
305 }
306 }
307
308 pub fn exchange_interval(&self) -> &'static str {
310 match self {
311 TimePeriod::Min1 => "1m",
312 TimePeriod::Min5 => "5m",
313 TimePeriod::Min15 => "15m",
314 TimePeriod::Hour1 => "1h",
315 TimePeriod::Hour4 => "4h",
316 TimePeriod::Day1 => "1d",
317 }
318 }
319
320 pub fn next(&self) -> Self {
322 match self {
323 TimePeriod::Min1 => TimePeriod::Min5,
324 TimePeriod::Min5 => TimePeriod::Min15,
325 TimePeriod::Min15 => TimePeriod::Hour1,
326 TimePeriod::Hour1 => TimePeriod::Hour4,
327 TimePeriod::Hour4 => TimePeriod::Day1,
328 TimePeriod::Day1 => TimePeriod::Min1,
329 }
330 }
331}
332
333impl std::fmt::Display for TimePeriod {
334 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335 write!(f, "{}", self.label())
336 }
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
341pub enum ChartMode {
342 #[default]
344 Line,
345 Candlestick,
347 VolumeProfile,
349}
350
351impl ChartMode {
352 pub fn next(&self) -> Self {
354 match self {
355 ChartMode::Line => ChartMode::Candlestick,
356 ChartMode::Candlestick => ChartMode::VolumeProfile,
357 ChartMode::VolumeProfile => ChartMode::Line,
358 }
359 }
360
361 pub fn label(&self) -> &'static str {
363 match self {
364 ChartMode::Line => "Line",
365 ChartMode::Candlestick => "Candle",
366 ChartMode::VolumeProfile => "VolPro",
367 }
368 }
369}
370
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
373#[serde(rename_all = "kebab-case")]
374pub enum ColorScheme {
375 #[default]
377 GreenRed,
378 BlueOrange,
380 Monochrome,
382}
383
384impl ColorScheme {
385 pub fn next(&self) -> Self {
387 match self {
388 ColorScheme::GreenRed => ColorScheme::BlueOrange,
389 ColorScheme::BlueOrange => ColorScheme::Monochrome,
390 ColorScheme::Monochrome => ColorScheme::GreenRed,
391 }
392 }
393
394 pub fn palette(&self) -> ColorPalette {
396 match self {
397 ColorScheme::GreenRed => ColorPalette {
398 up: Color::Green,
399 down: Color::Red,
400 neutral: Color::Gray,
401 header_fg: Color::White,
402 border: Color::DarkGray,
403 highlight: Color::Yellow,
404 volume_bar: Color::Blue,
405 sparkline: Color::Cyan,
406 },
407 ColorScheme::BlueOrange => ColorPalette {
408 up: Color::Blue,
409 down: Color::Rgb(255, 165, 0), neutral: Color::Gray,
411 header_fg: Color::White,
412 border: Color::DarkGray,
413 highlight: Color::Cyan,
414 volume_bar: Color::Magenta,
415 sparkline: Color::LightBlue,
416 },
417 ColorScheme::Monochrome => ColorPalette {
418 up: Color::White,
419 down: Color::DarkGray,
420 neutral: Color::Gray,
421 header_fg: Color::White,
422 border: Color::DarkGray,
423 highlight: Color::White,
424 volume_bar: Color::Gray,
425 sparkline: Color::White,
426 },
427 }
428 }
429
430 pub fn label(&self) -> &'static str {
432 match self {
433 ColorScheme::GreenRed => "G/R",
434 ColorScheme::BlueOrange => "B/O",
435 ColorScheme::Monochrome => "Mono",
436 }
437 }
438}
439
440#[derive(Debug, Clone, Copy)]
442pub struct ColorPalette {
443 pub up: Color,
445 pub down: Color,
447 pub neutral: Color,
449 pub header_fg: Color,
451 pub border: Color,
453 pub highlight: Color,
455 pub volume_bar: Color,
457 pub sparkline: Color,
459}
460
461#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
463#[serde(rename_all = "kebab-case")]
464pub enum ScaleMode {
465 #[default]
467 Linear,
468 Log,
470}
471
472impl ScaleMode {
473 pub fn toggle(&self) -> Self {
475 match self {
476 ScaleMode::Linear => ScaleMode::Log,
477 ScaleMode::Log => ScaleMode::Linear,
478 }
479 }
480
481 pub fn label(&self) -> &'static str {
483 match self {
484 ScaleMode::Linear => "Lin",
485 ScaleMode::Log => "Log",
486 }
487 }
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
492#[serde(default)]
493pub struct AlertConfig {
494 pub price_min: Option<f64>,
496 pub price_max: Option<f64>,
498 pub whale_min_usd: Option<f64>,
500 pub volume_spike_threshold_pct: Option<f64>,
502}
503
504impl Default for AlertConfig {
505 #[allow(clippy::derivable_impls)]
506 fn default() -> Self {
507 Self {
508 price_min: None,
509 price_max: None,
510 whale_min_usd: None,
511 volume_spike_threshold_pct: None,
512 }
513 }
514}
515
516#[derive(Debug, Clone)]
518pub struct ActiveAlert {
519 pub message: String,
521 pub triggered_at: Instant,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
527#[serde(default)]
528pub struct ExportConfig {
529 pub path: Option<String>,
531}
532
533impl Default for ExportConfig {
534 #[allow(clippy::derivable_impls)]
535 fn default() -> Self {
536 Self { path: None }
537 }
538}
539
540#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, clap::ValueEnum)]
545#[serde(rename_all = "kebab-case")]
546pub enum LayoutPreset {
547 #[default]
549 Dashboard,
550 ChartFocus,
552 Feed,
554 Compact,
556 Exchange,
558}
559
560impl LayoutPreset {
561 pub fn next(&self) -> Self {
563 match self {
564 LayoutPreset::Dashboard => LayoutPreset::ChartFocus,
565 LayoutPreset::ChartFocus => LayoutPreset::Feed,
566 LayoutPreset::Feed => LayoutPreset::Compact,
567 LayoutPreset::Compact => LayoutPreset::Exchange,
568 LayoutPreset::Exchange => LayoutPreset::Dashboard,
569 }
570 }
571
572 pub fn prev(&self) -> Self {
574 match self {
575 LayoutPreset::Dashboard => LayoutPreset::Exchange,
576 LayoutPreset::ChartFocus => LayoutPreset::Dashboard,
577 LayoutPreset::Feed => LayoutPreset::ChartFocus,
578 LayoutPreset::Compact => LayoutPreset::Feed,
579 LayoutPreset::Exchange => LayoutPreset::Compact,
580 }
581 }
582
583 pub fn label(&self) -> &'static str {
585 match self {
586 LayoutPreset::Dashboard => "Dashboard",
587 LayoutPreset::ChartFocus => "Chart",
588 LayoutPreset::Feed => "Feed",
589 LayoutPreset::Compact => "Compact",
590 LayoutPreset::Exchange => "Exchange",
591 }
592 }
593}
594
595#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
600#[serde(default)]
601pub struct WidgetVisibility {
602 pub price_chart: bool,
604 pub volume_chart: bool,
606 pub buy_sell_pressure: bool,
608 pub metrics_panel: bool,
610 pub activity_log: bool,
612 pub holder_count: bool,
614 pub liquidity_depth: bool,
616}
617
618impl Default for WidgetVisibility {
619 fn default() -> Self {
620 Self {
621 price_chart: true,
622 volume_chart: true,
623 buy_sell_pressure: true,
624 metrics_panel: true,
625 activity_log: true,
626 holder_count: true,
627 liquidity_depth: true,
628 }
629 }
630}
631
632impl WidgetVisibility {
633 pub fn visible_count(&self) -> usize {
635 [
636 self.price_chart,
637 self.volume_chart,
638 self.buy_sell_pressure,
639 self.metrics_panel,
640 self.activity_log,
641 ]
642 .iter()
643 .filter(|&&v| v)
644 .count()
645 }
646
647 pub fn toggle_by_index(&mut self, index: usize) {
649 match index {
650 1 => self.price_chart = !self.price_chart,
651 2 => self.volume_chart = !self.volume_chart,
652 3 => self.buy_sell_pressure = !self.buy_sell_pressure,
653 4 => self.metrics_panel = !self.metrics_panel,
654 5 => self.activity_log = !self.activity_log,
655 _ => {}
656 }
657 }
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
665#[serde(default)]
666pub struct MonitorConfig {
667 pub layout: LayoutPreset,
669 pub refresh_seconds: u64,
671 pub widgets: WidgetVisibility,
673 pub scale: ScaleMode,
675 pub color_scheme: ColorScheme,
677 pub alerts: AlertConfig,
679 pub export: ExportConfig,
681 pub auto_pause_on_input: bool,
683 #[serde(default)]
685 pub venue: Option<String>,
686}
687
688impl Default for MonitorConfig {
689 fn default() -> Self {
690 Self {
691 layout: LayoutPreset::Dashboard,
692 refresh_seconds: DEFAULT_REFRESH_SECS,
693 widgets: WidgetVisibility::default(),
694 scale: ScaleMode::Linear,
695 color_scheme: ColorScheme::GreenRed,
696 alerts: AlertConfig::default(),
697 export: ExportConfig::default(),
698 auto_pause_on_input: false,
699 venue: None,
700 }
701 }
702}
703
704pub struct MonitorState {
706 pub token_address: String,
708
709 pub symbol: String,
711
712 pub name: String,
714
715 pub chain: String,
717
718 pub price_history: VecDeque<DataPoint>,
720
721 pub volume_history: VecDeque<DataPoint>,
723
724 pub real_data_count: usize,
726
727 pub current_price: f64,
729
730 pub price_change_24h: f64,
732
733 pub price_change_6h: f64,
735
736 pub price_change_1h: f64,
738
739 pub price_change_5m: f64,
741
742 pub last_price_change_at: f64,
744
745 pub previous_price: f64,
747
748 pub buys_24h: u64,
750
751 pub sells_24h: u64,
753
754 pub liquidity_usd: f64,
756
757 pub volume_24h: f64,
759
760 pub market_cap: Option<f64>,
762
763 pub fdv: Option<f64>,
765
766 pub last_update: Instant,
768
769 pub refresh_rate: Duration,
771
772 pub paused: bool,
774
775 pub log_messages: VecDeque<String>,
777
778 pub log_list_state: ListState,
780
781 pub error_message: Option<String>,
783
784 pub time_period: TimePeriod,
786
787 pub chart_mode: ChartMode,
789
790 pub scale_mode: ScaleMode,
792
793 pub color_scheme: ColorScheme,
795
796 pub holder_count: Option<u64>,
798
799 pub liquidity_pairs: Vec<(String, f64)>,
801
802 pub order_book: Option<OrderBook>,
804
805 pub recent_trades: VecDeque<Trade>,
807
808 pub dex_pairs: Vec<DexPair>,
810
811 pub websites: Vec<String>,
813
814 pub socials: Vec<(String, String)>,
816
817 pub earliest_pair_created_at: Option<i64>,
819
820 pub dexscreener_url: Option<String>,
822
823 pub holder_fetch_counter: u32,
825
826 pub start_timestamp: i64,
828
829 pub layout: LayoutPreset,
831
832 pub widgets: WidgetVisibility,
834
835 pub auto_layout: bool,
837
838 pub widget_toggle_mode: bool,
840
841 pub alerts: AlertConfig,
844
845 pub active_alerts: Vec<ActiveAlert>,
847
848 pub alert_flash_until: Option<Instant>,
850
851 pub export_active: bool,
854
855 pub export_path: Option<PathBuf>,
857
858 pub volume_avg: f64,
860
861 pub auto_pause_on_input: bool,
864
865 pub last_input_at: Instant,
867
868 pub auto_pause_timeout: Duration,
870
871 pub exchange_ohlc: Vec<OhlcCandle>,
875
876 pub venue_pair: Option<String>,
878}
879
880impl MonitorState {
881 pub fn new(token_data: &DexTokenData, chain: &str) -> Self {
884 let now = Instant::now();
885 let now_ts = chrono::Utc::now().timestamp() as f64;
886
887 let (price_history, volume_history, real_data_count) =
889 if let Some(cached) = Self::load_cache(&token_data.address, chain) {
890 let cutoff = now_ts - MAX_DATA_AGE_SECS;
892 let price_hist: VecDeque<DataPoint> = cached
893 .price_history
894 .into_iter()
895 .filter(|p| p.timestamp >= cutoff)
896 .collect();
897 let vol_hist: VecDeque<DataPoint> = cached
898 .volume_history
899 .into_iter()
900 .filter(|p| p.timestamp >= cutoff)
901 .collect();
902 let real_count = price_hist.iter().filter(|p| p.is_real).count();
903 (price_hist, vol_hist, real_count)
904 } else {
905 let price_hist = Self::generate_synthetic_price_history(
907 token_data.price_usd,
908 token_data.price_change_1h,
909 token_data.price_change_6h,
910 token_data.price_change_24h,
911 now_ts,
912 );
913 let vol_hist = Self::generate_synthetic_volume_history(
914 token_data.volume_24h,
915 token_data.volume_6h,
916 token_data.volume_1h,
917 now_ts,
918 );
919 (price_hist, vol_hist, 0)
920 };
921
922 Self {
923 token_address: token_data.address.clone(),
924 symbol: token_data.symbol.clone(),
925 name: token_data.name.clone(),
926 chain: chain.to_string(),
927 price_history,
928 volume_history,
929 real_data_count,
930 current_price: token_data.price_usd,
931 price_change_24h: token_data.price_change_24h,
932 price_change_6h: token_data.price_change_6h,
933 price_change_1h: token_data.price_change_1h,
934 price_change_5m: token_data.price_change_5m,
935 last_price_change_at: now_ts, previous_price: token_data.price_usd,
937 buys_24h: token_data.total_buys_24h,
938 sells_24h: token_data.total_sells_24h,
939 liquidity_usd: token_data.liquidity_usd,
940 volume_24h: token_data.volume_24h,
941 market_cap: token_data.market_cap,
942 fdv: token_data.fdv,
943 last_update: now,
944 refresh_rate: Duration::from_secs(DEFAULT_REFRESH_SECS),
945 paused: false,
946 log_messages: VecDeque::with_capacity(10),
947 log_list_state: ListState::default(),
948 error_message: None,
949 time_period: TimePeriod::Hour1, chart_mode: ChartMode::Line, scale_mode: ScaleMode::Linear, color_scheme: ColorScheme::GreenRed, holder_count: None,
954 liquidity_pairs: Vec::new(),
955 order_book: None,
956 recent_trades: VecDeque::new(),
957 dex_pairs: token_data.pairs.clone(),
958 websites: token_data.websites.clone(),
959 socials: token_data
960 .socials
961 .iter()
962 .map(|s| (s.platform.clone(), s.url.clone()))
963 .collect(),
964 earliest_pair_created_at: token_data.earliest_pair_created_at,
965 dexscreener_url: token_data.dexscreener_url.clone(),
966 holder_fetch_counter: 0,
967 start_timestamp: now_ts as i64,
968 layout: LayoutPreset::Dashboard,
969 widgets: WidgetVisibility::default(),
970 auto_layout: true,
971 widget_toggle_mode: false,
972 alerts: AlertConfig::default(),
974 active_alerts: Vec::new(),
975 alert_flash_until: None,
976 export_active: false,
978 export_path: None,
979 volume_avg: token_data.volume_24h,
980 auto_pause_on_input: false,
982 last_input_at: now,
983 auto_pause_timeout: Duration::from_secs(3),
984 exchange_ohlc: Vec::new(),
986 venue_pair: None,
987 }
988 }
989
990 pub fn apply_config(&mut self, config: &MonitorConfig) {
992 self.layout = config.layout;
993 self.widgets = config.widgets.clone();
994 self.refresh_rate = Duration::from_secs(config.refresh_seconds);
995 self.scale_mode = config.scale;
996 self.color_scheme = config.color_scheme;
997 self.alerts = config.alerts.clone();
998 self.auto_pause_on_input = config.auto_pause_on_input;
999 }
1000
1001 pub fn palette(&self) -> ColorPalette {
1004 self.color_scheme.palette()
1005 }
1006
1007 pub fn toggle_chart_mode(&mut self) {
1008 self.chart_mode = self.chart_mode.next();
1009 self.log(format!("Chart mode: {}", self.chart_mode.label()));
1010 }
1011
1012 fn cache_path(token_address: &str, chain: &str) -> PathBuf {
1014 let mut path = std::env::temp_dir();
1015 let safe_addr = token_address
1017 .chars()
1018 .filter(|c| c.is_alphanumeric())
1019 .take(16)
1020 .collect::<String>()
1021 .to_lowercase();
1022 path.push(format!("{}{}_{}.json", CACHE_FILE_PREFIX, chain, safe_addr));
1023 path
1024 }
1025
1026 fn load_cache(token_address: &str, chain: &str) -> Option<CachedMonitorData> {
1028 let path = Self::cache_path(token_address, chain);
1029 if !path.exists() {
1030 return None;
1031 }
1032
1033 match fs::read_to_string(&path) {
1034 Ok(contents) => {
1035 match serde_json::from_str::<CachedMonitorData>(&contents) {
1036 Ok(cached) => {
1037 if cached.token_address.to_lowercase() == token_address.to_lowercase()
1039 && cached.chain.to_lowercase() == chain.to_lowercase()
1040 {
1041 Some(cached)
1042 } else {
1043 None
1044 }
1045 }
1046 Err(_) => None,
1047 }
1048 }
1049 Err(_) => None,
1050 }
1051 }
1052
1053 pub fn save_cache(&self) {
1055 let cached = CachedMonitorData {
1056 token_address: self.token_address.clone(),
1057 chain: self.chain.clone(),
1058 price_history: self.price_history.iter().copied().collect(),
1059 volume_history: self.volume_history.iter().copied().collect(),
1060 saved_at: chrono::Utc::now().timestamp() as f64,
1061 };
1062
1063 let path = Self::cache_path(&self.token_address, &self.chain);
1064 if let Ok(json) = serde_json::to_string(&cached) {
1065 let _ = fs::write(&path, json);
1066 }
1067 }
1068
1069 fn generate_synthetic_price_history(
1072 current_price: f64,
1073 change_1h: f64,
1074 change_6h: f64,
1075 change_24h: f64,
1076 now_ts: f64,
1077 ) -> VecDeque<DataPoint> {
1078 let mut history = VecDeque::with_capacity(50);
1079
1080 let price_1h_ago = current_price / (1.0 + change_1h / 100.0);
1082 let price_6h_ago = current_price / (1.0 + change_6h / 100.0);
1083 let price_24h_ago = current_price / (1.0 + change_24h / 100.0);
1084
1085 let points = [
1087 (now_ts - 24.0 * 3600.0, price_24h_ago),
1088 (now_ts - 12.0 * 3600.0, (price_24h_ago + price_6h_ago) / 2.0),
1089 (now_ts - 6.0 * 3600.0, price_6h_ago),
1090 (now_ts - 3.0 * 3600.0, (price_6h_ago + price_1h_ago) / 2.0),
1091 (now_ts - 1.0 * 3600.0, price_1h_ago),
1092 (now_ts - 0.5 * 3600.0, (price_1h_ago + current_price) / 2.0),
1093 (now_ts, current_price),
1094 ];
1095
1096 for i in 0..points.len() - 1 {
1098 let (t1, p1) = points[i];
1099 let (t2, p2) = points[i + 1];
1100 let steps = 4; for j in 0..steps {
1103 let frac = j as f64 / steps as f64;
1104 let t = t1 + (t2 - t1) * frac;
1105 let p = p1 + (p2 - p1) * frac;
1106 history.push_back(DataPoint {
1107 timestamp: t,
1108 value: p,
1109 is_real: false, });
1111 }
1112 }
1113 history.push_back(DataPoint {
1115 timestamp: points[points.len() - 1].0,
1116 value: points[points.len() - 1].1,
1117 is_real: false,
1118 });
1119
1120 history
1121 }
1122
1123 fn generate_synthetic_volume_history(
1126 volume_24h: f64,
1127 volume_6h: f64,
1128 volume_1h: f64,
1129 now_ts: f64,
1130 ) -> VecDeque<DataPoint> {
1131 let mut history = VecDeque::with_capacity(24);
1132
1133 let hourly_avg = volume_24h / 24.0;
1135
1136 for i in 0..24 {
1137 let hours_ago = 24 - i;
1138 let ts = now_ts - (hours_ago as f64) * 3600.0;
1139
1140 let volume = if hours_ago <= 1 {
1142 volume_1h
1143 } else if hours_ago <= 6 {
1144 volume_6h / 6.0
1145 } else {
1146 hourly_avg * (0.8 + (i as f64 / 24.0) * 0.4)
1148 };
1149
1150 history.push_back(DataPoint {
1151 timestamp: ts,
1152 value: volume,
1153 is_real: false, });
1155 }
1156
1157 history
1158 }
1159
1160 fn generate_synthetic_order_book(
1166 pairs: &[DexPair],
1167 symbol: &str,
1168 price: f64,
1169 total_liquidity: f64,
1170 ) -> Option<OrderBook> {
1171 if price <= 0.0 || total_liquidity <= 0.0 {
1172 return None;
1173 }
1174
1175 let base_spread_bps = if total_liquidity > 1_000_000.0 {
1177 5.0 } else if total_liquidity > 100_000.0 {
1179 15.0 } else {
1181 50.0 };
1183
1184 let half_spread = price * base_spread_bps / 10_000.0;
1185 let half_liq = total_liquidity / 2.0;
1186 let num_levels: usize = 15;
1187
1188 let mut asks = Vec::with_capacity(num_levels);
1190 for i in 0..num_levels {
1191 let offset_pct = (1.0 + i as f64 * 0.3).powf(1.4) * 0.001;
1193 let ask_price = price + half_spread + price * offset_pct;
1194 let weight = (-1.5 * i as f64 / num_levels as f64).exp();
1196 let level_liq = half_liq * weight / num_levels as f64 * 2.5;
1197 let quantity = level_liq / ask_price;
1198 if quantity > 0.0 {
1199 asks.push(OrderBookLevel {
1200 price: ask_price,
1201 quantity,
1202 });
1203 }
1204 }
1205
1206 let mut bids = Vec::with_capacity(num_levels);
1208 for i in 0..num_levels {
1209 let offset_pct = (1.0 + i as f64 * 0.3).powf(1.4) * 0.001;
1210 let bid_price = price - half_spread - price * offset_pct;
1211 if bid_price <= 0.0 {
1212 break;
1213 }
1214 let weight = (-1.5 * i as f64 / num_levels as f64).exp();
1215 let level_liq = half_liq * weight / num_levels as f64 * 2.5;
1216 let quantity = level_liq / bid_price;
1217 if quantity > 0.0 {
1218 bids.push(OrderBookLevel {
1219 price: bid_price,
1220 quantity,
1221 });
1222 }
1223 }
1224
1225 let quote = pairs
1227 .first()
1228 .map(|p| p.quote_token.as_str())
1229 .unwrap_or("USD");
1230
1231 Some(OrderBook {
1232 pair: format!("{}/{}", symbol, quote),
1233 bids,
1234 asks,
1235 })
1236 }
1237
1238 pub fn update(&mut self, token_data: &DexTokenData) {
1241 let now_ts = chrono::Utc::now().timestamp() as f64;
1242
1243 self.price_history.push_back(DataPoint {
1245 timestamp: now_ts,
1246 value: token_data.price_usd,
1247 is_real: true,
1248 });
1249 self.volume_history.push_back(DataPoint {
1250 timestamp: now_ts,
1251 value: token_data.volume_24h,
1252 is_real: true,
1253 });
1254 self.real_data_count += 1;
1255
1256 let cutoff = now_ts - MAX_DATA_AGE_SECS;
1258
1259 while let Some(point) = self.price_history.front() {
1260 if point.timestamp < cutoff {
1261 self.price_history.pop_front();
1262 } else {
1263 break;
1264 }
1265 }
1266 while let Some(point) = self.volume_history.front() {
1267 if point.timestamp < cutoff {
1268 self.volume_history.pop_front();
1269 } else {
1270 break;
1271 }
1272 }
1273
1274 let price_changed = (self.previous_price - token_data.price_usd).abs() > 0.00000001;
1276 if price_changed {
1277 self.last_price_change_at = now_ts;
1278 self.previous_price = token_data.price_usd;
1279 }
1280
1281 self.current_price = token_data.price_usd;
1283 self.price_change_24h = token_data.price_change_24h;
1284 self.price_change_6h = token_data.price_change_6h;
1285 self.price_change_1h = token_data.price_change_1h;
1286 self.price_change_5m = token_data.price_change_5m;
1287 self.buys_24h = token_data.total_buys_24h;
1288 self.sells_24h = token_data.total_sells_24h;
1289 self.liquidity_usd = token_data.liquidity_usd;
1290 self.volume_24h = token_data.volume_24h;
1291 self.market_cap = token_data.market_cap;
1292 self.fdv = token_data.fdv;
1293
1294 self.liquidity_pairs = token_data
1296 .pairs
1297 .iter()
1298 .map(|p| {
1299 let label = format!("{}/{} ({})", p.base_token, p.quote_token, p.dex_name);
1300 (label, p.liquidity_usd)
1301 })
1302 .collect();
1303
1304 self.dex_pairs = token_data.pairs.clone();
1306 self.order_book = Self::generate_synthetic_order_book(
1307 &token_data.pairs,
1308 &token_data.symbol,
1309 token_data.price_usd,
1310 token_data.liquidity_usd,
1311 );
1312
1313 if token_data.price_usd > 0.0 {
1315 let side = if price_changed && token_data.price_usd > self.current_price {
1316 TradeSide::Buy
1317 } else if price_changed {
1318 TradeSide::Sell
1319 } else {
1320 if token_data.total_buys_24h >= token_data.total_sells_24h {
1322 TradeSide::Buy
1323 } else {
1324 TradeSide::Sell
1325 }
1326 };
1327 let ts_ms = (now_ts * 1000.0) as u64;
1328 let qty = if token_data.volume_24h > 0.0 && token_data.price_usd > 0.0 {
1330 let per_update_vol =
1332 token_data.volume_24h / 86400.0 * self.refresh_rate.as_secs_f64();
1333 per_update_vol / token_data.price_usd
1334 } else {
1335 1.0
1336 };
1337 self.recent_trades.push_back(Trade {
1338 price: token_data.price_usd,
1339 quantity: qty,
1340 quote_quantity: Some(qty * token_data.price_usd),
1341 timestamp_ms: ts_ms,
1342 side,
1343 id: None,
1344 });
1345 while self.recent_trades.len() > 200 {
1347 self.recent_trades.pop_front();
1348 }
1349 }
1350
1351 self.websites = token_data.websites.clone();
1353 self.socials = token_data
1354 .socials
1355 .iter()
1356 .map(|s| (s.platform.clone(), s.url.clone()))
1357 .collect();
1358 self.earliest_pair_created_at = token_data.earliest_pair_created_at;
1359 self.dexscreener_url = token_data.dexscreener_url.clone();
1360
1361 self.last_update = Instant::now();
1362 self.error_message = None;
1363
1364 self.check_alerts(token_data);
1366
1367 if self.export_active {
1369 self.write_export_row();
1370 }
1371
1372 self.volume_avg = self.volume_avg * 0.9 + token_data.volume_24h * 0.1;
1375
1376 self.log(format!("Updated: ${:.6}", token_data.price_usd));
1377
1378 if self.real_data_count.is_multiple_of(60) {
1380 self.save_cache();
1381 }
1382 }
1383
1384 fn check_alerts(&mut self, token_data: &DexTokenData) {
1386 self.active_alerts.clear();
1387
1388 if let Some(min) = self.alerts.price_min
1390 && self.current_price < min
1391 {
1392 self.active_alerts.push(ActiveAlert {
1393 message: format!("⚠ Price ${:.6} below min ${:.6}", self.current_price, min),
1394 triggered_at: Instant::now(),
1395 });
1396 }
1397
1398 if let Some(max) = self.alerts.price_max
1400 && self.current_price > max
1401 {
1402 self.active_alerts.push(ActiveAlert {
1403 message: format!("⚠ Price ${:.6} above max ${:.6}", self.current_price, max),
1404 triggered_at: Instant::now(),
1405 });
1406 }
1407
1408 if let Some(threshold_pct) = self.alerts.volume_spike_threshold_pct
1410 && self.volume_avg > 0.0
1411 {
1412 let spike_pct = ((token_data.volume_24h - self.volume_avg) / self.volume_avg) * 100.0;
1413 if spike_pct > threshold_pct {
1414 self.active_alerts.push(ActiveAlert {
1415 message: format!("⚠ Volume spike: +{:.1}% vs avg", spike_pct),
1416 triggered_at: Instant::now(),
1417 });
1418 }
1419 }
1420
1421 if let Some(whale_min) = self.alerts.whale_min_usd {
1423 let total_txs = (token_data.total_buys_24h + token_data.total_sells_24h) as f64;
1425 if total_txs > 0.0 && token_data.volume_24h / total_txs >= whale_min {
1426 let avg_tx_size = token_data.volume_24h / total_txs;
1427 self.active_alerts.push(ActiveAlert {
1428 message: format!(
1429 "🐋 Avg tx size {} ≥ whale threshold {}",
1430 crate::display::format_usd(avg_tx_size),
1431 crate::display::format_usd(whale_min)
1432 ),
1433 triggered_at: Instant::now(),
1434 });
1435 }
1436 }
1437
1438 if !self.active_alerts.is_empty() {
1440 self.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
1441 }
1442 }
1443
1444 fn write_export_row(&mut self) {
1446 if let Some(ref path) = self.export_path {
1447 if let Ok(file) = fs::OpenOptions::new().append(true).open(path) {
1449 let mut writer = BufWriter::new(file);
1450 let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
1451 let market_cap_str = self
1452 .market_cap
1453 .map(|mc| format!("{:.2}", mc))
1454 .unwrap_or_default();
1455 let row = format!(
1456 "{},{:.8},{:.2},{:.2},{},{},{}\n",
1457 timestamp,
1458 self.current_price,
1459 self.volume_24h,
1460 self.liquidity_usd,
1461 self.buys_24h,
1462 self.sells_24h,
1463 market_cap_str,
1464 );
1465 let _ = writer.write_all(row.as_bytes());
1466 }
1467 }
1468 }
1469
1470 pub fn start_export(&mut self) {
1472 let base_dir = PathBuf::from("./scope-exports");
1473 let _ = fs::create_dir_all(&base_dir);
1474 let date_str = chrono::Local::now().format("%Y%m%d_%H%M%S").to_string();
1475 let filename = format!("{}_{}.csv", self.symbol, date_str);
1476 let path = base_dir.join(filename);
1477
1478 if let Ok(mut file) = fs::File::create(&path) {
1480 let header =
1481 "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
1482 let _ = file.write_all(header.as_bytes());
1483 }
1484
1485 self.export_path = Some(path.clone());
1486 self.export_active = true;
1487 self.log(format!("Export started: {}", path.display()));
1488 }
1489
1490 pub fn stop_export(&mut self) {
1492 if let Some(ref path) = self.export_path {
1493 self.log(format!("Export saved: {}", path.display()));
1494 }
1495 self.export_active = false;
1496 self.export_path = None;
1497 }
1498
1499 pub fn toggle_export(&mut self) {
1501 if self.export_active {
1502 self.stop_export();
1503 } else {
1504 self.start_export();
1505 }
1506 }
1507
1508 pub fn get_price_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
1511 let now_ts = chrono::Utc::now().timestamp() as f64;
1512 let cutoff = now_ts - self.time_period.duration_secs() as f64;
1513
1514 let filtered: Vec<&DataPoint> = self
1515 .price_history
1516 .iter()
1517 .filter(|p| p.timestamp >= cutoff)
1518 .collect();
1519
1520 let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
1521 let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
1522
1523 (data, is_real)
1524 }
1525
1526 pub fn get_volume_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
1529 let now_ts = chrono::Utc::now().timestamp() as f64;
1530 let cutoff = now_ts - self.time_period.duration_secs() as f64;
1531
1532 let filtered: Vec<&DataPoint> = self
1533 .volume_history
1534 .iter()
1535 .filter(|p| p.timestamp >= cutoff)
1536 .collect();
1537
1538 let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
1539 let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
1540
1541 (data, is_real)
1542 }
1543
1544 pub fn data_stats(&self) -> (usize, usize) {
1546 let now_ts = chrono::Utc::now().timestamp() as f64;
1547 let cutoff = now_ts - self.time_period.duration_secs() as f64;
1548
1549 let (synthetic, real) = self
1550 .price_history
1551 .iter()
1552 .filter(|p| p.timestamp >= cutoff)
1553 .fold(
1554 (0, 0),
1555 |(s, r), p| {
1556 if p.is_real { (s, r + 1) } else { (s + 1, r) }
1557 },
1558 );
1559
1560 (synthetic, real)
1561 }
1562
1563 pub fn memory_usage(&self) -> usize {
1565 let point_size = std::mem::size_of::<DataPoint>();
1567 (self.price_history.len() + self.volume_history.len()) * point_size
1568 }
1569
1570 pub fn get_ohlc_candles(&self) -> Vec<OhlcCandle> {
1580 if !self.exchange_ohlc.is_empty() {
1582 return self.exchange_ohlc.clone();
1583 }
1584
1585 let (data, _) = self.get_price_data_for_period();
1586
1587 if data.is_empty() {
1588 return vec![];
1589 }
1590
1591 let candle_duration_secs = match self.time_period {
1593 TimePeriod::Min1 => 5.0, TimePeriod::Min5 => 15.0, TimePeriod::Min15 => 60.0, TimePeriod::Hour1 => 300.0, TimePeriod::Hour4 => 900.0, TimePeriod::Day1 => 3600.0, };
1600
1601 let mut candles: Vec<OhlcCandle> = Vec::new();
1602
1603 for (timestamp, price) in data {
1604 let candle_start = (timestamp / candle_duration_secs).floor() * candle_duration_secs;
1606
1607 if let Some(last_candle) = candles.last_mut() {
1608 if (last_candle.timestamp - candle_start).abs() < 0.001 {
1609 last_candle.update(price);
1611 } else {
1612 candles.push(OhlcCandle::new(candle_start, price));
1614 }
1615 } else {
1616 candles.push(OhlcCandle::new(candle_start, price));
1618 }
1619 }
1620
1621 candles
1622 }
1623
1624 pub fn cycle_time_period(&mut self) {
1626 self.time_period = self.time_period.next();
1627 self.log(format!("Time period: {}", self.time_period.label()));
1628 }
1629
1630 pub fn set_time_period(&mut self, period: TimePeriod) {
1632 self.time_period = period;
1633 self.log(format!("Time period: {}", period.label()));
1634 }
1635
1636 fn log(&mut self, message: String) {
1638 let timestamp = chrono::Local::now().format("%H:%M:%S").to_string();
1639 self.log_messages
1640 .push_back(format!("[{}] {}", timestamp, message));
1641 while self.log_messages.len() > 10 {
1642 self.log_messages.pop_front();
1643 }
1644 }
1645
1646 pub fn should_refresh(&self) -> bool {
1649 if self.paused {
1650 return false;
1651 }
1652 if self.auto_pause_on_input && self.last_input_at.elapsed() < self.auto_pause_timeout {
1654 return false;
1655 }
1656 self.last_update.elapsed() >= self.refresh_rate
1657 }
1658
1659 pub fn is_auto_paused(&self) -> bool {
1661 self.auto_pause_on_input && self.last_input_at.elapsed() < self.auto_pause_timeout
1662 }
1663
1664 pub fn toggle_pause(&mut self) {
1666 self.paused = !self.paused;
1667 self.log(if self.paused {
1668 "Paused".to_string()
1669 } else {
1670 "Resumed".to_string()
1671 });
1672 }
1673
1674 pub fn force_refresh(&mut self) {
1676 self.paused = false;
1677 self.last_update = Instant::now() - self.refresh_rate;
1678 }
1679
1680 pub fn slower_refresh(&mut self) {
1682 let current_secs = self.refresh_rate.as_secs();
1683 let new_secs = (current_secs + 5).min(MAX_REFRESH_SECS);
1684 self.refresh_rate = Duration::from_secs(new_secs);
1685 self.log(format!("Refresh rate: {}s", new_secs));
1686 }
1687
1688 pub fn faster_refresh(&mut self) {
1690 let current_secs = self.refresh_rate.as_secs();
1691 let new_secs = current_secs.saturating_sub(5).max(MIN_REFRESH_SECS);
1692 self.refresh_rate = Duration::from_secs(new_secs);
1693 self.log(format!("Refresh rate: {}s", new_secs));
1694 }
1695
1696 pub fn scroll_log_down(&mut self) {
1698 let len = self.log_messages.len();
1699 if len == 0 {
1700 return;
1701 }
1702 let i = self
1703 .log_list_state
1704 .selected()
1705 .map_or(0, |i| if i + 1 < len { i + 1 } else { i });
1706 self.log_list_state.select(Some(i));
1707 }
1708
1709 pub fn scroll_log_up(&mut self) {
1711 let i = self
1712 .log_list_state
1713 .selected()
1714 .map_or(0, |i| i.saturating_sub(1));
1715 self.log_list_state.select(Some(i));
1716 }
1717
1718 pub fn refresh_rate_secs(&self) -> u64 {
1720 self.refresh_rate.as_secs()
1721 }
1722
1723 pub fn buy_ratio(&self) -> f64 {
1725 let total = self.buys_24h + self.sells_24h;
1726 if total == 0 {
1727 0.5
1728 } else {
1729 self.buys_24h as f64 / total as f64
1730 }
1731 }
1732}
1733
1734pub struct MonitorApp<B: ratatui::backend::Backend = ratatui::backend::CrosstermBackend<io::Stdout>>
1739{
1740 terminal: ratatui::Terminal<B>,
1742
1743 state: MonitorState,
1745
1746 dex_client: Box<dyn DexDataSource>,
1748
1749 chain_client: Option<Box<dyn ChainClient>>,
1751
1752 exchange_client: Option<crate::market::ExchangeClient>,
1754
1755 should_exit: bool,
1757
1758 owns_terminal: bool,
1761}
1762
1763impl MonitorApp {
1765 pub fn new(
1767 initial_data: DexTokenData,
1768 chain: &str,
1769 monitor_config: &MonitorConfig,
1770 chain_client: Option<Box<dyn ChainClient>>,
1771 exchange_client: Option<crate::market::ExchangeClient>,
1772 ) -> Result<Self> {
1773 let terminal = ratatui::init();
1775 execute!(io::stdout(), EnableMouseCapture)
1777 .map_err(|e| ScopeError::Chain(format!("Failed to enable mouse capture: {}", e)))?;
1778
1779 let mut state = MonitorState::new(&initial_data, chain);
1780 state.apply_config(monitor_config);
1781
1782 if let Some(ref ex) = exchange_client {
1784 let pair = ex.format_pair(&initial_data.symbol);
1785 state.venue_pair = Some(pair);
1786 }
1787
1788 Ok(Self {
1789 terminal,
1790 state,
1791 dex_client: Box::new(DexClient::new()),
1792 chain_client,
1793 exchange_client,
1794 should_exit: false,
1795 owns_terminal: true,
1796 })
1797 }
1798
1799 pub async fn run(&mut self) -> Result<()> {
1801 use futures::StreamExt;
1802
1803 let mut event_stream = crossterm::event::EventStream::new();
1804
1805 loop {
1806 self.terminal.draw(|f| ui(f, &mut self.state))?;
1808
1809 let refresh_delay = if self.state.paused {
1811 Duration::from_millis(200) } else {
1813 let elapsed = self.state.last_update.elapsed();
1814 self.state.refresh_rate.saturating_sub(elapsed)
1815 };
1816
1817 tokio::select! {
1819 maybe_event = event_stream.next() => {
1820 match maybe_event {
1821 Some(Ok(Event::Key(key))) => {
1822 self.handle_key_event(key);
1823 }
1824 Some(Ok(Event::Resize(_, _))) => {
1825 }
1827 _ => {}
1828 }
1829 }
1830 _ = tokio::time::sleep(refresh_delay) => {
1831 }
1833 }
1834
1835 if self.should_exit {
1836 break;
1837 }
1838
1839 if self.state.should_refresh() {
1841 self.fetch_data().await;
1842 }
1843 }
1844
1845 Ok(())
1846 }
1847}
1848
1849impl<B: ratatui::backend::Backend> Drop for MonitorApp<B> {
1850 fn drop(&mut self) {
1851 if self.owns_terminal {
1852 let _ = execute!(io::stdout(), DisableMouseCapture);
1853 ratatui::restore();
1854 }
1855 }
1856}
1857
1858impl<B: ratatui::backend::Backend> MonitorApp<B> {
1861 pub fn cleanup(&mut self) -> Result<()> {
1864 self.state.save_cache();
1866
1867 if self.owns_terminal {
1868 let _ = execute!(io::stdout(), DisableMouseCapture);
1870 ratatui::restore();
1872 }
1873 Ok(())
1874 }
1875
1876 fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) {
1879 self.state.last_input_at = Instant::now();
1881
1882 if self.state.widget_toggle_mode {
1884 self.state.widget_toggle_mode = false;
1885 if let KeyCode::Char(c @ '1'..='5') = key.code {
1886 let idx = (c as u8 - b'0') as usize;
1887 self.state.widgets.toggle_by_index(idx);
1888 return;
1889 }
1890 }
1892
1893 match key.code {
1894 KeyCode::Char('q') | KeyCode::Esc => {
1895 if self.state.export_active {
1897 self.state.stop_export();
1898 }
1899 self.should_exit = true;
1900 }
1901 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1902 if self.state.export_active {
1903 self.state.stop_export();
1904 }
1905 self.should_exit = true;
1906 }
1907 KeyCode::Char('r') => {
1908 self.state.force_refresh();
1909 }
1910 KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::SHIFT) => {
1912 self.state.auto_pause_on_input = !self.state.auto_pause_on_input;
1913 self.state.log(format!(
1914 "Auto-pause: {}",
1915 if self.state.auto_pause_on_input {
1916 "ON"
1917 } else {
1918 "OFF"
1919 }
1920 ));
1921 }
1922 KeyCode::Char('p') | KeyCode::Char(' ') => {
1923 self.state.toggle_pause();
1924 }
1925 KeyCode::Char('e') => {
1927 self.state.toggle_export();
1928 }
1929 KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
1931 self.state.slower_refresh();
1932 }
1933 KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
1935 self.state.faster_refresh();
1936 }
1937 KeyCode::Char('1') => {
1939 self.state.set_time_period(TimePeriod::Min1);
1940 }
1941 KeyCode::Char('2') => {
1942 self.state.set_time_period(TimePeriod::Min5);
1943 }
1944 KeyCode::Char('3') => {
1945 self.state.set_time_period(TimePeriod::Min15);
1946 }
1947 KeyCode::Char('4') => {
1948 self.state.set_time_period(TimePeriod::Hour1);
1949 }
1950 KeyCode::Char('5') => {
1951 self.state.set_time_period(TimePeriod::Hour4);
1952 }
1953 KeyCode::Char('6') => {
1954 self.state.set_time_period(TimePeriod::Day1);
1955 }
1956 KeyCode::Char('t') | KeyCode::Tab => {
1957 self.state.cycle_time_period();
1958 }
1959 KeyCode::Char('c') => {
1961 self.state.toggle_chart_mode();
1962 }
1963 KeyCode::Char('s') => {
1965 self.state.scale_mode = self.state.scale_mode.toggle();
1966 self.state
1967 .log(format!("Scale: {}", self.state.scale_mode.label()));
1968 }
1969 KeyCode::Char('/') => {
1971 self.state.color_scheme = self.state.color_scheme.next();
1972 self.state
1973 .log(format!("Colors: {}", self.state.color_scheme.label()));
1974 }
1975 KeyCode::Char('j') | KeyCode::Down => {
1977 self.state.scroll_log_down();
1978 }
1979 KeyCode::Char('k') | KeyCode::Up => {
1980 self.state.scroll_log_up();
1981 }
1982 KeyCode::Char('l') => {
1984 self.state.layout = self.state.layout.next();
1985 self.state.auto_layout = false;
1986 }
1987 KeyCode::Char('h') => {
1988 self.state.layout = self.state.layout.prev();
1989 self.state.auto_layout = false;
1990 }
1991 KeyCode::Char('w') => {
1993 self.state.widget_toggle_mode = true;
1994 }
1995 KeyCode::Char('a') => {
1997 self.state.auto_layout = true;
1998 }
1999 _ => {}
2000 }
2001 }
2002
2003 async fn fetch_data(&mut self) {
2005 match self
2006 .dex_client
2007 .get_token_data(&self.state.chain, &self.state.token_address)
2008 .await
2009 {
2010 Ok(data) => {
2011 self.state.update(&data);
2012 }
2013 Err(e) => {
2014 self.state.error_message = Some(format!("API Error: {}", e));
2015 self.state.last_update = Instant::now(); }
2017 }
2018
2019 if let (Some(ex), Some(pair)) = (&self.exchange_client, &self.state.venue_pair.clone())
2021 && ex.has_ohlc()
2022 {
2023 let interval = self.state.time_period.exchange_interval();
2024 let limit = 100;
2025 match ex.fetch_ohlc(pair, interval, limit).await {
2026 Ok(candles) => {
2027 self.state.exchange_ohlc = candles
2028 .into_iter()
2029 .map(|c| OhlcCandle {
2030 timestamp: c.open_time as f64 / 1000.0,
2031 open: c.open,
2032 high: c.high,
2033 low: c.low,
2034 close: c.close,
2035 is_bullish: c.close >= c.open,
2036 })
2037 .collect();
2038 }
2039 Err(e) => {
2040 tracing::debug!("Failed to fetch OHLC: {}", e);
2041 }
2043 }
2044 }
2045
2046 self.state.holder_fetch_counter += 1;
2048 if self.state.holder_fetch_counter.is_multiple_of(12)
2049 && let Some(ref client) = self.chain_client
2050 {
2051 match client
2052 .get_token_holder_count(&self.state.token_address)
2053 .await
2054 {
2055 Ok(count) if count > 0 => {
2056 self.state.holder_count = Some(count);
2057 }
2058 _ => {} }
2060 }
2061 }
2062}
2063
2064#[cfg(test)]
2067fn handle_key_event_on_state(key: crossterm::event::KeyEvent, state: &mut MonitorState) -> bool {
2068 state.last_input_at = Instant::now();
2070
2071 if state.widget_toggle_mode {
2073 state.widget_toggle_mode = false;
2074 if let KeyCode::Char(c @ '1'..='5') = key.code {
2075 let idx = (c as u8 - b'0') as usize;
2076 state.widgets.toggle_by_index(idx);
2077 return false;
2078 }
2079 }
2081
2082 match key.code {
2083 KeyCode::Char('q') | KeyCode::Esc => {
2084 if state.export_active {
2085 state.stop_export();
2086 }
2087 return true;
2088 }
2089 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
2090 if state.export_active {
2091 state.stop_export();
2092 }
2093 return true;
2094 }
2095 KeyCode::Char('r') => {
2096 state.force_refresh();
2097 }
2098 KeyCode::Char('P') if key.modifiers.contains(KeyModifiers::SHIFT) => {
2100 state.auto_pause_on_input = !state.auto_pause_on_input;
2101 state.log(format!(
2102 "Auto-pause: {}",
2103 if state.auto_pause_on_input {
2104 "ON"
2105 } else {
2106 "OFF"
2107 }
2108 ));
2109 }
2110 KeyCode::Char('p') | KeyCode::Char(' ') => {
2111 state.toggle_pause();
2112 }
2113 KeyCode::Char('e') => {
2115 state.toggle_export();
2116 }
2117 KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
2118 state.slower_refresh();
2119 }
2120 KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
2121 state.faster_refresh();
2122 }
2123 KeyCode::Char('1') => {
2124 state.set_time_period(TimePeriod::Min1);
2125 }
2126 KeyCode::Char('2') => {
2127 state.set_time_period(TimePeriod::Min5);
2128 }
2129 KeyCode::Char('3') => {
2130 state.set_time_period(TimePeriod::Min15);
2131 }
2132 KeyCode::Char('4') => {
2133 state.set_time_period(TimePeriod::Hour1);
2134 }
2135 KeyCode::Char('5') => {
2136 state.set_time_period(TimePeriod::Hour4);
2137 }
2138 KeyCode::Char('6') => {
2139 state.set_time_period(TimePeriod::Day1);
2140 }
2141 KeyCode::Char('t') | KeyCode::Tab => {
2142 state.cycle_time_period();
2143 }
2144 KeyCode::Char('c') => {
2145 state.toggle_chart_mode();
2146 }
2147 KeyCode::Char('s') => {
2148 state.scale_mode = state.scale_mode.toggle();
2149 state.log(format!("Scale: {}", state.scale_mode.label()));
2150 }
2151 KeyCode::Char('/') => {
2152 state.color_scheme = state.color_scheme.next();
2153 state.log(format!("Colors: {}", state.color_scheme.label()));
2154 }
2155 KeyCode::Char('j') | KeyCode::Down => {
2156 state.scroll_log_down();
2157 }
2158 KeyCode::Char('k') | KeyCode::Up => {
2159 state.scroll_log_up();
2160 }
2161 KeyCode::Char('l') => {
2162 state.layout = state.layout.next();
2163 state.auto_layout = false;
2164 }
2165 KeyCode::Char('h') => {
2166 state.layout = state.layout.prev();
2167 state.auto_layout = false;
2168 }
2169 KeyCode::Char('w') => {
2170 state.widget_toggle_mode = true;
2171 }
2172 KeyCode::Char('a') => {
2173 state.auto_layout = true;
2174 }
2175 _ => {}
2176 }
2177 false
2178}
2179
2180struct LayoutAreas {
2183 price_chart: Option<Rect>,
2184 volume_chart: Option<Rect>,
2185 buy_sell_gauge: Option<Rect>,
2186 metrics_panel: Option<Rect>,
2187 activity_feed: Option<Rect>,
2188 order_book: Option<Rect>,
2190 market_info: Option<Rect>,
2192 trade_history: Option<Rect>,
2194}
2195
2196fn layout_dashboard(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2208 let rows = Layout::default()
2209 .direction(Direction::Vertical)
2210 .constraints([
2211 Constraint::Percentage(55),
2212 Constraint::Percentage(20),
2213 Constraint::Percentage(25),
2214 ])
2215 .split(area);
2216
2217 let top = Layout::default()
2218 .direction(Direction::Horizontal)
2219 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
2220 .split(rows[0]);
2221
2222 let middle = Layout::default()
2223 .direction(Direction::Horizontal)
2224 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
2225 .split(rows[1]);
2226
2227 LayoutAreas {
2228 price_chart: if widgets.price_chart {
2229 Some(top[0])
2230 } else {
2231 None
2232 },
2233 volume_chart: if widgets.volume_chart {
2234 Some(top[1])
2235 } else {
2236 None
2237 },
2238 buy_sell_gauge: if widgets.buy_sell_pressure {
2239 Some(middle[0])
2240 } else {
2241 None
2242 },
2243 metrics_panel: if widgets.metrics_panel {
2244 Some(middle[1])
2245 } else {
2246 None
2247 },
2248 activity_feed: if widgets.activity_log {
2249 Some(rows[2])
2250 } else {
2251 None
2252 },
2253 order_book: None,
2254 market_info: None,
2255 trade_history: None,
2256 }
2257}
2258
2259fn layout_chart_focus(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2271 let vertical = Layout::default()
2272 .direction(Direction::Vertical)
2273 .constraints([Constraint::Percentage(85), Constraint::Percentage(15)])
2274 .split(area);
2275
2276 LayoutAreas {
2277 price_chart: if widgets.price_chart {
2278 Some(vertical[0])
2279 } else {
2280 None
2281 },
2282 volume_chart: None, buy_sell_gauge: None, metrics_panel: if widgets.metrics_panel {
2285 Some(vertical[1])
2286 } else {
2287 None
2288 },
2289 activity_feed: None, order_book: None,
2291 market_info: None,
2292 trade_history: None,
2293 }
2294}
2295
2296fn layout_feed(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2308 let vertical = Layout::default()
2309 .direction(Direction::Vertical)
2310 .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
2311 .split(area);
2312
2313 let top = Layout::default()
2314 .direction(Direction::Horizontal)
2315 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
2316 .split(vertical[0]);
2317
2318 LayoutAreas {
2319 price_chart: None, volume_chart: None, metrics_panel: if widgets.metrics_panel {
2322 Some(top[0])
2323 } else {
2324 None
2325 },
2326 buy_sell_gauge: if widgets.buy_sell_pressure {
2327 Some(top[1])
2328 } else {
2329 None
2330 },
2331 activity_feed: if widgets.activity_log {
2332 Some(vertical[1])
2333 } else {
2334 None
2335 },
2336 order_book: None,
2337 market_info: None,
2338 trade_history: None,
2339 }
2340}
2341
2342fn layout_compact(area: Rect, widgets: &WidgetVisibility) -> LayoutAreas {
2350 LayoutAreas {
2351 price_chart: None, volume_chart: None, buy_sell_gauge: None, metrics_panel: if widgets.metrics_panel {
2355 Some(area)
2356 } else {
2357 None
2358 },
2359 activity_feed: None, order_book: None,
2361 market_info: None,
2362 trade_history: None,
2363 }
2364}
2365
2366fn layout_exchange(area: Rect, _widgets: &WidgetVisibility) -> LayoutAreas {
2380 let rows = Layout::default()
2381 .direction(Direction::Vertical)
2382 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
2383 .split(area);
2384
2385 let top = Layout::default()
2386 .direction(Direction::Horizontal)
2387 .constraints([
2388 Constraint::Percentage(25),
2389 Constraint::Percentage(45),
2390 Constraint::Percentage(30),
2391 ])
2392 .split(rows[0]);
2393
2394 let bottom = Layout::default()
2395 .direction(Direction::Horizontal)
2396 .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
2397 .split(rows[1]);
2398
2399 LayoutAreas {
2400 price_chart: Some(top[1]),
2401 volume_chart: None,
2402 buy_sell_gauge: Some(bottom[0]),
2403 metrics_panel: None,
2404 activity_feed: None,
2405 order_book: Some(top[0]),
2406 market_info: Some(bottom[1]),
2407 trade_history: Some(top[2]),
2408 }
2409}
2410
2411fn auto_select_layout(size: Rect) -> LayoutPreset {
2413 match (size.width, size.height) {
2414 (w, h) if w < 80 || h < 24 => LayoutPreset::Compact,
2415 (w, _) if w < 120 => LayoutPreset::Feed,
2416 (_, h) if h < 30 => LayoutPreset::ChartFocus,
2417 _ => LayoutPreset::Dashboard,
2418 }
2419}
2420
2421fn ui(f: &mut Frame, state: &mut MonitorState) {
2423 if state.auto_layout {
2425 let suggested = auto_select_layout(f.area());
2426 if suggested != state.layout {
2427 state.layout = suggested;
2428 }
2429 }
2430
2431 let chunks = Layout::default()
2433 .direction(Direction::Vertical)
2434 .constraints([
2435 Constraint::Length(4), Constraint::Min(10), Constraint::Length(3), ])
2439 .split(f.area());
2440
2441 render_header(f, chunks[0], state);
2443
2444 let areas = match state.layout {
2446 LayoutPreset::Dashboard => layout_dashboard(chunks[1], &state.widgets),
2447 LayoutPreset::ChartFocus => layout_chart_focus(chunks[1], &state.widgets),
2448 LayoutPreset::Feed => layout_feed(chunks[1], &state.widgets),
2449 LayoutPreset::Compact => layout_compact(chunks[1], &state.widgets),
2450 LayoutPreset::Exchange => layout_exchange(chunks[1], &state.widgets),
2451 };
2452
2453 if let Some(area) = areas.price_chart {
2455 match state.chart_mode {
2456 ChartMode::Line => render_price_chart(f, area, state),
2457 ChartMode::Candlestick => render_candlestick_chart(f, area, state),
2458 ChartMode::VolumeProfile => render_volume_profile_chart(f, area, state),
2459 }
2460 }
2461 if let Some(area) = areas.volume_chart {
2462 render_volume_chart(f, area, &*state);
2463 }
2464 if let Some(area) = areas.buy_sell_gauge {
2465 render_buy_sell_gauge(f, area, state);
2466 }
2467 if let Some(area) = areas.metrics_panel {
2468 if state.widgets.liquidity_depth && !state.liquidity_pairs.is_empty() {
2470 let split = Layout::default()
2471 .direction(Direction::Vertical)
2472 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
2473 .split(area);
2474 render_metrics_panel(f, split[0], &*state);
2475 render_liquidity_depth(f, split[1], &*state);
2476 } else {
2477 render_metrics_panel(f, area, &*state);
2478 }
2479 }
2480 if let Some(area) = areas.activity_feed {
2481 render_activity_feed(f, area, state);
2482 }
2483 if let Some(area) = areas.order_book {
2485 render_order_book_panel(f, area, state);
2486 }
2487 if let Some(area) = areas.market_info {
2488 render_market_info_panel(f, area, state);
2489 }
2490 if let Some(area) = areas.trade_history {
2491 render_recent_trades_panel(f, area, state);
2492 }
2493
2494 if !state.active_alerts.is_empty() {
2496 let alert_height = (state.active_alerts.len() as u16 + 2).min(5);
2498 let alert_area = Rect::new(
2499 chunks[1].x,
2500 chunks[1].y,
2501 chunks[1].width,
2502 alert_height.min(chunks[1].height),
2503 );
2504 render_alert_overlay(f, alert_area, state);
2505 }
2506
2507 render_footer(f, chunks[2], state);
2509}
2510
2511fn render_header(f: &mut Frame, area: Rect, state: &MonitorState) {
2513 let price_color = if state.price_change_24h >= 0.0 {
2514 Color::Green
2515 } else {
2516 Color::Red
2517 };
2518
2519 let trend_arrow = if state.price_change_24h > 0.5 {
2521 "▲"
2522 } else if state.price_change_24h < -0.5 {
2523 "▼"
2524 } else if state.price_change_24h >= 0.0 {
2525 "△"
2526 } else {
2527 "▽"
2528 };
2529
2530 let change_str = format!(
2531 "{}{:.2}%",
2532 if state.price_change_24h >= 0.0 {
2533 "+"
2534 } else {
2535 ""
2536 },
2537 state.price_change_24h
2538 );
2539
2540 let title = format!(
2541 " ◈ {} ({}) │ {} ",
2542 state.symbol,
2543 state.name,
2544 state.chain.to_uppercase(),
2545 );
2546
2547 let price_str = format_price_usd(state.current_price);
2548
2549 let header_chunks = Layout::default()
2551 .direction(Direction::Vertical)
2552 .constraints([Constraint::Length(3), Constraint::Length(1)])
2553 .split(area);
2554
2555 let header = Paragraph::new(Line::from(vec![
2557 Span::styled(price_str, Style::new().fg(price_color).bold()),
2558 Span::raw(" "),
2559 Span::styled(trend_arrow, Style::new().fg(price_color)),
2560 Span::styled(format!(" {}", change_str), Style::new().fg(price_color)),
2561 ]))
2562 .block(
2563 Block::default()
2564 .title(title)
2565 .borders(Borders::ALL)
2566 .border_style(Style::new().cyan()),
2567 );
2568
2569 f.render_widget(header, header_chunks[0]);
2570
2571 let tab_titles = vec!["1m", "5m", "15m", "1h", "4h", "1d"];
2573 let chart_label = state.chart_mode.label();
2574 let tabs = Tabs::new(tab_titles)
2575 .select(state.time_period.index())
2576 .highlight_style(Style::new().cyan().bold())
2577 .divider("│")
2578 .padding(" ", " ");
2579 let tabs_line = Layout::default()
2580 .direction(Direction::Horizontal)
2581 .constraints([Constraint::Min(20), Constraint::Length(10)])
2582 .split(header_chunks[1]);
2583 f.render_widget(tabs, tabs_line[0]);
2584 f.render_widget(
2585 Paragraph::new(Span::styled(
2586 format!("⊞ {}", chart_label),
2587 Style::new().magenta(),
2588 )),
2589 tabs_line[1],
2590 );
2591}
2592
2593fn render_price_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2595 let (data, is_real) = state.get_price_data_for_period();
2597
2598 if data.is_empty() {
2599 let empty = Paragraph::new("No price data").block(
2600 Block::default()
2601 .title(" Price (USD) ")
2602 .borders(Borders::ALL),
2603 );
2604 f.render_widget(empty, area);
2605 return;
2606 }
2607
2608 let current_price = state.current_price;
2610 let first_price = data.first().map(|(_, p)| *p).unwrap_or(current_price);
2611 let price_change = current_price - first_price;
2612 let price_change_pct = if first_price > 0.0 {
2613 (price_change / first_price) * 100.0
2614 } else {
2615 0.0
2616 };
2617
2618 let pal = state.palette();
2620 let is_price_up = price_change >= 0.0;
2621 let trend_color = if is_price_up { pal.up } else { pal.down };
2622 let trend_symbol = if is_price_up { "▲" } else { "▼" };
2623
2624 let price_str = format_price_usd(current_price);
2626 let change_str = if price_change_pct.abs() < 0.01 {
2627 "0.00%".to_string()
2628 } else {
2629 format!(
2630 "{}{:.2}%",
2631 if is_price_up { "+" } else { "" },
2632 price_change_pct
2633 )
2634 };
2635
2636 let chart_title = Line::from(vec![
2638 Span::raw(" ◆ "),
2639 Span::styled(
2640 format!("{} {} ", price_str, trend_symbol),
2641 Style::new().fg(trend_color).bold(),
2642 ),
2643 Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
2644 Span::styled(
2645 format!("│{}│ ", state.time_period.label()),
2646 Style::new().gray(),
2647 ),
2648 ]);
2649
2650 let (min_price, max_price) = data
2652 .iter()
2653 .fold((f64::MAX, f64::MIN), |(min, max), (_, p)| {
2654 (min.min(*p), max.max(*p))
2655 });
2656
2657 let price_range = max_price - min_price;
2659 let (y_min, y_max) = if price_range < 0.0001 {
2660 let padding = min_price * 0.001;
2662 (min_price - padding, max_price + padding)
2663 } else {
2664 (min_price - price_range * 0.1, max_price + price_range * 0.1)
2665 };
2666
2667 let x_min = data.first().map(|(t, _)| *t).unwrap_or(0.0);
2668 let x_max = data.last().map(|(t, _)| *t).unwrap_or(1.0);
2669 let x_max = if (x_max - x_min).abs() < 0.001 {
2671 x_min + 1.0
2672 } else {
2673 x_max
2674 };
2675
2676 let apply_scale = |price: f64| -> f64 {
2678 match state.scale_mode {
2679 ScaleMode::Linear => price,
2680 ScaleMode::Log => {
2681 if price > 0.0 {
2682 price.ln()
2683 } else {
2684 0.0
2685 }
2686 }
2687 }
2688 };
2689
2690 let (y_min, y_max) = (apply_scale(y_min), apply_scale(y_max));
2691
2692 let synthetic_data: Vec<(f64, f64)> = data
2694 .iter()
2695 .zip(&is_real)
2696 .filter(|(_, real)| !**real)
2697 .map(|((t, p), _)| (*t, apply_scale(*p)))
2698 .collect();
2699
2700 let real_data: Vec<(f64, f64)> = data
2701 .iter()
2702 .zip(&is_real)
2703 .filter(|(_, real)| **real)
2704 .map(|((t, p), _)| (*t, apply_scale(*p)))
2705 .collect();
2706
2707 let reference_line: Vec<(f64, f64)> = vec![
2709 (x_min, apply_scale(first_price)),
2710 (x_max, apply_scale(first_price)),
2711 ];
2712
2713 let mut datasets = Vec::new();
2714
2715 datasets.push(
2717 Dataset::default()
2718 .name("━Start")
2719 .marker(symbols::Marker::Braille)
2720 .graph_type(GraphType::Line)
2721 .style(Style::new().dark_gray())
2722 .data(&reference_line),
2723 );
2724
2725 if !synthetic_data.is_empty() {
2727 datasets.push(
2728 Dataset::default()
2729 .name("◇Est")
2730 .marker(symbols::Marker::Braille)
2731 .graph_type(GraphType::Line)
2732 .style(Style::new().cyan())
2733 .data(&synthetic_data),
2734 );
2735 }
2736
2737 if !real_data.is_empty() {
2739 datasets.push(
2740 Dataset::default()
2741 .name("●Live")
2742 .marker(symbols::Marker::Braille)
2743 .graph_type(GraphType::Line)
2744 .style(Style::new().fg(trend_color))
2745 .data(&real_data),
2746 );
2747 }
2748
2749 let time_label = format!("-{}", state.time_period.label());
2751
2752 let mid_y = (y_min + y_max) / 2.0;
2755 let y_label = |val: f64| -> String {
2756 match state.scale_mode {
2757 ScaleMode::Linear => format_price_usd(val),
2758 ScaleMode::Log => format_price_usd(val.exp()),
2759 }
2760 };
2761
2762 let scale_label = match state.scale_mode {
2763 ScaleMode::Linear => "USD",
2764 ScaleMode::Log => "USD (log)",
2765 };
2766
2767 let chart = Chart::new(datasets)
2768 .block(
2769 Block::default()
2770 .title(chart_title)
2771 .borders(Borders::ALL)
2772 .border_style(Style::new().fg(trend_color)),
2773 )
2774 .x_axis(
2775 Axis::default()
2776 .title(Span::styled("Time", Style::new().gray()))
2777 .style(Style::new().gray())
2778 .bounds([x_min, x_max])
2779 .labels(vec![Span::raw(time_label), Span::raw("now")]),
2780 )
2781 .y_axis(
2782 Axis::default()
2783 .title(Span::styled(scale_label, Style::new().gray()))
2784 .style(Style::new().gray())
2785 .bounds([y_min, y_max])
2786 .labels(vec![
2787 Span::raw(y_label(y_min)),
2788 Span::raw(y_label(mid_y)),
2789 Span::raw(y_label(y_max)),
2790 ]),
2791 );
2792
2793 f.render_widget(chart, area);
2794}
2795
2796fn is_stablecoin_price(price: f64) -> bool {
2798 (0.95..=1.05).contains(&price)
2799}
2800
2801fn format_price_usd(price: f64) -> String {
2804 if price >= 1000.0 {
2805 format!("${:.2}", price)
2806 } else if is_stablecoin_price(price) {
2807 format!("${:.6}", price)
2809 } else if price >= 1.0 {
2810 format!("${:.4}", price)
2811 } else if price >= 0.01 {
2812 format!("${:.6}", price)
2813 } else if price >= 0.0001 {
2814 format!("${:.8}", price)
2815 } else {
2816 format!("${:.10}", price)
2817 }
2818}
2819
2820fn render_candlestick_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2822 let candles = state.get_ohlc_candles();
2823
2824 if candles.is_empty() {
2825 let empty = Paragraph::new("No candle data (waiting for more data points)").block(
2826 Block::default()
2827 .title(" Candlestick (USD) ")
2828 .borders(Borders::ALL),
2829 );
2830 f.render_widget(empty, area);
2831 return;
2832 }
2833
2834 let current_price = state.current_price;
2836 let first_candle = candles.first().unwrap();
2837 let last_candle = candles.last().unwrap();
2838 let price_change = last_candle.close - first_candle.open;
2839 let price_change_pct = if first_candle.open > 0.0 {
2840 (price_change / first_candle.open) * 100.0
2841 } else {
2842 0.0
2843 };
2844
2845 let pal = state.palette();
2846 let is_price_up = price_change >= 0.0;
2847 let trend_color = if is_price_up { pal.up } else { pal.down };
2848 let trend_symbol = if is_price_up { "▲" } else { "▼" };
2849
2850 let price_str = format_price_usd(current_price);
2851 let change_str = format!(
2852 "{}{:.2}%",
2853 if is_price_up { "+" } else { "" },
2854 price_change_pct
2855 );
2856
2857 let (min_price, max_price) = candles.iter().fold((f64::MAX, f64::MIN), |(min, max), c| {
2859 (min.min(c.low), max.max(c.high))
2860 });
2861
2862 let price_range = max_price - min_price;
2863 let (y_min, y_max) = if price_range < 0.0001 {
2864 let padding = min_price * 0.001;
2865 (min_price - padding, max_price + padding)
2866 } else {
2867 (min_price - price_range * 0.1, max_price + price_range * 0.1)
2868 };
2869
2870 let x_min = candles.first().map(|c| c.timestamp).unwrap_or(0.0);
2871 let x_max = candles.last().map(|c| c.timestamp).unwrap_or(1.0);
2872 let x_range = x_max - x_min;
2873 let x_max = if x_range < 0.001 {
2874 x_min + 1.0
2875 } else {
2876 x_max + x_range * 0.05
2877 };
2878
2879 let candle_count = candles.len() as f64;
2881 let candle_spacing = x_range / candle_count.max(1.0);
2882 let candle_width = candle_spacing * 0.6; let title = Line::from(vec![
2885 Span::raw(" ⬡ "),
2886 Span::styled(
2887 format!("{} {} ", price_str, trend_symbol),
2888 Style::new().fg(trend_color).bold(),
2889 ),
2890 Span::styled(format!("({}) ", change_str), Style::new().fg(trend_color)),
2891 Span::styled(
2892 format!("│{}│ ", state.time_period.label()),
2893 Style::new().gray(),
2894 ),
2895 Span::styled("⊞Candles ", Style::new().magenta()),
2896 ]);
2897
2898 let apply_scale = |price: f64| -> f64 {
2900 match state.scale_mode {
2901 ScaleMode::Linear => price,
2902 ScaleMode::Log => {
2903 if price > 0.0 {
2904 price.ln()
2905 } else {
2906 0.0
2907 }
2908 }
2909 }
2910 };
2911 let scaled_y_min = apply_scale(y_min);
2912 let scaled_y_max = apply_scale(y_max);
2913 let scaled_price_range = scaled_y_max - scaled_y_min;
2914
2915 let candles_clone = candles.clone();
2917 let is_log = matches!(state.scale_mode, ScaleMode::Log);
2918 let pal_up = pal.up;
2919 let pal_down = pal.down;
2920
2921 let canvas = Canvas::default()
2922 .block(
2923 Block::default()
2924 .title(title)
2925 .borders(Borders::ALL)
2926 .border_style(Style::new().fg(trend_color)),
2927 )
2928 .x_bounds([x_min - candle_spacing, x_max])
2929 .y_bounds([scaled_y_min, scaled_y_max])
2930 .paint(move |ctx| {
2931 let scale_fn = |p: f64| -> f64 { if is_log && p > 0.0 { p.ln() } else { p } };
2932 for candle in &candles_clone {
2933 let color = if candle.is_bullish { pal_up } else { pal_down };
2934
2935 ctx.draw(&CanvasLine {
2937 x1: candle.timestamp,
2938 y1: scale_fn(candle.low),
2939 x2: candle.timestamp,
2940 y2: scale_fn(candle.high),
2941 color,
2942 });
2943
2944 let body_top = scale_fn(candle.open.max(candle.close));
2946 let body_bottom = scale_fn(candle.open.min(candle.close));
2947 let body_height = (body_top - body_bottom).max(scaled_price_range * 0.002);
2948
2949 ctx.draw(&Rectangle {
2950 x: candle.timestamp - candle_width / 2.0,
2951 y: body_bottom,
2952 width: candle_width,
2953 height: body_height,
2954 color,
2955 });
2956 }
2957 });
2958
2959 f.render_widget(canvas, area);
2960}
2961
2962fn render_volume_profile_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
2968 let pal = state.palette();
2969 let (price_data, _) = state.get_price_data_for_period();
2970 let (volume_data, _) = state.get_volume_data_for_period();
2971
2972 if price_data.len() < 2 || volume_data.is_empty() {
2973 let block = Block::default()
2974 .title(" ◨ Volume Profile (collecting data...) ")
2975 .borders(Borders::ALL)
2976 .border_style(Style::new().fg(Color::DarkGray));
2977 f.render_widget(block, area);
2978 return;
2979 }
2980
2981 let min_price = price_data.iter().map(|(_, p)| *p).fold(f64::MAX, f64::min);
2983 let max_price = price_data.iter().map(|(_, p)| *p).fold(f64::MIN, f64::max);
2984
2985 if (max_price - min_price).abs() < f64::EPSILON {
2986 let block = Block::default()
2987 .title(" ◨ Volume Profile (no price range) ")
2988 .borders(Borders::ALL)
2989 .border_style(Style::new().fg(Color::DarkGray));
2990 f.render_widget(block, area);
2991 return;
2992 }
2993
2994 let inner_height = area.height.saturating_sub(2) as usize;
2996 let num_buckets = inner_height.clamp(1, 30);
2997 let bucket_size = (max_price - min_price) / num_buckets as f64;
2998
2999 let mut bucket_volumes = vec![0.0_f64; num_buckets];
3001
3002 let vol_iter: Vec<f64> = volume_data.iter().map(|(_, v)| *v).collect();
3004 for (i, (_, price)) in price_data.iter().enumerate() {
3005 let bucket_idx =
3006 (((price - min_price) / bucket_size).floor() as usize).min(num_buckets - 1);
3007 let vol_contribution = if i < vol_iter.len() {
3009 if i > 0 {
3011 (vol_iter[i] - vol_iter[i - 1]).abs().max(1.0)
3012 } else {
3013 1.0
3014 }
3015 } else {
3016 1.0
3017 };
3018 bucket_volumes[bucket_idx] += vol_contribution;
3019 }
3020
3021 let max_vol = bucket_volumes
3022 .iter()
3023 .cloned()
3024 .fold(0.0_f64, f64::max)
3025 .max(1.0);
3026
3027 let current_bucket = (((state.current_price - min_price) / bucket_size).floor() as usize)
3029 .min(num_buckets.saturating_sub(1));
3030
3031 let inner_width = area.width.saturating_sub(12) as usize; let lines: Vec<Line> = (0..num_buckets)
3035 .rev() .map(|i| {
3037 let bar_width = ((bucket_volumes[i] / max_vol) * inner_width as f64).round() as usize;
3038 let price_mid = min_price + (i as f64 + 0.5) * bucket_size;
3039 let label = if price_mid >= 1.0 {
3040 format!("{:>8.2}", price_mid)
3041 } else {
3042 format!("{:>8.6}", price_mid)
3043 };
3044 let bar_str = "█".repeat(bar_width);
3045 let style = if i == current_bucket {
3046 Style::new().fg(pal.highlight).bold()
3047 } else {
3048 Style::new().fg(pal.sparkline)
3049 };
3050 Line::from(vec![
3051 Span::styled(label, Style::new().dark_gray()),
3052 Span::raw(" "),
3053 Span::styled(bar_str, style),
3054 ])
3055 })
3056 .collect();
3057
3058 let block = Block::default()
3059 .title(" ◨ Volume Profile (accuracy improves over time) ")
3060 .borders(Borders::ALL)
3061 .border_style(Style::new().fg(pal.sparkline));
3062
3063 let paragraph = Paragraph::new(lines).block(block);
3064 f.render_widget(paragraph, area);
3065}
3066
3067fn render_volume_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
3069 let pal = state.palette();
3070 let (data, is_real) = state.get_volume_data_for_period();
3072
3073 if data.is_empty() {
3074 let empty = Paragraph::new("No volume data")
3075 .block(Block::default().title(" 24h Volume ").borders(Borders::ALL));
3076 f.render_widget(empty, area);
3077 return;
3078 }
3079
3080 let current_volume = state.volume_24h;
3082 let volume_str = crate::display::format_usd(current_volume);
3083
3084 let has_synthetic = is_real.iter().any(|r| !r);
3086 let has_real = is_real.iter().any(|r| *r);
3087
3088 let data_indicator = if has_synthetic && has_real {
3090 "[◆ est │ ● live]"
3091 } else if has_synthetic {
3092 "[◆ estimated]"
3093 } else {
3094 "[● live]"
3095 };
3096
3097 let chart_title = Line::from(vec![
3098 Span::raw(" ▣ "),
3099 Span::styled(
3100 format!("24h Vol: {} ", volume_str),
3101 Style::new().fg(pal.volume_bar).bold(),
3102 ),
3103 Span::styled(
3104 format!("│{}│ ", state.time_period.label()),
3105 Style::new().gray(),
3106 ),
3107 Span::styled(data_indicator, Style::new().dark_gray()),
3108 ]);
3109
3110 let inner_width = area.width.saturating_sub(2) as usize; let max_bars = (inner_width / 3).max(1).min(data.len());
3114 let bucket_size = data.len().div_ceil(max_bars);
3115
3116 let bars: Vec<Bar> = data
3117 .chunks(bucket_size)
3118 .zip(is_real.chunks(bucket_size))
3119 .enumerate()
3120 .map(|(i, (chunk, real_chunk))| {
3121 let avg_vol = chunk.iter().map(|(_, v)| v).sum::<f64>() / chunk.len() as f64;
3122 let any_real = real_chunk.iter().any(|r| *r);
3123 let bar_color = if any_real {
3124 pal.volume_bar
3125 } else {
3126 pal.neutral
3127 };
3128 let label = if i == 0 || i == max_bars.saturating_sub(1) || i == max_bars / 2 {
3130 format_number(avg_vol)
3131 } else {
3132 String::new()
3133 };
3134 Bar::default()
3135 .value(avg_vol as u64)
3136 .label(Line::from(label))
3137 .style(Style::new().fg(bar_color))
3138 })
3139 .collect();
3140
3141 let bar_width = if !bars.is_empty() {
3143 let total_bars = bars.len() as u16;
3144 ((inner_width as u16).saturating_sub(total_bars.saturating_sub(1))) / total_bars
3146 } else {
3147 1
3148 }
3149 .max(1);
3150
3151 let barchart = BarChart::default()
3152 .data(BarGroup::default().bars(&bars))
3153 .block(
3154 Block::default()
3155 .title(chart_title)
3156 .borders(Borders::ALL)
3157 .border_style(Style::new().blue()),
3158 )
3159 .bar_width(bar_width)
3160 .bar_gap(1)
3161 .value_style(Style::new().dark_gray());
3162
3163 f.render_widget(barchart, area);
3164}
3165
3166fn render_buy_sell_gauge(f: &mut Frame, area: Rect, state: &mut MonitorState) {
3168 let pal = state.palette();
3169 let ratio = state.buy_ratio();
3171 let border_color = if ratio > 0.5 { pal.up } else { pal.down };
3172
3173 let block = Block::default()
3174 .title(" ◐ Buy/Sell Ratio (24h) ")
3175 .borders(Borders::ALL)
3176 .border_style(Style::new().fg(border_color));
3177
3178 let inner = block.inner(area);
3179 f.render_widget(block, area);
3180
3181 if inner.width > 0 && inner.height > 0 {
3182 let buy_width = ((ratio * inner.width as f64).round() as u16).min(inner.width);
3183 let sell_width = inner.width.saturating_sub(buy_width);
3184
3185 let buy_indicator = if ratio > 0.5 { "▶" } else { "▷" };
3186 let sell_indicator = if ratio < 0.5 { "◀" } else { "◁" };
3187 let label = format!(
3188 "{}Buys: {} │ Sells: {}{} ({:.1}%)",
3189 buy_indicator,
3190 state.buys_24h,
3191 state.sells_24h,
3192 sell_indicator,
3193 ratio * 100.0
3194 );
3195
3196 let buy_bar = "█".repeat(buy_width as usize);
3197 let sell_bar = "█".repeat(sell_width as usize);
3198 let bar_line = Line::from(vec![
3199 Span::styled(buy_bar, Style::new().fg(pal.up)),
3200 Span::styled(sell_bar, Style::new().fg(pal.down)),
3201 ]);
3202 f.render_widget(Paragraph::new(bar_line), inner);
3203
3204 let label_len = label.len() as u16;
3206 if label_len <= inner.width {
3207 let x_offset = (inner.width.saturating_sub(label_len)) / 2;
3208 let label_area = Rect::new(inner.x + x_offset, inner.y, label_len, 1);
3209 let label_widget =
3210 Paragraph::new(Span::styled(label, Style::new().fg(Color::White).bold()));
3211 f.render_widget(label_widget, label_area);
3212 }
3213 }
3214}
3215
3216fn render_activity_feed(f: &mut Frame, area: Rect, state: &mut MonitorState) {
3218 let log_len = state.log_messages.len();
3219 let log_title = if log_len > 0 {
3220 let selected = state.log_list_state.selected().unwrap_or(0);
3221 format!(" ◷ Activity Log [{}/{}] ", selected + 1, log_len)
3222 } else {
3223 " ◷ Activity Log ".to_string()
3224 };
3225
3226 let items: Vec<ListItem> = state
3227 .log_messages
3228 .iter()
3229 .rev()
3230 .map(|msg| ListItem::new(msg.as_str()).style(Style::new().gray()))
3231 .collect();
3232
3233 let log_list = List::new(items)
3234 .block(
3235 Block::default()
3236 .title(log_title)
3237 .borders(Borders::ALL)
3238 .border_style(Style::new().dark_gray()),
3239 )
3240 .highlight_style(Style::new().white().bold())
3241 .highlight_symbol("▸ ");
3242
3243 f.render_stateful_widget(log_list, area, &mut state.log_list_state);
3244}
3245
3246fn render_alert_overlay(f: &mut Frame, area: Rect, state: &MonitorState) {
3248 if state.active_alerts.is_empty() {
3249 return;
3250 }
3251
3252 let is_flash_on = state
3253 .alert_flash_until
3254 .map(|deadline| {
3255 if Instant::now() < deadline {
3256 (Instant::now().elapsed().subsec_millis() / 500).is_multiple_of(2)
3258 } else {
3259 false
3260 }
3261 })
3262 .unwrap_or(false);
3263
3264 let border_color = if is_flash_on {
3265 Color::Red
3266 } else {
3267 Color::Yellow
3268 };
3269
3270 let alert_lines: Vec<Line> = state
3271 .active_alerts
3272 .iter()
3273 .map(|a| Line::from(Span::styled(&a.message, Style::new().fg(Color::Red).bold())))
3274 .collect();
3275
3276 let alert_widget = Paragraph::new(alert_lines).block(
3277 Block::default()
3278 .title(" ⚠ ALERTS ")
3279 .borders(Borders::ALL)
3280 .border_style(Style::new().fg(border_color).bold()),
3281 );
3282
3283 f.render_widget(alert_widget, area);
3284}
3285
3286fn render_liquidity_depth(f: &mut Frame, area: Rect, state: &MonitorState) {
3288 let pal = state.palette();
3289
3290 if state.liquidity_pairs.is_empty() {
3291 let block = Block::default()
3292 .title(" ◫ Liquidity Depth (no data) ")
3293 .borders(Borders::ALL)
3294 .border_style(Style::new().fg(Color::DarkGray));
3295 f.render_widget(block, area);
3296 return;
3297 }
3298
3299 let max_liquidity = state
3300 .liquidity_pairs
3301 .iter()
3302 .map(|(_, liq)| *liq)
3303 .fold(0.0_f64, f64::max)
3304 .max(1.0);
3305
3306 let inner_width = area.width.saturating_sub(2) as usize;
3307
3308 let lines: Vec<Line> = state
3309 .liquidity_pairs
3310 .iter()
3311 .take(area.height.saturating_sub(2) as usize) .map(|(name, liq)| {
3313 let bar_width = ((liq / max_liquidity) * inner_width as f64 * 0.6).round() as usize;
3314 let bar_str = "█".repeat(bar_width);
3315 let label = format!(" {} {}", crate::display::format_usd(*liq), name);
3316 Line::from(vec![
3317 Span::styled(bar_str, Style::new().fg(pal.volume_bar)),
3318 Span::styled(label, Style::new().fg(pal.neutral)),
3319 ])
3320 })
3321 .collect();
3322
3323 let block = Block::default()
3324 .title(format!(
3325 " ◫ Liquidity Depth ({} pairs) ",
3326 state.liquidity_pairs.len()
3327 ))
3328 .borders(Borders::ALL)
3329 .border_style(Style::new().fg(pal.border));
3330
3331 let paragraph = Paragraph::new(lines).block(block);
3332 f.render_widget(paragraph, area);
3333}
3334
3335fn render_metrics_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3337 let pal = state.palette();
3338 let chunks = Layout::default()
3340 .direction(Direction::Vertical)
3341 .constraints([Constraint::Length(3), Constraint::Min(0)])
3342 .split(area);
3343
3344 let sparkline_data: Vec<u64> = {
3346 let points: Vec<f64> = state.price_history.iter().map(|dp| dp.value).collect();
3348 if points.len() < 2 {
3349 vec![0; chunks[0].width.saturating_sub(2) as usize]
3350 } else {
3351 let min_p = points.iter().cloned().fold(f64::MAX, f64::min);
3352 let max_p = points.iter().cloned().fold(f64::MIN, f64::max);
3353 let range = (max_p - min_p).max(0.0001);
3354 points
3355 .iter()
3356 .map(|p| (((*p - min_p) / range) * 100.0) as u64)
3357 .collect()
3358 }
3359 };
3360
3361 let trend_color = if state.price_change_5m >= 0.0 {
3362 pal.up
3363 } else {
3364 pal.down
3365 };
3366
3367 let sparkline = Sparkline::default()
3368 .block(
3369 Block::default()
3370 .title(" ◉ Price Trend ")
3371 .borders(Borders::ALL)
3372 .border_style(Style::new().fg(pal.sparkline)),
3373 )
3374 .data(&sparkline_data)
3375 .style(Style::new().fg(trend_color));
3376
3377 f.render_widget(sparkline, chunks[0]);
3378
3379 let change_5m_str = if state.price_change_5m.abs() < 0.0001 {
3381 "0.00%".to_string()
3382 } else {
3383 format!("{:+.4}%", state.price_change_5m)
3384 };
3385 let change_5m_color = if state.price_change_5m > 0.0 {
3386 pal.up
3387 } else if state.price_change_5m < 0.0 {
3388 pal.down
3389 } else {
3390 pal.neutral
3391 };
3392
3393 let now_ts = chrono::Utc::now().timestamp() as f64;
3394 let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
3395 let last_change_str = if secs_since_change < 60 {
3396 format!("{}s ago", secs_since_change)
3397 } else if secs_since_change < 3600 {
3398 format!("{}m ago", secs_since_change / 60)
3399 } else {
3400 format!("{}h ago", secs_since_change / 3600)
3401 };
3402 let last_change_color = if secs_since_change < 60 {
3403 pal.up
3404 } else {
3405 pal.highlight
3406 };
3407
3408 let change_24h_str = format!(
3409 "{}{:.2}%",
3410 if state.price_change_24h >= 0.0 {
3411 "+"
3412 } else {
3413 ""
3414 },
3415 state.price_change_24h
3416 );
3417
3418 let market_cap_str = state
3419 .market_cap
3420 .map(crate::display::format_usd)
3421 .unwrap_or_else(|| "N/A".to_string());
3422
3423 let mut rows = vec![
3424 Row::new(vec![
3425 Span::styled("Price", Style::new().gray()),
3426 Span::styled(format_price_usd(state.current_price), Style::new().bold()),
3427 ]),
3428 Row::new(vec![
3429 Span::styled("5m Chg", Style::new().gray()),
3430 Span::styled(change_5m_str, Style::new().fg(change_5m_color)),
3431 ]),
3432 Row::new(vec![
3433 Span::styled("Last Δ", Style::new().gray()),
3434 Span::styled(last_change_str, Style::new().fg(last_change_color)),
3435 ]),
3436 Row::new(vec![
3437 Span::styled("24h Chg", Style::new().gray()),
3438 Span::raw(change_24h_str),
3439 ]),
3440 Row::new(vec![
3441 Span::styled("Liq", Style::new().gray()),
3442 Span::raw(crate::display::format_usd(state.liquidity_usd)),
3443 ]),
3444 Row::new(vec![
3445 Span::styled("Vol 24h", Style::new().gray()),
3446 Span::raw(crate::display::format_usd(state.volume_24h)),
3447 ]),
3448 Row::new(vec![
3449 Span::styled("Mkt Cap", Style::new().gray()),
3450 Span::raw(market_cap_str),
3451 ]),
3452 Row::new(vec![
3453 Span::styled("Buys", Style::new().gray()),
3454 Span::styled(format!("{}", state.buys_24h), Style::new().fg(pal.up)),
3455 ]),
3456 Row::new(vec![
3457 Span::styled("Sells", Style::new().gray()),
3458 Span::styled(format!("{}", state.sells_24h), Style::new().fg(pal.down)),
3459 ]),
3460 ];
3461
3462 if state.widgets.holder_count
3464 && let Some(count) = state.holder_count
3465 {
3466 rows.push(Row::new(vec![
3467 Span::styled("Holders", Style::new().gray()),
3468 Span::styled(format_number(count as f64), Style::new().fg(pal.highlight)),
3469 ]));
3470 }
3471
3472 let table = Table::new(rows, [Constraint::Length(8), Constraint::Min(10)]).block(
3473 Block::default()
3474 .title(" ◉ Key Metrics ")
3475 .borders(Borders::ALL)
3476 .border_style(Style::new().magenta()),
3477 );
3478
3479 f.render_widget(table, chunks[1]);
3480}
3481
3482fn render_order_book_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3486 let pal = state.palette();
3487
3488 let book = match &state.order_book {
3489 Some(b) => b,
3490 None => {
3491 let block = Block::default()
3492 .title(" ◈ Order Book (no data) ")
3493 .borders(Borders::ALL)
3494 .border_style(Style::new().fg(Color::DarkGray));
3495 f.render_widget(block, area);
3496 return;
3497 }
3498 };
3499
3500 let inner_height = area.height.saturating_sub(2) as usize; if inner_height < 3 {
3502 return;
3503 }
3504
3505 let ask_rows = (inner_height.saturating_sub(1)) / 2;
3507 let bid_rows = inner_height.saturating_sub(ask_rows).saturating_sub(1);
3508
3509 let max_qty = book
3511 .asks
3512 .iter()
3513 .chain(book.bids.iter())
3514 .map(|l| l.quantity)
3515 .fold(0.0_f64, f64::max)
3516 .max(0.001);
3517
3518 let inner_width = area.width.saturating_sub(2) as usize;
3519 let bar_width_max = (inner_width as f64 * 0.3).round() as usize;
3521
3522 let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
3523
3524 let visible_asks: Vec<_> = book.asks.iter().take(ask_rows).collect();
3526 for _ in 0..ask_rows.saturating_sub(visible_asks.len()) {
3528 lines.push(Line::from(""));
3529 }
3530 for level in visible_asks.iter().rev() {
3531 let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
3532 let bar = "█".repeat(bar_len);
3533 let price_str = format!("{:.6}", level.price);
3534 let qty_str = format_number(level.quantity);
3535 let val_str = format_number(level.value());
3536 let padding = inner_width
3537 .saturating_sub(bar_len)
3538 .saturating_sub(price_str.len())
3539 .saturating_sub(qty_str.len())
3540 .saturating_sub(val_str.len())
3541 .saturating_sub(4); lines.push(Line::from(vec![
3543 Span::styled(bar, Style::new().fg(pal.down).dim()),
3544 Span::raw(" "),
3545 Span::styled(price_str, Style::new().fg(pal.down)),
3546 Span::raw(" ".repeat(padding.max(1))),
3547 Span::styled(qty_str, Style::new().fg(pal.neutral)),
3548 Span::raw(" "),
3549 Span::styled(val_str, Style::new().fg(Color::DarkGray)),
3550 ]));
3551 }
3552
3553 let spread = book
3555 .best_ask()
3556 .zip(book.best_bid())
3557 .map(|(ask, bid)| {
3558 let s = ask - bid;
3559 let pct = if bid > 0.0 { (s / bid) * 100.0 } else { 0.0 };
3560 format!(" Spread: {:.6} ({:.3}%)", s, pct)
3561 })
3562 .unwrap_or_else(|| " Spread: --".to_string());
3563 lines.push(Line::from(Span::styled(
3564 spread,
3565 Style::new().fg(Color::Yellow).bold(),
3566 )));
3567
3568 for level in book.bids.iter().take(bid_rows) {
3570 let bar_len = ((level.quantity / max_qty) * bar_width_max as f64).round() as usize;
3571 let bar = "█".repeat(bar_len);
3572 let price_str = format!("{:.6}", level.price);
3573 let qty_str = format_number(level.quantity);
3574 let val_str = format_number(level.value());
3575 let padding = inner_width
3576 .saturating_sub(bar_len)
3577 .saturating_sub(price_str.len())
3578 .saturating_sub(qty_str.len())
3579 .saturating_sub(val_str.len())
3580 .saturating_sub(4);
3581 lines.push(Line::from(vec![
3582 Span::styled(bar, Style::new().fg(pal.up).dim()),
3583 Span::raw(" "),
3584 Span::styled(price_str, Style::new().fg(pal.up)),
3585 Span::raw(" ".repeat(padding.max(1))),
3586 Span::styled(qty_str, Style::new().fg(pal.neutral)),
3587 Span::raw(" "),
3588 Span::styled(val_str, Style::new().fg(Color::DarkGray)),
3589 ]));
3590 }
3591
3592 let ask_depth: f64 = book.asks.iter().map(|l| l.value()).sum();
3593 let bid_depth: f64 = book.bids.iter().map(|l| l.value()).sum();
3594 let title = format!(
3595 " ◈ {} │ Ask {} │ Bid {} ",
3596 book.pair,
3597 format_number(ask_depth),
3598 format_number(bid_depth),
3599 );
3600
3601 let block = Block::default()
3602 .title(title)
3603 .borders(Borders::ALL)
3604 .border_style(Style::new().fg(pal.border));
3605
3606 let paragraph = Paragraph::new(lines).block(block);
3607 f.render_widget(paragraph, area);
3608}
3609
3610fn render_recent_trades_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3615 let pal = state.palette();
3616
3617 if state.recent_trades.is_empty() {
3618 let block = Block::default()
3619 .title(" ◈ Recent Trades (no data) ")
3620 .borders(Borders::ALL)
3621 .border_style(Style::new().fg(Color::DarkGray));
3622 f.render_widget(block, area);
3623 return;
3624 }
3625
3626 let inner_height = area.height.saturating_sub(2) as usize;
3627 let inner_width = area.width.saturating_sub(2) as usize;
3628
3629 let time_width = 8; let side_width = 4; let price_width = inner_width
3633 .saturating_sub(time_width)
3634 .saturating_sub(side_width)
3635 .saturating_sub(3) / 2;
3637 let qty_width = inner_width
3638 .saturating_sub(time_width)
3639 .saturating_sub(side_width)
3640 .saturating_sub(price_width)
3641 .saturating_sub(3);
3642
3643 let mut lines: Vec<Line> = Vec::with_capacity(inner_height);
3644
3645 lines.push(Line::from(vec![
3647 Span::styled(
3648 format!("{:<time_width$}", "Time"),
3649 Style::new().fg(Color::DarkGray).bold(),
3650 ),
3651 Span::raw(" "),
3652 Span::styled(
3653 format!("{:<side_width$}", "Side"),
3654 Style::new().fg(Color::DarkGray).bold(),
3655 ),
3656 Span::raw(" "),
3657 Span::styled(
3658 format!("{:>price_width$}", "Price"),
3659 Style::new().fg(Color::DarkGray).bold(),
3660 ),
3661 Span::raw(" "),
3662 Span::styled(
3663 format!("{:>qty_width$}", "Qty"),
3664 Style::new().fg(Color::DarkGray).bold(),
3665 ),
3666 ]));
3667
3668 let visible_count = inner_height.saturating_sub(1); for trade in state.recent_trades.iter().rev().take(visible_count) {
3671 let (side_str, side_color) = match trade.side {
3672 TradeSide::Buy => ("BUY ", pal.up),
3673 TradeSide::Sell => ("SELL", pal.down),
3674 };
3675
3676 let secs = (trade.timestamp_ms / 1000) as i64;
3678 let hours = (secs / 3600) % 24;
3679 let mins = (secs / 60) % 60;
3680 let sec = secs % 60;
3681 let time_str = format!("{:02}:{:02}:{:02}", hours, mins, sec);
3682
3683 let price_str = if trade.price >= 1000.0 {
3684 format!("{:.2}", trade.price)
3685 } else if trade.price >= 1.0 {
3686 format!("{:.4}", trade.price)
3687 } else {
3688 format!("{:.6}", trade.price)
3689 };
3690
3691 let qty_str = format_number(trade.quantity);
3692
3693 lines.push(Line::from(vec![
3694 Span::styled(
3695 format!("{:<time_width$}", time_str),
3696 Style::new().fg(Color::DarkGray),
3697 ),
3698 Span::raw(" "),
3699 Span::styled(
3700 format!("{:<side_width$}", side_str),
3701 Style::new().fg(side_color),
3702 ),
3703 Span::raw(" "),
3704 Span::styled(
3705 format!("{:>price_width$}", price_str),
3706 Style::new().fg(side_color),
3707 ),
3708 Span::raw(" "),
3709 Span::styled(
3710 format!("{:>qty_width$}", qty_str),
3711 Style::new().fg(pal.neutral),
3712 ),
3713 ]));
3714 }
3715
3716 let title = format!(" ◈ Recent Trades ({}) ", state.recent_trades.len());
3717 let block = Block::default()
3718 .title(title)
3719 .borders(Borders::ALL)
3720 .border_style(Style::new().fg(pal.border));
3721
3722 let paragraph = Paragraph::new(lines).block(block);
3723 f.render_widget(paragraph, area);
3724}
3725
3726fn render_market_info_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
3731 let pal = state.palette();
3732
3733 let cols = Layout::default()
3735 .direction(Direction::Horizontal)
3736 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
3737 .split(area);
3738
3739 {
3741 let header = Row::new(vec!["DEX / Pair", "Volume 24h", "Liquidity", "Δ 24h"])
3742 .style(Style::new().fg(Color::Cyan).bold())
3743 .bottom_margin(0);
3744
3745 let rows: Vec<Row> = state
3746 .dex_pairs
3747 .iter()
3748 .take(cols[0].height.saturating_sub(3) as usize) .map(|p| {
3750 let pair_label = format!("{}/{}", p.base_token, p.quote_token);
3751 let dex_and_pair = format!("{} {}", p.dex_name, pair_label);
3752 let vol = format_number(p.volume_24h);
3753 let liq = format_number(p.liquidity_usd);
3754 let change_str = format!("{:+.1}%", p.price_change_24h);
3755 let change_color = if p.price_change_24h >= 0.0 {
3756 pal.up
3757 } else {
3758 pal.down
3759 };
3760 Row::new(vec![
3761 ratatui::text::Text::from(dex_and_pair),
3762 ratatui::text::Text::styled(vol, Style::new().fg(pal.neutral)),
3763 ratatui::text::Text::styled(liq, Style::new().fg(pal.volume_bar)),
3764 ratatui::text::Text::styled(change_str, Style::new().fg(change_color)),
3765 ])
3766 })
3767 .collect();
3768
3769 let widths = [
3770 Constraint::Percentage(40),
3771 Constraint::Percentage(22),
3772 Constraint::Percentage(22),
3773 Constraint::Percentage(16),
3774 ];
3775
3776 let table = Table::new(rows, widths).header(header).block(
3777 Block::default()
3778 .title(format!(" ◫ Trading Pairs ({}) ", state.dex_pairs.len()))
3779 .borders(Borders::ALL)
3780 .border_style(Style::new().fg(pal.border)),
3781 );
3782
3783 f.render_widget(table, cols[0]);
3784 }
3785
3786 {
3788 let mut info_lines: Vec<Line> = Vec::new();
3789
3790 let price_color = if state.price_change_24h >= 0.0 {
3792 pal.up
3793 } else {
3794 pal.down
3795 };
3796 info_lines.push(Line::from(vec![
3797 Span::styled(" Price ", Style::new().fg(Color::DarkGray)),
3798 Span::styled(
3799 format!("${:.6}", state.current_price),
3800 Style::new().fg(Color::White).bold(),
3801 ),
3802 ]));
3803
3804 let changes = [
3806 ("5m", state.price_change_5m),
3807 ("1h", state.price_change_1h),
3808 ("6h", state.price_change_6h),
3809 ("24h", state.price_change_24h),
3810 ];
3811 let change_spans: Vec<Span> = changes
3812 .iter()
3813 .flat_map(|(label, val)| {
3814 let color = if *val >= 0.0 { pal.up } else { pal.down };
3815 vec![
3816 Span::styled(format!(" {}: ", label), Style::new().fg(Color::DarkGray)),
3817 Span::styled(format!("{:+.2}%", val), Style::new().fg(color)),
3818 ]
3819 })
3820 .collect();
3821 info_lines.push(Line::from(change_spans));
3822
3823 info_lines.push(Line::from(""));
3824
3825 info_lines.push(Line::from(vec![
3827 Span::styled(" Vol 24h ", Style::new().fg(Color::DarkGray)),
3828 Span::styled(
3829 format!("${}", format_number(state.volume_24h)),
3830 Style::new().fg(pal.neutral),
3831 ),
3832 ]));
3833 info_lines.push(Line::from(vec![
3834 Span::styled(" Liq ", Style::new().fg(Color::DarkGray)),
3835 Span::styled(
3836 format!("${}", format_number(state.liquidity_usd)),
3837 Style::new().fg(pal.volume_bar),
3838 ),
3839 ]));
3840
3841 if let Some(mc) = state.market_cap {
3843 info_lines.push(Line::from(vec![
3844 Span::styled(" MCap ", Style::new().fg(Color::DarkGray)),
3845 Span::styled(
3846 format!("${}", format_number(mc)),
3847 Style::new().fg(pal.neutral),
3848 ),
3849 ]));
3850 }
3851 if let Some(fdv) = state.fdv {
3852 info_lines.push(Line::from(vec![
3853 Span::styled(" FDV ", Style::new().fg(Color::DarkGray)),
3854 Span::styled(
3855 format!("${}", format_number(fdv)),
3856 Style::new().fg(pal.neutral),
3857 ),
3858 ]));
3859 }
3860
3861 info_lines.push(Line::from(""));
3863 let total_txs = state.buys_24h + state.sells_24h;
3864 let buy_pct = if total_txs > 0 {
3865 (state.buys_24h as f64 / total_txs as f64) * 100.0
3866 } else {
3867 50.0
3868 };
3869 info_lines.push(Line::from(vec![
3870 Span::styled(" Buys ", Style::new().fg(Color::DarkGray)),
3871 Span::styled(
3872 format!("{} ({:.0}%)", state.buys_24h, buy_pct),
3873 Style::new().fg(pal.up),
3874 ),
3875 ]));
3876 info_lines.push(Line::from(vec![
3877 Span::styled(" Sells ", Style::new().fg(Color::DarkGray)),
3878 Span::styled(
3879 format!("{} ({:.0}%)", state.sells_24h, 100.0 - buy_pct),
3880 Style::new().fg(pal.down),
3881 ),
3882 ]));
3883
3884 if let Some(holders) = state.holder_count {
3886 info_lines.push(Line::from(vec![
3887 Span::styled(" Holders ", Style::new().fg(Color::DarkGray)),
3888 Span::styled(format_number(holders as f64), Style::new().fg(pal.neutral)),
3889 ]));
3890 }
3891
3892 if let Some(ts) = state.earliest_pair_created_at {
3894 let dt = chrono::DateTime::from_timestamp(ts, 0)
3895 .map(|d| d.format("%Y-%m-%d").to_string())
3896 .unwrap_or_else(|| "?".to_string());
3897 info_lines.push(Line::from(vec![
3898 Span::styled(" Listed ", Style::new().fg(Color::DarkGray)),
3899 Span::styled(dt, Style::new().fg(pal.neutral)),
3900 ]));
3901 }
3902
3903 if !state.websites.is_empty() || !state.socials.is_empty() {
3905 info_lines.push(Line::from(""));
3906 let mut link_spans = vec![Span::styled(" Links ", Style::new().fg(Color::DarkGray))];
3907 for (platform, _url) in &state.socials {
3908 link_spans.push(Span::styled(
3909 format!("[{}] ", platform),
3910 Style::new().fg(Color::Cyan),
3911 ));
3912 }
3913 for url in &state.websites {
3914 let domain = url
3916 .trim_start_matches("https://")
3917 .trim_start_matches("http://")
3918 .split('/')
3919 .next()
3920 .unwrap_or(url);
3921 link_spans.push(Span::styled(
3922 format!("[{}] ", domain),
3923 Style::new().fg(Color::Blue),
3924 ));
3925 }
3926 info_lines.push(Line::from(link_spans));
3927 }
3928
3929 let title = format!(" ◉ {} ({}) ", state.symbol, state.name);
3930 let block = Block::default()
3931 .title(title)
3932 .borders(Borders::ALL)
3933 .border_style(Style::new().fg(price_color));
3934
3935 let paragraph = Paragraph::new(info_lines).block(block);
3936 f.render_widget(paragraph, cols[1]);
3937 }
3938}
3939
3940fn render_footer(f: &mut Frame, area: Rect, state: &MonitorState) {
3942 let elapsed = state.last_update.elapsed().as_secs();
3943
3944 let now_ts = chrono::Utc::now().timestamp() as f64;
3946 let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
3947 let price_change_str = if secs_since_change < 60 {
3948 format!("{}s", secs_since_change)
3949 } else if secs_since_change < 3600 {
3950 format!("{}m", secs_since_change / 60)
3951 } else {
3952 format!("{}h", secs_since_change / 3600)
3953 };
3954
3955 let (synthetic_count, real_count) = state.data_stats();
3957 let memory_bytes = state.memory_usage();
3958 let memory_str = if memory_bytes >= 1024 * 1024 {
3959 format!("{:.1}MB", memory_bytes as f64 / (1024.0 * 1024.0))
3960 } else if memory_bytes >= 1024 {
3961 format!("{:.1}KB", memory_bytes as f64 / 1024.0)
3962 } else {
3963 format!("{}B", memory_bytes)
3964 };
3965
3966 let status = if let Some(ref err) = state.error_message {
3967 Span::styled(format!("⚠ {}", err), Style::new().red())
3968 } else if state.paused {
3969 Span::styled("⏸ PAUSED", Style::new().fg(Color::Yellow).bold())
3970 } else if state.is_auto_paused() {
3971 Span::styled("⏸ AUTO-PAUSED", Style::new().fg(Color::Cyan).bold())
3972 } else {
3973 Span::styled(
3974 format!(
3975 "↻ {}s │ Δ {} │ {} pts │ {}",
3976 elapsed,
3977 price_change_str,
3978 synthetic_count + real_count,
3979 memory_str
3980 ),
3981 Style::new().gray(),
3982 )
3983 };
3984
3985 let widget_hint = if state.widget_toggle_mode {
3986 Span::styled("W:1-5?", Style::new().fg(Color::Yellow).bold())
3987 } else {
3988 Span::styled("W", Style::new().fg(Color::Cyan).bold())
3989 };
3990
3991 let mut spans = vec![status, Span::raw(" ║ ")];
3992
3993 if state.export_active {
3995 spans.push(Span::styled("● REC ", Style::new().fg(Color::Red).bold()));
3996 }
3997
3998 spans.extend([
3999 Span::styled("Q", Style::new().red().bold()),
4000 Span::raw("uit "),
4001 Span::styled("R", Style::new().fg(Color::Green).bold()),
4002 Span::raw("efresh "),
4003 Span::styled("P", Style::new().fg(Color::Yellow).bold()),
4004 Span::raw("ause "),
4005 Span::styled("E", Style::new().fg(Color::LightRed).bold()),
4006 Span::raw("xport "),
4007 Span::styled("L", Style::new().fg(Color::Cyan).bold()),
4008 Span::raw(format!(":{} ", state.layout.label())),
4009 widget_hint,
4010 Span::raw("idget "),
4011 Span::styled("C", Style::new().fg(Color::LightBlue).bold()),
4012 Span::raw(format!("hart:{} ", state.chart_mode.label())),
4013 Span::styled("S", Style::new().fg(Color::LightGreen).bold()),
4014 Span::raw(format!("cale:{} ", state.scale_mode.label())),
4015 Span::styled("/", Style::new().fg(Color::LightRed).bold()),
4016 Span::raw(format!(":{} ", state.color_scheme.label())),
4017 Span::styled("T", Style::new().fg(Color::Magenta).bold()),
4018 Span::raw("ime "),
4019 ]);
4020
4021 let footer = Paragraph::new(Line::from(spans)).block(Block::default().borders(Borders::ALL));
4022
4023 f.render_widget(footer, area);
4024}
4025
4026fn format_number(n: f64) -> String {
4028 if n >= 1_000_000_000.0 {
4029 format!("{:.2}B", n / 1_000_000_000.0)
4030 } else if n >= 1_000_000.0 {
4031 format!("{:.2}M", n / 1_000_000.0)
4032 } else if n >= 1_000.0 {
4033 format!("{:.2}K", n / 1_000.0)
4034 } else {
4035 format!("{:.2}", n)
4036 }
4037}
4038
4039pub async fn run_direct(
4045 mut args: MonitorArgs,
4046 config: &Config,
4047 clients: &dyn ChainClientFactory,
4048) -> Result<()> {
4049 if let Some((address, chain)) =
4051 crate::cli::address_book::resolve_address_book_input(&args.token, config)?
4052 {
4053 args.token = address;
4054 if args.chain == "ethereum" {
4055 args.chain = chain;
4056 }
4057 }
4058
4059 let ctx = SessionContext {
4061 chain: args.chain,
4062 ..SessionContext::default()
4063 };
4064
4065 let mut monitor_config = config.monitor.clone();
4067 if let Some(layout) = args.layout {
4068 monitor_config.layout = layout;
4069 }
4070 if let Some(refresh) = args.refresh {
4071 monitor_config.refresh_seconds = refresh;
4072 }
4073 if let Some(scale) = args.scale {
4074 monitor_config.scale = scale;
4075 }
4076 if let Some(color_scheme) = args.color_scheme {
4077 monitor_config.color_scheme = color_scheme;
4078 }
4079 if let Some(ref path) = args.export {
4080 monitor_config.export.path = Some(path.to_string_lossy().into_owned());
4081 }
4082 if let Some(ref venue) = args.venue {
4083 monitor_config.venue = Some(venue.clone());
4084 }
4085
4086 let mut effective_config = config.clone();
4088 effective_config.monitor = monitor_config;
4089
4090 run(Some(args.token), &ctx, &effective_config, clients).await
4091}
4092
4093pub async fn run(
4095 token: Option<String>,
4096 ctx: &SessionContext,
4097 config: &Config,
4098 clients: &dyn ChainClientFactory,
4099) -> Result<()> {
4100 let token_input = match token {
4101 Some(t) => t,
4102 None => {
4103 return Err(ScopeError::Chain(
4104 "Token address or symbol required. Usage: monitor <token>".to_string(),
4105 ));
4106 }
4107 };
4108
4109 eprintln!(" Starting live monitor for {}...", token_input);
4110 eprintln!(" Fetching initial data...");
4111
4112 let dex_client = clients.create_dex_client();
4114 let token_address =
4115 resolve_token_address(&token_input, &ctx.chain, config, dex_client.as_ref()).await?;
4116
4117 let initial_data = dex_client
4119 .get_token_data(&ctx.chain, &token_address)
4120 .await?;
4121
4122 println!(
4123 "Monitoring {} ({}) on {}",
4124 initial_data.symbol, initial_data.name, ctx.chain
4125 );
4126 println!("Press Q to quit, R to refresh, P to pause...\n");
4127
4128 tokio::time::sleep(Duration::from_millis(500)).await;
4130
4131 let chain_client = clients.create_chain_client(&ctx.chain).ok();
4133
4134 let exchange_client = config.monitor.venue.as_ref().and_then(|venue_id| {
4136 crate::market::VenueRegistry::load()
4137 .ok()
4138 .and_then(|r| r.get(venue_id).cloned())
4139 .map(|desc| crate::market::ExchangeClient::from_descriptor(&desc))
4140 });
4141
4142 let mut app = MonitorApp::new(
4144 initial_data,
4145 &ctx.chain,
4146 &config.monitor,
4147 chain_client,
4148 exchange_client,
4149 )?;
4150 let result = app.run().await;
4151
4152 if let Err(e) = app.cleanup() {
4154 eprintln!("Warning: Failed to cleanup terminal: {}", e);
4155 }
4156
4157 result
4158}
4159
4160async fn resolve_token_address(
4162 input: &str,
4163 chain: &str,
4164 _config: &Config,
4165 dex_client: &dyn DexDataSource,
4166) -> Result<String> {
4167 if crate::tokens::TokenAliases::is_address(input) {
4169 return Ok(input.to_string());
4170 }
4171
4172 let aliases = crate::tokens::TokenAliases::load();
4174 if let Some(alias) = aliases.get(input, Some(chain)) {
4175 return Ok(alias.address.clone());
4176 }
4177
4178 let results = dex_client.search_tokens(input, Some(chain)).await?;
4180
4181 if results.is_empty() {
4182 return Err(ScopeError::NotFound(format!(
4183 "No token found matching '{}' on {}",
4184 input, chain
4185 )));
4186 }
4187
4188 if results.len() == 1 {
4190 let token = &results[0];
4191 println!(
4192 "Found: {} ({}) - ${:.6}",
4193 token.symbol,
4194 token.name,
4195 token.price_usd.unwrap_or(0.0)
4196 );
4197 return Ok(token.address.clone());
4198 }
4199
4200 let selected = select_token_interactive(&results)?;
4202 Ok(selected.address.clone())
4203}
4204
4205fn abbreviate_address(addr: &str) -> String {
4207 if addr.len() > 16 {
4208 format!("{}...{}", &addr[..8], &addr[addr.len() - 6..])
4209 } else {
4210 addr.to_string()
4211 }
4212}
4213
4214fn select_token_interactive(
4216 results: &[crate::chains::dex::TokenSearchResult],
4217) -> Result<&crate::chains::dex::TokenSearchResult> {
4218 let stdin = io::stdin();
4219 let stdout = io::stdout();
4220 select_token_impl(results, &mut stdin.lock(), &mut stdout.lock())
4221}
4222
4223fn select_token_impl<'a>(
4225 results: &'a [crate::chains::dex::TokenSearchResult],
4226 reader: &mut impl io::BufRead,
4227 writer: &mut impl io::Write,
4228) -> Result<&'a crate::chains::dex::TokenSearchResult> {
4229 writeln!(
4230 writer,
4231 "\nFound {} tokens matching your query:\n",
4232 results.len()
4233 )
4234 .map_err(|e| ScopeError::Io(e.to_string()))?;
4235
4236 writeln!(
4237 writer,
4238 "{:>3} {:>8} {:<22} {:<16} {:>12} {:>12}",
4239 "#", "Symbol", "Name", "Address", "Price", "Liquidity"
4240 )
4241 .map_err(|e| ScopeError::Io(e.to_string()))?;
4242
4243 writeln!(writer, "{}", "─".repeat(82)).map_err(|e| ScopeError::Io(e.to_string()))?;
4244
4245 for (i, token) in results.iter().enumerate() {
4246 let price = token
4247 .price_usd
4248 .map(|p| format!("${:.6}", p))
4249 .unwrap_or_else(|| "N/A".to_string());
4250
4251 let liquidity = format_monitor_number(token.liquidity_usd);
4252 let addr = abbreviate_address(&token.address);
4253
4254 let name = if token.name.len() > 20 {
4256 format!("{}...", &token.name[..17])
4257 } else {
4258 token.name.clone()
4259 };
4260
4261 writeln!(
4262 writer,
4263 "{:>3} {:>8} {:<22} {:<16} {:>12} {:>12}",
4264 i + 1,
4265 token.symbol,
4266 name,
4267 addr,
4268 price,
4269 liquidity
4270 )
4271 .map_err(|e| ScopeError::Io(e.to_string()))?;
4272 }
4273
4274 writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
4275 write!(writer, "Select token (1-{}): ", results.len())
4276 .map_err(|e| ScopeError::Io(e.to_string()))?;
4277 writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
4278
4279 let mut input = String::new();
4280 reader
4281 .read_line(&mut input)
4282 .map_err(|e| ScopeError::Io(e.to_string()))?;
4283
4284 let selection: usize = input
4285 .trim()
4286 .parse()
4287 .map_err(|_| ScopeError::Api("Invalid selection".to_string()))?;
4288
4289 if selection < 1 || selection > results.len() {
4290 return Err(ScopeError::Api(format!(
4291 "Selection must be between 1 and {}",
4292 results.len()
4293 )));
4294 }
4295
4296 let selected = &results[selection - 1];
4297 writeln!(
4298 writer,
4299 "Selected: {} ({}) at {}",
4300 selected.symbol, selected.name, selected.address
4301 )
4302 .map_err(|e| ScopeError::Io(e.to_string()))?;
4303
4304 Ok(selected)
4305}
4306
4307fn format_monitor_number(value: f64) -> String {
4309 if value >= 1_000_000_000.0 {
4310 format!("${:.2}B", value / 1_000_000_000.0)
4311 } else if value >= 1_000_000.0 {
4312 format!("${:.2}M", value / 1_000_000.0)
4313 } else if value >= 1_000.0 {
4314 format!("${:.2}K", value / 1_000.0)
4315 } else {
4316 format!("${:.2}", value)
4317 }
4318}
4319
4320#[cfg(test)]
4325mod tests {
4326 use super::*;
4327
4328 fn create_test_token_data() -> DexTokenData {
4329 DexTokenData {
4330 address: "0x1234".to_string(),
4331 symbol: "TEST".to_string(),
4332 name: "Test Token".to_string(),
4333 price_usd: 1.0,
4334 price_change_24h: 5.0,
4335 price_change_6h: 2.0,
4336 price_change_1h: 0.5,
4337 price_change_5m: 0.1,
4338 volume_24h: 1_000_000.0,
4339 volume_6h: 250_000.0,
4340 volume_1h: 50_000.0,
4341 liquidity_usd: 500_000.0,
4342 market_cap: Some(10_000_000.0),
4343 fdv: Some(100_000_000.0),
4344 pairs: vec![],
4345 price_history: vec![],
4346 volume_history: vec![],
4347 total_buys_24h: 100,
4348 total_sells_24h: 50,
4349 total_buys_6h: 25,
4350 total_sells_6h: 12,
4351 total_buys_1h: 5,
4352 total_sells_1h: 3,
4353 earliest_pair_created_at: Some(1700000000000),
4354 image_url: None,
4355 websites: vec![],
4356 socials: vec![],
4357 dexscreener_url: None,
4358 }
4359 }
4360
4361 #[test]
4362 fn test_monitor_state_new() {
4363 let token_data = create_test_token_data();
4364 let state = MonitorState::new(&token_data, "ethereum");
4365
4366 assert_eq!(state.symbol, "TEST");
4367 assert_eq!(state.chain, "ethereum");
4368 assert_eq!(state.current_price, 1.0);
4369 assert_eq!(state.buys_24h, 100);
4370 assert_eq!(state.sells_24h, 50);
4371 assert!(!state.paused);
4372 }
4373
4374 #[test]
4375 fn test_monitor_state_buy_ratio() {
4376 let token_data = create_test_token_data();
4377 let state = MonitorState::new(&token_data, "ethereum");
4378
4379 let ratio = state.buy_ratio();
4380 assert!((ratio - 0.6666).abs() < 0.01); }
4382
4383 #[test]
4384 fn test_monitor_state_buy_ratio_zero() {
4385 let mut token_data = create_test_token_data();
4386 token_data.total_buys_24h = 0;
4387 token_data.total_sells_24h = 0;
4388 let state = MonitorState::new(&token_data, "ethereum");
4389
4390 assert_eq!(state.buy_ratio(), 0.5); }
4392
4393 #[test]
4394 fn test_monitor_state_toggle_pause() {
4395 let token_data = create_test_token_data();
4396 let mut state = MonitorState::new(&token_data, "ethereum");
4397
4398 assert!(!state.paused);
4399 state.toggle_pause();
4400 assert!(state.paused);
4401 state.toggle_pause();
4402 assert!(!state.paused);
4403 }
4404
4405 #[test]
4406 fn test_monitor_state_should_refresh() {
4407 let token_data = create_test_token_data();
4408 let mut state = MonitorState::new(&token_data, "ethereum");
4409 state.refresh_rate = Duration::from_secs(60);
4410
4411 assert!(!state.should_refresh());
4413
4414 state.last_update = Instant::now() - Duration::from_secs(120);
4416 assert!(state.should_refresh());
4417
4418 state.paused = true;
4420 assert!(!state.should_refresh());
4421 }
4422
4423 #[test]
4424 fn test_format_number() {
4425 assert_eq!(format_number(500.0), "500.00");
4426 assert_eq!(format_number(1_500.0), "1.50K");
4427 assert_eq!(format_number(1_500_000.0), "1.50M");
4428 assert_eq!(format_number(1_500_000_000.0), "1.50B");
4429 }
4430
4431 #[test]
4432 fn test_format_usd() {
4433 assert_eq!(crate::display::format_usd(500.0), "$500.00");
4434 assert_eq!(crate::display::format_usd(1_500.0), "$1.50K");
4435 assert_eq!(crate::display::format_usd(1_500_000.0), "$1.50M");
4436 assert_eq!(crate::display::format_usd(1_500_000_000.0), "$1.50B");
4437 }
4438
4439 #[test]
4440 fn test_monitor_state_update() {
4441 let token_data = create_test_token_data();
4442 let mut state = MonitorState::new(&token_data, "ethereum");
4443
4444 let initial_len = state.price_history.len();
4445
4446 let mut updated_data = token_data.clone();
4447 updated_data.price_usd = 1.5;
4448 updated_data.total_buys_24h = 150;
4449
4450 state.update(&updated_data);
4451
4452 assert_eq!(state.current_price, 1.5);
4453 assert_eq!(state.buys_24h, 150);
4454 assert_eq!(state.price_history.len(), initial_len + 1);
4456 }
4457
4458 #[test]
4459 fn test_monitor_state_refresh_rate_adjustment() {
4460 let token_data = create_test_token_data();
4461 let mut state = MonitorState::new(&token_data, "ethereum");
4462
4463 assert_eq!(state.refresh_rate_secs(), 5);
4465
4466 state.slower_refresh();
4468 assert_eq!(state.refresh_rate_secs(), 10);
4469
4470 state.faster_refresh();
4472 assert_eq!(state.refresh_rate_secs(), 5);
4473
4474 state.faster_refresh();
4476 assert_eq!(state.refresh_rate_secs(), 1);
4477
4478 state.faster_refresh();
4480 assert_eq!(state.refresh_rate_secs(), 1);
4481
4482 for _ in 0..20 {
4484 state.slower_refresh();
4485 }
4486 assert_eq!(state.refresh_rate_secs(), 60);
4487 }
4488
4489 #[test]
4490 fn test_time_period() {
4491 assert_eq!(TimePeriod::Min1.label(), "1m");
4492 assert_eq!(TimePeriod::Min5.label(), "5m");
4493 assert_eq!(TimePeriod::Min15.label(), "15m");
4494 assert_eq!(TimePeriod::Hour1.label(), "1h");
4495 assert_eq!(TimePeriod::Hour4.label(), "4h");
4496 assert_eq!(TimePeriod::Day1.label(), "1d");
4497
4498 assert_eq!(TimePeriod::Min1.duration_secs(), 60);
4499 assert_eq!(TimePeriod::Min5.duration_secs(), 300);
4500 assert_eq!(TimePeriod::Min15.duration_secs(), 15 * 60);
4501 assert_eq!(TimePeriod::Hour1.duration_secs(), 3600);
4502 assert_eq!(TimePeriod::Hour4.duration_secs(), 4 * 3600);
4503 assert_eq!(TimePeriod::Day1.duration_secs(), 24 * 3600);
4504
4505 assert_eq!(TimePeriod::Min1.next(), TimePeriod::Min5);
4507 assert_eq!(TimePeriod::Min5.next(), TimePeriod::Min15);
4508 assert_eq!(TimePeriod::Min15.next(), TimePeriod::Hour1);
4509 assert_eq!(TimePeriod::Hour1.next(), TimePeriod::Hour4);
4510 assert_eq!(TimePeriod::Hour4.next(), TimePeriod::Day1);
4511 assert_eq!(TimePeriod::Day1.next(), TimePeriod::Min1);
4512 }
4513
4514 #[test]
4515 fn test_time_period_exchange_interval() {
4516 assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
4517 assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
4518 assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
4519 assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
4520 assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
4521 assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
4522 }
4523
4524 #[test]
4525 fn test_monitor_state_time_period() {
4526 let token_data = create_test_token_data();
4527 let mut state = MonitorState::new(&token_data, "ethereum");
4528
4529 assert_eq!(state.time_period, TimePeriod::Hour1);
4531
4532 state.cycle_time_period();
4534 assert_eq!(state.time_period, TimePeriod::Hour4);
4535
4536 state.set_time_period(TimePeriod::Day1);
4537 assert_eq!(state.time_period, TimePeriod::Day1);
4538 }
4539
4540 #[test]
4541 fn test_synthetic_history_generation() {
4542 let token_data = create_test_token_data();
4543 let state = MonitorState::new(&token_data, "ethereum");
4544
4545 assert!(state.price_history.len() > 1);
4547 assert!(state.volume_history.len() > 1);
4548
4549 if let (Some(first), Some(last)) = (state.price_history.front(), state.price_history.back())
4551 {
4552 let span = last.timestamp - first.timestamp;
4553 assert!(span > 0.0); }
4555 }
4556
4557 #[test]
4558 fn test_real_data_marking() {
4559 let token_data = create_test_token_data();
4560 let mut state = MonitorState::new(&token_data, "ethereum");
4561
4562 let (synthetic, real) = state.data_stats();
4564 assert!(synthetic > 0);
4565 assert_eq!(real, 0);
4566
4567 let mut updated_data = token_data.clone();
4569 updated_data.price_usd = 1.5;
4570 state.update(&updated_data);
4571
4572 let (synthetic2, real2) = state.data_stats();
4573 assert!(synthetic2 > 0);
4574 assert_eq!(real2, 1);
4575 assert_eq!(state.real_data_count, 1);
4576
4577 assert!(
4579 state
4580 .price_history
4581 .back()
4582 .map(|p| p.is_real)
4583 .unwrap_or(false)
4584 );
4585 }
4586
4587 #[test]
4588 fn test_memory_usage() {
4589 let token_data = create_test_token_data();
4590 let state = MonitorState::new(&token_data, "ethereum");
4591
4592 let mem = state.memory_usage();
4593 assert!(mem > 0);
4595
4596 let expected_point_size = std::mem::size_of::<DataPoint>();
4598 assert_eq!(expected_point_size, 24);
4599 }
4600
4601 #[test]
4602 fn test_get_data_for_period_returns_flags() {
4603 let token_data = create_test_token_data();
4604 let mut state = MonitorState::new(&token_data, "ethereum");
4605
4606 let (data, is_real) = state.get_price_data_for_period();
4608 assert_eq!(data.len(), is_real.len());
4609
4610 state.update(&token_data);
4612
4613 let (_data2, is_real2) = state.get_price_data_for_period();
4614 assert!(is_real2.iter().any(|r| *r));
4616 }
4617
4618 #[test]
4619 fn test_cache_path_generation() {
4620 let path =
4621 MonitorState::cache_path("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum");
4622 assert!(path.to_string_lossy().contains("bcc_monitor_"));
4623 assert!(path.to_string_lossy().contains("ethereum"));
4624 let temp_dir = std::env::temp_dir();
4626 assert!(path.starts_with(temp_dir));
4627 }
4628
4629 #[test]
4630 fn test_cache_save_and_load() {
4631 let token_data = create_test_token_data();
4632 let mut state = MonitorState::new(&token_data, "test_chain");
4633
4634 state.update(&token_data);
4636 state.update(&token_data);
4637
4638 state.save_cache();
4640
4641 let path = MonitorState::cache_path(&state.token_address, &state.chain);
4643 assert!(path.exists(), "Cache file should exist after save");
4644
4645 let loaded = MonitorState::load_cache(&state.token_address, &state.chain);
4647 assert!(loaded.is_some(), "Should be able to load saved cache");
4648
4649 let cached = loaded.unwrap();
4650 assert_eq!(cached.token_address, state.token_address);
4651 assert_eq!(cached.chain, state.chain);
4652 assert!(!cached.price_history.is_empty());
4653
4654 let _ = std::fs::remove_file(path);
4656 }
4657
4658 #[test]
4663 fn test_format_price_usd_high() {
4664 let formatted = format_price_usd(2500.50);
4665 assert!(formatted.starts_with("$2500.50"));
4666 }
4667
4668 #[test]
4669 fn test_format_price_usd_stablecoin() {
4670 let formatted = format_price_usd(1.0001);
4671 assert!(formatted.contains("1.000100"));
4672 assert!(is_stablecoin_price(1.0001));
4673 }
4674
4675 #[test]
4676 fn test_format_price_usd_medium() {
4677 let formatted = format_price_usd(5.1234);
4678 assert!(formatted.starts_with("$5.1234"));
4679 }
4680
4681 #[test]
4682 fn test_format_price_usd_small() {
4683 let formatted = format_price_usd(0.05);
4684 assert!(formatted.starts_with("$0.0500"));
4685 }
4686
4687 #[test]
4688 fn test_format_price_usd_micro() {
4689 let formatted = format_price_usd(0.001);
4690 assert!(formatted.starts_with("$0.0010"));
4691 }
4692
4693 #[test]
4694 fn test_format_price_usd_nano() {
4695 let formatted = format_price_usd(0.00001);
4696 assert!(formatted.contains("0.0000100"));
4697 }
4698
4699 #[test]
4700 fn test_is_stablecoin_price() {
4701 assert!(is_stablecoin_price(1.0));
4702 assert!(is_stablecoin_price(0.999));
4703 assert!(is_stablecoin_price(1.001));
4704 assert!(is_stablecoin_price(0.95));
4705 assert!(is_stablecoin_price(1.05));
4706 assert!(!is_stablecoin_price(0.94));
4707 assert!(!is_stablecoin_price(1.06));
4708 assert!(!is_stablecoin_price(100.0));
4709 }
4710
4711 #[test]
4716 fn test_ohlc_candle_new() {
4717 let candle = OhlcCandle::new(1000.0, 50.0);
4718 assert_eq!(candle.open, 50.0);
4719 assert_eq!(candle.high, 50.0);
4720 assert_eq!(candle.low, 50.0);
4721 assert_eq!(candle.close, 50.0);
4722 assert!(candle.is_bullish);
4723 }
4724
4725 #[test]
4726 fn test_ohlc_candle_update() {
4727 let mut candle = OhlcCandle::new(1000.0, 50.0);
4728 candle.update(55.0);
4729 assert_eq!(candle.high, 55.0);
4730 assert_eq!(candle.close, 55.0);
4731 assert!(candle.is_bullish);
4732
4733 candle.update(45.0);
4734 assert_eq!(candle.low, 45.0);
4735 assert_eq!(candle.close, 45.0);
4736 assert!(!candle.is_bullish); }
4738
4739 #[test]
4740 fn test_get_ohlc_candles() {
4741 let token_data = create_test_token_data();
4742 let mut state = MonitorState::new(&token_data, "ethereum");
4743 for i in 0..20 {
4745 let mut data = token_data.clone();
4746 data.price_usd = 1.0 + (i as f64 * 0.01);
4747 state.update(&data);
4748 }
4749 let candles = state.get_ohlc_candles();
4750 assert!(!candles.is_empty());
4752 }
4753
4754 #[test]
4755 fn test_get_ohlc_candles_returns_exchange_ohlc_when_populated() {
4756 let token_data = create_test_token_data();
4757 let mut state = MonitorState::new(&token_data, "ethereum");
4758 let exchange_candles = vec![
4760 OhlcCandle::new(1700000000.0, 100.0),
4761 OhlcCandle::new(1700003600.0, 101.0),
4762 ];
4763 state.exchange_ohlc = exchange_candles.clone();
4764 let candles = state.get_ohlc_candles();
4765 assert_eq!(candles.len(), 2);
4766 assert_eq!(candles[0].timestamp, 1700000000.0);
4767 assert_eq!(candles[0].open, 100.0);
4768 assert_eq!(candles[1].timestamp, 1700003600.0);
4769 assert_eq!(candles[1].open, 101.0);
4770 }
4771
4772 #[test]
4777 fn test_chart_mode_cycle() {
4778 let mode = ChartMode::Line;
4779 assert_eq!(mode.next(), ChartMode::Candlestick);
4780 assert_eq!(ChartMode::Candlestick.next(), ChartMode::VolumeProfile);
4781 assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
4782 }
4783
4784 #[test]
4785 fn test_chart_mode_label() {
4786 assert_eq!(ChartMode::Line.label(), "Line");
4787 assert_eq!(ChartMode::Candlestick.label(), "Candle");
4788 assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
4789 }
4790
4791 use ratatui::Terminal;
4796 use ratatui::backend::TestBackend;
4797
4798 fn create_test_terminal() -> Terminal<TestBackend> {
4799 let backend = TestBackend::new(120, 40);
4800 Terminal::new(backend).unwrap()
4801 }
4802
4803 fn create_populated_state() -> MonitorState {
4804 let token_data = create_test_token_data();
4805 let mut state = MonitorState::new(&token_data, "ethereum");
4806 for i in 0..30 {
4808 let mut data = token_data.clone();
4809 data.price_usd = 1.0 + (i as f64 * 0.01);
4810 data.volume_24h = 1_000_000.0 + (i as f64 * 10_000.0);
4811 state.update(&data);
4812 }
4813 state
4814 }
4815
4816 #[test]
4817 fn test_render_header_no_panic() {
4818 let mut terminal = create_test_terminal();
4819 let state = create_populated_state();
4820 terminal
4821 .draw(|f| render_header(f, f.area(), &state))
4822 .unwrap();
4823 }
4824
4825 #[test]
4826 fn test_render_price_chart_no_panic() {
4827 let mut terminal = create_test_terminal();
4828 let state = create_populated_state();
4829 terminal
4830 .draw(|f| render_price_chart(f, f.area(), &state))
4831 .unwrap();
4832 }
4833
4834 #[test]
4835 fn test_render_price_chart_line_mode() {
4836 let mut terminal = create_test_terminal();
4837 let mut state = create_populated_state();
4838 state.chart_mode = ChartMode::Line;
4839 terminal
4840 .draw(|f| render_price_chart(f, f.area(), &state))
4841 .unwrap();
4842 }
4843
4844 #[test]
4845 fn test_render_candlestick_chart_no_panic() {
4846 let mut terminal = create_test_terminal();
4847 let state = create_populated_state();
4848 terminal
4849 .draw(|f| render_candlestick_chart(f, f.area(), &state))
4850 .unwrap();
4851 }
4852
4853 #[test]
4854 fn test_render_candlestick_chart_empty() {
4855 let mut terminal = create_test_terminal();
4856 let token_data = create_test_token_data();
4857 let state = MonitorState::new(&token_data, "ethereum");
4858 terminal
4859 .draw(|f| render_candlestick_chart(f, f.area(), &state))
4860 .unwrap();
4861 }
4862
4863 #[test]
4864 fn test_render_volume_chart_no_panic() {
4865 let mut terminal = create_test_terminal();
4866 let state = create_populated_state();
4867 terminal
4868 .draw(|f| render_volume_chart(f, f.area(), &state))
4869 .unwrap();
4870 }
4871
4872 #[test]
4873 fn test_render_volume_chart_empty() {
4874 let mut terminal = create_test_terminal();
4875 let token_data = create_test_token_data();
4876 let state = MonitorState::new(&token_data, "ethereum");
4877 terminal
4878 .draw(|f| render_volume_chart(f, f.area(), &state))
4879 .unwrap();
4880 }
4881
4882 #[test]
4883 fn test_render_buy_sell_gauge_no_panic() {
4884 let mut terminal = create_test_terminal();
4885 let mut state = create_populated_state();
4886 terminal
4887 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
4888 .unwrap();
4889 }
4890
4891 #[test]
4892 fn test_render_buy_sell_gauge_balanced() {
4893 let mut terminal = create_test_terminal();
4894 let mut token_data = create_test_token_data();
4895 token_data.total_buys_24h = 100;
4896 token_data.total_sells_24h = 100;
4897 let mut state = MonitorState::new(&token_data, "ethereum");
4898 terminal
4899 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
4900 .unwrap();
4901 }
4902
4903 #[test]
4904 fn test_render_metrics_panel_no_panic() {
4905 let mut terminal = create_test_terminal();
4906 let state = create_populated_state();
4907 terminal
4908 .draw(|f| render_metrics_panel(f, f.area(), &state))
4909 .unwrap();
4910 }
4911
4912 #[test]
4913 fn test_render_metrics_panel_no_market_cap() {
4914 let mut terminal = create_test_terminal();
4915 let mut token_data = create_test_token_data();
4916 token_data.market_cap = None;
4917 token_data.fdv = None;
4918 let state = MonitorState::new(&token_data, "ethereum");
4919 terminal
4920 .draw(|f| render_metrics_panel(f, f.area(), &state))
4921 .unwrap();
4922 }
4923
4924 #[test]
4925 fn test_render_footer_no_panic() {
4926 let mut terminal = create_test_terminal();
4927 let state = create_populated_state();
4928 terminal
4929 .draw(|f| render_footer(f, f.area(), &state))
4930 .unwrap();
4931 }
4932
4933 #[test]
4934 fn test_render_footer_paused() {
4935 let mut terminal = create_test_terminal();
4936 let token_data = create_test_token_data();
4937 let mut state = MonitorState::new(&token_data, "ethereum");
4938 state.paused = true;
4939 terminal
4940 .draw(|f| render_footer(f, f.area(), &state))
4941 .unwrap();
4942 }
4943
4944 #[test]
4945 fn test_render_all_components() {
4946 let mut terminal = create_test_terminal();
4948 let mut state = create_populated_state();
4949 terminal
4950 .draw(|f| {
4951 let area = f.area();
4952 let chunks = Layout::default()
4953 .direction(Direction::Vertical)
4954 .constraints([
4955 Constraint::Length(3),
4956 Constraint::Min(10),
4957 Constraint::Length(5),
4958 Constraint::Length(3),
4959 Constraint::Length(3),
4960 ])
4961 .split(area);
4962 render_header(f, chunks[0], &state);
4963 render_price_chart(f, chunks[1], &state);
4964 render_volume_chart(f, chunks[2], &state);
4965 render_buy_sell_gauge(f, chunks[3], &mut state);
4966 render_footer(f, chunks[4], &state);
4967 })
4968 .unwrap();
4969 }
4970
4971 #[test]
4972 fn test_render_candlestick_mode() {
4973 let mut terminal = create_test_terminal();
4974 let mut state = create_populated_state();
4975 state.chart_mode = ChartMode::Candlestick;
4976 terminal
4977 .draw(|f| {
4978 let area = f.area();
4979 let chunks = Layout::default()
4980 .direction(Direction::Vertical)
4981 .constraints([Constraint::Length(3), Constraint::Min(10)])
4982 .split(area);
4983 render_header(f, chunks[0], &state);
4984 render_candlestick_chart(f, chunks[1], &state);
4985 })
4986 .unwrap();
4987 }
4988
4989 #[test]
4990 fn test_render_with_different_time_periods() {
4991 let mut terminal = create_test_terminal();
4992 let mut state = create_populated_state();
4993
4994 for period in [
4995 TimePeriod::Min1,
4996 TimePeriod::Min5,
4997 TimePeriod::Min15,
4998 TimePeriod::Hour1,
4999 TimePeriod::Hour4,
5000 TimePeriod::Day1,
5001 ] {
5002 state.time_period = period;
5003 terminal
5004 .draw(|f| render_price_chart(f, f.area(), &state))
5005 .unwrap();
5006 }
5007 }
5008
5009 #[test]
5010 fn test_render_metrics_with_stablecoin() {
5011 let mut terminal = create_test_terminal();
5012 let mut token_data = create_test_token_data();
5013 token_data.price_usd = 0.999;
5014 token_data.symbol = "USDC".to_string();
5015 let state = MonitorState::new(&token_data, "ethereum");
5016 terminal
5017 .draw(|f| render_metrics_panel(f, f.area(), &state))
5018 .unwrap();
5019 }
5020
5021 #[test]
5022 fn test_render_header_with_negative_change() {
5023 let mut terminal = create_test_terminal();
5024 let mut token_data = create_test_token_data();
5025 token_data.price_change_24h = -15.5;
5026 token_data.price_change_1h = -2.3;
5027 let state = MonitorState::new(&token_data, "ethereum");
5028 terminal
5029 .draw(|f| render_header(f, f.area(), &state))
5030 .unwrap();
5031 }
5032
5033 #[test]
5038 fn test_toggle_chart_mode_roundtrip() {
5039 let token_data = create_test_token_data();
5040 let mut state = MonitorState::new(&token_data, "ethereum");
5041 assert_eq!(state.chart_mode, ChartMode::Line);
5042 state.toggle_chart_mode();
5043 assert_eq!(state.chart_mode, ChartMode::Candlestick);
5044 state.toggle_chart_mode();
5045 assert_eq!(state.chart_mode, ChartMode::VolumeProfile);
5046 state.toggle_chart_mode();
5047 assert_eq!(state.chart_mode, ChartMode::Line);
5048 }
5049
5050 #[test]
5051 fn test_cycle_all_time_periods() {
5052 let token_data = create_test_token_data();
5053 let mut state = MonitorState::new(&token_data, "ethereum");
5054 assert_eq!(state.time_period, TimePeriod::Hour1);
5055 state.cycle_time_period();
5056 assert_eq!(state.time_period, TimePeriod::Hour4);
5057 state.cycle_time_period();
5058 assert_eq!(state.time_period, TimePeriod::Day1);
5059 state.cycle_time_period();
5060 assert_eq!(state.time_period, TimePeriod::Min1);
5061 state.cycle_time_period();
5062 assert_eq!(state.time_period, TimePeriod::Min5);
5063 state.cycle_time_period();
5064 assert_eq!(state.time_period, TimePeriod::Min15);
5065 state.cycle_time_period();
5066 assert_eq!(state.time_period, TimePeriod::Hour1);
5067 }
5068
5069 #[test]
5070 fn test_set_specific_time_period() {
5071 let token_data = create_test_token_data();
5072 let mut state = MonitorState::new(&token_data, "ethereum");
5073 state.set_time_period(TimePeriod::Day1);
5074 assert_eq!(state.time_period, TimePeriod::Day1);
5075 }
5076
5077 #[test]
5078 fn test_pause_resume_roundtrip() {
5079 let token_data = create_test_token_data();
5080 let mut state = MonitorState::new(&token_data, "ethereum");
5081 assert!(!state.paused);
5082 state.toggle_pause();
5083 assert!(state.paused);
5084 state.toggle_pause();
5085 assert!(!state.paused);
5086 }
5087
5088 #[test]
5089 fn test_force_refresh_unpauses() {
5090 let token_data = create_test_token_data();
5091 let mut state = MonitorState::new(&token_data, "ethereum");
5092 state.paused = true;
5093 state.force_refresh();
5094 assert!(!state.paused);
5095 assert!(state.should_refresh());
5096 }
5097
5098 #[test]
5099 fn test_refresh_rate_adjust() {
5100 let token_data = create_test_token_data();
5101 let mut state = MonitorState::new(&token_data, "ethereum");
5102 assert_eq!(state.refresh_rate_secs(), 5);
5103
5104 state.slower_refresh();
5105 assert_eq!(state.refresh_rate_secs(), 10);
5106
5107 state.faster_refresh();
5108 assert_eq!(state.refresh_rate_secs(), 5);
5109 }
5110
5111 #[test]
5112 fn test_faster_refresh_clamped_min() {
5113 let token_data = create_test_token_data();
5114 let mut state = MonitorState::new(&token_data, "ethereum");
5115 for _ in 0..10 {
5116 state.faster_refresh();
5117 }
5118 assert!(state.refresh_rate_secs() >= 1);
5119 }
5120
5121 #[test]
5122 fn test_slower_refresh_clamped_max() {
5123 let token_data = create_test_token_data();
5124 let mut state = MonitorState::new(&token_data, "ethereum");
5125 for _ in 0..20 {
5126 state.slower_refresh();
5127 }
5128 assert!(state.refresh_rate_secs() <= 60);
5129 }
5130
5131 #[test]
5132 fn test_buy_ratio_balanced() {
5133 let mut token_data = create_test_token_data();
5134 token_data.total_buys_24h = 100;
5135 token_data.total_sells_24h = 100;
5136 let state = MonitorState::new(&token_data, "ethereum");
5137 assert!((state.buy_ratio() - 0.5).abs() < 0.01);
5138 }
5139
5140 #[test]
5141 fn test_buy_ratio_no_trades() {
5142 let mut token_data = create_test_token_data();
5143 token_data.total_buys_24h = 0;
5144 token_data.total_sells_24h = 0;
5145 let state = MonitorState::new(&token_data, "ethereum");
5146 assert!((state.buy_ratio() - 0.5).abs() < 0.01);
5147 }
5148
5149 #[test]
5150 fn test_data_stats_initial() {
5151 let token_data = create_test_token_data();
5152 let state = MonitorState::new(&token_data, "ethereum");
5153 let (synthetic, real) = state.data_stats();
5154 assert!(synthetic > 0 || real == 0);
5155 }
5156
5157 #[test]
5158 fn test_memory_usage_nonzero() {
5159 let token_data = create_test_token_data();
5160 let state = MonitorState::new(&token_data, "ethereum");
5161 let usage = state.memory_usage();
5162 assert!(usage > 0);
5163 }
5164
5165 #[test]
5166 fn test_price_data_for_period() {
5167 let token_data = create_test_token_data();
5168 let state = MonitorState::new(&token_data, "ethereum");
5169 let (data, is_real) = state.get_price_data_for_period();
5170 assert_eq!(data.len(), is_real.len());
5171 }
5172
5173 #[test]
5174 fn test_volume_data_for_period() {
5175 let token_data = create_test_token_data();
5176 let state = MonitorState::new(&token_data, "ethereum");
5177 let (data, is_real) = state.get_volume_data_for_period();
5178 assert_eq!(data.len(), is_real.len());
5179 }
5180
5181 #[test]
5182 fn test_ohlc_candles_generation() {
5183 let token_data = create_test_token_data();
5184 let state = MonitorState::new(&token_data, "ethereum");
5185 let candles = state.get_ohlc_candles();
5186 for candle in &candles {
5187 assert!(candle.high >= candle.low);
5188 }
5189 }
5190
5191 #[test]
5192 fn test_state_update_with_new_data() {
5193 let token_data = create_test_token_data();
5194 let mut state = MonitorState::new(&token_data, "ethereum");
5195 let initial_count = state.real_data_count;
5196
5197 let mut updated_data = create_test_token_data();
5198 updated_data.price_usd = 2.0;
5199 updated_data.volume_24h = 2_000_000.0;
5200
5201 state.update(&updated_data);
5202 assert_eq!(state.current_price, 2.0);
5203 assert_eq!(state.real_data_count, initial_count + 1);
5204 assert!(state.error_message.is_none());
5205 }
5206
5207 #[test]
5208 fn test_cache_roundtrip_save_load() {
5209 let token_data = create_test_token_data();
5210 let state = MonitorState::new(&token_data, "ethereum");
5211
5212 state.save_cache();
5213
5214 let cache_path = MonitorState::cache_path(&token_data.address, "ethereum");
5215 assert!(cache_path.exists());
5216
5217 let cached = MonitorState::load_cache(&token_data.address, "ethereum");
5218 assert!(cached.is_some());
5219
5220 let _ = std::fs::remove_file(cache_path);
5221 }
5222
5223 #[test]
5224 fn test_should_refresh_when_paused() {
5225 let token_data = create_test_token_data();
5226 let mut state = MonitorState::new(&token_data, "ethereum");
5227 assert!(!state.should_refresh());
5228 state.paused = true;
5229 assert!(!state.should_refresh());
5230 }
5231
5232 #[test]
5233 fn test_ohlc_candle_lifecycle() {
5234 let mut candle = OhlcCandle::new(1700000000.0, 100.0);
5235 assert_eq!(candle.open, 100.0);
5236 assert!(candle.is_bullish);
5237 candle.update(110.0);
5238 assert_eq!(candle.high, 110.0);
5239 assert!(candle.is_bullish);
5240 candle.update(90.0);
5241 assert_eq!(candle.low, 90.0);
5242 assert!(!candle.is_bullish);
5243 }
5244
5245 #[test]
5246 fn test_time_period_display_impl() {
5247 assert_eq!(format!("{}", TimePeriod::Min1), "1m");
5248 assert_eq!(format!("{}", TimePeriod::Min15), "15m");
5249 assert_eq!(format!("{}", TimePeriod::Day1), "1d");
5250 }
5251
5252 #[test]
5253 fn test_log_messages_accumulate() {
5254 let token_data = create_test_token_data();
5255 let mut state = MonitorState::new(&token_data, "ethereum");
5256 state.toggle_pause();
5258 state.toggle_pause();
5259 state.cycle_time_period();
5260 state.toggle_chart_mode();
5261 assert!(!state.log_messages.is_empty());
5262 }
5263
5264 #[test]
5265 fn test_ui_function_full_render() {
5266 let mut terminal = create_test_terminal();
5268 let mut state = create_populated_state();
5269 terminal.draw(|f| ui(f, &mut state)).unwrap();
5270 }
5271
5272 #[test]
5273 fn test_ui_function_candlestick_mode() {
5274 let mut terminal = create_test_terminal();
5275 let mut state = create_populated_state();
5276 state.chart_mode = ChartMode::Candlestick;
5277 terminal.draw(|f| ui(f, &mut state)).unwrap();
5278 }
5279
5280 #[test]
5281 fn test_ui_function_with_error_message() {
5282 let mut terminal = create_test_terminal();
5283 let mut state = create_populated_state();
5284 state.error_message = Some("Test error".to_string());
5285 terminal.draw(|f| ui(f, &mut state)).unwrap();
5286 }
5287
5288 #[test]
5289 fn test_render_header_with_small_positive_change() {
5290 let mut terminal = create_test_terminal();
5291 let mut state = create_populated_state();
5292 state.price_change_24h = 0.3; terminal
5294 .draw(|f| render_header(f, f.area(), &state))
5295 .unwrap();
5296 }
5297
5298 #[test]
5299 fn test_render_header_with_small_negative_change() {
5300 let mut terminal = create_test_terminal();
5301 let mut state = create_populated_state();
5302 state.price_change_24h = -0.3; terminal
5304 .draw(|f| render_header(f, f.area(), &state))
5305 .unwrap();
5306 }
5307
5308 #[test]
5309 fn test_render_buy_sell_gauge_high_buy_ratio() {
5310 let mut terminal = create_test_terminal();
5311 let token_data = create_test_token_data();
5312 let mut state = MonitorState::new(&token_data, "ethereum");
5313 state.buys_24h = 100;
5314 state.sells_24h = 10;
5315 terminal
5316 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5317 .unwrap();
5318 }
5319
5320 #[test]
5321 fn test_render_buy_sell_gauge_zero_total() {
5322 let mut terminal = create_test_terminal();
5323 let token_data = create_test_token_data();
5324 let mut state = MonitorState::new(&token_data, "ethereum");
5325 state.buys_24h = 0;
5326 state.sells_24h = 0;
5327 terminal
5328 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
5329 .unwrap();
5330 }
5331
5332 #[test]
5333 fn test_render_metrics_with_market_cap() {
5334 let mut terminal = create_test_terminal();
5335 let token_data = create_test_token_data();
5336 let mut state = MonitorState::new(&token_data, "ethereum");
5337 state.market_cap = Some(1_000_000_000.0);
5338 state.fdv = Some(2_000_000_000.0);
5339 terminal
5340 .draw(|f| render_metrics_panel(f, f.area(), &state))
5341 .unwrap();
5342 }
5343
5344 #[test]
5345 fn test_render_footer_with_error() {
5346 let mut terminal = create_test_terminal();
5347 let mut state = create_populated_state();
5348 state.error_message = Some("Connection failed".to_string());
5349 terminal
5350 .draw(|f| render_footer(f, f.area(), &state))
5351 .unwrap();
5352 }
5353
5354 #[test]
5355 fn test_format_price_usd_various() {
5356 assert!(!format_price_usd(0.0000001).is_empty());
5358 assert!(!format_price_usd(0.001).is_empty());
5359 assert!(!format_price_usd(1.0).is_empty());
5360 assert!(!format_price_usd(100.0).is_empty());
5361 assert!(!format_price_usd(10000.0).is_empty());
5362 assert!(!format_price_usd(1000000.0).is_empty());
5363 }
5364
5365 #[test]
5366 fn test_format_usd_various() {
5367 assert!(!crate::display::format_usd(0.0).is_empty());
5368 assert!(!crate::display::format_usd(999.0).is_empty());
5369 assert!(!crate::display::format_usd(1500.0).is_empty());
5370 assert!(!crate::display::format_usd(1_500_000.0).is_empty());
5371 assert!(!crate::display::format_usd(1_500_000_000.0).is_empty());
5372 assert!(!crate::display::format_usd(1_500_000_000_000.0).is_empty());
5373 }
5374
5375 #[test]
5376 fn test_format_number_various() {
5377 assert!(!format_number(0.0).is_empty());
5378 assert!(!format_number(999.0).is_empty());
5379 assert!(!format_number(1500.0).is_empty());
5380 assert!(!format_number(1_500_000.0).is_empty());
5381 assert!(!format_number(1_500_000_000.0).is_empty());
5382 }
5383
5384 #[test]
5385 fn test_render_with_min15_period() {
5386 let mut terminal = create_test_terminal();
5387 let mut state = create_populated_state();
5388 state.set_time_period(TimePeriod::Min15);
5389 terminal.draw(|f| ui(f, &mut state)).unwrap();
5390 }
5391
5392 #[test]
5393 fn test_render_with_hour6_period() {
5394 let mut terminal = create_test_terminal();
5395 let mut state = create_populated_state();
5396 state.set_time_period(TimePeriod::Hour4);
5397 terminal.draw(|f| ui(f, &mut state)).unwrap();
5398 }
5399
5400 #[test]
5401 fn test_ui_with_fresh_state_no_real_data() {
5402 let mut terminal = create_test_terminal();
5403 let token_data = create_test_token_data();
5404 let mut state = MonitorState::new(&token_data, "ethereum");
5405 terminal.draw(|f| ui(f, &mut state)).unwrap();
5407 }
5408
5409 #[test]
5410 fn test_ui_with_paused_state() {
5411 let mut terminal = create_test_terminal();
5412 let mut state = create_populated_state();
5413 state.toggle_pause();
5414 terminal.draw(|f| ui(f, &mut state)).unwrap();
5415 }
5416
5417 #[test]
5418 fn test_render_all_with_different_time_periods_and_modes() {
5419 let mut terminal = create_test_terminal();
5420 let mut state = create_populated_state();
5421
5422 for period in &[
5424 TimePeriod::Min1,
5425 TimePeriod::Min5,
5426 TimePeriod::Min15,
5427 TimePeriod::Hour1,
5428 TimePeriod::Hour4,
5429 TimePeriod::Day1,
5430 ] {
5431 for mode in &[
5432 ChartMode::Line,
5433 ChartMode::Candlestick,
5434 ChartMode::VolumeProfile,
5435 ] {
5436 state.set_time_period(*period);
5437 state.chart_mode = *mode;
5438 terminal.draw(|f| ui(f, &mut state)).unwrap();
5439 }
5440 }
5441 }
5442
5443 #[test]
5444 fn test_render_metrics_with_large_values() {
5445 let mut terminal = create_test_terminal();
5446 let mut state = create_populated_state();
5447 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
5452 .draw(|f| render_metrics_panel(f, f.area(), &state))
5453 .unwrap();
5454 }
5455
5456 #[test]
5457 fn test_render_header_large_positive_change() {
5458 let mut terminal = create_test_terminal();
5459 let mut state = create_populated_state();
5460 state.price_change_24h = 50.0; terminal
5462 .draw(|f| render_header(f, f.area(), &state))
5463 .unwrap();
5464 }
5465
5466 #[test]
5467 fn test_render_header_large_negative_change() {
5468 let mut terminal = create_test_terminal();
5469 let mut state = create_populated_state();
5470 state.price_change_24h = -50.0; terminal
5472 .draw(|f| render_header(f, f.area(), &state))
5473 .unwrap();
5474 }
5475
5476 #[test]
5477 fn test_render_price_chart_empty_data() {
5478 let mut terminal = create_test_terminal();
5479 let token_data = create_test_token_data();
5480 let mut state = MonitorState::new(&token_data, "ethereum");
5482 state.price_history.clear();
5483 terminal
5484 .draw(|f| render_price_chart(f, f.area(), &state))
5485 .unwrap();
5486 }
5487
5488 #[test]
5489 fn test_render_price_chart_price_down() {
5490 let mut terminal = create_test_terminal();
5491 let mut state = create_populated_state();
5492 state.price_change_24h = -15.0;
5494 state.current_price = 0.5; terminal
5496 .draw(|f| render_price_chart(f, f.area(), &state))
5497 .unwrap();
5498 }
5499
5500 #[test]
5501 fn test_render_price_chart_zero_first_price() {
5502 let mut terminal = create_test_terminal();
5503 let mut token_data = create_test_token_data();
5504 token_data.price_usd = 0.0;
5505 let state = MonitorState::new(&token_data, "ethereum");
5506 terminal
5507 .draw(|f| render_price_chart(f, f.area(), &state))
5508 .unwrap();
5509 }
5510
5511 #[test]
5512 fn test_render_metrics_panel_zero_5m_change() {
5513 let mut terminal = create_test_terminal();
5514 let mut state = create_populated_state();
5515 state.price_change_5m = 0.0; terminal
5517 .draw(|f| render_metrics_panel(f, f.area(), &state))
5518 .unwrap();
5519 }
5520
5521 #[test]
5522 fn test_render_metrics_panel_positive_5m_change() {
5523 let mut terminal = create_test_terminal();
5524 let mut state = create_populated_state();
5525 state.price_change_5m = 5.0; terminal
5527 .draw(|f| render_metrics_panel(f, f.area(), &state))
5528 .unwrap();
5529 }
5530
5531 #[test]
5532 fn test_render_metrics_panel_negative_5m_change() {
5533 let mut terminal = create_test_terminal();
5534 let mut state = create_populated_state();
5535 state.price_change_5m = -3.0; terminal
5537 .draw(|f| render_metrics_panel(f, f.area(), &state))
5538 .unwrap();
5539 }
5540
5541 #[test]
5542 fn test_render_metrics_panel_negative_24h_change() {
5543 let mut terminal = create_test_terminal();
5544 let mut state = create_populated_state();
5545 state.price_change_24h = -10.0;
5546 terminal
5547 .draw(|f| render_metrics_panel(f, f.area(), &state))
5548 .unwrap();
5549 }
5550
5551 #[test]
5552 fn test_render_metrics_panel_old_last_change() {
5553 let mut terminal = create_test_terminal();
5554 let mut state = create_populated_state();
5555 state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 7200.0; terminal
5558 .draw(|f| render_metrics_panel(f, f.area(), &state))
5559 .unwrap();
5560 }
5561
5562 #[test]
5563 fn test_render_metrics_panel_minutes_ago_change() {
5564 let mut terminal = create_test_terminal();
5565 let mut state = create_populated_state();
5566 state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 300.0; terminal
5569 .draw(|f| render_metrics_panel(f, f.area(), &state))
5570 .unwrap();
5571 }
5572
5573 #[test]
5574 fn test_render_candlestick_empty_fresh_state() {
5575 let mut terminal = create_test_terminal();
5576 let token_data = create_test_token_data();
5577 let mut state = MonitorState::new(&token_data, "ethereum");
5578 state.price_history.clear();
5579 state.chart_mode = ChartMode::Candlestick;
5580 terminal
5581 .draw(|f| render_candlestick_chart(f, f.area(), &state))
5582 .unwrap();
5583 }
5584
5585 #[test]
5586 fn test_render_candlestick_price_down() {
5587 let mut terminal = create_test_terminal();
5588 let token_data = create_test_token_data();
5589 let mut state = MonitorState::new(&token_data, "ethereum");
5590 for i in 0..20 {
5592 let mut data = token_data.clone();
5593 data.price_usd = 2.0 - (i as f64 * 0.05);
5594 state.update(&data);
5595 }
5596 state.chart_mode = ChartMode::Candlestick;
5597 terminal
5598 .draw(|f| render_candlestick_chart(f, f.area(), &state))
5599 .unwrap();
5600 }
5601
5602 #[test]
5603 fn test_render_volume_chart_with_many_points() {
5604 let mut terminal = create_test_terminal();
5605 let token_data = create_test_token_data();
5606 let mut state = MonitorState::new(&token_data, "ethereum");
5607 for i in 0..100 {
5609 let mut data = token_data.clone();
5610 data.volume_24h = 1_000_000.0 + (i as f64 * 50_000.0);
5611 data.price_usd = 1.0 + (i as f64 * 0.001);
5612 state.update(&data);
5613 }
5614 terminal
5615 .draw(|f| render_volume_chart(f, f.area(), &state))
5616 .unwrap();
5617 }
5618
5619 fn make_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
5624 crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)
5625 }
5626
5627 fn make_ctrl_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
5628 crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
5629 }
5630
5631 #[test]
5632 fn test_handle_key_quit_q() {
5633 let token_data = create_test_token_data();
5634 let mut state = MonitorState::new(&token_data, "ethereum");
5635 assert!(handle_key_event_on_state(
5636 make_key_event(KeyCode::Char('q')),
5637 &mut state
5638 ));
5639 }
5640
5641 #[test]
5642 fn test_handle_key_quit_esc() {
5643 let token_data = create_test_token_data();
5644 let mut state = MonitorState::new(&token_data, "ethereum");
5645 assert!(handle_key_event_on_state(
5646 make_key_event(KeyCode::Esc),
5647 &mut state
5648 ));
5649 }
5650
5651 #[test]
5652 fn test_handle_key_quit_ctrl_c() {
5653 let token_data = create_test_token_data();
5654 let mut state = MonitorState::new(&token_data, "ethereum");
5655 assert!(handle_key_event_on_state(
5656 make_ctrl_key_event(KeyCode::Char('c')),
5657 &mut state
5658 ));
5659 }
5660
5661 #[test]
5662 fn test_handle_key_refresh() {
5663 let token_data = create_test_token_data();
5664 let mut state = MonitorState::new(&token_data, "ethereum");
5665 state.refresh_rate = Duration::from_secs(60);
5666 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('r')), &mut state);
5668 assert!(!exit);
5669 assert!(state.should_refresh());
5671 }
5672
5673 #[test]
5674 fn test_handle_key_pause_toggle() {
5675 let token_data = create_test_token_data();
5676 let mut state = MonitorState::new(&token_data, "ethereum");
5677 assert!(!state.paused);
5678
5679 handle_key_event_on_state(make_key_event(KeyCode::Char('p')), &mut state);
5680 assert!(state.paused);
5681
5682 handle_key_event_on_state(make_key_event(KeyCode::Char(' ')), &mut state);
5683 assert!(!state.paused);
5684 }
5685
5686 #[test]
5687 fn test_handle_key_slower_refresh() {
5688 let token_data = create_test_token_data();
5689 let mut state = MonitorState::new(&token_data, "ethereum");
5690 let initial = state.refresh_rate;
5691
5692 handle_key_event_on_state(make_key_event(KeyCode::Char('+')), &mut state);
5693 assert!(state.refresh_rate > initial);
5694
5695 state.refresh_rate = initial;
5696 handle_key_event_on_state(make_key_event(KeyCode::Char('=')), &mut state);
5697 assert!(state.refresh_rate > initial);
5698
5699 state.refresh_rate = initial;
5700 handle_key_event_on_state(make_key_event(KeyCode::Char(']')), &mut state);
5701 assert!(state.refresh_rate > initial);
5702 }
5703
5704 #[test]
5705 fn test_handle_key_faster_refresh() {
5706 let token_data = create_test_token_data();
5707 let mut state = MonitorState::new(&token_data, "ethereum");
5708 state.refresh_rate = Duration::from_secs(30);
5710 let initial = state.refresh_rate;
5711
5712 handle_key_event_on_state(make_key_event(KeyCode::Char('-')), &mut state);
5713 assert!(state.refresh_rate < initial);
5714
5715 state.refresh_rate = initial;
5716 handle_key_event_on_state(make_key_event(KeyCode::Char('_')), &mut state);
5717 assert!(state.refresh_rate < initial);
5718
5719 state.refresh_rate = initial;
5720 handle_key_event_on_state(make_key_event(KeyCode::Char('[')), &mut state);
5721 assert!(state.refresh_rate < initial);
5722 }
5723
5724 #[test]
5725 fn test_handle_key_time_periods() {
5726 let token_data = create_test_token_data();
5727 let mut state = MonitorState::new(&token_data, "ethereum");
5728
5729 handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
5730 assert!(matches!(state.time_period, TimePeriod::Min1));
5731
5732 handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
5733 assert!(matches!(state.time_period, TimePeriod::Min5));
5734
5735 handle_key_event_on_state(make_key_event(KeyCode::Char('3')), &mut state);
5736 assert!(matches!(state.time_period, TimePeriod::Min15));
5737
5738 handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
5739 assert!(matches!(state.time_period, TimePeriod::Hour1));
5740
5741 handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
5742 assert!(matches!(state.time_period, TimePeriod::Hour4));
5743
5744 handle_key_event_on_state(make_key_event(KeyCode::Char('6')), &mut state);
5745 assert!(matches!(state.time_period, TimePeriod::Day1));
5746 }
5747
5748 #[test]
5749 fn test_handle_key_cycle_time_period() {
5750 let token_data = create_test_token_data();
5751 let mut state = MonitorState::new(&token_data, "ethereum");
5752
5753 handle_key_event_on_state(make_key_event(KeyCode::Char('t')), &mut state);
5754 let first = state.time_period;
5756
5757 handle_key_event_on_state(make_key_event(KeyCode::Tab), &mut state);
5758 let _ = state.time_period;
5761 let _ = first;
5762 }
5763
5764 #[test]
5765 fn test_handle_key_toggle_chart_mode() {
5766 let token_data = create_test_token_data();
5767 let mut state = MonitorState::new(&token_data, "ethereum");
5768 let initial_mode = state.chart_mode;
5769
5770 handle_key_event_on_state(make_key_event(KeyCode::Char('c')), &mut state);
5771 assert!(state.chart_mode != initial_mode);
5772 }
5773
5774 #[test]
5775 fn test_handle_key_unknown_no_op() {
5776 let token_data = create_test_token_data();
5777 let mut state = MonitorState::new(&token_data, "ethereum");
5778 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
5779 assert!(!exit);
5780 }
5781
5782 #[test]
5787 fn test_save_and_load_cache() {
5788 let token_data = create_test_token_data();
5789 let mut state = MonitorState::new(&token_data, "ethereum");
5790 state.price_history.push_back(DataPoint {
5791 timestamp: 1.0,
5792 value: 100.0,
5793 is_real: true,
5794 });
5795 state.price_history.push_back(DataPoint {
5796 timestamp: 2.0,
5797 value: 101.0,
5798 is_real: true,
5799 });
5800 state.volume_history.push_back(DataPoint {
5801 timestamp: 1.0,
5802 value: 5000.0,
5803 is_real: true,
5804 });
5805
5806 state.save_cache();
5809 let cached = MonitorState::load_cache(&state.token_address, &state.chain);
5810 if let Some(c) = cached {
5812 assert_eq!(
5813 c.token_address.to_lowercase(),
5814 state.token_address.to_lowercase()
5815 );
5816 }
5817 }
5818
5819 #[test]
5820 fn test_load_cache_nonexistent_token() {
5821 let cached = MonitorState::load_cache("0xNONEXISTENT_TOKEN_ADDR", "nonexistent_chain");
5822 assert!(cached.is_none());
5823 }
5824
5825 #[test]
5830 fn test_render_volume_barchart_with_populated_data() {
5831 let mut terminal = create_test_terminal();
5834 let mut state = create_populated_state();
5835 for period in [
5836 TimePeriod::Min1,
5837 TimePeriod::Min5,
5838 TimePeriod::Min15,
5839 TimePeriod::Hour1,
5840 TimePeriod::Hour4,
5841 TimePeriod::Day1,
5842 ] {
5843 state.set_time_period(period);
5844 terminal
5845 .draw(|f| render_volume_chart(f, f.area(), &state))
5846 .unwrap();
5847 }
5848 }
5849
5850 #[test]
5851 fn test_render_volume_barchart_narrow_terminal() {
5852 let backend = TestBackend::new(20, 10);
5854 let mut terminal = Terminal::new(backend).unwrap();
5855 let state = create_populated_state();
5856 terminal
5857 .draw(|f| render_volume_chart(f, f.area(), &state))
5858 .unwrap();
5859 }
5860
5861 #[test]
5862 fn test_render_metrics_table_sparkline_no_panic() {
5863 let mut terminal = create_test_terminal();
5865 let state = create_populated_state();
5866 terminal
5867 .draw(|f| render_metrics_panel(f, f.area(), &state))
5868 .unwrap();
5869 }
5870
5871 #[test]
5872 fn test_render_metrics_table_sparkline_all_periods() {
5873 let mut terminal = create_test_terminal();
5875 let mut state = create_populated_state();
5876 for period in [
5877 TimePeriod::Min1,
5878 TimePeriod::Min5,
5879 TimePeriod::Min15,
5880 TimePeriod::Hour1,
5881 TimePeriod::Hour4,
5882 TimePeriod::Day1,
5883 ] {
5884 state.set_time_period(period);
5885 terminal
5886 .draw(|f| render_metrics_panel(f, f.area(), &state))
5887 .unwrap();
5888 }
5889 }
5890
5891 #[test]
5892 fn test_render_metrics_sparkline_trend_direction() {
5893 let mut terminal = create_test_terminal();
5895 let mut state = create_populated_state();
5896 state.price_change_5m = -3.5;
5897 terminal
5898 .draw(|f| render_metrics_panel(f, f.area(), &state))
5899 .unwrap();
5900
5901 state.price_change_5m = 2.0;
5903 terminal
5904 .draw(|f| render_metrics_panel(f, f.area(), &state))
5905 .unwrap();
5906
5907 state.price_change_5m = 0.0;
5909 terminal
5910 .draw(|f| render_metrics_panel(f, f.area(), &state))
5911 .unwrap();
5912 }
5913
5914 #[test]
5915 fn test_render_tabs_time_period() {
5916 let mut terminal = create_test_terminal();
5918 let mut state = create_populated_state();
5919 for period in [
5920 TimePeriod::Min1,
5921 TimePeriod::Min5,
5922 TimePeriod::Min15,
5923 TimePeriod::Hour1,
5924 TimePeriod::Hour4,
5925 TimePeriod::Day1,
5926 ] {
5927 state.set_time_period(period);
5928 terminal
5929 .draw(|f| render_header(f, f.area(), &state))
5930 .unwrap();
5931 }
5932 }
5933
5934 #[test]
5935 fn test_time_period_index() {
5936 assert_eq!(TimePeriod::Min1.index(), 0);
5937 assert_eq!(TimePeriod::Min5.index(), 1);
5938 assert_eq!(TimePeriod::Min15.index(), 2);
5939 assert_eq!(TimePeriod::Hour1.index(), 3);
5940 assert_eq!(TimePeriod::Hour4.index(), 4);
5941 assert_eq!(TimePeriod::Day1.index(), 5);
5942 }
5943
5944 #[test]
5945 fn test_scroll_log_down_from_start() {
5946 let token_data = create_test_token_data();
5947 let mut state = MonitorState::new(&token_data, "ethereum");
5948 state.log_messages.push_back("msg 1".to_string());
5949 state.log_messages.push_back("msg 2".to_string());
5950 state.log_messages.push_back("msg 3".to_string());
5951
5952 assert_eq!(state.log_list_state.selected(), None);
5954
5955 state.scroll_log_down();
5957 assert_eq!(state.log_list_state.selected(), Some(0));
5958
5959 state.scroll_log_down();
5961 assert_eq!(state.log_list_state.selected(), Some(1));
5962
5963 state.scroll_log_down();
5965 assert_eq!(state.log_list_state.selected(), Some(2));
5966
5967 state.scroll_log_down();
5969 assert_eq!(state.log_list_state.selected(), Some(2));
5970 }
5971
5972 #[test]
5973 fn test_scroll_log_up_from_start() {
5974 let token_data = create_test_token_data();
5975 let mut state = MonitorState::new(&token_data, "ethereum");
5976 state.log_messages.push_back("msg 1".to_string());
5977 state.log_messages.push_back("msg 2".to_string());
5978 state.log_messages.push_back("msg 3".to_string());
5979
5980 state.scroll_log_up();
5982 assert_eq!(state.log_list_state.selected(), Some(0));
5983
5984 state.scroll_log_up();
5986 assert_eq!(state.log_list_state.selected(), Some(0));
5987 }
5988
5989 #[test]
5990 fn test_scroll_log_up_down_roundtrip() {
5991 let token_data = create_test_token_data();
5992 let mut state = MonitorState::new(&token_data, "ethereum");
5993 for i in 0..10 {
5994 state.log_messages.push_back(format!("msg {}", i));
5995 }
5996
5997 for _ in 0..5 {
5999 state.scroll_log_down();
6000 }
6001 assert_eq!(state.log_list_state.selected(), Some(4));
6002
6003 for _ in 0..3 {
6005 state.scroll_log_up();
6006 }
6007 assert_eq!(state.log_list_state.selected(), Some(1));
6008 }
6009
6010 #[test]
6011 fn test_scroll_log_empty_no_panic() {
6012 let token_data = create_test_token_data();
6013 let mut state = MonitorState::new(&token_data, "ethereum");
6014 state.scroll_log_down();
6016 state.scroll_log_up();
6017 assert!(
6018 state.log_list_state.selected().is_none() || state.log_list_state.selected() == Some(0)
6019 );
6020 }
6021
6022 #[test]
6023 fn test_render_scrollable_activity_log() {
6024 let mut terminal = create_test_terminal();
6026 let mut state = create_populated_state();
6027 for i in 0..20 {
6028 state
6029 .log_messages
6030 .push_back(format!("Activity event #{}", i));
6031 }
6032 state.scroll_log_down();
6034 state.scroll_log_down();
6035 state.scroll_log_down();
6036
6037 terminal
6038 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
6039 .unwrap();
6040 }
6041
6042 #[test]
6043 fn test_handle_key_scroll_log_j_k() {
6044 let token_data = create_test_token_data();
6045 let mut state = MonitorState::new(&token_data, "ethereum");
6046 state.log_messages.push_back("line 1".to_string());
6047 state.log_messages.push_back("line 2".to_string());
6048
6049 handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
6051 assert_eq!(state.log_list_state.selected(), Some(0));
6052
6053 handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
6054 assert_eq!(state.log_list_state.selected(), Some(1));
6055
6056 handle_key_event_on_state(make_key_event(KeyCode::Char('k')), &mut state);
6058 assert_eq!(state.log_list_state.selected(), Some(0));
6059 }
6060
6061 #[test]
6062 fn test_handle_key_scroll_log_arrow_keys() {
6063 let token_data = create_test_token_data();
6064 let mut state = MonitorState::new(&token_data, "ethereum");
6065 state.log_messages.push_back("line 1".to_string());
6066 state.log_messages.push_back("line 2".to_string());
6067 state.log_messages.push_back("line 3".to_string());
6068
6069 handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
6071 assert_eq!(state.log_list_state.selected(), Some(0));
6072
6073 handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
6074 assert_eq!(state.log_list_state.selected(), Some(1));
6075
6076 handle_key_event_on_state(make_key_event(KeyCode::Up), &mut state);
6078 assert_eq!(state.log_list_state.selected(), Some(0));
6079 }
6080
6081 #[test]
6082 fn test_render_ui_with_scrolled_log() {
6083 let mut terminal = create_test_terminal();
6085 let mut state = create_populated_state();
6086 for i in 0..15 {
6087 state.log_messages.push_back(format!("Log entry {}", i));
6088 }
6089 state.scroll_log_down();
6090 state.scroll_log_down();
6091 state.scroll_log_down();
6092 state.scroll_log_down();
6093 state.scroll_log_down();
6094
6095 terminal.draw(|f| ui(f, &mut state)).unwrap();
6096 }
6097
6098 fn make_monitor_search_results() -> Vec<crate::chains::dex::TokenSearchResult> {
6103 vec![
6104 crate::chains::dex::TokenSearchResult {
6105 symbol: "USDC".to_string(),
6106 name: "USD Coin".to_string(),
6107 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
6108 chain: "ethereum".to_string(),
6109 price_usd: Some(1.0),
6110 volume_24h: 1_000_000.0,
6111 liquidity_usd: 500_000_000.0,
6112 market_cap: Some(30_000_000_000.0),
6113 },
6114 crate::chains::dex::TokenSearchResult {
6115 symbol: "USDC".to_string(),
6116 name: "Bridged USD Coin".to_string(),
6117 address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
6118 chain: "ethereum".to_string(),
6119 price_usd: Some(0.9998),
6120 volume_24h: 500_000.0,
6121 liquidity_usd: 100_000_000.0,
6122 market_cap: None,
6123 },
6124 crate::chains::dex::TokenSearchResult {
6125 symbol: "USDC".to_string(),
6126 name: "A Very Long Token Name That Exceeds The Limit".to_string(),
6127 address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
6128 chain: "ethereum".to_string(),
6129 price_usd: None,
6130 volume_24h: 0.0,
6131 liquidity_usd: 50_000.0,
6132 market_cap: None,
6133 },
6134 ]
6135 }
6136
6137 #[test]
6138 fn test_abbreviate_address_long() {
6139 let addr = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
6140 let abbr = abbreviate_address(addr);
6141 assert_eq!(abbr, "0xA0b869...06eB48");
6142 assert!(abbr.contains("..."));
6143 }
6144
6145 #[test]
6146 fn test_abbreviate_address_short() {
6147 let addr = "0x1234abcd";
6148 let abbr = abbreviate_address(addr);
6149 assert_eq!(abbr, "0x1234abcd");
6151 }
6152
6153 #[test]
6154 fn test_select_token_impl_first() {
6155 let results = make_monitor_search_results();
6156 let input = b"1\n";
6157 let mut reader = std::io::Cursor::new(&input[..]);
6158 let mut writer = Vec::new();
6159
6160 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
6161 assert_eq!(selected.name, "USD Coin");
6162 assert_eq!(
6163 selected.address,
6164 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
6165 );
6166
6167 let output = String::from_utf8(writer).unwrap();
6168 assert!(output.contains("Found 3 tokens"));
6169 assert!(output.contains("USDC"));
6170 assert!(output.contains("0xA0b869...06eB48"));
6171 assert!(output.contains("Selected:"));
6172 }
6173
6174 #[test]
6175 fn test_select_token_impl_second() {
6176 let results = make_monitor_search_results();
6177 let input = b"2\n";
6178 let mut reader = std::io::Cursor::new(&input[..]);
6179 let mut writer = Vec::new();
6180
6181 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
6182 assert_eq!(selected.name, "Bridged USD Coin");
6183 assert_eq!(
6184 selected.address,
6185 "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
6186 );
6187 }
6188
6189 #[test]
6190 fn test_select_token_impl_shows_address_column() {
6191 let results = make_monitor_search_results();
6192 let input = b"1\n";
6193 let mut reader = std::io::Cursor::new(&input[..]);
6194 let mut writer = Vec::new();
6195
6196 select_token_impl(&results, &mut reader, &mut writer).unwrap();
6197 let output = String::from_utf8(writer).unwrap();
6198
6199 assert!(output.contains("Address"));
6201 assert!(output.contains("0xA0b869...06eB48"));
6203 assert!(output.contains("0x2791Bc...a84174"));
6204 assert!(output.contains("0x123456...345678"));
6205 }
6206
6207 #[test]
6208 fn test_select_token_impl_truncates_long_name() {
6209 let results = make_monitor_search_results();
6210 let input = b"3\n";
6211 let mut reader = std::io::Cursor::new(&input[..]);
6212 let mut writer = Vec::new();
6213
6214 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
6215 assert_eq!(
6216 selected.address,
6217 "0x1234567890abcdef1234567890abcdef12345678"
6218 );
6219
6220 let output = String::from_utf8(writer).unwrap();
6221 assert!(output.contains("A Very Long Token..."));
6222 }
6223
6224 #[test]
6225 fn test_select_token_impl_invalid_input() {
6226 let results = make_monitor_search_results();
6227 let input = b"xyz\n";
6228 let mut reader = std::io::Cursor::new(&input[..]);
6229 let mut writer = Vec::new();
6230
6231 let result = select_token_impl(&results, &mut reader, &mut writer);
6232 assert!(result.is_err());
6233 assert!(
6234 result
6235 .unwrap_err()
6236 .to_string()
6237 .contains("Invalid selection")
6238 );
6239 }
6240
6241 #[test]
6242 fn test_select_token_impl_out_of_range_zero() {
6243 let results = make_monitor_search_results();
6244 let input = b"0\n";
6245 let mut reader = std::io::Cursor::new(&input[..]);
6246 let mut writer = Vec::new();
6247
6248 let result = select_token_impl(&results, &mut reader, &mut writer);
6249 assert!(result.is_err());
6250 assert!(
6251 result
6252 .unwrap_err()
6253 .to_string()
6254 .contains("Selection must be between")
6255 );
6256 }
6257
6258 #[test]
6259 fn test_select_token_impl_out_of_range_high() {
6260 let results = make_monitor_search_results();
6261 let input = b"99\n";
6262 let mut reader = std::io::Cursor::new(&input[..]);
6263 let mut writer = Vec::new();
6264
6265 let result = select_token_impl(&results, &mut reader, &mut writer);
6266 assert!(result.is_err());
6267 }
6268
6269 #[test]
6270 fn test_format_monitor_number() {
6271 assert_eq!(format_monitor_number(1_500_000_000.0), "$1.50B");
6272 assert_eq!(format_monitor_number(250_000_000.0), "$250.00M");
6273 assert_eq!(format_monitor_number(75_000.0), "$75.00K");
6274 assert_eq!(format_monitor_number(42.5), "$42.50");
6275 }
6276
6277 #[test]
6282 fn test_monitor_config_defaults() {
6283 let config = MonitorConfig::default();
6284 assert_eq!(config.layout, LayoutPreset::Dashboard);
6285 assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
6286 assert!(config.widgets.price_chart);
6287 assert!(config.widgets.volume_chart);
6288 assert!(config.widgets.buy_sell_pressure);
6289 assert!(config.widgets.metrics_panel);
6290 assert!(config.widgets.activity_log);
6291 }
6292
6293 #[test]
6294 fn test_layout_preset_next_cycles() {
6295 assert_eq!(LayoutPreset::Dashboard.next(), LayoutPreset::ChartFocus);
6296 assert_eq!(LayoutPreset::ChartFocus.next(), LayoutPreset::Feed);
6297 assert_eq!(LayoutPreset::Feed.next(), LayoutPreset::Compact);
6298 assert_eq!(LayoutPreset::Compact.next(), LayoutPreset::Exchange);
6299 assert_eq!(LayoutPreset::Exchange.next(), LayoutPreset::Dashboard);
6300 }
6301
6302 #[test]
6303 fn test_layout_preset_prev_cycles() {
6304 assert_eq!(LayoutPreset::Dashboard.prev(), LayoutPreset::Exchange);
6305 assert_eq!(LayoutPreset::Exchange.prev(), LayoutPreset::Compact);
6306 assert_eq!(LayoutPreset::Compact.prev(), LayoutPreset::Feed);
6307 assert_eq!(LayoutPreset::Feed.prev(), LayoutPreset::ChartFocus);
6308 assert_eq!(LayoutPreset::ChartFocus.prev(), LayoutPreset::Dashboard);
6309 }
6310
6311 #[test]
6312 fn test_layout_preset_full_cycle() {
6313 let start = LayoutPreset::Dashboard;
6314 let mut preset = start;
6315 for _ in 0..5 {
6316 preset = preset.next();
6317 }
6318 assert_eq!(preset, start);
6319 }
6320
6321 #[test]
6322 fn test_layout_preset_labels() {
6323 assert_eq!(LayoutPreset::Dashboard.label(), "Dashboard");
6324 assert_eq!(LayoutPreset::ChartFocus.label(), "Chart");
6325 assert_eq!(LayoutPreset::Feed.label(), "Feed");
6326 assert_eq!(LayoutPreset::Compact.label(), "Compact");
6327 assert_eq!(LayoutPreset::Exchange.label(), "Exchange");
6328 }
6329
6330 #[test]
6331 fn test_widget_visibility_default_all_visible() {
6332 let vis = WidgetVisibility::default();
6333 assert_eq!(vis.visible_count(), 5);
6334 }
6335
6336 #[test]
6337 fn test_widget_visibility_toggle_by_index() {
6338 let mut vis = WidgetVisibility::default();
6339 vis.toggle_by_index(1);
6340 assert!(!vis.price_chart);
6341 assert_eq!(vis.visible_count(), 4);
6342
6343 vis.toggle_by_index(2);
6344 assert!(!vis.volume_chart);
6345 assert_eq!(vis.visible_count(), 3);
6346
6347 vis.toggle_by_index(3);
6348 assert!(!vis.buy_sell_pressure);
6349 assert_eq!(vis.visible_count(), 2);
6350
6351 vis.toggle_by_index(4);
6352 assert!(!vis.metrics_panel);
6353 assert_eq!(vis.visible_count(), 1);
6354
6355 vis.toggle_by_index(5);
6356 assert!(!vis.activity_log);
6357 assert_eq!(vis.visible_count(), 0);
6358
6359 vis.toggle_by_index(1);
6361 assert!(vis.price_chart);
6362 assert_eq!(vis.visible_count(), 1);
6363 }
6364
6365 #[test]
6366 fn test_widget_visibility_toggle_invalid_index() {
6367 let mut vis = WidgetVisibility::default();
6368 vis.toggle_by_index(0);
6369 vis.toggle_by_index(6);
6370 vis.toggle_by_index(100);
6371 assert_eq!(vis.visible_count(), 5); }
6373
6374 #[test]
6375 fn test_auto_select_layout_small_terminal() {
6376 let size = Rect::new(0, 0, 60, 20);
6377 assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
6378 }
6379
6380 #[test]
6381 fn test_auto_select_layout_narrow_terminal() {
6382 let size = Rect::new(0, 0, 100, 40);
6383 assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
6384 }
6385
6386 #[test]
6387 fn test_auto_select_layout_short_terminal() {
6388 let size = Rect::new(0, 0, 140, 28);
6389 assert_eq!(auto_select_layout(size), LayoutPreset::ChartFocus);
6390 }
6391
6392 #[test]
6393 fn test_auto_select_layout_large_terminal() {
6394 let size = Rect::new(0, 0, 160, 50);
6395 assert_eq!(auto_select_layout(size), LayoutPreset::Dashboard);
6396 }
6397
6398 #[test]
6399 fn test_auto_select_layout_edge_80x24() {
6400 let size = Rect::new(0, 0, 80, 24);
6402 assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
6403 }
6404
6405 #[test]
6406 fn test_auto_select_layout_edge_79() {
6407 let size = Rect::new(0, 0, 79, 50);
6408 assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
6409 }
6410
6411 #[test]
6412 fn test_auto_select_layout_edge_23_height() {
6413 let size = Rect::new(0, 0, 160, 23);
6414 assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
6415 }
6416
6417 #[test]
6418 fn test_layout_dashboard_all_visible() {
6419 let area = Rect::new(0, 0, 120, 40);
6420 let vis = WidgetVisibility::default();
6421 let areas = layout_dashboard(area, &vis);
6422 assert!(areas.price_chart.is_some());
6423 assert!(areas.volume_chart.is_some());
6424 assert!(areas.buy_sell_gauge.is_some());
6425 assert!(areas.metrics_panel.is_some());
6426 assert!(areas.activity_feed.is_some());
6427 }
6428
6429 #[test]
6430 fn test_layout_dashboard_hidden_widget() {
6431 let area = Rect::new(0, 0, 120, 40);
6432 let vis = WidgetVisibility {
6433 price_chart: false,
6434 ..WidgetVisibility::default()
6435 };
6436 let areas = layout_dashboard(area, &vis);
6437 assert!(areas.price_chart.is_none());
6438 assert!(areas.volume_chart.is_some());
6439 }
6440
6441 #[test]
6442 fn test_layout_chart_focus_minimal_overlay() {
6443 let area = Rect::new(0, 0, 120, 40);
6444 let vis = WidgetVisibility::default();
6445 let areas = layout_chart_focus(area, &vis);
6446 assert!(areas.price_chart.is_some());
6447 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()); }
6452
6453 #[test]
6454 fn test_layout_feed_activity_priority() {
6455 let area = Rect::new(0, 0, 120, 40);
6456 let vis = WidgetVisibility::default();
6457 let areas = layout_feed(area, &vis);
6458 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()); }
6464
6465 #[test]
6466 fn test_layout_compact_metrics_only() {
6467 let area = Rect::new(0, 0, 60, 20);
6468 let vis = WidgetVisibility::default();
6469 let areas = layout_compact(area, &vis);
6470 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()); }
6476
6477 #[test]
6478 fn test_layout_exchange_has_order_book_and_market_info() {
6479 let area = Rect::new(0, 0, 160, 50);
6480 let vis = WidgetVisibility::default();
6481 let areas = layout_exchange(area, &vis);
6482 assert!(areas.order_book.is_some());
6483 assert!(areas.market_info.is_some());
6484 assert!(areas.price_chart.is_some());
6485 assert!(areas.buy_sell_gauge.is_some());
6486 assert!(areas.volume_chart.is_none()); assert!(areas.metrics_panel.is_none()); assert!(areas.activity_feed.is_none()); }
6490
6491 #[test]
6492 fn test_ui_render_all_layouts_no_panic() {
6493 let presets = [
6494 LayoutPreset::Dashboard,
6495 LayoutPreset::ChartFocus,
6496 LayoutPreset::Feed,
6497 LayoutPreset::Compact,
6498 LayoutPreset::Exchange,
6499 ];
6500 for preset in &presets {
6501 let mut terminal = create_test_terminal();
6502 let mut state = create_populated_state();
6503 state.layout = *preset;
6504 state.auto_layout = false; terminal.draw(|f| ui(f, &mut state)).unwrap();
6506 }
6507 }
6508
6509 #[test]
6510 fn test_ui_render_compact_small_terminal() {
6511 let backend = TestBackend::new(60, 20);
6512 let mut terminal = Terminal::new(backend).unwrap();
6513 let mut state = create_populated_state();
6514 state.layout = LayoutPreset::Compact;
6515 state.auto_layout = false;
6516 terminal.draw(|f| ui(f, &mut state)).unwrap();
6517 }
6518
6519 #[test]
6520 fn test_ui_auto_layout_selects_compact_for_small() {
6521 let backend = TestBackend::new(60, 20);
6522 let mut terminal = Terminal::new(backend).unwrap();
6523 let mut state = create_populated_state();
6524 state.layout = LayoutPreset::Dashboard;
6525 state.auto_layout = true;
6526 terminal.draw(|f| ui(f, &mut state)).unwrap();
6527 assert_eq!(state.layout, LayoutPreset::Compact);
6528 }
6529
6530 #[test]
6531 fn test_ui_auto_layout_disabled_keeps_preset() {
6532 let backend = TestBackend::new(60, 20);
6533 let mut terminal = Terminal::new(backend).unwrap();
6534 let mut state = create_populated_state();
6535 state.layout = LayoutPreset::Dashboard;
6536 state.auto_layout = false;
6537 terminal.draw(|f| ui(f, &mut state)).unwrap();
6538 assert_eq!(state.layout, LayoutPreset::Dashboard); }
6540
6541 #[test]
6542 fn test_keybinding_l_cycles_layout_forward() {
6543 let mut state = create_populated_state();
6544 state.layout = LayoutPreset::Dashboard;
6545 state.auto_layout = true;
6546
6547 handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
6548 assert_eq!(state.layout, LayoutPreset::ChartFocus);
6549 assert!(!state.auto_layout); handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
6552 assert_eq!(state.layout, LayoutPreset::Feed);
6553 }
6554
6555 #[test]
6556 fn test_keybinding_h_cycles_layout_backward() {
6557 let mut state = create_populated_state();
6558 state.layout = LayoutPreset::Dashboard;
6559 state.auto_layout = true;
6560
6561 handle_key_event_on_state(make_key_event(KeyCode::Char('h')), &mut state);
6562 assert_eq!(state.layout, LayoutPreset::Exchange);
6563 assert!(!state.auto_layout);
6564 }
6565
6566 #[test]
6567 fn test_keybinding_a_enables_auto_layout() {
6568 let mut state = create_populated_state();
6569 state.auto_layout = false;
6570
6571 handle_key_event_on_state(make_key_event(KeyCode::Char('a')), &mut state);
6572 assert!(state.auto_layout);
6573 }
6574
6575 #[test]
6576 fn test_keybinding_w_widget_toggle_mode() {
6577 let mut state = create_populated_state();
6578 assert!(!state.widget_toggle_mode);
6579
6580 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6582 assert!(state.widget_toggle_mode);
6583
6584 handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
6586 assert!(!state.widget_toggle_mode);
6587 assert!(!state.widgets.price_chart);
6588 }
6589
6590 #[test]
6591 fn test_keybinding_w_cancel_with_non_digit() {
6592 let mut state = create_populated_state();
6593
6594 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6596 assert!(state.widget_toggle_mode);
6597
6598 handle_key_event_on_state(make_key_event(KeyCode::Char('x')), &mut state);
6600 assert!(!state.widget_toggle_mode);
6601 assert!(state.widgets.price_chart); }
6603
6604 #[test]
6605 fn test_keybinding_w_toggle_multiple_widgets() {
6606 let mut state = create_populated_state();
6607
6608 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6610 handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
6611 assert!(!state.widgets.volume_chart);
6612
6613 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6615 handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
6616 assert!(!state.widgets.metrics_panel);
6617
6618 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
6620 handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
6621 assert!(!state.widgets.activity_log);
6622 }
6623
6624 #[test]
6625 fn test_monitor_config_serde_roundtrip() {
6626 let config = MonitorConfig {
6627 layout: LayoutPreset::ChartFocus,
6628 refresh_seconds: 5,
6629 widgets: WidgetVisibility {
6630 price_chart: true,
6631 volume_chart: false,
6632 buy_sell_pressure: true,
6633 metrics_panel: false,
6634 activity_log: true,
6635 holder_count: true,
6636 liquidity_depth: true,
6637 },
6638 scale: ScaleMode::Log,
6639 color_scheme: ColorScheme::BlueOrange,
6640 alerts: AlertConfig::default(),
6641 export: ExportConfig::default(),
6642 auto_pause_on_input: false,
6643 venue: None,
6644 };
6645
6646 let yaml = serde_yaml::to_string(&config).unwrap();
6647 let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
6648 assert_eq!(parsed.layout, LayoutPreset::ChartFocus);
6649 assert_eq!(parsed.refresh_seconds, 5);
6650 assert!(parsed.widgets.price_chart);
6651 assert!(!parsed.widgets.volume_chart);
6652 assert!(parsed.widgets.buy_sell_pressure);
6653 assert!(!parsed.widgets.metrics_panel);
6654 assert!(parsed.widgets.activity_log);
6655 }
6656
6657 #[test]
6658 fn test_monitor_config_serde_kebab_case() {
6659 let yaml = r#"
6660layout: chart-focus
6661refresh_seconds: 15
6662widgets:
6663 price_chart: true
6664 volume_chart: true
6665 buy_sell_pressure: false
6666 metrics_panel: true
6667 activity_log: false
6668"#;
6669 let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
6670 assert_eq!(config.layout, LayoutPreset::ChartFocus);
6671 assert_eq!(config.refresh_seconds, 15);
6672 assert!(!config.widgets.buy_sell_pressure);
6673 assert!(!config.widgets.activity_log);
6674 }
6675
6676 #[test]
6677 fn test_monitor_config_serde_default_missing_fields() {
6678 let yaml = "layout: feed\n";
6679 let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
6680 assert_eq!(config.layout, LayoutPreset::Feed);
6681 assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
6682 assert!(config.widgets.price_chart); }
6684
6685 #[test]
6686 fn test_state_apply_config() {
6687 let mut state = create_populated_state();
6688 let config = MonitorConfig {
6689 layout: LayoutPreset::Feed,
6690 refresh_seconds: 5,
6691 widgets: WidgetVisibility {
6692 price_chart: false,
6693 volume_chart: true,
6694 buy_sell_pressure: true,
6695 metrics_panel: false,
6696 activity_log: true,
6697 holder_count: true,
6698 liquidity_depth: true,
6699 },
6700 scale: ScaleMode::Log,
6701 color_scheme: ColorScheme::Monochrome,
6702 alerts: AlertConfig::default(),
6703 export: ExportConfig::default(),
6704 auto_pause_on_input: false,
6705 venue: None,
6706 };
6707 state.apply_config(&config);
6708 assert_eq!(state.layout, LayoutPreset::Feed);
6709 assert!(!state.widgets.price_chart);
6710 assert!(!state.widgets.metrics_panel);
6711 assert_eq!(state.refresh_rate, Duration::from_secs(5));
6712 }
6713
6714 #[test]
6715 fn test_layout_all_widgets_hidden_dashboard() {
6716 let area = Rect::new(0, 0, 120, 40);
6717 let vis = WidgetVisibility {
6718 price_chart: false,
6719 volume_chart: false,
6720 buy_sell_pressure: false,
6721 metrics_panel: false,
6722 activity_log: false,
6723 holder_count: false,
6724 liquidity_depth: false,
6725 };
6726 let areas = layout_dashboard(area, &vis);
6727 assert!(areas.price_chart.is_none());
6728 assert!(areas.volume_chart.is_none());
6729 assert!(areas.buy_sell_gauge.is_none());
6730 assert!(areas.metrics_panel.is_none());
6731 assert!(areas.activity_feed.is_none());
6732 }
6733
6734 #[test]
6735 fn test_ui_render_with_hidden_widgets() {
6736 let mut terminal = create_test_terminal();
6737 let mut state = create_populated_state();
6738 state.auto_layout = false;
6739 state.widgets.price_chart = false;
6740 state.widgets.volume_chart = false;
6741 terminal.draw(|f| ui(f, &mut state)).unwrap();
6742 }
6743
6744 #[test]
6745 fn test_ui_render_widget_toggle_mode_footer() {
6746 let mut terminal = create_test_terminal();
6747 let mut state = create_populated_state();
6748 state.auto_layout = false;
6749 state.widget_toggle_mode = true;
6750 terminal.draw(|f| ui(f, &mut state)).unwrap();
6751 }
6752
6753 #[test]
6754 fn test_monitor_state_new_has_layout_fields() {
6755 let token_data = create_test_token_data();
6756 let state = MonitorState::new(&token_data, "ethereum");
6757 assert_eq!(state.layout, LayoutPreset::Dashboard);
6758 assert!(state.auto_layout);
6759 assert!(!state.widget_toggle_mode);
6760 assert_eq!(state.widgets.visible_count(), 5);
6761 }
6762
6763 #[test]
6768 fn test_monitor_state_has_holder_count_field() {
6769 let token_data = create_test_token_data();
6770 let state = MonitorState::new(&token_data, "ethereum");
6771 assert_eq!(state.holder_count, None);
6772 assert!(state.liquidity_pairs.is_empty());
6773 assert_eq!(state.holder_fetch_counter, 0);
6774 }
6775
6776 #[test]
6777 fn test_liquidity_pairs_extracted_on_update() {
6778 let mut token_data = create_test_token_data();
6779 token_data.pairs = vec![
6780 crate::chains::DexPair {
6781 dex_name: "Uniswap V3".to_string(),
6782 pair_address: "0xpair1".to_string(),
6783 base_token: "TEST".to_string(),
6784 quote_token: "WETH".to_string(),
6785 price_usd: 1.0,
6786 volume_24h: 500_000.0,
6787 liquidity_usd: 250_000.0,
6788 price_change_24h: 5.0,
6789 buys_24h: 50,
6790 sells_24h: 25,
6791 buys_6h: 10,
6792 sells_6h: 5,
6793 buys_1h: 3,
6794 sells_1h: 2,
6795 pair_created_at: None,
6796 url: None,
6797 },
6798 crate::chains::DexPair {
6799 dex_name: "SushiSwap".to_string(),
6800 pair_address: "0xpair2".to_string(),
6801 base_token: "TEST".to_string(),
6802 quote_token: "USDC".to_string(),
6803 price_usd: 1.0,
6804 volume_24h: 300_000.0,
6805 liquidity_usd: 150_000.0,
6806 price_change_24h: 3.0,
6807 buys_24h: 30,
6808 sells_24h: 15,
6809 buys_6h: 8,
6810 sells_6h: 4,
6811 buys_1h: 2,
6812 sells_1h: 1,
6813 pair_created_at: None,
6814 url: None,
6815 },
6816 ];
6817
6818 let mut state = MonitorState::new(&token_data, "ethereum");
6819 state.update(&token_data);
6820
6821 assert_eq!(state.liquidity_pairs.len(), 2);
6822 assert!(state.liquidity_pairs[0].0.contains("Uniswap V3"));
6823 assert!((state.liquidity_pairs[0].1 - 250_000.0).abs() < 0.01);
6824 }
6825
6826 #[test]
6827 fn test_render_liquidity_depth_no_panic() {
6828 let mut terminal = create_test_terminal();
6829 let mut state = create_populated_state();
6830 state.liquidity_pairs = vec![
6831 ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
6832 ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
6833 ];
6834 terminal
6835 .draw(|f| render_liquidity_depth(f, f.area(), &state))
6836 .unwrap();
6837 }
6838
6839 #[test]
6840 fn test_render_liquidity_depth_empty() {
6841 let mut terminal = create_test_terminal();
6842 let state = create_populated_state();
6843 terminal
6844 .draw(|f| render_liquidity_depth(f, f.area(), &state))
6845 .unwrap();
6846 }
6847
6848 #[test]
6849 fn test_render_metrics_with_holder_count() {
6850 let mut terminal = create_test_terminal();
6851 let mut state = create_populated_state();
6852 state.holder_count = Some(42_000);
6853 terminal
6854 .draw(|f| render_metrics_panel(f, f.area(), &state))
6855 .unwrap();
6856 }
6857
6858 #[test]
6863 fn test_alert_config_default() {
6864 let config = AlertConfig::default();
6865 assert!(config.price_min.is_none());
6866 assert!(config.price_max.is_none());
6867 assert!(config.whale_min_usd.is_none());
6868 assert!(config.volume_spike_threshold_pct.is_none());
6869 }
6870
6871 #[test]
6872 fn test_alert_price_min_triggers() {
6873 let token_data = create_test_token_data();
6874 let mut state = MonitorState::new(&token_data, "ethereum");
6875 state.alerts.price_min = Some(2.0); state.update(&token_data);
6877 assert!(
6878 !state.active_alerts.is_empty(),
6879 "Should have price-min alert"
6880 );
6881 assert!(state.active_alerts[0].message.contains("below min"));
6882 }
6883
6884 #[test]
6885 fn test_alert_price_max_triggers() {
6886 let mut token_data = create_test_token_data();
6887 token_data.price_usd = 100.0;
6888 let mut state = MonitorState::new(&token_data, "ethereum");
6889 state.alerts.price_max = Some(50.0); state.update(&token_data);
6891 assert!(
6892 !state.active_alerts.is_empty(),
6893 "Should have price-max alert"
6894 );
6895 assert!(state.active_alerts[0].message.contains("above max"));
6896 }
6897
6898 #[test]
6899 fn test_alert_no_trigger_within_bounds() {
6900 let token_data = create_test_token_data();
6901 let mut state = MonitorState::new(&token_data, "ethereum");
6902 state.alerts.price_min = Some(0.5); state.alerts.price_max = Some(2.0); state.update(&token_data);
6905 assert!(
6906 state.active_alerts.is_empty(),
6907 "Should have no alerts when price is within bounds"
6908 );
6909 }
6910
6911 #[test]
6912 fn test_alert_volume_spike_triggers() {
6913 let token_data = create_test_token_data();
6914 let mut state = MonitorState::new(&token_data, "ethereum");
6915 state.alerts.volume_spike_threshold_pct = Some(10.0);
6916 state.volume_avg = 500_000.0; state.update(&token_data);
6920 let spike_alerts: Vec<_> = state
6921 .active_alerts
6922 .iter()
6923 .filter(|a| a.message.contains("spike"))
6924 .collect();
6925 assert!(!spike_alerts.is_empty(), "Should have volume spike alert");
6926 }
6927
6928 #[test]
6929 fn test_alert_flash_timer_set() {
6930 let token_data = create_test_token_data();
6931 let mut state = MonitorState::new(&token_data, "ethereum");
6932 state.alerts.price_min = Some(2.0);
6933 state.update(&token_data);
6934 assert!(state.alert_flash_until.is_some());
6935 }
6936
6937 #[test]
6938 fn test_render_alert_overlay_no_panic() {
6939 let mut terminal = create_test_terminal();
6940 let mut state = create_populated_state();
6941 state.active_alerts.push(ActiveAlert {
6942 message: "⚠ Test alert".to_string(),
6943 triggered_at: Instant::now(),
6944 });
6945 state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
6946 terminal
6947 .draw(|f| render_alert_overlay(f, f.area(), &state))
6948 .unwrap();
6949 }
6950
6951 #[test]
6952 fn test_render_alert_overlay_empty() {
6953 let mut terminal = create_test_terminal();
6954 let state = create_populated_state();
6955 terminal
6956 .draw(|f| render_alert_overlay(f, f.area(), &state))
6957 .unwrap();
6958 }
6959
6960 #[test]
6961 fn test_alert_config_serde_roundtrip() {
6962 let config = AlertConfig {
6963 price_min: Some(0.5),
6964 price_max: Some(2.0),
6965 whale_min_usd: Some(10_000.0),
6966 volume_spike_threshold_pct: Some(50.0),
6967 };
6968 let yaml = serde_yaml::to_string(&config).unwrap();
6969 let parsed: AlertConfig = serde_yaml::from_str(&yaml).unwrap();
6970 assert_eq!(parsed.price_min, Some(0.5));
6971 assert_eq!(parsed.price_max, Some(2.0));
6972 assert_eq!(parsed.whale_min_usd, Some(10_000.0));
6973 assert_eq!(parsed.volume_spike_threshold_pct, Some(50.0));
6974 }
6975
6976 #[test]
6977 fn test_ui_with_active_alerts() {
6978 let mut terminal = create_test_terminal();
6979 let mut state = create_populated_state();
6980 state.active_alerts.push(ActiveAlert {
6981 message: "⚠ Price below min".to_string(),
6982 triggered_at: Instant::now(),
6983 });
6984 state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
6985 terminal.draw(|f| ui(f, &mut state)).unwrap();
6986 }
6987
6988 #[test]
6993 fn test_export_config_default() {
6994 let config = ExportConfig::default();
6995 assert!(config.path.is_none());
6996 }
6997
6998 fn start_export_in_temp(state: &mut MonitorState) -> PathBuf {
7000 use std::sync::atomic::{AtomicU64, Ordering};
7001 static COUNTER: AtomicU64 = AtomicU64::new(0);
7002 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
7003 let dir =
7004 std::env::temp_dir().join(format!("scope_test_export_{}_{}", std::process::id(), id));
7005 let _ = fs::create_dir_all(&dir);
7006 let filename = format!("{}_test_{}.csv", state.symbol, id);
7007 let path = dir.join(filename);
7008
7009 let mut file = fs::File::create(&path).expect("failed to create export test file");
7010 let header = "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
7011 file.write_all(header.as_bytes())
7012 .expect("failed to write header");
7013 drop(file); state.export_path = Some(path.clone());
7016 state.export_active = true;
7017 path
7018 }
7019
7020 #[test]
7021 fn test_export_start_creates_file() {
7022 let token_data = create_test_token_data();
7023 let mut state = MonitorState::new(&token_data, "ethereum");
7024 let path = start_export_in_temp(&mut state);
7025
7026 assert!(state.export_active);
7027 assert!(state.export_path.is_some());
7028 assert!(path.exists(), "Export file should exist");
7029
7030 let _ = std::fs::remove_file(&path);
7032 }
7033
7034 #[test]
7035 fn test_export_stop() {
7036 let token_data = create_test_token_data();
7037 let mut state = MonitorState::new(&token_data, "ethereum");
7038 let path = start_export_in_temp(&mut state);
7039 state.stop_export();
7040
7041 assert!(!state.export_active);
7042 assert!(state.export_path.is_none());
7043
7044 let _ = std::fs::remove_file(&path);
7046 }
7047
7048 #[test]
7049 fn test_export_toggle() {
7050 let token_data = create_test_token_data();
7051 let mut state = MonitorState::new(&token_data, "ethereum");
7052
7053 state.toggle_export();
7054 assert!(state.export_active);
7055 let path = state.export_path.clone().unwrap();
7056
7057 state.toggle_export();
7058 assert!(!state.export_active);
7059
7060 let _ = std::fs::remove_file(path);
7062 }
7063
7064 #[test]
7065 fn test_export_writes_csv_rows() {
7066 let token_data = create_test_token_data();
7067 let mut state = MonitorState::new(&token_data, "ethereum");
7068 let path = start_export_in_temp(&mut state);
7069
7070 state.update(&token_data);
7072 state.update(&token_data);
7073
7074 let contents = std::fs::read_to_string(&path).unwrap();
7075 let lines: Vec<&str> = contents.lines().collect();
7076
7077 assert!(
7078 lines.len() >= 3,
7079 "Should have header + 2 data rows, got {}",
7080 lines.len()
7081 );
7082 assert!(lines[0].starts_with("timestamp,price_usd"));
7083
7084 state.stop_export();
7086 let _ = std::fs::remove_file(path);
7087 }
7088
7089 #[test]
7090 fn test_keybinding_e_toggles_export() {
7091 let token_data = create_test_token_data();
7092 let mut state = MonitorState::new(&token_data, "ethereum");
7093
7094 handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
7095 assert!(state.export_active);
7096 let path = state.export_path.clone().unwrap();
7097
7098 handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
7099 assert!(!state.export_active);
7100
7101 let _ = std::fs::remove_file(path);
7103 }
7104
7105 #[test]
7106 fn test_render_footer_with_export_active() {
7107 let mut terminal = create_test_terminal();
7108 let mut state = create_populated_state();
7109 state.export_active = true;
7110 terminal
7111 .draw(|f| render_footer(f, f.area(), &state))
7112 .unwrap();
7113 }
7114
7115 #[test]
7116 fn test_export_config_serde_roundtrip() {
7117 let config = ExportConfig {
7118 path: Some("./my-exports".to_string()),
7119 };
7120 let yaml = serde_yaml::to_string(&config).unwrap();
7121 let parsed: ExportConfig = serde_yaml::from_str(&yaml).unwrap();
7122 assert_eq!(parsed.path, Some("./my-exports".to_string()));
7123 }
7124
7125 #[test]
7130 fn test_auto_pause_default_disabled() {
7131 let token_data = create_test_token_data();
7132 let state = MonitorState::new(&token_data, "ethereum");
7133 assert!(!state.auto_pause_on_input);
7134 assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
7135 }
7136
7137 #[test]
7138 fn test_auto_pause_blocks_refresh() {
7139 let token_data = create_test_token_data();
7140 let mut state = MonitorState::new(&token_data, "ethereum");
7141 state.auto_pause_on_input = true;
7142 state.refresh_rate = Duration::from_secs(1);
7143
7144 state.last_input_at = Instant::now();
7146 state.last_update = Instant::now() - Duration::from_secs(10); assert!(!state.should_refresh());
7150 }
7151
7152 #[test]
7153 fn test_auto_pause_allows_refresh_after_timeout() {
7154 let token_data = create_test_token_data();
7155 let mut state = MonitorState::new(&token_data, "ethereum");
7156 state.auto_pause_on_input = true;
7157 state.refresh_rate = Duration::from_secs(1);
7158 state.auto_pause_timeout = Duration::from_millis(1); state.last_input_at = Instant::now() - Duration::from_secs(10);
7162 state.last_update = Instant::now() - Duration::from_secs(10);
7163
7164 assert!(state.should_refresh());
7166 }
7167
7168 #[test]
7169 fn test_auto_pause_disabled_does_not_block() {
7170 let token_data = create_test_token_data();
7171 let mut state = MonitorState::new(&token_data, "ethereum");
7172 state.auto_pause_on_input = false;
7173 state.refresh_rate = Duration::from_secs(1);
7174
7175 state.last_input_at = Instant::now(); state.last_update = Instant::now() - Duration::from_secs(10);
7177
7178 assert!(state.should_refresh());
7180 }
7181
7182 #[test]
7183 fn test_is_auto_paused() {
7184 let token_data = create_test_token_data();
7185 let mut state = MonitorState::new(&token_data, "ethereum");
7186
7187 state.auto_pause_on_input = false;
7189 state.last_input_at = Instant::now();
7190 assert!(!state.is_auto_paused());
7191
7192 state.auto_pause_on_input = true;
7194 state.last_input_at = Instant::now();
7195 assert!(state.is_auto_paused());
7196
7197 state.last_input_at = Instant::now() - Duration::from_secs(10);
7199 assert!(!state.is_auto_paused());
7200 }
7201
7202 #[test]
7203 fn test_keybinding_shift_p_toggles_auto_pause() {
7204 let token_data = create_test_token_data();
7205 let mut state = MonitorState::new(&token_data, "ethereum");
7206 assert!(!state.auto_pause_on_input);
7207
7208 let shift_p = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
7209 handle_key_event_on_state(shift_p, &mut state);
7210 assert!(state.auto_pause_on_input);
7211
7212 handle_key_event_on_state(shift_p, &mut state);
7213 assert!(!state.auto_pause_on_input);
7214 }
7215
7216 #[test]
7217 fn test_keybinding_updates_last_input_at() {
7218 let token_data = create_test_token_data();
7219 let mut state = MonitorState::new(&token_data, "ethereum");
7220
7221 state.last_input_at = Instant::now() - Duration::from_secs(60);
7223 let old_input = state.last_input_at;
7224
7225 handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
7227 assert!(state.last_input_at > old_input);
7228 }
7229
7230 #[test]
7231 fn test_render_footer_auto_paused() {
7232 let mut terminal = create_test_terminal();
7233 let mut state = create_populated_state();
7234 state.auto_pause_on_input = true;
7235 state.last_input_at = Instant::now(); terminal
7237 .draw(|f| render_footer(f, f.area(), &state))
7238 .unwrap();
7239 }
7240
7241 #[test]
7242 fn test_config_auto_pause_applied() {
7243 let mut state = create_populated_state();
7244 let config = MonitorConfig {
7245 auto_pause_on_input: true,
7246 ..MonitorConfig::default()
7247 };
7248 state.apply_config(&config);
7249 assert!(state.auto_pause_on_input);
7250 }
7251
7252 #[test]
7257 fn test_ui_render_all_layouts_with_alerts_and_export() {
7258 for preset in &[
7259 LayoutPreset::Dashboard,
7260 LayoutPreset::ChartFocus,
7261 LayoutPreset::Feed,
7262 LayoutPreset::Compact,
7263 ] {
7264 let mut terminal = create_test_terminal();
7265 let mut state = create_populated_state();
7266 state.layout = *preset;
7267 state.auto_layout = false;
7268 state.export_active = true;
7269 state.active_alerts.push(ActiveAlert {
7270 message: "⚠ Test alert".to_string(),
7271 triggered_at: Instant::now(),
7272 });
7273 terminal.draw(|f| ui(f, &mut state)).unwrap();
7274 }
7275 }
7276
7277 #[test]
7278 fn test_ui_render_with_liquidity_data() {
7279 let mut terminal = create_test_terminal();
7280 let mut state = create_populated_state();
7281 state.liquidity_pairs = vec![
7282 ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
7283 ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
7284 ];
7285 terminal.draw(|f| ui(f, &mut state)).unwrap();
7286 }
7287
7288 #[test]
7289 fn test_monitor_config_full_serde_roundtrip() {
7290 let config = MonitorConfig {
7291 layout: LayoutPreset::Dashboard,
7292 refresh_seconds: 10,
7293 widgets: WidgetVisibility::default(),
7294 scale: ScaleMode::Log,
7295 color_scheme: ColorScheme::BlueOrange,
7296 alerts: AlertConfig {
7297 price_min: Some(0.5),
7298 price_max: Some(10.0),
7299 whale_min_usd: Some(50_000.0),
7300 volume_spike_threshold_pct: Some(100.0),
7301 },
7302 export: ExportConfig {
7303 path: Some("./exports".to_string()),
7304 },
7305 auto_pause_on_input: true,
7306 venue: Some("binance".to_string()),
7307 };
7308
7309 let yaml = serde_yaml::to_string(&config).unwrap();
7310 let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
7311 assert_eq!(parsed.layout, LayoutPreset::Dashboard);
7312 assert_eq!(parsed.refresh_seconds, 10);
7313 assert_eq!(parsed.venue, Some("binance".to_string()));
7314 assert_eq!(parsed.alerts.price_min, Some(0.5));
7315 assert_eq!(parsed.alerts.price_max, Some(10.0));
7316 assert_eq!(parsed.export.path, Some("./exports".to_string()));
7317 assert!(parsed.auto_pause_on_input);
7318 }
7319
7320 #[test]
7321 fn test_monitor_config_serde_defaults_for_new_fields() {
7322 let yaml = r#"
7324layout: dashboard
7325refresh_seconds: 5
7326"#;
7327 let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
7328 assert!(config.alerts.price_min.is_none());
7329 assert!(config.export.path.is_none());
7330 assert!(!config.auto_pause_on_input);
7331 }
7332
7333 #[test]
7334 fn test_quit_stops_export() {
7335 let token_data = create_test_token_data();
7336 let mut state = MonitorState::new(&token_data, "ethereum");
7337 let path = start_export_in_temp(&mut state);
7338
7339 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('q')), &mut state);
7340 assert!(exit);
7341 assert!(!state.export_active);
7342
7343 let _ = std::fs::remove_file(path);
7345 }
7346
7347 #[test]
7348 fn test_monitor_state_new_has_alert_export_autopause_fields() {
7349 let token_data = create_test_token_data();
7350 let state = MonitorState::new(&token_data, "ethereum");
7351
7352 assert!(state.active_alerts.is_empty());
7354 assert!(state.alert_flash_until.is_none());
7355 assert!(state.alerts.price_min.is_none());
7356
7357 assert!(!state.export_active);
7359 assert!(state.export_path.is_none());
7360
7361 assert!(!state.auto_pause_on_input);
7363 assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
7364 }
7365
7366 #[test]
7371 fn test_scale_mode_serde_roundtrip() {
7372 for mode in &[ScaleMode::Linear, ScaleMode::Log] {
7373 let yaml = serde_yaml::to_string(mode).unwrap();
7374 let parsed: ScaleMode = serde_yaml::from_str(&yaml).unwrap();
7375 assert_eq!(&parsed, mode);
7376 }
7377 }
7378
7379 #[test]
7380 fn test_scale_mode_serde_kebab_case() {
7381 let parsed: ScaleMode = serde_yaml::from_str("linear").unwrap();
7382 assert_eq!(parsed, ScaleMode::Linear);
7383 let parsed: ScaleMode = serde_yaml::from_str("log").unwrap();
7384 assert_eq!(parsed, ScaleMode::Log);
7385 }
7386
7387 #[test]
7388 fn test_scale_mode_toggle() {
7389 assert_eq!(ScaleMode::Linear.toggle(), ScaleMode::Log);
7390 assert_eq!(ScaleMode::Log.toggle(), ScaleMode::Linear);
7391 }
7392
7393 #[test]
7394 fn test_scale_mode_label() {
7395 assert_eq!(ScaleMode::Linear.label(), "Lin");
7396 assert_eq!(ScaleMode::Log.label(), "Log");
7397 }
7398
7399 #[test]
7400 fn test_color_scheme_serde_roundtrip() {
7401 for scheme in &[
7402 ColorScheme::GreenRed,
7403 ColorScheme::BlueOrange,
7404 ColorScheme::Monochrome,
7405 ] {
7406 let yaml = serde_yaml::to_string(scheme).unwrap();
7407 let parsed: ColorScheme = serde_yaml::from_str(&yaml).unwrap();
7408 assert_eq!(&parsed, scheme);
7409 }
7410 }
7411
7412 #[test]
7413 fn test_color_scheme_serde_kebab_case() {
7414 let parsed: ColorScheme = serde_yaml::from_str("green-red").unwrap();
7415 assert_eq!(parsed, ColorScheme::GreenRed);
7416 let parsed: ColorScheme = serde_yaml::from_str("blue-orange").unwrap();
7417 assert_eq!(parsed, ColorScheme::BlueOrange);
7418 let parsed: ColorScheme = serde_yaml::from_str("monochrome").unwrap();
7419 assert_eq!(parsed, ColorScheme::Monochrome);
7420 }
7421
7422 #[test]
7423 fn test_color_scheme_cycle() {
7424 assert_eq!(ColorScheme::GreenRed.next(), ColorScheme::BlueOrange);
7425 assert_eq!(ColorScheme::BlueOrange.next(), ColorScheme::Monochrome);
7426 assert_eq!(ColorScheme::Monochrome.next(), ColorScheme::GreenRed);
7427 }
7428
7429 #[test]
7430 fn test_color_scheme_label() {
7431 assert_eq!(ColorScheme::GreenRed.label(), "G/R");
7432 assert_eq!(ColorScheme::BlueOrange.label(), "B/O");
7433 assert_eq!(ColorScheme::Monochrome.label(), "Mono");
7434 }
7435
7436 #[test]
7437 fn test_color_palette_fields_populated() {
7438 for scheme in &[
7440 ColorScheme::GreenRed,
7441 ColorScheme::BlueOrange,
7442 ColorScheme::Monochrome,
7443 ] {
7444 let pal = scheme.palette();
7445 assert_ne!(
7447 format!("{:?}", pal.up),
7448 format!("{:?}", pal.down),
7449 "Up/down should differ for {:?}",
7450 scheme
7451 );
7452 }
7453 }
7454
7455 #[test]
7456 fn test_layout_preset_serde_roundtrip() {
7457 for preset in &[
7458 LayoutPreset::Dashboard,
7459 LayoutPreset::ChartFocus,
7460 LayoutPreset::Feed,
7461 LayoutPreset::Compact,
7462 ] {
7463 let yaml = serde_yaml::to_string(preset).unwrap();
7464 let parsed: LayoutPreset = serde_yaml::from_str(&yaml).unwrap();
7465 assert_eq!(&parsed, preset);
7466 }
7467 }
7468
7469 #[test]
7470 fn test_layout_preset_serde_kebab_case() {
7471 let parsed: LayoutPreset = serde_yaml::from_str("dashboard").unwrap();
7472 assert_eq!(parsed, LayoutPreset::Dashboard);
7473 let parsed: LayoutPreset = serde_yaml::from_str("chart-focus").unwrap();
7474 assert_eq!(parsed, LayoutPreset::ChartFocus);
7475 let parsed: LayoutPreset = serde_yaml::from_str("feed").unwrap();
7476 assert_eq!(parsed, LayoutPreset::Feed);
7477 let parsed: LayoutPreset = serde_yaml::from_str("compact").unwrap();
7478 assert_eq!(parsed, LayoutPreset::Compact);
7479 }
7480
7481 #[test]
7482 fn test_widget_visibility_serde_roundtrip() {
7483 let vis = WidgetVisibility {
7484 price_chart: false,
7485 volume_chart: true,
7486 buy_sell_pressure: false,
7487 metrics_panel: true,
7488 activity_log: false,
7489 holder_count: false,
7490 liquidity_depth: true,
7491 };
7492 let yaml = serde_yaml::to_string(&vis).unwrap();
7493 let parsed: WidgetVisibility = serde_yaml::from_str(&yaml).unwrap();
7494 assert!(!parsed.price_chart);
7495 assert!(parsed.volume_chart);
7496 assert!(!parsed.buy_sell_pressure);
7497 assert!(parsed.metrics_panel);
7498 assert!(!parsed.activity_log);
7499 assert!(!parsed.holder_count);
7500 assert!(parsed.liquidity_depth);
7501 }
7502
7503 #[test]
7504 fn test_data_point_serde_roundtrip() {
7505 let dp = DataPoint {
7506 timestamp: 1700000000.5,
7507 value: 42.123456,
7508 is_real: true,
7509 };
7510 let json = serde_json::to_string(&dp).unwrap();
7511 let parsed: DataPoint = serde_json::from_str(&json).unwrap();
7512 assert!((parsed.timestamp - dp.timestamp).abs() < 0.001);
7513 assert!((parsed.value - dp.value).abs() < 0.001);
7514 assert_eq!(parsed.is_real, dp.is_real);
7515 }
7516
7517 #[test]
7522 fn test_handle_key_scale_toggle_s() {
7523 let token_data = create_test_token_data();
7524 let mut state = MonitorState::new(&token_data, "ethereum");
7525 assert_eq!(state.scale_mode, ScaleMode::Linear);
7526
7527 handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
7528 assert_eq!(state.scale_mode, ScaleMode::Log);
7529
7530 handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
7531 assert_eq!(state.scale_mode, ScaleMode::Linear);
7532 }
7533
7534 #[test]
7535 fn test_handle_key_color_scheme_cycle_slash() {
7536 let token_data = create_test_token_data();
7537 let mut state = MonitorState::new(&token_data, "ethereum");
7538 assert_eq!(state.color_scheme, ColorScheme::GreenRed);
7539
7540 handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
7541 assert_eq!(state.color_scheme, ColorScheme::BlueOrange);
7542
7543 handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
7544 assert_eq!(state.color_scheme, ColorScheme::Monochrome);
7545
7546 handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
7547 assert_eq!(state.color_scheme, ColorScheme::GreenRed);
7548 }
7549
7550 #[test]
7555 fn test_render_volume_profile_chart_no_panic() {
7556 let mut terminal = create_test_terminal();
7557 let state = create_populated_state();
7558 terminal
7559 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7560 .unwrap();
7561 }
7562
7563 #[test]
7564 fn test_render_volume_profile_chart_empty_data() {
7565 let mut terminal = create_test_terminal();
7566 let token_data = create_test_token_data();
7567 let mut state = MonitorState::new(&token_data, "ethereum");
7568 state.price_history.clear();
7569 state.volume_history.clear();
7570 terminal
7571 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7572 .unwrap();
7573 }
7574
7575 #[test]
7576 fn test_render_volume_profile_chart_single_price() {
7577 let mut terminal = create_test_terminal();
7579 let mut token_data = create_test_token_data();
7580 token_data.price_usd = 1.0;
7581 let mut state = MonitorState::new(&token_data, "ethereum");
7582 state.price_history.clear();
7584 state.volume_history.clear();
7585 let now = chrono::Utc::now().timestamp() as f64;
7586 for i in 0..5 {
7587 state.price_history.push_back(DataPoint {
7588 timestamp: now - (5.0 - i as f64) * 60.0,
7589 value: 1.0, is_real: true,
7591 });
7592 state.volume_history.push_back(DataPoint {
7593 timestamp: now - (5.0 - i as f64) * 60.0,
7594 value: 1000.0,
7595 is_real: true,
7596 });
7597 }
7598 terminal
7599 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7600 .unwrap();
7601 }
7602
7603 #[test]
7604 fn test_render_volume_profile_chart_narrow_terminal() {
7605 let backend = TestBackend::new(30, 15);
7606 let mut terminal = Terminal::new(backend).unwrap();
7607 let state = create_populated_state();
7608 terminal
7609 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
7610 .unwrap();
7611 }
7612
7613 #[test]
7618 fn test_render_price_chart_log_scale() {
7619 let mut terminal = create_test_terminal();
7620 let mut state = create_populated_state();
7621 state.scale_mode = ScaleMode::Log;
7622 terminal
7623 .draw(|f| render_price_chart(f, f.area(), &state))
7624 .unwrap();
7625 }
7626
7627 #[test]
7628 fn test_render_candlestick_chart_log_scale() {
7629 let mut terminal = create_test_terminal();
7630 let mut state = create_populated_state();
7631 state.scale_mode = ScaleMode::Log;
7632 state.chart_mode = ChartMode::Candlestick;
7633 terminal
7634 .draw(|f| render_candlestick_chart(f, f.area(), &state))
7635 .unwrap();
7636 }
7637
7638 #[test]
7639 fn test_render_price_chart_log_scale_zero_price() {
7640 let mut terminal = create_test_terminal();
7642 let mut token_data = create_test_token_data();
7643 token_data.price_usd = 0.0001;
7644 let mut state = MonitorState::new(&token_data, "ethereum");
7645 state.scale_mode = ScaleMode::Log;
7646 for i in 0..10 {
7647 let mut data = token_data.clone();
7648 data.price_usd = 0.0001 + (i as f64 * 0.00001);
7649 state.update(&data);
7650 }
7651 terminal
7652 .draw(|f| render_price_chart(f, f.area(), &state))
7653 .unwrap();
7654 }
7655
7656 #[test]
7661 fn test_render_ui_with_all_color_schemes() {
7662 for scheme in &[
7663 ColorScheme::GreenRed,
7664 ColorScheme::BlueOrange,
7665 ColorScheme::Monochrome,
7666 ] {
7667 let mut terminal = create_test_terminal();
7668 let mut state = create_populated_state();
7669 state.color_scheme = *scheme;
7670 terminal.draw(|f| ui(f, &mut state)).unwrap();
7671 }
7672 }
7673
7674 #[test]
7675 fn test_render_volume_chart_all_color_schemes() {
7676 for scheme in &[
7677 ColorScheme::GreenRed,
7678 ColorScheme::BlueOrange,
7679 ColorScheme::Monochrome,
7680 ] {
7681 let mut terminal = create_test_terminal();
7682 let mut state = create_populated_state();
7683 state.color_scheme = *scheme;
7684 terminal
7685 .draw(|f| render_volume_chart(f, f.area(), &state))
7686 .unwrap();
7687 }
7688 }
7689
7690 #[test]
7695 fn test_render_activity_feed_no_panic() {
7696 let mut terminal = create_test_terminal();
7697 let mut state = create_populated_state();
7698 for i in 0..5 {
7699 state.log_messages.push_back(format!("Event {}", i));
7700 }
7701 terminal
7702 .draw(|f| render_activity_feed(f, f.area(), &mut state))
7703 .unwrap();
7704 }
7705
7706 #[test]
7707 fn test_render_activity_feed_empty_log() {
7708 let mut terminal = create_test_terminal();
7709 let token_data = create_test_token_data();
7710 let mut state = MonitorState::new(&token_data, "ethereum");
7711 state.log_messages.clear();
7712 terminal
7713 .draw(|f| render_activity_feed(f, f.area(), &mut state))
7714 .unwrap();
7715 }
7716
7717 #[test]
7718 fn test_render_activity_feed_with_selection() {
7719 let mut terminal = create_test_terminal();
7720 let mut state = create_populated_state();
7721 for i in 0..10 {
7722 state.log_messages.push_back(format!("Event {}", i));
7723 }
7724 state.scroll_log_down();
7725 state.scroll_log_down();
7726 state.scroll_log_down();
7727 terminal
7728 .draw(|f| render_activity_feed(f, f.area(), &mut state))
7729 .unwrap();
7730 }
7731
7732 #[test]
7737 fn test_alert_whale_zero_transactions() {
7738 let mut token_data = create_test_token_data();
7739 token_data.total_buys_24h = 0;
7740 token_data.total_sells_24h = 0;
7741 let mut state = MonitorState::new(&token_data, "ethereum");
7742 state.alerts.whale_min_usd = Some(100.0);
7743 state.update(&token_data);
7744 let whale_alerts: Vec<_> = state
7746 .active_alerts
7747 .iter()
7748 .filter(|a| a.message.contains("whale") || a.message.contains("🐋"))
7749 .collect();
7750 assert!(
7751 whale_alerts.is_empty(),
7752 "Whale alert should not fire with zero transactions"
7753 );
7754 }
7755
7756 #[test]
7757 fn test_alert_multiple_simultaneous() {
7758 let mut token_data = create_test_token_data();
7759 token_data.price_usd = 0.1; let mut state = MonitorState::new(&token_data, "ethereum");
7761 state.alerts.price_min = Some(0.5); state.alerts.price_max = Some(0.05); state.alerts.volume_spike_threshold_pct = Some(1.0);
7764 state.volume_avg = 100.0; state.update(&token_data);
7767 assert!(
7769 state.active_alerts.len() >= 2,
7770 "Expected multiple alerts, got {}",
7771 state.active_alerts.len()
7772 );
7773 }
7774
7775 #[test]
7776 fn test_alert_clears_on_next_update() {
7777 let token_data = create_test_token_data();
7778 let mut state = MonitorState::new(&token_data, "ethereum");
7779 state.alerts.price_min = Some(2.0); state.update(&token_data);
7781 assert!(!state.active_alerts.is_empty());
7782
7783 let mut above_min = token_data.clone();
7785 above_min.price_usd = 3.0;
7786 state.alerts.price_min = Some(2.0);
7787 state.update(&above_min);
7788 let price_min_alerts: Vec<_> = state
7790 .active_alerts
7791 .iter()
7792 .filter(|a| a.message.contains("below min"))
7793 .collect();
7794 assert!(
7795 price_min_alerts.is_empty(),
7796 "Price-min alert should clear when price goes above min"
7797 );
7798 }
7799
7800 #[test]
7801 fn test_render_alert_overlay_multiple_alerts() {
7802 let mut terminal = create_test_terminal();
7803 let mut state = create_populated_state();
7804 state.active_alerts.push(ActiveAlert {
7805 message: "⚠ Price below min".to_string(),
7806 triggered_at: Instant::now(),
7807 });
7808 state.active_alerts.push(ActiveAlert {
7809 message: "🐋 Whale detected".to_string(),
7810 triggered_at: Instant::now(),
7811 });
7812 state.active_alerts.push(ActiveAlert {
7813 message: "⚠ Volume spike".to_string(),
7814 triggered_at: Instant::now(),
7815 });
7816 state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
7817 terminal
7818 .draw(|f| render_alert_overlay(f, f.area(), &state))
7819 .unwrap();
7820 }
7821
7822 #[test]
7823 fn test_render_alert_overlay_flash_expired() {
7824 let mut terminal = create_test_terminal();
7825 let mut state = create_populated_state();
7826 state.active_alerts.push(ActiveAlert {
7827 message: "⚠ Test".to_string(),
7828 triggered_at: Instant::now(),
7829 });
7830 state.alert_flash_until = Some(Instant::now() - Duration::from_secs(5));
7832 terminal
7833 .draw(|f| render_alert_overlay(f, f.area(), &state))
7834 .unwrap();
7835 }
7836
7837 #[test]
7842 fn test_render_liquidity_depth_many_pairs() {
7843 let mut terminal = create_test_terminal();
7844 let mut state = create_populated_state();
7845 for i in 0..20 {
7847 state.liquidity_pairs.push((
7848 format!("TEST/TOKEN{} (DEX{})", i, i),
7849 (100_000.0 + i as f64 * 50_000.0),
7850 ));
7851 }
7852 terminal
7853 .draw(|f| render_liquidity_depth(f, f.area(), &state))
7854 .unwrap();
7855 }
7856
7857 #[test]
7858 fn test_render_liquidity_depth_narrow_terminal() {
7859 let backend = TestBackend::new(30, 10);
7860 let mut terminal = Terminal::new(backend).unwrap();
7861 let mut state = create_populated_state();
7862 state.liquidity_pairs = vec![
7863 ("TEST/WETH (Uniswap)".to_string(), 500_000.0),
7864 ("TEST/USDC (Sushi)".to_string(), 100_000.0),
7865 ];
7866 terminal
7867 .draw(|f| render_liquidity_depth(f, f.area(), &state))
7868 .unwrap();
7869 }
7870
7871 #[test]
7876 fn test_render_metrics_panel_holder_count_disabled() {
7877 let mut terminal = create_test_terminal();
7878 let mut state = create_populated_state();
7879 state.holder_count = Some(42_000);
7880 state.widgets.holder_count = false; terminal
7882 .draw(|f| render_metrics_panel(f, f.area(), &state))
7883 .unwrap();
7884 }
7885
7886 #[test]
7887 fn test_render_metrics_panel_sparkline_single_point() {
7888 let mut terminal = create_test_terminal();
7889 let mut token_data = create_test_token_data();
7890 token_data.price_usd = 1.0;
7891 let mut state = MonitorState::new(&token_data, "ethereum");
7892 state.price_history.clear();
7893 state.price_history.push_back(DataPoint {
7894 timestamp: 1.0,
7895 value: 1.0,
7896 is_real: true,
7897 });
7898 terminal
7899 .draw(|f| render_metrics_panel(f, f.area(), &state))
7900 .unwrap();
7901 }
7902
7903 #[test]
7908 fn test_render_buy_sell_gauge_tiny_area() {
7909 let backend = TestBackend::new(5, 3);
7911 let mut terminal = Terminal::new(backend).unwrap();
7912 let mut state = create_populated_state();
7913 terminal
7914 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
7915 .unwrap();
7916 }
7917
7918 #[test]
7923 fn test_log_message_queue_overflow() {
7924 let token_data = create_test_token_data();
7925 let mut state = MonitorState::new(&token_data, "ethereum");
7926 for i in 0..20 {
7928 state.toggle_pause(); let _ = i;
7930 }
7931 assert!(
7932 state.log_messages.len() <= 10,
7933 "Log queue should cap at 10, got {}",
7934 state.log_messages.len()
7935 );
7936 }
7937
7938 #[test]
7943 fn test_export_writes_csv_row_content_format() {
7944 let token_data = create_test_token_data();
7945 let mut state = MonitorState::new(&token_data, "ethereum");
7946 let path = start_export_in_temp(&mut state);
7947
7948 state.update(&token_data);
7949
7950 let contents = std::fs::read_to_string(&path).unwrap();
7951 let lines: Vec<&str> = contents.lines().collect();
7952 assert!(lines.len() >= 2);
7953
7954 assert_eq!(
7956 lines[0],
7957 "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap"
7958 );
7959
7960 let data_cols: Vec<&str> = lines[1].split(',').collect();
7962 assert_eq!(
7963 data_cols.len(),
7964 7,
7965 "Expected 7 CSV columns, got {}",
7966 data_cols.len()
7967 );
7968
7969 assert!(data_cols[0].contains('T'));
7971 assert!(data_cols[0].ends_with('Z'));
7972
7973 state.stop_export();
7975 let _ = std::fs::remove_file(path);
7976 }
7977
7978 #[test]
7979 fn test_export_writes_csv_row_market_cap_none() {
7980 let mut token_data = create_test_token_data();
7981 token_data.market_cap = None;
7982 let mut state = MonitorState::new(&token_data, "ethereum");
7983 let path = start_export_in_temp(&mut state);
7984
7985 state.update(&token_data);
7986
7987 let contents = std::fs::read_to_string(&path).unwrap();
7988 let lines: Vec<&str> = contents.lines().collect();
7989 assert!(lines.len() >= 2);
7990
7991 let data_cols: Vec<&str> = lines[1].split(',').collect();
7993 assert_eq!(data_cols.len(), 7);
7994 assert!(
7995 data_cols[6].is_empty(),
7996 "Market cap column should be empty when None"
7997 );
7998
7999 state.stop_export();
8001 let _ = std::fs::remove_file(path);
8002 }
8003
8004 #[test]
8009 fn test_ui_render_log_scale_all_chart_modes() {
8010 for mode in &[
8011 ChartMode::Line,
8012 ChartMode::Candlestick,
8013 ChartMode::VolumeProfile,
8014 ] {
8015 let mut terminal = create_test_terminal();
8016 let mut state = create_populated_state();
8017 state.scale_mode = ScaleMode::Log;
8018 state.chart_mode = *mode;
8019 terminal.draw(|f| ui(f, &mut state)).unwrap();
8020 }
8021 }
8022
8023 #[test]
8028 fn test_render_footer_widget_toggle_mode_active() {
8029 let mut terminal = create_test_terminal();
8030 let mut state = create_populated_state();
8031 state.widget_toggle_mode = true;
8032 terminal
8033 .draw(|f| render_footer(f, f.area(), &state))
8034 .unwrap();
8035 }
8036
8037 #[test]
8038 fn test_render_footer_all_status_indicators() {
8039 let mut terminal = create_test_terminal();
8041 let mut state = create_populated_state();
8042 state.export_active = true;
8043 state.auto_pause_on_input = true;
8044 state.last_input_at = Instant::now(); terminal
8046 .draw(|f| render_footer(f, f.area(), &state))
8047 .unwrap();
8048 }
8049
8050 #[test]
8055 fn test_generate_synthetic_price_history_zero_price() {
8056 let mut token_data = create_test_token_data();
8057 token_data.price_usd = 0.0;
8058 token_data.price_change_1h = 0.0;
8059 token_data.price_change_6h = 0.0;
8060 token_data.price_change_24h = 0.0;
8061 let state = MonitorState::new(&token_data, "ethereum");
8062 assert!(!state.price_history.is_empty());
8064 }
8065
8066 #[test]
8067 fn test_generate_synthetic_volume_history_zero_volume() {
8068 let mut token_data = create_test_token_data();
8069 token_data.volume_24h = 0.0;
8070 token_data.volume_6h = 0.0;
8071 token_data.volume_1h = 0.0;
8072 let state = MonitorState::new(&token_data, "ethereum");
8073 assert!(!state.volume_history.is_empty());
8074 }
8075
8076 #[test]
8077 fn test_generate_synthetic_order_book() {
8078 let pairs = vec![crate::chains::DexPair {
8079 dex_name: "Uniswap V3".to_string(),
8080 pair_address: "0xabc".to_string(),
8081 base_token: "PUSD".to_string(),
8082 quote_token: "USDT".to_string(),
8083 price_usd: 1.0,
8084 volume_24h: 50_000.0,
8085 liquidity_usd: 200_000.0,
8086 price_change_24h: 0.1,
8087 buys_24h: 100,
8088 sells_24h: 90,
8089 buys_6h: 30,
8090 sells_6h: 25,
8091 buys_1h: 10,
8092 sells_1h: 8,
8093 pair_created_at: None,
8094 url: None,
8095 }];
8096 let book = MonitorState::generate_synthetic_order_book(&pairs, "PUSD", 1.0, 200_000.0);
8097 assert!(book.is_some());
8098 let book = book.unwrap();
8099 assert_eq!(book.pair, "PUSD/USDT");
8100 assert!(!book.asks.is_empty());
8101 assert!(!book.bids.is_empty());
8102 for w in book.asks.windows(2) {
8104 assert!(w[0].price <= w[1].price);
8105 }
8106 for w in book.bids.windows(2) {
8108 assert!(w[0].price >= w[1].price);
8109 }
8110 }
8111
8112 #[test]
8113 fn test_generate_synthetic_order_book_zero_price() {
8114 let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 0.0, 100_000.0);
8115 assert!(book.is_none());
8116 }
8117
8118 #[test]
8119 fn test_generate_synthetic_order_book_zero_liquidity() {
8120 let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 1.0, 0.0);
8121 assert!(book.is_none());
8122 }
8123
8124 #[test]
8129 fn test_auto_pause_custom_timeout() {
8130 let token_data = create_test_token_data();
8131 let mut state = MonitorState::new(&token_data, "ethereum");
8132 state.auto_pause_on_input = true;
8133 state.auto_pause_timeout = Duration::from_secs(10);
8134 state.refresh_rate = Duration::from_secs(1);
8135
8136 state.last_input_at = Instant::now();
8138 state.last_update = Instant::now() - Duration::from_secs(5);
8139 assert!(!state.should_refresh()); assert!(state.is_auto_paused());
8141 }
8142
8143 #[test]
8148 fn test_render_price_chart_stablecoin_flat_range() {
8149 let mut terminal = create_test_terminal();
8150 let mut token_data = create_test_token_data();
8151 token_data.price_usd = 1.0;
8152 let mut state = MonitorState::new(&token_data, "ethereum");
8153 for i in 0..20 {
8155 let mut data = token_data.clone();
8156 data.price_usd = 1.0 + (i as f64 * 0.000001); state.update(&data);
8158 }
8159 terminal
8160 .draw(|f| render_price_chart(f, f.area(), &state))
8161 .unwrap();
8162 }
8163
8164 #[test]
8169 fn test_load_cache_corrupted_json() {
8170 let path = MonitorState::cache_path("0xCORRUPTED_TEST", "test_chain");
8171 let _ = std::fs::write(&path, "not valid json {{{");
8173 let cached = MonitorState::load_cache("0xCORRUPTED_TEST", "test_chain");
8174 assert!(cached.is_none(), "Corrupted JSON should return None");
8175 let _ = std::fs::remove_file(path);
8176 }
8177
8178 #[test]
8179 fn test_load_cache_wrong_token() {
8180 let token_data = create_test_token_data();
8181 let state = MonitorState::new(&token_data, "ethereum");
8182 state.save_cache();
8183
8184 let cached = MonitorState::load_cache("0xDIFFERENT_ADDRESS", "ethereum");
8186 assert!(
8187 cached.is_none(),
8188 "Loading cache with wrong token address should return None"
8189 );
8190
8191 let path = MonitorState::cache_path(&token_data.address, "ethereum");
8193 let _ = std::fs::remove_file(path);
8194 }
8195
8196 use crate::chains::dex::TokenSearchResult;
8201
8202 struct MockDexDataSource {
8204 token_data_result: std::sync::Mutex<Result<DexTokenData>>,
8206 }
8207
8208 impl MockDexDataSource {
8209 fn new(data: DexTokenData) -> Self {
8210 Self {
8211 token_data_result: std::sync::Mutex::new(Ok(data)),
8212 }
8213 }
8214
8215 fn failing(msg: &str) -> Self {
8216 Self {
8217 token_data_result: std::sync::Mutex::new(Err(ScopeError::Api(msg.to_string()))),
8218 }
8219 }
8220 }
8221
8222 #[async_trait::async_trait]
8223 impl DexDataSource for MockDexDataSource {
8224 async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
8225 self.token_data_result
8226 .lock()
8227 .unwrap()
8228 .as_ref()
8229 .ok()
8230 .map(|d| d.price_usd)
8231 }
8232
8233 async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
8234 Some(2000.0)
8235 }
8236
8237 async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
8238 let guard = self.token_data_result.lock().unwrap();
8239 match &*guard {
8240 Ok(data) => Ok(data.clone()),
8241 Err(e) => Err(ScopeError::Api(e.to_string())),
8242 }
8243 }
8244
8245 async fn search_tokens(
8246 &self,
8247 _query: &str,
8248 _chain: Option<&str>,
8249 ) -> Result<Vec<TokenSearchResult>> {
8250 Ok(vec![])
8251 }
8252 }
8253
8254 struct MockChainClient {
8256 holder_count: u64,
8257 }
8258
8259 impl MockChainClient {
8260 fn new(holder_count: u64) -> Self {
8261 Self { holder_count }
8262 }
8263 }
8264
8265 #[async_trait::async_trait]
8266 impl ChainClient for MockChainClient {
8267 fn chain_name(&self) -> &str {
8268 "ethereum"
8269 }
8270 fn native_token_symbol(&self) -> &str {
8271 "ETH"
8272 }
8273 async fn get_balance(&self, _address: &str) -> Result<crate::chains::Balance> {
8274 unimplemented!("not needed for monitor tests")
8275 }
8276 async fn enrich_balance_usd(&self, _balance: &mut crate::chains::Balance) {}
8277 async fn get_transaction(&self, _hash: &str) -> Result<crate::chains::Transaction> {
8278 unimplemented!("not needed for monitor tests")
8279 }
8280 async fn get_transactions(
8281 &self,
8282 _address: &str,
8283 _limit: u32,
8284 ) -> Result<Vec<crate::chains::Transaction>> {
8285 Ok(vec![])
8286 }
8287 async fn get_block_number(&self) -> Result<u64> {
8288 Ok(1000000)
8289 }
8290 async fn get_token_balances(
8291 &self,
8292 _address: &str,
8293 ) -> Result<Vec<crate::chains::TokenBalance>> {
8294 Ok(vec![])
8295 }
8296 async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
8297 Ok(self.holder_count)
8298 }
8299 }
8300
8301 fn create_test_app(
8303 dex: Box<dyn DexDataSource>,
8304 chain_client: Option<Box<dyn ChainClient>>,
8305 ) -> MonitorApp<TestBackend> {
8306 let token_data = create_test_token_data();
8307 let state = MonitorState::new(&token_data, "ethereum");
8308 let backend = TestBackend::new(120, 40);
8309 let terminal = ratatui::Terminal::new(backend).unwrap();
8310 MonitorApp {
8311 terminal,
8312 state,
8313 dex_client: dex,
8314 chain_client,
8315 exchange_client: None,
8316 should_exit: false,
8317 owns_terminal: false,
8318 }
8319 }
8320
8321 fn create_test_app_with_state(
8322 state: MonitorState,
8323 dex: Box<dyn DexDataSource>,
8324 chain_client: Option<Box<dyn ChainClient>>,
8325 ) -> MonitorApp<TestBackend> {
8326 let backend = TestBackend::new(120, 40);
8327 let terminal = ratatui::Terminal::new(backend).unwrap();
8328 MonitorApp {
8329 terminal,
8330 state,
8331 dex_client: dex,
8332 chain_client,
8333 exchange_client: None,
8334 should_exit: false,
8335 owns_terminal: false,
8336 }
8337 }
8338
8339 #[test]
8344 fn test_app_handle_key_quit_q() {
8345 let data = create_test_token_data();
8346 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8347 assert!(!app.should_exit);
8348 app.handle_key_event(make_key_event(KeyCode::Char('q')));
8349 assert!(app.should_exit);
8350 }
8351
8352 #[test]
8353 fn test_app_handle_key_quit_esc() {
8354 let data = create_test_token_data();
8355 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8356 app.handle_key_event(make_key_event(KeyCode::Esc));
8357 assert!(app.should_exit);
8358 }
8359
8360 #[test]
8361 fn test_app_handle_key_quit_ctrl_c() {
8362 let data = create_test_token_data();
8363 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8364 let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
8365 app.handle_key_event(key);
8366 assert!(app.should_exit);
8367 }
8368
8369 #[test]
8370 fn test_app_handle_key_quit_stops_active_export() {
8371 let data = create_test_token_data();
8372 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8373 let path = start_export_in_temp(&mut app.state);
8374 assert!(app.state.export_active);
8375
8376 app.handle_key_event(make_key_event(KeyCode::Char('q')));
8377 assert!(app.should_exit);
8378 assert!(!app.state.export_active);
8379 let _ = std::fs::remove_file(path);
8380 }
8381
8382 #[test]
8383 fn test_app_handle_key_ctrl_c_stops_active_export() {
8384 let data = create_test_token_data();
8385 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8386 let path = start_export_in_temp(&mut app.state);
8387 assert!(app.state.export_active);
8388
8389 let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
8390 app.handle_key_event(key);
8391 assert!(app.should_exit);
8392 assert!(!app.state.export_active);
8393 let _ = std::fs::remove_file(path);
8394 }
8395
8396 #[test]
8397 fn test_app_handle_key_updates_last_input_time() {
8398 let data = create_test_token_data();
8399 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8400 let before = Instant::now();
8401 app.handle_key_event(make_key_event(KeyCode::Char('p')));
8402 assert!(app.state.last_input_at >= before);
8403 }
8404
8405 #[test]
8406 fn test_app_handle_key_widget_toggle_mode() {
8407 let data = create_test_token_data();
8408 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8409 assert!(app.state.widgets.price_chart);
8410
8411 app.handle_key_event(make_key_event(KeyCode::Char('w')));
8413 assert!(app.state.widget_toggle_mode);
8414
8415 app.handle_key_event(make_key_event(KeyCode::Char('1')));
8417 assert!(!app.state.widget_toggle_mode);
8418 assert!(!app.state.widgets.price_chart);
8419 }
8420
8421 #[test]
8422 fn test_app_handle_key_widget_toggle_mode_cancel() {
8423 let data = create_test_token_data();
8424 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8425
8426 app.handle_key_event(make_key_event(KeyCode::Char('w')));
8428 assert!(app.state.widget_toggle_mode);
8429
8430 app.handle_key_event(make_key_event(KeyCode::Char('x')));
8432 assert!(!app.state.widget_toggle_mode);
8433 }
8434
8435 #[test]
8436 fn test_app_handle_key_all_keybindings() {
8437 let data = create_test_token_data();
8438 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8439
8440 app.handle_key_event(make_key_event(KeyCode::Char('r')));
8442 assert!(!app.should_exit);
8443
8444 let key = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
8446 app.handle_key_event(key);
8447 assert!(app.state.auto_pause_on_input);
8448 app.handle_key_event(key);
8449 assert!(!app.state.auto_pause_on_input);
8450
8451 app.handle_key_event(make_key_event(KeyCode::Char('p')));
8453 assert!(app.state.paused);
8454
8455 app.handle_key_event(make_key_event(KeyCode::Char(' ')));
8457 assert!(!app.state.paused);
8458
8459 app.handle_key_event(make_key_event(KeyCode::Char('e')));
8461 assert!(app.state.export_active);
8462 app.state.stop_export();
8464
8465 let before_rate = app.state.refresh_rate;
8467 app.handle_key_event(make_key_event(KeyCode::Char('+')));
8468 assert!(app.state.refresh_rate >= before_rate);
8469
8470 let before_rate = app.state.refresh_rate;
8472 app.handle_key_event(make_key_event(KeyCode::Char('-')));
8473 assert!(app.state.refresh_rate <= before_rate);
8474
8475 app.handle_key_event(make_key_event(KeyCode::Char('1')));
8477 assert_eq!(app.state.time_period, TimePeriod::Min1);
8478 app.handle_key_event(make_key_event(KeyCode::Char('2')));
8479 assert_eq!(app.state.time_period, TimePeriod::Min5);
8480 app.handle_key_event(make_key_event(KeyCode::Char('3')));
8481 assert_eq!(app.state.time_period, TimePeriod::Min15);
8482 app.handle_key_event(make_key_event(KeyCode::Char('4')));
8483 assert_eq!(app.state.time_period, TimePeriod::Hour1);
8484 app.handle_key_event(make_key_event(KeyCode::Char('5')));
8485 assert_eq!(app.state.time_period, TimePeriod::Hour4);
8486 app.handle_key_event(make_key_event(KeyCode::Char('6')));
8487 assert_eq!(app.state.time_period, TimePeriod::Day1);
8488
8489 app.handle_key_event(make_key_event(KeyCode::Char('t')));
8491 assert_eq!(app.state.time_period, TimePeriod::Min1); app.handle_key_event(make_key_event(KeyCode::Char('c')));
8495 assert_eq!(app.state.chart_mode, ChartMode::Candlestick);
8496
8497 app.handle_key_event(make_key_event(KeyCode::Char('s')));
8499 assert_eq!(app.state.scale_mode, ScaleMode::Log);
8500
8501 app.handle_key_event(make_key_event(KeyCode::Char('/')));
8503 assert_eq!(app.state.color_scheme, ColorScheme::BlueOrange);
8504
8505 app.handle_key_event(make_key_event(KeyCode::Char('j')));
8507
8508 app.handle_key_event(make_key_event(KeyCode::Char('k')));
8510
8511 app.handle_key_event(make_key_event(KeyCode::Char('l')));
8513 assert!(!app.state.auto_layout);
8514
8515 app.handle_key_event(make_key_event(KeyCode::Char('h')));
8517
8518 app.handle_key_event(make_key_event(KeyCode::Char('a')));
8520 assert!(app.state.auto_layout);
8521
8522 app.handle_key_event(make_key_event(KeyCode::Char('w')));
8524 assert!(app.state.widget_toggle_mode);
8525 app.handle_key_event(make_key_event(KeyCode::Char('z')));
8527
8528 app.handle_key_event(make_key_event(KeyCode::F(12)));
8530 assert!(!app.should_exit);
8531 }
8532
8533 #[tokio::test]
8538 async fn test_app_fetch_data_success() {
8539 let data = create_test_token_data();
8540 let initial_price = data.price_usd;
8541 let mut updated = data.clone();
8542 updated.price_usd = 2.5;
8543 let mut app = create_test_app(Box::new(MockDexDataSource::new(updated)), None);
8544
8545 assert!((app.state.current_price - initial_price).abs() < 0.001);
8546 app.fetch_data().await;
8547 assert!((app.state.current_price - 2.5).abs() < 0.001);
8548 assert!(app.state.error_message.is_none());
8549 }
8550
8551 #[tokio::test]
8552 async fn test_app_fetch_data_api_error() {
8553 let mut app = create_test_app(Box::new(MockDexDataSource::failing("rate limited")), None);
8554
8555 app.fetch_data().await;
8556 assert!(app.state.error_message.is_some());
8557 assert!(
8558 app.state
8559 .error_message
8560 .as_ref()
8561 .unwrap()
8562 .contains("API Error")
8563 );
8564 }
8565
8566 #[tokio::test]
8567 async fn test_app_fetch_data_holder_count_on_12th_tick() {
8568 let data = create_test_token_data();
8569 let mock_chain = MockChainClient::new(42_000);
8570 let mut app = create_test_app(
8571 Box::new(MockDexDataSource::new(data)),
8572 Some(Box::new(mock_chain)),
8573 );
8574
8575 for _ in 0..11 {
8577 app.fetch_data().await;
8578 }
8579 assert!(app.state.holder_count.is_none());
8580
8581 app.fetch_data().await;
8583 assert_eq!(app.state.holder_count, Some(42_000));
8584 }
8585
8586 #[tokio::test]
8587 async fn test_app_fetch_data_holder_count_zero_not_stored() {
8588 let data = create_test_token_data();
8589 let mock_chain = MockChainClient::new(0); let mut app = create_test_app(
8591 Box::new(MockDexDataSource::new(data)),
8592 Some(Box::new(mock_chain)),
8593 );
8594
8595 app.state.holder_fetch_counter = 11;
8597 app.fetch_data().await;
8598 assert!(app.state.holder_count.is_none());
8600 }
8601
8602 #[tokio::test]
8603 async fn test_app_fetch_data_no_chain_client_skips_holders() {
8604 let data = create_test_token_data();
8605 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8606
8607 app.state.holder_fetch_counter = 11;
8609 app.fetch_data().await;
8610 assert!(app.state.holder_count.is_none());
8612 }
8613
8614 #[tokio::test]
8615 async fn test_app_fetch_data_preserves_holder_on_subsequent_failure() {
8616 let data = create_test_token_data();
8617 let mock_chain = MockChainClient::new(42_000);
8618 let mut app = create_test_app(
8619 Box::new(MockDexDataSource::new(data)),
8620 Some(Box::new(mock_chain)),
8621 );
8622
8623 app.state.holder_fetch_counter = 11;
8625 app.fetch_data().await;
8626 assert_eq!(app.state.holder_count, Some(42_000));
8627
8628 app.chain_client = Some(Box::new(MockChainClient::new(0)));
8630 app.state.holder_fetch_counter = 23;
8632 app.fetch_data().await;
8633 assert_eq!(app.state.holder_count, Some(42_000));
8635 }
8636
8637 #[test]
8642 fn test_app_cleanup_does_not_panic_test_backend() {
8643 let data = create_test_token_data();
8644 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8645 let result = app.cleanup();
8647 assert!(result.is_ok());
8648 }
8649
8650 #[test]
8655 fn test_app_draw_renders_ui() {
8656 let data = create_test_token_data();
8657 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
8658 app.terminal
8660 .draw(|f| ui(f, &mut app.state))
8661 .expect("should render without panic");
8662 }
8663
8664 fn make_search_results() -> Vec<TokenSearchResult> {
8669 vec![
8670 TokenSearchResult {
8671 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
8672 symbol: "USDC".to_string(),
8673 name: "USD Coin".to_string(),
8674 chain: "ethereum".to_string(),
8675 price_usd: Some(1.0),
8676 volume_24h: 5_000_000_000.0,
8677 liquidity_usd: 2_000_000_000.0,
8678 market_cap: Some(32_000_000_000.0),
8679 },
8680 TokenSearchResult {
8681 address: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(),
8682 symbol: "USDT".to_string(),
8683 name: "Tether USD".to_string(),
8684 chain: "ethereum".to_string(),
8685 price_usd: Some(1.0),
8686 volume_24h: 6_000_000_000.0,
8687 liquidity_usd: 3_000_000_000.0,
8688 market_cap: Some(83_000_000_000.0),
8689 },
8690 TokenSearchResult {
8691 address: "0x6B175474E89094C44Da98b954EedeAC495271d0F".to_string(),
8692 symbol: "DAI".to_string(),
8693 name: "Dai Stablecoin".to_string(),
8694 chain: "ethereum".to_string(),
8695 price_usd: Some(1.0),
8696 volume_24h: 200_000_000.0,
8697 liquidity_usd: 500_000_000.0,
8698 market_cap: Some(5_000_000_000.0),
8699 },
8700 ]
8701 }
8702
8703 #[test]
8704 fn test_select_token_impl_valid_first() {
8705 let results = make_search_results();
8706 let mut reader = io::Cursor::new(b"1\n");
8707 let mut writer = Vec::new();
8708 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8709 assert_eq!(selected.symbol, "USDC");
8710 assert_eq!(selected.address, results[0].address);
8711 let output = String::from_utf8(writer).unwrap();
8712 assert!(output.contains("Found 3 tokens"));
8713 assert!(output.contains("Selected: USDC"));
8714 }
8715
8716 #[test]
8717 fn test_select_token_impl_valid_last() {
8718 let results = make_search_results();
8719 let mut reader = io::Cursor::new(b"3\n");
8720 let mut writer = Vec::new();
8721 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8722 assert_eq!(selected.symbol, "DAI");
8723 }
8724
8725 #[test]
8726 fn test_select_token_impl_valid_middle() {
8727 let results = make_search_results();
8728 let mut reader = io::Cursor::new(b"2\n");
8729 let mut writer = Vec::new();
8730 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8731 assert_eq!(selected.symbol, "USDT");
8732 }
8733
8734 #[test]
8735 fn test_select_token_impl_out_of_bounds_zero() {
8736 let results = make_search_results();
8737 let mut reader = io::Cursor::new(b"0\n");
8738 let mut writer = Vec::new();
8739 let result = select_token_impl(&results, &mut reader, &mut writer);
8740 assert!(result.is_err());
8741 let err = result.unwrap_err().to_string();
8742 assert!(err.contains("Selection must be between 1 and 3"));
8743 }
8744
8745 #[test]
8746 fn test_select_token_impl_out_of_bounds_high() {
8747 let results = make_search_results();
8748 let mut reader = io::Cursor::new(b"99\n");
8749 let mut writer = Vec::new();
8750 let result = select_token_impl(&results, &mut reader, &mut writer);
8751 assert!(result.is_err());
8752 }
8753
8754 #[test]
8755 fn test_select_token_impl_non_numeric_input() {
8756 let results = make_search_results();
8757 let mut reader = io::Cursor::new(b"abc\n");
8758 let mut writer = Vec::new();
8759 let result = select_token_impl(&results, &mut reader, &mut writer);
8760 assert!(result.is_err());
8761 let err = result.unwrap_err().to_string();
8762 assert!(err.contains("Invalid selection"));
8763 }
8764
8765 #[test]
8766 fn test_select_token_impl_empty_input() {
8767 let results = make_search_results();
8768 let mut reader = io::Cursor::new(b"\n");
8769 let mut writer = Vec::new();
8770 let result = select_token_impl(&results, &mut reader, &mut writer);
8771 assert!(result.is_err());
8772 }
8773
8774 #[test]
8775 fn test_select_token_impl_long_name_truncation() {
8776 let results = vec![TokenSearchResult {
8777 address: "0xABCDEF1234567890ABCDEF1234567890ABCDEF12".to_string(),
8778 symbol: "LONG".to_string(),
8779 name: "A Very Long Token Name That Exceeds Twenty Characters".to_string(),
8780 chain: "ethereum".to_string(),
8781 price_usd: None,
8782 volume_24h: 100.0,
8783 liquidity_usd: 50.0,
8784 market_cap: None,
8785 }];
8786 let mut reader = io::Cursor::new(b"1\n");
8787 let mut writer = Vec::new();
8788 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8789 assert_eq!(selected.symbol, "LONG");
8790 let output = String::from_utf8(writer).unwrap();
8791 assert!(output.contains("A Very Long Token..."));
8793 assert!(output.contains("N/A"));
8795 }
8796
8797 #[test]
8798 fn test_select_token_impl_output_format() {
8799 let results = make_search_results();
8800 let mut reader = io::Cursor::new(b"1\n");
8801 let mut writer = Vec::new();
8802 let _ = select_token_impl(&results, &mut reader, &mut writer).unwrap();
8803 let output = String::from_utf8(writer).unwrap();
8804
8805 assert!(output.contains("#"));
8807 assert!(output.contains("Symbol"));
8808 assert!(output.contains("Name"));
8809 assert!(output.contains("Address"));
8810 assert!(output.contains("Price"));
8811 assert!(output.contains("Liquidity"));
8812 assert!(output.contains("─"));
8814 assert!(output.contains("Select token (1-3):"));
8816 }
8817
8818 #[test]
8823 fn test_format_monitor_number_billions() {
8824 assert_eq!(format_monitor_number(5_000_000_000.0), "$5.00B");
8825 assert_eq!(format_monitor_number(1_234_567_890.0), "$1.23B");
8826 }
8827
8828 #[test]
8829 fn test_format_monitor_number_millions() {
8830 assert_eq!(format_monitor_number(5_000_000.0), "$5.00M");
8831 assert_eq!(format_monitor_number(42_500_000.0), "$42.50M");
8832 }
8833
8834 #[test]
8835 fn test_format_monitor_number_thousands() {
8836 assert_eq!(format_monitor_number(5_000.0), "$5.00K");
8837 assert_eq!(format_monitor_number(999_999.0), "$1000.00K");
8838 }
8839
8840 #[test]
8841 fn test_format_monitor_number_small() {
8842 assert_eq!(format_monitor_number(42.0), "$42.00");
8843 assert_eq!(format_monitor_number(0.5), "$0.50");
8844 assert_eq!(format_monitor_number(0.0), "$0.00");
8845 }
8846
8847 #[test]
8852 fn test_abbreviate_address_exactly_16_chars() {
8853 let addr = "0123456789ABCDEF"; assert_eq!(abbreviate_address(addr), addr);
8855 }
8856
8857 #[test]
8858 fn test_abbreviate_address_17_chars() {
8859 let addr = "0123456789ABCDEFG"; assert_eq!(abbreviate_address(addr), "01234567...BCDEFG");
8861 }
8862
8863 #[tokio::test]
8868 async fn test_app_full_scenario_fetch_render_quit() {
8869 let data = create_test_token_data();
8870 let mut updated = data.clone();
8871 updated.price_usd = 3.0;
8872 let mock_chain = MockChainClient::new(10_000);
8873 let state = MonitorState::new(&data, "ethereum");
8874 let mut app = create_test_app_with_state(
8875 state,
8876 Box::new(MockDexDataSource::new(updated)),
8877 Some(Box::new(mock_chain)),
8878 );
8879
8880 app.fetch_data().await;
8882 assert!((app.state.current_price - 3.0).abs() < 0.001);
8883
8884 app.terminal
8886 .draw(|f| ui(f, &mut app.state))
8887 .expect("render");
8888
8889 app.handle_key_event(make_key_event(KeyCode::Char('e')));
8891 assert!(app.state.export_active);
8892
8893 app.handle_key_event(make_key_event(KeyCode::Char('q')));
8895 assert!(app.should_exit);
8896 assert!(!app.state.export_active);
8897 }
8898
8899 #[tokio::test]
8900 async fn test_app_fetch_data_error_then_recovery() {
8901 let mut app = create_test_app(Box::new(MockDexDataSource::failing("server down")), None);
8902
8903 app.fetch_data().await;
8905 assert!(app.state.error_message.is_some());
8906
8907 let mut recovered = create_test_token_data();
8909 recovered.price_usd = 5.0;
8910 app.dex_client = Box::new(MockDexDataSource::new(recovered));
8911
8912 app.fetch_data().await;
8914 assert!((app.state.current_price - 5.0).abs() < 0.001);
8915 }
8917
8918 #[test]
8923 fn test_monitor_args_defaults() {
8924 use super::super::Cli;
8925 use clap::Parser;
8926 let cli = Cli::try_parse_from(["scope", "monitor", "USDC"]).unwrap();
8928 if let super::super::Commands::Monitor(args) = cli.command {
8929 assert_eq!(args.token, "USDC");
8930 assert_eq!(args.chain, "ethereum");
8931 assert!(args.layout.is_none());
8932 assert!(args.refresh.is_none());
8933 assert!(args.scale.is_none());
8934 assert!(args.color_scheme.is_none());
8935 assert!(args.export.is_none());
8936 } else {
8937 panic!("Expected Monitor command");
8938 }
8939 }
8940
8941 #[test]
8942 fn test_monitor_args_all_flags() {
8943 use super::super::Cli;
8944 use clap::Parser;
8945 let cli = Cli::try_parse_from([
8946 "scope",
8947 "monitor",
8948 "PEPE",
8949 "--chain",
8950 "solana",
8951 "--layout",
8952 "feed",
8953 "--refresh",
8954 "2",
8955 "--scale",
8956 "log",
8957 "--color-scheme",
8958 "monochrome",
8959 "--export",
8960 "/tmp/data.csv",
8961 ])
8962 .unwrap();
8963 if let super::super::Commands::Monitor(args) = cli.command {
8964 assert_eq!(args.token, "PEPE");
8965 assert_eq!(args.chain, "solana");
8966 assert_eq!(args.layout, Some(LayoutPreset::Feed));
8967 assert_eq!(args.refresh, Some(2));
8968 assert_eq!(args.scale, Some(ScaleMode::Log));
8969 assert_eq!(args.color_scheme, Some(ColorScheme::Monochrome));
8970 assert_eq!(args.export, Some(PathBuf::from("/tmp/data.csv")));
8971 } else {
8972 panic!("Expected Monitor command");
8973 }
8974 }
8975
8976 #[test]
8977 fn test_run_direct_config_override_layout() {
8978 let config = Config::default();
8980 assert_eq!(config.monitor.layout, LayoutPreset::Dashboard);
8981
8982 let args = MonitorArgs {
8983 token: "USDC".to_string(),
8984 chain: "ethereum".to_string(),
8985 layout: Some(LayoutPreset::ChartFocus),
8986 refresh: None,
8987 scale: None,
8988 color_scheme: None,
8989 export: None,
8990 venue: None,
8991 };
8992
8993 let mut monitor_config = config.monitor.clone();
8995 if let Some(layout) = args.layout {
8996 monitor_config.layout = layout;
8997 }
8998 assert_eq!(monitor_config.layout, LayoutPreset::ChartFocus);
8999 }
9000
9001 #[test]
9002 fn test_run_direct_config_override_all_fields() {
9003 let config = Config::default();
9004 let args = MonitorArgs {
9005 token: "PEPE".to_string(),
9006 chain: "solana".to_string(),
9007 layout: Some(LayoutPreset::Compact),
9008 refresh: Some(2),
9009 scale: Some(ScaleMode::Log),
9010 color_scheme: Some(ColorScheme::BlueOrange),
9011 export: Some(PathBuf::from("/tmp/test.csv")),
9012 venue: None,
9013 };
9014
9015 let mut mc = config.monitor.clone();
9016 if let Some(layout) = args.layout {
9017 mc.layout = layout;
9018 }
9019 if let Some(refresh) = args.refresh {
9020 mc.refresh_seconds = refresh;
9021 }
9022 if let Some(scale) = args.scale {
9023 mc.scale = scale;
9024 }
9025 if let Some(color_scheme) = args.color_scheme {
9026 mc.color_scheme = color_scheme;
9027 }
9028 if let Some(ref path) = args.export {
9029 mc.export.path = Some(path.to_string_lossy().into_owned());
9030 }
9031
9032 assert_eq!(mc.layout, LayoutPreset::Compact);
9033 assert_eq!(mc.refresh_seconds, 2);
9034 assert_eq!(mc.scale, ScaleMode::Log);
9035 assert_eq!(mc.color_scheme, ColorScheme::BlueOrange);
9036 assert_eq!(mc.export.path, Some("/tmp/test.csv".to_string()));
9037 }
9038
9039 #[test]
9040 fn test_run_direct_config_no_overrides_preserves_defaults() {
9041 let config = Config::default();
9042 let args = MonitorArgs {
9043 token: "USDC".to_string(),
9044 chain: "ethereum".to_string(),
9045 layout: None,
9046 refresh: None,
9047 scale: None,
9048 color_scheme: None,
9049 export: None,
9050 venue: None,
9051 };
9052
9053 let mut mc = config.monitor.clone();
9054 if let Some(layout) = args.layout {
9055 mc.layout = layout;
9056 }
9057 if let Some(refresh) = args.refresh {
9058 mc.refresh_seconds = refresh;
9059 }
9060 if let Some(scale) = args.scale {
9061 mc.scale = scale;
9062 }
9063 if let Some(color_scheme) = args.color_scheme {
9064 mc.color_scheme = color_scheme;
9065 }
9066
9067 assert_eq!(mc.layout, LayoutPreset::Dashboard);
9069 assert_eq!(mc.refresh_seconds, DEFAULT_REFRESH_SECS);
9070 assert_eq!(mc.scale, ScaleMode::Linear);
9071 assert_eq!(mc.color_scheme, ColorScheme::GreenRed);
9072 assert!(mc.export.path.is_none());
9073 }
9074
9075 #[test]
9080 fn test_exchange_interval_mapping() {
9081 assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
9082 assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
9083 assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
9084 assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
9085 assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
9086 assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
9087 }
9088
9089 #[test]
9090 fn test_monitor_state_exchange_ohlc_default_empty() {
9091 let token_data = create_test_token_data();
9092 let state = MonitorState::new(&token_data, "ethereum");
9093 assert!(state.exchange_ohlc.is_empty());
9094 assert!(state.venue_pair.is_none());
9095 }
9096
9097 #[test]
9098 fn test_get_ohlc_candles_prefers_exchange_data() {
9099 let token_data = create_test_token_data();
9100 let mut state = MonitorState::new(&token_data, "ethereum");
9101
9102 let now = std::time::SystemTime::now()
9104 .duration_since(std::time::UNIX_EPOCH)
9105 .unwrap()
9106 .as_secs_f64();
9107 state.price_history.push_back(DataPoint {
9108 timestamp: now,
9109 value: 1.0,
9110 is_real: true,
9111 });
9112 state.price_history.push_back(DataPoint {
9113 timestamp: now + 5.0,
9114 value: 1.01,
9115 is_real: true,
9116 });
9117
9118 let candles_before = state.get_ohlc_candles();
9120
9121 state.exchange_ohlc = vec![
9123 OhlcCandle {
9124 timestamp: 1700000000.0,
9125 open: 50000.0,
9126 high: 50500.0,
9127 low: 49800.0,
9128 close: 50200.0,
9129 is_bullish: true,
9130 },
9131 OhlcCandle {
9132 timestamp: 1700003600.0,
9133 open: 50200.0,
9134 high: 50800.0,
9135 low: 50100.0,
9136 close: 50700.0,
9137 is_bullish: true,
9138 },
9139 ];
9140
9141 let candles_after = state.get_ohlc_candles();
9142 assert_eq!(candles_after.len(), 2);
9143 assert_eq!(candles_after[0].open, 50000.0);
9144 assert_eq!(candles_after[1].close, 50700.0);
9145
9146 if !candles_before.is_empty() {
9148 assert_ne!(candles_after[0].open, candles_before[0].open);
9149 }
9150 }
9151
9152 #[test]
9153 fn test_monitor_args_with_venue() {
9154 let args = MonitorArgs {
9155 token: "BTC".to_string(),
9156 chain: "ethereum".to_string(),
9157 refresh: None,
9158 layout: None,
9159 scale: None,
9160 color_scheme: None,
9161 export: None,
9162 venue: Some("binance".to_string()),
9163 };
9164 assert_eq!(args.venue, Some("binance".to_string()));
9165 }
9166
9167 #[test]
9168 fn test_ohlc_candle_is_bullish_calculation() {
9169 let bullish = OhlcCandle {
9170 timestamp: 1700000000.0,
9171 open: 100.0,
9172 high: 110.0,
9173 low: 95.0,
9174 close: 105.0,
9175 is_bullish: true,
9176 };
9177 assert!(bullish.is_bullish);
9178
9179 let bearish = OhlcCandle {
9180 timestamp: 1700000000.0,
9181 open: 100.0,
9182 high: 105.0,
9183 low: 90.0,
9184 close: 95.0,
9185 is_bullish: false,
9186 };
9187 assert!(!bearish.is_bullish);
9188 }
9189}