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