- FINDING-001: add activity column rendering in render_dashboard loop
- FINDING-002: map YAML 'token' key to 'auth' in _resolve_config
- FINDING-003/SEC-001: reject tokens containing unresolved ${...} refs
- FINDING-004: add tests for activity column rendering
- FINDING-006: strengthen test_main_columns_help assertions
- SEC-002: enrich timeout warning with collected items count
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
558 lines
18 KiB
Python
558 lines
18 KiB
Python
"""Tests for Rich dashboard display."""
|
|
|
|
from io import StringIO
|
|
|
|
import pytest
|
|
from rich.console import Console
|
|
|
|
from gitea_dashboard.display import (
|
|
render_dashboard,
|
|
sort_repos,
|
|
)
|
|
|
|
from tests.helpers import make_repo as _make_repo
|
|
|
|
|
|
def _make_console():
|
|
"""Create a console that captures output for testing.
|
|
|
|
highlight=False desactive le highlighting automatique de Rich
|
|
qui fragmente les nombres et noms avec des codes ANSI.
|
|
"""
|
|
buf = StringIO()
|
|
return Console(file=buf, force_terminal=True, width=120, highlight=False), buf
|
|
|
|
|
|
class TestRenderDashboardTable:
|
|
"""Test the main repos table rendering."""
|
|
|
|
def test_basic_repo_displayed(self):
|
|
"""Repo name and issues count appear in output."""
|
|
console, buf = _make_console()
|
|
repos = [
|
|
_make_repo(
|
|
name="mon-projet",
|
|
open_issues=3,
|
|
latest_release={"tag_name": "v1.2.0", "published_at": "2026-03-08T10:00:00Z"},
|
|
),
|
|
]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "mon-projet" in output
|
|
assert "v1.2.0" in output
|
|
|
|
def test_repo_without_release_shows_dash(self):
|
|
"""Repo with no release shows a dash character."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="no-release", latest_release=None)]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "no-release" in output
|
|
# The dash character for "no release"
|
|
assert "\u2014" in output or "—" in output
|
|
|
|
def test_fork_indicator(self):
|
|
"""Fork repos show [F] indicator."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="forked-repo", is_fork=True)]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "forked-repo" in output
|
|
assert "[F]" in output
|
|
|
|
def test_archive_indicator(self):
|
|
"""Archived repos show [A] indicator."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="old-repo", is_archived=True)]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "[A]" in output
|
|
|
|
def test_mirror_indicator(self):
|
|
"""Mirror repos show [M] indicator."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="mirror-repo", is_mirror=True)]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "[M]" in output
|
|
|
|
def test_multiple_indicators(self):
|
|
"""Repo with multiple flags shows all indicators."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="special", is_fork=True, is_archived=True)]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "[F]" in output
|
|
assert "[A]" in output
|
|
|
|
def test_issues_zero(self):
|
|
"""Repos with 0 issues display 0."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="clean-repo", open_issues=0)]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "0" in output
|
|
|
|
def test_multiple_repos(self):
|
|
"""Multiple repos all appear in the output."""
|
|
console, buf = _make_console()
|
|
repos = [
|
|
_make_repo(name="repo-alpha", open_issues=1),
|
|
_make_repo(name="repo-beta", open_issues=5),
|
|
]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "repo-alpha" in output
|
|
assert "repo-beta" in output
|
|
|
|
|
|
class TestRenderDashboardLastCommit:
|
|
"""Test the last commit column rendering."""
|
|
|
|
def test_last_commit_column_displayed(self):
|
|
"""Column 'Dernier commit' appears in the table."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="projet", last_commit_date="2026-03-10T14:30:00Z")]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "Dernier commit" in output
|
|
|
|
def test_last_commit_shows_relative_date(self):
|
|
"""Last commit date is shown as relative date."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="projet", last_commit_date="2026-03-10T14:30:00Z")]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
# Should show some relative date (il y a Xj, etc.)
|
|
assert "il y a" in output or "aujourd'hui" in output
|
|
|
|
def test_last_commit_none_shows_dash(self):
|
|
"""Repo without commit shows dash."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="vide", last_commit_date=None)]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "\u2014" in output or "—" in output
|
|
|
|
|
|
class TestRenderDashboardMilestones:
|
|
"""Test the milestones section rendering."""
|
|
|
|
def test_milestones_displayed(self):
|
|
"""Repos with milestones show milestone info."""
|
|
console, buf = _make_console()
|
|
repos = [
|
|
_make_repo(
|
|
name="mon-projet",
|
|
milestones=[
|
|
{
|
|
"title": "v2.0",
|
|
"open_issues": 2,
|
|
"closed_issues": 3,
|
|
"due_on": "2026-04-01T00:00:00Z",
|
|
},
|
|
],
|
|
),
|
|
]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "v2.0" in output
|
|
assert "3/5" in output # closed/total
|
|
assert "60%" in output
|
|
|
|
def test_milestone_without_due_date(self):
|
|
"""Milestone without due_on omits the deadline."""
|
|
console, buf = _make_console()
|
|
repos = [
|
|
_make_repo(
|
|
name="projet",
|
|
milestones=[
|
|
{
|
|
"title": "backlog",
|
|
"open_issues": 5,
|
|
"closed_issues": 1,
|
|
"due_on": None,
|
|
},
|
|
],
|
|
),
|
|
]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "backlog" in output
|
|
assert "1/6" in output
|
|
|
|
def test_no_milestones_section_when_none(self):
|
|
"""When no repos have milestones, milestone section is absent."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="simple", milestones=[])]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "Milestones" not in output
|
|
|
|
|
|
class TestRenderDashboardEmpty:
|
|
"""Test empty repo list handling."""
|
|
|
|
def test_empty_list_shows_message(self):
|
|
"""Empty repo list shows informative message."""
|
|
console, buf = _make_console()
|
|
|
|
render_dashboard([], console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "Aucun repo" in output
|
|
|
|
|
|
class TestDescriptionColumn:
|
|
"""Test Description column in dashboard table."""
|
|
|
|
def test_description_column_displayed(self):
|
|
"""Table contains a Description column by default."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="test", description="My project")]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "Description" in output
|
|
assert "My project" in output
|
|
|
|
def test_description_truncated_at_40(self):
|
|
"""Description longer than 40 chars is truncated with '...'."""
|
|
console, buf = _make_console()
|
|
long_desc = "A" * 60
|
|
repos = [_make_repo(name="test", description=long_desc)]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
# Should contain first 40 chars + "..."
|
|
assert "A" * 40 + "..." in output
|
|
# Should NOT contain the full 60-char string
|
|
assert "A" * 60 not in output
|
|
|
|
def test_description_short_not_truncated(self):
|
|
"""Description of 20 chars is displayed as-is."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="test", description="Short description")]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "Short description" in output
|
|
|
|
def test_description_empty(self):
|
|
"""Empty description renders without crash."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="test", description="")]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "test" in output
|
|
|
|
def test_no_description_flag(self):
|
|
"""show_description=False hides the Description column."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="test", description="My project")]
|
|
|
|
render_dashboard(repos, console=console, show_description=False)
|
|
output = buf.getvalue()
|
|
|
|
assert "Description" not in output
|
|
assert "test" in output
|
|
|
|
|
|
class TestRenderDashboardEdgeCases:
|
|
"""Test edge cases for dashboard rendering."""
|
|
|
|
def test_render_dashboard_unicode_description(self):
|
|
"""Repo with unicode description renders without crash."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="unicode", description="Projet 🚀 avec accents eaiu 中文")]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "unicode" in output
|
|
|
|
def test_render_dashboard_control_chars_in_name(self):
|
|
"""Repo with control characters in name renders without crash."""
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="test\x00repo")]
|
|
|
|
render_dashboard(repos, console=console)
|
|
output = buf.getvalue()
|
|
|
|
# Rich may strip or display the control char, but must not crash
|
|
assert "test" in output
|
|
|
|
|
|
class TestColorizeMilestoneDue:
|
|
"""Test _colorize_milestone_due function."""
|
|
|
|
def test_overdue(self):
|
|
"""Past due date returns 'red'."""
|
|
from gitea_dashboard.display import _colorize_milestone_due
|
|
|
|
assert _colorize_milestone_due("2020-01-01T00:00:00Z") == "red"
|
|
|
|
def test_soon(self):
|
|
"""Due date within 7 days returns 'yellow'."""
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from gitea_dashboard.display import _colorize_milestone_due
|
|
|
|
soon = datetime.now(timezone.utc) + timedelta(days=3)
|
|
assert _colorize_milestone_due(soon.isoformat()) == "yellow"
|
|
|
|
def test_ok(self):
|
|
"""Due date more than 7 days away returns 'green'."""
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from gitea_dashboard.display import _colorize_milestone_due
|
|
|
|
future = datetime.now(timezone.utc) + timedelta(days=15)
|
|
assert _colorize_milestone_due(future.isoformat()) == "green"
|
|
|
|
def test_no_due(self):
|
|
"""No due date returns empty string."""
|
|
from gitea_dashboard.display import _colorize_milestone_due
|
|
|
|
assert _colorize_milestone_due(None) == ""
|
|
|
|
|
|
class TestSortRepos:
|
|
"""Test sort_repos function."""
|
|
|
|
def test_sort_by_name(self):
|
|
"""Sorts alphabetically by name (case-insensitive)."""
|
|
|
|
repos = [
|
|
_make_repo(name="Charlie"),
|
|
_make_repo(name="alpha"),
|
|
_make_repo(name="Bravo"),
|
|
]
|
|
result = sort_repos(repos, "name")
|
|
assert [r.name for r in result] == ["alpha", "Bravo", "Charlie"]
|
|
|
|
def test_sort_by_issues(self):
|
|
"""Sorts by issues count descending."""
|
|
|
|
repos = [
|
|
_make_repo(name="low", open_issues=1),
|
|
_make_repo(name="high", open_issues=10),
|
|
_make_repo(name="mid", open_issues=5),
|
|
]
|
|
result = sort_repos(repos, "issues")
|
|
assert [r.name for r in result] == ["high", "mid", "low"]
|
|
|
|
def test_sort_by_release(self):
|
|
"""Sorts by release date descending; repos without release last."""
|
|
|
|
repos = [
|
|
_make_repo(name="no-rel", latest_release=None),
|
|
_make_repo(
|
|
name="old",
|
|
latest_release={"tag_name": "v1.0", "published_at": "2025-01-01T00:00:00Z"},
|
|
),
|
|
_make_repo(
|
|
name="new",
|
|
latest_release={"tag_name": "v2.0", "published_at": "2026-03-01T00:00:00Z"},
|
|
),
|
|
]
|
|
result = sort_repos(repos, "release")
|
|
assert [r.name for r in result] == ["new", "old", "no-rel"]
|
|
|
|
def test_sort_by_activity(self):
|
|
"""Sorts by last commit date descending; repos without commit last."""
|
|
|
|
repos = [
|
|
_make_repo(name="inactive", last_commit_date=None),
|
|
_make_repo(name="old-commit", last_commit_date="2025-06-01T00:00:00Z"),
|
|
_make_repo(name="recent", last_commit_date="2026-03-10T00:00:00Z"),
|
|
]
|
|
result = sort_repos(repos, "activity")
|
|
assert [r.name for r in result] == ["recent", "old-commit", "inactive"]
|
|
|
|
|
|
class TestRenderMilestones:
|
|
"""Test the dedicated milestones table rendering."""
|
|
|
|
def test_render_milestones_basic(self):
|
|
"""Milestones table displays expected columns."""
|
|
from gitea_dashboard.collector import MilestoneData
|
|
from gitea_dashboard.display import render_milestones
|
|
|
|
console, buf = _make_console()
|
|
milestones = [
|
|
MilestoneData(
|
|
repo_name="my-repo",
|
|
title="v1.0",
|
|
open_issues=2,
|
|
closed_issues=8,
|
|
progress_pct=80,
|
|
due_on="2026-04-01T00:00:00Z",
|
|
state="open",
|
|
),
|
|
]
|
|
|
|
render_milestones(milestones, console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "my-repo" in output
|
|
assert "v1.0" in output
|
|
assert "80" in output
|
|
|
|
def test_render_milestones_empty(self):
|
|
"""Empty list shows informative message."""
|
|
from gitea_dashboard.display import render_milestones
|
|
|
|
console, buf = _make_console()
|
|
|
|
render_milestones([], console=console)
|
|
output = buf.getvalue()
|
|
|
|
assert "Aucune milestone" in output
|
|
|
|
def test_render_milestones_progress_colors(self):
|
|
"""Progress coloring: green > 80%, yellow 50-80%, red < 50%."""
|
|
from gitea_dashboard.collector import MilestoneData
|
|
from gitea_dashboard.display import render_milestones
|
|
|
|
console, buf = _make_console()
|
|
milestones = [
|
|
MilestoneData("repo", "high", 1, 9, 90, None, "open"),
|
|
MilestoneData("repo", "mid", 3, 3, 50, None, "open"),
|
|
MilestoneData("repo", "low", 8, 2, 20, None, "open"),
|
|
]
|
|
|
|
render_milestones(milestones, console=console)
|
|
output = buf.getvalue()
|
|
|
|
# All three should appear without crash
|
|
assert "high" in output
|
|
assert "mid" in output
|
|
assert "low" in output
|
|
|
|
|
|
class TestParseColumns:
|
|
"""Test parse_columns function."""
|
|
|
|
def test_parse_columns_all_default(self):
|
|
"""None returns all columns."""
|
|
from gitea_dashboard.display import AVAILABLE_COLUMNS, parse_columns
|
|
|
|
result = parse_columns(None)
|
|
|
|
assert result == list(AVAILABLE_COLUMNS.keys())
|
|
|
|
def test_parse_columns_inclusion(self):
|
|
"""'name,issues' returns only those columns."""
|
|
from gitea_dashboard.display import parse_columns
|
|
|
|
result = parse_columns("name,issues")
|
|
|
|
assert result == ["name", "issues"]
|
|
|
|
def test_parse_columns_exclusion(self):
|
|
"""'-description,-commit' returns all except those."""
|
|
from gitea_dashboard.display import AVAILABLE_COLUMNS, parse_columns
|
|
|
|
result = parse_columns("-description,-commit")
|
|
|
|
assert "description" not in result
|
|
assert "commit" not in result
|
|
assert len(result) == len(AVAILABLE_COLUMNS) - 2
|
|
|
|
def test_parse_columns_unknown_raises(self):
|
|
"""Unknown column raises ValueError."""
|
|
from gitea_dashboard.display import parse_columns
|
|
|
|
with pytest.raises(ValueError, match="unknown"):
|
|
parse_columns("unknown")
|
|
|
|
def test_parse_columns_help(self):
|
|
"""'help' returns sentinel list."""
|
|
from gitea_dashboard.display import parse_columns
|
|
|
|
result = parse_columns("help")
|
|
|
|
assert result == ["__help__"]
|
|
|
|
def test_parse_columns_no_desc_compat(self):
|
|
"""no_desc=True excludes description column."""
|
|
from gitea_dashboard.display import parse_columns
|
|
|
|
result = parse_columns(None, no_desc=True)
|
|
|
|
assert "description" not in result
|
|
|
|
def test_render_dashboard_with_columns(self):
|
|
"""Only specified columns appear in the output."""
|
|
from gitea_dashboard.display import render_dashboard
|
|
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="test", open_issues=5)]
|
|
|
|
render_dashboard(repos, console=console, columns=["name", "issues"])
|
|
output = buf.getvalue()
|
|
|
|
assert "test" in output
|
|
assert "Description" not in output
|
|
assert "Release" not in output
|
|
|
|
def test_render_dashboard_activity_column(self):
|
|
"""Activity column renders relative date from last_commit_date."""
|
|
from gitea_dashboard.display import render_dashboard
|
|
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="active-repo", last_commit_date="2026-03-10T14:30:00Z")]
|
|
|
|
render_dashboard(repos, console=console, columns=["name", "activity"])
|
|
output = buf.getvalue()
|
|
|
|
assert "Activite" in output
|
|
assert "active-repo" in output
|
|
assert "il y a" in output or "aujourd'hui" in output
|
|
|
|
def test_render_dashboard_activity_column_no_commit(self):
|
|
"""Activity column shows dash when no commit date."""
|
|
from gitea_dashboard.display import render_dashboard
|
|
|
|
console, buf = _make_console()
|
|
repos = [_make_repo(name="empty-repo", last_commit_date=None)]
|
|
|
|
render_dashboard(repos, console=console, columns=["name", "activity"])
|
|
output = buf.getvalue()
|
|
|
|
assert "\u2014" in output or "\u2014" in output
|