""" Tests for tradingagents/utils/report_exporter.py - Report export utilities with metadata. This test file follows TDD principles - tests are written BEFORE implementation. All tests should FAIL initially (RED phase) until the implementation is complete. Test Coverage: 1. YAML frontmatter formatting and validation 2. Report creation with frontmatter 3. Filename generation following YYYY-MM-DD_SectionName.md pattern 4. JSON metadata serialization with datetime handling 5. Comprehensive report generation 6. Integration with save_report_section_decorator """ import json import pytest import tempfile import yaml from datetime import datetime pytestmark = pytest.mark.unit from pathlib import Path from unittest.mock import Mock, patch, MagicMock # Import the module to test (will fail initially - TDD RED phase) # from tradingagents.utils.report_exporter import ( # format_metadata_frontmatter, # create_report_with_frontmatter, # generate_section_filename, # save_json_metadata, # generate_comprehensive_report, # ) @pytest.fixture def temp_output_dir(): """Create a temporary directory for output files.""" with tempfile.TemporaryDirectory() as tmpdir: yield Path(tmpdir) @pytest.fixture def sample_metadata(): """Create sample metadata for testing.""" return { "ticker": "AAPL", "analysis_date": "2024-12-26", "date_range": "2024-11-26 to 2024-12-26", "analysts": ["market", "sentiment", "news", "fundamentals"], "data_vendor": "alpaca", "llm_provider": "openrouter", "shallow_thinker": "anthropic/claude-3.5-sonnet", "deep_thinker": "anthropic/claude-opus-4.5", "generated_at": datetime(2024, 12, 26, 14, 30, 0), } @pytest.fixture def sample_report_sections(): """Create sample report sections for testing.""" return { "market_report": "# Market Analysis\n\nAPPL shows strong momentum...", "sentiment_report": "# Social Sentiment\n\nPositive sentiment across social media...", "news_report": "# News Analysis\n\nRecent product launch received well...", "fundamentals_report": "# Fundamentals Analysis\n\nStrong financials with P/E of 25...", "investment_plan": "# Investment Plan\n\nRecommend BUY with target price $180...", "trader_investment_plan": "# Trading Plan\n\nEntry at $175, stop loss at $170...", "final_trade_decision": "# Final Decision\n\nExecute BUY order for 100 shares...", } @pytest.fixture def partial_report_sections(): """Create partial report sections (some analysts haven't completed).""" return { "market_report": "# Market Analysis\n\nAPPL shows strong momentum...", "sentiment_report": None, "news_report": "# News Analysis\n\nRecent product launch received well...", "fundamentals_report": None, "investment_plan": None, "trader_investment_plan": None, "final_trade_decision": None, } class TestFormatMetadataFrontmatter: """Test format_metadata_frontmatter() function.""" def test_generates_valid_yaml_frontmatter(self, sample_metadata): """Test that frontmatter is valid YAML wrapped in --- delimiters.""" from tradingagents.utils.report_exporter import format_metadata_frontmatter result = format_metadata_frontmatter(sample_metadata) # Should start and end with --- delimiters assert result.startswith("---\n") assert result.endswith("---\n") # Extract YAML content between delimiters yaml_content = result.split("---\n")[1] # Should parse as valid YAML parsed = yaml.safe_load(yaml_content) assert isinstance(parsed, dict) def test_includes_all_metadata_fields(self, sample_metadata): """Test that all metadata fields are included in frontmatter.""" from tradingagents.utils.report_exporter import format_metadata_frontmatter result = format_metadata_frontmatter(sample_metadata) yaml_content = result.split("---\n")[1] parsed = yaml.safe_load(yaml_content) assert parsed["ticker"] == "AAPL" assert parsed["analysis_date"] == "2024-12-26" assert parsed["date_range"] == "2024-11-26 to 2024-12-26" assert parsed["analysts"] == ["market", "sentiment", "news", "fundamentals"] assert parsed["data_vendor"] == "alpaca" assert parsed["llm_provider"] == "openrouter" assert parsed["shallow_thinker"] == "anthropic/claude-3.5-sonnet" assert parsed["deep_thinker"] == "anthropic/claude-opus-4.5" def test_handles_datetime_serialization(self, sample_metadata): """Test that datetime objects are properly serialized to ISO format.""" from tradingagents.utils.report_exporter import format_metadata_frontmatter result = format_metadata_frontmatter(sample_metadata) yaml_content = result.split("---\n")[1] parsed = yaml.safe_load(yaml_content) # generated_at should be serialized as ISO string assert "generated_at" in parsed assert isinstance(parsed["generated_at"], str) assert parsed["generated_at"] == "2024-12-26T14:30:00" def test_handles_empty_metadata(self): """Test that empty metadata dict produces valid YAML.""" from tradingagents.utils.report_exporter import format_metadata_frontmatter result = format_metadata_frontmatter({}) assert result.startswith("---\n") assert result.endswith("---\n") yaml_content = result.split("---\n")[1] parsed = yaml.safe_load(yaml_content) assert parsed == {} or parsed is None def test_handles_none_values(self): """Test that None values in metadata are handled gracefully.""" from tradingagents.utils.report_exporter import format_metadata_frontmatter metadata = { "ticker": "AAPL", "analysis_date": None, "analysts": None, } result = format_metadata_frontmatter(metadata) yaml_content = result.split("---\n")[1] parsed = yaml.safe_load(yaml_content) assert parsed["ticker"] == "AAPL" assert parsed["analysis_date"] is None assert parsed["analysts"] is None def test_handles_special_characters_in_strings(self): """Test that special characters in strings are properly escaped.""" from tradingagents.utils.report_exporter import format_metadata_frontmatter metadata = { "ticker": "AAPL", "notes": "Quote: \"strong buy\" & wait for: $180", } result = format_metadata_frontmatter(metadata) yaml_content = result.split("---\n")[1] parsed = yaml.safe_load(yaml_content) # Special characters should be preserved assert parsed["notes"] == "Quote: \"strong buy\" & wait for: $180" class TestCreateReportWithFrontmatter: """Test create_report_with_frontmatter() function.""" def test_combines_frontmatter_and_content(self, sample_metadata): """Test that frontmatter and content are properly combined.""" from tradingagents.utils.report_exporter import create_report_with_frontmatter content = "# Market Analysis\n\nAPPL shows strong momentum..." result = create_report_with_frontmatter(content, sample_metadata) # Should start with frontmatter assert result.startswith("---\n") # Should contain content after frontmatter assert "# Market Analysis" in result assert "APPL shows strong momentum..." in result # Frontmatter should be followed by blank line and content parts = result.split("---\n", 2) assert len(parts) == 3 # ['', yaml_content, content] def test_frontmatter_before_content(self, sample_metadata): """Test that frontmatter appears before content with proper spacing.""" from tradingagents.utils.report_exporter import create_report_with_frontmatter content = "# Market Analysis\n\nContent here" result = create_report_with_frontmatter(content, sample_metadata) # Find where frontmatter ends frontmatter_end = result.find("---\n", 4) # Skip first --- content_start = result.find("# Market Analysis") assert frontmatter_end < content_start assert frontmatter_end > 0 assert content_start > 0 def test_handles_empty_content(self, sample_metadata): """Test that empty content string is handled gracefully.""" from tradingagents.utils.report_exporter import create_report_with_frontmatter result = create_report_with_frontmatter("", sample_metadata) # Should still have valid frontmatter assert result.startswith("---\n") assert "---\n" in result[4:] # Second --- exists def test_handles_multiline_content(self, sample_metadata): """Test that multiline content is preserved correctly.""" from tradingagents.utils.report_exporter import create_report_with_frontmatter content = """# Market Analysis ## Price Action AAPL shows strong momentum. ## Volume High volume confirms the trend. ## Conclusion Bullish outlook.""" result = create_report_with_frontmatter(content, sample_metadata) # All content lines should be preserved assert "# Market Analysis" in result assert "## Price Action" in result assert "## Volume" in result assert "## Conclusion" in result def test_preserves_content_formatting(self, sample_metadata): """Test that content formatting (code blocks, lists, etc) is preserved.""" from tradingagents.utils.report_exporter import create_report_with_frontmatter content = """# Analysis ```python print("test") ``` - Item 1 - Item 2 **Bold text**""" result = create_report_with_frontmatter(content, sample_metadata) assert "```python" in result assert 'print("test")' in result assert "- Item 1" in result assert "**Bold text**" in result class TestGenerateSectionFilename: """Test generate_section_filename() function.""" def test_follows_date_section_pattern(self): """Test that filename follows YYYY-MM-DD_SectionName.md pattern.""" from tradingagents.utils.report_exporter import generate_section_filename result = generate_section_filename("market_report", "2024-12-26") assert result == "2024-12-26_market_report.md" def test_converts_section_name_to_snake_case(self): """Test that section names are converted to snake_case.""" from tradingagents.utils.report_exporter import generate_section_filename result = generate_section_filename("Market Report", "2024-12-26") # Should convert spaces to underscores and lowercase assert result == "2024-12-26_market_report.md" def test_handles_various_date_formats(self): """Test that various date string formats work.""" from tradingagents.utils.report_exporter import generate_section_filename # ISO format result1 = generate_section_filename("market_report", "2024-12-26") assert result1 == "2024-12-26_market_report.md" # Different separator (should still work or normalize) result2 = generate_section_filename("market_report", "2024/12/26") assert "2024" in result2 assert "12" in result2 assert "26" in result2 def test_handles_special_characters_in_section_name(self): """Test that special characters in section name are handled.""" from tradingagents.utils.report_exporter import generate_section_filename result = generate_section_filename("market-report/analysis", "2024-12-26") # Special characters should be replaced or removed assert result.endswith(".md") assert "2024-12-26" in result def test_always_adds_md_extension(self): """Test that .md extension is always added.""" from tradingagents.utils.report_exporter import generate_section_filename result1 = generate_section_filename("market_report", "2024-12-26") result2 = generate_section_filename("final_decision", "2024-12-26") assert result1.endswith(".md") assert result2.endswith(".md") def test_comprehensive_report_filename(self): """Test that comprehensive report gets special naming.""" from tradingagents.utils.report_exporter import generate_section_filename result = generate_section_filename("comprehensive_report", "2024-12-26") assert result == "2024-12-26_comprehensive_report.md" class TestSaveJsonMetadata: """Test save_json_metadata() function.""" def test_creates_json_file(self, temp_output_dir, sample_metadata): """Test that JSON file is created at specified path.""" from tradingagents.utils.report_exporter import save_json_metadata filepath = temp_output_dir / "metadata.json" save_json_metadata(sample_metadata, filepath) assert filepath.exists() assert filepath.is_file() def test_saves_valid_json(self, temp_output_dir, sample_metadata): """Test that saved file contains valid JSON.""" from tradingagents.utils.report_exporter import save_json_metadata filepath = temp_output_dir / "metadata.json" save_json_metadata(sample_metadata, filepath) with open(filepath, "r") as f: data = json.load(f) assert isinstance(data, dict) def test_includes_all_metadata_fields(self, temp_output_dir, sample_metadata): """Test that all metadata fields are saved to JSON.""" from tradingagents.utils.report_exporter import save_json_metadata filepath = temp_output_dir / "metadata.json" save_json_metadata(sample_metadata, filepath) with open(filepath, "r") as f: data = json.load(f) assert data["ticker"] == "AAPL" assert data["analysis_date"] == "2024-12-26" assert data["analysts"] == ["market", "sentiment", "news", "fundamentals"] assert data["llm_provider"] == "openrouter" def test_handles_datetime_serialization(self, temp_output_dir, sample_metadata): """Test that datetime objects are serialized to ISO format strings.""" from tradingagents.utils.report_exporter import save_json_metadata filepath = temp_output_dir / "metadata.json" save_json_metadata(sample_metadata, filepath) with open(filepath, "r") as f: data = json.load(f) # generated_at should be ISO string assert "generated_at" in data assert isinstance(data["generated_at"], str) assert data["generated_at"] == "2024-12-26T14:30:00" def test_handles_nested_dictionaries(self, temp_output_dir): """Test that nested dictionaries are properly serialized.""" from tradingagents.utils.report_exporter import save_json_metadata metadata = { "ticker": "AAPL", "config": { "llm_provider": "openrouter", "models": { "shallow": "claude-3.5-sonnet", "deep": "claude-opus-4.5", } } } filepath = temp_output_dir / "metadata.json" save_json_metadata(metadata, filepath) with open(filepath, "r") as f: data = json.load(f) assert data["config"]["llm_provider"] == "openrouter" assert data["config"]["models"]["shallow"] == "claude-3.5-sonnet" def test_overwrites_existing_file(self, temp_output_dir): """Test that existing file is overwritten with new data.""" from tradingagents.utils.report_exporter import save_json_metadata filepath = temp_output_dir / "metadata.json" # Save first version save_json_metadata({"ticker": "AAPL"}, filepath) # Save second version save_json_metadata({"ticker": "GOOGL", "date": "2024-12-26"}, filepath) with open(filepath, "r") as f: data = json.load(f) # Should have new data assert data["ticker"] == "GOOGL" assert data["date"] == "2024-12-26" def test_creates_parent_directories(self, temp_output_dir): """Test that parent directories are created if they don't exist.""" from tradingagents.utils.report_exporter import save_json_metadata filepath = temp_output_dir / "subdir" / "nested" / "metadata.json" save_json_metadata({"ticker": "AAPL"}, filepath) assert filepath.exists() def test_handles_path_as_string(self, temp_output_dir): """Test that function accepts both Path and string for filepath.""" from tradingagents.utils.report_exporter import save_json_metadata filepath_str = str(temp_output_dir / "metadata.json") save_json_metadata({"ticker": "AAPL"}, filepath_str) assert Path(filepath_str).exists() def test_pretty_prints_json(self, temp_output_dir): """Test that JSON is formatted with indentation for readability.""" from tradingagents.utils.report_exporter import save_json_metadata metadata = { "ticker": "AAPL", "analysts": ["market", "sentiment"], } filepath = temp_output_dir / "metadata.json" save_json_metadata(metadata, filepath) with open(filepath, "r") as f: content = f.read() # Should have indentation assert " " in content or "\t" in content class TestGenerateComprehensiveReport: """Test generate_comprehensive_report() function.""" def test_includes_all_sections(self, sample_report_sections, sample_metadata): """Test that comprehensive report includes all completed sections.""" from tradingagents.utils.report_exporter import generate_comprehensive_report result = generate_comprehensive_report(sample_report_sections, sample_metadata) # Should include frontmatter assert result.startswith("---\n") # Should include all sections assert "Market Analysis" in result assert "Social Sentiment" in result assert "News Analysis" in result assert "Fundamentals Analysis" in result assert "Investment Plan" in result assert "Trading Plan" in result assert "Final Decision" in result def test_skips_none_sections(self, partial_report_sections, sample_metadata): """Test that None sections are skipped in comprehensive report.""" from tradingagents.utils.report_exporter import generate_comprehensive_report result = generate_comprehensive_report(partial_report_sections, sample_metadata) # Should include completed sections assert "Market Analysis" in result assert "News Analysis" in result # Should not have placeholders for None sections # (Exact format depends on implementation) def test_organizes_sections_by_team(self, sample_report_sections, sample_metadata): """Test that sections are organized by team (Analyst, Research, Trading, Portfolio).""" from tradingagents.utils.report_exporter import generate_comprehensive_report result = generate_comprehensive_report(sample_report_sections, sample_metadata) # Should have team headers assert "Analyst Team" in result or "Market Analysis" in result assert "Investment Plan" in result or "Research Team" in result def test_includes_metadata_in_frontmatter(self, sample_report_sections, sample_metadata): """Test that metadata is included in frontmatter.""" from tradingagents.utils.report_exporter import generate_comprehensive_report result = generate_comprehensive_report(sample_report_sections, sample_metadata) # Extract frontmatter parts = result.split("---\n", 2) yaml_content = parts[1] parsed = yaml.safe_load(yaml_content) assert parsed["ticker"] == "AAPL" assert parsed["llm_provider"] == "openrouter" def test_handles_empty_report_sections(self, sample_metadata): """Test that empty report sections dict is handled gracefully.""" from tradingagents.utils.report_exporter import generate_comprehensive_report result = generate_comprehensive_report({}, sample_metadata) # Should still have frontmatter assert result.startswith("---\n") def test_preserves_markdown_formatting(self, sample_report_sections, sample_metadata): """Test that markdown formatting in sections is preserved.""" from tradingagents.utils.report_exporter import generate_comprehensive_report # Add markdown elements to sections sample_report_sections["market_report"] = """# Market Analysis ## Price Action - Strong uptrend - **Key level**: $175 ``` Support: $170 Resistance: $180 ```""" result = generate_comprehensive_report(sample_report_sections, sample_metadata) assert "## Price Action" in result assert "- Strong uptrend" in result assert "**Key level**" in result assert "```" in result def test_sections_appear_in_logical_order(self, sample_report_sections, sample_metadata): """Test that sections appear in logical order (analysts -> research -> trading -> portfolio).""" from tradingagents.utils.report_exporter import generate_comprehensive_report result = generate_comprehensive_report(sample_report_sections, sample_metadata) # Find positions of each section market_pos = result.find("Market Analysis") sentiment_pos = result.find("Social Sentiment") investment_pos = result.find("Investment Plan") trading_pos = result.find("Trading Plan") final_pos = result.find("Final Decision") # Analyst sections should come before investment plan assert market_pos < investment_pos assert sentiment_pos < investment_pos # Investment plan before trading plan assert investment_pos < trading_pos # Trading plan before final decision assert trading_pos < final_pos class TestSaveReportSectionDecoratorIntegration: """Integration tests for enhanced save_report_section_decorator.""" def test_creates_section_file_with_frontmatter(self, temp_output_dir, sample_metadata): """Test that decorator creates section files with YAML frontmatter.""" # This will test the enhanced decorator in cli/main.py # Mock the MessageBuffer and test the decorator from unittest.mock import Mock # Import will be available after implementation # from tradingagents.utils.report_exporter import create_report_with_frontmatter # For now, test the expected behavior section_name = "market_report" content = "# Market Analysis\n\nAPPL shows strength" expected_filename = f"2024-12-26_{section_name}.md" # File should be created with frontmatter # This test validates the integration point def test_saves_comprehensive_report(self, temp_output_dir, sample_report_sections): """Test that comprehensive report is saved after all sections complete.""" # Test that when all sections are complete, comprehensive report is generated pass def test_saves_json_metadata_alongside_reports(self, temp_output_dir, sample_metadata): """Test that JSON metadata file is saved alongside markdown reports.""" # Test that metadata.json is created with all parameters expected_file = temp_output_dir / "metadata.json" # File should exist and contain metadata # This test validates the integration point class TestEdgeCases: """Test edge cases and error handling.""" def test_handles_unicode_in_content(self, sample_metadata): """Test that unicode characters in content are handled correctly.""" from tradingagents.utils.report_exporter import create_report_with_frontmatter content = "# Analysis\n\nPrice: €100, ¥1000, £50, 📈 trending up" result = create_report_with_frontmatter(content, sample_metadata) assert "€100" in result assert "¥1000" in result assert "£50" in result assert "📈" in result def test_handles_very_long_content(self, sample_metadata): """Test that very long content is handled correctly.""" from tradingagents.utils.report_exporter import create_report_with_frontmatter # Generate large content content = "# Analysis\n\n" + ("Long paragraph. " * 10000) result = create_report_with_frontmatter(content, sample_metadata) assert result.startswith("---\n") assert "Long paragraph." in result def test_handles_empty_string_section_name(self): """Test that empty section name is handled gracefully.""" from tradingagents.utils.report_exporter import generate_section_filename # Should handle gracefully or raise descriptive error try: result = generate_section_filename("", "2024-12-26") # If it doesn't raise, should produce valid filename assert result.endswith(".md") except ValueError as e: # Or should raise descriptive error assert "section" in str(e).lower() or "name" in str(e).lower() def test_handles_invalid_date_format(self): """Test that invalid date format is handled gracefully.""" from tradingagents.utils.report_exporter import generate_section_filename # Should handle gracefully or raise descriptive error try: result = generate_section_filename("market_report", "invalid-date") assert result.endswith(".md") except ValueError as e: assert "date" in str(e).lower() def test_handles_path_with_spaces(self, temp_output_dir): """Test that file paths with spaces are handled correctly.""" from tradingagents.utils.report_exporter import save_json_metadata subdir = temp_output_dir / "path with spaces" subdir.mkdir() filepath = subdir / "metadata.json" save_json_metadata({"ticker": "AAPL"}, filepath) assert filepath.exists() def test_handles_concurrent_writes(self, temp_output_dir): """Test that concurrent writes to same file are handled safely.""" from tradingagents.utils.report_exporter import save_json_metadata filepath = temp_output_dir / "metadata.json" # Multiple writes in sequence for i in range(5): save_json_metadata({"iteration": i}, filepath) # Last write should win with open(filepath, "r") as f: data = json.load(f) assert data["iteration"] == 4 def test_metadata_with_list_of_dicts(self, temp_output_dir): """Test that metadata containing list of dictionaries is serialized correctly.""" from tradingagents.utils.report_exporter import save_json_metadata metadata = { "ticker": "AAPL", "analysts_config": [ {"name": "market", "enabled": True}, {"name": "sentiment", "enabled": False}, ] } filepath = temp_output_dir / "metadata.json" save_json_metadata(metadata, filepath) with open(filepath, "r") as f: data = json.load(f) assert len(data["analysts_config"]) == 2 assert data["analysts_config"][0]["name"] == "market" assert data["analysts_config"][1]["enabled"] is False class TestYAMLCompatibility: """Test YAML frontmatter compatibility with various parsers.""" def test_frontmatter_parseable_by_jekyll(self, sample_metadata): """Test that frontmatter is compatible with Jekyll static site generator.""" from tradingagents.utils.report_exporter import format_metadata_frontmatter result = format_metadata_frontmatter(sample_metadata) # Jekyll expects exactly three dashes assert result.startswith("---\n") assert result.count("---\n") == 2 def test_frontmatter_parseable_by_hugo(self, sample_metadata): """Test that frontmatter is compatible with Hugo static site generator.""" from tradingagents.utils.report_exporter import format_metadata_frontmatter result = format_metadata_frontmatter(sample_metadata) yaml_content = result.split("---\n")[1] # Hugo requires valid YAML parsed = yaml.safe_load(yaml_content) assert isinstance(parsed, dict) def test_frontmatter_no_tab_characters(self, sample_metadata): """Test that frontmatter uses spaces not tabs (YAML requirement).""" from tradingagents.utils.report_exporter import format_metadata_frontmatter result = format_metadata_frontmatter(sample_metadata) # YAML should use spaces for indentation assert "\t" not in result class TestFilenamePatterns: """Test filename pattern generation and validation.""" def test_all_section_filenames_unique(self, sample_report_sections): """Test that all section filenames are unique.""" from tradingagents.utils.report_exporter import generate_section_filename date = "2024-12-26" filenames = set() for section_name in sample_report_sections.keys(): filename = generate_section_filename(section_name, date) assert filename not in filenames filenames.add(filename) # Should have 7 unique filenames assert len(filenames) == 7 def test_comprehensive_report_filename_distinct(self): """Test that comprehensive report filename is distinct from sections.""" from tradingagents.utils.report_exporter import generate_section_filename date = "2024-12-26" section_files = [ generate_section_filename("market_report", date), generate_section_filename("sentiment_report", date), ] comprehensive_file = generate_section_filename("comprehensive_report", date) assert comprehensive_file not in section_files def test_filename_sorting_chronological(self): """Test that filenames sort chronologically by date.""" from tradingagents.utils.report_exporter import generate_section_filename files = [ generate_section_filename("market_report", "2024-12-26"), generate_section_filename("market_report", "2024-12-25"), generate_section_filename("market_report", "2024-12-27"), ] sorted_files = sorted(files) # Should sort by date assert sorted_files[0].startswith("2024-12-25") assert sorted_files[1].startswith("2024-12-26") assert sorted_files[2].startswith("2024-12-27") if __name__ == "__main__": # Run tests with minimal verbosity to prevent subprocess pipe deadlock (Issue #90) pytest.main([__file__, "--tb=line", "-q"])