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