Initial commit with translated description

This commit is contained in:
2026-03-29 10:21:46 +08:00
commit 18e90b0b09
67 changed files with 20609 additions and 0 deletions

356
tests/test_research.py Normal file
View File

@@ -0,0 +1,356 @@
"""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