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
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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: дефолтный период — текущий месяц (детерминированные тесты) --
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user