"""Tests for the Golden Path generator.""" import json from datetime import UTC, datetime from unittest.mock import MagicMock, patch from timmy_automations.daily_run.golden_path import ( TIME_ESTIMATES, TYPE_PATTERNS, GiteaClient, GoldenPath, PathItem, build_golden_path, classify_issue_type, estimate_time, extract_size, generate_golden_path, get_token, group_issues_by_type, load_config, score_issue_for_path, ) class TestLoadConfig: """Tests for configuration loading.""" def test_load_config_defaults(self): """Config should have sensible defaults.""" config = load_config() assert "gitea_api" in config assert "repo_slug" in config assert "size_labels" in config def test_load_config_env_override(self, monkeypatch): """Environment variables should override defaults.""" monkeypatch.setenv("TIMMY_GITEA_API", "http://custom:3000/api/v1") monkeypatch.setenv("TIMMY_REPO_SLUG", "custom/repo") monkeypatch.setenv("TIMMY_GITEA_TOKEN", "test-token") config = load_config() assert config["gitea_api"] == "http://custom:3000/api/v1" assert config["repo_slug"] == "custom/repo" assert config["token"] == "test-token" class TestGetToken: """Tests for token retrieval.""" def test_get_token_from_config(self): """Token from config takes precedence.""" config = {"token": "config-token", "token_file": "~/.test"} assert get_token(config) == "config-token" @patch("pathlib.Path.exists") @patch("pathlib.Path.read_text") def test_get_token_from_file(self, mock_read, mock_exists): """Token can be read from file.""" mock_exists.return_value = True mock_read.return_value = "file-token\n" config = {"token_file": "~/.hermes/test_token"} assert get_token(config) == "file-token" def test_get_token_none(self): """Returns None if no token available.""" config = {"token_file": "/nonexistent/path"} assert get_token(config) is None class TestExtractSize: """Tests for size label extraction.""" def test_extract_size_xs(self): """Should extract XS size.""" labels = [{"name": "size:XS"}, {"name": "bug"}] assert extract_size(labels) == "XS" def test_extract_size_s(self): """Should extract S size.""" labels = [{"name": "bug"}, {"name": "size:S"}] assert extract_size(labels) == "S" def test_extract_size_m(self): """Should extract M size.""" labels = [{"name": "size:M"}] assert extract_size(labels) == "M" def test_extract_size_unknown(self): """Should return ? for unknown size.""" labels = [{"name": "bug"}, {"name": "feature"}] assert extract_size(labels) == "?" def test_extract_size_empty(self): """Should return ? for empty labels.""" assert extract_size([]) == "?" class TestClassifyIssueType: """Tests for issue type classification.""" def test_classify_triage(self): """Should classify triage issues.""" issue = { "title": "Triage new issues", "labels": [{"name": "triage"}], } assert classify_issue_type(issue) == "triage" def test_classify_test(self): """Should classify test issues.""" issue = { "title": "Add unit tests for parser", "labels": [{"name": "test"}], } assert classify_issue_type(issue) == "test" def test_classify_fix(self): """Should classify fix issues.""" issue = { "title": "Fix login bug", "labels": [{"name": "bug"}], } assert classify_issue_type(issue) == "fix" def test_classify_docs(self): """Should classify docs issues.""" issue = { "title": "Update README", "labels": [{"name": "docs"}], } assert classify_issue_type(issue) == "docs" def test_classify_refactor(self): """Should classify refactor issues.""" issue = { "title": "Refactor validation logic", "labels": [{"name": "refactor"}], } assert classify_issue_type(issue) == "refactor" def test_classify_default_to_fix(self): """Should default to fix for uncategorized.""" issue = { "title": "Something vague", "labels": [{"name": "question"}], } assert classify_issue_type(issue) == "fix" def test_classify_title_priority(self): """Title patterns should contribute to classification.""" issue = { "title": "Fix the broken parser", "labels": [], } assert classify_issue_type(issue) == "fix" class TestEstimateTime: """Tests for time estimation.""" def test_estimate_xs_fix(self): """XS fix should be 10 minutes.""" issue = { "title": "Fix typo", "labels": [{"name": "size:XS"}, {"name": "bug"}], } assert estimate_time(issue) == 10 def test_estimate_s_test(self): """S test should be 15 minutes.""" issue = { "title": "Add test coverage", "labels": [{"name": "size:S"}, {"name": "test"}], } assert estimate_time(issue) == 15 def test_estimate_m_fix(self): """M fix should be 25 minutes.""" issue = { "title": "Fix complex bug", "labels": [{"name": "size:M"}, {"name": "bug"}], } assert estimate_time(issue) == 25 def test_estimate_unknown_size(self): """Unknown size should fallback to S.""" issue = { "title": "Some fix", "labels": [{"name": "bug"}], } # Falls back to S/fix = 15 assert estimate_time(issue) == 15 class TestScoreIssueForPath: """Tests for issue scoring.""" def test_score_prefers_xs(self): """XS issues should score higher.""" xs = {"title": "Fix", "labels": [{"name": "size:XS"}]} s = {"title": "Fix", "labels": [{"name": "size:S"}]} m = {"title": "Fix", "labels": [{"name": "size:M"}]} assert score_issue_for_path(xs) > score_issue_for_path(s) assert score_issue_for_path(s) > score_issue_for_path(m) def test_score_prefers_clear_types(self): """Issues with clear type labels score higher.""" # Bug label adds score, so with bug should be >= without bug with_type = { "title": "Fix bug", "labels": [{"name": "size:S"}, {"name": "bug"}], } without_type = { "title": "Something", "labels": [{"name": "size:S"}], } assert score_issue_for_path(with_type) >= score_issue_for_path(without_type) def test_score_accepts_criteria(self): """Issues with acceptance criteria score higher.""" with_criteria = { "title": "Fix", "labels": [{"name": "size:S"}], "body": "## Acceptance Criteria\n- [ ] Fix it", } without_criteria = { "title": "Fix", "labels": [{"name": "size:S"}], "body": "Just fix it", } assert score_issue_for_path(with_criteria) > score_issue_for_path(without_criteria) class TestGroupIssuesByType: """Tests for issue grouping.""" def test_groups_by_type(self): """Issues should be grouped by their type.""" issues = [ {"title": "Fix bug", "labels": [{"name": "bug"}], "number": 1}, {"title": "Add test", "labels": [{"name": "test"}], "number": 2}, {"title": "Another fix", "labels": [{"name": "bug"}], "number": 3}, ] grouped = group_issues_by_type(issues) assert len(grouped["fix"]) == 2 assert len(grouped["test"]) == 1 assert len(grouped["triage"]) == 0 def test_sorts_by_score(self): """Issues within groups should be sorted by score.""" issues = [ {"title": "Fix", "labels": [{"name": "size:M"}], "number": 1}, {"title": "Fix", "labels": [{"name": "size:XS"}], "number": 2}, {"title": "Fix", "labels": [{"name": "size:S"}], "number": 3}, ] grouped = group_issues_by_type(issues) # XS should be first (highest score) assert grouped["fix"][0]["number"] == 2 # M should be last (lowest score) assert grouped["fix"][2]["number"] == 1 class TestBuildGoldenPath: """Tests for Golden Path building.""" def test_builds_path_with_all_types(self): """Path should include items from different types.""" grouped = { "triage": [ {"title": "Triage", "labels": [{"name": "size:XS"}], "number": 1, "html_url": ""}, ], "fix": [ {"title": "Fix 1", "labels": [{"name": "size:S"}], "number": 2, "html_url": ""}, {"title": "Fix 2", "labels": [{"name": "size:XS"}], "number": 3, "html_url": ""}, ], "test": [ {"title": "Test", "labels": [{"name": "size:S"}], "number": 4, "html_url": ""}, ], "docs": [], "refactor": [], } path = build_golden_path(grouped, target_minutes=45) assert path.item_count >= 3 assert path.items[0].issue_type == "triage" # Warm-up assert any(item.issue_type == "test" for item in path.items) def test_respects_time_budget(self): """Path should stay within reasonable time budget.""" grouped = { "triage": [ {"title": "Triage", "labels": [{"name": "size:S"}], "number": 1, "html_url": ""}, ], "fix": [ {"title": "Fix 1", "labels": [{"name": "size:S"}], "number": 2, "html_url": ""}, {"title": "Fix 2", "labels": [{"name": "size:S"}], "number": 3, "html_url": ""}, ], "test": [ {"title": "Test", "labels": [{"name": "size:S"}], "number": 4, "html_url": ""}, ], "docs": [], "refactor": [], } path = build_golden_path(grouped, target_minutes=45) # Should be in 30-60 minute range assert 20 <= path.total_estimated_minutes <= 70 def test_no_duplicate_issues(self): """Path should not include the same issue twice.""" grouped = { "triage": [], "fix": [ {"title": "Fix", "labels": [{"name": "size:S"}], "number": 1, "html_url": ""}, ], "test": [], "docs": [], "refactor": [], } path = build_golden_path(grouped, target_minutes=45) numbers = [item.number for item in path.items] assert len(numbers) == len(set(numbers)) # No duplicates def test_fallback_when_triage_missing(self): """Should use fallback when no triage issues available.""" grouped = { "triage": [], "fix": [ {"title": "Fix", "labels": [{"name": "size:XS"}], "number": 1, "html_url": ""}, ], "test": [ {"title": "Test", "labels": [{"name": "size:XS"}], "number": 2, "html_url": ""}, ], "docs": [], "refactor": [], } path = build_golden_path(grouped, target_minutes=45) assert path.item_count > 0 class TestGoldenPathDataclass: """Tests for the GoldenPath dataclass.""" def test_total_time_calculation(self): """Should sum item times correctly.""" path = GoldenPath( generated_at=datetime.now(UTC).isoformat(), target_minutes=45, items=[ PathItem(1, "Test 1", "XS", "fix", 10, ""), PathItem(2, "Test 2", "S", "test", 15, ""), ], ) assert path.total_estimated_minutes == 25 def test_to_dict(self): """Should convert to dict correctly.""" path = GoldenPath( generated_at="2024-01-01T00:00:00+00:00", target_minutes=45, items=[PathItem(1, "Test", "XS", "fix", 10, "http://test")], ) data = path.to_dict() assert data["target_minutes"] == 45 assert data["total_estimated_minutes"] == 10 assert data["item_count"] == 1 assert len(data["items"]) == 1 def test_to_json(self): """Should convert to JSON correctly.""" path = GoldenPath( generated_at="2024-01-01T00:00:00+00:00", target_minutes=45, items=[], ) json_str = path.to_json() data = json.loads(json_str) assert data["target_minutes"] == 45 class TestGiteaClient: """Tests for the GiteaClient.""" def test_client_initialization(self): """Client should initialize with config.""" config = { "gitea_api": "http://test:3000/api/v1", "repo_slug": "test/repo", } client = GiteaClient(config, "token123") assert client.api_base == "http://test:3000/api/v1" assert client.repo_slug == "test/repo" assert client.token == "token123" def test_headers_with_token(self): """Headers should include auth token.""" config = {"gitea_api": "http://test", "repo_slug": "test/repo"} client = GiteaClient(config, "mytoken") headers = client._headers() assert headers["Authorization"] == "token mytoken" assert headers["Accept"] == "application/json" def test_headers_without_token(self): """Headers should work without token.""" config = {"gitea_api": "http://test", "repo_slug": "test/repo"} client = GiteaClient(config, None) headers = client._headers() assert "Authorization" not in headers assert headers["Accept"] == "application/json" @patch("timmy_automations.daily_run.golden_path.urlopen") def test_is_available_success(self, mock_urlopen): """Should detect API availability.""" mock_response = MagicMock() mock_response.status = 200 mock_context = MagicMock() mock_context.__enter__ = MagicMock(return_value=mock_response) mock_context.__exit__ = MagicMock(return_value=False) mock_urlopen.return_value = mock_context config = {"gitea_api": "http://test", "repo_slug": "test/repo"} client = GiteaClient(config, None) assert client.is_available() is True @patch("urllib.request.urlopen") def test_is_available_failure(self, mock_urlopen): """Should handle API unavailability.""" from urllib.error import URLError mock_urlopen.side_effect = URLError("Connection refused") config = {"gitea_api": "http://test", "repo_slug": "test/repo"} client = GiteaClient(config, None) assert client.is_available() is False class TestIntegration: """Integration-style tests.""" @patch("timmy_automations.daily_run.golden_path.GiteaClient") def test_generate_golden_path_integration(self, mock_client_class): """End-to-end test with mocked Gitea.""" # Setup mock mock_client = MagicMock() mock_client.is_available.return_value = True mock_client.get_paginated.return_value = [ { "number": 1, "title": "Triage issues", "labels": [{"name": "size:XS"}, {"name": "triage"}], "html_url": "http://test/1", }, { "number": 2, "title": "Fix bug", "labels": [{"name": "size:S"}, {"name": "bug"}], "html_url": "http://test/2", }, { "number": 3, "title": "Add tests", "labels": [{"name": "size:S"}, {"name": "test"}], "html_url": "http://test/3", }, { "number": 4, "title": "Another fix", "labels": [{"name": "size:XS"}, {"name": "bug"}], "html_url": "http://test/4", }, ] mock_client_class.return_value = mock_client path = generate_golden_path(target_minutes=45) assert path.item_count >= 3 assert all(item.url.startswith("http://test/") for item in path.items) @patch("timmy_automations.daily_run.golden_path.GiteaClient") def test_generate_when_unavailable(self, mock_client_class): """Should return empty path when Gitea unavailable.""" mock_client = MagicMock() mock_client.is_available.return_value = False mock_client_class.return_value = mock_client path = generate_golden_path(target_minutes=45) assert path.item_count == 0 assert path.items == [] class TestTypePatterns: """Tests for type pattern definitions.""" def test_type_patterns_structure(self): """Type patterns should have required keys.""" for _issue_type, patterns in TYPE_PATTERNS.items(): assert "labels" in patterns assert "title" in patterns assert isinstance(patterns["labels"], list) assert isinstance(patterns["title"], list) def test_time_estimates_structure(self): """Time estimates should have all sizes.""" for size in ["XS", "S", "M"]: assert size in TIME_ESTIMATES for issue_type in ["triage", "fix", "test", "docs", "refactor"]: assert issue_type in TIME_ESTIMATES[size] assert isinstance(TIME_ESTIMATES[size][issue_type], int) assert TIME_ESTIMATES[size][issue_type] > 0