RED phase: 5 tests in test_collector.py (collect_milestones), 10 tests in test_display.py (render_milestones, parse_columns), 2 tests in test_exporter.py (milestones JSON), 7 tests in test_cli.py (--milestones, --columns). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
472 lines
16 KiB
Python
472 lines
16 KiB
Python
"""Tests for data collector."""
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
from gitea_dashboard.collector import (
|
|
RepoData,
|
|
collect_all,
|
|
)
|
|
|
|
|
|
def _make_repo(
|
|
name="my-repo",
|
|
full_name="admin/my-repo",
|
|
description="A repo",
|
|
open_issues_count=5,
|
|
open_pr_counter=2,
|
|
fork=False,
|
|
archived=False,
|
|
mirror=False,
|
|
owner_login="admin",
|
|
):
|
|
"""Build a fake repo dict as returned by the Gitea API."""
|
|
return {
|
|
"name": name,
|
|
"full_name": full_name,
|
|
"description": description,
|
|
"open_issues_count": open_issues_count,
|
|
"open_pr_counter": open_pr_counter,
|
|
"fork": fork,
|
|
"archived": archived,
|
|
"mirror": mirror,
|
|
"owner": {"login": owner_login},
|
|
}
|
|
|
|
|
|
class TestCollectAll:
|
|
"""Test collect_all function."""
|
|
|
|
def test_basic_repo(self):
|
|
"""Collects repo data with release and milestones."""
|
|
client = MagicMock()
|
|
client.get_repos.return_value = [_make_repo()]
|
|
client.get_latest_release.return_value = {
|
|
"tag_name": "v1.0",
|
|
"published_at": "2026-01-01",
|
|
}
|
|
client.get_milestones.return_value = [
|
|
{"title": "v2.0", "open_issues": 3, "closed_issues": 2, "due_on": None},
|
|
]
|
|
|
|
result = collect_all(client)
|
|
|
|
assert len(result) == 1
|
|
repo = result[0]
|
|
assert isinstance(repo, RepoData)
|
|
assert repo.name == "my-repo"
|
|
assert repo.full_name == "admin/my-repo"
|
|
assert repo.description == "A repo"
|
|
assert repo.open_issues == 3 # 5 - 2
|
|
assert repo.is_fork is False
|
|
assert repo.is_archived is False
|
|
assert repo.is_mirror is False
|
|
assert repo.latest_release == {"tag_name": "v1.0", "published_at": "2026-01-01"}
|
|
assert len(repo.milestones) == 1
|
|
|
|
def test_repo_without_release(self):
|
|
"""Repo with no release gets None for latest_release."""
|
|
client = MagicMock()
|
|
client.get_repos.return_value = [_make_repo()]
|
|
client.get_latest_release.return_value = None
|
|
client.get_milestones.return_value = []
|
|
|
|
result = collect_all(client)
|
|
|
|
assert result[0].latest_release is None
|
|
|
|
def test_repo_without_milestones(self):
|
|
"""Repo with no milestones gets empty list."""
|
|
client = MagicMock()
|
|
client.get_repos.return_value = [_make_repo()]
|
|
client.get_latest_release.return_value = None
|
|
client.get_milestones.return_value = []
|
|
|
|
result = collect_all(client)
|
|
|
|
assert result[0].milestones == []
|
|
|
|
def test_open_issues_subtracts_prs(self):
|
|
"""open_issues = open_issues_count - open_pr_counter."""
|
|
client = MagicMock()
|
|
client.get_repos.return_value = [
|
|
_make_repo(open_issues_count=10, open_pr_counter=4),
|
|
]
|
|
client.get_latest_release.return_value = None
|
|
client.get_milestones.return_value = []
|
|
|
|
result = collect_all(client)
|
|
|
|
assert result[0].open_issues == 6
|
|
|
|
def test_multiple_repos(self):
|
|
"""Collects data for multiple repos."""
|
|
client = MagicMock()
|
|
client.get_repos.return_value = [
|
|
_make_repo(name="repo-a", full_name="admin/repo-a"),
|
|
_make_repo(name="repo-b", full_name="org/repo-b", owner_login="org"),
|
|
]
|
|
client.get_latest_release.return_value = None
|
|
client.get_milestones.return_value = []
|
|
|
|
result = collect_all(client)
|
|
|
|
assert len(result) == 2
|
|
assert result[0].name == "repo-a"
|
|
assert result[1].name == "repo-b"
|
|
# Verify correct owner/repo passed to enrichment calls
|
|
client.get_latest_release.assert_any_call("admin", "repo-a")
|
|
client.get_latest_release.assert_any_call("org", "repo-b")
|
|
|
|
def test_fork_and_archived_flags(self):
|
|
"""Fork and archived flags are propagated."""
|
|
client = MagicMock()
|
|
client.get_repos.return_value = [
|
|
_make_repo(fork=True, archived=True, mirror=True),
|
|
]
|
|
client.get_latest_release.return_value = None
|
|
client.get_milestones.return_value = []
|
|
|
|
result = collect_all(client)
|
|
|
|
assert result[0].is_fork is True
|
|
assert result[0].is_archived is True
|
|
assert result[0].is_mirror is True
|
|
|
|
|
|
class TestCollectAllLastCommit:
|
|
"""Test last_commit_date field in RepoData."""
|
|
|
|
def test_repo_data_has_last_commit_date(self):
|
|
"""RepoData includes last_commit_date field."""
|
|
repo = RepoData(
|
|
name="test",
|
|
full_name="admin/test",
|
|
description="",
|
|
open_issues=0,
|
|
is_fork=False,
|
|
is_archived=False,
|
|
is_mirror=False,
|
|
latest_release=None,
|
|
milestones=[],
|
|
last_commit_date="2026-03-10T14:30:00Z",
|
|
)
|
|
assert repo.last_commit_date == "2026-03-10T14:30:00Z"
|
|
|
|
def test_collect_all_calls_get_latest_commit(self):
|
|
"""collect_all calls get_latest_commit and fills last_commit_date."""
|
|
client = MagicMock()
|
|
client.get_repos.return_value = [_make_repo()]
|
|
client.get_latest_release.return_value = None
|
|
client.get_milestones.return_value = []
|
|
client.get_latest_commit.return_value = {
|
|
"sha": "abc123",
|
|
"created": "2026-03-10T14:30:00Z",
|
|
}
|
|
|
|
result = collect_all(client)
|
|
|
|
client.get_latest_commit.assert_called_once_with("admin", "my-repo")
|
|
assert result[0].last_commit_date == "2026-03-10T14:30:00Z"
|
|
|
|
def test_collect_all_no_commits(self):
|
|
"""Repo without commits gets last_commit_date=None."""
|
|
client = MagicMock()
|
|
client.get_repos.return_value = [_make_repo()]
|
|
client.get_latest_release.return_value = None
|
|
client.get_milestones.return_value = []
|
|
client.get_latest_commit.return_value = None
|
|
|
|
result = collect_all(client)
|
|
|
|
assert result[0].last_commit_date is None
|
|
|
|
|
|
class TestRepoDataEdgeCases:
|
|
"""Test RepoData with edge case data."""
|
|
|
|
def test_repo_data_unicode_description(self):
|
|
"""RepoData with full unicode description (accents, CJK, emojis)."""
|
|
repo = RepoData(
|
|
name="unicode-test",
|
|
full_name="admin/unicode-test",
|
|
description="Projet avec accents : e, a, u, CJK: 中文, emojis: 🚀🎉",
|
|
open_issues=0,
|
|
is_fork=False,
|
|
is_archived=False,
|
|
is_mirror=False,
|
|
latest_release=None,
|
|
milestones=[],
|
|
last_commit_date=None,
|
|
)
|
|
assert "🚀" in repo.description
|
|
assert "中文" in repo.description
|
|
|
|
def test_collect_all_repo_zero_commits_and_no_release(self):
|
|
"""Repo with no commits AND no release produces valid RepoData."""
|
|
client = MagicMock()
|
|
client.get_repos.return_value = [_make_repo()]
|
|
client.get_latest_release.return_value = None
|
|
client.get_milestones.return_value = []
|
|
client.get_latest_commit.return_value = None
|
|
|
|
result = collect_all(client)
|
|
|
|
assert result[0].last_commit_date is None
|
|
assert result[0].latest_release is None
|
|
|
|
|
|
class TestCollectAllFiltering:
|
|
"""Test collect_all filtering (include/exclude)."""
|
|
|
|
def _setup_client(self, repo_names: list[str]) -> MagicMock:
|
|
"""Create a mock client returning repos with the given names."""
|
|
client = MagicMock()
|
|
client.get_repos.return_value = [
|
|
_make_repo(name=n, full_name=f"admin/{n}") for n in repo_names
|
|
]
|
|
client.get_latest_release.return_value = None
|
|
client.get_milestones.return_value = []
|
|
return client
|
|
|
|
def test_no_filter_returns_all(self):
|
|
"""Without include/exclude, all repos are returned (backward compat)."""
|
|
client = self._setup_client(["alpha", "beta", "gamma"])
|
|
|
|
result = collect_all(client)
|
|
|
|
assert [r.name for r in result] == ["alpha", "beta", "gamma"]
|
|
|
|
def test_include_single(self):
|
|
"""Include filters repos by substring match."""
|
|
client = self._setup_client(["gitea-dashboard", "infra-core", "notes"])
|
|
|
|
result = collect_all(client, include=["dashboard"])
|
|
|
|
assert [r.name for r in result] == ["gitea-dashboard"]
|
|
|
|
def test_include_multiple(self):
|
|
"""Multiple include patterns are OR-combined."""
|
|
client = self._setup_client(["gitea-dashboard", "infra-core", "notes"])
|
|
|
|
result = collect_all(client, include=["dashboard", "infra"])
|
|
|
|
assert [r.name for r in result] == ["gitea-dashboard", "infra-core"]
|
|
|
|
def test_exclude_single(self):
|
|
"""Exclude removes repos matching the substring."""
|
|
client = self._setup_client(["gitea-dashboard", "old-fork", "notes"])
|
|
|
|
result = collect_all(client, exclude=["fork"])
|
|
|
|
assert [r.name for r in result] == ["gitea-dashboard", "notes"]
|
|
|
|
def test_include_and_exclude(self):
|
|
"""Include is applied first, then exclude."""
|
|
client = self._setup_client(["projet-web", "projet-old", "infra"])
|
|
|
|
result = collect_all(client, include=["projet"], exclude=["old"])
|
|
|
|
assert [r.name for r in result] == ["projet-web"]
|
|
|
|
def test_case_insensitive(self):
|
|
"""Filtering is case-insensitive."""
|
|
client = self._setup_client(["Gitea-Dashboard", "infra"])
|
|
|
|
result = collect_all(client, include=["dashboard"])
|
|
|
|
assert [r.name for r in result] == ["Gitea-Dashboard"]
|
|
|
|
def test_no_match_returns_empty(self):
|
|
"""Returns empty list when no repo matches include filter."""
|
|
client = self._setup_client(["alpha", "beta"])
|
|
|
|
result = collect_all(client, include=["inexistant"])
|
|
|
|
assert result == []
|
|
|
|
def test_exclude_all_returns_empty(self):
|
|
"""Returns empty list when all repos are excluded."""
|
|
client = self._setup_client(["alpha", "beta"])
|
|
|
|
result = collect_all(client, exclude=["alpha", "beta"])
|
|
|
|
assert result == []
|
|
|
|
def test_filtered_repos_have_no_api_calls(self):
|
|
"""Repos excluded by include filter must not trigger enrichment API calls."""
|
|
client = self._setup_client(["gitea-dashboard", "infra-core", "notes"])
|
|
|
|
collect_all(client, include=["dashboard"])
|
|
|
|
# Only gitea-dashboard passed the filter — enrichment calls must target it only
|
|
client.get_latest_release.assert_called_once_with("admin", "gitea-dashboard")
|
|
client.get_milestones.assert_called_once_with("admin", "gitea-dashboard")
|
|
|
|
def test_collect_all_include_empty_list(self):
|
|
"""include=[] behaves like include=None — all repos are returned.
|
|
|
|
The contract: an empty list is falsy, so `if include:` is False, meaning
|
|
no inclusion filter is applied and every repo is included before exclude.
|
|
"""
|
|
client = self._setup_client(["alpha", "beta", "gamma"])
|
|
|
|
result_none = collect_all(client)
|
|
result_empty = collect_all(client, include=[])
|
|
|
|
assert [r.name for r in result_empty] == [r.name for r in result_none]
|
|
|
|
|
|
class TestCollectMilestones:
|
|
"""Test collect_milestones function."""
|
|
|
|
def _setup_client(self, repo_names, milestones_by_repo=None):
|
|
"""Create a mock client with repos and milestones."""
|
|
client = MagicMock()
|
|
client.get_repos.return_value = [
|
|
_make_repo(name=n, full_name=f"admin/{n}") for n in repo_names
|
|
]
|
|
|
|
if milestones_by_repo is None:
|
|
milestones_by_repo = {}
|
|
|
|
def get_milestones_side_effect(owner, repo, state="all"):
|
|
return milestones_by_repo.get(repo, [])
|
|
|
|
client.get_milestones.side_effect = get_milestones_side_effect
|
|
return client
|
|
|
|
def test_collect_milestones_basic(self):
|
|
"""2 repos with milestones returns flat list of MilestoneData."""
|
|
from gitea_dashboard.collector import MilestoneData, collect_milestones
|
|
|
|
client = self._setup_client(
|
|
["repo-a", "repo-b"],
|
|
{
|
|
"repo-a": [
|
|
{
|
|
"title": "v1.0",
|
|
"open_issues": 2,
|
|
"closed_issues": 3,
|
|
"due_on": None,
|
|
"state": "open",
|
|
},
|
|
],
|
|
"repo-b": [
|
|
{
|
|
"title": "v2.0",
|
|
"open_issues": 0,
|
|
"closed_issues": 5,
|
|
"due_on": "2026-04-01T00:00:00Z",
|
|
"state": "closed",
|
|
},
|
|
],
|
|
},
|
|
)
|
|
|
|
result = collect_milestones(client)
|
|
|
|
assert len(result) == 2
|
|
assert all(isinstance(m, MilestoneData) for m in result)
|
|
assert result[0].repo_name == "repo-a"
|
|
assert result[0].title == "v1.0"
|
|
assert result[1].repo_name == "repo-b"
|
|
|
|
def test_collect_milestones_empty_repo(self):
|
|
"""Repo without milestones produces no entries."""
|
|
from gitea_dashboard.collector import collect_milestones
|
|
|
|
client = self._setup_client(["empty-repo"], {"empty-repo": []})
|
|
|
|
result = collect_milestones(client)
|
|
|
|
assert result == []
|
|
|
|
def test_collect_milestones_progress_calculation(self):
|
|
"""3 open + 7 closed = progress_pct 70."""
|
|
from gitea_dashboard.collector import collect_milestones
|
|
|
|
client = self._setup_client(
|
|
["repo"],
|
|
{
|
|
"repo": [
|
|
{
|
|
"title": "v1.0",
|
|
"open_issues": 3,
|
|
"closed_issues": 7,
|
|
"due_on": None,
|
|
"state": "open",
|
|
},
|
|
],
|
|
},
|
|
)
|
|
|
|
result = collect_milestones(client)
|
|
|
|
assert result[0].progress_pct == 70
|
|
|
|
def test_collect_milestones_with_include_filter(self):
|
|
"""Include filter is respected."""
|
|
from gitea_dashboard.collector import collect_milestones
|
|
|
|
client = self._setup_client(
|
|
["gitea-dashboard", "infra"],
|
|
{
|
|
"gitea-dashboard": [
|
|
{
|
|
"title": "v1.0",
|
|
"open_issues": 1,
|
|
"closed_issues": 1,
|
|
"due_on": None,
|
|
"state": "open",
|
|
},
|
|
],
|
|
"infra": [
|
|
{
|
|
"title": "v2.0",
|
|
"open_issues": 0,
|
|
"closed_issues": 5,
|
|
"due_on": None,
|
|
"state": "closed",
|
|
},
|
|
],
|
|
},
|
|
)
|
|
|
|
result = collect_milestones(client, include=["dashboard"])
|
|
|
|
assert len(result) == 1
|
|
assert result[0].repo_name == "gitea-dashboard"
|
|
|
|
def test_collect_milestones_with_exclude_filter(self):
|
|
"""Exclude filter is respected."""
|
|
from gitea_dashboard.collector import collect_milestones
|
|
|
|
client = self._setup_client(
|
|
["gitea-dashboard", "old-fork"],
|
|
{
|
|
"gitea-dashboard": [
|
|
{
|
|
"title": "v1.0",
|
|
"open_issues": 1,
|
|
"closed_issues": 1,
|
|
"due_on": None,
|
|
"state": "open",
|
|
},
|
|
],
|
|
"old-fork": [
|
|
{
|
|
"title": "v2.0",
|
|
"open_issues": 0,
|
|
"closed_issues": 5,
|
|
"due_on": None,
|
|
"state": "closed",
|
|
},
|
|
],
|
|
},
|
|
)
|
|
|
|
result = collect_milestones(client, exclude=["fork"])
|
|
|
|
assert len(result) == 1
|
|
assert result[0].repo_name == "gitea-dashboard"
|