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