537 lines
18 KiB
Python
537 lines
18 KiB
Python
"""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
|