Files
redmine-reporter/tests/test_formatters.py
Артём Кокос 14219564dd Fix 9 bugs: ODT covered-cells, CSV BOM, HTML charset, stderr, dead code
- #13 (critical): Add CoveredTableCell elements to ODT for valid row spans (ODF 1.2)
- #28: Move "Total issues" info message from stdout to stderr (clean pipe output)
- #27: Wrap HTML export in full document with DOCTYPE and meta charset utf-8
- #26: Save CSV with utf-8-sig encoding (UTF-8 BOM for Excel compatibility)
- #31: Document CSV uses full project/version values (not display_* like console/MD)
- #30: Fix ODT header formatting when author is empty (no leading dot/space)
- #36: Remove test_cli_smoke_empty testing unreachable code path (return [])
- #37: Remove unused mock_path variable in ODT test fixture
- #34: Remove unreachable len(parts) != 2 check in parse_date_range

Closes #13, #28, #27, #26, #31, #30, #36, #37, #34
2026-06-27 13:01:32 +07:00

427 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: версия <N/A> (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": "",
},
{
"project": "Проект A",
"version": "v2.0",
"display_project": "",
"display_version": "v2.0",
"issue_id": 103,
"subject": "Документация Z",
"status_ru": "Ожидание",
"time_text": "",
},
{
"project": "Проект B",
"version": "<N/A>",
"display_project": "Проект B",
"display_version": "<N/A>",
"issue_id": 201,
"subject": "Обновить README",
"status_ru": "Закрыто",
"time_text": "",
},
{
"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": "",
},
{
"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)
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()),
("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 "<N/A>" 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_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_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<br>line" in output
def test_html_formatter_escapes_cells():
rows = make_fake_report_rows()
rows[0]["project"] = 'A&B "<Project>"'
rows[0]["display_project"] = rows[0]["project"]
rows[0]["subject"] = "Fix <tag> & attrs"
output = HTMLFormatter().format(rows)
assert "A&amp;B &quot;&lt;Project&gt;&quot;" in output
assert "101. Fix &lt;tag&gt; &amp; attrs" in output
assert "Fix <tag>" not in output
def test_html_output_has_doctype_and_charset(fake_rows):
"""HTML-отчёт содержит DOCTYPE и meta charset для корректной кодировки (#27)."""
output = HTMLFormatter().format(fake_rows)
assert "<!DOCTYPE html>" in output
assert '<meta charset="utf-8">' 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: <N/A>(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