1use crate::chains::ChainClientFactory;
23use crate::chains::dex::{DexClient, DexDataSource, DexTokenData};
24use crate::config::Config;
25use crate::error::{Result, ScopeError};
26use crossterm::{
27 event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
28 execute,
29 terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
30};
31use ratatui::{
32 Frame, Terminal,
33 backend::CrosstermBackend,
34 layout::{Constraint, Direction, Layout, Rect},
35 style::{Color, Modifier, Style},
36 symbols,
37 text::{Line, Span},
38 widgets::{
39 Axis, Block, Borders, Chart, Dataset, Gauge, GraphType, List, ListItem, Paragraph,
40 canvas::{Canvas, Line as CanvasLine, Rectangle},
41 },
42};
43use serde::{Deserialize, Serialize};
44use std::collections::VecDeque;
45use std::fs;
46use std::io::{self, Stdout};
47use std::path::PathBuf;
48use std::time::{Duration, Instant};
49
50use super::interactive::SessionContext;
51
52const MAX_DATA_AGE_SECS: f64 = 24.0 * 3600.0; const CACHE_FILE_PREFIX: &str = "bcc_monitor_";
60
61const DEFAULT_REFRESH_SECS: u64 = 5;
63
64const MIN_REFRESH_SECS: u64 = 1;
66
67const MAX_REFRESH_SECS: u64 = 60;
69
70#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
72pub struct DataPoint {
73 pub timestamp: f64,
75 pub value: f64,
77 pub is_real: bool,
79}
80
81#[derive(Debug, Clone, Copy)]
83pub struct OhlcCandle {
84 pub timestamp: f64,
86 pub open: f64,
88 pub high: f64,
90 pub low: f64,
92 pub close: f64,
94 pub is_bullish: bool,
96}
97
98impl OhlcCandle {
99 pub fn new(timestamp: f64, price: f64) -> Self {
101 Self {
102 timestamp,
103 open: price,
104 high: price,
105 low: price,
106 close: price,
107 is_bullish: true,
108 }
109 }
110
111 pub fn update(&mut self, price: f64) {
113 self.high = self.high.max(price);
114 self.low = self.low.min(price);
115 self.close = price;
116 self.is_bullish = self.close >= self.open;
117 }
118}
119
120#[derive(Debug, Serialize, Deserialize)]
122struct CachedMonitorData {
123 token_address: String,
125 chain: String,
127 price_history: Vec<DataPoint>,
129 volume_history: Vec<DataPoint>,
131 saved_at: f64,
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
137pub enum TimePeriod {
138 Min15,
140 Hour1,
142 Hour6,
144 Hour24,
146}
147
148impl TimePeriod {
149 pub fn duration_secs(&self) -> i64 {
151 match self {
152 TimePeriod::Min15 => 15 * 60,
153 TimePeriod::Hour1 => 3600,
154 TimePeriod::Hour6 => 6 * 3600,
155 TimePeriod::Hour24 => 24 * 3600,
156 }
157 }
158
159 pub fn label(&self) -> &'static str {
161 match self {
162 TimePeriod::Min15 => "15m",
163 TimePeriod::Hour1 => "1h",
164 TimePeriod::Hour6 => "6h",
165 TimePeriod::Hour24 => "24h",
166 }
167 }
168
169 pub fn next(&self) -> Self {
171 match self {
172 TimePeriod::Min15 => TimePeriod::Hour1,
173 TimePeriod::Hour1 => TimePeriod::Hour6,
174 TimePeriod::Hour6 => TimePeriod::Hour24,
175 TimePeriod::Hour24 => TimePeriod::Min15,
176 }
177 }
178}
179
180impl std::fmt::Display for TimePeriod {
181 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
182 write!(f, "{}", self.label())
183 }
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
188pub enum ChartMode {
189 #[default]
191 Line,
192 Candlestick,
194}
195
196impl ChartMode {
197 pub fn next(&self) -> Self {
199 match self {
200 ChartMode::Line => ChartMode::Candlestick,
201 ChartMode::Candlestick => ChartMode::Line,
202 }
203 }
204
205 pub fn label(&self) -> &'static str {
207 match self {
208 ChartMode::Line => "Line",
209 ChartMode::Candlestick => "Candle",
210 }
211 }
212}
213
214pub struct MonitorState {
216 pub token_address: String,
218
219 pub symbol: String,
221
222 pub name: String,
224
225 pub chain: String,
227
228 pub price_history: VecDeque<DataPoint>,
230
231 pub volume_history: VecDeque<DataPoint>,
233
234 pub real_data_count: usize,
236
237 pub current_price: f64,
239
240 pub price_change_24h: f64,
242
243 pub price_change_6h: f64,
245
246 pub price_change_1h: f64,
248
249 pub price_change_5m: f64,
251
252 pub last_price_change_at: f64,
254
255 pub previous_price: f64,
257
258 pub buys_24h: u64,
260
261 pub sells_24h: u64,
263
264 pub liquidity_usd: f64,
266
267 pub volume_24h: f64,
269
270 pub market_cap: Option<f64>,
272
273 pub fdv: Option<f64>,
275
276 pub last_update: Instant,
278
279 pub refresh_rate: Duration,
281
282 pub paused: bool,
284
285 pub log_messages: VecDeque<String>,
287
288 pub error_message: Option<String>,
290
291 pub time_period: TimePeriod,
293
294 pub chart_mode: ChartMode,
296
297 pub start_timestamp: i64,
299}
300
301impl MonitorState {
302 pub fn new(token_data: &DexTokenData, chain: &str) -> Self {
305 let now = Instant::now();
306 let now_ts = chrono::Utc::now().timestamp() as f64;
307
308 let (price_history, volume_history, real_data_count) =
310 if let Some(cached) = Self::load_cache(&token_data.address, chain) {
311 let cutoff = now_ts - MAX_DATA_AGE_SECS;
313 let price_hist: VecDeque<DataPoint> = cached
314 .price_history
315 .into_iter()
316 .filter(|p| p.timestamp >= cutoff)
317 .collect();
318 let vol_hist: VecDeque<DataPoint> = cached
319 .volume_history
320 .into_iter()
321 .filter(|p| p.timestamp >= cutoff)
322 .collect();
323 let real_count = price_hist.iter().filter(|p| p.is_real).count();
324 (price_hist, vol_hist, real_count)
325 } else {
326 let price_hist = Self::generate_synthetic_price_history(
328 token_data.price_usd,
329 token_data.price_change_1h,
330 token_data.price_change_6h,
331 token_data.price_change_24h,
332 now_ts,
333 );
334 let vol_hist = Self::generate_synthetic_volume_history(
335 token_data.volume_24h,
336 token_data.volume_6h,
337 token_data.volume_1h,
338 now_ts,
339 );
340 (price_hist, vol_hist, 0)
341 };
342
343 Self {
344 token_address: token_data.address.clone(),
345 symbol: token_data.symbol.clone(),
346 name: token_data.name.clone(),
347 chain: chain.to_string(),
348 price_history,
349 volume_history,
350 real_data_count,
351 current_price: token_data.price_usd,
352 price_change_24h: token_data.price_change_24h,
353 price_change_6h: token_data.price_change_6h,
354 price_change_1h: token_data.price_change_1h,
355 price_change_5m: token_data.price_change_5m,
356 last_price_change_at: now_ts, previous_price: token_data.price_usd,
358 buys_24h: token_data.total_buys_24h,
359 sells_24h: token_data.total_sells_24h,
360 liquidity_usd: token_data.liquidity_usd,
361 volume_24h: token_data.volume_24h,
362 market_cap: token_data.market_cap,
363 fdv: token_data.fdv,
364 last_update: now,
365 refresh_rate: Duration::from_secs(DEFAULT_REFRESH_SECS),
366 paused: false,
367 log_messages: VecDeque::with_capacity(10),
368 error_message: None,
369 time_period: TimePeriod::Hour1, chart_mode: ChartMode::Line, start_timestamp: now_ts as i64,
372 }
373 }
374
375 pub fn toggle_chart_mode(&mut self) {
377 self.chart_mode = self.chart_mode.next();
378 self.log(format!("Chart mode: {}", self.chart_mode.label()));
379 }
380
381 fn cache_path(token_address: &str, chain: &str) -> PathBuf {
383 let mut path = std::env::temp_dir();
384 let safe_addr = token_address
386 .chars()
387 .filter(|c| c.is_alphanumeric())
388 .take(16)
389 .collect::<String>()
390 .to_lowercase();
391 path.push(format!("{}{}_{}.json", CACHE_FILE_PREFIX, chain, safe_addr));
392 path
393 }
394
395 fn load_cache(token_address: &str, chain: &str) -> Option<CachedMonitorData> {
397 let path = Self::cache_path(token_address, chain);
398 if !path.exists() {
399 return None;
400 }
401
402 match fs::read_to_string(&path) {
403 Ok(contents) => {
404 match serde_json::from_str::<CachedMonitorData>(&contents) {
405 Ok(cached) => {
406 if cached.token_address.to_lowercase() == token_address.to_lowercase()
408 && cached.chain.to_lowercase() == chain.to_lowercase()
409 {
410 Some(cached)
411 } else {
412 None
413 }
414 }
415 Err(_) => None,
416 }
417 }
418 Err(_) => None,
419 }
420 }
421
422 pub fn save_cache(&self) {
424 let cached = CachedMonitorData {
425 token_address: self.token_address.clone(),
426 chain: self.chain.clone(),
427 price_history: self.price_history.iter().copied().collect(),
428 volume_history: self.volume_history.iter().copied().collect(),
429 saved_at: chrono::Utc::now().timestamp() as f64,
430 };
431
432 let path = Self::cache_path(&self.token_address, &self.chain);
433 if let Ok(json) = serde_json::to_string(&cached) {
434 let _ = fs::write(&path, json);
435 }
436 }
437
438 fn generate_synthetic_price_history(
441 current_price: f64,
442 change_1h: f64,
443 change_6h: f64,
444 change_24h: f64,
445 now_ts: f64,
446 ) -> VecDeque<DataPoint> {
447 let mut history = VecDeque::with_capacity(50);
448
449 let price_1h_ago = current_price / (1.0 + change_1h / 100.0);
451 let price_6h_ago = current_price / (1.0 + change_6h / 100.0);
452 let price_24h_ago = current_price / (1.0 + change_24h / 100.0);
453
454 let points = [
456 (now_ts - 24.0 * 3600.0, price_24h_ago),
457 (now_ts - 12.0 * 3600.0, (price_24h_ago + price_6h_ago) / 2.0),
458 (now_ts - 6.0 * 3600.0, price_6h_ago),
459 (now_ts - 3.0 * 3600.0, (price_6h_ago + price_1h_ago) / 2.0),
460 (now_ts - 1.0 * 3600.0, price_1h_ago),
461 (now_ts - 0.5 * 3600.0, (price_1h_ago + current_price) / 2.0),
462 (now_ts, current_price),
463 ];
464
465 for i in 0..points.len() - 1 {
467 let (t1, p1) = points[i];
468 let (t2, p2) = points[i + 1];
469 let steps = 4; for j in 0..steps {
472 let frac = j as f64 / steps as f64;
473 let t = t1 + (t2 - t1) * frac;
474 let p = p1 + (p2 - p1) * frac;
475 history.push_back(DataPoint {
476 timestamp: t,
477 value: p,
478 is_real: false, });
480 }
481 }
482 history.push_back(DataPoint {
484 timestamp: points[points.len() - 1].0,
485 value: points[points.len() - 1].1,
486 is_real: false,
487 });
488
489 history
490 }
491
492 fn generate_synthetic_volume_history(
495 volume_24h: f64,
496 volume_6h: f64,
497 volume_1h: f64,
498 now_ts: f64,
499 ) -> VecDeque<DataPoint> {
500 let mut history = VecDeque::with_capacity(24);
501
502 let hourly_avg = volume_24h / 24.0;
504
505 for i in 0..24 {
506 let hours_ago = 24 - i;
507 let ts = now_ts - (hours_ago as f64) * 3600.0;
508
509 let volume = if hours_ago <= 1 {
511 volume_1h
512 } else if hours_ago <= 6 {
513 volume_6h / 6.0
514 } else {
515 hourly_avg * (0.8 + (i as f64 / 24.0) * 0.4)
517 };
518
519 history.push_back(DataPoint {
520 timestamp: ts,
521 value: volume,
522 is_real: false, });
524 }
525
526 history
527 }
528
529 pub fn update(&mut self, token_data: &DexTokenData) {
532 let now_ts = chrono::Utc::now().timestamp() as f64;
533
534 self.price_history.push_back(DataPoint {
536 timestamp: now_ts,
537 value: token_data.price_usd,
538 is_real: true,
539 });
540 self.volume_history.push_back(DataPoint {
541 timestamp: now_ts,
542 value: token_data.volume_24h,
543 is_real: true,
544 });
545 self.real_data_count += 1;
546
547 let cutoff = now_ts - MAX_DATA_AGE_SECS;
549
550 while let Some(point) = self.price_history.front() {
551 if point.timestamp < cutoff {
552 self.price_history.pop_front();
553 } else {
554 break;
555 }
556 }
557 while let Some(point) = self.volume_history.front() {
558 if point.timestamp < cutoff {
559 self.volume_history.pop_front();
560 } else {
561 break;
562 }
563 }
564
565 let price_changed = (self.previous_price - token_data.price_usd).abs() > 0.00000001;
567 if price_changed {
568 self.last_price_change_at = now_ts;
569 self.previous_price = token_data.price_usd;
570 }
571
572 self.current_price = token_data.price_usd;
574 self.price_change_24h = token_data.price_change_24h;
575 self.price_change_6h = token_data.price_change_6h;
576 self.price_change_1h = token_data.price_change_1h;
577 self.price_change_5m = token_data.price_change_5m;
578 self.buys_24h = token_data.total_buys_24h;
579 self.sells_24h = token_data.total_sells_24h;
580 self.liquidity_usd = token_data.liquidity_usd;
581 self.volume_24h = token_data.volume_24h;
582 self.market_cap = token_data.market_cap;
583 self.fdv = token_data.fdv;
584
585 self.last_update = Instant::now();
586 self.error_message = None;
587
588 self.log(format!("Updated: ${:.6}", token_data.price_usd));
589
590 if self.real_data_count.is_multiple_of(60) {
592 self.save_cache();
593 }
594 }
595
596 pub fn get_price_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
599 let now_ts = chrono::Utc::now().timestamp() as f64;
600 let cutoff = now_ts - self.time_period.duration_secs() as f64;
601
602 let filtered: Vec<&DataPoint> = self
603 .price_history
604 .iter()
605 .filter(|p| p.timestamp >= cutoff)
606 .collect();
607
608 let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
609 let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
610
611 (data, is_real)
612 }
613
614 pub fn get_volume_data_for_period(&self) -> (Vec<(f64, f64)>, Vec<bool>) {
617 let now_ts = chrono::Utc::now().timestamp() as f64;
618 let cutoff = now_ts - self.time_period.duration_secs() as f64;
619
620 let filtered: Vec<&DataPoint> = self
621 .volume_history
622 .iter()
623 .filter(|p| p.timestamp >= cutoff)
624 .collect();
625
626 let data: Vec<(f64, f64)> = filtered.iter().map(|p| (p.timestamp, p.value)).collect();
627 let is_real: Vec<bool> = filtered.iter().map(|p| p.is_real).collect();
628
629 (data, is_real)
630 }
631
632 pub fn data_stats(&self) -> (usize, usize) {
634 let now_ts = chrono::Utc::now().timestamp() as f64;
635 let cutoff = now_ts - self.time_period.duration_secs() as f64;
636
637 let (synthetic, real) = self
638 .price_history
639 .iter()
640 .filter(|p| p.timestamp >= cutoff)
641 .fold(
642 (0, 0),
643 |(s, r), p| {
644 if p.is_real { (s, r + 1) } else { (s + 1, r) }
645 },
646 );
647
648 (synthetic, real)
649 }
650
651 pub fn memory_usage(&self) -> usize {
653 let point_size = std::mem::size_of::<DataPoint>();
655 (self.price_history.len() + self.volume_history.len()) * point_size
656 }
657
658 pub fn get_ohlc_candles(&self) -> Vec<OhlcCandle> {
666 let (data, _) = self.get_price_data_for_period();
667
668 if data.is_empty() {
669 return vec![];
670 }
671
672 let candle_duration_secs = match self.time_period {
674 TimePeriod::Min15 => 60.0, TimePeriod::Hour1 => 300.0, TimePeriod::Hour6 => 900.0, TimePeriod::Hour24 => 3600.0, };
679
680 let mut candles: Vec<OhlcCandle> = Vec::new();
681
682 for (timestamp, price) in data {
683 let candle_start = (timestamp / candle_duration_secs).floor() * candle_duration_secs;
685
686 if let Some(last_candle) = candles.last_mut() {
687 if (last_candle.timestamp - candle_start).abs() < 0.001 {
688 last_candle.update(price);
690 } else {
691 candles.push(OhlcCandle::new(candle_start, price));
693 }
694 } else {
695 candles.push(OhlcCandle::new(candle_start, price));
697 }
698 }
699
700 candles
701 }
702
703 pub fn cycle_time_period(&mut self) {
705 self.time_period = self.time_period.next();
706 self.log(format!("Time period: {}", self.time_period.label()));
707 }
708
709 pub fn set_time_period(&mut self, period: TimePeriod) {
711 self.time_period = period;
712 self.log(format!("Time period: {}", period.label()));
713 }
714
715 fn log(&mut self, message: String) {
717 let timestamp = chrono::Local::now().format("%H:%M:%S").to_string();
718 self.log_messages
719 .push_back(format!("[{}] {}", timestamp, message));
720 while self.log_messages.len() > 10 {
721 self.log_messages.pop_front();
722 }
723 }
724
725 pub fn should_refresh(&self) -> bool {
727 !self.paused && self.last_update.elapsed() >= self.refresh_rate
728 }
729
730 pub fn toggle_pause(&mut self) {
732 self.paused = !self.paused;
733 self.log(if self.paused {
734 "Paused".to_string()
735 } else {
736 "Resumed".to_string()
737 });
738 }
739
740 pub fn force_refresh(&mut self) {
742 self.paused = false;
743 self.last_update = Instant::now() - self.refresh_rate;
744 }
745
746 pub fn slower_refresh(&mut self) {
748 let current_secs = self.refresh_rate.as_secs();
749 let new_secs = (current_secs + 5).min(MAX_REFRESH_SECS);
750 self.refresh_rate = Duration::from_secs(new_secs);
751 self.log(format!("Refresh rate: {}s", new_secs));
752 }
753
754 pub fn faster_refresh(&mut self) {
756 let current_secs = self.refresh_rate.as_secs();
757 let new_secs = current_secs.saturating_sub(5).max(MIN_REFRESH_SECS);
758 self.refresh_rate = Duration::from_secs(new_secs);
759 self.log(format!("Refresh rate: {}s", new_secs));
760 }
761
762 pub fn refresh_rate_secs(&self) -> u64 {
764 self.refresh_rate.as_secs()
765 }
766
767 pub fn buy_ratio(&self) -> f64 {
769 let total = self.buys_24h + self.sells_24h;
770 if total == 0 {
771 0.5
772 } else {
773 self.buys_24h as f64 / total as f64
774 }
775 }
776}
777
778pub struct MonitorApp {
780 terminal: Terminal<CrosstermBackend<Stdout>>,
782
783 state: MonitorState,
785
786 dex_client: DexClient,
788
789 should_exit: bool,
791}
792
793impl MonitorApp {
794 pub fn new(initial_data: DexTokenData, chain: &str) -> Result<Self> {
796 enable_raw_mode()
798 .map_err(|e| ScopeError::Chain(format!("Failed to enable raw mode: {}", e)))?;
799 let mut stdout = io::stdout();
800 execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
801 .map_err(|e| ScopeError::Chain(format!("Failed to enter alternate screen: {}", e)))?;
802 let backend = CrosstermBackend::new(stdout);
803 let terminal = Terminal::new(backend)
804 .map_err(|e| ScopeError::Chain(format!("Failed to create terminal: {}", e)))?;
805
806 Ok(Self {
807 terminal,
808 state: MonitorState::new(&initial_data, chain),
809 dex_client: DexClient::new(),
810 should_exit: false,
811 })
812 }
813
814 pub async fn run(&mut self) -> Result<()> {
816 loop {
817 self.terminal.draw(|f| ui(f, &self.state))?;
819
820 if crossterm::event::poll(Duration::from_millis(100))
822 .map_err(|e| ScopeError::Chain(format!("Event poll error: {}", e)))?
823 && let Event::Key(key) = event::read()
824 .map_err(|e| ScopeError::Chain(format!("Event read error: {}", e)))?
825 {
826 self.handle_key_event(key);
827 }
828
829 if self.should_exit {
830 break;
831 }
832
833 if self.state.should_refresh() {
835 self.fetch_data().await;
836 }
837 }
838
839 Ok(())
840 }
841
842 fn handle_key_event(&mut self, key: crossterm::event::KeyEvent) {
845 match key.code {
846 KeyCode::Char('q') | KeyCode::Esc => {
847 self.should_exit = true;
848 }
849 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
850 self.should_exit = true;
851 }
852 KeyCode::Char('r') => {
853 self.state.force_refresh();
854 }
855 KeyCode::Char('p') | KeyCode::Char(' ') => {
856 self.state.toggle_pause();
857 }
858 KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
860 self.state.slower_refresh();
861 }
862 KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
864 self.state.faster_refresh();
865 }
866 KeyCode::Char('1') => {
868 self.state.set_time_period(TimePeriod::Min15);
869 }
870 KeyCode::Char('2') => {
871 self.state.set_time_period(TimePeriod::Hour1);
872 }
873 KeyCode::Char('3') => {
874 self.state.set_time_period(TimePeriod::Hour6);
875 }
876 KeyCode::Char('4') => {
877 self.state.set_time_period(TimePeriod::Hour24);
878 }
879 KeyCode::Char('t') | KeyCode::Tab => {
880 self.state.cycle_time_period();
881 }
882 KeyCode::Char('c') => {
884 self.state.toggle_chart_mode();
885 }
886 _ => {}
887 }
888 }
889
890 async fn fetch_data(&mut self) {
892 match self
893 .dex_client
894 .get_token_data(&self.state.chain, &self.state.token_address)
895 .await
896 {
897 Ok(data) => {
898 self.state.update(&data);
899 }
900 Err(e) => {
901 self.state.error_message = Some(format!("API Error: {}", e));
902 self.state.last_update = Instant::now(); }
904 }
905 }
906
907 pub fn cleanup(&mut self) -> Result<()> {
909 self.state.save_cache();
911
912 disable_raw_mode()
913 .map_err(|e| ScopeError::Chain(format!("Failed to disable raw mode: {}", e)))?;
914 execute!(
915 self.terminal.backend_mut(),
916 LeaveAlternateScreen,
917 DisableMouseCapture
918 )
919 .map_err(|e| ScopeError::Chain(format!("Failed to leave alternate screen: {}", e)))?;
920 self.terminal
921 .show_cursor()
922 .map_err(|e| ScopeError::Chain(format!("Failed to show cursor: {}", e)))?;
923 Ok(())
924 }
925}
926
927impl Drop for MonitorApp {
928 fn drop(&mut self) {
929 let _ = self.cleanup();
930 }
931}
932
933#[cfg(test)]
936fn handle_key_event_on_state(key: crossterm::event::KeyEvent, state: &mut MonitorState) -> bool {
937 match key.code {
938 KeyCode::Char('q') | KeyCode::Esc => {
939 return true;
940 }
941 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
942 return true;
943 }
944 KeyCode::Char('r') => {
945 state.force_refresh();
946 }
947 KeyCode::Char('p') | KeyCode::Char(' ') => {
948 state.toggle_pause();
949 }
950 KeyCode::Char('+') | KeyCode::Char('=') | KeyCode::Char(']') => {
951 state.slower_refresh();
952 }
953 KeyCode::Char('-') | KeyCode::Char('_') | KeyCode::Char('[') => {
954 state.faster_refresh();
955 }
956 KeyCode::Char('1') => {
957 state.set_time_period(TimePeriod::Min15);
958 }
959 KeyCode::Char('2') => {
960 state.set_time_period(TimePeriod::Hour1);
961 }
962 KeyCode::Char('3') => {
963 state.set_time_period(TimePeriod::Hour6);
964 }
965 KeyCode::Char('4') => {
966 state.set_time_period(TimePeriod::Hour24);
967 }
968 KeyCode::Char('t') | KeyCode::Tab => {
969 state.cycle_time_period();
970 }
971 KeyCode::Char('c') => {
972 state.toggle_chart_mode();
973 }
974 _ => {}
975 }
976 false
977}
978
979fn ui(f: &mut Frame, state: &MonitorState) {
981 let chunks = Layout::default()
983 .direction(Direction::Vertical)
984 .constraints([
985 Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
989 .split(f.area());
990
991 render_header(f, chunks[0], state);
993
994 let content_chunks = Layout::default()
996 .direction(Direction::Horizontal)
997 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
998 .split(chunks[1]);
999
1000 let left_chunks = Layout::default()
1001 .direction(Direction::Vertical)
1002 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
1003 .split(content_chunks[0]);
1004
1005 let right_chunks = Layout::default()
1006 .direction(Direction::Vertical)
1007 .constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
1008 .split(content_chunks[1]);
1009
1010 match state.chart_mode {
1012 ChartMode::Line => render_price_chart(f, left_chunks[0], state),
1013 ChartMode::Candlestick => render_candlestick_chart(f, left_chunks[0], state),
1014 }
1015 render_buy_sell_gauge(f, left_chunks[1], state);
1016 render_volume_chart(f, right_chunks[0], state);
1017 render_metrics_panel(f, right_chunks[1], state);
1018
1019 render_footer(f, chunks[2], state);
1021}
1022
1023fn render_header(f: &mut Frame, area: Rect, state: &MonitorState) {
1025 let price_color = if state.price_change_24h >= 0.0 {
1026 Color::Green
1027 } else {
1028 Color::Red
1029 };
1030
1031 let trend_arrow = if state.price_change_24h > 0.5 {
1033 "▲"
1034 } else if state.price_change_24h < -0.5 {
1035 "▼"
1036 } else if state.price_change_24h >= 0.0 {
1037 "△"
1038 } else {
1039 "▽"
1040 };
1041
1042 let change_str = format!(
1043 "{}{:.2}%",
1044 if state.price_change_24h >= 0.0 {
1045 "+"
1046 } else {
1047 ""
1048 },
1049 state.price_change_24h
1050 );
1051
1052 let title = format!(
1053 " ◈ {} ({}) │ {} │ {} ",
1054 state.symbol,
1055 state.name,
1056 state.chain.to_uppercase(),
1057 state.time_period.label()
1058 );
1059
1060 let price_str = format_price_usd(state.current_price);
1061
1062 let header = Paragraph::new(Line::from(vec![
1063 Span::styled(
1064 price_str,
1065 Style::default()
1066 .fg(price_color)
1067 .add_modifier(Modifier::BOLD),
1068 ),
1069 Span::raw(" "),
1070 Span::styled(trend_arrow, Style::default().fg(price_color)),
1071 Span::styled(format!(" {}", change_str), Style::default().fg(price_color)),
1072 ]))
1073 .block(
1074 Block::default()
1075 .title(title)
1076 .borders(Borders::ALL)
1077 .border_style(Style::default().fg(Color::Cyan)),
1078 );
1079
1080 f.render_widget(header, area);
1081}
1082
1083fn render_price_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
1085 let (data, is_real) = state.get_price_data_for_period();
1087
1088 if data.is_empty() {
1089 let empty = Paragraph::new("No price data").block(
1090 Block::default()
1091 .title(" Price (USD) ")
1092 .borders(Borders::ALL),
1093 );
1094 f.render_widget(empty, area);
1095 return;
1096 }
1097
1098 let current_price = state.current_price;
1100 let first_price = data.first().map(|(_, p)| *p).unwrap_or(current_price);
1101 let price_change = current_price - first_price;
1102 let price_change_pct = if first_price > 0.0 {
1103 (price_change / first_price) * 100.0
1104 } else {
1105 0.0
1106 };
1107
1108 let is_price_up = price_change >= 0.0;
1110 let trend_color = if is_price_up {
1111 Color::Green
1112 } else {
1113 Color::Red
1114 };
1115 let trend_symbol = if is_price_up { "▲" } else { "▼" };
1116
1117 let price_str = format_price_usd(current_price);
1119 let change_str = if price_change_pct.abs() < 0.01 {
1120 "0.00%".to_string()
1121 } else {
1122 format!(
1123 "{}{:.2}%",
1124 if is_price_up { "+" } else { "" },
1125 price_change_pct
1126 )
1127 };
1128
1129 let chart_title = Line::from(vec![
1131 Span::raw(" ◆ "),
1132 Span::styled(
1133 format!("{} {} ", price_str, trend_symbol),
1134 Style::default()
1135 .fg(trend_color)
1136 .add_modifier(Modifier::BOLD),
1137 ),
1138 Span::styled(
1139 format!("({}) ", change_str),
1140 Style::default().fg(trend_color),
1141 ),
1142 Span::styled(
1143 format!("│{}│ ", state.time_period.label()),
1144 Style::default().fg(Color::Gray),
1145 ),
1146 ]);
1147
1148 let (min_price, max_price) = data
1150 .iter()
1151 .fold((f64::MAX, f64::MIN), |(min, max), (_, p)| {
1152 (min.min(*p), max.max(*p))
1153 });
1154
1155 let price_range = max_price - min_price;
1157 let (y_min, y_max) = if price_range < 0.0001 {
1158 let padding = min_price * 0.001;
1160 (min_price - padding, max_price + padding)
1161 } else {
1162 (min_price - price_range * 0.1, max_price + price_range * 0.1)
1163 };
1164
1165 let x_min = data.first().map(|(t, _)| *t).unwrap_or(0.0);
1166 let x_max = data.last().map(|(t, _)| *t).unwrap_or(1.0);
1167 let x_max = if (x_max - x_min).abs() < 0.001 {
1169 x_min + 1.0
1170 } else {
1171 x_max
1172 };
1173
1174 let synthetic_data: Vec<(f64, f64)> = data
1176 .iter()
1177 .zip(&is_real)
1178 .filter(|(_, real)| !**real)
1179 .map(|(point, _)| *point)
1180 .collect();
1181
1182 let real_data: Vec<(f64, f64)> = data
1183 .iter()
1184 .zip(&is_real)
1185 .filter(|(_, real)| **real)
1186 .map(|(point, _)| *point)
1187 .collect();
1188
1189 let reference_line: Vec<(f64, f64)> = vec![(x_min, first_price), (x_max, first_price)];
1191
1192 let mut datasets = Vec::new();
1193
1194 datasets.push(
1196 Dataset::default()
1197 .name("━Start")
1198 .marker(symbols::Marker::Braille)
1199 .graph_type(GraphType::Line)
1200 .style(Style::default().fg(Color::DarkGray))
1201 .data(&reference_line),
1202 );
1203
1204 if !synthetic_data.is_empty() {
1206 datasets.push(
1207 Dataset::default()
1208 .name("◇Est")
1209 .marker(symbols::Marker::Braille)
1210 .graph_type(GraphType::Line)
1211 .style(Style::default().fg(Color::Cyan))
1212 .data(&synthetic_data),
1213 );
1214 }
1215
1216 if !real_data.is_empty() {
1218 datasets.push(
1219 Dataset::default()
1220 .name("●Live")
1221 .marker(symbols::Marker::Braille)
1222 .graph_type(GraphType::Line)
1223 .style(Style::default().fg(trend_color))
1224 .data(&real_data),
1225 );
1226 }
1227
1228 let time_label = format!("-{}", state.time_period.label());
1230
1231 let mid_price = (y_min + y_max) / 2.0;
1233
1234 let chart = Chart::new(datasets)
1235 .block(
1236 Block::default()
1237 .title(chart_title)
1238 .borders(Borders::ALL)
1239 .border_style(Style::default().fg(trend_color)),
1240 )
1241 .x_axis(
1242 Axis::default()
1243 .title(Span::styled("Time", Style::default().fg(Color::Gray)))
1244 .style(Style::default().fg(Color::Gray))
1245 .bounds([x_min, x_max])
1246 .labels(vec![Span::raw(time_label), Span::raw("now")]),
1247 )
1248 .y_axis(
1249 Axis::default()
1250 .title(Span::styled("USD", Style::default().fg(Color::Gray)))
1251 .style(Style::default().fg(Color::Gray))
1252 .bounds([y_min, y_max])
1253 .labels(vec![
1254 Span::raw(format_price_usd(y_min)),
1255 Span::raw(format_price_usd(mid_price)),
1256 Span::raw(format_price_usd(y_max)),
1257 ]),
1258 );
1259
1260 f.render_widget(chart, area);
1261}
1262
1263fn is_stablecoin_price(price: f64) -> bool {
1265 (0.95..=1.05).contains(&price)
1266}
1267
1268fn format_price_usd(price: f64) -> String {
1271 if price >= 1000.0 {
1272 format!("${:.2}", price)
1273 } else if is_stablecoin_price(price) {
1274 format!("${:.6}", price)
1276 } else if price >= 1.0 {
1277 format!("${:.4}", price)
1278 } else if price >= 0.01 {
1279 format!("${:.6}", price)
1280 } else if price >= 0.0001 {
1281 format!("${:.8}", price)
1282 } else {
1283 format!("${:.10}", price)
1284 }
1285}
1286
1287fn render_candlestick_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
1289 let candles = state.get_ohlc_candles();
1290
1291 if candles.is_empty() {
1292 let empty = Paragraph::new("No candle data (waiting for more data points)").block(
1293 Block::default()
1294 .title(" Candlestick (USD) ")
1295 .borders(Borders::ALL),
1296 );
1297 f.render_widget(empty, area);
1298 return;
1299 }
1300
1301 let current_price = state.current_price;
1303 let first_candle = candles.first().unwrap();
1304 let last_candle = candles.last().unwrap();
1305 let price_change = last_candle.close - first_candle.open;
1306 let price_change_pct = if first_candle.open > 0.0 {
1307 (price_change / first_candle.open) * 100.0
1308 } else {
1309 0.0
1310 };
1311
1312 let is_price_up = price_change >= 0.0;
1313 let trend_color = if is_price_up {
1314 Color::Green
1315 } else {
1316 Color::Red
1317 };
1318 let trend_symbol = if is_price_up { "▲" } else { "▼" };
1319
1320 let price_str = format_price_usd(current_price);
1321 let change_str = format!(
1322 "{}{:.2}%",
1323 if is_price_up { "+" } else { "" },
1324 price_change_pct
1325 );
1326
1327 let (min_price, max_price) = candles.iter().fold((f64::MAX, f64::MIN), |(min, max), c| {
1329 (min.min(c.low), max.max(c.high))
1330 });
1331
1332 let price_range = max_price - min_price;
1333 let (y_min, y_max) = if price_range < 0.0001 {
1334 let padding = min_price * 0.001;
1335 (min_price - padding, max_price + padding)
1336 } else {
1337 (min_price - price_range * 0.1, max_price + price_range * 0.1)
1338 };
1339
1340 let x_min = candles.first().map(|c| c.timestamp).unwrap_or(0.0);
1341 let x_max = candles.last().map(|c| c.timestamp).unwrap_or(1.0);
1342 let x_range = x_max - x_min;
1343 let x_max = if x_range < 0.001 {
1344 x_min + 1.0
1345 } else {
1346 x_max + x_range * 0.05
1347 };
1348
1349 let candle_count = candles.len() as f64;
1351 let candle_spacing = x_range / candle_count.max(1.0);
1352 let candle_width = candle_spacing * 0.6; let title = Line::from(vec![
1355 Span::raw(" ⬡ "),
1356 Span::styled(
1357 format!("{} {} ", price_str, trend_symbol),
1358 Style::default()
1359 .fg(trend_color)
1360 .add_modifier(Modifier::BOLD),
1361 ),
1362 Span::styled(
1363 format!("({}) ", change_str),
1364 Style::default().fg(trend_color),
1365 ),
1366 Span::styled(
1367 format!("│{}│ ", state.time_period.label()),
1368 Style::default().fg(Color::Gray),
1369 ),
1370 Span::styled("⊞Candles ", Style::default().fg(Color::Magenta)),
1371 ]);
1372
1373 let candles_clone = candles.clone();
1375
1376 let canvas = Canvas::default()
1377 .block(
1378 Block::default()
1379 .title(title)
1380 .borders(Borders::ALL)
1381 .border_style(Style::default().fg(trend_color)),
1382 )
1383 .x_bounds([x_min - candle_spacing, x_max])
1384 .y_bounds([y_min, y_max])
1385 .paint(move |ctx| {
1386 for candle in &candles_clone {
1387 let color = if candle.is_bullish {
1388 Color::Green
1389 } else {
1390 Color::Red
1391 };
1392
1393 ctx.draw(&CanvasLine {
1395 x1: candle.timestamp,
1396 y1: candle.low,
1397 x2: candle.timestamp,
1398 y2: candle.high,
1399 color,
1400 });
1401
1402 let body_top = candle.open.max(candle.close);
1404 let body_bottom = candle.open.min(candle.close);
1405 let body_height = (body_top - body_bottom).max(price_range * 0.002); ctx.draw(&Rectangle {
1408 x: candle.timestamp - candle_width / 2.0,
1409 y: body_bottom,
1410 width: candle_width,
1411 height: body_height,
1412 color,
1413 });
1414 }
1415 });
1416
1417 f.render_widget(canvas, area);
1418}
1419
1420fn render_volume_chart(f: &mut Frame, area: Rect, state: &MonitorState) {
1422 let (data, is_real) = state.get_volume_data_for_period();
1424
1425 if data.is_empty() {
1426 let empty = Paragraph::new("No volume data")
1427 .block(Block::default().title(" 24h Volume ").borders(Borders::ALL));
1428 f.render_widget(empty, area);
1429 return;
1430 }
1431
1432 let current_volume = state.volume_24h;
1434 let volume_str = format_usd(current_volume);
1435
1436 let has_synthetic = is_real.iter().any(|r| !r);
1438 let has_real = is_real.iter().any(|r| *r);
1439
1440 let data_indicator = if has_synthetic && has_real {
1442 "[◆ est │ ● live]"
1443 } else if has_synthetic {
1444 "[◆ estimated]"
1445 } else {
1446 "[● live]"
1447 };
1448
1449 let chart_title = Line::from(vec![
1450 Span::raw(" ▣ "),
1451 Span::styled(
1452 format!("24h Vol: {} ", volume_str),
1453 Style::default()
1454 .fg(Color::Blue)
1455 .add_modifier(Modifier::BOLD),
1456 ),
1457 Span::styled(
1458 format!("│{}│ ", state.time_period.label()),
1459 Style::default().fg(Color::Gray),
1460 ),
1461 Span::styled(data_indicator, Style::default().fg(Color::DarkGray)),
1462 ]);
1463
1464 let max_volume = data.iter().map(|(_, v)| *v).fold(0.0_f64, f64::max);
1466 let min_volume = data.iter().map(|(_, v)| *v).fold(f64::MAX, f64::min);
1467
1468 let vol_range = max_volume - min_volume;
1470 let (y_min, y_max) = if vol_range < max_volume * 0.01 {
1471 let padding = max_volume * 0.05;
1473 (min_volume - padding, max_volume + padding)
1474 } else {
1475 (0.0, max_volume * 1.1)
1477 };
1478
1479 let y_min = y_min.max(0.0);
1481
1482 let x_min = data.first().map(|(t, _)| *t).unwrap_or(0.0);
1483 let x_max = data.last().map(|(t, _)| *t).unwrap_or(1.0);
1484 let x_max = if (x_max - x_min).abs() < 0.001 {
1486 x_min + 1.0
1487 } else {
1488 x_max
1489 };
1490
1491 let synthetic_data: Vec<(f64, f64)> = data
1493 .iter()
1494 .zip(&is_real)
1495 .filter(|(_, real)| !**real)
1496 .map(|(point, _)| *point)
1497 .collect();
1498
1499 let real_data: Vec<(f64, f64)> = data
1500 .iter()
1501 .zip(&is_real)
1502 .filter(|(_, real)| **real)
1503 .map(|(point, _)| *point)
1504 .collect();
1505
1506 let mut datasets = Vec::new();
1507
1508 if !synthetic_data.is_empty() {
1510 datasets.push(
1511 Dataset::default()
1512 .name("◇Est")
1513 .marker(symbols::Marker::Braille)
1514 .graph_type(GraphType::Line)
1515 .style(Style::default().fg(Color::LightBlue))
1516 .data(&synthetic_data),
1517 );
1518 }
1519
1520 if !real_data.is_empty() {
1522 datasets.push(
1523 Dataset::default()
1524 .name("●Live")
1525 .marker(symbols::Marker::Braille)
1526 .graph_type(GraphType::Line)
1527 .style(Style::default().fg(Color::Blue))
1528 .data(&real_data),
1529 );
1530 }
1531
1532 let time_label = format!("-{}", state.time_period.label());
1534
1535 let chart = Chart::new(datasets)
1536 .block(
1537 Block::default()
1538 .title(chart_title)
1539 .borders(Borders::ALL)
1540 .border_style(Style::default().fg(Color::Blue)),
1541 )
1542 .x_axis(
1543 Axis::default()
1544 .title("Time")
1545 .style(Style::default().fg(Color::Gray))
1546 .bounds([x_min, x_max])
1547 .labels(vec![Span::raw(time_label), Span::raw("now")]),
1548 )
1549 .y_axis(
1550 Axis::default()
1551 .title("USD")
1552 .style(Style::default().fg(Color::Gray))
1553 .bounds([y_min, y_max])
1554 .labels(vec![
1555 Span::raw(format_number(y_min)),
1556 Span::raw(format_number((y_min + y_max) / 2.0)),
1557 Span::raw(format_number(y_max)),
1558 ]),
1559 );
1560
1561 f.render_widget(chart, area);
1562}
1563
1564fn render_buy_sell_gauge(f: &mut Frame, area: Rect, state: &MonitorState) {
1566 let chunks = Layout::default()
1567 .direction(Direction::Vertical)
1568 .constraints([Constraint::Length(3), Constraint::Min(0)])
1569 .split(area);
1570
1571 let ratio = state.buy_ratio();
1573 let color = if ratio > 0.5 {
1574 Color::Green
1575 } else {
1576 Color::Red
1577 };
1578
1579 let buy_indicator = if ratio > 0.5 { "▶" } else { "▷" };
1581 let sell_indicator = if ratio < 0.5 { "◀" } else { "◁" };
1582
1583 let gauge = Gauge::default()
1584 .block(
1585 Block::default()
1586 .title(" ◐ Buy/Sell Ratio (24h) ")
1587 .borders(Borders::ALL)
1588 .border_style(Style::default().fg(color)),
1589 )
1590 .gauge_style(Style::default().fg(color))
1591 .ratio(ratio)
1592 .label(format!(
1593 "{}Buys: {} │ Sells: {}{} ({:.1}%)",
1594 buy_indicator,
1595 state.buys_24h,
1596 state.sells_24h,
1597 sell_indicator,
1598 ratio * 100.0
1599 ));
1600
1601 f.render_widget(gauge, chunks[0]);
1602
1603 let items: Vec<ListItem> = state
1605 .log_messages
1606 .iter()
1607 .rev()
1608 .take(5)
1609 .map(|msg| ListItem::new(msg.as_str()).style(Style::default().fg(Color::Gray)))
1610 .collect();
1611
1612 let log_list = List::new(items).block(
1613 Block::default()
1614 .title(" ◷ Activity Log ")
1615 .borders(Borders::ALL)
1616 .border_style(Style::default().fg(Color::DarkGray)),
1617 );
1618
1619 f.render_widget(log_list, chunks[1]);
1620}
1621
1622fn render_metrics_panel(f: &mut Frame, area: Rect, state: &MonitorState) {
1624 let change_5m_str = if state.price_change_5m.abs() < 0.0001 {
1626 "0.00%".to_string()
1627 } else {
1628 format!("{:+.4}%", state.price_change_5m)
1629 };
1630 let change_5m_color = if state.price_change_5m > 0.0 {
1631 Color::Green
1632 } else if state.price_change_5m < 0.0 {
1633 Color::Red
1634 } else {
1635 Color::Gray
1636 };
1637
1638 let now_ts = chrono::Utc::now().timestamp() as f64;
1640 let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
1641 let last_change_str = if secs_since_change < 60 {
1642 format!("{}s ago", secs_since_change)
1643 } else if secs_since_change < 3600 {
1644 format!("{}m ago", secs_since_change / 60)
1645 } else {
1646 format!("{}h ago", secs_since_change / 3600)
1647 };
1648
1649 let text: Vec<Line> = vec![
1651 Line::from(vec![
1652 Span::raw("Price: "),
1653 Span::styled(
1654 format_price_usd(state.current_price),
1655 Style::default().add_modifier(Modifier::BOLD),
1656 ),
1657 ]),
1658 Line::from(vec![
1659 Span::raw("5m Change: "),
1660 Span::styled(change_5m_str, Style::default().fg(change_5m_color)),
1661 ]),
1662 Line::from(vec![
1663 Span::raw("Last Δ: "),
1664 Span::styled(
1665 last_change_str,
1666 Style::default().fg(if secs_since_change < 60 {
1667 Color::Green
1668 } else {
1669 Color::Yellow
1670 }),
1671 ),
1672 ]),
1673 Line::from(format!(
1674 "24h Change: {}{:.2}%",
1675 if state.price_change_24h >= 0.0 {
1676 "+"
1677 } else {
1678 ""
1679 },
1680 state.price_change_24h
1681 )),
1682 Line::from(format!("Liquidity: {}", format_usd(state.liquidity_usd))),
1683 Line::from(format!("24h Volume: {}", format_usd(state.volume_24h))),
1684 Line::from(format!(
1685 "Market Cap: {}",
1686 state
1687 .market_cap
1688 .map(format_usd)
1689 .unwrap_or_else(|| "N/A".to_string())
1690 )),
1691 Line::from(String::new()),
1692 Line::from(format!("24h Buys: {}", state.buys_24h)),
1693 Line::from(format!("24h Sells: {}", state.sells_24h)),
1694 ];
1695
1696 let panel = Paragraph::new(text).block(
1697 Block::default()
1698 .title(" ◉ Key Metrics ")
1699 .borders(Borders::ALL)
1700 .border_style(Style::default().fg(Color::Magenta)),
1701 );
1702
1703 f.render_widget(panel, area);
1704}
1705
1706fn render_footer(f: &mut Frame, area: Rect, state: &MonitorState) {
1708 let elapsed = state.last_update.elapsed().as_secs();
1709
1710 let now_ts = chrono::Utc::now().timestamp() as f64;
1712 let secs_since_change = (now_ts - state.last_price_change_at).max(0.0) as u64;
1713 let price_change_str = if secs_since_change < 60 {
1714 format!("{}s", secs_since_change)
1715 } else if secs_since_change < 3600 {
1716 format!("{}m", secs_since_change / 60)
1717 } else {
1718 format!("{}h", secs_since_change / 3600)
1719 };
1720
1721 let (synthetic_count, real_count) = state.data_stats();
1723 let memory_bytes = state.memory_usage();
1724 let memory_str = if memory_bytes >= 1024 * 1024 {
1725 format!("{:.1}MB", memory_bytes as f64 / (1024.0 * 1024.0))
1726 } else if memory_bytes >= 1024 {
1727 format!("{:.1}KB", memory_bytes as f64 / 1024.0)
1728 } else {
1729 format!("{}B", memory_bytes)
1730 };
1731
1732 let status = if let Some(ref err) = state.error_message {
1733 Span::styled(format!("⚠ {}", err), Style::default().fg(Color::Red))
1734 } else if state.paused {
1735 Span::styled(
1736 "⏸ PAUSED",
1737 Style::default()
1738 .fg(Color::Yellow)
1739 .add_modifier(Modifier::BOLD),
1740 )
1741 } else {
1742 Span::styled(
1743 format!(
1744 "↻ {}s │ Δ {} │ {} pts │ {}",
1745 elapsed,
1746 price_change_str,
1747 synthetic_count + real_count,
1748 memory_str
1749 ),
1750 Style::default().fg(Color::Gray),
1751 )
1752 };
1753
1754 let spans = vec![
1755 status,
1756 Span::raw(" ║ "),
1757 Span::styled(
1758 "Q",
1759 Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
1760 ),
1761 Span::raw("uit "),
1762 Span::styled(
1763 "R",
1764 Style::default()
1765 .fg(Color::Green)
1766 .add_modifier(Modifier::BOLD),
1767 ),
1768 Span::raw("efresh "),
1769 Span::styled(
1770 "P",
1771 Style::default()
1772 .fg(Color::Yellow)
1773 .add_modifier(Modifier::BOLD),
1774 ),
1775 Span::raw("ause "),
1776 Span::styled(
1777 "1-4",
1778 Style::default()
1779 .fg(Color::Magenta)
1780 .add_modifier(Modifier::BOLD),
1781 ),
1782 Span::raw("/"),
1783 Span::styled(
1784 "T",
1785 Style::default()
1786 .fg(Color::Magenta)
1787 .add_modifier(Modifier::BOLD),
1788 ),
1789 Span::raw("ime "),
1790 Span::styled(
1791 "C",
1792 Style::default()
1793 .fg(Color::LightBlue)
1794 .add_modifier(Modifier::BOLD),
1795 ),
1796 Span::raw(format!("hart:{} ", state.chart_mode.label())),
1797 Span::styled(
1798 "±",
1799 Style::default()
1800 .fg(Color::Cyan)
1801 .add_modifier(Modifier::BOLD),
1802 ),
1803 Span::raw("Speed"),
1804 ];
1805
1806 let footer = Paragraph::new(Line::from(spans)).block(Block::default().borders(Borders::ALL));
1807
1808 f.render_widget(footer, area);
1809}
1810
1811fn format_number(n: f64) -> String {
1813 if n >= 1_000_000_000.0 {
1814 format!("{:.2}B", n / 1_000_000_000.0)
1815 } else if n >= 1_000_000.0 {
1816 format!("{:.2}M", n / 1_000_000.0)
1817 } else if n >= 1_000.0 {
1818 format!("{:.2}K", n / 1_000.0)
1819 } else {
1820 format!("{:.2}", n)
1821 }
1822}
1823
1824fn format_usd(n: f64) -> String {
1826 if n >= 1_000_000_000.0 {
1827 format!("${:.2}B", n / 1_000_000_000.0)
1828 } else if n >= 1_000_000.0 {
1829 format!("${:.2}M", n / 1_000_000.0)
1830 } else if n >= 1_000.0 {
1831 format!("${:.2}K", n / 1_000.0)
1832 } else {
1833 format!("${:.2}", n)
1834 }
1835}
1836
1837pub async fn run(
1839 token: Option<String>,
1840 ctx: &SessionContext,
1841 config: &Config,
1842 clients: &dyn ChainClientFactory,
1843) -> Result<()> {
1844 let token_input = match token {
1845 Some(t) => t,
1846 None => {
1847 return Err(ScopeError::Chain(
1848 "Token address or symbol required. Usage: monitor <token>".to_string(),
1849 ));
1850 }
1851 };
1852
1853 println!("Starting live monitor for {}...", token_input);
1854 println!("Fetching initial data...");
1855
1856 let dex_client = clients.create_dex_client();
1858 let token_address =
1859 resolve_token_address(&token_input, &ctx.chain, config, dex_client.as_ref()).await?;
1860
1861 let initial_data = dex_client
1863 .get_token_data(&ctx.chain, &token_address)
1864 .await?;
1865
1866 println!(
1867 "Monitoring {} ({}) on {}",
1868 initial_data.symbol, initial_data.name, ctx.chain
1869 );
1870 println!("Press Q to quit, R to refresh, P to pause...\n");
1871
1872 tokio::time::sleep(Duration::from_millis(500)).await;
1874
1875 let mut app = MonitorApp::new(initial_data, &ctx.chain)?;
1877 let result = app.run().await;
1878
1879 if let Err(e) = app.cleanup() {
1881 eprintln!("Warning: Failed to cleanup terminal: {}", e);
1882 }
1883
1884 result
1885}
1886
1887async fn resolve_token_address(
1889 input: &str,
1890 chain: &str,
1891 _config: &Config,
1892 dex_client: &dyn DexDataSource,
1893) -> Result<String> {
1894 if input.starts_with("0x") && input.len() == 42 {
1896 return Ok(input.to_string());
1897 }
1898
1899 let aliases = crate::tokens::TokenAliases::load();
1901 if let Some(alias) = aliases.get(input, Some(chain)) {
1902 return Ok(alias.address.clone());
1903 }
1904
1905 let results = dex_client.search_tokens(input, Some(chain)).await?;
1907
1908 if results.is_empty() {
1909 return Err(ScopeError::NotFound(format!(
1910 "No token found matching '{}' on {}",
1911 input, chain
1912 )));
1913 }
1914
1915 let token = &results[0];
1917 println!(
1918 "Found: {} ({}) - ${:.6}",
1919 token.symbol,
1920 token.name,
1921 token.price_usd.unwrap_or(0.0)
1922 );
1923
1924 Ok(token.address.clone())
1925}
1926
1927#[cfg(test)]
1932mod tests {
1933 use super::*;
1934
1935 fn create_test_token_data() -> DexTokenData {
1936 DexTokenData {
1937 address: "0x1234".to_string(),
1938 symbol: "TEST".to_string(),
1939 name: "Test Token".to_string(),
1940 price_usd: 1.0,
1941 price_change_24h: 5.0,
1942 price_change_6h: 2.0,
1943 price_change_1h: 0.5,
1944 price_change_5m: 0.1,
1945 volume_24h: 1_000_000.0,
1946 volume_6h: 250_000.0,
1947 volume_1h: 50_000.0,
1948 liquidity_usd: 500_000.0,
1949 market_cap: Some(10_000_000.0),
1950 fdv: Some(100_000_000.0),
1951 pairs: vec![],
1952 price_history: vec![],
1953 volume_history: vec![],
1954 total_buys_24h: 100,
1955 total_sells_24h: 50,
1956 total_buys_6h: 25,
1957 total_sells_6h: 12,
1958 total_buys_1h: 5,
1959 total_sells_1h: 3,
1960 earliest_pair_created_at: Some(1700000000000),
1961 image_url: None,
1962 websites: vec![],
1963 socials: vec![],
1964 dexscreener_url: None,
1965 }
1966 }
1967
1968 #[test]
1969 fn test_monitor_state_new() {
1970 let token_data = create_test_token_data();
1971 let state = MonitorState::new(&token_data, "ethereum");
1972
1973 assert_eq!(state.symbol, "TEST");
1974 assert_eq!(state.chain, "ethereum");
1975 assert_eq!(state.current_price, 1.0);
1976 assert_eq!(state.buys_24h, 100);
1977 assert_eq!(state.sells_24h, 50);
1978 assert!(!state.paused);
1979 }
1980
1981 #[test]
1982 fn test_monitor_state_buy_ratio() {
1983 let token_data = create_test_token_data();
1984 let state = MonitorState::new(&token_data, "ethereum");
1985
1986 let ratio = state.buy_ratio();
1987 assert!((ratio - 0.6666).abs() < 0.01); }
1989
1990 #[test]
1991 fn test_monitor_state_buy_ratio_zero() {
1992 let mut token_data = create_test_token_data();
1993 token_data.total_buys_24h = 0;
1994 token_data.total_sells_24h = 0;
1995 let state = MonitorState::new(&token_data, "ethereum");
1996
1997 assert_eq!(state.buy_ratio(), 0.5); }
1999
2000 #[test]
2001 fn test_monitor_state_toggle_pause() {
2002 let token_data = create_test_token_data();
2003 let mut state = MonitorState::new(&token_data, "ethereum");
2004
2005 assert!(!state.paused);
2006 state.toggle_pause();
2007 assert!(state.paused);
2008 state.toggle_pause();
2009 assert!(!state.paused);
2010 }
2011
2012 #[test]
2013 fn test_monitor_state_should_refresh() {
2014 let token_data = create_test_token_data();
2015 let mut state = MonitorState::new(&token_data, "ethereum");
2016 state.refresh_rate = Duration::from_secs(60);
2017
2018 assert!(!state.should_refresh());
2020
2021 state.last_update = Instant::now() - Duration::from_secs(120);
2023 assert!(state.should_refresh());
2024
2025 state.paused = true;
2027 assert!(!state.should_refresh());
2028 }
2029
2030 #[test]
2031 fn test_format_number() {
2032 assert_eq!(format_number(500.0), "500.00");
2033 assert_eq!(format_number(1_500.0), "1.50K");
2034 assert_eq!(format_number(1_500_000.0), "1.50M");
2035 assert_eq!(format_number(1_500_000_000.0), "1.50B");
2036 }
2037
2038 #[test]
2039 fn test_format_usd() {
2040 assert_eq!(format_usd(500.0), "$500.00");
2041 assert_eq!(format_usd(1_500.0), "$1.50K");
2042 assert_eq!(format_usd(1_500_000.0), "$1.50M");
2043 assert_eq!(format_usd(1_500_000_000.0), "$1.50B");
2044 }
2045
2046 #[test]
2047 fn test_monitor_state_update() {
2048 let token_data = create_test_token_data();
2049 let mut state = MonitorState::new(&token_data, "ethereum");
2050
2051 let initial_len = state.price_history.len();
2052
2053 let mut updated_data = token_data.clone();
2054 updated_data.price_usd = 1.5;
2055 updated_data.total_buys_24h = 150;
2056
2057 state.update(&updated_data);
2058
2059 assert_eq!(state.current_price, 1.5);
2060 assert_eq!(state.buys_24h, 150);
2061 assert_eq!(state.price_history.len(), initial_len + 1);
2063 }
2064
2065 #[test]
2066 fn test_monitor_state_refresh_rate_adjustment() {
2067 let token_data = create_test_token_data();
2068 let mut state = MonitorState::new(&token_data, "ethereum");
2069
2070 assert_eq!(state.refresh_rate_secs(), 5);
2072
2073 state.slower_refresh();
2075 assert_eq!(state.refresh_rate_secs(), 10);
2076
2077 state.faster_refresh();
2079 assert_eq!(state.refresh_rate_secs(), 5);
2080
2081 state.faster_refresh();
2083 assert_eq!(state.refresh_rate_secs(), 1);
2084
2085 state.faster_refresh();
2087 assert_eq!(state.refresh_rate_secs(), 1);
2088
2089 for _ in 0..20 {
2091 state.slower_refresh();
2092 }
2093 assert_eq!(state.refresh_rate_secs(), 60);
2094 }
2095
2096 #[test]
2097 fn test_time_period() {
2098 assert_eq!(TimePeriod::Min15.label(), "15m");
2099 assert_eq!(TimePeriod::Hour1.label(), "1h");
2100 assert_eq!(TimePeriod::Hour6.label(), "6h");
2101 assert_eq!(TimePeriod::Hour24.label(), "24h");
2102
2103 assert_eq!(TimePeriod::Min15.duration_secs(), 15 * 60);
2104 assert_eq!(TimePeriod::Hour1.duration_secs(), 3600);
2105 assert_eq!(TimePeriod::Hour6.duration_secs(), 6 * 3600);
2106 assert_eq!(TimePeriod::Hour24.duration_secs(), 24 * 3600);
2107
2108 assert_eq!(TimePeriod::Min15.next(), TimePeriod::Hour1);
2110 assert_eq!(TimePeriod::Hour1.next(), TimePeriod::Hour6);
2111 assert_eq!(TimePeriod::Hour6.next(), TimePeriod::Hour24);
2112 assert_eq!(TimePeriod::Hour24.next(), TimePeriod::Min15);
2113 }
2114
2115 #[test]
2116 fn test_monitor_state_time_period() {
2117 let token_data = create_test_token_data();
2118 let mut state = MonitorState::new(&token_data, "ethereum");
2119
2120 assert_eq!(state.time_period, TimePeriod::Hour1);
2122
2123 state.cycle_time_period();
2125 assert_eq!(state.time_period, TimePeriod::Hour6);
2126
2127 state.set_time_period(TimePeriod::Hour24);
2128 assert_eq!(state.time_period, TimePeriod::Hour24);
2129 }
2130
2131 #[test]
2132 fn test_synthetic_history_generation() {
2133 let token_data = create_test_token_data();
2134 let state = MonitorState::new(&token_data, "ethereum");
2135
2136 assert!(state.price_history.len() > 1);
2138 assert!(state.volume_history.len() > 1);
2139
2140 if let (Some(first), Some(last)) = (state.price_history.front(), state.price_history.back())
2142 {
2143 let span = last.timestamp - first.timestamp;
2144 assert!(span > 0.0); }
2146 }
2147
2148 #[test]
2149 fn test_real_data_marking() {
2150 let token_data = create_test_token_data();
2151 let mut state = MonitorState::new(&token_data, "ethereum");
2152
2153 let (synthetic, real) = state.data_stats();
2155 assert!(synthetic > 0);
2156 assert_eq!(real, 0);
2157
2158 let mut updated_data = token_data.clone();
2160 updated_data.price_usd = 1.5;
2161 state.update(&updated_data);
2162
2163 let (synthetic2, real2) = state.data_stats();
2164 assert!(synthetic2 > 0);
2165 assert_eq!(real2, 1);
2166 assert_eq!(state.real_data_count, 1);
2167
2168 assert!(
2170 state
2171 .price_history
2172 .back()
2173 .map(|p| p.is_real)
2174 .unwrap_or(false)
2175 );
2176 }
2177
2178 #[test]
2179 fn test_memory_usage() {
2180 let token_data = create_test_token_data();
2181 let state = MonitorState::new(&token_data, "ethereum");
2182
2183 let mem = state.memory_usage();
2184 assert!(mem > 0);
2186
2187 let expected_point_size = std::mem::size_of::<DataPoint>();
2189 assert_eq!(expected_point_size, 24);
2190 }
2191
2192 #[test]
2193 fn test_get_data_for_period_returns_flags() {
2194 let token_data = create_test_token_data();
2195 let mut state = MonitorState::new(&token_data, "ethereum");
2196
2197 let (data, is_real) = state.get_price_data_for_period();
2199 assert_eq!(data.len(), is_real.len());
2200
2201 state.update(&token_data);
2203
2204 let (_data2, is_real2) = state.get_price_data_for_period();
2205 assert!(is_real2.iter().any(|r| *r));
2207 }
2208
2209 #[test]
2210 fn test_cache_path_generation() {
2211 let path =
2212 MonitorState::cache_path("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum");
2213 assert!(path.to_string_lossy().contains("bcc_monitor_"));
2214 assert!(path.to_string_lossy().contains("ethereum"));
2215 let temp_dir = std::env::temp_dir();
2217 assert!(path.starts_with(temp_dir));
2218 }
2219
2220 #[test]
2221 fn test_cache_save_and_load() {
2222 let token_data = create_test_token_data();
2223 let mut state = MonitorState::new(&token_data, "test_chain");
2224
2225 state.update(&token_data);
2227 state.update(&token_data);
2228
2229 state.save_cache();
2231
2232 let path = MonitorState::cache_path(&state.token_address, &state.chain);
2234 assert!(path.exists(), "Cache file should exist after save");
2235
2236 let loaded = MonitorState::load_cache(&state.token_address, &state.chain);
2238 assert!(loaded.is_some(), "Should be able to load saved cache");
2239
2240 let cached = loaded.unwrap();
2241 assert_eq!(cached.token_address, state.token_address);
2242 assert_eq!(cached.chain, state.chain);
2243 assert!(!cached.price_history.is_empty());
2244
2245 let _ = std::fs::remove_file(path);
2247 }
2248
2249 #[test]
2254 fn test_format_price_usd_high() {
2255 let formatted = format_price_usd(2500.50);
2256 assert!(formatted.starts_with("$2500.50"));
2257 }
2258
2259 #[test]
2260 fn test_format_price_usd_stablecoin() {
2261 let formatted = format_price_usd(1.0001);
2262 assert!(formatted.contains("1.000100"));
2263 assert!(is_stablecoin_price(1.0001));
2264 }
2265
2266 #[test]
2267 fn test_format_price_usd_medium() {
2268 let formatted = format_price_usd(5.1234);
2269 assert!(formatted.starts_with("$5.1234"));
2270 }
2271
2272 #[test]
2273 fn test_format_price_usd_small() {
2274 let formatted = format_price_usd(0.05);
2275 assert!(formatted.starts_with("$0.0500"));
2276 }
2277
2278 #[test]
2279 fn test_format_price_usd_micro() {
2280 let formatted = format_price_usd(0.001);
2281 assert!(formatted.starts_with("$0.0010"));
2282 }
2283
2284 #[test]
2285 fn test_format_price_usd_nano() {
2286 let formatted = format_price_usd(0.00001);
2287 assert!(formatted.contains("0.0000100"));
2288 }
2289
2290 #[test]
2291 fn test_is_stablecoin_price() {
2292 assert!(is_stablecoin_price(1.0));
2293 assert!(is_stablecoin_price(0.999));
2294 assert!(is_stablecoin_price(1.001));
2295 assert!(is_stablecoin_price(0.95));
2296 assert!(is_stablecoin_price(1.05));
2297 assert!(!is_stablecoin_price(0.94));
2298 assert!(!is_stablecoin_price(1.06));
2299 assert!(!is_stablecoin_price(100.0));
2300 }
2301
2302 #[test]
2307 fn test_ohlc_candle_new() {
2308 let candle = OhlcCandle::new(1000.0, 50.0);
2309 assert_eq!(candle.open, 50.0);
2310 assert_eq!(candle.high, 50.0);
2311 assert_eq!(candle.low, 50.0);
2312 assert_eq!(candle.close, 50.0);
2313 assert!(candle.is_bullish);
2314 }
2315
2316 #[test]
2317 fn test_ohlc_candle_update() {
2318 let mut candle = OhlcCandle::new(1000.0, 50.0);
2319 candle.update(55.0);
2320 assert_eq!(candle.high, 55.0);
2321 assert_eq!(candle.close, 55.0);
2322 assert!(candle.is_bullish);
2323
2324 candle.update(45.0);
2325 assert_eq!(candle.low, 45.0);
2326 assert_eq!(candle.close, 45.0);
2327 assert!(!candle.is_bullish); }
2329
2330 #[test]
2331 fn test_get_ohlc_candles() {
2332 let token_data = create_test_token_data();
2333 let mut state = MonitorState::new(&token_data, "ethereum");
2334 for i in 0..20 {
2336 let mut data = token_data.clone();
2337 data.price_usd = 1.0 + (i as f64 * 0.01);
2338 state.update(&data);
2339 }
2340 let candles = state.get_ohlc_candles();
2341 assert!(!candles.is_empty());
2343 }
2344
2345 #[test]
2350 fn test_chart_mode_cycle() {
2351 let mode = ChartMode::Line;
2352 assert_eq!(mode.next(), ChartMode::Candlestick);
2353 assert_eq!(ChartMode::Candlestick.next(), ChartMode::Line);
2354 }
2355
2356 #[test]
2357 fn test_chart_mode_label() {
2358 assert_eq!(ChartMode::Line.label(), "Line");
2359 assert_eq!(ChartMode::Candlestick.label(), "Candle");
2360 }
2361
2362 use ratatui::backend::TestBackend;
2367
2368 fn create_test_terminal() -> Terminal<TestBackend> {
2369 let backend = TestBackend::new(120, 40);
2370 Terminal::new(backend).unwrap()
2371 }
2372
2373 fn create_populated_state() -> MonitorState {
2374 let token_data = create_test_token_data();
2375 let mut state = MonitorState::new(&token_data, "ethereum");
2376 for i in 0..30 {
2378 let mut data = token_data.clone();
2379 data.price_usd = 1.0 + (i as f64 * 0.01);
2380 data.volume_24h = 1_000_000.0 + (i as f64 * 10_000.0);
2381 state.update(&data);
2382 }
2383 state
2384 }
2385
2386 #[test]
2387 fn test_render_header_no_panic() {
2388 let mut terminal = create_test_terminal();
2389 let state = create_populated_state();
2390 terminal
2391 .draw(|f| render_header(f, f.area(), &state))
2392 .unwrap();
2393 }
2394
2395 #[test]
2396 fn test_render_price_chart_no_panic() {
2397 let mut terminal = create_test_terminal();
2398 let state = create_populated_state();
2399 terminal
2400 .draw(|f| render_price_chart(f, f.area(), &state))
2401 .unwrap();
2402 }
2403
2404 #[test]
2405 fn test_render_price_chart_line_mode() {
2406 let mut terminal = create_test_terminal();
2407 let mut state = create_populated_state();
2408 state.chart_mode = ChartMode::Line;
2409 terminal
2410 .draw(|f| render_price_chart(f, f.area(), &state))
2411 .unwrap();
2412 }
2413
2414 #[test]
2415 fn test_render_candlestick_chart_no_panic() {
2416 let mut terminal = create_test_terminal();
2417 let state = create_populated_state();
2418 terminal
2419 .draw(|f| render_candlestick_chart(f, f.area(), &state))
2420 .unwrap();
2421 }
2422
2423 #[test]
2424 fn test_render_candlestick_chart_empty() {
2425 let mut terminal = create_test_terminal();
2426 let token_data = create_test_token_data();
2427 let state = MonitorState::new(&token_data, "ethereum");
2428 terminal
2429 .draw(|f| render_candlestick_chart(f, f.area(), &state))
2430 .unwrap();
2431 }
2432
2433 #[test]
2434 fn test_render_volume_chart_no_panic() {
2435 let mut terminal = create_test_terminal();
2436 let state = create_populated_state();
2437 terminal
2438 .draw(|f| render_volume_chart(f, f.area(), &state))
2439 .unwrap();
2440 }
2441
2442 #[test]
2443 fn test_render_volume_chart_empty() {
2444 let mut terminal = create_test_terminal();
2445 let token_data = create_test_token_data();
2446 let state = MonitorState::new(&token_data, "ethereum");
2447 terminal
2448 .draw(|f| render_volume_chart(f, f.area(), &state))
2449 .unwrap();
2450 }
2451
2452 #[test]
2453 fn test_render_buy_sell_gauge_no_panic() {
2454 let mut terminal = create_test_terminal();
2455 let state = create_populated_state();
2456 terminal
2457 .draw(|f| render_buy_sell_gauge(f, f.area(), &state))
2458 .unwrap();
2459 }
2460
2461 #[test]
2462 fn test_render_buy_sell_gauge_balanced() {
2463 let mut terminal = create_test_terminal();
2464 let mut token_data = create_test_token_data();
2465 token_data.total_buys_24h = 100;
2466 token_data.total_sells_24h = 100;
2467 let state = MonitorState::new(&token_data, "ethereum");
2468 terminal
2469 .draw(|f| render_buy_sell_gauge(f, f.area(), &state))
2470 .unwrap();
2471 }
2472
2473 #[test]
2474 fn test_render_metrics_panel_no_panic() {
2475 let mut terminal = create_test_terminal();
2476 let state = create_populated_state();
2477 terminal
2478 .draw(|f| render_metrics_panel(f, f.area(), &state))
2479 .unwrap();
2480 }
2481
2482 #[test]
2483 fn test_render_metrics_panel_no_market_cap() {
2484 let mut terminal = create_test_terminal();
2485 let mut token_data = create_test_token_data();
2486 token_data.market_cap = None;
2487 token_data.fdv = None;
2488 let state = MonitorState::new(&token_data, "ethereum");
2489 terminal
2490 .draw(|f| render_metrics_panel(f, f.area(), &state))
2491 .unwrap();
2492 }
2493
2494 #[test]
2495 fn test_render_footer_no_panic() {
2496 let mut terminal = create_test_terminal();
2497 let state = create_populated_state();
2498 terminal
2499 .draw(|f| render_footer(f, f.area(), &state))
2500 .unwrap();
2501 }
2502
2503 #[test]
2504 fn test_render_footer_paused() {
2505 let mut terminal = create_test_terminal();
2506 let token_data = create_test_token_data();
2507 let mut state = MonitorState::new(&token_data, "ethereum");
2508 state.paused = true;
2509 terminal
2510 .draw(|f| render_footer(f, f.area(), &state))
2511 .unwrap();
2512 }
2513
2514 #[test]
2515 fn test_render_all_components() {
2516 let mut terminal = create_test_terminal();
2518 let state = create_populated_state();
2519 terminal
2520 .draw(|f| {
2521 let area = f.area();
2522 let chunks = Layout::default()
2523 .direction(Direction::Vertical)
2524 .constraints([
2525 Constraint::Length(3),
2526 Constraint::Min(10),
2527 Constraint::Length(5),
2528 Constraint::Length(3),
2529 Constraint::Length(3),
2530 ])
2531 .split(area);
2532 render_header(f, chunks[0], &state);
2533 render_price_chart(f, chunks[1], &state);
2534 render_volume_chart(f, chunks[2], &state);
2535 render_buy_sell_gauge(f, chunks[3], &state);
2536 render_footer(f, chunks[4], &state);
2537 })
2538 .unwrap();
2539 }
2540
2541 #[test]
2542 fn test_render_candlestick_mode() {
2543 let mut terminal = create_test_terminal();
2544 let mut state = create_populated_state();
2545 state.chart_mode = ChartMode::Candlestick;
2546 terminal
2547 .draw(|f| {
2548 let area = f.area();
2549 let chunks = Layout::default()
2550 .direction(Direction::Vertical)
2551 .constraints([Constraint::Length(3), Constraint::Min(10)])
2552 .split(area);
2553 render_header(f, chunks[0], &state);
2554 render_candlestick_chart(f, chunks[1], &state);
2555 })
2556 .unwrap();
2557 }
2558
2559 #[test]
2560 fn test_render_with_different_time_periods() {
2561 let mut terminal = create_test_terminal();
2562 let mut state = create_populated_state();
2563
2564 for period in [
2565 TimePeriod::Min15,
2566 TimePeriod::Hour1,
2567 TimePeriod::Hour6,
2568 TimePeriod::Hour24,
2569 ] {
2570 state.time_period = period;
2571 terminal
2572 .draw(|f| render_price_chart(f, f.area(), &state))
2573 .unwrap();
2574 }
2575 }
2576
2577 #[test]
2578 fn test_render_metrics_with_stablecoin() {
2579 let mut terminal = create_test_terminal();
2580 let mut token_data = create_test_token_data();
2581 token_data.price_usd = 0.999;
2582 token_data.symbol = "USDC".to_string();
2583 let state = MonitorState::new(&token_data, "ethereum");
2584 terminal
2585 .draw(|f| render_metrics_panel(f, f.area(), &state))
2586 .unwrap();
2587 }
2588
2589 #[test]
2590 fn test_render_header_with_negative_change() {
2591 let mut terminal = create_test_terminal();
2592 let mut token_data = create_test_token_data();
2593 token_data.price_change_24h = -15.5;
2594 token_data.price_change_1h = -2.3;
2595 let state = MonitorState::new(&token_data, "ethereum");
2596 terminal
2597 .draw(|f| render_header(f, f.area(), &state))
2598 .unwrap();
2599 }
2600
2601 #[test]
2606 fn test_toggle_chart_mode_roundtrip() {
2607 let token_data = create_test_token_data();
2608 let mut state = MonitorState::new(&token_data, "ethereum");
2609 assert_eq!(state.chart_mode, ChartMode::Line);
2610 state.toggle_chart_mode();
2611 assert_eq!(state.chart_mode, ChartMode::Candlestick);
2612 state.toggle_chart_mode();
2613 assert_eq!(state.chart_mode, ChartMode::Line);
2614 }
2615
2616 #[test]
2617 fn test_cycle_all_time_periods() {
2618 let token_data = create_test_token_data();
2619 let mut state = MonitorState::new(&token_data, "ethereum");
2620 assert_eq!(state.time_period, TimePeriod::Hour1);
2621 state.cycle_time_period();
2622 assert_eq!(state.time_period, TimePeriod::Hour6);
2623 state.cycle_time_period();
2624 assert_eq!(state.time_period, TimePeriod::Hour24);
2625 state.cycle_time_period();
2626 assert_eq!(state.time_period, TimePeriod::Min15);
2627 state.cycle_time_period();
2628 assert_eq!(state.time_period, TimePeriod::Hour1);
2629 }
2630
2631 #[test]
2632 fn test_set_specific_time_period() {
2633 let token_data = create_test_token_data();
2634 let mut state = MonitorState::new(&token_data, "ethereum");
2635 state.set_time_period(TimePeriod::Hour24);
2636 assert_eq!(state.time_period, TimePeriod::Hour24);
2637 }
2638
2639 #[test]
2640 fn test_pause_resume_roundtrip() {
2641 let token_data = create_test_token_data();
2642 let mut state = MonitorState::new(&token_data, "ethereum");
2643 assert!(!state.paused);
2644 state.toggle_pause();
2645 assert!(state.paused);
2646 state.toggle_pause();
2647 assert!(!state.paused);
2648 }
2649
2650 #[test]
2651 fn test_force_refresh_unpauses() {
2652 let token_data = create_test_token_data();
2653 let mut state = MonitorState::new(&token_data, "ethereum");
2654 state.paused = true;
2655 state.force_refresh();
2656 assert!(!state.paused);
2657 assert!(state.should_refresh());
2658 }
2659
2660 #[test]
2661 fn test_refresh_rate_adjust() {
2662 let token_data = create_test_token_data();
2663 let mut state = MonitorState::new(&token_data, "ethereum");
2664 assert_eq!(state.refresh_rate_secs(), 5);
2665
2666 state.slower_refresh();
2667 assert_eq!(state.refresh_rate_secs(), 10);
2668
2669 state.faster_refresh();
2670 assert_eq!(state.refresh_rate_secs(), 5);
2671 }
2672
2673 #[test]
2674 fn test_faster_refresh_clamped_min() {
2675 let token_data = create_test_token_data();
2676 let mut state = MonitorState::new(&token_data, "ethereum");
2677 for _ in 0..10 {
2678 state.faster_refresh();
2679 }
2680 assert!(state.refresh_rate_secs() >= 1);
2681 }
2682
2683 #[test]
2684 fn test_slower_refresh_clamped_max() {
2685 let token_data = create_test_token_data();
2686 let mut state = MonitorState::new(&token_data, "ethereum");
2687 for _ in 0..20 {
2688 state.slower_refresh();
2689 }
2690 assert!(state.refresh_rate_secs() <= 60);
2691 }
2692
2693 #[test]
2694 fn test_buy_ratio_balanced() {
2695 let mut token_data = create_test_token_data();
2696 token_data.total_buys_24h = 100;
2697 token_data.total_sells_24h = 100;
2698 let state = MonitorState::new(&token_data, "ethereum");
2699 assert!((state.buy_ratio() - 0.5).abs() < 0.01);
2700 }
2701
2702 #[test]
2703 fn test_buy_ratio_no_trades() {
2704 let mut token_data = create_test_token_data();
2705 token_data.total_buys_24h = 0;
2706 token_data.total_sells_24h = 0;
2707 let state = MonitorState::new(&token_data, "ethereum");
2708 assert!((state.buy_ratio() - 0.5).abs() < 0.01);
2709 }
2710
2711 #[test]
2712 fn test_data_stats_initial() {
2713 let token_data = create_test_token_data();
2714 let state = MonitorState::new(&token_data, "ethereum");
2715 let (synthetic, real) = state.data_stats();
2716 assert!(synthetic > 0 || real == 0);
2717 }
2718
2719 #[test]
2720 fn test_memory_usage_nonzero() {
2721 let token_data = create_test_token_data();
2722 let state = MonitorState::new(&token_data, "ethereum");
2723 let usage = state.memory_usage();
2724 assert!(usage > 0);
2725 }
2726
2727 #[test]
2728 fn test_price_data_for_period() {
2729 let token_data = create_test_token_data();
2730 let state = MonitorState::new(&token_data, "ethereum");
2731 let (data, is_real) = state.get_price_data_for_period();
2732 assert_eq!(data.len(), is_real.len());
2733 }
2734
2735 #[test]
2736 fn test_volume_data_for_period() {
2737 let token_data = create_test_token_data();
2738 let state = MonitorState::new(&token_data, "ethereum");
2739 let (data, is_real) = state.get_volume_data_for_period();
2740 assert_eq!(data.len(), is_real.len());
2741 }
2742
2743 #[test]
2744 fn test_ohlc_candles_generation() {
2745 let token_data = create_test_token_data();
2746 let state = MonitorState::new(&token_data, "ethereum");
2747 let candles = state.get_ohlc_candles();
2748 for candle in &candles {
2749 assert!(candle.high >= candle.low);
2750 }
2751 }
2752
2753 #[test]
2754 fn test_state_update_with_new_data() {
2755 let token_data = create_test_token_data();
2756 let mut state = MonitorState::new(&token_data, "ethereum");
2757 let initial_count = state.real_data_count;
2758
2759 let mut updated_data = create_test_token_data();
2760 updated_data.price_usd = 2.0;
2761 updated_data.volume_24h = 2_000_000.0;
2762
2763 state.update(&updated_data);
2764 assert_eq!(state.current_price, 2.0);
2765 assert_eq!(state.real_data_count, initial_count + 1);
2766 assert!(state.error_message.is_none());
2767 }
2768
2769 #[test]
2770 fn test_cache_roundtrip_save_load() {
2771 let token_data = create_test_token_data();
2772 let state = MonitorState::new(&token_data, "ethereum");
2773
2774 state.save_cache();
2775
2776 let cache_path = MonitorState::cache_path(&token_data.address, "ethereum");
2777 assert!(cache_path.exists());
2778
2779 let cached = MonitorState::load_cache(&token_data.address, "ethereum");
2780 assert!(cached.is_some());
2781
2782 let _ = std::fs::remove_file(cache_path);
2783 }
2784
2785 #[test]
2786 fn test_should_refresh_when_paused() {
2787 let token_data = create_test_token_data();
2788 let mut state = MonitorState::new(&token_data, "ethereum");
2789 assert!(!state.should_refresh());
2790 state.paused = true;
2791 assert!(!state.should_refresh());
2792 }
2793
2794 #[test]
2795 fn test_ohlc_candle_lifecycle() {
2796 let mut candle = OhlcCandle::new(1700000000.0, 100.0);
2797 assert_eq!(candle.open, 100.0);
2798 assert!(candle.is_bullish);
2799 candle.update(110.0);
2800 assert_eq!(candle.high, 110.0);
2801 assert!(candle.is_bullish);
2802 candle.update(90.0);
2803 assert_eq!(candle.low, 90.0);
2804 assert!(!candle.is_bullish);
2805 }
2806
2807 #[test]
2808 fn test_time_period_display_impl() {
2809 assert_eq!(format!("{}", TimePeriod::Min15), "15m");
2810 assert_eq!(format!("{}", TimePeriod::Hour24), "24h");
2811 }
2812
2813 #[test]
2814 fn test_log_messages_accumulate() {
2815 let token_data = create_test_token_data();
2816 let mut state = MonitorState::new(&token_data, "ethereum");
2817 state.toggle_pause();
2819 state.toggle_pause();
2820 state.cycle_time_period();
2821 state.toggle_chart_mode();
2822 assert!(!state.log_messages.is_empty());
2823 }
2824
2825 #[test]
2826 fn test_ui_function_full_render() {
2827 let mut terminal = create_test_terminal();
2829 let state = create_populated_state();
2830 terminal.draw(|f| ui(f, &state)).unwrap();
2831 }
2832
2833 #[test]
2834 fn test_ui_function_candlestick_mode() {
2835 let mut terminal = create_test_terminal();
2836 let mut state = create_populated_state();
2837 state.chart_mode = ChartMode::Candlestick;
2838 terminal.draw(|f| ui(f, &state)).unwrap();
2839 }
2840
2841 #[test]
2842 fn test_ui_function_with_error_message() {
2843 let mut terminal = create_test_terminal();
2844 let mut state = create_populated_state();
2845 state.error_message = Some("Test error".to_string());
2846 terminal.draw(|f| ui(f, &state)).unwrap();
2847 }
2848
2849 #[test]
2850 fn test_render_header_with_small_positive_change() {
2851 let mut terminal = create_test_terminal();
2852 let mut state = create_populated_state();
2853 state.price_change_24h = 0.3; terminal
2855 .draw(|f| render_header(f, f.area(), &state))
2856 .unwrap();
2857 }
2858
2859 #[test]
2860 fn test_render_header_with_small_negative_change() {
2861 let mut terminal = create_test_terminal();
2862 let mut state = create_populated_state();
2863 state.price_change_24h = -0.3; terminal
2865 .draw(|f| render_header(f, f.area(), &state))
2866 .unwrap();
2867 }
2868
2869 #[test]
2870 fn test_render_buy_sell_gauge_high_buy_ratio() {
2871 let mut terminal = create_test_terminal();
2872 let token_data = create_test_token_data();
2873 let mut state = MonitorState::new(&token_data, "ethereum");
2874 state.buys_24h = 100;
2875 state.sells_24h = 10;
2876 terminal
2877 .draw(|f| render_buy_sell_gauge(f, f.area(), &state))
2878 .unwrap();
2879 }
2880
2881 #[test]
2882 fn test_render_buy_sell_gauge_zero_total() {
2883 let mut terminal = create_test_terminal();
2884 let token_data = create_test_token_data();
2885 let mut state = MonitorState::new(&token_data, "ethereum");
2886 state.buys_24h = 0;
2887 state.sells_24h = 0;
2888 terminal
2889 .draw(|f| render_buy_sell_gauge(f, f.area(), &state))
2890 .unwrap();
2891 }
2892
2893 #[test]
2894 fn test_render_metrics_with_market_cap() {
2895 let mut terminal = create_test_terminal();
2896 let token_data = create_test_token_data();
2897 let mut state = MonitorState::new(&token_data, "ethereum");
2898 state.market_cap = Some(1_000_000_000.0);
2899 state.fdv = Some(2_000_000_000.0);
2900 terminal
2901 .draw(|f| render_metrics_panel(f, f.area(), &state))
2902 .unwrap();
2903 }
2904
2905 #[test]
2906 fn test_render_footer_with_error() {
2907 let mut terminal = create_test_terminal();
2908 let mut state = create_populated_state();
2909 state.error_message = Some("Connection failed".to_string());
2910 terminal
2911 .draw(|f| render_footer(f, f.area(), &state))
2912 .unwrap();
2913 }
2914
2915 #[test]
2916 fn test_format_price_usd_various() {
2917 assert!(!format_price_usd(0.0000001).is_empty());
2919 assert!(!format_price_usd(0.001).is_empty());
2920 assert!(!format_price_usd(1.0).is_empty());
2921 assert!(!format_price_usd(100.0).is_empty());
2922 assert!(!format_price_usd(10000.0).is_empty());
2923 assert!(!format_price_usd(1000000.0).is_empty());
2924 }
2925
2926 #[test]
2927 fn test_format_usd_various() {
2928 assert!(!format_usd(0.0).is_empty());
2929 assert!(!format_usd(999.0).is_empty());
2930 assert!(!format_usd(1500.0).is_empty());
2931 assert!(!format_usd(1_500_000.0).is_empty());
2932 assert!(!format_usd(1_500_000_000.0).is_empty());
2933 assert!(!format_usd(1_500_000_000_000.0).is_empty());
2934 }
2935
2936 #[test]
2937 fn test_format_number_various() {
2938 assert!(!format_number(0.0).is_empty());
2939 assert!(!format_number(999.0).is_empty());
2940 assert!(!format_number(1500.0).is_empty());
2941 assert!(!format_number(1_500_000.0).is_empty());
2942 assert!(!format_number(1_500_000_000.0).is_empty());
2943 }
2944
2945 #[test]
2946 fn test_render_with_min15_period() {
2947 let mut terminal = create_test_terminal();
2948 let mut state = create_populated_state();
2949 state.set_time_period(TimePeriod::Min15);
2950 terminal.draw(|f| ui(f, &state)).unwrap();
2951 }
2952
2953 #[test]
2954 fn test_render_with_hour6_period() {
2955 let mut terminal = create_test_terminal();
2956 let mut state = create_populated_state();
2957 state.set_time_period(TimePeriod::Hour6);
2958 terminal.draw(|f| ui(f, &state)).unwrap();
2959 }
2960
2961 #[test]
2962 fn test_ui_with_fresh_state_no_real_data() {
2963 let mut terminal = create_test_terminal();
2964 let token_data = create_test_token_data();
2965 let state = MonitorState::new(&token_data, "ethereum");
2966 terminal.draw(|f| ui(f, &state)).unwrap();
2968 }
2969
2970 #[test]
2971 fn test_ui_with_paused_state() {
2972 let mut terminal = create_test_terminal();
2973 let mut state = create_populated_state();
2974 state.toggle_pause();
2975 terminal.draw(|f| ui(f, &state)).unwrap();
2976 }
2977
2978 #[test]
2979 fn test_render_all_with_different_time_periods_and_modes() {
2980 let mut terminal = create_test_terminal();
2981 let mut state = create_populated_state();
2982
2983 for period in &[
2985 TimePeriod::Min15,
2986 TimePeriod::Hour1,
2987 TimePeriod::Hour6,
2988 TimePeriod::Hour24,
2989 ] {
2990 for mode in &[ChartMode::Line, ChartMode::Candlestick] {
2991 state.set_time_period(*period);
2992 state.chart_mode = *mode;
2993 terminal.draw(|f| ui(f, &state)).unwrap();
2994 }
2995 }
2996 }
2997
2998 #[test]
2999 fn test_render_metrics_with_large_values() {
3000 let mut terminal = create_test_terminal();
3001 let mut state = create_populated_state();
3002 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
3007 .draw(|f| render_metrics_panel(f, f.area(), &state))
3008 .unwrap();
3009 }
3010
3011 #[test]
3012 fn test_render_header_large_positive_change() {
3013 let mut terminal = create_test_terminal();
3014 let mut state = create_populated_state();
3015 state.price_change_24h = 50.0; terminal
3017 .draw(|f| render_header(f, f.area(), &state))
3018 .unwrap();
3019 }
3020
3021 #[test]
3022 fn test_render_header_large_negative_change() {
3023 let mut terminal = create_test_terminal();
3024 let mut state = create_populated_state();
3025 state.price_change_24h = -50.0; terminal
3027 .draw(|f| render_header(f, f.area(), &state))
3028 .unwrap();
3029 }
3030
3031 #[test]
3032 fn test_render_price_chart_empty_data() {
3033 let mut terminal = create_test_terminal();
3034 let token_data = create_test_token_data();
3035 let mut state = MonitorState::new(&token_data, "ethereum");
3037 state.price_history.clear();
3038 terminal
3039 .draw(|f| render_price_chart(f, f.area(), &state))
3040 .unwrap();
3041 }
3042
3043 #[test]
3044 fn test_render_price_chart_price_down() {
3045 let mut terminal = create_test_terminal();
3046 let mut state = create_populated_state();
3047 state.price_change_24h = -15.0;
3049 state.current_price = 0.5; terminal
3051 .draw(|f| render_price_chart(f, f.area(), &state))
3052 .unwrap();
3053 }
3054
3055 #[test]
3056 fn test_render_price_chart_zero_first_price() {
3057 let mut terminal = create_test_terminal();
3058 let mut token_data = create_test_token_data();
3059 token_data.price_usd = 0.0;
3060 let state = MonitorState::new(&token_data, "ethereum");
3061 terminal
3062 .draw(|f| render_price_chart(f, f.area(), &state))
3063 .unwrap();
3064 }
3065
3066 #[test]
3067 fn test_render_metrics_panel_zero_5m_change() {
3068 let mut terminal = create_test_terminal();
3069 let mut state = create_populated_state();
3070 state.price_change_5m = 0.0; terminal
3072 .draw(|f| render_metrics_panel(f, f.area(), &state))
3073 .unwrap();
3074 }
3075
3076 #[test]
3077 fn test_render_metrics_panel_positive_5m_change() {
3078 let mut terminal = create_test_terminal();
3079 let mut state = create_populated_state();
3080 state.price_change_5m = 5.0; terminal
3082 .draw(|f| render_metrics_panel(f, f.area(), &state))
3083 .unwrap();
3084 }
3085
3086 #[test]
3087 fn test_render_metrics_panel_negative_5m_change() {
3088 let mut terminal = create_test_terminal();
3089 let mut state = create_populated_state();
3090 state.price_change_5m = -3.0; terminal
3092 .draw(|f| render_metrics_panel(f, f.area(), &state))
3093 .unwrap();
3094 }
3095
3096 #[test]
3097 fn test_render_metrics_panel_negative_24h_change() {
3098 let mut terminal = create_test_terminal();
3099 let mut state = create_populated_state();
3100 state.price_change_24h = -10.0;
3101 terminal
3102 .draw(|f| render_metrics_panel(f, f.area(), &state))
3103 .unwrap();
3104 }
3105
3106 #[test]
3107 fn test_render_metrics_panel_old_last_change() {
3108 let mut terminal = create_test_terminal();
3109 let mut state = create_populated_state();
3110 state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 7200.0; terminal
3113 .draw(|f| render_metrics_panel(f, f.area(), &state))
3114 .unwrap();
3115 }
3116
3117 #[test]
3118 fn test_render_metrics_panel_minutes_ago_change() {
3119 let mut terminal = create_test_terminal();
3120 let mut state = create_populated_state();
3121 state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 300.0; terminal
3124 .draw(|f| render_metrics_panel(f, f.area(), &state))
3125 .unwrap();
3126 }
3127
3128 #[test]
3129 fn test_render_candlestick_empty_fresh_state() {
3130 let mut terminal = create_test_terminal();
3131 let token_data = create_test_token_data();
3132 let mut state = MonitorState::new(&token_data, "ethereum");
3133 state.price_history.clear();
3134 state.chart_mode = ChartMode::Candlestick;
3135 terminal
3136 .draw(|f| render_candlestick_chart(f, f.area(), &state))
3137 .unwrap();
3138 }
3139
3140 #[test]
3141 fn test_render_candlestick_price_down() {
3142 let mut terminal = create_test_terminal();
3143 let token_data = create_test_token_data();
3144 let mut state = MonitorState::new(&token_data, "ethereum");
3145 for i in 0..20 {
3147 let mut data = token_data.clone();
3148 data.price_usd = 2.0 - (i as f64 * 0.05);
3149 state.update(&data);
3150 }
3151 state.chart_mode = ChartMode::Candlestick;
3152 terminal
3153 .draw(|f| render_candlestick_chart(f, f.area(), &state))
3154 .unwrap();
3155 }
3156
3157 #[test]
3158 fn test_render_volume_chart_with_many_points() {
3159 let mut terminal = create_test_terminal();
3160 let token_data = create_test_token_data();
3161 let mut state = MonitorState::new(&token_data, "ethereum");
3162 for i in 0..100 {
3164 let mut data = token_data.clone();
3165 data.volume_24h = 1_000_000.0 + (i as f64 * 50_000.0);
3166 data.price_usd = 1.0 + (i as f64 * 0.001);
3167 state.update(&data);
3168 }
3169 terminal
3170 .draw(|f| render_volume_chart(f, f.area(), &state))
3171 .unwrap();
3172 }
3173
3174 fn make_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
3179 crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)
3180 }
3181
3182 fn make_ctrl_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
3183 crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
3184 }
3185
3186 #[test]
3187 fn test_handle_key_quit_q() {
3188 let token_data = create_test_token_data();
3189 let mut state = MonitorState::new(&token_data, "ethereum");
3190 assert!(handle_key_event_on_state(
3191 make_key_event(KeyCode::Char('q')),
3192 &mut state
3193 ));
3194 }
3195
3196 #[test]
3197 fn test_handle_key_quit_esc() {
3198 let token_data = create_test_token_data();
3199 let mut state = MonitorState::new(&token_data, "ethereum");
3200 assert!(handle_key_event_on_state(
3201 make_key_event(KeyCode::Esc),
3202 &mut state
3203 ));
3204 }
3205
3206 #[test]
3207 fn test_handle_key_quit_ctrl_c() {
3208 let token_data = create_test_token_data();
3209 let mut state = MonitorState::new(&token_data, "ethereum");
3210 assert!(handle_key_event_on_state(
3211 make_ctrl_key_event(KeyCode::Char('c')),
3212 &mut state
3213 ));
3214 }
3215
3216 #[test]
3217 fn test_handle_key_refresh() {
3218 let token_data = create_test_token_data();
3219 let mut state = MonitorState::new(&token_data, "ethereum");
3220 state.refresh_rate = Duration::from_secs(60);
3221 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('r')), &mut state);
3223 assert!(!exit);
3224 assert!(state.should_refresh());
3226 }
3227
3228 #[test]
3229 fn test_handle_key_pause_toggle() {
3230 let token_data = create_test_token_data();
3231 let mut state = MonitorState::new(&token_data, "ethereum");
3232 assert!(!state.paused);
3233
3234 handle_key_event_on_state(make_key_event(KeyCode::Char('p')), &mut state);
3235 assert!(state.paused);
3236
3237 handle_key_event_on_state(make_key_event(KeyCode::Char(' ')), &mut state);
3238 assert!(!state.paused);
3239 }
3240
3241 #[test]
3242 fn test_handle_key_slower_refresh() {
3243 let token_data = create_test_token_data();
3244 let mut state = MonitorState::new(&token_data, "ethereum");
3245 let initial = state.refresh_rate;
3246
3247 handle_key_event_on_state(make_key_event(KeyCode::Char('+')), &mut state);
3248 assert!(state.refresh_rate > initial);
3249
3250 state.refresh_rate = initial;
3251 handle_key_event_on_state(make_key_event(KeyCode::Char('=')), &mut state);
3252 assert!(state.refresh_rate > initial);
3253
3254 state.refresh_rate = initial;
3255 handle_key_event_on_state(make_key_event(KeyCode::Char(']')), &mut state);
3256 assert!(state.refresh_rate > initial);
3257 }
3258
3259 #[test]
3260 fn test_handle_key_faster_refresh() {
3261 let token_data = create_test_token_data();
3262 let mut state = MonitorState::new(&token_data, "ethereum");
3263 state.refresh_rate = Duration::from_secs(30);
3265 let initial = state.refresh_rate;
3266
3267 handle_key_event_on_state(make_key_event(KeyCode::Char('-')), &mut state);
3268 assert!(state.refresh_rate < initial);
3269
3270 state.refresh_rate = initial;
3271 handle_key_event_on_state(make_key_event(KeyCode::Char('_')), &mut state);
3272 assert!(state.refresh_rate < initial);
3273
3274 state.refresh_rate = initial;
3275 handle_key_event_on_state(make_key_event(KeyCode::Char('[')), &mut state);
3276 assert!(state.refresh_rate < initial);
3277 }
3278
3279 #[test]
3280 fn test_handle_key_time_periods() {
3281 let token_data = create_test_token_data();
3282 let mut state = MonitorState::new(&token_data, "ethereum");
3283
3284 handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
3285 assert!(matches!(state.time_period, TimePeriod::Min15));
3286
3287 handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
3288 assert!(matches!(state.time_period, TimePeriod::Hour1));
3289
3290 handle_key_event_on_state(make_key_event(KeyCode::Char('3')), &mut state);
3291 assert!(matches!(state.time_period, TimePeriod::Hour6));
3292
3293 handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
3294 assert!(matches!(state.time_period, TimePeriod::Hour24));
3295 }
3296
3297 #[test]
3298 fn test_handle_key_cycle_time_period() {
3299 let token_data = create_test_token_data();
3300 let mut state = MonitorState::new(&token_data, "ethereum");
3301
3302 handle_key_event_on_state(make_key_event(KeyCode::Char('t')), &mut state);
3303 let first = state.time_period;
3305
3306 handle_key_event_on_state(make_key_event(KeyCode::Tab), &mut state);
3307 let _ = state.time_period;
3310 let _ = first;
3311 }
3312
3313 #[test]
3314 fn test_handle_key_toggle_chart_mode() {
3315 let token_data = create_test_token_data();
3316 let mut state = MonitorState::new(&token_data, "ethereum");
3317 let initial_mode = state.chart_mode;
3318
3319 handle_key_event_on_state(make_key_event(KeyCode::Char('c')), &mut state);
3320 assert!(state.chart_mode != initial_mode);
3321 }
3322
3323 #[test]
3324 fn test_handle_key_unknown_no_op() {
3325 let token_data = create_test_token_data();
3326 let mut state = MonitorState::new(&token_data, "ethereum");
3327 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
3328 assert!(!exit);
3329 }
3330
3331 #[test]
3336 fn test_save_and_load_cache() {
3337 let token_data = create_test_token_data();
3338 let mut state = MonitorState::new(&token_data, "ethereum");
3339 state.price_history.push_back(DataPoint {
3340 timestamp: 1.0,
3341 value: 100.0,
3342 is_real: true,
3343 });
3344 state.price_history.push_back(DataPoint {
3345 timestamp: 2.0,
3346 value: 101.0,
3347 is_real: true,
3348 });
3349 state.volume_history.push_back(DataPoint {
3350 timestamp: 1.0,
3351 value: 5000.0,
3352 is_real: true,
3353 });
3354
3355 state.save_cache();
3358 let cached = MonitorState::load_cache(&state.token_address, &state.chain);
3359 if let Some(c) = cached {
3361 assert_eq!(
3362 c.token_address.to_lowercase(),
3363 state.token_address.to_lowercase()
3364 );
3365 }
3366 }
3367
3368 #[test]
3369 fn test_load_cache_nonexistent_token() {
3370 let cached = MonitorState::load_cache("0xNONEXISTENT_TOKEN_ADDR", "nonexistent_chain");
3371 assert!(cached.is_none());
3372 }
3373}