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