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:
23
README.md
23
README.md
@@ -14,11 +14,13 @@ CLI-инструмент для генерации отчётов по зада
|
|||||||
- Группировка задач по проекту и версии.
|
- Группировка задач по проекту и версии.
|
||||||
- Перевод статусов задач на русский язык.
|
- Перевод статусов задач на русский язык.
|
||||||
- Вывод в консоль в табличном или компактном виде.
|
- Вывод в консоль в табличном или компактном виде.
|
||||||
- Экспорт в ODT, CSV, Markdown и HTML.
|
- Экспорт в ODT, CSV, Markdown, HTML, JSON и Excel (.xlsx).
|
||||||
|
- Сводка по затраченному времени (итоги и разбивка по проектам/версиям).
|
||||||
- Автоматическое определение месяца ODT-отчёта по конечной дате периода.
|
- Автоматическое определение месяца ODT-отчёта по конечной дате периода.
|
||||||
- Настройка периода отчёта по умолчанию через `.env` (или автоматически — текущий месяц).
|
- Настройка периода отчёта по умолчанию через `.env` (или автоматически — текущий месяц).
|
||||||
- Переопределение URL и API-ключа через CLI.
|
- Переопределение URL и API-ключа через CLI.
|
||||||
- Загрузка альтернативного `.env` через `--config`.
|
- Загрузка альтернативного `.env` через `--config`.
|
||||||
|
- Загрузка альтернативного `.env` через `--config`.
|
||||||
|
|
||||||
## Установка
|
## Установка
|
||||||
|
|
||||||
@@ -140,8 +142,21 @@ redmine-reporter --output report.odt
|
|||||||
redmine-reporter --output report.csv
|
redmine-reporter --output report.csv
|
||||||
redmine-reporter --output report.md
|
redmine-reporter --output report.md
|
||||||
redmine-reporter --output report.html
|
redmine-reporter --output report.html
|
||||||
|
redmine-reporter --output report.json
|
||||||
|
redmine-reporter --output report.xlsx
|
||||||
```
|
```
|
||||||
|
|
||||||
|
JSON-отчёт:
|
||||||
|
|
||||||
|
- массив объектов с полями `project`, `version`, `issue_id`, `subject`, `status`, `time`;
|
||||||
|
- UTF-8, читаемый машиной.
|
||||||
|
|
||||||
|
Excel-отчёт (.xlsx):
|
||||||
|
|
||||||
|
- одна таблица с заголовками и строками данных;
|
||||||
|
- шапка выделена жирным;
|
||||||
|
- каждая строка содержит полные значения проекта и версии.
|
||||||
|
|
||||||
CSV-отчёт:
|
CSV-отчёт:
|
||||||
|
|
||||||
- файл сохраняется в UTF-8 с BOM (`utf-8-sig`) для корректного отображения кириллицы в Microsoft Excel;
|
- файл сохраняется в UTF-8 с BOM (`utf-8-sig`) для корректного отображения кириллицы в Microsoft Excel;
|
||||||
@@ -164,6 +179,12 @@ ODT-отчёт:
|
|||||||
redmine-reporter --no-time
|
redmine-reporter --no-time
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Сводка по времени (итоги и разбивка по проектам):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
redmine-reporter --summary
|
||||||
|
```
|
||||||
|
|
||||||
## Разработка
|
## Разработка
|
||||||
|
|
||||||
Установка зависимостей для разработки:
|
Установка зависимостей для разработки:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ dependencies = [
|
|||||||
"tabulate>=0.9.0",
|
"tabulate>=0.9.0",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
"odfpy>=1.4.0",
|
"odfpy>=1.4.0",
|
||||||
|
"openpyxl>=3.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from . import __version__
|
|||||||
from .client import fetch_issues_with_spent_time
|
from .client import fetch_issues_with_spent_time
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .formatters.factory import get_console_formatter, get_formatter_by_extension
|
from .formatters.factory import get_console_formatter, get_formatter_by_extension
|
||||||
from .report_builder import build_grouped_report
|
from .report_builder import build_grouped_report, calculate_summary
|
||||||
|
|
||||||
|
|
||||||
def parse_date_range(date_arg: str) -> tuple[str, str]:
|
def parse_date_range(date_arg: str) -> tuple[str, str]:
|
||||||
@@ -52,7 +52,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--output",
|
"--output",
|
||||||
help="Path to output file (.odt, .csv, .md, .html). If omitted, prints to stdout.",
|
help="Path to output file (.odt, .csv, .md, .html, .json, .xlsx). If omitted, prints to stdout.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--author", default="", help="Override author name from .env (REDMINE_AUTHOR)"
|
"--author", default="", help="Override author name from .env (REDMINE_AUTHOR)"
|
||||||
@@ -71,6 +71,11 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
version=f"%(prog)s {__version__}",
|
version=f"%(prog)s {__version__}",
|
||||||
help="Show version and exit",
|
help="Show version and exit",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--summary",
|
||||||
|
action="store_true",
|
||||||
|
help="Print summary (total hours by project/version) to stderr",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
# CLI-переопределения имеют приоритет над .env/env.
|
# CLI-переопределения имеют приоритет над .env/env.
|
||||||
@@ -113,12 +118,20 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
|
|
||||||
rows = build_grouped_report(issue_hours, fill_time=not args.no_time)
|
rows = build_grouped_report(issue_hours, fill_time=not args.no_time)
|
||||||
|
|
||||||
|
if args.summary:
|
||||||
|
summary = calculate_summary(rows)
|
||||||
|
print(f"⏱️ Total time: {summary['total']}h", file=sys.stderr)
|
||||||
|
for key, value in summary.items():
|
||||||
|
if key.startswith("project:"):
|
||||||
|
project = key.split(":", 1)[1]
|
||||||
|
print(f" {project}: {value}h", file=sys.stderr)
|
||||||
|
|
||||||
if args.output:
|
if args.output:
|
||||||
output_ext = os.path.splitext(args.output)[1].lower()
|
output_ext = os.path.splitext(args.output)[1].lower()
|
||||||
|
|
||||||
if not output_ext:
|
if not output_ext:
|
||||||
print(
|
print(
|
||||||
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md или .html",
|
"❌ Файл без расширения. Укажите расширение: .odt, .csv, .md, .html, .json или .xlsx",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
@@ -137,7 +150,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
known_exts = ", ".join([".odt", ".csv", ".md", ".html"])
|
known_exts = ", ".join([".odt", ".csv", ".md", ".html", ".json", ".xlsx"])
|
||||||
print(
|
print(
|
||||||
f"❌ Неизвестный формат файла: {output_ext!r}. "
|
f"❌ Неизвестный формат файла: {output_ext!r}. "
|
||||||
f"Поддерживаются: {known_exts}",
|
f"Поддерживаются: {known_exts}",
|
||||||
|
|||||||
@@ -4,15 +4,19 @@ from .base import Formatter
|
|||||||
from .console import CompactFormatter, TableFormatter
|
from .console import CompactFormatter, TableFormatter
|
||||||
from .csv import CSVFormatter
|
from .csv import CSVFormatter
|
||||||
from .html import HTMLFormatter
|
from .html import HTMLFormatter
|
||||||
|
from .json import JSONFormatter
|
||||||
from .markdown import MarkdownFormatter
|
from .markdown import MarkdownFormatter
|
||||||
|
from .xlsx import XLSXFormatter
|
||||||
|
|
||||||
# Словарь для сопоставления расширений файлов с классами форматтеров.
|
# Словарь для сопоставления расширений файлов с классами форматтеров.
|
||||||
# ODT намеренно отсутствует — его импорт отложен (ленивый), так как odfpy
|
# ODT и XLSX намеренно отсутствуют — их импорт отложен (ленивый), так как
|
||||||
# может быть не установлен. См. get_formatter_by_extension.
|
# odfpy/openpyxl может быть не установлен. См. get_formatter_by_extension.
|
||||||
FORMATTER_MAP: Dict[str, Type[Formatter]] = {
|
FORMATTER_MAP: Dict[str, Type[Formatter]] = {
|
||||||
".csv": CSVFormatter,
|
".csv": CSVFormatter,
|
||||||
|
".json": JSONFormatter,
|
||||||
".md": MarkdownFormatter,
|
".md": MarkdownFormatter,
|
||||||
".html": HTMLFormatter,
|
".html": HTMLFormatter,
|
||||||
|
".xlsx": XLSXFormatter,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
31
redmine_reporter/formatters/json.py
Normal file
31
redmine_reporter/formatters/json.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import json
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ..types import ReportRow
|
||||||
|
from .base import Formatter
|
||||||
|
|
||||||
|
|
||||||
|
class JSONFormatter(Formatter):
|
||||||
|
"""Форматтер для экспорта отчёта в JSON."""
|
||||||
|
|
||||||
|
def __init__(self, **_kwargs):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def format(self, rows: List[ReportRow]) -> str:
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"project": r["project"],
|
||||||
|
"version": r["version"],
|
||||||
|
"issue_id": r["issue_id"],
|
||||||
|
"subject": r["subject"],
|
||||||
|
"status": r["status_ru"],
|
||||||
|
"time": r["time_text"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||||
|
content = self.format(rows)
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
42
redmine_reporter/formatters/xlsx.py
Normal file
42
redmine_reporter/formatters/xlsx.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Font
|
||||||
|
|
||||||
|
from ..types import ReportRow
|
||||||
|
from .base import Formatter
|
||||||
|
|
||||||
|
|
||||||
|
class XLSXFormatter(Formatter):
|
||||||
|
"""Форматтер для экспорта отчёта в Excel (.xlsx)."""
|
||||||
|
|
||||||
|
def __init__(self, **_kwargs):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def format(self, rows: List[ReportRow]) -> Workbook:
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Report"
|
||||||
|
|
||||||
|
headers = ["Project", "Version", "Issue ID", "Subject", "Status", "Spent Time"]
|
||||||
|
ws.append(headers)
|
||||||
|
for cell in ws[1]:
|
||||||
|
cell.font = Font(bold=True)
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
ws.append(
|
||||||
|
[
|
||||||
|
r["project"],
|
||||||
|
r["version"],
|
||||||
|
r["issue_id"],
|
||||||
|
r["subject"],
|
||||||
|
r["status_ru"],
|
||||||
|
r["time_text"],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
return wb
|
||||||
|
|
||||||
|
def save(self, rows: List[ReportRow], output_path: str) -> None:
|
||||||
|
wb = self.format(rows)
|
||||||
|
wb.save(output_path)
|
||||||
@@ -63,6 +63,7 @@ def build_grouped_report(
|
|||||||
"subject": issue.subject,
|
"subject": issue.subject,
|
||||||
"status_ru": status_ru,
|
"status_ru": status_ru,
|
||||||
"time_text": time_text,
|
"time_text": time_text,
|
||||||
|
"hours": round(hours, 2),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -73,6 +74,26 @@ def build_grouped_report(
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_summary(rows: List[ReportRow]) -> Dict[str, float]:
|
||||||
|
"""Возвращает сводку: общее время, время по проектам и версиям."""
|
||||||
|
total = 0.0
|
||||||
|
by_project: Dict[str, float] = {}
|
||||||
|
by_project_version: Dict[str, float] = {}
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
hours = r.get("hours", 0.0)
|
||||||
|
total += hours
|
||||||
|
by_project[r["project"]] = by_project.get(r["project"], 0.0) + hours
|
||||||
|
key = f"{r['project']}::{r['version']}"
|
||||||
|
by_project_version[key] = by_project_version.get(key, 0.0) + hours
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": round(total, 2),
|
||||||
|
**{f"project:{k}": round(v, 2) for k, v in by_project.items()},
|
||||||
|
**{f"version:{k}": round(v, 2) for k, v in by_project_version.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def group_rows_by_project_and_version(
|
def group_rows_by_project_and_version(
|
||||||
rows: List[ReportRow],
|
rows: List[ReportRow],
|
||||||
) -> Dict[str, Dict[str, List[ReportRow]]]:
|
) -> Dict[str, Dict[str, List[ReportRow]]]:
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ class ReportRow(TypedDict):
|
|||||||
subject: str
|
subject: str
|
||||||
status_ru: str
|
status_ru: str
|
||||||
time_text: str
|
time_text: str
|
||||||
|
hours: float
|
||||||
|
|||||||
@@ -170,6 +170,18 @@ def test_cli_config_file_loading(mock_fetch, tmp_path):
|
|||||||
assert code == 0
|
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.dict(os.environ, VALID_ENV, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
|
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import io
|
import io
|
||||||
|
import json
|
||||||
from typing import List
|
from typing import List
|
||||||
from unittest import mock
|
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.console import CompactFormatter, TableFormatter
|
||||||
from redmine_reporter.formatters.csv import CSVFormatter
|
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.html import HTMLFormatter
|
||||||
|
from redmine_reporter.formatters.json import JSONFormatter
|
||||||
from redmine_reporter.formatters.markdown import MarkdownFormatter
|
from redmine_reporter.formatters.markdown import MarkdownFormatter
|
||||||
from redmine_reporter.formatters.odt import ODTFormatter
|
from redmine_reporter.formatters.odt import ODTFormatter
|
||||||
|
from redmine_reporter.formatters.xlsx import XLSXFormatter
|
||||||
from redmine_reporter.types import ReportRow
|
from redmine_reporter.types import ReportRow
|
||||||
|
|
||||||
|
|
||||||
@@ -223,6 +227,7 @@ TEXT_FORMATTER_FACTORIES = [
|
|||||||
("table", lambda: TableFormatter()),
|
("table", lambda: TableFormatter()),
|
||||||
("compact", lambda: CompactFormatter()),
|
("compact", lambda: CompactFormatter()),
|
||||||
("csv", lambda: CSVFormatter()),
|
("csv", lambda: CSVFormatter()),
|
||||||
|
("json", lambda: JSONFormatter()),
|
||||||
("markdown", lambda: MarkdownFormatter()),
|
("markdown", lambda: MarkdownFormatter()),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -250,6 +255,10 @@ def test_text_formatter_contains_key_content(fake_rows, name, factory):
|
|||||||
# В CSV issue_id и subject -- отдельные колонки
|
# В CSV issue_id и subject -- отдельные колонки
|
||||||
assert "101" in output
|
assert "101" in output
|
||||||
assert "Реализовать фичу X" in output
|
assert "Реализовать фичу X" in output
|
||||||
|
elif name == "json":
|
||||||
|
# В JSON issue_id -- число, subject -- отдельное поле
|
||||||
|
assert '"issue_id": 101' in output
|
||||||
|
assert "Реализовать фичу X" in output
|
||||||
else:
|
else:
|
||||||
assert "101. Реализовать фичу X" in output
|
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]
|
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():
|
def test_markdown_formatter_escapes_table_cells():
|
||||||
rows = make_fake_report_rows()
|
rows = make_fake_report_rows()
|
||||||
rows[0]["project"] = "A|B"
|
rows[0]["project"] = "A|B"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from redmine_reporter.report_builder import (
|
from redmine_reporter.report_builder import (
|
||||||
STATUS_TRANSLATION,
|
STATUS_TRANSLATION,
|
||||||
build_grouped_report,
|
build_grouped_report,
|
||||||
|
calculate_summary,
|
||||||
group_rows_by_project_and_version,
|
group_rows_by_project_and_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,7 +61,61 @@ def test_build_grouped_report_grouping():
|
|||||||
|
|
||||||
assert rows[0]["status_ru"] == "В работе"
|
assert rows[0]["status_ru"] == "В работе"
|
||||||
assert rows[0]["time_text"] == "2ч"
|
assert rows[0]["time_text"] == "2ч"
|
||||||
|
assert rows[0]["hours"] == 2.0
|
||||||
assert rows[1]["time_text"] == "1ч 30м"
|
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():
|
def test_build_grouped_report_new_version_same_project():
|
||||||
|
|||||||
Reference in New Issue
Block a user