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