Files
redmine-reporter/tests/test_task_list.py
Кокос Артем Николаевич 5d1cf4ab74 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.
2026-10-28 15:26:41 +07:00

283 lines
8.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Тесты доменного ядра режима --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