From 4a9a21624d93f45df97309f8a7b9fae3ff75ad9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=BA=D0=BE=D1=81=20=D0=90=D1=80=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=20=D0=9D=D0=B8=D0=BA=D0=BE=D0=BB=D0=B0=D0=B5=D0=B2=D0=B8?= =?UTF-8?q?=D1=87?= Date: Tue, 15 Sep 2026 15:40:20 +0700 Subject: [PATCH] fix: sliding period window for datetime precision After --commit with precision=datetime, last_used held from==to==now, so the next default period degenerated to a single second and showed no new time entries. Now --commit stores the real report window start and get_default_date_range() returns last_used.to..now for datetime+dynamic mode. Closes #70 --- redmine_reporter/cli.py | 7 ++- redmine_reporter/config.py | 19 +++++++- tests/test_cli.py | 96 ++++++++++++++++++++++++++++++-------- tests/test_config.py | 19 ++++++++ 4 files changed, 116 insertions(+), 25 deletions(-) diff --git a/redmine_reporter/cli.py b/redmine_reporter/cli.py index 59090bf..4b486f4 100644 --- a/redmine_reporter/cli.py +++ b/redmine_reporter/cli.py @@ -580,10 +580,9 @@ def main(argv: Optional[List[str]] = None) -> int: dynamic = Config._app.period_dynamic if Config._app else False if precision == "datetime": - # Сохраняем aware UTC (#58): следующий запуск вычисляет из этой - # метки aware cutoff для дедупликации. - now = datetime.now(timezone.utc).isoformat(timespec="seconds") - from_str = now + # from -- фактическое начало окна отчёта; to -- дедуп-маркер (naive UTC). + now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") + from_str = from_date to_str = now else: from_str = from_date diff --git a/redmine_reporter/config.py b/redmine_reporter/config.py index 1c20fb3..105a17c 100644 --- a/redmine_reporter/config.py +++ b/redmine_reporter/config.py @@ -2,7 +2,7 @@ import logging import os import sys from dataclasses import dataclass, field -from datetime import date, timedelta +from datetime import date, datetime, timedelta, timezone from pathlib import Path from typing import Dict, Union @@ -440,6 +440,23 @@ class Config: if from_env: return f"{from_env}--{to_env or today_str}" + if ( + cls._app + and cls._app.period_dynamic + and (cls._app.period_precision or "date") == "datetime" + and cls._app.period_last_used_to + ): + last_to = datetime.fromisoformat( + cls._app.period_last_used_to.replace("Z", "+00:00") + ) + if last_to.tzinfo is None: + last_to = last_to.replace(tzinfo=timezone.utc) + else: + last_to = last_to.astimezone(timezone.utc) + now_utc = datetime.now(timezone.utc) + fmt = "%Y-%m-%dT%H:%M:%S" + return f"{last_to.strftime(fmt)}--{now_utc.strftime(fmt)}" + if ( cls._app and cls._app.period_dynamic diff --git a/tests/test_cli.py b/tests/test_cli.py index 5020f54..41db9cc 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -715,7 +715,9 @@ class TestCommitFlag: def test_commit_with_precision_datetime_passes_datetime_strings( self, mock_save, mock_fetch, tmp_path ): - """При precision=datetime --commit сохраняет timestamps.""" + """При precision=datetime --commit сохраняет from окна и to в naive UTC.""" + from datetime import datetime + import yaml issue = _MockIssue() @@ -732,7 +734,7 @@ class TestCommitFlag: code = main( [ "--date", - "2026-06-30--2026-06-30", + "2026-09-10T09:00:00--2026-09-12T18:00:00", "--commit", "--output", str(tmp_path / "report.xlsx"), @@ -745,17 +747,19 @@ class TestCommitFlag: 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 + assert saved_from == "2026-09-10T09:00:00" + assert datetime.strptime(saved_to, "%Y-%m-%dT%H:%M:%S") + assert call_args.args[3] == "datetime" + assert call_args.args[4] is 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_saves_aware_utc( + def test_commit_with_precision_datetime_saves_naive_utc_to( self, mock_save, mock_fetch, tmp_path ): - """При precision=datetime --commit сохраняет last_used как aware UTC (#58).""" - from datetime import datetime, timezone + """При precision=datetime --commit сохраняет to как naive UTC (#70).""" + from datetime import datetime import yaml @@ -773,7 +777,7 @@ class TestCommitFlag: code = main( [ "--date", - "2026-06-30--2026-06-30", + "2026-09-10T09:00:00--2026-09-12T18:00:00", "--commit", "--output", str(tmp_path / "report.xlsx"), @@ -786,10 +790,60 @@ class TestCommitFlag: call_args = mock_save.call_args assert call_args is not None saved_from, saved_to = call_args.args[1], call_args.args[2] - for saved in (saved_from, saved_to): - parsed = datetime.fromisoformat(saved) - assert parsed.tzinfo is not None, f"{saved} must be timezone-aware" - assert parsed.utcoffset() == timezone.utc.utcoffset(None) + assert saved_from == "2026-09-10T09:00:00" + parsed = datetime.fromisoformat(saved_to) + assert parsed.tzinfo is None, f"{saved_to} must be naive UTC" + + @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_datetime_persists_window_start_and_now( + self, mock_save, mock_fetch, tmp_path + ): + """--commit при datetime сохраняет начало окна и текущий naive UTC (#70).""" + from datetime import datetime as real_datetime + + import yaml + + issue = _MockIssue() + mock_fetch.return_value = [(issue, 1.0, None)] + + config_path = tmp_path / "config.yml" + config_path.write_text( + yaml.dump({"period": {"precision": "datetime", "dynamic": True}}) + ) + + fake_datetime = mock.MagicMock() + fake_datetime.fromisoformat = real_datetime.fromisoformat + fake_datetime.strptime = real_datetime.strptime + fake_datetime.now.return_value = real_datetime(2026, 9, 15, 12, 0, 0) + + with mock.patch("redmine_reporter.cli.datetime", fake_datetime): + 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( + [ + "--commit", + "--date", + "2026-09-10T09:00:00--2026-09-12T18:00:00", + "--output", + str(tmp_path / "report.xlsx"), + "--config-path", + str(config_path), + ] + ) + assert code == 0 + + mock_save.assert_called_once_with( + str(config_path), + "2026-09-10T09:00:00", + "2026-09-15T12:00:00", + "datetime", + True, + ) @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @@ -1535,11 +1589,10 @@ class TestReportNoTimeIntegration: @mock.patch.dict(os.environ, VALID_ENV, clear=True) @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path): - """dynamic+datetime без --date: datetime-диапазон из last_used принимается. + """dynamic+datetime без --date: скользящее окно от last_used.to до now (#70). - Ожидаемое поведение после фикса #59: parse_date_range принимает диапазон - с временем (YYYY-MM-DDTHH:MM:SS), следующий период вычисляется от last_used, - отчёт строится без ошибки. + Ожидаемое поведение после фикса #70: дефолтный диапазон начинается с + last_used.to и заканчивается текущим моментом, а не сдвинутым периодом. """ issue = _MockIssue() mock_fetch.return_value = [(issue, 1.0, None)] @@ -1554,14 +1607,17 @@ def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path): " to: '2026-06-30T23:59:59'\n" ) - code = main(["--config-path", str(config_path)]) + real_dt = __import__("datetime").datetime + with mock.patch("redmine_reporter.config.datetime") as dt: + dt.fromisoformat = real_dt.fromisoformat + dt.now.return_value = real_dt(2026, 7, 15, 10, 0, 0) + code = main(["--config-path", str(config_path)]) assert code == 0 args, _ = mock_fetch.call_args from_date, to_date = args[0], args[1] - # Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59 - assert from_date == "2026-07-01T00:00:00" - assert to_date == "2026-07-30T23:59:59" + assert from_date == "2026-06-30T23:59:59" + assert to_date == "2026-07-15T10:00:00" # --------------------------------------------------------------------------- diff --git a/tests/test_config.py b/tests/test_config.py index 2e63738..1599d34 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -99,6 +99,25 @@ def test_get_default_date_range_fallback(): assert result == f"{start.isoformat()}--{today.isoformat()}" +@mock.patch.dict(os.environ, {}, clear=True) +def test_get_default_date_range_datetime_dynamic_sliding_window(): + app = AppConfig( + period_precision="datetime", + period_dynamic=True, + period_last_used_to="2026-09-10T15:00:00+00:00", + ) + Config._app = app + try: + real_dt = __import__("datetime").datetime + with mock.patch("redmine_reporter.config.datetime") as dt: + dt.fromisoformat = real_dt.fromisoformat + dt.now.return_value = real_dt(2026, 9, 15, 10, 0, 0) + rng = Config.get_default_date_range() + finally: + Config._app = None + assert rng == "2026-09-10T15:00:00--2026-09-15T10:00:00" + + # -- #56: дефолтный период — текущий месяц (детерминированные тесты) --