import io from typing import List from unittest import mock import pytest from odf.opendocument import OpenDocument, OpenDocumentText from redmine_reporter.formatters.console import CompactFormatter, TableFormatter from redmine_reporter.formatters.csv import CSVFormatter from redmine_reporter.formatters.html import HTMLFormatter from redmine_reporter.formatters.markdown import MarkdownFormatter from redmine_reporter.formatters.odt import ODTFormatter from redmine_reporter.types import ReportRow def _make_empty_odt_bytes() -> bytes: """Создаёт минимальный валидный ODT-документ в памяти.""" doc = OpenDocumentText() buf = io.BytesIO() doc.save(buf) return buf.getvalue() def make_fake_report_rows() -> List[ReportRow]: """ Генерирует фейковый отчёт с полным покрытием логики группировки: - Проект A: версия v1.0 (2 задачи), версия v2.0 (1 задача) - Проект B: версия (1 задача) - Проект C: версия v1.0 (1 задача), версия v1.1 (2 задачи) """ return [ { "project": "Проект A", "version": "v1.0", "display_project": "Проект A", "display_version": "v1.0", "issue_id": 101, "subject": "Реализовать фичу X", "status_ru": "В работе", "time_text": "4ч 30м", }, { "project": "Проект A", "version": "v1.0", "display_project": "", "display_version": "", "issue_id": 102, "subject": "Исправить баг Y", "status_ru": "Решена", "time_text": "2ч", }, { "project": "Проект A", "version": "v2.0", "display_project": "", "display_version": "v2.0", "issue_id": 103, "subject": "Документация Z", "status_ru": "Ожидание", "time_text": "1ч", }, { "project": "Проект B", "version": "", "display_project": "Проект B", "display_version": "", "issue_id": 201, "subject": "Обновить README", "status_ru": "Закрыто", "time_text": "0ч", }, { "project": "Проект C", "version": "v1.0", "display_project": "Проект C", "display_version": "v1.0", "issue_id": 301, "subject": "Настроить CI", "status_ru": "В работе", "time_text": "3ч 15м", }, { "project": "Проект C", "version": "v1.1", "display_project": "", "display_version": "v1.1", "issue_id": 302, "subject": "Добавить тесты", "status_ru": "В работе", "time_text": "5ч", }, { "project": "Проект C", "version": "v1.1", "display_project": "", "display_version": "", "issue_id": 303, "subject": "Рефакторинг", "status_ru": "Решена", "time_text": "6ч 45м", }, ] @pytest.fixture def fake_rows(): return make_fake_report_rows() @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 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") # -- Параметризованные тесты текстовых форматтеров -- TEXT_FORMATTER_FACTORIES = [ ("table", lambda: TableFormatter()), ("compact", lambda: CompactFormatter()), ("csv", lambda: CSVFormatter()), ("markdown", lambda: MarkdownFormatter()), ] @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, factory", TEXT_FORMATTER_FACTORIES) def test_text_formatter_contains_key_content(fake_rows, name, factory): """Вывод содержит ключевые данные из отчёта.""" output = factory().format(fake_rows) assert "Проект A" in output assert "Проект B" in output assert "В работе" in output assert "" in output assert "6ч 45м" in output if name == "csv": # В CSV issue_id и subject -- отдельные колонки assert "101" in output assert "Реализовать фичу X" in output else: assert "101. Реализовать фичу X" in output @pytest.mark.parametrize("name, factory", TEXT_FORMATTER_FACTORIES) def test_text_formatter_empty_rows(name, factory): """Форматтер не падает на пустом списке строк.""" result = factory().format([]) assert isinstance(result, str) # -- Тесты консольных форматтеров -- 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") def test_markdown_formatter_escapes_table_cells(): rows = make_fake_report_rows() rows[0]["project"] = "A|B" rows[0]["display_project"] = "A|B" rows[0]["subject"] = "Fix | split\nline" output = MarkdownFormatter().format(rows) assert "A\\|B" in output assert "101. Fix \\| split
line" in output def test_html_formatter_escapes_cells(): rows = make_fake_report_rows() rows[0]["project"] = 'A&B ""' rows[0]["display_project"] = rows[0]["project"] rows[0]["subject"] = "Fix & attrs" output = HTMLFormatter().format(rows) assert "A&B "<Project>"" in output assert "101. Fix <tag> & attrs" in output assert "Fix " not in output # -- Тесты 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.read_bytes()[:2] == b"PK" # сигнатура ZIP