From 14219564ddfd6977413c75c72550b87eccbc04e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D1=80=D1=82=D1=91=D0=BC=20=D0=9A=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=D1=81?= Date: Sat, 27 Jun 2026 13:01:32 +0700 Subject: [PATCH] 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 --- README.md | 10 +++ redmine_reporter/cli.py | 4 +- redmine_reporter/formatters/csv.py | 10 ++- redmine_reporter/formatters/html.py | 8 +++ redmine_reporter/formatters/odt.py | 17 +++-- tests/test_cli.py | 40 +++++++----- tests/test_formatters.py | 97 ++++++++++++++++++++++++++++- 7 files changed, 158 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index fcd8e2f..9ff6364 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,16 @@ redmine-reporter --output report.md redmine-reporter --output report.html ``` +CSV-отчёт: + +- файл сохраняется в UTF-8 с BOM (`utf-8-sig`) для корректного отображения кириллицы в Microsoft Excel; +- каждая строка содержит полные значения проекта и версии (в отличие от консольного и Markdown-вывода, где повторяющиеся значения скрыты для компактности). + +HTML-отчёт: + +- полноценный HTML-документ с ``; +- корректно отображается в браузере и почтовых клиентах. + ODT-отчёт: - месяц в заголовке определяется по `to_date`; diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 4e09f4c..ed4bae6 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -15,8 +15,6 @@ def parse_date_range(date_arg: str) -> tuple[str, str]: if "--" not in date_arg: raise ValueError("Date range must be in format YYYY-MM-DD--YYYY-MM-DD") 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() 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) 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) diff --git a/redmine_reporter/formatters/csv.py b/redmine_reporter/formatters/csv.py index a1cbd2c..b157c77 100644 --- a/redmine_reporter/formatters/csv.py +++ b/redmine_reporter/formatters/csv.py @@ -7,7 +7,13 @@ from .base import Formatter class CSVFormatter(Formatter): - """Форматтер для экспорта в CSV.""" + """Форматтер для экспорта в CSV. + + Использует полные значения project/version (а не display-значения с пустыми + ячейками для групп). Каждая строка CSV самодостаточна — это корректно для + табличного формата (#31). Файл сохраняется в UTF-8 с BOM (utf-8-sig) для + корректного отображения кириллицы в Microsoft Excel (#26). + """ def __init__(self, **_kwargs): super().__init__() @@ -31,5 +37,5 @@ class CSVFormatter(Formatter): def save(self, rows: List[ReportRow], output_path: str) -> None: 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) diff --git a/redmine_reporter/formatters/html.py b/redmine_reporter/formatters/html.py index 2fd9bdd..349dca6 100644 --- a/redmine_reporter/formatters/html.py +++ b/redmine_reporter/formatters/html.py @@ -16,6 +16,12 @@ class HTMLFormatter(Formatter): projects = group_rows_by_project_and_version(rows) lines = [ + "", + '', + "", + ' ', + "", + "", '', " ", " ", @@ -69,6 +75,8 @@ class HTMLFormatter(Formatter): lines.append(" ") lines.append("
") + lines.append("") + lines.append("") return "\n".join(lines) def save(self, rows: List[ReportRow], output_path: str) -> None: diff --git a/redmine_reporter/formatters/odt.py b/redmine_reporter/formatters/odt.py index ac47947..9bea76a 100644 --- a/redmine_reporter/formatters/odt.py +++ b/redmine_reporter/formatters/odt.py @@ -3,7 +3,7 @@ from typing import List from odf.opendocument import OpenDocument, load 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 ..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) - header_text = f"{self.author}. Отчет за месяц {month_name}." + if self.author: + 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="")) @@ -86,15 +89,19 @@ class ODTFormatter(Formatter): for r in rows_for_version: row = TableRow() - # Ячейка "Проект" - только в первой строке всего проекта + # Ячейка "Проект" - только в первой строке всего проекта, + # в остальных — covered-cell для валидности ODF (#13) if first_version_in_project and first_row_in_version: cell_project = TableCell(stylename=cell_style_name) cell_project.setAttribute("numberrowsspanned", str(total_project_rows)) p = P(stylename=para_style_name, text=project) cell_project.addElement(p) row.addElement(cell_project) + else: + row.addElement(CoveredTableCell()) - # Ячейка "Версия" - только в первой строке каждой версии + # Ячейка "Версия" - только в первой строке каждой версии, + # в остальных — covered-cell для валидности ODF (#13) if first_row_in_version: cell_version = TableCell(stylename=cell_style_name) cell_version.setAttribute("numberrowsspanned", str(row_span_version)) @@ -102,6 +109,8 @@ class ODTFormatter(Formatter): cell_version.addElement(p) row.addElement(cell_version) first_row_in_version = False + else: + row.addElement(CoveredTableCell()) # Остальные колонки task_cell = TableCell(stylename=cell_style_name) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5c67d7a..313ccb8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 127edbe..b1d19c7 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -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 " 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 форматтера -- @@ -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: (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