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
This commit is contained in:
@@ -330,6 +330,7 @@ email:
|
|||||||
| Поле | Тип | По умолчанию | Описание |
|
| Поле | Тип | По умолчанию | Описание |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `no_time` | bool | `false` | Не включать затраченное время в файл отчёта |
|
| `no_time` | bool | `false` | Не включать затраченное время в файл отчёта |
|
||||||
|
| `status_translation` | map[str,str] | `{}` | Переопределение/дополнение перевода статусов |
|
||||||
|
|
||||||
`report.no_time` применяется только в автоматических режимах (`--commit`, `--send`).
|
`report.no_time` применяется только в автоматических режимах (`--commit`, `--send`).
|
||||||
При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`.
|
При ручном `--output` YAML-настройка игнорируется — там работает только CLI-флаг `--no-time`.
|
||||||
@@ -353,6 +354,22 @@ redmine-reporter --output report.odt
|
|||||||
redmine-reporter --output report.odt --no-time
|
redmine-reporter --output report.odt --no-time
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `report.status_translation` — перевод статусов
|
||||||
|
|
||||||
|
По умолчанию статусы Redmine переводятся встроенным словарём
|
||||||
|
(`New` → `В работе`, `Closed` → `Закрыто` и т.д.). Секция
|
||||||
|
`report.status_translation` переопределяет отдельные переводы и/или
|
||||||
|
добавляет новые статусы; не указанные здесь статусы переводятся
|
||||||
|
встроенным словарём, а совсем неизвестные выводятся как есть.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
report:
|
||||||
|
status_translation:
|
||||||
|
"New": "Новая"
|
||||||
|
"Wait Release": "Ожидает релиза"
|
||||||
|
"Custom Status": "Кастомный статус"
|
||||||
|
```
|
||||||
|
|
||||||
## Разрешение выходного пути
|
## Разрешение выходного пути
|
||||||
|
|
||||||
Функция `resolve_output_path()` определяет итоговый путь к файлу:
|
Функция `resolve_output_path()` определяет итоговый путь к файлу:
|
||||||
|
|||||||
@@ -136,6 +136,16 @@ def _run_init_config(config_path: str, force: bool) -> int:
|
|||||||
yaml.dump(
|
yaml.dump(
|
||||||
data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
|
data, fh, allow_unicode=True, default_flow_style=False, sort_keys=False
|
||||||
)
|
)
|
||||||
|
fh.write(
|
||||||
|
"\n"
|
||||||
|
"# report.status_translation: переопределение/дополнение перевода\n"
|
||||||
|
"# статусов Redmine. Без секции используется встроенный словарь.\n"
|
||||||
|
"# Пример:\n"
|
||||||
|
"# report:\n"
|
||||||
|
"# status_translation:\n"
|
||||||
|
'# "New": "Новая"\n'
|
||||||
|
'# "Wait Release": "Ожидает релиза"\n'
|
||||||
|
)
|
||||||
path.chmod(0o600)
|
path.chmod(0o600)
|
||||||
|
|
||||||
sections_found = [s for s in data if data[s]]
|
sections_found = [s for s in data if data[s]]
|
||||||
@@ -445,6 +455,7 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|||||||
issue_hours,
|
issue_hours,
|
||||||
fill_time=not no_time,
|
fill_time=not no_time,
|
||||||
by_activity=args.by_activity,
|
by_activity=args.by_activity,
|
||||||
|
status_translation=Config.get_status_translation(),
|
||||||
)
|
)
|
||||||
|
|
||||||
if args.summary:
|
if args.summary:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import sys
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union
|
from typing import Dict, Union
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@@ -62,6 +62,7 @@ class AppConfig:
|
|||||||
output_filename: str = "{author}_{from}_{to}.{ext}"
|
output_filename: str = "{author}_{from}_{to}.{ext}"
|
||||||
output_default_format: str = "xlsx"
|
output_default_format: str = "xlsx"
|
||||||
report_no_time: bool = False
|
report_no_time: bool = False
|
||||||
|
report_status_translation: Dict[str, str] = field(default_factory=dict)
|
||||||
email: EmailConfig = field(default_factory=EmailConfig)
|
email: EmailConfig = field(default_factory=EmailConfig)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -111,6 +112,9 @@ class AppConfig:
|
|||||||
output_default_format=cls._resolve_str(raw, "output", "default_format")
|
output_default_format=cls._resolve_str(raw, "output", "default_format")
|
||||||
or "xlsx",
|
or "xlsx",
|
||||||
report_no_time=cls._resolve_bool(raw, "report", "no_time"),
|
report_no_time=cls._resolve_bool(raw, "report", "no_time"),
|
||||||
|
report_status_translation=cls._resolve_str_dict(
|
||||||
|
raw, "report", "status_translation"
|
||||||
|
),
|
||||||
email=cls._resolve_email(raw),
|
email=cls._resolve_email(raw),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -140,6 +144,43 @@ class AppConfig:
|
|||||||
return ""
|
return ""
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_str_dict(raw: dict, section: str, key: str) -> Dict[str, str]:
|
||||||
|
"""Читает вложенный mapping строк из YAML-секции.
|
||||||
|
|
||||||
|
Не-mapping значение логируется с warning и игнорируется.
|
||||||
|
Ключи и значения приводятся к str, ${VAR} в значениях резолвится.
|
||||||
|
Записи со значением null или не-scalar (dict/list) пропускаются
|
||||||
|
с warning — иначе null стал бы строкой "None", а dict — repr.
|
||||||
|
"""
|
||||||
|
section_value = raw.get(section)
|
||||||
|
if not isinstance(section_value, dict):
|
||||||
|
return {}
|
||||||
|
value = section_value.get(key)
|
||||||
|
if value is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
logger.warning(
|
||||||
|
"Config %s.%s must be a mapping, got %s — ignoring",
|
||||||
|
section,
|
||||||
|
key,
|
||||||
|
type(value).__name__,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
result: Dict[str, str] = {}
|
||||||
|
for k, v in value.items():
|
||||||
|
if v is None or isinstance(v, (dict, list)):
|
||||||
|
logger.warning(
|
||||||
|
"Config %s.%s entry %r must be a scalar, got %s — skipping",
|
||||||
|
section,
|
||||||
|
key,
|
||||||
|
k,
|
||||||
|
type(v).__name__,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
result[str(k)] = resolve_env_vars(str(v))
|
||||||
|
return result
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_bool(raw: dict, section: str, key: str) -> bool:
|
def _resolve_bool(raw: dict, section: str, key: str) -> bool:
|
||||||
value = raw.get(section, {}).get(key)
|
value = raw.get(section, {}).get(key)
|
||||||
@@ -367,6 +408,18 @@ class Config:
|
|||||||
return cls._app.report_no_time
|
return cls._app.report_no_time
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_status_translation(cls) -> Dict[str, str]:
|
||||||
|
"""Полный словарь перевода статусов: встроенный + переопределения из YAML.
|
||||||
|
|
||||||
|
Возвращает новый словарь — модульный STATUS_TRANSLATION не мутируется.
|
||||||
|
Импорт ленивый: config.py не должен тянуть redminelib при импорте.
|
||||||
|
"""
|
||||||
|
from .report_builder import STATUS_TRANSLATION
|
||||||
|
|
||||||
|
overrides = cls._app.report_status_translation if cls._app else {}
|
||||||
|
return {**STATUS_TRANSLATION, **overrides}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_email_config(cls) -> "EmailConfig | None":
|
def get_email_config(cls) -> "EmailConfig | None":
|
||||||
"""Возвращает EmailConfig из YAML-конфига или None, если не настроен."""
|
"""Возвращает EmailConfig из YAML-конфига или None, если не настроен."""
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ def build_grouped_report(
|
|||||||
issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]],
|
issue_hours: List[Tuple[Issue, float, Optional[Dict[str, float]]]],
|
||||||
fill_time: bool = True,
|
fill_time: bool = True,
|
||||||
by_activity: bool = False,
|
by_activity: bool = False,
|
||||||
|
status_translation: Optional[Dict[str, str]] = None,
|
||||||
) -> List[ReportRow]:
|
) -> List[ReportRow]:
|
||||||
"""
|
"""
|
||||||
Преобразует список задач с затраченным временем в плоский список строк отчёта,
|
Преобразует список задач с затраченным временем в плоский список строк отчёта,
|
||||||
@@ -42,6 +43,11 @@ def build_grouped_report(
|
|||||||
|
|
||||||
Предусловие: issue_hours должен быть отсортирован по (project, version).
|
Предусловие: issue_hours должен быть отсортирован по (project, version).
|
||||||
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
|
Функция выполняет сортировку самостоятельно для защиты от несортированного ввода.
|
||||||
|
|
||||||
|
status_translation: перевод статусов; None — встроенный STATUS_TRANSLATION.
|
||||||
|
Переданный словарь ЗАМЕНЯЕТ встроенный полностью (без merge): статус,
|
||||||
|
отсутствующий в нём, выводится как есть (passthrough); пустой dict —
|
||||||
|
все статусы выводятся как есть.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
|
# Защитная сортировка -- гарантирует корректную группировку независимо от порядка на входе
|
||||||
@@ -49,6 +55,10 @@ def build_grouped_report(
|
|||||||
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)
|
issue_hours, key=lambda x: (str(x[0].project), get_version(x[0]), x[0].id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
translation = (
|
||||||
|
status_translation if status_translation is not None else STATUS_TRANSLATION
|
||||||
|
)
|
||||||
|
|
||||||
rows: List[ReportRow] = []
|
rows: List[ReportRow] = []
|
||||||
prev_project: str = ""
|
prev_project: str = ""
|
||||||
prev_version: str = ""
|
prev_version: str = ""
|
||||||
@@ -58,7 +68,7 @@ def build_grouped_report(
|
|||||||
project = str(issue.project)
|
project = str(issue.project)
|
||||||
version = get_version(issue)
|
version = get_version(issue)
|
||||||
status_en = str(issue.status)
|
status_en = str(issue.status)
|
||||||
status_ru = STATUS_TRANSLATION.get(status_en, status_en)
|
status_ru = translation.get(status_en, status_en)
|
||||||
|
|
||||||
if fill_time:
|
if fill_time:
|
||||||
if by_activity and activities:
|
if by_activity and activities:
|
||||||
|
|||||||
@@ -483,6 +483,55 @@ class TestInitConfig:
|
|||||||
assert "report" in data
|
assert "report" in data
|
||||||
assert data["report"]["no_time"] is False
|
assert data["report"]["no_time"] is False
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
def test_init_config_includes_status_translation_example(self, tmp_path):
|
||||||
|
"""#65: --init-config дописывает закомментированный пример status_translation.
|
||||||
|
|
||||||
|
Файл остаётся валидным YAML, активной секции status_translation нет.
|
||||||
|
"""
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
code = main(["--init-config", "--config-path", str(config_path)])
|
||||||
|
assert code == 0
|
||||||
|
|
||||||
|
text = config_path.read_text(encoding="utf-8")
|
||||||
|
assert "# status_translation:" in text
|
||||||
|
|
||||||
|
with open(config_path) as fh:
|
||||||
|
data = yaml.safe_load(fh)
|
||||||
|
|
||||||
|
assert "status_translation" not in data["report"]
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
|
||||||
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
def test_cli_status_translation_from_yaml_reaches_builder(mock_fetch, tmp_path):
|
||||||
|
"""#65: report.status_translation из YAML доезжает до build_grouped_report."""
|
||||||
|
issue = _MockIssue()
|
||||||
|
mock_fetch.return_value = [(issue, 1.0, None)]
|
||||||
|
config_path = tmp_path / "config.yml"
|
||||||
|
config_path.write_text(
|
||||||
|
"report:\n"
|
||||||
|
" status_translation:\n"
|
||||||
|
' New: "Новая"\n'
|
||||||
|
' "Custom Status": "Кастом"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch("redmine_reporter.cli.build_grouped_report") as mock_build:
|
||||||
|
mock_build.return_value = []
|
||||||
|
code = main(
|
||||||
|
["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert code == 0
|
||||||
|
_, kwargs = mock_build.call_args
|
||||||
|
translation = kwargs["status_translation"]
|
||||||
|
assert translation["New"] == "Новая"
|
||||||
|
assert translation["Custom Status"] == "Кастом"
|
||||||
|
# встроенные переводы сохранились
|
||||||
|
assert translation["Closed"] == "Закрыто"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.dict(os.environ, {}, clear=True)
|
@mock.patch.dict(os.environ, {}, clear=True)
|
||||||
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||||
|
|||||||
@@ -907,3 +907,169 @@ class TestReportNoTime:
|
|||||||
|
|
||||||
Config._app = AppConfig.from_yaml(yaml_path)
|
Config._app = AppConfig.from_yaml(yaml_path)
|
||||||
assert Config.get_report_no_time() is False
|
assert Config.get_report_no_time() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# #65: report.status_translation — перевод статусов из YAML
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestStatusTranslationFromYaml:
|
||||||
|
"""Tests for report.status_translation loading in AppConfig.from_yaml()."""
|
||||||
|
|
||||||
|
def test_status_translation_loaded_from_yaml(self):
|
||||||
|
"""report.status_translation заполняет поле AppConfig (override + новый)."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"report:\n"
|
||||||
|
" status_translation:\n"
|
||||||
|
' New: "Новая"\n'
|
||||||
|
' "Custom Status": "Кастом"\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {
|
||||||
|
"New": "Новая",
|
||||||
|
"Custom Status": "Кастом",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_status_translation_missing_defaults_to_empty(self):
|
||||||
|
"""Без секции report.status_translation поле пустое."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("report:\n no_time: true\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {}
|
||||||
|
|
||||||
|
def test_status_translation_non_mapping_warns_and_ignored(self, caplog):
|
||||||
|
"""status_translation не-mapping (строка) → warning в лог, поле {}."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("report:\n status_translation: 'just a string'\n")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {}
|
||||||
|
assert "status_translation" in caplog.text
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {"STATUS_RU": "На проверке"}, clear=True)
|
||||||
|
def test_status_translation_env_var_resolved(self):
|
||||||
|
"""${VAR} в значении перевода резолвится из окружения."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"report:\n status_translation:\n Testing: ${STATUS_RU}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {"Testing": "На проверке"}
|
||||||
|
|
||||||
|
def test_status_translation_null_value_skipped_with_warning(self, caplog):
|
||||||
|
"""Значение null пропускается с warning, а не превращается в 'None'."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"report:\n status_translation:\n New:\n Closed: Закрыто\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {"Closed": "Закрыто"}
|
||||||
|
assert "status_translation" in caplog.text
|
||||||
|
assert "New" in caplog.text
|
||||||
|
|
||||||
|
def test_status_translation_nested_dict_skipped_with_warning(self, caplog):
|
||||||
|
"""Вложенный dict в значении пропускается с warning, а не в repr."""
|
||||||
|
import logging
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text(
|
||||||
|
"report:\n"
|
||||||
|
" status_translation:\n"
|
||||||
|
" New:\n"
|
||||||
|
" nested: value\n"
|
||||||
|
" Closed: Закрыто\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {"Closed": "Закрыто"}
|
||||||
|
assert "status_translation" in caplog.text
|
||||||
|
assert "New" in caplog.text
|
||||||
|
|
||||||
|
def test_status_translation_int_value_coerced_to_str(self):
|
||||||
|
"""Scalar-значения (int) по-прежнему приводятся к str."""
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
yaml_path = Path(tmp) / "config.yml"
|
||||||
|
yaml_path.write_text("report:\n status_translation:\n New: 42\n")
|
||||||
|
|
||||||
|
cfg = AppConfig.from_yaml(yaml_path)
|
||||||
|
|
||||||
|
assert cfg.report_status_translation == {"New": "42"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetStatusTranslation:
|
||||||
|
"""Tests for Config.get_status_translation()."""
|
||||||
|
|
||||||
|
def test_no_yaml_returns_builtin(self):
|
||||||
|
"""Без YAML-конфига возвращается встроенный словарь."""
|
||||||
|
from redmine_reporter.report_builder import STATUS_TRANSLATION
|
||||||
|
|
||||||
|
Config._app = None
|
||||||
|
try:
|
||||||
|
assert Config.get_status_translation() == STATUS_TRANSLATION
|
||||||
|
finally:
|
||||||
|
Config._app = None
|
||||||
|
|
||||||
|
def test_empty_overrides_returns_builtin(self):
|
||||||
|
"""Пустые overrides — результат равен встроенному словарю."""
|
||||||
|
from redmine_reporter.report_builder import STATUS_TRANSLATION
|
||||||
|
|
||||||
|
Config._app = AppConfig()
|
||||||
|
try:
|
||||||
|
assert Config.get_status_translation() == STATUS_TRANSLATION
|
||||||
|
finally:
|
||||||
|
Config._app = None
|
||||||
|
|
||||||
|
def test_override_and_extend(self):
|
||||||
|
"""Override существующего статуса + добавление нового."""
|
||||||
|
Config._app = AppConfig(
|
||||||
|
report_status_translation={"New": "Новая", "Custom Status": "Кастом"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
translation = Config.get_status_translation()
|
||||||
|
finally:
|
||||||
|
Config._app = None
|
||||||
|
|
||||||
|
assert translation["New"] == "Новая"
|
||||||
|
assert translation["Custom Status"] == "Кастом"
|
||||||
|
# остальные встроенные переводы не тронуты
|
||||||
|
assert translation["Closed"] == "Закрыто"
|
||||||
|
|
||||||
|
def test_result_is_a_copy(self):
|
||||||
|
"""Мутация результата не трогает модульный STATUS_TRANSLATION."""
|
||||||
|
from redmine_reporter.report_builder import STATUS_TRANSLATION
|
||||||
|
|
||||||
|
Config._app = AppConfig(report_status_translation={"New": "Новая"})
|
||||||
|
try:
|
||||||
|
translation = Config.get_status_translation()
|
||||||
|
translation["New"] = "ИЗМЕНЕНО"
|
||||||
|
translation["Junk"] = "мусор"
|
||||||
|
finally:
|
||||||
|
Config._app = None
|
||||||
|
|
||||||
|
assert STATUS_TRANSLATION["New"] == "В работе"
|
||||||
|
assert "Junk" not in STATUS_TRANSLATION
|
||||||
|
|||||||
@@ -277,3 +277,27 @@ def test_group_rows_preserves_row_data():
|
|||||||
def test_group_rows_empty():
|
def test_group_rows_empty():
|
||||||
"""Пустой список — пустой словарь."""
|
"""Пустой список — пустой словарь."""
|
||||||
assert group_rows_by_project_and_version([]) == {}
|
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"] == "Закрыто"
|
||||||
|
|||||||
Reference in New Issue
Block a user