1pub mod config;
64pub mod input;
65pub mod state;
66pub mod widgets;
67
68pub use config::{
70 AlertConfig, ChartMode, ColorScheme, ExportConfig, LayoutPreset, MonitorConfig, ScaleMode,
71 TimePeriod, WidgetVisibility,
72};
73pub use input::MonitorApp;
74pub use state::MonitorState;
75
76use clap::Args;
77use scope::chains::ChainClientFactory;
78use scope::chains::dex::{DexDataSource, DexTokenData};
79use scope::config::Config;
80use scope::error::{Result, ScopeError};
81use std::io;
82use std::path::PathBuf;
83use std::time::Duration;
84
85use super::interactive::SessionContext;
86
87#[derive(Debug, Args)]
112#[command(after_help = "\x1b[1mExamples:\x1b[0m
113 scope monitor USDC
114 scope monitor @dai-token \x1b[2m# address book shortcut\x1b[0m
115 scope monitor PEPE --chain ethereum --layout chart-focus
116 scope monitor DAI --venue binance --pair DAI_USDT
117 scope monitor BTC --venue binance --refresh 5 --scale log")]
118pub struct MonitorArgs {
119 pub token: String,
126
127 #[arg(short, long, default_value = "ethereum")]
131 pub chain: String,
132
133 #[arg(short, long)]
138 pub layout: Option<LayoutPreset>,
139
140 #[arg(short, long)]
145 pub refresh: Option<u64>,
146
147 #[arg(short, long)]
151 pub scale: Option<ScaleMode>,
152
153 #[arg(long)]
157 pub color_scheme: Option<ColorScheme>,
158
159 #[arg(short, long, value_name = "PATH")]
161 pub export: Option<PathBuf>,
162
163 #[arg(long, value_name = "VENUE")]
168 pub venue: Option<String>,
169
170 #[arg(long, value_name = "PAIR")]
176 pub pair: Option<String>,
177}
178pub async fn run_direct(
184 mut args: MonitorArgs,
185 config: &Config,
186 clients: &dyn ChainClientFactory,
187) -> Result<()> {
188 if let Some((address, chain)) =
190 crate::cli::address_book::resolve_address_book_input(&args.token, config)?
191 {
192 args.token = address;
193 if args.chain == "ethereum" {
194 args.chain = chain;
195 }
196 }
197
198 let ctx = SessionContext {
200 chain: args.chain,
201 ..SessionContext::default()
202 };
203
204 let mut monitor_config = config.monitor.clone();
206 if let Some(layout) = args.layout {
207 monitor_config.layout = layout;
208 }
209 if let Some(refresh) = args.refresh {
210 monitor_config.refresh_seconds = refresh;
211 }
212 if let Some(scale) = args.scale {
213 monitor_config.scale = scale;
214 }
215 if let Some(color_scheme) = args.color_scheme {
216 monitor_config.color_scheme = color_scheme;
217 }
218 if let Some(ref path) = args.export {
219 monitor_config.export.path = Some(path.to_string_lossy().into_owned());
220 }
221 if let Some(ref venue) = args.venue {
222 monitor_config.venue = Some(venue.clone());
223 }
224
225 let mut effective_config = config.clone();
227 effective_config.monitor = monitor_config;
228
229 run(
230 Some(args.token),
231 args.pair,
232 &ctx,
233 &effective_config,
234 clients,
235 )
236 .await
237}
238
239pub async fn run(
245 token: Option<String>,
246 explicit_pair: Option<String>,
247 ctx: &SessionContext,
248 config: &Config,
249 clients: &dyn ChainClientFactory,
250) -> Result<()> {
251 let token_input = match token {
252 Some(t) => t,
253 None => {
254 return Err(ScopeError::Chain(
255 "Token address or symbol required. Usage: monitor <token>".to_string(),
256 ));
257 }
258 };
259
260 eprintln!(" Starting live monitor for {}...", token_input);
261 eprintln!(" Fetching initial data...");
262
263 let exchange_client = config.monitor.venue.as_ref().and_then(|venue_id| {
265 scope::market::VenueRegistry::load()
266 .ok()
267 .and_then(|r| r.get(venue_id).cloned())
268 .map(|desc| scope::market::ExchangeClient::from_descriptor(&desc))
269 });
270
271 let initial_data = if let Some(ref pair_str) = explicit_pair {
274 let ex = exchange_client.as_ref().ok_or_else(|| {
275 ScopeError::Chain("--pair requires --venue to be specified".to_string())
276 })?;
277
278 let ticker = ex.fetch_ticker(pair_str).await.map_err(|e| {
280 ScopeError::Chain(format!("Failed to fetch ticker for {}: {}", pair_str, e))
281 })?;
282
283 let base_symbol = ticker
285 .pair
286 .split('/')
287 .next()
288 .unwrap_or(&token_input)
289 .to_string();
290
291 eprintln!(
292 " Exchange-only mode: {} @ ${:.6}",
293 pair_str,
294 ticker.last_price.unwrap_or(0.0)
295 );
296
297 build_exchange_token_data(&base_symbol, pair_str, &ticker)
299 } else {
300 let dex_client = clients.create_dex_client();
302 let token_address =
303 resolve_token_address(&token_input, &ctx.chain, config, dex_client.as_ref()).await?;
304
305 dex_client
306 .get_token_data(&ctx.chain, &token_address)
307 .await?
308 };
309
310 println!(
311 "Monitoring {} ({}) on {}",
312 initial_data.symbol, initial_data.name, ctx.chain
313 );
314 println!("Press Q to quit, R to refresh, P to pause...\n");
315
316 tokio::time::sleep(Duration::from_millis(500)).await;
318
319 let chain_client = clients.create_chain_client(&ctx.chain).ok();
321
322 let mut app = MonitorApp::new(
324 initial_data,
325 &ctx.chain,
326 &config.monitor,
327 chain_client,
328 exchange_client,
329 )?;
330
331 if let Some(ref pair_str) = explicit_pair {
333 app.state.venue_pair = Some(pair_str.clone());
334 }
335
336 let result = app.run().await;
337
338 if let Err(e) = app.cleanup() {
340 eprintln!("Warning: Failed to cleanup terminal: {}", e);
341 }
342
343 result
344}
345
346fn build_exchange_token_data(
351 symbol: &str,
352 pair_label: &str,
353 ticker: &scope::market::Ticker,
354) -> DexTokenData {
355 let price = ticker.last_price.unwrap_or(0.0);
356 DexTokenData {
357 address: format!("exchange:{}", pair_label),
358 symbol: symbol.to_string(),
359 name: symbol.to_string(),
360 price_usd: price,
361 price_change_24h: 0.0,
362 price_change_6h: 0.0,
363 price_change_1h: 0.0,
364 price_change_5m: 0.0,
365 volume_24h: ticker.volume_24h.unwrap_or(0.0),
366 volume_6h: 0.0,
367 volume_1h: 0.0,
368 liquidity_usd: 0.0,
369 market_cap: None,
370 fdv: None,
371 pairs: vec![],
372 price_history: vec![],
373 volume_history: vec![],
374 total_buys_24h: 0,
375 total_sells_24h: 0,
376 total_buys_6h: 0,
377 total_sells_6h: 0,
378 total_buys_1h: 0,
379 total_sells_1h: 0,
380 earliest_pair_created_at: None,
381 image_url: None,
382 websites: vec![],
383 socials: vec![],
384 dexscreener_url: None,
385 }
386}
387
388async fn resolve_token_address(
395 input: &str,
396 chain: &str,
397 _config: &Config,
398 dex_client: &dyn DexDataSource,
399) -> Result<String> {
400 if scope::tokens::TokenAliases::is_address(input) {
402 return Ok(input.to_string());
403 }
404
405 let chain_filter = if chain != "ethereum" {
407 Some(chain)
408 } else {
409 None
410 };
411 let aliases = scope::tokens::TokenAliases::load();
412 if let Some(alias) = aliases.get(input, chain_filter) {
413 return Ok(alias.address.clone());
414 }
415
416 let mut results = dex_client.search_tokens(input, chain_filter).await?;
419
420 if results.is_empty()
422 && let Some(fallback) = try_cex_fallback(input, chain).await
423 {
424 eprintln!(
425 " Not found on DexScreener; found {} on {} (CEX)",
426 fallback.symbol, fallback.chain
427 );
428 results.push(fallback);
429 }
430
431 if results.is_empty() {
432 return Err(ScopeError::NotFound(format!(
433 "No token found matching '{}' on {} (checked DexScreener and CEX venues)",
434 input, chain
435 )));
436 }
437
438 if results.len() == 1 {
440 let token = &results[0];
441 println!(
442 "Found: {} ({}) - ${:.6}",
443 token.symbol,
444 token.name,
445 token.price_usd.unwrap_or(0.0)
446 );
447 return Ok(token.address.clone());
448 }
449
450 let selected = select_token_interactive(&results)?;
452 Ok(selected.address.clone())
453}
454
455async fn try_cex_fallback(symbol: &str, chain: &str) -> Option<scope::chains::TokenSearchResult> {
461 let registry = scope::market::VenueRegistry::load().ok()?;
462 let descriptor = registry.get("binance")?;
463 let client = scope::market::ExchangeClient::from_descriptor(&descriptor.clone());
464 let pair = client.format_pair(&format!("{}USDT", symbol.to_uppercase()));
465 let ticker = client.fetch_ticker(&pair).await.ok()?;
466 let price = ticker.last_price.unwrap_or(0.0);
467 Some(scope::chains::TokenSearchResult {
468 address: String::new(),
469 symbol: symbol.to_uppercase(),
470 name: symbol.to_uppercase(),
471 chain: chain.to_string(),
472 price_usd: Some(price),
473 volume_24h: ticker.volume_24h.unwrap_or(0.0),
474 liquidity_usd: 0.0,
475 market_cap: None,
476 })
477}
478
479fn abbreviate_address(addr: &str) -> String {
481 if addr.len() > 16 {
482 format!("{}...{}", &addr[..8], &addr[addr.len() - 6..])
483 } else {
484 addr.to_string()
485 }
486}
487
488fn select_token_interactive(
490 results: &[scope::chains::dex::TokenSearchResult],
491) -> Result<&scope::chains::dex::TokenSearchResult> {
492 let stdin = io::stdin();
493 let stdout = io::stdout();
494 select_token_impl(results, &mut stdin.lock(), &mut stdout.lock())
495}
496
497fn select_token_impl<'a>(
499 results: &'a [scope::chains::dex::TokenSearchResult],
500 reader: &mut impl io::BufRead,
501 writer: &mut impl io::Write,
502) -> Result<&'a scope::chains::dex::TokenSearchResult> {
503 writeln!(
504 writer,
505 "\nFound {} tokens matching your query:\n",
506 results.len()
507 )
508 .map_err(|e| ScopeError::Io(e.to_string()))?;
509
510 writeln!(
511 writer,
512 "{:>3} {:>8} {:<22} {:<16} {:>12} {:>12}",
513 "#", "Symbol", "Name", "Address", "Price", "Liquidity"
514 )
515 .map_err(|e| ScopeError::Io(e.to_string()))?;
516
517 writeln!(writer, "{}", "─".repeat(82)).map_err(|e| ScopeError::Io(e.to_string()))?;
518
519 for (i, token) in results.iter().enumerate() {
520 let price = token
521 .price_usd
522 .map(|p| format!("${:.6}", p))
523 .unwrap_or_else(|| "N/A".to_string());
524
525 let liquidity = format_monitor_number(token.liquidity_usd);
526 let addr = abbreviate_address(&token.address);
527
528 let name = if token.name.len() > 20 {
530 format!("{}...", &token.name[..17])
531 } else {
532 token.name.clone()
533 };
534
535 writeln!(
536 writer,
537 "{:>3} {:>8} {:<22} {:<16} {:>12} {:>12}",
538 i + 1,
539 token.symbol,
540 name,
541 addr,
542 price,
543 liquidity
544 )
545 .map_err(|e| ScopeError::Io(e.to_string()))?;
546 }
547
548 writeln!(writer).map_err(|e| ScopeError::Io(e.to_string()))?;
549 write!(writer, "Select token (1-{}): ", results.len())
550 .map_err(|e| ScopeError::Io(e.to_string()))?;
551 writer.flush().map_err(|e| ScopeError::Io(e.to_string()))?;
552
553 let mut input = String::new();
554 reader
555 .read_line(&mut input)
556 .map_err(|e| ScopeError::Io(e.to_string()))?;
557
558 let selection: usize = input
559 .trim()
560 .parse()
561 .map_err(|_| ScopeError::Api("Invalid selection".to_string()))?;
562
563 if selection < 1 || selection > results.len() {
564 return Err(ScopeError::Api(format!(
565 "Selection must be between 1 and {}",
566 results.len()
567 )));
568 }
569
570 let selected = &results[selection - 1];
571 writeln!(
572 writer,
573 "Selected: {} ({}) at {}",
574 selected.symbol, selected.name, selected.address
575 )
576 .map_err(|e| ScopeError::Io(e.to_string()))?;
577
578 Ok(selected)
579}
580
581fn format_monitor_number(value: f64) -> String {
583 if value >= 1_000_000_000.0 {
584 format!("${:.2}B", value / 1_000_000_000.0)
585 } else if value >= 1_000_000.0 {
586 format!("${:.2}M", value / 1_000_000.0)
587 } else if value >= 1_000.0 {
588 format!("${:.2}K", value / 1_000.0)
589 } else {
590 format!("${:.2}", value)
591 }
592}
593#[cfg(test)]
598mod tests {
599 use super::config::*;
600 use super::input::*;
601 use super::widgets::*;
602 use super::*;
603
604 use crossterm::event::{KeyCode, KeyModifiers};
605 use ratatui::layout::{Constraint, Direction, Layout, Rect};
606 use scope::chains::ChainClient;
607 use scope::market::{Trade, TradeSide};
608 use std::fs;
609 use std::io::Write as _;
610 use std::time::Instant;
611
612 fn create_test_token_data() -> DexTokenData {
613 DexTokenData {
614 address: "0x1234".to_string(),
615 symbol: "TEST".to_string(),
616 name: "Test Token".to_string(),
617 price_usd: 1.0,
618 price_change_24h: 5.0,
619 price_change_6h: 2.0,
620 price_change_1h: 0.5,
621 price_change_5m: 0.1,
622 volume_24h: 1_000_000.0,
623 volume_6h: 250_000.0,
624 volume_1h: 50_000.0,
625 liquidity_usd: 500_000.0,
626 market_cap: Some(10_000_000.0),
627 fdv: Some(100_000_000.0),
628 pairs: vec![],
629 price_history: vec![],
630 volume_history: vec![],
631 total_buys_24h: 100,
632 total_sells_24h: 50,
633 total_buys_6h: 25,
634 total_sells_6h: 12,
635 total_buys_1h: 5,
636 total_sells_1h: 3,
637 earliest_pair_created_at: Some(1700000000000),
638 image_url: None,
639 websites: vec![],
640 socials: vec![],
641 dexscreener_url: None,
642 }
643 }
644
645 #[test]
646 fn test_monitor_state_new() {
647 let token_data = create_test_token_data();
648 let state = MonitorState::new(&token_data, "ethereum");
649
650 assert_eq!(state.symbol, "TEST");
651 assert_eq!(state.chain, "ethereum");
652 assert_eq!(state.current_price, 1.0);
653 assert_eq!(state.buys_24h, 100);
654 assert_eq!(state.sells_24h, 50);
655 assert!(!state.paused);
656 }
657
658 #[test]
659 fn test_monitor_state_buy_ratio() {
660 let token_data = create_test_token_data();
661 let state = MonitorState::new(&token_data, "ethereum");
662
663 let ratio = state.buy_ratio();
664 assert!((ratio - 0.6666).abs() < 0.01); }
666
667 #[test]
668 fn test_monitor_state_buy_ratio_zero() {
669 let mut token_data = create_test_token_data();
670 token_data.total_buys_24h = 0;
671 token_data.total_sells_24h = 0;
672 let state = MonitorState::new(&token_data, "ethereum");
673
674 assert_eq!(state.buy_ratio(), 0.5); }
676
677 #[test]
678 fn test_monitor_state_toggle_pause() {
679 let token_data = create_test_token_data();
680 let mut state = MonitorState::new(&token_data, "ethereum");
681
682 assert!(!state.paused);
683 state.toggle_pause();
684 assert!(state.paused);
685 state.toggle_pause();
686 assert!(!state.paused);
687 }
688
689 #[test]
690 fn test_monitor_state_should_refresh() {
691 let token_data = create_test_token_data();
692 let mut state = MonitorState::new(&token_data, "ethereum");
693 state.refresh_rate = Duration::from_secs(60);
694
695 assert!(!state.should_refresh());
697
698 state.last_update = Instant::now() - Duration::from_secs(120);
700 assert!(state.should_refresh());
701
702 state.paused = true;
704 assert!(!state.should_refresh());
705 }
706
707 #[test]
708 fn test_format_number() {
709 assert_eq!(format_number(500.0), "500.00");
710 assert_eq!(format_number(1_500.0), "1.50K");
711 assert_eq!(format_number(1_500_000.0), "1.50M");
712 assert_eq!(format_number(1_500_000_000.0), "1.50B");
713 }
714
715 #[test]
716 fn test_format_usd() {
717 assert_eq!(scope::display::format_usd(500.0), "$500.00");
718 assert_eq!(scope::display::format_usd(1_500.0), "$1.50K");
719 assert_eq!(scope::display::format_usd(1_500_000.0), "$1.50M");
720 assert_eq!(scope::display::format_usd(1_500_000_000.0), "$1.50B");
721 }
722
723 #[test]
724 fn test_monitor_state_update() {
725 let token_data = create_test_token_data();
726 let mut state = MonitorState::new(&token_data, "ethereum");
727
728 let initial_len = state.price_history.len();
729
730 let mut updated_data = token_data.clone();
731 updated_data.price_usd = 1.5;
732 updated_data.total_buys_24h = 150;
733
734 state.update(&updated_data);
735
736 assert_eq!(state.current_price, 1.5);
737 assert_eq!(state.buys_24h, 150);
738 assert_eq!(state.price_history.len(), initial_len + 1);
740 }
741
742 #[test]
743 fn test_monitor_state_refresh_rate_adjustment() {
744 let token_data = create_test_token_data();
745 let mut state = MonitorState::new(&token_data, "ethereum");
746
747 assert_eq!(state.refresh_rate_secs(), 5);
749
750 state.slower_refresh();
752 assert_eq!(state.refresh_rate_secs(), 10);
753
754 state.faster_refresh();
756 assert_eq!(state.refresh_rate_secs(), 5);
757
758 state.faster_refresh();
760 assert_eq!(state.refresh_rate_secs(), 1);
761
762 state.faster_refresh();
764 assert_eq!(state.refresh_rate_secs(), 1);
765
766 for _ in 0..20 {
768 state.slower_refresh();
769 }
770 assert_eq!(state.refresh_rate_secs(), 60);
771 }
772
773 #[test]
774 fn test_time_period() {
775 assert_eq!(TimePeriod::Min1.label(), "1m");
776 assert_eq!(TimePeriod::Min5.label(), "5m");
777 assert_eq!(TimePeriod::Min15.label(), "15m");
778 assert_eq!(TimePeriod::Hour1.label(), "1h");
779 assert_eq!(TimePeriod::Hour4.label(), "4h");
780 assert_eq!(TimePeriod::Day1.label(), "1d");
781
782 assert_eq!(TimePeriod::Min1.duration_secs(), 60);
783 assert_eq!(TimePeriod::Min5.duration_secs(), 300);
784 assert_eq!(TimePeriod::Min15.duration_secs(), 15 * 60);
785 assert_eq!(TimePeriod::Hour1.duration_secs(), 3600);
786 assert_eq!(TimePeriod::Hour4.duration_secs(), 4 * 3600);
787 assert_eq!(TimePeriod::Day1.duration_secs(), 24 * 3600);
788
789 assert_eq!(TimePeriod::Min1.next(), TimePeriod::Min5);
791 assert_eq!(TimePeriod::Min5.next(), TimePeriod::Min15);
792 assert_eq!(TimePeriod::Min15.next(), TimePeriod::Hour1);
793 assert_eq!(TimePeriod::Hour1.next(), TimePeriod::Hour4);
794 assert_eq!(TimePeriod::Hour4.next(), TimePeriod::Day1);
795 assert_eq!(TimePeriod::Day1.next(), TimePeriod::Min1);
796 }
797
798 #[test]
799 fn test_time_period_exchange_interval() {
800 assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
801 assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
802 assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
803 assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
804 assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
805 assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
806 }
807
808 #[test]
809 fn test_monitor_state_time_period() {
810 let token_data = create_test_token_data();
811 let mut state = MonitorState::new(&token_data, "ethereum");
812
813 assert_eq!(state.time_period, TimePeriod::Hour1);
815
816 state.cycle_time_period();
818 assert_eq!(state.time_period, TimePeriod::Hour4);
819
820 state.set_time_period(TimePeriod::Day1);
821 assert_eq!(state.time_period, TimePeriod::Day1);
822 }
823
824 #[test]
825 fn test_synthetic_history_generation() {
826 let token_data = create_test_token_data();
827 let state = MonitorState::new(&token_data, "ethereum");
828
829 assert!(state.price_history.len() > 1);
831 assert!(state.volume_history.len() > 1);
832
833 if let (Some(first), Some(last)) = (state.price_history.front(), state.price_history.back())
835 {
836 let span = last.timestamp - first.timestamp;
837 assert!(span > 0.0); }
839 }
840
841 #[test]
842 fn test_real_data_marking() {
843 let token_data = create_test_token_data();
844 let mut state = MonitorState::new(&token_data, "ethereum");
845
846 let (synthetic, real) = state.data_stats();
848 assert!(synthetic > 0);
849 assert_eq!(real, 0);
850
851 let mut updated_data = token_data.clone();
853 updated_data.price_usd = 1.5;
854 state.update(&updated_data);
855
856 let (synthetic2, real2) = state.data_stats();
857 assert!(synthetic2 > 0);
858 assert_eq!(real2, 1);
859 assert_eq!(state.real_data_count, 1);
860
861 assert!(
863 state
864 .price_history
865 .back()
866 .map(|p| p.is_real)
867 .unwrap_or(false)
868 );
869 }
870
871 #[test]
872 fn test_memory_usage() {
873 let token_data = create_test_token_data();
874 let state = MonitorState::new(&token_data, "ethereum");
875
876 let mem = state.memory_usage();
877 assert!(mem > 0);
879
880 let expected_point_size = std::mem::size_of::<DataPoint>();
882 assert_eq!(expected_point_size, 24);
883 }
884
885 #[test]
886 fn test_get_data_for_period_returns_flags() {
887 let token_data = create_test_token_data();
888 let mut state = MonitorState::new(&token_data, "ethereum");
889
890 let (data, is_real) = state.get_price_data_for_period();
892 assert_eq!(data.len(), is_real.len());
893
894 state.update(&token_data);
896
897 let (_data2, is_real2) = state.get_price_data_for_period();
898 assert!(is_real2.iter().any(|r| *r));
900 }
901
902 #[test]
903 fn test_cache_path_generation() {
904 let path =
905 MonitorState::cache_path("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum");
906 assert!(path.to_string_lossy().contains("bcc_monitor_"));
907 assert!(path.to_string_lossy().contains("ethereum"));
908 let temp_dir = std::env::temp_dir();
910 assert!(path.starts_with(temp_dir));
911 }
912
913 #[test]
914 fn test_cache_save_and_load() {
915 let token_data = create_test_token_data();
916 let mut state = MonitorState::new(&token_data, "test_chain");
917
918 state.update(&token_data);
920 state.update(&token_data);
921
922 state.save_cache();
924
925 let path = MonitorState::cache_path(&state.token_address, &state.chain);
927 assert!(path.exists(), "Cache file should exist after save");
928
929 let loaded = MonitorState::load_cache(&state.token_address, &state.chain);
931 assert!(loaded.is_some(), "Should be able to load saved cache");
932
933 let cached = loaded.unwrap();
934 assert_eq!(cached.token_address, state.token_address);
935 assert_eq!(cached.chain, state.chain);
936 assert!(!cached.price_history.is_empty());
937
938 let _ = std::fs::remove_file(path);
940 }
941
942 #[test]
947 fn test_format_price_usd_high() {
948 let formatted = format_price_usd(2500.50);
949 assert!(formatted.starts_with("$2500.50"));
950 }
951
952 #[test]
953 fn test_format_price_usd_stablecoin() {
954 let formatted = format_price_usd(1.0001);
955 assert!(formatted.contains("1.000100"));
956 assert!(is_stablecoin_price(1.0001));
957 }
958
959 #[test]
960 fn test_format_price_usd_medium() {
961 let formatted = format_price_usd(5.1234);
962 assert!(formatted.starts_with("$5.1234"));
963 }
964
965 #[test]
966 fn test_format_price_usd_small() {
967 let formatted = format_price_usd(0.05);
968 assert!(formatted.starts_with("$0.0500"));
969 }
970
971 #[test]
972 fn test_format_price_usd_micro() {
973 let formatted = format_price_usd(0.001);
974 assert!(formatted.starts_with("$0.0010"));
975 }
976
977 #[test]
978 fn test_format_price_usd_nano() {
979 let formatted = format_price_usd(0.00001);
980 assert!(formatted.contains("0.0000100"));
981 }
982
983 #[test]
984 fn test_is_stablecoin_price() {
985 assert!(is_stablecoin_price(1.0));
986 assert!(is_stablecoin_price(0.999));
987 assert!(is_stablecoin_price(1.001));
988 assert!(is_stablecoin_price(0.95));
989 assert!(is_stablecoin_price(1.05));
990 assert!(!is_stablecoin_price(0.94));
991 assert!(!is_stablecoin_price(1.06));
992 assert!(!is_stablecoin_price(100.0));
993 }
994
995 #[test]
1000 fn test_ohlc_candle_new() {
1001 let candle = OhlcCandle::new(1000.0, 50.0);
1002 assert_eq!(candle.open, 50.0);
1003 assert_eq!(candle.high, 50.0);
1004 assert_eq!(candle.low, 50.0);
1005 assert_eq!(candle.close, 50.0);
1006 assert!(candle.is_bullish);
1007 }
1008
1009 #[test]
1010 fn test_ohlc_candle_update() {
1011 let mut candle = OhlcCandle::new(1000.0, 50.0);
1012 candle.update(55.0);
1013 assert_eq!(candle.high, 55.0);
1014 assert_eq!(candle.close, 55.0);
1015 assert!(candle.is_bullish);
1016
1017 candle.update(45.0);
1018 assert_eq!(candle.low, 45.0);
1019 assert_eq!(candle.close, 45.0);
1020 assert!(!candle.is_bullish); }
1022
1023 #[test]
1024 fn test_get_ohlc_candles() {
1025 let token_data = create_test_token_data();
1026 let mut state = MonitorState::new(&token_data, "ethereum");
1027 for i in 0..20 {
1029 let mut data = token_data.clone();
1030 data.price_usd = 1.0 + (i as f64 * 0.01);
1031 state.update(&data);
1032 }
1033 let candles = state.get_ohlc_candles();
1034 assert!(!candles.is_empty());
1036 }
1037
1038 #[test]
1039 fn test_get_ohlc_candles_returns_exchange_ohlc_when_populated() {
1040 let token_data = create_test_token_data();
1041 let mut state = MonitorState::new(&token_data, "ethereum");
1042 let exchange_candles = vec![
1044 OhlcCandle::new(1700000000.0, 100.0),
1045 OhlcCandle::new(1700003600.0, 101.0),
1046 ];
1047 state.exchange_ohlc = exchange_candles.clone();
1048 let candles = state.get_ohlc_candles();
1049 assert_eq!(candles.len(), 2);
1050 assert_eq!(candles[0].timestamp, 1700000000.0);
1051 assert_eq!(candles[0].open, 100.0);
1052 assert_eq!(candles[1].timestamp, 1700003600.0);
1053 assert_eq!(candles[1].open, 101.0);
1054 }
1055
1056 #[test]
1061 fn test_chart_mode_cycle() {
1062 let mode = ChartMode::Line;
1063 assert_eq!(mode.next(), ChartMode::Candlestick);
1064 assert_eq!(ChartMode::Candlestick.next(), ChartMode::VolumeProfile);
1065 assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
1066 }
1067
1068 #[test]
1069 fn test_chart_mode_label() {
1070 assert_eq!(ChartMode::Line.label(), "Line");
1071 assert_eq!(ChartMode::Candlestick.label(), "Candle");
1072 assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
1073 }
1074
1075 use ratatui::Terminal;
1080 use ratatui::backend::TestBackend;
1081
1082 fn create_test_terminal() -> Terminal<TestBackend> {
1083 let backend = TestBackend::new(120, 40);
1084 Terminal::new(backend).unwrap()
1085 }
1086
1087 fn create_populated_state() -> MonitorState {
1088 let token_data = create_test_token_data();
1089 let mut state = MonitorState::new(&token_data, "ethereum");
1090 for i in 0..30 {
1092 let mut data = token_data.clone();
1093 data.price_usd = 1.0 + (i as f64 * 0.01);
1094 data.volume_24h = 1_000_000.0 + (i as f64 * 10_000.0);
1095 state.update(&data);
1096 }
1097 state
1098 }
1099
1100 #[test]
1101 fn test_render_header_no_panic() {
1102 let mut terminal = create_test_terminal();
1103 let state = create_populated_state();
1104 terminal
1105 .draw(|f| render_header(f, f.area(), &state))
1106 .unwrap();
1107 }
1108
1109 #[test]
1110 fn test_render_price_chart_no_panic() {
1111 let mut terminal = create_test_terminal();
1112 let state = create_populated_state();
1113 terminal
1114 .draw(|f| render_price_chart(f, f.area(), &state))
1115 .unwrap();
1116 }
1117
1118 #[test]
1119 fn test_render_price_chart_line_mode() {
1120 let mut terminal = create_test_terminal();
1121 let mut state = create_populated_state();
1122 state.chart_mode = ChartMode::Line;
1123 terminal
1124 .draw(|f| render_price_chart(f, f.area(), &state))
1125 .unwrap();
1126 }
1127
1128 #[test]
1129 fn test_render_candlestick_chart_no_panic() {
1130 let mut terminal = create_test_terminal();
1131 let state = create_populated_state();
1132 terminal
1133 .draw(|f| render_candlestick_chart(f, f.area(), &state))
1134 .unwrap();
1135 }
1136
1137 #[test]
1138 fn test_render_candlestick_chart_empty() {
1139 let mut terminal = create_test_terminal();
1140 let token_data = create_test_token_data();
1141 let state = MonitorState::new(&token_data, "ethereum");
1142 terminal
1143 .draw(|f| render_candlestick_chart(f, f.area(), &state))
1144 .unwrap();
1145 }
1146
1147 #[test]
1148 fn test_render_volume_chart_no_panic() {
1149 let mut terminal = create_test_terminal();
1150 let state = create_populated_state();
1151 terminal
1152 .draw(|f| render_volume_chart(f, f.area(), &state))
1153 .unwrap();
1154 }
1155
1156 #[test]
1157 fn test_render_volume_chart_empty() {
1158 let mut terminal = create_test_terminal();
1159 let token_data = create_test_token_data();
1160 let state = MonitorState::new(&token_data, "ethereum");
1161 terminal
1162 .draw(|f| render_volume_chart(f, f.area(), &state))
1163 .unwrap();
1164 }
1165
1166 #[test]
1167 fn test_render_buy_sell_gauge_no_panic() {
1168 let mut terminal = create_test_terminal();
1169 let mut state = create_populated_state();
1170 terminal
1171 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
1172 .unwrap();
1173 }
1174
1175 #[test]
1176 fn test_render_buy_sell_gauge_balanced() {
1177 let mut terminal = create_test_terminal();
1178 let mut token_data = create_test_token_data();
1179 token_data.total_buys_24h = 100;
1180 token_data.total_sells_24h = 100;
1181 let mut state = MonitorState::new(&token_data, "ethereum");
1182 terminal
1183 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
1184 .unwrap();
1185 }
1186
1187 #[test]
1188 fn test_render_metrics_panel_no_panic() {
1189 let mut terminal = create_test_terminal();
1190 let state = create_populated_state();
1191 terminal
1192 .draw(|f| render_metrics_panel(f, f.area(), &state))
1193 .unwrap();
1194 }
1195
1196 #[test]
1197 fn test_render_metrics_panel_no_market_cap() {
1198 let mut terminal = create_test_terminal();
1199 let mut token_data = create_test_token_data();
1200 token_data.market_cap = None;
1201 token_data.fdv = None;
1202 let state = MonitorState::new(&token_data, "ethereum");
1203 terminal
1204 .draw(|f| render_metrics_panel(f, f.area(), &state))
1205 .unwrap();
1206 }
1207
1208 #[test]
1209 fn test_render_footer_no_panic() {
1210 let mut terminal = create_test_terminal();
1211 let state = create_populated_state();
1212 terminal
1213 .draw(|f| render_footer(f, f.area(), &state))
1214 .unwrap();
1215 }
1216
1217 #[test]
1218 fn test_render_footer_paused() {
1219 let mut terminal = create_test_terminal();
1220 let token_data = create_test_token_data();
1221 let mut state = MonitorState::new(&token_data, "ethereum");
1222 state.paused = true;
1223 terminal
1224 .draw(|f| render_footer(f, f.area(), &state))
1225 .unwrap();
1226 }
1227
1228 #[test]
1229 fn test_render_all_components() {
1230 let mut terminal = create_test_terminal();
1232 let mut state = create_populated_state();
1233 terminal
1234 .draw(|f| {
1235 let area = f.area();
1236 let chunks = Layout::default()
1237 .direction(Direction::Vertical)
1238 .constraints([
1239 Constraint::Length(3),
1240 Constraint::Min(10),
1241 Constraint::Length(5),
1242 Constraint::Length(3),
1243 Constraint::Length(3),
1244 ])
1245 .split(area);
1246 render_header(f, chunks[0], &state);
1247 render_price_chart(f, chunks[1], &state);
1248 render_volume_chart(f, chunks[2], &state);
1249 render_buy_sell_gauge(f, chunks[3], &mut state);
1250 render_footer(f, chunks[4], &state);
1251 })
1252 .unwrap();
1253 }
1254
1255 #[test]
1256 fn test_render_candlestick_mode() {
1257 let mut terminal = create_test_terminal();
1258 let mut state = create_populated_state();
1259 state.chart_mode = ChartMode::Candlestick;
1260 terminal
1261 .draw(|f| {
1262 let area = f.area();
1263 let chunks = Layout::default()
1264 .direction(Direction::Vertical)
1265 .constraints([Constraint::Length(3), Constraint::Min(10)])
1266 .split(area);
1267 render_header(f, chunks[0], &state);
1268 render_candlestick_chart(f, chunks[1], &state);
1269 })
1270 .unwrap();
1271 }
1272
1273 #[test]
1274 fn test_render_with_different_time_periods() {
1275 let mut terminal = create_test_terminal();
1276 let mut state = create_populated_state();
1277
1278 for period in [
1279 TimePeriod::Min1,
1280 TimePeriod::Min5,
1281 TimePeriod::Min15,
1282 TimePeriod::Hour1,
1283 TimePeriod::Hour4,
1284 TimePeriod::Day1,
1285 ] {
1286 state.time_period = period;
1287 terminal
1288 .draw(|f| render_price_chart(f, f.area(), &state))
1289 .unwrap();
1290 }
1291 }
1292
1293 #[test]
1294 fn test_render_metrics_with_stablecoin() {
1295 let mut terminal = create_test_terminal();
1296 let mut token_data = create_test_token_data();
1297 token_data.price_usd = 0.999;
1298 token_data.symbol = "USDC".to_string();
1299 let state = MonitorState::new(&token_data, "ethereum");
1300 terminal
1301 .draw(|f| render_metrics_panel(f, f.area(), &state))
1302 .unwrap();
1303 }
1304
1305 #[test]
1306 fn test_render_header_with_negative_change() {
1307 let mut terminal = create_test_terminal();
1308 let mut token_data = create_test_token_data();
1309 token_data.price_change_24h = -15.5;
1310 token_data.price_change_1h = -2.3;
1311 let state = MonitorState::new(&token_data, "ethereum");
1312 terminal
1313 .draw(|f| render_header(f, f.area(), &state))
1314 .unwrap();
1315 }
1316
1317 #[test]
1322 fn test_toggle_chart_mode_roundtrip() {
1323 let token_data = create_test_token_data();
1324 let mut state = MonitorState::new(&token_data, "ethereum");
1325 assert_eq!(state.chart_mode, ChartMode::Line);
1326 state.toggle_chart_mode();
1327 assert_eq!(state.chart_mode, ChartMode::Candlestick);
1328 state.toggle_chart_mode();
1329 assert_eq!(state.chart_mode, ChartMode::VolumeProfile);
1330 state.toggle_chart_mode();
1331 assert_eq!(state.chart_mode, ChartMode::Line);
1332 }
1333
1334 #[test]
1335 fn test_cycle_all_time_periods() {
1336 let token_data = create_test_token_data();
1337 let mut state = MonitorState::new(&token_data, "ethereum");
1338 assert_eq!(state.time_period, TimePeriod::Hour1);
1339 state.cycle_time_period();
1340 assert_eq!(state.time_period, TimePeriod::Hour4);
1341 state.cycle_time_period();
1342 assert_eq!(state.time_period, TimePeriod::Day1);
1343 state.cycle_time_period();
1344 assert_eq!(state.time_period, TimePeriod::Min1);
1345 state.cycle_time_period();
1346 assert_eq!(state.time_period, TimePeriod::Min5);
1347 state.cycle_time_period();
1348 assert_eq!(state.time_period, TimePeriod::Min15);
1349 state.cycle_time_period();
1350 assert_eq!(state.time_period, TimePeriod::Hour1);
1351 }
1352
1353 #[test]
1354 fn test_set_specific_time_period() {
1355 let token_data = create_test_token_data();
1356 let mut state = MonitorState::new(&token_data, "ethereum");
1357 state.set_time_period(TimePeriod::Day1);
1358 assert_eq!(state.time_period, TimePeriod::Day1);
1359 }
1360
1361 #[test]
1362 fn test_pause_resume_roundtrip() {
1363 let token_data = create_test_token_data();
1364 let mut state = MonitorState::new(&token_data, "ethereum");
1365 assert!(!state.paused);
1366 state.toggle_pause();
1367 assert!(state.paused);
1368 state.toggle_pause();
1369 assert!(!state.paused);
1370 }
1371
1372 #[test]
1373 fn test_force_refresh_unpauses() {
1374 let token_data = create_test_token_data();
1375 let mut state = MonitorState::new(&token_data, "ethereum");
1376 state.paused = true;
1377 state.force_refresh();
1378 assert!(!state.paused);
1379 assert!(state.should_refresh());
1380 }
1381
1382 #[test]
1383 fn test_refresh_rate_adjust() {
1384 let token_data = create_test_token_data();
1385 let mut state = MonitorState::new(&token_data, "ethereum");
1386 assert_eq!(state.refresh_rate_secs(), 5);
1387
1388 state.slower_refresh();
1389 assert_eq!(state.refresh_rate_secs(), 10);
1390
1391 state.faster_refresh();
1392 assert_eq!(state.refresh_rate_secs(), 5);
1393 }
1394
1395 #[test]
1396 fn test_faster_refresh_clamped_min() {
1397 let token_data = create_test_token_data();
1398 let mut state = MonitorState::new(&token_data, "ethereum");
1399 for _ in 0..10 {
1400 state.faster_refresh();
1401 }
1402 assert!(state.refresh_rate_secs() >= 1);
1403 }
1404
1405 #[test]
1406 fn test_slower_refresh_clamped_max() {
1407 let token_data = create_test_token_data();
1408 let mut state = MonitorState::new(&token_data, "ethereum");
1409 for _ in 0..20 {
1410 state.slower_refresh();
1411 }
1412 assert!(state.refresh_rate_secs() <= 60);
1413 }
1414
1415 #[test]
1416 fn test_buy_ratio_balanced() {
1417 let mut token_data = create_test_token_data();
1418 token_data.total_buys_24h = 100;
1419 token_data.total_sells_24h = 100;
1420 let state = MonitorState::new(&token_data, "ethereum");
1421 assert!((state.buy_ratio() - 0.5).abs() < 0.01);
1422 }
1423
1424 #[test]
1425 fn test_buy_ratio_no_trades() {
1426 let mut token_data = create_test_token_data();
1427 token_data.total_buys_24h = 0;
1428 token_data.total_sells_24h = 0;
1429 let state = MonitorState::new(&token_data, "ethereum");
1430 assert!((state.buy_ratio() - 0.5).abs() < 0.01);
1431 }
1432
1433 #[test]
1434 fn test_data_stats_initial() {
1435 let token_data = create_test_token_data();
1436 let state = MonitorState::new(&token_data, "ethereum");
1437 let (synthetic, real) = state.data_stats();
1438 assert!(synthetic > 0 || real == 0);
1439 }
1440
1441 #[test]
1442 fn test_memory_usage_nonzero() {
1443 let token_data = create_test_token_data();
1444 let state = MonitorState::new(&token_data, "ethereum");
1445 let usage = state.memory_usage();
1446 assert!(usage > 0);
1447 }
1448
1449 #[test]
1450 fn test_price_data_for_period() {
1451 let token_data = create_test_token_data();
1452 let state = MonitorState::new(&token_data, "ethereum");
1453 let (data, is_real) = state.get_price_data_for_period();
1454 assert_eq!(data.len(), is_real.len());
1455 }
1456
1457 #[test]
1458 fn test_volume_data_for_period() {
1459 let token_data = create_test_token_data();
1460 let state = MonitorState::new(&token_data, "ethereum");
1461 let (data, is_real) = state.get_volume_data_for_period();
1462 assert_eq!(data.len(), is_real.len());
1463 }
1464
1465 #[test]
1466 fn test_ohlc_candles_generation() {
1467 let token_data = create_test_token_data();
1468 let state = MonitorState::new(&token_data, "ethereum");
1469 let candles = state.get_ohlc_candles();
1470 for candle in &candles {
1471 assert!(candle.high >= candle.low);
1472 }
1473 }
1474
1475 #[test]
1476 fn test_state_update_with_new_data() {
1477 let token_data = create_test_token_data();
1478 let mut state = MonitorState::new(&token_data, "ethereum");
1479 let initial_count = state.real_data_count;
1480
1481 let mut updated_data = create_test_token_data();
1482 updated_data.price_usd = 2.0;
1483 updated_data.volume_24h = 2_000_000.0;
1484
1485 state.update(&updated_data);
1486 assert_eq!(state.current_price, 2.0);
1487 assert_eq!(state.real_data_count, initial_count + 1);
1488 assert!(state.error_message.is_none());
1489 }
1490
1491 #[test]
1492 fn test_cache_roundtrip_save_load() {
1493 let token_data = create_test_token_data();
1494 let state = MonitorState::new(&token_data, "ethereum");
1495
1496 state.save_cache();
1497
1498 let cache_path = MonitorState::cache_path(&token_data.address, "ethereum");
1499 assert!(cache_path.exists());
1500
1501 let cached = MonitorState::load_cache(&token_data.address, "ethereum");
1502 assert!(cached.is_some());
1503
1504 let _ = std::fs::remove_file(cache_path);
1505 }
1506
1507 #[test]
1508 fn test_should_refresh_when_paused() {
1509 let token_data = create_test_token_data();
1510 let mut state = MonitorState::new(&token_data, "ethereum");
1511 assert!(!state.should_refresh());
1512 state.paused = true;
1513 assert!(!state.should_refresh());
1514 }
1515
1516 #[test]
1517 fn test_ohlc_candle_lifecycle() {
1518 let mut candle = OhlcCandle::new(1700000000.0, 100.0);
1519 assert_eq!(candle.open, 100.0);
1520 assert!(candle.is_bullish);
1521 candle.update(110.0);
1522 assert_eq!(candle.high, 110.0);
1523 assert!(candle.is_bullish);
1524 candle.update(90.0);
1525 assert_eq!(candle.low, 90.0);
1526 assert!(!candle.is_bullish);
1527 }
1528
1529 #[test]
1530 fn test_time_period_display_impl() {
1531 assert_eq!(format!("{}", TimePeriod::Min1), "1m");
1532 assert_eq!(format!("{}", TimePeriod::Min15), "15m");
1533 assert_eq!(format!("{}", TimePeriod::Day1), "1d");
1534 }
1535
1536 #[test]
1537 fn test_log_messages_accumulate() {
1538 let token_data = create_test_token_data();
1539 let mut state = MonitorState::new(&token_data, "ethereum");
1540 state.toggle_pause();
1542 state.toggle_pause();
1543 state.cycle_time_period();
1544 state.toggle_chart_mode();
1545 assert!(!state.log_messages.is_empty());
1546 }
1547
1548 #[test]
1549 fn test_ui_function_full_render() {
1550 let mut terminal = create_test_terminal();
1552 let mut state = create_populated_state();
1553 terminal.draw(|f| ui(f, &mut state)).unwrap();
1554 }
1555
1556 #[test]
1557 fn test_ui_function_candlestick_mode() {
1558 let mut terminal = create_test_terminal();
1559 let mut state = create_populated_state();
1560 state.chart_mode = ChartMode::Candlestick;
1561 terminal.draw(|f| ui(f, &mut state)).unwrap();
1562 }
1563
1564 #[test]
1565 fn test_ui_function_with_error_message() {
1566 let mut terminal = create_test_terminal();
1567 let mut state = create_populated_state();
1568 state.error_message = Some("Test error".to_string());
1569 terminal.draw(|f| ui(f, &mut state)).unwrap();
1570 }
1571
1572 #[test]
1573 fn test_render_header_with_small_positive_change() {
1574 let mut terminal = create_test_terminal();
1575 let mut state = create_populated_state();
1576 state.price_change_24h = 0.3; terminal
1578 .draw(|f| render_header(f, f.area(), &state))
1579 .unwrap();
1580 }
1581
1582 #[test]
1583 fn test_render_header_with_small_negative_change() {
1584 let mut terminal = create_test_terminal();
1585 let mut state = create_populated_state();
1586 state.price_change_24h = -0.3; terminal
1588 .draw(|f| render_header(f, f.area(), &state))
1589 .unwrap();
1590 }
1591
1592 #[test]
1593 fn test_render_buy_sell_gauge_high_buy_ratio() {
1594 let mut terminal = create_test_terminal();
1595 let token_data = create_test_token_data();
1596 let mut state = MonitorState::new(&token_data, "ethereum");
1597 state.buys_24h = 100;
1598 state.sells_24h = 10;
1599 terminal
1600 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
1601 .unwrap();
1602 }
1603
1604 #[test]
1605 fn test_render_buy_sell_gauge_zero_total() {
1606 let mut terminal = create_test_terminal();
1607 let token_data = create_test_token_data();
1608 let mut state = MonitorState::new(&token_data, "ethereum");
1609 state.buys_24h = 0;
1610 state.sells_24h = 0;
1611 terminal
1612 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
1613 .unwrap();
1614 }
1615
1616 #[test]
1617 fn test_render_metrics_with_market_cap() {
1618 let mut terminal = create_test_terminal();
1619 let token_data = create_test_token_data();
1620 let mut state = MonitorState::new(&token_data, "ethereum");
1621 state.market_cap = Some(1_000_000_000.0);
1622 state.fdv = Some(2_000_000_000.0);
1623 terminal
1624 .draw(|f| render_metrics_panel(f, f.area(), &state))
1625 .unwrap();
1626 }
1627
1628 #[test]
1629 fn test_render_footer_with_error() {
1630 let mut terminal = create_test_terminal();
1631 let mut state = create_populated_state();
1632 state.error_message = Some("Connection failed".to_string());
1633 terminal
1634 .draw(|f| render_footer(f, f.area(), &state))
1635 .unwrap();
1636 }
1637
1638 #[test]
1639 fn test_format_price_usd_various() {
1640 assert!(!format_price_usd(0.0000001).is_empty());
1642 assert!(!format_price_usd(0.001).is_empty());
1643 assert!(!format_price_usd(1.0).is_empty());
1644 assert!(!format_price_usd(100.0).is_empty());
1645 assert!(!format_price_usd(10000.0).is_empty());
1646 assert!(!format_price_usd(1000000.0).is_empty());
1647 }
1648
1649 #[test]
1650 fn test_format_usd_various() {
1651 assert!(!scope::display::format_usd(0.0).is_empty());
1652 assert!(!scope::display::format_usd(999.0).is_empty());
1653 assert!(!scope::display::format_usd(1500.0).is_empty());
1654 assert!(!scope::display::format_usd(1_500_000.0).is_empty());
1655 assert!(!scope::display::format_usd(1_500_000_000.0).is_empty());
1656 assert!(!scope::display::format_usd(1_500_000_000_000.0).is_empty());
1657 }
1658
1659 #[test]
1660 fn test_format_number_various() {
1661 assert!(!format_number(0.0).is_empty());
1662 assert!(!format_number(999.0).is_empty());
1663 assert!(!format_number(1500.0).is_empty());
1664 assert!(!format_number(1_500_000.0).is_empty());
1665 assert!(!format_number(1_500_000_000.0).is_empty());
1666 }
1667
1668 #[test]
1669 fn test_render_with_min15_period() {
1670 let mut terminal = create_test_terminal();
1671 let mut state = create_populated_state();
1672 state.set_time_period(TimePeriod::Min15);
1673 terminal.draw(|f| ui(f, &mut state)).unwrap();
1674 }
1675
1676 #[test]
1677 fn test_render_with_hour6_period() {
1678 let mut terminal = create_test_terminal();
1679 let mut state = create_populated_state();
1680 state.set_time_period(TimePeriod::Hour4);
1681 terminal.draw(|f| ui(f, &mut state)).unwrap();
1682 }
1683
1684 #[test]
1685 fn test_ui_with_fresh_state_no_real_data() {
1686 let mut terminal = create_test_terminal();
1687 let token_data = create_test_token_data();
1688 let mut state = MonitorState::new(&token_data, "ethereum");
1689 terminal.draw(|f| ui(f, &mut state)).unwrap();
1691 }
1692
1693 #[test]
1694 fn test_ui_with_paused_state() {
1695 let mut terminal = create_test_terminal();
1696 let mut state = create_populated_state();
1697 state.toggle_pause();
1698 terminal.draw(|f| ui(f, &mut state)).unwrap();
1699 }
1700
1701 #[test]
1702 fn test_render_all_with_different_time_periods_and_modes() {
1703 let mut terminal = create_test_terminal();
1704 let mut state = create_populated_state();
1705
1706 for period in &[
1708 TimePeriod::Min1,
1709 TimePeriod::Min5,
1710 TimePeriod::Min15,
1711 TimePeriod::Hour1,
1712 TimePeriod::Hour4,
1713 TimePeriod::Day1,
1714 ] {
1715 for mode in &[
1716 ChartMode::Line,
1717 ChartMode::Candlestick,
1718 ChartMode::VolumeProfile,
1719 ] {
1720 state.set_time_period(*period);
1721 state.chart_mode = *mode;
1722 terminal.draw(|f| ui(f, &mut state)).unwrap();
1723 }
1724 }
1725 }
1726
1727 #[test]
1728 fn test_render_metrics_with_large_values() {
1729 let mut terminal = create_test_terminal();
1730 let mut state = create_populated_state();
1731 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
1736 .draw(|f| render_metrics_panel(f, f.area(), &state))
1737 .unwrap();
1738 }
1739
1740 #[test]
1741 fn test_render_header_large_positive_change() {
1742 let mut terminal = create_test_terminal();
1743 let mut state = create_populated_state();
1744 state.price_change_24h = 50.0; terminal
1746 .draw(|f| render_header(f, f.area(), &state))
1747 .unwrap();
1748 }
1749
1750 #[test]
1751 fn test_render_header_large_negative_change() {
1752 let mut terminal = create_test_terminal();
1753 let mut state = create_populated_state();
1754 state.price_change_24h = -50.0; terminal
1756 .draw(|f| render_header(f, f.area(), &state))
1757 .unwrap();
1758 }
1759
1760 #[test]
1761 fn test_render_price_chart_empty_data() {
1762 let mut terminal = create_test_terminal();
1763 let token_data = create_test_token_data();
1764 let mut state = MonitorState::new(&token_data, "ethereum");
1766 state.price_history.clear();
1767 terminal
1768 .draw(|f| render_price_chart(f, f.area(), &state))
1769 .unwrap();
1770 }
1771
1772 #[test]
1773 fn test_render_price_chart_price_down() {
1774 let mut terminal = create_test_terminal();
1775 let mut state = create_populated_state();
1776 state.price_change_24h = -15.0;
1778 state.current_price = 0.5; terminal
1780 .draw(|f| render_price_chart(f, f.area(), &state))
1781 .unwrap();
1782 }
1783
1784 #[test]
1785 fn test_render_price_chart_zero_first_price() {
1786 let mut terminal = create_test_terminal();
1787 let mut token_data = create_test_token_data();
1788 token_data.price_usd = 0.0;
1789 let state = MonitorState::new(&token_data, "ethereum");
1790 terminal
1791 .draw(|f| render_price_chart(f, f.area(), &state))
1792 .unwrap();
1793 }
1794
1795 #[test]
1796 fn test_render_metrics_panel_zero_5m_change() {
1797 let mut terminal = create_test_terminal();
1798 let mut state = create_populated_state();
1799 state.price_change_5m = 0.0; terminal
1801 .draw(|f| render_metrics_panel(f, f.area(), &state))
1802 .unwrap();
1803 }
1804
1805 #[test]
1806 fn test_render_metrics_panel_positive_5m_change() {
1807 let mut terminal = create_test_terminal();
1808 let mut state = create_populated_state();
1809 state.price_change_5m = 5.0; terminal
1811 .draw(|f| render_metrics_panel(f, f.area(), &state))
1812 .unwrap();
1813 }
1814
1815 #[test]
1816 fn test_render_metrics_panel_negative_5m_change() {
1817 let mut terminal = create_test_terminal();
1818 let mut state = create_populated_state();
1819 state.price_change_5m = -3.0; terminal
1821 .draw(|f| render_metrics_panel(f, f.area(), &state))
1822 .unwrap();
1823 }
1824
1825 #[test]
1826 fn test_render_metrics_panel_negative_24h_change() {
1827 let mut terminal = create_test_terminal();
1828 let mut state = create_populated_state();
1829 state.price_change_24h = -10.0;
1830 terminal
1831 .draw(|f| render_metrics_panel(f, f.area(), &state))
1832 .unwrap();
1833 }
1834
1835 #[test]
1836 fn test_render_metrics_panel_old_last_change() {
1837 let mut terminal = create_test_terminal();
1838 let mut state = create_populated_state();
1839 state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 7200.0; terminal
1842 .draw(|f| render_metrics_panel(f, f.area(), &state))
1843 .unwrap();
1844 }
1845
1846 #[test]
1847 fn test_render_metrics_panel_minutes_ago_change() {
1848 let mut terminal = create_test_terminal();
1849 let mut state = create_populated_state();
1850 state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 300.0; terminal
1853 .draw(|f| render_metrics_panel(f, f.area(), &state))
1854 .unwrap();
1855 }
1856
1857 #[test]
1858 fn test_render_candlestick_empty_fresh_state() {
1859 let mut terminal = create_test_terminal();
1860 let token_data = create_test_token_data();
1861 let mut state = MonitorState::new(&token_data, "ethereum");
1862 state.price_history.clear();
1863 state.chart_mode = ChartMode::Candlestick;
1864 terminal
1865 .draw(|f| render_candlestick_chart(f, f.area(), &state))
1866 .unwrap();
1867 }
1868
1869 #[test]
1870 fn test_render_candlestick_price_down() {
1871 let mut terminal = create_test_terminal();
1872 let token_data = create_test_token_data();
1873 let mut state = MonitorState::new(&token_data, "ethereum");
1874 for i in 0..20 {
1876 let mut data = token_data.clone();
1877 data.price_usd = 2.0 - (i as f64 * 0.05);
1878 state.update(&data);
1879 }
1880 state.chart_mode = ChartMode::Candlestick;
1881 terminal
1882 .draw(|f| render_candlestick_chart(f, f.area(), &state))
1883 .unwrap();
1884 }
1885
1886 #[test]
1887 fn test_render_volume_chart_with_many_points() {
1888 let mut terminal = create_test_terminal();
1889 let token_data = create_test_token_data();
1890 let mut state = MonitorState::new(&token_data, "ethereum");
1891 for i in 0..100 {
1893 let mut data = token_data.clone();
1894 data.volume_24h = 1_000_000.0 + (i as f64 * 50_000.0);
1895 data.price_usd = 1.0 + (i as f64 * 0.001);
1896 state.update(&data);
1897 }
1898 terminal
1899 .draw(|f| render_volume_chart(f, f.area(), &state))
1900 .unwrap();
1901 }
1902
1903 fn make_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
1908 crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)
1909 }
1910
1911 fn make_ctrl_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
1912 crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
1913 }
1914
1915 #[test]
1916 fn test_handle_key_quit_q() {
1917 let token_data = create_test_token_data();
1918 let mut state = MonitorState::new(&token_data, "ethereum");
1919 assert!(handle_key_event_on_state(
1920 make_key_event(KeyCode::Char('q')),
1921 &mut state
1922 ));
1923 }
1924
1925 #[test]
1926 fn test_handle_key_quit_esc() {
1927 let token_data = create_test_token_data();
1928 let mut state = MonitorState::new(&token_data, "ethereum");
1929 assert!(handle_key_event_on_state(
1930 make_key_event(KeyCode::Esc),
1931 &mut state
1932 ));
1933 }
1934
1935 #[test]
1936 fn test_handle_key_quit_ctrl_c() {
1937 let token_data = create_test_token_data();
1938 let mut state = MonitorState::new(&token_data, "ethereum");
1939 assert!(handle_key_event_on_state(
1940 make_ctrl_key_event(KeyCode::Char('c')),
1941 &mut state
1942 ));
1943 }
1944
1945 #[test]
1946 fn test_handle_key_refresh() {
1947 let token_data = create_test_token_data();
1948 let mut state = MonitorState::new(&token_data, "ethereum");
1949 state.refresh_rate = Duration::from_secs(60);
1950 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('r')), &mut state);
1952 assert!(!exit);
1953 assert!(state.should_refresh());
1955 }
1956
1957 #[test]
1958 fn test_handle_key_pause_toggle() {
1959 let token_data = create_test_token_data();
1960 let mut state = MonitorState::new(&token_data, "ethereum");
1961 assert!(!state.paused);
1962
1963 handle_key_event_on_state(make_key_event(KeyCode::Char('p')), &mut state);
1964 assert!(state.paused);
1965
1966 handle_key_event_on_state(make_key_event(KeyCode::Char(' ')), &mut state);
1967 assert!(!state.paused);
1968 }
1969
1970 #[test]
1971 fn test_handle_key_slower_refresh() {
1972 let token_data = create_test_token_data();
1973 let mut state = MonitorState::new(&token_data, "ethereum");
1974 let initial = state.refresh_rate;
1975
1976 handle_key_event_on_state(make_key_event(KeyCode::Char('+')), &mut state);
1977 assert!(state.refresh_rate > initial);
1978
1979 state.refresh_rate = initial;
1980 handle_key_event_on_state(make_key_event(KeyCode::Char('=')), &mut state);
1981 assert!(state.refresh_rate > initial);
1982
1983 state.refresh_rate = initial;
1984 handle_key_event_on_state(make_key_event(KeyCode::Char(']')), &mut state);
1985 assert!(state.refresh_rate > initial);
1986 }
1987
1988 #[test]
1989 fn test_handle_key_faster_refresh() {
1990 let token_data = create_test_token_data();
1991 let mut state = MonitorState::new(&token_data, "ethereum");
1992 state.refresh_rate = Duration::from_secs(30);
1994 let initial = state.refresh_rate;
1995
1996 handle_key_event_on_state(make_key_event(KeyCode::Char('-')), &mut state);
1997 assert!(state.refresh_rate < initial);
1998
1999 state.refresh_rate = initial;
2000 handle_key_event_on_state(make_key_event(KeyCode::Char('_')), &mut state);
2001 assert!(state.refresh_rate < initial);
2002
2003 state.refresh_rate = initial;
2004 handle_key_event_on_state(make_key_event(KeyCode::Char('[')), &mut state);
2005 assert!(state.refresh_rate < initial);
2006 }
2007
2008 #[test]
2009 fn test_handle_key_time_periods() {
2010 let token_data = create_test_token_data();
2011 let mut state = MonitorState::new(&token_data, "ethereum");
2012
2013 handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
2014 assert!(matches!(state.time_period, TimePeriod::Min1));
2015
2016 handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
2017 assert!(matches!(state.time_period, TimePeriod::Min5));
2018
2019 handle_key_event_on_state(make_key_event(KeyCode::Char('3')), &mut state);
2020 assert!(matches!(state.time_period, TimePeriod::Min15));
2021
2022 handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
2023 assert!(matches!(state.time_period, TimePeriod::Hour1));
2024
2025 handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
2026 assert!(matches!(state.time_period, TimePeriod::Hour4));
2027
2028 handle_key_event_on_state(make_key_event(KeyCode::Char('6')), &mut state);
2029 assert!(matches!(state.time_period, TimePeriod::Day1));
2030 }
2031
2032 #[test]
2033 fn test_handle_key_cycle_time_period() {
2034 let token_data = create_test_token_data();
2035 let mut state = MonitorState::new(&token_data, "ethereum");
2036
2037 handle_key_event_on_state(make_key_event(KeyCode::Char('t')), &mut state);
2038 let first = state.time_period;
2040
2041 handle_key_event_on_state(make_key_event(KeyCode::Tab), &mut state);
2042 let _ = state.time_period;
2045 let _ = first;
2046 }
2047
2048 #[test]
2049 fn test_handle_key_toggle_chart_mode() {
2050 let token_data = create_test_token_data();
2051 let mut state = MonitorState::new(&token_data, "ethereum");
2052 let initial_mode = state.chart_mode;
2053
2054 handle_key_event_on_state(make_key_event(KeyCode::Char('c')), &mut state);
2055 assert!(state.chart_mode != initial_mode);
2056 }
2057
2058 #[test]
2059 fn test_handle_key_unknown_no_op() {
2060 let token_data = create_test_token_data();
2061 let mut state = MonitorState::new(&token_data, "ethereum");
2062 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
2063 assert!(!exit);
2064 }
2065
2066 #[test]
2071 fn test_save_and_load_cache() {
2072 let token_data = create_test_token_data();
2073 let mut state = MonitorState::new(&token_data, "ethereum");
2074 state.price_history.push_back(DataPoint {
2075 timestamp: 1.0,
2076 value: 100.0,
2077 is_real: true,
2078 });
2079 state.price_history.push_back(DataPoint {
2080 timestamp: 2.0,
2081 value: 101.0,
2082 is_real: true,
2083 });
2084 state.volume_history.push_back(DataPoint {
2085 timestamp: 1.0,
2086 value: 5000.0,
2087 is_real: true,
2088 });
2089
2090 state.save_cache();
2093 let cached = MonitorState::load_cache(&state.token_address, &state.chain);
2094 if let Some(c) = cached {
2096 assert_eq!(
2097 c.token_address.to_lowercase(),
2098 state.token_address.to_lowercase()
2099 );
2100 }
2101 }
2102
2103 #[test]
2104 fn test_load_cache_nonexistent_token() {
2105 let cached = MonitorState::load_cache("0xNONEXISTENT_TOKEN_ADDR", "nonexistent_chain");
2106 assert!(cached.is_none());
2107 }
2108
2109 #[test]
2114 fn test_render_volume_barchart_with_populated_data() {
2115 let mut terminal = create_test_terminal();
2118 let mut state = create_populated_state();
2119 for period in [
2120 TimePeriod::Min1,
2121 TimePeriod::Min5,
2122 TimePeriod::Min15,
2123 TimePeriod::Hour1,
2124 TimePeriod::Hour4,
2125 TimePeriod::Day1,
2126 ] {
2127 state.set_time_period(period);
2128 terminal
2129 .draw(|f| render_volume_chart(f, f.area(), &state))
2130 .unwrap();
2131 }
2132 }
2133
2134 #[test]
2135 fn test_render_volume_barchart_narrow_terminal() {
2136 let backend = TestBackend::new(20, 10);
2138 let mut terminal = Terminal::new(backend).unwrap();
2139 let state = create_populated_state();
2140 terminal
2141 .draw(|f| render_volume_chart(f, f.area(), &state))
2142 .unwrap();
2143 }
2144
2145 #[test]
2146 fn test_render_metrics_table_sparkline_no_panic() {
2147 let mut terminal = create_test_terminal();
2149 let state = create_populated_state();
2150 terminal
2151 .draw(|f| render_metrics_panel(f, f.area(), &state))
2152 .unwrap();
2153 }
2154
2155 #[test]
2156 fn test_render_metrics_table_sparkline_all_periods() {
2157 let mut terminal = create_test_terminal();
2159 let mut state = create_populated_state();
2160 for period in [
2161 TimePeriod::Min1,
2162 TimePeriod::Min5,
2163 TimePeriod::Min15,
2164 TimePeriod::Hour1,
2165 TimePeriod::Hour4,
2166 TimePeriod::Day1,
2167 ] {
2168 state.set_time_period(period);
2169 terminal
2170 .draw(|f| render_metrics_panel(f, f.area(), &state))
2171 .unwrap();
2172 }
2173 }
2174
2175 #[test]
2176 fn test_render_metrics_sparkline_trend_direction() {
2177 let mut terminal = create_test_terminal();
2179 let mut state = create_populated_state();
2180 state.price_change_5m = -3.5;
2181 terminal
2182 .draw(|f| render_metrics_panel(f, f.area(), &state))
2183 .unwrap();
2184
2185 state.price_change_5m = 2.0;
2187 terminal
2188 .draw(|f| render_metrics_panel(f, f.area(), &state))
2189 .unwrap();
2190
2191 state.price_change_5m = 0.0;
2193 terminal
2194 .draw(|f| render_metrics_panel(f, f.area(), &state))
2195 .unwrap();
2196 }
2197
2198 #[test]
2199 fn test_render_tabs_time_period() {
2200 let mut terminal = create_test_terminal();
2202 let mut state = create_populated_state();
2203 for period in [
2204 TimePeriod::Min1,
2205 TimePeriod::Min5,
2206 TimePeriod::Min15,
2207 TimePeriod::Hour1,
2208 TimePeriod::Hour4,
2209 TimePeriod::Day1,
2210 ] {
2211 state.set_time_period(period);
2212 terminal
2213 .draw(|f| render_header(f, f.area(), &state))
2214 .unwrap();
2215 }
2216 }
2217
2218 #[test]
2219 fn test_time_period_index() {
2220 assert_eq!(TimePeriod::Min1.index(), 0);
2221 assert_eq!(TimePeriod::Min5.index(), 1);
2222 assert_eq!(TimePeriod::Min15.index(), 2);
2223 assert_eq!(TimePeriod::Hour1.index(), 3);
2224 assert_eq!(TimePeriod::Hour4.index(), 4);
2225 assert_eq!(TimePeriod::Day1.index(), 5);
2226 }
2227
2228 #[test]
2229 fn test_scroll_log_down_from_start() {
2230 let token_data = create_test_token_data();
2231 let mut state = MonitorState::new(&token_data, "ethereum");
2232 state.log_messages.push_back("msg 1".to_string());
2233 state.log_messages.push_back("msg 2".to_string());
2234 state.log_messages.push_back("msg 3".to_string());
2235
2236 assert_eq!(state.log_list_state.selected(), None);
2238
2239 state.scroll_log_down();
2241 assert_eq!(state.log_list_state.selected(), Some(0));
2242
2243 state.scroll_log_down();
2245 assert_eq!(state.log_list_state.selected(), Some(1));
2246
2247 state.scroll_log_down();
2249 assert_eq!(state.log_list_state.selected(), Some(2));
2250
2251 state.scroll_log_down();
2253 assert_eq!(state.log_list_state.selected(), Some(2));
2254 }
2255
2256 #[test]
2257 fn test_scroll_log_up_from_start() {
2258 let token_data = create_test_token_data();
2259 let mut state = MonitorState::new(&token_data, "ethereum");
2260 state.log_messages.push_back("msg 1".to_string());
2261 state.log_messages.push_back("msg 2".to_string());
2262 state.log_messages.push_back("msg 3".to_string());
2263
2264 state.scroll_log_up();
2266 assert_eq!(state.log_list_state.selected(), Some(0));
2267
2268 state.scroll_log_up();
2270 assert_eq!(state.log_list_state.selected(), Some(0));
2271 }
2272
2273 #[test]
2274 fn test_scroll_log_up_down_roundtrip() {
2275 let token_data = create_test_token_data();
2276 let mut state = MonitorState::new(&token_data, "ethereum");
2277 for i in 0..10 {
2278 state.log_messages.push_back(format!("msg {}", i));
2279 }
2280
2281 for _ in 0..5 {
2283 state.scroll_log_down();
2284 }
2285 assert_eq!(state.log_list_state.selected(), Some(4));
2286
2287 for _ in 0..3 {
2289 state.scroll_log_up();
2290 }
2291 assert_eq!(state.log_list_state.selected(), Some(1));
2292 }
2293
2294 #[test]
2295 fn test_scroll_log_empty_no_panic() {
2296 let token_data = create_test_token_data();
2297 let mut state = MonitorState::new(&token_data, "ethereum");
2298 state.scroll_log_down();
2300 state.scroll_log_up();
2301 assert!(
2302 state.log_list_state.selected().is_none() || state.log_list_state.selected() == Some(0)
2303 );
2304 }
2305
2306 #[test]
2307 fn test_render_scrollable_activity_log() {
2308 let mut terminal = create_test_terminal();
2310 let mut state = create_populated_state();
2311 for i in 0..20 {
2312 state
2313 .log_messages
2314 .push_back(format!("Activity event #{}", i));
2315 }
2316 state.scroll_log_down();
2318 state.scroll_log_down();
2319 state.scroll_log_down();
2320
2321 terminal
2322 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
2323 .unwrap();
2324 }
2325
2326 #[test]
2327 fn test_handle_key_scroll_log_j_k() {
2328 let token_data = create_test_token_data();
2329 let mut state = MonitorState::new(&token_data, "ethereum");
2330 state.log_messages.push_back("line 1".to_string());
2331 state.log_messages.push_back("line 2".to_string());
2332
2333 handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
2335 assert_eq!(state.log_list_state.selected(), Some(0));
2336
2337 handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
2338 assert_eq!(state.log_list_state.selected(), Some(1));
2339
2340 handle_key_event_on_state(make_key_event(KeyCode::Char('k')), &mut state);
2342 assert_eq!(state.log_list_state.selected(), Some(0));
2343 }
2344
2345 #[test]
2346 fn test_handle_key_scroll_log_arrow_keys() {
2347 let token_data = create_test_token_data();
2348 let mut state = MonitorState::new(&token_data, "ethereum");
2349 state.log_messages.push_back("line 1".to_string());
2350 state.log_messages.push_back("line 2".to_string());
2351 state.log_messages.push_back("line 3".to_string());
2352
2353 handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
2355 assert_eq!(state.log_list_state.selected(), Some(0));
2356
2357 handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
2358 assert_eq!(state.log_list_state.selected(), Some(1));
2359
2360 handle_key_event_on_state(make_key_event(KeyCode::Up), &mut state);
2362 assert_eq!(state.log_list_state.selected(), Some(0));
2363 }
2364
2365 #[test]
2366 fn test_render_ui_with_scrolled_log() {
2367 let mut terminal = create_test_terminal();
2369 let mut state = create_populated_state();
2370 for i in 0..15 {
2371 state.log_messages.push_back(format!("Log entry {}", i));
2372 }
2373 state.scroll_log_down();
2374 state.scroll_log_down();
2375 state.scroll_log_down();
2376 state.scroll_log_down();
2377 state.scroll_log_down();
2378
2379 terminal.draw(|f| ui(f, &mut state)).unwrap();
2380 }
2381
2382 fn make_monitor_search_results() -> Vec<scope::chains::dex::TokenSearchResult> {
2387 vec![
2388 scope::chains::dex::TokenSearchResult {
2389 symbol: "USDC".to_string(),
2390 name: "USD Coin".to_string(),
2391 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
2392 chain: "ethereum".to_string(),
2393 price_usd: Some(1.0),
2394 volume_24h: 1_000_000.0,
2395 liquidity_usd: 500_000_000.0,
2396 market_cap: Some(30_000_000_000.0),
2397 },
2398 scope::chains::dex::TokenSearchResult {
2399 symbol: "USDC".to_string(),
2400 name: "Bridged USD Coin".to_string(),
2401 address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
2402 chain: "ethereum".to_string(),
2403 price_usd: Some(0.9998),
2404 volume_24h: 500_000.0,
2405 liquidity_usd: 100_000_000.0,
2406 market_cap: None,
2407 },
2408 scope::chains::dex::TokenSearchResult {
2409 symbol: "USDC".to_string(),
2410 name: "A Very Long Token Name That Exceeds The Limit".to_string(),
2411 address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
2412 chain: "ethereum".to_string(),
2413 price_usd: None,
2414 volume_24h: 0.0,
2415 liquidity_usd: 50_000.0,
2416 market_cap: None,
2417 },
2418 ]
2419 }
2420
2421 #[test]
2422 fn test_abbreviate_address_long() {
2423 let addr = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
2424 let abbr = abbreviate_address(addr);
2425 assert_eq!(abbr, "0xA0b869...06eB48");
2426 assert!(abbr.contains("..."));
2427 }
2428
2429 #[test]
2430 fn test_abbreviate_address_short() {
2431 let addr = "0x1234abcd";
2432 let abbr = abbreviate_address(addr);
2433 assert_eq!(abbr, "0x1234abcd");
2435 }
2436
2437 #[test]
2438 fn test_select_token_impl_first() {
2439 let results = make_monitor_search_results();
2440 let input = b"1\n";
2441 let mut reader = std::io::Cursor::new(&input[..]);
2442 let mut writer = Vec::new();
2443
2444 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
2445 assert_eq!(selected.name, "USD Coin");
2446 assert_eq!(
2447 selected.address,
2448 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
2449 );
2450
2451 let output = String::from_utf8(writer).unwrap();
2452 assert!(output.contains("Found 3 tokens"));
2453 assert!(output.contains("USDC"));
2454 assert!(output.contains("0xA0b869...06eB48"));
2455 assert!(output.contains("Selected:"));
2456 }
2457
2458 #[test]
2459 fn test_select_token_impl_second() {
2460 let results = make_monitor_search_results();
2461 let input = b"2\n";
2462 let mut reader = std::io::Cursor::new(&input[..]);
2463 let mut writer = Vec::new();
2464
2465 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
2466 assert_eq!(selected.name, "Bridged USD Coin");
2467 assert_eq!(
2468 selected.address,
2469 "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
2470 );
2471 }
2472
2473 #[test]
2474 fn test_select_token_impl_shows_address_column() {
2475 let results = make_monitor_search_results();
2476 let input = b"1\n";
2477 let mut reader = std::io::Cursor::new(&input[..]);
2478 let mut writer = Vec::new();
2479
2480 select_token_impl(&results, &mut reader, &mut writer).unwrap();
2481 let output = String::from_utf8(writer).unwrap();
2482
2483 assert!(output.contains("Address"));
2485 assert!(output.contains("0xA0b869...06eB48"));
2487 assert!(output.contains("0x2791Bc...a84174"));
2488 assert!(output.contains("0x123456...345678"));
2489 }
2490
2491 #[test]
2492 fn test_select_token_impl_truncates_long_name() {
2493 let results = make_monitor_search_results();
2494 let input = b"3\n";
2495 let mut reader = std::io::Cursor::new(&input[..]);
2496 let mut writer = Vec::new();
2497
2498 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
2499 assert_eq!(
2500 selected.address,
2501 "0x1234567890abcdef1234567890abcdef12345678"
2502 );
2503
2504 let output = String::from_utf8(writer).unwrap();
2505 assert!(output.contains("A Very Long Token..."));
2506 }
2507
2508 #[test]
2509 fn test_select_token_impl_invalid_input() {
2510 let results = make_monitor_search_results();
2511 let input = b"xyz\n";
2512 let mut reader = std::io::Cursor::new(&input[..]);
2513 let mut writer = Vec::new();
2514
2515 let result = select_token_impl(&results, &mut reader, &mut writer);
2516 assert!(result.is_err());
2517 assert!(
2518 result
2519 .unwrap_err()
2520 .to_string()
2521 .contains("Invalid selection")
2522 );
2523 }
2524
2525 #[test]
2526 fn test_select_token_impl_out_of_range_zero() {
2527 let results = make_monitor_search_results();
2528 let input = b"0\n";
2529 let mut reader = std::io::Cursor::new(&input[..]);
2530 let mut writer = Vec::new();
2531
2532 let result = select_token_impl(&results, &mut reader, &mut writer);
2533 assert!(result.is_err());
2534 assert!(
2535 result
2536 .unwrap_err()
2537 .to_string()
2538 .contains("Selection must be between")
2539 );
2540 }
2541
2542 #[test]
2543 fn test_select_token_impl_out_of_range_high() {
2544 let results = make_monitor_search_results();
2545 let input = b"99\n";
2546 let mut reader = std::io::Cursor::new(&input[..]);
2547 let mut writer = Vec::new();
2548
2549 let result = select_token_impl(&results, &mut reader, &mut writer);
2550 assert!(result.is_err());
2551 }
2552
2553 #[test]
2554 fn test_format_monitor_number() {
2555 assert_eq!(format_monitor_number(1_500_000_000.0), "$1.50B");
2556 assert_eq!(format_monitor_number(250_000_000.0), "$250.00M");
2557 assert_eq!(format_monitor_number(75_000.0), "$75.00K");
2558 assert_eq!(format_monitor_number(42.5), "$42.50");
2559 }
2560
2561 #[test]
2566 fn test_monitor_config_defaults() {
2567 let config = MonitorConfig::default();
2568 assert_eq!(config.layout, LayoutPreset::Dashboard);
2569 assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
2570 assert!(config.widgets.price_chart);
2571 assert!(config.widgets.volume_chart);
2572 assert!(config.widgets.buy_sell_pressure);
2573 assert!(config.widgets.metrics_panel);
2574 assert!(config.widgets.activity_log);
2575 }
2576
2577 #[test]
2578 fn test_layout_preset_next_cycles() {
2579 assert_eq!(LayoutPreset::Dashboard.next(), LayoutPreset::ChartFocus);
2580 assert_eq!(LayoutPreset::ChartFocus.next(), LayoutPreset::Feed);
2581 assert_eq!(LayoutPreset::Feed.next(), LayoutPreset::Compact);
2582 assert_eq!(LayoutPreset::Compact.next(), LayoutPreset::Exchange);
2583 assert_eq!(LayoutPreset::Exchange.next(), LayoutPreset::Dashboard);
2584 }
2585
2586 #[test]
2587 fn test_layout_preset_prev_cycles() {
2588 assert_eq!(LayoutPreset::Dashboard.prev(), LayoutPreset::Exchange);
2589 assert_eq!(LayoutPreset::Exchange.prev(), LayoutPreset::Compact);
2590 assert_eq!(LayoutPreset::Compact.prev(), LayoutPreset::Feed);
2591 assert_eq!(LayoutPreset::Feed.prev(), LayoutPreset::ChartFocus);
2592 assert_eq!(LayoutPreset::ChartFocus.prev(), LayoutPreset::Dashboard);
2593 }
2594
2595 #[test]
2596 fn test_layout_preset_full_cycle() {
2597 let start = LayoutPreset::Dashboard;
2598 let mut preset = start;
2599 for _ in 0..5 {
2600 preset = preset.next();
2601 }
2602 assert_eq!(preset, start);
2603 }
2604
2605 #[test]
2606 fn test_layout_preset_labels() {
2607 assert_eq!(LayoutPreset::Dashboard.label(), "Dashboard");
2608 assert_eq!(LayoutPreset::ChartFocus.label(), "Chart");
2609 assert_eq!(LayoutPreset::Feed.label(), "Feed");
2610 assert_eq!(LayoutPreset::Compact.label(), "Compact");
2611 assert_eq!(LayoutPreset::Exchange.label(), "Exchange");
2612 }
2613
2614 #[test]
2615 fn test_widget_visibility_default_all_visible() {
2616 let vis = WidgetVisibility::default();
2617 assert_eq!(vis.visible_count(), 5);
2618 }
2619
2620 #[test]
2621 fn test_widget_visibility_toggle_by_index() {
2622 let mut vis = WidgetVisibility::default();
2623 vis.toggle_by_index(1);
2624 assert!(!vis.price_chart);
2625 assert_eq!(vis.visible_count(), 4);
2626
2627 vis.toggle_by_index(2);
2628 assert!(!vis.volume_chart);
2629 assert_eq!(vis.visible_count(), 3);
2630
2631 vis.toggle_by_index(3);
2632 assert!(!vis.buy_sell_pressure);
2633 assert_eq!(vis.visible_count(), 2);
2634
2635 vis.toggle_by_index(4);
2636 assert!(!vis.metrics_panel);
2637 assert_eq!(vis.visible_count(), 1);
2638
2639 vis.toggle_by_index(5);
2640 assert!(!vis.activity_log);
2641 assert_eq!(vis.visible_count(), 0);
2642
2643 vis.toggle_by_index(1);
2645 assert!(vis.price_chart);
2646 assert_eq!(vis.visible_count(), 1);
2647 }
2648
2649 #[test]
2650 fn test_widget_visibility_toggle_invalid_index() {
2651 let mut vis = WidgetVisibility::default();
2652 vis.toggle_by_index(0);
2653 vis.toggle_by_index(6);
2654 vis.toggle_by_index(100);
2655 assert_eq!(vis.visible_count(), 5); }
2657
2658 #[test]
2659 fn test_auto_select_layout_small_terminal() {
2660 let size = Rect::new(0, 0, 60, 20);
2661 assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
2662 }
2663
2664 #[test]
2665 fn test_auto_select_layout_narrow_terminal() {
2666 let size = Rect::new(0, 0, 100, 40);
2667 assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
2668 }
2669
2670 #[test]
2671 fn test_auto_select_layout_short_terminal() {
2672 let size = Rect::new(0, 0, 140, 28);
2673 assert_eq!(auto_select_layout(size), LayoutPreset::ChartFocus);
2674 }
2675
2676 #[test]
2677 fn test_auto_select_layout_large_terminal() {
2678 let size = Rect::new(0, 0, 160, 50);
2679 assert_eq!(auto_select_layout(size), LayoutPreset::Dashboard);
2680 }
2681
2682 #[test]
2683 fn test_auto_select_layout_edge_80x24() {
2684 let size = Rect::new(0, 0, 80, 24);
2686 assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
2687 }
2688
2689 #[test]
2690 fn test_auto_select_layout_edge_79() {
2691 let size = Rect::new(0, 0, 79, 50);
2692 assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
2693 }
2694
2695 #[test]
2696 fn test_auto_select_layout_edge_23_height() {
2697 let size = Rect::new(0, 0, 160, 23);
2698 assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
2699 }
2700
2701 #[test]
2702 fn test_layout_dashboard_all_visible() {
2703 let area = Rect::new(0, 0, 120, 40);
2704 let vis = WidgetVisibility::default();
2705 let areas = layout_dashboard(area, &vis);
2706 assert!(areas.price_chart.is_some());
2707 assert!(areas.volume_chart.is_some());
2708 assert!(areas.buy_sell_gauge.is_some());
2709 assert!(areas.metrics_panel.is_some());
2710 assert!(areas.activity_feed.is_some());
2711 }
2712
2713 #[test]
2714 fn test_layout_dashboard_hidden_widget() {
2715 let area = Rect::new(0, 0, 120, 40);
2716 let vis = WidgetVisibility {
2717 price_chart: false,
2718 ..WidgetVisibility::default()
2719 };
2720 let areas = layout_dashboard(area, &vis);
2721 assert!(areas.price_chart.is_none());
2722 assert!(areas.volume_chart.is_some());
2723 }
2724
2725 #[test]
2726 fn test_layout_chart_focus_minimal_overlay() {
2727 let area = Rect::new(0, 0, 120, 40);
2728 let vis = WidgetVisibility::default();
2729 let areas = layout_chart_focus(area, &vis);
2730 assert!(areas.price_chart.is_some());
2731 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()); }
2736
2737 #[test]
2738 fn test_layout_feed_activity_priority() {
2739 let area = Rect::new(0, 0, 120, 40);
2740 let vis = WidgetVisibility::default();
2741 let areas = layout_feed(area, &vis);
2742 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()); }
2748
2749 #[test]
2750 fn test_layout_compact_metrics_only() {
2751 let area = Rect::new(0, 0, 60, 20);
2752 let vis = WidgetVisibility::default();
2753 let areas = layout_compact(area, &vis);
2754 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()); }
2760
2761 #[test]
2762 fn test_layout_exchange_has_order_book_and_market_info() {
2763 let area = Rect::new(0, 0, 160, 50);
2764 let vis = WidgetVisibility::default();
2765 let areas = layout_exchange(area, &vis);
2766 assert!(areas.order_book.is_some());
2767 assert!(areas.market_info.is_some());
2768 assert!(areas.price_chart.is_some());
2769 assert!(areas.buy_sell_gauge.is_some());
2770 assert!(areas.volume_chart.is_none()); assert!(areas.metrics_panel.is_none()); assert!(areas.activity_feed.is_none()); }
2774
2775 #[test]
2776 fn test_ui_render_all_layouts_no_panic() {
2777 let presets = [
2778 LayoutPreset::Dashboard,
2779 LayoutPreset::ChartFocus,
2780 LayoutPreset::Feed,
2781 LayoutPreset::Compact,
2782 LayoutPreset::Exchange,
2783 ];
2784 for preset in &presets {
2785 let mut terminal = create_test_terminal();
2786 let mut state = create_populated_state();
2787 state.layout = *preset;
2788 state.auto_layout = false; terminal.draw(|f| ui(f, &mut state)).unwrap();
2790 }
2791 }
2792
2793 #[test]
2794 fn test_ui_render_compact_small_terminal() {
2795 let backend = TestBackend::new(60, 20);
2796 let mut terminal = Terminal::new(backend).unwrap();
2797 let mut state = create_populated_state();
2798 state.layout = LayoutPreset::Compact;
2799 state.auto_layout = false;
2800 terminal.draw(|f| ui(f, &mut state)).unwrap();
2801 }
2802
2803 #[test]
2804 fn test_ui_auto_layout_selects_compact_for_small() {
2805 let backend = TestBackend::new(60, 20);
2806 let mut terminal = Terminal::new(backend).unwrap();
2807 let mut state = create_populated_state();
2808 state.layout = LayoutPreset::Dashboard;
2809 state.auto_layout = true;
2810 terminal.draw(|f| ui(f, &mut state)).unwrap();
2811 assert_eq!(state.layout, LayoutPreset::Compact);
2812 }
2813
2814 #[test]
2815 fn test_ui_auto_layout_disabled_keeps_preset() {
2816 let backend = TestBackend::new(60, 20);
2817 let mut terminal = Terminal::new(backend).unwrap();
2818 let mut state = create_populated_state();
2819 state.layout = LayoutPreset::Dashboard;
2820 state.auto_layout = false;
2821 terminal.draw(|f| ui(f, &mut state)).unwrap();
2822 assert_eq!(state.layout, LayoutPreset::Dashboard); }
2824
2825 #[test]
2826 fn test_keybinding_l_cycles_layout_forward() {
2827 let mut state = create_populated_state();
2828 state.layout = LayoutPreset::Dashboard;
2829 state.auto_layout = true;
2830
2831 handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
2832 assert_eq!(state.layout, LayoutPreset::ChartFocus);
2833 assert!(!state.auto_layout); handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
2836 assert_eq!(state.layout, LayoutPreset::Feed);
2837 }
2838
2839 #[test]
2840 fn test_keybinding_h_cycles_layout_backward() {
2841 let mut state = create_populated_state();
2842 state.layout = LayoutPreset::Dashboard;
2843 state.auto_layout = true;
2844
2845 handle_key_event_on_state(make_key_event(KeyCode::Char('h')), &mut state);
2846 assert_eq!(state.layout, LayoutPreset::Exchange);
2847 assert!(!state.auto_layout);
2848 }
2849
2850 #[test]
2851 fn test_keybinding_a_enables_auto_layout() {
2852 let mut state = create_populated_state();
2853 state.auto_layout = false;
2854
2855 handle_key_event_on_state(make_key_event(KeyCode::Char('a')), &mut state);
2856 assert!(state.auto_layout);
2857 }
2858
2859 #[test]
2860 fn test_keybinding_w_widget_toggle_mode() {
2861 let mut state = create_populated_state();
2862 assert!(!state.widget_toggle_mode);
2863
2864 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2866 assert!(state.widget_toggle_mode);
2867
2868 handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
2870 assert!(!state.widget_toggle_mode);
2871 assert!(!state.widgets.price_chart);
2872 }
2873
2874 #[test]
2875 fn test_keybinding_w_cancel_with_non_digit() {
2876 let mut state = create_populated_state();
2877
2878 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2880 assert!(state.widget_toggle_mode);
2881
2882 handle_key_event_on_state(make_key_event(KeyCode::Char('x')), &mut state);
2884 assert!(!state.widget_toggle_mode);
2885 assert!(state.widgets.price_chart); }
2887
2888 #[test]
2889 fn test_keybinding_w_toggle_multiple_widgets() {
2890 let mut state = create_populated_state();
2891
2892 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2894 handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
2895 assert!(!state.widgets.volume_chart);
2896
2897 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2899 handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
2900 assert!(!state.widgets.metrics_panel);
2901
2902 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2904 handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
2905 assert!(!state.widgets.activity_log);
2906 }
2907
2908 #[test]
2909 fn test_monitor_config_serde_roundtrip() {
2910 let config = MonitorConfig {
2911 layout: LayoutPreset::ChartFocus,
2912 refresh_seconds: 5,
2913 widgets: WidgetVisibility {
2914 price_chart: true,
2915 volume_chart: false,
2916 buy_sell_pressure: true,
2917 metrics_panel: false,
2918 activity_log: true,
2919 holder_count: true,
2920 liquidity_depth: true,
2921 },
2922 scale: ScaleMode::Log,
2923 color_scheme: ColorScheme::BlueOrange,
2924 alerts: AlertConfig::default(),
2925 export: ExportConfig::default(),
2926 auto_pause_on_input: false,
2927 venue: None,
2928 };
2929
2930 let yaml = serde_yaml::to_string(&config).unwrap();
2931 let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
2932 assert_eq!(parsed.layout, LayoutPreset::ChartFocus);
2933 assert_eq!(parsed.refresh_seconds, 5);
2934 assert!(parsed.widgets.price_chart);
2935 assert!(!parsed.widgets.volume_chart);
2936 assert!(parsed.widgets.buy_sell_pressure);
2937 assert!(!parsed.widgets.metrics_panel);
2938 assert!(parsed.widgets.activity_log);
2939 }
2940
2941 #[test]
2942 fn test_monitor_config_serde_kebab_case() {
2943 let yaml = r#"
2944layout: chart-focus
2945refresh_seconds: 15
2946widgets:
2947 price_chart: true
2948 volume_chart: true
2949 buy_sell_pressure: false
2950 metrics_panel: true
2951 activity_log: false
2952"#;
2953 let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
2954 assert_eq!(config.layout, LayoutPreset::ChartFocus);
2955 assert_eq!(config.refresh_seconds, 15);
2956 assert!(!config.widgets.buy_sell_pressure);
2957 assert!(!config.widgets.activity_log);
2958 }
2959
2960 #[test]
2961 fn test_monitor_config_serde_default_missing_fields() {
2962 let yaml = "layout: feed\n";
2963 let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
2964 assert_eq!(config.layout, LayoutPreset::Feed);
2965 assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
2966 assert!(config.widgets.price_chart); }
2968
2969 #[test]
2970 fn test_state_apply_config() {
2971 let mut state = create_populated_state();
2972 let config = MonitorConfig {
2973 layout: LayoutPreset::Feed,
2974 refresh_seconds: 5,
2975 widgets: WidgetVisibility {
2976 price_chart: false,
2977 volume_chart: true,
2978 buy_sell_pressure: true,
2979 metrics_panel: false,
2980 activity_log: true,
2981 holder_count: true,
2982 liquidity_depth: true,
2983 },
2984 scale: ScaleMode::Log,
2985 color_scheme: ColorScheme::Monochrome,
2986 alerts: AlertConfig::default(),
2987 export: ExportConfig::default(),
2988 auto_pause_on_input: false,
2989 venue: None,
2990 };
2991 state.apply_config(&config);
2992 assert_eq!(state.layout, LayoutPreset::Feed);
2993 assert!(!state.widgets.price_chart);
2994 assert!(!state.widgets.metrics_panel);
2995 assert_eq!(state.refresh_rate, Duration::from_secs(5));
2996 }
2997
2998 #[test]
2999 fn test_layout_all_widgets_hidden_dashboard() {
3000 let area = Rect::new(0, 0, 120, 40);
3001 let vis = WidgetVisibility {
3002 price_chart: false,
3003 volume_chart: false,
3004 buy_sell_pressure: false,
3005 metrics_panel: false,
3006 activity_log: false,
3007 holder_count: false,
3008 liquidity_depth: false,
3009 };
3010 let areas = layout_dashboard(area, &vis);
3011 assert!(areas.price_chart.is_none());
3012 assert!(areas.volume_chart.is_none());
3013 assert!(areas.buy_sell_gauge.is_none());
3014 assert!(areas.metrics_panel.is_none());
3015 assert!(areas.activity_feed.is_none());
3016 }
3017
3018 #[test]
3019 fn test_ui_render_with_hidden_widgets() {
3020 let mut terminal = create_test_terminal();
3021 let mut state = create_populated_state();
3022 state.auto_layout = false;
3023 state.widgets.price_chart = false;
3024 state.widgets.volume_chart = false;
3025 terminal.draw(|f| ui(f, &mut state)).unwrap();
3026 }
3027
3028 #[test]
3029 fn test_ui_render_widget_toggle_mode_footer() {
3030 let mut terminal = create_test_terminal();
3031 let mut state = create_populated_state();
3032 state.auto_layout = false;
3033 state.widget_toggle_mode = true;
3034 terminal.draw(|f| ui(f, &mut state)).unwrap();
3035 }
3036
3037 #[test]
3038 fn test_monitor_state_new_has_layout_fields() {
3039 let token_data = create_test_token_data();
3040 let state = MonitorState::new(&token_data, "ethereum");
3041 assert_eq!(state.layout, LayoutPreset::Dashboard);
3042 assert!(state.auto_layout);
3043 assert!(!state.widget_toggle_mode);
3044 assert_eq!(state.widgets.visible_count(), 5);
3045 }
3046
3047 #[test]
3052 fn test_monitor_state_has_holder_count_field() {
3053 let token_data = create_test_token_data();
3054 let state = MonitorState::new(&token_data, "ethereum");
3055 assert_eq!(state.holder_count, None);
3056 assert!(state.liquidity_pairs.is_empty());
3057 assert_eq!(state.holder_fetch_counter, 0);
3058 }
3059
3060 #[test]
3061 fn test_liquidity_pairs_extracted_on_update() {
3062 let mut token_data = create_test_token_data();
3063 token_data.pairs = vec![
3064 scope::chains::DexPair {
3065 dex_name: "Uniswap V3".to_string(),
3066 pair_address: "0xpair1".to_string(),
3067 base_token: "TEST".to_string(),
3068 quote_token: "WETH".to_string(),
3069 price_usd: 1.0,
3070 volume_24h: 500_000.0,
3071 liquidity_usd: 250_000.0,
3072 price_change_24h: 5.0,
3073 buys_24h: 50,
3074 sells_24h: 25,
3075 buys_6h: 10,
3076 sells_6h: 5,
3077 buys_1h: 3,
3078 sells_1h: 2,
3079 pair_created_at: None,
3080 url: None,
3081 },
3082 scope::chains::DexPair {
3083 dex_name: "SushiSwap".to_string(),
3084 pair_address: "0xpair2".to_string(),
3085 base_token: "TEST".to_string(),
3086 quote_token: "USDC".to_string(),
3087 price_usd: 1.0,
3088 volume_24h: 300_000.0,
3089 liquidity_usd: 150_000.0,
3090 price_change_24h: 3.0,
3091 buys_24h: 30,
3092 sells_24h: 15,
3093 buys_6h: 8,
3094 sells_6h: 4,
3095 buys_1h: 2,
3096 sells_1h: 1,
3097 pair_created_at: None,
3098 url: None,
3099 },
3100 ];
3101
3102 let mut state = MonitorState::new(&token_data, "ethereum");
3103 state.update(&token_data);
3104
3105 assert_eq!(state.liquidity_pairs.len(), 2);
3106 assert!(state.liquidity_pairs[0].0.contains("Uniswap V3"));
3107 assert!((state.liquidity_pairs[0].1 - 250_000.0).abs() < 0.01);
3108 }
3109
3110 #[test]
3111 fn test_render_liquidity_depth_no_panic() {
3112 let mut terminal = create_test_terminal();
3113 let mut state = create_populated_state();
3114 state.liquidity_pairs = vec![
3115 ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
3116 ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
3117 ];
3118 terminal
3119 .draw(|f| render_liquidity_depth(f, f.area(), &state))
3120 .unwrap();
3121 }
3122
3123 #[test]
3124 fn test_render_liquidity_depth_empty() {
3125 let mut terminal = create_test_terminal();
3126 let state = create_populated_state();
3127 terminal
3128 .draw(|f| render_liquidity_depth(f, f.area(), &state))
3129 .unwrap();
3130 }
3131
3132 #[test]
3133 fn test_render_metrics_with_holder_count() {
3134 let mut terminal = create_test_terminal();
3135 let mut state = create_populated_state();
3136 state.holder_count = Some(42_000);
3137 terminal
3138 .draw(|f| render_metrics_panel(f, f.area(), &state))
3139 .unwrap();
3140 }
3141
3142 #[test]
3147 fn test_alert_config_default() {
3148 let config = AlertConfig::default();
3149 assert!(config.price_min.is_none());
3150 assert!(config.price_max.is_none());
3151 assert!(config.whale_min_usd.is_none());
3152 assert!(config.volume_spike_threshold_pct.is_none());
3153 }
3154
3155 #[test]
3156 fn test_alert_price_min_triggers() {
3157 let token_data = create_test_token_data();
3158 let mut state = MonitorState::new(&token_data, "ethereum");
3159 state.alerts.price_min = Some(2.0); state.update(&token_data);
3161 assert!(
3162 !state.active_alerts.is_empty(),
3163 "Should have price-min alert"
3164 );
3165 assert!(state.active_alerts[0].message.contains("below min"));
3166 }
3167
3168 #[test]
3169 fn test_alert_price_max_triggers() {
3170 let mut token_data = create_test_token_data();
3171 token_data.price_usd = 100.0;
3172 let mut state = MonitorState::new(&token_data, "ethereum");
3173 state.alerts.price_max = Some(50.0); state.update(&token_data);
3175 assert!(
3176 !state.active_alerts.is_empty(),
3177 "Should have price-max alert"
3178 );
3179 assert!(state.active_alerts[0].message.contains("above max"));
3180 }
3181
3182 #[test]
3183 fn test_alert_no_trigger_within_bounds() {
3184 let token_data = create_test_token_data();
3185 let mut state = MonitorState::new(&token_data, "ethereum");
3186 state.alerts.price_min = Some(0.5); state.alerts.price_max = Some(2.0); state.update(&token_data);
3189 assert!(
3190 state.active_alerts.is_empty(),
3191 "Should have no alerts when price is within bounds"
3192 );
3193 }
3194
3195 #[test]
3196 fn test_alert_volume_spike_triggers() {
3197 let token_data = create_test_token_data();
3198 let mut state = MonitorState::new(&token_data, "ethereum");
3199 state.alerts.volume_spike_threshold_pct = Some(10.0);
3200 state.volume_avg = 500_000.0; state.update(&token_data);
3204 let spike_alerts: Vec<_> = state
3205 .active_alerts
3206 .iter()
3207 .filter(|a| a.message.contains("spike"))
3208 .collect();
3209 assert!(!spike_alerts.is_empty(), "Should have volume spike alert");
3210 }
3211
3212 #[test]
3213 fn test_alert_flash_timer_set() {
3214 let token_data = create_test_token_data();
3215 let mut state = MonitorState::new(&token_data, "ethereum");
3216 state.alerts.price_min = Some(2.0);
3217 state.update(&token_data);
3218 assert!(state.alert_flash_until.is_some());
3219 }
3220
3221 #[test]
3222 fn test_render_alert_overlay_no_panic() {
3223 let mut terminal = create_test_terminal();
3224 let mut state = create_populated_state();
3225 state.active_alerts.push(ActiveAlert {
3226 message: "⚠ Test alert".to_string(),
3227 triggered_at: Instant::now(),
3228 });
3229 state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
3230 terminal
3231 .draw(|f| render_alert_overlay(f, f.area(), &state))
3232 .unwrap();
3233 }
3234
3235 #[test]
3236 fn test_render_alert_overlay_empty() {
3237 let mut terminal = create_test_terminal();
3238 let state = create_populated_state();
3239 terminal
3240 .draw(|f| render_alert_overlay(f, f.area(), &state))
3241 .unwrap();
3242 }
3243
3244 #[test]
3245 fn test_alert_config_serde_roundtrip() {
3246 let config = AlertConfig {
3247 price_min: Some(0.5),
3248 price_max: Some(2.0),
3249 whale_min_usd: Some(10_000.0),
3250 volume_spike_threshold_pct: Some(50.0),
3251 };
3252 let yaml = serde_yaml::to_string(&config).unwrap();
3253 let parsed: AlertConfig = serde_yaml::from_str(&yaml).unwrap();
3254 assert_eq!(parsed.price_min, Some(0.5));
3255 assert_eq!(parsed.price_max, Some(2.0));
3256 assert_eq!(parsed.whale_min_usd, Some(10_000.0));
3257 assert_eq!(parsed.volume_spike_threshold_pct, Some(50.0));
3258 }
3259
3260 #[test]
3261 fn test_ui_with_active_alerts() {
3262 let mut terminal = create_test_terminal();
3263 let mut state = create_populated_state();
3264 state.active_alerts.push(ActiveAlert {
3265 message: "⚠ Price below min".to_string(),
3266 triggered_at: Instant::now(),
3267 });
3268 state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
3269 terminal.draw(|f| ui(f, &mut state)).unwrap();
3270 }
3271
3272 #[test]
3277 fn test_export_config_default() {
3278 let config = ExportConfig::default();
3279 assert!(config.path.is_none());
3280 }
3281
3282 fn start_export_in_temp(state: &mut MonitorState) -> PathBuf {
3284 use std::sync::atomic::{AtomicU64, Ordering};
3285 static COUNTER: AtomicU64 = AtomicU64::new(0);
3286 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
3287 let dir =
3288 std::env::temp_dir().join(format!("scope_test_export_{}_{}", std::process::id(), id));
3289 let _ = fs::create_dir_all(&dir);
3290 let filename = format!("{}_test_{}.csv", state.symbol, id);
3291 let path = dir.join(filename);
3292
3293 let mut file = fs::File::create(&path).expect("failed to create export test file");
3294 let header = "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
3295 file.write_all(header.as_bytes())
3296 .expect("failed to write header");
3297 drop(file); state.export_path = Some(path.clone());
3300 state.export_active = true;
3301 path
3302 }
3303
3304 #[test]
3305 fn test_export_start_creates_file() {
3306 let token_data = create_test_token_data();
3307 let mut state = MonitorState::new(&token_data, "ethereum");
3308 let path = start_export_in_temp(&mut state);
3309
3310 assert!(state.export_active);
3311 assert!(state.export_path.is_some());
3312 assert!(path.exists(), "Export file should exist");
3313
3314 let _ = std::fs::remove_file(&path);
3316 }
3317
3318 #[test]
3319 fn test_export_stop() {
3320 let token_data = create_test_token_data();
3321 let mut state = MonitorState::new(&token_data, "ethereum");
3322 let path = start_export_in_temp(&mut state);
3323 state.stop_export();
3324
3325 assert!(!state.export_active);
3326 assert!(state.export_path.is_none());
3327
3328 let _ = std::fs::remove_file(&path);
3330 }
3331
3332 #[test]
3333 fn test_export_toggle() {
3334 let token_data = create_test_token_data();
3335 let mut state = MonitorState::new(&token_data, "ethereum");
3336
3337 state.toggle_export();
3338 assert!(state.export_active);
3339 let path = state.export_path.clone().unwrap();
3340
3341 state.toggle_export();
3342 assert!(!state.export_active);
3343
3344 let _ = std::fs::remove_file(path);
3346 }
3347
3348 #[test]
3349 fn test_export_writes_csv_rows() {
3350 let token_data = create_test_token_data();
3351 let mut state = MonitorState::new(&token_data, "ethereum");
3352 let path = start_export_in_temp(&mut state);
3353
3354 state.update(&token_data);
3356 state.update(&token_data);
3357
3358 let contents = std::fs::read_to_string(&path).unwrap();
3359 let lines: Vec<&str> = contents.lines().collect();
3360
3361 assert!(
3362 lines.len() >= 3,
3363 "Should have header + 2 data rows, got {}",
3364 lines.len()
3365 );
3366 assert!(lines[0].starts_with("timestamp,price_usd"));
3367
3368 state.stop_export();
3370 let _ = std::fs::remove_file(path);
3371 }
3372
3373 #[test]
3374 fn test_keybinding_e_toggles_export() {
3375 let token_data = create_test_token_data();
3376 let mut state = MonitorState::new(&token_data, "ethereum");
3377
3378 handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
3379 assert!(state.export_active);
3380 let path = state.export_path.clone().unwrap();
3381
3382 handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
3383 assert!(!state.export_active);
3384
3385 let _ = std::fs::remove_file(path);
3387 }
3388
3389 #[test]
3390 fn test_render_footer_with_export_active() {
3391 let mut terminal = create_test_terminal();
3392 let mut state = create_populated_state();
3393 state.export_active = true;
3394 terminal
3395 .draw(|f| render_footer(f, f.area(), &state))
3396 .unwrap();
3397 }
3398
3399 #[test]
3400 fn test_export_config_serde_roundtrip() {
3401 let config = ExportConfig {
3402 path: Some("./my-exports".to_string()),
3403 };
3404 let yaml = serde_yaml::to_string(&config).unwrap();
3405 let parsed: ExportConfig = serde_yaml::from_str(&yaml).unwrap();
3406 assert_eq!(parsed.path, Some("./my-exports".to_string()));
3407 }
3408
3409 #[test]
3414 fn test_auto_pause_default_disabled() {
3415 let token_data = create_test_token_data();
3416 let state = MonitorState::new(&token_data, "ethereum");
3417 assert!(!state.auto_pause_on_input);
3418 assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
3419 }
3420
3421 #[test]
3422 fn test_auto_pause_blocks_refresh() {
3423 let token_data = create_test_token_data();
3424 let mut state = MonitorState::new(&token_data, "ethereum");
3425 state.auto_pause_on_input = true;
3426 state.refresh_rate = Duration::from_secs(1);
3427
3428 state.last_input_at = Instant::now();
3430 state.last_update = Instant::now() - Duration::from_secs(10); assert!(!state.should_refresh());
3434 }
3435
3436 #[test]
3437 fn test_auto_pause_allows_refresh_after_timeout() {
3438 let token_data = create_test_token_data();
3439 let mut state = MonitorState::new(&token_data, "ethereum");
3440 state.auto_pause_on_input = true;
3441 state.refresh_rate = Duration::from_secs(1);
3442 state.auto_pause_timeout = Duration::from_millis(1); state.last_input_at = Instant::now() - Duration::from_secs(10);
3446 state.last_update = Instant::now() - Duration::from_secs(10);
3447
3448 assert!(state.should_refresh());
3450 }
3451
3452 #[test]
3453 fn test_auto_pause_disabled_does_not_block() {
3454 let token_data = create_test_token_data();
3455 let mut state = MonitorState::new(&token_data, "ethereum");
3456 state.auto_pause_on_input = false;
3457 state.refresh_rate = Duration::from_secs(1);
3458
3459 state.last_input_at = Instant::now(); state.last_update = Instant::now() - Duration::from_secs(10);
3461
3462 assert!(state.should_refresh());
3464 }
3465
3466 #[test]
3467 fn test_is_auto_paused() {
3468 let token_data = create_test_token_data();
3469 let mut state = MonitorState::new(&token_data, "ethereum");
3470
3471 state.auto_pause_on_input = false;
3473 state.last_input_at = Instant::now();
3474 assert!(!state.is_auto_paused());
3475
3476 state.auto_pause_on_input = true;
3478 state.last_input_at = Instant::now();
3479 assert!(state.is_auto_paused());
3480
3481 state.last_input_at = Instant::now() - Duration::from_secs(10);
3483 assert!(!state.is_auto_paused());
3484 }
3485
3486 #[test]
3487 fn test_keybinding_shift_p_toggles_auto_pause() {
3488 let token_data = create_test_token_data();
3489 let mut state = MonitorState::new(&token_data, "ethereum");
3490 assert!(!state.auto_pause_on_input);
3491
3492 let shift_p = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
3493 handle_key_event_on_state(shift_p, &mut state);
3494 assert!(state.auto_pause_on_input);
3495
3496 handle_key_event_on_state(shift_p, &mut state);
3497 assert!(!state.auto_pause_on_input);
3498 }
3499
3500 #[test]
3501 fn test_keybinding_updates_last_input_at() {
3502 let token_data = create_test_token_data();
3503 let mut state = MonitorState::new(&token_data, "ethereum");
3504
3505 state.last_input_at = Instant::now() - Duration::from_secs(60);
3507 let old_input = state.last_input_at;
3508
3509 handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
3511 assert!(state.last_input_at > old_input);
3512 }
3513
3514 #[test]
3515 fn test_render_footer_auto_paused() {
3516 let mut terminal = create_test_terminal();
3517 let mut state = create_populated_state();
3518 state.auto_pause_on_input = true;
3519 state.last_input_at = Instant::now(); terminal
3521 .draw(|f| render_footer(f, f.area(), &state))
3522 .unwrap();
3523 }
3524
3525 #[test]
3526 fn test_config_auto_pause_applied() {
3527 let mut state = create_populated_state();
3528 let config = MonitorConfig {
3529 auto_pause_on_input: true,
3530 ..MonitorConfig::default()
3531 };
3532 state.apply_config(&config);
3533 assert!(state.auto_pause_on_input);
3534 }
3535
3536 #[test]
3541 fn test_ui_render_all_layouts_with_alerts_and_export() {
3542 for preset in &[
3543 LayoutPreset::Dashboard,
3544 LayoutPreset::ChartFocus,
3545 LayoutPreset::Feed,
3546 LayoutPreset::Compact,
3547 ] {
3548 let mut terminal = create_test_terminal();
3549 let mut state = create_populated_state();
3550 state.layout = *preset;
3551 state.auto_layout = false;
3552 state.export_active = true;
3553 state.active_alerts.push(ActiveAlert {
3554 message: "⚠ Test alert".to_string(),
3555 triggered_at: Instant::now(),
3556 });
3557 terminal.draw(|f| ui(f, &mut state)).unwrap();
3558 }
3559 }
3560
3561 #[test]
3562 fn test_ui_render_with_liquidity_data() {
3563 let mut terminal = create_test_terminal();
3564 let mut state = create_populated_state();
3565 state.liquidity_pairs = vec![
3566 ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
3567 ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
3568 ];
3569 terminal.draw(|f| ui(f, &mut state)).unwrap();
3570 }
3571
3572 #[test]
3573 fn test_monitor_config_full_serde_roundtrip() {
3574 let config = MonitorConfig {
3575 layout: LayoutPreset::Dashboard,
3576 refresh_seconds: 10,
3577 widgets: WidgetVisibility::default(),
3578 scale: ScaleMode::Log,
3579 color_scheme: ColorScheme::BlueOrange,
3580 alerts: AlertConfig {
3581 price_min: Some(0.5),
3582 price_max: Some(10.0),
3583 whale_min_usd: Some(50_000.0),
3584 volume_spike_threshold_pct: Some(100.0),
3585 },
3586 export: ExportConfig {
3587 path: Some("./exports".to_string()),
3588 },
3589 auto_pause_on_input: true,
3590 venue: Some("binance".to_string()),
3591 };
3592
3593 let yaml = serde_yaml::to_string(&config).unwrap();
3594 let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
3595 assert_eq!(parsed.layout, LayoutPreset::Dashboard);
3596 assert_eq!(parsed.refresh_seconds, 10);
3597 assert_eq!(parsed.venue, Some("binance".to_string()));
3598 assert_eq!(parsed.alerts.price_min, Some(0.5));
3599 assert_eq!(parsed.alerts.price_max, Some(10.0));
3600 assert_eq!(parsed.export.path, Some("./exports".to_string()));
3601 assert!(parsed.auto_pause_on_input);
3602 }
3603
3604 #[test]
3605 fn test_monitor_config_serde_defaults_for_new_fields() {
3606 let yaml = r#"
3608layout: dashboard
3609refresh_seconds: 5
3610"#;
3611 let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
3612 assert!(config.alerts.price_min.is_none());
3613 assert!(config.export.path.is_none());
3614 assert!(!config.auto_pause_on_input);
3615 }
3616
3617 #[test]
3618 fn test_quit_stops_export() {
3619 let token_data = create_test_token_data();
3620 let mut state = MonitorState::new(&token_data, "ethereum");
3621 let path = start_export_in_temp(&mut state);
3622
3623 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('q')), &mut state);
3624 assert!(exit);
3625 assert!(!state.export_active);
3626
3627 let _ = std::fs::remove_file(path);
3629 }
3630
3631 #[test]
3632 fn test_monitor_state_new_has_alert_export_autopause_fields() {
3633 let token_data = create_test_token_data();
3634 let state = MonitorState::new(&token_data, "ethereum");
3635
3636 assert!(state.active_alerts.is_empty());
3638 assert!(state.alert_flash_until.is_none());
3639 assert!(state.alerts.price_min.is_none());
3640
3641 assert!(!state.export_active);
3643 assert!(state.export_path.is_none());
3644
3645 assert!(!state.auto_pause_on_input);
3647 assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
3648 }
3649
3650 #[test]
3655 fn test_scale_mode_serde_roundtrip() {
3656 for mode in &[ScaleMode::Linear, ScaleMode::Log] {
3657 let yaml = serde_yaml::to_string(mode).unwrap();
3658 let parsed: ScaleMode = serde_yaml::from_str(&yaml).unwrap();
3659 assert_eq!(&parsed, mode);
3660 }
3661 }
3662
3663 #[test]
3664 fn test_scale_mode_serde_kebab_case() {
3665 let parsed: ScaleMode = serde_yaml::from_str("linear").unwrap();
3666 assert_eq!(parsed, ScaleMode::Linear);
3667 let parsed: ScaleMode = serde_yaml::from_str("log").unwrap();
3668 assert_eq!(parsed, ScaleMode::Log);
3669 }
3670
3671 #[test]
3672 fn test_scale_mode_toggle() {
3673 assert_eq!(ScaleMode::Linear.toggle(), ScaleMode::Log);
3674 assert_eq!(ScaleMode::Log.toggle(), ScaleMode::Linear);
3675 }
3676
3677 #[test]
3678 fn test_scale_mode_label() {
3679 assert_eq!(ScaleMode::Linear.label(), "Lin");
3680 assert_eq!(ScaleMode::Log.label(), "Log");
3681 }
3682
3683 #[test]
3684 fn test_color_scheme_serde_roundtrip() {
3685 for scheme in &[
3686 ColorScheme::GreenRed,
3687 ColorScheme::BlueOrange,
3688 ColorScheme::Monochrome,
3689 ] {
3690 let yaml = serde_yaml::to_string(scheme).unwrap();
3691 let parsed: ColorScheme = serde_yaml::from_str(&yaml).unwrap();
3692 assert_eq!(&parsed, scheme);
3693 }
3694 }
3695
3696 #[test]
3697 fn test_color_scheme_serde_kebab_case() {
3698 let parsed: ColorScheme = serde_yaml::from_str("green-red").unwrap();
3699 assert_eq!(parsed, ColorScheme::GreenRed);
3700 let parsed: ColorScheme = serde_yaml::from_str("blue-orange").unwrap();
3701 assert_eq!(parsed, ColorScheme::BlueOrange);
3702 let parsed: ColorScheme = serde_yaml::from_str("monochrome").unwrap();
3703 assert_eq!(parsed, ColorScheme::Monochrome);
3704 }
3705
3706 #[test]
3707 fn test_color_scheme_cycle() {
3708 assert_eq!(ColorScheme::GreenRed.next(), ColorScheme::BlueOrange);
3709 assert_eq!(ColorScheme::BlueOrange.next(), ColorScheme::Monochrome);
3710 assert_eq!(ColorScheme::Monochrome.next(), ColorScheme::GreenRed);
3711 }
3712
3713 #[test]
3714 fn test_color_scheme_label() {
3715 assert_eq!(ColorScheme::GreenRed.label(), "G/R");
3716 assert_eq!(ColorScheme::BlueOrange.label(), "B/O");
3717 assert_eq!(ColorScheme::Monochrome.label(), "Mono");
3718 }
3719
3720 #[test]
3721 fn test_color_palette_fields_populated() {
3722 for scheme in &[
3724 ColorScheme::GreenRed,
3725 ColorScheme::BlueOrange,
3726 ColorScheme::Monochrome,
3727 ] {
3728 let pal = scheme.palette();
3729 assert_ne!(
3731 format!("{:?}", pal.up),
3732 format!("{:?}", pal.down),
3733 "Up/down should differ for {:?}",
3734 scheme
3735 );
3736 }
3737 }
3738
3739 #[test]
3740 fn test_layout_preset_serde_roundtrip() {
3741 for preset in &[
3742 LayoutPreset::Dashboard,
3743 LayoutPreset::ChartFocus,
3744 LayoutPreset::Feed,
3745 LayoutPreset::Compact,
3746 ] {
3747 let yaml = serde_yaml::to_string(preset).unwrap();
3748 let parsed: LayoutPreset = serde_yaml::from_str(&yaml).unwrap();
3749 assert_eq!(&parsed, preset);
3750 }
3751 }
3752
3753 #[test]
3754 fn test_layout_preset_serde_kebab_case() {
3755 let parsed: LayoutPreset = serde_yaml::from_str("dashboard").unwrap();
3756 assert_eq!(parsed, LayoutPreset::Dashboard);
3757 let parsed: LayoutPreset = serde_yaml::from_str("chart-focus").unwrap();
3758 assert_eq!(parsed, LayoutPreset::ChartFocus);
3759 let parsed: LayoutPreset = serde_yaml::from_str("feed").unwrap();
3760 assert_eq!(parsed, LayoutPreset::Feed);
3761 let parsed: LayoutPreset = serde_yaml::from_str("compact").unwrap();
3762 assert_eq!(parsed, LayoutPreset::Compact);
3763 }
3764
3765 #[test]
3766 fn test_widget_visibility_serde_roundtrip() {
3767 let vis = WidgetVisibility {
3768 price_chart: false,
3769 volume_chart: true,
3770 buy_sell_pressure: false,
3771 metrics_panel: true,
3772 activity_log: false,
3773 holder_count: false,
3774 liquidity_depth: true,
3775 };
3776 let yaml = serde_yaml::to_string(&vis).unwrap();
3777 let parsed: WidgetVisibility = serde_yaml::from_str(&yaml).unwrap();
3778 assert!(!parsed.price_chart);
3779 assert!(parsed.volume_chart);
3780 assert!(!parsed.buy_sell_pressure);
3781 assert!(parsed.metrics_panel);
3782 assert!(!parsed.activity_log);
3783 assert!(!parsed.holder_count);
3784 assert!(parsed.liquidity_depth);
3785 }
3786
3787 #[test]
3788 fn test_data_point_serde_roundtrip() {
3789 let dp = DataPoint {
3790 timestamp: 1700000000.5,
3791 value: 42.123456,
3792 is_real: true,
3793 };
3794 let json = serde_json::to_string(&dp).unwrap();
3795 let parsed: DataPoint = serde_json::from_str(&json).unwrap();
3796 assert!((parsed.timestamp - dp.timestamp).abs() < 0.001);
3797 assert!((parsed.value - dp.value).abs() < 0.001);
3798 assert_eq!(parsed.is_real, dp.is_real);
3799 }
3800
3801 #[test]
3806 fn test_handle_key_scale_toggle_s() {
3807 let token_data = create_test_token_data();
3808 let mut state = MonitorState::new(&token_data, "ethereum");
3809 assert_eq!(state.scale_mode, ScaleMode::Linear);
3810
3811 handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
3812 assert_eq!(state.scale_mode, ScaleMode::Log);
3813
3814 handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
3815 assert_eq!(state.scale_mode, ScaleMode::Linear);
3816 }
3817
3818 #[test]
3819 fn test_handle_key_color_scheme_cycle_slash() {
3820 let token_data = create_test_token_data();
3821 let mut state = MonitorState::new(&token_data, "ethereum");
3822 assert_eq!(state.color_scheme, ColorScheme::GreenRed);
3823
3824 handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
3825 assert_eq!(state.color_scheme, ColorScheme::BlueOrange);
3826
3827 handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
3828 assert_eq!(state.color_scheme, ColorScheme::Monochrome);
3829
3830 handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
3831 assert_eq!(state.color_scheme, ColorScheme::GreenRed);
3832 }
3833
3834 #[test]
3839 fn test_render_volume_profile_chart_no_panic() {
3840 let mut terminal = create_test_terminal();
3841 let state = create_populated_state();
3842 terminal
3843 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
3844 .unwrap();
3845 }
3846
3847 #[test]
3848 fn test_render_volume_profile_chart_empty_data() {
3849 let mut terminal = create_test_terminal();
3850 let token_data = create_test_token_data();
3851 let mut state = MonitorState::new(&token_data, "ethereum");
3852 state.price_history.clear();
3853 state.volume_history.clear();
3854 terminal
3855 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
3856 .unwrap();
3857 }
3858
3859 #[test]
3860 fn test_render_volume_profile_chart_single_price() {
3861 let mut terminal = create_test_terminal();
3863 let mut token_data = create_test_token_data();
3864 token_data.price_usd = 1.0;
3865 let mut state = MonitorState::new(&token_data, "ethereum");
3866 state.price_history.clear();
3868 state.volume_history.clear();
3869 let now = chrono::Utc::now().timestamp() as f64;
3870 for i in 0..5 {
3871 state.price_history.push_back(DataPoint {
3872 timestamp: now - (5.0 - i as f64) * 60.0,
3873 value: 1.0, is_real: true,
3875 });
3876 state.volume_history.push_back(DataPoint {
3877 timestamp: now - (5.0 - i as f64) * 60.0,
3878 value: 1000.0,
3879 is_real: true,
3880 });
3881 }
3882 terminal
3883 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
3884 .unwrap();
3885 }
3886
3887 #[test]
3888 fn test_render_volume_profile_chart_narrow_terminal() {
3889 let backend = TestBackend::new(30, 15);
3890 let mut terminal = Terminal::new(backend).unwrap();
3891 let state = create_populated_state();
3892 terminal
3893 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
3894 .unwrap();
3895 }
3896
3897 #[test]
3902 fn test_render_price_chart_log_scale() {
3903 let mut terminal = create_test_terminal();
3904 let mut state = create_populated_state();
3905 state.scale_mode = ScaleMode::Log;
3906 terminal
3907 .draw(|f| render_price_chart(f, f.area(), &state))
3908 .unwrap();
3909 }
3910
3911 #[test]
3912 fn test_render_candlestick_chart_log_scale() {
3913 let mut terminal = create_test_terminal();
3914 let mut state = create_populated_state();
3915 state.scale_mode = ScaleMode::Log;
3916 state.chart_mode = ChartMode::Candlestick;
3917 terminal
3918 .draw(|f| render_candlestick_chart(f, f.area(), &state))
3919 .unwrap();
3920 }
3921
3922 #[test]
3923 fn test_render_price_chart_log_scale_zero_price() {
3924 let mut terminal = create_test_terminal();
3926 let mut token_data = create_test_token_data();
3927 token_data.price_usd = 0.0001;
3928 let mut state = MonitorState::new(&token_data, "ethereum");
3929 state.scale_mode = ScaleMode::Log;
3930 for i in 0..10 {
3931 let mut data = token_data.clone();
3932 data.price_usd = 0.0001 + (i as f64 * 0.00001);
3933 state.update(&data);
3934 }
3935 terminal
3936 .draw(|f| render_price_chart(f, f.area(), &state))
3937 .unwrap();
3938 }
3939
3940 #[test]
3945 fn test_render_ui_with_all_color_schemes() {
3946 for scheme in &[
3947 ColorScheme::GreenRed,
3948 ColorScheme::BlueOrange,
3949 ColorScheme::Monochrome,
3950 ] {
3951 let mut terminal = create_test_terminal();
3952 let mut state = create_populated_state();
3953 state.color_scheme = *scheme;
3954 terminal.draw(|f| ui(f, &mut state)).unwrap();
3955 }
3956 }
3957
3958 #[test]
3959 fn test_render_volume_chart_all_color_schemes() {
3960 for scheme in &[
3961 ColorScheme::GreenRed,
3962 ColorScheme::BlueOrange,
3963 ColorScheme::Monochrome,
3964 ] {
3965 let mut terminal = create_test_terminal();
3966 let mut state = create_populated_state();
3967 state.color_scheme = *scheme;
3968 terminal
3969 .draw(|f| render_volume_chart(f, f.area(), &state))
3970 .unwrap();
3971 }
3972 }
3973
3974 #[test]
3979 fn test_render_activity_feed_no_panic() {
3980 let mut terminal = create_test_terminal();
3981 let mut state = create_populated_state();
3982 for i in 0..5 {
3983 state.log_messages.push_back(format!("Event {}", i));
3984 }
3985 terminal
3986 .draw(|f| render_activity_feed(f, f.area(), &mut state))
3987 .unwrap();
3988 }
3989
3990 #[test]
3991 fn test_render_activity_feed_empty_log() {
3992 let mut terminal = create_test_terminal();
3993 let token_data = create_test_token_data();
3994 let mut state = MonitorState::new(&token_data, "ethereum");
3995 state.log_messages.clear();
3996 terminal
3997 .draw(|f| render_activity_feed(f, f.area(), &mut state))
3998 .unwrap();
3999 }
4000
4001 #[test]
4002 fn test_render_activity_feed_with_selection() {
4003 let mut terminal = create_test_terminal();
4004 let mut state = create_populated_state();
4005 for i in 0..10 {
4006 state.log_messages.push_back(format!("Event {}", i));
4007 }
4008 state.scroll_log_down();
4009 state.scroll_log_down();
4010 state.scroll_log_down();
4011 terminal
4012 .draw(|f| render_activity_feed(f, f.area(), &mut state))
4013 .unwrap();
4014 }
4015
4016 #[test]
4021 fn test_alert_whale_zero_transactions() {
4022 let mut token_data = create_test_token_data();
4023 token_data.total_buys_24h = 0;
4024 token_data.total_sells_24h = 0;
4025 let mut state = MonitorState::new(&token_data, "ethereum");
4026 state.alerts.whale_min_usd = Some(100.0);
4027 state.update(&token_data);
4028 let whale_alerts: Vec<_> = state
4030 .active_alerts
4031 .iter()
4032 .filter(|a| a.message.contains("whale") || a.message.contains("🐋"))
4033 .collect();
4034 assert!(
4035 whale_alerts.is_empty(),
4036 "Whale alert should not fire with zero transactions"
4037 );
4038 }
4039
4040 #[test]
4041 fn test_alert_multiple_simultaneous() {
4042 let mut token_data = create_test_token_data();
4043 token_data.price_usd = 0.1; let mut state = MonitorState::new(&token_data, "ethereum");
4045 state.alerts.price_min = Some(0.5); state.alerts.price_max = Some(0.05); state.alerts.volume_spike_threshold_pct = Some(1.0);
4048 state.volume_avg = 100.0; state.update(&token_data);
4051 assert!(
4053 state.active_alerts.len() >= 2,
4054 "Expected multiple alerts, got {}",
4055 state.active_alerts.len()
4056 );
4057 }
4058
4059 #[test]
4060 fn test_alert_clears_on_next_update() {
4061 let token_data = create_test_token_data();
4062 let mut state = MonitorState::new(&token_data, "ethereum");
4063 state.alerts.price_min = Some(2.0); state.update(&token_data);
4065 assert!(!state.active_alerts.is_empty());
4066
4067 let mut above_min = token_data.clone();
4069 above_min.price_usd = 3.0;
4070 state.alerts.price_min = Some(2.0);
4071 state.update(&above_min);
4072 let price_min_alerts: Vec<_> = state
4074 .active_alerts
4075 .iter()
4076 .filter(|a| a.message.contains("below min"))
4077 .collect();
4078 assert!(
4079 price_min_alerts.is_empty(),
4080 "Price-min alert should clear when price goes above min"
4081 );
4082 }
4083
4084 #[test]
4085 fn test_render_alert_overlay_multiple_alerts() {
4086 let mut terminal = create_test_terminal();
4087 let mut state = create_populated_state();
4088 state.active_alerts.push(ActiveAlert {
4089 message: "⚠ Price below min".to_string(),
4090 triggered_at: Instant::now(),
4091 });
4092 state.active_alerts.push(ActiveAlert {
4093 message: "🐋 Whale detected".to_string(),
4094 triggered_at: Instant::now(),
4095 });
4096 state.active_alerts.push(ActiveAlert {
4097 message: "⚠ Volume spike".to_string(),
4098 triggered_at: Instant::now(),
4099 });
4100 state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
4101 terminal
4102 .draw(|f| render_alert_overlay(f, f.area(), &state))
4103 .unwrap();
4104 }
4105
4106 #[test]
4107 fn test_render_alert_overlay_flash_expired() {
4108 let mut terminal = create_test_terminal();
4109 let mut state = create_populated_state();
4110 state.active_alerts.push(ActiveAlert {
4111 message: "⚠ Test".to_string(),
4112 triggered_at: Instant::now(),
4113 });
4114 state.alert_flash_until = Some(Instant::now() - Duration::from_secs(5));
4116 terminal
4117 .draw(|f| render_alert_overlay(f, f.area(), &state))
4118 .unwrap();
4119 }
4120
4121 #[test]
4126 fn test_render_liquidity_depth_many_pairs() {
4127 let mut terminal = create_test_terminal();
4128 let mut state = create_populated_state();
4129 for i in 0..20 {
4131 state.liquidity_pairs.push((
4132 format!("TEST/TOKEN{} (DEX{})", i, i),
4133 (100_000.0 + i as f64 * 50_000.0),
4134 ));
4135 }
4136 terminal
4137 .draw(|f| render_liquidity_depth(f, f.area(), &state))
4138 .unwrap();
4139 }
4140
4141 #[test]
4142 fn test_render_liquidity_depth_narrow_terminal() {
4143 let backend = TestBackend::new(30, 10);
4144 let mut terminal = Terminal::new(backend).unwrap();
4145 let mut state = create_populated_state();
4146 state.liquidity_pairs = vec![
4147 ("TEST/WETH (Uniswap)".to_string(), 500_000.0),
4148 ("TEST/USDC (Sushi)".to_string(), 100_000.0),
4149 ];
4150 terminal
4151 .draw(|f| render_liquidity_depth(f, f.area(), &state))
4152 .unwrap();
4153 }
4154
4155 #[test]
4160 fn test_render_metrics_panel_holder_count_disabled() {
4161 let mut terminal = create_test_terminal();
4162 let mut state = create_populated_state();
4163 state.holder_count = Some(42_000);
4164 state.widgets.holder_count = false; terminal
4166 .draw(|f| render_metrics_panel(f, f.area(), &state))
4167 .unwrap();
4168 }
4169
4170 #[test]
4171 fn test_render_metrics_panel_sparkline_single_point() {
4172 let mut terminal = create_test_terminal();
4173 let mut token_data = create_test_token_data();
4174 token_data.price_usd = 1.0;
4175 let mut state = MonitorState::new(&token_data, "ethereum");
4176 state.price_history.clear();
4177 state.price_history.push_back(DataPoint {
4178 timestamp: 1.0,
4179 value: 1.0,
4180 is_real: true,
4181 });
4182 terminal
4183 .draw(|f| render_metrics_panel(f, f.area(), &state))
4184 .unwrap();
4185 }
4186
4187 #[test]
4192 fn test_render_buy_sell_gauge_tiny_area() {
4193 let backend = TestBackend::new(5, 3);
4195 let mut terminal = Terminal::new(backend).unwrap();
4196 let mut state = create_populated_state();
4197 terminal
4198 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
4199 .unwrap();
4200 }
4201
4202 #[test]
4207 fn test_log_message_queue_overflow() {
4208 let token_data = create_test_token_data();
4209 let mut state = MonitorState::new(&token_data, "ethereum");
4210 for i in 0..20 {
4212 state.toggle_pause(); let _ = i;
4214 }
4215 assert!(
4216 state.log_messages.len() <= 10,
4217 "Log queue should cap at 10, got {}",
4218 state.log_messages.len()
4219 );
4220 }
4221
4222 #[test]
4227 fn test_export_writes_csv_row_content_format() {
4228 let token_data = create_test_token_data();
4229 let mut state = MonitorState::new(&token_data, "ethereum");
4230 let path = start_export_in_temp(&mut state);
4231
4232 state.update(&token_data);
4233
4234 let contents = std::fs::read_to_string(&path).unwrap();
4235 let lines: Vec<&str> = contents.lines().collect();
4236 assert!(lines.len() >= 2);
4237
4238 assert_eq!(
4240 lines[0],
4241 "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap"
4242 );
4243
4244 let data_cols: Vec<&str> = lines[1].split(',').collect();
4246 assert_eq!(
4247 data_cols.len(),
4248 7,
4249 "Expected 7 CSV columns, got {}",
4250 data_cols.len()
4251 );
4252
4253 assert!(data_cols[0].contains('T'));
4255 assert!(data_cols[0].ends_with('Z'));
4256
4257 state.stop_export();
4259 let _ = std::fs::remove_file(path);
4260 }
4261
4262 #[test]
4263 fn test_export_writes_csv_row_market_cap_none() {
4264 let mut token_data = create_test_token_data();
4265 token_data.market_cap = None;
4266 let mut state = MonitorState::new(&token_data, "ethereum");
4267 let path = start_export_in_temp(&mut state);
4268
4269 state.update(&token_data);
4270
4271 let contents = std::fs::read_to_string(&path).unwrap();
4272 let lines: Vec<&str> = contents.lines().collect();
4273 assert!(lines.len() >= 2);
4274
4275 let data_cols: Vec<&str> = lines[1].split(',').collect();
4277 assert_eq!(data_cols.len(), 7);
4278 assert!(
4279 data_cols[6].is_empty(),
4280 "Market cap column should be empty when None"
4281 );
4282
4283 state.stop_export();
4285 let _ = std::fs::remove_file(path);
4286 }
4287
4288 #[test]
4293 fn test_ui_render_log_scale_all_chart_modes() {
4294 for mode in &[
4295 ChartMode::Line,
4296 ChartMode::Candlestick,
4297 ChartMode::VolumeProfile,
4298 ] {
4299 let mut terminal = create_test_terminal();
4300 let mut state = create_populated_state();
4301 state.scale_mode = ScaleMode::Log;
4302 state.chart_mode = *mode;
4303 terminal.draw(|f| ui(f, &mut state)).unwrap();
4304 }
4305 }
4306
4307 #[test]
4312 fn test_render_footer_widget_toggle_mode_active() {
4313 let mut terminal = create_test_terminal();
4314 let mut state = create_populated_state();
4315 state.widget_toggle_mode = true;
4316 terminal
4317 .draw(|f| render_footer(f, f.area(), &state))
4318 .unwrap();
4319 }
4320
4321 #[test]
4322 fn test_render_footer_all_status_indicators() {
4323 let mut terminal = create_test_terminal();
4325 let mut state = create_populated_state();
4326 state.export_active = true;
4327 state.auto_pause_on_input = true;
4328 state.last_input_at = Instant::now(); terminal
4330 .draw(|f| render_footer(f, f.area(), &state))
4331 .unwrap();
4332 }
4333
4334 #[test]
4339 fn test_generate_synthetic_price_history_zero_price() {
4340 let mut token_data = create_test_token_data();
4341 token_data.price_usd = 0.0;
4342 token_data.price_change_1h = 0.0;
4343 token_data.price_change_6h = 0.0;
4344 token_data.price_change_24h = 0.0;
4345 let state = MonitorState::new(&token_data, "ethereum");
4346 assert!(!state.price_history.is_empty());
4348 }
4349
4350 #[test]
4351 fn test_generate_synthetic_volume_history_zero_volume() {
4352 let mut token_data = create_test_token_data();
4353 token_data.volume_24h = 0.0;
4354 token_data.volume_6h = 0.0;
4355 token_data.volume_1h = 0.0;
4356 let state = MonitorState::new(&token_data, "ethereum");
4357 assert!(!state.volume_history.is_empty());
4358 }
4359
4360 #[test]
4361 fn test_generate_synthetic_order_book() {
4362 let pairs = vec![scope::chains::DexPair {
4363 dex_name: "Uniswap V3".to_string(),
4364 pair_address: "0xabc".to_string(),
4365 base_token: "DAI".to_string(),
4366 quote_token: "USDT".to_string(),
4367 price_usd: 1.0,
4368 volume_24h: 50_000.0,
4369 liquidity_usd: 200_000.0,
4370 price_change_24h: 0.1,
4371 buys_24h: 100,
4372 sells_24h: 90,
4373 buys_6h: 30,
4374 sells_6h: 25,
4375 buys_1h: 10,
4376 sells_1h: 8,
4377 pair_created_at: None,
4378 url: None,
4379 }];
4380 let book = MonitorState::generate_synthetic_order_book(&pairs, "DAI", 1.0, 200_000.0);
4381 assert!(book.is_some());
4382 let book = book.unwrap();
4383 assert_eq!(book.pair, "DAI/USDT");
4384 assert!(!book.asks.is_empty());
4385 assert!(!book.bids.is_empty());
4386 for w in book.asks.windows(2) {
4388 assert!(w[0].price <= w[1].price);
4389 }
4390 for w in book.bids.windows(2) {
4392 assert!(w[0].price >= w[1].price);
4393 }
4394 }
4395
4396 #[test]
4397 fn test_generate_synthetic_order_book_zero_price() {
4398 let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 0.0, 100_000.0);
4399 assert!(book.is_none());
4400 }
4401
4402 #[test]
4403 fn test_generate_synthetic_order_book_zero_liquidity() {
4404 let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 1.0, 0.0);
4405 assert!(book.is_none());
4406 }
4407
4408 #[test]
4413 fn test_auto_pause_custom_timeout() {
4414 let token_data = create_test_token_data();
4415 let mut state = MonitorState::new(&token_data, "ethereum");
4416 state.auto_pause_on_input = true;
4417 state.auto_pause_timeout = Duration::from_secs(10);
4418 state.refresh_rate = Duration::from_secs(1);
4419
4420 state.last_input_at = Instant::now();
4422 state.last_update = Instant::now() - Duration::from_secs(5);
4423 assert!(!state.should_refresh()); assert!(state.is_auto_paused());
4425 }
4426
4427 #[test]
4432 fn test_render_price_chart_stablecoin_flat_range() {
4433 let mut terminal = create_test_terminal();
4434 let mut token_data = create_test_token_data();
4435 token_data.price_usd = 1.0;
4436 let mut state = MonitorState::new(&token_data, "ethereum");
4437 for i in 0..20 {
4439 let mut data = token_data.clone();
4440 data.price_usd = 1.0 + (i as f64 * 0.000001); state.update(&data);
4442 }
4443 terminal
4444 .draw(|f| render_price_chart(f, f.area(), &state))
4445 .unwrap();
4446 }
4447
4448 #[test]
4453 fn test_load_cache_corrupted_json() {
4454 let path = MonitorState::cache_path("0xCORRUPTED_TEST", "test_chain");
4455 let _ = std::fs::write(&path, "not valid json {{{");
4457 let cached = MonitorState::load_cache("0xCORRUPTED_TEST", "test_chain");
4458 assert!(cached.is_none(), "Corrupted JSON should return None");
4459 let _ = std::fs::remove_file(path);
4460 }
4461
4462 #[test]
4463 fn test_load_cache_wrong_token() {
4464 let token_data = create_test_token_data();
4465 let state = MonitorState::new(&token_data, "ethereum");
4466 state.save_cache();
4467
4468 let cached = MonitorState::load_cache("0xDIFFERENT_ADDRESS", "ethereum");
4470 assert!(
4471 cached.is_none(),
4472 "Loading cache with wrong token address should return None"
4473 );
4474
4475 let path = MonitorState::cache_path(&token_data.address, "ethereum");
4477 let _ = std::fs::remove_file(path);
4478 }
4479
4480 use scope::chains::dex::TokenSearchResult;
4485
4486 struct MockDexDataSource {
4488 token_data_result: std::sync::Mutex<Result<DexTokenData>>,
4490 }
4491
4492 impl MockDexDataSource {
4493 fn new(data: DexTokenData) -> Self {
4494 Self {
4495 token_data_result: std::sync::Mutex::new(Ok(data)),
4496 }
4497 }
4498
4499 fn failing(msg: &str) -> Self {
4500 Self {
4501 token_data_result: std::sync::Mutex::new(Err(ScopeError::Api(msg.to_string()))),
4502 }
4503 }
4504 }
4505
4506 #[async_trait::async_trait]
4507 impl DexDataSource for MockDexDataSource {
4508 async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
4509 self.token_data_result
4510 .lock()
4511 .unwrap()
4512 .as_ref()
4513 .ok()
4514 .map(|d| d.price_usd)
4515 }
4516
4517 async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
4518 Some(2000.0)
4519 }
4520
4521 async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
4522 let guard = self.token_data_result.lock().unwrap();
4523 match &*guard {
4524 Ok(data) => Ok(data.clone()),
4525 Err(e) => Err(ScopeError::Api(e.to_string())),
4526 }
4527 }
4528
4529 async fn search_tokens(
4530 &self,
4531 _query: &str,
4532 _chain: Option<&str>,
4533 ) -> Result<Vec<TokenSearchResult>> {
4534 Ok(vec![])
4535 }
4536 }
4537
4538 struct MockChainClient {
4540 holder_count: u64,
4541 }
4542
4543 impl MockChainClient {
4544 fn new(holder_count: u64) -> Self {
4545 Self { holder_count }
4546 }
4547 }
4548
4549 #[async_trait::async_trait]
4550 impl ChainClient for MockChainClient {
4551 fn chain_name(&self) -> &str {
4552 "ethereum"
4553 }
4554 fn native_token_symbol(&self) -> &str {
4555 "ETH"
4556 }
4557 async fn get_balance(&self, _address: &str) -> Result<scope::chains::Balance> {
4558 unimplemented!("not needed for monitor tests")
4559 }
4560 async fn enrich_balance_usd(&self, _balance: &mut scope::chains::Balance) {}
4561 async fn get_transaction(&self, _hash: &str) -> Result<scope::chains::Transaction> {
4562 unimplemented!("not needed for monitor tests")
4563 }
4564 async fn get_transactions(
4565 &self,
4566 _address: &str,
4567 _limit: u32,
4568 ) -> Result<Vec<scope::chains::Transaction>> {
4569 Ok(vec![])
4570 }
4571 async fn get_block_number(&self) -> Result<u64> {
4572 Ok(1000000)
4573 }
4574 async fn get_token_balances(
4575 &self,
4576 _address: &str,
4577 ) -> Result<Vec<scope::chains::TokenBalance>> {
4578 Ok(vec![])
4579 }
4580 async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
4581 Ok(self.holder_count)
4582 }
4583 }
4584
4585 fn create_test_app(
4587 dex: Box<dyn DexDataSource>,
4588 chain_client: Option<Box<dyn ChainClient>>,
4589 ) -> MonitorApp<TestBackend> {
4590 let token_data = create_test_token_data();
4591 let state = MonitorState::new(&token_data, "ethereum");
4592 let backend = TestBackend::new(120, 40);
4593 let terminal = ratatui::Terminal::new(backend).unwrap();
4594 MonitorApp {
4595 terminal,
4596 state,
4597 dex_client: dex,
4598 chain_client,
4599 exchange_client: None,
4600 should_exit: false,
4601 owns_terminal: false,
4602 }
4603 }
4604
4605 fn create_test_app_with_state(
4606 state: MonitorState,
4607 dex: Box<dyn DexDataSource>,
4608 chain_client: Option<Box<dyn ChainClient>>,
4609 ) -> MonitorApp<TestBackend> {
4610 let backend = TestBackend::new(120, 40);
4611 let terminal = ratatui::Terminal::new(backend).unwrap();
4612 MonitorApp {
4613 terminal,
4614 state,
4615 dex_client: dex,
4616 chain_client,
4617 exchange_client: None,
4618 should_exit: false,
4619 owns_terminal: false,
4620 }
4621 }
4622
4623 #[test]
4628 fn test_app_handle_key_quit_q() {
4629 let data = create_test_token_data();
4630 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4631 assert!(!app.should_exit);
4632 app.handle_key_event(make_key_event(KeyCode::Char('q')));
4633 assert!(app.should_exit);
4634 }
4635
4636 #[test]
4637 fn test_app_handle_key_quit_esc() {
4638 let data = create_test_token_data();
4639 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4640 app.handle_key_event(make_key_event(KeyCode::Esc));
4641 assert!(app.should_exit);
4642 }
4643
4644 #[test]
4645 fn test_app_handle_key_quit_ctrl_c() {
4646 let data = create_test_token_data();
4647 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4648 let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
4649 app.handle_key_event(key);
4650 assert!(app.should_exit);
4651 }
4652
4653 #[test]
4654 fn test_app_handle_key_quit_stops_active_export() {
4655 let data = create_test_token_data();
4656 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4657 let path = start_export_in_temp(&mut app.state);
4658 assert!(app.state.export_active);
4659
4660 app.handle_key_event(make_key_event(KeyCode::Char('q')));
4661 assert!(app.should_exit);
4662 assert!(!app.state.export_active);
4663 let _ = std::fs::remove_file(path);
4664 }
4665
4666 #[test]
4667 fn test_app_handle_key_ctrl_c_stops_active_export() {
4668 let data = create_test_token_data();
4669 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4670 let path = start_export_in_temp(&mut app.state);
4671 assert!(app.state.export_active);
4672
4673 let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
4674 app.handle_key_event(key);
4675 assert!(app.should_exit);
4676 assert!(!app.state.export_active);
4677 let _ = std::fs::remove_file(path);
4678 }
4679
4680 #[test]
4681 fn test_app_handle_key_updates_last_input_time() {
4682 let data = create_test_token_data();
4683 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4684 let before = Instant::now();
4685 app.handle_key_event(make_key_event(KeyCode::Char('p')));
4686 assert!(app.state.last_input_at >= before);
4687 }
4688
4689 #[test]
4690 fn test_app_handle_key_widget_toggle_mode() {
4691 let data = create_test_token_data();
4692 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4693 assert!(app.state.widgets.price_chart);
4694
4695 app.handle_key_event(make_key_event(KeyCode::Char('w')));
4697 assert!(app.state.widget_toggle_mode);
4698
4699 app.handle_key_event(make_key_event(KeyCode::Char('1')));
4701 assert!(!app.state.widget_toggle_mode);
4702 assert!(!app.state.widgets.price_chart);
4703 }
4704
4705 #[test]
4706 fn test_app_handle_key_widget_toggle_mode_cancel() {
4707 let data = create_test_token_data();
4708 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4709
4710 app.handle_key_event(make_key_event(KeyCode::Char('w')));
4712 assert!(app.state.widget_toggle_mode);
4713
4714 app.handle_key_event(make_key_event(KeyCode::Char('x')));
4716 assert!(!app.state.widget_toggle_mode);
4717 }
4718
4719 #[test]
4720 fn test_app_handle_key_all_keybindings() {
4721 let data = create_test_token_data();
4722 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4723
4724 app.handle_key_event(make_key_event(KeyCode::Char('r')));
4726 assert!(!app.should_exit);
4727
4728 let key = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
4730 app.handle_key_event(key);
4731 assert!(app.state.auto_pause_on_input);
4732 app.handle_key_event(key);
4733 assert!(!app.state.auto_pause_on_input);
4734
4735 app.handle_key_event(make_key_event(KeyCode::Char('p')));
4737 assert!(app.state.paused);
4738
4739 app.handle_key_event(make_key_event(KeyCode::Char(' ')));
4741 assert!(!app.state.paused);
4742
4743 app.handle_key_event(make_key_event(KeyCode::Char('e')));
4745 assert!(app.state.export_active);
4746 app.state.stop_export();
4748
4749 let before_rate = app.state.refresh_rate;
4751 app.handle_key_event(make_key_event(KeyCode::Char('+')));
4752 assert!(app.state.refresh_rate >= before_rate);
4753
4754 let before_rate = app.state.refresh_rate;
4756 app.handle_key_event(make_key_event(KeyCode::Char('-')));
4757 assert!(app.state.refresh_rate <= before_rate);
4758
4759 app.handle_key_event(make_key_event(KeyCode::Char('1')));
4761 assert_eq!(app.state.time_period, TimePeriod::Min1);
4762 app.handle_key_event(make_key_event(KeyCode::Char('2')));
4763 assert_eq!(app.state.time_period, TimePeriod::Min5);
4764 app.handle_key_event(make_key_event(KeyCode::Char('3')));
4765 assert_eq!(app.state.time_period, TimePeriod::Min15);
4766 app.handle_key_event(make_key_event(KeyCode::Char('4')));
4767 assert_eq!(app.state.time_period, TimePeriod::Hour1);
4768 app.handle_key_event(make_key_event(KeyCode::Char('5')));
4769 assert_eq!(app.state.time_period, TimePeriod::Hour4);
4770 app.handle_key_event(make_key_event(KeyCode::Char('6')));
4771 assert_eq!(app.state.time_period, TimePeriod::Day1);
4772
4773 app.handle_key_event(make_key_event(KeyCode::Char('t')));
4775 assert_eq!(app.state.time_period, TimePeriod::Min1); app.handle_key_event(make_key_event(KeyCode::Char('c')));
4779 assert_eq!(app.state.chart_mode, ChartMode::Candlestick);
4780
4781 app.handle_key_event(make_key_event(KeyCode::Char('s')));
4783 assert_eq!(app.state.scale_mode, ScaleMode::Log);
4784
4785 app.handle_key_event(make_key_event(KeyCode::Char('/')));
4787 assert_eq!(app.state.color_scheme, ColorScheme::BlueOrange);
4788
4789 app.handle_key_event(make_key_event(KeyCode::Char('j')));
4791
4792 app.handle_key_event(make_key_event(KeyCode::Char('k')));
4794
4795 app.handle_key_event(make_key_event(KeyCode::Char('l')));
4797 assert!(!app.state.auto_layout);
4798
4799 app.handle_key_event(make_key_event(KeyCode::Char('h')));
4801
4802 app.handle_key_event(make_key_event(KeyCode::Char('a')));
4804 assert!(app.state.auto_layout);
4805
4806 app.handle_key_event(make_key_event(KeyCode::Char('w')));
4808 assert!(app.state.widget_toggle_mode);
4809 app.handle_key_event(make_key_event(KeyCode::Char('z')));
4811
4812 app.handle_key_event(make_key_event(KeyCode::F(12)));
4814 assert!(!app.should_exit);
4815 }
4816
4817 #[tokio::test]
4822 async fn test_app_fetch_data_success() {
4823 let data = create_test_token_data();
4824 let initial_price = data.price_usd;
4825 let mut updated = data.clone();
4826 updated.price_usd = 2.5;
4827 let mut app = create_test_app(Box::new(MockDexDataSource::new(updated)), None);
4828
4829 assert!((app.state.current_price - initial_price).abs() < 0.001);
4830 app.fetch_data().await;
4831 assert!((app.state.current_price - 2.5).abs() < 0.001);
4832 assert!(app.state.error_message.is_none());
4833 }
4834
4835 #[tokio::test]
4836 async fn test_app_fetch_data_api_error() {
4837 let mut app = create_test_app(Box::new(MockDexDataSource::failing("rate limited")), None);
4838
4839 app.fetch_data().await;
4840 assert!(app.state.error_message.is_some());
4841 assert!(
4842 app.state
4843 .error_message
4844 .as_ref()
4845 .unwrap()
4846 .contains("API Error")
4847 );
4848 }
4849
4850 #[tokio::test]
4851 async fn test_app_fetch_data_holder_count_on_12th_tick() {
4852 let data = create_test_token_data();
4853 let mock_chain = MockChainClient::new(42_000);
4854 let mut app = create_test_app(
4855 Box::new(MockDexDataSource::new(data)),
4856 Some(Box::new(mock_chain)),
4857 );
4858
4859 for _ in 0..11 {
4861 app.fetch_data().await;
4862 }
4863 assert!(app.state.holder_count.is_none());
4864
4865 app.fetch_data().await;
4867 assert_eq!(app.state.holder_count, Some(42_000));
4868 }
4869
4870 #[tokio::test]
4871 async fn test_app_fetch_data_holder_count_zero_not_stored() {
4872 let data = create_test_token_data();
4873 let mock_chain = MockChainClient::new(0); let mut app = create_test_app(
4875 Box::new(MockDexDataSource::new(data)),
4876 Some(Box::new(mock_chain)),
4877 );
4878
4879 app.state.holder_fetch_counter = 11;
4881 app.fetch_data().await;
4882 assert!(app.state.holder_count.is_none());
4884 }
4885
4886 #[tokio::test]
4887 async fn test_app_fetch_data_no_chain_client_skips_holders() {
4888 let data = create_test_token_data();
4889 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4890
4891 app.state.holder_fetch_counter = 11;
4893 app.fetch_data().await;
4894 assert!(app.state.holder_count.is_none());
4896 }
4897
4898 #[tokio::test]
4899 async fn test_app_fetch_data_preserves_holder_on_subsequent_failure() {
4900 let data = create_test_token_data();
4901 let mock_chain = MockChainClient::new(42_000);
4902 let mut app = create_test_app(
4903 Box::new(MockDexDataSource::new(data)),
4904 Some(Box::new(mock_chain)),
4905 );
4906
4907 app.state.holder_fetch_counter = 11;
4909 app.fetch_data().await;
4910 assert_eq!(app.state.holder_count, Some(42_000));
4911
4912 app.chain_client = Some(Box::new(MockChainClient::new(0)));
4914 app.state.holder_fetch_counter = 23;
4916 app.fetch_data().await;
4917 assert_eq!(app.state.holder_count, Some(42_000));
4919 }
4920
4921 #[test]
4926 fn test_app_cleanup_does_not_panic_test_backend() {
4927 let data = create_test_token_data();
4928 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4929 let result = app.cleanup();
4931 assert!(result.is_ok());
4932 }
4933
4934 #[test]
4939 fn test_app_draw_renders_ui() {
4940 let data = create_test_token_data();
4941 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4942 app.terminal
4944 .draw(|f| ui(f, &mut app.state))
4945 .expect("should render without panic");
4946 }
4947
4948 fn make_search_results() -> Vec<TokenSearchResult> {
4953 vec![
4954 TokenSearchResult {
4955 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
4956 symbol: "USDC".to_string(),
4957 name: "USD Coin".to_string(),
4958 chain: "ethereum".to_string(),
4959 price_usd: Some(1.0),
4960 volume_24h: 5_000_000_000.0,
4961 liquidity_usd: 2_000_000_000.0,
4962 market_cap: Some(32_000_000_000.0),
4963 },
4964 TokenSearchResult {
4965 address: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(),
4966 symbol: "USDT".to_string(),
4967 name: "Tether USD".to_string(),
4968 chain: "ethereum".to_string(),
4969 price_usd: Some(1.0),
4970 volume_24h: 6_000_000_000.0,
4971 liquidity_usd: 3_000_000_000.0,
4972 market_cap: Some(83_000_000_000.0),
4973 },
4974 TokenSearchResult {
4975 address: "0x6B175474E89094C44Da98b954EedeAC495271d0F".to_string(),
4976 symbol: "DAI".to_string(),
4977 name: "Dai Stablecoin".to_string(),
4978 chain: "ethereum".to_string(),
4979 price_usd: Some(1.0),
4980 volume_24h: 200_000_000.0,
4981 liquidity_usd: 500_000_000.0,
4982 market_cap: Some(5_000_000_000.0),
4983 },
4984 ]
4985 }
4986
4987 #[test]
4988 fn test_select_token_impl_valid_first() {
4989 let results = make_search_results();
4990 let mut reader = io::Cursor::new(b"1\n");
4991 let mut writer = Vec::new();
4992 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
4993 assert_eq!(selected.symbol, "USDC");
4994 assert_eq!(selected.address, results[0].address);
4995 let output = String::from_utf8(writer).unwrap();
4996 assert!(output.contains("Found 3 tokens"));
4997 assert!(output.contains("Selected: USDC"));
4998 }
4999
5000 #[test]
5001 fn test_select_token_impl_valid_last() {
5002 let results = make_search_results();
5003 let mut reader = io::Cursor::new(b"3\n");
5004 let mut writer = Vec::new();
5005 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5006 assert_eq!(selected.symbol, "DAI");
5007 }
5008
5009 #[test]
5010 fn test_select_token_impl_valid_middle() {
5011 let results = make_search_results();
5012 let mut reader = io::Cursor::new(b"2\n");
5013 let mut writer = Vec::new();
5014 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5015 assert_eq!(selected.symbol, "USDT");
5016 }
5017
5018 #[test]
5019 fn test_select_token_impl_out_of_bounds_zero() {
5020 let results = make_search_results();
5021 let mut reader = io::Cursor::new(b"0\n");
5022 let mut writer = Vec::new();
5023 let result = select_token_impl(&results, &mut reader, &mut writer);
5024 assert!(result.is_err());
5025 let err = result.unwrap_err().to_string();
5026 assert!(err.contains("Selection must be between 1 and 3"));
5027 }
5028
5029 #[test]
5030 fn test_select_token_impl_out_of_bounds_high() {
5031 let results = make_search_results();
5032 let mut reader = io::Cursor::new(b"99\n");
5033 let mut writer = Vec::new();
5034 let result = select_token_impl(&results, &mut reader, &mut writer);
5035 assert!(result.is_err());
5036 }
5037
5038 #[test]
5039 fn test_select_token_impl_non_numeric_input() {
5040 let results = make_search_results();
5041 let mut reader = io::Cursor::new(b"abc\n");
5042 let mut writer = Vec::new();
5043 let result = select_token_impl(&results, &mut reader, &mut writer);
5044 assert!(result.is_err());
5045 let err = result.unwrap_err().to_string();
5046 assert!(err.contains("Invalid selection"));
5047 }
5048
5049 #[test]
5050 fn test_select_token_impl_empty_input() {
5051 let results = make_search_results();
5052 let mut reader = io::Cursor::new(b"\n");
5053 let mut writer = Vec::new();
5054 let result = select_token_impl(&results, &mut reader, &mut writer);
5055 assert!(result.is_err());
5056 }
5057
5058 #[test]
5059 fn test_select_token_impl_long_name_truncation() {
5060 let results = vec![TokenSearchResult {
5061 address: "0xABCDEF1234567890ABCDEF1234567890ABCDEF12".to_string(),
5062 symbol: "LONG".to_string(),
5063 name: "A Very Long Token Name That Exceeds Twenty Characters".to_string(),
5064 chain: "ethereum".to_string(),
5065 price_usd: None,
5066 volume_24h: 100.0,
5067 liquidity_usd: 50.0,
5068 market_cap: None,
5069 }];
5070 let mut reader = io::Cursor::new(b"1\n");
5071 let mut writer = Vec::new();
5072 let selected = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5073 assert_eq!(selected.symbol, "LONG");
5074 let output = String::from_utf8(writer).unwrap();
5075 assert!(output.contains("A Very Long Token..."));
5077 assert!(output.contains("N/A"));
5079 }
5080
5081 #[test]
5082 fn test_select_token_impl_output_format() {
5083 let results = make_search_results();
5084 let mut reader = io::Cursor::new(b"1\n");
5085 let mut writer = Vec::new();
5086 let _ = select_token_impl(&results, &mut reader, &mut writer).unwrap();
5087 let output = String::from_utf8(writer).unwrap();
5088
5089 assert!(output.contains("#"));
5091 assert!(output.contains("Symbol"));
5092 assert!(output.contains("Name"));
5093 assert!(output.contains("Address"));
5094 assert!(output.contains("Price"));
5095 assert!(output.contains("Liquidity"));
5096 assert!(output.contains("─"));
5098 assert!(output.contains("Select token (1-3):"));
5100 }
5101
5102 #[test]
5107 fn test_format_monitor_number_billions() {
5108 assert_eq!(format_monitor_number(5_000_000_000.0), "$5.00B");
5109 assert_eq!(format_monitor_number(1_234_567_890.0), "$1.23B");
5110 }
5111
5112 #[test]
5113 fn test_format_monitor_number_millions() {
5114 assert_eq!(format_monitor_number(5_000_000.0), "$5.00M");
5115 assert_eq!(format_monitor_number(42_500_000.0), "$42.50M");
5116 }
5117
5118 #[test]
5119 fn test_format_monitor_number_thousands() {
5120 assert_eq!(format_monitor_number(5_000.0), "$5.00K");
5121 assert_eq!(format_monitor_number(999_999.0), "$1000.00K");
5122 }
5123
5124 #[test]
5125 fn test_format_monitor_number_small() {
5126 assert_eq!(format_monitor_number(42.0), "$42.00");
5127 assert_eq!(format_monitor_number(0.5), "$0.50");
5128 assert_eq!(format_monitor_number(0.0), "$0.00");
5129 }
5130
5131 #[test]
5136 fn test_abbreviate_address_exactly_16_chars() {
5137 let addr = "0123456789ABCDEF"; assert_eq!(abbreviate_address(addr), addr);
5139 }
5140
5141 #[test]
5142 fn test_abbreviate_address_17_chars() {
5143 let addr = "0123456789ABCDEFG"; assert_eq!(abbreviate_address(addr), "01234567...BCDEFG");
5145 }
5146
5147 #[tokio::test]
5152 async fn test_app_full_scenario_fetch_render_quit() {
5153 let data = create_test_token_data();
5154 let mut updated = data.clone();
5155 updated.price_usd = 3.0;
5156 let mock_chain = MockChainClient::new(10_000);
5157 let state = MonitorState::new(&data, "ethereum");
5158 let mut app = create_test_app_with_state(
5159 state,
5160 Box::new(MockDexDataSource::new(updated)),
5161 Some(Box::new(mock_chain)),
5162 );
5163
5164 app.fetch_data().await;
5166 assert!((app.state.current_price - 3.0).abs() < 0.001);
5167
5168 app.terminal
5170 .draw(|f| ui(f, &mut app.state))
5171 .expect("render");
5172
5173 app.handle_key_event(make_key_event(KeyCode::Char('e')));
5175 assert!(app.state.export_active);
5176
5177 app.handle_key_event(make_key_event(KeyCode::Char('q')));
5179 assert!(app.should_exit);
5180 assert!(!app.state.export_active);
5181 }
5182
5183 #[tokio::test]
5184 async fn test_app_fetch_data_error_then_recovery() {
5185 let mut app = create_test_app(Box::new(MockDexDataSource::failing("server down")), None);
5186
5187 app.fetch_data().await;
5189 assert!(app.state.error_message.is_some());
5190
5191 let mut recovered = create_test_token_data();
5193 recovered.price_usd = 5.0;
5194 app.dex_client = Box::new(MockDexDataSource::new(recovered));
5195
5196 app.fetch_data().await;
5198 assert!((app.state.current_price - 5.0).abs() < 0.001);
5199 }
5201
5202 #[test]
5207 fn test_monitor_args_defaults() {
5208 use super::super::Cli;
5209 use clap::Parser;
5210 let cli = Cli::try_parse_from(["scope", "monitor", "USDC"]).unwrap();
5212 if let super::super::Commands::Monitor(args) = cli.command {
5213 assert_eq!(args.token, "USDC");
5214 assert_eq!(args.chain, "ethereum");
5215 assert!(args.layout.is_none());
5216 assert!(args.refresh.is_none());
5217 assert!(args.scale.is_none());
5218 assert!(args.color_scheme.is_none());
5219 assert!(args.export.is_none());
5220 } else {
5221 panic!("Expected Monitor command");
5222 }
5223 }
5224
5225 #[test]
5226 fn test_monitor_args_all_flags() {
5227 use super::super::Cli;
5228 use clap::Parser;
5229 let cli = Cli::try_parse_from([
5230 "scope",
5231 "monitor",
5232 "PEPE",
5233 "--chain",
5234 "solana",
5235 "--layout",
5236 "feed",
5237 "--refresh",
5238 "2",
5239 "--scale",
5240 "log",
5241 "--color-scheme",
5242 "monochrome",
5243 "--export",
5244 "/tmp/data.csv",
5245 ])
5246 .unwrap();
5247 if let super::super::Commands::Monitor(args) = cli.command {
5248 assert_eq!(args.token, "PEPE");
5249 assert_eq!(args.chain, "solana");
5250 assert_eq!(args.layout, Some(LayoutPreset::Feed));
5251 assert_eq!(args.refresh, Some(2));
5252 assert_eq!(args.scale, Some(ScaleMode::Log));
5253 assert_eq!(args.color_scheme, Some(ColorScheme::Monochrome));
5254 assert_eq!(args.export, Some(PathBuf::from("/tmp/data.csv")));
5255 } else {
5256 panic!("Expected Monitor command");
5257 }
5258 }
5259
5260 #[test]
5261 fn test_run_direct_config_override_layout() {
5262 let config = Config::default();
5264 assert_eq!(config.monitor.layout, LayoutPreset::Dashboard);
5265
5266 let args = MonitorArgs {
5267 token: "USDC".to_string(),
5268 chain: "ethereum".to_string(),
5269 layout: Some(LayoutPreset::ChartFocus),
5270 refresh: None,
5271 scale: None,
5272 color_scheme: None,
5273 export: None,
5274 venue: None,
5275 pair: None,
5276 };
5277
5278 let mut monitor_config = config.monitor.clone();
5280 if let Some(layout) = args.layout {
5281 monitor_config.layout = layout;
5282 }
5283 assert_eq!(monitor_config.layout, LayoutPreset::ChartFocus);
5284 }
5285
5286 #[test]
5287 fn test_run_direct_config_override_all_fields() {
5288 let config = Config::default();
5289 let args = MonitorArgs {
5290 token: "PEPE".to_string(),
5291 chain: "solana".to_string(),
5292 layout: Some(LayoutPreset::Compact),
5293 refresh: Some(2),
5294 scale: Some(ScaleMode::Log),
5295 color_scheme: Some(ColorScheme::BlueOrange),
5296 export: Some(PathBuf::from("/tmp/test.csv")),
5297 venue: None,
5298 pair: None,
5299 };
5300
5301 let mut mc = config.monitor.clone();
5302 if let Some(layout) = args.layout {
5303 mc.layout = layout;
5304 }
5305 if let Some(refresh) = args.refresh {
5306 mc.refresh_seconds = refresh;
5307 }
5308 if let Some(scale) = args.scale {
5309 mc.scale = scale;
5310 }
5311 if let Some(color_scheme) = args.color_scheme {
5312 mc.color_scheme = color_scheme;
5313 }
5314 if let Some(ref path) = args.export {
5315 mc.export.path = Some(path.to_string_lossy().into_owned());
5316 }
5317
5318 assert_eq!(mc.layout, LayoutPreset::Compact);
5319 assert_eq!(mc.refresh_seconds, 2);
5320 assert_eq!(mc.scale, ScaleMode::Log);
5321 assert_eq!(mc.color_scheme, ColorScheme::BlueOrange);
5322 assert_eq!(mc.export.path, Some("/tmp/test.csv".to_string()));
5323 }
5324
5325 #[test]
5326 fn test_run_direct_config_no_overrides_preserves_defaults() {
5327 let config = Config::default();
5328 let args = MonitorArgs {
5329 token: "USDC".to_string(),
5330 chain: "ethereum".to_string(),
5331 layout: None,
5332 refresh: None,
5333 scale: None,
5334 color_scheme: None,
5335 export: None,
5336 venue: None,
5337 pair: None,
5338 };
5339
5340 let mut mc = config.monitor.clone();
5341 if let Some(layout) = args.layout {
5342 mc.layout = layout;
5343 }
5344 if let Some(refresh) = args.refresh {
5345 mc.refresh_seconds = refresh;
5346 }
5347 if let Some(scale) = args.scale {
5348 mc.scale = scale;
5349 }
5350 if let Some(color_scheme) = args.color_scheme {
5351 mc.color_scheme = color_scheme;
5352 }
5353
5354 assert_eq!(mc.layout, LayoutPreset::Dashboard);
5356 assert_eq!(mc.refresh_seconds, DEFAULT_REFRESH_SECS);
5357 assert_eq!(mc.scale, ScaleMode::Linear);
5358 assert_eq!(mc.color_scheme, ColorScheme::GreenRed);
5359 assert!(mc.export.path.is_none());
5360 }
5361
5362 #[test]
5367 fn test_exchange_interval_mapping() {
5368 assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
5369 assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
5370 assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
5371 assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
5372 assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
5373 assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
5374 }
5375
5376 #[test]
5377 fn test_monitor_state_exchange_ohlc_default_empty() {
5378 let token_data = create_test_token_data();
5379 let state = MonitorState::new(&token_data, "ethereum");
5380 assert!(state.exchange_ohlc.is_empty());
5381 assert!(state.venue_pair.is_none());
5382 }
5383
5384 #[test]
5385 fn test_get_ohlc_candles_prefers_exchange_data() {
5386 let token_data = create_test_token_data();
5387 let mut state = MonitorState::new(&token_data, "ethereum");
5388
5389 let now = std::time::SystemTime::now()
5391 .duration_since(std::time::UNIX_EPOCH)
5392 .unwrap()
5393 .as_secs_f64();
5394 state.price_history.push_back(DataPoint {
5395 timestamp: now,
5396 value: 1.0,
5397 is_real: true,
5398 });
5399 state.price_history.push_back(DataPoint {
5400 timestamp: now + 5.0,
5401 value: 1.01,
5402 is_real: true,
5403 });
5404
5405 let candles_before = state.get_ohlc_candles();
5407
5408 state.exchange_ohlc = vec![
5410 OhlcCandle {
5411 timestamp: 1700000000.0,
5412 open: 50000.0,
5413 high: 50500.0,
5414 low: 49800.0,
5415 close: 50200.0,
5416 is_bullish: true,
5417 },
5418 OhlcCandle {
5419 timestamp: 1700003600.0,
5420 open: 50200.0,
5421 high: 50800.0,
5422 low: 50100.0,
5423 close: 50700.0,
5424 is_bullish: true,
5425 },
5426 ];
5427
5428 let candles_after = state.get_ohlc_candles();
5429 assert_eq!(candles_after.len(), 2);
5430 assert_eq!(candles_after[0].open, 50000.0);
5431 assert_eq!(candles_after[1].close, 50700.0);
5432
5433 if !candles_before.is_empty() {
5435 assert_ne!(candles_after[0].open, candles_before[0].open);
5436 }
5437 }
5438
5439 #[test]
5440 fn test_monitor_args_with_venue() {
5441 let args = MonitorArgs {
5442 token: "BTC".to_string(),
5443 chain: "ethereum".to_string(),
5444 refresh: None,
5445 layout: None,
5446 scale: None,
5447 color_scheme: None,
5448 export: None,
5449 venue: Some("binance".to_string()),
5450 pair: None,
5451 };
5452 assert_eq!(args.venue, Some("binance".to_string()));
5453 }
5454
5455 #[test]
5456 fn test_ohlc_candle_is_bullish_calculation() {
5457 let bullish = OhlcCandle {
5458 timestamp: 1700000000.0,
5459 open: 100.0,
5460 high: 110.0,
5461 low: 95.0,
5462 close: 105.0,
5463 is_bullish: true,
5464 };
5465 assert!(bullish.is_bullish);
5466
5467 let bearish = OhlcCandle {
5468 timestamp: 1700000000.0,
5469 open: 100.0,
5470 high: 105.0,
5471 low: 90.0,
5472 close: 95.0,
5473 is_bullish: false,
5474 };
5475 assert!(!bearish.is_bullish);
5476 }
5477
5478 #[test]
5479 fn test_build_exchange_token_data_from_ticker() {
5480 let ticker = scope::market::Ticker {
5481 pair: "DAI/USDT".to_string(),
5482 last_price: Some(1.001),
5483 high_24h: Some(1.005),
5484 low_24h: Some(0.998),
5485 volume_24h: Some(500_000.0),
5486 quote_volume_24h: Some(500_500.0),
5487 best_bid: Some(1.0005),
5488 best_ask: Some(1.0015),
5489 };
5490
5491 let data = build_exchange_token_data("DAI", "DAI_USDT", &ticker);
5492
5493 assert_eq!(data.symbol, "DAI");
5494 assert_eq!(data.name, "DAI");
5495 assert_eq!(data.price_usd, 1.001);
5496 assert_eq!(data.volume_24h, 500_000.0);
5497 assert!(data.address.contains("exchange:"));
5498 assert!(data.pairs.is_empty());
5499 assert!(data.price_history.is_empty());
5500 assert!(data.dexscreener_url.is_none());
5501 }
5502
5503 #[test]
5504 fn test_build_exchange_token_data_missing_price() {
5505 let ticker = scope::market::Ticker {
5506 pair: "FOO/USDT".to_string(),
5507 last_price: None,
5508 high_24h: None,
5509 low_24h: None,
5510 volume_24h: None,
5511 quote_volume_24h: None,
5512 best_bid: None,
5513 best_ask: None,
5514 };
5515
5516 let data = build_exchange_token_data("FOO", "FOO_USDT", &ticker);
5517 assert_eq!(data.price_usd, 0.0);
5518 assert_eq!(data.volume_24h, 0.0);
5519 }
5520
5521 #[test]
5522 fn test_monitor_args_with_pair() {
5523 let args = MonitorArgs {
5524 token: "DAI".to_string(),
5525 chain: "ethereum".to_string(),
5526 refresh: None,
5527 layout: None,
5528 scale: None,
5529 color_scheme: None,
5530 export: None,
5531 venue: Some("biconomy".to_string()),
5532 pair: Some("DAI_USDT".to_string()),
5533 };
5534 assert_eq!(args.pair, Some("DAI_USDT".to_string()));
5535 assert_eq!(args.venue, Some("biconomy".to_string()));
5536 }
5537
5538 #[test]
5539 fn test_monitor_args_pair_none_by_default() {
5540 let args = MonitorArgs {
5541 token: "BTC".to_string(),
5542 chain: "ethereum".to_string(),
5543 refresh: None,
5544 layout: None,
5545 scale: None,
5546 color_scheme: None,
5547 export: None,
5548 venue: None,
5549 pair: None,
5550 };
5551 assert!(args.pair.is_none());
5552 }
5553
5554 #[test]
5555 fn test_build_exchange_token_data_extracts_base_symbol() {
5556 let ticker = scope::market::Ticker {
5557 pair: "DOGE/USDT".to_string(),
5558 last_price: Some(0.123),
5559 high_24h: Some(0.13),
5560 low_24h: Some(0.12),
5561 volume_24h: Some(1_000_000.0),
5562 quote_volume_24h: Some(123_000.0),
5563 best_bid: Some(0.1225),
5564 best_ask: Some(0.1235),
5565 };
5566
5567 let data = build_exchange_token_data("DOGE", "DOGE_USDT", &ticker);
5568 assert_eq!(data.symbol, "DOGE");
5569 assert_eq!(data.name, "DOGE");
5570 assert_eq!(data.price_usd, 0.123);
5571 assert_eq!(data.volume_24h, 1_000_000.0);
5572 assert_eq!(data.price_change_24h, 0.0);
5574 assert_eq!(data.price_change_6h, 0.0);
5575 assert_eq!(data.price_change_1h, 0.0);
5576 assert_eq!(data.price_change_5m, 0.0);
5577 assert_eq!(data.volume_6h, 0.0);
5578 assert_eq!(data.volume_1h, 0.0);
5579 assert_eq!(data.liquidity_usd, 0.0);
5580 assert!(data.market_cap.is_none());
5581 assert!(data.fdv.is_none());
5582 assert!(data.earliest_pair_created_at.is_none());
5583 assert!(data.image_url.is_none());
5584 assert!(data.websites.is_empty());
5585 assert!(data.socials.is_empty());
5586 assert_eq!(data.total_buys_24h, 0);
5587 assert_eq!(data.total_sells_24h, 0);
5588 }
5589
5590 #[test]
5591 fn test_build_exchange_token_data_address_format() {
5592 let ticker = scope::market::Ticker {
5593 pair: "X/Y".to_string(),
5594 last_price: Some(1.0),
5595 high_24h: None,
5596 low_24h: None,
5597 volume_24h: None,
5598 quote_volume_24h: None,
5599 best_bid: None,
5600 best_ask: None,
5601 };
5602
5603 let data = build_exchange_token_data("X", "X_Y", &ticker);
5604 assert_eq!(data.address, "exchange:X_Y");
5605 }
5606
5607 #[test]
5608 fn test_monitor_args_pair_requires_venue_conceptually() {
5609 let args = MonitorArgs {
5612 token: "DAI".to_string(),
5613 chain: "ethereum".to_string(),
5614 refresh: None,
5615 layout: None,
5616 scale: None,
5617 color_scheme: None,
5618 export: None,
5619 venue: Some("biconomy".to_string()),
5620 pair: Some("DAI_USDT".to_string()),
5621 };
5622 assert!(args.venue.is_some());
5623 assert!(args.pair.is_some());
5624 }
5625
5626 #[test]
5627 fn test_run_direct_config_pair_passthrough() {
5628 let config = Config::default();
5630 let args = MonitorArgs {
5631 token: "DAI".to_string(),
5632 chain: "ethereum".to_string(),
5633 layout: None,
5634 refresh: None,
5635 scale: None,
5636 color_scheme: None,
5637 export: None,
5638 venue: Some("biconomy".to_string()),
5639 pair: Some("DAI_USDT".to_string()),
5640 };
5641
5642 let mut mc = config.monitor.clone();
5644 if let Some(ref venue) = args.venue {
5645 mc.venue = Some(venue.clone());
5646 }
5647 assert_eq!(mc.venue, Some("biconomy".to_string()));
5648 assert_eq!(args.pair, Some("DAI_USDT".to_string()));
5650 }
5651
5652 #[test]
5657 fn test_chain_filter_logic_ethereum_default() {
5658 let chain = "ethereum";
5661 let chain_filter: Option<&str> = if chain != "ethereum" {
5662 Some(chain)
5663 } else {
5664 None
5665 };
5666 assert!(chain_filter.is_none());
5667 }
5668
5669 #[test]
5670 fn test_chain_filter_logic_explicit_chain() {
5671 let chain = "solana";
5673 let chain_filter: Option<&str> = if chain != "ethereum" {
5674 Some(chain)
5675 } else {
5676 None
5677 };
5678 assert_eq!(chain_filter, Some("solana"));
5679 }
5680
5681 #[test]
5682 fn test_chain_filter_logic_bsc() {
5683 let chain = "bsc";
5684 let chain_filter: Option<&str> = if chain != "ethereum" {
5685 Some(chain)
5686 } else {
5687 None
5688 };
5689 assert_eq!(chain_filter, Some("bsc"));
5690 }
5691
5692 #[tokio::test]
5693 async fn test_resolve_token_address_with_address_input() {
5694 use scope::chains::dex::DexClient;
5695 let config = Config::default();
5696 let dex = DexClient::new();
5697 let result = resolve_token_address(
5699 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
5700 "ethereum",
5701 &config,
5702 &dex,
5703 )
5704 .await
5705 .unwrap();
5706 assert_eq!(result, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
5707 }
5708
5709 #[tokio::test]
5710 async fn test_resolve_token_address_solana_address() {
5711 use scope::chains::dex::DexClient;
5712 let config = Config::default();
5713 let dex = DexClient::new();
5714 let addr = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
5716 let result = resolve_token_address(addr, "solana", &config, &dex)
5717 .await
5718 .unwrap();
5719 assert_eq!(result, addr);
5720 }
5721
5722 #[test]
5723 fn test_try_cex_fallback_returns_correct_structure() {
5724 let result = scope::chains::TokenSearchResult {
5727 address: String::new(),
5728 symbol: "TEST".to_string(),
5729 name: "TEST".to_string(),
5730 chain: "ethereum".to_string(),
5731 price_usd: Some(1.0),
5732 volume_24h: 100.0,
5733 liquidity_usd: 0.0,
5734 market_cap: None,
5735 };
5736 assert_eq!(result.symbol, "TEST");
5737 assert!(result.address.is_empty());
5738 assert_eq!(result.liquidity_usd, 0.0);
5739 }
5740
5741 #[test]
5742 fn test_select_token_impl_single_result_pick() {
5743 let results = vec![scope::chains::dex::TokenSearchResult {
5744 address: "0xabc".to_string(),
5745 symbol: "TEST".to_string(),
5746 name: "Test Token".to_string(),
5747 chain: "ethereum".to_string(),
5748 price_usd: Some(1.0),
5749 volume_24h: 100.0,
5750 liquidity_usd: 1000.0,
5751 market_cap: None,
5752 }];
5753 let input = b"1\n";
5754 let mut output = Vec::new();
5755 let result = select_token_impl(&results, &mut &input[..], &mut output);
5756 assert!(result.is_ok());
5757 assert_eq!(result.unwrap().symbol, "TEST");
5758 }
5759
5760 #[test]
5761 fn test_select_token_impl_out_of_range() {
5762 let results = vec![scope::chains::dex::TokenSearchResult {
5763 address: "0xabc".to_string(),
5764 symbol: "TEST".to_string(),
5765 name: "Test Token".to_string(),
5766 chain: "ethereum".to_string(),
5767 price_usd: Some(1.0),
5768 volume_24h: 100.0,
5769 liquidity_usd: 1000.0,
5770 market_cap: None,
5771 }];
5772 let input = b"0\n";
5773 let mut output = Vec::new();
5774 let result = select_token_impl(&results, &mut &input[..], &mut output);
5775 assert!(result.is_err());
5776 }
5777
5778 #[test]
5779 fn test_select_token_impl_second_of_two() {
5780 let results = vec![
5781 scope::chains::dex::TokenSearchResult {
5782 address: "0xabc".to_string(),
5783 symbol: "TOKEN1".to_string(),
5784 name: "Token One".to_string(),
5785 chain: "ethereum".to_string(),
5786 price_usd: Some(1.0),
5787 volume_24h: 100.0,
5788 liquidity_usd: 1000.0,
5789 market_cap: None,
5790 },
5791 scope::chains::dex::TokenSearchResult {
5792 address: "0xdef".to_string(),
5793 symbol: "TOKEN2".to_string(),
5794 name: "Token Two".to_string(),
5795 chain: "solana".to_string(),
5796 price_usd: Some(2.0),
5797 volume_24h: 200.0,
5798 liquidity_usd: 2000.0,
5799 market_cap: None,
5800 },
5801 ];
5802 let input = b"2\n";
5803 let mut output = Vec::new();
5804 let result = select_token_impl(&results, &mut &input[..], &mut output);
5805 assert!(result.is_ok());
5806 assert_eq!(result.unwrap().symbol, "TOKEN2");
5807 }
5808
5809 #[test]
5814 fn test_handle_key_event_quit() {
5815 let token_data = create_test_token_data();
5816 let mut state = MonitorState::new(&token_data, "ethereum");
5817 let key = crossterm::event::KeyEvent::new(
5818 crossterm::event::KeyCode::Char('q'),
5819 crossterm::event::KeyModifiers::NONE,
5820 );
5821 assert!(handle_key_event_on_state(key, &mut state));
5822 }
5823
5824 #[test]
5825 fn test_handle_key_event_pause() {
5826 let token_data = create_test_token_data();
5827 let mut state = MonitorState::new(&token_data, "ethereum");
5828 let key = crossterm::event::KeyEvent::new(
5829 crossterm::event::KeyCode::Char(' '),
5830 crossterm::event::KeyModifiers::NONE,
5831 );
5832 assert!(!state.paused);
5833 assert!(!handle_key_event_on_state(key, &mut state));
5834 assert!(state.paused);
5835 }
5836
5837 #[test]
5838 fn test_handle_key_event_chart_cycle() {
5839 let token_data = create_test_token_data();
5840 let mut state = MonitorState::new(&token_data, "ethereum");
5841 let initial_mode = state.chart_mode;
5842 let key = crossterm::event::KeyEvent::new(
5843 crossterm::event::KeyCode::Char('c'),
5844 crossterm::event::KeyModifiers::NONE,
5845 );
5846 handle_key_event_on_state(key, &mut state);
5847 assert_ne!(state.chart_mode, initial_mode);
5848 }
5849
5850 #[test]
5851 fn test_handle_key_event_slower_faster() {
5852 let token_data = create_test_token_data();
5853 let mut state = MonitorState::new(&token_data, "ethereum");
5854 let initial_rate = state.refresh_rate_secs();
5855
5856 let key_plus = crossterm::event::KeyEvent::new(
5858 crossterm::event::KeyCode::Char('+'),
5859 crossterm::event::KeyModifiers::NONE,
5860 );
5861 handle_key_event_on_state(key_plus, &mut state);
5862 assert!(state.refresh_rate_secs() > initial_rate);
5863
5864 let key_minus = crossterm::event::KeyEvent::new(
5866 crossterm::event::KeyCode::Char('-'),
5867 crossterm::event::KeyModifiers::NONE,
5868 );
5869 handle_key_event_on_state(key_minus, &mut state);
5870 assert_eq!(state.refresh_rate_secs(), initial_rate);
5871 }
5872
5873 #[test]
5874 fn test_handle_key_event_time_period() {
5875 let token_data = create_test_token_data();
5876 let mut state = MonitorState::new(&token_data, "ethereum");
5877 let initial_period = state.time_period;
5878 let key = crossterm::event::KeyEvent::new(
5879 crossterm::event::KeyCode::Char('t'),
5880 crossterm::event::KeyModifiers::NONE,
5881 );
5882 handle_key_event_on_state(key, &mut state);
5883 assert_ne!(state.time_period, initial_period);
5884 }
5885
5886 #[test]
5887 fn test_handle_key_event_escape() {
5888 let token_data = create_test_token_data();
5889 let mut state = MonitorState::new(&token_data, "ethereum");
5890 let key = crossterm::event::KeyEvent::new(
5891 crossterm::event::KeyCode::Esc,
5892 crossterm::event::KeyModifiers::NONE,
5893 );
5894 assert!(handle_key_event_on_state(key, &mut state));
5895 }
5896
5897 #[test]
5902 fn test_render_order_book_panel_no_data() {
5903 let mut terminal = create_test_terminal();
5904 let mut state = create_populated_state();
5905 state.order_book = None;
5906 let area = Rect::new(0, 0, 50, 20);
5907 terminal
5908 .draw(|f| render_order_book_panel(f, area, &state))
5909 .unwrap();
5910 }
5911
5912 #[test]
5913 fn test_render_order_book_panel_with_data() {
5914 let mut terminal = create_test_terminal();
5915 let state = create_populated_state();
5916 let area = Rect::new(0, 0, 60, 25);
5917 terminal
5918 .draw(|f| render_order_book_panel(f, area, &state))
5919 .unwrap();
5920 }
5921
5922 #[test]
5923 fn test_render_order_book_panel_narrow_height() {
5924 let mut terminal = create_test_terminal();
5925 let state = create_populated_state();
5926 let area = Rect::new(0, 0, 50, 3);
5927 terminal
5928 .draw(|f| render_order_book_panel(f, area, &state))
5929 .unwrap();
5930 }
5931
5932 #[test]
5933 fn test_render_recent_trades_panel_empty() {
5934 let mut terminal = create_test_terminal();
5935 let mut state = create_populated_state();
5936 state.recent_trades.clear();
5937 let area = Rect::new(0, 0, 60, 15);
5938 terminal
5939 .draw(|f| render_recent_trades_panel(f, area, &state))
5940 .unwrap();
5941 }
5942
5943 #[test]
5944 fn test_render_recent_trades_panel_with_trades() {
5945 let mut terminal = create_test_terminal();
5946 let mut state = create_populated_state();
5947 state.recent_trades.push_back(Trade {
5948 price: 1.5,
5949 quantity: 100.0,
5950 quote_quantity: None,
5951 timestamp_ms: 1700000000000,
5952 side: TradeSide::Buy,
5953 id: None,
5954 });
5955 state.recent_trades.push_back(Trade {
5956 price: 1.49,
5957 quantity: 50.0,
5958 quote_quantity: None,
5959 timestamp_ms: 1700000001000,
5960 side: TradeSide::Sell,
5961 id: None,
5962 });
5963 let area = Rect::new(0, 0, 60, 20);
5964 terminal
5965 .draw(|f| render_recent_trades_panel(f, area, &state))
5966 .unwrap();
5967 }
5968
5969 #[test]
5970 fn test_render_recent_trades_panel_high_price_trade() {
5971 let mut terminal = create_test_terminal();
5972 let mut state = create_populated_state();
5973 state.recent_trades.push_back(Trade {
5974 price: 50_000.0,
5975 quantity: 0.5,
5976 quote_quantity: None,
5977 timestamp_ms: 1700000000000,
5978 side: TradeSide::Buy,
5979 id: None,
5980 });
5981 let area = Rect::new(0, 0, 60, 15);
5982 terminal
5983 .draw(|f| render_recent_trades_panel(f, area, &state))
5984 .unwrap();
5985 }
5986
5987 #[test]
5988 fn test_render_market_info_panel_no_panic() {
5989 let mut terminal = create_test_terminal();
5990 let state = create_populated_state();
5991 let area = Rect::new(0, 0, 80, 30);
5992 terminal
5993 .draw(|f| render_market_info_panel(f, area, &state))
5994 .unwrap();
5995 }
5996
5997 #[test]
5998 fn test_render_market_info_panel_with_dex_pairs() {
5999 let mut terminal = create_test_terminal();
6000 let mut state = create_populated_state();
6001 state.dex_pairs = vec![
6002 scope::chains::DexPair {
6003 dex_name: "Uniswap V3".to_string(),
6004 pair_address: "0xabc".to_string(),
6005 base_token: "TEST".to_string(),
6006 quote_token: "USDT".to_string(),
6007 price_usd: 1.0,
6008 volume_24h: 100_000.0,
6009 liquidity_usd: 250_000.0,
6010 price_change_24h: 2.5,
6011 buys_24h: 50,
6012 sells_24h: 40,
6013 buys_6h: 15,
6014 sells_6h: 12,
6015 buys_1h: 5,
6016 sells_1h: 4,
6017 pair_created_at: None,
6018 url: None,
6019 },
6020 scope::chains::DexPair {
6021 dex_name: "SushiSwap".to_string(),
6022 pair_address: "0xdef".to_string(),
6023 base_token: "TEST".to_string(),
6024 quote_token: "WETH".to_string(),
6025 price_usd: 1.01,
6026 volume_24h: 25_000.0,
6027 liquidity_usd: 80_000.0,
6028 price_change_24h: -1.2,
6029 buys_24h: 20,
6030 sells_24h: 25,
6031 buys_6h: 8,
6032 sells_6h: 10,
6033 buys_1h: 3,
6034 sells_1h: 4,
6035 pair_created_at: None,
6036 url: None,
6037 },
6038 ];
6039 let area = Rect::new(0, 0, 100, 35);
6040 terminal
6041 .draw(|f| render_market_info_panel(f, area, &state))
6042 .unwrap();
6043 }
6044
6045 #[test]
6046 fn test_ohlc_candle_update_bearish() {
6047 let mut candle = OhlcCandle::new(1700000000.0, 10.0);
6048 assert!(candle.is_bullish);
6049 candle.update(9.5);
6050 assert!(!candle.is_bullish);
6051 assert_eq!(candle.high, 10.0);
6052 assert_eq!(candle.low, 9.5);
6053 assert_eq!(candle.close, 9.5);
6054 }
6055
6056 #[test]
6057 fn test_time_period_index_all() {
6058 assert_eq!(TimePeriod::Min1.index(), 0);
6059 assert_eq!(TimePeriod::Min5.index(), 1);
6060 assert_eq!(TimePeriod::Min15.index(), 2);
6061 assert_eq!(TimePeriod::Hour1.index(), 3);
6062 assert_eq!(TimePeriod::Hour4.index(), 4);
6063 assert_eq!(TimePeriod::Day1.index(), 5);
6064 }
6065
6066 #[test]
6067 fn test_format_number_boundary_values() {
6068 assert_eq!(format_number(0.5), "0.50");
6069 assert_eq!(format_number(999.99), "999.99");
6070 assert_eq!(format_number(1000.0), "1.00K");
6071 assert_eq!(format_number(999_999.0), "1000.00K"); assert_eq!(format_number(1_000_000.0), "1.00M");
6073 assert_eq!(format_number(999_999_999.0), "1000.00M");
6074 assert_eq!(format_number(1_000_000_000.0), "1.00B");
6075 }
6076
6077 #[test]
6078 fn test_chart_mode_volume_profile_label() {
6079 assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
6080 assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
6081 }
6082
6083 #[test]
6084 fn test_layout_exchange_ui_full_render() {
6085 let mut terminal = create_test_terminal();
6086 let mut state = create_populated_state();
6087 state.layout = LayoutPreset::Exchange;
6088 state.auto_layout = false;
6089 state.dex_pairs = vec![scope::chains::DexPair {
6090 dex_name: "Uniswap".to_string(),
6091 pair_address: "0xabc".to_string(),
6092 base_token: "TEST".to_string(),
6093 quote_token: "USDT".to_string(),
6094 price_usd: 1.0,
6095 volume_24h: 50_000.0,
6096 liquidity_usd: 100_000.0,
6097 price_change_24h: 0.5,
6098 buys_24h: 100,
6099 sells_24h: 80,
6100 buys_6h: 30,
6101 sells_6h: 25,
6102 buys_1h: 10,
6103 sells_1h: 8,
6104 pair_created_at: None,
6105 url: None,
6106 }];
6107 state.order_book = MonitorState::generate_synthetic_order_book(
6108 &state.dex_pairs,
6109 &state.symbol,
6110 state.current_price,
6111 state.liquidity_usd,
6112 );
6113 state.recent_trades.push_back(Trade {
6114 price: 1.0,
6115 quantity: 500.0,
6116 quote_quantity: None,
6117 timestamp_ms: 1700000000000,
6118 side: TradeSide::Buy,
6119 id: None,
6120 });
6121 terminal.draw(|f| ui(f, &mut state)).unwrap();
6122 }
6123
6124 fn make_dex_pair(
6129 dex: &str,
6130 base: &str,
6131 quote: &str,
6132 price: f64,
6133 vol: f64,
6134 liq: f64,
6135 ) -> scope::chains::DexPair {
6136 scope::chains::DexPair {
6137 dex_name: dex.to_string(),
6138 base_token: base.to_string(),
6139 quote_token: quote.to_string(),
6140 price_usd: price,
6141 volume_24h: vol,
6142 liquidity_usd: liq,
6143 price_change_24h: 5.0,
6144 pair_address: format!("0x{}", dex),
6145 buys_24h: 100,
6146 sells_24h: 50,
6147 buys_6h: 25,
6148 sells_6h: 12,
6149 buys_1h: 5,
6150 sells_1h: 3,
6151 pair_created_at: None,
6152 url: None,
6153 }
6154 }
6155
6156 fn create_test_token_data_with_pairs() -> DexTokenData {
6157 let mut data = create_test_token_data();
6158 data.pairs = vec![
6159 make_dex_pair("uniswap", "TEST", "USDC", 1.0, 500_000.0, 300_000.0),
6160 make_dex_pair("sushiswap", "TEST", "WETH", 1.01, 200_000.0, 150_000.0),
6161 ];
6162 data
6163 }
6164
6165 #[test]
6166 fn test_generate_synthetic_order_book_with_pairs() {
6167 let data = create_test_token_data_with_pairs();
6168 let book = MonitorState::generate_synthetic_order_book(
6169 &data.pairs,
6170 &data.symbol,
6171 data.price_usd,
6172 data.liquidity_usd,
6173 );
6174 assert!(book.is_some());
6175 let book = book.unwrap();
6176 assert!(!book.bids.is_empty());
6177 assert!(!book.asks.is_empty());
6178 assert!(book.bids[0].price < data.price_usd);
6179 assert!(book.asks[0].price > data.price_usd);
6180 }
6181
6182 #[test]
6183 fn test_generate_synthetic_order_book_empty_pairs() {
6184 let book = MonitorState::generate_synthetic_order_book(&[], "TEST", 1.0, 500_000.0);
6185 assert!(book.is_some());
6187 }
6188
6189 #[test]
6190 fn test_generate_synthetic_order_book_zero_mid_price() {
6191 let pairs = vec![make_dex_pair("uniswap", "TEST", "USDC", 0.0, 100.0, 100.0)];
6192 let book = MonitorState::generate_synthetic_order_book(&pairs, "TEST", 0.0, 100.0);
6193 assert!(book.is_none());
6194 }
6195
6196 #[test]
6197 fn test_generate_synthetic_order_book_high_liquidity() {
6198 let pairs = vec![make_dex_pair(
6199 "uniswap",
6200 "WETH",
6201 "USDC",
6202 3500.0,
6203 50_000_000.0,
6204 5_000_000.0,
6205 )];
6206 let book = MonitorState::generate_synthetic_order_book(&pairs, "WETH", 3500.0, 5_000_000.0);
6207 assert!(book.is_some());
6208 let book = book.unwrap();
6209 assert!(book.bids.len() > 5);
6210 assert!(book.asks.len() > 5);
6211 }
6212
6213 #[test]
6214 fn test_monitor_state_update_with_token_data() {
6215 let initial_data = create_test_token_data_with_pairs();
6216 let mut state = MonitorState::new(&initial_data, "ethereum");
6217 assert_eq!(state.current_price, 1.0);
6218 assert_eq!(state.real_data_count, 0);
6219
6220 let mut updated_data = initial_data.clone();
6222 updated_data.price_usd = 1.05;
6223 updated_data.volume_24h = 1_200_000.0;
6224 state.update(&updated_data);
6225
6226 assert_eq!(state.current_price, 1.05);
6227 assert_eq!(state.real_data_count, 1);
6228 assert!(!state.price_history.is_empty());
6229 assert!(!state.volume_history.is_empty());
6230 }
6231
6232 #[test]
6233 fn test_monitor_state_update_price_unchanged() {
6234 let data = create_test_token_data();
6235 let mut state = MonitorState::new(&data, "ethereum");
6236 state.update(&data);
6237 assert_eq!(state.real_data_count, 1);
6238 assert_eq!(state.current_price, 1.0);
6240 }
6241
6242 #[test]
6243 fn test_monitor_state_update_generates_trades() {
6244 let data = create_test_token_data_with_pairs();
6245 let mut state = MonitorState::new(&data, "ethereum");
6246
6247 let mut new_data = data.clone();
6249 new_data.price_usd = 1.10;
6250 state.update(&new_data);
6251
6252 assert!(!state.recent_trades.is_empty());
6254 }
6255
6256 #[test]
6257 fn test_monitor_state_update_liquidity_pairs() {
6258 let data = create_test_token_data_with_pairs();
6259 let mut state = MonitorState::new(&data, "ethereum");
6260 state.update(&data);
6261 assert_eq!(state.liquidity_pairs.len(), 2);
6262 assert!(state.liquidity_pairs[0].0.contains("TEST/USDC"));
6263 assert!(state.liquidity_pairs[1].0.contains("TEST/WETH"));
6264 }
6265
6266 #[test]
6267 fn test_monitor_state_update_dex_pairs_and_order_book() {
6268 let data = create_test_token_data_with_pairs();
6269 let mut state = MonitorState::new(&data, "ethereum");
6270 state.update(&data);
6271 assert_eq!(state.dex_pairs.len(), 2);
6272 assert!(state.order_book.is_some());
6273 }
6274
6275 #[test]
6276 fn test_monitor_state_update_sells_more_than_buys() {
6277 let mut data = create_test_token_data_with_pairs();
6278 data.total_buys_24h = 10;
6279 data.total_sells_24h = 50;
6280 let mut state = MonitorState::new(&data, "ethereum");
6281 state.update(&data);
6282 assert!(!state.recent_trades.is_empty());
6284 }
6285
6286 #[test]
6287 fn test_monitor_state_update_zero_volume() {
6288 let mut data = create_test_token_data_with_pairs();
6289 data.volume_24h = 0.0;
6290 let mut state = MonitorState::new(&data, "ethereum");
6291 state.update(&data);
6292 if let Some(trade) = state.recent_trades.back() {
6294 assert_eq!(trade.quantity, 1.0);
6295 }
6296 }
6297}