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:
@@ -483,6 +483,55 @@ class TestInitConfig:
|
||||
assert "report" in data
|
||||
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("redmine_reporter.cli.fetch_issues_with_spent_time")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -277,3 +277,27 @@ def test_group_rows_preserves_row_data():
|
||||
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"] == "Закрыто"
|
||||
|
||||
Reference in New Issue
Block a user