Quick fixes & tests

This commit is contained in:
Artem Kokos
2026-03-28 23:55:46 +07:00
parent 06cd57e2c4
commit 7bc6e024c0
13 changed files with 455 additions and 112 deletions

View File

@@ -74,12 +74,23 @@ def main(argv: Optional[List[str]] = None) -> int:
if args.output: if args.output:
output_ext = os.path.splitext(args.output)[1].lower() output_ext = os.path.splitext(args.output)[1].lower()
if not output_ext:
print(
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md или .html",
file=sys.stderr,
)
return 1
formatter = get_formatter_by_extension( formatter = get_formatter_by_extension(
output_ext, author=Config.get_author(args.author), from_date=from_date, to_date=to_date output_ext, author=Config.get_author(args.author), from_date=from_date, to_date=to_date
) )
if not formatter: if not formatter:
print(f"❌ Неизвестный формат файла: {output_ext}", file=sys.stderr) known_exts = ", ".join([".odt", ".csv", ".md", ".html"])
print(
f"❌ Неизвестный формат файла: {output_ext!r}. Поддерживаются: {known_exts}",
file=sys.stderr,
)
return 1 return 1
try: try:
@@ -92,7 +103,7 @@ def main(argv: Optional[List[str]] = None) -> int:
print(f"❌ Import error: {e}", file=sys.stderr) print(f"❌ Import error: {e}", file=sys.stderr)
return 1 return 1
except Exception as e: except Exception as e:
fmt = "ODT" if output_ext == ".odt" else ("CSV" if output_ext == ".csv" else "Markdown") fmt = output_ext.lstrip(".").upper()
print(f"{fmt} export error: {e}", file=sys.stderr) print(f"{fmt} export error: {e}", file=sys.stderr)
return 1 return 1

View File

@@ -1,5 +1,5 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List from typing import Any, List
from ..types import ReportRow from ..types import ReportRow
@@ -7,13 +7,19 @@ class Formatter(ABC):
""" """
Абстрактный базовый класс для всех форматтеров. Абстрактный базовый класс для всех форматтеров.
Определяет общий интерфейс для форматирования отчета. Определяет общий интерфейс для форматирования отчета.
Контракт:
- format() возвращает строку для текстовых форматтеров (CSV, HTML, Markdown, console)
и объект OpenDocument для ODTFormatter.
- save() сохраняет результат в файл; консольные форматтеры бросают NotImplementedError.
""" """
@abstractmethod @abstractmethod
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> Any:
""" """
Форматирует список строк отчета в нужный формат. Форматирует список строк отчета в нужный формат.
Возвращает строковое представление отчета. Для текстовых форматтеров возвращает str.
Для ODTFormatter возвращает объект OpenDocument.
""" """
pass pass
@@ -22,6 +28,6 @@ class Formatter(ABC):
""" """
Сохраняет отформатированный отчет в файл по указанному пути. Сохраняет отформатированный отчет в файл по указанному пути.
Для форматтеров, которые не поддерживают сохранение (например, консольные), Для форматтеров, которые не поддерживают сохранение (например, консольные),
можно вызывать `format` и записывать результат вручную. бросает NotImplementedError.
""" """
pass pass

View File

@@ -8,7 +8,7 @@ from ..types import ReportRow
class CSVFormatter(Formatter): class CSVFormatter(Formatter):
"""Форматтер для экспорта в CSV.""" """Форматтер для экспорта в CSV."""
def __init__(self, **kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:

View File

@@ -6,7 +6,7 @@ from ..types import ReportRow
class HTMLFormatter(Formatter): class HTMLFormatter(Formatter):
"""Форматтер для экспорта отчёта в HTML.""" """Форматтер для экспорта отчёта в HTML."""
def __init__(self, **kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:

View File

@@ -6,7 +6,7 @@ from ..types import ReportRow
class MarkdownFormatter(Formatter): class MarkdownFormatter(Formatter):
"""Форматтер для экспорта в Markdown.""" """Форматтер для экспорта в Markdown."""
def __init__(self, **kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
def format(self, rows: List[ReportRow]) -> str: def format(self, rows: List[ReportRow]) -> str:

View File

@@ -103,7 +103,7 @@ class ODTFormatter(Formatter):
if first_version_in_project and first_row_in_version: if first_version_in_project and first_row_in_version:
cell_project = TableCell(stylename=cell_style_name) cell_project = TableCell(stylename=cell_style_name)
cell_project.setAttribute("numberrowsspanned", str(total_project_rows)) cell_project.setAttribute("numberrowsspanned", str(total_project_rows))
p = P(stylename=para_style_name, text=project) # Полное название проекта p = P(stylename=para_style_name, text=project)
cell_project.addElement(p) cell_project.addElement(p)
row.addElement(cell_project) row.addElement(cell_project)
@@ -115,9 +115,6 @@ class ODTFormatter(Formatter):
cell_version.addElement(p) cell_version.addElement(p)
row.addElement(cell_version) row.addElement(cell_version)
first_row_in_version = False first_row_in_version = False
else:
# Пропускаем - уже объединена
pass
# Остальные колонки # Остальные колонки
task_cell = TableCell(stylename=cell_style_name) task_cell = TableCell(stylename=cell_style_name)
@@ -137,7 +134,8 @@ class ODTFormatter(Formatter):
row.addElement(time_cell) row.addElement(time_cell)
table.addElement(row) table.addElement(row)
first_version_in_project = False
first_version_in_project = False
doc.text.addElement(table) doc.text.addElement(table)
doc.text.addElement(P(stylename=para_style_name, text="")) doc.text.addElement(P(stylename=para_style_name, text=""))

View File

@@ -27,8 +27,14 @@ def build_grouped_report(
""" """
Преобразует список задач с затраченным временем в плоский список строк отчёта, Преобразует список задач с затраченным временем в плоский список строк отчёта,
с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений). с учётом группировки по проекту и версии (пустые ячейки для повторяющихся значений).
Предусловие: issue_hours должен быть отсортирован по (project, version).
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
""" """
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
issue_hours = sorted(issue_hours, key=lambda x: (str(x[0].project), get_version(x[0])))
rows: List[ReportRow] = [] rows: List[ReportRow] = []
prev_project: str = "" prev_project: str = ""
prev_version: str = "" prev_version: str = ""

View File

@@ -1,22 +1,93 @@
import sys import sys
import pytest
from io import StringIO from io import StringIO
from unittest import mock from unittest import mock
from redmine_reporter.cli import main from redmine_reporter.cli import main
from redmine_reporter.config import Config
@mock.patch.dict( # Config читает env при импорте -- патчим класс напрямую.
"os.environ", # fetch_issues_with_spent_time импортируется в cli.py через "from .client import ...",
{"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_USER": "x", "REDMINE_PASSWORD": "y"}, # поэтому мокать нужно имя в модуле cli, а не в client.
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_USER="x",
REDMINE_PASSWORD="y",
) )
@mock.patch("redmine_reporter.client.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_smoke(mock_fetch): def test_cli_smoke_empty(mock_fetch):
"""Пустой список задач -- выход 0, сообщение о 0 задачах."""
mock_fetch.return_value = [] mock_fetch.return_value = []
old_stdout = sys.stdout captured = StringIO()
sys.stdout = captured = StringIO() old_stdout, sys.stdout = sys.stdout, captured
try: try:
code = main(["--date", "2026-01-01--2026-01-31"]) code = main(["--date", "2026-01-01--2026-01-31"])
assert code == 0
output = captured.getvalue()
assert "Total issues: 0" in output
finally: finally:
sys.stdout = old_stdout sys.stdout = old_stdout
assert code == 0
assert "Total issues: 0" in captured.getvalue()
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_USER="x",
REDMINE_PASSWORD="y",
)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_returns_zero_on_no_entries(mock_fetch):
"""None от fetch (нет time entries) -- выход 0."""
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31"])
assert code == 0
@mock.patch.multiple(
Config,
REDMINE_URL="",
REDMINE_USER=None,
REDMINE_PASSWORD=None,
)
def test_cli_config_error():
"""Невалидный конфиг -- выход 1."""
code = main(["--date", "2026-01-01--2026-01-31"])
assert code == 1
def test_cli_invalid_date_format():
"""Неверный формат даты -- выход 1."""
code = main(["--date", "20260101-20260131"])
assert code == 1
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_USER="x",
REDMINE_PASSWORD="y",
)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
"""Неизвестное расширение файла -- выход 1."""
mock_fetch.return_value = []
output = str(tmp_path / "report.xyz")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1
@mock.patch.multiple(
Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_USER="x",
REDMINE_PASSWORD="y",
)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_output_without_extension(mock_fetch, tmp_path):
"""Файл без расширения -- выход 1 с подсказкой."""
mock_fetch.return_value = []
output = str(tmp_path / "report")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1

View File

@@ -4,14 +4,13 @@ from redmine_reporter.client import fetch_issues_with_spent_time
@mock.patch("redmine_reporter.client.Redmine") @mock.patch("redmine_reporter.client.Redmine")
def test_fetch_issues_with_spent_time(mock_redmine_class): def test_fetch_aggregates_hours_per_issue(mock_redmine_class):
# Подготовка моков """Два time entry на одну задачу -- часы суммируются."""
mock_redmine = mock_redmine_class.return_value mock_redmine = mock_redmine_class.return_value
mock_user = mock.MagicMock() mock_user = mock.MagicMock()
mock_user.id = 123 mock_user.id = 123
mock_redmine.user.get.return_value = mock_user mock_redmine.user.get.return_value = mock_user
# Два time entry на одну задачу
mock_entry1 = mock.MagicMock() mock_entry1 = mock.MagicMock()
mock_entry1.issue.id = 101 mock_entry1.issue.id = 101
mock_entry1.hours = 2.0 mock_entry1.hours = 2.0
@@ -20,7 +19,6 @@ def test_fetch_issues_with_spent_time(mock_redmine_class):
mock_entry2.hours = 1.5 mock_entry2.hours = 1.5
mock_redmine.time_entry.filter.return_value = [mock_entry1, mock_entry2] mock_redmine.time_entry.filter.return_value = [mock_entry1, mock_entry2]
# Мок задачи
mock_issue = mock.MagicMock() mock_issue = mock.MagicMock()
mock_issue.id = 101 mock_issue.id = 101
mock_issue.project = "Проект X" mock_issue.project = "Проект X"
@@ -34,3 +32,71 @@ def test_fetch_issues_with_spent_time(mock_redmine_class):
assert len(result) == 1 assert len(result) == 1
issue, total_hours = result[0] issue, total_hours = result[0]
assert total_hours == 3.5 assert total_hours == 3.5
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_returns_none_when_no_entries(mock_redmine_class):
"""Нет time entries -- возвращается None."""
mock_redmine = mock_redmine_class.return_value
mock_user = mock.MagicMock()
mock_user.id = 1
mock_redmine.user.get.return_value = mock_user
mock_redmine.time_entry.filter.return_value = []
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is None
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_skips_entries_without_issue(mock_redmine_class):
"""Time entry без привязки к задаче игнорируется."""
mock_redmine = mock_redmine_class.return_value
mock_user = mock.MagicMock()
mock_user.id = 1
mock_redmine.user.get.return_value = mock_user
# entry без issue атрибута
entry_no_issue = mock.MagicMock(spec=["hours"]) # нет .issue
entry_no_issue.hours = 1.0
mock_redmine.time_entry.filter.return_value = [entry_no_issue]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is None
@mock.patch("redmine_reporter.client.Redmine")
def test_fetch_multiple_issues(mock_redmine_class):
"""Несколько задач -- каждая с правильным суммарным временем."""
mock_redmine = mock_redmine_class.return_value
mock_user = mock.MagicMock()
mock_user.id = 1
mock_redmine.user.get.return_value = mock_user
def make_entry(issue_id, hours):
e = mock.MagicMock()
e.issue.id = issue_id
e.hours = hours
return e
mock_redmine.time_entry.filter.return_value = [
make_entry(1, 1.0),
make_entry(2, 2.0),
make_entry(1, 0.5),
]
mock_issue1 = mock.MagicMock()
mock_issue1.id = 1
mock_issue1.project = "P"
mock_issue2 = mock.MagicMock()
mock_issue2.id = 2
mock_issue2.project = "P"
mock_redmine.issue.filter.return_value = [mock_issue1, mock_issue2]
result = fetch_issues_with_spent_time("2026-01-01", "2026-01-31")
assert result is not None
assert len(result) == 2
hours_by_id = {issue.id: hours for issue, hours in result}
assert hours_by_id[1] == 1.5
assert hours_by_id[2] == 2.0

View File

@@ -3,22 +3,55 @@ import pytest
from unittest import mock from unittest import mock
from redmine_reporter.config import Config from redmine_reporter.config import Config
# Config читает os.getenv() в момент определения класса (class-level атрибуты),
# поэтому mock.patch.dict(os.environ) не помогает -- класс уже загружен.
# Правильный способ -- патчить атрибуты самого класса.
@mock.patch.dict(
os.environ, @mock.patch.multiple(
{"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_USER": "test", "REDMINE_PASSWORD": "secret"}, Config,
REDMINE_URL="https://red.eltex.loc",
REDMINE_USER="test",
REDMINE_PASSWORD="secret",
) )
def test_config_valid(): def test_config_valid():
Config.validate() # не должно быть исключения Config.validate() # не должно быть исключения
@mock.patch.dict(os.environ, {}, clear=True) @mock.patch.multiple(
Config,
REDMINE_URL="",
REDMINE_USER=None,
REDMINE_PASSWORD=None,
)
def test_config_missing(): def test_config_missing():
with pytest.raises(ValueError, match="REDMINE_URL"): with pytest.raises(ValueError, match="REDMINE_URL"):
Config.validate() Config.validate()
@mock.patch.dict(os.environ, {"REDMINE_AUTHOR": "Иванов И.И."}) @mock.patch.multiple(Config, REDMINE_AUTHOR="Иванов И.И.")
def test_get_author(): def test_get_author():
assert Config.get_author("") == "Иванов И.И." assert Config.get_author("") == "Иванов И.И."
assert Config.get_author("Петров П.П.") == "Петров П.П." assert Config.get_author("Петров П.П.") == "Петров П.П."
@mock.patch.multiple(Config, REDMINE_AUTHOR=None)
def test_get_author_fallback():
"""Если ни CLI, ни .env не задали автора -- возвращается пустая строка."""
assert Config.get_author("") == ""
@mock.patch.multiple(
Config,
DEFAULT_FROM_DATE="2026-01-01",
DEFAULT_TO_DATE="2026-01-31",
)
def test_get_default_date_range_from_env():
assert Config.get_default_date_range() == "2026-01-01--2026-01-31"
@mock.patch.multiple(Config, DEFAULT_FROM_DATE=None, DEFAULT_TO_DATE=None)
def test_get_default_date_range_fallback():
"""Если даты не заданы -- используется хардкод-заглушка."""
result = Config.get_default_date_range()
assert "--" in result # формат YYYY-MM-DD--YYYY-MM-DD

View File

@@ -1,12 +1,21 @@
import pytest import pytest
import os import io
from typing import List from typing import List
from unittest import mock
from redmine_reporter.types import ReportRow from redmine_reporter.types import ReportRow
from redmine_reporter.formatters.console import TableFormatter, CompactFormatter from redmine_reporter.formatters.console import TableFormatter, CompactFormatter
from redmine_reporter.formatters.csv import CSVFormatter from redmine_reporter.formatters.csv import CSVFormatter
from redmine_reporter.formatters.markdown import MarkdownFormatter from redmine_reporter.formatters.markdown import MarkdownFormatter
from redmine_reporter.formatters.odt import ODTFormatter from redmine_reporter.formatters.odt import ODTFormatter
from odf.opendocument import OpenDocument from odf.opendocument import OpenDocument, newdoc
def _make_empty_odt_bytes() -> bytes:
"""Создаёт минимальный валидный ODT-документ в памяти."""
doc = newdoc(doctype="odt")
buf = io.BytesIO()
doc.save(buf)
return buf.getvalue()
def make_fake_report_rows() -> List[ReportRow]: def make_fake_report_rows() -> List[ReportRow]:
@@ -16,9 +25,7 @@ def make_fake_report_rows() -> List[ReportRow]:
- Проект B: версия <N/A> (1 задача) - Проект B: версия <N/A> (1 задача)
- Проект C: версия v1.0 (1 задача), версия v1.1 (2 задачи) - Проект C: версия v1.0 (1 задача), версия v1.1 (2 задачи)
""" """
return [ return [
# Проект A, v1.0
{ {
"project": "Проект A", "project": "Проект A",
"version": "v1.0", "version": "v1.0",
@@ -39,7 +46,6 @@ def make_fake_report_rows() -> List[ReportRow]:
"status_ru": "Решена", "status_ru": "Решена",
"time_text": "", "time_text": "",
}, },
# Проект A, v2.0
{ {
"project": "Проект A", "project": "Проект A",
"version": "v2.0", "version": "v2.0",
@@ -50,7 +56,6 @@ def make_fake_report_rows() -> List[ReportRow]:
"status_ru": "Ожидание", "status_ru": "Ожидание",
"time_text": "", "time_text": "",
}, },
# Проект B, без версии
{ {
"project": "Проект B", "project": "Проект B",
"version": "<N/A>", "version": "<N/A>",
@@ -61,7 +66,6 @@ def make_fake_report_rows() -> List[ReportRow]:
"status_ru": "Закрыто", "status_ru": "Закрыто",
"time_text": "", "time_text": "",
}, },
# Проект C, v1.0
{ {
"project": "Проект C", "project": "Проект C",
"version": "v1.0", "version": "v1.0",
@@ -72,7 +76,6 @@ def make_fake_report_rows() -> List[ReportRow]:
"status_ru": "В работе", "status_ru": "В работе",
"time_text": "3ч 15м", "time_text": "3ч 15м",
}, },
# Проект C, v1.1
{ {
"project": "Проект C", "project": "Проект C",
"version": "v1.1", "version": "v1.1",
@@ -101,78 +104,111 @@ def fake_rows():
return make_fake_report_rows() return make_fake_report_rows()
def _get_formatter_output_text(result): @pytest.fixture
"""Преобразует результат форматтера в строку для проверки содержимого.""" def odt_formatter():
"""ODTFormatter с замоканной загрузкой шаблона."""
odt_bytes = _make_empty_odt_bytes()
mock_file = mock.MagicMock()
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
mock_file.__exit__ = mock.MagicMock(return_value=False)
mock_path = mock.MagicMock()
mock_path.open.return_value = mock_file
if isinstance(result, OpenDocument): with mock.patch(
return "" "redmine_reporter.formatters.odt.resources.files",
elif isinstance(result, str): return_value=mock.MagicMock(
return result joinpath=mock.MagicMock(
else: return_value=mock.MagicMock(open=mock.MagicMock(return_value=mock_file))
raise TypeError(f"Unexpected formatter output type: {type(result)}") )
),
):
yield ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31")
FORMATTER_FACTORIES = [ # -- Параметризованные тесты текстовых форматтеров --
TEXT_FORMATTER_FACTORIES = [
("table", lambda: TableFormatter()), ("table", lambda: TableFormatter()),
("compact", lambda: CompactFormatter()), ("compact", lambda: CompactFormatter()),
("csv", lambda: CSVFormatter()), ("csv", lambda: CSVFormatter()),
("markdown", lambda: MarkdownFormatter()), ("markdown", lambda: MarkdownFormatter()),
(
"odt",
lambda: ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31"),
),
] ]
@pytest.mark.parametrize("name, formatter_factory", FORMATTER_FACTORIES) @pytest.mark.parametrize("name, factory", TEXT_FORMATTER_FACTORIES)
def test_formatter_does_not_crash(fake_rows, name, formatter_factory): def test_text_formatter_returns_nonempty_string(fake_rows, name, factory):
"""Проверяем, что форматтер не падает на валидных данных.""" """Текстовые форматтеры возвращают непустую строку."""
result = factory().format(fake_rows)
formatter = formatter_factory() assert isinstance(result, str)
result = formatter.format(fake_rows) assert len(result.strip()) > 0
if name == "odt":
assert isinstance(result, OpenDocument)
else:
assert isinstance(result, str)
assert len(result.strip()) > 0
@pytest.mark.parametrize("name, formatter_factory", FORMATTER_FACTORIES) @pytest.mark.parametrize("name, factory", TEXT_FORMATTER_FACTORIES)
def test_formatter_contains_key_content(fake_rows, name, formatter_factory): def test_text_formatter_contains_key_content(fake_rows, name, factory):
"""Проверяем, что вывод содержит ключевые элементы.""" """Вывод содержит ключевые данные из отчёта."""
output = factory().format(fake_rows)
formatter = formatter_factory() assert "Проект A" in output
result = formatter.format(fake_rows) assert "Проект B" in output
assert "В работе" in output
assert "<N/A>" in output
assert "6ч 45м" in output
output_text = _get_formatter_output_text(result)
if not output_text:
return # Пропускаем ODT
# Общие элементы
assert "Проект A" in output_text
assert "Проект B" in output_text
assert "В работе" in output_text
assert "<N/A>" in output_text
assert "6ч 45м" in output_text
# Специфика по форматам
if name == "csv": if name == "csv":
# В CSV ID и subject отдельные колонки # В CSV issue_id и subject -- отдельные колонки
assert "101" in output_text assert "101" in output
assert "Реализовать фичу X" in output_text assert "Реализовать фичу X" in output
else: else:
# В остальных — вместе assert "101. Реализовать фичу X" in output
assert "101. Реализовать фичу X" in output_text
def test_odt_save_creates_valid_file(fake_rows, tmp_path): @pytest.mark.parametrize("name, factory", TEXT_FORMATTER_FACTORIES)
"""Проверяем, что ODT можно сохранить и он открывается как ZIP.""" def test_text_formatter_empty_rows(name, factory):
"""Форматтер не падает на пустом списке строк."""
result = factory().format([])
assert isinstance(result, str)
output_file = tmp_path / "report.odt"
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31") # -- Тесты консольных форматтеров --
formatter.save(fake_rows, str(output_file))
def test_table_formatter_save_raises(fake_rows):
with pytest.raises(NotImplementedError):
TableFormatter().save(fake_rows, "/dev/null")
def test_compact_formatter_save_raises(fake_rows):
with pytest.raises(NotImplementedError):
CompactFormatter().save(fake_rows, "/dev/null")
# -- Тесты ODT форматтера --
def test_odt_formatter_returns_opendocument(fake_rows, odt_formatter):
"""ODTFormatter.format() возвращает объект OpenDocument."""
result = odt_formatter.format(fake_rows)
assert isinstance(result, OpenDocument)
def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
"""ODT можно сохранить -- файл валиден (сигнатура ZIP)."""
odt_bytes = _make_empty_odt_bytes()
mock_file = mock.MagicMock()
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
mock_file.__exit__ = mock.MagicMock(return_value=False)
with mock.patch(
"redmine_reporter.formatters.odt.resources.files",
return_value=mock.MagicMock(
joinpath=mock.MagicMock(
return_value=mock.MagicMock(open=mock.MagicMock(return_value=mock_file))
)
),
):
formatter = ODTFormatter(author="Тест", from_date="2026-01-01", to_date="2026-01-31")
output_file = tmp_path / "report.odt"
formatter.save(fake_rows, str(output_file))
assert output_file.exists() assert output_file.exists()
with open(output_file, "rb") as f: assert output_file.read_bytes()[:2] == b"PK" # сигнатура ZIP
assert f.read(2) == b"PK" # сигнатура ZIP

View File

@@ -9,18 +9,35 @@ class MockIssue:
self.project = project self.project = project
self.subject = subject self.subject = subject
self.status = status self.status = status
if fixed_version is not None: if fixed_version is not None:
self.fixed_version = fixed_version self.fixed_version = fixed_version
# -- Таблица переводов статусов --
def test_status_translation(): def test_status_translation():
assert STATUS_TRANSLATION["Closed"] == "Закрыто" assert STATUS_TRANSLATION["Closed"] == "Закрыто"
assert STATUS_TRANSLATION["New"] == "В работе" assert STATUS_TRANSLATION["New"] == "В работе"
assert STATUS_TRANSLATION["Resolved"] == "Решена" assert STATUS_TRANSLATION["Resolved"] == "Решена"
assert STATUS_TRANSLATION["Pending"] == "Ожидание"
def test_build_grouped_report(): def test_status_translation_unknown_passthrough():
"""Неизвестный статус возвращается как есть."""
from redmine_reporter.report_builder import STATUS_TRANSLATION
assert "SomeNewStatus" not in STATUS_TRANSLATION
# build_grouped_report вернёт оригинальное значение
issue = MockIssue("P", "S", "SomeNewStatus", "v1.0", 1)
rows = build_grouped_report([(issue, 1.0)])
assert rows[0]["status_ru"] == "SomeNewStatus"
# -- Основная логика группировки --
def test_build_grouped_report_grouping():
issues = [ issues = [
(MockIssue("Камеры", "Фича A", "New", "v2.5.0", 101), 2.0), (MockIssue("Камеры", "Фича A", "New", "v2.5.0", 101), 2.0),
(MockIssue("Камеры", "Баг B", "Resolved", "v2.5.0", 102), 1.5), (MockIssue("Камеры", "Баг B", "Resolved", "v2.5.0", 102), 1.5),
@@ -29,17 +46,64 @@ def test_build_grouped_report():
rows = build_grouped_report(issues) rows = build_grouped_report(issues)
assert len(rows) == 3 assert len(rows) == 3
# Первая строка полное название проекта и версии # Первая строка -- полное название проекта и версии
assert rows[0]["display_project"] == "Камеры" assert rows[0]["display_project"] == "Камеры"
assert rows[0]["display_version"] == "v2.5.0" assert rows[0]["display_version"] == "v2.5.0"
# Вторая пустые display_* из-за совпадения # Вторая -- пустые display_* из-за совпадения проекта+версии
assert rows[1]["display_project"] == "" assert rows[1]["display_project"] == ""
assert rows[1]["display_version"] == "" assert rows[1]["display_version"] == ""
# Третья новый проект # Третья -- новый проект
assert rows[2]["display_project"] == "ПО" assert rows[2]["display_project"] == "ПО"
assert rows[2]["display_version"] == "<N/A>" assert rows[2]["display_version"] == "<N/A>"
# Проверка перевода и времени
assert rows[0]["status_ru"] == "В работе" assert rows[0]["status_ru"] == "В работе"
assert rows[0]["time_text"] == "" assert rows[0]["time_text"] == ""
assert rows[1]["time_text"] == "1ч 30м" assert rows[1]["time_text"] == "1ч 30м"
def test_build_grouped_report_new_version_same_project():
"""Смена версии внутри одного проекта -- display_project пустой, display_version новая."""
issues = [
(MockIssue("Камеры", "Задача 1", "New", "v1.0", 1), 1.0),
(MockIssue("Камеры", "Задача 2", "New", "v2.0", 2), 1.0),
]
rows = build_grouped_report(issues)
assert rows[0]["display_project"] == "Камеры"
assert rows[0]["display_version"] == "v1.0"
assert rows[1]["display_project"] == ""
assert rows[1]["display_version"] == "v2.0"
def test_build_grouped_report_sorts_input():
"""Несортированный вход -- результат всё равно корректно сгруппирован."""
issues = [
(MockIssue("ПО", "Задача B", "New", "v1.0", 2), 1.0),
(MockIssue("Камеры", "Задача A", "New", "v1.0", 1), 2.0),
]
rows = build_grouped_report(issues)
# После сортировки "Камеры" < "ПО" (лексикографически по кириллице)
assert rows[0]["project"] == "Камеры"
assert rows[1]["project"] == "ПО"
# Оба display_project непустые -- разные проекты
assert rows[0]["display_project"] == "Камеры"
assert rows[1]["display_project"] == "ПО"
def test_build_grouped_report_empty():
"""Пустой вход -- пустой результат, без исключений."""
rows = build_grouped_report([])
assert rows == []
def test_build_grouped_report_no_time():
"""fill_time=False -- time_text пустой для всех строк."""
issues = [(MockIssue("P", "S", "New", "v1.0", 1), 3.5)]
rows = build_grouped_report(issues, fill_time=False)
assert rows[0]["time_text"] == ""
def test_build_grouped_report_preserves_issue_id_and_subject():
issues = [(MockIssue("P", "Моя задача", "Closed", "v1.0", 42), 0.5)]
rows = build_grouped_report(issues)
assert rows[0]["issue_id"] == 42
assert rows[0]["subject"] == "Моя задача"

View File

@@ -2,27 +2,79 @@ import pytest
from redmine_reporter.utils import hours_to_human, get_month_name_from_range, get_version from redmine_reporter.utils import hours_to_human, get_month_name_from_range, get_version
def test_hours_to_human(): def test_hours_to_human_zero():
assert hours_to_human(0) == "" assert hours_to_human(0) == ""
assert hours_to_human(-1) == ""
def test_hours_to_human_whole_hours():
assert hours_to_human(1.0) == "" assert hours_to_human(1.0) == ""
assert hours_to_human(2.5) == "2ч 30м" assert hours_to_human(8.0) == ""
def test_hours_to_human_minutes_only():
assert hours_to_human(0.75) == "45м" assert hours_to_human(0.75) == "45м"
assert hours_to_human(3.1666) == "3ч 1" # ≈ 3ч 10м assert hours_to_human(0.5) == "30м"
def test_hours_to_human_mixed():
assert hours_to_human(2.5) == "2ч 30м"
assert hours_to_human(1.5) == "1ч 30м"
def test_hours_to_human_rounding():
assert hours_to_human(3.1666) == "3ч 10м" # 190 минут -> 3ч 10м
def test_get_month_name_from_range(): def test_get_month_name_from_range():
assert get_month_name_from_range("2026-01-01", "2026-01-31") == "Январь" assert get_month_name_from_range("2026-01-01", "2026-01-31") == "Январь"
assert get_month_name_from_range("2025-12-01", "2026-02-15") == "Февраль" # берётся to_date assert get_month_name_from_range("2025-12-01", "2026-02-15") == "Февраль"
assert get_month_name_from_range("invalid", "also_invalid") == "Январь" # fallback
def test_get_version(): def test_get_month_name_from_range_all_months():
months = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
]
for i, name in enumerate(months, start=1):
to_date = f"2026-{i:02d}-01"
assert get_month_name_from_range("2026-01-01", to_date) == name
def test_get_month_name_from_range_invalid_fallback():
"""Невалидная дата -- возвращается 'Январь'."""
assert get_month_name_from_range("invalid", "also_invalid") == "Январь"
def test_get_version_with_attribute():
class MockIssue:
fixed_version = "v2.5.0"
assert get_version(MockIssue()) == "v2.5.0"
def test_get_version_without_attribute():
class MockIssue: class MockIssue:
pass pass
issue_with = MockIssue() assert get_version(MockIssue()) == "<N/A>"
issue_with.fixed_version = "v2.5.0"
assert get_version(issue_with) == "v2.5.0"
issue_without = MockIssue()
assert get_version(issue_without) == "<N/A>" def test_get_version_none_attribute():
"""fixed_version = None -- str(None) == 'None', не '<N/A>'."""
class MockIssue:
fixed_version = None
# get_version возвращает str(getattr(...)), None задан явно -> "None"
assert get_version(MockIssue()) == "None"