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:
@@ -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": "2ч",
|
||||
"hours": 2.0,
|
||||
},
|
||||
{
|
||||
"project": "Проект A",
|
||||
@@ -62,6 +64,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
||||
"subject": "Документация Z",
|
||||
"status_ru": "Ожидание",
|
||||
"time_text": "1ч",
|
||||
"hours": 1.0,
|
||||
},
|
||||
{
|
||||
"project": "Проект B",
|
||||
@@ -72,6 +75,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
||||
"subject": "Обновить README",
|
||||
"status_ru": "Закрыто",
|
||||
"time_text": "0ч",
|
||||
"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": "5ч",
|
||||
"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():
|
||||
|
||||
Reference in New Issue
Block a user