import io import json from typing import List from unittest import mock import pytest from odf.opendocument import OpenDocument, OpenDocumentText from redmine_reporter.formatters.console import CompactFormatter, TableFormatter from redmine_reporter.formatters.csv import CSVFormatter from redmine_reporter.formatters.factory import get_formatter_by_extension from redmine_reporter.formatters.html import HTMLFormatter from redmine_reporter.formatters.json import JSONFormatter from redmine_reporter.formatters.markdown import MarkdownFormatter from redmine_reporter.formatters.odt import ODTFormatter from redmine_reporter.formatters.xlsx import XLSXFormatter 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м", "hours": 4.5, }, { "project": "Проект A", "version": "v1.0", "display_project": "", "display_version": "", "issue_id": 102, "subject": "Исправить баг Y", "status_ru": "Решена", "time_text": "2ч", "hours": 2.0, }, { "project": "Проект A", "version": "v2.0", "display_project": "", "display_version": "v2.0", "issue_id": 103, "subject": "Документация Z", "status_ru": "Ожидание", "time_text": "1ч", "hours": 1.0, }, { "project": "Проект B", "version": "", "display_project": "Проект B", "display_version": "", "issue_id": 201, "subject": "Обновить README", "status_ru": "Закрыто", "time_text": "0ч", "hours": 0.0, }, { "project": "Проект C", "version": "v1.0", "display_project": "Проект C", "display_version": "v1.0", "issue_id": 301, "subject": "Настроить CI", "status_ru": "В работе", "time_text": "3ч 15м", "hours": 3.25, }, { "project": "Проект C", "version": "v1.1", "display_project": "", "display_version": "v1.1", "issue_id": 302, "subject": "Добавить тесты", "status_ru": "В работе", "time_text": "5ч", "hours": 5.0, }, { "project": "Проект C", "version": "v1.1", "display_project": "", "display_version": "", "issue_id": 303, "subject": "Рефакторинг", "status_ru": "Решена", "time_text": "6ч 45м", "hours": 6.75, }, ] @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) 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") # -- Тесты упаковки formatters как полноценного пакета -- def test_formatters_is_regular_package(): """redmine_reporter.formatters — полноценный пакет с __init__.py, не namespace.""" import redmine_reporter.formatters assert hasattr(redmine_reporter.formatters, "__file__") assert redmine_reporter.formatters.__file__.endswith("__init__.py") def test_formatters_found_by_setuptools(): """setuptools.find_packages находит formatters как полноценный пакет.""" import os from setuptools import find_packages project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) found = find_packages(where=project_root, include=["redmine_reporter*"]) assert "redmine_reporter.formatters" in found # -- Тесты ленивого импорта ODT (#25) -- def _simulate_missing_odfpy(): """Вспомогательная функция: подготавливает очистку кэша модулей odf и odt. Возвращает словарь сохранённых модулей для последующего восстановления. """ import sys saved = {} for key in list(sys.modules.keys()): if key == "odf" or key.startswith("odf.") or key == "redmine_reporter.formatters.odt": saved[key] = sys.modules.pop(key) return saved def _restore_modules(saved): import sys sys.modules.update(saved) def test_get_formatter_odt_returns_none_without_odfpy(): """Без odfpy — get_formatter_by_extension('.odt') возвращает None, не падает.""" import builtins from redmine_reporter.formatters.factory import get_formatter_by_extension real_import = builtins.__import__ def blocking_import(name, *args, **kwargs): if name == "odf" or name.startswith("odf."): raise ImportError(f"No module named '{name}'") return real_import(name, *args, **kwargs) saved = _simulate_missing_odfpy() try: with mock.patch("builtins.__import__", side_effect=blocking_import): result = get_formatter_by_extension( ".odt", author="test", from_date="2026-01-01", to_date="2026-01-31" ) assert result is None finally: _restore_modules(saved) def test_get_formatter_csv_works_without_odfpy(): """Без odfpy — get_formatter_by_extension('.csv') работает нормально.""" import builtins from redmine_reporter.formatters.factory import get_formatter_by_extension real_import = builtins.__import__ def blocking_import(name, *args, **kwargs): if name == "odf" or name.startswith("odf."): raise ImportError(f"No module named '{name}'") return real_import(name, *args, **kwargs) saved = _simulate_missing_odfpy() try: with mock.patch("builtins.__import__", side_effect=blocking_import): result = get_formatter_by_extension(".csv") assert result is not None finally: _restore_modules(saved) # -- Параметризованные тесты текстовых форматтеров -- TEXT_FORMATTER_FACTORIES = [ ("table", lambda: TableFormatter()), ("compact", lambda: CompactFormatter()), ("csv", lambda: CSVFormatter()), ("json", lambda: JSONFormatter()), ("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 elif name == "json": # В JSON issue_id -- число, subject -- отдельное поле assert '"issue_id": 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_csv_save_writes_utf8_bom(fake_rows, tmp_path): """CSV-файл начинается с UTF-8 BOM для корректного открытия в Excel (#26).""" output = tmp_path / "report.csv" CSVFormatter().save(fake_rows, str(output)) content = output.read_bytes() assert content[:3] == b"\xef\xbb\xbf" # UTF-8 BOM def test_csv_uses_full_values_not_display(fake_rows): """CSV экспортирует полные project/version, а не display-значения (#31). В отличие от консольных и Markdown форматтеров (display_*), CSV содержит полные значения в каждой строке — это корректно для табличного формата. """ output = CSVFormatter().format(fake_rows) lines = output.strip().split("\n") # Header + 7 data rows = 8 lines assert len(lines) == 8 # Вторая строка данных (lines[2]) имеет display_project="" и display_version="", # но CSV должен содержать полные значения assert "Проект A" in lines[2] assert "v1.0" in lines[2] def test_json_save_writes_parsable_data(fake_rows, tmp_path): """JSON-файл содержит валидный JSON со всеми строками отчёта.""" output = tmp_path / "report.json" JSONFormatter().save(fake_rows, str(output)) data = json.loads(output.read_text(encoding="utf-8")) assert len(data) == len(fake_rows) assert data[0]["project"] == "Проект A" assert data[0]["issue_id"] == 101 def test_get_formatter_by_extension_json(): """get_formatter_by_extension('.json') возвращает JSONFormatter.""" formatter = get_formatter_by_extension(".json") assert isinstance(formatter, JSONFormatter) def test_get_formatter_by_extension_xlsx(): """get_formatter_by_extension('.xlsx') возвращает XLSXFormatter.""" formatter = get_formatter_by_extension(".xlsx") assert isinstance(formatter, XLSXFormatter) def test_xlsx_save_creates_valid_file(fake_rows, tmp_path): """XLSX-файл сохраняется и содержит корректные данные.""" from openpyxl import load_workbook output = tmp_path / "report.xlsx" XLSXFormatter().save(fake_rows, str(output)) wb = load_workbook(str(output)) ws = wb.active assert ws.title == "Report" assert ws["A1"].value == "Project" assert ws["A2"].value == "Проект A" assert ws["C2"].value == 101 assert ws["F2"].value == 4.5 assert ws["G2"].value == "4ч 30м" # header + 7 data rows + 5 version totals + 3 project totals + 1 grand total assert ws.max_row == 17 def test_xlsx_has_merged_cells(fake_rows, tmp_path): """XLSX содержит объединённые ячейки по проектам и версиям.""" from openpyxl import load_workbook output = tmp_path / "report.xlsx" XLSXFormatter().save(fake_rows, str(output)) wb = load_workbook(str(output)) ws = wb.active merged_ranges = [str(r) for r in ws.merged_cells.ranges] # Проект A: 3 строки данных + 2 итога по версиям + 1 итог по проекту = строки 2-7 assert any("A2:A7" in r for r in merged_ranges) # Версия v1.0 проекта A: 2 строки данных + 1 итог по версии = строки 2-4 assert any("B2:B4" in r for r in merged_ranges) def test_xlsx_has_totals(fake_rows, tmp_path): """XLSX содержит итоги по версиям, проектам и общий итог.""" from openpyxl import load_workbook output = tmp_path / "report.xlsx" XLSXFormatter().save(fake_rows, str(output)) wb = load_workbook(str(output)) ws = wb.active total_values = [ws.cell(row=r, column=6).value for r in range(2, ws.max_row + 1)] # Проект A всего: 4.5 + 2 + 1 = 7.5 assert 7.5 in total_values # Общий итог: 4.5 + 2 + 1 + 0 + 3.25 + 5 + 6.75 = 22.5 assert 22.5 in total_values # Проверим числовой формат assert ws["F2"].number_format == "0.00" def test_xlsx_no_time_keeps_columns_empty_and_skips_totals(fake_rows, tmp_path): """XLSX с no_time: колонки времени пустые, итогов нет.""" from openpyxl import load_workbook output = tmp_path / "report.xlsx" XLSXFormatter(no_time=True).save(fake_rows, str(output)) wb = load_workbook(str(output)) ws = wb.active # header + 7 data rows assert ws.max_row == 8 # Колонки времени пустые для всех строк данных for row in range(2, ws.max_row + 1): assert ws.cell(row=row, column=6).value in (None, "") assert ws.cell(row=row, column=7).value in (None, "") # Итоговых строк нет for row in range(2, ws.max_row + 1): assert not str(ws.cell(row=row, column=1).value or "").startswith("Total") assert not str(ws.cell(row=row, column=2).value or "").startswith("Total") # Автофильтр и freeze panes на месте assert ws.freeze_panes == "A2" assert ws.auto_filter.ref == "A1:G8" 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 def test_html_output_has_doctype_and_charset(fake_rows): """HTML-отчёт содержит DOCTYPE и meta charset для корректной кодировки (#27).""" output = HTMLFormatter().format(fake_rows) assert "" in output assert '' 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_empty_author_no_garbage_in_header(fake_rows): """При пустом авторе заголовок не содержит мусорных символов (#30).""" 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") doc = formatter.format(fake_rows) from odf.text import P paragraphs = doc.text.getElementsByType(P) header_text = paragraphs[0].firstChild.data assert not header_text.startswith(".") assert "Отчет за месяц" in header_text 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 def test_odt_has_covered_cells_for_spans(fake_rows): """ODT содержит covered-table-cell для замещённых ячеек при объединении (#13). Тестовые данные (fake_rows): Проект A: v1.0(2 задачи), v2.0(1) → project span=3 row1: project+version (0 covered) row2: covered project + covered version (2) row3: covered project + new version cell (1) Проект B: (1) → span=1, нет covered Проект C: v1.0(1), v1.1(2) → project span=3 row5: project+version (0 covered) row6: covered project + new version cell (1) row7: covered project + covered version (2) Итого: 6 covered cells """ 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") doc = formatter.format(fake_rows) from odf.table import CoveredTableCell covered_cells = doc.getElementsByType(CoveredTableCell) assert len(covered_cells) == 6