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