utils/focus_project/focus_project/tests/test_update_ratchet.py
Krzysztof kuhy Rudnicki 640985a421 focus_project 0.7.0: cuts wait a day
An edit that lowers a target or adds a replaced gate
holds from tomorrow, never today. apply_update returns
effective_from.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 16:01:58 +01:00

146 lines
5.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""An edit never lowers today's obligation: reductions apply from tomorrow."""
from __future__ import annotations
from datetime import date, datetime, time, timedelta
import pytest
import focus_project as fp
START = date(2026, 11, 2)
TODAY = date(2026, 11, 4)
TOMORROW = TODAY + timedelta(days=1)
YESTERDAY = TODAY - timedelta(days=1)
END = date(2026, 11, 20)
def _make(start: date = START, replaces: list[str] | None = None) -> fp.Project:
return fp.create_project(
"p",
name="P",
start=start,
end=END,
replaces=replaces or ["leetcode"],
daily_minutes=120,
unlock_minutes=30,
)
def _noon(day: date) -> float:
return datetime.combine(day, time(12)).timestamp()
def _limits(day: date) -> tuple[int, int, tuple[str, ...]]:
p = fp.list_projects()[0]
return p.daily_on(day), p.unlock_on(day), p.replaces_on(day)
def test_red_team_unlock_zero_waits_for_tomorrow() -> None:
_make()
got = fp.apply_update("p", today=TODAY, unlock_minutes=0)
assert got.effective_from == TOMORROW
assert _limits(TODAY)[1] == 30
assert _limits(YESTERDAY)[1] == 30
assert _limits(TOMORROW)[1] == 0
assert fp.status(TODAY)["unlock_minutes"] == 30
def test_one_minute_day_does_not_change_today() -> None:
_make()
fp.record_activity("tutor", "heartbeat", ts=_noon(TODAY), project="p")
before = fp.status(TODAY)
fp.update_project("p", today=TODAY, daily_minutes=1, unlock_minutes=0)
after = fp.status(TODAY)
assert after == before
assert after["daily_minutes"] == 120
assert fp.minutes_for(TODAY) == before["counted_minutes"]
assert fp.status(TOMORROW)["daily_minutes"] == 1
def test_raises_apply_today() -> None:
_make()
got = fp.apply_update("p", today=TODAY, daily_minutes=180, unlock_minutes=40)
assert got.effective_from == TODAY
assert _limits(TODAY)[:2] == (180, 40)
assert _limits(YESTERDAY)[:2] == (120, 30)
def test_mixed_edit_defers_whole_edit() -> None:
_make()
got = fp.apply_update("p", today=TODAY, daily_minutes=180, unlock_minutes=10)
assert got.effective_from == TOMORROW
assert _limits(TODAY)[:2] == (120, 30)
assert _limits(TOMORROW)[:2] == (180, 10)
def test_added_gate_waits_removed_gate_is_immediate() -> None:
_make(replaces=["leetcode", "reading"])
got = fp.apply_update("p", today=TODAY, replaces=["leetcode", "reading", "tutor"])
assert got.effective_from == TOMORROW
assert not fp.stands_down("tutor", TODAY)
assert fp.stands_down("tutor", TOMORROW)
assert fp.status(TODAY)["replaces"] == ["leetcode", "reading"]
got = fp.apply_update("p", today=TODAY, replaces=["leetcode"])
assert got.effective_from == TODAY
assert not fp.stands_down("reading", TODAY)
assert not fp.stands_down("reading", TOMORROW)
assert fp.stands_down("reading", YESTERDAY)
def test_pending_cut_then_second_edits_the_same_day() -> None:
_make()
fp.update_project("p", today=TODAY, daily_minutes=60, unlock_minutes=10)
assert _limits(TODAY)[:2] == (120, 30)
# a smaller cut than E is still a cut; a deeper cut still waits
fp.update_project("p", today=TODAY, daily_minutes=50)
assert _limits(TODAY)[:2] == (120, 30)
assert _limits(TOMORROW)[:2] == (50, 10)
assert len(fp.list_projects()[0].history) == 1
# a name-only edit leaves the pending cut alone
assert fp.apply_update("p", today=TODAY, name="Q").effective_from == TODAY
assert _limits(TOMORROW)[:2] == (50, 10)
# back up to (or over) today's targets cancels the pending cut, applies now
got = fp.apply_update("p", today=TODAY, daily_minutes=120, unlock_minutes=30)
assert got.effective_from == TODAY
assert fp.list_projects()[0].history == ()
got = fp.apply_update("p", today=TODAY, daily_minutes=60)
got = fp.apply_update("p", today=TODAY, daily_minutes=200)
assert got.effective_from == TODAY
assert _limits(TODAY)[:2] == (200, 30)
assert _limits(YESTERDAY)[:2] == (120, 30)
def test_not_yet_started_project_is_a_new_obligation() -> None:
_make(start=TOMORROW)
got = fp.apply_update("p", today=TODAY, daily_minutes=10, unlock_minutes=0)
assert got.effective_from == TODAY
assert fp.list_projects()[0].history == ()
assert _limits(TOMORROW)[:2] == (10, 0)
def test_project_starting_today_defers_a_cut() -> None:
_make(start=TODAY)
got = fp.apply_update("p", today=TODAY, daily_minutes=1, unlock_minutes=0)
assert got.effective_from == TOMORROW
assert _limits(TODAY)[:2] == (120, 30)
def test_early_end_keeps_today_covered() -> None:
_make()
fp.update_project("p", today=TODAY, end=TODAY)
assert fp.active(TODAY) is not None
assert fp.active(TOMORROW) is None
with pytest.raises(ValueError, match="past"):
fp.update_project("p", today=TODAY, end=YESTERDAY)
def test_legacy_history_row_without_replaces_round_trips() -> None:
old = {"until": "2026-11-03", "daily_minutes": 5, "unlock_minutes": 1}
row = fp.Limits.from_dict(old)
assert row.replaces is None
assert "replaces" not in row.to_dict()
with_r = fp.Limits(YESTERDAY, 5, 1, ("tutor",))
assert fp.Limits.from_dict(with_r.to_dict()) == with_r