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:
@@ -170,6 +170,18 @@ def test_cli_config_file_loading(mock_fetch, tmp_path):
|
||||
assert code == 0
|
||||
|
||||
|
||||
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
|
||||
"""--summary выводит общее время и разбивку по проектам в stderr."""
|
||||
issue = _MockIssue()
|
||||
mock_fetch.return_value = [(issue, 1.0)]
|
||||
main(["--date", "2026-01-01--2026-01-31", "--summary"])
|
||||
captured = capsys.readouterr()
|
||||
assert "Total time" in captured.err
|
||||
assert "Project" in captured.err or "Project" in captured.out
|
||||
|
||||
|
||||
@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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from redmine_reporter.report_builder import (
|
||||
STATUS_TRANSLATION,
|
||||
build_grouped_report,
|
||||
calculate_summary,
|
||||
group_rows_by_project_and_version,
|
||||
)
|
||||
|
||||
@@ -60,7 +61,61 @@ def test_build_grouped_report_grouping():
|
||||
|
||||
assert rows[0]["status_ru"] == "В работе"
|
||||
assert rows[0]["time_text"] == "2ч"
|
||||
assert rows[0]["hours"] == 2.0
|
||||
assert rows[1]["time_text"] == "1ч 30м"
|
||||
assert rows[1]["hours"] == 1.5
|
||||
|
||||
|
||||
# -- #22: Сводка по времени --
|
||||
|
||||
|
||||
def test_calculate_summary_totals():
|
||||
"""Сводка содержит общее время и разбивку по проектам/версиям."""
|
||||
rows = [
|
||||
{
|
||||
"project": "Камеры",
|
||||
"version": "v2.5.0",
|
||||
"issue_id": 101,
|
||||
"subject": "Фича A",
|
||||
"status_ru": "В работе",
|
||||
"time_text": "2ч",
|
||||
"hours": 2.0,
|
||||
},
|
||||
{
|
||||
"project": "Камеры",
|
||||
"version": "v2.5.0",
|
||||
"issue_id": 102,
|
||||
"subject": "Баг B",
|
||||
"status_ru": "Решена",
|
||||
"time_text": "1ч 30м",
|
||||
"hours": 1.5,
|
||||
},
|
||||
{
|
||||
"project": "ПО",
|
||||
"version": "<N/A>",
|
||||
"issue_id": 201,
|
||||
"subject": "Доки",
|
||||
"status_ru": "Ожидание",
|
||||
"time_text": "4ч",
|
||||
"hours": 4.0,
|
||||
},
|
||||
]
|
||||
summary = calculate_summary(rows)
|
||||
|
||||
assert summary["total"] == 7.5
|
||||
assert summary["project:Камеры"] == 3.5
|
||||
assert summary["project:ПО"] == 4.0
|
||||
assert summary["version:Камеры::v2.5.0"] == 3.5
|
||||
assert summary["version:ПО::<N/A>"] == 4.0
|
||||
|
||||
|
||||
def test_calculate_summary_empty():
|
||||
"""Сводка для пустого списка -- только total = 0."""
|
||||
summary = calculate_summary([])
|
||||
assert summary == {"total": 0.0}
|
||||
|
||||
|
||||
# -- #19: Общая функция группировки --
|
||||
|
||||
|
||||
def test_build_grouped_report_new_version_same_project():
|
||||
|
||||
Reference in New Issue
Block a user