fix: sliding period window for datetime precision
Some checks failed
checks / checks (3.10) (push) Has been cancelled
checks / checks (3.11) (push) Has been cancelled
checks / checks (3.12) (push) Has been cancelled
checks / checks (3.13) (push) Has been cancelled

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
This commit is contained in:
Кокос Артем Николаевич
2026-09-15 15:40:20 +07:00
parent 35fa585bd0
commit 4a9a21624d
4 changed files with 116 additions and 25 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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"
# ---------------------------------------------------------------------------

View File

@@ -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: дефолтный период — текущий месяц (детерминированные тесты) --