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