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::path::PathBuf;
82use std::sync::Arc;
83use std::time::Duration;
84
85use super::interactive::SessionContext;
86use crate::cli::output::Output;
87use crate::note;
88
89#[derive(Debug, Args)]
114#[command(after_help = "\x1b[1mExamples:\x1b[0m
115 scope monitor USDN --venue weex --every 30s --duration 1h
116 scope monitor DAI --venue binance --pair DAI_USDT --report dai.md
117 scope monitor USDC --venue binance --save stables --yes \x1b[2m# save, start at once\x1b[0m
118 scope monitor PEPE --chain ethereum \x1b[2m# price and volume\x1b[0m
119 scope monitor @dai-token \x1b[2m# address book shortcut\x1b[0m
120 scope monitor BTC --venue binance --layout exchange --dashboard \x1b[2m# deprecated\x1b[0m")]
121pub struct MonitorArgs {
122 pub token: String,
129
130 #[arg(long = "token", value_name = "CONTRACT")]
132 pub token_contract: Option<String>,
133
134 #[arg(short, long, default_value = scope::chains::DEFAULT_CHAIN)]
138 pub chain: String,
139
140 #[arg(short, long)]
146 pub layout: Option<LayoutPreset>,
147
148 #[arg(short, long)]
153 pub refresh: Option<u64>,
154
155 #[arg(short, long)]
159 pub scale: Option<ScaleMode>,
160
161 #[arg(long)]
165 pub color_scheme: Option<ColorScheme>,
166
167 #[arg(short, long, value_name = "PATH")]
169 pub export: Option<PathBuf>,
170
171 #[arg(long, value_name = "VENUE", value_delimiter = ',')]
178 pub venue: Vec<String>,
179
180 #[arg(long, value_name = "PAIR")]
186 pub pair: Option<String>,
187
188 #[command(flatten)]
191 pub watch: MonitorWatchArgs,
192}
193
194pub const DASHBOARD_ONE_VENUE: &str =
196 "the dashboard reads one venue; remove --dashboard to watch several";
197
198pub const DASHBOARD_DEPRECATED: &str = "The monitor dashboard is deprecated and goes in the next release. `scope monitor` without --dashboard opens the monitor wizard, with health checks, --duration, --csv and --report.";
200
201#[derive(Debug, Clone, Default, Args)]
204pub struct MonitorWatchArgs {
205 #[arg(long, value_name = "INTERVAL", conflicts_with = "refresh")]
208 pub every: Option<String>,
209
210 #[arg(long, value_name = "DURATION")]
212 pub duration: Option<String>,
213
214 #[arg(long, value_name = "PATH")]
216 pub csv: Option<PathBuf>,
217
218 #[arg(long, value_name = "PATH")]
220 pub report: Option<PathBuf>,
221
222 #[arg(long, value_name = "PRESET")]
224 pub preset: Option<String>,
225
226 #[arg(long, value_name = "NAME")]
228 pub save: Option<String>,
229
230 #[arg(long)]
232 pub yes: bool,
233
234 #[arg(long)]
237 pub dashboard: bool,
238
239 #[command(flatten)]
241 pub health: crate::cli::health_args::HealthArgs,
242}
243pub async fn run_direct(
249 mut args: MonitorArgs,
250 config: &Config,
251 clients: &dyn ChainClientFactory,
252 out: &Output,
253) -> Result<()> {
254 crate::note!(out, "{}", DASHBOARD_DEPRECATED);
255 if args.venue.len() > 1 {
256 return Err(ScopeError::InvalidInput(DASHBOARD_ONE_VENUE.to_string()));
257 }
258 if let Some((address, chain)) =
260 crate::cli::address_book::resolve_address_book_input(&args.token, config)?
261 {
262 args.token = address;
263 if args.chain == "ethereum" {
264 args.chain = chain;
265 }
266 }
267
268 let (monitor_config, export_file, layout_pinned) = monitor_config_from_args(&args, config);
269
270 let ctx = SessionContext {
272 chain: args.chain,
273 ..SessionContext::default()
274 };
275
276 let mut effective_config = config.clone();
278 effective_config.monitor = monitor_config;
279
280 let start = MonitorStart {
281 token: Some(args.token_contract.unwrap_or(args.token)),
282 explicit_pair: args.pair,
283 export_file,
284 layout_pinned,
285 };
286 run(start, &ctx, &effective_config, clients, out).await
287}
288
289pub(crate) fn monitor_config_from_args(
295 args: &MonitorArgs,
296 config: &Config,
297) -> (scope::config::MonitorConfig, Option<PathBuf>, bool) {
298 let mut monitor_config = config.monitor.clone();
299 if let Some(layout) = args.layout {
300 monitor_config.layout = layout;
301 }
302 if let Some(refresh) = args.refresh {
303 monitor_config.refresh_seconds = refresh;
304 }
305 if let Some(scale) = args.scale {
306 monitor_config.scale = scale;
307 }
308 if let Some(color_scheme) = args.color_scheme {
309 monitor_config.color_scheme = color_scheme;
310 }
311 if let Some(venue) = args.venue.first() {
312 monitor_config.venue = Some(venue.clone());
313 }
314 (monitor_config, args.export.clone(), args.layout.is_some())
315}
316
317pub struct MonitorStart {
319 pub token: Option<String>,
321 pub explicit_pair: Option<String>,
325 pub export_file: Option<PathBuf>,
327 pub layout_pinned: bool,
329}
330
331pub async fn run(
333 start: MonitorStart,
334 ctx: &SessionContext,
335 config: &Config,
336 clients: &dyn ChainClientFactory,
337 out: &Output,
338) -> Result<()> {
339 let MonitorStart {
340 token,
341 explicit_pair,
342 export_file,
343 layout_pinned,
344 } = start;
345 let token_input = match token {
346 Some(t) => t,
347 None => {
348 return Err(ScopeError::Chain(
349 "Token address or symbol required. Usage: monitor <token>".to_string(),
350 ));
351 }
352 };
353
354 note!(out, "Starting live monitor for {}...", token_input);
355 note!(out, "Fetching initial data...");
356
357 let venue = config.monitor.venue.as_deref();
359 let exchange_client = exchange_for_venue(venue, clients.http())?;
360
361 let dex_client = clients.create_dex_client();
362 let (initial_data, venue_quote) = fetch_initial_data(
363 &token_input,
364 explicit_pair.as_deref(),
365 &ctx.chain,
366 dex_client.as_ref(),
367 venue.zip(exchange_client.as_ref()),
368 clients.http(),
369 out,
370 )
371 .await?;
372
373 let source = match (&venue_quote, venue) {
374 (Some((pair, _)), Some(venue_id)) => {
375 state::source_label(&ctx.chain, Some((venue_id, pair)))
376 }
377 _ => ctx.chain.clone(),
378 };
379 note!(
380 out,
381 "Monitoring {} ({}) on {}",
382 initial_data.symbol,
383 initial_data.name,
384 source
385 );
386 note!(out, "Press Q to quit, R to refresh, P to pause...\n");
387
388 tokio::time::sleep(Duration::from_millis(500)).await;
390
391 let chain_client = clients.create_chain_client(&ctx.chain).ok();
393
394 let mut app = MonitorApp::new(
396 initial_data,
397 &ctx.chain,
398 &config.monitor,
399 clients.create_dex_client(),
400 chain_client,
401 exchange_client,
402 )?;
403
404 app.state.auto_layout = !layout_pinned;
405 app.state.thresholds = config.market.health.clone();
406
407 if let Some((pair_str, ticker)) = venue_quote {
410 app.state.venue_pair = Some(pair_str);
411 app.state.set_venue_ticker(&ticker);
412 }
413
414 if let Some(path) = export_file {
415 app.state.start_export_to(&path).map_err(|e| {
417 ScopeError::io_context(e, format!("cannot write --export {}", path.display()))
418 })?;
419 }
420
421 let result = app.run().await;
422
423 if let Err(e) = app.cleanup() {
425 note!(out, "Warning: Failed to cleanup terminal: {}", e);
426 }
427
428 result
429}
430
431pub(crate) fn exchange_for_venue(
434 venue: Option<&str>,
435 http: Arc<dyn scope::http::HttpClient>,
436) -> Result<Option<scope::market::ExchangeClient>> {
437 let Some(id) = venue else {
438 return Ok(None);
439 };
440 scope::market::VenueRegistry::load()?
441 .create_exchange_client_with_http(id, http)
442 .map(Some)
443}
444
445pub(crate) async fn fetch_initial_data(
455 token_input: &str,
456 explicit_pair: Option<&str>,
457 chain: &str,
458 dex_client: &dyn DexDataSource,
459 venue: Option<(&str, &scope::market::ExchangeClient)>,
460 http: Arc<dyn scope::http::HttpClient>,
461 out: &Output,
462) -> Result<(DexTokenData, Option<(String, scope::market::Ticker)>)> {
463 if let Some(pair) = explicit_pair {
464 let (_, ex) = venue.ok_or_else(|| {
465 ScopeError::Chain("--pair requires --venue to be specified".to_string())
466 })?;
467 let pair = crate::cli::market::venue_pair(ex, pair);
468 let (data, ticker) = exchange_token_data(ex, &pair, token_input, out).await?;
469 return Ok((data, Some((pair, ticker))));
470 }
471
472 let dex = async {
474 let address = resolve_token_address(
475 token_input,
476 chain,
477 dex_client,
478 venue.is_none().then_some(http),
479 out,
480 )
481 .await?;
482 dex_client.get_token_data(chain, &address).await
483 }
484 .await;
485
486 match (dex, venue) {
487 (Ok(data), _) => Ok((data, None)),
488 (Err(e @ (ScopeError::AmbiguousToken { .. } | ScopeError::TokenMismatch { .. })), _) => {
491 Err(e)
492 }
493 (Err(dex_err), Some((venue_id, ex))) => {
494 let pair = crate::cli::market::venue_pair(ex, token_input);
495 note!(
496 out,
497 "DexScreener lookup failed ({}); using the {} ticker {} instead",
498 dex_err,
499 venue_id,
500 pair
501 );
502 match exchange_token_data(ex, &pair, token_input, out).await {
503 Ok((data, ticker)) => Ok((data, Some((pair, ticker)))),
504 Err(ex_err) => Err(ScopeError::Other(format!(
505 "DexScreener lookup failed: {}; {} ticker {} failed: {}",
506 dex_err, venue_id, pair, ex_err
507 ))),
508 }
509 }
510 (Err(e), None) => Err(e),
511 }
512}
513
514async fn exchange_token_data(
516 ex: &scope::market::ExchangeClient,
517 pair: &str,
518 token_input: &str,
519 out: &Output,
520) -> Result<(DexTokenData, scope::market::Ticker)> {
521 let ticker = ex
522 .fetch_ticker(pair)
523 .await
524 .map_err(|e| ScopeError::Other(format!("Failed to fetch ticker for {}: {}", pair, e)))?;
525 let base_symbol = ticker
527 .pair
528 .split('/')
529 .next()
530 .filter(|s| !s.is_empty())
531 .unwrap_or(token_input)
532 .to_string();
533 note!(
534 out,
535 "Exchange-only mode: {} @ {}",
536 pair,
537 scope::display::format::format_price_usd(ticker.last_price.unwrap_or(0.0))
538 );
539 let data = build_exchange_token_data(&base_symbol, pair, &ticker);
540 Ok((data, ticker))
541}
542
543fn build_exchange_token_data(
549 symbol: &str,
550 pair_label: &str,
551 ticker: &scope::market::Ticker,
552) -> DexTokenData {
553 let price = ticker.last_price.unwrap_or(0.0);
554 DexTokenData {
555 address: format!("exchange:{}", pair_label),
556 symbol: symbol.to_string(),
557 name: symbol.to_string(),
558 price_usd: price,
559 price_change_24h: ticker.price_change_pct_24h.unwrap_or(0.0),
560 price_change_6h: 0.0,
561 price_change_1h: 0.0,
562 price_change_5m: 0.0,
563 volume_24h: ticker.usd_volume().unwrap_or(0.0),
564 volume_6h: 0.0,
565 volume_1h: 0.0,
566 liquidity_usd: 0.0,
567 market_cap: None,
568 fdv: None,
569 pairs: vec![],
570 price_history: vec![],
571 volume_history: vec![],
572 total_buys_24h: 0,
573 total_sells_24h: 0,
574 total_buys_6h: 0,
575 total_sells_6h: 0,
576 total_buys_1h: 0,
577 total_sells_1h: 0,
578 earliest_pair_created_at: None,
579 image_url: None,
580 websites: vec![],
581 socials: vec![],
582 dexscreener_url: None,
583 }
584}
585
586async fn resolve_token_address(
590 input: &str,
591 chain: &str,
592 dex_client: &dyn DexDataSource,
593 cex_fallback: Option<Arc<dyn scope::http::HttpClient>>,
594 out: &Output,
595) -> Result<String> {
596 use crate::cli::resolve::{DexCandidates, ResolveContext, VenueKind, picker_for, resolve};
597 let ctx = ResolveContext {
601 chain: Some(chain.to_string()),
602 venue: VenueKind::Dex,
603 token_flag: None,
604 yes: false,
605 save: false,
606 };
607 let source = DexCandidates {
608 dex: dex_client,
609 out,
610 fallback: cex_fallback,
611 };
612 let picker = picker_for(out, false);
613 let mut aliases = scope::tokens::TokenAliases::load();
614 let r = resolve(input, &ctx, &source, picker.as_ref(), &mut aliases, out).await?;
615 Ok(r.address.unwrap_or_default())
616}
617
618#[cfg(test)]
623mod tests {
624 use super::config::*;
625 use super::input::*;
626 use super::widgets::*;
627 use super::*;
628 use scope::tokens::is_stablecoin_price;
629
630 use crossterm::event::{KeyCode, KeyModifiers};
631 use ratatui::layout::{Constraint, Direction, Layout, Rect};
632 use scope::chains::ChainClient;
633 use scope::market::{Trade, TradeSide};
634 use std::fs;
635 use std::io::Write as _;
636 use std::time::Instant;
637
638 fn create_test_token_data() -> DexTokenData {
639 DexTokenData {
640 address: "0x1234".to_string(),
641 symbol: "TEST".to_string(),
642 name: "Test Token".to_string(),
643 price_usd: 1.0,
644 price_change_24h: 5.0,
645 price_change_6h: 2.0,
646 price_change_1h: 0.5,
647 price_change_5m: 0.1,
648 volume_24h: 1_000_000.0,
649 volume_6h: 250_000.0,
650 volume_1h: 50_000.0,
651 liquidity_usd: 500_000.0,
652 market_cap: Some(10_000_000.0),
653 fdv: Some(100_000_000.0),
654 pairs: vec![],
655 price_history: vec![],
656 volume_history: vec![],
657 total_buys_24h: 100,
658 total_sells_24h: 50,
659 total_buys_6h: 25,
660 total_sells_6h: 12,
661 total_buys_1h: 5,
662 total_sells_1h: 3,
663 earliest_pair_created_at: Some(1700000000000),
664 image_url: None,
665 websites: vec![],
666 socials: vec![],
667 dexscreener_url: None,
668 }
669 }
670
671 #[test]
672 fn test_monitor_state_new() {
673 let token_data = create_test_token_data();
674 let state = MonitorState::new(&token_data, "ethereum");
675
676 assert_eq!(state.symbol, "TEST");
677 assert_eq!(state.chain, "ethereum");
678 assert_eq!(state.current_price, 1.0);
679 assert_eq!(state.buys_24h, 100);
680 assert_eq!(state.sells_24h, 50);
681 assert!(!state.paused);
682 }
683
684 #[test]
685 fn test_monitor_state_buy_ratio() {
686 let token_data = create_test_token_data();
687 let state = MonitorState::new(&token_data, "ethereum");
688
689 let ratio = state.buy_ratio();
690 assert!((ratio - 0.6666).abs() < 0.01); }
692
693 #[test]
694 fn test_monitor_state_buy_ratio_zero() {
695 let mut token_data = create_test_token_data();
696 token_data.total_buys_24h = 0;
697 token_data.total_sells_24h = 0;
698 let state = MonitorState::new(&token_data, "ethereum");
699
700 assert_eq!(state.buy_ratio(), 0.5); }
702
703 #[test]
704 fn test_monitor_state_toggle_pause() {
705 let token_data = create_test_token_data();
706 let mut state = MonitorState::new(&token_data, "ethereum");
707
708 assert!(!state.paused);
709 state.toggle_pause();
710 assert!(state.paused);
711 state.toggle_pause();
712 assert!(!state.paused);
713 }
714
715 #[test]
716 fn test_monitor_state_should_refresh() {
717 let token_data = create_test_token_data();
718 let mut state = MonitorState::new(&token_data, "ethereum");
719 state.refresh_rate = Duration::from_secs(60);
720
721 assert!(!state.should_refresh());
723
724 state.last_update = Instant::now() - Duration::from_secs(120);
726 assert!(state.should_refresh());
727
728 state.paused = true;
730 assert!(!state.should_refresh());
731 }
732
733 #[test]
734 fn test_format_number() {
735 assert_eq!(format_number(500.0), "500.00");
736 assert_eq!(format_number(1_500.0), "1.50K");
737 assert_eq!(format_number(1_500_000.0), "1.50M");
738 assert_eq!(format_number(1_500_000_000.0), "1.50B");
739 }
740
741 #[test]
742 fn test_format_usd() {
743 assert_eq!(scope::display::format_usd(500.0), "$500.00");
744 assert_eq!(scope::display::format_usd(1_500.0), "$1.50K");
745 assert_eq!(scope::display::format_usd(1_500_000.0), "$1.50M");
746 assert_eq!(scope::display::format_usd(1_500_000_000.0), "$1.50B");
747 }
748
749 #[test]
750 fn test_monitor_state_update() {
751 let token_data = create_test_token_data();
752 let mut state = MonitorState::new(&token_data, "ethereum");
753
754 let initial_len = state.price_history.len();
755
756 let mut updated_data = token_data.clone();
757 updated_data.price_usd = 1.5;
758 updated_data.total_buys_24h = 150;
759
760 state.update(&updated_data);
761
762 assert_eq!(state.current_price, 1.5);
763 assert_eq!(state.buys_24h, 150);
764 let last = state.price_history.back().unwrap();
768 assert_eq!(last.value, 1.5);
769 assert!(last.is_real);
770 let len = state.price_history.len();
771 assert!(
772 len == initial_len + 1 || len == initial_len,
773 "{initial_len} → {len}"
774 );
775 }
776
777 #[test]
778 fn test_monitor_state_refresh_rate_adjustment() {
779 let token_data = create_test_token_data();
780 let mut state = MonitorState::new(&token_data, "ethereum");
781
782 assert_eq!(state.refresh_rate_secs(), 5);
784
785 state.slower_refresh();
787 assert_eq!(state.refresh_rate_secs(), 10);
788
789 state.faster_refresh();
791 assert_eq!(state.refresh_rate_secs(), 5);
792
793 state.faster_refresh();
795 assert_eq!(state.refresh_rate_secs(), 1);
796
797 state.faster_refresh();
799 assert_eq!(state.refresh_rate_secs(), 1);
800
801 for _ in 0..20 {
803 state.slower_refresh();
804 }
805 assert_eq!(state.refresh_rate_secs(), 60);
806 }
807
808 #[test]
809 fn test_time_period() {
810 assert_eq!(TimePeriod::Min1.label(), "1m");
811 assert_eq!(TimePeriod::Min5.label(), "5m");
812 assert_eq!(TimePeriod::Min15.label(), "15m");
813 assert_eq!(TimePeriod::Hour1.label(), "1h");
814 assert_eq!(TimePeriod::Hour4.label(), "4h");
815 assert_eq!(TimePeriod::Day1.label(), "1d");
816
817 assert_eq!(TimePeriod::Min1.duration_secs(), 60);
818 assert_eq!(TimePeriod::Min5.duration_secs(), 300);
819 assert_eq!(TimePeriod::Min15.duration_secs(), 15 * 60);
820 assert_eq!(TimePeriod::Hour1.duration_secs(), 3600);
821 assert_eq!(TimePeriod::Hour4.duration_secs(), 4 * 3600);
822 assert_eq!(TimePeriod::Day1.duration_secs(), 24 * 3600);
823
824 assert_eq!(TimePeriod::Min1.next(), TimePeriod::Min5);
826 assert_eq!(TimePeriod::Min5.next(), TimePeriod::Min15);
827 assert_eq!(TimePeriod::Min15.next(), TimePeriod::Hour1);
828 assert_eq!(TimePeriod::Hour1.next(), TimePeriod::Hour4);
829 assert_eq!(TimePeriod::Hour4.next(), TimePeriod::Day1);
830 assert_eq!(TimePeriod::Day1.next(), TimePeriod::Min1);
831 }
832
833 #[test]
834 fn test_time_period_exchange_interval() {
835 assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
836 assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
837 assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
838 assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
839 assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
840 assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
841 }
842
843 #[test]
844 fn test_monitor_state_time_period() {
845 let token_data = create_test_token_data();
846 let mut state = MonitorState::new(&token_data, "ethereum");
847
848 assert_eq!(state.time_period, TimePeriod::Hour1);
850
851 state.cycle_time_period();
853 assert_eq!(state.time_period, TimePeriod::Hour4);
854
855 state.set_time_period(TimePeriod::Day1);
856 assert_eq!(state.time_period, TimePeriod::Day1);
857 }
858
859 #[test]
860 fn test_synthetic_history_generation() {
861 let token_data = create_test_token_data();
862 let state = MonitorState::new(&token_data, "ethereum");
863
864 assert!(state.price_history.len() > 1);
866 assert!(state.volume_history.len() > 1);
867
868 if let (Some(first), Some(last)) = (state.price_history.front(), state.price_history.back())
870 {
871 let span = last.timestamp - first.timestamp;
872 assert!(span > 0.0); }
874 }
875
876 #[test]
877 fn test_real_data_marking() {
878 let token_data = create_test_token_data();
879 let mut state = MonitorState::new(&token_data, "ethereum");
880
881 let (synthetic, real) = state.data_stats();
883 assert!(synthetic > 0);
884 assert_eq!(real, 0);
885
886 let mut updated_data = token_data.clone();
888 updated_data.price_usd = 1.5;
889 state.update(&updated_data);
890
891 let (synthetic2, real2) = state.data_stats();
892 assert!(synthetic2 > 0);
893 assert_eq!(real2, 1);
894 assert_eq!(state.real_data_count, 1);
895
896 assert!(
898 state
899 .price_history
900 .back()
901 .map(|p| p.is_real)
902 .unwrap_or(false)
903 );
904 }
905
906 #[test]
907 fn test_memory_usage() {
908 let token_data = create_test_token_data();
909 let state = MonitorState::new(&token_data, "ethereum");
910
911 let mem = state.memory_usage();
912 assert!(mem > 0);
914
915 let expected_point_size = std::mem::size_of::<DataPoint>();
917 assert_eq!(expected_point_size, 24);
918 }
919
920 #[test]
921 fn test_get_data_for_period_returns_flags() {
922 let token_data = create_test_token_data();
923 let mut state = MonitorState::new(&token_data, "ethereum");
924
925 let (data, is_real) = state.get_price_data_for_period();
927 assert_eq!(data.len(), is_real.len());
928
929 state.update(&token_data);
931
932 let (_data2, is_real2) = state.get_price_data_for_period();
933 assert!(is_real2.iter().any(|r| *r));
935 }
936
937 #[test]
938 fn test_cache_path_generation() {
939 let path =
940 MonitorState::cache_path("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum");
941 assert!(path.to_string_lossy().contains("bcc_monitor_"));
942 assert!(path.to_string_lossy().contains("ethereum"));
943 let temp_dir = std::env::temp_dir();
945 assert!(path.starts_with(temp_dir));
946 }
947
948 #[test]
949 fn test_cache_save_and_load() {
950 let token_data = create_test_token_data();
951 let mut state = MonitorState::new(&token_data, "test_chain");
952
953 state.update(&token_data);
955 state.update(&token_data);
956
957 state.save_cache();
959
960 let path = MonitorState::cache_path(&state.token_address, &state.chain);
962 assert!(path.exists(), "Cache file should exist after save");
963
964 let loaded = MonitorState::load_cache(&state.token_address, &state.chain);
966 assert!(loaded.is_some(), "Should be able to load saved cache");
967
968 let cached = loaded.unwrap();
969 assert_eq!(cached.token_address, state.token_address);
970 assert_eq!(cached.chain, state.chain);
971 assert!(!cached.price_history.is_empty());
972
973 let _ = std::fs::remove_file(path);
975 }
976
977 #[test]
982 fn test_format_price_usd_high() {
983 let formatted = format_price_usd(2500.50);
984 assert!(formatted.starts_with("$2500.50"));
985 }
986
987 #[test]
988 fn test_format_price_usd_stablecoin() {
989 let formatted = format_price_usd(1.0001);
990 assert!(formatted.contains("1.000100"));
991 assert!(is_stablecoin_price(1.0001));
992 }
993
994 #[test]
995 fn test_format_price_usd_medium() {
996 let formatted = format_price_usd(5.1234);
997 assert!(formatted.starts_with("$5.1234"));
998 }
999
1000 #[test]
1001 fn test_format_price_usd_small() {
1002 let formatted = format_price_usd(0.05);
1003 assert!(formatted.starts_with("$0.0500"));
1004 }
1005
1006 #[test]
1007 fn test_format_price_usd_micro() {
1008 let formatted = format_price_usd(0.001);
1009 assert!(formatted.starts_with("$0.0010"));
1010 }
1011
1012 #[test]
1013 fn test_format_price_usd_nano() {
1014 let formatted = format_price_usd(0.00001);
1015 assert!(formatted.contains("0.0000100"));
1016 }
1017
1018 #[test]
1019 fn test_is_stablecoin_price() {
1020 assert!(is_stablecoin_price(1.0));
1021 assert!(is_stablecoin_price(0.999));
1022 assert!(is_stablecoin_price(1.001));
1023 assert!(is_stablecoin_price(0.95));
1024 assert!(is_stablecoin_price(1.05));
1025 assert!(!is_stablecoin_price(0.94));
1026 assert!(!is_stablecoin_price(1.06));
1027 assert!(!is_stablecoin_price(100.0));
1028 }
1029
1030 #[test]
1035 fn test_ohlc_candle_new() {
1036 let candle = OhlcCandle::new(1000.0, 50.0);
1037 assert_eq!(candle.open, 50.0);
1038 assert_eq!(candle.high, 50.0);
1039 assert_eq!(candle.low, 50.0);
1040 assert_eq!(candle.close, 50.0);
1041 assert!(candle.is_bullish);
1042 }
1043
1044 #[test]
1045 fn test_ohlc_candle_update() {
1046 let mut candle = OhlcCandle::new(1000.0, 50.0);
1047 candle.update(55.0);
1048 assert_eq!(candle.high, 55.0);
1049 assert_eq!(candle.close, 55.0);
1050 assert!(candle.is_bullish);
1051
1052 candle.update(45.0);
1053 assert_eq!(candle.low, 45.0);
1054 assert_eq!(candle.close, 45.0);
1055 assert!(!candle.is_bullish); }
1057
1058 #[test]
1059 fn test_get_ohlc_candles() {
1060 let token_data = create_test_token_data();
1061 let mut state = MonitorState::new(&token_data, "ethereum");
1062 for i in 0..20 {
1064 let mut data = token_data.clone();
1065 data.price_usd = 1.0 + (i as f64 * 0.01);
1066 state.update(&data);
1067 }
1068 let candles = state.get_ohlc_candles();
1069 assert!(!candles.is_empty());
1071 }
1072
1073 #[test]
1074 fn test_get_ohlc_candles_returns_exchange_ohlc_when_populated() {
1075 let token_data = create_test_token_data();
1076 let mut state = MonitorState::new(&token_data, "ethereum");
1077 let exchange_candles = vec![
1079 OhlcCandle::new(1700000000.0, 100.0),
1080 OhlcCandle::new(1700003600.0, 101.0),
1081 ];
1082 state.exchange_ohlc = exchange_candles.clone();
1083 let candles = state.get_ohlc_candles();
1084 assert_eq!(candles.len(), 2);
1085 assert_eq!(candles[0].timestamp, 1700000000.0);
1086 assert_eq!(candles[0].open, 100.0);
1087 assert_eq!(candles[1].timestamp, 1700003600.0);
1088 assert_eq!(candles[1].open, 101.0);
1089 }
1090
1091 #[test]
1096 fn test_chart_mode_cycle() {
1097 let mode = ChartMode::Line;
1098 assert_eq!(mode.next(), ChartMode::Candlestick);
1099 assert_eq!(ChartMode::Candlestick.next(), ChartMode::VolumeProfile);
1100 assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
1101 }
1102
1103 #[test]
1104 fn test_chart_mode_label() {
1105 assert_eq!(ChartMode::Line.label(), "Line");
1106 assert_eq!(ChartMode::Candlestick.label(), "Candle");
1107 assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
1108 }
1109
1110 use ratatui::Terminal;
1115 use ratatui::backend::TestBackend;
1116
1117 fn create_test_terminal() -> Terminal<TestBackend> {
1118 let backend = TestBackend::new(120, 40);
1119 Terminal::new(backend).unwrap()
1120 }
1121
1122 fn create_populated_state() -> MonitorState {
1123 let token_data = create_test_token_data();
1124 let mut state = MonitorState::new(&token_data, "ethereum");
1125 for i in 0..30 {
1127 let mut data = token_data.clone();
1128 data.price_usd = 1.0 + (i as f64 * 0.01);
1129 data.volume_24h = 1_000_000.0 + (i as f64 * 10_000.0);
1130 state.update(&data);
1131 }
1132 state
1133 }
1134
1135 #[test]
1136 fn test_render_header_no_panic() {
1137 let mut terminal = create_test_terminal();
1138 let state = create_populated_state();
1139 terminal
1140 .draw(|f| render_header(f, f.area(), &state))
1141 .unwrap();
1142 }
1143
1144 #[test]
1145 fn test_render_price_chart_no_panic() {
1146 let mut terminal = create_test_terminal();
1147 let state = create_populated_state();
1148 terminal
1149 .draw(|f| render_price_chart(f, f.area(), &state))
1150 .unwrap();
1151 }
1152
1153 #[test]
1154 fn test_render_price_chart_line_mode() {
1155 let mut terminal = create_test_terminal();
1156 let mut state = create_populated_state();
1157 state.chart_mode = ChartMode::Line;
1158 terminal
1159 .draw(|f| render_price_chart(f, f.area(), &state))
1160 .unwrap();
1161 }
1162
1163 #[test]
1164 fn test_render_candlestick_chart_no_panic() {
1165 let mut terminal = create_test_terminal();
1166 let state = create_populated_state();
1167 terminal
1168 .draw(|f| render_candlestick_chart(f, f.area(), &state))
1169 .unwrap();
1170 }
1171
1172 #[test]
1173 fn test_render_candlestick_chart_empty() {
1174 let mut terminal = create_test_terminal();
1175 let token_data = create_test_token_data();
1176 let state = MonitorState::new(&token_data, "ethereum");
1177 terminal
1178 .draw(|f| render_candlestick_chart(f, f.area(), &state))
1179 .unwrap();
1180 }
1181
1182 #[test]
1183 fn test_render_volume_chart_no_panic() {
1184 let mut terminal = create_test_terminal();
1185 let state = create_populated_state();
1186 terminal
1187 .draw(|f| render_volume_chart(f, f.area(), &state))
1188 .unwrap();
1189 }
1190
1191 #[test]
1192 fn test_render_volume_chart_empty() {
1193 let mut terminal = create_test_terminal();
1194 let token_data = create_test_token_data();
1195 let state = MonitorState::new(&token_data, "ethereum");
1196 terminal
1197 .draw(|f| render_volume_chart(f, f.area(), &state))
1198 .unwrap();
1199 }
1200
1201 #[test]
1202 fn test_render_buy_sell_gauge_no_panic() {
1203 let mut terminal = create_test_terminal();
1204 let mut state = create_populated_state();
1205 terminal
1206 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
1207 .unwrap();
1208 }
1209
1210 #[test]
1211 fn test_render_buy_sell_gauge_balanced() {
1212 let mut terminal = create_test_terminal();
1213 let mut token_data = create_test_token_data();
1214 token_data.total_buys_24h = 100;
1215 token_data.total_sells_24h = 100;
1216 let mut state = MonitorState::new(&token_data, "ethereum");
1217 terminal
1218 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
1219 .unwrap();
1220 }
1221
1222 #[test]
1223 fn test_render_metrics_panel_no_panic() {
1224 let mut terminal = create_test_terminal();
1225 let state = create_populated_state();
1226 terminal
1227 .draw(|f| render_metrics_panel(f, f.area(), &state))
1228 .unwrap();
1229 }
1230
1231 #[test]
1232 fn test_render_metrics_panel_no_market_cap() {
1233 let mut terminal = create_test_terminal();
1234 let mut token_data = create_test_token_data();
1235 token_data.market_cap = None;
1236 token_data.fdv = None;
1237 let state = MonitorState::new(&token_data, "ethereum");
1238 terminal
1239 .draw(|f| render_metrics_panel(f, f.area(), &state))
1240 .unwrap();
1241 }
1242
1243 #[test]
1244 fn test_render_footer_no_panic() {
1245 let mut terminal = create_test_terminal();
1246 let state = create_populated_state();
1247 terminal
1248 .draw(|f| render_footer(f, f.area(), &state))
1249 .unwrap();
1250 }
1251
1252 #[test]
1253 fn test_render_footer_paused() {
1254 let mut terminal = create_test_terminal();
1255 let token_data = create_test_token_data();
1256 let mut state = MonitorState::new(&token_data, "ethereum");
1257 state.paused = true;
1258 terminal
1259 .draw(|f| render_footer(f, f.area(), &state))
1260 .unwrap();
1261 }
1262
1263 #[test]
1264 fn test_render_all_components() {
1265 let mut terminal = create_test_terminal();
1267 let mut state = create_populated_state();
1268 terminal
1269 .draw(|f| {
1270 let area = f.area();
1271 let chunks = Layout::default()
1272 .direction(Direction::Vertical)
1273 .constraints([
1274 Constraint::Length(3),
1275 Constraint::Min(10),
1276 Constraint::Length(5),
1277 Constraint::Length(3),
1278 Constraint::Length(3),
1279 ])
1280 .split(area);
1281 render_header(f, chunks[0], &state);
1282 render_price_chart(f, chunks[1], &state);
1283 render_volume_chart(f, chunks[2], &state);
1284 render_buy_sell_gauge(f, chunks[3], &mut state);
1285 render_footer(f, chunks[4], &state);
1286 })
1287 .unwrap();
1288 }
1289
1290 #[test]
1291 fn test_render_candlestick_mode() {
1292 let mut terminal = create_test_terminal();
1293 let mut state = create_populated_state();
1294 state.chart_mode = ChartMode::Candlestick;
1295 terminal
1296 .draw(|f| {
1297 let area = f.area();
1298 let chunks = Layout::default()
1299 .direction(Direction::Vertical)
1300 .constraints([Constraint::Length(3), Constraint::Min(10)])
1301 .split(area);
1302 render_header(f, chunks[0], &state);
1303 render_candlestick_chart(f, chunks[1], &state);
1304 })
1305 .unwrap();
1306 }
1307
1308 #[test]
1309 fn test_render_with_different_time_periods() {
1310 let mut terminal = create_test_terminal();
1311 let mut state = create_populated_state();
1312
1313 for period in [
1314 TimePeriod::Min1,
1315 TimePeriod::Min5,
1316 TimePeriod::Min15,
1317 TimePeriod::Hour1,
1318 TimePeriod::Hour4,
1319 TimePeriod::Day1,
1320 ] {
1321 state.time_period = period;
1322 terminal
1323 .draw(|f| render_price_chart(f, f.area(), &state))
1324 .unwrap();
1325 }
1326 }
1327
1328 #[test]
1329 fn test_render_metrics_with_stablecoin() {
1330 let mut terminal = create_test_terminal();
1331 let mut token_data = create_test_token_data();
1332 token_data.price_usd = 0.999;
1333 token_data.symbol = "USDC".to_string();
1334 let state = MonitorState::new(&token_data, "ethereum");
1335 terminal
1336 .draw(|f| render_metrics_panel(f, f.area(), &state))
1337 .unwrap();
1338 }
1339
1340 #[test]
1341 fn test_render_header_with_negative_change() {
1342 let mut terminal = create_test_terminal();
1343 let mut token_data = create_test_token_data();
1344 token_data.price_change_24h = -15.5;
1345 token_data.price_change_1h = -2.3;
1346 let state = MonitorState::new(&token_data, "ethereum");
1347 terminal
1348 .draw(|f| render_header(f, f.area(), &state))
1349 .unwrap();
1350 }
1351
1352 #[test]
1357 fn test_toggle_chart_mode_roundtrip() {
1358 let token_data = create_test_token_data();
1359 let mut state = MonitorState::new(&token_data, "ethereum");
1360 assert_eq!(state.chart_mode, ChartMode::Line);
1361 state.toggle_chart_mode();
1362 assert_eq!(state.chart_mode, ChartMode::Candlestick);
1363 state.toggle_chart_mode();
1364 assert_eq!(state.chart_mode, ChartMode::VolumeProfile);
1365 state.toggle_chart_mode();
1366 assert_eq!(state.chart_mode, ChartMode::Line);
1367 }
1368
1369 #[test]
1370 fn test_cycle_all_time_periods() {
1371 let token_data = create_test_token_data();
1372 let mut state = MonitorState::new(&token_data, "ethereum");
1373 assert_eq!(state.time_period, TimePeriod::Hour1);
1374 state.cycle_time_period();
1375 assert_eq!(state.time_period, TimePeriod::Hour4);
1376 state.cycle_time_period();
1377 assert_eq!(state.time_period, TimePeriod::Day1);
1378 state.cycle_time_period();
1379 assert_eq!(state.time_period, TimePeriod::Min1);
1380 state.cycle_time_period();
1381 assert_eq!(state.time_period, TimePeriod::Min5);
1382 state.cycle_time_period();
1383 assert_eq!(state.time_period, TimePeriod::Min15);
1384 state.cycle_time_period();
1385 assert_eq!(state.time_period, TimePeriod::Hour1);
1386 }
1387
1388 #[test]
1389 fn test_set_specific_time_period() {
1390 let token_data = create_test_token_data();
1391 let mut state = MonitorState::new(&token_data, "ethereum");
1392 state.set_time_period(TimePeriod::Day1);
1393 assert_eq!(state.time_period, TimePeriod::Day1);
1394 }
1395
1396 #[test]
1397 fn test_pause_resume_roundtrip() {
1398 let token_data = create_test_token_data();
1399 let mut state = MonitorState::new(&token_data, "ethereum");
1400 assert!(!state.paused);
1401 state.toggle_pause();
1402 assert!(state.paused);
1403 state.toggle_pause();
1404 assert!(!state.paused);
1405 }
1406
1407 #[test]
1408 fn test_force_refresh_unpauses() {
1409 let token_data = create_test_token_data();
1410 let mut state = MonitorState::new(&token_data, "ethereum");
1411 state.paused = true;
1412 state.force_refresh();
1413 assert!(!state.paused);
1414 assert!(state.should_refresh());
1415 }
1416
1417 #[test]
1418 fn test_refresh_rate_adjust() {
1419 let token_data = create_test_token_data();
1420 let mut state = MonitorState::new(&token_data, "ethereum");
1421 assert_eq!(state.refresh_rate_secs(), 5);
1422
1423 state.slower_refresh();
1424 assert_eq!(state.refresh_rate_secs(), 10);
1425
1426 state.faster_refresh();
1427 assert_eq!(state.refresh_rate_secs(), 5);
1428 }
1429
1430 #[test]
1431 fn test_faster_refresh_clamped_min() {
1432 let token_data = create_test_token_data();
1433 let mut state = MonitorState::new(&token_data, "ethereum");
1434 for _ in 0..10 {
1435 state.faster_refresh();
1436 }
1437 assert!(state.refresh_rate_secs() >= 1);
1438 }
1439
1440 #[test]
1441 fn test_slower_refresh_clamped_max() {
1442 let token_data = create_test_token_data();
1443 let mut state = MonitorState::new(&token_data, "ethereum");
1444 for _ in 0..20 {
1445 state.slower_refresh();
1446 }
1447 assert!(state.refresh_rate_secs() <= 60);
1448 }
1449
1450 #[test]
1451 fn test_buy_ratio_balanced() {
1452 let mut token_data = create_test_token_data();
1453 token_data.total_buys_24h = 100;
1454 token_data.total_sells_24h = 100;
1455 let state = MonitorState::new(&token_data, "ethereum");
1456 assert!((state.buy_ratio() - 0.5).abs() < 0.01);
1457 }
1458
1459 #[test]
1460 fn test_buy_ratio_no_trades() {
1461 let mut token_data = create_test_token_data();
1462 token_data.total_buys_24h = 0;
1463 token_data.total_sells_24h = 0;
1464 let state = MonitorState::new(&token_data, "ethereum");
1465 assert!((state.buy_ratio() - 0.5).abs() < 0.01);
1466 }
1467
1468 #[test]
1469 fn test_data_stats_initial() {
1470 let token_data = create_test_token_data();
1471 let state = MonitorState::new(&token_data, "ethereum");
1472 let (synthetic, real) = state.data_stats();
1473 assert!(synthetic > 0 || real == 0);
1474 }
1475
1476 #[test]
1477 fn test_memory_usage_nonzero() {
1478 let token_data = create_test_token_data();
1479 let state = MonitorState::new(&token_data, "ethereum");
1480 let usage = state.memory_usage();
1481 assert!(usage > 0);
1482 }
1483
1484 #[test]
1485 fn test_price_data_for_period() {
1486 let token_data = create_test_token_data();
1487 let state = MonitorState::new(&token_data, "ethereum");
1488 let (data, is_real) = state.get_price_data_for_period();
1489 assert_eq!(data.len(), is_real.len());
1490 }
1491
1492 #[test]
1493 fn test_volume_data_for_period() {
1494 let token_data = create_test_token_data();
1495 let state = MonitorState::new(&token_data, "ethereum");
1496 let (data, is_real) = state.get_volume_data_for_period();
1497 assert_eq!(data.len(), is_real.len());
1498 }
1499
1500 #[test]
1501 fn test_ohlc_candles_generation() {
1502 let token_data = create_test_token_data();
1503 let state = MonitorState::new(&token_data, "ethereum");
1504 let candles = state.get_ohlc_candles();
1505 for candle in &candles {
1506 assert!(candle.high >= candle.low);
1507 }
1508 }
1509
1510 #[test]
1511 fn test_state_update_with_new_data() {
1512 let token_data = create_test_token_data();
1513 let mut state = MonitorState::new(&token_data, "ethereum");
1514 let initial_count = state.real_data_count;
1515
1516 let mut updated_data = create_test_token_data();
1517 updated_data.price_usd = 2.0;
1518 updated_data.volume_24h = 2_000_000.0;
1519
1520 state.update(&updated_data);
1521 assert_eq!(state.current_price, 2.0);
1522 assert_eq!(state.real_data_count, initial_count + 1);
1523 assert!(state.error_message.is_none());
1524 }
1525
1526 #[test]
1529 fn test_parallel_tests_do_not_share_a_cache_file() {
1530 let token_data = create_test_token_data();
1531 let state = MonitorState::new(&token_data, "ethereum");
1532 state.save_cache();
1533 let mine = MonitorState::cache_path(&token_data.address, "ethereum");
1534 let other = std::thread::spawn(move || {
1535 let state = MonitorState::new(&create_test_token_data(), "ethereum");
1536 state.save_cache();
1537 let path = MonitorState::cache_path(&state.token_address, "ethereum");
1538 let _ = std::fs::remove_file(&path);
1539 path
1540 })
1541 .join()
1542 .unwrap();
1543 assert_ne!(mine, other);
1544 assert!(mine.exists(), "the other test removed this test's cache");
1545 assert!(MonitorState::load_cache(&token_data.address, "ethereum").is_some());
1546 let _ = std::fs::remove_file(mine);
1547 }
1548
1549 #[test]
1553 fn test_a_new_monitor_resumes_from_its_cache() {
1554 let token_data = create_test_token_data();
1555 let mut state = MonitorState::new(&token_data, "ethereum");
1556 let now = chrono::Utc::now().timestamp() as f64;
1557 state.price_history.clear();
1558 state.volume_history.clear();
1559 for (age, value) in [(MAX_DATA_AGE_SECS + 60.0, 0.5), (120.0, 1.01), (60.0, 1.02)] {
1560 let point = DataPoint {
1561 timestamp: now - age,
1562 value,
1563 is_real: true,
1564 };
1565 state.price_history.push_back(point);
1566 state.volume_history.push_back(point);
1567 }
1568 state.save_cache();
1569
1570 let resumed = MonitorState::new(&token_data, "ethereum");
1571 let prices: Vec<f64> = resumed.price_history.iter().map(|p| p.value).collect();
1572 assert_eq!(prices, vec![1.01, 1.02], "the 24h-old point is dropped");
1573 assert_eq!(resumed.volume_history.len(), 2);
1574 assert_eq!(resumed.real_data_count, 2);
1575 let _ = std::fs::remove_file(MonitorState::cache_path(&token_data.address, "ethereum"));
1576 }
1577
1578 #[test]
1579 fn test_cache_roundtrip_save_load() {
1580 let token_data = create_test_token_data();
1581 let state = MonitorState::new(&token_data, "ethereum");
1582
1583 state.save_cache();
1584
1585 let cache_path = MonitorState::cache_path(&token_data.address, "ethereum");
1586 assert!(cache_path.exists());
1587
1588 let cached = MonitorState::load_cache(&token_data.address, "ethereum");
1589 assert!(cached.is_some());
1590
1591 let _ = std::fs::remove_file(cache_path);
1592 }
1593
1594 #[test]
1595 fn test_should_refresh_when_paused() {
1596 let token_data = create_test_token_data();
1597 let mut state = MonitorState::new(&token_data, "ethereum");
1598 assert!(!state.should_refresh());
1599 state.paused = true;
1600 assert!(!state.should_refresh());
1601 }
1602
1603 #[test]
1604 fn test_ohlc_candle_lifecycle() {
1605 let mut candle = OhlcCandle::new(1700000000.0, 100.0);
1606 assert_eq!(candle.open, 100.0);
1607 assert!(candle.is_bullish);
1608 candle.update(110.0);
1609 assert_eq!(candle.high, 110.0);
1610 assert!(candle.is_bullish);
1611 candle.update(90.0);
1612 assert_eq!(candle.low, 90.0);
1613 assert!(!candle.is_bullish);
1614 }
1615
1616 #[test]
1617 fn test_time_period_display_impl() {
1618 assert_eq!(format!("{}", TimePeriod::Min1), "1m");
1619 assert_eq!(format!("{}", TimePeriod::Min15), "15m");
1620 assert_eq!(format!("{}", TimePeriod::Day1), "1d");
1621 }
1622
1623 #[test]
1624 fn test_log_messages_accumulate() {
1625 let token_data = create_test_token_data();
1626 let mut state = MonitorState::new(&token_data, "ethereum");
1627 state.toggle_pause();
1629 state.toggle_pause();
1630 state.cycle_time_period();
1631 state.toggle_chart_mode();
1632 assert!(!state.log_messages.is_empty());
1633 }
1634
1635 #[test]
1636 fn test_ui_function_full_render() {
1637 let mut terminal = create_test_terminal();
1639 let mut state = create_populated_state();
1640 terminal.draw(|f| ui(f, &mut state)).unwrap();
1641 }
1642
1643 #[test]
1644 fn test_ui_function_candlestick_mode() {
1645 let mut terminal = create_test_terminal();
1646 let mut state = create_populated_state();
1647 state.chart_mode = ChartMode::Candlestick;
1648 terminal.draw(|f| ui(f, &mut state)).unwrap();
1649 }
1650
1651 #[test]
1652 fn test_ui_function_with_error_message() {
1653 let mut terminal = create_test_terminal();
1654 let mut state = create_populated_state();
1655 state.error_message = Some("Test error".to_string());
1656 terminal.draw(|f| ui(f, &mut state)).unwrap();
1657 }
1658
1659 #[test]
1660 fn test_render_header_with_small_positive_change() {
1661 let mut terminal = create_test_terminal();
1662 let mut state = create_populated_state();
1663 state.price_change_24h = 0.3; terminal
1665 .draw(|f| render_header(f, f.area(), &state))
1666 .unwrap();
1667 }
1668
1669 #[test]
1670 fn test_render_header_with_small_negative_change() {
1671 let mut terminal = create_test_terminal();
1672 let mut state = create_populated_state();
1673 state.price_change_24h = -0.3; terminal
1675 .draw(|f| render_header(f, f.area(), &state))
1676 .unwrap();
1677 }
1678
1679 #[test]
1680 fn test_render_buy_sell_gauge_high_buy_ratio() {
1681 let mut terminal = create_test_terminal();
1682 let token_data = create_test_token_data();
1683 let mut state = MonitorState::new(&token_data, "ethereum");
1684 state.buys_24h = 100;
1685 state.sells_24h = 10;
1686 terminal
1687 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
1688 .unwrap();
1689 }
1690
1691 #[test]
1692 fn test_render_buy_sell_gauge_zero_total() {
1693 let mut terminal = create_test_terminal();
1694 let token_data = create_test_token_data();
1695 let mut state = MonitorState::new(&token_data, "ethereum");
1696 state.buys_24h = 0;
1697 state.sells_24h = 0;
1698 terminal
1699 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
1700 .unwrap();
1701 }
1702
1703 #[test]
1704 fn test_render_metrics_with_market_cap() {
1705 let mut terminal = create_test_terminal();
1706 let token_data = create_test_token_data();
1707 let mut state = MonitorState::new(&token_data, "ethereum");
1708 state.market_cap = Some(1_000_000_000.0);
1709 state.fdv = Some(2_000_000_000.0);
1710 terminal
1711 .draw(|f| render_metrics_panel(f, f.area(), &state))
1712 .unwrap();
1713 }
1714
1715 #[test]
1716 fn test_render_footer_with_error() {
1717 let mut terminal = create_test_terminal();
1718 let mut state = create_populated_state();
1719 state.error_message = Some("Connection failed".to_string());
1720 terminal
1721 .draw(|f| render_footer(f, f.area(), &state))
1722 .unwrap();
1723 }
1724
1725 #[test]
1726 fn test_format_price_usd_various() {
1727 assert!(!format_price_usd(0.0000001).is_empty());
1729 assert!(!format_price_usd(0.001).is_empty());
1730 assert!(!format_price_usd(1.0).is_empty());
1731 assert!(!format_price_usd(100.0).is_empty());
1732 assert!(!format_price_usd(10000.0).is_empty());
1733 assert!(!format_price_usd(1000000.0).is_empty());
1734 }
1735
1736 #[test]
1737 fn test_format_usd_various() {
1738 assert!(!scope::display::format_usd(0.0).is_empty());
1739 assert!(!scope::display::format_usd(999.0).is_empty());
1740 assert!(!scope::display::format_usd(1500.0).is_empty());
1741 assert!(!scope::display::format_usd(1_500_000.0).is_empty());
1742 assert!(!scope::display::format_usd(1_500_000_000.0).is_empty());
1743 assert!(!scope::display::format_usd(1_500_000_000_000.0).is_empty());
1744 }
1745
1746 #[test]
1747 fn test_format_number_various() {
1748 assert!(!format_number(0.0).is_empty());
1749 assert!(!format_number(999.0).is_empty());
1750 assert!(!format_number(1500.0).is_empty());
1751 assert!(!format_number(1_500_000.0).is_empty());
1752 assert!(!format_number(1_500_000_000.0).is_empty());
1753 }
1754
1755 #[test]
1756 fn test_render_with_min15_period() {
1757 let mut terminal = create_test_terminal();
1758 let mut state = create_populated_state();
1759 state.set_time_period(TimePeriod::Min15);
1760 terminal.draw(|f| ui(f, &mut state)).unwrap();
1761 }
1762
1763 #[test]
1764 fn test_render_with_hour6_period() {
1765 let mut terminal = create_test_terminal();
1766 let mut state = create_populated_state();
1767 state.set_time_period(TimePeriod::Hour4);
1768 terminal.draw(|f| ui(f, &mut state)).unwrap();
1769 }
1770
1771 #[test]
1772 fn test_ui_with_fresh_state_no_real_data() {
1773 let mut terminal = create_test_terminal();
1774 let token_data = create_test_token_data();
1775 let mut state = MonitorState::new(&token_data, "ethereum");
1776 terminal.draw(|f| ui(f, &mut state)).unwrap();
1778 }
1779
1780 #[test]
1781 fn test_ui_with_paused_state() {
1782 let mut terminal = create_test_terminal();
1783 let mut state = create_populated_state();
1784 state.toggle_pause();
1785 terminal.draw(|f| ui(f, &mut state)).unwrap();
1786 }
1787
1788 #[test]
1789 fn test_render_all_with_different_time_periods_and_modes() {
1790 let mut terminal = create_test_terminal();
1791 let mut state = create_populated_state();
1792
1793 for period in &[
1795 TimePeriod::Min1,
1796 TimePeriod::Min5,
1797 TimePeriod::Min15,
1798 TimePeriod::Hour1,
1799 TimePeriod::Hour4,
1800 TimePeriod::Day1,
1801 ] {
1802 for mode in &[
1803 ChartMode::Line,
1804 ChartMode::Candlestick,
1805 ChartMode::VolumeProfile,
1806 ] {
1807 state.set_time_period(*period);
1808 state.chart_mode = *mode;
1809 terminal.draw(|f| ui(f, &mut state)).unwrap();
1810 }
1811 }
1812 }
1813
1814 #[test]
1815 fn test_render_metrics_with_large_values() {
1816 let mut terminal = create_test_terminal();
1817 let mut state = create_populated_state();
1818 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
1823 .draw(|f| render_metrics_panel(f, f.area(), &state))
1824 .unwrap();
1825 }
1826
1827 #[test]
1828 fn test_render_header_large_positive_change() {
1829 let mut terminal = create_test_terminal();
1830 let mut state = create_populated_state();
1831 state.price_change_24h = 50.0; terminal
1833 .draw(|f| render_header(f, f.area(), &state))
1834 .unwrap();
1835 }
1836
1837 #[test]
1838 fn test_render_header_large_negative_change() {
1839 let mut terminal = create_test_terminal();
1840 let mut state = create_populated_state();
1841 state.price_change_24h = -50.0; terminal
1843 .draw(|f| render_header(f, f.area(), &state))
1844 .unwrap();
1845 }
1846
1847 #[test]
1848 fn test_render_price_chart_empty_data() {
1849 let mut terminal = create_test_terminal();
1850 let token_data = create_test_token_data();
1851 let mut state = MonitorState::new(&token_data, "ethereum");
1853 state.price_history.clear();
1854 terminal
1855 .draw(|f| render_price_chart(f, f.area(), &state))
1856 .unwrap();
1857 }
1858
1859 #[test]
1860 fn test_render_price_chart_price_down() {
1861 let mut terminal = create_test_terminal();
1862 let mut state = create_populated_state();
1863 state.price_change_24h = -15.0;
1865 state.current_price = 0.5; terminal
1867 .draw(|f| render_price_chart(f, f.area(), &state))
1868 .unwrap();
1869 }
1870
1871 #[test]
1872 fn test_render_price_chart_zero_first_price() {
1873 let mut terminal = create_test_terminal();
1874 let mut token_data = create_test_token_data();
1875 token_data.price_usd = 0.0;
1876 let state = MonitorState::new(&token_data, "ethereum");
1877 terminal
1878 .draw(|f| render_price_chart(f, f.area(), &state))
1879 .unwrap();
1880 }
1881
1882 #[test]
1883 fn test_render_metrics_panel_zero_5m_change() {
1884 let mut terminal = create_test_terminal();
1885 let mut state = create_populated_state();
1886 state.price_change_5m = 0.0; terminal
1888 .draw(|f| render_metrics_panel(f, f.area(), &state))
1889 .unwrap();
1890 }
1891
1892 #[test]
1893 fn test_render_metrics_panel_positive_5m_change() {
1894 let mut terminal = create_test_terminal();
1895 let mut state = create_populated_state();
1896 state.price_change_5m = 5.0; terminal
1898 .draw(|f| render_metrics_panel(f, f.area(), &state))
1899 .unwrap();
1900 }
1901
1902 #[test]
1903 fn test_render_metrics_panel_negative_5m_change() {
1904 let mut terminal = create_test_terminal();
1905 let mut state = create_populated_state();
1906 state.price_change_5m = -3.0; terminal
1908 .draw(|f| render_metrics_panel(f, f.area(), &state))
1909 .unwrap();
1910 }
1911
1912 #[test]
1913 fn test_render_metrics_panel_negative_24h_change() {
1914 let mut terminal = create_test_terminal();
1915 let mut state = create_populated_state();
1916 state.price_change_24h = -10.0;
1917 terminal
1918 .draw(|f| render_metrics_panel(f, f.area(), &state))
1919 .unwrap();
1920 }
1921
1922 #[test]
1923 fn test_render_metrics_panel_old_last_change() {
1924 let mut terminal = create_test_terminal();
1925 let mut state = create_populated_state();
1926 state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 7200.0; terminal
1929 .draw(|f| render_metrics_panel(f, f.area(), &state))
1930 .unwrap();
1931 }
1932
1933 #[test]
1934 fn test_render_metrics_panel_minutes_ago_change() {
1935 let mut terminal = create_test_terminal();
1936 let mut state = create_populated_state();
1937 state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 300.0; terminal
1940 .draw(|f| render_metrics_panel(f, f.area(), &state))
1941 .unwrap();
1942 }
1943
1944 #[test]
1945 fn test_render_candlestick_empty_fresh_state() {
1946 let mut terminal = create_test_terminal();
1947 let token_data = create_test_token_data();
1948 let mut state = MonitorState::new(&token_data, "ethereum");
1949 state.price_history.clear();
1950 state.chart_mode = ChartMode::Candlestick;
1951 terminal
1952 .draw(|f| render_candlestick_chart(f, f.area(), &state))
1953 .unwrap();
1954 }
1955
1956 #[test]
1957 fn test_render_candlestick_price_down() {
1958 let mut terminal = create_test_terminal();
1959 let token_data = create_test_token_data();
1960 let mut state = MonitorState::new(&token_data, "ethereum");
1961 for i in 0..20 {
1963 let mut data = token_data.clone();
1964 data.price_usd = 2.0 - (i as f64 * 0.05);
1965 state.update(&data);
1966 }
1967 state.chart_mode = ChartMode::Candlestick;
1968 terminal
1969 .draw(|f| render_candlestick_chart(f, f.area(), &state))
1970 .unwrap();
1971 }
1972
1973 #[test]
1974 fn test_render_volume_chart_with_many_points() {
1975 let mut terminal = create_test_terminal();
1976 let token_data = create_test_token_data();
1977 let mut state = MonitorState::new(&token_data, "ethereum");
1978 for i in 0..100 {
1980 let mut data = token_data.clone();
1981 data.volume_24h = 1_000_000.0 + (i as f64 * 50_000.0);
1982 data.price_usd = 1.0 + (i as f64 * 0.001);
1983 state.update(&data);
1984 }
1985 terminal
1986 .draw(|f| render_volume_chart(f, f.area(), &state))
1987 .unwrap();
1988 }
1989
1990 fn make_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
1995 crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)
1996 }
1997
1998 fn make_ctrl_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
1999 crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
2000 }
2001
2002 #[test]
2003 fn test_handle_key_quit_q() {
2004 let token_data = create_test_token_data();
2005 let mut state = MonitorState::new(&token_data, "ethereum");
2006 assert!(handle_key_event_on_state(
2007 make_key_event(KeyCode::Char('q')),
2008 &mut state
2009 ));
2010 }
2011
2012 #[test]
2013 fn test_handle_key_quit_esc() {
2014 let token_data = create_test_token_data();
2015 let mut state = MonitorState::new(&token_data, "ethereum");
2016 assert!(handle_key_event_on_state(
2017 make_key_event(KeyCode::Esc),
2018 &mut state
2019 ));
2020 }
2021
2022 #[test]
2023 fn test_handle_key_quit_ctrl_c() {
2024 let token_data = create_test_token_data();
2025 let mut state = MonitorState::new(&token_data, "ethereum");
2026 assert!(handle_key_event_on_state(
2027 make_ctrl_key_event(KeyCode::Char('c')),
2028 &mut state
2029 ));
2030 }
2031
2032 #[test]
2033 fn test_handle_key_refresh() {
2034 let token_data = create_test_token_data();
2035 let mut state = MonitorState::new(&token_data, "ethereum");
2036 state.refresh_rate = Duration::from_secs(60);
2037 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('r')), &mut state);
2039 assert!(!exit);
2040 assert!(state.should_refresh());
2042 }
2043
2044 #[test]
2045 fn test_handle_key_pause_toggle() {
2046 let token_data = create_test_token_data();
2047 let mut state = MonitorState::new(&token_data, "ethereum");
2048 assert!(!state.paused);
2049
2050 handle_key_event_on_state(make_key_event(KeyCode::Char('p')), &mut state);
2051 assert!(state.paused);
2052
2053 handle_key_event_on_state(make_key_event(KeyCode::Char(' ')), &mut state);
2054 assert!(!state.paused);
2055 }
2056
2057 #[test]
2058 fn test_handle_key_slower_refresh() {
2059 let token_data = create_test_token_data();
2060 let mut state = MonitorState::new(&token_data, "ethereum");
2061 let initial = state.refresh_rate;
2062
2063 handle_key_event_on_state(make_key_event(KeyCode::Char('+')), &mut state);
2064 assert!(state.refresh_rate > initial);
2065
2066 state.refresh_rate = initial;
2067 handle_key_event_on_state(make_key_event(KeyCode::Char('=')), &mut state);
2068 assert!(state.refresh_rate > initial);
2069
2070 state.refresh_rate = initial;
2071 handle_key_event_on_state(make_key_event(KeyCode::Char(']')), &mut state);
2072 assert!(state.refresh_rate > initial);
2073 }
2074
2075 #[test]
2076 fn test_handle_key_faster_refresh() {
2077 let token_data = create_test_token_data();
2078 let mut state = MonitorState::new(&token_data, "ethereum");
2079 state.refresh_rate = Duration::from_secs(30);
2081 let initial = state.refresh_rate;
2082
2083 handle_key_event_on_state(make_key_event(KeyCode::Char('-')), &mut state);
2084 assert!(state.refresh_rate < initial);
2085
2086 state.refresh_rate = initial;
2087 handle_key_event_on_state(make_key_event(KeyCode::Char('_')), &mut state);
2088 assert!(state.refresh_rate < initial);
2089
2090 state.refresh_rate = initial;
2091 handle_key_event_on_state(make_key_event(KeyCode::Char('[')), &mut state);
2092 assert!(state.refresh_rate < initial);
2093 }
2094
2095 #[test]
2096 fn test_handle_key_time_periods() {
2097 let token_data = create_test_token_data();
2098 let mut state = MonitorState::new(&token_data, "ethereum");
2099
2100 handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
2101 assert!(matches!(state.time_period, TimePeriod::Min1));
2102
2103 handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
2104 assert!(matches!(state.time_period, TimePeriod::Min5));
2105
2106 handle_key_event_on_state(make_key_event(KeyCode::Char('3')), &mut state);
2107 assert!(matches!(state.time_period, TimePeriod::Min15));
2108
2109 handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
2110 assert!(matches!(state.time_period, TimePeriod::Hour1));
2111
2112 handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
2113 assert!(matches!(state.time_period, TimePeriod::Hour4));
2114
2115 handle_key_event_on_state(make_key_event(KeyCode::Char('6')), &mut state);
2116 assert!(matches!(state.time_period, TimePeriod::Day1));
2117 }
2118
2119 #[test]
2120 fn test_handle_key_cycle_time_period() {
2121 let token_data = create_test_token_data();
2122 let mut state = MonitorState::new(&token_data, "ethereum");
2123
2124 handle_key_event_on_state(make_key_event(KeyCode::Char('t')), &mut state);
2125 let first = state.time_period;
2127
2128 handle_key_event_on_state(make_key_event(KeyCode::Tab), &mut state);
2129 let _ = state.time_period;
2132 let _ = first;
2133 }
2134
2135 #[test]
2136 fn test_handle_key_toggle_chart_mode() {
2137 let token_data = create_test_token_data();
2138 let mut state = MonitorState::new(&token_data, "ethereum");
2139 let initial_mode = state.chart_mode;
2140
2141 handle_key_event_on_state(make_key_event(KeyCode::Char('c')), &mut state);
2142 assert!(state.chart_mode != initial_mode);
2143 }
2144
2145 #[test]
2146 fn test_handle_key_unknown_no_op() {
2147 let token_data = create_test_token_data();
2148 let mut state = MonitorState::new(&token_data, "ethereum");
2149 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
2150 assert!(!exit);
2151 }
2152
2153 #[test]
2158 fn test_save_and_load_cache() {
2159 let token_data = create_test_token_data();
2160 let mut state = MonitorState::new(&token_data, "ethereum");
2161 state.price_history.push_back(DataPoint {
2162 timestamp: 1.0,
2163 value: 100.0,
2164 is_real: true,
2165 });
2166 state.price_history.push_back(DataPoint {
2167 timestamp: 2.0,
2168 value: 101.0,
2169 is_real: true,
2170 });
2171 state.volume_history.push_back(DataPoint {
2172 timestamp: 1.0,
2173 value: 5000.0,
2174 is_real: true,
2175 });
2176
2177 state.save_cache();
2180 let cached = MonitorState::load_cache(&state.token_address, &state.chain);
2181 if let Some(c) = cached {
2183 assert_eq!(
2184 c.token_address.to_lowercase(),
2185 state.token_address.to_lowercase()
2186 );
2187 }
2188 }
2189
2190 #[test]
2191 fn test_load_cache_nonexistent_token() {
2192 let cached = MonitorState::load_cache("0xNONEXISTENT_TOKEN_ADDR", "nonexistent_chain");
2193 assert!(cached.is_none());
2194 }
2195
2196 #[test]
2201 fn test_render_volume_barchart_with_populated_data() {
2202 let mut terminal = create_test_terminal();
2205 let mut state = create_populated_state();
2206 for period in [
2207 TimePeriod::Min1,
2208 TimePeriod::Min5,
2209 TimePeriod::Min15,
2210 TimePeriod::Hour1,
2211 TimePeriod::Hour4,
2212 TimePeriod::Day1,
2213 ] {
2214 state.set_time_period(period);
2215 terminal
2216 .draw(|f| render_volume_chart(f, f.area(), &state))
2217 .unwrap();
2218 }
2219 }
2220
2221 #[test]
2222 fn test_render_volume_barchart_narrow_terminal() {
2223 let backend = TestBackend::new(20, 10);
2225 let mut terminal = Terminal::new(backend).unwrap();
2226 let state = create_populated_state();
2227 terminal
2228 .draw(|f| render_volume_chart(f, f.area(), &state))
2229 .unwrap();
2230 }
2231
2232 #[test]
2233 fn test_render_metrics_table_sparkline_no_panic() {
2234 let mut terminal = create_test_terminal();
2236 let state = create_populated_state();
2237 terminal
2238 .draw(|f| render_metrics_panel(f, f.area(), &state))
2239 .unwrap();
2240 }
2241
2242 #[test]
2243 fn test_render_metrics_table_sparkline_all_periods() {
2244 let mut terminal = create_test_terminal();
2246 let mut state = create_populated_state();
2247 for period in [
2248 TimePeriod::Min1,
2249 TimePeriod::Min5,
2250 TimePeriod::Min15,
2251 TimePeriod::Hour1,
2252 TimePeriod::Hour4,
2253 TimePeriod::Day1,
2254 ] {
2255 state.set_time_period(period);
2256 terminal
2257 .draw(|f| render_metrics_panel(f, f.area(), &state))
2258 .unwrap();
2259 }
2260 }
2261
2262 #[test]
2263 fn test_render_metrics_sparkline_trend_direction() {
2264 let mut terminal = create_test_terminal();
2266 let mut state = create_populated_state();
2267 state.price_change_5m = -3.5;
2268 terminal
2269 .draw(|f| render_metrics_panel(f, f.area(), &state))
2270 .unwrap();
2271
2272 state.price_change_5m = 2.0;
2274 terminal
2275 .draw(|f| render_metrics_panel(f, f.area(), &state))
2276 .unwrap();
2277
2278 state.price_change_5m = 0.0;
2280 terminal
2281 .draw(|f| render_metrics_panel(f, f.area(), &state))
2282 .unwrap();
2283 }
2284
2285 #[test]
2286 fn test_render_tabs_time_period() {
2287 let mut terminal = create_test_terminal();
2289 let mut state = create_populated_state();
2290 for period in [
2291 TimePeriod::Min1,
2292 TimePeriod::Min5,
2293 TimePeriod::Min15,
2294 TimePeriod::Hour1,
2295 TimePeriod::Hour4,
2296 TimePeriod::Day1,
2297 ] {
2298 state.set_time_period(period);
2299 terminal
2300 .draw(|f| render_header(f, f.area(), &state))
2301 .unwrap();
2302 }
2303 }
2304
2305 #[test]
2306 fn test_time_period_index() {
2307 assert_eq!(TimePeriod::Min1.index(), 0);
2308 assert_eq!(TimePeriod::Min5.index(), 1);
2309 assert_eq!(TimePeriod::Min15.index(), 2);
2310 assert_eq!(TimePeriod::Hour1.index(), 3);
2311 assert_eq!(TimePeriod::Hour4.index(), 4);
2312 assert_eq!(TimePeriod::Day1.index(), 5);
2313 }
2314
2315 #[test]
2316 fn test_scroll_log_down_from_start() {
2317 let token_data = create_test_token_data();
2318 let mut state = MonitorState::new(&token_data, "ethereum");
2319 state.log_messages.push_back("msg 1".to_string());
2320 state.log_messages.push_back("msg 2".to_string());
2321 state.log_messages.push_back("msg 3".to_string());
2322
2323 assert_eq!(state.log_list_state.selected(), None);
2325
2326 state.scroll_log_down();
2328 assert_eq!(state.log_list_state.selected(), Some(0));
2329
2330 state.scroll_log_down();
2332 assert_eq!(state.log_list_state.selected(), Some(1));
2333
2334 state.scroll_log_down();
2336 assert_eq!(state.log_list_state.selected(), Some(2));
2337
2338 state.scroll_log_down();
2340 assert_eq!(state.log_list_state.selected(), Some(2));
2341 }
2342
2343 #[test]
2344 fn test_scroll_log_up_from_start() {
2345 let token_data = create_test_token_data();
2346 let mut state = MonitorState::new(&token_data, "ethereum");
2347 state.log_messages.push_back("msg 1".to_string());
2348 state.log_messages.push_back("msg 2".to_string());
2349 state.log_messages.push_back("msg 3".to_string());
2350
2351 state.scroll_log_up();
2353 assert_eq!(state.log_list_state.selected(), Some(0));
2354
2355 state.scroll_log_up();
2357 assert_eq!(state.log_list_state.selected(), Some(0));
2358 }
2359
2360 #[test]
2361 fn test_scroll_log_up_down_roundtrip() {
2362 let token_data = create_test_token_data();
2363 let mut state = MonitorState::new(&token_data, "ethereum");
2364 for i in 0..10 {
2365 state.log_messages.push_back(format!("msg {}", i));
2366 }
2367
2368 for _ in 0..5 {
2370 state.scroll_log_down();
2371 }
2372 assert_eq!(state.log_list_state.selected(), Some(4));
2373
2374 for _ in 0..3 {
2376 state.scroll_log_up();
2377 }
2378 assert_eq!(state.log_list_state.selected(), Some(1));
2379 }
2380
2381 #[test]
2382 fn test_scroll_log_empty_no_panic() {
2383 let token_data = create_test_token_data();
2384 let mut state = MonitorState::new(&token_data, "ethereum");
2385 state.scroll_log_down();
2387 state.scroll_log_up();
2388 assert!(
2389 state.log_list_state.selected().is_none() || state.log_list_state.selected() == Some(0)
2390 );
2391 }
2392
2393 #[test]
2394 fn test_render_scrollable_activity_log() {
2395 let mut terminal = create_test_terminal();
2397 let mut state = create_populated_state();
2398 for i in 0..20 {
2399 state
2400 .log_messages
2401 .push_back(format!("Activity event #{}", i));
2402 }
2403 state.scroll_log_down();
2405 state.scroll_log_down();
2406 state.scroll_log_down();
2407
2408 terminal
2409 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
2410 .unwrap();
2411 }
2412
2413 #[test]
2414 fn test_handle_key_scroll_log_j_k() {
2415 let token_data = create_test_token_data();
2416 let mut state = MonitorState::new(&token_data, "ethereum");
2417 state.log_messages.push_back("line 1".to_string());
2418 state.log_messages.push_back("line 2".to_string());
2419
2420 handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
2422 assert_eq!(state.log_list_state.selected(), Some(0));
2423
2424 handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
2425 assert_eq!(state.log_list_state.selected(), Some(1));
2426
2427 handle_key_event_on_state(make_key_event(KeyCode::Char('k')), &mut state);
2429 assert_eq!(state.log_list_state.selected(), Some(0));
2430 }
2431
2432 #[test]
2433 fn test_handle_key_scroll_log_arrow_keys() {
2434 let token_data = create_test_token_data();
2435 let mut state = MonitorState::new(&token_data, "ethereum");
2436 state.log_messages.push_back("line 1".to_string());
2437 state.log_messages.push_back("line 2".to_string());
2438 state.log_messages.push_back("line 3".to_string());
2439
2440 handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
2442 assert_eq!(state.log_list_state.selected(), Some(0));
2443
2444 handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
2445 assert_eq!(state.log_list_state.selected(), Some(1));
2446
2447 handle_key_event_on_state(make_key_event(KeyCode::Up), &mut state);
2449 assert_eq!(state.log_list_state.selected(), Some(0));
2450 }
2451
2452 #[test]
2453 fn test_render_ui_with_scrolled_log() {
2454 let mut terminal = create_test_terminal();
2456 let mut state = create_populated_state();
2457 for i in 0..15 {
2458 state.log_messages.push_back(format!("Log entry {}", i));
2459 }
2460 state.scroll_log_down();
2461 state.scroll_log_down();
2462 state.scroll_log_down();
2463 state.scroll_log_down();
2464 state.scroll_log_down();
2465
2466 terminal.draw(|f| ui(f, &mut state)).unwrap();
2467 }
2468
2469 #[test]
2478 fn test_monitor_config_defaults() {
2479 let config = MonitorConfig::default();
2480 assert_eq!(config.layout, LayoutPreset::Dashboard);
2481 assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
2482 assert!(config.widgets.price_chart);
2483 assert!(config.widgets.volume_chart);
2484 assert!(config.widgets.buy_sell_pressure);
2485 assert!(config.widgets.metrics_panel);
2486 assert!(config.widgets.activity_log);
2487 }
2488
2489 #[test]
2490 fn test_layout_preset_next_cycles() {
2491 assert_eq!(LayoutPreset::Dashboard.next(), LayoutPreset::ChartFocus);
2492 assert_eq!(LayoutPreset::ChartFocus.next(), LayoutPreset::Feed);
2493 assert_eq!(LayoutPreset::Feed.next(), LayoutPreset::Compact);
2494 assert_eq!(LayoutPreset::Compact.next(), LayoutPreset::Exchange);
2495 assert_eq!(LayoutPreset::Exchange.next(), LayoutPreset::Dashboard);
2496 }
2497
2498 #[test]
2499 fn test_layout_preset_prev_cycles() {
2500 assert_eq!(LayoutPreset::Dashboard.prev(), LayoutPreset::Exchange);
2501 assert_eq!(LayoutPreset::Exchange.prev(), LayoutPreset::Compact);
2502 assert_eq!(LayoutPreset::Compact.prev(), LayoutPreset::Feed);
2503 assert_eq!(LayoutPreset::Feed.prev(), LayoutPreset::ChartFocus);
2504 assert_eq!(LayoutPreset::ChartFocus.prev(), LayoutPreset::Dashboard);
2505 }
2506
2507 #[test]
2508 fn test_layout_preset_full_cycle() {
2509 let start = LayoutPreset::Dashboard;
2510 let mut preset = start;
2511 for _ in 0..5 {
2512 preset = preset.next();
2513 }
2514 assert_eq!(preset, start);
2515 }
2516
2517 #[test]
2518 fn test_layout_preset_labels() {
2519 assert_eq!(LayoutPreset::Dashboard.label(), "Dashboard");
2520 assert_eq!(LayoutPreset::ChartFocus.label(), "Chart");
2521 assert_eq!(LayoutPreset::Feed.label(), "Feed");
2522 assert_eq!(LayoutPreset::Compact.label(), "Compact");
2523 assert_eq!(LayoutPreset::Exchange.label(), "Exchange");
2524 }
2525
2526 #[test]
2527 fn test_widget_visibility_default_all_visible() {
2528 let vis = WidgetVisibility::default();
2529 assert_eq!(vis.visible_count(), 5);
2530 }
2531
2532 #[test]
2533 fn test_widget_visibility_toggle_by_index() {
2534 let mut vis = WidgetVisibility::default();
2535 vis.toggle_by_index(1);
2536 assert!(!vis.price_chart);
2537 assert_eq!(vis.visible_count(), 4);
2538
2539 vis.toggle_by_index(2);
2540 assert!(!vis.volume_chart);
2541 assert_eq!(vis.visible_count(), 3);
2542
2543 vis.toggle_by_index(3);
2544 assert!(!vis.buy_sell_pressure);
2545 assert_eq!(vis.visible_count(), 2);
2546
2547 vis.toggle_by_index(4);
2548 assert!(!vis.metrics_panel);
2549 assert_eq!(vis.visible_count(), 1);
2550
2551 vis.toggle_by_index(5);
2552 assert!(!vis.activity_log);
2553 assert_eq!(vis.visible_count(), 0);
2554
2555 vis.toggle_by_index(1);
2557 assert!(vis.price_chart);
2558 assert_eq!(vis.visible_count(), 1);
2559 }
2560
2561 #[test]
2562 fn test_widget_visibility_toggle_invalid_index() {
2563 let mut vis = WidgetVisibility::default();
2564 vis.toggle_by_index(0);
2565 vis.toggle_by_index(6);
2566 vis.toggle_by_index(100);
2567 assert_eq!(vis.visible_count(), 5); }
2569
2570 #[test]
2571 fn test_auto_select_layout_small_terminal() {
2572 let size = Rect::new(0, 0, 60, 20);
2573 assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
2574 }
2575
2576 #[test]
2577 fn test_auto_select_layout_narrow_terminal() {
2578 let size = Rect::new(0, 0, 100, 40);
2579 assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
2580 }
2581
2582 #[test]
2583 fn test_auto_select_layout_short_terminal() {
2584 let size = Rect::new(0, 0, 140, 28);
2585 assert_eq!(auto_select_layout(size), LayoutPreset::ChartFocus);
2586 }
2587
2588 #[test]
2589 fn test_auto_select_layout_large_terminal() {
2590 let size = Rect::new(0, 0, 160, 50);
2591 assert_eq!(auto_select_layout(size), LayoutPreset::Dashboard);
2592 }
2593
2594 #[test]
2595 fn test_auto_select_layout_edge_80x24() {
2596 let size = Rect::new(0, 0, 80, 24);
2598 assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
2599 }
2600
2601 #[test]
2602 fn test_auto_select_layout_edge_79() {
2603 let size = Rect::new(0, 0, 79, 50);
2604 assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
2605 }
2606
2607 #[test]
2608 fn test_auto_select_layout_edge_23_height() {
2609 let size = Rect::new(0, 0, 160, 23);
2610 assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
2611 }
2612
2613 #[test]
2614 fn test_layout_dashboard_all_visible() {
2615 let area = Rect::new(0, 0, 120, 40);
2616 let vis = WidgetVisibility::default();
2617 let areas = layout_dashboard(area, &vis);
2618 assert!(areas.price_chart.is_some());
2619 assert!(areas.volume_chart.is_some());
2620 assert!(areas.buy_sell_gauge.is_some());
2621 assert!(areas.metrics_panel.is_some());
2622 assert!(areas.activity_feed.is_some());
2623 }
2624
2625 #[test]
2626 fn test_layout_dashboard_hidden_widget() {
2627 let area = Rect::new(0, 0, 120, 40);
2628 let vis = WidgetVisibility {
2629 price_chart: false,
2630 ..WidgetVisibility::default()
2631 };
2632 let areas = layout_dashboard(area, &vis);
2633 assert!(areas.price_chart.is_none());
2634 assert!(areas.volume_chart.is_some());
2635 }
2636
2637 #[test]
2638 fn test_layout_chart_focus_minimal_overlay() {
2639 let area = Rect::new(0, 0, 120, 40);
2640 let vis = WidgetVisibility::default();
2641 let areas = layout_chart_focus(area, &vis);
2642 assert!(areas.price_chart.is_some());
2643 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()); }
2648
2649 #[test]
2650 fn test_layout_feed_activity_priority() {
2651 let area = Rect::new(0, 0, 120, 40);
2652 let vis = WidgetVisibility::default();
2653 let areas = layout_feed(area, &vis);
2654 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()); }
2660
2661 #[test]
2662 fn test_layout_compact_metrics_only() {
2663 let area = Rect::new(0, 0, 60, 20);
2664 let vis = WidgetVisibility::default();
2665 let areas = layout_compact(area, &vis);
2666 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()); }
2672
2673 #[test]
2674 fn test_layout_exchange_has_order_book_and_market_info() {
2675 let area = Rect::new(0, 0, 160, 50);
2676 let vis = WidgetVisibility::default();
2677 let areas = layout_exchange(area, &vis);
2678 assert!(areas.order_book.is_some());
2679 assert!(areas.market_info.is_some());
2680 assert!(areas.price_chart.is_some());
2681 assert!(areas.buy_sell_gauge.is_some());
2682 assert!(areas.volume_chart.is_none()); assert!(areas.metrics_panel.is_none()); assert!(areas.activity_feed.is_none()); }
2686
2687 #[test]
2688 fn test_ui_render_all_layouts_no_panic() {
2689 let presets = [
2690 LayoutPreset::Dashboard,
2691 LayoutPreset::ChartFocus,
2692 LayoutPreset::Feed,
2693 LayoutPreset::Compact,
2694 LayoutPreset::Exchange,
2695 ];
2696 for preset in &presets {
2697 let mut terminal = create_test_terminal();
2698 let mut state = create_populated_state();
2699 state.layout = *preset;
2700 state.auto_layout = false; terminal.draw(|f| ui(f, &mut state)).unwrap();
2702 }
2703 }
2704
2705 #[test]
2706 fn test_ui_render_compact_small_terminal() {
2707 let backend = TestBackend::new(60, 20);
2708 let mut terminal = Terminal::new(backend).unwrap();
2709 let mut state = create_populated_state();
2710 state.layout = LayoutPreset::Compact;
2711 state.auto_layout = false;
2712 terminal.draw(|f| ui(f, &mut state)).unwrap();
2713 }
2714
2715 #[test]
2716 fn test_ui_auto_layout_selects_compact_for_small() {
2717 let backend = TestBackend::new(60, 20);
2718 let mut terminal = Terminal::new(backend).unwrap();
2719 let mut state = create_populated_state();
2720 state.layout = LayoutPreset::Dashboard;
2721 state.auto_layout = true;
2722 terminal.draw(|f| ui(f, &mut state)).unwrap();
2723 assert_eq!(state.layout, LayoutPreset::Compact);
2724 }
2725
2726 #[test]
2727 fn test_ui_auto_layout_disabled_keeps_preset() {
2728 let backend = TestBackend::new(60, 20);
2729 let mut terminal = Terminal::new(backend).unwrap();
2730 let mut state = create_populated_state();
2731 state.layout = LayoutPreset::Dashboard;
2732 state.auto_layout = false;
2733 terminal.draw(|f| ui(f, &mut state)).unwrap();
2734 assert_eq!(state.layout, LayoutPreset::Dashboard); }
2736
2737 #[test]
2738 fn test_keybinding_l_cycles_layout_forward() {
2739 let mut state = create_populated_state();
2740 state.layout = LayoutPreset::Dashboard;
2741 state.auto_layout = true;
2742
2743 handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
2744 assert_eq!(state.layout, LayoutPreset::ChartFocus);
2745 assert!(!state.auto_layout); handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
2748 assert_eq!(state.layout, LayoutPreset::Feed);
2749 }
2750
2751 #[test]
2752 fn test_keybinding_h_cycles_layout_backward() {
2753 let mut state = create_populated_state();
2754 state.layout = LayoutPreset::Dashboard;
2755 state.auto_layout = true;
2756
2757 handle_key_event_on_state(make_key_event(KeyCode::Char('h')), &mut state);
2758 assert_eq!(state.layout, LayoutPreset::Exchange);
2759 assert!(!state.auto_layout);
2760 }
2761
2762 #[test]
2763 fn test_keybinding_a_enables_auto_layout() {
2764 let mut state = create_populated_state();
2765 state.auto_layout = false;
2766
2767 handle_key_event_on_state(make_key_event(KeyCode::Char('a')), &mut state);
2768 assert!(state.auto_layout);
2769 }
2770
2771 #[test]
2772 fn test_keybinding_w_widget_toggle_mode() {
2773 let mut state = create_populated_state();
2774 assert!(!state.widget_toggle_mode);
2775
2776 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2778 assert!(state.widget_toggle_mode);
2779
2780 handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
2782 assert!(!state.widget_toggle_mode);
2783 assert!(!state.widgets.price_chart);
2784 }
2785
2786 #[test]
2787 fn test_keybinding_w_cancel_with_non_digit() {
2788 let mut state = create_populated_state();
2789
2790 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2792 assert!(state.widget_toggle_mode);
2793
2794 handle_key_event_on_state(make_key_event(KeyCode::Char('x')), &mut state);
2796 assert!(!state.widget_toggle_mode);
2797 assert!(state.widgets.price_chart); }
2799
2800 #[test]
2801 fn test_keybinding_w_toggle_multiple_widgets() {
2802 let mut state = create_populated_state();
2803
2804 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2806 handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
2807 assert!(!state.widgets.volume_chart);
2808
2809 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2811 handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
2812 assert!(!state.widgets.metrics_panel);
2813
2814 handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
2816 handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
2817 assert!(!state.widgets.activity_log);
2818 }
2819
2820 #[test]
2821 fn test_monitor_config_serde_roundtrip() {
2822 let config = MonitorConfig {
2823 layout: LayoutPreset::ChartFocus,
2824 refresh_seconds: 5,
2825 widgets: WidgetVisibility {
2826 price_chart: true,
2827 volume_chart: false,
2828 buy_sell_pressure: true,
2829 metrics_panel: false,
2830 activity_log: true,
2831 holder_count: true,
2832 liquidity_depth: true,
2833 },
2834 scale: ScaleMode::Log,
2835 color_scheme: ColorScheme::BlueOrange,
2836 alerts: AlertConfig::default(),
2837 export: ExportConfig::default(),
2838 auto_pause_on_input: false,
2839 venue: None,
2840 };
2841
2842 let yaml = serde_saphyr::to_string(&config).unwrap();
2843 let parsed: MonitorConfig = serde_saphyr::from_str(&yaml).unwrap();
2844 assert_eq!(parsed.layout, LayoutPreset::ChartFocus);
2845 assert_eq!(parsed.refresh_seconds, 5);
2846 assert!(parsed.widgets.price_chart);
2847 assert!(!parsed.widgets.volume_chart);
2848 assert!(parsed.widgets.buy_sell_pressure);
2849 assert!(!parsed.widgets.metrics_panel);
2850 assert!(parsed.widgets.activity_log);
2851 }
2852
2853 #[test]
2854 fn test_monitor_config_serde_kebab_case() {
2855 let yaml = r#"
2856layout: chart-focus
2857refresh_seconds: 15
2858widgets:
2859 price_chart: true
2860 volume_chart: true
2861 buy_sell_pressure: false
2862 metrics_panel: true
2863 activity_log: false
2864"#;
2865 let config: MonitorConfig = serde_saphyr::from_str(yaml).unwrap();
2866 assert_eq!(config.layout, LayoutPreset::ChartFocus);
2867 assert_eq!(config.refresh_seconds, 15);
2868 assert!(!config.widgets.buy_sell_pressure);
2869 assert!(!config.widgets.activity_log);
2870 }
2871
2872 #[test]
2873 fn test_monitor_config_serde_default_missing_fields() {
2874 let yaml = "layout: feed\n";
2875 let config: MonitorConfig = serde_saphyr::from_str(yaml).unwrap();
2876 assert_eq!(config.layout, LayoutPreset::Feed);
2877 assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
2878 assert!(config.widgets.price_chart); }
2880
2881 #[test]
2882 fn test_state_apply_config() {
2883 let mut state = create_populated_state();
2884 let config = MonitorConfig {
2885 layout: LayoutPreset::Feed,
2886 refresh_seconds: 5,
2887 widgets: WidgetVisibility {
2888 price_chart: false,
2889 volume_chart: true,
2890 buy_sell_pressure: true,
2891 metrics_panel: false,
2892 activity_log: true,
2893 holder_count: true,
2894 liquidity_depth: true,
2895 },
2896 scale: ScaleMode::Log,
2897 color_scheme: ColorScheme::Monochrome,
2898 alerts: AlertConfig::default(),
2899 export: ExportConfig::default(),
2900 auto_pause_on_input: false,
2901 venue: None,
2902 };
2903 state.apply_config(&config);
2904 assert_eq!(state.layout, LayoutPreset::Feed);
2905 assert!(!state.widgets.price_chart);
2906 assert!(!state.widgets.metrics_panel);
2907 assert_eq!(state.refresh_rate, Duration::from_secs(5));
2908 }
2909
2910 #[test]
2911 fn test_layout_all_widgets_hidden_dashboard() {
2912 let area = Rect::new(0, 0, 120, 40);
2913 let vis = WidgetVisibility {
2914 price_chart: false,
2915 volume_chart: false,
2916 buy_sell_pressure: false,
2917 metrics_panel: false,
2918 activity_log: false,
2919 holder_count: false,
2920 liquidity_depth: false,
2921 };
2922 let areas = layout_dashboard(area, &vis);
2923 assert!(areas.price_chart.is_none());
2924 assert!(areas.volume_chart.is_none());
2925 assert!(areas.buy_sell_gauge.is_none());
2926 assert!(areas.metrics_panel.is_none());
2927 assert!(areas.activity_feed.is_none());
2928 }
2929
2930 #[test]
2931 fn test_ui_render_with_hidden_widgets() {
2932 let mut terminal = create_test_terminal();
2933 let mut state = create_populated_state();
2934 state.auto_layout = false;
2935 state.widgets.price_chart = false;
2936 state.widgets.volume_chart = false;
2937 terminal.draw(|f| ui(f, &mut state)).unwrap();
2938 }
2939
2940 #[test]
2941 fn test_ui_render_widget_toggle_mode_footer() {
2942 let mut terminal = create_test_terminal();
2943 let mut state = create_populated_state();
2944 state.auto_layout = false;
2945 state.widget_toggle_mode = true;
2946 terminal.draw(|f| ui(f, &mut state)).unwrap();
2947 }
2948
2949 #[test]
2950 fn test_monitor_state_new_has_layout_fields() {
2951 let token_data = create_test_token_data();
2952 let state = MonitorState::new(&token_data, "ethereum");
2953 assert_eq!(state.layout, LayoutPreset::Dashboard);
2954 assert!(state.auto_layout);
2955 assert!(!state.widget_toggle_mode);
2956 assert_eq!(state.widgets.visible_count(), 5);
2957 }
2958
2959 #[test]
2964 fn test_monitor_state_has_holder_count_field() {
2965 let token_data = create_test_token_data();
2966 let state = MonitorState::new(&token_data, "ethereum");
2967 assert_eq!(state.holder_count, None);
2968 assert!(state.liquidity_pairs.is_empty());
2969 assert_eq!(state.holder_fetch_counter, 0);
2970 }
2971
2972 #[test]
2973 fn test_liquidity_pairs_extracted_on_update() {
2974 let mut token_data = create_test_token_data();
2975 token_data.pairs = vec![
2976 scope::chains::DexPair {
2977 dex_name: "Uniswap V3".to_string(),
2978 pair_address: "0xpair1".to_string(),
2979 base_token: "TEST".to_string(),
2980 quote_token: "WETH".to_string(),
2981 price_usd: 1.0,
2982 volume_24h: 500_000.0,
2983 liquidity_usd: 250_000.0,
2984 liquidity_base: None,
2985 price_change_24h: 5.0,
2986 buys_24h: 50,
2987 sells_24h: 25,
2988 buys_6h: 10,
2989 sells_6h: 5,
2990 buys_1h: 3,
2991 sells_1h: 2,
2992 pair_created_at: None,
2993 url: None,
2994 },
2995 scope::chains::DexPair {
2996 dex_name: "SushiSwap".to_string(),
2997 pair_address: "0xpair2".to_string(),
2998 base_token: "TEST".to_string(),
2999 quote_token: "USDC".to_string(),
3000 price_usd: 1.0,
3001 volume_24h: 300_000.0,
3002 liquidity_usd: 150_000.0,
3003 liquidity_base: None,
3004 price_change_24h: 3.0,
3005 buys_24h: 30,
3006 sells_24h: 15,
3007 buys_6h: 8,
3008 sells_6h: 4,
3009 buys_1h: 2,
3010 sells_1h: 1,
3011 pair_created_at: None,
3012 url: None,
3013 },
3014 ];
3015
3016 let mut state = MonitorState::new(&token_data, "ethereum");
3017 state.update(&token_data);
3018
3019 assert_eq!(state.liquidity_pairs.len(), 2);
3020 assert!(state.liquidity_pairs[0].0.contains("Uniswap V3"));
3021 assert!((state.liquidity_pairs[0].1 - 250_000.0).abs() < 0.01);
3022 }
3023
3024 #[test]
3025 fn test_render_liquidity_depth_no_panic() {
3026 let mut terminal = create_test_terminal();
3027 let mut state = create_populated_state();
3028 state.liquidity_pairs = vec![
3029 ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
3030 ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
3031 ];
3032 terminal
3033 .draw(|f| render_liquidity_depth(f, f.area(), &state))
3034 .unwrap();
3035 }
3036
3037 #[test]
3038 fn test_render_liquidity_depth_empty() {
3039 let mut terminal = create_test_terminal();
3040 let state = create_populated_state();
3041 terminal
3042 .draw(|f| render_liquidity_depth(f, f.area(), &state))
3043 .unwrap();
3044 }
3045
3046 #[test]
3047 fn test_render_metrics_with_holder_count() {
3048 let mut terminal = create_test_terminal();
3049 let mut state = create_populated_state();
3050 state.holder_count = Some(42_000);
3051 terminal
3052 .draw(|f| render_metrics_panel(f, f.area(), &state))
3053 .unwrap();
3054 }
3055
3056 #[test]
3061 fn test_alert_config_default() {
3062 let config = AlertConfig::default();
3063 assert!(config.price_min.is_none());
3064 assert!(config.price_max.is_none());
3065 assert!(config.whale_min_usd.is_none());
3066 assert!(config.volume_spike_threshold_pct.is_none());
3067 }
3068
3069 #[test]
3070 fn test_alert_price_min_triggers() {
3071 let token_data = create_test_token_data();
3072 let mut state = MonitorState::new(&token_data, "ethereum");
3073 state.alerts.price_min = Some(2.0); state.update(&token_data);
3075 assert!(
3076 !state.active_alerts.is_empty(),
3077 "Should have price-min alert"
3078 );
3079 assert!(state.active_alerts[0].message.contains("below min"));
3080 }
3081
3082 #[test]
3083 fn test_alert_price_max_triggers() {
3084 let mut token_data = create_test_token_data();
3085 token_data.price_usd = 100.0;
3086 let mut state = MonitorState::new(&token_data, "ethereum");
3087 state.alerts.price_max = Some(50.0); state.update(&token_data);
3089 assert!(
3090 !state.active_alerts.is_empty(),
3091 "Should have price-max alert"
3092 );
3093 assert!(state.active_alerts[0].message.contains("above max"));
3094 }
3095
3096 #[test]
3097 fn test_alert_no_trigger_within_bounds() {
3098 let token_data = create_test_token_data();
3099 let mut state = MonitorState::new(&token_data, "ethereum");
3100 state.alerts.price_min = Some(0.5); state.alerts.price_max = Some(2.0); state.update(&token_data);
3103 assert!(
3104 state.active_alerts.is_empty(),
3105 "Should have no alerts when price is within bounds"
3106 );
3107 }
3108
3109 #[test]
3110 fn test_alert_volume_spike_triggers() {
3111 let token_data = create_test_token_data();
3112 let mut state = MonitorState::new(&token_data, "ethereum");
3113 state.alerts.volume_spike_threshold_pct = Some(10.0);
3114 state.volume_avg = 500_000.0; state.update(&token_data);
3118 let spike_alerts: Vec<_> = state
3119 .active_alerts
3120 .iter()
3121 .filter(|a| a.message.contains("spike"))
3122 .collect();
3123 assert!(!spike_alerts.is_empty(), "Should have volume spike alert");
3124 }
3125
3126 #[test]
3127 fn test_alert_flash_timer_set() {
3128 let token_data = create_test_token_data();
3129 let mut state = MonitorState::new(&token_data, "ethereum");
3130 state.alerts.price_min = Some(2.0);
3131 state.update(&token_data);
3132 assert!(state.alert_flash_until.is_some());
3133 }
3134
3135 #[test]
3136 fn test_render_alert_overlay_no_panic() {
3137 let mut terminal = create_test_terminal();
3138 let mut state = create_populated_state();
3139 state.active_alerts.push(ActiveAlert {
3140 message: "⚠ Test alert".to_string(),
3141 triggered_at: Instant::now(),
3142 });
3143 state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
3144 terminal
3145 .draw(|f| render_alert_overlay(f, f.area(), &state))
3146 .unwrap();
3147 }
3148
3149 #[test]
3150 fn test_render_alert_overlay_empty() {
3151 let mut terminal = create_test_terminal();
3152 let state = create_populated_state();
3153 terminal
3154 .draw(|f| render_alert_overlay(f, f.area(), &state))
3155 .unwrap();
3156 }
3157
3158 #[test]
3159 fn test_alert_config_serde_roundtrip() {
3160 let config = AlertConfig {
3161 price_min: Some(0.5),
3162 price_max: Some(2.0),
3163 whale_min_usd: Some(10_000.0),
3164 volume_spike_threshold_pct: Some(50.0),
3165 };
3166 let yaml = serde_saphyr::to_string(&config).unwrap();
3167 let parsed: AlertConfig = serde_saphyr::from_str(&yaml).unwrap();
3168 assert_eq!(parsed.price_min, Some(0.5));
3169 assert_eq!(parsed.price_max, Some(2.0));
3170 assert_eq!(parsed.whale_min_usd, Some(10_000.0));
3171 assert_eq!(parsed.volume_spike_threshold_pct, Some(50.0));
3172 }
3173
3174 #[test]
3175 fn test_ui_with_active_alerts() {
3176 let mut terminal = create_test_terminal();
3177 let mut state = create_populated_state();
3178 state.active_alerts.push(ActiveAlert {
3179 message: "⚠ Price below min".to_string(),
3180 triggered_at: Instant::now(),
3181 });
3182 state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
3183 terminal.draw(|f| ui(f, &mut state)).unwrap();
3184 }
3185
3186 #[test]
3191 fn test_export_config_default() {
3192 let config = ExportConfig::default();
3193 assert!(config.path.is_none());
3194 }
3195
3196 fn start_export_in_temp(state: &mut MonitorState) -> PathBuf {
3198 use std::sync::atomic::{AtomicU64, Ordering};
3199 static COUNTER: AtomicU64 = AtomicU64::new(0);
3200 let id = COUNTER.fetch_add(1, Ordering::Relaxed);
3201 let dir =
3202 std::env::temp_dir().join(format!("scope_test_export_{}_{}", std::process::id(), id));
3203 let _ = fs::create_dir_all(&dir);
3204 let filename = format!("{}_test_{}.csv", state.symbol, id);
3205 let path = dir.join(filename);
3206
3207 let mut file = fs::File::create(&path).expect("failed to create export test file");
3208 let header = "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
3209 file.write_all(header.as_bytes())
3210 .expect("failed to write header");
3211 drop(file); state.export_path = Some(path.clone());
3214 state.export_active = true;
3215 path
3216 }
3217
3218 #[test]
3219 fn test_export_start_creates_file() {
3220 let token_data = create_test_token_data();
3221 let mut state = MonitorState::new(&token_data, "ethereum");
3222 let path = start_export_in_temp(&mut state);
3223
3224 assert!(state.export_active);
3225 assert!(state.export_path.is_some());
3226 assert!(path.exists(), "Export file should exist");
3227
3228 let _ = std::fs::remove_file(&path);
3230 }
3231
3232 #[test]
3233 fn test_export_stop() {
3234 let token_data = create_test_token_data();
3235 let mut state = MonitorState::new(&token_data, "ethereum");
3236 let path = start_export_in_temp(&mut state);
3237 state.stop_export();
3238
3239 assert!(!state.export_active);
3240 assert!(state.export_path.is_none());
3241
3242 let _ = std::fs::remove_file(&path);
3244 }
3245
3246 #[test]
3247 fn test_export_toggle() {
3248 let token_data = create_test_token_data();
3249 let mut state = MonitorState::new(&token_data, "ethereum");
3250 let dir = tempfile::tempdir().unwrap();
3251 state.export_dir = dir.path().to_path_buf();
3252
3253 state.toggle_export();
3254 assert!(state.export_active);
3255 let path = state.export_path.clone().unwrap();
3256
3257 state.toggle_export();
3258 assert!(!state.export_active);
3259
3260 let _ = std::fs::remove_file(path);
3262 }
3263
3264 #[test]
3265 fn test_export_toggle_uses_configured_directory() {
3266 let dir = tempfile::tempdir().unwrap();
3269 let mut state = MonitorState::new(&create_test_token_data(), "ethereum");
3270 let mut cfg = MonitorConfig::default();
3271 cfg.export.path = Some(dir.path().to_string_lossy().into_owned());
3272 state.apply_config(&cfg);
3273 state.toggle_export();
3274 let path = state.export_path.clone().unwrap();
3275 assert!(path.starts_with(dir.path()), "{}", path.display());
3276 assert!(
3277 std::fs::read_to_string(&path)
3278 .unwrap()
3279 .starts_with("timestamp,")
3280 );
3281 }
3282
3283 #[test]
3284 fn test_start_export_to_writes_header_at_the_given_file() {
3285 let dir = tempfile::tempdir().unwrap();
3287 let path = dir.path().join("nested").join("usdc.csv");
3288 let mut state = MonitorState::new(&create_test_token_data(), "ethereum");
3289 state.start_export_to(&path).unwrap();
3290 assert!(state.export_active);
3291 assert_eq!(state.export_path.as_deref(), Some(path.as_path()));
3292 assert!(
3293 std::fs::read_to_string(&path)
3294 .unwrap()
3295 .starts_with("timestamp,")
3296 );
3297 }
3298
3299 #[test]
3300 fn test_start_export_to_unwritable_path_fails_loud() {
3301 let dir = tempfile::tempdir().unwrap();
3304 let file = dir.path().join("a-file");
3305 std::fs::write(&file, "x").unwrap();
3306 let mut state = MonitorState::new(&create_test_token_data(), "ethereum");
3307 assert!(state.start_export_to(&file.join("out.csv")).is_err());
3308 assert!(!state.export_active);
3309 assert!(state.export_path.is_none());
3310 }
3311
3312 #[test]
3313 fn test_failed_export_row_stops_export_and_logs() {
3314 let dir = tempfile::tempdir().unwrap();
3316 let mut state = MonitorState::new(&create_test_token_data(), "ethereum");
3317 state.export_path = Some(dir.path().to_path_buf()); state.export_active = true;
3319 state.write_export_row();
3320 assert!(!state.export_active);
3321 assert!(
3322 state
3323 .log_messages
3324 .iter()
3325 .any(|m| m.contains("Export failed")),
3326 "{:?}",
3327 state.log_messages
3328 );
3329 }
3330
3331 #[test]
3332 fn test_cli_export_file_is_not_written_into_export_directory() {
3333 use clap::Parser;
3336 let cli =
3337 crate::cli::Cli::try_parse_from(["scope", "monitor", "USDC", "--export", "out.csv"])
3338 .unwrap();
3339 let crate::cli::Commands::Monitor(args) = cli.command else {
3340 panic!("expected monitor")
3341 };
3342 let config = Config::default();
3343 let (monitor_config, export_file, _) = monitor_config_from_args(&args, &config);
3344 assert_eq!(monitor_config.export.path, config.monitor.export.path);
3345 assert_eq!(export_file, Some(PathBuf::from("out.csv")));
3346 }
3347
3348 #[derive(Clone)]
3352 struct BlockedDex;
3353
3354 #[async_trait::async_trait]
3355 impl DexDataSource for BlockedDex {
3356 async fn get_token_price(&self, _: &str, _: &str) -> Option<f64> {
3357 None
3358 }
3359 async fn get_native_token_price(&self, _: &str) -> Option<f64> {
3360 None
3361 }
3362 async fn get_token_data(&self, _: &str, _: &str) -> Result<DexTokenData> {
3363 Err(ScopeError::Network("blocked by router".into()))
3364 }
3365 async fn search_tokens(
3366 &self,
3367 _: &str,
3368 _: Option<&str>,
3369 ) -> Result<Vec<scope::chains::TokenSearchResult>> {
3370 Err(ScopeError::Network("blocked by router".into()))
3371 }
3372 }
3373
3374 async fn mock_venue(status: usize) -> (mockito::ServerGuard, scope::market::ExchangeClient) {
3376 let mut server = mockito::Server::new_async().await;
3377 server
3378 .mock("GET", mockito::Matcher::Regex(r"^/api/v1/ticker.*".into()))
3379 .with_status(status)
3380 .with_header("content-type", "application/json")
3381 .with_body(r#"{"last":"1.0002","vol":"12345"}"#)
3382 .create_async()
3383 .await;
3384 let yaml = format!(
3385 r#"
3386id: mockvenue
3387name: Mock Venue
3388base_url: {}
3389timeout_secs: 5
3390symbol:
3391 template: "{{base}}_{{quote}}"
3392 default_quote: USDT
3393capabilities:
3394 ticker:
3395 path: /api/v1/ticker
3396 params:
3397 symbol: "{{pair}}"
3398 response:
3399 last_price: last
3400 volume_24h: vol
3401"#,
3402 server.url()
3403 );
3404 let desc: scope::market::VenueDescriptor = serde_saphyr::from_str(&yaml).unwrap();
3405 (
3406 server,
3407 scope::market::ExchangeClient::from_descriptor_with_http(
3408 &desc,
3409 scope::http::test_transport(),
3410 ),
3411 )
3412 }
3413
3414 #[derive(Clone)]
3416 struct TwoChainDex;
3417
3418 #[async_trait::async_trait]
3419 impl DexDataSource for TwoChainDex {
3420 async fn get_token_price(&self, _: &str, _: &str) -> Option<f64> {
3421 None
3422 }
3423 async fn get_native_token_price(&self, _: &str) -> Option<f64> {
3424 None
3425 }
3426 async fn get_token_data(&self, _: &str, _: &str) -> Result<DexTokenData> {
3427 Err(ScopeError::NotFound("no data in this fake".into()))
3428 }
3429 async fn search_tokens(
3430 &self,
3431 _: &str,
3432 _: Option<&str>,
3433 ) -> Result<Vec<scope::chains::TokenSearchResult>> {
3434 let usdn = |chain: &str, address: &str| scope::chains::TokenSearchResult {
3435 address: address.into(),
3436 symbol: "USDN".into(),
3437 name: "USDN".into(),
3438 chain: chain.into(),
3439 price_usd: Some(1.0),
3440 volume_24h: 0.0,
3441 liquidity_usd: 1.0,
3442 market_cap: None,
3443 };
3444 Ok(vec![usdn("ethereum", "0xA"), usdn("bsc", "0xB")])
3445 }
3446 }
3447
3448 #[tokio::test]
3449 async fn test_monitor_resolves_on_its_own_chain() {
3450 let address =
3452 resolve_token_address("USDN", "ethereum", &TwoChainDex, None, &Output::capture().0)
3453 .await
3454 .unwrap();
3455 assert_eq!(address, "0xA");
3456 }
3457
3458 #[tokio::test]
3459 async fn test_an_ambiguous_symbol_does_not_fall_back_to_the_venue() {
3460 let (_server, ex) = mock_venue(200).await;
3463 let mut dex = scope::chains::mocks::MockDexSource::new();
3464 let usdn = |address: &str| scope::chains::TokenSearchResult {
3465 address: address.into(),
3466 symbol: "USDN".into(),
3467 name: "USDN".into(),
3468 chain: "bsc".into(),
3469 price_usd: Some(1.0),
3470 volume_24h: 0.0,
3471 liquidity_usd: 1.0,
3472 market_cap: None,
3473 };
3474 dex.search_results = vec![usdn("0xA"), usdn("0xB")];
3475 let err = fetch_initial_data(
3476 "USDN",
3477 None,
3478 "bsc",
3479 &dex,
3480 Some(("mockvenue", &ex)),
3481 scope::http::test_transport(),
3482 &Output::capture().0,
3483 )
3484 .await
3485 .unwrap_err();
3486 assert!(matches!(err, ScopeError::AmbiguousToken { .. }), "{err}");
3487 }
3488
3489 #[tokio::test]
3490 async fn test_blocked_dexscreener_falls_back_to_the_venue_ticker() {
3491 let (_server, ex) = mock_venue(200).await;
3494 let (out, cap) = Output::capture();
3495 let (data, pair) = fetch_initial_data(
3496 "USDN",
3497 None,
3498 "ethereum",
3499 &BlockedDex,
3500 Some(("mockvenue", &ex)),
3501 scope::http::test_transport(),
3502 &out,
3503 )
3504 .await
3505 .unwrap();
3506 assert_eq!(pair.as_ref().map(|(p, _)| p.as_str()), Some("USDN_USDT"));
3507 assert!((data.price_usd - 1.0002).abs() < 1e-9);
3508 let told = cap.err();
3509 assert!(
3510 told.lines()
3511 .any(|n| n.contains("DexScreener") && n.contains("blocked by router")),
3512 "the user must see why: {told:?}"
3513 );
3514 assert_eq!(cap.out(), "", "notes are not data");
3515 }
3516
3517 #[tokio::test]
3518 async fn test_working_dexscreener_does_not_use_the_venue_fallback() {
3519 let (_server, ex) = mock_venue(200).await;
3520 let dex = scope::chains::mocks::MockDexSource::new();
3521 let (data, pair) = fetch_initial_data(
3522 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
3523 None,
3524 "ethereum",
3525 &dex,
3526 Some(("mockvenue", &ex)),
3527 scope::http::test_transport(),
3528 &Output::capture().0,
3529 )
3530 .await
3531 .unwrap();
3532 assert_eq!(data.symbol, "MOCK");
3533 assert!(pair.is_none());
3534 }
3535
3536 #[tokio::test]
3537 async fn test_dex_and_venue_both_failing_names_both() {
3538 let (_server, ex) = mock_venue(500).await;
3539 let err = fetch_initial_data(
3540 "USDN",
3541 None,
3542 "ethereum",
3543 &BlockedDex,
3544 Some(("mockvenue", &ex)),
3545 scope::http::test_transport(),
3546 &Output::capture().0,
3547 )
3548 .await
3549 .unwrap_err()
3550 .to_string();
3551 assert!(err.contains("blocked by router"), "{}", err);
3552 assert!(err.contains("USDN_USDT"), "{}", err);
3553 }
3554
3555 #[tokio::test]
3556 async fn test_explicit_pair_is_converted_to_the_venue_format() {
3557 let mut server = mockito::Server::new_async().await;
3560 server
3561 .mock("GET", "/api/v1/ticker")
3562 .match_query(mockito::Matcher::UrlEncoded(
3563 "symbol".into(),
3564 "USDNUSDT".into(),
3565 ))
3566 .with_status(200)
3567 .with_body(r#"{"last":"0.9999","vol":"841"}"#)
3568 .expect(3)
3569 .create_async()
3570 .await;
3571 let yaml = format!(
3572 "id: weexlike\nname: WEEX-like\nbase_url: {}\nsymbol:\n template: \"{{base}}{{quote}}\"\n default_quote: USDT\ncapabilities:\n ticker:\n path: /api/v1/ticker\n params:\n symbol: \"{{pair}}\"\n response:\n last_price: last\n volume_24h: vol\n",
3573 server.url()
3574 );
3575 let desc: scope::market::VenueDescriptor = serde_saphyr::from_str(&yaml).unwrap();
3576 let ex = scope::market::ExchangeClient::from_descriptor_with_http(
3577 &desc,
3578 scope::http::test_transport(),
3579 );
3580 for input in ["USDN_USDT", "USDN/USDT", "USDNUSDT"] {
3581 let (data, pair) = fetch_initial_data(
3582 "USDN",
3583 Some(input),
3584 "ethereum",
3585 &BlockedDex,
3586 Some(("weexlike", &ex)),
3587 scope::http::test_transport(),
3588 &Output::capture().0,
3589 )
3590 .await
3591 .unwrap_or_else(|e| panic!("{input}: {e}"));
3592 assert_eq!(
3593 pair.as_ref().map(|(p, _)| p.as_str()),
3594 Some("USDNUSDT"),
3595 "{input}"
3596 );
3597 assert!((data.price_usd - 0.9999).abs() < 1e-9);
3598 }
3599 }
3600
3601 #[tokio::test]
3602 async fn test_ticker_failure_has_one_error_prefix() {
3603 let (_server, ex) = mock_venue(500).await;
3605 let err = fetch_initial_data(
3606 "USDN",
3607 Some("USDN_USDT"),
3608 "ethereum",
3609 &BlockedDex,
3610 Some(("mockvenue", &ex)),
3611 scope::http::test_transport(),
3612 &Output::capture().0,
3613 )
3614 .await
3615 .unwrap_err()
3616 .to_string();
3617 assert_eq!(err.matches("Venue error (mockvenue)").count(), 1, "{}", err);
3618 }
3619
3620 #[test]
3621 fn test_unknown_venue_is_an_error_not_ignored() {
3622 let err = exchange_for_venue(Some("binace"), scope::http::test_transport())
3625 .err()
3626 .unwrap();
3627 assert!(matches!(err, ScopeError::InvalidInput(_)), "{err:?}");
3628 let msg = err.to_string();
3629 assert!(msg.contains("Unknown venue \"binace\""), "{msg}");
3630 assert!(msg.contains("Did you mean \"binance\""), "{msg}");
3631 assert!(
3632 exchange_for_venue(None, scope::http::test_transport())
3633 .unwrap()
3634 .is_none()
3635 );
3636 assert!(
3637 exchange_for_venue(Some("binance"), scope::http::test_transport())
3638 .unwrap()
3639 .is_some()
3640 );
3641 }
3642
3643 #[test]
3644 fn test_export_writes_csv_rows() {
3645 let token_data = create_test_token_data();
3646 let mut state = MonitorState::new(&token_data, "ethereum");
3647 let path = start_export_in_temp(&mut state);
3648
3649 state.update(&token_data);
3651 state.update(&token_data);
3652
3653 let contents = std::fs::read_to_string(&path).unwrap();
3654 let lines: Vec<&str> = contents.lines().collect();
3655
3656 assert!(
3657 lines.len() >= 3,
3658 "Should have header + 2 data rows, got {}",
3659 lines.len()
3660 );
3661 assert!(lines[0].starts_with("timestamp,price_usd"));
3662
3663 state.stop_export();
3665 let _ = std::fs::remove_file(path);
3666 }
3667
3668 #[test]
3669 fn test_keybinding_e_toggles_export() {
3670 let token_data = create_test_token_data();
3671 let mut state = MonitorState::new(&token_data, "ethereum");
3672 let dir = tempfile::tempdir().unwrap();
3673 state.export_dir = dir.path().to_path_buf();
3674
3675 handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
3676 assert!(state.export_active);
3677 let path = state.export_path.clone().unwrap();
3678
3679 handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
3680 assert!(!state.export_active);
3681
3682 let _ = std::fs::remove_file(path);
3684 }
3685
3686 #[test]
3687 fn test_render_footer_with_export_active() {
3688 let mut terminal = create_test_terminal();
3689 let mut state = create_populated_state();
3690 state.export_active = true;
3691 terminal
3692 .draw(|f| render_footer(f, f.area(), &state))
3693 .unwrap();
3694 }
3695
3696 #[test]
3697 fn test_export_config_serde_roundtrip() {
3698 let config = ExportConfig {
3699 path: Some("./my-exports".to_string()),
3700 };
3701 let yaml = serde_saphyr::to_string(&config).unwrap();
3702 let parsed: ExportConfig = serde_saphyr::from_str(&yaml).unwrap();
3703 assert_eq!(parsed.path, Some("./my-exports".to_string()));
3704 }
3705
3706 #[test]
3711 fn test_auto_pause_default_disabled() {
3712 let token_data = create_test_token_data();
3713 let state = MonitorState::new(&token_data, "ethereum");
3714 assert!(!state.auto_pause_on_input);
3715 assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
3716 }
3717
3718 #[test]
3719 fn test_auto_pause_blocks_refresh() {
3720 let token_data = create_test_token_data();
3721 let mut state = MonitorState::new(&token_data, "ethereum");
3722 state.auto_pause_on_input = true;
3723 state.refresh_rate = Duration::from_secs(1);
3724
3725 state.last_input_at = Instant::now();
3727 state.last_update = Instant::now() - Duration::from_secs(10); assert!(!state.should_refresh());
3731 }
3732
3733 #[test]
3734 fn test_auto_pause_allows_refresh_after_timeout() {
3735 let token_data = create_test_token_data();
3736 let mut state = MonitorState::new(&token_data, "ethereum");
3737 state.auto_pause_on_input = true;
3738 state.refresh_rate = Duration::from_secs(1);
3739 state.auto_pause_timeout = Duration::from_millis(1); state.last_input_at = Instant::now() - Duration::from_secs(10);
3743 state.last_update = Instant::now() - Duration::from_secs(10);
3744
3745 assert!(state.should_refresh());
3747 }
3748
3749 #[test]
3750 fn test_auto_pause_disabled_does_not_block() {
3751 let token_data = create_test_token_data();
3752 let mut state = MonitorState::new(&token_data, "ethereum");
3753 state.auto_pause_on_input = false;
3754 state.refresh_rate = Duration::from_secs(1);
3755
3756 state.last_input_at = Instant::now(); state.last_update = Instant::now() - Duration::from_secs(10);
3758
3759 assert!(state.should_refresh());
3761 }
3762
3763 #[test]
3764 fn test_is_auto_paused() {
3765 let token_data = create_test_token_data();
3766 let mut state = MonitorState::new(&token_data, "ethereum");
3767
3768 state.auto_pause_on_input = false;
3770 state.last_input_at = Instant::now();
3771 assert!(!state.is_auto_paused());
3772
3773 state.auto_pause_on_input = true;
3775 state.last_input_at = Instant::now();
3776 assert!(state.is_auto_paused());
3777
3778 state.last_input_at = Instant::now() - Duration::from_secs(10);
3780 assert!(!state.is_auto_paused());
3781 }
3782
3783 #[test]
3784 fn test_keybinding_shift_p_toggles_auto_pause() {
3785 let token_data = create_test_token_data();
3786 let mut state = MonitorState::new(&token_data, "ethereum");
3787 assert!(!state.auto_pause_on_input);
3788
3789 let shift_p = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
3790 handle_key_event_on_state(shift_p, &mut state);
3791 assert!(state.auto_pause_on_input);
3792
3793 handle_key_event_on_state(shift_p, &mut state);
3794 assert!(!state.auto_pause_on_input);
3795 }
3796
3797 #[test]
3798 fn test_keybinding_updates_last_input_at() {
3799 let token_data = create_test_token_data();
3800 let mut state = MonitorState::new(&token_data, "ethereum");
3801
3802 state.last_input_at = Instant::now() - Duration::from_secs(60);
3804 let old_input = state.last_input_at;
3805
3806 handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
3808 assert!(state.last_input_at > old_input);
3809 }
3810
3811 #[test]
3812 fn test_render_footer_auto_paused() {
3813 let mut terminal = create_test_terminal();
3814 let mut state = create_populated_state();
3815 state.auto_pause_on_input = true;
3816 state.last_input_at = Instant::now(); terminal
3818 .draw(|f| render_footer(f, f.area(), &state))
3819 .unwrap();
3820 }
3821
3822 #[test]
3823 fn test_config_auto_pause_applied() {
3824 let mut state = create_populated_state();
3825 let config = MonitorConfig {
3826 auto_pause_on_input: true,
3827 ..MonitorConfig::default()
3828 };
3829 state.apply_config(&config);
3830 assert!(state.auto_pause_on_input);
3831 }
3832
3833 #[test]
3838 fn test_ui_render_all_layouts_with_alerts_and_export() {
3839 for preset in &[
3840 LayoutPreset::Dashboard,
3841 LayoutPreset::ChartFocus,
3842 LayoutPreset::Feed,
3843 LayoutPreset::Compact,
3844 ] {
3845 let mut terminal = create_test_terminal();
3846 let mut state = create_populated_state();
3847 state.layout = *preset;
3848 state.auto_layout = false;
3849 state.export_active = true;
3850 state.active_alerts.push(ActiveAlert {
3851 message: "⚠ Test alert".to_string(),
3852 triggered_at: Instant::now(),
3853 });
3854 terminal.draw(|f| ui(f, &mut state)).unwrap();
3855 }
3856 }
3857
3858 #[test]
3859 fn test_ui_render_with_liquidity_data() {
3860 let mut terminal = create_test_terminal();
3861 let mut state = create_populated_state();
3862 state.liquidity_pairs = vec![
3863 ("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
3864 ("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
3865 ];
3866 terminal.draw(|f| ui(f, &mut state)).unwrap();
3867 }
3868
3869 #[test]
3870 fn test_monitor_config_full_serde_roundtrip() {
3871 let config = MonitorConfig {
3872 layout: LayoutPreset::Dashboard,
3873 refresh_seconds: 10,
3874 widgets: WidgetVisibility::default(),
3875 scale: ScaleMode::Log,
3876 color_scheme: ColorScheme::BlueOrange,
3877 alerts: AlertConfig {
3878 price_min: Some(0.5),
3879 price_max: Some(10.0),
3880 whale_min_usd: Some(50_000.0),
3881 volume_spike_threshold_pct: Some(100.0),
3882 },
3883 export: ExportConfig {
3884 path: Some("./exports".to_string()),
3885 },
3886 auto_pause_on_input: true,
3887 venue: Some("binance".to_string()),
3888 };
3889
3890 let yaml = serde_saphyr::to_string(&config).unwrap();
3891 let parsed: MonitorConfig = serde_saphyr::from_str(&yaml).unwrap();
3892 assert_eq!(parsed.layout, LayoutPreset::Dashboard);
3893 assert_eq!(parsed.refresh_seconds, 10);
3894 assert_eq!(parsed.venue, Some("binance".to_string()));
3895 assert_eq!(parsed.alerts.price_min, Some(0.5));
3896 assert_eq!(parsed.alerts.price_max, Some(10.0));
3897 assert_eq!(parsed.export.path, Some("./exports".to_string()));
3898 assert!(parsed.auto_pause_on_input);
3899 }
3900
3901 #[test]
3902 fn test_monitor_config_serde_defaults_for_new_fields() {
3903 let yaml = r#"
3905layout: dashboard
3906refresh_seconds: 5
3907"#;
3908 let config: MonitorConfig = serde_saphyr::from_str(yaml).unwrap();
3909 assert!(config.alerts.price_min.is_none());
3910 assert!(config.export.path.is_none());
3911 assert!(!config.auto_pause_on_input);
3912 }
3913
3914 #[test]
3915 fn test_quit_stops_export() {
3916 let token_data = create_test_token_data();
3917 let mut state = MonitorState::new(&token_data, "ethereum");
3918 let path = start_export_in_temp(&mut state);
3919
3920 let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('q')), &mut state);
3921 assert!(exit);
3922 assert!(!state.export_active);
3923
3924 let _ = std::fs::remove_file(path);
3926 }
3927
3928 #[test]
3929 fn test_monitor_state_new_has_alert_export_autopause_fields() {
3930 let token_data = create_test_token_data();
3931 let state = MonitorState::new(&token_data, "ethereum");
3932
3933 assert!(state.active_alerts.is_empty());
3935 assert!(state.alert_flash_until.is_none());
3936 assert!(state.alerts.price_min.is_none());
3937
3938 assert!(!state.export_active);
3940 assert!(state.export_path.is_none());
3941
3942 assert!(!state.auto_pause_on_input);
3944 assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
3945 }
3946
3947 #[test]
3952 fn test_scale_mode_serde_roundtrip() {
3953 for mode in &[ScaleMode::Linear, ScaleMode::Log] {
3954 let yaml = serde_saphyr::to_string(mode).unwrap();
3955 let parsed: ScaleMode = serde_saphyr::from_str(&yaml).unwrap();
3956 assert_eq!(&parsed, mode);
3957 }
3958 }
3959
3960 #[test]
3961 fn test_scale_mode_serde_kebab_case() {
3962 let parsed: ScaleMode = serde_saphyr::from_str("linear").unwrap();
3963 assert_eq!(parsed, ScaleMode::Linear);
3964 let parsed: ScaleMode = serde_saphyr::from_str("log").unwrap();
3965 assert_eq!(parsed, ScaleMode::Log);
3966 }
3967
3968 #[test]
3969 fn test_scale_mode_toggle() {
3970 assert_eq!(ScaleMode::Linear.toggle(), ScaleMode::Log);
3971 assert_eq!(ScaleMode::Log.toggle(), ScaleMode::Linear);
3972 }
3973
3974 #[test]
3975 fn test_scale_mode_label() {
3976 assert_eq!(ScaleMode::Linear.label(), "Lin");
3977 assert_eq!(ScaleMode::Log.label(), "Log");
3978 }
3979
3980 #[test]
3981 fn test_color_scheme_serde_roundtrip() {
3982 for scheme in &[
3983 ColorScheme::GreenRed,
3984 ColorScheme::BlueOrange,
3985 ColorScheme::Monochrome,
3986 ] {
3987 let yaml = serde_saphyr::to_string(scheme).unwrap();
3988 let parsed: ColorScheme = serde_saphyr::from_str(&yaml).unwrap();
3989 assert_eq!(&parsed, scheme);
3990 }
3991 }
3992
3993 #[test]
3994 fn test_color_scheme_serde_kebab_case() {
3995 let parsed: ColorScheme = serde_saphyr::from_str("green-red").unwrap();
3996 assert_eq!(parsed, ColorScheme::GreenRed);
3997 let parsed: ColorScheme = serde_saphyr::from_str("blue-orange").unwrap();
3998 assert_eq!(parsed, ColorScheme::BlueOrange);
3999 let parsed: ColorScheme = serde_saphyr::from_str("monochrome").unwrap();
4000 assert_eq!(parsed, ColorScheme::Monochrome);
4001 }
4002
4003 #[test]
4004 fn test_color_scheme_cycle() {
4005 assert_eq!(ColorScheme::GreenRed.next(), ColorScheme::BlueOrange);
4006 assert_eq!(ColorScheme::BlueOrange.next(), ColorScheme::Monochrome);
4007 assert_eq!(ColorScheme::Monochrome.next(), ColorScheme::GreenRed);
4008 }
4009
4010 #[test]
4011 fn test_color_scheme_label() {
4012 assert_eq!(ColorScheme::GreenRed.label(), "green-red");
4014 assert_eq!(ColorScheme::BlueOrange.label(), "blue-orange");
4015 assert_eq!(ColorScheme::Monochrome.label(), "mono");
4016 }
4017
4018 #[test]
4019 fn test_color_palette_fields_populated() {
4020 for scheme in &[ColorScheme::GreenRed, ColorScheme::BlueOrange] {
4022 let pal = scheme.palette();
4023 assert_ne!(
4024 format!("{:?}", pal.up),
4025 format!("{:?}", pal.down),
4026 "Up/down should differ for {:?}",
4027 scheme
4028 );
4029 }
4030 }
4031
4032 #[test]
4033 fn test_monochrome_adds_no_color_and_keeps_direction_in_glyphs() {
4034 let pal = ColorScheme::Monochrome.palette();
4036 assert_eq!(pal.up, ratatui::style::Color::Reset);
4037 assert_eq!(pal.down, ratatui::style::Color::Reset);
4038 let header = |change: f64| {
4039 let mut terminal = create_test_terminal();
4040 let mut state = create_populated_state();
4041 state.color_scheme = ColorScheme::Monochrome;
4042 state.price_change_24h = change;
4043 terminal
4044 .draw(|f| render_header(f, f.area(), &state))
4045 .unwrap();
4046 let buf = terminal.backend().buffer();
4047 for cell in &buf.content {
4048 for c in [ratatui::style::Color::Green, ratatui::style::Color::Red] {
4049 assert_ne!(cell.fg, c, "Monochrome header drew {c:?}");
4050 }
4051 }
4052 format!("{}", terminal.backend())
4053 };
4054 let (up, down) = (header(50.0), header(-50.0));
4055 assert!(up.contains('▲') && !up.contains('▼'));
4056 assert!(down.contains('▼') && !down.contains('▲'));
4057 }
4058
4059 #[test]
4060 fn test_layout_preset_serde_roundtrip() {
4061 for preset in &[
4062 LayoutPreset::Dashboard,
4063 LayoutPreset::ChartFocus,
4064 LayoutPreset::Feed,
4065 LayoutPreset::Compact,
4066 ] {
4067 let yaml = serde_saphyr::to_string(preset).unwrap();
4068 let parsed: LayoutPreset = serde_saphyr::from_str(&yaml).unwrap();
4069 assert_eq!(&parsed, preset);
4070 }
4071 }
4072
4073 #[test]
4074 fn test_layout_preset_serde_kebab_case() {
4075 let parsed: LayoutPreset = serde_saphyr::from_str("dashboard").unwrap();
4076 assert_eq!(parsed, LayoutPreset::Dashboard);
4077 let parsed: LayoutPreset = serde_saphyr::from_str("chart-focus").unwrap();
4078 assert_eq!(parsed, LayoutPreset::ChartFocus);
4079 let parsed: LayoutPreset = serde_saphyr::from_str("feed").unwrap();
4080 assert_eq!(parsed, LayoutPreset::Feed);
4081 let parsed: LayoutPreset = serde_saphyr::from_str("compact").unwrap();
4082 assert_eq!(parsed, LayoutPreset::Compact);
4083 }
4084
4085 #[test]
4086 fn test_widget_visibility_serde_roundtrip() {
4087 let vis = WidgetVisibility {
4088 price_chart: false,
4089 volume_chart: true,
4090 buy_sell_pressure: false,
4091 metrics_panel: true,
4092 activity_log: false,
4093 holder_count: false,
4094 liquidity_depth: true,
4095 };
4096 let yaml = serde_saphyr::to_string(&vis).unwrap();
4097 let parsed: WidgetVisibility = serde_saphyr::from_str(&yaml).unwrap();
4098 assert!(!parsed.price_chart);
4099 assert!(parsed.volume_chart);
4100 assert!(!parsed.buy_sell_pressure);
4101 assert!(parsed.metrics_panel);
4102 assert!(!parsed.activity_log);
4103 assert!(!parsed.holder_count);
4104 assert!(parsed.liquidity_depth);
4105 }
4106
4107 #[test]
4108 fn test_data_point_serde_roundtrip() {
4109 let dp = DataPoint {
4110 timestamp: 1700000000.5,
4111 value: 42.123456,
4112 is_real: true,
4113 };
4114 let json = serde_json::to_string(&dp).unwrap();
4115 let parsed: DataPoint = serde_json::from_str(&json).unwrap();
4116 assert!((parsed.timestamp - dp.timestamp).abs() < 0.001);
4117 assert!((parsed.value - dp.value).abs() < 0.001);
4118 assert_eq!(parsed.is_real, dp.is_real);
4119 }
4120
4121 #[test]
4126 fn test_handle_key_scale_toggle_s() {
4127 let token_data = create_test_token_data();
4128 let mut state = MonitorState::new(&token_data, "ethereum");
4129 assert_eq!(state.scale_mode, ScaleMode::Linear);
4130
4131 handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
4132 assert_eq!(state.scale_mode, ScaleMode::Log);
4133
4134 handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
4135 assert_eq!(state.scale_mode, ScaleMode::Linear);
4136 }
4137
4138 #[test]
4139 fn test_handle_key_color_scheme_cycle_slash() {
4140 let token_data = create_test_token_data();
4141 let mut state = MonitorState::new(&token_data, "ethereum");
4142 assert_eq!(state.color_scheme, ColorScheme::GreenRed);
4143
4144 handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
4145 assert_eq!(state.color_scheme, ColorScheme::BlueOrange);
4146
4147 handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
4148 assert_eq!(state.color_scheme, ColorScheme::Monochrome);
4149
4150 handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
4151 assert_eq!(state.color_scheme, ColorScheme::GreenRed);
4152 }
4153
4154 #[test]
4159 fn test_render_volume_profile_chart_no_panic() {
4160 let mut terminal = create_test_terminal();
4161 let state = create_populated_state();
4162 terminal
4163 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
4164 .unwrap();
4165 }
4166
4167 #[test]
4168 fn test_render_volume_profile_chart_empty_data() {
4169 let mut terminal = create_test_terminal();
4170 let token_data = create_test_token_data();
4171 let mut state = MonitorState::new(&token_data, "ethereum");
4172 state.price_history.clear();
4173 state.volume_history.clear();
4174 terminal
4175 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
4176 .unwrap();
4177 }
4178
4179 #[test]
4180 fn test_render_volume_profile_chart_single_price() {
4181 let mut terminal = create_test_terminal();
4183 let mut token_data = create_test_token_data();
4184 token_data.price_usd = 1.0;
4185 let mut state = MonitorState::new(&token_data, "ethereum");
4186 state.price_history.clear();
4188 state.volume_history.clear();
4189 let now = chrono::Utc::now().timestamp() as f64;
4190 for i in 0..5 {
4191 state.price_history.push_back(DataPoint {
4192 timestamp: now - (5.0 - i as f64) * 60.0,
4193 value: 1.0, is_real: true,
4195 });
4196 state.volume_history.push_back(DataPoint {
4197 timestamp: now - (5.0 - i as f64) * 60.0,
4198 value: 1000.0,
4199 is_real: true,
4200 });
4201 }
4202 terminal
4203 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
4204 .unwrap();
4205 }
4206
4207 #[test]
4208 fn test_render_volume_profile_chart_narrow_terminal() {
4209 let backend = TestBackend::new(30, 15);
4210 let mut terminal = Terminal::new(backend).unwrap();
4211 let state = create_populated_state();
4212 terminal
4213 .draw(|f| render_volume_profile_chart(f, f.area(), &state))
4214 .unwrap();
4215 }
4216
4217 #[test]
4222 fn test_render_price_chart_log_scale() {
4223 let mut terminal = create_test_terminal();
4224 let mut state = create_populated_state();
4225 state.scale_mode = ScaleMode::Log;
4226 terminal
4227 .draw(|f| render_price_chart(f, f.area(), &state))
4228 .unwrap();
4229 }
4230
4231 #[test]
4232 fn test_render_candlestick_chart_log_scale() {
4233 let mut terminal = create_test_terminal();
4234 let mut state = create_populated_state();
4235 state.scale_mode = ScaleMode::Log;
4236 state.chart_mode = ChartMode::Candlestick;
4237 terminal
4238 .draw(|f| render_candlestick_chart(f, f.area(), &state))
4239 .unwrap();
4240 }
4241
4242 #[test]
4243 fn test_render_price_chart_log_scale_zero_price() {
4244 let mut terminal = create_test_terminal();
4246 let mut token_data = create_test_token_data();
4247 token_data.price_usd = 0.0001;
4248 let mut state = MonitorState::new(&token_data, "ethereum");
4249 state.scale_mode = ScaleMode::Log;
4250 for i in 0..10 {
4251 let mut data = token_data.clone();
4252 data.price_usd = 0.0001 + (i as f64 * 0.00001);
4253 state.update(&data);
4254 }
4255 terminal
4256 .draw(|f| render_price_chart(f, f.area(), &state))
4257 .unwrap();
4258 }
4259
4260 #[test]
4265 fn test_render_ui_with_all_color_schemes() {
4266 for scheme in &[
4267 ColorScheme::GreenRed,
4268 ColorScheme::BlueOrange,
4269 ColorScheme::Monochrome,
4270 ] {
4271 let mut terminal = create_test_terminal();
4272 let mut state = create_populated_state();
4273 state.color_scheme = *scheme;
4274 terminal.draw(|f| ui(f, &mut state)).unwrap();
4275 }
4276 }
4277
4278 #[test]
4279 fn test_render_volume_chart_all_color_schemes() {
4280 for scheme in &[
4281 ColorScheme::GreenRed,
4282 ColorScheme::BlueOrange,
4283 ColorScheme::Monochrome,
4284 ] {
4285 let mut terminal = create_test_terminal();
4286 let mut state = create_populated_state();
4287 state.color_scheme = *scheme;
4288 terminal
4289 .draw(|f| render_volume_chart(f, f.area(), &state))
4290 .unwrap();
4291 }
4292 }
4293
4294 #[test]
4299 fn test_render_activity_feed_no_panic() {
4300 let mut terminal = create_test_terminal();
4301 let mut state = create_populated_state();
4302 for i in 0..5 {
4303 state.log_messages.push_back(format!("Event {}", i));
4304 }
4305 terminal
4306 .draw(|f| render_activity_feed(f, f.area(), &mut state))
4307 .unwrap();
4308 }
4309
4310 #[test]
4311 fn test_render_activity_feed_empty_log() {
4312 let mut terminal = create_test_terminal();
4313 let token_data = create_test_token_data();
4314 let mut state = MonitorState::new(&token_data, "ethereum");
4315 state.log_messages.clear();
4316 terminal
4317 .draw(|f| render_activity_feed(f, f.area(), &mut state))
4318 .unwrap();
4319 }
4320
4321 #[test]
4322 fn test_render_activity_feed_with_selection() {
4323 let mut terminal = create_test_terminal();
4324 let mut state = create_populated_state();
4325 for i in 0..10 {
4326 state.log_messages.push_back(format!("Event {}", i));
4327 }
4328 state.scroll_log_down();
4329 state.scroll_log_down();
4330 state.scroll_log_down();
4331 terminal
4332 .draw(|f| render_activity_feed(f, f.area(), &mut state))
4333 .unwrap();
4334 }
4335
4336 #[test]
4341 fn test_alert_whale_zero_transactions() {
4342 let mut token_data = create_test_token_data();
4343 token_data.total_buys_24h = 0;
4344 token_data.total_sells_24h = 0;
4345 let mut state = MonitorState::new(&token_data, "ethereum");
4346 state.alerts.whale_min_usd = Some(100.0);
4347 state.update(&token_data);
4348 let whale_alerts: Vec<_> = state
4350 .active_alerts
4351 .iter()
4352 .filter(|a| a.message.contains("whale") || a.message.contains("🐋"))
4353 .collect();
4354 assert!(
4355 whale_alerts.is_empty(),
4356 "Whale alert should not fire with zero transactions"
4357 );
4358 }
4359
4360 #[test]
4361 fn test_alert_multiple_simultaneous() {
4362 let mut token_data = create_test_token_data();
4363 token_data.price_usd = 0.1; let mut state = MonitorState::new(&token_data, "ethereum");
4365 state.alerts.price_min = Some(0.5); state.alerts.price_max = Some(0.05); state.alerts.volume_spike_threshold_pct = Some(1.0);
4368 state.volume_avg = 100.0; state.update(&token_data);
4371 assert!(
4373 state.active_alerts.len() >= 2,
4374 "Expected multiple alerts, got {}",
4375 state.active_alerts.len()
4376 );
4377 }
4378
4379 #[test]
4380 fn test_alert_clears_on_next_update() {
4381 let token_data = create_test_token_data();
4382 let mut state = MonitorState::new(&token_data, "ethereum");
4383 state.alerts.price_min = Some(2.0); state.update(&token_data);
4385 assert!(!state.active_alerts.is_empty());
4386
4387 let mut above_min = token_data.clone();
4389 above_min.price_usd = 3.0;
4390 state.alerts.price_min = Some(2.0);
4391 state.update(&above_min);
4392 let price_min_alerts: Vec<_> = state
4394 .active_alerts
4395 .iter()
4396 .filter(|a| a.message.contains("below min"))
4397 .collect();
4398 assert!(
4399 price_min_alerts.is_empty(),
4400 "Price-min alert should clear when price goes above min"
4401 );
4402 }
4403
4404 #[test]
4405 fn test_render_alert_overlay_multiple_alerts() {
4406 let mut terminal = create_test_terminal();
4407 let mut state = create_populated_state();
4408 state.active_alerts.push(ActiveAlert {
4409 message: "⚠ Price below min".to_string(),
4410 triggered_at: Instant::now(),
4411 });
4412 state.active_alerts.push(ActiveAlert {
4413 message: "🐋 Whale detected".to_string(),
4414 triggered_at: Instant::now(),
4415 });
4416 state.active_alerts.push(ActiveAlert {
4417 message: "⚠ Volume spike".to_string(),
4418 triggered_at: Instant::now(),
4419 });
4420 state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
4421 terminal
4422 .draw(|f| render_alert_overlay(f, f.area(), &state))
4423 .unwrap();
4424 }
4425
4426 #[test]
4427 fn test_render_alert_overlay_flash_expired() {
4428 let mut terminal = create_test_terminal();
4429 let mut state = create_populated_state();
4430 state.active_alerts.push(ActiveAlert {
4431 message: "⚠ Test".to_string(),
4432 triggered_at: Instant::now(),
4433 });
4434 state.alert_flash_until = Some(Instant::now() - Duration::from_secs(5));
4436 terminal
4437 .draw(|f| render_alert_overlay(f, f.area(), &state))
4438 .unwrap();
4439 }
4440
4441 #[test]
4446 fn test_render_liquidity_depth_many_pairs() {
4447 let mut terminal = create_test_terminal();
4448 let mut state = create_populated_state();
4449 for i in 0..20 {
4451 state.liquidity_pairs.push((
4452 format!("TEST/TOKEN{} (DEX{})", i, i),
4453 (100_000.0 + i as f64 * 50_000.0),
4454 ));
4455 }
4456 terminal
4457 .draw(|f| render_liquidity_depth(f, f.area(), &state))
4458 .unwrap();
4459 }
4460
4461 #[test]
4462 fn test_render_liquidity_depth_narrow_terminal() {
4463 let backend = TestBackend::new(30, 10);
4464 let mut terminal = Terminal::new(backend).unwrap();
4465 let mut state = create_populated_state();
4466 state.liquidity_pairs = vec![
4467 ("TEST/WETH (Uniswap)".to_string(), 500_000.0),
4468 ("TEST/USDC (Sushi)".to_string(), 100_000.0),
4469 ];
4470 terminal
4471 .draw(|f| render_liquidity_depth(f, f.area(), &state))
4472 .unwrap();
4473 }
4474
4475 #[test]
4480 fn test_render_metrics_panel_holder_count_disabled() {
4481 let mut terminal = create_test_terminal();
4482 let mut state = create_populated_state();
4483 state.holder_count = Some(42_000);
4484 state.widgets.holder_count = false; terminal
4486 .draw(|f| render_metrics_panel(f, f.area(), &state))
4487 .unwrap();
4488 }
4489
4490 #[test]
4491 fn test_render_metrics_panel_sparkline_single_point() {
4492 let mut terminal = create_test_terminal();
4493 let mut token_data = create_test_token_data();
4494 token_data.price_usd = 1.0;
4495 let mut state = MonitorState::new(&token_data, "ethereum");
4496 state.price_history.clear();
4497 state.price_history.push_back(DataPoint {
4498 timestamp: 1.0,
4499 value: 1.0,
4500 is_real: true,
4501 });
4502 terminal
4503 .draw(|f| render_metrics_panel(f, f.area(), &state))
4504 .unwrap();
4505 }
4506
4507 #[test]
4512 fn test_render_buy_sell_gauge_tiny_area() {
4513 let backend = TestBackend::new(5, 3);
4515 let mut terminal = Terminal::new(backend).unwrap();
4516 let mut state = create_populated_state();
4517 terminal
4518 .draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
4519 .unwrap();
4520 }
4521
4522 #[test]
4527 fn test_log_message_queue_overflow() {
4528 let token_data = create_test_token_data();
4529 let mut state = MonitorState::new(&token_data, "ethereum");
4530 for i in 0..20 {
4532 state.toggle_pause(); let _ = i;
4534 }
4535 assert!(
4536 state.log_messages.len() <= 10,
4537 "Log queue should cap at 10, got {}",
4538 state.log_messages.len()
4539 );
4540 }
4541
4542 #[test]
4547 fn test_export_writes_csv_row_content_format() {
4548 let token_data = create_test_token_data();
4549 let mut state = MonitorState::new(&token_data, "ethereum");
4550 let path = start_export_in_temp(&mut state);
4551
4552 state.update(&token_data);
4553
4554 let contents = std::fs::read_to_string(&path).unwrap();
4555 let lines: Vec<&str> = contents.lines().collect();
4556 assert!(lines.len() >= 2);
4557
4558 assert_eq!(
4560 lines[0],
4561 "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap"
4562 );
4563
4564 let data_cols: Vec<&str> = lines[1].split(',').collect();
4566 assert_eq!(
4567 data_cols.len(),
4568 7,
4569 "Expected 7 CSV columns, got {}",
4570 data_cols.len()
4571 );
4572
4573 assert!(data_cols[0].contains('T'));
4575 assert!(data_cols[0].ends_with('Z'));
4576
4577 state.stop_export();
4579 let _ = std::fs::remove_file(path);
4580 }
4581
4582 #[test]
4583 fn test_export_writes_csv_row_market_cap_none() {
4584 let mut token_data = create_test_token_data();
4585 token_data.market_cap = None;
4586 let mut state = MonitorState::new(&token_data, "ethereum");
4587 let path = start_export_in_temp(&mut state);
4588
4589 state.update(&token_data);
4590
4591 let contents = std::fs::read_to_string(&path).unwrap();
4592 let lines: Vec<&str> = contents.lines().collect();
4593 assert!(lines.len() >= 2);
4594
4595 let data_cols: Vec<&str> = lines[1].split(',').collect();
4597 assert_eq!(data_cols.len(), 7);
4598 assert!(
4599 data_cols[6].is_empty(),
4600 "Market cap column should be empty when None"
4601 );
4602
4603 state.stop_export();
4605 let _ = std::fs::remove_file(path);
4606 }
4607
4608 #[test]
4613 fn test_ui_render_log_scale_all_chart_modes() {
4614 for mode in &[
4615 ChartMode::Line,
4616 ChartMode::Candlestick,
4617 ChartMode::VolumeProfile,
4618 ] {
4619 let mut terminal = create_test_terminal();
4620 let mut state = create_populated_state();
4621 state.scale_mode = ScaleMode::Log;
4622 state.chart_mode = *mode;
4623 terminal.draw(|f| ui(f, &mut state)).unwrap();
4624 }
4625 }
4626
4627 #[test]
4632 fn test_render_footer_widget_toggle_mode_active() {
4633 let mut terminal = create_test_terminal();
4634 let mut state = create_populated_state();
4635 state.widget_toggle_mode = true;
4636 terminal
4637 .draw(|f| render_footer(f, f.area(), &state))
4638 .unwrap();
4639 }
4640
4641 #[test]
4642 fn test_render_footer_all_status_indicators() {
4643 let mut terminal = create_test_terminal();
4645 let mut state = create_populated_state();
4646 state.export_active = true;
4647 state.auto_pause_on_input = true;
4648 state.last_input_at = Instant::now(); terminal
4650 .draw(|f| render_footer(f, f.area(), &state))
4651 .unwrap();
4652 }
4653
4654 #[test]
4659 fn test_generate_synthetic_price_history_zero_price() {
4660 let mut token_data = create_test_token_data();
4661 token_data.price_usd = 0.0;
4662 token_data.price_change_1h = 0.0;
4663 token_data.price_change_6h = 0.0;
4664 token_data.price_change_24h = 0.0;
4665 let state = MonitorState::new(&token_data, "ethereum");
4666 assert!(!state.price_history.is_empty());
4668 }
4669
4670 #[test]
4671 fn test_generate_synthetic_volume_history_zero_volume() {
4672 let mut token_data = create_test_token_data();
4673 token_data.volume_24h = 0.0;
4674 token_data.volume_6h = 0.0;
4675 token_data.volume_1h = 0.0;
4676 let state = MonitorState::new(&token_data, "ethereum");
4677 assert!(!state.volume_history.is_empty());
4678 }
4679
4680 #[test]
4681 fn test_auto_pause_custom_timeout() {
4682 let token_data = create_test_token_data();
4683 let mut state = MonitorState::new(&token_data, "ethereum");
4684 state.auto_pause_on_input = true;
4685 state.auto_pause_timeout = Duration::from_secs(10);
4686 state.refresh_rate = Duration::from_secs(1);
4687
4688 state.last_input_at = Instant::now();
4690 state.last_update = Instant::now() - Duration::from_secs(5);
4691 assert!(!state.should_refresh()); assert!(state.is_auto_paused());
4693 }
4694
4695 #[test]
4700 fn test_render_price_chart_stablecoin_flat_range() {
4701 let mut terminal = create_test_terminal();
4702 let mut token_data = create_test_token_data();
4703 token_data.price_usd = 1.0;
4704 let mut state = MonitorState::new(&token_data, "ethereum");
4705 for i in 0..20 {
4707 let mut data = token_data.clone();
4708 data.price_usd = 1.0 + (i as f64 * 0.000001); state.update(&data);
4710 }
4711 terminal
4712 .draw(|f| render_price_chart(f, f.area(), &state))
4713 .unwrap();
4714 }
4715
4716 #[test]
4721 fn test_load_cache_corrupted_json() {
4722 let path = MonitorState::cache_path("0xCORRUPTED_TEST", "test_chain");
4723 let _ = std::fs::write(&path, "not valid json {{{");
4725 let cached = MonitorState::load_cache("0xCORRUPTED_TEST", "test_chain");
4726 assert!(cached.is_none(), "Corrupted JSON should return None");
4727 let _ = std::fs::remove_file(path);
4728 }
4729
4730 #[test]
4731 fn test_load_cache_wrong_token() {
4732 let token_data = create_test_token_data();
4733 let state = MonitorState::new(&token_data, "ethereum");
4734 state.save_cache();
4735
4736 let cached = MonitorState::load_cache("0xDIFFERENT_ADDRESS", "ethereum");
4738 assert!(
4739 cached.is_none(),
4740 "Loading cache with wrong token address should return None"
4741 );
4742
4743 let path = MonitorState::cache_path(&token_data.address, "ethereum");
4745 let _ = std::fs::remove_file(path);
4746 }
4747
4748 use scope::chains::dex::TokenSearchResult;
4753
4754 struct MockDexDataSource {
4756 token_data_result: std::sync::Mutex<Result<DexTokenData>>,
4758 calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
4760 }
4761
4762 impl MockDexDataSource {
4763 fn new(data: DexTokenData) -> Self {
4764 Self {
4765 token_data_result: std::sync::Mutex::new(Ok(data)),
4766 calls: Default::default(),
4767 }
4768 }
4769
4770 fn failing(msg: &str) -> Self {
4771 Self {
4772 token_data_result: std::sync::Mutex::new(Err(ScopeError::Api(msg.to_string()))),
4773 calls: Default::default(),
4774 }
4775 }
4776 }
4777
4778 #[async_trait::async_trait]
4779 impl DexDataSource for MockDexDataSource {
4780 async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
4781 self.token_data_result
4782 .lock()
4783 .unwrap()
4784 .as_ref()
4785 .ok()
4786 .map(|d| d.price_usd)
4787 }
4788
4789 async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
4790 Some(2000.0)
4791 }
4792
4793 async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
4794 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4795 let guard = self.token_data_result.lock().unwrap();
4796 match &*guard {
4797 Ok(data) => Ok(data.clone()),
4798 Err(e) => Err(ScopeError::Api(e.to_string())),
4799 }
4800 }
4801
4802 async fn search_tokens(
4803 &self,
4804 _query: &str,
4805 _chain: Option<&str>,
4806 ) -> Result<Vec<TokenSearchResult>> {
4807 Ok(vec![])
4808 }
4809 }
4810
4811 struct MockChainClient {
4813 holder_count: u64,
4814 }
4815
4816 impl MockChainClient {
4817 fn new(holder_count: u64) -> Self {
4818 Self { holder_count }
4819 }
4820 }
4821
4822 #[async_trait::async_trait]
4823 impl ChainClient for MockChainClient {
4824 fn chain_name(&self) -> &str {
4825 "ethereum"
4826 }
4827 fn native_token_symbol(&self) -> &str {
4828 "ETH"
4829 }
4830 async fn get_balance(&self, _address: &str) -> Result<scope::chains::Balance> {
4831 unimplemented!("not needed for monitor tests")
4832 }
4833 async fn enrich_balance_usd(&self, _balance: &mut scope::chains::Balance) {}
4834 async fn get_transaction(&self, _hash: &str) -> Result<scope::chains::Transaction> {
4835 unimplemented!("not needed for monitor tests")
4836 }
4837 async fn get_transactions(
4838 &self,
4839 _address: &str,
4840 _limit: u32,
4841 ) -> Result<Vec<scope::chains::Transaction>> {
4842 Ok(vec![])
4843 }
4844 async fn get_block_number(&self) -> Result<u64> {
4845 Ok(1000000)
4846 }
4847 async fn get_token_balances(
4848 &self,
4849 _address: &str,
4850 ) -> Result<Vec<scope::chains::TokenBalance>> {
4851 Ok(vec![])
4852 }
4853 async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
4854 Ok(self.holder_count)
4855 }
4856 }
4857
4858 fn create_test_app(
4860 dex: Box<dyn DexDataSource>,
4861 chain_client: Option<Box<dyn ChainClient>>,
4862 ) -> MonitorApp<TestBackend> {
4863 let token_data = create_test_token_data();
4864 let state = MonitorState::new(&token_data, "ethereum");
4865 let backend = TestBackend::new(120, 40);
4866 let terminal = ratatui::Terminal::new(backend).unwrap();
4867 MonitorApp {
4868 terminal,
4869 state,
4870 dex_client: dex,
4871 chain_client,
4872 exchange_client: None,
4873 should_exit: false,
4874 owns_terminal: false,
4875 }
4876 }
4877
4878 fn create_test_app_with_state(
4879 state: MonitorState,
4880 dex: Box<dyn DexDataSource>,
4881 chain_client: Option<Box<dyn ChainClient>>,
4882 ) -> MonitorApp<TestBackend> {
4883 let backend = TestBackend::new(120, 40);
4884 let terminal = ratatui::Terminal::new(backend).unwrap();
4885 MonitorApp {
4886 terminal,
4887 state,
4888 dex_client: dex,
4889 chain_client,
4890 exchange_client: None,
4891 should_exit: false,
4892 owns_terminal: false,
4893 }
4894 }
4895
4896 #[test]
4901 fn test_app_handle_key_quit_q() {
4902 let data = create_test_token_data();
4903 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4904 assert!(!app.should_exit);
4905 app.handle_key_event(make_key_event(KeyCode::Char('q')));
4906 assert!(app.should_exit);
4907 }
4908
4909 #[test]
4910 fn test_app_handle_key_quit_esc() {
4911 let data = create_test_token_data();
4912 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4913 app.handle_key_event(make_key_event(KeyCode::Esc));
4914 assert!(app.should_exit);
4915 }
4916
4917 #[test]
4918 fn test_app_handle_key_quit_ctrl_c() {
4919 let data = create_test_token_data();
4920 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4921 let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
4922 app.handle_key_event(key);
4923 assert!(app.should_exit);
4924 }
4925
4926 #[test]
4927 fn test_app_handle_key_quit_stops_active_export() {
4928 let data = create_test_token_data();
4929 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4930 let path = start_export_in_temp(&mut app.state);
4931 assert!(app.state.export_active);
4932
4933 app.handle_key_event(make_key_event(KeyCode::Char('q')));
4934 assert!(app.should_exit);
4935 assert!(!app.state.export_active);
4936 let _ = std::fs::remove_file(path);
4937 }
4938
4939 #[test]
4940 fn test_app_handle_key_ctrl_c_stops_active_export() {
4941 let data = create_test_token_data();
4942 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4943 let path = start_export_in_temp(&mut app.state);
4944 assert!(app.state.export_active);
4945
4946 let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
4947 app.handle_key_event(key);
4948 assert!(app.should_exit);
4949 assert!(!app.state.export_active);
4950 let _ = std::fs::remove_file(path);
4951 }
4952
4953 #[test]
4954 fn test_app_handle_key_updates_last_input_time() {
4955 let data = create_test_token_data();
4956 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4957 let before = Instant::now();
4958 app.handle_key_event(make_key_event(KeyCode::Char('p')));
4959 assert!(app.state.last_input_at >= before);
4960 }
4961
4962 #[test]
4963 fn test_app_handle_key_widget_toggle_mode() {
4964 let data = create_test_token_data();
4965 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4966 assert!(app.state.widgets.price_chart);
4967
4968 app.handle_key_event(make_key_event(KeyCode::Char('w')));
4970 assert!(app.state.widget_toggle_mode);
4971
4972 app.handle_key_event(make_key_event(KeyCode::Char('1')));
4974 assert!(!app.state.widget_toggle_mode);
4975 assert!(!app.state.widgets.price_chart);
4976 }
4977
4978 #[test]
4979 fn test_app_handle_key_widget_toggle_mode_cancel() {
4980 let data = create_test_token_data();
4981 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4982
4983 app.handle_key_event(make_key_event(KeyCode::Char('w')));
4985 assert!(app.state.widget_toggle_mode);
4986
4987 app.handle_key_event(make_key_event(KeyCode::Char('x')));
4989 assert!(!app.state.widget_toggle_mode);
4990 }
4991
4992 #[test]
4993 fn test_app_handle_key_all_keybindings() {
4994 let data = create_test_token_data();
4995 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
4996
4997 app.handle_key_event(make_key_event(KeyCode::Char('r')));
4999 assert!(!app.should_exit);
5000
5001 let key = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
5003 app.handle_key_event(key);
5004 assert!(app.state.auto_pause_on_input);
5005 app.handle_key_event(key);
5006 assert!(!app.state.auto_pause_on_input);
5007
5008 app.handle_key_event(make_key_event(KeyCode::Char('p')));
5010 assert!(app.state.paused);
5011
5012 app.handle_key_event(make_key_event(KeyCode::Char(' ')));
5014 assert!(!app.state.paused);
5015
5016 let dir = tempfile::tempdir().unwrap();
5018 app.state.export_dir = dir.path().to_path_buf();
5019 app.handle_key_event(make_key_event(KeyCode::Char('e')));
5020 assert!(app.state.export_active);
5021 app.state.stop_export();
5023
5024 let before_rate = app.state.refresh_rate;
5026 app.handle_key_event(make_key_event(KeyCode::Char('+')));
5027 assert!(app.state.refresh_rate >= before_rate);
5028
5029 let before_rate = app.state.refresh_rate;
5031 app.handle_key_event(make_key_event(KeyCode::Char('-')));
5032 assert!(app.state.refresh_rate <= before_rate);
5033
5034 app.handle_key_event(make_key_event(KeyCode::Char('1')));
5036 assert_eq!(app.state.time_period, TimePeriod::Min1);
5037 app.handle_key_event(make_key_event(KeyCode::Char('2')));
5038 assert_eq!(app.state.time_period, TimePeriod::Min5);
5039 app.handle_key_event(make_key_event(KeyCode::Char('3')));
5040 assert_eq!(app.state.time_period, TimePeriod::Min15);
5041 app.handle_key_event(make_key_event(KeyCode::Char('4')));
5042 assert_eq!(app.state.time_period, TimePeriod::Hour1);
5043 app.handle_key_event(make_key_event(KeyCode::Char('5')));
5044 assert_eq!(app.state.time_period, TimePeriod::Hour4);
5045 app.handle_key_event(make_key_event(KeyCode::Char('6')));
5046 assert_eq!(app.state.time_period, TimePeriod::Day1);
5047
5048 app.handle_key_event(make_key_event(KeyCode::Char('t')));
5050 assert_eq!(app.state.time_period, TimePeriod::Min1); app.handle_key_event(make_key_event(KeyCode::Char('c')));
5054 assert_eq!(app.state.chart_mode, ChartMode::Candlestick);
5055
5056 app.handle_key_event(make_key_event(KeyCode::Char('s')));
5058 assert_eq!(app.state.scale_mode, ScaleMode::Log);
5059
5060 app.handle_key_event(make_key_event(KeyCode::Char('/')));
5062 assert_eq!(app.state.color_scheme, ColorScheme::BlueOrange);
5063
5064 app.handle_key_event(make_key_event(KeyCode::Char('j')));
5066
5067 app.handle_key_event(make_key_event(KeyCode::Char('k')));
5069
5070 app.handle_key_event(make_key_event(KeyCode::Char('l')));
5072 assert!(!app.state.auto_layout);
5073
5074 app.handle_key_event(make_key_event(KeyCode::Char('h')));
5076
5077 app.handle_key_event(make_key_event(KeyCode::Char('a')));
5079 assert!(app.state.auto_layout);
5080
5081 app.handle_key_event(make_key_event(KeyCode::Char('w')));
5083 assert!(app.state.widget_toggle_mode);
5084 app.handle_key_event(make_key_event(KeyCode::Char('z')));
5086
5087 app.handle_key_event(make_key_event(KeyCode::F(12)));
5089 assert!(!app.should_exit);
5090 }
5091
5092 #[test]
5093 fn test_standalone_key_handler_matches_the_app_handler_for_every_key() {
5094 let shift_p = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
5099 let keys = [
5100 make_key_event(KeyCode::Char('r')),
5101 shift_p,
5102 make_key_event(KeyCode::Char('p')),
5103 make_key_event(KeyCode::Char(' ')),
5104 make_key_event(KeyCode::Char('=')),
5105 make_key_event(KeyCode::Char(']')),
5106 make_key_event(KeyCode::Char('_')),
5107 make_key_event(KeyCode::Char('[')),
5108 make_key_event(KeyCode::Char('3')),
5109 make_key_event(KeyCode::Tab),
5110 make_key_event(KeyCode::Char('c')),
5111 make_key_event(KeyCode::Char('s')),
5112 make_key_event(KeyCode::Char('/')),
5113 make_key_event(KeyCode::Char('j')),
5114 make_key_event(KeyCode::Down),
5115 make_key_event(KeyCode::Char('k')),
5116 make_key_event(KeyCode::Up),
5117 make_key_event(KeyCode::Char('l')),
5118 make_key_event(KeyCode::Char('l')),
5119 make_key_event(KeyCode::Char('h')),
5120 make_key_event(KeyCode::Char('a')),
5121 make_key_event(KeyCode::Char('w')),
5122 make_key_event(KeyCode::Char('2')),
5123 make_key_event(KeyCode::Char('w')),
5124 make_key_event(KeyCode::Char('x')),
5125 make_key_event(KeyCode::F(12)),
5126 ];
5127 let data = create_test_token_data();
5128 let mut app = create_test_app(Box::new(MockDexDataSource::new(data.clone())), None);
5129 let mut twin = MonitorState::new(&data, "ethereum");
5130 for key in keys {
5131 app.handle_key_event(key);
5132 assert!(!handle_key_event_on_state(key, &mut twin), "{key:?}");
5133 let (a, b) = (&app.state, &twin);
5134 assert_eq!(a.layout, b.layout, "{key:?}");
5135 assert_eq!(a.auto_layout, b.auto_layout, "{key:?}");
5136 assert_eq!(a.paused, b.paused, "{key:?}");
5137 assert_eq!(a.auto_pause_on_input, b.auto_pause_on_input, "{key:?}");
5138 assert_eq!(a.refresh_rate, b.refresh_rate, "{key:?}");
5139 assert_eq!(a.time_period, b.time_period, "{key:?}");
5140 assert_eq!(a.chart_mode, b.chart_mode, "{key:?}");
5141 assert_eq!(a.scale_mode, b.scale_mode, "{key:?}");
5142 assert_eq!(a.color_scheme, b.color_scheme, "{key:?}");
5143 assert_eq!(a.widget_toggle_mode, b.widget_toggle_mode, "{key:?}");
5144 assert_eq!(a.widgets.price_chart, b.widgets.price_chart, "{key:?}");
5145 assert_eq!(
5146 a.log_list_state.selected(),
5147 b.log_list_state.selected(),
5148 "{key:?}"
5149 );
5150 }
5151 let q = make_key_event(KeyCode::Char('q'));
5153 app.handle_key_event(q);
5154 assert!(app.should_exit && handle_key_event_on_state(q, &mut twin));
5155 }
5156
5157 #[tokio::test]
5162 async fn test_app_fetch_data_success() {
5163 let data = create_test_token_data();
5164 let initial_price = data.price_usd;
5165 let mut updated = data.clone();
5166 updated.price_usd = 2.5;
5167 let mut app = create_test_app(Box::new(MockDexDataSource::new(updated)), None);
5168
5169 assert!((app.state.current_price - initial_price).abs() < 0.001);
5170 app.fetch_data().await;
5171 assert!((app.state.current_price - 2.5).abs() < 0.001);
5172 assert!(app.state.error_message.is_none());
5173 }
5174
5175 #[tokio::test]
5176 async fn test_app_fetch_data_api_error() {
5177 let mut app = create_test_app(Box::new(MockDexDataSource::failing("rate limited")), None);
5178
5179 app.fetch_data().await;
5180 assert!(app.state.error_message.is_some());
5181 assert!(
5182 app.state
5183 .error_message
5184 .as_ref()
5185 .unwrap()
5186 .contains("API Error")
5187 );
5188 }
5189
5190 #[tokio::test]
5191 async fn test_app_fetch_data_holder_count_on_12th_tick() {
5192 let data = create_test_token_data();
5193 let mock_chain = MockChainClient::new(42_000);
5194 let mut app = create_test_app(
5195 Box::new(MockDexDataSource::new(data)),
5196 Some(Box::new(mock_chain)),
5197 );
5198
5199 for _ in 0..11 {
5201 app.fetch_data().await;
5202 }
5203 assert!(app.state.holder_count.is_none());
5204
5205 app.fetch_data().await;
5207 assert_eq!(app.state.holder_count, Some(42_000));
5208 }
5209
5210 #[tokio::test]
5211 async fn test_app_fetch_data_holder_count_zero_not_stored() {
5212 let data = create_test_token_data();
5213 let mock_chain = MockChainClient::new(0); let mut app = create_test_app(
5215 Box::new(MockDexDataSource::new(data)),
5216 Some(Box::new(mock_chain)),
5217 );
5218
5219 app.state.holder_fetch_counter = 11;
5221 app.fetch_data().await;
5222 assert!(app.state.holder_count.is_none());
5224 }
5225
5226 #[tokio::test]
5227 async fn test_app_fetch_data_no_chain_client_skips_holders() {
5228 let data = create_test_token_data();
5229 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
5230
5231 app.state.holder_fetch_counter = 11;
5233 app.fetch_data().await;
5234 assert!(app.state.holder_count.is_none());
5236 }
5237
5238 #[tokio::test]
5239 async fn test_app_fetch_data_preserves_holder_on_subsequent_failure() {
5240 let data = create_test_token_data();
5241 let mock_chain = MockChainClient::new(42_000);
5242 let mut app = create_test_app(
5243 Box::new(MockDexDataSource::new(data)),
5244 Some(Box::new(mock_chain)),
5245 );
5246
5247 app.state.holder_fetch_counter = 11;
5249 app.fetch_data().await;
5250 assert_eq!(app.state.holder_count, Some(42_000));
5251
5252 app.chain_client = Some(Box::new(MockChainClient::new(0)));
5254 app.state.holder_fetch_counter = 23;
5256 app.fetch_data().await;
5257 assert_eq!(app.state.holder_count, Some(42_000));
5259 }
5260
5261 #[test]
5266 fn test_app_cleanup_does_not_panic_test_backend() {
5267 let data = create_test_token_data();
5268 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
5269 let result = app.cleanup();
5271 assert!(result.is_ok());
5272 }
5273
5274 #[test]
5279 fn test_app_draw_renders_ui() {
5280 let data = create_test_token_data();
5281 let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
5282 app.terminal
5284 .draw(|f| ui(f, &mut app.state))
5285 .expect("should render without panic");
5286 }
5287
5288 #[tokio::test]
5297 async fn test_app_full_scenario_fetch_render_quit() {
5298 let data = create_test_token_data();
5299 let mut updated = data.clone();
5300 updated.price_usd = 3.0;
5301 let mock_chain = MockChainClient::new(10_000);
5302 let state = MonitorState::new(&data, "ethereum");
5303 let mut app = create_test_app_with_state(
5304 state,
5305 Box::new(MockDexDataSource::new(updated)),
5306 Some(Box::new(mock_chain)),
5307 );
5308
5309 app.fetch_data().await;
5311 assert!((app.state.current_price - 3.0).abs() < 0.001);
5312
5313 app.terminal
5315 .draw(|f| ui(f, &mut app.state))
5316 .expect("render");
5317
5318 let dir = tempfile::tempdir().unwrap();
5320 app.state.export_dir = dir.path().to_path_buf();
5321 app.handle_key_event(make_key_event(KeyCode::Char('e')));
5322 assert!(app.state.export_active);
5323
5324 app.handle_key_event(make_key_event(KeyCode::Char('q')));
5326 assert!(app.should_exit);
5327 assert!(!app.state.export_active);
5328 }
5329
5330 #[tokio::test]
5331 async fn test_app_fetch_data_error_then_recovery() {
5332 let mut app = create_test_app(Box::new(MockDexDataSource::failing("server down")), None);
5333
5334 app.fetch_data().await;
5336 assert!(app.state.error_message.is_some());
5337
5338 let mut recovered = create_test_token_data();
5340 recovered.price_usd = 5.0;
5341 app.dex_client = Box::new(MockDexDataSource::new(recovered));
5342
5343 app.fetch_data().await;
5345 assert!((app.state.current_price - 5.0).abs() < 0.001);
5346 }
5348
5349 #[test]
5354 fn test_monitor_args_defaults() {
5355 use super::super::Cli;
5356 use clap::Parser;
5357 let cli = Cli::try_parse_from(["scope", "monitor", "USDC"]).unwrap();
5359 if let super::super::Commands::Monitor(args) = cli.command {
5360 assert_eq!(args.token, "USDC");
5361 assert_eq!(args.chain, "ethereum");
5362 assert!(args.layout.is_none());
5363 assert!(args.refresh.is_none());
5364 assert!(args.scale.is_none());
5365 assert!(args.color_scheme.is_none());
5366 assert!(args.export.is_none());
5367 } else {
5368 panic!("Expected Monitor command");
5369 }
5370 }
5371
5372 #[test]
5373 fn test_monitor_args_all_flags() {
5374 use super::super::Cli;
5375 use clap::Parser;
5376 let cli = Cli::try_parse_from([
5377 "scope",
5378 "monitor",
5379 "PEPE",
5380 "--chain",
5381 "solana",
5382 "--layout",
5383 "feed",
5384 "--refresh",
5385 "2",
5386 "--scale",
5387 "log",
5388 "--color-scheme",
5389 "monochrome",
5390 "--export",
5391 "/tmp/data.csv",
5392 ])
5393 .unwrap();
5394 if let super::super::Commands::Monitor(args) = cli.command {
5395 assert_eq!(args.token, "PEPE");
5396 assert_eq!(args.chain, "solana");
5397 assert_eq!(args.layout, Some(LayoutPreset::Feed));
5398 assert_eq!(args.refresh, Some(2));
5399 assert_eq!(args.scale, Some(ScaleMode::Log));
5400 assert_eq!(args.color_scheme, Some(ColorScheme::Monochrome));
5401 assert_eq!(args.export, Some(PathBuf::from("/tmp/data.csv")));
5402 } else {
5403 panic!("Expected Monitor command");
5404 }
5405 }
5406
5407 #[test]
5408 fn test_run_direct_config_override_layout() {
5409 let config = Config::default();
5411 assert_eq!(config.monitor.layout, LayoutPreset::Dashboard);
5412
5413 let args = MonitorArgs {
5414 token: "USDC".to_string(),
5415 token_contract: None,
5416 chain: "ethereum".to_string(),
5417 layout: Some(LayoutPreset::ChartFocus),
5418 refresh: None,
5419 scale: None,
5420 color_scheme: None,
5421 export: None,
5422 venue: Vec::new(),
5423 pair: None,
5424 watch: Default::default(),
5425 };
5426
5427 let mut monitor_config = config.monitor.clone();
5429 if let Some(layout) = args.layout {
5430 monitor_config.layout = layout;
5431 }
5432 assert_eq!(monitor_config.layout, LayoutPreset::ChartFocus);
5433 }
5434
5435 #[test]
5436 fn test_run_direct_config_override_all_fields() {
5437 let config = Config::default();
5438 let args = MonitorArgs {
5439 token: "PEPE".to_string(),
5440 token_contract: None,
5441 chain: "solana".to_string(),
5442 layout: Some(LayoutPreset::Compact),
5443 refresh: Some(2),
5444 scale: Some(ScaleMode::Log),
5445 color_scheme: Some(ColorScheme::BlueOrange),
5446 export: Some(PathBuf::from("/tmp/test.csv")),
5447 venue: Vec::new(),
5448 pair: None,
5449 watch: Default::default(),
5450 };
5451
5452 let mut mc = config.monitor.clone();
5453 if let Some(layout) = args.layout {
5454 mc.layout = layout;
5455 }
5456 if let Some(refresh) = args.refresh {
5457 mc.refresh_seconds = refresh;
5458 }
5459 if let Some(scale) = args.scale {
5460 mc.scale = scale;
5461 }
5462 if let Some(color_scheme) = args.color_scheme {
5463 mc.color_scheme = color_scheme;
5464 }
5465 if let Some(ref path) = args.export {
5466 mc.export.path = Some(path.to_string_lossy().into_owned());
5467 }
5468
5469 assert_eq!(mc.layout, LayoutPreset::Compact);
5470 assert_eq!(mc.refresh_seconds, 2);
5471 assert_eq!(mc.scale, ScaleMode::Log);
5472 assert_eq!(mc.color_scheme, ColorScheme::BlueOrange);
5473 assert_eq!(mc.export.path, Some("/tmp/test.csv".to_string()));
5474 }
5475
5476 #[test]
5477 fn test_run_direct_config_no_overrides_preserves_defaults() {
5478 let config = Config::default();
5479 let args = MonitorArgs {
5480 token: "USDC".to_string(),
5481 token_contract: None,
5482 chain: "ethereum".to_string(),
5483 layout: None,
5484 refresh: None,
5485 scale: None,
5486 color_scheme: None,
5487 export: None,
5488 venue: Vec::new(),
5489 pair: None,
5490 watch: Default::default(),
5491 };
5492
5493 let mut mc = config.monitor.clone();
5494 if let Some(layout) = args.layout {
5495 mc.layout = layout;
5496 }
5497 if let Some(refresh) = args.refresh {
5498 mc.refresh_seconds = refresh;
5499 }
5500 if let Some(scale) = args.scale {
5501 mc.scale = scale;
5502 }
5503 if let Some(color_scheme) = args.color_scheme {
5504 mc.color_scheme = color_scheme;
5505 }
5506
5507 assert_eq!(mc.layout, LayoutPreset::Dashboard);
5509 assert_eq!(mc.refresh_seconds, DEFAULT_REFRESH_SECS);
5510 assert_eq!(mc.scale, ScaleMode::Linear);
5511 assert_eq!(mc.color_scheme, ColorScheme::GreenRed);
5512 assert!(mc.export.path.is_none());
5513 }
5514
5515 #[test]
5520 fn test_exchange_interval_mapping() {
5521 assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
5522 assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
5523 assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
5524 assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
5525 assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
5526 assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
5527 }
5528
5529 #[test]
5530 fn test_monitor_state_exchange_ohlc_default_empty() {
5531 let token_data = create_test_token_data();
5532 let state = MonitorState::new(&token_data, "ethereum");
5533 assert!(state.exchange_ohlc.is_empty());
5534 assert!(state.venue_pair.is_none());
5535 }
5536
5537 #[test]
5538 fn test_get_ohlc_candles_prefers_exchange_data() {
5539 let token_data = create_test_token_data();
5540 let mut state = MonitorState::new(&token_data, "ethereum");
5541
5542 let now = std::time::SystemTime::now()
5544 .duration_since(std::time::UNIX_EPOCH)
5545 .unwrap()
5546 .as_secs_f64();
5547 state.price_history.push_back(DataPoint {
5548 timestamp: now,
5549 value: 1.0,
5550 is_real: true,
5551 });
5552 state.price_history.push_back(DataPoint {
5553 timestamp: now + 5.0,
5554 value: 1.01,
5555 is_real: true,
5556 });
5557
5558 let candles_before = state.get_ohlc_candles();
5560
5561 state.exchange_ohlc = vec![
5563 OhlcCandle {
5564 timestamp: 1700000000.0,
5565 open: 50000.0,
5566 high: 50500.0,
5567 low: 49800.0,
5568 close: 50200.0,
5569 is_bullish: true,
5570 },
5571 OhlcCandle {
5572 timestamp: 1700003600.0,
5573 open: 50200.0,
5574 high: 50800.0,
5575 low: 50100.0,
5576 close: 50700.0,
5577 is_bullish: true,
5578 },
5579 ];
5580
5581 let candles_after = state.get_ohlc_candles();
5582 assert_eq!(candles_after.len(), 2);
5583 assert_eq!(candles_after[0].open, 50000.0);
5584 assert_eq!(candles_after[1].close, 50700.0);
5585
5586 if !candles_before.is_empty() {
5588 assert_ne!(candles_after[0].open, candles_before[0].open);
5589 }
5590 }
5591
5592 #[test]
5593 fn test_monitor_args_with_venue() {
5594 let args = MonitorArgs {
5595 token: "BTC".to_string(),
5596 token_contract: None,
5597 chain: "ethereum".to_string(),
5598 refresh: None,
5599 layout: None,
5600 scale: None,
5601 color_scheme: None,
5602 export: None,
5603 venue: vec!["binance".to_string()],
5604 pair: None,
5605 watch: Default::default(),
5606 };
5607 assert_eq!(args.venue, ["binance"]);
5608 }
5609
5610 #[test]
5611 fn test_ohlc_candle_is_bullish_calculation() {
5612 let bullish = OhlcCandle {
5613 timestamp: 1700000000.0,
5614 open: 100.0,
5615 high: 110.0,
5616 low: 95.0,
5617 close: 105.0,
5618 is_bullish: true,
5619 };
5620 assert!(bullish.is_bullish);
5621
5622 let bearish = OhlcCandle {
5623 timestamp: 1700000000.0,
5624 open: 100.0,
5625 high: 105.0,
5626 low: 90.0,
5627 close: 95.0,
5628 is_bullish: false,
5629 };
5630 assert!(!bearish.is_bullish);
5631 }
5632
5633 #[test]
5634 fn test_build_exchange_token_data_from_ticker() {
5635 let ticker = scope::market::Ticker {
5636 pair: "DAI/USDT".to_string(),
5637 last_price: Some(1.001),
5638 high_24h: Some(1.005),
5639 low_24h: Some(0.998),
5640 volume_24h: Some(500_000.0),
5641 quote_volume_24h: Some(500_500.0),
5642 price_change_pct_24h: None,
5643 best_bid: Some(1.0005),
5644 best_ask: Some(1.0015),
5645 };
5646
5647 let data = build_exchange_token_data("DAI", "DAI_USDT", &ticker);
5648
5649 assert_eq!(data.symbol, "DAI");
5650 assert_eq!(data.name, "DAI");
5651 assert_eq!(data.price_usd, 1.001);
5652 assert_eq!(data.volume_24h, 500_500.0);
5654 assert!(data.address.contains("exchange:"));
5655 assert!(data.pairs.is_empty());
5656 assert!(data.price_history.is_empty());
5657 assert!(data.dexscreener_url.is_none());
5658 }
5659
5660 #[test]
5661 fn test_build_exchange_token_data_missing_price() {
5662 let ticker = scope::market::Ticker {
5663 pair: "FOO/USDT".to_string(),
5664 last_price: None,
5665 high_24h: None,
5666 low_24h: None,
5667 volume_24h: None,
5668 quote_volume_24h: None,
5669 price_change_pct_24h: None,
5670 best_bid: None,
5671 best_ask: None,
5672 };
5673
5674 let data = build_exchange_token_data("FOO", "FOO_USDT", &ticker);
5675 assert_eq!(data.price_usd, 0.0);
5676 assert_eq!(data.volume_24h, 0.0);
5677 }
5678
5679 #[test]
5680 fn test_monitor_args_with_pair() {
5681 let args = MonitorArgs {
5682 token: "DAI".to_string(),
5683 token_contract: None,
5684 chain: "ethereum".to_string(),
5685 refresh: None,
5686 layout: None,
5687 scale: None,
5688 color_scheme: None,
5689 export: None,
5690 venue: vec!["biconomy".to_string()],
5691 pair: Some("DAI_USDT".to_string()),
5692 watch: Default::default(),
5693 };
5694 assert_eq!(args.pair, Some("DAI_USDT".to_string()));
5695 assert_eq!(args.venue, ["biconomy"]);
5696 }
5697
5698 #[test]
5699 fn test_monitor_args_pair_none_by_default() {
5700 let args = MonitorArgs {
5701 token: "BTC".to_string(),
5702 token_contract: None,
5703 chain: "ethereum".to_string(),
5704 refresh: None,
5705 layout: None,
5706 scale: None,
5707 color_scheme: None,
5708 export: None,
5709 venue: Vec::new(),
5710 pair: None,
5711 watch: Default::default(),
5712 };
5713 assert!(args.pair.is_none());
5714 }
5715
5716 #[test]
5717 fn test_build_exchange_token_data_extracts_base_symbol() {
5718 let ticker = scope::market::Ticker {
5719 pair: "DOGE/USDT".to_string(),
5720 last_price: Some(0.123),
5721 high_24h: Some(0.13),
5722 low_24h: Some(0.12),
5723 volume_24h: Some(1_000_000.0),
5724 quote_volume_24h: Some(123_000.0),
5725 price_change_pct_24h: None,
5726 best_bid: Some(0.1225),
5727 best_ask: Some(0.1235),
5728 };
5729
5730 let data = build_exchange_token_data("DOGE", "DOGE_USDT", &ticker);
5731 assert_eq!(data.symbol, "DOGE");
5732 assert_eq!(data.name, "DOGE");
5733 assert_eq!(data.price_usd, 0.123);
5734 assert_eq!(data.volume_24h, 123_000.0);
5736 assert_eq!(data.price_change_24h, 0.0);
5738 assert_eq!(data.price_change_6h, 0.0);
5739 assert_eq!(data.price_change_1h, 0.0);
5740 assert_eq!(data.price_change_5m, 0.0);
5741 assert_eq!(data.volume_6h, 0.0);
5742 assert_eq!(data.volume_1h, 0.0);
5743 assert_eq!(data.liquidity_usd, 0.0);
5744 assert!(data.market_cap.is_none());
5745 assert!(data.fdv.is_none());
5746 assert!(data.earliest_pair_created_at.is_none());
5747 assert!(data.image_url.is_none());
5748 assert!(data.websites.is_empty());
5749 assert!(data.socials.is_empty());
5750 assert_eq!(data.total_buys_24h, 0);
5751 assert_eq!(data.total_sells_24h, 0);
5752 }
5753
5754 #[test]
5755 fn test_build_exchange_token_data_address_format() {
5756 let ticker = scope::market::Ticker {
5757 pair: "X/Y".to_string(),
5758 last_price: Some(1.0),
5759 high_24h: None,
5760 low_24h: None,
5761 volume_24h: None,
5762 quote_volume_24h: None,
5763 price_change_pct_24h: None,
5764 best_bid: None,
5765 best_ask: None,
5766 };
5767
5768 let data = build_exchange_token_data("X", "X_Y", &ticker);
5769 assert_eq!(data.address, "exchange:X_Y");
5770 }
5771
5772 #[test]
5773 fn test_monitor_args_pair_requires_venue_conceptually() {
5774 let args = MonitorArgs {
5777 token: "DAI".to_string(),
5778 token_contract: None,
5779 chain: "ethereum".to_string(),
5780 refresh: None,
5781 layout: None,
5782 scale: None,
5783 color_scheme: None,
5784 export: None,
5785 venue: vec!["biconomy".to_string()],
5786 pair: Some("DAI_USDT".to_string()),
5787 watch: Default::default(),
5788 };
5789 assert!(!args.venue.is_empty());
5790 assert!(args.pair.is_some());
5791 }
5792
5793 #[test]
5794 fn test_run_direct_config_pair_passthrough() {
5795 let config = Config::default();
5797 let args = MonitorArgs {
5798 token: "DAI".to_string(),
5799 token_contract: None,
5800 chain: "ethereum".to_string(),
5801 layout: None,
5802 refresh: None,
5803 scale: None,
5804 color_scheme: None,
5805 export: None,
5806 venue: vec!["biconomy".to_string()],
5807 pair: Some("DAI_USDT".to_string()),
5808 watch: Default::default(),
5809 };
5810
5811 let mut mc = config.monitor.clone();
5813 if let Some(venue) = args.venue.first() {
5814 mc.venue = Some(venue.clone());
5815 }
5816 assert_eq!(mc.venue, Some("biconomy".to_string()));
5817 assert_eq!(args.pair, Some("DAI_USDT".to_string()));
5819 }
5820
5821 #[test]
5826 fn test_chain_filter_logic_ethereum_default() {
5827 let chain = "ethereum";
5830 let chain_filter: Option<&str> = if chain != "ethereum" {
5831 Some(chain)
5832 } else {
5833 None
5834 };
5835 assert!(chain_filter.is_none());
5836 }
5837
5838 #[test]
5839 fn test_chain_filter_logic_explicit_chain() {
5840 let chain = "solana";
5842 let chain_filter: Option<&str> = if chain != "ethereum" {
5843 Some(chain)
5844 } else {
5845 None
5846 };
5847 assert_eq!(chain_filter, Some("solana"));
5848 }
5849
5850 #[test]
5851 fn test_chain_filter_logic_bsc() {
5852 let chain = "bsc";
5853 let chain_filter: Option<&str> = if chain != "ethereum" {
5854 Some(chain)
5855 } else {
5856 None
5857 };
5858 assert_eq!(chain_filter, Some("bsc"));
5859 }
5860
5861 #[tokio::test]
5862 async fn test_resolve_token_address_with_address_input() {
5863 use scope::chains::dex::DexClient;
5864 let dex = DexClient::new_with_http(scope::http::test_transport());
5865 let result = resolve_token_address(
5867 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
5868 "ethereum",
5869 &dex,
5870 Some(scope::http::test_transport()),
5871 &Output::capture().0,
5872 )
5873 .await
5874 .unwrap();
5875 assert_eq!(result, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
5876 }
5877
5878 #[tokio::test]
5879 async fn test_resolve_token_address_reports_the_match_through_the_output() {
5880 let mut dex = scope::chains::mocks::MockDexSource::new();
5884 dex.search_results = vec![scope::chains::dex::TokenSearchResult {
5885 address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".into(),
5886 symbol: "ZZQT".into(),
5887 name: "Zz Quiet Token".into(),
5888 chain: "ethereum".into(),
5889 price_usd: Some(1.5),
5890 volume_24h: 1.0,
5891 liquidity_usd: 1.0,
5892 market_cap: None,
5893 }];
5894 let (out, cap) = Output::capture();
5895 let address = resolve_token_address("ZZQT", "ethereum", &dex, None, &out)
5896 .await
5897 .unwrap();
5898 assert_eq!(address, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
5899 assert_eq!(
5900 cap.err(),
5901 "Using ethereum 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48 Zz Quiet Token\n"
5902 );
5903 assert_eq!(cap.out(), "", "what a search found is not data");
5904 }
5905
5906 #[tokio::test]
5907 async fn test_resolve_token_address_solana_address() {
5908 use scope::chains::dex::DexClient;
5909 let dex = DexClient::new_with_http(scope::http::test_transport());
5910 let addr = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
5912 let result = resolve_token_address(
5913 addr,
5914 "solana",
5915 &dex,
5916 Some(scope::http::test_transport()),
5917 &Output::capture().0,
5918 )
5919 .await
5920 .unwrap();
5921 assert_eq!(result, addr);
5922 }
5923
5924 #[test]
5925 fn test_try_cex_fallback_returns_correct_structure() {
5926 let result = scope::chains::TokenSearchResult {
5929 address: String::new(),
5930 symbol: "TEST".to_string(),
5931 name: "TEST".to_string(),
5932 chain: "ethereum".to_string(),
5933 price_usd: Some(1.0),
5934 volume_24h: 100.0,
5935 liquidity_usd: 0.0,
5936 market_cap: None,
5937 };
5938 assert_eq!(result.symbol, "TEST");
5939 assert!(result.address.is_empty());
5940 assert_eq!(result.liquidity_usd, 0.0);
5941 }
5942
5943 #[test]
5948 fn test_handle_key_event_quit() {
5949 let token_data = create_test_token_data();
5950 let mut state = MonitorState::new(&token_data, "ethereum");
5951 let key = crossterm::event::KeyEvent::new(
5952 crossterm::event::KeyCode::Char('q'),
5953 crossterm::event::KeyModifiers::NONE,
5954 );
5955 assert!(handle_key_event_on_state(key, &mut state));
5956 }
5957
5958 #[test]
5959 fn test_handle_key_event_pause() {
5960 let token_data = create_test_token_data();
5961 let mut state = MonitorState::new(&token_data, "ethereum");
5962 let key = crossterm::event::KeyEvent::new(
5963 crossterm::event::KeyCode::Char(' '),
5964 crossterm::event::KeyModifiers::NONE,
5965 );
5966 assert!(!state.paused);
5967 assert!(!handle_key_event_on_state(key, &mut state));
5968 assert!(state.paused);
5969 }
5970
5971 #[test]
5972 fn test_handle_key_event_chart_cycle() {
5973 let token_data = create_test_token_data();
5974 let mut state = MonitorState::new(&token_data, "ethereum");
5975 let initial_mode = state.chart_mode;
5976 let key = crossterm::event::KeyEvent::new(
5977 crossterm::event::KeyCode::Char('c'),
5978 crossterm::event::KeyModifiers::NONE,
5979 );
5980 handle_key_event_on_state(key, &mut state);
5981 assert_ne!(state.chart_mode, initial_mode);
5982 }
5983
5984 #[test]
5985 fn test_handle_key_event_slower_faster() {
5986 let token_data = create_test_token_data();
5987 let mut state = MonitorState::new(&token_data, "ethereum");
5988 let initial_rate = state.refresh_rate_secs();
5989
5990 let key_plus = crossterm::event::KeyEvent::new(
5992 crossterm::event::KeyCode::Char('+'),
5993 crossterm::event::KeyModifiers::NONE,
5994 );
5995 handle_key_event_on_state(key_plus, &mut state);
5996 assert!(state.refresh_rate_secs() > initial_rate);
5997
5998 let key_minus = crossterm::event::KeyEvent::new(
6000 crossterm::event::KeyCode::Char('-'),
6001 crossterm::event::KeyModifiers::NONE,
6002 );
6003 handle_key_event_on_state(key_minus, &mut state);
6004 assert_eq!(state.refresh_rate_secs(), initial_rate);
6005 }
6006
6007 #[test]
6008 fn test_handle_key_event_time_period() {
6009 let token_data = create_test_token_data();
6010 let mut state = MonitorState::new(&token_data, "ethereum");
6011 let initial_period = state.time_period;
6012 let key = crossterm::event::KeyEvent::new(
6013 crossterm::event::KeyCode::Char('t'),
6014 crossterm::event::KeyModifiers::NONE,
6015 );
6016 handle_key_event_on_state(key, &mut state);
6017 assert_ne!(state.time_period, initial_period);
6018 }
6019
6020 #[test]
6021 fn test_handle_key_event_escape() {
6022 let token_data = create_test_token_data();
6023 let mut state = MonitorState::new(&token_data, "ethereum");
6024 let key = crossterm::event::KeyEvent::new(
6025 crossterm::event::KeyCode::Esc,
6026 crossterm::event::KeyModifiers::NONE,
6027 );
6028 assert!(handle_key_event_on_state(key, &mut state));
6029 }
6030
6031 #[test]
6036 fn test_spread_line_matches_the_market_summary_for_the_same_book() {
6037 use scope::market::{
6038 BookSource, HealthThresholds, MarketSummary, OrderBook, OrderBookLevel,
6039 };
6040 let level = |price, quantity| OrderBookLevel { price, quantity };
6041 let book = OrderBook {
6043 pair: "X/USDT".to_string(),
6044 source: BookSource::Exchange,
6045 bids: vec![level(1.0, 0.0), level(0.999, 500.0)],
6046 asks: vec![level(1.0001, 0.0), level(1.001, 500.0)],
6047 };
6048 let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
6049 let expected = format!(
6050 " Spread: {:.6} ({:.3}%)",
6051 summary.spread.unwrap(),
6052 summary.spread_pct.unwrap()
6053 );
6054 assert_eq!(widgets::spread_line(&book), expected);
6055 assert_eq!(expected, " Spread: 0.002000 (0.200%)");
6056 }
6057
6058 #[test]
6059 fn test_render_order_book_panel_no_data() {
6060 let mut terminal = create_test_terminal();
6061 let mut state = create_populated_state();
6062 state.order_book = None;
6063 let area = Rect::new(0, 0, 50, 20);
6064 terminal
6065 .draw(|f| render_order_book_panel(f, area, &state))
6066 .unwrap();
6067 }
6068
6069 #[test]
6070 fn test_render_order_book_panel_with_data() {
6071 let mut terminal = create_test_terminal();
6072 let state = create_populated_state();
6073 let area = Rect::new(0, 0, 60, 25);
6074 terminal
6075 .draw(|f| render_order_book_panel(f, area, &state))
6076 .unwrap();
6077 }
6078
6079 #[test]
6080 fn test_render_order_book_panel_narrow_height() {
6081 let mut terminal = create_test_terminal();
6082 let state = create_populated_state();
6083 let area = Rect::new(0, 0, 50, 3);
6084 terminal
6085 .draw(|f| render_order_book_panel(f, area, &state))
6086 .unwrap();
6087 }
6088
6089 #[test]
6090 fn test_render_recent_trades_panel_empty() {
6091 let mut terminal = create_test_terminal();
6092 let mut state = create_populated_state();
6093 state.recent_trades.clear();
6094 let area = Rect::new(0, 0, 60, 15);
6095 terminal
6096 .draw(|f| render_recent_trades_panel(f, area, &state))
6097 .unwrap();
6098 }
6099
6100 #[test]
6101 fn test_render_recent_trades_panel_with_trades() {
6102 let mut terminal = create_test_terminal();
6103 let mut state = create_populated_state();
6104 state.recent_trades.push_back(Trade {
6105 price: 1.5,
6106 quantity: 100.0,
6107 quote_quantity: None,
6108 timestamp_ms: 1700000000000,
6109 side: TradeSide::Buy,
6110 id: None,
6111 });
6112 state.recent_trades.push_back(Trade {
6113 price: 1.49,
6114 quantity: 50.0,
6115 quote_quantity: None,
6116 timestamp_ms: 1700000001000,
6117 side: TradeSide::Sell,
6118 id: None,
6119 });
6120 let area = Rect::new(0, 0, 60, 20);
6121 terminal
6122 .draw(|f| render_recent_trades_panel(f, area, &state))
6123 .unwrap();
6124 }
6125
6126 #[test]
6127 fn test_render_recent_trades_panel_high_price_trade() {
6128 let mut terminal = create_test_terminal();
6129 let mut state = create_populated_state();
6130 state.recent_trades.push_back(Trade {
6131 price: 50_000.0,
6132 quantity: 0.5,
6133 quote_quantity: None,
6134 timestamp_ms: 1700000000000,
6135 side: TradeSide::Buy,
6136 id: None,
6137 });
6138 let area = Rect::new(0, 0, 60, 15);
6139 terminal
6140 .draw(|f| render_recent_trades_panel(f, area, &state))
6141 .unwrap();
6142 }
6143
6144 #[test]
6145 fn test_render_market_info_panel_no_panic() {
6146 let mut terminal = create_test_terminal();
6147 let state = create_populated_state();
6148 let area = Rect::new(0, 0, 80, 30);
6149 terminal
6150 .draw(|f| render_market_info_panel(f, area, &state))
6151 .unwrap();
6152 }
6153
6154 #[test]
6155 fn test_render_market_info_panel_with_dex_pairs() {
6156 let mut terminal = create_test_terminal();
6157 let mut state = create_populated_state();
6158 state.dex_pairs = vec![
6159 scope::chains::DexPair {
6160 dex_name: "Uniswap V3".to_string(),
6161 pair_address: "0xabc".to_string(),
6162 base_token: "TEST".to_string(),
6163 quote_token: "USDT".to_string(),
6164 price_usd: 1.0,
6165 volume_24h: 100_000.0,
6166 liquidity_usd: 250_000.0,
6167 liquidity_base: None,
6168 price_change_24h: 2.5,
6169 buys_24h: 50,
6170 sells_24h: 40,
6171 buys_6h: 15,
6172 sells_6h: 12,
6173 buys_1h: 5,
6174 sells_1h: 4,
6175 pair_created_at: None,
6176 url: None,
6177 },
6178 scope::chains::DexPair {
6179 dex_name: "SushiSwap".to_string(),
6180 pair_address: "0xdef".to_string(),
6181 base_token: "TEST".to_string(),
6182 quote_token: "WETH".to_string(),
6183 price_usd: 1.01,
6184 volume_24h: 25_000.0,
6185 liquidity_usd: 80_000.0,
6186 liquidity_base: None,
6187 price_change_24h: -1.2,
6188 buys_24h: 20,
6189 sells_24h: 25,
6190 buys_6h: 8,
6191 sells_6h: 10,
6192 buys_1h: 3,
6193 sells_1h: 4,
6194 pair_created_at: None,
6195 url: None,
6196 },
6197 ];
6198 let area = Rect::new(0, 0, 100, 35);
6199 terminal
6200 .draw(|f| render_market_info_panel(f, area, &state))
6201 .unwrap();
6202 }
6203
6204 #[test]
6205 fn test_ohlc_candle_update_bearish() {
6206 let mut candle = OhlcCandle::new(1700000000.0, 10.0);
6207 assert!(candle.is_bullish);
6208 candle.update(9.5);
6209 assert!(!candle.is_bullish);
6210 assert_eq!(candle.high, 10.0);
6211 assert_eq!(candle.low, 9.5);
6212 assert_eq!(candle.close, 9.5);
6213 }
6214
6215 #[test]
6216 fn test_time_period_index_all() {
6217 assert_eq!(TimePeriod::Min1.index(), 0);
6218 assert_eq!(TimePeriod::Min5.index(), 1);
6219 assert_eq!(TimePeriod::Min15.index(), 2);
6220 assert_eq!(TimePeriod::Hour1.index(), 3);
6221 assert_eq!(TimePeriod::Hour4.index(), 4);
6222 assert_eq!(TimePeriod::Day1.index(), 5);
6223 }
6224
6225 #[test]
6226 fn test_format_number_boundary_values() {
6227 assert_eq!(format_number(0.5), "0.50");
6228 assert_eq!(format_number(999.99), "999.99");
6229 assert_eq!(format_number(1000.0), "1.00K");
6230 assert_eq!(format_number(999_999.0), "1000.00K"); assert_eq!(format_number(1_000_000.0), "1.00M");
6232 assert_eq!(format_number(999_999_999.0), "1000.00M");
6233 assert_eq!(format_number(1_000_000_000.0), "1.00B");
6234 }
6235
6236 #[test]
6237 fn test_chart_mode_volume_profile_label() {
6238 assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
6239 assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
6240 }
6241
6242 #[test]
6243 fn test_layout_exchange_ui_full_render() {
6244 let mut terminal = create_test_terminal();
6245 let mut state = create_populated_state();
6246 state.layout = LayoutPreset::Exchange;
6247 state.auto_layout = false;
6248 state.dex_pairs = vec![scope::chains::DexPair {
6249 dex_name: "Uniswap".to_string(),
6250 pair_address: "0xabc".to_string(),
6251 base_token: "TEST".to_string(),
6252 quote_token: "USDT".to_string(),
6253 price_usd: 1.0,
6254 volume_24h: 50_000.0,
6255 liquidity_usd: 100_000.0,
6256 liquidity_base: None,
6257 price_change_24h: 0.5,
6258 buys_24h: 100,
6259 sells_24h: 80,
6260 buys_6h: 30,
6261 sells_6h: 25,
6262 buys_1h: 10,
6263 sells_1h: 8,
6264 pair_created_at: None,
6265 url: None,
6266 }];
6267 state.order_book = state.dex_book(&state.dex_pairs, &state.symbol);
6268 state.recent_trades.push_back(Trade {
6269 price: 1.0,
6270 quantity: 500.0,
6271 quote_quantity: None,
6272 timestamp_ms: 1700000000000,
6273 side: TradeSide::Buy,
6274 id: None,
6275 });
6276 terminal.draw(|f| ui(f, &mut state)).unwrap();
6277 }
6278
6279 fn make_dex_pair(
6284 dex: &str,
6285 base: &str,
6286 quote: &str,
6287 price: f64,
6288 vol: f64,
6289 liq: f64,
6290 ) -> scope::chains::DexPair {
6291 scope::chains::DexPair {
6292 dex_name: dex.to_string(),
6293 base_token: base.to_string(),
6294 quote_token: quote.to_string(),
6295 price_usd: price,
6296 volume_24h: vol,
6297 liquidity_usd: liq,
6298 liquidity_base: None,
6299 price_change_24h: 5.0,
6300 pair_address: format!("0x{}", dex),
6301 buys_24h: 100,
6302 sells_24h: 50,
6303 buys_6h: 25,
6304 sells_6h: 12,
6305 buys_1h: 5,
6306 sells_1h: 3,
6307 pair_created_at: None,
6308 url: None,
6309 }
6310 }
6311
6312 fn create_test_token_data_with_pairs() -> DexTokenData {
6313 let mut data = create_test_token_data();
6314 data.pairs = vec![
6315 make_dex_pair("uniswap", "TEST", "USDC", 1.0, 500_000.0, 300_000.0),
6316 make_dex_pair("sushiswap", "TEST", "WETH", 1.01, 200_000.0, 150_000.0),
6317 ];
6318 data
6319 }
6320
6321 #[test]
6322 fn test_monitor_state_update_with_token_data() {
6323 let initial_data = create_test_token_data_with_pairs();
6324 let mut state = MonitorState::new(&initial_data, "ethereum");
6325 assert_eq!(state.current_price, 1.0);
6326 assert_eq!(state.real_data_count, 0);
6327
6328 let mut updated_data = initial_data.clone();
6330 updated_data.price_usd = 1.05;
6331 updated_data.volume_24h = 1_200_000.0;
6332 state.update(&updated_data);
6333
6334 assert_eq!(state.current_price, 1.05);
6335 assert_eq!(state.real_data_count, 1);
6336 assert!(!state.price_history.is_empty());
6337 assert!(!state.volume_history.is_empty());
6338 }
6339
6340 #[test]
6341 fn test_monitor_state_update_price_unchanged() {
6342 let data = create_test_token_data();
6343 let mut state = MonitorState::new(&data, "ethereum");
6344 state.update(&data);
6345 assert_eq!(state.real_data_count, 1);
6346 assert_eq!(state.current_price, 1.0);
6348 }
6349
6350 #[test]
6351 fn test_monitor_state_update_generates_trades() {
6352 let data = create_test_token_data_with_pairs();
6353 let mut state = MonitorState::new(&data, "ethereum");
6354
6355 let mut new_data = data.clone();
6357 new_data.price_usd = 1.10;
6358 state.update(&new_data);
6359
6360 assert!(!state.recent_trades.is_empty());
6362 }
6363
6364 #[test]
6365 fn test_monitor_state_update_liquidity_pairs() {
6366 let data = create_test_token_data_with_pairs();
6367 let mut state = MonitorState::new(&data, "ethereum");
6368 state.update(&data);
6369 assert_eq!(state.liquidity_pairs.len(), 2);
6370 assert!(state.liquidity_pairs[0].0.contains("TEST/USDC"));
6371 assert!(state.liquidity_pairs[1].0.contains("TEST/WETH"));
6372 }
6373
6374 #[test]
6375 fn test_monitor_state_update_dex_pairs_and_order_book() {
6376 let data = create_test_token_data_with_pairs();
6377 let mut state = MonitorState::new(&data, "ethereum");
6378 state.update(&data);
6379 assert_eq!(state.dex_pairs.len(), 2);
6380 assert!(state.order_book.is_some());
6381 }
6382
6383 #[test]
6384 fn test_monitor_state_update_sells_more_than_buys() {
6385 let mut data = create_test_token_data_with_pairs();
6386 data.total_buys_24h = 10;
6387 data.total_sells_24h = 50;
6388 let mut state = MonitorState::new(&data, "ethereum");
6389 state.update(&data);
6390 assert!(!state.recent_trades.is_empty());
6392 }
6393
6394 #[test]
6395 fn test_monitor_state_update_zero_volume() {
6396 let mut data = create_test_token_data_with_pairs();
6397 data.volume_24h = 0.0;
6398 let mut state = MonitorState::new(&data, "ethereum");
6399 state.update(&data);
6400 if let Some(trade) = state.recent_trades.back() {
6402 assert_eq!(trade.quantity, 1.0);
6403 }
6404 }
6405
6406 fn spans_text(spans: &[ratatui::text::Span]) -> String {
6411 spans.iter().map(|s| s.content.as_ref()).collect()
6412 }
6413
6414 fn ui_text(state: &mut MonitorState, w: u16, h: u16) -> String {
6415 let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
6416 terminal.draw(|f| ui(f, state)).unwrap();
6417 format!("{}", terminal.backend())
6418 }
6419
6420 fn venue_only_state() -> MonitorState {
6422 venue_state(&venue_ticker("USDC/USDT", 1.0001, Some(1000.0), None, None))
6423 }
6424
6425 #[test]
6426 fn test_footer_puts_quit_first_and_keeps_pause_while_an_error_is_set() {
6427 let mut state = create_populated_state();
6430 state.error_message = Some("API Error: boom".into());
6431 state.paused = true;
6432 let text = spans_text(&footer_spans(&state));
6433 assert!(text.starts_with("[q]uit"), "{text}");
6434 assert!(text.contains("PAUSED"), "{text}");
6435 assert!(
6436 !text.contains("API Error"),
6437 "the error is in the header: {text}"
6438 );
6439 let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap();
6441 terminal
6442 .draw(|f| render_footer(f, f.area(), &state))
6443 .unwrap();
6444 let row = format!("{}", terminal.backend());
6445 assert!(row.contains("[q]uit") && row.contains("PAUSED"), "{row}");
6446 }
6447
6448 #[test]
6449 fn test_header_title_carries_the_error_cut_to_fit() {
6450 let mut state = create_populated_state();
6451 state.error_message = Some(format!("API Error: {}", "x".repeat(300)));
6452 let mut terminal = Terminal::new(TestBackend::new(80, 4)).unwrap();
6453 terminal
6454 .draw(|f| render_header(f, f.area(), &state))
6455 .unwrap();
6456 let text = format!("{}", terminal.backend());
6457 let top = text.lines().next().unwrap();
6458 assert!(top.contains("⚠ API Error: xx"), "{top}");
6459 assert!(top.contains('…'), "a long error is cut with …: {top}");
6460 assert!(top.trim_end_matches('"').ends_with('┐'), "{top}");
6461 }
6462
6463 #[tokio::test]
6464 async fn test_venue_only_refresh_never_calls_dexscreener() {
6465 let (_server, ex) = mock_venue(200).await;
6468 let dex = MockDexDataSource::new(create_test_token_data());
6469 let calls = dex.calls.clone();
6470 let mut state = venue_only_state();
6471 state.venue_pair = Some("USDN_USDT".into());
6472 let mut app = create_test_app_with_state(state, Box::new(dex), None);
6473 app.exchange_client = Some(ex);
6474 for _ in 0..3 {
6475 app.fetch_data().await;
6476 }
6477 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
6478 assert!(
6479 app.state.error_message.is_none(),
6480 "{:?}",
6481 app.state.error_message
6482 );
6483 assert!(
6484 (app.state.current_price - 1.0002).abs() < 1e-9,
6485 "priced by the venue"
6486 );
6487
6488 let dex = MockDexDataSource::new(create_test_token_data());
6490 let calls = dex.calls.clone();
6491 let mut app = create_test_app(Box::new(dex), None);
6492 for _ in 0..3 {
6493 app.fetch_data().await;
6494 }
6495 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 3);
6496 }
6497
6498 #[test]
6502 fn test_update_builds_the_core_dex_book() {
6503 let data = create_test_token_data_with_pairs();
6504 let mut state = MonitorState::new(&data, "ethereum");
6505 state.update(&data);
6506 let best = scope::market::most_liquid(&data.pairs).unwrap();
6507 let expected = scope::market::order_book_from_analytics(
6508 "ethereum",
6509 best,
6510 &data.symbol,
6511 &state.thresholds,
6512 );
6513 let book = state.order_book.as_ref().unwrap();
6514 assert_eq!(book.pair, expected.pair);
6515 assert_eq!(book.source, expected.source);
6516 assert_ne!(book.source, scope::market::BookSource::Exchange);
6517 assert_eq!(book.bids.len(), expected.bids.len());
6518 assert_eq!(book.asks.len(), expected.asks.len());
6519 }
6520
6521 #[test]
6522 fn test_a_venue_book_and_trades_survive_dex_refreshes() {
6523 let data = create_test_token_data_with_pairs();
6524 let mut state = MonitorState::new(&data, "ethereum");
6525 let book = scope::market::OrderBook {
6526 pair: "TEST/USDT".into(),
6527 source: scope::market::BookSource::Exchange,
6528 bids: vec![scope::market::OrderBookLevel {
6529 price: 0.99,
6530 quantity: 5.0,
6531 }],
6532 asks: vec![scope::market::OrderBookLevel {
6533 price: 1.01,
6534 quantity: 7.0,
6535 }],
6536 };
6537 state.set_venue_book("binance", book.clone());
6538 let trade = |ts| Trade {
6539 price: 1.0,
6540 quantity: 1.0,
6541 quote_quantity: None,
6542 timestamp_ms: ts,
6543 side: TradeSide::Buy,
6544 id: None,
6545 };
6546 state.set_venue_trades("binance", vec![trade(3), trade(1), trade(2)]);
6547 state.update(&data);
6548 state.update(&data);
6549 assert_eq!(state.order_book.as_ref().unwrap().pair, book.pair);
6550 assert_eq!(state.recent_trades.len(), 3, "no synthetic trade added");
6551 let times: Vec<u64> = state.recent_trades.iter().map(|t| t.timestamp_ms).collect();
6552 assert_eq!(times, vec![1, 2, 3], "oldest first");
6553 assert!(order_book_title(&state).contains("Order Book (binance)"));
6554 assert!(trades_title(&state).contains("Recent Trades (binance)"));
6555 }
6556
6557 #[tokio::test]
6560 async fn test_a_venue_fills_the_book_and_trades_panels_with_real_data() {
6561 let mut server = mockito::Server::new_async().await;
6562 server
6563 .mock("GET", "/api/v3/depth")
6564 .match_query(mockito::Matcher::Any)
6565 .with_body(r#"{"bids":[["0.9991","1234"]],"asks":[["1.0009","4321"]]}"#)
6566 .create_async()
6567 .await;
6568 server
6569 .mock("GET", "/api/v3/trades")
6570 .match_query(mockito::Matcher::Any)
6571 .with_body(r#"[{"id":1,"price":"1.0001","qty":"50","quoteQty":"50","time":1700000000000,"isBuyerMaker":false}]"#)
6572 .create_async()
6573 .await;
6574 let dex = MockDexDataSource::new(create_test_token_data());
6575 let mut app = create_test_app(Box::new(dex), None);
6576 app.exchange_client = Some(scope::market::testing::venue_at("binance", &server.url()));
6577 app.state.venue_pair = Some("USDCUSDT".into());
6578 app.fetch_data().await;
6579 let book = app.state.order_book.as_ref().unwrap();
6580 assert_eq!(book.source, scope::market::BookSource::Exchange);
6581 assert_eq!(book.bids[0].price, 0.9991);
6582 assert_eq!(book.asks[0].quantity, 4321.0);
6583 assert_eq!(app.state.recent_trades.len(), 1);
6584 assert!(order_book_title(&app.state).contains("(binance)"));
6585 assert!(trades_title(&app.state).contains("(binance)"));
6586 app.state.layout = LayoutPreset::Exchange;
6587 app.state.auto_layout = false;
6588 let text = ui_text(&mut app.state, 220, 50);
6589 assert!(text.contains("Order Book (binance)"), "{text}");
6590 assert!(text.contains("Recent Trades (binance)"), "{text}");
6591 }
6592
6593 #[test]
6594 fn test_panel_titles_name_their_source() {
6595 let data = create_test_token_data_with_pairs();
6596 let mut state = MonitorState::new(&data, "ethereum");
6597 state.update(&data);
6598 let label = state.order_book.as_ref().unwrap().source.label();
6600 assert!(order_book_title(&state).contains(&format!("({label})")));
6601 assert!(trades_title(&state).contains("(synthetic, from DEX pairs)"));
6602
6603 let state = venue_only_state();
6604 assert!(order_book_title(&state).contains("Order Book (not available in venue-only mode)"));
6605 assert!(trades_title(&state).contains("Recent Trades (not available in venue-only mode)"));
6606 }
6607
6608 #[test]
6609 fn test_venue_only_screen_names_the_venue_and_hides_dex_only_stats() {
6610 let mut state = venue_only_state();
6611 state.layout = LayoutPreset::Exchange;
6612 state.auto_layout = false;
6613 let text = ui_text(&mut state, 220, 50);
6614 assert!(text.contains("BINANCE USDCUSDT"), "{text}");
6615 assert!(!text.contains("ETHEREUM"), "{text}");
6616 assert!(
6617 text.contains("Order Book (not available in venue-only mode)"),
6618 "{text}"
6619 );
6620 assert!(!text.contains("$0.00"), "{text}");
6621 assert!(!text.contains("0 (50%)"), "{text}");
6622 assert!(!text.contains("Buys: 0"), "{text}");
6623 assert!(text.contains("n/a"), "{text}");
6624
6625 state.layout = LayoutPreset::Dashboard;
6626 let text = ui_text(&mut state, 220, 50);
6627 assert!(!text.contains("$0.00"), "{text}");
6628 assert!(!text.contains("Buys: 0"), "{text}");
6629 }
6630
6631 #[test]
6632 fn test_only_the_layout_flag_pins_the_layout() {
6633 use clap::Parser;
6634 let launch = |argv: &[&str], config: &Config| {
6637 let cli = crate::cli::Cli::try_parse_from(argv).unwrap();
6638 let crate::cli::Commands::Monitor(args) = cli.command else {
6639 panic!("expected monitor")
6640 };
6641 let (cfg, _, pinned) = monitor_config_from_args(&args, config);
6642 let mut state = create_populated_state();
6643 state.apply_config(&cfg);
6644 state.auto_layout = !pinned;
6645 let start = state.layout;
6646 ui_text(&mut state, 120, 40);
6647 (start, state.layout)
6648 };
6649 let auto = auto_select_layout(Rect::new(0, 0, 120, 40));
6650 assert_ne!(
6651 auto,
6652 LayoutPreset::Exchange,
6653 "the test needs auto to differ"
6654 );
6655 let bare = ["scope", "monitor", "USDC"];
6656
6657 let flag = ["scope", "monitor", "USDC", "--layout", "exchange"];
6659 let (_, first) = launch(&flag, &Config::default());
6660 assert_eq!(first, LayoutPreset::Exchange);
6661
6662 let config = Config {
6666 monitor: serde_saphyr::from_str("layout: exchange\n").unwrap(),
6667 ..Default::default()
6668 };
6669 assert_eq!(launch(&bare, &config), (LayoutPreset::Exchange, auto));
6670
6671 assert_eq!(launch(&bare, &Config::default()).1, auto);
6673 }
6674
6675 const MONITOR_TOO_SMALL: &str = "Terminal too small: need at least 60×16";
6676
6677 #[test]
6679 fn test_question_mark_shows_the_keys_and_any_key_closes_them() {
6680 let mut state = create_populated_state();
6681 let k = |c| crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
6682 assert!(!handle_key_event_on_state(k('?'), &mut state));
6683 assert!(state.show_keys);
6684 let text = ui_text(&mut state, 120, 40);
6685 assert!(text.contains(" Keys "), "{text}");
6686 assert!(text.contains("slower or faster refresh"), "{text}");
6687 assert!(!handle_key_event_on_state(k('q'), &mut state));
6689 assert!(!state.show_keys);
6690 }
6691
6692 #[test]
6693 fn test_refresh_is_acknowledged_and_the_interval_is_shown() {
6694 let mut state = create_populated_state();
6695 let k = crossterm::event::KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
6696 handle_key_event_on_state(k, &mut state);
6697 assert!(
6698 state
6699 .log_messages
6700 .iter()
6701 .any(|m| m.contains("Refresh requested"))
6702 );
6703 let text = spans_text(&footer_spans(&state));
6704 let every = state.refresh_rate.as_secs();
6705 assert!(text.contains(&format!("of {every}s")), "{text}");
6706 }
6707
6708 #[test]
6709 fn test_a_price_beside_an_error_is_marked_stale() {
6710 let mut state = create_populated_state();
6711 state.error_message = Some("API Error: boom".into());
6712 let text = ui_text(&mut state, 120, 40);
6713 assert!(text.contains("(stale)"), "{text}");
6714 }
6715
6716 #[test]
6717 fn test_below_60x16_only_the_too_small_message_is_drawn() {
6718 let mut state = create_populated_state();
6719 state.layout = LayoutPreset::Exchange;
6720 let text = ui_text(&mut state, 50, 12);
6721 assert!(text.contains(MONITOR_TOO_SMALL), "{text}");
6722 assert!(!text.contains("[q]uit"), "{text}");
6723 let text = ui_text(&mut state, 30, 8);
6725 assert!(text.contains("Terminal too small: need at"), "{text}");
6726 assert!(text.contains("least 60×16"), "{text}");
6727 assert_eq!(state.layout, LayoutPreset::Exchange, "state is kept");
6728 let text = ui_text(&mut state, 60, 16);
6730 assert!(!text.contains("Terminal too small"), "{text}");
6731 assert!(text.contains("[q]uit"), "{text}");
6732 assert!(ui_text(&mut state, 59, 16).contains("Terminal too small"));
6733 assert!(ui_text(&mut state, 60, 15).contains("Terminal too small"));
6734 }
6735
6736 fn venue_ticker(
6741 pair: &str,
6742 last: f64,
6743 base_vol: Option<f64>,
6744 quote_vol: Option<f64>,
6745 change: Option<f64>,
6746 ) -> scope::market::Ticker {
6747 scope::market::Ticker {
6748 pair: pair.into(),
6749 last_price: Some(last),
6750 high_24h: None,
6751 low_24h: None,
6752 volume_24h: base_vol,
6753 quote_volume_24h: quote_vol,
6754 price_change_pct_24h: change,
6755 best_bid: None,
6756 best_ask: None,
6757 }
6758 }
6759
6760 fn venue_state(ticker: &scope::market::Ticker) -> MonitorState {
6762 let data = build_exchange_token_data("USDC", "USDCUSDT", ticker);
6763 let mut state = MonitorState::new(&data, "ethereum");
6764 state.venue = Some("binance".into());
6765 state.venue_pair = Some("USDCUSDT".into());
6766 state.set_venue_ticker(ticker);
6767 state
6768 }
6769
6770 fn header_text(state: &MonitorState) -> (String, Vec<ratatui::style::Color>) {
6771 let mut terminal = Terminal::new(TestBackend::new(120, 4)).unwrap();
6772 terminal
6773 .draw(|f| render_header(f, f.area(), state))
6774 .unwrap();
6775 let colors = terminal
6776 .backend()
6777 .buffer()
6778 .content
6779 .iter()
6780 .map(|c| c.fg)
6781 .collect();
6782 (format!("{}", terminal.backend()), colors)
6783 }
6784
6785 fn metrics_text(state: &MonitorState) -> String {
6787 let mut terminal = Terminal::new(TestBackend::new(60, 30)).unwrap();
6788 terminal
6789 .draw(|f| render_metrics_panel(f, f.area(), state))
6790 .unwrap();
6791 format!("{}", terminal.backend())
6792 }
6793
6794 #[test]
6795 fn test_venue_only_export_leaves_unknown_fields_empty() {
6796 let last_row = |t: &scope::market::Ticker| {
6800 let mut state = venue_state(t);
6801 let path = start_export_in_temp(&mut state);
6802 state.update(&build_exchange_token_data("USDC", "USDCUSDT", t));
6803 let contents = std::fs::read_to_string(&path).unwrap();
6804 contents.lines().last().unwrap().to_string()
6805 };
6806 let row = last_row(&venue_ticker(
6808 "USDC/USDT",
6809 1.0,
6810 Some(10.0),
6811 Some(10.5),
6812 None,
6813 ));
6814 let fields: Vec<&str> = row.split(',').collect();
6815 assert_eq!(fields[2], "10.50", "{row}");
6816 assert_eq!(&fields[3..], ["", "", "", ""], "{row}");
6817 let row = last_row(&venue_ticker(
6819 "USDC/BTC",
6820 0.00001,
6821 Some(10.0),
6822 Some(0.0001),
6823 None,
6824 ));
6825 let fields: Vec<&str> = row.split(',').collect();
6826 assert_eq!(&fields[2..], ["", "", "", "", ""], "{row}");
6827
6828 let data = create_test_token_data();
6830 let mut state = MonitorState::new(&data, "ethereum");
6831 let path = start_export_in_temp(&mut state);
6832 state.update(&data);
6833 let contents = std::fs::read_to_string(&path).unwrap();
6834 let row = contents.lines().last().unwrap();
6835 assert!(!row.contains(",,"), "{row}");
6836 }
6837
6838 #[test]
6839 fn test_venue_24h_change_is_carried_and_an_unknown_change_is_na() {
6840 let t = venue_ticker("USDC/USDT", 1.0, Some(10.0), Some(10.0), Some(-0.25));
6841 let data = build_exchange_token_data("USDC", "USDCUSDT", &t);
6842 assert_eq!(data.price_change_24h, -0.25);
6843 let mut state = venue_state(&t);
6844 state.update(&data);
6845 assert_eq!(state.change_24h(), Some(-0.25));
6846 let (text, _) = header_text(&state);
6847 assert!(text.contains("-0.25%"), "{text}");
6848
6849 let t = venue_ticker("USDC/USDT", 1.0, Some(10.0), Some(10.0), None);
6851 let mut state = venue_state(&t);
6852 state.update(&build_exchange_token_data("USDC", "USDCUSDT", &t));
6853 assert_eq!(state.change_24h(), None);
6854 let (text, colors) = header_text(&state);
6855 assert!(text.contains("24h n/a"), "{text}");
6856 assert!(!text.contains(['▲', '△', '▼', '▽']), "{text}");
6857 let pal = state.palette();
6858 assert!(
6859 !colors.contains(&pal.up) && !colors.contains(&pal.down),
6860 "an unknown change has no direction color"
6861 );
6862
6863 state.layout = LayoutPreset::Exchange;
6865 state.auto_layout = false;
6866 let text = ui_text(&mut state, 220, 50);
6867 for label in ["5m", "1h", "6h", "24h"] {
6868 assert!(text.contains(&format!(" {label}: n/a")), "{label}: {text}");
6869 }
6870 let text = metrics_text(&state);
6871 for label in ["5m Chg", "24h Chg"] {
6872 let row = text.lines().find(|l| l.contains(label)).expect(label);
6873 assert!(row.contains("n/a"), "{row}");
6874 }
6875 }
6876
6877 #[test]
6878 fn test_venue_volume_is_usd_only_for_a_usd_quote() {
6879 let usd = |pair: &str, base: Option<f64>, quote: Option<f64>| {
6880 venue_ticker(pair, 2.0, base, quote, None).usd_volume()
6881 };
6882 assert_eq!(usd("ETH/USDT", Some(5.0), Some(10.5)), Some(10.5));
6884 assert_eq!(usd("ETH/USDC", Some(5.0), None), Some(10.0));
6886 assert_eq!(usd("ETH/USD", None, Some(7.0)), Some(7.0));
6887 assert_eq!(usd("ETH/BTC", Some(5.0), Some(0.2)), None);
6889 assert_eq!(usd("ETHUSDT", Some(5.0), Some(10.5)), Some(10.5));
6891 assert_eq!(usd("ETH/USDT", None, None), None);
6892
6893 let t = venue_ticker("USDC/BTC", 0.00001, Some(10.0), Some(5.0), None);
6895 let mut state = venue_state(&t);
6896 assert!(state.volume_usd().is_none());
6897 assert!(state.volume_history.is_empty(), "no synthetic volume");
6898 state.volume_avg = 100.0;
6899 state.alerts.volume_spike_threshold_pct = Some(1.0);
6900 state.update(&build_exchange_token_data("USDC", "USDCBTC", &t));
6901 assert_eq!(state.volume_avg, 100.0);
6902 assert!(state.volume_history.is_empty());
6903 assert!(
6904 state.active_alerts.is_empty(),
6905 "{:?}",
6906 state.active_alerts.len()
6907 );
6908 let text = metrics_text(&state);
6909 let row = text.lines().find(|l| l.contains("Vol 24h")).expect(&text);
6910 assert!(row.contains("n/a"), "{row}");
6911 }
6912
6913 #[test]
6914 fn test_startup_line_names_the_venue_and_pair_in_venue_only_mode() {
6915 assert_eq!(
6916 state::source_label("ethereum", Some(("binance", "USDCUSDT"))),
6917 "BINANCE USDCUSDT"
6918 );
6919 assert_eq!(state::source_label("ethereum", None), "ETHEREUM");
6920 assert_eq!(venue_only_state().source_label(), "BINANCE USDCUSDT");
6921 }
6922
6923 #[tokio::test]
6924 async fn test_a_failed_venue_ticker_keeps_the_price_and_names_the_venue() {
6925 let (_server, ex) = mock_venue(500).await;
6926 let dex = MockDexDataSource::new(create_test_token_data());
6927 let calls = dex.calls.clone();
6928 let mut state = venue_only_state();
6929 state.venue_pair = Some("USDN_USDT".into());
6930 let before = state.current_price;
6931 let mut app = create_test_app_with_state(state, Box::new(dex), None);
6932 app.exchange_client = Some(ex);
6933 app.fetch_data().await;
6934 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
6935 assert_eq!(app.state.current_price, before, "the old price stays");
6936 let (text, _) = header_text(&app.state);
6937 assert!(text.contains("Venue error ("), "{text}");
6938 }
6939
6940 #[tokio::test]
6941 async fn test_venue_only_without_a_venue_says_so() {
6942 let dex = MockDexDataSource::new(create_test_token_data());
6944 let calls = dex.calls.clone();
6945 let mut app = create_test_app_with_state(venue_only_state(), Box::new(dex), None);
6946 app.fetch_data().await;
6947 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
6948 assert!(app.state.error_message.is_some());
6949 }
6950
6951 #[test]
6952 fn test_every_layout_draws_at_60x16_with_quit_and_pause() {
6953 let mut state = create_populated_state();
6954 state.error_message = Some("API Error: boom".into());
6955 state.paused = true;
6956 let mut terminal = Terminal::new(TestBackend::new(60, 3)).unwrap();
6957 terminal
6958 .draw(|f| render_footer(f, f.area(), &state))
6959 .unwrap();
6960 let row = format!("{}", terminal.backend());
6961 assert!(row.contains("[q]uit") && row.contains("PAUSED"), "{row}");
6962
6963 state.auto_layout = false;
6964 for layout in [
6965 LayoutPreset::Dashboard,
6966 LayoutPreset::ChartFocus,
6967 LayoutPreset::Feed,
6968 LayoutPreset::Compact,
6969 LayoutPreset::Exchange,
6970 ] {
6971 state.layout = layout;
6972 let text = ui_text(&mut state, 60, 16);
6973 assert!(text.contains("[q]uit"), "{layout:?}: {text}");
6974 assert_eq!(state.layout, layout);
6975 }
6976 }
6977 #[derive(Clone, Default)]
6981 struct NoPairsDex {
6982 chains: Arc<std::sync::Mutex<Vec<String>>>,
6983 }
6984
6985 #[async_trait::async_trait]
6986 impl DexDataSource for NoPairsDex {
6987 async fn get_token_price(&self, _: &str, _: &str) -> Option<f64> {
6988 None
6989 }
6990 async fn get_native_token_price(&self, _: &str) -> Option<f64> {
6991 None
6992 }
6993 async fn get_token_data(&self, chain: &str, _: &str) -> Result<DexTokenData> {
6994 self.chains.lock().unwrap().push(chain.to_string());
6995 Err(ScopeError::NotFound("no pairs for this token".into()))
6996 }
6997 async fn search_tokens(
6998 &self,
6999 _: &str,
7000 _: Option<&str>,
7001 ) -> Result<Vec<scope::chains::TokenSearchResult>> {
7002 Ok(vec![])
7003 }
7004 }
7005
7006 struct NoPairsFactory {
7009 dex: NoPairsDex,
7010 }
7011
7012 impl scope::chains::ChainClientFactory for NoPairsFactory {
7013 fn create_chain_client(&self, chain: &str) -> Result<Box<dyn ChainClient>> {
7014 Err(ScopeError::Chain(format!("no {chain} client in this test")))
7015 }
7016 fn create_dex_client(&self) -> Box<dyn DexDataSource> {
7017 Box::new(self.dex.clone())
7018 }
7019 fn http(&self) -> Arc<dyn scope::http::HttpClient> {
7020 scope::http::test_transport()
7021 }
7022 }
7023
7024 const USDC_ADDRESS: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
7025
7026 fn config_with_address_book(dir: &std::path::Path) -> Config {
7028 Config {
7029 address_book: scope::config::AddressBookConfig {
7030 data_dir: Some(dir.to_path_buf()),
7031 },
7032 ..Default::default()
7033 }
7034 }
7035
7036 fn dashboard_args(token: &str) -> MonitorArgs {
7037 MonitorArgs {
7038 token: token.to_string(),
7039 token_contract: None,
7040 chain: "ethereum".to_string(),
7041 layout: None,
7042 refresh: None,
7043 scale: None,
7044 color_scheme: None,
7045 export: None,
7046 venue: Vec::new(),
7047 pair: None,
7048 watch: Default::default(),
7049 }
7050 }
7051
7052 #[tokio::test]
7053 async fn test_run_without_a_token_says_how_to_use_it() {
7054 let factory = NoPairsFactory {
7055 dex: NoPairsDex::default(),
7056 };
7057 let start = MonitorStart {
7058 token: None,
7059 explicit_pair: None,
7060 export_file: None,
7061 layout_pinned: false,
7062 };
7063 let (out, cap) = Output::capture();
7064 let err = run(
7065 start,
7066 &SessionContext::default(),
7067 &Config::default(),
7068 &factory,
7069 &out,
7070 )
7071 .await
7072 .unwrap_err();
7073 assert!(err.to_string().contains("Usage: monitor <token>"), "{err}");
7074 assert_eq!(cap.err(), "", "nothing starts");
7075 assert!(factory.dex.chains.lock().unwrap().is_empty());
7076 }
7077
7078 #[tokio::test]
7081 async fn test_the_dashboard_with_two_venues_stops_before_any_lookup() {
7082 let factory = NoPairsFactory {
7083 dex: NoPairsDex::default(),
7084 };
7085 let mut args = dashboard_args("USDN");
7086 args.venue = vec!["weex".to_string(), "binance".to_string()];
7087 let (out, _cap) = Output::capture();
7088 let err = run_direct(args, &Config::default(), &factory, &out)
7089 .await
7090 .unwrap_err();
7091 assert!(err.to_string().contains(DASHBOARD_ONE_VENUE), "{err}");
7092 assert!(factory.dex.chains.lock().unwrap().is_empty());
7093 }
7094
7095 #[tokio::test]
7096 async fn test_run_with_an_unknown_venue_stops_before_any_lookup() {
7097 let factory = NoPairsFactory {
7098 dex: NoPairsDex::default(),
7099 };
7100 let mut config = Config::default();
7101 config.monitor.venue = Some("binace".to_string());
7102 let start = MonitorStart {
7103 token: Some("USDC".to_string()),
7104 explicit_pair: None,
7105 export_file: None,
7106 layout_pinned: false,
7107 };
7108 let (out, cap) = Output::capture();
7109 let err = run(start, &SessionContext::default(), &config, &factory, &out)
7110 .await
7111 .unwrap_err();
7112 assert!(matches!(err, ScopeError::InvalidInput(_)), "{err:?}");
7113 assert!(
7114 err.to_string().contains("Did you mean \"binance\""),
7115 "{err}"
7116 );
7117 let told = cap.err();
7118 assert!(told.contains("Starting live monitor for USDC..."), "{told}");
7119 assert!(told.contains("Fetching initial data..."), "{told}");
7120 assert_eq!(cap.out(), "", "notes are not data");
7121 assert!(factory.dex.chains.lock().unwrap().is_empty());
7122 }
7123
7124 #[tokio::test]
7125 async fn test_run_without_a_venue_returns_the_dexscreener_error() {
7126 let factory = NoPairsFactory {
7128 dex: NoPairsDex::default(),
7129 };
7130 let ctx = SessionContext {
7131 chain: "base".to_string(),
7132 ..SessionContext::default()
7133 };
7134 let start = MonitorStart {
7135 token: Some(USDC_ADDRESS.to_string()),
7136 explicit_pair: None,
7137 export_file: None,
7138 layout_pinned: true,
7139 };
7140 let err = run(
7141 start,
7142 &ctx,
7143 &Config::default(),
7144 &factory,
7145 &Output::capture().0,
7146 )
7147 .await
7148 .unwrap_err();
7149 assert!(matches!(err, ScopeError::NotFound(_)), "{err:?}");
7150 assert!(err.to_string().contains("no pairs for this token"), "{err}");
7151 assert_eq!(*factory.dex.chains.lock().unwrap(), vec!["base"]);
7152 }
7153
7154 #[tokio::test]
7155 async fn test_run_direct_notes_the_deprecation_and_prefers_the_token_contract() {
7156 let dir = tempfile::tempdir().unwrap();
7157 let factory = NoPairsFactory {
7158 dex: NoPairsDex::default(),
7159 };
7160 let mut args = dashboard_args("USDC");
7161 args.token_contract = Some(USDC_ADDRESS.to_string());
7162 args.chain = "polygon".to_string();
7163 let (out, cap) = Output::capture();
7164 let err = run_direct(args, &config_with_address_book(dir.path()), &factory, &out)
7165 .await
7166 .unwrap_err();
7167 assert!(err.to_string().contains("no pairs"), "{err}");
7168 let told = cap.err();
7169 assert!(told.starts_with(DASHBOARD_DEPRECATED), "{told}");
7170 assert!(
7171 told.contains(&format!("Starting live monitor for {USDC_ADDRESS}...")),
7172 "--token wins over the symbol: {told}"
7173 );
7174 assert_eq!(*factory.dex.chains.lock().unwrap(), vec!["polygon"]);
7175 }
7176
7177 #[tokio::test]
7178 async fn test_run_direct_resolves_an_address_book_label_and_its_chain() {
7179 use scope::domain::address_book::{AddressBook, WatchedAddress};
7180 let dir = tempfile::tempdir().unwrap();
7181 let mut book = AddressBook::default();
7182 book.add_address(WatchedAddress {
7183 address: USDC_ADDRESS.to_string(),
7184 label: Some("treasury".to_string()),
7185 chain: "arbitrum".to_string(),
7186 tags: vec![],
7187 added_at: 0,
7188 })
7189 .unwrap();
7190 book.save(&dir.path().to_path_buf()).unwrap();
7191 let config = config_with_address_book(dir.path());
7192
7193 let factory = NoPairsFactory {
7195 dex: NoPairsDex::default(),
7196 };
7197 let (out, cap) = Output::capture();
7198 run_direct(dashboard_args("@treasury"), &config, &factory, &out)
7199 .await
7200 .unwrap_err();
7201 assert!(cap.err().contains(&format!("for {USDC_ADDRESS}...")));
7202 assert_eq!(*factory.dex.chains.lock().unwrap(), vec!["arbitrum"]);
7203
7204 let factory = NoPairsFactory {
7206 dex: NoPairsDex::default(),
7207 };
7208 let mut args = dashboard_args("@treasury");
7209 args.chain = "optimism".to_string();
7210 run_direct(args, &config, &factory, &Output::capture().0)
7211 .await
7212 .unwrap_err();
7213 assert_eq!(*factory.dex.chains.lock().unwrap(), vec!["optimism"]);
7214
7215 let err = run_direct(
7217 dashboard_args("@nosuch"),
7218 &config,
7219 &factory,
7220 &Output::capture().0,
7221 )
7222 .await
7223 .unwrap_err();
7224 assert!(err.to_string().contains("@treasury"), "{err}");
7225 }
7226
7227 #[tokio::test]
7228 async fn test_run_direct_pair_without_venue_is_an_error() {
7229 let dir = tempfile::tempdir().unwrap();
7230 let factory = NoPairsFactory {
7231 dex: NoPairsDex::default(),
7232 };
7233 let mut args = dashboard_args("USDC");
7234 args.pair = Some("USDC_USDT".to_string());
7235 let err = run_direct(
7236 args,
7237 &config_with_address_book(dir.path()),
7238 &factory,
7239 &Output::capture().0,
7240 )
7241 .await
7242 .unwrap_err();
7243 assert!(
7244 err.to_string()
7245 .contains("--pair requires --venue to be specified"),
7246 "{err}"
7247 );
7248 assert!(
7249 factory.dex.chains.lock().unwrap().is_empty(),
7250 "no DEX lookup"
7251 );
7252 }
7253
7254 #[test]
7255 fn test_monitor_config_from_args_applies_each_flag_over_the_config() {
7256 let mut config = Config::default();
7257 config.monitor.refresh_seconds = 30;
7258 config.monitor.venue = Some("okx".to_string());
7259 let mut args = dashboard_args("USDC");
7260 args.layout = Some(LayoutPreset::Compact);
7261 args.refresh = Some(2);
7262 args.scale = Some(ScaleMode::Log);
7263 args.color_scheme = Some(ColorScheme::BlueOrange);
7264 args.venue = vec!["kraken".to_string()];
7265 args.export = Some(PathBuf::from("run.csv"));
7266
7267 let (mc, export_file, pinned) = monitor_config_from_args(&args, &config);
7268 assert_eq!(mc.layout, LayoutPreset::Compact);
7269 assert_eq!(mc.refresh_seconds, 2);
7270 assert_eq!(mc.scale, ScaleMode::Log);
7271 assert_eq!(mc.color_scheme, ColorScheme::BlueOrange);
7272 assert_eq!(mc.venue.as_deref(), Some("kraken"));
7273 assert_eq!(export_file, Some(PathBuf::from("run.csv")));
7274 assert!(pinned);
7275
7276 let (mc, export_file, pinned) = monitor_config_from_args(&dashboard_args("USDC"), &config);
7278 assert_eq!(mc.refresh_seconds, 30);
7279 assert_eq!(mc.venue.as_deref(), Some("okx"));
7280 assert_eq!(mc.layout, config.monitor.layout);
7281 assert_eq!(export_file, None);
7282 assert!(!pinned);
7283 }
7284
7285 #[tokio::test]
7286 async fn test_fetch_initial_data_without_a_venue_returns_the_dex_error() {
7287 let err = fetch_initial_data(
7288 USDC_ADDRESS,
7289 None,
7290 "ethereum",
7291 &BlockedDex,
7292 None,
7293 scope::http::test_transport(),
7294 &Output::capture().0,
7295 )
7296 .await
7297 .unwrap_err();
7298 assert!(matches!(err, ScopeError::Network(_)), "{err:?}");
7299 }
7300
7301 const KLINES: &str = r#"[
7305 [1700000000000, "1.00", "1.20", "0.90", "1.10", "100", 1700000059999],
7306 [1700000060000, "1.10", "1.15", "1.00", "1.05", "80", 1700000119999]
7307 ]"#;
7308
7309 #[tokio::test]
7310 async fn test_fetch_data_takes_venue_candles_and_names_a_failed_trade_fetch() {
7311 let mut server = mockito::Server::new_async().await;
7312 server
7313 .mock("GET", "/api/v3/klines")
7314 .match_query(mockito::Matcher::AllOf(vec![
7315 mockito::Matcher::UrlEncoded("symbol".into(), "USDCUSDT".into()),
7316 mockito::Matcher::UrlEncoded("limit".into(), "100".into()),
7317 ]))
7318 .with_body(KLINES)
7319 .create_async()
7320 .await;
7321 server
7322 .mock("GET", "/api/v3/depth")
7323 .match_query(mockito::Matcher::Any)
7324 .with_body(r#"{"bids":[["0.9991","1234"]],"asks":[["1.0009","4321"]]}"#)
7325 .create_async()
7326 .await;
7327 let mut app = create_test_app(
7329 Box::new(MockDexDataSource::new(create_test_token_data())),
7330 None,
7331 );
7332 app.exchange_client = Some(scope::market::testing::venue_at("binance", &server.url()));
7333 app.state.venue_pair = Some("USDCUSDT".into());
7334 app.fetch_data().await;
7335
7336 let candles = &app.state.exchange_ohlc;
7337 assert_eq!(candles.len(), 2);
7338 assert_eq!(candles[0].timestamp, 1_700_000_000.0, "ms to seconds");
7339 assert_eq!(
7340 (
7341 candles[0].open,
7342 candles[0].high,
7343 candles[0].low,
7344 candles[0].close
7345 ),
7346 (1.0, 1.2, 0.9, 1.1)
7347 );
7348 assert!(candles[0].is_bullish);
7349 assert!(!candles[1].is_bullish);
7350 assert_eq!(
7351 app.state.order_book.as_ref().unwrap().source,
7352 scope::market::BookSource::Exchange
7353 );
7354 let error = app.state.error_message.as_deref().unwrap();
7355 assert!(error.starts_with("binance trades: "), "{error}");
7356 }
7357
7358 #[tokio::test]
7359 async fn test_fetch_data_names_a_failed_venue_book_and_keeps_dex_candles() {
7360 let mut server = mockito::Server::new_async().await;
7361 server
7363 .mock("GET", "/api/v3/trades")
7364 .match_query(mockito::Matcher::Any)
7365 .with_body(r#"[{"id":1,"price":"1.0001","qty":"50","quoteQty":"50","time":1700000000000,"isBuyerMaker":false}]"#)
7366 .create_async()
7367 .await;
7368 let mut app = create_test_app(
7369 Box::new(MockDexDataSource::new(create_test_token_data())),
7370 None,
7371 );
7372 app.exchange_client = Some(scope::market::testing::venue_at("binance", &server.url()));
7373 app.state.venue_pair = Some("USDCUSDT".into());
7374 app.fetch_data().await;
7375
7376 assert!(
7377 app.state.exchange_ohlc.is_empty(),
7378 "a failed OHLC fetch leaves the candles built from DEX data"
7379 );
7380 assert_eq!(app.state.recent_trades.len(), 1);
7381 let error = app.state.error_message.as_deref().unwrap();
7382 assert!(error.starts_with("binance order book: "), "{error}");
7383 }
7384}