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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 задача)
|
||||
|
||||
@@ -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
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
|
||||
160
tests/test_task_table.py
Normal file
160
tests/test_task_table.py
Normal 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")
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user