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:
Артём Кокос
2026-06-27 13:01:32 +07:00
parent da069993b9
commit 14219564dd
7 changed files with 158 additions and 28 deletions

View File

@@ -115,6 +115,16 @@ redmine-reporter --output report.md
redmine-reporter --output report.html redmine-reporter --output report.html
``` ```
CSV-отчёт:
- файл сохраняется в UTF-8 с BOM (`utf-8-sig`) для корректного отображения кириллицы в Microsoft Excel;
- каждая строка содержит полные значения проекта и версии (в отличие от консольного и Markdown-вывода, где повторяющиеся значения скрыты для компактности).
HTML-отчёт:
- полноценный HTML-документ с `<meta charset="utf-8">`;
- корректно отображается в браузере и почтовых клиентах.
ODT-отчёт: ODT-отчёт:
- месяц в заголовке определяется по `to_date`; - месяц в заголовке определяется по `to_date`;

View File

@@ -15,8 +15,6 @@ def parse_date_range(date_arg: str) -> tuple[str, str]:
if "--" not in date_arg: if "--" not in date_arg:
raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD") raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD")
parts = date_arg.split("--", 1) parts = date_arg.split("--", 1)
if len(parts) != 2:
raise ValueError("Invalid date range format")
from_date, to_date = parts[0].strip(), parts[1].strip() from_date, to_date = parts[0].strip(), parts[1].strip()
date_pattern = r"\d{4}-\d{2}-\d{2}" date_pattern = r"\d{4}-\d{2}-\d{2}"
@@ -85,7 +83,7 @@ def main(argv: Optional[List[str]] = None) -> int:
print(" No time entries found in the given period.", file=sys.stderr) print(" No time entries found in the given period.", file=sys.stderr)
return 0 return 0
print(f"✅ Total issues: {len(issue_hours)} [{args.date}]") print(f"✅ Total issues: {len(issue_hours)} [{args.date}]", file=sys.stderr)
rows = build_grouped_report(issue_hours, fill_time=not args.no_time) rows = build_grouped_report(issue_hours, fill_time=not args.no_time)

View File

@@ -7,7 +7,13 @@ from .base import Formatter
class CSVFormatter(Formatter): class CSVFormatter(Formatter):
"""Форматтер для экспорта в CSV.""" """Форматтер для экспорта в CSV.
Использует полные значения project/version (а не display-значения с пустыми
ячейками для групп). Каждая строка CSV самодостаточна — это корректно для
табличного формата (#31). Файл сохраняется в UTF-8 с BOM (utf-8-sig) для
корректного отображения кириллицы в Microsoft Excel (#26).
"""
def __init__(self, **_kwargs): def __init__(self, **_kwargs):
super().__init__() super().__init__()
@@ -31,5 +37,5 @@ class CSVFormatter(Formatter):
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: List[ReportRow], output_path: str) -> None:
content = self.format(rows) content = self.format(rows)
with open(output_path, "w", encoding="utf-8", newline="") as f: with open(output_path, "w", encoding="utf-8-sig", newline="") as f:
f.write(content) f.write(content)

View File

@@ -16,6 +16,12 @@ class HTMLFormatter(Formatter):
projects = group_rows_by_project_and_version(rows) projects = group_rows_by_project_and_version(rows)
lines = [ lines = [
"<!DOCTYPE html>",
'<html lang="ru">',
"<head>",
' <meta charset="utf-8">',
"</head>",
"<body>",
'<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse; font-family: Arial, sans-serif;">', '<table border="1" cellpadding="6" cellspacing="0" style="border-collapse: collapse; font-family: Arial, sans-serif;">',
" <thead>", " <thead>",
" <tr>", " <tr>",
@@ -69,6 +75,8 @@ class HTMLFormatter(Formatter):
lines.append(" </tbody>") lines.append(" </tbody>")
lines.append("</table>") lines.append("</table>")
lines.append("</body>")
lines.append("</html>")
return "\n".join(lines) return "\n".join(lines)
def save(self, rows: List[ReportRow], output_path: str) -> None: def save(self, rows: List[ReportRow], output_path: str) -> None:

View File

@@ -3,7 +3,7 @@ from typing import List
from odf.opendocument import OpenDocument, load from odf.opendocument import OpenDocument, load
from odf.style import Style, TableCellProperties, TableColumnProperties from odf.style import Style, TableCellProperties, TableColumnProperties
from odf.table import Table, TableCell, TableColumn, TableRow from odf.table import CoveredTableCell, Table, TableCell, TableColumn, TableRow
from odf.text import P from odf.text import P
from ..report_builder import group_rows_by_project_and_version from ..report_builder import group_rows_by_project_and_version
@@ -35,7 +35,10 @@ class ODTFormatter(Formatter):
# Заголовок # Заголовок
month_name = get_month_name_from_range(self.from_date, self.to_date) month_name = get_month_name_from_range(self.from_date, self.to_date)
if self.author:
header_text = f"{self.author}. Отчет за месяц {month_name}." header_text = f"{self.author}. Отчет за месяц {month_name}."
else:
header_text = f"Отчет за месяц {month_name}."
doc.text.addElement(P(stylename=para_style_name, text=header_text)) doc.text.addElement(P(stylename=para_style_name, text=header_text))
doc.text.addElement(P(stylename=para_style_name, text="")) doc.text.addElement(P(stylename=para_style_name, text=""))
@@ -86,15 +89,19 @@ class ODTFormatter(Formatter):
for r in rows_for_version: for r in rows_for_version:
row = TableRow() row = TableRow()
# Ячейка "Проект" - только в первой строке всего проекта # Ячейка "Проект" - только в первой строке всего проекта,
# в остальных — covered-cell для валидности ODF (#13)
if first_version_in_project and first_row_in_version: if first_version_in_project and first_row_in_version:
cell_project = TableCell(stylename=cell_style_name) cell_project = TableCell(stylename=cell_style_name)
cell_project.setAttribute("numberrowsspanned", str(total_project_rows)) cell_project.setAttribute("numberrowsspanned", str(total_project_rows))
p = P(stylename=para_style_name, text=project) p = P(stylename=para_style_name, text=project)
cell_project.addElement(p) cell_project.addElement(p)
row.addElement(cell_project) row.addElement(cell_project)
else:
row.addElement(CoveredTableCell())
# Ячейка "Версия" - только в первой строке каждой версии # Ячейка "Версия" - только в первой строке каждой версии,
# в остальных — covered-cell для валидности ODF (#13)
if first_row_in_version: if first_row_in_version:
cell_version = TableCell(stylename=cell_style_name) cell_version = TableCell(stylename=cell_style_name)
cell_version.setAttribute("numberrowsspanned", str(row_span_version)) cell_version.setAttribute("numberrowsspanned", str(row_span_version))
@@ -102,6 +109,8 @@ class ODTFormatter(Formatter):
cell_version.addElement(p) cell_version.addElement(p)
row.addElement(cell_version) row.addElement(cell_version)
first_row_in_version = False first_row_in_version = False
else:
row.addElement(CoveredTableCell())
# Остальные колонки # Остальные колонки
task_cell = TableCell(stylename=cell_style_name) task_cell = TableCell(stylename=cell_style_name)

View File

@@ -1,6 +1,4 @@
import os import os
import sys
from io import StringIO
from unittest import mock from unittest import mock
import pytest import pytest
@@ -38,21 +36,6 @@ def test_parse_date_range_invalid(date_arg):
parse_date_range(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.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_returns_zero_on_no_entries(mock_fetch): 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 assert code == 1
captured = capsys.readouterr() captured = capsys.readouterr()
assert "odfpy" in captured.err 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

View File

@@ -114,8 +114,6 @@ def odt_formatter():
mock_file = mock.MagicMock() mock_file = mock.MagicMock()
mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes)) mock_file.__enter__ = mock.MagicMock(return_value=io.BytesIO(odt_bytes))
mock_file.__exit__ = mock.MagicMock(return_value=False) mock_file.__exit__ = mock.MagicMock(return_value=False)
mock_path = mock.MagicMock()
mock_path.open.return_value = mock_file
with mock.patch( with mock.patch(
"redmine_reporter.formatters.odt.resources.files", "redmine_reporter.formatters.odt.resources.files",
@@ -276,6 +274,30 @@ def test_compact_formatter_save_raises(fake_rows):
CompactFormatter().save(fake_rows, "/dev/null") 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(): def test_markdown_formatter_escapes_table_cells():
rows = make_fake_report_rows() rows = make_fake_report_rows()
rows[0]["project"] = "A|B" rows[0]["project"] = "A|B"
@@ -301,6 +323,13 @@ def test_html_formatter_escapes_cells():
assert "Fix <tag>" not 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 форматтера -- # -- Тесты ODT форматтера --
@@ -310,6 +339,33 @@ def test_odt_formatter_returns_opendocument(fake_rows, odt_formatter):
assert isinstance(result, OpenDocument) 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): def test_odt_formatter_save_creates_valid_file(fake_rows, tmp_path):
"""ODT можно сохранить -- файл валиден (сигнатура ZIP).""" """ODT можно сохранить -- файл валиден (сигнатура ZIP)."""
odt_bytes = _make_empty_odt_bytes() 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.exists()
assert output_file.read_bytes()[:2] == b"PK" # сигнатура ZIP 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