"""Tests for weekly_narrative.py script.""" from __future__ import annotations import json import sys from datetime import UTC, datetime, timedelta from pathlib import Path from unittest.mock import MagicMock, patch # Add timmy_automations to path for imports sys.path.insert( 0, str(Path(__file__).resolve().parent.parent.parent / "timmy_automations" / "daily_run") ) import weekly_narrative as wn class TestParseTimestamp: """Test timestamp parsing.""" def test_parse_iso_with_z(self): """Parse ISO timestamp with Z suffix.""" result = wn.parse_ts("2026-03-21T12:00:00Z") assert result is not None assert result.year == 2026 assert result.month == 3 assert result.day == 21 def test_parse_iso_with_offset(self): """Parse ISO timestamp with timezone offset.""" result = wn.parse_ts("2026-03-21T12:00:00+00:00") assert result is not None assert result.year == 2026 def test_parse_empty_string(self): """Empty string returns None.""" result = wn.parse_ts("") assert result is None def test_parse_invalid_string(self): """Invalid string returns None.""" result = wn.parse_ts("not-a-timestamp") assert result is None class TestCollectCyclesData: """Test cycle data collection.""" def test_no_cycles_file(self, tmp_path): """Handle missing cycles file gracefully.""" with patch.object(wn, "REPO_ROOT", tmp_path): since = datetime.now(UTC) - timedelta(days=7) result = wn.collect_cycles_data(since) assert result["total"] == 0 assert result["successes"] == 0 assert result["failures"] == 0 def test_collect_recent_cycles(self, tmp_path): """Collect cycles within lookback period.""" retro_dir = tmp_path / ".loop" / "retro" retro_dir.mkdir(parents=True) now = datetime.now(UTC) cycles = [ {"timestamp": now.isoformat(), "success": True, "cycle": 1}, {"timestamp": now.isoformat(), "success": False, "cycle": 2}, {"timestamp": (now - timedelta(days=10)).isoformat(), "success": True, "cycle": 3}, ] with open(retro_dir / "cycles.jsonl", "w") as f: for c in cycles: f.write(json.dumps(c) + "\n") with patch.object(wn, "REPO_ROOT", tmp_path): since = now - timedelta(days=7) result = wn.collect_cycles_data(since) assert result["total"] == 2 # Only recent 2 assert result["successes"] == 1 assert result["failures"] == 1 class TestExtractThemes: """Test theme extraction from issues.""" def test_extract_layer_labels(self): """Extract layer labels from issues.""" issues = [ {"labels": [{"name": "layer:triage"}, {"name": "bug"}]}, {"labels": [{"name": "layer:tests"}, {"name": "bug"}]}, {"labels": [{"name": "layer:triage"}, {"name": "feature"}]}, ] result = wn.extract_themes(issues) assert len(result["layers"]) == 2 layer_names = {layer["name"] for layer in result["layers"]} assert "triage" in layer_names assert "tests" in layer_names def test_extract_type_labels(self): """Extract type labels (bug/feature/etc).""" issues = [ {"labels": [{"name": "bug"}]}, {"labels": [{"name": "feature"}]}, {"labels": [{"name": "bug"}]}, ] result = wn.extract_themes(issues) type_names = {t_type["name"] for t_type in result["types"]} assert "bug" in type_names assert "feature" in type_names def test_empty_issues(self): """Handle empty issue list.""" result = wn.extract_themes([]) assert result["layers"] == [] assert result["types"] == [] assert result["top_labels"] == [] class TestExtractAgentContributions: """Test agent contribution extraction.""" def test_extract_assignees(self): """Extract assignee counts.""" issues = [ {"assignee": {"login": "kimi"}}, {"assignee": {"login": "hermes"}}, {"assignee": {"login": "kimi"}}, ] result = wn.extract_agent_contributions(issues, [], []) assert len(result["active_assignees"]) == 2 assignee_logins = {a["login"] for a in result["active_assignees"]} # noqa: E741 assert "kimi" in assignee_logins assert "hermes" in assignee_logins def test_extract_pr_authors(self): """Extract PR author counts.""" prs = [ {"user": {"login": "kimi"}}, {"user": {"login": "claude"}}, {"user": {"login": "kimi"}}, ] result = wn.extract_agent_contributions([], prs, []) assert len(result["pr_authors"]) == 2 def test_kimi_mentions_in_cycles(self): """Count Kimi mentions in cycle notes.""" cycles = [ {"notes": "Kimi did great work", "reason": ""}, {"notes": "", "reason": "Kimi timeout"}, {"notes": "All good", "reason": ""}, ] result = wn.extract_agent_contributions([], [], cycles) assert result["kimi_mentioned_cycles"] == 2 class TestAnalyzeTestShifts: """Test test pattern analysis.""" def test_no_cycles(self): """Handle no cycle data.""" result = wn.analyze_test_shifts([]) assert "note" in result def test_test_metrics(self): """Calculate test metrics from cycles.""" cycles = [ {"tests_passed": 100, "tests_added": 5}, {"tests_passed": 150, "tests_added": 3}, ] result = wn.analyze_test_shifts(cycles) assert result["total_tests_passed"] == 250 assert result["total_tests_added"] == 8 class TestGenerateVibeSummary: """Test vibe summary generation.""" def test_productive_vibe(self): """High success rate and activity = productive vibe.""" cycles_data = {"success_rate": 0.95, "successes": 10, "failures": 1} issues_data = {"closed_count": 5} result = wn.generate_vibe_summary(cycles_data, issues_data, {}, {"layers": []}, {}, {}, {}) assert result["overall"] == "productive" assert "strong week" in result["description"].lower() def test_struggling_vibe(self): """More failures than successes = struggling vibe.""" cycles_data = {"success_rate": 0.3, "successes": 3, "failures": 7} issues_data = {"closed_count": 0} result = wn.generate_vibe_summary(cycles_data, issues_data, {}, {"layers": []}, {}, {}, {}) assert result["overall"] == "struggling" def test_quiet_vibe(self): """Low activity = quiet vibe.""" cycles_data = {"success_rate": 0.0, "successes": 0, "failures": 0} issues_data = {"closed_count": 0} result = wn.generate_vibe_summary(cycles_data, issues_data, {}, {"layers": []}, {}, {}, {}) assert result["overall"] == "quiet" class TestGenerateMarkdownSummary: """Test markdown summary generation.""" def test_includes_header(self): """Markdown includes header.""" narrative = { "period": {"start": "2026-03-14T00:00:00", "end": "2026-03-21T00:00:00"}, "vibe": {"overall": "productive", "description": "Good week"}, "activity": { "cycles": {"total": 10, "successes": 9, "failures": 1}, "issues": {"closed": 5, "opened": 3}, "pull_requests": {"merged": 4, "opened": 2}, }, } result = wn.generate_markdown_summary(narrative) assert "# Weekly Narrative Summary" in result assert "productive" in result.lower() assert "10 total" in result or "10" in result def test_includes_focus_areas(self): """Markdown includes focus areas when present.""" narrative = { "period": {"start": "2026-03-14", "end": "2026-03-21"}, "vibe": { "overall": "productive", "description": "Good week", "focus_areas": ["triage (5 items)", "tests (3 items)"], }, "activity": { "cycles": {"total": 0, "successes": 0, "failures": 0}, "issues": {"closed": 0, "opened": 0}, "pull_requests": {"merged": 0, "opened": 0}, }, } result = wn.generate_markdown_summary(narrative) assert "Focus Areas" in result assert "triage" in result class TestConfigLoading: """Test configuration loading.""" def test_default_config(self, tmp_path): """Default config when manifest missing.""" with patch.object(wn, "CONFIG_PATH", tmp_path / "nonexistent.json"): config = wn.load_automation_config() assert config["lookback_days"] == 7 assert config["enabled"] is True def test_environment_override(self, tmp_path): """Environment variables override config.""" with patch.dict("os.environ", {"TIMMY_WEEKLY_NARRATIVE_ENABLED": "false"}): with patch.object(wn, "CONFIG_PATH", tmp_path / "nonexistent.json"): config = wn.load_automation_config() assert config["enabled"] is False class TestMain: """Test main function.""" def test_disabled_exits_cleanly(self, tmp_path): """When disabled and no --force, exits cleanly.""" with patch.object(wn, "REPO_ROOT", tmp_path): with patch.object(wn, "load_automation_config", return_value={"enabled": False}): with patch("sys.argv", ["weekly_narrative"]): result = wn.main() assert result == 0 def test_force_runs_when_disabled(self, tmp_path): """--force runs even when disabled.""" # Setup minimal structure (tmp_path / ".loop" / "retro").mkdir(parents=True) with patch.object(wn, "REPO_ROOT", tmp_path): with patch.object( wn, "load_automation_config", return_value={ "enabled": False, "lookback_days": 7, "gitea_api": "http://localhost:3000/api/v1", "repo_slug": "test/repo", "token_file": "~/.hermes/gitea_token", }, ): with patch.object(wn, "GiteaClient") as mock_client: mock_instance = MagicMock() mock_instance.is_available.return_value = False mock_client.return_value = mock_instance with patch("sys.argv", ["weekly_narrative", "--force"]): result = wn.main() # Should complete without error even though Gitea unavailable assert result == 0 class TestGiteaClient: """Test Gitea API client.""" def test_is_available_when_unavailable(self): """is_available returns False when server down.""" config = {"gitea_api": "http://localhost:99999", "repo_slug": "test/repo"} client = wn.GiteaClient(config, None) # Should return False without raising assert client.is_available() is False def test_headers_with_token(self): """Headers include Authorization when token provided.""" config = {"gitea_api": "http://localhost:3000", "repo_slug": "test/repo"} client = wn.GiteaClient(config, "test-token") headers = client._headers() assert headers["Authorization"] == "token test-token" def test_headers_without_token(self): """Headers don't include Authorization when no token.""" config = {"gitea_api": "http://localhost:3000", "repo_slug": "test/repo"} client = wn.GiteaClient(config, None) headers = client._headers() assert "Authorization" not in headers