- tests/test_cli.py: fix 26 fetch_issues_with_spent_time mocks to return 3-element tuples (issue, hours, activities) matching the real signature - pyproject.toml: add [tool.pytest.ini_options] with testpaths and pythonpath so bare pytest works - tests/test_client.py: add pagination test (>100 time entries arriving in pages, hours aggregated across all pages) - tests/test_formatters.py: add structural tests — XLSX full header row and grand total row via openpyxl, HTML thead with all columns and tbody row count - add strict xfail trap-tests for known bugs: #58 (naive created_on vs aware dedup cutoff TypeError) and #59 (parse_date_range rejects datetime range from dynamic+datetime period) Closes #67
604 lines
22 KiB
Python
604 lines
22 KiB
Python
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: версия <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м",
|
||
"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": "<N/A>",
|
||
"display_project": "Проект B",
|
||
"display_version": "<N/A>",
|
||
"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 "<N/A>" 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_has_full_header_row_and_grand_total_row(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
|
||
|
||
headers = [ws.cell(row=1, column=c).value for c in range(1, 8)]
|
||
assert headers == [
|
||
"Project",
|
||
"Version",
|
||
"Issue ID",
|
||
"Subject",
|
||
"Status",
|
||
"Hours",
|
||
"Spent Time",
|
||
]
|
||
|
||
# Последняя строка — общий итог по всем проектам
|
||
assert ws.cell(row=ws.max_row, column=1).value == "Total"
|
||
assert ws.cell(row=ws.max_row, column=6).value == 22.5
|
||
|
||
|
||
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<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&B "<Project>"" in output
|
||
assert "101. Fix <tag> & 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
|
||
|
||
|
||
def test_html_has_table_structure_with_all_columns(fake_rows):
|
||
"""HTML-отчёт содержит thead со всеми колонками и по строке на каждую задачу."""
|
||
output = HTMLFormatter().format(fake_rows)
|
||
|
||
assert "<thead>" in output
|
||
assert "<tbody>" in output
|
||
for header in (
|
||
"Наименование Проекта",
|
||
"Номер версии*",
|
||
"Задача",
|
||
"Статус Готовность*",
|
||
"Затрачено за отчетный период",
|
||
):
|
||
assert f"<th>{header}</th>" in output
|
||
|
||
tbody = output.split("<tbody>", 1)[1]
|
||
assert tbody.count("<tr>") == len(fake_rows)
|
||
|
||
|
||
# -- Тесты 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
|