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:
282
tests/test_task_list.py
Normal file
282
tests/test_task_list.py
Normal 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
|
||||
Reference in New Issue
Block a user