feat: JSON, Excel export and time summary

- #23: add JSONFormatter and XLSXFormatter
- add openpyxl dependency for .xlsx export
- #22: add --summary flag and calculate_summary() in report_builder
- ReportRow now carries raw hours for summary calculations
- update CLI help and README with .json/.xlsx formats and --summary
- add tests for new formatters and summary computation

Closes #22, closes #23
This commit is contained in:
Кокос Артем Николаевич
2026-06-29 12:09:58 +07:00
parent f6afc4096d
commit ca89832d74
11 changed files with 255 additions and 7 deletions

View File

@@ -1,4 +1,5 @@
import io
import json
from typing import List
from unittest import mock
@@ -7,9 +8,12 @@ from odf.opendocument import OpenDocument, OpenDocumentText
from redmine_reporter.formatters.console import CompactFormatter, TableFormatter
from redmine_reporter.formatters.csv import CSVFormatter
from redmine_reporter.formatters.factory import get_formatter_by_extension
from redmine_reporter.formatters.html import HTMLFormatter
from redmine_reporter.formatters.json import JSONFormatter
from redmine_reporter.formatters.markdown import MarkdownFormatter
from redmine_reporter.formatters.odt import ODTFormatter
from redmine_reporter.formatters.xlsx import XLSXFormatter
from redmine_reporter.types import ReportRow
@@ -223,6 +227,7 @@ TEXT_FORMATTER_FACTORIES = [
("table", lambda: TableFormatter()),
("compact", lambda: CompactFormatter()),
("csv", lambda: CSVFormatter()),
("json", lambda: JSONFormatter()),
("markdown", lambda: MarkdownFormatter()),
]
@@ -250,6 +255,10 @@ def test_text_formatter_contains_key_content(fake_rows, name, factory):
# В CSV issue_id и subject -- отдельные колонки
assert "101" in output
assert "Реализовать фичу X" in output
elif name == "json":
# В JSON issue_id -- число, subject -- отдельное поле
assert '"issue_id": 101' in output
assert "Реализовать фичу X" in output
else:
assert "101. Реализовать фичу X" in output
@@ -298,6 +307,44 @@ def test_csv_uses_full_values_not_display(fake_rows):
assert "v1.0" in lines[2]
def test_json_save_writes_parsable_data(fake_rows, tmp_path):
"""JSON-файл содержит валидный JSON со всеми строками отчёта."""
output = tmp_path / "report.json"
JSONFormatter().save(fake_rows, str(output))
data = json.loads(output.read_text(encoding="utf-8"))
assert len(data) == len(fake_rows)
assert data[0]["project"] == "Проект A"
assert data[0]["issue_id"] == 101
def test_get_formatter_by_extension_json():
"""get_formatter_by_extension('.json') возвращает JSONFormatter."""
formatter = get_formatter_by_extension(".json")
assert isinstance(formatter, JSONFormatter)
def test_get_formatter_by_extension_xlsx():
"""get_formatter_by_extension('.xlsx') возвращает XLSXFormatter."""
formatter = get_formatter_by_extension(".xlsx")
assert isinstance(formatter, XLSXFormatter)
def test_xlsx_save_creates_valid_file(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
assert ws.title == "Report"
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
def test_markdown_formatter_escapes_table_cells():
rows = make_fake_report_rows()
rows[0]["project"] = "A|B"