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:
Кокос Артем Николаевич
2026-07-17 15:18:27 +07:00
parent 09f6062e8c
commit 8614062ecd
7 changed files with 332 additions and 2 deletions

View File

@@ -907,3 +907,169 @@ class TestReportNoTime:
Config._app = AppConfig.from_yaml(yaml_path)
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