mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 19:53:48 +01:00
The budget was binary: 8h with a counted workout, 6h without. There was no base/bonus structure anywhere -- resolve_budget_seconds returned one of two absolute config values, and the "2h bonus" was emergent arithmetic (8 - 6) that appeared in no line of code. A second earner could not be bolted onto that shape without coupling the two. It is now a sum: 5h floor + 2h workout + 1h LeetCode, so a day is worth 5, 6, 7 or 8 hours. Note the deliberate tightening -- a workout-only day drops 8h to 7h, and a day that earns nothing drops 6h to 5h. The two earners are read independently and added. That is what "must not interfere with the workout" means in code: an unreadable LeetCode ledger leaves a workout day at 7h, not 5h, and there is a test asserting exactly that. LeetCode is read from leetcode-guard's ledger directly -- it answers even when leetcode-guard is not running, which is most of the day -- and falls back to its loopback endpoint. Credits are signature-checked; _leetcode_ledger reproduces gatelock's canonicalisation in eight lines because gatelock lives in the user's site-packages and this daemon runs as root, and a pinned vector fails loudly if that ever drifts. Failing to get an answer grants nothing AND raises an incident: desktop notification, warning log, and a line in leetcode_bonus_incidents.log (gitignored, so it never becomes an uncommitted change), rate-limited to once per reason per day. A silently missing hour looks exactly like an unearned one. resolve_budget_seconds is *renamed*, not wrapped: two functions would recreate the second resolution seam this module's docstring warns about. rules_for calls it once and carries the breakdown on PlaytimeRules, so /api/budget reports why today is what it is without resolving again. Known limitation, accepted: leetcode-guard only writes credits from its lock window's poll loop, so a voluntary extra solve after the day is settled never reaches the ledger. Verified live against real services -- all four cases (5/6/7/8h), the endpoint fallback, the fail-closed path with its notification, and the rate limit. 1782 tests, 100% branch coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fp6zWH9zW1AkGdDyKjrbi8
146 lines
5.4 KiB
Python
146 lines
5.4 KiB
Python
"""Tests for playtime accumulation, rollover, warnings and /proc scanning."""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from steam_backlog_enforcer._playtime_budget import (
|
|
accumulate,
|
|
pending_warning,
|
|
roll_over,
|
|
)
|
|
from steam_backlog_enforcer._playtime_state import (
|
|
PlaytimeState,
|
|
rules_for,
|
|
)
|
|
from steam_backlog_enforcer.config import Config
|
|
|
|
PKG = "steam_backlog_enforcer._playtime"
|
|
LOCAL = timezone(timedelta(hours=2))
|
|
NOW = datetime(2026, 7, 27, 20, 0, tzinfo=LOCAL)
|
|
INTERVAL = 3.0
|
|
|
|
|
|
def _state_ticked(seconds_ago: float, **kwargs: object) -> PlaytimeState:
|
|
return PlaytimeState(last_tick_at=NOW.timestamp() - seconds_ago, **kwargs)
|
|
|
|
|
|
class TestAccumulate:
|
|
def test_first_ever_tick_credits_nothing(self) -> None:
|
|
out = accumulate(
|
|
PlaytimeState(last_tick_at=0.0),
|
|
now=NOW,
|
|
qualifying={1},
|
|
interval=INTERVAL,
|
|
)
|
|
assert out.seconds == 0.0
|
|
assert out.last_tick_at == NOW.timestamp()
|
|
|
|
def test_normal_tick_credits_the_delta(self) -> None:
|
|
out = accumulate(_state_ticked(3.0), now=NOW, qualifying={1}, interval=INTERVAL)
|
|
assert out.seconds == 3.0
|
|
|
|
def test_idle_tick_credits_nothing_but_advances_the_clock(self) -> None:
|
|
out = accumulate(
|
|
_state_ticked(3.0), now=NOW, qualifying=set(), interval=INTERVAL
|
|
)
|
|
assert out.seconds == 0.0
|
|
assert out.last_tick_at == NOW.timestamp()
|
|
|
|
def test_long_gap_is_clamped(self) -> None:
|
|
"""A suspend must not credit hours of gaming that never happened."""
|
|
out = accumulate(
|
|
_state_ticked(3600.0), now=NOW, qualifying={1}, interval=INTERVAL
|
|
)
|
|
assert out.seconds == 6.0
|
|
|
|
def test_gap_exactly_at_the_clamp(self) -> None:
|
|
out = accumulate(_state_ticked(6.0), now=NOW, qualifying={1}, interval=INTERVAL)
|
|
assert out.seconds == 6.0
|
|
|
|
def test_backward_clock_step_credits_nothing(self) -> None:
|
|
"""An NTP step backwards must never refund time already spent."""
|
|
out = accumulate(
|
|
_state_ticked(-10.0),
|
|
now=NOW,
|
|
qualifying={1},
|
|
interval=INTERVAL,
|
|
)
|
|
assert out.seconds == 0.0
|
|
assert out.last_tick_at == NOW.timestamp()
|
|
|
|
def test_zero_delta(self) -> None:
|
|
out = accumulate(_state_ticked(0.0), now=NOW, qualifying={1}, interval=INTERVAL)
|
|
assert out.seconds == 0.0
|
|
|
|
def test_accrues_across_ticks(self) -> None:
|
|
state = PlaytimeState(seconds=100.0, last_tick_at=NOW.timestamp() - 3.0)
|
|
out = accumulate(state, now=NOW, qualifying={1}, interval=INTERVAL)
|
|
assert out.seconds == 103.0
|
|
|
|
def test_is_pure(self) -> None:
|
|
state = _state_ticked(3.0)
|
|
accumulate(state, now=NOW, qualifying={1}, interval=INTERVAL)
|
|
assert state.seconds == 0.0
|
|
|
|
|
|
class TestRollOver:
|
|
def test_same_day_is_unchanged(self) -> None:
|
|
state = PlaytimeState(day_key="2026-07-27", seconds=50.0, blocked_at=9.0)
|
|
assert roll_over(state, day_key="2026-07-27") is state
|
|
|
|
def test_new_day_resets_counter_and_block(self) -> None:
|
|
state = PlaytimeState(
|
|
day_key="2026-07-27",
|
|
seconds=50.0,
|
|
blocked_at=9.0,
|
|
warned_seconds=[600],
|
|
)
|
|
out = roll_over(state, day_key="2026-07-28")
|
|
assert out.day_key == "2026-07-28"
|
|
assert out.seconds == 0.0
|
|
assert out.is_blocked() is False
|
|
assert out.warned_seconds == []
|
|
|
|
def test_last_tick_carries_across_the_boundary(self) -> None:
|
|
"""Otherwise the first tick of the new day measures a bogus delta."""
|
|
state = PlaytimeState(day_key="2026-07-27", last_tick_at=1234.0)
|
|
assert roll_over(state, day_key="2026-07-28").last_tick_at == 1234.0
|
|
|
|
def test_empty_day_key_rolls_over(self) -> None:
|
|
assert roll_over(PlaytimeState(), day_key="2026-07-28").day_key == "2026-07-28"
|
|
|
|
|
|
class TestPendingWarning:
|
|
def _rules(self, *, demo: bool = False) -> object:
|
|
return rules_for(Config(), demo=demo)
|
|
|
|
def test_none_when_far_from_the_budget(self) -> None:
|
|
state = PlaytimeState(seconds=0.0)
|
|
assert pending_warning(state, self._rules()) is None
|
|
|
|
def test_fires_at_the_first_threshold(self) -> None:
|
|
state = PlaytimeState(seconds=8 * 3600 - 3600)
|
|
assert pending_warning(state, self._rules()) == 3600
|
|
|
|
def test_skips_already_fired_thresholds(self) -> None:
|
|
state = PlaytimeState(seconds=8 * 3600 - 1800, warned_seconds=[3600])
|
|
assert pending_warning(state, self._rules()) == 1800
|
|
|
|
def test_returns_largest_uncrossed_when_two_are_skipped(self) -> None:
|
|
"""A missed tick crossing two thresholds must warn once, not twice."""
|
|
state = PlaytimeState(seconds=8 * 3600 - 400)
|
|
assert pending_warning(state, self._rules()) == 3600
|
|
|
|
def test_none_when_all_fired(self) -> None:
|
|
state = PlaytimeState(
|
|
seconds=8 * 3600 - 100, warned_seconds=[3600, 1800, 600, 300]
|
|
)
|
|
assert pending_warning(state, self._rules()) is None
|
|
|
|
def test_demo_thresholds(self) -> None:
|
|
state = PlaytimeState(seconds=35.0)
|
|
assert pending_warning(state, self._rules(demo=True)) == 30
|
|
|
|
def test_over_budget_still_reports(self) -> None:
|
|
state = PlaytimeState(seconds=8 * 3600 + 500, warned_seconds=[3600, 1800, 600])
|
|
assert pending_warning(state, self._rules()) == 300
|