feat(xlsx): make Excel export production-grade

- Add merge cells for project/version groups.
- Add numeric Hours column and human-readable Spent Time.
- Add version/project/grand totals.
- Apply auto-width, freeze panes, auto-filter and styling.
- Respect --no-time: keep columns empty, skip totals.
- Pass no_time flag through formatter factory to all file formatters.
- Add tests for XLSX features and --no-time behavior.

Closes #38
This commit is contained in:
Кокос Артем Николаевич
2026-06-29 14:41:03 +07:00
parent 86f083aa79
commit 222d31730e
8 changed files with 300 additions and 33 deletions

View File

@@ -208,3 +208,20 @@ def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
captured = capsys.readouterr()
assert "Total issues" not in captured.out
assert "Total issues" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
"""CLI --no-time передаётся в файловый форматтер."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report.xlsx")
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get_formatter:
mock_formatter = mock.MagicMock()
mock_get_formatter.return_value = mock_formatter
main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"])
_, kwargs = mock_get_formatter.call_args
assert kwargs.get("no_time") is True

View File

@@ -42,6 +42,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Реализовать фичу X",
"status_ru": "В работе",
"time_text": "4ч 30м",
"hours": 4.5,
},
{
"project": "Проект A",
@@ -52,6 +53,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Исправить баг Y",
"status_ru": "Решена",
"time_text": "",
"hours": 2.0,
},
{
"project": "Проект A",
@@ -62,6 +64,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Документация Z",
"status_ru": "Ожидание",
"time_text": "",
"hours": 1.0,
},
{
"project": "Проект B",
@@ -72,6 +75,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Обновить README",
"status_ru": "Закрыто",
"time_text": "",
"hours": 0.0,
},
{
"project": "Проект C",
@@ -82,6 +86,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Настроить CI",
"status_ru": "В работе",
"time_text": "3ч 15м",
"hours": 3.25,
},
{
"project": "Проект C",
@@ -92,6 +97,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Добавить тесты",
"status_ru": "В работе",
"time_text": "",
"hours": 5.0,
},
{
"project": "Проект C",
@@ -102,6 +108,7 @@ def make_fake_report_rows() -> List[ReportRow]:
"subject": "Рефакторинг",
"status_ru": "Решена",
"time_text": "6ч 45м",
"hours": 6.75,
},
]
@@ -342,7 +349,73 @@ def test_xlsx_save_creates_valid_file(fake_rows, tmp_path):
assert ws["A1"].value == "Project"
assert ws["A2"].value == "Проект A"
assert ws["C2"].value == 101
assert ws.max_row == len(fake_rows) + 1 # header + data
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_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():