use std::sync::{Arc, RwLock};
use std::time::Instant;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use tokio::sync::Notify;
use crate::alphai::{self, Article, InsiderSummary, SentimentSummary, TRENDING_KEY};
use crate::app::{App, AppInit, ChartStyle, InsiderBundle, NewsBundle, NewsLayout, NewsScope};
use crate::config::Config;
use crate::domain::{Candle, Interval, Quote, Range, TickerData};
use crate::source::make_source;
use crate::ui;
fn empty_app(symbols: Vec<String>) -> App {
let (app, _rx) = empty_app_with_cmds(symbols);
app
}
fn empty_app_with_cmds(
symbols: Vec<String>,
) -> (App, tokio::sync::mpsc::UnboundedReceiver<alphai::Cmd>) {
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
let (alphai_tx, alphai_rx) = tokio::sync::mpsc::unbounded_channel();
let source = make_source("yahoo", None, None).unwrap();
let app = App::new(AppInit {
symbols,
source: Arc::new(RwLock::new(source)),
source_name: "yahoo",
range: Range::D1,
interval: Interval::M5,
params: Arc::new(RwLock::new((Range::D1, Interval::M5))),
rx,
refresh: Arc::new(Notify::new()),
alphai_tx,
config: Config::default(),
alphai_enabled: true,
first_run: false,
});
(app, alphai_rx)
}
fn press(app: &mut App, code: KeyCode) {
app.handle_key(KeyEvent::from(code));
}
fn has_candles(screen: &str) -> bool {
screen.contains('▀') || screen.contains('▄') || screen.contains('█')
}
fn fake_app() -> App {
let mut app = empty_app(vec!["AAPL".into(), "MSFT".into()]);
for (symbol, base) in [("AAPL", 200.0), ("MSFT", 400.0)] {
let candles: Vec<Candle> = (0..30)
.map(|i| {
let close = base + i as f64 * 0.5;
Candle {
ts: 1_700_000_000 + i * 300,
open: close - 0.2,
high: close + 0.3,
low: close - 0.4,
close,
volume: Some(1000.0),
}
})
.collect();
let price = candles.last().unwrap().close;
app.data.insert(
symbol.into(),
TickerData {
quote: Quote {
symbol: symbol.into(),
price,
prev_close: Some(base),
currency: Some("USD".into()),
},
candles,
},
);
}
app
}
fn article(title: &str, ticker: &str, score: i64, sentiment: &str) -> Article {
serde_json::from_str(&format!(
r#"{{
"original": {{
"title": "{title}",
"url": "https://example.com/a",
"time_published": "2026-07-10T12:00:00Z",
"summary": "Summary of {title}.",
"source_domain": "example.com"
}},
"enrichment": {{
"category": "earnings",
"tickers": ["{ticker}"],
"relevance_score": {score},
"ai_trading_insights": {{
"ticker_analysis": [
{{"ticker": "{ticker}", "impact_analysis": {{
"sentiment": "{sentiment}",
"confidence": "high",
"price_impact_prediction": "+2-4% near term",
"summary": "Impact summary for {title}."
}}}}
],
"news_trading_value": {{
"actionability_score": "high",
"information_novelty": 7,
"timing_relevance": "pre-market"
}},
"alternative_perspectives": {{
"contrarian_view": "Priced in already.",
"overlooked_factors": "Margins are thinning."
}}
}},
"news_context_enhancement": {{
"background_context": "Follows last quarter's beat.",
"key_entities": [
{{"name": "Acme", "type": "company", "description": "widget maker"}}
],
"market_relevance_summary": "Read-through for the sector."
}}
}}
}}"#
))
.unwrap()
}
fn market_article(title: &str, sources: i64) -> Article {
let mut a = article(title, "AAPL", 8, "positive");
a.enrichment.tickers.push("MSFT".into());
a.sources_count = Some(sources);
a
}
fn filing(title: &str, form: &str) -> Article {
serde_json::from_str(&format!(
r#"{{
"original": {{
"title": "{title}",
"url": "https://example.com/f",
"time_published": "2026-07-10T12:00:00Z",
"summary": "Summary of {title}.",
"source_domain": "sec.gov",
"ownership_form": "{form}"
}},
"enrichment": {{"category": "insider", "tickers": ["AAPL"], "relevance_score": 7}}
}}"#
))
.unwrap()
}
fn render(app: &mut App) -> String {
render_sized(app, 100, 30)
}
fn render_sized(app: &mut App, width: u16, height: u16) -> String {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal.draw(|f| ui::draw(f, app)).unwrap();
let buffer = terminal.backend().buffer().clone();
let area = buffer.area;
let mut out = String::new();
for y in area.top()..area.bottom() {
for x in area.left()..area.right() {
out.push_str(buffer.cell((x, y)).unwrap().symbol());
}
out.push('\n');
}
out
}
#[test]
fn table_view_shows_quotes() {
let mut app = fake_app();
app.view_idx = 2; let screen = render(&mut app);
assert!(screen.contains("Watchlist"), "screen:\n{screen}");
assert!(screen.contains("AAPL"), "screen:\n{screen}");
assert!(screen.contains("214.50"), "screen:\n{screen}"); assert!(screen.contains("+14.50"), "screen:\n{screen}");
assert!(screen.contains("+7.25%"), "screen:\n{screen}");
assert!(screen.contains("▶"), "selection marker missing:\n{screen}");
}
#[test]
fn chart_view_shows_selected_symbol() {
let mut app = fake_app();
app.view_idx = ui::VIEW_CHART;
app.selected = 1;
let screen = render(&mut app);
assert!(screen.contains("MSFT"), "screen:\n{screen}");
assert!(screen.contains("414.50"), "screen:\n{screen}");
assert!(has_candles(&screen), "no candle cells rendered:\n{screen}");
}
#[test]
fn chart_toggles_to_line_mode() {
let mut app = fake_app();
app.view_idx = ui::VIEW_CHART;
press(&mut app, KeyCode::Char('c'));
assert_eq!(app.chart_style, ChartStyle::Line);
let screen = render(&mut app);
assert!(
screen.chars().any(|c| ('\u{2800}'..='\u{28FF}').contains(&c)),
"no Braille line in line mode:\n{screen}"
);
press(&mut app, KeyCode::Char('c'));
assert_eq!(app.chart_style, ChartStyle::Candles);
}
#[test]
fn sma_toggle_hides_legend() {
let mut app = fake_app();
app.view_idx = ui::VIEW_CHART;
let screen = render(&mut app);
assert!(screen.contains("SMA20"), "screen:\n{screen}");
assert!(!screen.contains("SMA100"), "screen:\n{screen}");
press(&mut app, KeyCode::Char('m'));
let screen = render(&mut app);
assert!(!screen.contains("SMA20"), "screen:\n{screen}");
}
#[test]
fn sma_slow_appears_with_warmup_history() {
let mut app = fake_app();
app.view_idx = ui::VIEW_CHART;
let candles: Vec<Candle> = (0..600)
.map(|i| {
let close = 200.0 + (i % 40) as f64 * 0.5;
Candle {
ts: 1_700_000_000 + i * 300,
open: close - 0.2,
high: close + 0.3,
low: close - 0.4,
close,
volume: Some(1000.0),
}
})
.collect();
app.data.get_mut("AAPL").unwrap().candles = candles;
let screen = render(&mut app);
assert!(screen.contains("SMA100"), "screen:\n{screen}");
press(&mut app, KeyCode::Char('c'));
let screen = render(&mut app);
assert!(screen.contains("SMA100"), "screen:\n{screen}");
}
#[test]
fn rsi_toggle_hides_panel() {
let mut app = fake_app();
app.view_idx = ui::VIEW_CHART;
let screen = render(&mut app);
assert!(screen.contains("RSI(14)"), "screen:\n{screen}");
press(&mut app, KeyCode::Char('i'));
let screen = render(&mut app);
assert!(!screen.contains("RSI(14)"), "screen:\n{screen}");
}
#[test]
fn rsi_panel_hidden_on_short_terminal() {
let mut app = fake_app();
app.view_idx = ui::VIEW_CHART;
let screen = render_sized(&mut app, 100, 18);
assert!(!screen.contains("RSI(14)"), "screen:\n{screen}");
assert!(has_candles(&screen), "screen:\n{screen}");
}
#[test]
fn range_keys_cycle_presets_and_update_header() {
let mut app = fake_app();
app.view_idx = ui::VIEW_CHART;
press(&mut app, KeyCode::Char('t'));
assert_eq!((app.range, app.interval), (Range::D5, Interval::M15));
let screen = render(&mut app);
assert!(screen.contains("· 15m"), "screen:\n{screen}");
press(&mut app, KeyCode::Char('T'));
press(&mut app, KeyCode::Char('T'));
assert_eq!((app.range, app.interval), (Range::Y1, Interval::D1));
assert!(render(&mut app).contains("· 1d"));
assert!(app.data.contains_key("AAPL"));
}
#[test]
fn range_switch_keeps_news_bundle() {
let mut app = fake_app();
app.view_idx = ui::VIEW_NEWS;
app.news.insert(
"AAPL".into(),
NewsBundle::new(
vec![article("Apple beats expectations", "AAPL", 9, "positive")],
None,
None,
),
);
press(&mut app, KeyCode::Char('t'));
assert!(app.news.contains_key("AAPL"), "range switch dropped the news bundle");
}
#[test]
fn split_view_combines_table_chart_and_news() {
let mut app = fake_app();
app.view_idx = ui::VIEW_SPLIT;
app.news.insert(
"AAPL".into(),
NewsBundle::new(
vec![article("Apple beats expectations", "AAPL", 9, "positive")],
None,
None,
),
);
let screen = render(&mut app);
assert!(screen.contains("Watchlist"), "screen:\n{screen}");
assert!(
screen.contains('▀'),
"no candle chart in split view:\n{screen}"
);
assert!(screen.contains("News · AAPL"), "screen:\n{screen}");
assert!(screen.contains("Apple beats expectations"), "screen:\n{screen}");
}
#[test]
fn split_view_news_panel_without_key_shows_one_line_hint() {
let mut app = fake_app();
app.alphai_enabled = false;
app.view_idx = ui::VIEW_SPLIT;
let screen = render(&mut app);
assert!(screen.contains("alphai.io"), "screen:\n{screen}");
assert!(screen.contains("press s"), "screen:\n{screen}");
}
#[test]
fn split_view_drops_news_panel_on_tiny_terminal() {
let mut app = fake_app();
app.view_idx = ui::VIEW_SPLIT;
let mut terminal = Terminal::new(TestBackend::new(100, 12)).unwrap();
terminal.draw(|f| ui::draw(f, &mut app)).unwrap();
let buffer = terminal.backend().buffer().clone();
let mut screen = String::new();
for y in 0..12 {
for x in 0..100 {
screen.push_str(buffer.cell((x, y)).unwrap().symbol());
}
screen.push('\n');
}
assert!(screen.contains("Watchlist"), "screen:\n{screen}");
assert!(!screen.contains("News ·"), "news strip should be hidden:\n{screen}");
}
#[test]
fn missing_data_renders_placeholders() {
let mut app = empty_app(vec!["AAPL".into()]);
app.errors.insert("AAPL".into(), "boom".into());
for view_idx in 0..ui::VIEWS.len() {
app.view_idx = view_idx;
let screen = render(&mut app); assert!(!screen.is_empty(), "view {view_idx} rendered nothing");
}
}
#[test]
fn news_view_lists_articles_and_sentiment() {
let mut app = fake_app();
app.view_idx = ui::VIEW_NEWS;
app.news.insert(
"AAPL".into(),
NewsBundle::new(
vec![
article("Apple beats expectations", "AAPL", 9, "positive"),
article("Supplier note weighs on outlook", "AAPL", 6, "negative"),
],
Some(SentimentSummary {
days: 7,
total: 20,
bullish: 12,
neutral: 5,
bearish: 3,
}),
None,
),
);
let screen = render(&mut app);
assert!(screen.contains("News · AAPL"), "screen:\n{screen}");
assert!(screen.contains("Apple beats expectations"), "screen:\n{screen}");
assert!(screen.contains("12 bullish"), "screen:\n{screen}");
assert!(screen.contains("▲"), "sentiment glyph missing:\n{screen}");
assert!(
screen.contains("Summary of Apple beats expectations"),
"screen:\n{screen}"
);
assert!(screen.contains("nov 7"), "novelty missing from meta:\n{screen}");
assert!(screen.contains("positive/high"), "sentiment/confidence missing:\n{screen}");
assert!(screen.contains("act high"), "actionability missing:\n{screen}");
}
#[test]
fn news_view_without_key_shows_hint() {
let mut app = fake_app();
app.alphai_enabled = false;
app.view_idx = ui::VIEW_NEWS;
let screen = render(&mut app);
assert!(screen.contains("alphai.io"), "screen:\n{screen}");
assert!(screen.contains("free API key"), "screen:\n{screen}");
}
#[test]
fn news_view_shows_error_state() {
let mut app = fake_app();
app.view_idx = ui::VIEW_NEWS;
app.alphai_errors
.insert("AAPL".into(), "invalid AlphaAI API key".into());
let screen = render(&mut app);
assert!(screen.contains("invalid AlphaAI API key"), "screen:\n{screen}");
assert!(screen.contains("press r to retry"), "screen:\n{screen}");
}
#[test]
fn insider_view_shows_summary_and_filings() {
let mut app = fake_app();
app.view_idx = ui::VIEW_INSIDER;
let summary: InsiderSummary = serde_json::from_str(
r#"{
"ticker": "AAPL", "days": 30, "total_transactions": 14,
"buy_count": 2, "sell_count": 12,
"buy_value_usd": "1240000.00", "sell_value_usd": "224580213.05",
"pct_10b5_1": 85,
"top_insiders": [{"name": "COOK TIMOTHY", "title": "CEO", "transaction_count": 3, "net_value": "-50300000.00"}]
}"#,
)
.unwrap();
app.insider.insert(
"AAPL".into(),
InsiderBundle::new(
vec![filing("Apple insider sold $12.5M of stock", "direct")],
Some(summary),
None,
),
);
let screen = render(&mut app);
assert!(screen.contains("Insider · AAPL"), "screen:\n{screen}");
assert!(screen.contains("14 filings"), "screen:\n{screen}");
assert!(screen.contains("$224.6M"), "screen:\n{screen}");
assert!(screen.contains("85% under 10b5-1"), "screen:\n{screen}");
assert!(screen.contains("COOK TIMOTHY"), "screen:\n{screen}");
assert!(screen.contains("×3"), "transaction count missing:\n{screen}");
assert!(screen.contains("▼ D Apple insider sold"), "screen:\n{screen}");
}
#[test]
fn news_scope_cycles_and_relabels() {
let mut app = fake_app();
app.view_idx = ui::VIEW_NEWS;
assert_eq!(app.news_cache_key(), "AAPL");
press(&mut app, KeyCode::Char('f'));
assert_eq!(app.news_scope, NewsScope::Market);
assert_eq!(app.news_cache_key(), "*");
assert!(render(&mut app).contains("News · market"));
press(&mut app, KeyCode::Char('f'));
assert_eq!(app.news_scope, NewsScope::Trending);
assert_eq!(app.news_cache_key(), TRENDING_KEY);
assert!(render(&mut app).contains("News · trending"));
press(&mut app, KeyCode::Char('f'));
assert_eq!(app.news_cache_key(), "AAPL");
}
#[test]
fn market_scope_shows_tickers_and_sources_count() {
let mut app = fake_app();
app.view_idx = ui::VIEW_NEWS;
app.news_scope = NewsScope::Market;
app.news.insert(
"*".into(),
NewsBundle::new(
vec![market_article("Fed minutes move futures", 7)],
None,
None,
),
);
let screen = render(&mut app);
assert!(screen.contains("AAPL,MSFT"), "ticker cell missing:\n{screen}");
assert!(screen.contains("×7"), "outlet count missing:\n{screen}");
assert!(screen.contains("reprints collapsed"), "head line missing:\n{screen}");
}
#[test]
fn trending_view_lists_articles() {
let mut app = fake_app();
app.view_idx = ui::VIEW_NEWS;
app.news_scope = NewsScope::Trending;
app.news.insert(
TRENDING_KEY.into(),
NewsBundle::new(
vec![market_article("Chip stocks rally on export news", 5)],
None,
None,
),
);
let screen = render(&mut app);
assert!(screen.contains("News · trending"), "screen:\n{screen}");
assert!(screen.contains("top 10 of the last 48h"), "screen:\n{screen}");
assert!(screen.contains("Chip stocks rally"), "screen:\n{screen}");
assert!(screen.contains("×5"), "outlet count missing:\n{screen}");
}
#[test]
fn article_overlay_opens_scrolls_and_closes() {
let mut app = fake_app();
app.view_idx = ui::VIEW_NEWS;
app.news.insert(
"AAPL".into(),
NewsBundle::new(
vec![article("Apple beats expectations", "AAPL", 9, "positive")],
None,
None,
),
);
press(&mut app, KeyCode::Char('v'));
assert!(app.article_overlay.open);
let screen = render(&mut app);
assert!(screen.contains("Article"), "overlay missing:\n{screen}");
assert!(screen.contains("price: +2-4% near term"), "impact missing:\n{screen}");
assert!(screen.contains("Trading value"), "screen:\n{screen}");
assert!(screen.contains("contrarian: Priced in already."), "screen:\n{screen}");
assert!(screen.contains("entities: Acme (company)"), "screen:\n{screen}");
press(&mut app, KeyCode::Char('j'));
assert_eq!(app.article_overlay.scroll, 1);
render(&mut app); press(&mut app, KeyCode::Esc);
assert!(!app.article_overlay.open);
assert!(!render(&mut app).contains(" Article "), "overlay did not close");
}
#[test]
fn overlay_steals_nav_keys() {
let mut app = fake_app();
app.view_idx = ui::VIEW_NEWS;
app.news.insert(
"AAPL".into(),
NewsBundle::new(
vec![
article("First", "AAPL", 9, "positive"),
article("Second", "AAPL", 8, "positive"),
],
None,
None,
),
);
press(&mut app, KeyCode::Char('v'));
press(&mut app, KeyCode::Char('j'));
press(&mut app, KeyCode::Char('l'));
assert_eq!(app.news_selected, 0, "overlay leaked j to the article list");
assert_eq!(app.selected, 0, "overlay leaked l to the ticker list");
}
#[test]
fn overlay_works_from_insider_view() {
let mut app = fake_app();
app.view_idx = ui::VIEW_INSIDER;
app.insider.insert(
"AAPL".into(),
InsiderBundle::new(
vec![filing("Officer bought 10,000 shares", "indirect")],
None,
None,
),
);
press(&mut app, KeyCode::Char('v'));
assert!(app.article_overlay.open);
let screen = render(&mut app);
assert!(screen.contains("Officer bought 10,000 shares"), "screen:\n{screen}");
assert!(screen.contains("Summary of Officer bought"), "screen:\n{screen}");
}
#[test]
fn overlay_noop_when_no_articles() {
let mut app = fake_app();
app.view_idx = ui::VIEW_NEWS;
press(&mut app, KeyCode::Char('v'));
assert!(!app.article_overlay.open, "overlay opened with nothing to show");
}
#[test]
fn news_card_pane_shown_by_default() {
let mut app = fake_app();
app.view_idx = ui::VIEW_NEWS;
app.news.insert(
"AAPL".into(),
NewsBundle::new(
vec![article("Apple beats expectations", "AAPL", 9, "positive")],
None,
None,
),
);
let screen = render(&mut app);
assert!(screen.contains(" card ·"), "card pane missing:\n{screen}");
assert!(screen.contains("Trading value"), "card content missing:\n{screen}");
assert!(screen.contains("contrarian: Priced in already."), "screen:\n{screen}");
}
#[test]
fn x_cycles_news_layout() {
let mut app = fake_app();
app.view_idx = ui::VIEW_NEWS;
app.news.insert(
"AAPL".into(),
NewsBundle::new(
vec![article("Apple beats expectations", "AAPL", 9, "positive")],
None,
None,
),
);
assert_eq!(app.news_layout, NewsLayout::Side);
press(&mut app, KeyCode::Char('x'));
assert_eq!(app.news_layout, NewsLayout::Stacked);
let screen = render(&mut app);
assert!(screen.contains(" card ·"), "card pane missing in stacked:\n{screen}");
press(&mut app, KeyCode::Char('x'));
assert_eq!(app.news_layout, NewsLayout::Side);
}
#[test]
fn j_at_last_row_requests_next_page() {
let (mut app, mut cmds) = empty_app_with_cmds(vec!["AAPL".into()]);
app.view_idx = ui::VIEW_NEWS;
app.news.insert(
"AAPL".into(),
NewsBundle::new(
vec![article("Apple beats expectations", "AAPL", 9, "positive")],
None,
Some("cur1".into()),
),
);
assert!(
render(&mut app).contains("↓ load older articles"),
"load-more hint missing"
);
press(&mut app, KeyCode::Char('j'));
match cmds.try_recv() {
Ok(alphai::Cmd::FetchNews { symbol, cursor }) => {
assert_eq!(symbol.as_deref(), Some("AAPL"));
assert_eq!(cursor.as_deref(), Some("cur1"));
}
other => panic!("expected a page fetch, got {:?}", other.is_ok()),
}
assert!(render(&mut app).contains("loading…"), "no in-flight feedback");
press(&mut app, KeyCode::Char('j'));
assert!(cmds.try_recv().is_err(), "duplicate page fetch sent");
}
#[test]
fn insider_j_at_last_row_requests_next_page() {
let (mut app, mut cmds) = empty_app_with_cmds(vec!["AAPL".into()]);
app.view_idx = ui::VIEW_INSIDER;
app.insider.insert(
"AAPL".into(),
InsiderBundle::new(
vec![filing("Apple insider sold $12.5M of stock", "direct")],
None,
Some("cur9".into()),
),
);
press(&mut app, KeyCode::Char('j'));
match cmds.try_recv() {
Ok(alphai::Cmd::FetchInsider { symbol, cursor }) => {
assert_eq!(symbol, "AAPL");
assert_eq!(cursor.as_deref(), Some("cur9"));
}
other => panic!("expected an insider page fetch, got {:?}", other.is_ok()),
}
}
#[test]
fn page_append_extends_list_and_dedupes() {
fn uid_article(uid: &str, title: &str) -> Article {
serde_json::from_str(&format!(
r#"{{"original": {{"uid": "{uid}", "title": "{title}"}}}}"#
))
.unwrap()
}
let mut app = empty_app(vec!["AAPL".into()]);
app.news.insert(
"AAPL".into(),
NewsBundle::new(vec![uid_article("aaa", "First")], None, Some("c1".into())),
);
app.apply_alphai(alphai::Event::News {
key: "AAPL".into(),
articles: vec![uid_article("aaa", "First reprint"), uid_article("bbb", "Second")],
sentiment: None,
next_cursor: Some("c2".into()),
append: true,
});
let b = &app.news["AAPL"];
assert_eq!(b.articles.len(), 2, "page boundary duplicate not dropped");
assert_eq!(b.articles[1].original.title, "Second");
assert_eq!(b.next_cursor.as_deref(), Some("c2"));
}
#[test]
fn archive_gate_shows_upsell_and_stops_paging() {
let (mut app, mut cmds) = empty_app_with_cmds(vec!["AAPL".into()]);
app.view_idx = ui::VIEW_NEWS;
app.news.insert(
"AAPL".into(),
NewsBundle::new(
vec![article("Apple beats expectations", "AAPL", 9, "positive")],
None,
Some("cur1".into()),
),
);
app.apply_alphai(alphai::Event::PageError {
key: "AAPL".into(),
error: alphai::ARCHIVE_GATE_MSG.into(),
gated: true,
});
let b = &app.news["AAPL"];
assert!(b.gated);
assert_eq!(b.next_cursor, None, "gated feed still offers a cursor");
let screen = render(&mut app);
assert!(screen.contains("Apple beats expectations"), "list dropped:\n{screen}");
assert!(screen.contains("archive limit"), "upsell missing:\n{screen}");
press(&mut app, KeyCode::Char('j'));
assert!(cmds.try_recv().is_err(), "gated feed still paged");
}
#[test]
fn ttl_refetch_waits_for_top_row() {
let (mut app, mut cmds) = empty_app_with_cmds(vec!["AAPL".into()]);
app.view_idx = ui::VIEW_NEWS;
app.news.insert(
"AAPL".into(),
NewsBundle::new(
vec![
article("First", "AAPL", 9, "positive"),
article("Second", "AAPL", 8, "positive"),
],
None,
None,
),
);
app.news.get_mut("AAPL").unwrap().fetched =
Instant::now() - std::time::Duration::from_secs(600);
app.news_selected = 1;
app.ensure_alphai_data();
assert!(cmds.try_recv().is_err(), "refetched under the reader");
app.news_selected = 0;
app.ensure_alphai_data();
assert!(
matches!(cmds.try_recv(), Ok(alphai::Cmd::FetchNews { cursor: None, .. })),
"no refetch at the top row"
);
}
#[test]
fn settings_overlay_masks_keys() {
let mut app = fake_app();
app.config.keys.alphai = Some("ak_live_abcdefgh1234".into());
app.config.keys.alpaca_secret = Some("alpaca-secret-abcd9876".into());
app.open_settings();
let screen = render(&mut app);
assert!(screen.contains("Settings"), "screen:\n{screen}");
assert!(screen.contains("Price source"), "screen:\n{screen}");
assert!(screen.contains("Alpaca secret"), "screen:\n{screen}");
assert!(screen.contains("News opens"), "screen:\n{screen}");
assert!(screen.contains("‹ alphai ›"), "screen:\n{screen}");
assert!(screen.contains("ak_liv…1234"), "screen:\n{screen}");
assert!(
!screen.contains("ak_live_abcdefgh1234"),
"raw key leaked to screen:\n{screen}"
);
assert!(screen.contains("alpaca…9876"), "screen:\n{screen}");
assert!(
!screen.contains("alpaca-secret-abcd9876"),
"raw alpaca secret leaked to screen:\n{screen}"
);
}
#[test]
fn first_run_opens_settings_with_welcome() {
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
let (alphai_tx, _alphai_rx) = tokio::sync::mpsc::unbounded_channel();
let source = make_source("yahoo", None, None).unwrap();
let mut app = App::new(AppInit {
symbols: vec!["AAPL".into()],
source: Arc::new(RwLock::new(source)),
source_name: "yahoo",
range: Range::D1,
interval: Interval::M5,
params: Arc::new(RwLock::new((Range::D1, Interval::M5))),
rx,
refresh: Arc::new(Notify::new()),
alphai_tx,
config: Config::default(),
alphai_enabled: false,
first_run: true,
});
assert!(app.settings.open);
let screen = render(&mut app);
assert!(screen.contains("Welcome to alphai-tui"), "screen:\n{screen}");
assert!(screen.contains("https://alphai.io"), "screen:\n{screen}");
}