"""Tests for research.py - deep research module.""" import json import sys from pathlib import Path from unittest.mock import Mock, patch, MagicMock import subprocess import pytest # Add scripts to path sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) from research import ( format_market_data, format_headlines, format_portfolio_news, gemini_available, research_with_gemini, format_raw_data_report, generate_research_content, ) @pytest.fixture def sample_market_data(): """Sample market data for testing.""" return { "markets": { "us": { "name": "US Markets", "indices": { "SPY": { "name": "S&P 500", "data": {"price": 5200.50, "change_percent": 1.25} }, "QQQ": { "name": "Nasdaq 100", "data": {"price": 18500.00, "change_percent": -0.50} } } }, "europe": { "name": "European Markets", "indices": { "DAX": { "name": "DAX", "data": {"price": 18200.00, "change_percent": 0.75} } } } }, "headlines": [ {"source": "Reuters", "title": "Fed holds rates steady", "link": "https://example.com/1"}, {"source": "Bloomberg", "title": "Tech stocks rally", "link": "https://example.com/2"}, ] } @pytest.fixture def sample_portfolio_data(): """Sample portfolio data for testing.""" return { "stocks": { "AAPL": { "quote": {"price": 185.50, "change_percent": 2.3}, "articles": [ {"title": "Apple reports strong earnings", "link": "https://example.com/aapl1"}, {"title": "iPhone sales beat expectations", "link": "https://example.com/aapl2"}, ] }, "MSFT": { "quote": {"price": 420.00, "change_percent": -1.1}, "articles": [ {"title": "Microsoft cloud growth slows", "link": "https://example.com/msft1"}, ] } } } class TestFormatMarketData: """Tests for format_market_data().""" def test_formats_market_indices(self, sample_market_data): """Format market indices with prices and changes.""" result = format_market_data(sample_market_data) assert "## Market Data" in result assert "### US Markets" in result assert "S&P 500" in result assert "5200.5" in result # Price (may not have trailing zero) assert "+1.25%" in result assert "📈" in result # Positive change def test_shows_negative_change_emoji(self, sample_market_data): """Negative changes show down emoji.""" result = format_market_data(sample_market_data) assert "Nasdaq 100" in result assert "-0.50%" in result assert "📉" in result # Negative change def test_handles_empty_data(self): """Handle empty market data.""" result = format_market_data({}) assert "## Market Data" in result assert "### " not in result # No region headers def test_handles_missing_index_data(self): """Handle indices without data.""" data = { "markets": { "us": { "name": "US Markets", "indices": { "SPY": {"name": "S&P 500"} # No 'data' key } } } } result = format_market_data(data) assert "## Market Data" in result # Should not crash, just skip the index class TestFormatHeadlines: """Tests for format_headlines().""" def test_formats_headlines_with_links(self): """Format headlines with sources and links.""" headlines = [ {"source": "Reuters", "title": "Breaking news", "link": "https://example.com/1"}, {"source": "Bloomberg", "title": "Market update", "link": "https://example.com/2"}, ] result = format_headlines(headlines) assert "## Current Headlines" in result assert "[Reuters] Breaking news" in result assert "URL: https://example.com/1" in result assert "[Bloomberg] Market update" in result def test_handles_missing_source(self): """Handle headlines with missing source.""" headlines = [{"title": "No source headline", "link": "https://example.com"}] result = format_headlines(headlines) assert "[Unknown] No source headline" in result def test_handles_missing_link(self): """Handle headlines without links.""" headlines = [{"source": "Reuters", "title": "No link"}] result = format_headlines(headlines) assert "[Reuters] No link" in result assert "URL:" not in result def test_limits_to_20_headlines(self): """Limit output to 20 headlines max.""" headlines = [{"source": f"Source{i}", "title": f"Title {i}"} for i in range(30)] result = format_headlines(headlines) assert "[Source19]" in result assert "[Source20]" not in result def test_handles_empty_list(self): """Handle empty headlines list.""" result = format_headlines([]) assert "## Current Headlines" in result class TestFormatPortfolioNews: """Tests for format_portfolio_news().""" def test_formats_portfolio_stocks(self, sample_portfolio_data): """Format portfolio stocks with quotes and news.""" result = format_portfolio_news(sample_portfolio_data) assert "## Portfolio Analysis" in result assert "### AAPL" in result assert "$185.5" in result # Price (may not have trailing zero) assert "+2.30%" in result assert "Apple reports strong earnings" in result def test_shows_negative_changes(self, sample_portfolio_data): """Show negative change percentages.""" result = format_portfolio_news(sample_portfolio_data) assert "### MSFT" in result assert "-1.10%" in result def test_limits_articles_to_5(self): """Limit articles per stock to 5.""" data = { "stocks": { "AAPL": { "quote": {"price": 185.0, "change_percent": 1.0}, "articles": [{"title": f"Article {i}"} for i in range(10)] } } } result = format_portfolio_news(data) assert "Article 4" in result assert "Article 5" not in result def test_handles_empty_stocks(self): """Handle empty stocks dict.""" result = format_portfolio_news({"stocks": {}}) assert "## Portfolio Analysis" in result class TestGeminiAvailable: """Tests for gemini_available().""" def test_returns_true_when_gemini_found(self): """Return True when gemini CLI is found.""" with patch("shutil.which", return_value="/usr/local/bin/gemini"): assert gemini_available() is True def test_returns_false_when_gemini_not_found(self): """Return False when gemini CLI is not found.""" with patch("shutil.which", return_value=None): assert gemini_available() is False class TestResearchWithGemini: """Tests for research_with_gemini().""" def test_successful_research(self): """Execute gemini research successfully.""" mock_result = Mock() mock_result.returncode = 0 mock_result.stdout = "# Research Report\n\nMarket analysis..." with patch("subprocess.run", return_value=mock_result) as mock_run: result = research_with_gemini("Market data content") assert result == "# Research Report\n\nMarket analysis..." mock_run.assert_called_once() def test_research_with_focus_areas(self): """Include focus areas in prompt.""" mock_result = Mock() mock_result.returncode = 0 mock_result.stdout = "Focused analysis" with patch("subprocess.run", return_value=mock_result) as mock_run: result = research_with_gemini("content", focus_areas=["earnings", "macro"]) assert result == "Focused analysis" # Verify focus areas were in the prompt call_args = mock_run.call_args[0][0] prompt = call_args[1] assert "earnings" in prompt assert "macro" in prompt def test_handles_gemini_error(self): """Handle gemini error gracefully.""" mock_result = Mock() mock_result.returncode = 1 mock_result.stderr = "API error" with patch("subprocess.run", return_value=mock_result): result = research_with_gemini("content") assert "⚠️ Gemini research error" in result assert "API error" in result def test_handles_timeout(self): """Handle subprocess timeout.""" with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="gemini", timeout=120)): result = research_with_gemini("content") assert "⚠️ Gemini research timeout" in result def test_handles_missing_gemini(self): """Handle missing gemini CLI.""" with patch("subprocess.run", side_effect=FileNotFoundError()): result = research_with_gemini("content") assert "⚠️ Gemini CLI not found" in result class TestFormatRawDataReport: """Tests for format_raw_data_report().""" def test_combines_market_and_portfolio(self, sample_market_data, sample_portfolio_data): """Combine market data, headlines, and portfolio.""" result = format_raw_data_report(sample_market_data, sample_portfolio_data) assert "## Market Data" in result assert "## Current Headlines" in result assert "## Portfolio Analysis" in result def test_handles_no_headlines(self, sample_portfolio_data): """Handle market data without headlines.""" market_data = {"markets": {"us": {"name": "US", "indices": {}}}} result = format_raw_data_report(market_data, sample_portfolio_data) assert "## Market Data" in result assert "## Current Headlines" not in result def test_handles_portfolio_error(self, sample_market_data): """Skip portfolio with error.""" portfolio_data = {"error": "No portfolio configured"} result = format_raw_data_report(sample_market_data, portfolio_data) assert "## Portfolio Analysis" not in result def test_handles_empty_data(self): """Handle empty market and portfolio data.""" result = format_raw_data_report({}, {}) assert result == "" class TestGenerateResearchContent: """Tests for generate_research_content().""" def test_uses_gemini_when_available(self, sample_market_data, sample_portfolio_data): """Use Gemini research when available.""" with patch("research.gemini_available", return_value=True): with patch("research.research_with_gemini", return_value="Gemini report") as mock_gemini: result = generate_research_content(sample_market_data, sample_portfolio_data) assert result["report"] == "Gemini report" assert result["source"] == "gemini" mock_gemini.assert_called_once() def test_falls_back_to_raw_report(self, sample_market_data, sample_portfolio_data): """Fall back to raw report when Gemini unavailable.""" with patch("research.gemini_available", return_value=False): result = generate_research_content(sample_market_data, sample_portfolio_data) assert "## Market Data" in result["report"] assert result["source"] == "raw" def test_handles_empty_report(self): """Return empty when no data available.""" result = generate_research_content({}, {}) assert result["report"] == "" assert result["source"] == "none" def test_passes_focus_areas_to_gemini(self, sample_market_data, sample_portfolio_data): """Pass focus areas to Gemini research.""" focus = ["earnings", "tech"] with patch("research.gemini_available", return_value=True): with patch("research.research_with_gemini", return_value="Report") as mock_gemini: generate_research_content(sample_market_data, sample_portfolio_data, focus_areas=focus) mock_gemini.assert_called_once() # Check that focus_areas was passed (positional or keyword) call_args = mock_gemini.call_args # Focus areas passed as second positional arg assert call_args[0][1] == focus or call_args.kwargs.get("focus_areas") == focus