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

View File

@@ -1,9 +1,10 @@
import os
from datetime import date
from unittest import mock
import pytest
from redmine_reporter.cli import main, parse_date_range
from redmine_reporter.client import RedmineAPIError
VALID_ENV = {
"REDMINE_URL": "https://red.eltex.loc",
@@ -1997,3 +1998,153 @@ class TestSendConfirmation:
assert "bcc@example.com" in captured.out
assert output in captured.out
mock_input.assert_called_once_with("Отправить? [y/N]: ")
# ---------------------------------------------------------------------------
# #71: --my-tasks tests
# ---------------------------------------------------------------------------
def _make_task_issue(issue_id, subject="Задача", status="New", priority="P3"):
"""Mock-issue с явно заданными атрибутами: MagicMock-автогенерация недопустима.
build_task_rows читает nested-атрибуты (priority.name); unset-атрибут
MagicMock был бы автосконструирован и str() дал бы нестабильный текст.
"""
issue = mock.MagicMock()
issue.id = issue_id
issue.subject = subject
issue.status = status
issue.priority.name = priority
issue.fixed_version = None
issue.spent_hours = 0.0
issue.estimated_hours = None
return issue
def _expected_link(issue_id):
"""Точная OSC 8-последовательность для ссылки на задачу в stdout."""
return (
f"\x1b]8;;https://red.eltex.loc/issues/{issue_id}\x1b\\{issue_id}\x1b]8;;\x1b\\"
)
class TestMyTasksFlag:
"""Tests for --my-tasks flag (#71)."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@pytest.mark.parametrize(
"flag_args",
[
["--date", "2026-01-01--2026-01-31"],
["--output", "/tmp/report.xlsx"],
["--compact"],
["--summary"],
["--by-activity"],
["--no-time"],
["--commit"],
["--send"],
],
)
def test_my_tasks_rejects_report_flags(self, flag_args, capsys):
"""--my-tasks несовместим с каждым отчётным флагом: выход 1 и сообщение."""
code = main(["--my-tasks"] + flag_args)
captured = capsys.readouterr()
assert code == 1
assert "--my-tasks cannot be used with report-generating flags" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_empty_list_exits_zero(self, mock_fetch, capsys):
"""Пустой список открытых задач -- выход 0 и сообщение в stderr."""
mock_fetch.return_value = []
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 0
assert "Открытых задач нет" in captured.err
assert captured.out == ""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_passes_user_id(self, mock_fetch):
"""--user-id передаётся в fetch_open_issues."""
mock_fetch.return_value = []
code = main(["--my-tasks", "--user-id", "42"])
assert code == 0
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] == "42"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_no_user_flag_passes_none(self, mock_fetch):
"""Без флагов пользователя в fetch_open_issues уходит user_id=None."""
mock_fetch.return_value = []
code = main(["--my-tasks"])
assert code == 0
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] is None
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_happy_path(self, mock_fetch, capsys):
"""Таблица с OSC 8-ссылкой и переведённым статусом в stdout; саммари в stderr."""
issue_overdue = _make_task_issue(42, subject="Починить баг")
issue_fresh = _make_task_issue(43, subject="Добавить фичу", status="Pending")
# Срок глубоко в прошлом: overdue не зависит от даты прогона тестов.
mock_fetch.return_value = [
(issue_fresh, None),
(issue_overdue, date(2000, 1, 1)),
]
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 0
# Ссылка на base_url из env теста с точной escape-последовательностью.
assert _expected_link(42) in captured.out
assert _expected_link(43) in captured.out
# Переведённые статусы попадают в таблицу.
assert "В работе" in captured.out
assert "Ожидание" in captured.out
assert "Починить баг" in captured.out
# Саммари -- в stderr, таблица -- не там.
assert "Открытых: 2" in captured.err
assert "просроченных: 1" in captured.err
assert "потрачено:" in captured.err
assert "Открытых:" not in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_api_error_exits_one(self, mock_fetch, capsys):
"""RedmineAPIError -- понятное сообщение и выход 1."""
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 1
assert "Authentication failed" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_unexpected_error_exits_one(self, mock_fetch, capsys):
"""Непредвиденное исключение -- Unexpected error и выход 1."""
mock_fetch.side_effect = RuntimeError("boom")
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 1
assert "Unexpected error" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.TaskTableFormatter")
@mock.patch("redmine_reporter.cli.fetch_open_issues")
def test_my_tasks_format_error_exits_one(
self, mock_fetch, mock_formatter_cls, capsys
):
"""Сбой рендера таблицы -- Formatting error и выход 1, без трейсбека."""
mock_fetch.return_value = [(_make_task_issue(7), None)]
mock_formatter_cls.return_value.format.side_effect = ValueError("boom")
code = main(["--my-tasks"])
captured = capsys.readouterr()
assert code == 1
assert "❌ Formatting error: boom" in captured.err

View File

@@ -1,9 +1,9 @@
import os
from datetime import date
from unittest import mock
import pytest
from redmine_reporter.client import fetch_issues_with_spent_time
from redmine_reporter.client import fetch_issues_with_spent_time, fetch_open_issues
from redmine_reporter.config import DEFAULT_REDMINE_VERIFY
PASSWORD_ENV = {
@@ -57,7 +57,7 @@ def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
assert result is not None
assert len(result) == 1
issue, total_hours, _activities = result[0]
_issue, total_hours, _activities = result[0]
assert total_hours == 3.5
@@ -805,3 +805,200 @@ def test_fetch_works_without_dedup_argument(mock_redmine_class):
assert result is not None
assert [r[0].id for r in result] == [1]
assert result[0][1] == 2.0
# -- #71: fetch_open_issues --
def _make_open_issue(issue_id, version_id=None):
"""Создаёт mock открытой задачи; без version_id эмулирует отсутствие fixed_version.
Спека без fixed_version нужна, чтобы getattr(issue, "fixed_version", None)
вернул None, а не auto-атрибут MagicMock.
"""
if version_id is None:
issue = mock.MagicMock(spec=["id", "subject", "project"])
issue.id = issue_id
return issue
issue = mock.MagicMock()
issue.id = issue_id
issue.fixed_version.id = version_id
return issue
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_filters_by_user_and_open_status(mock_redmine_class):
"""issue.filter вызван ровно с assigned_to_id и status_id="open" (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = []
fetch_open_issues()
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=123, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_default_user_is_current(mock_redmine_class):
"""Без user_id резолвится текущий пользователь через user.get("current") (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=77)
mock_redmine.issue.filter.return_value = []
fetch_open_issues()
mock_redmine.user.get.assert_called_once_with("current")
_, kwargs = mock_redmine.issue.filter.call_args
assert kwargs["assigned_to_id"] == 77
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_numeric_user_id_skips_user_lookup(mock_redmine_class):
"""Числовой user_id: user.get не вызывается, фильтр по переданному ID (#71)."""
mock_redmine = mock_redmine_class.return_value
mock_redmine.issue.filter.return_value = []
fetch_open_issues(user_id=42)
mock_redmine.user.get.assert_not_called()
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=42, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_resolves_login_to_id(mock_redmine_class):
"""Строковый логин разрешается в числовой ID как в fetch_issues_with_spent_time (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine)
mock_user = mock.MagicMock()
mock_user.id = 7
mock_user.login = "ivanov"
mock_redmine.user.filter.return_value = [mock_user]
mock_redmine.issue.filter.return_value = []
fetch_open_issues(user_id="ivanov")
login_filter_call = mock_redmine.user.filter.call_args_list[0]
assert login_filter_call.kwargs == {"login": "ivanov"}
mock_redmine.issue.filter.assert_called_once_with(
assigned_to_id=7, status_id="open"
)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_fetches_each_unique_version_once(mock_redmine_class):
"""Две задачи с одной версией -> один version.get на версию (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2, version_id=5),
_make_open_issue(3, version_id=6),
]
result = fetch_open_issues()
assert mock_redmine.version.get.call_count == 2
version_ids = [call.args[0] for call in mock_redmine.version.get.call_args_list]
assert version_ids == [5, 6]
assert len(result) == 3
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_attaches_version_due_date(mock_redmine_class):
"""due_date из version.get попадает в кортеж задачи (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
result = fetch_open_issues()
assert result is not None
issue, due = result[0]
assert issue.id == 1
assert due == date(2026, 11, 1)
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_issue_without_version_has_empty_due(mock_redmine_class):
"""Задача без fixed_version: version.get для неё не вызывается, due None (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2),
]
mock_redmine.version.get.return_value = mock.MagicMock(due_date=date(2026, 11, 1))
result = fetch_open_issues()
mock_redmine.version.get.assert_called_once_with(5)
assert result is not None
assert result[1][0].id == 2
assert result[1][1] is None
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_version_failure_warns_and_continues(mock_redmine_class, capsys):
"""Сбой version.get: warning в stderr, due None, общий результат не валится (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [_make_open_issue(1, version_id=5)]
mock_redmine.version.get.side_effect = Exception("version boom")
result = fetch_open_issues()
assert result is not None
assert result[0][1] is None
captured = capsys.readouterr()
assert "Could not load version 5" in captured.err
assert "version boom" in captured.err
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_result_length_matches_issues(mock_redmine_class):
"""Результат по длине и порядку соответствует списку задач из API (#71)."""
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.return_value = [
_make_open_issue(1, version_id=5),
_make_open_issue(2),
_make_open_issue(3, version_id=6),
]
result = fetch_open_issues()
assert result is not None
assert len(result) == 3
assert [issue.id for issue, _ in result] == [1, 2, 3]
@mock.patch.dict(os.environ, PASSWORD_ENV, clear=True)
@mock.patch("redmine_reporter.client.Redmine")
def test_open_issues_wraps_api_errors(mock_redmine_class):
"""Сбой issue.filter оборачивается в RedmineAPIError с понятным сообщением (#71)."""
from redminelib.exceptions import AuthError
mock_redmine = mock_redmine_class.return_value
_configure_current_user(mock_redmine, user_id=123)
mock_redmine.issue.filter.side_effect = AuthError()
from redmine_reporter.client import RedmineAPIError
with pytest.raises(RedmineAPIError, match="Authentication failed"):
fetch_open_issues()

View File

@@ -2,10 +2,10 @@ import contextlib
import os
import tempfile
from pathlib import Path
from typing import ClassVar
from unittest import mock
import pytest
from redmine_reporter.config import AppConfig, Config, EmailConfig
@@ -194,9 +194,8 @@ class TestDefaultDateRangePeriodFrom:
yaml_path.write_text("period:\n from: 'not-a-date'\n")
Config._app = AppConfig.from_yaml(yaml_path)
with caplog.at_level(logging.WARNING):
with mock_today(date(2026, 9, 15)):
rng = Config.get_default_date_range()
with caplog.at_level(logging.WARNING), mock_today(date(2026, 9, 15)):
rng = Config.get_default_date_range()
assert rng == "2026-09-01--2026-09-15"
assert "not-a-date" in caplog.text
@@ -208,9 +207,8 @@ class TestDefaultDateRangePeriodFrom:
from datetime import date
Config._app = None
with caplog.at_level(logging.WARNING):
with mock_today(date(2026, 9, 15)):
rng = Config.get_default_date_range()
with caplog.at_level(logging.WARNING), mock_today(date(2026, 9, 15)):
rng = Config.get_default_date_range()
assert rng == "2026-09-01--2026-09-15"
assert "garbage" in caplog.text
@@ -296,7 +294,10 @@ class TestConfigRequiresHttpsUrl:
class TestWarnWhenTlsVerificationDisabled:
"""verify_ssl: false должен давать видимый warning в stderr (риск MITM)."""
_ENV = {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_API_KEY": "token"}
_ENV: ClassVar[dict[str, str]] = {
"REDMINE_URL": "https://red.eltex.loc/",
"REDMINE_API_KEY": "token",
}
def _validate_env(self, verify: str):
return {**self._ENV, "REDMINE_VERIFY": verify}
@@ -661,9 +662,11 @@ class TestConfigYamlFallback:
Config._app = AppConfig.from_yaml(yaml_path)
with mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True):
with mock_today(date(2026, 7, 15)):
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
with (
mock.patch.dict(os.environ, {"FROM_DATE": "2026-07-01"}, clear=True),
mock_today(date(2026, 7, 15)),
):
assert Config.get_default_date_range() == "2026-07-01--2026-07-15"
class TestGetEmailConfig:

View File

@@ -1,11 +1,9 @@
import io
import json
from typing import List
from unittest import mock
import pytest
from odf.opendocument import OpenDocument, OpenDocumentText
from redmine_reporter.formatters.console import CompactFormatter, TableFormatter
from redmine_reporter.formatters.csv import CSVFormatter
from redmine_reporter.formatters.factory import get_formatter_by_extension
@@ -25,7 +23,7 @@ def _make_empty_odt_bytes() -> bytes:
return buf.getvalue()
def make_fake_report_rows() -> List[ReportRow]:
def make_fake_report_rows() -> list[ReportRow]:
"""
Генерирует фейковый отчёт с полным покрытием логики группировки:
- Проект A: версия v1.0 (2 задачи), версия v2.0 (1 задача)

View File

@@ -2,7 +2,6 @@ import smtplib
from unittest import mock
import pytest
from redmine_reporter.client import RedmineAPIError
from redmine_reporter.config import EmailConfig, SmtpConfig
from redmine_reporter.mailer import build_message, send_report
@@ -65,7 +64,7 @@ class TestBuildMessage:
data = buf.getvalue()
assert b"Content-Transfer-Encoding: 8bit" in data
assert "Автор: Иванов".encode("utf-8") in data
assert "Автор: Иванов".encode() in data
def test_plain_part_uses_8bit_utf8(self):
"""Тела письма кодируются 8bit UTF-8 (не base64), точечно на часть."""
@@ -74,7 +73,7 @@ class TestBuildMessage:
)
msg = build_message(cfg, "/tmp/r.xlsx", "Иванов", "Q1", [])
plain = [p for p in msg.walk() if p.get_content_type() == "text/plain"][0]
plain = next(p for p in msg.walk() if p.get_content_type() == "text/plain")
assert plain["Content-Transfer-Encoding"] == "8bit"
assert (
plain.get_payload(decode=True).decode("utf-8")
@@ -205,7 +204,7 @@ class TestBuildMessage:
]
msg = build_message(cfg, str(report), "A", "P", rows)
html_part = [p for p in msg.walk() if p.get_content_type() == "text/html"][0]
html_part = next(p for p in msg.walk() if p.get_content_type() == "text/html")
payload = html_part.get_payload(decode=True).decode("utf-8")
assert "Project X" in payload
assert "Important task" in payload
@@ -228,9 +227,9 @@ class TestBuildMessage:
]
msg = build_message(cfg, str(report), "A", "P", rows)
alternative = [
alternative = next(
p for p in msg.walk() if p.get_content_type() == "multipart/alternative"
][0]
)
payloads = alternative.get_payload()
assert payloads[0].get_content_type() == "text/plain"
assert payloads[1].get_content_type() == "text/html"
@@ -273,9 +272,11 @@ class TestSendReport:
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")):
with pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"):
send_report(cfg, str(report), "A", "P", [])
with (
mock.patch("smtplib.SMTP", side_effect=OSError("Connection refused")),
pytest.raises(RedmineAPIError, match="Не удалось подключиться к SMTP"),
):
send_report(cfg, str(report), "A", "P", [])
def test_send_auth_error(self, tmp_path):
report = tmp_path / "report.xlsx"
@@ -297,9 +298,11 @@ class TestSendReport:
report.write_text("fake content")
cfg = _make_email_config()
with mock.patch("smtplib.SMTP", side_effect=TimeoutError()):
with pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"):
send_report(cfg, str(report), "A", "P", [])
with (
mock.patch("smtplib.SMTP", side_effect=TimeoutError()),
pytest.raises(RedmineAPIError, match="Таймаут соединения с SMTP"),
):
send_report(cfg, str(report), "A", "P", [])
def test_send_generic_smtp_error(self, tmp_path):
report = tmp_path / "report.xlsx"

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

160
tests/test_task_table.py Normal file
View File

@@ -0,0 +1,160 @@
"""Тесты консольного форматтера таблицы режима --my-tasks (#71)."""
import re
from typing import cast
import pytest
from redmine_reporter.formatters.task_table import TaskTableFormatter
from redmine_reporter.types import TaskRow
BASE_URL = "https://red.eltex.loc"
# OSC 8-гиперссылка: esc-последовательность до текста, текст, закрытие ссылки.
OSC8_RE = re.compile(r"\x1b\]8;;[^\x1b]*\x1b\\")
def make_row(**overrides) -> TaskRow:
row: TaskRow = {
"issue_id": 42,
"subject": "Починить баг",
"status_ru": "Новая",
"priority": "P2",
"version_name": "v2.5.0",
"due_text": "2026-10-15",
"overdue": False,
}
# **-распаковка dict[str, Any] в TypedDict-литерале mypy не поддерживает,
# а kwargs-словарь типизируется только как dict[str, Any], поэтому приведение.
row.update(cast(TaskRow, overrides))
return row
def expected_hyperlink(issue_id, base_url=BASE_URL):
return f"\x1b]8;;{base_url}/issues/{issue_id}\x1b\\{issue_id}\x1b]8;;\x1b\\"
# -- OSC 8-гиперссылка на ID --
def test_id_wrapped_in_exact_osc8_sequence():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row()])
assert expected_hyperlink(42) in output
def test_base_url_trailing_slash_not_duplicated():
"""base_url с завершающим слэшем не даёт двойного слэша перед /issues."""
output = TaskTableFormatter(base_url=BASE_URL + "/").format([make_row()])
assert expected_hyperlink(42) in output
assert "//issues/" not in output
def test_hyperlink_text_is_issue_number_only():
"""Текст ссылки -- только номер, тема не попадает внутрь OSC 8."""
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(issue_id=7, subject="Тема задачи")]
)
assert expected_hyperlink(7) in output
assert "Тема задачи" in output
start = output.index("Тема задачи")
assert "\x1b" not in output[:start].replace(expected_hyperlink(7), "")
def test_hyperlink_uses_row_issue_id():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row(issue_id=1234)])
assert expected_hyperlink(1234) in output
# -- Заполнение пустых полей --
def test_empty_version_rendered_as_dash():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row(version_name="")])
assert "-" in output
def test_empty_due_rendered_as_dash():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row(due_text="")])
assert "-" in output
def test_nonempty_fields_not_replaced_by_dash():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row()])
assert "v2.5.0" in output
assert "2026-10-15" in output
# -- Маркер просрочки --
def test_overdue_due_appends_marker():
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(due_text="2026-10-15", overdue=True)]
)
assert "2026-10-15 (!)" in output
def test_not_overdue_due_without_marker():
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(due_text="2026-10-15", overdue=False)]
)
assert "2026-10-15" in output
assert "(!)" not in output
# -- Заголовки и содержимое --
def test_column_headers_present():
output = TaskTableFormatter(base_url=BASE_URL).format([make_row()])
for header in ("ID", "Тема", "Статус", "Приор.", "Версия", "Срок"):
assert header in output
def test_status_passed_through_as_is():
"""status_ru из TaskRow попадает в таблицу без изменений."""
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(status_ru="В работе")]
)
assert "В работе" in output
def test_subject_and_priority_present():
output = TaskTableFormatter(base_url=BASE_URL).format(
[make_row(subject="Обновить драйвер", priority="P1")]
)
assert "Обновить драйвер" in output
assert "P1" in output
def test_multiple_rows_rendered():
rows = [make_row(issue_id=1), make_row(issue_id=2)]
output = TaskTableFormatter(base_url=BASE_URL).format(rows)
assert expected_hyperlink(1) in output
assert expected_hyperlink(2) in output
def test_empty_rows_renders_headers_only():
output = TaskTableFormatter(base_url=BASE_URL).format([])
assert "ID" in output
assert expected_hyperlink(42) not in output
# -- ESC только внутри ссылок --
def test_esc_only_inside_hyperlinks():
"""После вырезания OSC 8-ссылок в строке не остаётся ESC-символов."""
rows = [
make_row(issue_id=1),
make_row(issue_id=2, version_name=""),
make_row(issue_id=3, due_text="", overdue=True),
]
output = TaskTableFormatter(base_url=BASE_URL).format(rows)
cleaned = OSC8_RE.sub("", output)
assert "\x1b" not in cleaned
def test_save_raises_not_implemented():
formatter = TaskTableFormatter(base_url=BASE_URL)
with pytest.raises(NotImplementedError):
formatter.save([], "unused.xlsx")

View File

@@ -110,3 +110,21 @@ def test_get_version_falls_back_to_str_when_no_name():
fixed_version = MockVersionNoName()
assert get_version(MockIssue()) == "fallback-id"
def test_get_version_custom_default():
"""Явный default заменяет <N/A>, когда версия не назначена."""
class MockIssue:
pass
assert get_version(MockIssue(), default="") == ""
def test_get_version_custom_default_not_used_when_version_present():
"""При наличии версии default не применяется."""
class MockIssue:
fixed_version = "v1.0"
assert get_version(MockIssue(), default="") == "v1.0"

View File

@@ -5,7 +5,6 @@ from pathlib import Path
from unittest import mock
import yaml
from redmine_reporter.yaml_config import (
check_file_permissions,
ensure_config_dir,