Files
redmine-reporter/tests/test_cli.py
Кокос Артем Николаевич 9b78d66769 feat: --commit flag (#44)
- --commit: после генерации отчёта сохраняет период в
  period.last_used.from/to YAML-конфига
- При period.dynamic=true следующий запуск вычисляет период от last_used:
  полный месяц → следующий месяц, произвольный диапазон → та же длина
- При period.dynamic=false перезаписывает default_from/default_to
- При period.precision=datetime сохраняет timestamps с точностью до секунд
- --commit без --output сохраняет отчёт по шаблону из конфига
- compute_next_period() в config.py
- save_period_to_config() в yaml_config.py
- 23 новых теста (7 CLI, 6 config, 6 yaml, 4 date_range)

Closes #44
2026-07-07 10:47:08 +07:00

768 lines
28 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.
import os
from unittest import mock
import pytest
from redmine_reporter.cli import main, parse_date_range
VALID_ENV = {
"REDMINE_URL": "https://red.eltex.loc",
"REDMINE_API_KEY": "token",
}
@pytest.fixture(autouse=True)
def _reset_config_overrides():
"""Сбрасывает CLI-переопределения Config перед каждым тестом."""
from redmine_reporter.config import Config
Config.set_redmine_url("")
Config.set_redmine_api_key("")
Config._app = None
yield
Config.set_redmine_url("")
Config.set_redmine_api_key("")
Config._app = None
@pytest.mark.parametrize(
"date_arg, expected",
[
("2026-01-01--2026-01-31", ("2026-01-01", "2026-01-31")),
(" 2026-01-01 -- 2026-01-31 ", ("2026-01-01", "2026-01-31")),
],
)
def test_parse_date_range_valid(date_arg, expected):
assert parse_date_range(date_arg) == expected
@pytest.mark.parametrize(
"date_arg",
[
"20260101-20260131",
"2026-1-01--2026-01-31",
"2026-02-30--2026-03-01",
"2026-02-01--2026-01-31",
],
)
def test_parse_date_range_invalid(date_arg):
with pytest.raises(ValueError):
parse_date_range(date_arg)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_returns_zero_on_no_entries(mock_fetch):
"""None от fetch (нет time entries) -- выход 0."""
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31"])
assert code == 0
@mock.patch.dict(os.environ, {}, clear=True)
def test_cli_config_error():
"""Невалидный конфиг -- выход 1."""
code = main(["--date", "2026-01-01--2026-01-31", "--config-path", "/nonexistent/config.yml"])
assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_cli_invalid_date_format():
"""Неверный формат даты -- выход 1."""
code = main(["--date", "20260101-20260131"])
assert code == 1
class _MockIssue:
"""Простой mock Redmine Issue для CLI-тестов."""
def __init__(self, issue_id=1, subject="Task", project="Project", status="New"):
self.id = issue_id
self.subject = subject
self.project = project
self.status = status
self.fixed_version = None
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_unknown_output_extension(mock_fetch, tmp_path):
"""Неизвестное расширение файла -- выход 1."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report.xyz")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
def test_cli_output_without_extension(mock_get, mock_fetch, tmp_path):
"""Файл без расширения — приложение расширяет default_format (#43)."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
output = str(tmp_path / "report")
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--output",
output,
"--config-path",
str(tmp_path / "nonexistent.yml"),
]
)
assert code == 0
saved_path = mock_formatter.save.call_args.args[1]
assert saved_path.endswith(".xlsx")
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.get_formatter_by_extension")
def test_cli_odt_missing_odfpy_message(mock_gf, mock_fetch, tmp_path, capsys):
"""При запросе .odt без odfpy — выход 1, понятное сообщение про odfpy."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
mock_gf.return_value = None
output = str(tmp_path / "report.odt")
code = main(["--date", "2026-01-01--2026-01-31", "--output", output])
assert code == 1
captured = capsys.readouterr()
assert "odfpy" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_cli_version_flag(capsys):
"""--version выводит версию и завершается с 0."""
with pytest.raises(SystemExit) as exc_info:
main(["--version"])
assert exc_info.value.code == 0
captured = capsys.readouterr()
assert "redmine-reporter" in captured.out
from redmine_reporter import __version__
assert __version__ in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_verbose_and_debug_flags_accepted(mock_fetch):
"""--verbose и --debug не ломают запуск."""
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31", "--verbose"])
assert code == 0
code = main(["--date", "2026-01-01--2026-01-31", "--debug"])
assert code == 0
class _MockIssue:
"""Простой mock Redmine Issue для CLI-тестов."""
def __init__(self, issue_id=1, subject="Task", project="Project", status="New"):
self.id = issue_id
self.subject = subject
self.project = project
self.status = status
self.fixed_version = None
@mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_url_and_api_key_override_env(mock_fetch):
"""--url и --api-key переопределяют отсутствующие/другие env-переменные."""
mock_fetch.return_value = None
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--url",
"https://other.redmine.loc",
"--api-key",
"cli-token",
]
)
assert code == 0
@mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_config_file_loading(mock_fetch, tmp_path):
"""--config загружает переменные из указанного .env-файла."""
config_path = tmp_path / "custom.env"
config_path.write_text(
"REDMINE_URL=https://config.redmine.loc\n" "REDMINE_API_KEY=config-token\n",
encoding="utf-8",
)
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31", "--config", str(config_path)])
assert code == 0
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_summary_flag_prints_totals(mock_fetch, capsys):
"""--summary выводит общее время и разбивку по проектам в stderr."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
main(["--date", "2026-01-01--2026-01-31", "--summary"])
captured = capsys.readouterr()
assert "Total time" in captured.err
assert "Project" in captured.err or "Project" in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_total_issues_message_goes_to_stderr(mock_fetch, capsys):
"""«Total issues» пишется в stderr, не загрязняя stdout при pipe (#28)."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert "Total issues" not in captured.out
assert "Total issues" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_auth_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при ошибке аутентификации."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Authentication failed: bad key")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "Authentication failed" in captured.err
assert "Redmine API error" not in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_forbidden_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при недостаточных правах."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Access denied: no permission")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "Access denied" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_prints_readable_timeout_error(mock_fetch, capsys):
"""CLI выводит понятное сообщение при таймауте."""
from redmine_reporter.client import RedmineAPIError
mock_fetch.side_effect = RedmineAPIError("Redmine request timed out after 30 seconds")
code = main(["--date", "2026-01-01--2026-01-31"])
captured = capsys.readouterr()
assert code == 1
assert "timed out" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_no_time_passed_to_formatter(mock_fetch, tmp_path):
"""CLI --no-time передаётся в файловый форматтер."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report.xlsx")
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get_formatter:
mock_formatter = mock.MagicMock()
mock_get_formatter.return_value = mock_formatter
main(["--date", "2026-01-01--2026-01-31", "--output", output, "--no-time"])
_, kwargs = mock_get_formatter.call_args
assert kwargs.get("no_time") is True
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_passes_user_id_to_fetch(mock_fetch):
"""CLI --user-id передаётся в fetch_issues_with_spent_time."""
mock_fetch.return_value = None
main(["--date", "2026-01-01--2026-01-31", "--user-id", "42"])
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] == "42"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_passes_user_login_to_fetch(mock_fetch):
"""CLI --user-login передаётся в fetch_issues_with_spent_time."""
mock_fetch.return_value = None
main(["--date", "2026-01-01--2026-01-31", "--user-login", "ivanov"])
_, kwargs = mock_fetch.call_args
assert kwargs["user_id"] == "ivanov"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_cli_rejects_multiple_user_flags():
"""CLI не принимает одновременно несколько флагов пользователя."""
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--user-id",
"42",
"--user-login",
"ivanov",
]
)
assert code == 1
# ---------------------------------------------------------------------------
# --init-config tests
# ---------------------------------------------------------------------------
class TestInitConfig:
"""Tests for --init-config flag."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_creates_yaml_file(self, tmp_path):
"""--init-config создаёт YAML файл с правильной структурой."""
import yaml
config_path = tmp_path / "config.yml"
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 0
assert config_path.exists()
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert "redmine" in data
assert data["redmine"]["url"] == "https://red.eltex.loc"
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_existing_file_no_force(self, tmp_path, capsys):
"""Без --force существующий файл → предупреждение и выход 1."""
config_path = tmp_path / "config.yml"
config_path.write_text("existing: true")
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 1
captured = capsys.readouterr()
assert "already exists" in captured.err or "already exists" in captured.out
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_force_overwrites(self, tmp_path):
"""--init-config --force перезаписывает существующий файл."""
import yaml
config_path = tmp_path / "config.yml"
config_path.write_text("existing: true")
code = main(["--init-config", "--force", "--config-path", str(config_path)])
assert code == 0
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert "redmine" in data
assert "existing" not in data
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_with_date_is_error(self, capsys):
"""--init-config с --date → ошибка, взаимоисключающие."""
code = main(["--init-config", "--date", "2026-01-01--2026-01-31"])
assert code == 1
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
def test_init_config_writes_secrets_as_env_ref(self, tmp_path):
"""Секреты пишутся как ${VAR} когда переменная окружения существует."""
import yaml
config_path = tmp_path / "config.yml"
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 0
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert data["redmine"]["api_key"] == "${REDMINE_API_KEY}"
@mock.patch.dict(os.environ, {}, clear=True)
def test_init_config_no_env_vars(self, tmp_path):
"""Без переменных окружения --init-config создаёт скелет."""
import yaml
config_path = tmp_path / "config.yml"
code = main(["--init-config", "--config-path", str(config_path)])
assert code == 0
with open(config_path) as fh:
data = yaml.safe_load(fh)
assert "redmine" in data
assert data["redmine"]["url"] == ""
assert data["redmine"]["api_key"] == ""
@mock.patch.dict(os.environ, {}, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_autoloads_yaml_config(mock_fetch, tmp_path):
"""CLI автоматически подгружает YAML-конфиг при запуске."""
config_path = tmp_path / "config.yml"
config_path.write_text(
"redmine:\n url: https://yaml-redmine.example.com/\n api_key: yaml-token\n"
)
mock_fetch.return_value = None
code = main(["--date", "2026-01-01--2026-01-31", "--config-path", str(config_path)])
assert code == 0
# ---------------------------------------------------------------------------
# #43: --output path resolution tests
# ---------------------------------------------------------------------------
class TestOutputPathResolution:
"""Tests for --output flag with path defaults."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_bare_format_name_xlsx_uses_template_path(self, mock_fetch, tmp_path):
"""--output xlsx без пути → использует шаблон из конфига."""
import yaml
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump(
{
"output": {
"dir": str(tmp_path / "reports"),
"filename": "report_{date}.{ext}",
"default_format": "xlsx",
}
}
)
)
mock_fetch.return_value = None
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--output",
"xlsx",
"--config-path",
str(config_path),
]
)
assert code == 0
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_output_without_extension_appends_default_format(self, mock_fetch, tmp_path):
"""--output report без расширения → добавляет .xlsx."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
output = str(tmp_path / "report")
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--output",
output,
"--config-path",
str(tmp_path / "nonexistent.yml"),
]
)
assert code == 0
mock_formatter.save.assert_called_once()
saved_path = mock_formatter.save.call_args.args[1]
assert saved_path.endswith(".xlsx")
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_output_with_known_extension_unchanged(self, mock_fetch, tmp_path):
"""--output report.csv с явным расширением передаётся как есть."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--output",
str(tmp_path / "report.csv"),
"--config-path",
str(tmp_path / "nonexistent.yml"),
]
)
assert code == 0
mock_formatter.save.assert_called_once()
saved_path = mock_formatter.save.call_args.args[1]
assert saved_path.endswith(".csv")
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_output_bare_format_invalid_is_treated_as_path(self, mock_fetch, tmp_path):
"""--output notanxlsx (no path, not known format) → treated as path + .xlsx appended."""
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--output",
"myreport",
"--config-path",
str(tmp_path / "nonexistent.yml"),
]
)
assert code == 0
mock_formatter.save.assert_called_once()
saved_path = mock_formatter.save.call_args.args[1]
assert saved_path == "myreport.xlsx"
# ---------------------------------------------------------------------------
# #44: --commit tests
# ---------------------------------------------------------------------------
class TestCommitFlag:
"""Tests for --commit flag."""
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_saves_period_to_config(self, mock_save, mock_fetch, tmp_path):
"""--commit сохраняет период в YAML-конфиг."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_called_once_with(
str(config_path), "2026-06-01", "2026-06-30", "date", True
)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_with_precision_datetime_passes_datetime_strings(
self, mock_save, mock_fetch, tmp_path
):
"""При precision=datetime --commit сохраняет timestamps."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "datetime", "dynamic": True}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-30--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
call_args = mock_save.call_args
assert call_args is not None
saved_from, saved_to = call_args.args[1], call_args.args[2]
assert "T" in saved_from
assert "T" in saved_to
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_commit_no_entries_does_not_save(self, mock_fetch, tmp_path):
"""Без записей --commit не пишет конфиг."""
mock_fetch.return_value = None
config_path = tmp_path / "config.yml"
config_path.write_text("period:\n precision: date\n dynamic: true\n")
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--commit",
"--config-path",
str(config_path),
]
)
assert code == 0
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_dynamic_false_overwrites_defaults(self, mock_save, mock_fetch, tmp_path):
"""При dynamic=false --commit перезаписывает default_from/to."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": False}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_called_once_with(
str(config_path), "2026-06-01", "2026-06-30", "date", False
)
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_without_output_uses_default_path(self, mock_save, mock_fetch, tmp_path):
"""--commit без --output сохраняет файл по шаблону из конфига."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(
yaml.dump(
{
"period": {"precision": "date", "dynamic": True},
"output": {
"dir": str(tmp_path / "reports"),
"filename": "report_{date}.{ext}",
"default_format": "xlsx",
},
}
)
)
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--config-path",
str(config_path),
]
)
assert code == 0
mock_formatter.save.assert_called_once()
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_prints_info_to_stderr(self, mock_save, mock_fetch, capsys, tmp_path):
"""--commit выводит сообщение о фиксации в stderr."""
import yaml
issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0)]
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
with mock.patch("redmine_reporter.cli.get_formatter_by_extension") as mock_get:
mock_formatter = mock.MagicMock()
mock_get.return_value = mock_formatter
code = main(
[
"--date",
"2026-06-01--2026-06-30",
"--commit",
"--output",
str(tmp_path / "report.xlsx"),
"--config-path",
str(config_path),
]
)
assert code == 0
captured = capsys.readouterr()
assert "commit" in captured.err.lower() or "Commit" in captured.err
@mock.patch.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config")
def test_commit_empty_issues_no_save(self, mock_save, mock_fetch, tmp_path):
"""Пустой список задач — --commit не сохраняет конфиг."""
import yaml
mock_fetch.return_value = []
config_path = tmp_path / "config.yml"
config_path.write_text(yaml.dump({"period": {"precision": "date", "dynamic": True}}))
code = main(
[
"--date",
"2026-01-01--2026-01-31",
"--commit",
"--config-path",
str(config_path),
]
)
assert code == 0
mock_save.assert_not_called()