"""Unit tests for AI summarizer. Tests the deterministic fallback summary generation and chunk_data edge cases from services.reporting.summarizer. Requirements validated: 2.2, 2.6 """ from __future__ import annotations from services.reporting.summarizer import build_deterministic_summary, chunk_data # ═══════════════════════════════════════════════════════════════════════ # 1. chunk_data — edge cases # Requirements validated: 2.2 # ═══════════════════════════════════════════════════════════════════════ class TestChunkDataEdgeCases: """Tests for chunk_data edge cases.""" def test_empty_input_returns_single_empty_chunk(self) -> None: """Empty input produces exactly one empty-string chunk.""" result = chunk_data("", max_chars=100) assert result == [""] def test_single_character_returns_one_chunk(self) -> None: """A single character fits in one chunk.""" result = chunk_data("x", max_chars=100) assert result == ["x"] def test_exactly_at_limit_returns_one_chunk(self) -> None: """A string exactly at the limit fits in one chunk.""" data = "a" * 50 result = chunk_data(data, max_chars=50) assert result == [data] def test_one_char_over_limit_with_newline_returns_two_chunks(self) -> None: """A string one char over the limit (with a newline) splits into two chunks.""" # 25 chars + newline + 25 chars = 51 chars total, limit=50 data = "a" * 25 + "\n" + "b" * 25 result = chunk_data(data, max_chars=50) assert len(result) == 2 # First chunk: "aaa...a\n" (26 chars), second chunk: "bbb...b" (25 chars) assert result[0] == "a" * 25 + "\n" assert result[1] == "b" * 25 # Round-trip: concatenation reconstructs original assert "".join(result) == data def test_no_newlines_in_long_string_returns_one_chunk(self) -> None: """A long string with no newlines is never broken mid-line — stays as one chunk.""" data = "x" * 200 result = chunk_data(data, max_chars=50) # No newlines means no split points, so the entire string is one chunk assert result == [data] def test_multiple_newlines_proper_splitting(self) -> None: """Multiple newlines produce proper splitting at line boundaries.""" # 3 lines of 30 chars each (including newlines): "aaa...\n" "bbb...\n" "ccc..." line_a = "a" * 29 + "\n" # 30 chars line_b = "b" * 29 + "\n" # 30 chars line_c = "c" * 29 # 29 chars data = line_a + line_b + line_c # 89 chars total result = chunk_data(data, max_chars=60) # First chunk: line_a + line_b = 60 chars (exactly at limit) # Second chunk: line_c = 29 chars assert len(result) == 2 assert result[0] == line_a + line_b assert result[1] == line_c assert "".join(result) == data def test_round_trip_concatenation(self) -> None: """Concatenating all chunks reconstructs the original string.""" data = "line1\nline2\nline3\nline4\n" result = chunk_data(data, max_chars=12) assert "".join(result) == data def test_max_chars_one(self) -> None: """With max_chars=1, each line-segment becomes its own chunk.""" data = "a\nb" result = chunk_data(data, max_chars=1) # "a\n" is 2 chars but no split point within it, so it's one chunk # "b" is 1 char, another chunk assert "".join(result) == data assert len(result) >= 2 # ═══════════════════════════════════════════════════════════════════════ # 2. build_deterministic_summary — section type templates # Requirements validated: 2.6 # ═══════════════════════════════════════════════════════════════════════ class TestBuildDeterministicSummary: """Tests for build_deterministic_summary with each section type.""" def test_pnl_section(self) -> None: """P&L section uses the pnl template with realized_pnl, unrealized_pnl, etc.""" data = { "realized_pnl": 125.50, "unrealized_pnl": -30.20, "daily_return": 1.2, "win_rate": 72.7, } result = build_deterministic_summary("pnl", data) assert "125.5" in result assert "-30.2" in result assert "1.2" in result assert "72.7" in result assert result.startswith("P&L Summary:") def test_recommendation_accuracy_section(self) -> None: """Recommendation accuracy section uses the template with total_evaluated, act_count, etc.""" data = { "total_evaluated": 15, "act_count": 8, "acted_win_rate": 75.0, "skip_count": 7, "avg_confidence_acted": 0.72, "avg_confidence_skipped": 0.48, } result = build_deterministic_summary("recommendation_accuracy", data) assert "15" in result assert "8" in result assert "75.0" in result or "75" in result assert "7" in result assert result.startswith("Recommendation Accuracy:") def test_position_performance_section(self) -> None: """Position performance section uses the template with position count.""" data = { "positions": [ {"ticker": "AAPL", "pnl": 68.0}, {"ticker": "MSFT", "pnl": -12.0}, {"ticker": "GOOG", "pnl": 25.0}, ], } result = build_deterministic_summary("position_performance", data) assert "3" in result assert "Position Performance:" in result def test_position_performance_empty_positions(self) -> None: """Position performance with no positions reports 0.""" data = {"positions": []} result = build_deterministic_summary("position_performance", data) assert "0" in result def test_risk_metrics_section(self) -> None: """Risk metrics section uses the template with risk_tier, portfolio_heat, etc.""" data = { "current_risk_tier": "moderate", "portfolio_heat": 0.12, "max_drawdown": 0.08, "current_drawdown_pct": 3.0, "reserve_pool_balance": 450.00, "circuit_breaker_event_count": 1, } result = build_deterministic_summary("risk_metrics", data) assert "moderate" in result assert "0.12" in result assert "0.08" in result assert "3.0" in result or "3" in result assert "450" in result assert "1" in result assert result.startswith("Risk Metrics:") def test_model_quality_section(self) -> None: """Model quality section uses the template with window count.""" data = { "windows": [ {"lookback": "7d"}, {"lookback": "30d"}, {"lookback": "90d"}, ], } result = build_deterministic_summary("model_quality", data) assert "3" in result assert "Model Quality:" in result def test_model_quality_no_windows(self) -> None: """Model quality with no windows reports 0.""" data = {"windows": []} result = build_deterministic_summary("model_quality", data) assert "0" in result def test_unknown_section_generic_fallback(self) -> None: """An unknown section name produces a generic fallback summary.""" data = {"metric_a": 1, "metric_b": 2, "metric_c": 3} result = build_deterministic_summary("unknown_section", data) assert "unknown_section" in result assert "3 metrics reported" in result def test_unknown_section_empty_data(self) -> None: """An unknown section with empty data reports 0 metrics.""" result = build_deterministic_summary("totally_new", {}) assert "totally_new" in result assert "0 metrics reported" in result def test_pnl_missing_key_falls_back(self) -> None: """P&L template with missing keys falls back to error message.""" data = {"realized_pnl": 100.0} # missing other keys result = build_deterministic_summary("pnl", data) # Should fall back to the error message since template.format() will raise KeyError assert "template formatting failed" in result