feat: add --my-tasks mode for open assigned issues (#71)

List open Redmine issues assigned to the current (or overridden) user,
sorted by priority rank (P1..P5) then version due date with a stable id
tie-break. Renders an OSC 8 hyperlinked console table on stdout and a
counts/hours summary line on stderr; version due dates are fetched once
per unique version, unavailable versions degrade to an empty due date.

Also migrates the codebase to ruff 0.17.3 defaults (PEP 585/604
annotations, targeted noqa for intentional blind excepts and CLI root
logging). Behavior-neutral: 408 tests pass with -W error.
This commit is contained in:
Кокос Артем Николаевич
2026-10-28 15:26:41 +07:00
parent 3ce4f408ed
commit 5d1cf4ab74
27 changed files with 1255 additions and 137 deletions

282
tests/test_task_list.py Normal file
View File

@@ -0,0 +1,282 @@
"""Тесты доменного ядра режима --my-tasks (#71): строки, сортировка, саммари."""
from datetime import date
from unittest import mock
from redmine_reporter.task_list import (
PRIORITY_RANK,
build_task_rows,
calculate_task_summary,
format_summary_line,
priority_rank,
)
TODAY = date(2026, 10, 28)
def make_issue(
issue_id,
priority="P3",
status="New",
fixed_version=None,
subject="Задача",
due=None,
spent_hours=0.0,
estimated_hours=None,
):
"""Собирает mock-issue с nested-атрибутами priority.name / fixed_version.name.
fixed_version=None присваивается явно: unset-атрибут MagicMock был бы
автосконструирован, и getattr() вернул бы его вместо безопасного дефолта.
"""
issue = mock.MagicMock()
issue.id = issue_id
issue.subject = subject
issue.status = status
issue.priority.name = priority
if fixed_version is None:
issue.fixed_version = None
else:
issue.fixed_version.name = fixed_version
issue.spent_hours = spent_hours
# None присваивается явно, чтобы getattr() в реализации не наткнулся
# на автосконструированный MagicMock-атрибут.
issue.estimated_hours = estimated_hours
return issue
# -- Приоритеты --
def test_priority_rank_table():
assert PRIORITY_RANK == {"P1": 1, "P2": 2, "P3": 3, "P4": 4, "P5": 5}
def test_priority_rank_known():
assert priority_rank("P1") == 1
assert priority_rank("P5") == 5
def test_priority_rank_unknown_is_lowest():
"""Неизвестное имя приоритета получает ранг ниже P5."""
assert priority_rank("P9") == 6
assert priority_rank("") == 6
def test_rows_sorted_by_priority():
"""P1 раньше P2 раньше P4; неизвестный P9 -- после P5."""
items = [
(make_issue(4, priority="P4"), None),
(make_issue(9, priority="P9"), None),
(make_issue(2, priority="P2"), None),
(make_issue(5, priority="P5"), None),
(make_issue(1, priority="P1"), None),
]
rows = build_task_rows(items, {}, today=TODAY)
assert [r["issue_id"] for r in rows] == [1, 2, 4, 5, 9]
# -- Сортировка внутри приоритетной группы --
def test_rows_due_ascending_within_priority():
items = [
(make_issue(2, priority="P2"), date(2026, 3, 10)),
(make_issue(1, priority="P2"), date(2026, 1, 1)),
(make_issue(3, priority="P2"), date(2026, 2, 15)),
]
rows = build_task_rows(items, {}, today=TODAY)
assert [r["issue_id"] for r in rows] == [1, 3, 2]
def test_rows_without_due_go_last_and_tiebreak_by_id():
"""Задачи без срока -- в конец своей группы; между ними tie-break по id."""
items = [
(make_issue(25, fixed_version="v2"), None), # версия есть, срока нет
(make_issue(30, fixed_version="v1"), date(2026, 2, 1)),
(make_issue(12), None), # версии нет, срока нет
]
rows = build_task_rows(items, {}, today=TODAY)
assert [r["issue_id"] for r in rows] == [30, 12, 25]
# -- Просроченность --
def test_overdue_yesterday():
rows = build_task_rows([(make_issue(1), date(2026, 10, 27))], {}, today=TODAY)
assert rows[0]["overdue"] is True
def test_not_overdue_today():
"""Срок сегодня -- НЕ просрочен (строгое сравнение)."""
rows = build_task_rows([(make_issue(1), date(2026, 10, 28))], {}, today=TODAY)
assert rows[0]["overdue"] is False
def test_not_overdue_tomorrow():
rows = build_task_rows([(make_issue(1), date(2026, 10, 29))], {}, today=TODAY)
assert rows[0]["overdue"] is False
def test_not_overdue_without_due():
rows = build_task_rows([(make_issue(1), None)], {}, today=TODAY)
assert rows[0]["overdue"] is False
# -- Поля строки --
def test_row_fields():
issue = make_issue(
42,
priority="P1",
status="New",
fixed_version="v2.5.0",
subject="Починить баг",
)
rows = build_task_rows([(issue, date(2026, 10, 27))], {}, today=TODAY)
row = rows[0]
assert row["issue_id"] == 42
assert row["subject"] == "Починить баг"
assert row["priority"] == "P1"
assert row["version_name"] == "v2.5.0"
assert row["due_text"] == "2026-10-27"
assert row["overdue"] is True
def test_row_empty_version_and_due_text():
"""Без версии и без срока в строке пустые строки, а не заглушки."""
rows = build_task_rows([(make_issue(7), None)], {}, today=TODAY)
assert rows[0]["version_name"] == ""
assert rows[0]["due_text"] == ""
# -- Перевод статусов --
def test_status_translated_by_dict():
issue = make_issue(1, status="New")
rows = build_task_rows([(issue, None)], {"New": "Новая"}, today=TODAY)
assert rows[0]["status_ru"] == "Новая"
def test_status_unknown_passthrough():
issue = make_issue(1, status="SomeNewStatus")
rows = build_task_rows([(issue, None)], {"New": "Новая"}, today=TODAY)
assert rows[0]["status_ru"] == "SomeNewStatus"
# -- Саммари --
def test_summary_mixed_items():
"""Смесь: без версии / версия без срока / просроченная / с оценками и без."""
items = [
# без версии, без срока, без оценки
(make_issue(1, spent_hours=1.5), None),
# версия есть, срока нет, оценка есть
(make_issue(2, fixed_version="v1", spent_hours=2.0, estimated_hours=4.0), None),
# просроченная, оценка есть
(
make_issue(3, fixed_version="v2", spent_hours=0.5, estimated_hours=3.0),
date(2026, 10, 27),
),
# не просроченная, оценка None
(
make_issue(4, fixed_version="v3", spent_hours=0.0),
date(2026, 10, 29),
),
]
summary = calculate_task_summary(items, today=TODAY)
assert summary["total"] == 4
assert summary["overdue"] == 1
assert summary["no_version"] == 1
assert summary["no_due"] == 1
assert summary["spent_hours"] == 4.0
assert summary["estimated_hours"] == 7.0
assert summary["estimated_count"] == 2
def test_summary_empty():
summary = calculate_task_summary([], today=TODAY)
assert summary == {
"total": 0,
"overdue": 0,
"no_version": 0,
"no_due": 0,
"spent_hours": 0.0,
"estimated_hours": 0.0,
"estimated_count": 0,
}
def test_summary_missing_attributes_default_to_zero():
"""У issue без атрибутов spent_hours/estimated_hours безопасные нули."""
issue = mock.MagicMock(spec=["id", "subject", "status", "priority"])
issue.id = 5
issue.status = "New"
issue.priority.name = "P3"
summary = calculate_task_summary([(issue, None)], today=TODAY)
assert summary["spent_hours"] == 0.0
assert summary["estimated_hours"] == 0.0
assert summary["estimated_count"] == 0
assert summary["no_version"] == 1
assert summary["no_due"] == 0
# -- Строка саммари --
def test_format_summary_line_with_estimates():
summary = {
"total": 4,
"overdue": 1,
"no_version": 1,
"no_due": 1,
"spent_hours": 4.0,
"estimated_hours": 7.0,
"estimated_count": 2,
}
line = format_summary_line(summary)
assert line == (
"Открытых: 4 | просроченных: 1 | без версии: 1 | без срока: 1 "
"| потрачено: 4h | оценка: 7h (по 2 из 4)"
)
def test_format_summary_line_without_estimates():
summary = {
"total": 3,
"overdue": 0,
"no_version": 2,
"no_due": 1,
"spent_hours": 2.5,
"estimated_hours": 0.0,
"estimated_count": 0,
}
line = format_summary_line(summary)
assert "оценка" not in line
assert line == (
"Открытых: 3 | просроченных: 0 | без версии: 2 | без срока: 1 | потрачено: 2.5h"
)
def test_format_summary_line_fractional_hours_use_g_format():
"""Часы форматируются :g -- без хвостовых нулей."""
summary = {
"total": 1,
"overdue": 0,
"no_version": 0,
"no_due": 0,
"spent_hours": 21.5,
"estimated_hours": 7.25,
"estimated_count": 1,
}
line = format_summary_line(summary)
assert "21.5h" in line
assert "7.25h" in line
assert "21.500000" not in line