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