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 dynamic = Config._app.period_dynamic if Config._app else False
if precision == "datetime": if precision == "datetime":
# Сохраняем aware UTC (#58): следующий запуск вычисляет из этой # from -- фактическое начало окна отчёта; to -- дедуп-маркер (naive UTC).
# метки aware cutoff для дедупликации. now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
now = datetime.now(timezone.utc).isoformat(timespec="seconds") from_str = from_date
from_str = now
to_str = now to_str = now
else: else:
from_str = from_date from_str = from_date

View File

@@ -2,7 +2,7 @@ import logging
import os import os
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date, timedelta from datetime import date, datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Dict, Union from typing import Dict, Union
@@ -440,6 +440,23 @@ class Config:
if from_env: if from_env:
return f"{from_env}--{to_env or today_str}" 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 ( if (
cls._app cls._app
and cls._app.period_dynamic and cls._app.period_dynamic

View File

@@ -715,7 +715,9 @@ class TestCommitFlag:
def test_commit_with_precision_datetime_passes_datetime_strings( def test_commit_with_precision_datetime_passes_datetime_strings(
self, mock_save, mock_fetch, tmp_path self, mock_save, mock_fetch, tmp_path
): ):
"""При precision=datetime --commit сохраняет timestamps.""" """При precision=datetime --commit сохраняет from окна и to в naive UTC."""
from datetime import datetime
import yaml import yaml
issue = _MockIssue() issue = _MockIssue()
@@ -732,7 +734,7 @@ class TestCommitFlag:
code = main( code = main(
[ [
"--date", "--date",
"2026-06-30--2026-06-30", "2026-09-10T09:00:00--2026-09-12T18:00:00",
"--commit", "--commit",
"--output", "--output",
str(tmp_path / "report.xlsx"), str(tmp_path / "report.xlsx"),
@@ -745,17 +747,19 @@ class TestCommitFlag:
call_args = mock_save.call_args call_args = mock_save.call_args
assert call_args is not None assert call_args is not None
saved_from, saved_to = call_args.args[1], call_args.args[2] saved_from, saved_to = call_args.args[1], call_args.args[2]
assert "T" in saved_from assert saved_from == "2026-09-10T09:00:00"
assert "T" in saved_to 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.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
@mock.patch("redmine_reporter.cli.save_period_to_config") @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 self, mock_save, mock_fetch, tmp_path
): ):
"""При precision=datetime --commit сохраняет last_used как aware UTC (#58).""" """При precision=datetime --commit сохраняет to как naive UTC (#70)."""
from datetime import datetime, timezone from datetime import datetime
import yaml import yaml
@@ -773,7 +777,7 @@ class TestCommitFlag:
code = main( code = main(
[ [
"--date", "--date",
"2026-06-30--2026-06-30", "2026-09-10T09:00:00--2026-09-12T18:00:00",
"--commit", "--commit",
"--output", "--output",
str(tmp_path / "report.xlsx"), str(tmp_path / "report.xlsx"),
@@ -786,10 +790,60 @@ class TestCommitFlag:
call_args = mock_save.call_args call_args = mock_save.call_args
assert call_args is not None assert call_args is not None
saved_from, saved_to = call_args.args[1], call_args.args[2] saved_from, saved_to = call_args.args[1], call_args.args[2]
for saved in (saved_from, saved_to): assert saved_from == "2026-09-10T09:00:00"
parsed = datetime.fromisoformat(saved) parsed = datetime.fromisoformat(saved_to)
assert parsed.tzinfo is not None, f"{saved} must be timezone-aware" assert parsed.tzinfo is None, f"{saved_to} must be naive UTC"
assert parsed.utcoffset() == timezone.utc.utcoffset(None)
@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.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @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.dict(os.environ, VALID_ENV, clear=True)
@mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time") @mock.patch("redmine_reporter.cli.fetch_issues_with_spent_time")
def test_cli_dynamic_datetime_period_without_date(mock_fetch, tmp_path): 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 принимает диапазон Ожидаемое поведение после фикса #70: дефолтный диапазон начинается с
с временем (YYYY-MM-DDTHH:MM:SS), следующий период вычисляется от last_used, last_used.to и заканчивается текущим моментом, а не сдвинутым периодом.
отчёт строится без ошибки.
""" """
issue = _MockIssue() issue = _MockIssue()
mock_fetch.return_value = [(issue, 1.0, None)] 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" " 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 assert code == 0
args, _ = mock_fetch.call_args args, _ = mock_fetch.call_args
from_date, to_date = args[0], args[1] from_date, to_date = args[0], args[1]
# Следующий период после 2026-06-01T00:00:00--2026-06-30T23:59:59 assert from_date == "2026-06-30T23:59:59"
assert from_date == "2026-07-01T00:00:00" assert to_date == "2026-07-15T10:00:00"
assert to_date == "2026-07-30T23:59:59"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -99,6 +99,25 @@ def test_get_default_date_range_fallback():
assert result == f"{start.isoformat()}--{today.isoformat()}" 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: дефолтный период — текущий месяц (детерминированные тесты) -- # -- #56: дефолтный период — текущий месяц (детерминированные тесты) --