Tasks in the "Wishlist" bucket version are now excluded from the list and summary entirely (no due date, noise backlog); exact case match, tasks without a version are unaffected. Document --my-tasks in README and the user guide (sorting, summary metrics, OSC 8 links, stream separation); all examples use synthetic data. Declare ruff known-first-party in pyproject so the lint result is CWD-independent.
351 lines
11 KiB
Python
351 lines
11 KiB
Python
"""Тесты доменного ядра режима --my-tasks (#71): строки, сортировка, саммари."""
|
||
|
||
from datetime import date
|
||
from unittest import mock
|
||
|
||
from redmine_reporter.task_list import (
|
||
EXCLUDED_VERSION_NAMES,
|
||
PRIORITY_RANK,
|
||
build_task_rows,
|
||
calculate_task_summary,
|
||
filter_excluded_versions,
|
||
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]
|
||
|
||
|
||
# -- Фильтр версий-исключений (Wishlist) --
|
||
|
||
|
||
def test_excluded_version_names_constant():
|
||
"""Точный состав бакет-версий, исключаемых из --my-tasks."""
|
||
assert EXCLUDED_VERSION_NAMES == frozenset({"Wishlist"})
|
||
|
||
|
||
def test_filter_excludes_wishlist_issue():
|
||
"""Задача в версии Wishlist удаляется из выборки."""
|
||
items = [
|
||
(make_issue(1, fixed_version="Wishlist"), None),
|
||
(make_issue(2, fixed_version="v2.5.0"), date(2026, 11, 1)),
|
||
]
|
||
kept = filter_excluded_versions(items)
|
||
assert [issue.id for issue, _ in kept] == [2]
|
||
|
||
|
||
def test_filter_keeps_issue_without_version():
|
||
"""Задача без версии фильтром не трогается."""
|
||
items = [(make_issue(3), None)]
|
||
kept = filter_excluded_versions(items)
|
||
assert [issue.id for issue, _ in kept] == [3]
|
||
|
||
|
||
def test_filter_keeps_regular_version_without_due():
|
||
"""Обычная версия без срока -- не бакет, задача остаётся."""
|
||
items = [(make_issue(4, fixed_version="v3.0.0"), None)]
|
||
kept = filter_excluded_versions(items)
|
||
assert [issue.id for issue, _ in kept] == [4]
|
||
|
||
|
||
def test_filter_is_case_sensitive():
|
||
"""Сравнение имени версии точное: wishlist строчными -- НЕ исключение."""
|
||
items = [
|
||
(make_issue(5, fixed_version="wishlist"), None),
|
||
(make_issue(6, fixed_version="WISHLIST"), None),
|
||
(make_issue(7, fixed_version="Wishlist"), None),
|
||
]
|
||
kept = filter_excluded_versions(items)
|
||
assert [issue.id for issue, _ in kept] == [5, 6]
|
||
|
||
|
||
def test_filter_returns_only_excluded_when_all_match():
|
||
items = [(make_issue(8, fixed_version="Wishlist"), None)]
|
||
assert filter_excluded_versions(items) == []
|
||
|
||
|
||
def test_filter_preserves_due_pairing():
|
||
"""Фильтр не теряет парную к задаче дату срока."""
|
||
due = date(2026, 12, 1)
|
||
items = [(make_issue(9, fixed_version="v1"), due)]
|
||
((issue, kept_due),) = filter_excluded_versions(items)
|
||
assert issue.id == 9
|
||
assert kept_due is due
|
||
|
||
|
||
def test_filter_tolerates_version_without_name_attribute():
|
||
"""fixed_version без атрибута name -- задача остаётся (getattr-безопасность)."""
|
||
issue = make_issue(10)
|
||
issue.fixed_version = mock.MagicMock(spec=[])
|
||
items = [(issue, None)]
|
||
kept = filter_excluded_versions(items)
|
||
assert [i.id for i, _ in kept] == [10]
|
||
|
||
|
||
# -- Просроченность --
|
||
|
||
|
||
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
|