From 7bc6e024c0552e562ea227e6181f766f78057946 Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Sat, 28 Mar 2026 23:55:46 +0700 Subject: [PATCH] Quick fixes & tests --- redmine_reporter/cli.py | 15 ++- redmine_reporter/formatters/base.py | 14 ++- redmine_reporter/formatters/csv.py | 2 +- redmine_reporter/formatters/html.py | 2 +- redmine_reporter/formatters/markdown.py | 2 +- redmine_reporter/formatters/odt.py | 8 +- redmine_reporter/report_builder.py | 6 + tests/test_cli.py | 91 ++++++++++++-- tests/test_client.py | 74 ++++++++++- tests/test_config.py | 43 ++++++- tests/test_formatters.py | 160 +++++++++++++++--------- tests/test_report_builder.py | 76 ++++++++++- tests/test_utils.py | 74 +++++++++-- 13 files changed, 455 insertions(+), 112 deletions(-) diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 823f553..734984b 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -74,12 +74,23 @@ def main(argv: Optional[List[str]] = None) -> int: if args.output: 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( output_ext, author=Config.get_author(args.author), from_date=from_date, to_date=to_date ) 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 try: @@ -92,7 +103,7 @@ def main(argv: Optional[List[str]] = None) -> int: print(f"❌ Import error: {e}", file=sys.stderr) return 1 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) return 1 diff --git a/redmine_reporter/formatters/base.py b/redmine_reporter/formatters/base.py index a7c57b0..ce9ec8c 100644 --- a/redmine_reporter/formatters/base.py +++ b/redmine_reporter/formatters/base.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import List +from typing import Any, List from ..types import ReportRow @@ -7,13 +7,19 @@ class Formatter(ABC): """ Абстрактный базовый класс для всех форматтеров. Определяет общий интерфейс для форматирования отчета. + + Контракт: + - format() возвращает строку для текстовых форматтеров (CSV, HTML, Markdown, console) + и объект OpenDocument для ODTFormatter. + - save() сохраняет результат в файл; консольные форматтеры бросают NotImplementedError. """ @abstractmethod - def format(self, rows: List[ReportRow]) -> str: + def format(self, rows: List[ReportRow]) -> Any: """ Форматирует список строк отчета в нужный формат. - Возвращает строковое представление отчета. + Для текстовых форматтеров возвращает str. + Для ODTFormatter возвращает объект OpenDocument. """ pass @@ -22,6 +28,6 @@ class Formatter(ABC): """ Сохраняет отформатированный отчет в файл по указанному пути. Для форматтеров, которые не поддерживают сохранение (например, консольные), - можно вызывать `format` и записывать результат вручную. + бросает NotImplementedError. """ pass diff --git a/redmine_reporter/formatters/csv.py b/redmine_reporter/formatters/csv.py index 26a0ee5..2fa3c63 100644 --- a/redmine_reporter/formatters/csv.py +++ b/redmine_reporter/formatters/csv.py @@ -8,7 +8,7 @@ from ..types import ReportRow class CSVFormatter(Formatter): """Форматтер для экспорта в CSV.""" - def __init__(self, **kwargs): + def __init__(self, **_kwargs): super().__init__() def format(self, rows: List[ReportRow]) -> str: diff --git a/redmine_reporter/formatters/html.py b/redmine_reporter/formatters/html.py index 307c6f8..9202ca9 100644 --- a/redmine_reporter/formatters/html.py +++ b/redmine_reporter/formatters/html.py @@ -6,7 +6,7 @@ from ..types import ReportRow class HTMLFormatter(Formatter): """Форматтер для экспорта отчёта в HTML.""" - def __init__(self, **kwargs): + def __init__(self, **_kwargs): super().__init__() def format(self, rows: List[ReportRow]) -> str: diff --git a/redmine_reporter/formatters/markdown.py b/redmine_reporter/formatters/markdown.py index 5538436..fa5fc85 100644 --- a/redmine_reporter/formatters/markdown.py +++ b/redmine_reporter/formatters/markdown.py @@ -6,7 +6,7 @@ from ..types import ReportRow class MarkdownFormatter(Formatter): """Форматтер для экспорта в Markdown.""" - def __init__(self, **kwargs): + def __init__(self, **_kwargs): super().__init__() def format(self, rows: List[ReportRow]) -> str: diff --git a/redmine_reporter/formatters/odt.py b/redmine_reporter/formatters/odt.py index 32bcc30..1e3b6fc 100644 --- a/redmine_reporter/formatters/odt.py +++ b/redmine_reporter/formatters/odt.py @@ -103,7 +103,7 @@ class ODTFormatter(Formatter): if first_version_in_project and first_row_in_version: cell_project = TableCell(stylename=cell_style_name) 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) row.addElement(cell_project) @@ -115,9 +115,6 @@ class ODTFormatter(Formatter): cell_version.addElement(p) row.addElement(cell_version) first_row_in_version = False - else: - # Пропускаем - уже объединена - pass # Остальные колонки task_cell = TableCell(stylename=cell_style_name) @@ -137,7 +134,8 @@ class ODTFormatter(Formatter): row.addElement(time_cell) table.addElement(row) - first_version_in_project = False + + first_version_in_project = False doc.text.addElement(table) doc.text.addElement(P(stylename=para_style_name, text="")) diff --git a/redmine_reporter/report_builder.py b/redmine_reporter/report_builder.py index 1ba655f..4955e47 100644 --- a/redmine_reporter/report_builder.py +++ b/redmine_reporter/report_builder.py @@ -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] = [] prev_project: str = "" prev_version: str = "" diff --git a/tests/test_cli.py b/tests/test_cli.py index 07cd0cf..5e71b21 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,22 +1,93 @@ import sys +import pytest from io import StringIO from unittest import mock from redmine_reporter.cli import main +from redmine_reporter.config import Config -@mock.patch.dict( - "os.environ", - {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_USER": "x", "REDMINE_PASSWORD": "y"}, +# Config читает env при импорте -- патчим класс напрямую. +# fetch_issues_with_spent_time импортируется в cli.py через "from .client import ...", +# поэтому мокать нужно имя в модуле 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") -def test_cli_smoke(mock_fetch): +@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") +def test_cli_smoke_empty(mock_fetch): + """Пустой список задач -- выход 0, сообщение о 0 задачах.""" mock_fetch.return_value = [] - old_stdout = sys.stdout - sys.stdout = captured = StringIO() + captured = StringIO() + old_stdout, sys.stdout = sys.stdout, captured try: code = main(["--date", "2026-01-01--2026-01-31"]) - assert code == 0 - output = captured.getvalue() - assert "Total issues: 0" in output finally: 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 diff --git a/tests/test_client.py b/tests/test_client.py index f23d223..aca5a4e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,14 +4,13 @@ from redmine_reporter.client import fetch_issues_with_spent_time @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_user = mock.MagicMock() mock_user.id = 123 mock_redmine.user.get.return_value = mock_user - # Два time entry на одну задачу mock_entry1 = mock.MagicMock() mock_entry1.issue.id = 101 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_redmine.time_entry.filter.return_value = [mock_entry1, mock_entry2] - # Мок задачи mock_issue = mock.MagicMock() mock_issue.id = 101 mock_issue.project = "Проект X" @@ -34,3 +32,71 @@ def test_fetch_issues_with_spent_time(mock_redmine_class): assert len(result) == 1 issue, total_hours = result[0] 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 diff --git a/tests/test_config.py b/tests/test_config.py index f79b3e1..3dfa223 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -3,22 +3,55 @@ import pytest from unittest import mock from redmine_reporter.config import Config +# Config читает os.getenv() в момент определения класса (class-level атрибуты), +# поэтому mock.patch.dict(os.environ) не помогает -- класс уже загружен. +# Правильный способ -- патчить атрибуты самого класса. -@mock.patch.dict( - os.environ, - {"REDMINE_URL": "https://red.eltex.loc/", "REDMINE_USER": "test", "REDMINE_PASSWORD": "secret"}, + +@mock.patch.multiple( + Config, + REDMINE_URL="https://red.eltex.loc", + REDMINE_USER="test", + REDMINE_PASSWORD="secret", ) def test_config_valid(): 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(): with pytest.raises(ValueError, match="REDMINE_URL"): Config.validate() -@mock.patch.dict(os.environ, {"REDMINE_AUTHOR": "Иванов И.И."}) +@mock.patch.multiple(Config, REDMINE_AUTHOR="Иванов И.И.") def test_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 diff --git a/tests/test_formatters.py b/tests/test_formatters.py index c6f46aa..ccd7aad 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -1,12 +1,21 @@ import pytest -import os +import io from typing import List +from unittest import mock from redmine_reporter.types import ReportRow from redmine_reporter.formatters.console import TableFormatter, CompactFormatter from redmine_reporter.formatters.csv import CSVFormatter from redmine_reporter.formatters.markdown import MarkdownFormatter 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]: @@ -16,9 +25,7 @@ def make_fake_report_rows() -> List[ReportRow]: - Проект B: версия (1 задача) - Проект C: версия v1.0 (1 задача), версия v1.1 (2 задачи) """ - return [ - # Проект A, v1.0 { "project": "Проект A", "version": "v1.0", @@ -39,7 +46,6 @@ def make_fake_report_rows() -> List[ReportRow]: "status_ru": "Решена", "time_text": "2ч", }, - # Проект A, v2.0 { "project": "Проект A", "version": "v2.0", @@ -50,7 +56,6 @@ def make_fake_report_rows() -> List[ReportRow]: "status_ru": "Ожидание", "time_text": "1ч", }, - # Проект B, без версии { "project": "Проект B", "version": "", @@ -61,7 +66,6 @@ def make_fake_report_rows() -> List[ReportRow]: "status_ru": "Закрыто", "time_text": "0ч", }, - # Проект C, v1.0 { "project": "Проект C", "version": "v1.0", @@ -72,7 +76,6 @@ def make_fake_report_rows() -> List[ReportRow]: "status_ru": "В работе", "time_text": "3ч 15м", }, - # Проект C, v1.1 { "project": "Проект C", "version": "v1.1", @@ -101,78 +104,111 @@ def fake_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): - return "" - elif isinstance(result, str): - return result - else: - raise TypeError(f"Unexpected formatter output type: {type(result)}") + 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)) + ) + ), + ): + yield ODTFormatter(author="Тест Автор", from_date="2026-01-01", to_date="2026-01-31") -FORMATTER_FACTORIES = [ +# -- Параметризованные тесты текстовых форматтеров -- + +TEXT_FORMATTER_FACTORIES = [ ("table", lambda: TableFormatter()), ("compact", lambda: CompactFormatter()), ("csv", lambda: CSVFormatter()), ("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) -def test_formatter_does_not_crash(fake_rows, name, formatter_factory): - """Проверяем, что форматтер не падает на валидных данных.""" - - formatter = formatter_factory() - result = formatter.format(fake_rows) - - if name == "odt": - assert isinstance(result, OpenDocument) - else: - assert isinstance(result, str) - assert len(result.strip()) > 0 +@pytest.mark.parametrize("name, factory", TEXT_FORMATTER_FACTORIES) +def test_text_formatter_returns_nonempty_string(fake_rows, name, factory): + """Текстовые форматтеры возвращают непустую строку.""" + result = factory().format(fake_rows) + assert isinstance(result, str) + assert len(result.strip()) > 0 -@pytest.mark.parametrize("name, formatter_factory", FORMATTER_FACTORIES) -def test_formatter_contains_key_content(fake_rows, name, formatter_factory): - """Проверяем, что вывод содержит ключевые элементы.""" +@pytest.mark.parametrize("name, factory", TEXT_FORMATTER_FACTORIES) +def test_text_formatter_contains_key_content(fake_rows, name, factory): + """Вывод содержит ключевые данные из отчёта.""" + output = factory().format(fake_rows) - formatter = formatter_factory() - result = formatter.format(fake_rows) + assert "Проект A" in output + assert "Проект B" in output + assert "В работе" in output + assert "" 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 "" in output_text - assert "6ч 45м" in output_text - - # Специфика по форматам if name == "csv": - # В CSV ID и subject — отдельные колонки - assert "101" in output_text - assert "Реализовать фичу X" in output_text + # В CSV issue_id и subject -- отдельные колонки + assert "101" in output + assert "Реализовать фичу X" in output else: - # В остальных — вместе - assert "101. Реализовать фичу X" in output_text + assert "101. Реализовать фичу X" in output -def test_odt_save_creates_valid_file(fake_rows, tmp_path): - """Проверяем, что ODT можно сохранить и он открывается как ZIP.""" +@pytest.mark.parametrize("name, factory", TEXT_FORMATTER_FACTORIES) +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() - with open(output_file, "rb") as f: - assert f.read(2) == b"PK" # сигнатура ZIP + assert output_file.read_bytes()[:2] == b"PK" # сигнатура ZIP diff --git a/tests/test_report_builder.py b/tests/test_report_builder.py index 6622be5..98be112 100644 --- a/tests/test_report_builder.py +++ b/tests/test_report_builder.py @@ -9,18 +9,35 @@ class MockIssue: self.project = project self.subject = subject self.status = status - if fixed_version is not None: self.fixed_version = fixed_version +# -- Таблица переводов статусов -- + + def test_status_translation(): assert STATUS_TRANSLATION["Closed"] == "Закрыто" assert STATUS_TRANSLATION["New"] == "В работе" 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 = [ (MockIssue("Камеры", "Фича A", "New", "v2.5.0", 101), 2.0), (MockIssue("Камеры", "Баг B", "Resolved", "v2.5.0", 102), 1.5), @@ -29,17 +46,64 @@ def test_build_grouped_report(): rows = build_grouped_report(issues) assert len(rows) == 3 - # Первая строка — полное название проекта и версии + # Первая строка -- полное название проекта и версии assert rows[0]["display_project"] == "Камеры" assert rows[0]["display_version"] == "v2.5.0" - # Вторая — пустые display_* из-за совпадения + # Вторая -- пустые display_* из-за совпадения проекта+версии assert rows[1]["display_project"] == "" assert rows[1]["display_version"] == "" - # Третья — новый проект + # Третья -- новый проект assert rows[2]["display_project"] == "ПО" assert rows[2]["display_version"] == "" - # Проверка перевода и времени assert rows[0]["status_ru"] == "В работе" assert rows[0]["time_text"] == "2ч" 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"] == "Моя задача" diff --git a/tests/test_utils.py b/tests/test_utils.py index 4769679..bac7bda 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,27 +2,79 @@ import pytest 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(-1) == "0ч" + + +def test_hours_to_human_whole_hours(): 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(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(): 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("invalid", "also_invalid") == "Январь" # fallback + assert get_month_name_from_range("2025-12-01", "2026-02-15") == "Февраль" -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: pass - issue_with = MockIssue() - issue_with.fixed_version = "v2.5.0" - assert get_version(issue_with) == "v2.5.0" + assert get_version(MockIssue()) == "" - issue_without = MockIssue() - assert get_version(issue_without) == "" + +def test_get_version_none_attribute(): + """fixed_version = None -- str(None) == 'None', не ''.""" + + class MockIssue: + fixed_version = None + + # get_version возвращает str(getattr(...)), None задан явно -> "None" + assert get_version(MockIssue()) == "None"