- #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
280 lines
9.3 KiB
Python
280 lines
9.3 KiB
Python
from redmine_reporter.report_builder import (
|
||
STATUS_TRANSLATION,
|
||
build_grouped_report,
|
||
calculate_summary,
|
||
group_rows_by_project_and_version,
|
||
)
|
||
|
||
|
||
class MockIssue:
|
||
def __init__(self, project, subject, status, fixed_version=None, issue_id=999):
|
||
self.id = issue_id
|
||
self.project = project
|
||
self.subject = subject
|
||
self.status = status
|
||
if fixed_version is not None:
|
||
self.fixed_version = fixed_version
|
||
|
||
|
||
# -- Таблица переводов статусов --
|
||
|
||
|
||
def test_status_translation():
|
||
assert STATUS_TRANSLATION["Closed"] == "Закрыто"
|
||
assert STATUS_TRANSLATION["New"] == "В работе"
|
||
assert STATUS_TRANSLATION["Resolved"] == "Решена"
|
||
assert STATUS_TRANSLATION["Pending"] == "Ожидание"
|
||
|
||
|
||
def test_status_translation_unknown_passthrough():
|
||
"""Неизвестный статус возвращается как есть."""
|
||
from redmine_reporter.report_builder import STATUS_TRANSLATION
|
||
|
||
assert "SomeNewStatus" not in STATUS_TRANSLATION
|
||
# build_grouped_report вернёт оригинальное значение
|
||
issue = MockIssue("P", "S", "SomeNewStatus", "v1.0", 1)
|
||
rows = build_grouped_report([(issue, 1.0)])
|
||
assert rows[0]["status_ru"] == "SomeNewStatus"
|
||
|
||
|
||
# -- Основная логика группировки --
|
||
|
||
|
||
def test_build_grouped_report_grouping():
|
||
issues = [
|
||
(MockIssue("Камеры", "Фича A", "New", "v2.5.0", 101), 2.0),
|
||
(MockIssue("Камеры", "Баг B", "Resolved", "v2.5.0", 102), 1.5),
|
||
(MockIssue("ПО", "Доки", "Pending", None, 201), 4.0),
|
||
]
|
||
rows = build_grouped_report(issues)
|
||
|
||
assert len(rows) == 3
|
||
# Первая строка -- полное название проекта и версии
|
||
assert rows[0]["display_project"] == "Камеры"
|
||
assert rows[0]["display_version"] == "v2.5.0"
|
||
# Вторая -- пустые display_* из-за совпадения проекта+версии
|
||
assert rows[1]["display_project"] == ""
|
||
assert rows[1]["display_version"] == ""
|
||
# Третья -- новый проект
|
||
assert rows[2]["display_project"] == "ПО"
|
||
assert rows[2]["display_version"] == "<N/A>"
|
||
|
||
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():
|
||
"""Смена версии внутри одного проекта -- display_project пустой, display_version новая."""
|
||
issues = [
|
||
(MockIssue("Камеры", "Задача 1", "New", "v1.0", 1), 1.0),
|
||
(MockIssue("Камеры", "Задача 2", "New", "v2.0", 2), 1.0),
|
||
]
|
||
rows = build_grouped_report(issues)
|
||
assert rows[0]["display_project"] == "Камеры"
|
||
assert rows[0]["display_version"] == "v1.0"
|
||
assert rows[1]["display_project"] == ""
|
||
assert rows[1]["display_version"] == "v2.0"
|
||
|
||
|
||
def test_build_grouped_report_sorts_input():
|
||
"""Несортированный вход -- результат всё равно корректно сгруппирован."""
|
||
issues = [
|
||
(MockIssue("ПО", "Задача B", "New", "v1.0", 2), 1.0),
|
||
(MockIssue("Камеры", "Задача A", "New", "v1.0", 1), 2.0),
|
||
]
|
||
rows = build_grouped_report(issues)
|
||
# После сортировки "Камеры" < "ПО" (лексикографически по кириллице)
|
||
assert rows[0]["project"] == "Камеры"
|
||
assert rows[1]["project"] == "ПО"
|
||
# Оба display_project непустые -- разные проекты
|
||
assert rows[0]["display_project"] == "Камеры"
|
||
assert rows[1]["display_project"] == "ПО"
|
||
|
||
|
||
def test_build_grouped_report_empty():
|
||
"""Пустой вход -- пустой результат, без исключений."""
|
||
rows = build_grouped_report([])
|
||
assert rows == []
|
||
|
||
|
||
def test_build_grouped_report_no_time():
|
||
"""fill_time=False -- time_text пустой для всех строк."""
|
||
issues = [(MockIssue("P", "S", "New", "v1.0", 1), 3.5)]
|
||
rows = build_grouped_report(issues, fill_time=False)
|
||
assert rows[0]["time_text"] == ""
|
||
|
||
|
||
def test_build_grouped_report_preserves_issue_id_and_subject():
|
||
issues = [(MockIssue("P", "Моя задача", "Closed", "v1.0", 42), 0.5)]
|
||
rows = build_grouped_report(issues)
|
||
assert rows[0]["issue_id"] == 42
|
||
assert rows[0]["subject"] == "Моя задача"
|
||
|
||
|
||
def test_build_grouped_report_deterministic_order_within_group():
|
||
"""Задачи в одной группе проект+версия упорядочены по issue.id
|
||
независимо от порядка на входе (#33)."""
|
||
issues = [
|
||
(MockIssue("P", "Task C", "New", "v1.0", 103), 1.0),
|
||
(MockIssue("P", "Task A", "New", "v1.0", 101), 1.0),
|
||
(MockIssue("P", "Task B", "New", "v1.0", 102), 1.0),
|
||
]
|
||
rows = build_grouped_report(issues)
|
||
assert [r["issue_id"] for r in rows] == [101, 102, 103]
|
||
|
||
|
||
# -- #19: Общая функция группировки --
|
||
|
||
|
||
def test_group_rows_basic():
|
||
"""Два проекта, в одном две версии — структура корректна."""
|
||
rows = [
|
||
{
|
||
"project": "A",
|
||
"version": "v1",
|
||
"issue_id": 1,
|
||
"subject": "T1",
|
||
"status_ru": "S",
|
||
"time_text": "1ч",
|
||
},
|
||
{
|
||
"project": "A",
|
||
"version": "v2",
|
||
"issue_id": 2,
|
||
"subject": "T2",
|
||
"status_ru": "S",
|
||
"time_text": "2ч",
|
||
},
|
||
{
|
||
"project": "B",
|
||
"version": "v1",
|
||
"issue_id": 3,
|
||
"subject": "T3",
|
||
"status_ru": "S",
|
||
"time_text": "3ч",
|
||
},
|
||
]
|
||
grouped = group_rows_by_project_and_version(rows)
|
||
|
||
assert list(grouped.keys()) == ["A", "B"]
|
||
assert list(grouped["A"].keys()) == ["v1", "v2"]
|
||
assert list(grouped["B"].keys()) == ["v1"]
|
||
assert len(grouped["A"]["v1"]) == 1
|
||
assert len(grouped["A"]["v2"]) == 1
|
||
assert len(grouped["B"]["v1"]) == 1
|
||
|
||
|
||
def test_group_rows_multiple_tasks_per_version():
|
||
"""Несколько задач в одной версии — все попадают в список."""
|
||
rows = [
|
||
{
|
||
"project": "A",
|
||
"version": "v1",
|
||
"issue_id": 1,
|
||
"subject": "T1",
|
||
"status_ru": "S",
|
||
"time_text": "1ч",
|
||
},
|
||
{
|
||
"project": "A",
|
||
"version": "v1",
|
||
"issue_id": 2,
|
||
"subject": "T2",
|
||
"status_ru": "S",
|
||
"time_text": "2ч",
|
||
},
|
||
{
|
||
"project": "A",
|
||
"version": "v1",
|
||
"issue_id": 3,
|
||
"subject": "T3",
|
||
"status_ru": "S",
|
||
"time_text": "3ч",
|
||
},
|
||
]
|
||
grouped = group_rows_by_project_and_version(rows)
|
||
|
||
assert len(grouped["A"]["v1"]) == 3
|
||
assert [r["issue_id"] for r in grouped["A"]["v1"]] == [1, 2, 3]
|
||
|
||
|
||
def test_group_rows_preserves_row_data():
|
||
"""Строки в группировке — те же объекты, без потери данных."""
|
||
rows = [
|
||
{
|
||
"project": "P",
|
||
"version": "v1",
|
||
"issue_id": 42,
|
||
"subject": "Task",
|
||
"status_ru": "Готово",
|
||
"time_text": "5ч",
|
||
},
|
||
]
|
||
grouped = group_rows_by_project_and_version(rows)
|
||
|
||
row = grouped["P"]["v1"][0]
|
||
assert row["issue_id"] == 42
|
||
assert row["subject"] == "Task"
|
||
assert row["status_ru"] == "Готово"
|
||
assert row["time_text"] == "5ч"
|
||
|
||
|
||
def test_group_rows_empty():
|
||
"""Пустой список — пустой словарь."""
|
||
assert group_rows_by_project_and_version([]) == {}
|