"""Тесты доменного ядра режима --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