pub mod config;
pub mod input;
pub mod state;
pub mod widgets;
pub use config::{
AlertConfig, ChartMode, ColorScheme, ExportConfig, LayoutPreset, MonitorConfig, ScaleMode,
TimePeriod, WidgetVisibility,
};
pub use input::MonitorApp;
pub use state::MonitorState;
use clap::Args;
use scope::chains::ChainClientFactory;
use scope::chains::dex::{DexDataSource, DexTokenData};
use scope::config::Config;
use scope::error::{Result, ScopeError};
use std::io;
use std::path::PathBuf;
use std::time::Duration;
use super::interactive::SessionContext;
use crate::cli::output::Output;
use crate::note;
#[derive(Debug, Args)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
scope monitor USDC
scope monitor @dai-token \x1b[2m# address book shortcut\x1b[0m
scope monitor PEPE --chain ethereum --layout chart-focus
scope monitor DAI --venue binance --pair DAI_USDT
scope monitor BTC --venue binance --refresh 5 --scale log")]
pub struct MonitorArgs {
pub token: String,
#[arg(short, long, default_value = scope::chains::DEFAULT_CHAIN)]
pub chain: String,
#[arg(short, long)]
pub layout: Option<LayoutPreset>,
#[arg(short, long)]
pub refresh: Option<u64>,
#[arg(short, long)]
pub scale: Option<ScaleMode>,
#[arg(long)]
pub color_scheme: Option<ColorScheme>,
#[arg(short, long, value_name = "PATH")]
pub export: Option<PathBuf>,
#[arg(long, value_name = "VENUE")]
pub venue: Option<String>,
#[arg(long, value_name = "PAIR")]
pub pair: Option<String>,
}
pub async fn run_direct(
mut args: MonitorArgs,
config: &Config,
clients: &dyn ChainClientFactory,
out: &Output,
) -> Result<()> {
if let Some((address, chain)) =
crate::cli::address_book::resolve_address_book_input(&args.token, config)?
{
args.token = address;
if args.chain == "ethereum" {
args.chain = chain;
}
}
let (monitor_config, export_file, layout_pinned) = monitor_config_from_args(&args, config);
let ctx = SessionContext {
chain: args.chain,
..SessionContext::default()
};
let mut effective_config = config.clone();
effective_config.monitor = monitor_config;
let start = MonitorStart {
token: Some(args.token),
explicit_pair: args.pair,
export_file,
layout_pinned,
};
run(start, &ctx, &effective_config, clients, out).await
}
pub(crate) fn monitor_config_from_args(
args: &MonitorArgs,
config: &Config,
) -> (scope::config::MonitorConfig, Option<PathBuf>, bool) {
let mut monitor_config = config.monitor.clone();
if let Some(layout) = args.layout {
monitor_config.layout = layout;
}
if let Some(refresh) = args.refresh {
monitor_config.refresh_seconds = refresh;
}
if let Some(scale) = args.scale {
monitor_config.scale = scale;
}
if let Some(color_scheme) = args.color_scheme {
monitor_config.color_scheme = color_scheme;
}
if let Some(ref venue) = args.venue {
monitor_config.venue = Some(venue.clone());
}
(monitor_config, args.export.clone(), args.layout.is_some())
}
pub struct MonitorStart {
pub token: Option<String>,
pub explicit_pair: Option<String>,
pub export_file: Option<PathBuf>,
pub layout_pinned: bool,
}
pub async fn run(
start: MonitorStart,
ctx: &SessionContext,
config: &Config,
clients: &dyn ChainClientFactory,
out: &Output,
) -> Result<()> {
let MonitorStart {
token,
explicit_pair,
export_file,
layout_pinned,
} = start;
let token_input = match token {
Some(t) => t,
None => {
return Err(ScopeError::Chain(
"Token address or symbol required. Usage: monitor <token>".to_string(),
));
}
};
note!(out, "Starting live monitor for {}...", token_input);
note!(out, "Fetching initial data...");
let venue = config.monitor.venue.as_deref();
let exchange_client = exchange_for_venue(venue)?;
let dex_client = clients.create_dex_client();
let (initial_data, venue_quote) = fetch_initial_data(
&token_input,
explicit_pair.as_deref(),
&ctx.chain,
dex_client.as_ref(),
venue.zip(exchange_client.as_ref()),
out,
)
.await?;
let source = match (&venue_quote, venue) {
(Some((pair, _)), Some(venue_id)) => {
state::source_label(&ctx.chain, Some((venue_id, pair)))
}
_ => ctx.chain.clone(),
};
note!(
out,
"Monitoring {} ({}) on {}",
initial_data.symbol,
initial_data.name,
source
);
note!(out, "Press Q to quit, R to refresh, P to pause...\n");
tokio::time::sleep(Duration::from_millis(500)).await;
let chain_client = clients.create_chain_client(&ctx.chain).ok();
let mut app = MonitorApp::new(
initial_data,
&ctx.chain,
&config.monitor,
clients.create_dex_client(),
chain_client,
exchange_client,
)?;
app.state.auto_layout = !layout_pinned;
app.state.thresholds = config.market.health.clone();
if let Some((pair_str, ticker)) = venue_quote {
app.state.venue_pair = Some(pair_str);
app.state.set_venue_ticker(&ticker);
}
if let Some(path) = export_file {
app.state.start_export_to(&path).map_err(|e| {
ScopeError::io_context(e, format!("cannot write --export {}", path.display()))
})?;
}
let result = app.run().await;
if let Err(e) = app.cleanup() {
note!(out, "Warning: Failed to cleanup terminal: {}", e);
}
result
}
pub(crate) fn exchange_for_venue(
venue: Option<&str>,
) -> Result<Option<scope::market::ExchangeClient>> {
let Some(id) = venue else {
return Ok(None);
};
scope::market::VenueRegistry::load()?
.create_exchange_client(id)
.map(Some)
}
pub(crate) async fn fetch_initial_data(
token_input: &str,
explicit_pair: Option<&str>,
chain: &str,
dex_client: &dyn DexDataSource,
venue: Option<(&str, &scope::market::ExchangeClient)>,
out: &Output,
) -> Result<(DexTokenData, Option<(String, scope::market::Ticker)>)> {
if let Some(pair) = explicit_pair {
let (_, ex) = venue.ok_or_else(|| {
ScopeError::Chain("--pair requires --venue to be specified".to_string())
})?;
let pair = crate::cli::market::venue_pair(ex, pair);
let (data, ticker) = exchange_token_data(ex, &pair, token_input, out).await?;
return Ok((data, Some((pair, ticker))));
}
let dex = async {
let address =
resolve_token_address(token_input, chain, dex_client, venue.is_none(), out).await?;
dex_client.get_token_data(chain, &address).await
}
.await;
match (dex, venue) {
(Ok(data), _) => Ok((data, None)),
(Err(dex_err), Some((venue_id, ex))) => {
let pair = crate::cli::market::venue_pair(ex, token_input);
note!(
out,
"DexScreener lookup failed ({}); using the {} ticker {} instead",
dex_err,
venue_id,
pair
);
match exchange_token_data(ex, &pair, token_input, out).await {
Ok((data, ticker)) => Ok((data, Some((pair, ticker)))),
Err(ex_err) => Err(ScopeError::Other(format!(
"DexScreener lookup failed: {}; {} ticker {} failed: {}",
dex_err, venue_id, pair, ex_err
))),
}
}
(Err(e), None) => Err(e),
}
}
async fn exchange_token_data(
ex: &scope::market::ExchangeClient,
pair: &str,
token_input: &str,
out: &Output,
) -> Result<(DexTokenData, scope::market::Ticker)> {
let ticker = ex
.fetch_ticker(pair)
.await
.map_err(|e| ScopeError::Other(format!("Failed to fetch ticker for {}: {}", pair, e)))?;
let base_symbol = ticker
.pair
.split('/')
.next()
.filter(|s| !s.is_empty())
.unwrap_or(token_input)
.to_string();
note!(
out,
"Exchange-only mode: {} @ {}",
pair,
scope::display::format::format_price_usd(ticker.last_price.unwrap_or(0.0))
);
let data = build_exchange_token_data(&base_symbol, pair, &ticker);
Ok((data, ticker))
}
fn build_exchange_token_data(
symbol: &str,
pair_label: &str,
ticker: &scope::market::Ticker,
) -> DexTokenData {
let price = ticker.last_price.unwrap_or(0.0);
DexTokenData {
address: format!("exchange:{}", pair_label),
symbol: symbol.to_string(),
name: symbol.to_string(),
price_usd: price,
price_change_24h: ticker.price_change_pct_24h.unwrap_or(0.0),
price_change_6h: 0.0,
price_change_1h: 0.0,
price_change_5m: 0.0,
volume_24h: ticker.usd_volume().unwrap_or(0.0),
volume_6h: 0.0,
volume_1h: 0.0,
liquidity_usd: 0.0,
market_cap: None,
fdv: None,
pairs: vec![],
price_history: vec![],
volume_history: vec![],
total_buys_24h: 0,
total_sells_24h: 0,
total_buys_6h: 0,
total_sells_6h: 0,
total_buys_1h: 0,
total_sells_1h: 0,
earliest_pair_created_at: None,
image_url: None,
websites: vec![],
socials: vec![],
dexscreener_url: None,
}
}
async fn resolve_token_address(
input: &str,
chain: &str,
dex_client: &dyn DexDataSource,
allow_cex_fallback: bool,
out: &Output,
) -> Result<String> {
if scope::tokens::TokenAliases::is_address(input) {
return Ok(input.to_string());
}
let chain_filter = if chain != "ethereum" {
Some(chain)
} else {
None
};
let aliases = scope::tokens::TokenAliases::load();
if let Some(alias) = aliases.get(input, chain_filter) {
return Ok(alias.address.clone());
}
let mut results = dex_client.search_tokens(input, chain_filter).await?;
if results.is_empty()
&& allow_cex_fallback
&& let Some(fallback) = crate::cli::token_pick::cex_fallback(input, chain, None, out).await
{
results.push(fallback);
}
if results.is_empty() {
return Err(ScopeError::NotFound(format!(
"No token found matching '{}' on {} (checked DexScreener and CEX venues)",
input, chain
)));
}
if results.len() == 1 {
let token = &results[0];
crate::cli::token_pick::announce(None, out, token);
return Ok(token.address.clone());
}
let selected = select_token_interactive(&results, out)?;
Ok(selected.address.clone())
}
fn select_token_interactive<'a>(
results: &'a [scope::chains::dex::TokenSearchResult],
out: &Output,
) -> Result<&'a scope::chains::dex::TokenSearchResult> {
select_token_impl(results, &mut crate::cli::prompt::stdin(), out)
}
fn select_token_impl<'a>(
results: &'a [scope::chains::dex::TokenSearchResult],
reader: &mut impl io::BufRead,
out: &Output,
) -> Result<&'a scope::chains::dex::TokenSearchResult> {
crate::cli::token_pick::pick_token(reader, None, out, results)
}
#[cfg(test)]
mod tests {
use super::config::*;
use super::input::*;
use super::widgets::*;
use super::*;
use scope::tokens::is_stablecoin_price;
use crossterm::event::{KeyCode, KeyModifiers};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use scope::chains::ChainClient;
use scope::market::{Trade, TradeSide};
use std::fs;
use std::io::Write as _;
use std::time::Instant;
fn create_test_token_data() -> DexTokenData {
DexTokenData {
address: "0x1234".to_string(),
symbol: "TEST".to_string(),
name: "Test Token".to_string(),
price_usd: 1.0,
price_change_24h: 5.0,
price_change_6h: 2.0,
price_change_1h: 0.5,
price_change_5m: 0.1,
volume_24h: 1_000_000.0,
volume_6h: 250_000.0,
volume_1h: 50_000.0,
liquidity_usd: 500_000.0,
market_cap: Some(10_000_000.0),
fdv: Some(100_000_000.0),
pairs: vec![],
price_history: vec![],
volume_history: vec![],
total_buys_24h: 100,
total_sells_24h: 50,
total_buys_6h: 25,
total_sells_6h: 12,
total_buys_1h: 5,
total_sells_1h: 3,
earliest_pair_created_at: Some(1700000000000),
image_url: None,
websites: vec![],
socials: vec![],
dexscreener_url: None,
}
}
#[test]
fn test_monitor_state_new() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
assert_eq!(state.symbol, "TEST");
assert_eq!(state.chain, "ethereum");
assert_eq!(state.current_price, 1.0);
assert_eq!(state.buys_24h, 100);
assert_eq!(state.sells_24h, 50);
assert!(!state.paused);
}
#[test]
fn test_monitor_state_buy_ratio() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
let ratio = state.buy_ratio();
assert!((ratio - 0.6666).abs() < 0.01); }
#[test]
fn test_monitor_state_buy_ratio_zero() {
let mut token_data = create_test_token_data();
token_data.total_buys_24h = 0;
token_data.total_sells_24h = 0;
let state = MonitorState::new(&token_data, "ethereum");
assert_eq!(state.buy_ratio(), 0.5); }
#[test]
fn test_monitor_state_toggle_pause() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert!(!state.paused);
state.toggle_pause();
assert!(state.paused);
state.toggle_pause();
assert!(!state.paused);
}
#[test]
fn test_monitor_state_should_refresh() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.refresh_rate = Duration::from_secs(60);
assert!(!state.should_refresh());
state.last_update = Instant::now() - Duration::from_secs(120);
assert!(state.should_refresh());
state.paused = true;
assert!(!state.should_refresh());
}
#[test]
fn test_format_number() {
assert_eq!(format_number(500.0), "500.00");
assert_eq!(format_number(1_500.0), "1.50K");
assert_eq!(format_number(1_500_000.0), "1.50M");
assert_eq!(format_number(1_500_000_000.0), "1.50B");
}
#[test]
fn test_format_usd() {
assert_eq!(scope::display::format_usd(500.0), "$500.00");
assert_eq!(scope::display::format_usd(1_500.0), "$1.50K");
assert_eq!(scope::display::format_usd(1_500_000.0), "$1.50M");
assert_eq!(scope::display::format_usd(1_500_000_000.0), "$1.50B");
}
#[test]
fn test_monitor_state_update() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let initial_len = state.price_history.len();
let mut updated_data = token_data.clone();
updated_data.price_usd = 1.5;
updated_data.total_buys_24h = 150;
state.update(&updated_data);
assert_eq!(state.current_price, 1.5);
assert_eq!(state.buys_24h, 150);
assert_eq!(state.price_history.len(), initial_len + 1);
}
#[test]
fn test_monitor_state_refresh_rate_adjustment() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert_eq!(state.refresh_rate_secs(), 5);
state.slower_refresh();
assert_eq!(state.refresh_rate_secs(), 10);
state.faster_refresh();
assert_eq!(state.refresh_rate_secs(), 5);
state.faster_refresh();
assert_eq!(state.refresh_rate_secs(), 1);
state.faster_refresh();
assert_eq!(state.refresh_rate_secs(), 1);
for _ in 0..20 {
state.slower_refresh();
}
assert_eq!(state.refresh_rate_secs(), 60);
}
#[test]
fn test_time_period() {
assert_eq!(TimePeriod::Min1.label(), "1m");
assert_eq!(TimePeriod::Min5.label(), "5m");
assert_eq!(TimePeriod::Min15.label(), "15m");
assert_eq!(TimePeriod::Hour1.label(), "1h");
assert_eq!(TimePeriod::Hour4.label(), "4h");
assert_eq!(TimePeriod::Day1.label(), "1d");
assert_eq!(TimePeriod::Min1.duration_secs(), 60);
assert_eq!(TimePeriod::Min5.duration_secs(), 300);
assert_eq!(TimePeriod::Min15.duration_secs(), 15 * 60);
assert_eq!(TimePeriod::Hour1.duration_secs(), 3600);
assert_eq!(TimePeriod::Hour4.duration_secs(), 4 * 3600);
assert_eq!(TimePeriod::Day1.duration_secs(), 24 * 3600);
assert_eq!(TimePeriod::Min1.next(), TimePeriod::Min5);
assert_eq!(TimePeriod::Min5.next(), TimePeriod::Min15);
assert_eq!(TimePeriod::Min15.next(), TimePeriod::Hour1);
assert_eq!(TimePeriod::Hour1.next(), TimePeriod::Hour4);
assert_eq!(TimePeriod::Hour4.next(), TimePeriod::Day1);
assert_eq!(TimePeriod::Day1.next(), TimePeriod::Min1);
}
#[test]
fn test_time_period_exchange_interval() {
assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
}
#[test]
fn test_monitor_state_time_period() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert_eq!(state.time_period, TimePeriod::Hour1);
state.cycle_time_period();
assert_eq!(state.time_period, TimePeriod::Hour4);
state.set_time_period(TimePeriod::Day1);
assert_eq!(state.time_period, TimePeriod::Day1);
}
#[test]
fn test_synthetic_history_generation() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
assert!(state.price_history.len() > 1);
assert!(state.volume_history.len() > 1);
if let (Some(first), Some(last)) = (state.price_history.front(), state.price_history.back())
{
let span = last.timestamp - first.timestamp;
assert!(span > 0.0); }
}
#[test]
fn test_real_data_marking() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let (synthetic, real) = state.data_stats();
assert!(synthetic > 0);
assert_eq!(real, 0);
let mut updated_data = token_data.clone();
updated_data.price_usd = 1.5;
state.update(&updated_data);
let (synthetic2, real2) = state.data_stats();
assert!(synthetic2 > 0);
assert_eq!(real2, 1);
assert_eq!(state.real_data_count, 1);
assert!(
state
.price_history
.back()
.map(|p| p.is_real)
.unwrap_or(false)
);
}
#[test]
fn test_memory_usage() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
let mem = state.memory_usage();
assert!(mem > 0);
let expected_point_size = std::mem::size_of::<DataPoint>();
assert_eq!(expected_point_size, 24);
}
#[test]
fn test_get_data_for_period_returns_flags() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let (data, is_real) = state.get_price_data_for_period();
assert_eq!(data.len(), is_real.len());
state.update(&token_data);
let (_data2, is_real2) = state.get_price_data_for_period();
assert!(is_real2.iter().any(|r| *r));
}
#[test]
fn test_cache_path_generation() {
let path =
MonitorState::cache_path("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "ethereum");
assert!(path.to_string_lossy().contains("bcc_monitor_"));
assert!(path.to_string_lossy().contains("ethereum"));
let temp_dir = std::env::temp_dir();
assert!(path.starts_with(temp_dir));
}
#[test]
fn test_cache_save_and_load() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "test_chain");
state.update(&token_data);
state.update(&token_data);
state.save_cache();
let path = MonitorState::cache_path(&state.token_address, &state.chain);
assert!(path.exists(), "Cache file should exist after save");
let loaded = MonitorState::load_cache(&state.token_address, &state.chain);
assert!(loaded.is_some(), "Should be able to load saved cache");
let cached = loaded.unwrap();
assert_eq!(cached.token_address, state.token_address);
assert_eq!(cached.chain, state.chain);
assert!(!cached.price_history.is_empty());
let _ = std::fs::remove_file(path);
}
#[test]
fn test_format_price_usd_high() {
let formatted = format_price_usd(2500.50);
assert!(formatted.starts_with("$2500.50"));
}
#[test]
fn test_format_price_usd_stablecoin() {
let formatted = format_price_usd(1.0001);
assert!(formatted.contains("1.000100"));
assert!(is_stablecoin_price(1.0001));
}
#[test]
fn test_format_price_usd_medium() {
let formatted = format_price_usd(5.1234);
assert!(formatted.starts_with("$5.1234"));
}
#[test]
fn test_format_price_usd_small() {
let formatted = format_price_usd(0.05);
assert!(formatted.starts_with("$0.0500"));
}
#[test]
fn test_format_price_usd_micro() {
let formatted = format_price_usd(0.001);
assert!(formatted.starts_with("$0.0010"));
}
#[test]
fn test_format_price_usd_nano() {
let formatted = format_price_usd(0.00001);
assert!(formatted.contains("0.0000100"));
}
#[test]
fn test_is_stablecoin_price() {
assert!(is_stablecoin_price(1.0));
assert!(is_stablecoin_price(0.999));
assert!(is_stablecoin_price(1.001));
assert!(is_stablecoin_price(0.95));
assert!(is_stablecoin_price(1.05));
assert!(!is_stablecoin_price(0.94));
assert!(!is_stablecoin_price(1.06));
assert!(!is_stablecoin_price(100.0));
}
#[test]
fn test_ohlc_candle_new() {
let candle = OhlcCandle::new(1000.0, 50.0);
assert_eq!(candle.open, 50.0);
assert_eq!(candle.high, 50.0);
assert_eq!(candle.low, 50.0);
assert_eq!(candle.close, 50.0);
assert!(candle.is_bullish);
}
#[test]
fn test_ohlc_candle_update() {
let mut candle = OhlcCandle::new(1000.0, 50.0);
candle.update(55.0);
assert_eq!(candle.high, 55.0);
assert_eq!(candle.close, 55.0);
assert!(candle.is_bullish);
candle.update(45.0);
assert_eq!(candle.low, 45.0);
assert_eq!(candle.close, 45.0);
assert!(!candle.is_bullish); }
#[test]
fn test_get_ohlc_candles() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
for i in 0..20 {
let mut data = token_data.clone();
data.price_usd = 1.0 + (i as f64 * 0.01);
state.update(&data);
}
let candles = state.get_ohlc_candles();
assert!(!candles.is_empty());
}
#[test]
fn test_get_ohlc_candles_returns_exchange_ohlc_when_populated() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let exchange_candles = vec![
OhlcCandle::new(1700000000.0, 100.0),
OhlcCandle::new(1700003600.0, 101.0),
];
state.exchange_ohlc = exchange_candles.clone();
let candles = state.get_ohlc_candles();
assert_eq!(candles.len(), 2);
assert_eq!(candles[0].timestamp, 1700000000.0);
assert_eq!(candles[0].open, 100.0);
assert_eq!(candles[1].timestamp, 1700003600.0);
assert_eq!(candles[1].open, 101.0);
}
#[test]
fn test_chart_mode_cycle() {
let mode = ChartMode::Line;
assert_eq!(mode.next(), ChartMode::Candlestick);
assert_eq!(ChartMode::Candlestick.next(), ChartMode::VolumeProfile);
assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
}
#[test]
fn test_chart_mode_label() {
assert_eq!(ChartMode::Line.label(), "Line");
assert_eq!(ChartMode::Candlestick.label(), "Candle");
assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
}
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn create_test_terminal() -> Terminal<TestBackend> {
let backend = TestBackend::new(120, 40);
Terminal::new(backend).unwrap()
}
fn create_populated_state() -> MonitorState {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
for i in 0..30 {
let mut data = token_data.clone();
data.price_usd = 1.0 + (i as f64 * 0.01);
data.volume_24h = 1_000_000.0 + (i as f64 * 10_000.0);
state.update(&data);
}
state
}
#[test]
fn test_render_header_no_panic() {
let mut terminal = create_test_terminal();
let state = create_populated_state();
terminal
.draw(|f| render_header(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_price_chart_no_panic() {
let mut terminal = create_test_terminal();
let state = create_populated_state();
terminal
.draw(|f| render_price_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_price_chart_line_mode() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.chart_mode = ChartMode::Line;
terminal
.draw(|f| render_price_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_candlestick_chart_no_panic() {
let mut terminal = create_test_terminal();
let state = create_populated_state();
terminal
.draw(|f| render_candlestick_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_candlestick_chart_empty() {
let mut terminal = create_test_terminal();
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
terminal
.draw(|f| render_candlestick_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_volume_chart_no_panic() {
let mut terminal = create_test_terminal();
let state = create_populated_state();
terminal
.draw(|f| render_volume_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_volume_chart_empty() {
let mut terminal = create_test_terminal();
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
terminal
.draw(|f| render_volume_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_buy_sell_gauge_no_panic() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
terminal
.draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
.unwrap();
}
#[test]
fn test_render_buy_sell_gauge_balanced() {
let mut terminal = create_test_terminal();
let mut token_data = create_test_token_data();
token_data.total_buys_24h = 100;
token_data.total_sells_24h = 100;
let mut state = MonitorState::new(&token_data, "ethereum");
terminal
.draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
.unwrap();
}
#[test]
fn test_render_metrics_panel_no_panic() {
let mut terminal = create_test_terminal();
let state = create_populated_state();
terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_metrics_panel_no_market_cap() {
let mut terminal = create_test_terminal();
let mut token_data = create_test_token_data();
token_data.market_cap = None;
token_data.fdv = None;
let state = MonitorState::new(&token_data, "ethereum");
terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_footer_no_panic() {
let mut terminal = create_test_terminal();
let state = create_populated_state();
terminal
.draw(|f| render_footer(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_footer_paused() {
let mut terminal = create_test_terminal();
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.paused = true;
terminal
.draw(|f| render_footer(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_all_components() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
terminal
.draw(|f| {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(5),
Constraint::Length(3),
Constraint::Length(3),
])
.split(area);
render_header(f, chunks[0], &state);
render_price_chart(f, chunks[1], &state);
render_volume_chart(f, chunks[2], &state);
render_buy_sell_gauge(f, chunks[3], &mut state);
render_footer(f, chunks[4], &state);
})
.unwrap();
}
#[test]
fn test_render_candlestick_mode() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.chart_mode = ChartMode::Candlestick;
terminal
.draw(|f| {
let area = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(10)])
.split(area);
render_header(f, chunks[0], &state);
render_candlestick_chart(f, chunks[1], &state);
})
.unwrap();
}
#[test]
fn test_render_with_different_time_periods() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
for period in [
TimePeriod::Min1,
TimePeriod::Min5,
TimePeriod::Min15,
TimePeriod::Hour1,
TimePeriod::Hour4,
TimePeriod::Day1,
] {
state.time_period = period;
terminal
.draw(|f| render_price_chart(f, f.area(), &state))
.unwrap();
}
}
#[test]
fn test_render_metrics_with_stablecoin() {
let mut terminal = create_test_terminal();
let mut token_data = create_test_token_data();
token_data.price_usd = 0.999;
token_data.symbol = "USDC".to_string();
let state = MonitorState::new(&token_data, "ethereum");
terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_header_with_negative_change() {
let mut terminal = create_test_terminal();
let mut token_data = create_test_token_data();
token_data.price_change_24h = -15.5;
token_data.price_change_1h = -2.3;
let state = MonitorState::new(&token_data, "ethereum");
terminal
.draw(|f| render_header(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_toggle_chart_mode_roundtrip() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert_eq!(state.chart_mode, ChartMode::Line);
state.toggle_chart_mode();
assert_eq!(state.chart_mode, ChartMode::Candlestick);
state.toggle_chart_mode();
assert_eq!(state.chart_mode, ChartMode::VolumeProfile);
state.toggle_chart_mode();
assert_eq!(state.chart_mode, ChartMode::Line);
}
#[test]
fn test_cycle_all_time_periods() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert_eq!(state.time_period, TimePeriod::Hour1);
state.cycle_time_period();
assert_eq!(state.time_period, TimePeriod::Hour4);
state.cycle_time_period();
assert_eq!(state.time_period, TimePeriod::Day1);
state.cycle_time_period();
assert_eq!(state.time_period, TimePeriod::Min1);
state.cycle_time_period();
assert_eq!(state.time_period, TimePeriod::Min5);
state.cycle_time_period();
assert_eq!(state.time_period, TimePeriod::Min15);
state.cycle_time_period();
assert_eq!(state.time_period, TimePeriod::Hour1);
}
#[test]
fn test_set_specific_time_period() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.set_time_period(TimePeriod::Day1);
assert_eq!(state.time_period, TimePeriod::Day1);
}
#[test]
fn test_pause_resume_roundtrip() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert!(!state.paused);
state.toggle_pause();
assert!(state.paused);
state.toggle_pause();
assert!(!state.paused);
}
#[test]
fn test_force_refresh_unpauses() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.paused = true;
state.force_refresh();
assert!(!state.paused);
assert!(state.should_refresh());
}
#[test]
fn test_refresh_rate_adjust() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert_eq!(state.refresh_rate_secs(), 5);
state.slower_refresh();
assert_eq!(state.refresh_rate_secs(), 10);
state.faster_refresh();
assert_eq!(state.refresh_rate_secs(), 5);
}
#[test]
fn test_faster_refresh_clamped_min() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
for _ in 0..10 {
state.faster_refresh();
}
assert!(state.refresh_rate_secs() >= 1);
}
#[test]
fn test_slower_refresh_clamped_max() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
for _ in 0..20 {
state.slower_refresh();
}
assert!(state.refresh_rate_secs() <= 60);
}
#[test]
fn test_buy_ratio_balanced() {
let mut token_data = create_test_token_data();
token_data.total_buys_24h = 100;
token_data.total_sells_24h = 100;
let state = MonitorState::new(&token_data, "ethereum");
assert!((state.buy_ratio() - 0.5).abs() < 0.01);
}
#[test]
fn test_buy_ratio_no_trades() {
let mut token_data = create_test_token_data();
token_data.total_buys_24h = 0;
token_data.total_sells_24h = 0;
let state = MonitorState::new(&token_data, "ethereum");
assert!((state.buy_ratio() - 0.5).abs() < 0.01);
}
#[test]
fn test_data_stats_initial() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
let (synthetic, real) = state.data_stats();
assert!(synthetic > 0 || real == 0);
}
#[test]
fn test_memory_usage_nonzero() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
let usage = state.memory_usage();
assert!(usage > 0);
}
#[test]
fn test_price_data_for_period() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
let (data, is_real) = state.get_price_data_for_period();
assert_eq!(data.len(), is_real.len());
}
#[test]
fn test_volume_data_for_period() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
let (data, is_real) = state.get_volume_data_for_period();
assert_eq!(data.len(), is_real.len());
}
#[test]
fn test_ohlc_candles_generation() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
let candles = state.get_ohlc_candles();
for candle in &candles {
assert!(candle.high >= candle.low);
}
}
#[test]
fn test_state_update_with_new_data() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let initial_count = state.real_data_count;
let mut updated_data = create_test_token_data();
updated_data.price_usd = 2.0;
updated_data.volume_24h = 2_000_000.0;
state.update(&updated_data);
assert_eq!(state.current_price, 2.0);
assert_eq!(state.real_data_count, initial_count + 1);
assert!(state.error_message.is_none());
}
#[test]
fn test_parallel_tests_do_not_share_a_cache_file() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
state.save_cache();
let mine = MonitorState::cache_path(&token_data.address, "ethereum");
let other = std::thread::spawn(move || {
let state = MonitorState::new(&create_test_token_data(), "ethereum");
state.save_cache();
let path = MonitorState::cache_path(&state.token_address, "ethereum");
let _ = std::fs::remove_file(&path);
path
})
.join()
.unwrap();
assert_ne!(mine, other);
assert!(mine.exists(), "the other test removed this test's cache");
assert!(MonitorState::load_cache(&token_data.address, "ethereum").is_some());
let _ = std::fs::remove_file(mine);
}
#[test]
fn test_a_new_monitor_resumes_from_its_cache() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let now = chrono::Utc::now().timestamp() as f64;
state.price_history.clear();
state.volume_history.clear();
for (age, value) in [(MAX_DATA_AGE_SECS + 60.0, 0.5), (120.0, 1.01), (60.0, 1.02)] {
let point = DataPoint {
timestamp: now - age,
value,
is_real: true,
};
state.price_history.push_back(point);
state.volume_history.push_back(point);
}
state.save_cache();
let resumed = MonitorState::new(&token_data, "ethereum");
let prices: Vec<f64> = resumed.price_history.iter().map(|p| p.value).collect();
assert_eq!(prices, vec![1.01, 1.02], "the 24h-old point is dropped");
assert_eq!(resumed.volume_history.len(), 2);
assert_eq!(resumed.real_data_count, 2);
let _ = std::fs::remove_file(MonitorState::cache_path(&token_data.address, "ethereum"));
}
#[test]
fn test_cache_roundtrip_save_load() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
state.save_cache();
let cache_path = MonitorState::cache_path(&token_data.address, "ethereum");
assert!(cache_path.exists());
let cached = MonitorState::load_cache(&token_data.address, "ethereum");
assert!(cached.is_some());
let _ = std::fs::remove_file(cache_path);
}
#[test]
fn test_should_refresh_when_paused() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert!(!state.should_refresh());
state.paused = true;
assert!(!state.should_refresh());
}
#[test]
fn test_ohlc_candle_lifecycle() {
let mut candle = OhlcCandle::new(1700000000.0, 100.0);
assert_eq!(candle.open, 100.0);
assert!(candle.is_bullish);
candle.update(110.0);
assert_eq!(candle.high, 110.0);
assert!(candle.is_bullish);
candle.update(90.0);
assert_eq!(candle.low, 90.0);
assert!(!candle.is_bullish);
}
#[test]
fn test_time_period_display_impl() {
assert_eq!(format!("{}", TimePeriod::Min1), "1m");
assert_eq!(format!("{}", TimePeriod::Min15), "15m");
assert_eq!(format!("{}", TimePeriod::Day1), "1d");
}
#[test]
fn test_log_messages_accumulate() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.toggle_pause();
state.toggle_pause();
state.cycle_time_period();
state.toggle_chart_mode();
assert!(!state.log_messages.is_empty());
}
#[test]
fn test_ui_function_full_render() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
#[test]
fn test_ui_function_candlestick_mode() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.chart_mode = ChartMode::Candlestick;
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
#[test]
fn test_ui_function_with_error_message() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.error_message = Some("Test error".to_string());
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
#[test]
fn test_render_header_with_small_positive_change() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.price_change_24h = 0.3; terminal
.draw(|f| render_header(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_header_with_small_negative_change() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.price_change_24h = -0.3; terminal
.draw(|f| render_header(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_buy_sell_gauge_high_buy_ratio() {
let mut terminal = create_test_terminal();
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.buys_24h = 100;
state.sells_24h = 10;
terminal
.draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
.unwrap();
}
#[test]
fn test_render_buy_sell_gauge_zero_total() {
let mut terminal = create_test_terminal();
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.buys_24h = 0;
state.sells_24h = 0;
terminal
.draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
.unwrap();
}
#[test]
fn test_render_metrics_with_market_cap() {
let mut terminal = create_test_terminal();
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.market_cap = Some(1_000_000_000.0);
state.fdv = Some(2_000_000_000.0);
terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_footer_with_error() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.error_message = Some("Connection failed".to_string());
terminal
.draw(|f| render_footer(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_format_price_usd_various() {
assert!(!format_price_usd(0.0000001).is_empty());
assert!(!format_price_usd(0.001).is_empty());
assert!(!format_price_usd(1.0).is_empty());
assert!(!format_price_usd(100.0).is_empty());
assert!(!format_price_usd(10000.0).is_empty());
assert!(!format_price_usd(1000000.0).is_empty());
}
#[test]
fn test_format_usd_various() {
assert!(!scope::display::format_usd(0.0).is_empty());
assert!(!scope::display::format_usd(999.0).is_empty());
assert!(!scope::display::format_usd(1500.0).is_empty());
assert!(!scope::display::format_usd(1_500_000.0).is_empty());
assert!(!scope::display::format_usd(1_500_000_000.0).is_empty());
assert!(!scope::display::format_usd(1_500_000_000_000.0).is_empty());
}
#[test]
fn test_format_number_various() {
assert!(!format_number(0.0).is_empty());
assert!(!format_number(999.0).is_empty());
assert!(!format_number(1500.0).is_empty());
assert!(!format_number(1_500_000.0).is_empty());
assert!(!format_number(1_500_000_000.0).is_empty());
}
#[test]
fn test_render_with_min15_period() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.set_time_period(TimePeriod::Min15);
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
#[test]
fn test_render_with_hour6_period() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.set_time_period(TimePeriod::Hour4);
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
#[test]
fn test_ui_with_fresh_state_no_real_data() {
let mut terminal = create_test_terminal();
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
#[test]
fn test_ui_with_paused_state() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.toggle_pause();
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
#[test]
fn test_render_all_with_different_time_periods_and_modes() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
for period in &[
TimePeriod::Min1,
TimePeriod::Min5,
TimePeriod::Min15,
TimePeriod::Hour1,
TimePeriod::Hour4,
TimePeriod::Day1,
] {
for mode in &[
ChartMode::Line,
ChartMode::Candlestick,
ChartMode::VolumeProfile,
] {
state.set_time_period(*period);
state.chart_mode = *mode;
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
}
}
#[test]
fn test_render_metrics_with_large_values() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
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
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_header_large_positive_change() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.price_change_24h = 50.0; terminal
.draw(|f| render_header(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_header_large_negative_change() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.price_change_24h = -50.0; terminal
.draw(|f| render_header(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_price_chart_empty_data() {
let mut terminal = create_test_terminal();
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.price_history.clear();
terminal
.draw(|f| render_price_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_price_chart_price_down() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.price_change_24h = -15.0;
state.current_price = 0.5; terminal
.draw(|f| render_price_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_price_chart_zero_first_price() {
let mut terminal = create_test_terminal();
let mut token_data = create_test_token_data();
token_data.price_usd = 0.0;
let state = MonitorState::new(&token_data, "ethereum");
terminal
.draw(|f| render_price_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_metrics_panel_zero_5m_change() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.price_change_5m = 0.0; terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_metrics_panel_positive_5m_change() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.price_change_5m = 5.0; terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_metrics_panel_negative_5m_change() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.price_change_5m = -3.0; terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_metrics_panel_negative_24h_change() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.price_change_24h = -10.0;
terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_metrics_panel_old_last_change() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 7200.0; terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_metrics_panel_minutes_ago_change() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.last_price_change_at = chrono::Utc::now().timestamp() as f64 - 300.0; terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_candlestick_empty_fresh_state() {
let mut terminal = create_test_terminal();
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.price_history.clear();
state.chart_mode = ChartMode::Candlestick;
terminal
.draw(|f| render_candlestick_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_candlestick_price_down() {
let mut terminal = create_test_terminal();
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
for i in 0..20 {
let mut data = token_data.clone();
data.price_usd = 2.0 - (i as f64 * 0.05);
state.update(&data);
}
state.chart_mode = ChartMode::Candlestick;
terminal
.draw(|f| render_candlestick_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_volume_chart_with_many_points() {
let mut terminal = create_test_terminal();
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
for i in 0..100 {
let mut data = token_data.clone();
data.volume_24h = 1_000_000.0 + (i as f64 * 50_000.0);
data.price_usd = 1.0 + (i as f64 * 0.001);
state.update(&data);
}
terminal
.draw(|f| render_volume_chart(f, f.area(), &state))
.unwrap();
}
fn make_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
crossterm::event::KeyEvent::new(code, KeyModifiers::NONE)
}
fn make_ctrl_key_event(code: KeyCode) -> crossterm::event::KeyEvent {
crossterm::event::KeyEvent::new(code, KeyModifiers::CONTROL)
}
#[test]
fn test_handle_key_quit_q() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert!(handle_key_event_on_state(
make_key_event(KeyCode::Char('q')),
&mut state
));
}
#[test]
fn test_handle_key_quit_esc() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert!(handle_key_event_on_state(
make_key_event(KeyCode::Esc),
&mut state
));
}
#[test]
fn test_handle_key_quit_ctrl_c() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert!(handle_key_event_on_state(
make_ctrl_key_event(KeyCode::Char('c')),
&mut state
));
}
#[test]
fn test_handle_key_refresh() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.refresh_rate = Duration::from_secs(60);
let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('r')), &mut state);
assert!(!exit);
assert!(state.should_refresh());
}
#[test]
fn test_handle_key_pause_toggle() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert!(!state.paused);
handle_key_event_on_state(make_key_event(KeyCode::Char('p')), &mut state);
assert!(state.paused);
handle_key_event_on_state(make_key_event(KeyCode::Char(' ')), &mut state);
assert!(!state.paused);
}
#[test]
fn test_handle_key_slower_refresh() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let initial = state.refresh_rate;
handle_key_event_on_state(make_key_event(KeyCode::Char('+')), &mut state);
assert!(state.refresh_rate > initial);
state.refresh_rate = initial;
handle_key_event_on_state(make_key_event(KeyCode::Char('=')), &mut state);
assert!(state.refresh_rate > initial);
state.refresh_rate = initial;
handle_key_event_on_state(make_key_event(KeyCode::Char(']')), &mut state);
assert!(state.refresh_rate > initial);
}
#[test]
fn test_handle_key_faster_refresh() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.refresh_rate = Duration::from_secs(30);
let initial = state.refresh_rate;
handle_key_event_on_state(make_key_event(KeyCode::Char('-')), &mut state);
assert!(state.refresh_rate < initial);
state.refresh_rate = initial;
handle_key_event_on_state(make_key_event(KeyCode::Char('_')), &mut state);
assert!(state.refresh_rate < initial);
state.refresh_rate = initial;
handle_key_event_on_state(make_key_event(KeyCode::Char('[')), &mut state);
assert!(state.refresh_rate < initial);
}
#[test]
fn test_handle_key_time_periods() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
assert!(matches!(state.time_period, TimePeriod::Min1));
handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
assert!(matches!(state.time_period, TimePeriod::Min5));
handle_key_event_on_state(make_key_event(KeyCode::Char('3')), &mut state);
assert!(matches!(state.time_period, TimePeriod::Min15));
handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
assert!(matches!(state.time_period, TimePeriod::Hour1));
handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
assert!(matches!(state.time_period, TimePeriod::Hour4));
handle_key_event_on_state(make_key_event(KeyCode::Char('6')), &mut state);
assert!(matches!(state.time_period, TimePeriod::Day1));
}
#[test]
fn test_handle_key_cycle_time_period() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
handle_key_event_on_state(make_key_event(KeyCode::Char('t')), &mut state);
let first = state.time_period;
handle_key_event_on_state(make_key_event(KeyCode::Tab), &mut state);
let _ = state.time_period;
let _ = first;
}
#[test]
fn test_handle_key_toggle_chart_mode() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let initial_mode = state.chart_mode;
handle_key_event_on_state(make_key_event(KeyCode::Char('c')), &mut state);
assert!(state.chart_mode != initial_mode);
}
#[test]
fn test_handle_key_unknown_no_op() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
assert!(!exit);
}
#[test]
fn test_save_and_load_cache() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.price_history.push_back(DataPoint {
timestamp: 1.0,
value: 100.0,
is_real: true,
});
state.price_history.push_back(DataPoint {
timestamp: 2.0,
value: 101.0,
is_real: true,
});
state.volume_history.push_back(DataPoint {
timestamp: 1.0,
value: 5000.0,
is_real: true,
});
state.save_cache();
let cached = MonitorState::load_cache(&state.token_address, &state.chain);
if let Some(c) = cached {
assert_eq!(
c.token_address.to_lowercase(),
state.token_address.to_lowercase()
);
}
}
#[test]
fn test_load_cache_nonexistent_token() {
let cached = MonitorState::load_cache("0xNONEXISTENT_TOKEN_ADDR", "nonexistent_chain");
assert!(cached.is_none());
}
#[test]
fn test_render_volume_barchart_with_populated_data() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
for period in [
TimePeriod::Min1,
TimePeriod::Min5,
TimePeriod::Min15,
TimePeriod::Hour1,
TimePeriod::Hour4,
TimePeriod::Day1,
] {
state.set_time_period(period);
terminal
.draw(|f| render_volume_chart(f, f.area(), &state))
.unwrap();
}
}
#[test]
fn test_render_volume_barchart_narrow_terminal() {
let backend = TestBackend::new(20, 10);
let mut terminal = Terminal::new(backend).unwrap();
let state = create_populated_state();
terminal
.draw(|f| render_volume_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_metrics_table_sparkline_no_panic() {
let mut terminal = create_test_terminal();
let state = create_populated_state();
terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_metrics_table_sparkline_all_periods() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
for period in [
TimePeriod::Min1,
TimePeriod::Min5,
TimePeriod::Min15,
TimePeriod::Hour1,
TimePeriod::Hour4,
TimePeriod::Day1,
] {
state.set_time_period(period);
terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
}
#[test]
fn test_render_metrics_sparkline_trend_direction() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.price_change_5m = -3.5;
terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
state.price_change_5m = 2.0;
terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
state.price_change_5m = 0.0;
terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_tabs_time_period() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
for period in [
TimePeriod::Min1,
TimePeriod::Min5,
TimePeriod::Min15,
TimePeriod::Hour1,
TimePeriod::Hour4,
TimePeriod::Day1,
] {
state.set_time_period(period);
terminal
.draw(|f| render_header(f, f.area(), &state))
.unwrap();
}
}
#[test]
fn test_time_period_index() {
assert_eq!(TimePeriod::Min1.index(), 0);
assert_eq!(TimePeriod::Min5.index(), 1);
assert_eq!(TimePeriod::Min15.index(), 2);
assert_eq!(TimePeriod::Hour1.index(), 3);
assert_eq!(TimePeriod::Hour4.index(), 4);
assert_eq!(TimePeriod::Day1.index(), 5);
}
#[test]
fn test_scroll_log_down_from_start() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.log_messages.push_back("msg 1".to_string());
state.log_messages.push_back("msg 2".to_string());
state.log_messages.push_back("msg 3".to_string());
assert_eq!(state.log_list_state.selected(), None);
state.scroll_log_down();
assert_eq!(state.log_list_state.selected(), Some(0));
state.scroll_log_down();
assert_eq!(state.log_list_state.selected(), Some(1));
state.scroll_log_down();
assert_eq!(state.log_list_state.selected(), Some(2));
state.scroll_log_down();
assert_eq!(state.log_list_state.selected(), Some(2));
}
#[test]
fn test_scroll_log_up_from_start() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.log_messages.push_back("msg 1".to_string());
state.log_messages.push_back("msg 2".to_string());
state.log_messages.push_back("msg 3".to_string());
state.scroll_log_up();
assert_eq!(state.log_list_state.selected(), Some(0));
state.scroll_log_up();
assert_eq!(state.log_list_state.selected(), Some(0));
}
#[test]
fn test_scroll_log_up_down_roundtrip() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
for i in 0..10 {
state.log_messages.push_back(format!("msg {}", i));
}
for _ in 0..5 {
state.scroll_log_down();
}
assert_eq!(state.log_list_state.selected(), Some(4));
for _ in 0..3 {
state.scroll_log_up();
}
assert_eq!(state.log_list_state.selected(), Some(1));
}
#[test]
fn test_scroll_log_empty_no_panic() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.scroll_log_down();
state.scroll_log_up();
assert!(
state.log_list_state.selected().is_none() || state.log_list_state.selected() == Some(0)
);
}
#[test]
fn test_render_scrollable_activity_log() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
for i in 0..20 {
state
.log_messages
.push_back(format!("Activity event #{}", i));
}
state.scroll_log_down();
state.scroll_log_down();
state.scroll_log_down();
terminal
.draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
.unwrap();
}
#[test]
fn test_handle_key_scroll_log_j_k() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.log_messages.push_back("line 1".to_string());
state.log_messages.push_back("line 2".to_string());
handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
assert_eq!(state.log_list_state.selected(), Some(0));
handle_key_event_on_state(make_key_event(KeyCode::Char('j')), &mut state);
assert_eq!(state.log_list_state.selected(), Some(1));
handle_key_event_on_state(make_key_event(KeyCode::Char('k')), &mut state);
assert_eq!(state.log_list_state.selected(), Some(0));
}
#[test]
fn test_handle_key_scroll_log_arrow_keys() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.log_messages.push_back("line 1".to_string());
state.log_messages.push_back("line 2".to_string());
state.log_messages.push_back("line 3".to_string());
handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
assert_eq!(state.log_list_state.selected(), Some(0));
handle_key_event_on_state(make_key_event(KeyCode::Down), &mut state);
assert_eq!(state.log_list_state.selected(), Some(1));
handle_key_event_on_state(make_key_event(KeyCode::Up), &mut state);
assert_eq!(state.log_list_state.selected(), Some(0));
}
#[test]
fn test_render_ui_with_scrolled_log() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
for i in 0..15 {
state.log_messages.push_back(format!("Log entry {}", i));
}
state.scroll_log_down();
state.scroll_log_down();
state.scroll_log_down();
state.scroll_log_down();
state.scroll_log_down();
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
fn make_monitor_search_results() -> Vec<scope::chains::dex::TokenSearchResult> {
vec![
scope::chains::dex::TokenSearchResult {
symbol: "USDC".to_string(),
name: "USD Coin".to_string(),
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
chain: "ethereum".to_string(),
price_usd: Some(1.0),
volume_24h: 1_000_000.0,
liquidity_usd: 500_000_000.0,
market_cap: Some(30_000_000_000.0),
},
scope::chains::dex::TokenSearchResult {
symbol: "USDC".to_string(),
name: "Bridged USD Coin".to_string(),
address: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174".to_string(),
chain: "ethereum".to_string(),
price_usd: Some(0.9998),
volume_24h: 500_000.0,
liquidity_usd: 100_000_000.0,
market_cap: None,
},
scope::chains::dex::TokenSearchResult {
symbol: "USDC".to_string(),
name: "A Very Long Token Name That Exceeds The Limit".to_string(),
address: "0x1234567890abcdef1234567890abcdef12345678".to_string(),
chain: "ethereum".to_string(),
price_usd: None,
volume_24h: 0.0,
liquidity_usd: 50_000.0,
market_cap: None,
},
]
}
#[test]
fn test_select_token_impl_first() {
let results = make_monitor_search_results();
let input = b"1\n";
let mut reader = std::io::Cursor::new(&input[..]);
let (out, cap) = crate::cli::output::Output::capture();
let selected = select_token_impl(&results, &mut reader, &out).unwrap();
assert_eq!(selected.name, "USD Coin");
assert_eq!(
selected.address,
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
);
let output = cap.err();
assert!(output.contains("Found 3 matching tokens"));
assert!(output.contains("USDC"));
assert!(output.contains("0xA0b8…eB48"));
assert!(output.contains("Selected:"));
}
#[test]
fn test_select_token_impl_second() {
let results = make_monitor_search_results();
let input = b"2\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::capture().0;
let selected = select_token_impl(&results, &mut reader, &out).unwrap();
assert_eq!(selected.name, "Bridged USD Coin");
assert_eq!(
selected.address,
"0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174"
);
}
#[test]
fn test_select_token_impl_shows_address_column() {
let results = make_monitor_search_results();
let input = b"1\n";
let mut reader = std::io::Cursor::new(&input[..]);
let (out, cap) = crate::cli::output::Output::capture();
select_token_impl(&results, &mut reader, &out).unwrap();
let output = cap.err();
assert!(output.contains("Address"));
assert!(output.contains("0xA0b8…eB48"));
assert!(output.contains("0x2791…4174"));
assert!(output.contains("0x1234…5678"));
}
#[test]
fn test_select_token_impl_truncates_long_name() {
let results = make_monitor_search_results();
let input = b"3\n";
let mut reader = std::io::Cursor::new(&input[..]);
let (out, cap) = crate::cli::output::Output::capture();
let selected = select_token_impl(&results, &mut reader, &out).unwrap();
assert_eq!(
selected.address,
"0x1234567890abcdef1234567890abcdef12345678"
);
let output = cap.err();
assert!(output.contains("A Very Long Token N…"));
}
#[test]
fn test_select_token_impl_invalid_input() {
let results = make_monitor_search_results();
let input = b"xyz\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::capture().0;
let result = select_token_impl(&results, &mut reader, &out);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Invalid selection")
);
}
#[test]
fn test_select_token_impl_out_of_range_zero() {
let results = make_monitor_search_results();
let input = b"0\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::capture().0;
let result = select_token_impl(&results, &mut reader, &out);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("Selection must be between")
);
}
#[test]
fn test_select_token_impl_out_of_range_high() {
let results = make_monitor_search_results();
let input = b"99\n";
let mut reader = std::io::Cursor::new(&input[..]);
let out = crate::cli::output::Output::capture().0;
let result = select_token_impl(&results, &mut reader, &out);
assert!(result.is_err());
}
#[test]
fn test_monitor_config_defaults() {
let config = MonitorConfig::default();
assert_eq!(config.layout, LayoutPreset::Dashboard);
assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
assert!(config.widgets.price_chart);
assert!(config.widgets.volume_chart);
assert!(config.widgets.buy_sell_pressure);
assert!(config.widgets.metrics_panel);
assert!(config.widgets.activity_log);
}
#[test]
fn test_layout_preset_next_cycles() {
assert_eq!(LayoutPreset::Dashboard.next(), LayoutPreset::ChartFocus);
assert_eq!(LayoutPreset::ChartFocus.next(), LayoutPreset::Feed);
assert_eq!(LayoutPreset::Feed.next(), LayoutPreset::Compact);
assert_eq!(LayoutPreset::Compact.next(), LayoutPreset::Exchange);
assert_eq!(LayoutPreset::Exchange.next(), LayoutPreset::Dashboard);
}
#[test]
fn test_layout_preset_prev_cycles() {
assert_eq!(LayoutPreset::Dashboard.prev(), LayoutPreset::Exchange);
assert_eq!(LayoutPreset::Exchange.prev(), LayoutPreset::Compact);
assert_eq!(LayoutPreset::Compact.prev(), LayoutPreset::Feed);
assert_eq!(LayoutPreset::Feed.prev(), LayoutPreset::ChartFocus);
assert_eq!(LayoutPreset::ChartFocus.prev(), LayoutPreset::Dashboard);
}
#[test]
fn test_layout_preset_full_cycle() {
let start = LayoutPreset::Dashboard;
let mut preset = start;
for _ in 0..5 {
preset = preset.next();
}
assert_eq!(preset, start);
}
#[test]
fn test_layout_preset_labels() {
assert_eq!(LayoutPreset::Dashboard.label(), "Dashboard");
assert_eq!(LayoutPreset::ChartFocus.label(), "Chart");
assert_eq!(LayoutPreset::Feed.label(), "Feed");
assert_eq!(LayoutPreset::Compact.label(), "Compact");
assert_eq!(LayoutPreset::Exchange.label(), "Exchange");
}
#[test]
fn test_widget_visibility_default_all_visible() {
let vis = WidgetVisibility::default();
assert_eq!(vis.visible_count(), 5);
}
#[test]
fn test_widget_visibility_toggle_by_index() {
let mut vis = WidgetVisibility::default();
vis.toggle_by_index(1);
assert!(!vis.price_chart);
assert_eq!(vis.visible_count(), 4);
vis.toggle_by_index(2);
assert!(!vis.volume_chart);
assert_eq!(vis.visible_count(), 3);
vis.toggle_by_index(3);
assert!(!vis.buy_sell_pressure);
assert_eq!(vis.visible_count(), 2);
vis.toggle_by_index(4);
assert!(!vis.metrics_panel);
assert_eq!(vis.visible_count(), 1);
vis.toggle_by_index(5);
assert!(!vis.activity_log);
assert_eq!(vis.visible_count(), 0);
vis.toggle_by_index(1);
assert!(vis.price_chart);
assert_eq!(vis.visible_count(), 1);
}
#[test]
fn test_widget_visibility_toggle_invalid_index() {
let mut vis = WidgetVisibility::default();
vis.toggle_by_index(0);
vis.toggle_by_index(6);
vis.toggle_by_index(100);
assert_eq!(vis.visible_count(), 5); }
#[test]
fn test_auto_select_layout_small_terminal() {
let size = Rect::new(0, 0, 60, 20);
assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
}
#[test]
fn test_auto_select_layout_narrow_terminal() {
let size = Rect::new(0, 0, 100, 40);
assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
}
#[test]
fn test_auto_select_layout_short_terminal() {
let size = Rect::new(0, 0, 140, 28);
assert_eq!(auto_select_layout(size), LayoutPreset::ChartFocus);
}
#[test]
fn test_auto_select_layout_large_terminal() {
let size = Rect::new(0, 0, 160, 50);
assert_eq!(auto_select_layout(size), LayoutPreset::Dashboard);
}
#[test]
fn test_auto_select_layout_edge_80x24() {
let size = Rect::new(0, 0, 80, 24);
assert_eq!(auto_select_layout(size), LayoutPreset::Feed);
}
#[test]
fn test_auto_select_layout_edge_79() {
let size = Rect::new(0, 0, 79, 50);
assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
}
#[test]
fn test_auto_select_layout_edge_23_height() {
let size = Rect::new(0, 0, 160, 23);
assert_eq!(auto_select_layout(size), LayoutPreset::Compact);
}
#[test]
fn test_layout_dashboard_all_visible() {
let area = Rect::new(0, 0, 120, 40);
let vis = WidgetVisibility::default();
let areas = layout_dashboard(area, &vis);
assert!(areas.price_chart.is_some());
assert!(areas.volume_chart.is_some());
assert!(areas.buy_sell_gauge.is_some());
assert!(areas.metrics_panel.is_some());
assert!(areas.activity_feed.is_some());
}
#[test]
fn test_layout_dashboard_hidden_widget() {
let area = Rect::new(0, 0, 120, 40);
let vis = WidgetVisibility {
price_chart: false,
..WidgetVisibility::default()
};
let areas = layout_dashboard(area, &vis);
assert!(areas.price_chart.is_none());
assert!(areas.volume_chart.is_some());
}
#[test]
fn test_layout_chart_focus_minimal_overlay() {
let area = Rect::new(0, 0, 120, 40);
let vis = WidgetVisibility::default();
let areas = layout_chart_focus(area, &vis);
assert!(areas.price_chart.is_some());
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()); }
#[test]
fn test_layout_feed_activity_priority() {
let area = Rect::new(0, 0, 120, 40);
let vis = WidgetVisibility::default();
let areas = layout_feed(area, &vis);
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()); }
#[test]
fn test_layout_compact_metrics_only() {
let area = Rect::new(0, 0, 60, 20);
let vis = WidgetVisibility::default();
let areas = layout_compact(area, &vis);
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()); }
#[test]
fn test_layout_exchange_has_order_book_and_market_info() {
let area = Rect::new(0, 0, 160, 50);
let vis = WidgetVisibility::default();
let areas = layout_exchange(area, &vis);
assert!(areas.order_book.is_some());
assert!(areas.market_info.is_some());
assert!(areas.price_chart.is_some());
assert!(areas.buy_sell_gauge.is_some());
assert!(areas.volume_chart.is_none()); assert!(areas.metrics_panel.is_none()); assert!(areas.activity_feed.is_none()); }
#[test]
fn test_ui_render_all_layouts_no_panic() {
let presets = [
LayoutPreset::Dashboard,
LayoutPreset::ChartFocus,
LayoutPreset::Feed,
LayoutPreset::Compact,
LayoutPreset::Exchange,
];
for preset in &presets {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.layout = *preset;
state.auto_layout = false; terminal.draw(|f| ui(f, &mut state)).unwrap();
}
}
#[test]
fn test_ui_render_compact_small_terminal() {
let backend = TestBackend::new(60, 20);
let mut terminal = Terminal::new(backend).unwrap();
let mut state = create_populated_state();
state.layout = LayoutPreset::Compact;
state.auto_layout = false;
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
#[test]
fn test_ui_auto_layout_selects_compact_for_small() {
let backend = TestBackend::new(60, 20);
let mut terminal = Terminal::new(backend).unwrap();
let mut state = create_populated_state();
state.layout = LayoutPreset::Dashboard;
state.auto_layout = true;
terminal.draw(|f| ui(f, &mut state)).unwrap();
assert_eq!(state.layout, LayoutPreset::Compact);
}
#[test]
fn test_ui_auto_layout_disabled_keeps_preset() {
let backend = TestBackend::new(60, 20);
let mut terminal = Terminal::new(backend).unwrap();
let mut state = create_populated_state();
state.layout = LayoutPreset::Dashboard;
state.auto_layout = false;
terminal.draw(|f| ui(f, &mut state)).unwrap();
assert_eq!(state.layout, LayoutPreset::Dashboard); }
#[test]
fn test_keybinding_l_cycles_layout_forward() {
let mut state = create_populated_state();
state.layout = LayoutPreset::Dashboard;
state.auto_layout = true;
handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
assert_eq!(state.layout, LayoutPreset::ChartFocus);
assert!(!state.auto_layout);
handle_key_event_on_state(make_key_event(KeyCode::Char('l')), &mut state);
assert_eq!(state.layout, LayoutPreset::Feed);
}
#[test]
fn test_keybinding_h_cycles_layout_backward() {
let mut state = create_populated_state();
state.layout = LayoutPreset::Dashboard;
state.auto_layout = true;
handle_key_event_on_state(make_key_event(KeyCode::Char('h')), &mut state);
assert_eq!(state.layout, LayoutPreset::Exchange);
assert!(!state.auto_layout);
}
#[test]
fn test_keybinding_a_enables_auto_layout() {
let mut state = create_populated_state();
state.auto_layout = false;
handle_key_event_on_state(make_key_event(KeyCode::Char('a')), &mut state);
assert!(state.auto_layout);
}
#[test]
fn test_keybinding_w_widget_toggle_mode() {
let mut state = create_populated_state();
assert!(!state.widget_toggle_mode);
handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
assert!(state.widget_toggle_mode);
handle_key_event_on_state(make_key_event(KeyCode::Char('1')), &mut state);
assert!(!state.widget_toggle_mode);
assert!(!state.widgets.price_chart);
}
#[test]
fn test_keybinding_w_cancel_with_non_digit() {
let mut state = create_populated_state();
handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
assert!(state.widget_toggle_mode);
handle_key_event_on_state(make_key_event(KeyCode::Char('x')), &mut state);
assert!(!state.widget_toggle_mode);
assert!(state.widgets.price_chart); }
#[test]
fn test_keybinding_w_toggle_multiple_widgets() {
let mut state = create_populated_state();
handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
handle_key_event_on_state(make_key_event(KeyCode::Char('2')), &mut state);
assert!(!state.widgets.volume_chart);
handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
handle_key_event_on_state(make_key_event(KeyCode::Char('4')), &mut state);
assert!(!state.widgets.metrics_panel);
handle_key_event_on_state(make_key_event(KeyCode::Char('w')), &mut state);
handle_key_event_on_state(make_key_event(KeyCode::Char('5')), &mut state);
assert!(!state.widgets.activity_log);
}
#[test]
fn test_monitor_config_serde_roundtrip() {
let config = MonitorConfig {
layout: LayoutPreset::ChartFocus,
refresh_seconds: 5,
widgets: WidgetVisibility {
price_chart: true,
volume_chart: false,
buy_sell_pressure: true,
metrics_panel: false,
activity_log: true,
holder_count: true,
liquidity_depth: true,
},
scale: ScaleMode::Log,
color_scheme: ColorScheme::BlueOrange,
alerts: AlertConfig::default(),
export: ExportConfig::default(),
auto_pause_on_input: false,
venue: None,
};
let yaml = serde_yaml::to_string(&config).unwrap();
let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed.layout, LayoutPreset::ChartFocus);
assert_eq!(parsed.refresh_seconds, 5);
assert!(parsed.widgets.price_chart);
assert!(!parsed.widgets.volume_chart);
assert!(parsed.widgets.buy_sell_pressure);
assert!(!parsed.widgets.metrics_panel);
assert!(parsed.widgets.activity_log);
}
#[test]
fn test_monitor_config_serde_kebab_case() {
let yaml = r#"
layout: chart-focus
refresh_seconds: 15
widgets:
price_chart: true
volume_chart: true
buy_sell_pressure: false
metrics_panel: true
activity_log: false
"#;
let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.layout, LayoutPreset::ChartFocus);
assert_eq!(config.refresh_seconds, 15);
assert!(!config.widgets.buy_sell_pressure);
assert!(!config.widgets.activity_log);
}
#[test]
fn test_monitor_config_serde_default_missing_fields() {
let yaml = "layout: feed\n";
let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.layout, LayoutPreset::Feed);
assert_eq!(config.refresh_seconds, DEFAULT_REFRESH_SECS);
assert!(config.widgets.price_chart); }
#[test]
fn test_state_apply_config() {
let mut state = create_populated_state();
let config = MonitorConfig {
layout: LayoutPreset::Feed,
refresh_seconds: 5,
widgets: WidgetVisibility {
price_chart: false,
volume_chart: true,
buy_sell_pressure: true,
metrics_panel: false,
activity_log: true,
holder_count: true,
liquidity_depth: true,
},
scale: ScaleMode::Log,
color_scheme: ColorScheme::Monochrome,
alerts: AlertConfig::default(),
export: ExportConfig::default(),
auto_pause_on_input: false,
venue: None,
};
state.apply_config(&config);
assert_eq!(state.layout, LayoutPreset::Feed);
assert!(!state.widgets.price_chart);
assert!(!state.widgets.metrics_panel);
assert_eq!(state.refresh_rate, Duration::from_secs(5));
}
#[test]
fn test_layout_all_widgets_hidden_dashboard() {
let area = Rect::new(0, 0, 120, 40);
let vis = WidgetVisibility {
price_chart: false,
volume_chart: false,
buy_sell_pressure: false,
metrics_panel: false,
activity_log: false,
holder_count: false,
liquidity_depth: false,
};
let areas = layout_dashboard(area, &vis);
assert!(areas.price_chart.is_none());
assert!(areas.volume_chart.is_none());
assert!(areas.buy_sell_gauge.is_none());
assert!(areas.metrics_panel.is_none());
assert!(areas.activity_feed.is_none());
}
#[test]
fn test_ui_render_with_hidden_widgets() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.auto_layout = false;
state.widgets.price_chart = false;
state.widgets.volume_chart = false;
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
#[test]
fn test_ui_render_widget_toggle_mode_footer() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.auto_layout = false;
state.widget_toggle_mode = true;
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
#[test]
fn test_monitor_state_new_has_layout_fields() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
assert_eq!(state.layout, LayoutPreset::Dashboard);
assert!(state.auto_layout);
assert!(!state.widget_toggle_mode);
assert_eq!(state.widgets.visible_count(), 5);
}
#[test]
fn test_monitor_state_has_holder_count_field() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
assert_eq!(state.holder_count, None);
assert!(state.liquidity_pairs.is_empty());
assert_eq!(state.holder_fetch_counter, 0);
}
#[test]
fn test_liquidity_pairs_extracted_on_update() {
let mut token_data = create_test_token_data();
token_data.pairs = vec![
scope::chains::DexPair {
dex_name: "Uniswap V3".to_string(),
pair_address: "0xpair1".to_string(),
base_token: "TEST".to_string(),
quote_token: "WETH".to_string(),
price_usd: 1.0,
volume_24h: 500_000.0,
liquidity_usd: 250_000.0,
liquidity_base: None,
price_change_24h: 5.0,
buys_24h: 50,
sells_24h: 25,
buys_6h: 10,
sells_6h: 5,
buys_1h: 3,
sells_1h: 2,
pair_created_at: None,
url: None,
},
scope::chains::DexPair {
dex_name: "SushiSwap".to_string(),
pair_address: "0xpair2".to_string(),
base_token: "TEST".to_string(),
quote_token: "USDC".to_string(),
price_usd: 1.0,
volume_24h: 300_000.0,
liquidity_usd: 150_000.0,
liquidity_base: None,
price_change_24h: 3.0,
buys_24h: 30,
sells_24h: 15,
buys_6h: 8,
sells_6h: 4,
buys_1h: 2,
sells_1h: 1,
pair_created_at: None,
url: None,
},
];
let mut state = MonitorState::new(&token_data, "ethereum");
state.update(&token_data);
assert_eq!(state.liquidity_pairs.len(), 2);
assert!(state.liquidity_pairs[0].0.contains("Uniswap V3"));
assert!((state.liquidity_pairs[0].1 - 250_000.0).abs() < 0.01);
}
#[test]
fn test_render_liquidity_depth_no_panic() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.liquidity_pairs = vec![
("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
];
terminal
.draw(|f| render_liquidity_depth(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_liquidity_depth_empty() {
let mut terminal = create_test_terminal();
let state = create_populated_state();
terminal
.draw(|f| render_liquidity_depth(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_metrics_with_holder_count() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.holder_count = Some(42_000);
terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_alert_config_default() {
let config = AlertConfig::default();
assert!(config.price_min.is_none());
assert!(config.price_max.is_none());
assert!(config.whale_min_usd.is_none());
assert!(config.volume_spike_threshold_pct.is_none());
}
#[test]
fn test_alert_price_min_triggers() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.alerts.price_min = Some(2.0); state.update(&token_data);
assert!(
!state.active_alerts.is_empty(),
"Should have price-min alert"
);
assert!(state.active_alerts[0].message.contains("below min"));
}
#[test]
fn test_alert_price_max_triggers() {
let mut token_data = create_test_token_data();
token_data.price_usd = 100.0;
let mut state = MonitorState::new(&token_data, "ethereum");
state.alerts.price_max = Some(50.0); state.update(&token_data);
assert!(
!state.active_alerts.is_empty(),
"Should have price-max alert"
);
assert!(state.active_alerts[0].message.contains("above max"));
}
#[test]
fn test_alert_no_trigger_within_bounds() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.alerts.price_min = Some(0.5); state.alerts.price_max = Some(2.0); state.update(&token_data);
assert!(
state.active_alerts.is_empty(),
"Should have no alerts when price is within bounds"
);
}
#[test]
fn test_alert_volume_spike_triggers() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.alerts.volume_spike_threshold_pct = Some(10.0);
state.volume_avg = 500_000.0;
state.update(&token_data);
let spike_alerts: Vec<_> = state
.active_alerts
.iter()
.filter(|a| a.message.contains("spike"))
.collect();
assert!(!spike_alerts.is_empty(), "Should have volume spike alert");
}
#[test]
fn test_alert_flash_timer_set() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.alerts.price_min = Some(2.0);
state.update(&token_data);
assert!(state.alert_flash_until.is_some());
}
#[test]
fn test_render_alert_overlay_no_panic() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.active_alerts.push(ActiveAlert {
message: "⚠ Test alert".to_string(),
triggered_at: Instant::now(),
});
state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
terminal
.draw(|f| render_alert_overlay(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_alert_overlay_empty() {
let mut terminal = create_test_terminal();
let state = create_populated_state();
terminal
.draw(|f| render_alert_overlay(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_alert_config_serde_roundtrip() {
let config = AlertConfig {
price_min: Some(0.5),
price_max: Some(2.0),
whale_min_usd: Some(10_000.0),
volume_spike_threshold_pct: Some(50.0),
};
let yaml = serde_yaml::to_string(&config).unwrap();
let parsed: AlertConfig = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed.price_min, Some(0.5));
assert_eq!(parsed.price_max, Some(2.0));
assert_eq!(parsed.whale_min_usd, Some(10_000.0));
assert_eq!(parsed.volume_spike_threshold_pct, Some(50.0));
}
#[test]
fn test_ui_with_active_alerts() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.active_alerts.push(ActiveAlert {
message: "⚠ Price below min".to_string(),
triggered_at: Instant::now(),
});
state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
#[test]
fn test_export_config_default() {
let config = ExportConfig::default();
assert!(config.path.is_none());
}
fn start_export_in_temp(state: &mut MonitorState) -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("scope_test_export_{}_{}", std::process::id(), id));
let _ = fs::create_dir_all(&dir);
let filename = format!("{}_test_{}.csv", state.symbol, id);
let path = dir.join(filename);
let mut file = fs::File::create(&path).expect("failed to create export test file");
let header = "timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap\n";
file.write_all(header.as_bytes())
.expect("failed to write header");
drop(file);
state.export_path = Some(path.clone());
state.export_active = true;
path
}
#[test]
fn test_export_start_creates_file() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let path = start_export_in_temp(&mut state);
assert!(state.export_active);
assert!(state.export_path.is_some());
assert!(path.exists(), "Export file should exist");
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_export_stop() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let path = start_export_in_temp(&mut state);
state.stop_export();
assert!(!state.export_active);
assert!(state.export_path.is_none());
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_export_toggle() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let dir = tempfile::tempdir().unwrap();
state.export_dir = dir.path().to_path_buf();
state.toggle_export();
assert!(state.export_active);
let path = state.export_path.clone().unwrap();
state.toggle_export();
assert!(!state.export_active);
let _ = std::fs::remove_file(path);
}
#[test]
fn test_export_toggle_uses_configured_directory() {
let dir = tempfile::tempdir().unwrap();
let mut state = MonitorState::new(&create_test_token_data(), "ethereum");
let mut cfg = MonitorConfig::default();
cfg.export.path = Some(dir.path().to_string_lossy().into_owned());
state.apply_config(&cfg);
state.toggle_export();
let path = state.export_path.clone().unwrap();
assert!(path.starts_with(dir.path()), "{}", path.display());
assert!(
std::fs::read_to_string(&path)
.unwrap()
.starts_with("timestamp,")
);
}
#[test]
fn test_start_export_to_writes_header_at_the_given_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("usdc.csv");
let mut state = MonitorState::new(&create_test_token_data(), "ethereum");
state.start_export_to(&path).unwrap();
assert!(state.export_active);
assert_eq!(state.export_path.as_deref(), Some(path.as_path()));
assert!(
std::fs::read_to_string(&path)
.unwrap()
.starts_with("timestamp,")
);
}
#[test]
fn test_start_export_to_unwritable_path_fails_loud() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("a-file");
std::fs::write(&file, "x").unwrap();
let mut state = MonitorState::new(&create_test_token_data(), "ethereum");
assert!(state.start_export_to(&file.join("out.csv")).is_err());
assert!(!state.export_active);
assert!(state.export_path.is_none());
}
#[test]
fn test_failed_export_row_stops_export_and_logs() {
let dir = tempfile::tempdir().unwrap();
let mut state = MonitorState::new(&create_test_token_data(), "ethereum");
state.export_path = Some(dir.path().to_path_buf()); state.export_active = true;
state.write_export_row();
assert!(!state.export_active);
assert!(
state
.log_messages
.iter()
.any(|m| m.contains("Export failed")),
"{:?}",
state.log_messages
);
}
#[test]
fn test_cli_export_file_is_not_written_into_export_directory() {
use clap::Parser;
let cli =
crate::cli::Cli::try_parse_from(["scope", "monitor", "USDC", "--export", "out.csv"])
.unwrap();
let crate::cli::Commands::Monitor(args) = cli.command else {
panic!("expected monitor")
};
let config = Config::default();
let (monitor_config, export_file, _) = monitor_config_from_args(&args, &config);
assert_eq!(monitor_config.export.path, config.monitor.export.path);
assert_eq!(export_file, Some(PathBuf::from("out.csv")));
}
#[derive(Clone)]
struct BlockedDex;
#[async_trait::async_trait]
impl DexDataSource for BlockedDex {
async fn get_token_price(&self, _: &str, _: &str) -> Option<f64> {
None
}
async fn get_native_token_price(&self, _: &str) -> Option<f64> {
None
}
async fn get_token_data(&self, _: &str, _: &str) -> Result<DexTokenData> {
Err(ScopeError::Network("blocked by router".into()))
}
async fn search_tokens(
&self,
_: &str,
_: Option<&str>,
) -> Result<Vec<scope::chains::TokenSearchResult>> {
Err(ScopeError::Network("blocked by router".into()))
}
}
async fn mock_venue(status: usize) -> (mockito::ServerGuard, scope::market::ExchangeClient) {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", mockito::Matcher::Regex(r"^/api/v1/ticker.*".into()))
.with_status(status)
.with_header("content-type", "application/json")
.with_body(r#"{"last":"1.0002","vol":"12345"}"#)
.create_async()
.await;
let yaml = format!(
r#"
id: mockvenue
name: Mock Venue
base_url: {}
timeout_secs: 5
symbol:
template: "{{base}}_{{quote}}"
default_quote: USDT
capabilities:
ticker:
path: /api/v1/ticker
params:
symbol: "{{pair}}"
response:
last_price: last
volume_24h: vol
"#,
server.url()
);
let desc: scope::market::VenueDescriptor = serde_yaml::from_str(&yaml).unwrap();
(
server,
scope::market::ExchangeClient::from_descriptor(&desc),
)
}
#[tokio::test]
async fn test_blocked_dexscreener_falls_back_to_the_venue_ticker() {
let (_server, ex) = mock_venue(200).await;
let (out, cap) = Output::capture();
let (data, pair) = fetch_initial_data(
"USDN",
None,
"ethereum",
&BlockedDex,
Some(("mockvenue", &ex)),
&out,
)
.await
.unwrap();
assert_eq!(pair.as_ref().map(|(p, _)| p.as_str()), Some("USDN_USDT"));
assert!((data.price_usd - 1.0002).abs() < 1e-9);
let told = cap.err();
assert!(
told.lines()
.any(|n| n.contains("DexScreener") && n.contains("blocked by router")),
"the user must see why: {told:?}"
);
assert_eq!(cap.out(), "", "notes are not data");
}
#[tokio::test]
async fn test_working_dexscreener_does_not_use_the_venue_fallback() {
let (_server, ex) = mock_venue(200).await;
let dex = scope::chains::mocks::MockDexSource::new();
let (data, pair) = fetch_initial_data(
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
None,
"ethereum",
&dex,
Some(("mockvenue", &ex)),
&Output::capture().0,
)
.await
.unwrap();
assert_eq!(data.symbol, "MOCK");
assert!(pair.is_none());
}
#[tokio::test]
async fn test_dex_and_venue_both_failing_names_both() {
let (_server, ex) = mock_venue(500).await;
let err = fetch_initial_data(
"USDN",
None,
"ethereum",
&BlockedDex,
Some(("mockvenue", &ex)),
&Output::capture().0,
)
.await
.unwrap_err()
.to_string();
assert!(err.contains("blocked by router"), "{}", err);
assert!(err.contains("USDN_USDT"), "{}", err);
}
#[tokio::test]
async fn test_explicit_pair_is_converted_to_the_venue_format() {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", "/api/v1/ticker")
.match_query(mockito::Matcher::UrlEncoded(
"symbol".into(),
"USDNUSDT".into(),
))
.with_status(200)
.with_body(r#"{"last":"0.9999","vol":"841"}"#)
.expect(3)
.create_async()
.await;
let yaml = format!(
"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",
server.url()
);
let desc: scope::market::VenueDescriptor = serde_yaml::from_str(&yaml).unwrap();
let ex = scope::market::ExchangeClient::from_descriptor(&desc);
for input in ["USDN_USDT", "USDN/USDT", "USDNUSDT"] {
let (data, pair) = fetch_initial_data(
"USDN",
Some(input),
"ethereum",
&BlockedDex,
Some(("weexlike", &ex)),
&Output::capture().0,
)
.await
.unwrap_or_else(|e| panic!("{input}: {e}"));
assert_eq!(
pair.as_ref().map(|(p, _)| p.as_str()),
Some("USDNUSDT"),
"{input}"
);
assert!((data.price_usd - 0.9999).abs() < 1e-9);
}
}
#[tokio::test]
async fn test_ticker_failure_has_one_error_prefix() {
let (_server, ex) = mock_venue(500).await;
let err = fetch_initial_data(
"USDN",
Some("USDN_USDT"),
"ethereum",
&BlockedDex,
Some(("mockvenue", &ex)),
&Output::capture().0,
)
.await
.unwrap_err()
.to_string();
assert_eq!(err.matches("Venue error (mockvenue)").count(), 1, "{}", err);
}
#[test]
fn test_unknown_venue_is_an_error_not_ignored() {
let err = exchange_for_venue(Some("binace")).err().unwrap();
assert!(matches!(err, ScopeError::InvalidInput(_)), "{err:?}");
let msg = err.to_string();
assert!(msg.contains("Unknown venue \"binace\""), "{msg}");
assert!(msg.contains("Did you mean \"binance\""), "{msg}");
assert!(exchange_for_venue(None).unwrap().is_none());
assert!(exchange_for_venue(Some("binance")).unwrap().is_some());
}
#[test]
fn test_export_writes_csv_rows() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let path = start_export_in_temp(&mut state);
state.update(&token_data);
state.update(&token_data);
let contents = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert!(
lines.len() >= 3,
"Should have header + 2 data rows, got {}",
lines.len()
);
assert!(lines[0].starts_with("timestamp,price_usd"));
state.stop_export();
let _ = std::fs::remove_file(path);
}
#[test]
fn test_keybinding_e_toggles_export() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let dir = tempfile::tempdir().unwrap();
state.export_dir = dir.path().to_path_buf();
handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
assert!(state.export_active);
let path = state.export_path.clone().unwrap();
handle_key_event_on_state(make_key_event(KeyCode::Char('e')), &mut state);
assert!(!state.export_active);
let _ = std::fs::remove_file(path);
}
#[test]
fn test_render_footer_with_export_active() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.export_active = true;
terminal
.draw(|f| render_footer(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_export_config_serde_roundtrip() {
let config = ExportConfig {
path: Some("./my-exports".to_string()),
};
let yaml = serde_yaml::to_string(&config).unwrap();
let parsed: ExportConfig = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed.path, Some("./my-exports".to_string()));
}
#[test]
fn test_auto_pause_default_disabled() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
assert!(!state.auto_pause_on_input);
assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
}
#[test]
fn test_auto_pause_blocks_refresh() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.auto_pause_on_input = true;
state.refresh_rate = Duration::from_secs(1);
state.last_input_at = Instant::now();
state.last_update = Instant::now() - Duration::from_secs(10);
assert!(!state.should_refresh());
}
#[test]
fn test_auto_pause_allows_refresh_after_timeout() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.auto_pause_on_input = true;
state.refresh_rate = Duration::from_secs(1);
state.auto_pause_timeout = Duration::from_millis(1);
state.last_input_at = Instant::now() - Duration::from_secs(10);
state.last_update = Instant::now() - Duration::from_secs(10);
assert!(state.should_refresh());
}
#[test]
fn test_auto_pause_disabled_does_not_block() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.auto_pause_on_input = false;
state.refresh_rate = Duration::from_secs(1);
state.last_input_at = Instant::now(); state.last_update = Instant::now() - Duration::from_secs(10);
assert!(state.should_refresh());
}
#[test]
fn test_is_auto_paused() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.auto_pause_on_input = false;
state.last_input_at = Instant::now();
assert!(!state.is_auto_paused());
state.auto_pause_on_input = true;
state.last_input_at = Instant::now();
assert!(state.is_auto_paused());
state.last_input_at = Instant::now() - Duration::from_secs(10);
assert!(!state.is_auto_paused());
}
#[test]
fn test_keybinding_shift_p_toggles_auto_pause() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert!(!state.auto_pause_on_input);
let shift_p = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
handle_key_event_on_state(shift_p, &mut state);
assert!(state.auto_pause_on_input);
handle_key_event_on_state(shift_p, &mut state);
assert!(!state.auto_pause_on_input);
}
#[test]
fn test_keybinding_updates_last_input_at() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.last_input_at = Instant::now() - Duration::from_secs(60);
let old_input = state.last_input_at;
handle_key_event_on_state(make_key_event(KeyCode::Char('z')), &mut state);
assert!(state.last_input_at > old_input);
}
#[test]
fn test_render_footer_auto_paused() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.auto_pause_on_input = true;
state.last_input_at = Instant::now(); terminal
.draw(|f| render_footer(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_config_auto_pause_applied() {
let mut state = create_populated_state();
let config = MonitorConfig {
auto_pause_on_input: true,
..MonitorConfig::default()
};
state.apply_config(&config);
assert!(state.auto_pause_on_input);
}
#[test]
fn test_ui_render_all_layouts_with_alerts_and_export() {
for preset in &[
LayoutPreset::Dashboard,
LayoutPreset::ChartFocus,
LayoutPreset::Feed,
LayoutPreset::Compact,
] {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.layout = *preset;
state.auto_layout = false;
state.export_active = true;
state.active_alerts.push(ActiveAlert {
message: "⚠ Test alert".to_string(),
triggered_at: Instant::now(),
});
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
}
#[test]
fn test_ui_render_with_liquidity_data() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.liquidity_pairs = vec![
("TEST/WETH (Uniswap V3)".to_string(), 250_000.0),
("TEST/USDC (SushiSwap)".to_string(), 150_000.0),
];
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
#[test]
fn test_monitor_config_full_serde_roundtrip() {
let config = MonitorConfig {
layout: LayoutPreset::Dashboard,
refresh_seconds: 10,
widgets: WidgetVisibility::default(),
scale: ScaleMode::Log,
color_scheme: ColorScheme::BlueOrange,
alerts: AlertConfig {
price_min: Some(0.5),
price_max: Some(10.0),
whale_min_usd: Some(50_000.0),
volume_spike_threshold_pct: Some(100.0),
},
export: ExportConfig {
path: Some("./exports".to_string()),
},
auto_pause_on_input: true,
venue: Some("binance".to_string()),
};
let yaml = serde_yaml::to_string(&config).unwrap();
let parsed: MonitorConfig = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(parsed.layout, LayoutPreset::Dashboard);
assert_eq!(parsed.refresh_seconds, 10);
assert_eq!(parsed.venue, Some("binance".to_string()));
assert_eq!(parsed.alerts.price_min, Some(0.5));
assert_eq!(parsed.alerts.price_max, Some(10.0));
assert_eq!(parsed.export.path, Some("./exports".to_string()));
assert!(parsed.auto_pause_on_input);
}
#[test]
fn test_monitor_config_serde_defaults_for_new_fields() {
let yaml = r#"
layout: dashboard
refresh_seconds: 5
"#;
let config: MonitorConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.alerts.price_min.is_none());
assert!(config.export.path.is_none());
assert!(!config.auto_pause_on_input);
}
#[test]
fn test_quit_stops_export() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let path = start_export_in_temp(&mut state);
let exit = handle_key_event_on_state(make_key_event(KeyCode::Char('q')), &mut state);
assert!(exit);
assert!(!state.export_active);
let _ = std::fs::remove_file(path);
}
#[test]
fn test_monitor_state_new_has_alert_export_autopause_fields() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
assert!(state.active_alerts.is_empty());
assert!(state.alert_flash_until.is_none());
assert!(state.alerts.price_min.is_none());
assert!(!state.export_active);
assert!(state.export_path.is_none());
assert!(!state.auto_pause_on_input);
assert_eq!(state.auto_pause_timeout, Duration::from_secs(3));
}
#[test]
fn test_scale_mode_serde_roundtrip() {
for mode in &[ScaleMode::Linear, ScaleMode::Log] {
let yaml = serde_yaml::to_string(mode).unwrap();
let parsed: ScaleMode = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(&parsed, mode);
}
}
#[test]
fn test_scale_mode_serde_kebab_case() {
let parsed: ScaleMode = serde_yaml::from_str("linear").unwrap();
assert_eq!(parsed, ScaleMode::Linear);
let parsed: ScaleMode = serde_yaml::from_str("log").unwrap();
assert_eq!(parsed, ScaleMode::Log);
}
#[test]
fn test_scale_mode_toggle() {
assert_eq!(ScaleMode::Linear.toggle(), ScaleMode::Log);
assert_eq!(ScaleMode::Log.toggle(), ScaleMode::Linear);
}
#[test]
fn test_scale_mode_label() {
assert_eq!(ScaleMode::Linear.label(), "Lin");
assert_eq!(ScaleMode::Log.label(), "Log");
}
#[test]
fn test_color_scheme_serde_roundtrip() {
for scheme in &[
ColorScheme::GreenRed,
ColorScheme::BlueOrange,
ColorScheme::Monochrome,
] {
let yaml = serde_yaml::to_string(scheme).unwrap();
let parsed: ColorScheme = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(&parsed, scheme);
}
}
#[test]
fn test_color_scheme_serde_kebab_case() {
let parsed: ColorScheme = serde_yaml::from_str("green-red").unwrap();
assert_eq!(parsed, ColorScheme::GreenRed);
let parsed: ColorScheme = serde_yaml::from_str("blue-orange").unwrap();
assert_eq!(parsed, ColorScheme::BlueOrange);
let parsed: ColorScheme = serde_yaml::from_str("monochrome").unwrap();
assert_eq!(parsed, ColorScheme::Monochrome);
}
#[test]
fn test_color_scheme_cycle() {
assert_eq!(ColorScheme::GreenRed.next(), ColorScheme::BlueOrange);
assert_eq!(ColorScheme::BlueOrange.next(), ColorScheme::Monochrome);
assert_eq!(ColorScheme::Monochrome.next(), ColorScheme::GreenRed);
}
#[test]
fn test_color_scheme_label() {
assert_eq!(ColorScheme::GreenRed.label(), "green-red");
assert_eq!(ColorScheme::BlueOrange.label(), "blue-orange");
assert_eq!(ColorScheme::Monochrome.label(), "mono");
}
#[test]
fn test_color_palette_fields_populated() {
for scheme in &[ColorScheme::GreenRed, ColorScheme::BlueOrange] {
let pal = scheme.palette();
assert_ne!(
format!("{:?}", pal.up),
format!("{:?}", pal.down),
"Up/down should differ for {:?}",
scheme
);
}
}
#[test]
fn test_monochrome_adds_no_color_and_keeps_direction_in_glyphs() {
let pal = ColorScheme::Monochrome.palette();
assert_eq!(pal.up, ratatui::style::Color::Reset);
assert_eq!(pal.down, ratatui::style::Color::Reset);
let header = |change: f64| {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.color_scheme = ColorScheme::Monochrome;
state.price_change_24h = change;
terminal
.draw(|f| render_header(f, f.area(), &state))
.unwrap();
let buf = terminal.backend().buffer();
for cell in &buf.content {
for c in [ratatui::style::Color::Green, ratatui::style::Color::Red] {
assert_ne!(cell.fg, c, "Monochrome header drew {c:?}");
}
}
format!("{}", terminal.backend())
};
let (up, down) = (header(50.0), header(-50.0));
assert!(up.contains('▲') && !up.contains('▼'));
assert!(down.contains('▼') && !down.contains('▲'));
}
#[test]
fn test_layout_preset_serde_roundtrip() {
for preset in &[
LayoutPreset::Dashboard,
LayoutPreset::ChartFocus,
LayoutPreset::Feed,
LayoutPreset::Compact,
] {
let yaml = serde_yaml::to_string(preset).unwrap();
let parsed: LayoutPreset = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(&parsed, preset);
}
}
#[test]
fn test_layout_preset_serde_kebab_case() {
let parsed: LayoutPreset = serde_yaml::from_str("dashboard").unwrap();
assert_eq!(parsed, LayoutPreset::Dashboard);
let parsed: LayoutPreset = serde_yaml::from_str("chart-focus").unwrap();
assert_eq!(parsed, LayoutPreset::ChartFocus);
let parsed: LayoutPreset = serde_yaml::from_str("feed").unwrap();
assert_eq!(parsed, LayoutPreset::Feed);
let parsed: LayoutPreset = serde_yaml::from_str("compact").unwrap();
assert_eq!(parsed, LayoutPreset::Compact);
}
#[test]
fn test_widget_visibility_serde_roundtrip() {
let vis = WidgetVisibility {
price_chart: false,
volume_chart: true,
buy_sell_pressure: false,
metrics_panel: true,
activity_log: false,
holder_count: false,
liquidity_depth: true,
};
let yaml = serde_yaml::to_string(&vis).unwrap();
let parsed: WidgetVisibility = serde_yaml::from_str(&yaml).unwrap();
assert!(!parsed.price_chart);
assert!(parsed.volume_chart);
assert!(!parsed.buy_sell_pressure);
assert!(parsed.metrics_panel);
assert!(!parsed.activity_log);
assert!(!parsed.holder_count);
assert!(parsed.liquidity_depth);
}
#[test]
fn test_data_point_serde_roundtrip() {
let dp = DataPoint {
timestamp: 1700000000.5,
value: 42.123456,
is_real: true,
};
let json = serde_json::to_string(&dp).unwrap();
let parsed: DataPoint = serde_json::from_str(&json).unwrap();
assert!((parsed.timestamp - dp.timestamp).abs() < 0.001);
assert!((parsed.value - dp.value).abs() < 0.001);
assert_eq!(parsed.is_real, dp.is_real);
}
#[test]
fn test_handle_key_scale_toggle_s() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert_eq!(state.scale_mode, ScaleMode::Linear);
handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
assert_eq!(state.scale_mode, ScaleMode::Log);
handle_key_event_on_state(make_key_event(KeyCode::Char('s')), &mut state);
assert_eq!(state.scale_mode, ScaleMode::Linear);
}
#[test]
fn test_handle_key_color_scheme_cycle_slash() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
assert_eq!(state.color_scheme, ColorScheme::GreenRed);
handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
assert_eq!(state.color_scheme, ColorScheme::BlueOrange);
handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
assert_eq!(state.color_scheme, ColorScheme::Monochrome);
handle_key_event_on_state(make_key_event(KeyCode::Char('/')), &mut state);
assert_eq!(state.color_scheme, ColorScheme::GreenRed);
}
#[test]
fn test_render_volume_profile_chart_no_panic() {
let mut terminal = create_test_terminal();
let state = create_populated_state();
terminal
.draw(|f| render_volume_profile_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_volume_profile_chart_empty_data() {
let mut terminal = create_test_terminal();
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.price_history.clear();
state.volume_history.clear();
terminal
.draw(|f| render_volume_profile_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_volume_profile_chart_single_price() {
let mut terminal = create_test_terminal();
let mut token_data = create_test_token_data();
token_data.price_usd = 1.0;
let mut state = MonitorState::new(&token_data, "ethereum");
state.price_history.clear();
state.volume_history.clear();
let now = chrono::Utc::now().timestamp() as f64;
for i in 0..5 {
state.price_history.push_back(DataPoint {
timestamp: now - (5.0 - i as f64) * 60.0,
value: 1.0, is_real: true,
});
state.volume_history.push_back(DataPoint {
timestamp: now - (5.0 - i as f64) * 60.0,
value: 1000.0,
is_real: true,
});
}
terminal
.draw(|f| render_volume_profile_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_volume_profile_chart_narrow_terminal() {
let backend = TestBackend::new(30, 15);
let mut terminal = Terminal::new(backend).unwrap();
let state = create_populated_state();
terminal
.draw(|f| render_volume_profile_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_price_chart_log_scale() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.scale_mode = ScaleMode::Log;
terminal
.draw(|f| render_price_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_candlestick_chart_log_scale() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.scale_mode = ScaleMode::Log;
state.chart_mode = ChartMode::Candlestick;
terminal
.draw(|f| render_candlestick_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_price_chart_log_scale_zero_price() {
let mut terminal = create_test_terminal();
let mut token_data = create_test_token_data();
token_data.price_usd = 0.0001;
let mut state = MonitorState::new(&token_data, "ethereum");
state.scale_mode = ScaleMode::Log;
for i in 0..10 {
let mut data = token_data.clone();
data.price_usd = 0.0001 + (i as f64 * 0.00001);
state.update(&data);
}
terminal
.draw(|f| render_price_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_ui_with_all_color_schemes() {
for scheme in &[
ColorScheme::GreenRed,
ColorScheme::BlueOrange,
ColorScheme::Monochrome,
] {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.color_scheme = *scheme;
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
}
#[test]
fn test_render_volume_chart_all_color_schemes() {
for scheme in &[
ColorScheme::GreenRed,
ColorScheme::BlueOrange,
ColorScheme::Monochrome,
] {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.color_scheme = *scheme;
terminal
.draw(|f| render_volume_chart(f, f.area(), &state))
.unwrap();
}
}
#[test]
fn test_render_activity_feed_no_panic() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
for i in 0..5 {
state.log_messages.push_back(format!("Event {}", i));
}
terminal
.draw(|f| render_activity_feed(f, f.area(), &mut state))
.unwrap();
}
#[test]
fn test_render_activity_feed_empty_log() {
let mut terminal = create_test_terminal();
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.log_messages.clear();
terminal
.draw(|f| render_activity_feed(f, f.area(), &mut state))
.unwrap();
}
#[test]
fn test_render_activity_feed_with_selection() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
for i in 0..10 {
state.log_messages.push_back(format!("Event {}", i));
}
state.scroll_log_down();
state.scroll_log_down();
state.scroll_log_down();
terminal
.draw(|f| render_activity_feed(f, f.area(), &mut state))
.unwrap();
}
#[test]
fn test_alert_whale_zero_transactions() {
let mut token_data = create_test_token_data();
token_data.total_buys_24h = 0;
token_data.total_sells_24h = 0;
let mut state = MonitorState::new(&token_data, "ethereum");
state.alerts.whale_min_usd = Some(100.0);
state.update(&token_data);
let whale_alerts: Vec<_> = state
.active_alerts
.iter()
.filter(|a| a.message.contains("whale") || a.message.contains("🐋"))
.collect();
assert!(
whale_alerts.is_empty(),
"Whale alert should not fire with zero transactions"
);
}
#[test]
fn test_alert_multiple_simultaneous() {
let mut token_data = create_test_token_data();
token_data.price_usd = 0.1; let mut state = MonitorState::new(&token_data, "ethereum");
state.alerts.price_min = Some(0.5); state.alerts.price_max = Some(0.05); state.alerts.volume_spike_threshold_pct = Some(1.0);
state.volume_avg = 100.0;
state.update(&token_data);
assert!(
state.active_alerts.len() >= 2,
"Expected multiple alerts, got {}",
state.active_alerts.len()
);
}
#[test]
fn test_alert_clears_on_next_update() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.alerts.price_min = Some(2.0); state.update(&token_data);
assert!(!state.active_alerts.is_empty());
let mut above_min = token_data.clone();
above_min.price_usd = 3.0;
state.alerts.price_min = Some(2.0);
state.update(&above_min);
let price_min_alerts: Vec<_> = state
.active_alerts
.iter()
.filter(|a| a.message.contains("below min"))
.collect();
assert!(
price_min_alerts.is_empty(),
"Price-min alert should clear when price goes above min"
);
}
#[test]
fn test_render_alert_overlay_multiple_alerts() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.active_alerts.push(ActiveAlert {
message: "⚠ Price below min".to_string(),
triggered_at: Instant::now(),
});
state.active_alerts.push(ActiveAlert {
message: "🐋 Whale detected".to_string(),
triggered_at: Instant::now(),
});
state.active_alerts.push(ActiveAlert {
message: "⚠ Volume spike".to_string(),
triggered_at: Instant::now(),
});
state.alert_flash_until = Some(Instant::now() + Duration::from_secs(2));
terminal
.draw(|f| render_alert_overlay(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_alert_overlay_flash_expired() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.active_alerts.push(ActiveAlert {
message: "⚠ Test".to_string(),
triggered_at: Instant::now(),
});
state.alert_flash_until = Some(Instant::now() - Duration::from_secs(5));
terminal
.draw(|f| render_alert_overlay(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_liquidity_depth_many_pairs() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
for i in 0..20 {
state.liquidity_pairs.push((
format!("TEST/TOKEN{} (DEX{})", i, i),
(100_000.0 + i as f64 * 50_000.0),
));
}
terminal
.draw(|f| render_liquidity_depth(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_liquidity_depth_narrow_terminal() {
let backend = TestBackend::new(30, 10);
let mut terminal = Terminal::new(backend).unwrap();
let mut state = create_populated_state();
state.liquidity_pairs = vec![
("TEST/WETH (Uniswap)".to_string(), 500_000.0),
("TEST/USDC (Sushi)".to_string(), 100_000.0),
];
terminal
.draw(|f| render_liquidity_depth(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_metrics_panel_holder_count_disabled() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.holder_count = Some(42_000);
state.widgets.holder_count = false; terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_metrics_panel_sparkline_single_point() {
let mut terminal = create_test_terminal();
let mut token_data = create_test_token_data();
token_data.price_usd = 1.0;
let mut state = MonitorState::new(&token_data, "ethereum");
state.price_history.clear();
state.price_history.push_back(DataPoint {
timestamp: 1.0,
value: 1.0,
is_real: true,
});
terminal
.draw(|f| render_metrics_panel(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_buy_sell_gauge_tiny_area() {
let backend = TestBackend::new(5, 3);
let mut terminal = Terminal::new(backend).unwrap();
let mut state = create_populated_state();
terminal
.draw(|f| render_buy_sell_gauge(f, f.area(), &mut state))
.unwrap();
}
#[test]
fn test_log_message_queue_overflow() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
for i in 0..20 {
state.toggle_pause(); let _ = i;
}
assert!(
state.log_messages.len() <= 10,
"Log queue should cap at 10, got {}",
state.log_messages.len()
);
}
#[test]
fn test_export_writes_csv_row_content_format() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let path = start_export_in_temp(&mut state);
state.update(&token_data);
let contents = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert!(lines.len() >= 2);
assert_eq!(
lines[0],
"timestamp,price_usd,volume_24h,liquidity_usd,buys_24h,sells_24h,market_cap"
);
let data_cols: Vec<&str> = lines[1].split(',').collect();
assert_eq!(
data_cols.len(),
7,
"Expected 7 CSV columns, got {}",
data_cols.len()
);
assert!(data_cols[0].contains('T'));
assert!(data_cols[0].ends_with('Z'));
state.stop_export();
let _ = std::fs::remove_file(path);
}
#[test]
fn test_export_writes_csv_row_market_cap_none() {
let mut token_data = create_test_token_data();
token_data.market_cap = None;
let mut state = MonitorState::new(&token_data, "ethereum");
let path = start_export_in_temp(&mut state);
state.update(&token_data);
let contents = std::fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert!(lines.len() >= 2);
let data_cols: Vec<&str> = lines[1].split(',').collect();
assert_eq!(data_cols.len(), 7);
assert!(
data_cols[6].is_empty(),
"Market cap column should be empty when None"
);
state.stop_export();
let _ = std::fs::remove_file(path);
}
#[test]
fn test_ui_render_log_scale_all_chart_modes() {
for mode in &[
ChartMode::Line,
ChartMode::Candlestick,
ChartMode::VolumeProfile,
] {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.scale_mode = ScaleMode::Log;
state.chart_mode = *mode;
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
}
#[test]
fn test_render_footer_widget_toggle_mode_active() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.widget_toggle_mode = true;
terminal
.draw(|f| render_footer(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_render_footer_all_status_indicators() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.export_active = true;
state.auto_pause_on_input = true;
state.last_input_at = Instant::now(); terminal
.draw(|f| render_footer(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_generate_synthetic_price_history_zero_price() {
let mut token_data = create_test_token_data();
token_data.price_usd = 0.0;
token_data.price_change_1h = 0.0;
token_data.price_change_6h = 0.0;
token_data.price_change_24h = 0.0;
let state = MonitorState::new(&token_data, "ethereum");
assert!(!state.price_history.is_empty());
}
#[test]
fn test_generate_synthetic_volume_history_zero_volume() {
let mut token_data = create_test_token_data();
token_data.volume_24h = 0.0;
token_data.volume_6h = 0.0;
token_data.volume_1h = 0.0;
let state = MonitorState::new(&token_data, "ethereum");
assert!(!state.volume_history.is_empty());
}
#[test]
fn test_auto_pause_custom_timeout() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
state.auto_pause_on_input = true;
state.auto_pause_timeout = Duration::from_secs(10);
state.refresh_rate = Duration::from_secs(1);
state.last_input_at = Instant::now();
state.last_update = Instant::now() - Duration::from_secs(5);
assert!(!state.should_refresh()); assert!(state.is_auto_paused());
}
#[test]
fn test_render_price_chart_stablecoin_flat_range() {
let mut terminal = create_test_terminal();
let mut token_data = create_test_token_data();
token_data.price_usd = 1.0;
let mut state = MonitorState::new(&token_data, "ethereum");
for i in 0..20 {
let mut data = token_data.clone();
data.price_usd = 1.0 + (i as f64 * 0.000001); state.update(&data);
}
terminal
.draw(|f| render_price_chart(f, f.area(), &state))
.unwrap();
}
#[test]
fn test_load_cache_corrupted_json() {
let path = MonitorState::cache_path("0xCORRUPTED_TEST", "test_chain");
let _ = std::fs::write(&path, "not valid json {{{");
let cached = MonitorState::load_cache("0xCORRUPTED_TEST", "test_chain");
assert!(cached.is_none(), "Corrupted JSON should return None");
let _ = std::fs::remove_file(path);
}
#[test]
fn test_load_cache_wrong_token() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
state.save_cache();
let cached = MonitorState::load_cache("0xDIFFERENT_ADDRESS", "ethereum");
assert!(
cached.is_none(),
"Loading cache with wrong token address should return None"
);
let path = MonitorState::cache_path(&token_data.address, "ethereum");
let _ = std::fs::remove_file(path);
}
use scope::chains::dex::TokenSearchResult;
struct MockDexDataSource {
token_data_result: std::sync::Mutex<Result<DexTokenData>>,
calls: std::sync::Arc<std::sync::atomic::AtomicUsize>,
}
impl MockDexDataSource {
fn new(data: DexTokenData) -> Self {
Self {
token_data_result: std::sync::Mutex::new(Ok(data)),
calls: Default::default(),
}
}
fn failing(msg: &str) -> Self {
Self {
token_data_result: std::sync::Mutex::new(Err(ScopeError::Api(msg.to_string()))),
calls: Default::default(),
}
}
}
#[async_trait::async_trait]
impl DexDataSource for MockDexDataSource {
async fn get_token_price(&self, _chain: &str, _address: &str) -> Option<f64> {
self.token_data_result
.lock()
.unwrap()
.as_ref()
.ok()
.map(|d| d.price_usd)
}
async fn get_native_token_price(&self, _chain: &str) -> Option<f64> {
Some(2000.0)
}
async fn get_token_data(&self, _chain: &str, _address: &str) -> Result<DexTokenData> {
self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let guard = self.token_data_result.lock().unwrap();
match &*guard {
Ok(data) => Ok(data.clone()),
Err(e) => Err(ScopeError::Api(e.to_string())),
}
}
async fn search_tokens(
&self,
_query: &str,
_chain: Option<&str>,
) -> Result<Vec<TokenSearchResult>> {
Ok(vec![])
}
}
struct MockChainClient {
holder_count: u64,
}
impl MockChainClient {
fn new(holder_count: u64) -> Self {
Self { holder_count }
}
}
#[async_trait::async_trait]
impl ChainClient for MockChainClient {
fn chain_name(&self) -> &str {
"ethereum"
}
fn native_token_symbol(&self) -> &str {
"ETH"
}
async fn get_balance(&self, _address: &str) -> Result<scope::chains::Balance> {
unimplemented!("not needed for monitor tests")
}
async fn enrich_balance_usd(&self, _balance: &mut scope::chains::Balance) {}
async fn get_transaction(&self, _hash: &str) -> Result<scope::chains::Transaction> {
unimplemented!("not needed for monitor tests")
}
async fn get_transactions(
&self,
_address: &str,
_limit: u32,
) -> Result<Vec<scope::chains::Transaction>> {
Ok(vec![])
}
async fn get_block_number(&self) -> Result<u64> {
Ok(1000000)
}
async fn get_token_balances(
&self,
_address: &str,
) -> Result<Vec<scope::chains::TokenBalance>> {
Ok(vec![])
}
async fn get_token_holder_count(&self, _address: &str) -> Result<u64> {
Ok(self.holder_count)
}
}
fn create_test_app(
dex: Box<dyn DexDataSource>,
chain_client: Option<Box<dyn ChainClient>>,
) -> MonitorApp<TestBackend> {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
let backend = TestBackend::new(120, 40);
let terminal = ratatui::Terminal::new(backend).unwrap();
MonitorApp {
terminal,
state,
dex_client: dex,
chain_client,
exchange_client: None,
should_exit: false,
owns_terminal: false,
}
}
fn create_test_app_with_state(
state: MonitorState,
dex: Box<dyn DexDataSource>,
chain_client: Option<Box<dyn ChainClient>>,
) -> MonitorApp<TestBackend> {
let backend = TestBackend::new(120, 40);
let terminal = ratatui::Terminal::new(backend).unwrap();
MonitorApp {
terminal,
state,
dex_client: dex,
chain_client,
exchange_client: None,
should_exit: false,
owns_terminal: false,
}
}
#[test]
fn test_app_handle_key_quit_q() {
let data = create_test_token_data();
let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
assert!(!app.should_exit);
app.handle_key_event(make_key_event(KeyCode::Char('q')));
assert!(app.should_exit);
}
#[test]
fn test_app_handle_key_quit_esc() {
let data = create_test_token_data();
let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
app.handle_key_event(make_key_event(KeyCode::Esc));
assert!(app.should_exit);
}
#[test]
fn test_app_handle_key_quit_ctrl_c() {
let data = create_test_token_data();
let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
app.handle_key_event(key);
assert!(app.should_exit);
}
#[test]
fn test_app_handle_key_quit_stops_active_export() {
let data = create_test_token_data();
let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
let path = start_export_in_temp(&mut app.state);
assert!(app.state.export_active);
app.handle_key_event(make_key_event(KeyCode::Char('q')));
assert!(app.should_exit);
assert!(!app.state.export_active);
let _ = std::fs::remove_file(path);
}
#[test]
fn test_app_handle_key_ctrl_c_stops_active_export() {
let data = create_test_token_data();
let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
let path = start_export_in_temp(&mut app.state);
assert!(app.state.export_active);
let key = crossterm::event::KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
app.handle_key_event(key);
assert!(app.should_exit);
assert!(!app.state.export_active);
let _ = std::fs::remove_file(path);
}
#[test]
fn test_app_handle_key_updates_last_input_time() {
let data = create_test_token_data();
let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
let before = Instant::now();
app.handle_key_event(make_key_event(KeyCode::Char('p')));
assert!(app.state.last_input_at >= before);
}
#[test]
fn test_app_handle_key_widget_toggle_mode() {
let data = create_test_token_data();
let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
assert!(app.state.widgets.price_chart);
app.handle_key_event(make_key_event(KeyCode::Char('w')));
assert!(app.state.widget_toggle_mode);
app.handle_key_event(make_key_event(KeyCode::Char('1')));
assert!(!app.state.widget_toggle_mode);
assert!(!app.state.widgets.price_chart);
}
#[test]
fn test_app_handle_key_widget_toggle_mode_cancel() {
let data = create_test_token_data();
let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
app.handle_key_event(make_key_event(KeyCode::Char('w')));
assert!(app.state.widget_toggle_mode);
app.handle_key_event(make_key_event(KeyCode::Char('x')));
assert!(!app.state.widget_toggle_mode);
}
#[test]
fn test_app_handle_key_all_keybindings() {
let data = create_test_token_data();
let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
app.handle_key_event(make_key_event(KeyCode::Char('r')));
assert!(!app.should_exit);
let key = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
app.handle_key_event(key);
assert!(app.state.auto_pause_on_input);
app.handle_key_event(key);
assert!(!app.state.auto_pause_on_input);
app.handle_key_event(make_key_event(KeyCode::Char('p')));
assert!(app.state.paused);
app.handle_key_event(make_key_event(KeyCode::Char(' ')));
assert!(!app.state.paused);
let dir = tempfile::tempdir().unwrap();
app.state.export_dir = dir.path().to_path_buf();
app.handle_key_event(make_key_event(KeyCode::Char('e')));
assert!(app.state.export_active);
app.state.stop_export();
let before_rate = app.state.refresh_rate;
app.handle_key_event(make_key_event(KeyCode::Char('+')));
assert!(app.state.refresh_rate >= before_rate);
let before_rate = app.state.refresh_rate;
app.handle_key_event(make_key_event(KeyCode::Char('-')));
assert!(app.state.refresh_rate <= before_rate);
app.handle_key_event(make_key_event(KeyCode::Char('1')));
assert_eq!(app.state.time_period, TimePeriod::Min1);
app.handle_key_event(make_key_event(KeyCode::Char('2')));
assert_eq!(app.state.time_period, TimePeriod::Min5);
app.handle_key_event(make_key_event(KeyCode::Char('3')));
assert_eq!(app.state.time_period, TimePeriod::Min15);
app.handle_key_event(make_key_event(KeyCode::Char('4')));
assert_eq!(app.state.time_period, TimePeriod::Hour1);
app.handle_key_event(make_key_event(KeyCode::Char('5')));
assert_eq!(app.state.time_period, TimePeriod::Hour4);
app.handle_key_event(make_key_event(KeyCode::Char('6')));
assert_eq!(app.state.time_period, TimePeriod::Day1);
app.handle_key_event(make_key_event(KeyCode::Char('t')));
assert_eq!(app.state.time_period, TimePeriod::Min1);
app.handle_key_event(make_key_event(KeyCode::Char('c')));
assert_eq!(app.state.chart_mode, ChartMode::Candlestick);
app.handle_key_event(make_key_event(KeyCode::Char('s')));
assert_eq!(app.state.scale_mode, ScaleMode::Log);
app.handle_key_event(make_key_event(KeyCode::Char('/')));
assert_eq!(app.state.color_scheme, ColorScheme::BlueOrange);
app.handle_key_event(make_key_event(KeyCode::Char('j')));
app.handle_key_event(make_key_event(KeyCode::Char('k')));
app.handle_key_event(make_key_event(KeyCode::Char('l')));
assert!(!app.state.auto_layout);
app.handle_key_event(make_key_event(KeyCode::Char('h')));
app.handle_key_event(make_key_event(KeyCode::Char('a')));
assert!(app.state.auto_layout);
app.handle_key_event(make_key_event(KeyCode::Char('w')));
assert!(app.state.widget_toggle_mode);
app.handle_key_event(make_key_event(KeyCode::Char('z')));
app.handle_key_event(make_key_event(KeyCode::F(12)));
assert!(!app.should_exit);
}
#[test]
fn test_standalone_key_handler_matches_the_app_handler_for_every_key() {
let shift_p = crossterm::event::KeyEvent::new(KeyCode::Char('P'), KeyModifiers::SHIFT);
let keys = [
make_key_event(KeyCode::Char('r')),
shift_p,
make_key_event(KeyCode::Char('p')),
make_key_event(KeyCode::Char(' ')),
make_key_event(KeyCode::Char('=')),
make_key_event(KeyCode::Char(']')),
make_key_event(KeyCode::Char('_')),
make_key_event(KeyCode::Char('[')),
make_key_event(KeyCode::Char('3')),
make_key_event(KeyCode::Tab),
make_key_event(KeyCode::Char('c')),
make_key_event(KeyCode::Char('s')),
make_key_event(KeyCode::Char('/')),
make_key_event(KeyCode::Char('j')),
make_key_event(KeyCode::Down),
make_key_event(KeyCode::Char('k')),
make_key_event(KeyCode::Up),
make_key_event(KeyCode::Char('l')),
make_key_event(KeyCode::Char('l')),
make_key_event(KeyCode::Char('h')),
make_key_event(KeyCode::Char('a')),
make_key_event(KeyCode::Char('w')),
make_key_event(KeyCode::Char('2')),
make_key_event(KeyCode::Char('w')),
make_key_event(KeyCode::Char('x')),
make_key_event(KeyCode::F(12)),
];
let data = create_test_token_data();
let mut app = create_test_app(Box::new(MockDexDataSource::new(data.clone())), None);
let mut twin = MonitorState::new(&data, "ethereum");
for key in keys {
app.handle_key_event(key);
assert!(!handle_key_event_on_state(key, &mut twin), "{key:?}");
let (a, b) = (&app.state, &twin);
assert_eq!(a.layout, b.layout, "{key:?}");
assert_eq!(a.auto_layout, b.auto_layout, "{key:?}");
assert_eq!(a.paused, b.paused, "{key:?}");
assert_eq!(a.auto_pause_on_input, b.auto_pause_on_input, "{key:?}");
assert_eq!(a.refresh_rate, b.refresh_rate, "{key:?}");
assert_eq!(a.time_period, b.time_period, "{key:?}");
assert_eq!(a.chart_mode, b.chart_mode, "{key:?}");
assert_eq!(a.scale_mode, b.scale_mode, "{key:?}");
assert_eq!(a.color_scheme, b.color_scheme, "{key:?}");
assert_eq!(a.widget_toggle_mode, b.widget_toggle_mode, "{key:?}");
assert_eq!(a.widgets.price_chart, b.widgets.price_chart, "{key:?}");
assert_eq!(
a.log_list_state.selected(),
b.log_list_state.selected(),
"{key:?}"
);
}
let q = make_key_event(KeyCode::Char('q'));
app.handle_key_event(q);
assert!(app.should_exit && handle_key_event_on_state(q, &mut twin));
}
#[tokio::test]
async fn test_app_fetch_data_success() {
let data = create_test_token_data();
let initial_price = data.price_usd;
let mut updated = data.clone();
updated.price_usd = 2.5;
let mut app = create_test_app(Box::new(MockDexDataSource::new(updated)), None);
assert!((app.state.current_price - initial_price).abs() < 0.001);
app.fetch_data().await;
assert!((app.state.current_price - 2.5).abs() < 0.001);
assert!(app.state.error_message.is_none());
}
#[tokio::test]
async fn test_app_fetch_data_api_error() {
let mut app = create_test_app(Box::new(MockDexDataSource::failing("rate limited")), None);
app.fetch_data().await;
assert!(app.state.error_message.is_some());
assert!(
app.state
.error_message
.as_ref()
.unwrap()
.contains("API Error")
);
}
#[tokio::test]
async fn test_app_fetch_data_holder_count_on_12th_tick() {
let data = create_test_token_data();
let mock_chain = MockChainClient::new(42_000);
let mut app = create_test_app(
Box::new(MockDexDataSource::new(data)),
Some(Box::new(mock_chain)),
);
for _ in 0..11 {
app.fetch_data().await;
}
assert!(app.state.holder_count.is_none());
app.fetch_data().await;
assert_eq!(app.state.holder_count, Some(42_000));
}
#[tokio::test]
async fn test_app_fetch_data_holder_count_zero_not_stored() {
let data = create_test_token_data();
let mock_chain = MockChainClient::new(0); let mut app = create_test_app(
Box::new(MockDexDataSource::new(data)),
Some(Box::new(mock_chain)),
);
app.state.holder_fetch_counter = 11;
app.fetch_data().await;
assert!(app.state.holder_count.is_none());
}
#[tokio::test]
async fn test_app_fetch_data_no_chain_client_skips_holders() {
let data = create_test_token_data();
let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
app.state.holder_fetch_counter = 11;
app.fetch_data().await;
assert!(app.state.holder_count.is_none());
}
#[tokio::test]
async fn test_app_fetch_data_preserves_holder_on_subsequent_failure() {
let data = create_test_token_data();
let mock_chain = MockChainClient::new(42_000);
let mut app = create_test_app(
Box::new(MockDexDataSource::new(data)),
Some(Box::new(mock_chain)),
);
app.state.holder_fetch_counter = 11;
app.fetch_data().await;
assert_eq!(app.state.holder_count, Some(42_000));
app.chain_client = Some(Box::new(MockChainClient::new(0)));
app.state.holder_fetch_counter = 23;
app.fetch_data().await;
assert_eq!(app.state.holder_count, Some(42_000));
}
#[test]
fn test_app_cleanup_does_not_panic_test_backend() {
let data = create_test_token_data();
let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
let result = app.cleanup();
assert!(result.is_ok());
}
#[test]
fn test_app_draw_renders_ui() {
let data = create_test_token_data();
let mut app = create_test_app(Box::new(MockDexDataSource::new(data)), None);
app.terminal
.draw(|f| ui(f, &mut app.state))
.expect("should render without panic");
}
fn make_search_results() -> Vec<TokenSearchResult> {
vec![
TokenSearchResult {
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
symbol: "USDC".to_string(),
name: "USD Coin".to_string(),
chain: "ethereum".to_string(),
price_usd: Some(1.0),
volume_24h: 5_000_000_000.0,
liquidity_usd: 2_000_000_000.0,
market_cap: Some(32_000_000_000.0),
},
TokenSearchResult {
address: "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(),
symbol: "USDT".to_string(),
name: "Tether USD".to_string(),
chain: "ethereum".to_string(),
price_usd: Some(1.0),
volume_24h: 6_000_000_000.0,
liquidity_usd: 3_000_000_000.0,
market_cap: Some(83_000_000_000.0),
},
TokenSearchResult {
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F".to_string(),
symbol: "DAI".to_string(),
name: "Dai Stablecoin".to_string(),
chain: "ethereum".to_string(),
price_usd: Some(1.0),
volume_24h: 200_000_000.0,
liquidity_usd: 500_000_000.0,
market_cap: Some(5_000_000_000.0),
},
]
}
#[test]
fn test_select_token_impl_valid_first() {
let results = make_search_results();
let mut reader = io::Cursor::new(b"1\n");
let (out, cap) = crate::cli::output::Output::capture();
let selected = select_token_impl(&results, &mut reader, &out).unwrap();
assert_eq!(selected.symbol, "USDC");
assert_eq!(selected.address, results[0].address);
let output = cap.err();
assert!(output.contains("Found 3 matching tokens"));
assert!(output.contains("Selected: USDC"));
}
#[test]
fn test_select_token_impl_valid_last() {
let results = make_search_results();
let mut reader = io::Cursor::new(b"3\n");
let out = crate::cli::output::Output::capture().0;
let selected = select_token_impl(&results, &mut reader, &out).unwrap();
assert_eq!(selected.symbol, "DAI");
}
#[test]
fn test_select_token_impl_valid_middle() {
let results = make_search_results();
let mut reader = io::Cursor::new(b"2\n");
let out = crate::cli::output::Output::capture().0;
let selected = select_token_impl(&results, &mut reader, &out).unwrap();
assert_eq!(selected.symbol, "USDT");
}
#[test]
fn test_select_token_impl_out_of_bounds_zero() {
let results = make_search_results();
let mut reader = io::Cursor::new(b"0\n");
let out = crate::cli::output::Output::capture().0;
let result = select_token_impl(&results, &mut reader, &out);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Selection must be between 1 and 3"));
}
#[test]
fn test_select_token_impl_out_of_bounds_high() {
let results = make_search_results();
let mut reader = io::Cursor::new(b"99\n");
let out = crate::cli::output::Output::capture().0;
let result = select_token_impl(&results, &mut reader, &out);
assert!(result.is_err());
}
#[test]
fn test_select_token_impl_non_numeric_input() {
let results = make_search_results();
let mut reader = io::Cursor::new(b"abc\n");
let out = crate::cli::output::Output::capture().0;
let result = select_token_impl(&results, &mut reader, &out);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("Invalid selection"));
}
#[test]
fn test_select_token_impl_empty_input() {
let results = make_search_results();
let mut reader = io::Cursor::new(b"\n");
let out = crate::cli::output::Output::capture().0;
let result = select_token_impl(&results, &mut reader, &out);
assert!(result.is_err());
}
#[test]
fn test_select_token_impl_long_name_truncation() {
let results = vec![TokenSearchResult {
address: "0xABCDEF1234567890ABCDEF1234567890ABCDEF12".to_string(),
symbol: "LONG".to_string(),
name: "A Very Long Token Name That Exceeds Twenty Characters".to_string(),
chain: "ethereum".to_string(),
price_usd: None,
volume_24h: 100.0,
liquidity_usd: 50.0,
market_cap: None,
}];
let mut reader = io::Cursor::new(b"1\n");
let (out, cap) = crate::cli::output::Output::capture();
let selected = select_token_impl(&results, &mut reader, &out).unwrap();
assert_eq!(selected.symbol, "LONG");
let output = cap.err();
assert!(output.contains("A Very Long Token N…"));
assert!(output.contains("N/A"));
}
#[test]
fn test_select_token_impl_output_format() {
let results = make_search_results();
let mut reader = io::Cursor::new(b"1\n");
let (out, cap) = crate::cli::output::Output::capture();
let _ = select_token_impl(&results, &mut reader, &out).unwrap();
let output = cap.err();
assert!(output.contains("#"));
assert!(output.contains("Symbol"));
assert!(output.contains("Name"));
assert!(output.contains("Address"));
assert!(output.contains("Price"));
assert!(output.contains("Liquidity"));
assert!(output.contains("─"));
assert!(output.contains("Select token (1-3):"));
}
#[tokio::test]
async fn test_app_full_scenario_fetch_render_quit() {
let data = create_test_token_data();
let mut updated = data.clone();
updated.price_usd = 3.0;
let mock_chain = MockChainClient::new(10_000);
let state = MonitorState::new(&data, "ethereum");
let mut app = create_test_app_with_state(
state,
Box::new(MockDexDataSource::new(updated)),
Some(Box::new(mock_chain)),
);
app.fetch_data().await;
assert!((app.state.current_price - 3.0).abs() < 0.001);
app.terminal
.draw(|f| ui(f, &mut app.state))
.expect("render");
let dir = tempfile::tempdir().unwrap();
app.state.export_dir = dir.path().to_path_buf();
app.handle_key_event(make_key_event(KeyCode::Char('e')));
assert!(app.state.export_active);
app.handle_key_event(make_key_event(KeyCode::Char('q')));
assert!(app.should_exit);
assert!(!app.state.export_active);
}
#[tokio::test]
async fn test_app_fetch_data_error_then_recovery() {
let mut app = create_test_app(Box::new(MockDexDataSource::failing("server down")), None);
app.fetch_data().await;
assert!(app.state.error_message.is_some());
let mut recovered = create_test_token_data();
recovered.price_usd = 5.0;
app.dex_client = Box::new(MockDexDataSource::new(recovered));
app.fetch_data().await;
assert!((app.state.current_price - 5.0).abs() < 0.001);
}
#[test]
fn test_monitor_args_defaults() {
use super::super::Cli;
use clap::Parser;
let cli = Cli::try_parse_from(["scope", "monitor", "USDC"]).unwrap();
if let super::super::Commands::Monitor(args) = cli.command {
assert_eq!(args.token, "USDC");
assert_eq!(args.chain, "ethereum");
assert!(args.layout.is_none());
assert!(args.refresh.is_none());
assert!(args.scale.is_none());
assert!(args.color_scheme.is_none());
assert!(args.export.is_none());
} else {
panic!("Expected Monitor command");
}
}
#[test]
fn test_monitor_args_all_flags() {
use super::super::Cli;
use clap::Parser;
let cli = Cli::try_parse_from([
"scope",
"monitor",
"PEPE",
"--chain",
"solana",
"--layout",
"feed",
"--refresh",
"2",
"--scale",
"log",
"--color-scheme",
"monochrome",
"--export",
"/tmp/data.csv",
])
.unwrap();
if let super::super::Commands::Monitor(args) = cli.command {
assert_eq!(args.token, "PEPE");
assert_eq!(args.chain, "solana");
assert_eq!(args.layout, Some(LayoutPreset::Feed));
assert_eq!(args.refresh, Some(2));
assert_eq!(args.scale, Some(ScaleMode::Log));
assert_eq!(args.color_scheme, Some(ColorScheme::Monochrome));
assert_eq!(args.export, Some(PathBuf::from("/tmp/data.csv")));
} else {
panic!("Expected Monitor command");
}
}
#[test]
fn test_run_direct_config_override_layout() {
let config = Config::default();
assert_eq!(config.monitor.layout, LayoutPreset::Dashboard);
let args = MonitorArgs {
token: "USDC".to_string(),
chain: "ethereum".to_string(),
layout: Some(LayoutPreset::ChartFocus),
refresh: None,
scale: None,
color_scheme: None,
export: None,
venue: None,
pair: None,
};
let mut monitor_config = config.monitor.clone();
if let Some(layout) = args.layout {
monitor_config.layout = layout;
}
assert_eq!(monitor_config.layout, LayoutPreset::ChartFocus);
}
#[test]
fn test_run_direct_config_override_all_fields() {
let config = Config::default();
let args = MonitorArgs {
token: "PEPE".to_string(),
chain: "solana".to_string(),
layout: Some(LayoutPreset::Compact),
refresh: Some(2),
scale: Some(ScaleMode::Log),
color_scheme: Some(ColorScheme::BlueOrange),
export: Some(PathBuf::from("/tmp/test.csv")),
venue: None,
pair: None,
};
let mut mc = config.monitor.clone();
if let Some(layout) = args.layout {
mc.layout = layout;
}
if let Some(refresh) = args.refresh {
mc.refresh_seconds = refresh;
}
if let Some(scale) = args.scale {
mc.scale = scale;
}
if let Some(color_scheme) = args.color_scheme {
mc.color_scheme = color_scheme;
}
if let Some(ref path) = args.export {
mc.export.path = Some(path.to_string_lossy().into_owned());
}
assert_eq!(mc.layout, LayoutPreset::Compact);
assert_eq!(mc.refresh_seconds, 2);
assert_eq!(mc.scale, ScaleMode::Log);
assert_eq!(mc.color_scheme, ColorScheme::BlueOrange);
assert_eq!(mc.export.path, Some("/tmp/test.csv".to_string()));
}
#[test]
fn test_run_direct_config_no_overrides_preserves_defaults() {
let config = Config::default();
let args = MonitorArgs {
token: "USDC".to_string(),
chain: "ethereum".to_string(),
layout: None,
refresh: None,
scale: None,
color_scheme: None,
export: None,
venue: None,
pair: None,
};
let mut mc = config.monitor.clone();
if let Some(layout) = args.layout {
mc.layout = layout;
}
if let Some(refresh) = args.refresh {
mc.refresh_seconds = refresh;
}
if let Some(scale) = args.scale {
mc.scale = scale;
}
if let Some(color_scheme) = args.color_scheme {
mc.color_scheme = color_scheme;
}
assert_eq!(mc.layout, LayoutPreset::Dashboard);
assert_eq!(mc.refresh_seconds, DEFAULT_REFRESH_SECS);
assert_eq!(mc.scale, ScaleMode::Linear);
assert_eq!(mc.color_scheme, ColorScheme::GreenRed);
assert!(mc.export.path.is_none());
}
#[test]
fn test_exchange_interval_mapping() {
assert_eq!(TimePeriod::Min1.exchange_interval(), "1m");
assert_eq!(TimePeriod::Min5.exchange_interval(), "5m");
assert_eq!(TimePeriod::Min15.exchange_interval(), "15m");
assert_eq!(TimePeriod::Hour1.exchange_interval(), "1h");
assert_eq!(TimePeriod::Hour4.exchange_interval(), "4h");
assert_eq!(TimePeriod::Day1.exchange_interval(), "1d");
}
#[test]
fn test_monitor_state_exchange_ohlc_default_empty() {
let token_data = create_test_token_data();
let state = MonitorState::new(&token_data, "ethereum");
assert!(state.exchange_ohlc.is_empty());
assert!(state.venue_pair.is_none());
}
#[test]
fn test_get_ohlc_candles_prefers_exchange_data() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64();
state.price_history.push_back(DataPoint {
timestamp: now,
value: 1.0,
is_real: true,
});
state.price_history.push_back(DataPoint {
timestamp: now + 5.0,
value: 1.01,
is_real: true,
});
let candles_before = state.get_ohlc_candles();
state.exchange_ohlc = vec![
OhlcCandle {
timestamp: 1700000000.0,
open: 50000.0,
high: 50500.0,
low: 49800.0,
close: 50200.0,
is_bullish: true,
},
OhlcCandle {
timestamp: 1700003600.0,
open: 50200.0,
high: 50800.0,
low: 50100.0,
close: 50700.0,
is_bullish: true,
},
];
let candles_after = state.get_ohlc_candles();
assert_eq!(candles_after.len(), 2);
assert_eq!(candles_after[0].open, 50000.0);
assert_eq!(candles_after[1].close, 50700.0);
if !candles_before.is_empty() {
assert_ne!(candles_after[0].open, candles_before[0].open);
}
}
#[test]
fn test_monitor_args_with_venue() {
let args = MonitorArgs {
token: "BTC".to_string(),
chain: "ethereum".to_string(),
refresh: None,
layout: None,
scale: None,
color_scheme: None,
export: None,
venue: Some("binance".to_string()),
pair: None,
};
assert_eq!(args.venue, Some("binance".to_string()));
}
#[test]
fn test_ohlc_candle_is_bullish_calculation() {
let bullish = OhlcCandle {
timestamp: 1700000000.0,
open: 100.0,
high: 110.0,
low: 95.0,
close: 105.0,
is_bullish: true,
};
assert!(bullish.is_bullish);
let bearish = OhlcCandle {
timestamp: 1700000000.0,
open: 100.0,
high: 105.0,
low: 90.0,
close: 95.0,
is_bullish: false,
};
assert!(!bearish.is_bullish);
}
#[test]
fn test_build_exchange_token_data_from_ticker() {
let ticker = scope::market::Ticker {
pair: "DAI/USDT".to_string(),
last_price: Some(1.001),
high_24h: Some(1.005),
low_24h: Some(0.998),
volume_24h: Some(500_000.0),
quote_volume_24h: Some(500_500.0),
price_change_pct_24h: None,
best_bid: Some(1.0005),
best_ask: Some(1.0015),
};
let data = build_exchange_token_data("DAI", "DAI_USDT", &ticker);
assert_eq!(data.symbol, "DAI");
assert_eq!(data.name, "DAI");
assert_eq!(data.price_usd, 1.001);
assert_eq!(data.volume_24h, 500_500.0);
assert!(data.address.contains("exchange:"));
assert!(data.pairs.is_empty());
assert!(data.price_history.is_empty());
assert!(data.dexscreener_url.is_none());
}
#[test]
fn test_build_exchange_token_data_missing_price() {
let ticker = scope::market::Ticker {
pair: "FOO/USDT".to_string(),
last_price: None,
high_24h: None,
low_24h: None,
volume_24h: None,
quote_volume_24h: None,
price_change_pct_24h: None,
best_bid: None,
best_ask: None,
};
let data = build_exchange_token_data("FOO", "FOO_USDT", &ticker);
assert_eq!(data.price_usd, 0.0);
assert_eq!(data.volume_24h, 0.0);
}
#[test]
fn test_monitor_args_with_pair() {
let args = MonitorArgs {
token: "DAI".to_string(),
chain: "ethereum".to_string(),
refresh: None,
layout: None,
scale: None,
color_scheme: None,
export: None,
venue: Some("biconomy".to_string()),
pair: Some("DAI_USDT".to_string()),
};
assert_eq!(args.pair, Some("DAI_USDT".to_string()));
assert_eq!(args.venue, Some("biconomy".to_string()));
}
#[test]
fn test_monitor_args_pair_none_by_default() {
let args = MonitorArgs {
token: "BTC".to_string(),
chain: "ethereum".to_string(),
refresh: None,
layout: None,
scale: None,
color_scheme: None,
export: None,
venue: None,
pair: None,
};
assert!(args.pair.is_none());
}
#[test]
fn test_build_exchange_token_data_extracts_base_symbol() {
let ticker = scope::market::Ticker {
pair: "DOGE/USDT".to_string(),
last_price: Some(0.123),
high_24h: Some(0.13),
low_24h: Some(0.12),
volume_24h: Some(1_000_000.0),
quote_volume_24h: Some(123_000.0),
price_change_pct_24h: None,
best_bid: Some(0.1225),
best_ask: Some(0.1235),
};
let data = build_exchange_token_data("DOGE", "DOGE_USDT", &ticker);
assert_eq!(data.symbol, "DOGE");
assert_eq!(data.name, "DOGE");
assert_eq!(data.price_usd, 0.123);
assert_eq!(data.volume_24h, 123_000.0);
assert_eq!(data.price_change_24h, 0.0);
assert_eq!(data.price_change_6h, 0.0);
assert_eq!(data.price_change_1h, 0.0);
assert_eq!(data.price_change_5m, 0.0);
assert_eq!(data.volume_6h, 0.0);
assert_eq!(data.volume_1h, 0.0);
assert_eq!(data.liquidity_usd, 0.0);
assert!(data.market_cap.is_none());
assert!(data.fdv.is_none());
assert!(data.earliest_pair_created_at.is_none());
assert!(data.image_url.is_none());
assert!(data.websites.is_empty());
assert!(data.socials.is_empty());
assert_eq!(data.total_buys_24h, 0);
assert_eq!(data.total_sells_24h, 0);
}
#[test]
fn test_build_exchange_token_data_address_format() {
let ticker = scope::market::Ticker {
pair: "X/Y".to_string(),
last_price: Some(1.0),
high_24h: None,
low_24h: None,
volume_24h: None,
quote_volume_24h: None,
price_change_pct_24h: None,
best_bid: None,
best_ask: None,
};
let data = build_exchange_token_data("X", "X_Y", &ticker);
assert_eq!(data.address, "exchange:X_Y");
}
#[test]
fn test_monitor_args_pair_requires_venue_conceptually() {
let args = MonitorArgs {
token: "DAI".to_string(),
chain: "ethereum".to_string(),
refresh: None,
layout: None,
scale: None,
color_scheme: None,
export: None,
venue: Some("biconomy".to_string()),
pair: Some("DAI_USDT".to_string()),
};
assert!(args.venue.is_some());
assert!(args.pair.is_some());
}
#[test]
fn test_run_direct_config_pair_passthrough() {
let config = Config::default();
let args = MonitorArgs {
token: "DAI".to_string(),
chain: "ethereum".to_string(),
layout: None,
refresh: None,
scale: None,
color_scheme: None,
export: None,
venue: Some("biconomy".to_string()),
pair: Some("DAI_USDT".to_string()),
};
let mut mc = config.monitor.clone();
if let Some(ref venue) = args.venue {
mc.venue = Some(venue.clone());
}
assert_eq!(mc.venue, Some("biconomy".to_string()));
assert_eq!(args.pair, Some("DAI_USDT".to_string()));
}
#[test]
fn test_chain_filter_logic_ethereum_default() {
let chain = "ethereum";
let chain_filter: Option<&str> = if chain != "ethereum" {
Some(chain)
} else {
None
};
assert!(chain_filter.is_none());
}
#[test]
fn test_chain_filter_logic_explicit_chain() {
let chain = "solana";
let chain_filter: Option<&str> = if chain != "ethereum" {
Some(chain)
} else {
None
};
assert_eq!(chain_filter, Some("solana"));
}
#[test]
fn test_chain_filter_logic_bsc() {
let chain = "bsc";
let chain_filter: Option<&str> = if chain != "ethereum" {
Some(chain)
} else {
None
};
assert_eq!(chain_filter, Some("bsc"));
}
#[tokio::test]
async fn test_resolve_token_address_with_address_input() {
use scope::chains::dex::DexClient;
let dex = DexClient::new();
let result = resolve_token_address(
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"ethereum",
&dex,
true,
&Output::capture().0,
)
.await
.unwrap();
assert_eq!(result, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
}
#[tokio::test]
async fn test_resolve_token_address_reports_the_match_through_the_output() {
let mut dex = scope::chains::mocks::MockDexSource::new();
dex.search_results = vec![scope::chains::dex::TokenSearchResult {
address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".into(),
symbol: "ZZQT".into(),
name: "Zz Quiet Token".into(),
chain: "ethereum".into(),
price_usd: Some(1.5),
volume_24h: 1.0,
liquidity_usd: 1.0,
market_cap: None,
}];
let (out, cap) = Output::capture();
let address = resolve_token_address("ZZQT", "ethereum", &dex, false, &out)
.await
.unwrap();
assert_eq!(address, "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
assert_eq!(
cap.err(),
"Selected: ZZQT (Zz Quiet Token) on ethereum - $1.5000 at 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48\n"
);
assert_eq!(cap.out(), "", "what a search found is not data");
}
#[tokio::test]
async fn test_resolve_token_address_solana_address() {
use scope::chains::dex::DexClient;
let dex = DexClient::new();
let addr = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
let result = resolve_token_address(addr, "solana", &dex, true, &Output::capture().0)
.await
.unwrap();
assert_eq!(result, addr);
}
#[test]
fn test_try_cex_fallback_returns_correct_structure() {
let result = scope::chains::TokenSearchResult {
address: String::new(),
symbol: "TEST".to_string(),
name: "TEST".to_string(),
chain: "ethereum".to_string(),
price_usd: Some(1.0),
volume_24h: 100.0,
liquidity_usd: 0.0,
market_cap: None,
};
assert_eq!(result.symbol, "TEST");
assert!(result.address.is_empty());
assert_eq!(result.liquidity_usd, 0.0);
}
#[test]
fn test_select_token_impl_single_result_pick() {
let results = vec![scope::chains::dex::TokenSearchResult {
address: "0xabc".to_string(),
symbol: "TEST".to_string(),
name: "Test Token".to_string(),
chain: "ethereum".to_string(),
price_usd: Some(1.0),
volume_24h: 100.0,
liquidity_usd: 1000.0,
market_cap: None,
}];
let input = b"1\n";
let out = crate::cli::output::Output::capture().0;
let result = select_token_impl(&results, &mut &input[..], &out);
assert!(result.is_ok());
assert_eq!(result.unwrap().symbol, "TEST");
}
#[test]
fn test_select_token_impl_out_of_range() {
let results = vec![scope::chains::dex::TokenSearchResult {
address: "0xabc".to_string(),
symbol: "TEST".to_string(),
name: "Test Token".to_string(),
chain: "ethereum".to_string(),
price_usd: Some(1.0),
volume_24h: 100.0,
liquidity_usd: 1000.0,
market_cap: None,
}];
let input = b"0\n";
let out = crate::cli::output::Output::capture().0;
let result = select_token_impl(&results, &mut &input[..], &out);
assert!(result.is_err());
}
#[test]
fn test_select_token_impl_second_of_two() {
let results = vec![
scope::chains::dex::TokenSearchResult {
address: "0xabc".to_string(),
symbol: "TOKEN1".to_string(),
name: "Token One".to_string(),
chain: "ethereum".to_string(),
price_usd: Some(1.0),
volume_24h: 100.0,
liquidity_usd: 1000.0,
market_cap: None,
},
scope::chains::dex::TokenSearchResult {
address: "0xdef".to_string(),
symbol: "TOKEN2".to_string(),
name: "Token Two".to_string(),
chain: "solana".to_string(),
price_usd: Some(2.0),
volume_24h: 200.0,
liquidity_usd: 2000.0,
market_cap: None,
},
];
let input = b"2\n";
let out = crate::cli::output::Output::capture().0;
let result = select_token_impl(&results, &mut &input[..], &out);
assert!(result.is_ok());
assert_eq!(result.unwrap().symbol, "TOKEN2");
}
#[test]
fn test_handle_key_event_quit() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('q'),
crossterm::event::KeyModifiers::NONE,
);
assert!(handle_key_event_on_state(key, &mut state));
}
#[test]
fn test_handle_key_event_pause() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char(' '),
crossterm::event::KeyModifiers::NONE,
);
assert!(!state.paused);
assert!(!handle_key_event_on_state(key, &mut state));
assert!(state.paused);
}
#[test]
fn test_handle_key_event_chart_cycle() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let initial_mode = state.chart_mode;
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('c'),
crossterm::event::KeyModifiers::NONE,
);
handle_key_event_on_state(key, &mut state);
assert_ne!(state.chart_mode, initial_mode);
}
#[test]
fn test_handle_key_event_slower_faster() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let initial_rate = state.refresh_rate_secs();
let key_plus = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('+'),
crossterm::event::KeyModifiers::NONE,
);
handle_key_event_on_state(key_plus, &mut state);
assert!(state.refresh_rate_secs() > initial_rate);
let key_minus = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('-'),
crossterm::event::KeyModifiers::NONE,
);
handle_key_event_on_state(key_minus, &mut state);
assert_eq!(state.refresh_rate_secs(), initial_rate);
}
#[test]
fn test_handle_key_event_time_period() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let initial_period = state.time_period;
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Char('t'),
crossterm::event::KeyModifiers::NONE,
);
handle_key_event_on_state(key, &mut state);
assert_ne!(state.time_period, initial_period);
}
#[test]
fn test_handle_key_event_escape() {
let token_data = create_test_token_data();
let mut state = MonitorState::new(&token_data, "ethereum");
let key = crossterm::event::KeyEvent::new(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
assert!(handle_key_event_on_state(key, &mut state));
}
#[test]
fn test_spread_line_matches_the_market_summary_for_the_same_book() {
use scope::market::{
BookSource, HealthThresholds, MarketSummary, OrderBook, OrderBookLevel,
};
let level = |price, quantity| OrderBookLevel { price, quantity };
let book = OrderBook {
pair: "X/USDT".to_string(),
source: BookSource::Exchange,
bids: vec![level(1.0, 0.0), level(0.999, 500.0)],
asks: vec![level(1.0001, 0.0), level(1.001, 500.0)],
};
let summary = MarketSummary::from_order_book(&book, &HealthThresholds::default(), None);
let expected = format!(
" Spread: {:.6} ({:.3}%)",
summary.spread.unwrap(),
summary.spread_pct.unwrap()
);
assert_eq!(widgets::spread_line(&book), expected);
assert_eq!(expected, " Spread: 0.002000 (0.200%)");
}
#[test]
fn test_render_order_book_panel_no_data() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.order_book = None;
let area = Rect::new(0, 0, 50, 20);
terminal
.draw(|f| render_order_book_panel(f, area, &state))
.unwrap();
}
#[test]
fn test_render_order_book_panel_with_data() {
let mut terminal = create_test_terminal();
let state = create_populated_state();
let area = Rect::new(0, 0, 60, 25);
terminal
.draw(|f| render_order_book_panel(f, area, &state))
.unwrap();
}
#[test]
fn test_render_order_book_panel_narrow_height() {
let mut terminal = create_test_terminal();
let state = create_populated_state();
let area = Rect::new(0, 0, 50, 3);
terminal
.draw(|f| render_order_book_panel(f, area, &state))
.unwrap();
}
#[test]
fn test_render_recent_trades_panel_empty() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.recent_trades.clear();
let area = Rect::new(0, 0, 60, 15);
terminal
.draw(|f| render_recent_trades_panel(f, area, &state))
.unwrap();
}
#[test]
fn test_render_recent_trades_panel_with_trades() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.recent_trades.push_back(Trade {
price: 1.5,
quantity: 100.0,
quote_quantity: None,
timestamp_ms: 1700000000000,
side: TradeSide::Buy,
id: None,
});
state.recent_trades.push_back(Trade {
price: 1.49,
quantity: 50.0,
quote_quantity: None,
timestamp_ms: 1700000001000,
side: TradeSide::Sell,
id: None,
});
let area = Rect::new(0, 0, 60, 20);
terminal
.draw(|f| render_recent_trades_panel(f, area, &state))
.unwrap();
}
#[test]
fn test_render_recent_trades_panel_high_price_trade() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.recent_trades.push_back(Trade {
price: 50_000.0,
quantity: 0.5,
quote_quantity: None,
timestamp_ms: 1700000000000,
side: TradeSide::Buy,
id: None,
});
let area = Rect::new(0, 0, 60, 15);
terminal
.draw(|f| render_recent_trades_panel(f, area, &state))
.unwrap();
}
#[test]
fn test_render_market_info_panel_no_panic() {
let mut terminal = create_test_terminal();
let state = create_populated_state();
let area = Rect::new(0, 0, 80, 30);
terminal
.draw(|f| render_market_info_panel(f, area, &state))
.unwrap();
}
#[test]
fn test_render_market_info_panel_with_dex_pairs() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.dex_pairs = vec![
scope::chains::DexPair {
dex_name: "Uniswap V3".to_string(),
pair_address: "0xabc".to_string(),
base_token: "TEST".to_string(),
quote_token: "USDT".to_string(),
price_usd: 1.0,
volume_24h: 100_000.0,
liquidity_usd: 250_000.0,
liquidity_base: None,
price_change_24h: 2.5,
buys_24h: 50,
sells_24h: 40,
buys_6h: 15,
sells_6h: 12,
buys_1h: 5,
sells_1h: 4,
pair_created_at: None,
url: None,
},
scope::chains::DexPair {
dex_name: "SushiSwap".to_string(),
pair_address: "0xdef".to_string(),
base_token: "TEST".to_string(),
quote_token: "WETH".to_string(),
price_usd: 1.01,
volume_24h: 25_000.0,
liquidity_usd: 80_000.0,
liquidity_base: None,
price_change_24h: -1.2,
buys_24h: 20,
sells_24h: 25,
buys_6h: 8,
sells_6h: 10,
buys_1h: 3,
sells_1h: 4,
pair_created_at: None,
url: None,
},
];
let area = Rect::new(0, 0, 100, 35);
terminal
.draw(|f| render_market_info_panel(f, area, &state))
.unwrap();
}
#[test]
fn test_ohlc_candle_update_bearish() {
let mut candle = OhlcCandle::new(1700000000.0, 10.0);
assert!(candle.is_bullish);
candle.update(9.5);
assert!(!candle.is_bullish);
assert_eq!(candle.high, 10.0);
assert_eq!(candle.low, 9.5);
assert_eq!(candle.close, 9.5);
}
#[test]
fn test_time_period_index_all() {
assert_eq!(TimePeriod::Min1.index(), 0);
assert_eq!(TimePeriod::Min5.index(), 1);
assert_eq!(TimePeriod::Min15.index(), 2);
assert_eq!(TimePeriod::Hour1.index(), 3);
assert_eq!(TimePeriod::Hour4.index(), 4);
assert_eq!(TimePeriod::Day1.index(), 5);
}
#[test]
fn test_format_number_boundary_values() {
assert_eq!(format_number(0.5), "0.50");
assert_eq!(format_number(999.99), "999.99");
assert_eq!(format_number(1000.0), "1.00K");
assert_eq!(format_number(999_999.0), "1000.00K"); assert_eq!(format_number(1_000_000.0), "1.00M");
assert_eq!(format_number(999_999_999.0), "1000.00M");
assert_eq!(format_number(1_000_000_000.0), "1.00B");
}
#[test]
fn test_chart_mode_volume_profile_label() {
assert_eq!(ChartMode::VolumeProfile.label(), "VolPro");
assert_eq!(ChartMode::VolumeProfile.next(), ChartMode::Line);
}
#[test]
fn test_layout_exchange_ui_full_render() {
let mut terminal = create_test_terminal();
let mut state = create_populated_state();
state.layout = LayoutPreset::Exchange;
state.auto_layout = false;
state.dex_pairs = vec![scope::chains::DexPair {
dex_name: "Uniswap".to_string(),
pair_address: "0xabc".to_string(),
base_token: "TEST".to_string(),
quote_token: "USDT".to_string(),
price_usd: 1.0,
volume_24h: 50_000.0,
liquidity_usd: 100_000.0,
liquidity_base: None,
price_change_24h: 0.5,
buys_24h: 100,
sells_24h: 80,
buys_6h: 30,
sells_6h: 25,
buys_1h: 10,
sells_1h: 8,
pair_created_at: None,
url: None,
}];
state.order_book = state.dex_book(&state.dex_pairs, &state.symbol);
state.recent_trades.push_back(Trade {
price: 1.0,
quantity: 500.0,
quote_quantity: None,
timestamp_ms: 1700000000000,
side: TradeSide::Buy,
id: None,
});
terminal.draw(|f| ui(f, &mut state)).unwrap();
}
fn make_dex_pair(
dex: &str,
base: &str,
quote: &str,
price: f64,
vol: f64,
liq: f64,
) -> scope::chains::DexPair {
scope::chains::DexPair {
dex_name: dex.to_string(),
base_token: base.to_string(),
quote_token: quote.to_string(),
price_usd: price,
volume_24h: vol,
liquidity_usd: liq,
liquidity_base: None,
price_change_24h: 5.0,
pair_address: format!("0x{}", dex),
buys_24h: 100,
sells_24h: 50,
buys_6h: 25,
sells_6h: 12,
buys_1h: 5,
sells_1h: 3,
pair_created_at: None,
url: None,
}
}
fn create_test_token_data_with_pairs() -> DexTokenData {
let mut data = create_test_token_data();
data.pairs = vec![
make_dex_pair("uniswap", "TEST", "USDC", 1.0, 500_000.0, 300_000.0),
make_dex_pair("sushiswap", "TEST", "WETH", 1.01, 200_000.0, 150_000.0),
];
data
}
#[test]
fn test_monitor_state_update_with_token_data() {
let initial_data = create_test_token_data_with_pairs();
let mut state = MonitorState::new(&initial_data, "ethereum");
assert_eq!(state.current_price, 1.0);
assert_eq!(state.real_data_count, 0);
let mut updated_data = initial_data.clone();
updated_data.price_usd = 1.05;
updated_data.volume_24h = 1_200_000.0;
state.update(&updated_data);
assert_eq!(state.current_price, 1.05);
assert_eq!(state.real_data_count, 1);
assert!(!state.price_history.is_empty());
assert!(!state.volume_history.is_empty());
}
#[test]
fn test_monitor_state_update_price_unchanged() {
let data = create_test_token_data();
let mut state = MonitorState::new(&data, "ethereum");
state.update(&data);
assert_eq!(state.real_data_count, 1);
assert_eq!(state.current_price, 1.0);
}
#[test]
fn test_monitor_state_update_generates_trades() {
let data = create_test_token_data_with_pairs();
let mut state = MonitorState::new(&data, "ethereum");
let mut new_data = data.clone();
new_data.price_usd = 1.10;
state.update(&new_data);
assert!(!state.recent_trades.is_empty());
}
#[test]
fn test_monitor_state_update_liquidity_pairs() {
let data = create_test_token_data_with_pairs();
let mut state = MonitorState::new(&data, "ethereum");
state.update(&data);
assert_eq!(state.liquidity_pairs.len(), 2);
assert!(state.liquidity_pairs[0].0.contains("TEST/USDC"));
assert!(state.liquidity_pairs[1].0.contains("TEST/WETH"));
}
#[test]
fn test_monitor_state_update_dex_pairs_and_order_book() {
let data = create_test_token_data_with_pairs();
let mut state = MonitorState::new(&data, "ethereum");
state.update(&data);
assert_eq!(state.dex_pairs.len(), 2);
assert!(state.order_book.is_some());
}
#[test]
fn test_monitor_state_update_sells_more_than_buys() {
let mut data = create_test_token_data_with_pairs();
data.total_buys_24h = 10;
data.total_sells_24h = 50;
let mut state = MonitorState::new(&data, "ethereum");
state.update(&data);
assert!(!state.recent_trades.is_empty());
}
#[test]
fn test_monitor_state_update_zero_volume() {
let mut data = create_test_token_data_with_pairs();
data.volume_24h = 0.0;
let mut state = MonitorState::new(&data, "ethereum");
state.update(&data);
if let Some(trade) = state.recent_trades.back() {
assert_eq!(trade.quantity, 1.0);
}
}
fn spans_text(spans: &[ratatui::text::Span]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
fn ui_text(state: &mut MonitorState, w: u16, h: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal.draw(|f| ui(f, state)).unwrap();
format!("{}", terminal.backend())
}
fn venue_only_state() -> MonitorState {
venue_state(&venue_ticker("USDC/USDT", 1.0001, Some(1000.0), None, None))
}
#[test]
fn test_footer_puts_quit_first_and_keeps_pause_while_an_error_is_set() {
let mut state = create_populated_state();
state.error_message = Some("API Error: boom".into());
state.paused = true;
let text = spans_text(&footer_spans(&state));
assert!(text.starts_with("[q]uit"), "{text}");
assert!(text.contains("PAUSED"), "{text}");
assert!(
!text.contains("API Error"),
"the error is in the header: {text}"
);
let mut terminal = Terminal::new(TestBackend::new(80, 3)).unwrap();
terminal
.draw(|f| render_footer(f, f.area(), &state))
.unwrap();
let row = format!("{}", terminal.backend());
assert!(row.contains("[q]uit") && row.contains("PAUSED"), "{row}");
}
#[test]
fn test_header_title_carries_the_error_cut_to_fit() {
let mut state = create_populated_state();
state.error_message = Some(format!("API Error: {}", "x".repeat(300)));
let mut terminal = Terminal::new(TestBackend::new(80, 4)).unwrap();
terminal
.draw(|f| render_header(f, f.area(), &state))
.unwrap();
let text = format!("{}", terminal.backend());
let top = text.lines().next().unwrap();
assert!(top.contains("⚠ API Error: xx"), "{top}");
assert!(top.contains('…'), "a long error is cut with …: {top}");
assert!(top.trim_end_matches('"').ends_with('┐'), "{top}");
}
#[tokio::test]
async fn test_venue_only_refresh_never_calls_dexscreener() {
let (_server, ex) = mock_venue(200).await;
let dex = MockDexDataSource::new(create_test_token_data());
let calls = dex.calls.clone();
let mut state = venue_only_state();
state.venue_pair = Some("USDN_USDT".into());
let mut app = create_test_app_with_state(state, Box::new(dex), None);
app.exchange_client = Some(ex);
for _ in 0..3 {
app.fetch_data().await;
}
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
assert!(
app.state.error_message.is_none(),
"{:?}",
app.state.error_message
);
assert!(
(app.state.current_price - 1.0002).abs() < 1e-9,
"priced by the venue"
);
let dex = MockDexDataSource::new(create_test_token_data());
let calls = dex.calls.clone();
let mut app = create_test_app(Box::new(dex), None);
for _ in 0..3 {
app.fetch_data().await;
}
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 3);
}
#[test]
fn test_update_builds_the_core_dex_book() {
let data = create_test_token_data_with_pairs();
let mut state = MonitorState::new(&data, "ethereum");
state.update(&data);
let best = scope::market::most_liquid(&data.pairs).unwrap();
let expected = scope::market::order_book_from_analytics(
"ethereum",
best,
&data.symbol,
&state.thresholds,
);
let book = state.order_book.as_ref().unwrap();
assert_eq!(book.pair, expected.pair);
assert_eq!(book.source, expected.source);
assert_ne!(book.source, scope::market::BookSource::Exchange);
assert_eq!(book.bids.len(), expected.bids.len());
assert_eq!(book.asks.len(), expected.asks.len());
}
#[test]
fn test_a_venue_book_and_trades_survive_dex_refreshes() {
let data = create_test_token_data_with_pairs();
let mut state = MonitorState::new(&data, "ethereum");
let book = scope::market::OrderBook {
pair: "TEST/USDT".into(),
source: scope::market::BookSource::Exchange,
bids: vec![scope::market::OrderBookLevel {
price: 0.99,
quantity: 5.0,
}],
asks: vec![scope::market::OrderBookLevel {
price: 1.01,
quantity: 7.0,
}],
};
state.set_venue_book("binance", book.clone());
let trade = |ts| Trade {
price: 1.0,
quantity: 1.0,
quote_quantity: None,
timestamp_ms: ts,
side: TradeSide::Buy,
id: None,
};
state.set_venue_trades("binance", vec![trade(3), trade(1), trade(2)]);
state.update(&data);
state.update(&data);
assert_eq!(state.order_book.as_ref().unwrap().pair, book.pair);
assert_eq!(state.recent_trades.len(), 3, "no synthetic trade added");
let times: Vec<u64> = state.recent_trades.iter().map(|t| t.timestamp_ms).collect();
assert_eq!(times, vec![1, 2, 3], "oldest first");
assert!(order_book_title(&state).contains("Order Book (binance)"));
assert!(trades_title(&state).contains("Recent Trades (binance)"));
}
#[tokio::test]
async fn test_a_venue_fills_the_book_and_trades_panels_with_real_data() {
let mut server = mockito::Server::new_async().await;
server
.mock("GET", "/api/v3/depth")
.match_query(mockito::Matcher::Any)
.with_body(r#"{"bids":[["0.9991","1234"]],"asks":[["1.0009","4321"]]}"#)
.create_async()
.await;
server
.mock("GET", "/api/v3/trades")
.match_query(mockito::Matcher::Any)
.with_body(r#"[{"id":1,"price":"1.0001","qty":"50","quoteQty":"50","time":1700000000000,"isBuyerMaker":false}]"#)
.create_async()
.await;
let dex = MockDexDataSource::new(create_test_token_data());
let mut app = create_test_app(Box::new(dex), None);
app.exchange_client = Some(scope::market::testing::venue_at("binance", &server.url()));
app.state.venue_pair = Some("USDCUSDT".into());
app.fetch_data().await;
let book = app.state.order_book.as_ref().unwrap();
assert_eq!(book.source, scope::market::BookSource::Exchange);
assert_eq!(book.bids[0].price, 0.9991);
assert_eq!(book.asks[0].quantity, 4321.0);
assert_eq!(app.state.recent_trades.len(), 1);
assert!(order_book_title(&app.state).contains("(binance)"));
assert!(trades_title(&app.state).contains("(binance)"));
app.state.layout = LayoutPreset::Exchange;
app.state.auto_layout = false;
let text = ui_text(&mut app.state, 220, 50);
assert!(text.contains("Order Book (binance)"), "{text}");
assert!(text.contains("Recent Trades (binance)"), "{text}");
}
#[test]
fn test_panel_titles_name_their_source() {
let data = create_test_token_data_with_pairs();
let mut state = MonitorState::new(&data, "ethereum");
state.update(&data);
let label = state.order_book.as_ref().unwrap().source.label();
assert!(order_book_title(&state).contains(&format!("({label})")));
assert!(trades_title(&state).contains("(synthetic, from DEX pairs)"));
let state = venue_only_state();
assert!(order_book_title(&state).contains("Order Book (not available in venue-only mode)"));
assert!(trades_title(&state).contains("Recent Trades (not available in venue-only mode)"));
}
#[test]
fn test_venue_only_screen_names_the_venue_and_hides_dex_only_stats() {
let mut state = venue_only_state();
state.layout = LayoutPreset::Exchange;
state.auto_layout = false;
let text = ui_text(&mut state, 220, 50);
assert!(text.contains("BINANCE USDCUSDT"), "{text}");
assert!(!text.contains("ETHEREUM"), "{text}");
assert!(
text.contains("Order Book (not available in venue-only mode)"),
"{text}"
);
assert!(!text.contains("$0.00"), "{text}");
assert!(!text.contains("0 (50%)"), "{text}");
assert!(!text.contains("Buys: 0"), "{text}");
assert!(text.contains("n/a"), "{text}");
state.layout = LayoutPreset::Dashboard;
let text = ui_text(&mut state, 220, 50);
assert!(!text.contains("$0.00"), "{text}");
assert!(!text.contains("Buys: 0"), "{text}");
}
#[test]
fn test_only_the_layout_flag_pins_the_layout() {
use clap::Parser;
let launch = |argv: &[&str], config: &Config| {
let cli = crate::cli::Cli::try_parse_from(argv).unwrap();
let crate::cli::Commands::Monitor(args) = cli.command else {
panic!("expected monitor")
};
let (cfg, _, pinned) = monitor_config_from_args(&args, config);
let mut state = create_populated_state();
state.apply_config(&cfg);
state.auto_layout = !pinned;
let start = state.layout;
ui_text(&mut state, 120, 40);
(start, state.layout)
};
let auto = auto_select_layout(Rect::new(0, 0, 120, 40));
assert_ne!(
auto,
LayoutPreset::Exchange,
"the test needs auto to differ"
);
let bare = ["scope", "monitor", "USDC"];
let flag = ["scope", "monitor", "USDC", "--layout", "exchange"];
let (_, first) = launch(&flag, &Config::default());
assert_eq!(first, LayoutPreset::Exchange);
let config = Config {
monitor: serde_yaml::from_str("layout: exchange\n").unwrap(),
..Default::default()
};
assert_eq!(launch(&bare, &config), (LayoutPreset::Exchange, auto));
assert_eq!(launch(&bare, &Config::default()).1, auto);
}
const MONITOR_TOO_SMALL: &str = "Terminal too small: need at least 60×16";
#[test]
fn test_question_mark_shows_the_keys_and_any_key_closes_them() {
let mut state = create_populated_state();
let k = |c| crossterm::event::KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE);
assert!(!handle_key_event_on_state(k('?'), &mut state));
assert!(state.show_keys);
let text = ui_text(&mut state, 120, 40);
assert!(text.contains(" Keys "), "{text}");
assert!(text.contains("slower or faster refresh"), "{text}");
assert!(!handle_key_event_on_state(k('q'), &mut state));
assert!(!state.show_keys);
}
#[test]
fn test_refresh_is_acknowledged_and_the_interval_is_shown() {
let mut state = create_populated_state();
let k = crossterm::event::KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE);
handle_key_event_on_state(k, &mut state);
assert!(
state
.log_messages
.iter()
.any(|m| m.contains("Refresh requested"))
);
let text = spans_text(&footer_spans(&state));
let every = state.refresh_rate.as_secs();
assert!(text.contains(&format!("of {every}s")), "{text}");
}
#[test]
fn test_a_price_beside_an_error_is_marked_stale() {
let mut state = create_populated_state();
state.error_message = Some("API Error: boom".into());
let text = ui_text(&mut state, 120, 40);
assert!(text.contains("(stale)"), "{text}");
}
#[test]
fn test_below_60x16_only_the_too_small_message_is_drawn() {
let mut state = create_populated_state();
state.layout = LayoutPreset::Exchange;
let text = ui_text(&mut state, 50, 12);
assert!(text.contains(MONITOR_TOO_SMALL), "{text}");
assert!(!text.contains("[q]uit"), "{text}");
let text = ui_text(&mut state, 30, 8);
assert!(text.contains("Terminal too small: need at"), "{text}");
assert!(text.contains("least 60×16"), "{text}");
assert_eq!(state.layout, LayoutPreset::Exchange, "state is kept");
let text = ui_text(&mut state, 60, 16);
assert!(!text.contains("Terminal too small"), "{text}");
assert!(text.contains("[q]uit"), "{text}");
assert!(ui_text(&mut state, 59, 16).contains("Terminal too small"));
assert!(ui_text(&mut state, 60, 15).contains("Terminal too small"));
}
fn venue_ticker(
pair: &str,
last: f64,
base_vol: Option<f64>,
quote_vol: Option<f64>,
change: Option<f64>,
) -> scope::market::Ticker {
scope::market::Ticker {
pair: pair.into(),
last_price: Some(last),
high_24h: None,
low_24h: None,
volume_24h: base_vol,
quote_volume_24h: quote_vol,
price_change_pct_24h: change,
best_bid: None,
best_ask: None,
}
}
fn venue_state(ticker: &scope::market::Ticker) -> MonitorState {
let data = build_exchange_token_data("USDC", "USDCUSDT", ticker);
let mut state = MonitorState::new(&data, "ethereum");
state.venue = Some("binance".into());
state.venue_pair = Some("USDCUSDT".into());
state.set_venue_ticker(ticker);
state
}
fn header_text(state: &MonitorState) -> (String, Vec<ratatui::style::Color>) {
let mut terminal = Terminal::new(TestBackend::new(120, 4)).unwrap();
terminal
.draw(|f| render_header(f, f.area(), state))
.unwrap();
let colors = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.fg)
.collect();
(format!("{}", terminal.backend()), colors)
}
fn metrics_text(state: &MonitorState) -> String {
let mut terminal = Terminal::new(TestBackend::new(60, 30)).unwrap();
terminal
.draw(|f| render_metrics_panel(f, f.area(), state))
.unwrap();
format!("{}", terminal.backend())
}
#[test]
fn test_venue_only_export_leaves_unknown_fields_empty() {
let last_row = |t: &scope::market::Ticker| {
let mut state = venue_state(t);
let path = start_export_in_temp(&mut state);
state.update(&build_exchange_token_data("USDC", "USDCUSDT", t));
let contents = std::fs::read_to_string(&path).unwrap();
contents.lines().last().unwrap().to_string()
};
let row = last_row(&venue_ticker(
"USDC/USDT",
1.0,
Some(10.0),
Some(10.5),
None,
));
let fields: Vec<&str> = row.split(',').collect();
assert_eq!(fields[2], "10.50", "{row}");
assert_eq!(&fields[3..], ["", "", "", ""], "{row}");
let row = last_row(&venue_ticker(
"USDC/BTC",
0.00001,
Some(10.0),
Some(0.0001),
None,
));
let fields: Vec<&str> = row.split(',').collect();
assert_eq!(&fields[2..], ["", "", "", "", ""], "{row}");
let data = create_test_token_data();
let mut state = MonitorState::new(&data, "ethereum");
let path = start_export_in_temp(&mut state);
state.update(&data);
let contents = std::fs::read_to_string(&path).unwrap();
let row = contents.lines().last().unwrap();
assert!(!row.contains(",,"), "{row}");
}
#[test]
fn test_venue_24h_change_is_carried_and_an_unknown_change_is_na() {
let t = venue_ticker("USDC/USDT", 1.0, Some(10.0), Some(10.0), Some(-0.25));
let data = build_exchange_token_data("USDC", "USDCUSDT", &t);
assert_eq!(data.price_change_24h, -0.25);
let mut state = venue_state(&t);
state.update(&data);
assert_eq!(state.change_24h(), Some(-0.25));
let (text, _) = header_text(&state);
assert!(text.contains("-0.25%"), "{text}");
let t = venue_ticker("USDC/USDT", 1.0, Some(10.0), Some(10.0), None);
let mut state = venue_state(&t);
state.update(&build_exchange_token_data("USDC", "USDCUSDT", &t));
assert_eq!(state.change_24h(), None);
let (text, colors) = header_text(&state);
assert!(text.contains("24h n/a"), "{text}");
assert!(!text.contains(['▲', '△', '▼', '▽']), "{text}");
let pal = state.palette();
assert!(
!colors.contains(&pal.up) && !colors.contains(&pal.down),
"an unknown change has no direction color"
);
state.layout = LayoutPreset::Exchange;
state.auto_layout = false;
let text = ui_text(&mut state, 220, 50);
for label in ["5m", "1h", "6h", "24h"] {
assert!(text.contains(&format!(" {label}: n/a")), "{label}: {text}");
}
let text = metrics_text(&state);
for label in ["5m Chg", "24h Chg"] {
let row = text.lines().find(|l| l.contains(label)).expect(label);
assert!(row.contains("n/a"), "{row}");
}
}
#[test]
fn test_venue_volume_is_usd_only_for_a_usd_quote() {
let usd = |pair: &str, base: Option<f64>, quote: Option<f64>| {
venue_ticker(pair, 2.0, base, quote, None).usd_volume()
};
assert_eq!(usd("ETH/USDT", Some(5.0), Some(10.5)), Some(10.5));
assert_eq!(usd("ETH/USDC", Some(5.0), None), Some(10.0));
assert_eq!(usd("ETH/USD", None, Some(7.0)), Some(7.0));
assert_eq!(usd("ETH/BTC", Some(5.0), Some(0.2)), None);
assert_eq!(usd("ETHUSDT", Some(5.0), Some(10.5)), Some(10.5));
assert_eq!(usd("ETH/USDT", None, None), None);
let t = venue_ticker("USDC/BTC", 0.00001, Some(10.0), Some(5.0), None);
let mut state = venue_state(&t);
assert!(state.volume_usd().is_none());
assert!(state.volume_history.is_empty(), "no synthetic volume");
state.volume_avg = 100.0;
state.alerts.volume_spike_threshold_pct = Some(1.0);
state.update(&build_exchange_token_data("USDC", "USDCBTC", &t));
assert_eq!(state.volume_avg, 100.0);
assert!(state.volume_history.is_empty());
assert!(
state.active_alerts.is_empty(),
"{:?}",
state.active_alerts.len()
);
let text = metrics_text(&state);
let row = text.lines().find(|l| l.contains("Vol 24h")).expect(&text);
assert!(row.contains("n/a"), "{row}");
}
#[test]
fn test_startup_line_names_the_venue_and_pair_in_venue_only_mode() {
assert_eq!(
state::source_label("ethereum", Some(("binance", "USDCUSDT"))),
"BINANCE USDCUSDT"
);
assert_eq!(state::source_label("ethereum", None), "ETHEREUM");
assert_eq!(venue_only_state().source_label(), "BINANCE USDCUSDT");
}
#[tokio::test]
async fn test_a_failed_venue_ticker_keeps_the_price_and_names_the_venue() {
let (_server, ex) = mock_venue(500).await;
let dex = MockDexDataSource::new(create_test_token_data());
let calls = dex.calls.clone();
let mut state = venue_only_state();
state.venue_pair = Some("USDN_USDT".into());
let before = state.current_price;
let mut app = create_test_app_with_state(state, Box::new(dex), None);
app.exchange_client = Some(ex);
app.fetch_data().await;
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
assert_eq!(app.state.current_price, before, "the old price stays");
let (text, _) = header_text(&app.state);
assert!(text.contains("Venue error ("), "{text}");
}
#[tokio::test]
async fn test_venue_only_without_a_venue_says_so() {
let dex = MockDexDataSource::new(create_test_token_data());
let calls = dex.calls.clone();
let mut app = create_test_app_with_state(venue_only_state(), Box::new(dex), None);
app.fetch_data().await;
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
assert!(app.state.error_message.is_some());
}
#[test]
fn test_every_layout_draws_at_60x16_with_quit_and_pause() {
let mut state = create_populated_state();
state.error_message = Some("API Error: boom".into());
state.paused = true;
let mut terminal = Terminal::new(TestBackend::new(60, 3)).unwrap();
terminal
.draw(|f| render_footer(f, f.area(), &state))
.unwrap();
let row = format!("{}", terminal.backend());
assert!(row.contains("[q]uit") && row.contains("PAUSED"), "{row}");
state.auto_layout = false;
for layout in [
LayoutPreset::Dashboard,
LayoutPreset::ChartFocus,
LayoutPreset::Feed,
LayoutPreset::Compact,
LayoutPreset::Exchange,
] {
state.layout = layout;
let text = ui_text(&mut state, 60, 16);
assert!(text.contains("[q]uit"), "{layout:?}: {text}");
assert_eq!(state.layout, layout);
}
}
}