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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -88,7 +88,7 @@ secrets.json
|
|||||||
|
|
||||||
# Just in case
|
# Just in case
|
||||||
.~*
|
.~*
|
||||||
report.odt
|
# Local report outputs
|
||||||
report.csv
|
report.*
|
||||||
report.md
|
rep.*
|
||||||
*.html
|
*.html
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "1.6.0"
|
__version__ = "1.6.1"
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
author=Config.get_author(args.author),
|
author=Config.get_author(args.author),
|
||||||
from_date=from_date,
|
from_date=from_date,
|
||||||
to_date=to_date,
|
to_date=to_date,
|
||||||
|
no_time=args.no_time,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not formatter:
|
if not formatter:
|
||||||
|
|||||||
@@ -15,24 +15,28 @@ class CSVFormatter(Formatter):
|
|||||||
корректного отображения кириллицы в Microsoft Excel (#26).
|
корректного отображения кириллицы в Microsoft Excel (#26).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, **_kwargs):
|
def __init__(self, no_time: bool = False, **_kwargs):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self.no_time = no_time
|
||||||
|
|
||||||
def format(self, rows: List[ReportRow]) -> str:
|
def format(self, rows: List[ReportRow]) -> str:
|
||||||
output = io.StringIO()
|
output = io.StringIO()
|
||||||
writer = csv.writer(output, dialect="excel")
|
writer = csv.writer(output, dialect="excel")
|
||||||
writer.writerow(["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"])
|
headers = ["Project", "Version", "Issue ID", "Subject", "Status"]
|
||||||
|
if not self.no_time:
|
||||||
|
headers.append("Spent Time")
|
||||||
|
writer.writerow(headers)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
writer.writerow(
|
data = [
|
||||||
[
|
|
||||||
r["project"],
|
r["project"],
|
||||||
r["version"],
|
r["version"],
|
||||||
r["issue_id"],
|
r["issue_id"],
|
||||||
r["subject"],
|
r["subject"],
|
||||||
r["status_ru"],
|
r["status_ru"],
|
||||||
r["time_text"],
|
|
||||||
]
|
]
|
||||||
)
|
if not self.no_time:
|
||||||
|
data.append(r["time_text"])
|
||||||
|
writer.writerow(data)
|
||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
|
|
||||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from .base import Formatter
|
|||||||
class ODTFormatter(Formatter):
|
class ODTFormatter(Formatter):
|
||||||
"""Форматтер для экспорта в ODT."""
|
"""Форматтер для экспорта в ODT."""
|
||||||
|
|
||||||
def __init__(self, author: str = "", from_date: str = "", to_date: str = ""):
|
def __init__(self, author: str = "", from_date: str = "", to_date: str = "", **_kwargs):
|
||||||
"""
|
"""
|
||||||
Инициализирует форматтер с параметрами для шапки отчета.
|
Инициализирует форматтер с параметрами для шапки отчета.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -1,42 +1,214 @@
|
|||||||
from typing import List
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
from openpyxl.styles import Font
|
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
from openpyxl.worksheet.worksheet import Worksheet
|
||||||
|
|
||||||
|
from ..report_builder import group_rows_by_project_and_version
|
||||||
from ..types import ReportRow
|
from ..types import ReportRow
|
||||||
|
from ..utils import hours_to_human
|
||||||
from .base import Formatter
|
from .base import Formatter
|
||||||
|
|
||||||
|
|
||||||
class XLSXFormatter(Formatter):
|
class XLSXFormatter(Formatter):
|
||||||
"""Форматтер для экспорта отчёта в Excel (.xlsx)."""
|
"""Форматтер для экспорта отчёта в Excel (.xlsx).
|
||||||
|
|
||||||
def __init__(self, **_kwargs):
|
Использует группировку по проекту и версии: объединяет ячейки,
|
||||||
|
добавляет итоги по группам, закрепляет заголовок, включает автофильтр
|
||||||
|
и числовой столбец с часами для удобного суммирования.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_HEADER_FILL = PatternFill(start_color="D9E1F2", end_color="D9E1F2", fill_type="solid")
|
||||||
|
_TOTAL_FILL = PatternFill(start_color="FFF2CC", end_color="FFF2CC", fill_type="solid")
|
||||||
|
_BORDER = Border(
|
||||||
|
left=Side(style="thin"),
|
||||||
|
right=Side(style="thin"),
|
||||||
|
top=Side(style="thin"),
|
||||||
|
bottom=Side(style="thin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, no_time: bool = False, **_kwargs):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self.no_time = no_time
|
||||||
|
|
||||||
def format(self, rows: List[ReportRow]) -> Workbook:
|
def format(self, rows: List[ReportRow]) -> Workbook:
|
||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
|
if ws is None:
|
||||||
|
ws = wb.create_sheet("Report")
|
||||||
|
else:
|
||||||
ws.title = "Report"
|
ws.title = "Report"
|
||||||
|
|
||||||
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]
|
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Hours", "Spent Time"]
|
||||||
ws.append(headers)
|
ws.append(headers)
|
||||||
for cell in ws[1]:
|
self._style_header_row(ws, headers)
|
||||||
cell.font = Font(bold=True)
|
|
||||||
|
|
||||||
for r in rows:
|
grouped = group_rows_by_project_and_version(rows)
|
||||||
|
|
||||||
|
current_row = 2
|
||||||
|
project_ranges: List[Tuple[int, int]] = []
|
||||||
|
version_ranges: List[Tuple[int, int]] = []
|
||||||
|
project_totals: Dict[str, float] = {}
|
||||||
|
|
||||||
|
for project, versions in grouped.items():
|
||||||
|
project_start_row = current_row
|
||||||
|
|
||||||
|
for version, task_rows in versions.items():
|
||||||
|
version_start_row = current_row
|
||||||
|
|
||||||
|
for r in task_rows:
|
||||||
|
hours = "" if self.no_time else r.get("hours", 0.0)
|
||||||
|
time_text = "" if self.no_time else r["time_text"]
|
||||||
ws.append(
|
ws.append(
|
||||||
[
|
[
|
||||||
r["project"],
|
project,
|
||||||
r["version"],
|
version,
|
||||||
r["issue_id"],
|
r["issue_id"],
|
||||||
r["subject"],
|
r["subject"],
|
||||||
r["status_ru"],
|
r["status_ru"],
|
||||||
r["time_text"],
|
hours,
|
||||||
|
time_text,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
self._style_data_row(ws, current_row)
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
if not self.no_time:
|
||||||
|
version_hours = sum(r.get("hours", 0.0) for r in task_rows)
|
||||||
|
ws.append(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
f"Total {version}",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
version_hours,
|
||||||
|
hours_to_human(version_hours),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self._style_total_row(ws, current_row, bold=False)
|
||||||
|
ws.merge_cells(
|
||||||
|
start_row=current_row, start_column=2, end_row=current_row, end_column=5
|
||||||
|
)
|
||||||
|
current_row += 1
|
||||||
|
|
||||||
|
version_end_row = current_row - 1
|
||||||
|
if version_end_row > version_start_row:
|
||||||
|
version_ranges.append((version_start_row, version_end_row))
|
||||||
|
|
||||||
|
if not self.no_time:
|
||||||
|
project_hours = sum(
|
||||||
|
sum(r.get("hours", 0.0) for r in task_rows) for task_rows in versions.values()
|
||||||
|
)
|
||||||
|
ws.append(
|
||||||
|
[
|
||||||
|
f"Total {project}",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
project_hours,
|
||||||
|
hours_to_human(project_hours),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self._style_total_row(ws, current_row, bold=True)
|
||||||
|
ws.merge_cells(
|
||||||
|
start_row=current_row, start_column=1, end_row=current_row, end_column=5
|
||||||
|
)
|
||||||
|
current_row += 1
|
||||||
|
project_totals[project] = project_hours
|
||||||
|
|
||||||
|
project_end_row = current_row - 1
|
||||||
|
if project_end_row > project_start_row:
|
||||||
|
project_ranges.append((project_start_row, project_end_row))
|
||||||
|
|
||||||
|
if not self.no_time and project_totals:
|
||||||
|
total_hours = sum(project_totals.values())
|
||||||
|
ws.append(
|
||||||
|
[
|
||||||
|
"Total",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
total_hours,
|
||||||
|
hours_to_human(total_hours),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self._style_total_row(ws, current_row, bold=True)
|
||||||
|
ws.merge_cells(start_row=current_row, start_column=1, end_row=current_row, end_column=5)
|
||||||
|
|
||||||
|
for start, end in project_ranges:
|
||||||
|
ws.merge_cells(start_row=start, start_column=1, end_row=end, end_column=1)
|
||||||
|
cell = ws.cell(row=start, column=1)
|
||||||
|
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||||
|
|
||||||
|
for start, end in version_ranges:
|
||||||
|
ws.merge_cells(start_row=start, start_column=2, end_row=end, end_column=2)
|
||||||
|
cell = ws.cell(row=start, column=2)
|
||||||
|
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||||
|
|
||||||
|
self._apply_column_widths(ws)
|
||||||
|
if not self.no_time:
|
||||||
|
self._apply_number_format(ws)
|
||||||
|
self._apply_auto_filter(ws, ws.max_row)
|
||||||
|
ws.freeze_panes = "A2"
|
||||||
|
|
||||||
return wb
|
return wb
|
||||||
|
|
||||||
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||||
wb = self.format(rows)
|
wb = self.format(rows)
|
||||||
wb.save(output_path)
|
wb.save(output_path)
|
||||||
|
|
||||||
|
def _style_header_row(self, ws: Worksheet, headers: List[str]) -> None:
|
||||||
|
for col_idx, _ in enumerate(headers, start=1):
|
||||||
|
cell = ws.cell(row=1, column=col_idx)
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
cell.fill = self._HEADER_FILL
|
||||||
|
cell.border = self._BORDER
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||||
|
|
||||||
|
def _style_data_row(self, ws: Worksheet, row: int) -> None:
|
||||||
|
for col_idx in range(1, 8):
|
||||||
|
cell = ws.cell(row=row, column=col_idx)
|
||||||
|
cell.border = self._BORDER
|
||||||
|
if col_idx in (1, 2):
|
||||||
|
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||||
|
elif col_idx == 4:
|
||||||
|
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
||||||
|
else:
|
||||||
|
cell.alignment = Alignment(vertical="top")
|
||||||
|
|
||||||
|
def _style_total_row(self, ws: Worksheet, row: int, bold: bool) -> None:
|
||||||
|
for col_idx in range(1, 8):
|
||||||
|
cell = ws.cell(row=row, column=col_idx)
|
||||||
|
cell.border = self._BORDER
|
||||||
|
cell.fill = self._TOTAL_FILL
|
||||||
|
cell.font = Font(bold=bold)
|
||||||
|
cell.alignment = Alignment(vertical="center")
|
||||||
|
|
||||||
|
def _apply_column_widths(self, ws: Worksheet) -> None:
|
||||||
|
# Минимальные ширины по умолчанию
|
||||||
|
widths: Dict[int, float] = {1: 18.0, 2: 16.0, 3: 12.0, 4: 45.0, 5: 14.0, 6: 10.0, 7: 14.0}
|
||||||
|
|
||||||
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
|
||||||
|
for col_idx, cell in enumerate(row, start=1):
|
||||||
|
if cell.value is None:
|
||||||
|
continue
|
||||||
|
text = str(cell.value)
|
||||||
|
# Оценочная ширина: примерно 1.1 символа на единицу ширины Excel
|
||||||
|
estimated = len(text) * 1.1 + 2
|
||||||
|
widths[col_idx] = max(widths[col_idx], min(estimated, 80))
|
||||||
|
|
||||||
|
for col_idx, width in widths.items():
|
||||||
|
ws.column_dimensions[get_column_letter(col_idx)].width = width
|
||||||
|
|
||||||
|
def _apply_number_format(self, ws: Worksheet) -> None:
|
||||||
|
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=6, max_col=6):
|
||||||
|
for cell in row:
|
||||||
|
if isinstance(cell.value, (int, float)):
|
||||||
|
cell.number_format = "0.00"
|
||||||
|
|
||||||
|
def _apply_auto_filter(self, ws: Worksheet, max_row: int) -> None:
|
||||||
|
ws.auto_filter.ref = f"A1:G{max_row}"
|
||||||
|
|||||||
@@ -208,3 +208,20 @@ def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
|
|||||||
captured = capsys.readouterr()
|
captured = capsys.readouterr()
|
||||||
assert "Total issues" not in captured.out
|
assert "Total issues" not in captured.out
|
||||||
assert "Total issues" in captured.err
|
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
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Реализовать фичу X",
|
"subject": "Реализовать фичу X",
|
||||||
"status_ru": "В работе",
|
"status_ru": "В работе",
|
||||||
"time_text": "4ч 30м",
|
"time_text": "4ч 30м",
|
||||||
|
"hours": 4.5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "Проект A",
|
"project": "Проект A",
|
||||||
@@ -52,6 +53,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Исправить баг Y",
|
"subject": "Исправить баг Y",
|
||||||
"status_ru": "Решена",
|
"status_ru": "Решена",
|
||||||
"time_text": "2ч",
|
"time_text": "2ч",
|
||||||
|
"hours": 2.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "Проект A",
|
"project": "Проект A",
|
||||||
@@ -62,6 +64,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Документация Z",
|
"subject": "Документация Z",
|
||||||
"status_ru": "Ожидание",
|
"status_ru": "Ожидание",
|
||||||
"time_text": "1ч",
|
"time_text": "1ч",
|
||||||
|
"hours": 1.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "Проект B",
|
"project": "Проект B",
|
||||||
@@ -72,6 +75,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Обновить README",
|
"subject": "Обновить README",
|
||||||
"status_ru": "Закрыто",
|
"status_ru": "Закрыто",
|
||||||
"time_text": "0ч",
|
"time_text": "0ч",
|
||||||
|
"hours": 0.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "Проект C",
|
"project": "Проект C",
|
||||||
@@ -82,6 +86,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Настроить CI",
|
"subject": "Настроить CI",
|
||||||
"status_ru": "В работе",
|
"status_ru": "В работе",
|
||||||
"time_text": "3ч 15м",
|
"time_text": "3ч 15м",
|
||||||
|
"hours": 3.25,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "Проект C",
|
"project": "Проект C",
|
||||||
@@ -92,6 +97,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Добавить тесты",
|
"subject": "Добавить тесты",
|
||||||
"status_ru": "В работе",
|
"status_ru": "В работе",
|
||||||
"time_text": "5ч",
|
"time_text": "5ч",
|
||||||
|
"hours": 5.0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"project": "Проект C",
|
"project": "Проект C",
|
||||||
@@ -102,6 +108,7 @@ def make_fake_report_rows() -> List[ReportRow]:
|
|||||||
"subject": "Рефакторинг",
|
"subject": "Рефакторинг",
|
||||||
"status_ru": "Решена",
|
"status_ru": "Решена",
|
||||||
"time_text": "6ч 45м",
|
"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["A1"].value == "Project"
|
||||||
assert ws["A2"].value == "Проект A"
|
assert ws["A2"].value == "Проект A"
|
||||||
assert ws["C2"].value == 101
|
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():
|
def test_markdown_formatter_escapes_table_cells():
|
||||||
|
|||||||
Reference in New Issue
Block a user