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