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
This commit is contained in:
@@ -1,6 +1,4 @@
|
||||
import os
|
||||
import sys
|
||||
from io import StringIO
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
@@ -38,21 +36,6 @@ def test_parse_date_range_invalid(date_arg):
|
||||
parse_date_range(date_arg)
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||
def test_cli_smoke_empty(mock_fetch):
|
||||
"""Пустой список задач -- выход 0, сообщение о 0 задачах."""
|
||||
mock_fetch.return_value = []
|
||||
captured = StringIO()
|
||||
old_stdout, sys.stdout = sys.stdout, captured
|
||||
try:
|
||||
code = main(["--date", "2026-01-01--2026-01-31"])
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
assert code == 0
|
||||
assert "Total issues: 0" in captured.getvalue()
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||
def test_cli_returns_zero_on_no_entries(mock_fetch):
|
||||
@@ -108,3 +91,26 @@ def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys):
|
||||
assert code == 1
|
||||
captured = capsys.readouterr()
|
||||
assert "odfpy" in captured.err
|
||||
|
||||
|
||||
class _MockIssue:
|
||||
"""Простой mock Redmine Issue для CLI-тестов."""
|
||||
|
||||
def __init__(self, issue_id=1, subject="Task", project="Project", status="New"):
|
||||
self.id = issue_id
|
||||
self.subject = subject
|
||||
self.project = project
|
||||
self.status = status
|
||||
self.fixed_version = None
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
|
||||
"""«Total issues» пишется в stderr, не загрязняя stdout при pipe (#28)."""
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
main(["--date", "2026-01-01--2026-01-31"])
|
||||
captured = capsys.readouterr()
|
||||
assert "Total issues" not in captured.out
|
||||
assert "Total issues" in captured.err
|
||||
|
||||
@@ -114,8 +114,6 @@ def odt_formatter():
|
||||
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",
|
||||
@@ -276,6 +274,30 @@ def test_compact_formatter_save_raises(fake_rows):
|
||||
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"
|
||||
@@ -301,6 +323,13 @@ def test_html_formatter_escapes_cells():
|
||||
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 форматтера --
|
||||
|
||||
|
||||
@@ -310,6 +339,33 @@ def test_odt_formatter_returns_opendocument(fake_rows, odt_formatter):
|
||||
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()
|
||||
@@ -331,3 +387,40 @@ def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user