Quick fixes & tests
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,6 +134,7 @@ 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)
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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": "2ч",
|
"time_text": "2ч",
|
||||||
},
|
},
|
||||||
# Проект 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": "1ч",
|
"time_text": "1ч",
|
||||||
},
|
},
|
||||||
# Проект 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": "0ч",
|
"time_text": "0ч",
|
||||||
},
|
},
|
||||||
# Проект 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()
|
|
||||||
result = formatter.format(fake_rows)
|
|
||||||
|
|
||||||
if name == "odt":
|
|
||||||
assert isinstance(result, OpenDocument)
|
|
||||||
else:
|
|
||||||
assert isinstance(result, str)
|
assert isinstance(result, str)
|
||||||
assert len(result.strip()) > 0
|
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"
|
|
||||||
|
# -- Тесты консольных форматтеров --
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
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))
|
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
|
|
||||||
|
|||||||
@@ -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"] == "2ч"
|
assert rows[0]["time_text"] == "2ч"
|
||||||
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"] == "Моя задача"
|
||||||
|
|||||||
@@ -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) == "0ч"
|
assert hours_to_human(0) == "0ч"
|
||||||
|
assert hours_to_human(-1) == "0ч"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hours_to_human_whole_hours():
|
||||||
assert hours_to_human(1.0) == "1ч"
|
assert hours_to_human(1.0) == "1ч"
|
||||||
assert hours_to_human(2.5) == "2ч 30м"
|
assert hours_to_human(8.0) == "8ч"
|
||||||
|
|
||||||
|
|
||||||
|
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ч 10м" # ≈ 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"
|
||||||
|
|||||||
Reference in New Issue
Block a user