use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyModifiers};
use crate::app::{App, Modal, StatusMessage, Tab};
use crate::clipboard;
use crate::commands::Command;
use crate::events::{Event, StreamKind};
use crate::input::{
handle_modal_key, handle_mouse, handle_orders_key, handle_positions_key, handle_search_key,
handle_watchlist_key,
};
const INTRADAY_REFRESH_INTERVAL: Duration = Duration::from_secs(60);
pub fn update(app: &mut App, event: Event) {
match event {
Event::Input(key) => handle_key(app, key),
Event::Mouse(m) => handle_mouse(app, m),
Event::Resize(_, _) => {
app.needs_redraw = true;
}
Event::AccountUpdated(a) => {
app.account = Some(a);
app.push_equity();
}
Event::PositionsUpdated(p) => {
app.positions = p;
if app.positions_state.selected().is_none() && !app.positions.is_empty() {
app.positions_state.select(Some(0));
}
}
Event::OrdersUpdated(o) => {
app.orders = o;
if app.orders_state.selected().is_none() && !app.orders.is_empty() {
app.orders_state.select(Some(0));
}
}
Event::ClockUpdated(c) => app.clock = Some(c),
Event::WatchlistUpdated(w) => {
let symbols: Vec<String> = w.assets.iter().map(|a| a.symbol.clone()).collect();
let _ = app.symbol_tx.send(symbols);
if app.watchlist_state.selected().is_none() && !w.assets.is_empty() {
app.watchlist_state.select(Some(0));
}
app.watchlist = Some(w);
}
Event::WatchlistUnavailable => {
app.watchlist_unavailable = true;
}
Event::MarketQuote(q) => {
app.quotes.insert(q.symbol.clone(), q);
app.push_equity_from_quotes();
}
Event::TradeUpdate {
order: o,
event_type,
} => {
if app.prefs.notifications.fill_notifications_enabled {
if let Some(msg) = fill_notification_text(&o, &event_type) {
app.push_fill_notification(msg);
}
}
if let Some(existing) = app.orders.iter_mut().find(|x| x.id == o.id) {
*existing = o;
} else {
app.orders.insert(0, o);
}
}
Event::StatusMsg(msg) => app.push_status(StatusMessage::persistent(msg)),
Event::StreamConnected(kind) => match kind {
StreamKind::Market => app.market_stream_ok = true,
StreamKind::Account => app.account_stream_ok = true,
},
Event::StreamDisconnected(kind) => match kind {
StreamKind::Market => app.market_stream_ok = false,
StreamKind::Account => app.account_stream_ok = false,
},
Event::PortfolioHistoryLoaded(data) => {
app.equity_history = data.into_iter().map(|v| (v * 100.0) as u64).collect();
}
Event::SnapshotsUpdated(snapshots) => {
app.snapshots = snapshots;
}
Event::IntradayBarsReceived { symbol, bars } => {
app.intraday_fetched_at
.insert(symbol.clone(), Instant::now());
app.intraday_bars.insert(symbol, bars);
}
Event::FetchStarted => app.request_started(),
Event::FetchComplete => app.request_finished(),
Event::Tick => {
if app.pending_requests > 0 {
app.tick_spinner();
}
loop {
match app.status_queue.front() {
Some(m) if m.expires_at.is_some_and(|e| e <= Instant::now()) => {
app.status_queue.pop_front();
}
_ => break,
}
}
if let Some(Modal::SymbolDetail(symbol)) = &app.modal.clone() {
let due = app
.intraday_fetched_at
.get(symbol)
.map(|t| t.elapsed() >= INTRADAY_REFRESH_INTERVAL)
.unwrap_or(false);
if due {
let _ = app
.command_tx
.try_send(Command::FetchIntradayBars(symbol.clone()));
}
}
}
Event::Quit => app.should_quit = true,
}
}
fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) {
if app.modal.is_some() {
handle_modal_key(app, key);
return;
}
if app.searching {
handle_search_key(app, key);
return;
}
match key.code {
KeyCode::Char('q') => app.should_quit = true,
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
app.should_quit = true
}
KeyCode::Char('?') => app.modal = Some(Modal::Help),
KeyCode::Char('A') => app.modal = Some(Modal::About),
KeyCode::Char('1') if app.active_tab != Tab::Orders => app.active_tab = Tab::Account,
KeyCode::Char('2') if app.active_tab != Tab::Orders => app.active_tab = Tab::Watchlist,
KeyCode::Char('3') if app.active_tab != Tab::Orders => app.active_tab = Tab::Positions,
KeyCode::Char('4') => app.active_tab = Tab::Orders,
KeyCode::Tab => app.active_tab = app.active_tab.next(),
KeyCode::BackTab => app.active_tab = app.active_tab.prev(),
KeyCode::Char('r') => {
app.push_transient_status("Refreshing…");
app.refresh_notify.notify_one();
}
KeyCode::Char('T') => {
app.cycle_theme();
app.push_transient_status(format!("Theme: {}", app.current_theme.display_name()));
}
KeyCode::Char('c') if app.active_tab != Tab::Orders => {
copy_focused_symbol(app);
}
_ => handle_panel_key(app, key),
}
}
fn copy_focused_symbol(app: &mut App) {
match app.focused_symbol() {
None => app.push_transient_status("No symbol selected"),
Some(symbol) => match clipboard::copy_to_clipboard(&symbol) {
Ok(()) => app.push_transient_status(format!("Copied {symbol} to clipboard")),
Err(e) => app.push_transient_status(e),
},
}
}
fn handle_panel_key(app: &mut App, key: crossterm::event::KeyEvent) {
match app.active_tab.clone() {
Tab::Account => {}
Tab::Watchlist => handle_watchlist_key(app, key),
Tab::Positions => handle_positions_key(app, key),
Tab::Orders => handle_orders_key(app, key),
}
}
fn fill_notification_text(order: &crate::types::Order, event_type: &str) -> Option<String> {
let side = order.side.to_uppercase();
let symbol = &order.symbol;
let qty = order.qty.as_deref().unwrap_or("?");
let filled_qty = &order.filled_qty;
match event_type {
"fill" => {
let price_suffix = order
.filled_avg_price
.as_deref()
.map(|p| format!(" @ ${p}"))
.unwrap_or_default();
Some(format!("✓ {side} {qty} {symbol} filled{price_suffix}"))
}
"partial_fill" => {
let price_suffix = order
.filled_avg_price
.as_deref()
.map(|p| format!(" @ ${p}"))
.unwrap_or_default();
Some(format!(
"~ {side} {filled_qty}/{qty} {symbol} partial fill{price_suffix}"
))
}
"rejected" | "expired" | "suspended" => {
Some(format!("✗ {side} {qty} {symbol} {event_type}"))
}
"canceled" => Some(format!("✗ {side} {qty} {symbol} canceled")),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::test_helpers::*;
use crate::app::{Modal, OrdersSubTab, Tab};
use crate::commands::Command;
use crate::events::{Event, StreamKind};
use crate::types::{AccountInfo, MarketClock, Order, Quote};
use crossterm::event::{
KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::layout::Rect;
fn key(code: KeyCode) -> Event {
Event::Input(KeyEvent::new(code, KeyModifiers::NONE))
}
fn ctrl(code: KeyCode) -> Event {
Event::Input(KeyEvent::new(code, KeyModifiers::CONTROL))
}
fn mouse_click(col: u16, row: u16) -> Event {
Event::Mouse(MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: col,
row,
modifiers: KeyModifiers::NONE,
})
}
fn mouse_move(col: u16, row: u16) -> Event {
Event::Mouse(MouseEvent {
kind: MouseEventKind::Moved,
column: col,
row,
modifiers: KeyModifiers::NONE,
})
}
fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
Rect {
x,
y,
width: w,
height: h,
}
}
#[test]
fn resize_event_sets_needs_redraw() {
let mut app = make_test_app();
assert!(!app.needs_redraw);
update(&mut app, Event::Resize(80, 24));
assert!(app.needs_redraw, "needs_redraw must be true after resize");
}
#[test]
fn resize_event_does_not_quit_or_change_state() {
let mut app = make_test_app();
update(&mut app, Event::Resize(120, 40));
assert!(!app.should_quit);
assert_eq!(app.active_tab, Tab::Account);
assert!(app.needs_redraw);
}
#[test]
fn account_updated_sets_account_and_pushes_equity() {
let mut app = make_test_app();
let acc = AccountInfo {
equity: "500".into(),
status: "ACTIVE".into(),
..Default::default()
};
update(&mut app, Event::AccountUpdated(acc));
assert!(app.account.is_some());
assert_eq!(app.equity_history.len(), 1);
}
#[test]
fn positions_updated_empty_does_not_select() {
let mut app = make_test_app();
update(&mut app, Event::PositionsUpdated(vec![]));
assert_eq!(app.positions_state.selected(), None);
}
#[test]
fn positions_updated_non_empty_auto_selects_zero() {
let mut app = make_test_app();
let pos = vec![crate::types::Position {
symbol: "AAPL".into(),
qty: "10".into(),
avg_entry_price: "100".into(),
current_price: "110".into(),
market_value: "1100".into(),
unrealized_pl: "100".into(),
unrealized_plpc: "0.1".into(),
side: "long".into(),
asset_class: "us_equity".into(),
}];
update(&mut app, Event::PositionsUpdated(pos));
assert_eq!(app.positions_state.selected(), Some(0));
}
#[test]
fn orders_updated_non_empty_auto_selects_zero() {
let mut app = make_test_app();
let orders = vec![make_order("o1", "accepted")];
update(&mut app, Event::OrdersUpdated(orders));
assert_eq!(app.orders_state.selected(), Some(0));
}
#[test]
fn watchlist_updated_auto_selects_zero() {
let mut app = make_test_app();
let wl = make_watchlist(&["AAPL", "TSLA"]);
update(&mut app, Event::WatchlistUpdated(wl));
assert!(app.watchlist.is_some());
assert_eq!(app.watchlist_state.selected(), Some(0));
}
#[test]
fn watchlist_unavailable_sets_flag() {
let mut app = make_test_app();
assert!(!app.watchlist_unavailable);
update(&mut app, Event::WatchlistUnavailable);
assert!(app.watchlist_unavailable);
}
#[test]
fn watchlist_unavailable_is_idempotent() {
let mut app = make_test_app();
update(&mut app, Event::WatchlistUnavailable);
update(&mut app, Event::WatchlistUnavailable);
assert!(app.watchlist_unavailable);
}
#[test]
fn trade_update_existing_replaces_in_place() {
let mut app = make_test_app();
app.orders = vec![make_order("o1", "accepted")];
let updated = Order {
id: "o1".into(),
status: "filled".into(),
..make_order("o1", "filled")
};
update(
&mut app,
Event::TradeUpdate {
order: updated,
event_type: "fill".to_string(),
},
);
assert_eq!(app.orders.len(), 1);
assert_eq!(app.orders[0].status, "filled");
}
#[test]
fn trade_update_new_id_prepends() {
let mut app = make_test_app();
app.orders = vec![make_order("o1", "accepted")];
update(
&mut app,
Event::TradeUpdate {
order: make_order("o2", "accepted"),
event_type: "pending_new".to_string(),
},
);
assert_eq!(app.orders.len(), 2);
assert_eq!(app.orders[0].id, "o2");
}
#[test]
fn market_quote_inserted() {
let mut app = make_test_app();
let q = Quote {
symbol: "AAPL".into(),
ap: Some(185.0),
bp: Some(184.9),
..Default::default()
};
update(&mut app, Event::MarketQuote(q));
assert!(app.quotes.contains_key("AAPL"));
assert_eq!(app.quotes["AAPL"].ap, Some(185.0));
}
#[test]
fn clock_updated() {
let mut app = make_test_app();
let clock = MarketClock {
is_open: true,
..Default::default()
};
update(&mut app, Event::ClockUpdated(clock));
assert!(app.clock.as_ref().unwrap().is_open);
}
#[test]
fn status_msg_updated() {
let mut app = make_test_app();
update(&mut app, Event::StatusMsg("hello".into()));
assert_eq!(app.current_status_text(), "hello");
}
#[test]
fn stream_connected_market_sets_flag() {
let mut app = make_test_app();
assert!(!app.market_stream_ok);
update(&mut app, Event::StreamConnected(StreamKind::Market));
assert!(app.market_stream_ok);
assert!(
!app.account_stream_ok,
"account flag must remain unaffected"
);
}
#[test]
fn stream_connected_account_sets_flag() {
let mut app = make_test_app();
update(&mut app, Event::StreamConnected(StreamKind::Account));
assert!(app.account_stream_ok);
assert!(!app.market_stream_ok, "market flag must remain unaffected");
}
#[test]
fn stream_disconnected_market_clears_flag() {
let mut app = make_test_app();
app.market_stream_ok = true;
app.account_stream_ok = true;
update(&mut app, Event::StreamDisconnected(StreamKind::Market));
assert!(!app.market_stream_ok);
assert!(app.account_stream_ok, "account flag must remain unaffected");
}
#[test]
fn stream_disconnected_account_clears_flag() {
let mut app = make_test_app();
app.market_stream_ok = true;
app.account_stream_ok = true;
update(&mut app, Event::StreamDisconnected(StreamKind::Account));
assert!(!app.account_stream_ok);
assert!(app.market_stream_ok, "market flag must remain unaffected");
}
#[test]
fn stream_connected_then_disconnected_roundtrip() {
let mut app = make_test_app();
update(&mut app, Event::StreamConnected(StreamKind::Market));
update(&mut app, Event::StreamConnected(StreamKind::Account));
assert!(app.market_stream_ok && app.account_stream_ok);
update(&mut app, Event::StreamDisconnected(StreamKind::Market));
assert!(!app.market_stream_ok);
assert!(app.account_stream_ok);
}
#[test]
fn quit_event_sets_flag() {
let mut app = make_test_app();
update(&mut app, Event::Quit);
assert!(app.should_quit);
}
#[test]
fn portfolio_history_loaded_replaces_equity_history() {
let mut app = make_test_app();
let data = vec![1000.0_f64, 1001.5, 1002.0];
update(&mut app, Event::PortfolioHistoryLoaded(data));
assert_eq!(app.equity_history, vec![100000, 100150, 100200]);
}
#[test]
fn portfolio_history_loaded_overwrites_existing_history() {
let mut app = make_test_app();
app.equity_history = vec![99999];
let data = vec![500.0_f64, 600.0];
update(&mut app, Event::PortfolioHistoryLoaded(data));
assert_eq!(app.equity_history, vec![50000, 60000]);
}
#[test]
fn portfolio_history_loaded_empty_vec_clears_history() {
let mut app = make_test_app();
app.equity_history = vec![12345];
update(&mut app, Event::PortfolioHistoryLoaded(vec![]));
assert!(app.equity_history.is_empty());
}
#[test]
fn portfolio_history_loaded_preserves_all_samples() {
let mut app = make_test_app();
let data: Vec<f64> = (0..390).map(|i| 100.0 + i as f64 * 0.1).collect();
update(&mut app, Event::PortfolioHistoryLoaded(data));
assert_eq!(
app.equity_history.len(),
390,
"all intraday samples should be kept"
);
}
#[test]
fn tick_is_noop() {
let mut app = make_test_app();
let before_status = app.current_status_text().to_owned();
update(&mut app, Event::Tick);
assert!(!app.should_quit);
assert_eq!(app.current_status_text(), before_status);
}
#[test]
fn tick_clears_expired_transient_status_msg() {
use std::time::{Duration, Instant};
let mut app = make_test_app();
app.status_queue.clear();
app.status_queue.push_back(crate::app::StatusMessage {
text: "Order submitted".into(),
expires_at: Some(Instant::now() - Duration::from_secs(1)),
});
update(&mut app, Event::Tick);
assert!(
app.current_status_text().is_empty(),
"expired transient message should be cleared"
);
}
#[test]
fn tick_does_not_clear_unexpired_transient_status_msg() {
use std::time::{Duration, Instant};
let mut app = make_test_app();
app.status_queue.clear();
app.status_queue.push_back(crate::app::StatusMessage {
text: "Refreshing…".into(),
expires_at: Some(Instant::now() + Duration::from_secs(60)),
});
update(&mut app, Event::Tick);
assert_eq!(
app.current_status_text(),
"Refreshing…",
"non-expired message must not be cleared"
);
}
#[test]
fn tick_does_not_clear_persistent_status_msg() {
let mut app = make_test_app();
app.status_queue.clear();
app.status_queue
.push_back(crate::app::StatusMessage::persistent("Loading…"));
update(&mut app, Event::Tick);
assert_eq!(
app.current_status_text(),
"Loading…",
"persistent message must survive tick"
);
}
#[test]
fn status_msg_transient_has_expiry() {
let msg = crate::app::StatusMessage::with_ttl(
"Submitting order…",
std::time::Duration::from_secs(3),
);
assert!(!msg.text.is_empty());
assert!(
msg.expires_at.is_some(),
"transient message must have an expiry"
);
}
#[test]
fn status_msg_persistent_has_no_expiry() {
let msg = crate::app::StatusMessage::persistent("Error: unauthorized");
assert!(
msg.expires_at.is_none(),
"persistent message must have no expiry"
);
}
#[test]
fn status_queue_multiple_messages_display_first_then_second() {
use crate::app::StatusMessage;
use std::time::{Duration, Instant};
let mut app = make_test_app();
app.status_queue.push_back(StatusMessage {
text: "First".into(),
expires_at: Some(Instant::now() - Duration::from_secs(1)),
});
app.status_queue
.push_back(StatusMessage::persistent("Second"));
assert_eq!(app.current_status_text(), "First");
update(&mut app, Event::Tick);
assert_eq!(app.current_status_text(), "Second");
}
#[test]
fn status_queue_cap_drops_oldest_when_full() {
use crate::app::StatusMessage;
let mut app = make_test_app();
for i in 0..5 {
app.push_status(StatusMessage::persistent(format!("msg{i}")));
}
assert_eq!(app.status_queue.len(), 5);
app.push_status(StatusMessage::persistent("msg5"));
assert_eq!(app.status_queue.len(), 5);
assert_eq!(app.current_status_text(), "msg1");
}
#[test]
fn status_queue_tick_drains_all_expired_messages() {
use crate::app::StatusMessage;
use std::time::{Duration, Instant};
let mut app = make_test_app();
for i in 0..3 {
app.status_queue.push_back(StatusMessage {
text: format!("old{i}"),
expires_at: Some(Instant::now() - Duration::from_secs(1)),
});
}
app.push_status(StatusMessage::persistent("fresh"));
update(&mut app, Event::Tick);
assert_eq!(app.current_status_text(), "fresh");
assert_eq!(app.status_queue.len(), 1);
}
#[test]
fn push_status_first_message_becomes_current() {
use crate::app::StatusMessage;
let mut app = make_test_app();
assert_eq!(app.current_status_text(), "");
app.push_status(StatusMessage::persistent("hello"));
assert_eq!(app.current_status_text(), "hello");
}
#[test]
fn key_q_quits() {
let mut app = make_test_app();
update(&mut app, key(KeyCode::Char('q')));
assert!(app.should_quit);
}
#[test]
fn key_ctrl_c_quits() {
let mut app = make_test_app();
update(&mut app, ctrl(KeyCode::Char('c')));
assert!(app.should_quit);
}
#[test]
fn key_question_mark_opens_help() {
let mut app = make_test_app();
update(&mut app, key(KeyCode::Char('?')));
assert!(matches!(app.modal, Some(Modal::Help)));
}
#[test]
fn key_uppercase_a_opens_about() {
let mut app = make_test_app();
update(&mut app, key(KeyCode::Char('A')));
assert!(matches!(app.modal, Some(Modal::About)));
}
#[test]
fn about_modal_any_key_closes() {
let mut app = make_test_app();
app.modal = Some(Modal::About);
update(&mut app, key(KeyCode::Enter));
assert!(app.modal.is_none(), "any key should close About modal");
}
#[test]
fn about_modal_space_closes() {
let mut app = make_test_app();
app.modal = Some(Modal::About);
update(&mut app, key(KeyCode::Char(' ')));
assert!(app.modal.is_none());
}
#[test]
fn key_1_switches_to_account() {
let mut app = make_test_app();
app.active_tab = Tab::Positions; update(&mut app, key(KeyCode::Char('1')));
assert_eq!(app.active_tab, Tab::Account);
}
#[test]
fn key_4_switches_to_orders() {
let mut app = make_test_app();
update(&mut app, key(KeyCode::Char('4')));
assert_eq!(app.active_tab, Tab::Orders);
}
#[test]
fn key_tab_cycles_forward() {
let mut app = make_test_app();
update(&mut app, key(KeyCode::Tab));
assert_eq!(app.active_tab, Tab::Watchlist);
}
#[test]
fn key_backtab_cycles_backward() {
let mut app = make_test_app();
update(&mut app, key(KeyCode::BackTab));
assert_eq!(app.active_tab, Tab::Orders);
}
#[test]
fn key_esc_closes_modal() {
let mut app = make_test_app();
app.modal = Some(Modal::Help);
update(&mut app, key(KeyCode::Esc));
assert!(app.modal.is_none());
}
#[test]
fn key_r_sets_refreshing_status() {
let mut app = make_test_app();
update(&mut app, key(KeyCode::Char('r')));
assert_eq!(app.current_status_text(), "Refreshing…");
}
fn watchlist_app() -> App {
let mut app = make_test_app();
app.active_tab = Tab::Watchlist;
app.watchlist = Some(make_watchlist(&["AAPL", "TSLA", "NVDA"]));
app.watchlist_state.select(Some(0));
app
}
#[test]
fn watchlist_j_moves_down() {
let mut app = watchlist_app();
update(&mut app, key(KeyCode::Char('j')));
assert_eq!(app.watchlist_state.selected(), Some(1));
}
#[test]
fn watchlist_j_clamps_at_end() {
let mut app = watchlist_app();
app.watchlist_state.select(Some(2)); update(&mut app, key(KeyCode::Char('j')));
assert_eq!(app.watchlist_state.selected(), Some(2));
}
#[test]
fn watchlist_k_moves_up() {
let mut app = watchlist_app();
app.watchlist_state.select(Some(2));
update(&mut app, key(KeyCode::Char('k')));
assert_eq!(app.watchlist_state.selected(), Some(1));
}
#[test]
fn watchlist_k_clamps_at_zero() {
let mut app = watchlist_app();
update(&mut app, key(KeyCode::Char('k')));
assert_eq!(app.watchlist_state.selected(), Some(0));
}
#[test]
fn watchlist_gg_jumps_to_top() {
let mut app = watchlist_app();
app.watchlist_state.select(Some(2));
update(&mut app, key(KeyCode::Char('g')));
assert_eq!(app.watchlist_state.selected(), Some(2));
assert!(app.pending_g_at.is_some());
update(&mut app, key(KeyCode::Char('g')));
assert_eq!(app.watchlist_state.selected(), Some(0));
assert!(app.pending_g_at.is_none());
}
#[test]
fn watchlist_g_single_sets_pending_no_jump() {
let mut app = watchlist_app();
app.watchlist_state.select(Some(2));
update(&mut app, key(KeyCode::Char('g')));
assert_eq!(
app.watchlist_state.selected(),
Some(2),
"single g must not jump"
);
assert!(app.pending_g_at.is_some());
}
#[test]
fn watchlist_g_then_other_key_clears_pending() {
let mut app = watchlist_app();
app.watchlist_state.select(Some(2));
update(&mut app, key(KeyCode::Char('g')));
assert!(app.pending_g_at.is_some());
update(&mut app, key(KeyCode::Char('j'))); assert!(app.pending_g_at.is_none());
assert_eq!(
app.watchlist_state.selected(),
Some(2),
"pending cleared, no jump"
);
}
#[test]
#[allow(non_snake_case)]
fn watchlist_G_jumps_to_bottom() {
let mut app = watchlist_app();
update(&mut app, key(KeyCode::Char('G')));
assert_eq!(app.watchlist_state.selected(), Some(2));
}
#[test]
fn watchlist_enter_opens_symbol_detail() {
let mut app = watchlist_app();
update(&mut app, key(KeyCode::Enter));
assert!(matches!(&app.modal, Some(Modal::SymbolDetail(s)) if s == "AAPL"));
}
#[test]
fn watchlist_o_opens_order_entry_with_symbol() {
let mut app = watchlist_app();
update(&mut app, key(KeyCode::Char('o')));
assert!(matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.symbol == "AAPL"));
}
#[test]
fn watchlist_a_opens_add_symbol() {
let mut app = watchlist_app();
update(&mut app, key(KeyCode::Char('a')));
assert!(matches!(&app.modal, Some(Modal::AddSymbol { .. })));
}
#[test]
fn watchlist_d_opens_confirm_remove_watchlist() {
let mut app = watchlist_app();
update(&mut app, key(KeyCode::Char('d')));
assert!(matches!(
&app.modal,
Some(Modal::ConfirmRemoveWatchlist { symbol, .. }) if symbol == "AAPL"
));
}
#[test]
fn watchlist_slash_starts_search() {
let mut app = watchlist_app();
update(&mut app, key(KeyCode::Char('/')));
assert!(app.searching);
}
fn orders_app() -> App {
let mut app = make_test_app();
app.active_tab = Tab::Orders;
app.orders = vec![make_order("o1", "accepted"), make_order("o2", "accepted")];
app.orders_state.select(Some(0));
app
}
#[test]
fn orders_key_1_switches_to_open_subtab() {
let mut app = orders_app();
app.orders_subtab = OrdersSubTab::Filled;
update(&mut app, key(KeyCode::Char('1')));
assert_eq!(app.orders_subtab, OrdersSubTab::Open);
assert_eq!(app.active_tab, Tab::Orders); }
#[test]
fn orders_key_2_switches_to_filled_subtab() {
let mut app = orders_app();
update(&mut app, key(KeyCode::Char('2')));
assert_eq!(app.orders_subtab, OrdersSubTab::Filled);
assert_eq!(app.active_tab, Tab::Orders);
}
#[test]
fn orders_key_3_switches_to_cancelled_subtab() {
let mut app = orders_app();
update(&mut app, key(KeyCode::Char('3')));
assert_eq!(app.orders_subtab, OrdersSubTab::Cancelled);
assert_eq!(app.active_tab, Tab::Orders);
}
#[test]
fn key_1_from_other_panels_still_switches_tab() {
let mut app = make_test_app();
app.active_tab = Tab::Watchlist;
update(&mut app, key(KeyCode::Char('1')));
assert_eq!(app.active_tab, Tab::Account);
}
#[test]
fn orders_c_opens_confirm_for_selected() {
let mut app = orders_app();
update(&mut app, key(KeyCode::Char('c')));
assert!(matches!(&app.modal, Some(Modal::Confirm { .. })));
}
#[test]
fn orders_o_opens_blank_order_entry() {
let mut app = orders_app();
update(&mut app, key(KeyCode::Char('o')));
assert!(matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.symbol.is_empty()));
}
#[test]
fn modal_tab_advances_focused_field() {
use crate::app::{OrderEntryState, OrderField};
let mut app = make_test_app();
let mut state = OrderEntryState::new(String::new());
state.focused_field = OrderField::Qty;
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Tab));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.focused_field == OrderField::Price)
);
}
#[test]
fn modal_char_appends_to_symbol_field() {
use crate::app::{OrderEntryState, OrderField};
let mut app = make_test_app();
let mut state = OrderEntryState::new(String::new());
state.focused_field = OrderField::Symbol;
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Char('A')));
assert!(matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.symbol == "A"));
}
#[test]
fn modal_digit_appends_to_qty_field() {
use crate::app::{OrderEntryState, OrderField};
let mut app = make_test_app();
let mut state = OrderEntryState::new(String::new());
state.focused_field = OrderField::Qty;
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Char('5')));
assert!(matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.qty_input == "5"));
}
#[test]
fn modal_non_digit_ignored_in_qty_field() {
use crate::app::{OrderEntryState, OrderField};
let mut app = make_test_app();
let mut state = OrderEntryState::new(String::new());
state.focused_field = OrderField::Qty;
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Char('x')));
assert!(matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.qty_input.is_empty()));
}
#[test]
fn modal_backspace_removes_last_char_from_symbol() {
use crate::app::{OrderEntryState, OrderField};
let mut app = make_test_app();
let mut state = OrderEntryState::new("AB".into());
state.focused_field = OrderField::Symbol;
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Backspace));
assert!(matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.symbol == "A"));
}
#[test]
fn search_char_appends_to_query() {
let mut app = watchlist_app();
app.searching = true;
update(&mut app, key(KeyCode::Char('A')));
assert_eq!(app.search_query, "A");
}
#[test]
fn search_esc_exits_search_mode() {
let mut app = watchlist_app();
app.searching = true;
update(&mut app, key(KeyCode::Esc));
assert!(!app.searching);
}
#[test]
fn search_enter_exits_search_mode() {
let mut app = watchlist_app();
app.searching = true;
update(&mut app, key(KeyCode::Enter));
assert!(!app.searching);
}
fn app_with_rx() -> (App, tokio::sync::mpsc::Receiver<Command>) {
use crate::config::{AlpacaConfig, AlpacaEnv};
let (command_tx, command_rx) = tokio::sync::mpsc::channel(16);
let (symbol_tx, _) = tokio::sync::watch::channel(vec![]);
let app = App::new(
AlpacaConfig {
base_url: "http://localhost".into(),
key: "k".into(),
secret: "s".into(),
env: AlpacaEnv::Paper,
dry_run: false,
},
crate::prefs::AppPrefs::default(),
std::sync::Arc::new(tokio::sync::Notify::new()),
command_tx,
symbol_tx,
);
(app, command_rx)
}
#[test]
fn order_entry_submit_sends_submit_order_command() {
use crate::app::{OrderEntryState, OrderField};
use crate::types::AccountInfo;
let (mut app, mut cmd_rx) = app_with_rx();
app.account = Some(AccountInfo {
buying_power: "100000".into(),
..Default::default()
});
let mut state = OrderEntryState::new("AAPL".into());
state.focused_field = OrderField::Submit;
state.qty_input = "10".into();
state.price_input = "185.00".into();
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Enter));
assert!(app.modal.is_none(), "modal should close after submit");
assert_eq!(app.current_status_text(), "Submitting order…");
let cmd = cmd_rx.try_recv().expect("command should be sent");
assert!(
matches!(cmd, Command::SubmitOrder { symbol, .. } if symbol == "AAPL"),
"expected SubmitOrder for AAPL"
);
}
#[test]
fn order_entry_submit_market_order_omits_price() {
use crate::app::{OrderEntryState, OrderField};
use crate::types::AccountInfo;
let (mut app, mut cmd_rx) = app_with_rx();
app.account = Some(AccountInfo {
buying_power: "100000".into(),
..Default::default()
});
let mut state = OrderEntryState::new("TSLA".into());
state.focused_field = OrderField::Submit;
state.market_order = true;
state.qty_input = "5".into();
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Enter));
let cmd = cmd_rx.try_recv().expect("command should be sent");
assert!(
matches!(cmd, Command::SubmitOrder { order_type, price: None, .. }
if order_type == "market"),
"market order should have no price"
);
}
#[test]
fn confirm_cancel_order_sends_cancel_command() {
use crate::app::ConfirmAction;
let (mut app, mut cmd_rx) = app_with_rx();
app.active_tab = Tab::Orders;
app.modal = Some(Modal::Confirm {
message: "Cancel?".into(),
action: ConfirmAction::CancelOrder("order-xyz".into()),
confirmed: false,
});
update(&mut app, key(KeyCode::Char('y')));
assert!(app.modal.is_none());
let cmd = cmd_rx.try_recv().expect("command should be sent");
assert!(
matches!(cmd, Command::CancelOrder(id) if id == "order-xyz"),
"expected CancelOrder command"
);
}
#[test]
fn confirm_remove_watchlist_sends_remove_command() {
let (mut app, mut cmd_rx) = app_with_rx();
app.modal = Some(Modal::ConfirmRemoveWatchlist {
symbol: "TLRY".into(),
watchlist_id: "wl-id".into(),
});
update(&mut app, key(KeyCode::Char('y')));
assert!(app.modal.is_none());
let cmd = cmd_rx.try_recv().expect("command should be sent");
assert!(
matches!(cmd, Command::RemoveFromWatchlist { symbol, .. } if symbol == "TLRY"),
"expected RemoveFromWatchlist command"
);
}
#[test]
fn confirm_remove_watchlist_enter_confirms() {
let (mut app, mut cmd_rx) = app_with_rx();
app.modal = Some(Modal::ConfirmRemoveWatchlist {
symbol: "AAPL".into(),
watchlist_id: "wl-id".into(),
});
update(&mut app, key(KeyCode::Enter));
assert!(app.modal.is_none(), "Enter should close the modal");
let cmd = cmd_rx.try_recv().expect("command should be sent");
assert!(
matches!(cmd, Command::RemoveFromWatchlist { symbol, .. } if symbol == "AAPL"),
"expected RemoveFromWatchlist command on Enter"
);
}
#[test]
fn confirm_remove_watchlist_n_cancels() {
let (mut app, mut cmd_rx) = app_with_rx();
app.modal = Some(Modal::ConfirmRemoveWatchlist {
symbol: "AAPL".into(),
watchlist_id: "wl-id".into(),
});
update(&mut app, key(KeyCode::Char('n')));
assert!(
app.modal.is_none(),
"'n' should close the modal without action"
);
assert!(
cmd_rx.try_recv().is_err(),
"no command should be sent on cancel"
);
}
#[test]
fn confirm_remove_watchlist_esc_cancels() {
let (mut app, mut cmd_rx) = app_with_rx();
app.modal = Some(Modal::ConfirmRemoveWatchlist {
symbol: "AAPL".into(),
watchlist_id: "wl-id".into(),
});
update(&mut app, key(KeyCode::Esc));
assert!(
app.modal.is_none(),
"Esc should close the modal without action"
);
assert!(
cmd_rx.try_recv().is_err(),
"no command should be sent on Esc"
);
}
#[test]
fn watchlist_d_no_confirm_pref_sends_remove_directly() {
let (mut app, mut cmd_rx) = app_with_rx();
app.active_tab = Tab::Watchlist;
app.watchlist = Some(make_watchlist(&["AAPL"]));
app.watchlist_state.select(Some(0));
app.prefs.safety.confirm_watchlist_remove = false;
update(&mut app, key(KeyCode::Char('d')));
assert!(app.modal.is_none(), "no modal when pref is false");
let cmd = cmd_rx.try_recv().expect("command should be sent directly");
assert!(
matches!(cmd, Command::RemoveFromWatchlist { symbol, .. } if symbol == "AAPL"),
"expected RemoveFromWatchlist command"
);
}
#[test]
fn confirm_remove_watchlist_unhandled_key_keeps_modal_open() {
let (mut app, mut cmd_rx) = app_with_rx();
app.modal = Some(Modal::ConfirmRemoveWatchlist {
symbol: "AAPL".into(),
watchlist_id: "wl-id".into(),
});
update(&mut app, key(KeyCode::F(1)));
assert!(
matches!(&app.modal, Some(Modal::ConfirmRemoveWatchlist { symbol, .. }) if symbol == "AAPL"),
"unrecognized key should keep ConfirmRemoveWatchlist modal open"
);
assert!(
cmd_rx.try_recv().is_err(),
"no command should be sent on unrecognized key"
);
}
#[test]
fn add_symbol_enter_sends_add_command() {
let (mut app, mut cmd_rx) = app_with_rx();
app.modal = Some(Modal::AddSymbol {
input: "NVDA".into(),
watchlist_id: "wl-id".into(),
});
update(&mut app, key(KeyCode::Enter));
assert!(app.modal.is_none());
let cmd = cmd_rx.try_recv().expect("command should be sent");
assert!(
matches!(cmd, Command::AddToWatchlist { symbol, .. } if symbol == "NVDA"),
"expected AddToWatchlist command"
);
}
#[test]
fn add_symbol_empty_input_sends_no_command() {
let (mut app, mut cmd_rx) = app_with_rx();
app.modal = Some(Modal::AddSymbol {
input: String::new(),
watchlist_id: "wl-id".into(),
});
update(&mut app, key(KeyCode::Enter));
assert!(cmd_rx.try_recv().is_err(), "no command for empty input");
}
#[test]
fn watchlist_updated_pushes_symbols_to_symbol_tx() {
use crate::config::{AlpacaConfig, AlpacaEnv};
use tokio::sync::watch;
let (command_tx, _) = tokio::sync::mpsc::channel(1);
let (symbol_tx, symbol_rx) = watch::channel(vec![]);
let mut app = App::new(
AlpacaConfig {
base_url: "http://localhost".into(),
key: "k".into(),
secret: "s".into(),
env: AlpacaEnv::Paper,
dry_run: false,
},
crate::prefs::AppPrefs::default(),
std::sync::Arc::new(tokio::sync::Notify::new()),
command_tx,
symbol_tx,
);
let wl = make_watchlist(&["AAPL", "TSLA", "NVDA"]);
update(&mut app, Event::WatchlistUpdated(wl));
assert!(
symbol_rx.has_changed().unwrap_or(false),
"symbol_tx should have been updated"
);
let symbols = symbol_rx.borrow().clone();
assert_eq!(symbols, vec!["AAPL", "TSLA", "NVDA"]);
}
fn app_with_capacity(cap: usize) -> (App, tokio::sync::mpsc::Receiver<Command>) {
use crate::config::{AlpacaConfig, AlpacaEnv};
let (command_tx, command_rx) = tokio::sync::mpsc::channel(cap);
let (symbol_tx, _) = tokio::sync::watch::channel(vec![]);
let app = App::new(
AlpacaConfig {
base_url: "http://localhost".into(),
key: "k".into(),
secret: "s".into(),
env: AlpacaEnv::Paper,
dry_run: false,
},
crate::prefs::AppPrefs::default(),
std::sync::Arc::new(tokio::sync::Notify::new()),
command_tx,
symbol_tx,
);
(app, command_rx)
}
#[test]
fn channel_full_sets_busy_status_msg() {
let (mut app, _rx) = app_with_capacity(1);
let _ = app
.command_tx
.try_send(Command::CancelOrder("dummy".into()));
use crate::app::{OrderEntryState, OrderField};
use crate::types::AccountInfo;
app.account = Some(AccountInfo {
buying_power: "100000".into(),
..Default::default()
});
let mut state = OrderEntryState::new("AAPL".into());
state.focused_field = OrderField::Submit;
state.qty_input = "1".into();
state.price_input = "100.00".into();
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Enter));
assert_eq!(
app.current_status_text(),
"System busy — please retry",
"full channel should show busy message"
);
}
#[test]
fn mouse_click_tab_bar_switches_tab() {
let (mut app, _rx) = app_with_capacity(4);
app.hit_areas.tab_bar = rect(0, 2, 80, 1);
assert_eq!(app.active_tab, Tab::Account);
update(&mut app, mouse_click(18, 2));
assert_eq!(app.active_tab, Tab::Watchlist);
update(&mut app, mouse_click(32, 2));
assert_eq!(app.active_tab, Tab::Positions);
update(&mut app, mouse_click(45, 2));
assert_eq!(app.active_tab, Tab::Orders);
update(&mut app, mouse_click(5, 2));
assert_eq!(app.active_tab, Tab::Account);
}
#[test]
fn mouse_non_left_click_ignored() {
let (mut app, _rx) = app_with_capacity(4);
app.hit_areas.tab_bar = rect(0, 2, 80, 1);
app.active_tab = Tab::Watchlist;
update(&mut app, mouse_move(25, 2));
assert_eq!(app.active_tab, Tab::Watchlist);
}
#[test]
fn mouse_click_orders_subtab() {
let (mut app, _rx) = app_with_capacity(4);
app.active_tab = Tab::Orders;
app.hit_areas.orders_subtab_rects = vec![
rect(0, 5, 12, 1), rect(13, 5, 14, 1), rect(28, 5, 17, 1), ];
assert_eq!(app.orders_subtab, OrdersSubTab::Open);
update(&mut app, mouse_click(13, 5));
assert_eq!(app.orders_subtab, OrdersSubTab::Filled);
update(&mut app, mouse_click(28, 5));
assert_eq!(app.orders_subtab, OrdersSubTab::Cancelled);
update(&mut app, mouse_click(0, 5));
assert_eq!(app.orders_subtab, OrdersSubTab::Open);
}
#[test]
fn mouse_click_list_row_selects_item() {
use crate::types::{Asset, Watchlist};
let (mut app, _rx) = app_with_capacity(4);
let make_asset = |sym: &str| Asset {
id: sym.to_string(),
symbol: sym.to_string(),
name: sym.to_string(),
exchange: "NASDAQ".to_string(),
asset_class: "us_equity".to_string(),
tradable: true,
shortable: false,
fractionable: false,
easy_to_borrow: false,
};
app.watchlist = Some(Watchlist {
id: "wl1".to_string(),
name: "Test".to_string(),
assets: vec![make_asset("AAPL"), make_asset("GOOG")],
});
app.watchlist_state.select(Some(0));
app.active_tab = Tab::Watchlist;
app.hit_areas.list_data_start_y = 10;
update(&mut app, mouse_click(5, 11));
assert_eq!(app.watchlist_state.selected(), Some(1));
}
#[test]
fn channel_closed_sets_stopped_status_msg() {
let (mut app, rx) = app_with_capacity(8);
drop(rx);
use crate::app::{OrderEntryState, OrderField};
use crate::types::AccountInfo;
app.account = Some(AccountInfo {
buying_power: "100000".into(),
..Default::default()
});
let mut state = OrderEntryState::new("AAPL".into());
state.focused_field = OrderField::Submit;
state.qty_input = "1".into();
state.price_input = "100.00".into();
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Enter));
assert_eq!(
app.current_status_text(),
"Command handler stopped — restart app",
"closed channel should show stopped message"
);
}
fn order_entry_submit_state(symbol: &str) -> crate::app::OrderEntryState {
use crate::app::{OrderEntryState, OrderField};
let mut s = OrderEntryState::new(symbol.into());
s.focused_field = OrderField::Submit;
s.market_order = false;
s.qty_input = "10".into();
s.price_input = "100.00".into();
s
}
#[test]
fn validation_empty_symbol_keeps_modal_open() {
let (mut app, _rx) = app_with_capacity(4);
let mut state = order_entry_submit_state("");
state.symbol.clear();
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Enter));
assert!(
app.modal.is_some(),
"modal must stay open on validation failure"
);
assert!(
!app.current_status_text().is_empty(),
"status_msg must contain error text"
);
}
#[test]
fn validation_zero_qty_keeps_modal_open() {
let (mut app, _rx) = app_with_capacity(4);
let mut state = order_entry_submit_state("AAPL");
state.qty_input = "0".into();
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Enter));
assert!(app.modal.is_some());
assert!(!app.current_status_text().is_empty());
}
#[test]
fn validation_non_numeric_price_on_limit_keeps_modal_open() {
let (mut app, _rx) = app_with_capacity(4);
let mut state = order_entry_submit_state("AAPL");
state.price_input = "bad".into();
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Enter));
assert!(app.modal.is_some());
assert!(!app.current_status_text().is_empty());
}
#[test]
fn validation_exceeds_buying_power_keeps_modal_open() {
use crate::types::AccountInfo;
let (mut app, _rx) = app_with_capacity(4);
app.account = Some(AccountInfo {
buying_power: "500".into(), ..Default::default()
});
let state = order_entry_submit_state("AAPL");
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Enter));
assert!(app.modal.is_some());
assert!(!app.current_status_text().is_empty());
}
#[test]
fn validation_pass_sends_command_and_closes_modal() {
use crate::types::AccountInfo;
let (mut app, mut cmd_rx) = app_with_rx();
app.account = Some(AccountInfo {
buying_power: "100000".into(),
..Default::default()
});
let state = order_entry_submit_state("AAPL");
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Enter));
assert!(app.modal.is_none(), "modal should close on valid submit");
cmd_rx.try_recv().expect("command should be sent");
}
#[test]
fn intraday_bars_received_stores_bars() {
let (mut app, _rx) = app_with_rx();
update(
&mut app,
Event::IntradayBarsReceived {
symbol: "AAPL".into(),
bars: vec![14200, 14215, 14198],
},
);
assert_eq!(
app.intraday_bars.get("AAPL"),
Some(&vec![14200u64, 14215, 14198])
);
}
#[test]
fn intraday_bars_received_overwrites_existing_bars() {
let (mut app, _rx) = app_with_rx();
app.intraday_bars.insert("AAPL".into(), vec![100, 200]);
update(
&mut app,
Event::IntradayBarsReceived {
symbol: "AAPL".into(),
bars: vec![300, 400, 500],
},
);
assert_eq!(app.intraday_bars.get("AAPL"), Some(&vec![300u64, 400, 500]));
}
#[test]
fn symbol_detail_o_opens_order_entry() {
let (mut app, _rx) = app_with_rx();
app.modal = Some(Modal::SymbolDetail("AAPL".into()));
update(&mut app, key(KeyCode::Char('o')));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.symbol == "AAPL" && s.side == crate::app::OrderSide::Buy),
"o key should open buy order entry for symbol"
);
}
#[test]
fn symbol_detail_s_opens_sell_order_entry() {
let (mut app, _rx) = app_with_rx();
app.modal = Some(Modal::SymbolDetail("NVDA".into()));
update(&mut app, key(KeyCode::Char('s')));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.symbol == "NVDA" && s.side == crate::app::OrderSide::Sell),
"s key should open sell order entry for symbol"
);
}
#[test]
fn symbol_detail_w_sends_add_watchlist_command() {
use crate::types::Watchlist;
let (mut app, mut cmd_rx) = app_with_rx();
app.watchlist = Some(Watchlist {
id: "wl-1".into(),
name: "Primary".into(),
assets: vec![],
});
app.modal = Some(Modal::SymbolDetail("AAPL".into()));
update(&mut app, key(KeyCode::Char('w')));
assert!(
matches!(&app.modal, Some(Modal::SymbolDetail(s)) if s == "AAPL"),
"w key should keep symbol detail modal open"
);
let cmd = cmd_rx.try_recv().expect("AddToWatchlist command expected");
assert!(
matches!(cmd, Command::AddToWatchlist { watchlist_id, symbol }
if watchlist_id == "wl-1" && symbol == "AAPL")
);
}
#[test]
fn symbol_detail_w_sends_remove_watchlist_command_when_in_watchlist() {
use crate::types::{Asset, Watchlist};
let (mut app, mut cmd_rx) = app_with_rx();
let asset = Asset {
id: "asset-1".into(),
symbol: "AAPL".into(),
name: "Apple Inc.".into(),
exchange: "NASDAQ".into(),
asset_class: "us_equity".into(),
tradable: true,
shortable: true,
fractionable: true,
easy_to_borrow: true,
};
app.watchlist = Some(Watchlist {
id: "wl-1".into(),
name: "Primary".into(),
assets: vec![asset],
});
app.modal = Some(Modal::SymbolDetail("AAPL".into()));
update(&mut app, key(KeyCode::Char('w')));
let cmd = cmd_rx
.try_recv()
.expect("RemoveFromWatchlist command expected");
assert!(
matches!(cmd, Command::RemoveFromWatchlist { watchlist_id, symbol }
if watchlist_id == "wl-1" && symbol == "AAPL")
);
}
#[test]
fn symbol_detail_other_key_keeps_modal_open() {
let (mut app, _rx) = app_with_rx();
app.modal = Some(Modal::SymbolDetail("TSLA".into()));
update(&mut app, key(KeyCode::Char('j')));
assert!(
matches!(&app.modal, Some(Modal::SymbolDetail(s)) if s == "TSLA"),
"unknown key should keep symbol detail modal open"
);
}
#[test]
fn symbol_detail_esc_closes_modal() {
let (mut app, _rx) = app_with_rx();
app.modal = Some(Modal::SymbolDetail("TSLA".into()));
update(&mut app, key(KeyCode::Esc));
assert!(app.modal.is_none(), "Esc should close the modal");
}
#[test]
fn symbol_detail_w_with_no_watchlist_keeps_modal_open() {
let (mut app, mut cmd_rx) = app_with_rx();
app.watchlist = None;
app.modal = Some(Modal::SymbolDetail("AAPL".into()));
update(&mut app, key(KeyCode::Char('w')));
assert!(
matches!(&app.modal, Some(Modal::SymbolDetail(s)) if s == "AAPL"),
"modal should remain open when no watchlist is set"
);
assert!(
cmd_rx.try_recv().is_err(),
"no command should be dispatched without a watchlist"
);
}
fn positions_app() -> (App, tokio::sync::mpsc::Receiver<Command>) {
let (mut app, rx) = app_with_rx();
app.active_tab = Tab::Positions;
app.positions = vec![crate::types::Position {
symbol: "AAPL".into(),
qty: "10".into(),
avg_entry_price: "150.00".into(),
current_price: "155.00".into(),
market_value: "1550.00".into(),
unrealized_pl: "50.00".into(),
unrealized_plpc: "0.033".into(),
side: "long".into(),
asset_class: "us_equity".into(),
}];
app.positions_state.select(Some(0));
(app, rx)
}
#[test]
fn positions_enter_opens_symbol_detail_and_dispatches_fetch() {
let (mut app, mut cmd_rx) = positions_app();
update(&mut app, key(KeyCode::Enter));
assert!(
matches!(&app.modal, Some(Modal::SymbolDetail(s)) if s == "AAPL"),
"Enter on a position should open SymbolDetail for that symbol"
);
let cmd = cmd_rx
.try_recv()
.expect("FetchIntradayBars should be dispatched");
assert!(
matches!(cmd, Command::FetchIntradayBars(s) if s == "AAPL"),
"expected FetchIntradayBars for AAPL"
);
}
#[test]
fn positions_enter_with_no_selection_does_nothing() {
let (mut app, _rx) = app_with_rx();
app.active_tab = Tab::Positions;
app.positions_state.select(None);
update(&mut app, key(KeyCode::Enter));
assert!(
app.modal.is_none(),
"Enter with no selection should not open a modal"
);
}
#[test]
fn positions_o_opens_sell_order_entry() {
let (mut app, _rx) = positions_app();
update(&mut app, key(KeyCode::Char('o')));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.symbol == "AAPL" && s.side == crate::app::OrderSide::Sell),
"o key in positions should open SELL order entry for selected symbol"
);
}
#[test]
fn positions_s_opens_sell_short_order_entry() {
let (mut app, _rx) = positions_app();
update(&mut app, key(KeyCode::Char('s')));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.symbol == "AAPL" && s.side == crate::app::OrderSide::SellShort),
"s key in positions should open SELL SHORT order entry for selected symbol"
);
}
#[test]
fn order_side_cycle_next_wraps() {
use crate::app::OrderSide;
assert_eq!(OrderSide::Buy.cycle_next(), OrderSide::Sell);
assert_eq!(OrderSide::Sell.cycle_next(), OrderSide::SellShort);
assert_eq!(OrderSide::SellShort.cycle_next(), OrderSide::Buy);
}
#[test]
fn order_side_cycle_prev_wraps() {
use crate::app::OrderSide;
assert_eq!(OrderSide::Buy.cycle_prev(), OrderSide::SellShort);
assert_eq!(OrderSide::Sell.cycle_prev(), OrderSide::Buy);
assert_eq!(OrderSide::SellShort.cycle_prev(), OrderSide::Sell);
}
#[test]
fn order_side_as_str() {
use crate::app::OrderSide;
assert_eq!(OrderSide::Buy.as_str(), "buy");
assert_eq!(OrderSide::Sell.as_str(), "sell");
assert_eq!(OrderSide::SellShort.as_str(), "sell_short");
}
#[test]
fn modal_side_right_arrow_cycles_forward() {
use crate::app::{OrderEntryState, OrderField, OrderSide};
let (mut app, _rx) = app_with_rx();
let mut state = OrderEntryState::new("AAPL".into());
state.focused_field = OrderField::Side;
state.side = OrderSide::Buy;
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Right));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.side == OrderSide::Sell),
"right arrow should cycle Buy → Sell"
);
}
#[test]
fn modal_side_left_arrow_cycles_backward() {
use crate::app::{OrderEntryState, OrderField, OrderSide};
let (mut app, _rx) = app_with_rx();
let mut state = OrderEntryState::new("AAPL".into());
state.focused_field = OrderField::Side;
state.side = OrderSide::Sell;
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Left));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.side == OrderSide::Buy),
"left arrow should cycle Sell → Buy"
);
}
#[test]
fn modal_side_down_arrow_cycles_forward() {
use crate::app::{OrderEntryState, OrderField, OrderSide};
let (mut app, _rx) = app_with_rx();
let mut state = OrderEntryState::new("AAPL".into());
state.focused_field = OrderField::Side;
state.side = OrderSide::Buy;
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Down));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.side == OrderSide::Sell),
"down arrow should cycle Buy → Sell"
);
}
#[test]
fn modal_side_up_arrow_cycles_backward() {
use crate::app::{OrderEntryState, OrderField, OrderSide};
let (mut app, _rx) = app_with_rx();
let mut state = OrderEntryState::new("AAPL".into());
state.focused_field = OrderField::Side;
state.side = OrderSide::Sell;
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Up));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.side == OrderSide::Buy),
"up arrow should cycle Sell → Buy"
);
}
#[test]
fn modal_order_type_down_arrow_toggles() {
use crate::app::{OrderEntryState, OrderField};
let (mut app, _rx) = app_with_rx();
let mut state = OrderEntryState::new("AAPL".into());
state.focused_field = OrderField::OrderType;
state.market_order = false;
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Down));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.market_order),
"down arrow on OrderType should toggle to market"
);
}
#[test]
fn modal_order_type_up_arrow_toggles() {
use crate::app::{OrderEntryState, OrderField};
let (mut app, _rx) = app_with_rx();
let mut state = OrderEntryState::new("AAPL".into());
state.focused_field = OrderField::OrderType;
state.market_order = true;
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Up));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if !s.market_order),
"up arrow on OrderType should toggle to limit"
);
}
#[test]
fn modal_tif_down_arrow_toggles() {
use crate::app::{OrderEntryState, OrderField};
let (mut app, _rx) = app_with_rx();
let mut state = OrderEntryState::new("AAPL".into());
state.focused_field = OrderField::TimeInForce;
state.gtc_order = false;
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Down));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.gtc_order),
"down arrow on TIF should toggle to GTC"
);
}
#[test]
fn modal_tif_up_arrow_toggles() {
use crate::app::{OrderEntryState, OrderField};
let (mut app, _rx) = app_with_rx();
let mut state = OrderEntryState::new("AAPL".into());
state.focused_field = OrderField::TimeInForce;
state.gtc_order = true;
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Up));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if !s.gtc_order),
"up arrow on TIF should toggle to DAY"
);
}
#[test]
fn modal_up_down_on_text_field_is_noop() {
use crate::app::{OrderEntryState, OrderField};
let (mut app, _rx) = app_with_rx();
let mut state = OrderEntryState::new("AAPL".into());
state.focused_field = OrderField::Qty;
state.qty_input = "10".into();
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Down));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.qty_input == "10"),
"down arrow on text field should not modify input"
);
update(&mut app, key(KeyCode::Up));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.qty_input == "10"),
"up arrow on text field should not modify input"
);
}
#[test]
fn order_entry_submit_sell_short_sends_correct_side() {
use crate::app::{OrderEntryState, OrderField, OrderSide};
use crate::types::AccountInfo;
let (mut app, mut cmd_rx) = app_with_rx();
app.account = Some(AccountInfo {
buying_power: "100000".into(),
..Default::default()
});
let mut state = OrderEntryState::new("AAPL".into());
state.side = OrderSide::SellShort;
state.focused_field = OrderField::Submit;
state.qty_input = "5".into();
state.price_input = "180.00".into();
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, key(KeyCode::Enter));
assert!(app.modal.is_none(), "modal should close after submit");
let cmd = cmd_rx.try_recv().expect("command should be sent");
assert!(
matches!(cmd, Command::SubmitOrder { side, .. } if side == "sell_short"),
"expected sell_short side in SubmitOrder"
);
}
#[test]
fn orders_j_moves_down() {
let mut app = orders_app();
update(&mut app, key(KeyCode::Char('j')));
assert_eq!(app.orders_state.selected(), Some(1));
}
#[test]
fn orders_j_clamps_at_end() {
let mut app = orders_app();
app.orders_state.select(Some(1)); update(&mut app, key(KeyCode::Char('j')));
assert_eq!(app.orders_state.selected(), Some(1));
}
#[test]
fn orders_k_moves_up() {
let mut app = orders_app();
app.orders_state.select(Some(1));
update(&mut app, key(KeyCode::Char('k')));
assert_eq!(app.orders_state.selected(), Some(0));
}
#[test]
fn orders_k_clamps_at_zero() {
let mut app = orders_app();
update(&mut app, key(KeyCode::Char('k')));
assert_eq!(app.orders_state.selected(), Some(0));
}
#[test]
fn orders_gg_jumps_to_top() {
let mut app = orders_app();
app.orders_state.select(Some(1));
update(&mut app, key(KeyCode::Char('g')));
assert_eq!(
app.orders_state.selected(),
Some(1),
"single g must not jump"
);
update(&mut app, key(KeyCode::Char('g')));
assert_eq!(app.orders_state.selected(), Some(0));
assert!(app.pending_g_at.is_none());
}
#[test]
fn orders_g_single_sets_pending_no_jump() {
let mut app = orders_app();
app.orders_state.select(Some(1));
update(&mut app, key(KeyCode::Char('g')));
assert_eq!(app.orders_state.selected(), Some(1));
assert!(app.pending_g_at.is_some());
}
#[test]
fn orders_g_then_other_key_clears_pending() {
let mut app = orders_app();
app.orders_state.select(Some(1));
update(&mut app, key(KeyCode::Char('g')));
update(&mut app, key(KeyCode::Char('k')));
assert!(app.pending_g_at.is_none());
}
#[test]
#[allow(non_snake_case)]
fn orders_G_jumps_to_bottom() {
let mut app = orders_app();
update(&mut app, key(KeyCode::Char('G')));
assert_eq!(app.orders_state.selected(), Some(1));
}
#[test]
fn orders_down_arrow_moves_down() {
let mut app = orders_app();
update(&mut app, key(KeyCode::Down));
assert_eq!(app.orders_state.selected(), Some(1));
}
#[test]
fn orders_up_arrow_moves_up() {
let mut app = orders_app();
app.orders_state.select(Some(1));
update(&mut app, key(KeyCode::Up));
assert_eq!(app.orders_state.selected(), Some(0));
}
#[test]
fn orders_c_with_no_selection_does_nothing() {
let mut app = orders_app();
app.orders_state.select(None);
update(&mut app, key(KeyCode::Char('c')));
assert!(
app.modal.is_none(),
"c with no selection should not open confirm"
);
}
#[test]
fn positions_j_moves_down() {
let (mut app, _rx) = positions_app();
app.positions.push(crate::types::Position {
symbol: "TSLA".into(),
qty: "5".into(),
avg_entry_price: "200.00".into(),
current_price: "210.00".into(),
market_value: "1050.00".into(),
unrealized_pl: "50.00".into(),
unrealized_plpc: "0.05".into(),
side: "long".into(),
asset_class: "us_equity".into(),
});
update(&mut app, key(KeyCode::Char('j')));
assert_eq!(app.positions_state.selected(), Some(1));
}
#[test]
fn positions_j_clamps_at_end() {
let (mut app, _rx) = positions_app();
update(&mut app, key(KeyCode::Char('j')));
assert_eq!(app.positions_state.selected(), Some(0)); }
#[test]
fn positions_k_clamps_at_zero() {
let (mut app, _rx) = positions_app();
update(&mut app, key(KeyCode::Char('k')));
assert_eq!(app.positions_state.selected(), Some(0));
}
#[test]
fn positions_gg_jumps_to_top() {
let (mut app, _rx) = positions_app();
app.positions.push(crate::types::Position {
symbol: "TSLA".into(),
qty: "5".into(),
avg_entry_price: "200.00".into(),
current_price: "210.00".into(),
market_value: "1050.00".into(),
unrealized_pl: "50.00".into(),
unrealized_plpc: "0.05".into(),
side: "long".into(),
asset_class: "us_equity".into(),
});
app.positions_state.select(Some(1));
update(&mut app, key(KeyCode::Char('g')));
assert_eq!(
app.positions_state.selected(),
Some(1),
"single g must not jump"
);
update(&mut app, key(KeyCode::Char('g')));
assert_eq!(app.positions_state.selected(), Some(0));
assert!(app.pending_g_at.is_none());
}
#[test]
fn positions_g_single_sets_pending_no_jump() {
let (mut app, _rx) = positions_app();
app.positions.push(crate::types::Position {
symbol: "TSLA".into(),
qty: "5".into(),
avg_entry_price: "200.00".into(),
current_price: "210.00".into(),
market_value: "1050.00".into(),
unrealized_pl: "50.00".into(),
unrealized_plpc: "0.05".into(),
side: "long".into(),
asset_class: "us_equity".into(),
});
app.positions_state.select(Some(1));
update(&mut app, key(KeyCode::Char('g')));
assert_eq!(app.positions_state.selected(), Some(1));
assert!(app.pending_g_at.is_some());
}
#[test]
fn positions_g_then_other_key_clears_pending() {
let (mut app, _rx) = positions_app();
update(&mut app, key(KeyCode::Char('g')));
update(&mut app, key(KeyCode::Char('j')));
assert!(app.pending_g_at.is_none());
}
#[test]
#[allow(non_snake_case)]
fn positions_G_jumps_to_bottom() {
let (mut app, _rx) = positions_app();
update(&mut app, key(KeyCode::Char('G')));
assert_eq!(app.positions_state.selected(), Some(0)); }
#[test]
fn positions_down_arrow_moves_down() {
let (mut app, _rx) = positions_app();
update(&mut app, key(KeyCode::Down));
assert_eq!(app.positions_state.selected(), Some(0));
}
#[test]
fn positions_up_arrow_clamps_at_zero() {
let (mut app, _rx) = positions_app();
update(&mut app, key(KeyCode::Up));
assert_eq!(app.positions_state.selected(), Some(0));
}
#[test]
fn positions_j_with_no_positions_is_noop() {
let (mut app, _rx) = app_with_rx();
app.active_tab = Tab::Positions;
update(&mut app, key(KeyCode::Char('j')));
assert_eq!(app.positions_state.selected(), None);
}
#[test]
fn watchlist_d_with_no_selection_does_nothing() {
let mut app = watchlist_app();
app.watchlist_state.select(None);
update(&mut app, key(KeyCode::Char('d')));
assert!(
app.modal.is_none(),
"d with no selection should not open confirm"
);
}
#[test]
fn watchlist_enter_with_no_selection_does_nothing() {
let mut app = watchlist_app();
app.watchlist_state.select(None);
update(&mut app, key(KeyCode::Enter));
assert!(
app.modal.is_none(),
"enter with no selection should not open modal"
);
}
#[test]
fn watchlist_a_without_watchlist_does_nothing() {
let mut app = make_test_app();
app.active_tab = Tab::Watchlist;
app.watchlist = None;
update(&mut app, key(KeyCode::Char('a')));
assert!(
app.modal.is_none(),
"a with no watchlist should not open AddSymbol"
);
}
#[test]
fn watchlist_d_without_watchlist_does_nothing() {
let mut app = make_test_app();
app.active_tab = Tab::Watchlist;
app.watchlist = None;
update(&mut app, key(KeyCode::Char('d')));
assert!(app.modal.is_none());
}
#[test]
fn mouse_click_modal_submit_button_submits_order() {
use crate::app::OrderEntryState;
use crate::types::AccountInfo;
let (mut app, mut cmd_rx) = app_with_rx();
app.account = Some(AccountInfo {
buying_power: "100000".into(),
..Default::default()
});
let mut state = OrderEntryState::new("AAPL".into());
state.qty_input = "10".into();
state.price_input = "150.00".into();
app.modal = Some(Modal::OrderEntry(state));
app.hit_areas.modal_submit = Some(rect(10, 20, 10, 1));
update(&mut app, mouse_click(12, 20));
assert!(
app.modal.is_none(),
"modal should close after clicking submit"
);
let cmd = cmd_rx
.try_recv()
.expect("SubmitOrder command should be dispatched");
assert!(matches!(cmd, Command::SubmitOrder { .. }));
}
#[test]
fn mouse_click_modal_field_side_left_third_selects_buy() {
use crate::app::{OrderEntryState, OrderField, OrderSide};
let (mut app, _rx) = app_with_rx();
let state = OrderEntryState::new("AAPL".into());
app.modal = Some(Modal::OrderEntry(state));
app.hit_areas.modal_fields = vec![(OrderField::Side, rect(10, 5, 30, 1))];
update(&mut app, mouse_click(10, 5));
assert!(matches!(&app.modal, Some(Modal::OrderEntry(s))
if s.side == OrderSide::Buy && s.focused_field == OrderField::Side));
}
#[test]
fn mouse_click_modal_field_side_middle_third_selects_sell() {
use crate::app::{OrderEntryState, OrderField, OrderSide};
let (mut app, _rx) = app_with_rx();
let state = OrderEntryState::new("AAPL".into());
app.modal = Some(Modal::OrderEntry(state));
app.hit_areas.modal_fields = vec![(OrderField::Side, rect(10, 5, 30, 1))];
update(&mut app, mouse_click(22, 5));
assert!(matches!(&app.modal, Some(Modal::OrderEntry(s))
if s.side == OrderSide::Sell && s.focused_field == OrderField::Side));
}
#[test]
fn mouse_click_modal_field_side_right_third_selects_sell_short() {
use crate::app::{OrderEntryState, OrderField, OrderSide};
let (mut app, _rx) = app_with_rx();
let state = OrderEntryState::new("AAPL".into());
app.modal = Some(Modal::OrderEntry(state));
app.hit_areas.modal_fields = vec![(OrderField::Side, rect(10, 5, 30, 1))];
update(&mut app, mouse_click(32, 5));
assert!(matches!(&app.modal, Some(Modal::OrderEntry(s))
if s.side == OrderSide::SellShort && s.focused_field == OrderField::Side));
}
#[test]
fn mouse_click_modal_field_order_type_left_half_selects_limit() {
use crate::app::{OrderEntryState, OrderField};
let (mut app, _rx) = app_with_rx();
let state = OrderEntryState::new("AAPL".into());
app.modal = Some(Modal::OrderEntry(state));
app.hit_areas.modal_fields = vec![(OrderField::OrderType, rect(10, 6, 20, 1))];
update(&mut app, mouse_click(12, 6));
assert!(matches!(&app.modal, Some(Modal::OrderEntry(s))
if !s.market_order && s.focused_field == OrderField::OrderType));
}
#[test]
fn mouse_click_modal_field_order_type_right_half_selects_market() {
use crate::app::{OrderEntryState, OrderField};
let (mut app, _rx) = app_with_rx();
let state = OrderEntryState::new("AAPL".into());
app.modal = Some(Modal::OrderEntry(state));
app.hit_areas.modal_fields = vec![(OrderField::OrderType, rect(10, 6, 20, 1))];
update(&mut app, mouse_click(22, 6));
assert!(matches!(&app.modal, Some(Modal::OrderEntry(s))
if s.market_order && s.focused_field == OrderField::OrderType));
}
#[test]
fn mouse_click_modal_field_other_focuses_field() {
use crate::app::{OrderEntryState, OrderField};
let (mut app, _rx) = app_with_rx();
let state = OrderEntryState::new("AAPL".into());
app.modal = Some(Modal::OrderEntry(state));
app.hit_areas.modal_fields = vec![(OrderField::Qty, rect(10, 7, 20, 1))];
update(&mut app, mouse_click(15, 7));
assert!(
matches!(&app.modal, Some(Modal::OrderEntry(s)) if s.focused_field == OrderField::Qty)
);
}
#[test]
fn mouse_click_confirm_yes_button_accepts() {
let mut app = make_test_app();
app.active_tab = Tab::Watchlist;
app.watchlist = Some(make_watchlist(&["AAPL"]));
app.watchlist_state.select(Some(0));
update(&mut app, key(KeyCode::Char('d')));
assert!(matches!(
&app.modal,
Some(Modal::ConfirmRemoveWatchlist { .. })
));
app.hit_areas.modal_confirm_buttons = Some(rect(10, 10, 20, 1));
update(&mut app, mouse_click(12, 10));
assert!(app.modal.is_none(), "yes click should close confirm modal");
}
#[test]
fn mouse_click_confirm_no_button_cancels() {
let mut app = make_test_app();
app.active_tab = Tab::Watchlist;
app.watchlist = Some(make_watchlist(&["AAPL"]));
app.watchlist_state.select(Some(0));
update(&mut app, key(KeyCode::Char('d')));
assert!(matches!(
&app.modal,
Some(Modal::ConfirmRemoveWatchlist { .. })
));
app.hit_areas.modal_confirm_buttons = Some(rect(10, 10, 20, 1));
update(&mut app, mouse_click(22, 10));
assert!(app.modal.is_none(), "no click should close confirm modal");
}
#[test]
fn mouse_click_outside_modal_does_not_close_it() {
use crate::app::OrderEntryState;
let (mut app, _rx) = app_with_rx();
let state = OrderEntryState::new("AAPL".into());
app.modal = Some(Modal::OrderEntry(state));
update(&mut app, mouse_click(0, 0));
assert!(
app.modal.is_some(),
"click outside modal regions should leave modal open"
);
}
#[test]
fn mouse_click_list_row_selects_positions_item() {
let (mut app, _rx) = positions_app();
app.positions.push(crate::types::Position {
symbol: "TSLA".into(),
qty: "5".into(),
avg_entry_price: "200.00".into(),
current_price: "210.00".into(),
market_value: "1050.00".into(),
unrealized_pl: "50.00".into(),
unrealized_plpc: "0.05".into(),
side: "long".into(),
asset_class: "us_equity".into(),
});
app.active_tab = Tab::Positions;
app.hit_areas.list_data_start_y = 10;
update(&mut app, mouse_click(5, 11)); assert_eq!(app.positions_state.selected(), Some(1));
}
#[test]
fn mouse_click_list_row_selects_orders_item() {
let mut app = orders_app();
app.hit_areas.list_data_start_y = 10;
update(&mut app, mouse_click(5, 11)); assert_eq!(app.orders_state.selected(), Some(1));
}
#[test]
fn mouse_click_list_row_account_tab_is_noop() {
let mut app = make_test_app();
app.active_tab = Tab::Account;
app.hit_areas.list_data_start_y = 10;
update(&mut app, mouse_click(5, 11));
}
#[test]
fn mouse_click_list_row_out_of_bounds_does_not_panic() {
let mut app = watchlist_app();
app.hit_areas.list_data_start_y = 10;
update(&mut app, mouse_click(5, 99));
assert_eq!(app.watchlist_state.selected(), Some(0));
}
#[test]
fn search_backspace_pops_char() {
let mut app = make_test_app();
app.searching = true;
app.search_query = "AB".into();
update(&mut app, key(KeyCode::Backspace));
assert_eq!(app.search_query, "A");
}
#[test]
fn search_backspace_on_empty_is_noop() {
let mut app = make_test_app();
app.searching = true;
app.search_query = String::new();
update(&mut app, key(KeyCode::Backspace));
assert!(app.search_query.is_empty());
}
#[test]
fn search_char_resets_watchlist_selection() {
let mut app = watchlist_app();
app.searching = true;
app.watchlist_state.select(Some(2));
update(&mut app, key(KeyCode::Char('A')));
assert_eq!(app.watchlist_state.selected(), Some(0));
}
#[test]
fn t_key_cycles_theme_default_to_dark() {
use crate::ui::theme::Theme;
let mut app = make_test_app();
assert_eq!(app.current_theme, Theme::Default);
update(&mut app, key(KeyCode::Char('T')));
assert_eq!(app.current_theme, Theme::Dark);
}
#[test]
fn t_key_cycles_dark_to_high_contrast() {
use crate::ui::theme::Theme;
let mut app = make_test_app();
app.current_theme = Theme::Dark;
update(&mut app, key(KeyCode::Char('T')));
assert_eq!(app.current_theme, Theme::HighContrast);
}
#[test]
fn t_key_wraps_high_contrast_to_default() {
use crate::ui::theme::Theme;
let mut app = make_test_app();
app.current_theme = Theme::HighContrast;
update(&mut app, key(KeyCode::Char('T')));
assert_eq!(app.current_theme, Theme::Default);
}
#[test]
fn t_key_sets_status_message() {
let mut app = make_test_app();
update(&mut app, key(KeyCode::Char('T')));
let status = app.current_status_text();
assert!(
status.contains("Theme:"),
"Status should contain 'Theme:' after T key, got: {:?}",
status
);
}
#[test]
fn t_key_three_presses_returns_to_default() {
use crate::ui::theme::Theme;
let mut app = make_test_app();
for _ in 0..3 {
update(&mut app, key(KeyCode::Char('T')));
}
assert_eq!(app.current_theme, Theme::Default);
}
#[test]
fn fetch_started_increments_pending_requests() {
let mut app = make_test_app();
assert_eq!(app.pending_requests, 0);
update(&mut app, Event::FetchStarted);
assert_eq!(app.pending_requests, 1);
update(&mut app, Event::FetchStarted);
assert_eq!(app.pending_requests, 2);
}
#[test]
fn fetch_complete_decrements_pending_requests() {
let mut app = make_test_app();
update(&mut app, Event::FetchStarted);
update(&mut app, Event::FetchStarted);
update(&mut app, Event::FetchComplete);
assert_eq!(app.pending_requests, 1);
}
#[test]
fn fetch_complete_sets_last_updated_when_counter_reaches_zero() {
let mut app = make_test_app();
assert!(app.last_updated.is_none());
update(&mut app, Event::FetchStarted);
update(&mut app, Event::FetchComplete);
assert!(
app.last_updated.is_some(),
"last_updated should be set once all fetches complete"
);
}
#[test]
fn fetch_complete_does_not_set_last_updated_while_still_pending() {
let mut app = make_test_app();
update(&mut app, Event::FetchStarted);
update(&mut app, Event::FetchStarted);
update(&mut app, Event::FetchComplete); assert!(
app.last_updated.is_none(),
"last_updated must not be set while fetches are still in-flight"
);
}
#[test]
fn fetch_complete_does_not_underflow_pending_requests() {
let mut app = make_test_app();
update(&mut app, Event::FetchComplete);
assert_eq!(
app.pending_requests, 0,
"saturating_sub should prevent underflow"
);
}
#[test]
fn tick_advances_spinner_when_requests_pending() {
let mut app = make_test_app();
app.pending_requests = 1;
let before = app.spinner_tick;
update(&mut app, Event::Tick);
assert_eq!(app.spinner_tick, before.wrapping_add(1));
}
#[test]
fn tick_does_not_advance_spinner_when_idle() {
let mut app = make_test_app();
assert_eq!(app.pending_requests, 0);
let before = app.spinner_tick;
update(&mut app, Event::Tick);
assert_eq!(
app.spinner_tick, before,
"spinner should not advance when idle"
);
}
#[test]
fn spinner_frame_cycles_through_ten_frames() {
let mut app = make_test_app();
let frames: Vec<char> = (0..10)
.map(|_| {
let f = app.spinner_frame();
app.spinner_tick = app.spinner_tick.wrapping_add(1);
f
})
.collect();
let unique: std::collections::HashSet<char> = frames.iter().copied().collect();
assert_eq!(unique.len(), 10, "spinner should have 10 distinct frames");
assert_eq!(
app.spinner_frame(),
frames[0],
"spinner should wrap after 10 ticks"
);
}
#[test]
fn c_key_on_watchlist_with_selection_sets_status_message() {
let mut app = make_test_app();
app.active_tab = Tab::Watchlist;
app.watchlist = Some(make_watchlist(&["AAPL", "TSLA"]));
app.watchlist_state.select(Some(0));
update(&mut app, key(KeyCode::Char('c')));
let status = app.current_status_text();
assert!(
!status.is_empty(),
"pressing 'c' with a selection must always set a status message"
);
assert!(
status.contains("AAPL") || status.contains("Clipboard") || status.contains("clipboard"),
"status should mention symbol or clipboard; got: {status:?}"
);
}
#[test]
fn c_key_on_watchlist_without_selection_sets_no_symbol_selected_message() {
let mut app = make_test_app();
app.active_tab = Tab::Watchlist;
app.watchlist = Some(make_watchlist(&["AAPL"]));
update(&mut app, key(KeyCode::Char('c')));
assert_eq!(app.current_status_text(), "No symbol selected");
}
#[test]
fn c_key_on_positions_with_selection_sets_status_message() {
let (mut app, _rx) = positions_app();
update(&mut app, key(KeyCode::Char('c')));
let status = app.current_status_text();
assert!(
!status.is_empty(),
"pressing 'c' on positions with selection must set a status message"
);
}
#[test]
fn c_key_on_orders_does_not_copy_but_opens_cancel_modal() {
let mut app = orders_app();
update(&mut app, key(KeyCode::Char('c')));
assert!(
matches!(app.modal, Some(Modal::Confirm { .. })),
"pressing 'c' in Orders should open cancel confirm modal, not copy"
);
let status = app.current_status_text();
assert!(
!status.contains("Copied"),
"Orders 'c' must not trigger copy; got: {status:?}"
);
}
#[test]
fn c_key_on_account_tab_sets_no_symbol_selected() {
let mut app = make_test_app();
assert_eq!(app.active_tab, Tab::Account);
update(&mut app, key(KeyCode::Char('c')));
assert_eq!(app.current_status_text(), "No symbol selected");
}
#[test]
fn fill_notification_fill_with_price() {
let order = Order {
filled_avg_price: Some("173.42".into()),
..make_order("o1", "filled")
};
let msg = fill_notification_text(&order, "fill").unwrap();
assert_eq!(msg, "✓ BUY 10 AAPL filled @ $173.42");
}
#[test]
fn fill_notification_fill_without_price() {
let order = make_order("o1", "filled");
let msg = fill_notification_text(&order, "fill").unwrap();
assert_eq!(msg, "✓ BUY 10 AAPL filled");
}
#[test]
fn fill_notification_partial_fill_with_price() {
let order = Order {
filled_qty: "5".into(),
filled_avg_price: Some("173.40".into()),
..make_order("o1", "partially_filled")
};
let msg = fill_notification_text(&order, "partial_fill").unwrap();
assert_eq!(msg, "~ BUY 5/10 AAPL partial fill @ $173.40");
}
#[test]
fn fill_notification_partial_fill_without_price() {
let order = Order {
filled_qty: "3".into(),
..make_order("o1", "partially_filled")
};
let msg = fill_notification_text(&order, "partial_fill").unwrap();
assert_eq!(msg, "~ BUY 3/10 AAPL partial fill");
}
#[test]
fn fill_notification_rejected() {
let order = make_order("o1", "rejected");
let msg = fill_notification_text(&order, "rejected").unwrap();
assert_eq!(msg, "✗ BUY 10 AAPL rejected");
}
#[test]
fn fill_notification_expired() {
let order = make_order("o1", "expired");
let msg = fill_notification_text(&order, "expired").unwrap();
assert_eq!(msg, "✗ BUY 10 AAPL expired");
}
#[test]
fn fill_notification_canceled() {
let order = make_order("o1", "canceled");
let msg = fill_notification_text(&order, "canceled").unwrap();
assert_eq!(msg, "✗ BUY 10 AAPL canceled");
}
#[test]
fn fill_notification_pending_new_is_none() {
let order = make_order("o1", "pending_new");
assert!(fill_notification_text(&order, "pending_new").is_none());
}
#[test]
fn fill_notification_unknown_event_is_none() {
let order = make_order("o1", "accepted");
assert!(fill_notification_text(&order, "replaced").is_none());
}
#[test]
fn trade_update_fill_pushes_notification() {
let mut app = make_test_app();
app.prefs.notifications.fill_notifications_enabled = true;
let order = Order {
filled_avg_price: Some("173.42".into()),
..make_order("o1", "filled")
};
update(
&mut app,
Event::TradeUpdate {
order,
event_type: "fill".to_string(),
},
);
let status = app.current_status_text();
assert!(
status.contains("✓") && status.contains("AAPL"),
"expected fill notification, got: {status:?}"
);
}
#[test]
fn trade_update_fill_skipped_when_notifications_disabled() {
let mut app = make_test_app();
app.prefs.notifications.fill_notifications_enabled = false;
let order = Order {
filled_avg_price: Some("173.42".into()),
..make_order("o1", "filled")
};
update(
&mut app,
Event::TradeUpdate {
order,
event_type: "fill".to_string(),
},
);
let status = app.current_status_text();
assert!(
!status.contains("✓"),
"expected no notification when disabled, got: {status:?}"
);
}
#[test]
fn trade_update_rejected_pushes_notification() {
let mut app = make_test_app();
app.prefs.notifications.fill_notifications_enabled = true;
update(
&mut app,
Event::TradeUpdate {
order: make_order("o1", "rejected"),
event_type: "rejected".to_string(),
},
);
let status = app.current_status_text();
assert!(
status.contains("✗") && status.contains("AAPL"),
"expected rejected notification, got: {status:?}"
);
}
#[test]
fn trade_update_pending_no_notification() {
let mut app = make_test_app();
app.prefs.notifications.fill_notifications_enabled = true;
update(
&mut app,
Event::TradeUpdate {
order: make_order("o1", "pending_new"),
event_type: "pending_new".to_string(),
},
);
let status = app.current_status_text();
assert!(
!status.contains("✓") && !status.contains("✗") && !status.contains("~"),
"pending_new should not push a notification, got: {status:?}"
);
}
fn make_position(symbol: &str, qty: &str, price: &str) -> crate::types::Position {
crate::types::Position {
symbol: symbol.into(),
qty: qty.into(),
avg_entry_price: price.into(),
current_price: price.into(),
market_value: "0".into(),
unrealized_pl: "0".into(),
unrealized_plpc: "0".into(),
side: "long".into(),
asset_class: "us_equity".into(),
}
}
#[test]
fn market_quote_pushes_equity_when_positions_present() {
let mut app = make_test_app();
app.account = Some(crate::types::AccountInfo {
cash: "0.00".into(),
..Default::default()
});
app.positions = vec![make_position("AAPL", "10", "150.00")];
update(
&mut app,
Event::MarketQuote(Quote {
symbol: "AAPL".into(),
ap: Some(200.00),
bp: None,
..Default::default()
}),
);
assert!(app.quotes.contains_key("AAPL"));
assert_eq!(
app.equity_history,
vec![200_000],
"equity_history should have streaming sample"
);
}
#[test]
fn market_quote_no_equity_push_without_positions() {
let mut app = make_test_app();
update(
&mut app,
Event::MarketQuote(Quote {
symbol: "AAPL".into(),
ap: Some(200.00),
bp: None,
..Default::default()
}),
);
assert!(
app.equity_history.is_empty(),
"no equity sample without positions"
);
}
#[test]
fn intraday_bars_received_records_fetched_at_timestamp() {
let mut app = make_test_app();
let before = std::time::Instant::now();
update(
&mut app,
Event::IntradayBarsReceived {
symbol: "MSFT".into(),
bars: vec![10_000, 10_050],
},
);
let after = std::time::Instant::now();
let ts = app
.intraday_fetched_at
.get("MSFT")
.expect("fetched_at should be recorded");
assert!(
*ts >= before && *ts <= after,
"fetched_at should be close to now"
);
assert_eq!(
app.intraday_bars.get("MSFT"),
Some(&vec![10_000u64, 10_050])
);
}
#[test]
fn tick_dispatches_intraday_refresh_when_due() {
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::channel(4);
let (symbol_tx, _) = tokio::sync::watch::channel(vec![]);
let mut app = crate::app::App::new(
crate::config::AlpacaConfig {
base_url: "http://localhost".into(),
key: "k".into(),
secret: "s".into(),
env: crate::config::AlpacaEnv::Paper,
dry_run: false,
},
crate::prefs::AppPrefs::default(),
std::sync::Arc::new(tokio::sync::Notify::new()),
cmd_tx,
symbol_tx,
);
app.modal = Some(crate::app::Modal::SymbolDetail("AAPL".into()));
app.intraday_fetched_at.insert(
"AAPL".into(),
std::time::Instant::now() - std::time::Duration::from_secs(61),
);
update(&mut app, Event::Tick);
let cmd = cmd_rx.try_recv().expect("command should be dispatched");
assert!(
matches!(cmd, Command::FetchIntradayBars(s) if s == "AAPL"),
"expected FetchIntradayBars for AAPL"
);
}
#[test]
fn tick_skips_intraday_refresh_when_not_due() {
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::channel(4);
let (symbol_tx, _) = tokio::sync::watch::channel(vec![]);
let mut app = crate::app::App::new(
crate::config::AlpacaConfig {
base_url: "http://localhost".into(),
key: "k".into(),
secret: "s".into(),
env: crate::config::AlpacaEnv::Paper,
dry_run: false,
},
crate::prefs::AppPrefs::default(),
std::sync::Arc::new(tokio::sync::Notify::new()),
cmd_tx,
symbol_tx,
);
app.modal = Some(crate::app::Modal::SymbolDetail("AAPL".into()));
app.intraday_fetched_at
.insert("AAPL".into(), std::time::Instant::now());
update(&mut app, Event::Tick);
assert!(
cmd_rx.try_recv().is_err(),
"no refresh command when interval not elapsed"
);
}
#[test]
fn tick_skips_intraday_refresh_when_no_modal() {
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::channel(4);
let (symbol_tx, _) = tokio::sync::watch::channel(vec![]);
let mut app = crate::app::App::new(
crate::config::AlpacaConfig {
base_url: "http://localhost".into(),
key: "k".into(),
secret: "s".into(),
env: crate::config::AlpacaEnv::Paper,
dry_run: false,
},
crate::prefs::AppPrefs::default(),
std::sync::Arc::new(tokio::sync::Notify::new()),
cmd_tx,
symbol_tx,
);
app.intraday_fetched_at.insert(
"AAPL".into(),
std::time::Instant::now() - std::time::Duration::from_secs(61),
);
update(&mut app, Event::Tick);
assert!(
cmd_rx.try_recv().is_err(),
"no refresh command without an open modal"
);
}
#[test]
fn tick_skips_intraday_refresh_when_never_fetched() {
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::channel(4);
let (symbol_tx, _) = tokio::sync::watch::channel(vec![]);
let mut app = crate::app::App::new(
crate::config::AlpacaConfig {
base_url: "http://localhost".into(),
key: "k".into(),
secret: "s".into(),
env: crate::config::AlpacaEnv::Paper,
dry_run: false,
},
crate::prefs::AppPrefs::default(),
std::sync::Arc::new(tokio::sync::Notify::new()),
cmd_tx,
symbol_tx,
);
app.modal = Some(crate::app::Modal::SymbolDetail("AAPL".into()));
update(&mut app, Event::Tick);
assert!(
cmd_rx.try_recv().is_err(),
"no refresh command when bars have never been fetched"
);
}
}