mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
focus_project: late days, past end, clock, v0.4.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c25ae4e0d1
commit
637428585c
@ -14,4 +14,9 @@ fp.record_activity("timer", "start")
|
||||
|
||||
Data: `$FOCUS_PROJECT_DATA` or `~/.local/share/focus_project/` (`projects.json`,
|
||||
`activity.jsonl`). `minutes.py` is owned by the minutes/harvester agent.
|
||||
Time rules: `minutes_for` clips every interval at "now" (an event stamped ahead of
|
||||
the clock earns only once the clock reaches it); `record_activity` rejects a `ts`
|
||||
more than 120 s ahead (ValueError). `end_project(id, day)` rejects a past `day`
|
||||
(ValueError); today or later is fine. The harvester pays every project day of the
|
||||
last 31 days, so late-synced events for older days are credited (idempotent).
|
||||
Install: `./install.sh` (stdlib only, editable user install for `/usr/bin/python3`).
|
||||
|
||||
@ -9,7 +9,7 @@ import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from focus_project import _store
|
||||
from focus_project import _clock, _store
|
||||
from focus_project._errors import RegistryError
|
||||
from focus_project._model import GATES, Project, normalize_gate
|
||||
|
||||
@ -108,7 +108,11 @@ def create_project( # noqa: PLR0913
|
||||
|
||||
|
||||
def end_project(project_id: str, day: date) -> Project:
|
||||
"""End early: ``day`` becomes the last active day (clamped to ``end``)."""
|
||||
"""End early: ``day`` becomes the last active day (clamped to ``end``).
|
||||
|
||||
``day`` must be today or later: ending a past day would retroactively
|
||||
erase that day's shortfall (and the lock it causes).
|
||||
"""
|
||||
with _store.locked():
|
||||
existing = _store.read_all()
|
||||
for i, project in enumerate(existing):
|
||||
@ -120,6 +124,9 @@ def end_project(project_id: str, day: date) -> Project:
|
||||
if day < project.start:
|
||||
msg = f"cannot end {project_id!r} before its start {project.start}"
|
||||
raise ValueError(msg)
|
||||
if day < _clock.today():
|
||||
msg = f"cannot end {project_id!r} in the past ({day})"
|
||||
raise ValueError(msg)
|
||||
ended = replace(project, ended_on=min(day, project.end))
|
||||
existing[i] = ended
|
||||
_store.write_all(existing)
|
||||
|
||||
17
focus_project/focus_project/_clock.py
Normal file
17
focus_project/focus_project/_clock.py
Normal file
@ -0,0 +1,17 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The one wall-clock seam: tests replace :func:`now` instead of sleeping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import time
|
||||
|
||||
|
||||
def now() -> float:
|
||||
"""Unix seconds right now."""
|
||||
return time.time()
|
||||
|
||||
|
||||
def today() -> date:
|
||||
"""The local calendar date right now."""
|
||||
return date.fromtimestamp(now()) # noqa: DTZ012 (local day by contract)
|
||||
@ -7,15 +7,17 @@ from datetime import date
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from focus_project import _api
|
||||
from focus_project import _api, _clock
|
||||
from focus_project._paths import activity_path, data_dir
|
||||
|
||||
SOURCES = frozenset({"timer", "tutor", "sql_drills", "leetcode", "browser"})
|
||||
KINDS = frozenset({"start", "checkin", "stop", "heartbeat"})
|
||||
DEVICES = frozenset({"pc", "phone"})
|
||||
# Clock skew tolerated on a stamped event; a bigger lead is rejected. Whatever
|
||||
# is accepted is still clipped at "now" by ``minutes_for``.
|
||||
MAX_SKEW_S = 120.0
|
||||
|
||||
|
||||
def record_activity( # noqa: PLR0913, PLR0917
|
||||
@ -26,7 +28,12 @@ def record_activity( # noqa: PLR0913, PLR0917
|
||||
dev: str = "pc",
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Append one event. ``project`` defaults to the project active at ``ts``."""
|
||||
"""Append one event. ``project`` defaults to the project active at ``ts``.
|
||||
|
||||
Raises:
|
||||
ValueError: unknown source/kind/dev, or ``ts`` more than
|
||||
:data:`MAX_SKEW_S` ahead of the clock (nothing is written).
|
||||
"""
|
||||
for value, allowed, label in (
|
||||
(source, SOURCES, "source"),
|
||||
(kind, KINDS, "kind"),
|
||||
@ -35,7 +42,11 @@ def record_activity( # noqa: PLR0913, PLR0917
|
||||
if value not in allowed:
|
||||
msg = f"unknown {label} {value!r}; expected one of {sorted(allowed)}"
|
||||
raise ValueError(msg)
|
||||
when = time.time() if ts is None else ts
|
||||
now = _clock.now()
|
||||
when = now if ts is None else ts
|
||||
if when > now + MAX_SKEW_S:
|
||||
msg = f"ts is {when - now:.0f} s ahead of the clock (max {MAX_SKEW_S:.0f} s)"
|
||||
raise ValueError(msg)
|
||||
if project is None:
|
||||
found = _api.active(date.fromtimestamp(when)) # noqa: DTZ012
|
||||
project = found.id if found else None
|
||||
|
||||
@ -2,8 +2,10 @@
|
||||
"""The harvester: turn credited focus minutes into signed ledger rows.
|
||||
|
||||
Run by ``focus-project-harvest.service`` (``.path`` on activity.jsonl plus a
|
||||
one-minute ``.timer``). Harvests yesterday too, so work recorded just before
|
||||
midnight is paid once the next run sees it. Failure modes, by path:
|
||||
one-minute ``.timer``). Harvests every project day of the last
|
||||
:data:`BACKFILL_DAYS` days, so work recorded late (a phone synced days
|
||||
after the fact) is paid once a run sees it; ``credit_to`` is idempotent, so
|
||||
settled days cost one read and write nothing. Failure modes, by path:
|
||||
|
||||
* a source that errors: logged, contributes 0, the run continues (fail closed);
|
||||
* an unreadable registry, signing key or ledger: logged, exit status 1, so
|
||||
@ -15,21 +17,33 @@ from __future__ import annotations
|
||||
from datetime import date, timedelta
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
|
||||
from focus_project import _api, ledger
|
||||
from focus_project import _api, _clock, ledger
|
||||
from focus_project._errors import RegistryError
|
||||
from focus_project.minutes import minutes_for
|
||||
|
||||
BACKFILL_DAYS = 31 # bounds the per-run cost; the relay accepts events <= 48 h old
|
||||
_LOG = logging.getLogger("focus_project.harvest")
|
||||
|
||||
|
||||
def project_days(today: date) -> list[date]:
|
||||
"""Every project day in the backfill window up to ``today``, oldest first."""
|
||||
oldest = today - timedelta(days=BACKFILL_DAYS - 1)
|
||||
days: set[date] = set()
|
||||
for project in _api.list_projects():
|
||||
day = max(project.start, oldest)
|
||||
while day <= min(project.last_day, today):
|
||||
days.add(day)
|
||||
day += timedelta(days=1)
|
||||
return sorted(days)
|
||||
|
||||
|
||||
def harvest_day(day: date, now: float) -> int:
|
||||
"""Credit ``day``; return the minutes added (0 with no project)."""
|
||||
project = _api.active(day)
|
||||
if project is None:
|
||||
return 0
|
||||
total = minutes_for(day)
|
||||
total = minutes_for(day, now=now)
|
||||
added = ledger.credit_to(project, day, total, now=now)
|
||||
if added:
|
||||
_LOG.info(
|
||||
@ -39,11 +53,16 @@ def harvest_day(day: date, now: float) -> int:
|
||||
|
||||
|
||||
def harvest(now: float | None = None) -> int:
|
||||
"""Harvest yesterday and today; 0 on success, 1 if any path failed."""
|
||||
moment = time.time() if now is None else now
|
||||
"""Harvest the backfill window; 0 on success, 1 if any path failed."""
|
||||
moment = _clock.now() if now is None else now
|
||||
today = date.fromtimestamp(moment) # noqa: DTZ012 (local day)
|
||||
try:
|
||||
days = project_days(today)
|
||||
except (RegistryError, OSError) as exc:
|
||||
_LOG.error("harvest registry unreadable: %s", exc)
|
||||
return 1
|
||||
status = 0
|
||||
for day in (today - timedelta(days=1), today):
|
||||
for day in days:
|
||||
try:
|
||||
harvest_day(day, moment)
|
||||
except (RegistryError, ledger.LedgerError, OSError) as exc:
|
||||
|
||||
@ -3,7 +3,10 @@
|
||||
|
||||
Contract: union of activity intervals on the local calendar day, capped at
|
||||
the active project's ``daily_minutes``; overlaps never double count; whole
|
||||
minutes (floor). An unreadable registry reads as 0 (logged): fail closed.
|
||||
minutes (floor). Time not yet lived is never credited: every interval is
|
||||
clipped at "now", so an event stamped ahead of the clock (a fast phone clock)
|
||||
earns only once the real clock reaches it. An unreadable registry reads as 0
|
||||
(logged): fail closed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -11,7 +14,7 @@ from __future__ import annotations
|
||||
from datetime import date # noqa: TC003
|
||||
import logging
|
||||
|
||||
from focus_project import _api, sources
|
||||
from focus_project import _api, _clock, sources
|
||||
from focus_project._errors import RegistryError
|
||||
|
||||
_LOG = logging.getLogger(__name__)
|
||||
@ -29,8 +32,11 @@ def union_seconds(spans: list[sources.Interval]) -> float:
|
||||
return total
|
||||
|
||||
|
||||
def minutes_for(day: date) -> int:
|
||||
"""Minutes of focus work credited on ``day`` (0 with no project active)."""
|
||||
def minutes_for(day: date, now: float | None = None) -> int:
|
||||
"""Minutes of focus work credited on ``day`` (0 with no project active).
|
||||
|
||||
``now`` (default: the clock) is the moment after which nothing counts.
|
||||
"""
|
||||
try:
|
||||
project = _api.active(day)
|
||||
except (RegistryError, OSError) as exc:
|
||||
@ -38,5 +44,7 @@ def minutes_for(day: date) -> int:
|
||||
return 0
|
||||
if project is None:
|
||||
return 0
|
||||
seconds = union_seconds(sources.collect(day, project))
|
||||
moment = _clock.now() if now is None else now
|
||||
spans = sources.clip(sources.collect(day, project), (float("-inf"), moment))
|
||||
seconds = union_seconds(spans)
|
||||
return min(int(seconds // 60), project.daily_on(day))
|
||||
|
||||
@ -11,6 +11,8 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from focus_project import _clock
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
@ -25,6 +27,15 @@ def _redirect_state(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setenv("FOCUS_PROJECT_KEY", str(tmp_path / "hmac.key"))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _far_future_clock(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Pin "now" to 2030 so fixture days of 2026 are past, never in the future.
|
||||
|
||||
Tests about the clock itself set ``_clock.now`` explicitly.
|
||||
"""
|
||||
monkeypatch.setattr(_clock, "now", lambda: 1_893_456_000.0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data(tmp_path: Path) -> Path:
|
||||
"""The redirected data directory (may not exist yet)."""
|
||||
|
||||
@ -4,14 +4,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
import importlib
|
||||
import json
|
||||
from pathlib import Path # noqa: TC003
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project import minutes
|
||||
from focus_project import _clock, minutes
|
||||
from focus_project._paths import data_dir
|
||||
|
||||
D1 = date(2026, 11, 2)
|
||||
@ -54,7 +56,10 @@ def test_record_activity_line(data: Path) -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_record_activity_defaults_now_and_project(data: Path) -> None:
|
||||
def test_record_activity_defaults_now_and_project(
|
||||
data: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(_clock, "now", time.time) # the real clock, once
|
||||
today = date.today() # noqa: DTZ011
|
||||
fp.create_project(
|
||||
"now",
|
||||
@ -145,3 +150,21 @@ def test_status_inactive() -> None:
|
||||
|
||||
def test_minutes_for_exported() -> None:
|
||||
assert fp.minutes_for(D1) == 0
|
||||
|
||||
|
||||
def test_a_timestamp_far_ahead_is_rejected(
|
||||
data: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(_clock, "now", lambda: 10_000.0)
|
||||
with pytest.raises(ValueError, match="ahead"):
|
||||
fp.record_activity("timer", "start", ts=10_000.0 + 3 * 3600)
|
||||
assert not (data / "activity.jsonl").exists()
|
||||
fp.record_activity("timer", "start", ts=10_000.0 + 120) # small skew is kept
|
||||
fp.record_activity("timer", "stop", ts=9_000.0)
|
||||
assert len((data / "activity.jsonl").read_text().splitlines()) == 2
|
||||
|
||||
|
||||
def test_the_real_clock_seam_reads_wall_time() -> None:
|
||||
importlib.reload(_clock) # undo the autouse pin on the module attribute
|
||||
assert abs(_clock.now() - time.time()) < 5
|
||||
assert _clock.today() == date.today() # noqa: DTZ011
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, datetime, timedelta
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest import mock
|
||||
@ -11,6 +11,7 @@ from unittest import mock
|
||||
import pytest
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project import _clock
|
||||
from focus_project._errors import RegistryError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -86,6 +87,24 @@ def test_overlap_rejected_and_adjacent_ok() -> None:
|
||||
assert len(fp.list_projects()) == 2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _on_d1(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The clock reads D1 noon: ``end_project`` days are judged against it."""
|
||||
noon = datetime(D1.year, D1.month, D1.day, 12).astimezone().timestamp()
|
||||
monkeypatch.setattr(_clock, "now", lambda: noon)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_on_d1")
|
||||
def test_end_project_rejects_a_past_day() -> None:
|
||||
"""Ending yesterday would erase yesterday's shortfall and lift the lock."""
|
||||
_make("late", start=D1 - timedelta(days=3), end=D4)
|
||||
with pytest.raises(ValueError, match="past"):
|
||||
fp.end_project("late", D1 - timedelta(days=1))
|
||||
assert fp.list_projects()[0].ended_on is None
|
||||
assert fp.end_project("late", D1).ended_on == D1 # today is allowed
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_on_d1")
|
||||
def test_end_early_frees_later_days() -> None:
|
||||
_make()
|
||||
ended = fp.end_project("hr", D1 + timedelta(days=1))
|
||||
@ -96,6 +115,7 @@ def test_end_early_frees_later_days() -> None:
|
||||
_make("again", start=D1 + timedelta(days=2), end=D4)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_on_d1")
|
||||
def test_end_project_errors() -> None:
|
||||
_make()
|
||||
with pytest.raises(KeyError):
|
||||
|
||||
65
focus_project/focus_project/tests/test_harvest_backfill.py
Normal file
65
focus_project/focus_project/tests/test_harvest_backfill.py
Normal file
@ -0,0 +1,65 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Harvester backfill: late-synced events for older project days are paid."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path # noqa: TC003
|
||||
|
||||
import pytest
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project import harvest, ledger
|
||||
from focus_project.tests.test_ledger_harvest import KEY, rows
|
||||
from focus_project.tests.test_minutes import D1, at, events
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _key(tmp_path: Path) -> None:
|
||||
(tmp_path / "hmac.key").write_bytes(KEY)
|
||||
|
||||
|
||||
def _late_phone_batch(start: date) -> None:
|
||||
"""A phone offline since ``start`` delivers a full timer run late."""
|
||||
fp.create_project(
|
||||
"hr",
|
||||
name="HR",
|
||||
start=start,
|
||||
end=start + timedelta(days=5),
|
||||
replaces=["leetcode"],
|
||||
daily_minutes=120,
|
||||
unlock_minutes=30,
|
||||
)
|
||||
t = at(10, day=start)
|
||||
events(
|
||||
("timer", "start", t),
|
||||
*(("timer", "checkin", t + i * 420) for i in range(1, 18)),
|
||||
("timer", "stop", t + 17 * 420 + 60),
|
||||
)
|
||||
|
||||
|
||||
def test_late_events_for_an_older_project_day_are_paid() -> None:
|
||||
"""Friday's work, synced Monday, used to be skipped with exit 0."""
|
||||
fri = date(2026, 10, 30)
|
||||
_late_phone_batch(fri)
|
||||
assert harvest.harvest(now=at(10, day=D1)) == 0
|
||||
(row,) = rows()
|
||||
assert row["day"] == "2026-10-30"
|
||||
assert row["detail"]["minutes"] == 120
|
||||
assert row["detail"]["ended_at"] < at(0, day=fri + timedelta(days=1))
|
||||
assert harvest.harvest(now=at(10, 1, day=D1)) == 0 # idempotent
|
||||
assert len(rows()) == 1
|
||||
|
||||
|
||||
def test_backfill_covers_a_finished_project_and_stops_at_its_last_day() -> None:
|
||||
first = date(2026, 10, 20) # ran 20..25 Oct, long over
|
||||
_late_phone_batch(first)
|
||||
harvest.harvest(now=at(10, day=D1))
|
||||
assert [r["day"] for r in rows()] == ["2026-10-20"]
|
||||
|
||||
|
||||
def test_backfill_is_bounded() -> None:
|
||||
old = D1 - timedelta(days=harvest.BACKFILL_DAYS + 5)
|
||||
_late_phone_batch(old)
|
||||
harvest.harvest(now=at(10, day=D1))
|
||||
assert not ledger.ledger_path().exists() # beyond BACKFILL_DAYS
|
||||
@ -108,14 +108,31 @@ def test_ledger_without_entries_array_is_an_error() -> None:
|
||||
|
||||
|
||||
def test_broken_registry_fails_loudly(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def boom(_day: date) -> None:
|
||||
def boom(*_day: date) -> None:
|
||||
msg = "corrupt"
|
||||
raise fp.RegistryError(msg)
|
||||
|
||||
monkeypatch.setattr("focus_project._api.active", boom)
|
||||
monkeypatch.setattr("focus_project._api.list_projects", boom)
|
||||
assert harvest.harvest(now=at(9, 31)) == 1
|
||||
|
||||
|
||||
def test_a_day_that_fails_does_not_stop_the_others(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
project()
|
||||
beats(at(9, 30))
|
||||
real = harvest.harvest_day
|
||||
|
||||
def flaky(day: date, now: float) -> int:
|
||||
if day == D1:
|
||||
msg = "disk"
|
||||
raise OSError(msg)
|
||||
return real(day, now)
|
||||
|
||||
monkeypatch.setattr(harvest, "harvest_day", flaky)
|
||||
assert harvest.harvest(now=at(9, 31, day=D1 + timedelta(days=1))) == 1
|
||||
|
||||
|
||||
def test_empty_key_file_counts_as_unreadable(tmp_path: Path) -> None:
|
||||
(tmp_path / "hmac.key").write_bytes(b"\n")
|
||||
assert ledger.read_key() is None
|
||||
@ -179,3 +196,9 @@ def test_main_exits_with_status(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
with pytest.raises(SystemExit) as info:
|
||||
harvest.main()
|
||||
assert info.value.code == 7
|
||||
|
||||
|
||||
def test_harvest_day_without_a_project_pays_nothing() -> None:
|
||||
beats(at(9, 30))
|
||||
assert harvest.harvest_day(D1, at(9, 31)) == 0
|
||||
assert not ledger.ledger_path().exists()
|
||||
|
||||
@ -8,7 +8,7 @@ import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import focus_project as fp
|
||||
from focus_project import minutes
|
||||
from focus_project import _clock, minutes
|
||||
from focus_project._paths import activity_path
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -200,3 +200,46 @@ def test_non_timer_kinds_from_timer_source_ignored() -> None:
|
||||
project()
|
||||
events(("timer", "heartbeat", at(9)))
|
||||
assert minutes.minutes_for(D1) == 0
|
||||
|
||||
|
||||
def _timer_run(start: float, checkins: int) -> None:
|
||||
events(
|
||||
("timer", "start", start),
|
||||
*(("timer", "checkin", start + i * 420) for i in range(1, checkins + 1)),
|
||||
("timer", "stop", start + checkins * 420 + 60),
|
||||
)
|
||||
|
||||
|
||||
def test_events_ahead_of_the_clock_earn_nothing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A phone clock 3 h fast must not pay before the work happens."""
|
||||
project()
|
||||
_timer_run(at(11), 17) # 119 min of "work", all after the real 08:00
|
||||
monkeypatch.setattr(_clock, "now", lambda: at(8))
|
||||
assert minutes.minutes_for(D1) == 0
|
||||
|
||||
|
||||
def test_an_interval_straddling_now_counts_only_up_to_now(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
project()
|
||||
_timer_run(at(11), 17)
|
||||
monkeypatch.setattr(_clock, "now", lambda: at(11, 30))
|
||||
assert minutes.minutes_for(D1) == 30
|
||||
monkeypatch.setattr(_clock, "now", lambda: at(12, 0))
|
||||
assert minutes.minutes_for(D1) == 60
|
||||
|
||||
|
||||
def test_a_heartbeat_ahead_of_now_is_clipped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
project()
|
||||
events(("tutor", "heartbeat", at(12, 2))) # vouches for 11:57..12:02
|
||||
monkeypatch.setattr(_clock, "now", lambda: at(12, 0))
|
||||
assert minutes.minutes_for(D1) == 3
|
||||
|
||||
|
||||
def test_an_explicit_now_overrides_the_clock() -> None:
|
||||
project()
|
||||
_timer_run(at(11), 17)
|
||||
assert minutes.minutes_for(D1, now=at(11, 10)) == 10
|
||||
assert minutes.minutes_for(D1) == 120 # the pinned 2030 clock: all past
|
||||
|
||||
@ -113,7 +113,7 @@ def test_ended_and_finished_projects_refused() -> None:
|
||||
_make()
|
||||
with pytest.raises(ValueError, match="over"):
|
||||
fp.update_project("hr", today=D4 + DAY, name="late")
|
||||
fp.end_project("hr", D1)
|
||||
fp.end_project("hr", date(2031, 1, 1))
|
||||
with pytest.raises(ValueError, match="ended"):
|
||||
fp.update_project("hr", today=D1, name="x")
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "focus_project"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
description = "Time-boxed focus projects: replace chosen gates, pay a daily-minutes gate"
|
||||
requires-python = ">=3.14"
|
||||
# Stdlib only on purpose: guards import it from the system python.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user