Files
redmine-reporter/tests/test_report_builder.py
Кокос Артем Николаевич 8614062ecd feat: status translation overrides via YAML config
report.status_translation in YAML config overrides/extends the builtin
STATUS_TRANSLATION dictionary; without the section behavior is unchanged.

- AppConfig.report_status_translation + _resolve_str_dict (warns and
  skips non-scalar values, resolves ${VAR} references)
- Config.get_status_translation() returns merged copy (lazy import,
  builtin dict never mutated)
- build_grouped_report() accepts optional status_translation parameter
- --init-config template gains commented example, docs/CONFIG.md updated

Closes #65
2026-07-17 15:18:27 +07:00

304 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"] == ""
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": "",
"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": "",
"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": "",
},
{
"project": "A",
"version": "v2",
"issue_id": 2,
"subject": "T2",
"status_ru": "S",
"time_text": "",
},
{
"project": "B",
"version": "v1",
"issue_id": 3,
"subject": "T3",
"status_ru": "S",
"time_text": "",
},
]
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": "",
},
{
"project": "A",
"version": "v1",
"issue_id": 2,
"subject": "T2",
"status_ru": "S",
"time_text": "",
},
{
"project": "A",
"version": "v1",
"issue_id": 3,
"subject": "T3",
"status_ru": "S",
"time_text": "",
},
]
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": "",
},
]
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"] == ""
def test_group_rows_empty():
"""Пустой список — пустой словарь."""
assert group_rows_by_project_and_version([]) == {}
# -- #65: кастомный перевод статусов --
def test_build_grouped_report_custom_status_translation():
"""status_translation переопределяет встроенный словарь (#65)."""
issue = MockIssue("P", "S", "New", "v1.0", 1)
rows = build_grouped_report([(issue, 1.0)], status_translation={"New": "Новая"})
assert rows[0]["status_ru"] == "Новая"
def test_build_grouped_report_custom_status_translation_passthrough():
"""Статус вне кастомного словаря возвращается как есть (#65)."""
issue = MockIssue("P", "S", "Closed", "v1.0", 1)
rows = build_grouped_report([(issue, 1.0)], status_translation={"New": "Новая"})
assert rows[0]["status_ru"] == "Closed"
def test_build_grouped_report_none_translation_uses_builtin():
"""status_translation=None — используется встроенный словарь (#65)."""
issue = MockIssue("P", "S", "Closed", "v1.0", 1)
rows = build_grouped_report([(issue, 1.0)], status_translation=None)
assert rows[0]["status_ru"] == "Закрыто"