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
This commit is contained in:
@@ -538,3 +538,230 @@ class TestOutputPathResolution:
|
||||
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()
|
||||
|
||||
@@ -451,3 +451,109 @@ class TestConfigPeriodPrecision:
|
||||
cfg = AppConfig.from_yaml(yaml_path)
|
||||
assert cfg.period_last_used_from == ""
|
||||
assert cfg.period_last_used_to == ""
|
||||
|
||||
|
||||
class TestComputeNextPeriod:
|
||||
"""Tests for compute_next_period()."""
|
||||
|
||||
def test_full_month_goes_to_next_full_month(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period("2026-06-01", "2026-06-30", "date")
|
||||
assert nf == "2026-07-01"
|
||||
assert nt == "2026-07-31"
|
||||
|
||||
def test_december_to_next_year_january(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period("2025-12-01", "2025-12-31", "date")
|
||||
assert nf == "2026-01-01"
|
||||
assert nt == "2026-01-31"
|
||||
|
||||
def test_february_2026_non_leap_to_march(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period("2026-02-01", "2026-02-28", "date")
|
||||
assert nf == "2026-03-01"
|
||||
assert nt == "2026-03-31"
|
||||
|
||||
def test_arbitrary_range_same_length_from_to_plus_one(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period("2026-06-15", "2026-06-20", "date")
|
||||
assert nf == "2026-06-21"
|
||||
assert nt == "2026-06-26"
|
||||
|
||||
def test_single_day_range_moves_one_day(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period("2026-06-15", "2026-06-15", "date")
|
||||
assert nf == "2026-06-16"
|
||||
assert nt == "2026-06-16"
|
||||
|
||||
def test_cross_month_range(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period("2026-06-25", "2026-07-05", "date")
|
||||
assert nf == "2026-07-06"
|
||||
assert nt == "2026-07-16"
|
||||
|
||||
def test_datetime_precision_moves_by_seconds(self):
|
||||
from redmine_reporter.config import compute_next_period
|
||||
|
||||
nf, nt = compute_next_period("2026-06-30T09:00:00", "2026-06-30T12:00:00", "datetime")
|
||||
assert nf == "2026-06-30T12:00:01"
|
||||
assert nt == "2026-06-30T15:00:01"
|
||||
|
||||
|
||||
class TestDefaultDateRangeWithLastUsed:
|
||||
"""Tests that get_default_date_range() uses last_used when dynamic=True."""
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_uses_last_used_when_dynamic_true(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n"
|
||||
" precision: date\n"
|
||||
" dynamic: true\n"
|
||||
" last_used:\n"
|
||||
" from: '2026-05-01'\n"
|
||||
" to: '2026-05-31'\n"
|
||||
)
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
result = Config.get_default_date_range()
|
||||
assert result == "2026-06-01--2026-06-30"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_ignores_last_used_when_dynamic_false(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n"
|
||||
" precision: date\n"
|
||||
" dynamic: false\n"
|
||||
" default_from: '2026-03-01'\n"
|
||||
" default_to: '2026-03-15'\n"
|
||||
" last_used:\n"
|
||||
" from: '2026-06-01'\n"
|
||||
" to: '2026-06-30'\n"
|
||||
)
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
result = Config.get_default_date_range()
|
||||
assert result == "2026-03-01--2026-03-15"
|
||||
|
||||
@mock.patch.dict(os.environ, {}, clear=True)
|
||||
def test_falls_back_when_no_last_used_even_with_dynamic(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
yaml_path = Path(tmp) / "config.yml"
|
||||
yaml_path.write_text(
|
||||
"period:\n"
|
||||
" precision: date\n"
|
||||
" dynamic: true\n"
|
||||
" default_from: '2026-04-01'\n"
|
||||
" default_to: '2026-04-15'\n"
|
||||
)
|
||||
Config._app = AppConfig.from_yaml(yaml_path)
|
||||
result = Config.get_default_date_range()
|
||||
assert result == "2026-04-01--2026-04-15"
|
||||
|
||||
@@ -206,3 +206,115 @@ class TestResolveOutputPath:
|
||||
|
||||
result = resolve_output_path("unknown_format")
|
||||
assert result == "unknown_format.xlsx"
|
||||
|
||||
|
||||
class TestSavePeriodToConfig:
|
||||
"""Tests for save_period_to_config()."""
|
||||
|
||||
def test_creates_last_used_in_empty_config(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-01"
|
||||
assert data["period"]["last_used"]["to"] == "2026-06-30"
|
||||
assert "default_from" not in data["period"]
|
||||
|
||||
def test_overwrites_existing_last_used(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
"period:\n" " last_used:\n" " from: '2026-05-01'\n" " to: '2026-05-31'\n"
|
||||
)
|
||||
|
||||
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-01"
|
||||
assert data["period"]["last_used"]["to"] == "2026-06-30"
|
||||
|
||||
def test_dynamic_false_overwrites_defaults(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
"period:\n" " default_from: '2026-01-01'\n" " default_to: '2026-01-31'\n"
|
||||
)
|
||||
|
||||
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", False)
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["period"]["default_from"] == "2026-06-01"
|
||||
assert data["period"]["default_to"] == "2026-06-30"
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-01"
|
||||
assert data["period"]["last_used"]["to"] == "2026-06-30"
|
||||
|
||||
def test_datetime_precision_stores_timestamps(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
|
||||
save_period_to_config(
|
||||
str(config_path),
|
||||
"2026-06-30T09:00:00",
|
||||
"2026-06-30T12:00:00",
|
||||
"datetime",
|
||||
True,
|
||||
)
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-30T09:00:00"
|
||||
assert data["period"]["last_used"]["to"] == "2026-06-30T12:00:00"
|
||||
|
||||
def test_preserves_existing_sections(self, tmp_path):
|
||||
import yaml
|
||||
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
config_path.write_text(
|
||||
"redmine:\n"
|
||||
" url: https://example.com\n"
|
||||
" author: Test\n"
|
||||
"output:\n"
|
||||
" dir: /tmp\n"
|
||||
)
|
||||
|
||||
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
|
||||
|
||||
with open(config_path) as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
|
||||
assert data["redmine"]["url"] == "https://example.com"
|
||||
assert data["redmine"]["author"] == "Test"
|
||||
assert data["output"]["dir"] == "/tmp"
|
||||
assert data["period"]["last_used"]["from"] == "2026-06-01"
|
||||
|
||||
def test_sets_permissions_0600(self, tmp_path):
|
||||
from redmine_reporter.yaml_config import save_period_to_config
|
||||
|
||||
config_path = tmp_path / "config.yml"
|
||||
|
||||
save_period_to_config(str(config_path), "2026-06-01", "2026-06-30", "date", True)
|
||||
|
||||
mode = config_path.stat().st_mode & 0o777
|
||||
assert mode == 0o600
|
||||
|
||||
Reference in New Issue
Block a user