mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
playtime: drop the Fri-Mon carry-over; log budget on change only
A Sunday ran past the 6.0h the i3 bar showed: 2h47m of Saturday's leftover was carried into the budget the cutoff used, and the 3-second "Gaming budget" journal line omitted it. Unspent time is now dropped at 06:00 every day; load_state takes a legacy ledger's share back out of today's high-water mark. The budget line logs only on change (was 25k lines/day), fired warnings are logged, and budget.jsonl records carry budget_seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015PrwCnQouKTF4EUMENMxnG
This commit is contained in:
parent
157365ff48
commit
a09f08701a
@ -36,8 +36,8 @@ server: `_ctl_client.py` (results in `_ctl_types.py`, errors in `_ctl_protocol.p
|
||||
## Gaming reset
|
||||
|
||||
`_gaming_reset.reset_today`, shared with the CLI: zeroes billed time and per-game
|
||||
attribution, re-arms warnings, releases the mounts, keeps earned time (`carry`,
|
||||
`budget_seconds`; the old CLI path dropped both), and writes a `manual_adjustment`
|
||||
attribution, re-arms warnings, releases the mounts, keeps earned time
|
||||
(`budget_seconds`; the old CLI path dropped it), and writes a `manual_adjustment`
|
||||
record to `/var/log/steam-backlog-enforcer/budget.jsonl` (billed seconds and
|
||||
per-game breakdown before). That record is the auditable trail; the per-day
|
||||
history mirrors current billed time, so it shows the day as quiet.
|
||||
@ -55,7 +55,7 @@ whole downtime. Everything else already lives on disk after every tick:
|
||||
| Billed seconds, per-game attribution | yes | `playtime_state.json` saved every tick; the final tick bills up to the exit, the first tick of the new daemon bills the gap |
|
||||
| Cutoff / kill-grace timing | yes | `blocked_at` is persisted; `_sustain_block` measures from it |
|
||||
| Playtime mounts (the block itself) | yes | kernel mount table; `reconcile` is mountinfo-driven. `RuntimeDirectoryPreserve=yes` keeps the stub file |
|
||||
| Fired warnings, carry ledger | yes | in `PlaytimeState` |
|
||||
| Fired warnings, budget high-water | yes | in `PlaytimeState` |
|
||||
| Open store window | yes (blip) | deadline in `state.json`; startup re-blocks the store once, the first tick re-opens it |
|
||||
| Total block | yes | lock file, guard-lib |
|
||||
| Armed gaming reset | no | memory only: arm again (fails closed) |
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
# Log the Fri->Sat/Sun/Mon carry in the budget resolver line
|
||||
|
||||
Carried over from the 2026-10-10 tutor-cutover handoff (item 4b, optional).
|
||||
|
||||
## what
|
||||
Saturday's budget showed 15144 s = 14400 + 744 s carried over from Friday
|
||||
(`_gaming_days.py` `pass_on`, commit 9586128). That is intended, but the
|
||||
"Gaming budget ..." log line (`_budget_resolve.py` ~:176) does not show the
|
||||
carry, so the number looks wrong. Include the carry seconds in that line.
|
||||
|
||||
## where
|
||||
`steam_backlog_enforcer/_budget_resolve.py`, carry from
|
||||
`_playtime_budget.py` (`PlaytimeState.carry`).
|
||||
|
||||
## must
|
||||
- must not: change the budget arithmetic — log only.
|
||||
|
||||
## done
|
||||
On a carry day the journal line reads like
|
||||
`Gaming budget ... (+744 s carried from Fri)`; a unit test asserts it.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
@ -28,7 +28,7 @@ while the gaming day runs to 05:59, and a cold restart resolves with empty
|
||||
answer caches, so a live answer can come in lower than one given hours ago.
|
||||
That bit twice (2026-10-03 and 2026-10-04 00:00, 8h -> 4h, unwarned cutoff).
|
||||
``PlaytimeState.budget_seconds`` is the day's high-water mark, and
|
||||
``rules_for`` applies ``max(resolved + carry, held)`` via
|
||||
``rules_for`` applies ``max(resolved, held)`` via
|
||||
``_gaming_days.held_today`` -- so the daemon, ``/api/budget`` and MCP all see
|
||||
the held figure. Only the 06:00 roll-over lowers it.
|
||||
|
||||
@ -64,6 +64,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_SECONDS_PER_MINUTE: Final = 60.0
|
||||
_SECONDS_PER_HOUR: Final = 3600.0
|
||||
# The last reason logged, so a long-lived daemon logs a budget only on change.
|
||||
_last_logged: Final[dict[str, str]] = {}
|
||||
|
||||
# How each earner reads in the reason string: (earned, missed, unknown).
|
||||
# An earner registered later without an entry here gets a generic phrase.
|
||||
@ -173,7 +175,11 @@ def resolve_budget(config: Config) -> BudgetResolution:
|
||||
_describe(t.earner.name, t.earner.label, t.answer) for t in day.terms
|
||||
)
|
||||
reason = f"{total / _SECONDS_PER_HOUR:.1f}h: {parts}"
|
||||
logger.info("Gaming budget %s", reason)
|
||||
# The daemon resolves every tick (3 s): an unchanged answer logged each
|
||||
# time was 25k identical journal lines a day that buried everything else.
|
||||
if reason != _last_logged.get("reason"):
|
||||
_last_logged["reason"] = reason
|
||||
logger.info("Gaming budget %s", reason)
|
||||
return BudgetResolution(
|
||||
seconds=total,
|
||||
base_seconds=day.base.gaming_minutes * _SECONDS_PER_MINUTE,
|
||||
|
||||
@ -167,8 +167,6 @@ def build_rules(rules: PlaytimeRules) -> dict[str, Any]:
|
||||
# is what would let this view report a budget the daemon is not
|
||||
# enforcing. See _budget_resolve's "one seam".
|
||||
"budget_reason": rules.budget_reason,
|
||||
# Unspent Fri-Sun time inherited by today; already inside budget_seconds.
|
||||
"carry_seconds": rules.carry_seconds,
|
||||
"bonuses": {
|
||||
"base": rules.base_seconds,
|
||||
"workout": rules.workout_seconds,
|
||||
|
||||
@ -1,24 +1,13 @@
|
||||
"""Gaming-day arithmetic: which day *now* is, and what it inherits.
|
||||
"""Gaming-day arithmetic: which day *now* is, and what it already granted.
|
||||
|
||||
A gaming "day" runs 06:00-05:59 local, so late-night sessions count against
|
||||
the day they started in.
|
||||
|
||||
**Free-day carry-over.** Friday to Monday is one block. Whatever a Friday,
|
||||
Saturday or Sunday leaves unspent is split evenly, with no strings attached,
|
||||
across the days still left in that block:
|
||||
|
||||
Fri leftover -> Sat, Sun, Mon (a third each)
|
||||
Sat leftover -> Sun, Mon (half each)
|
||||
Sun leftover -> Mon
|
||||
|
||||
Monday's leftover, and any Tuesday-Thursday leftover, is dropped. A day's
|
||||
leftover is measured against its whole budget *including* what it inherited,
|
||||
so time carried into Saturday and not played moves on to Sunday and Monday
|
||||
rather than being lost.
|
||||
|
||||
The ledger lives on ``PlaytimeState`` -- root-owned and immutable -- and never
|
||||
in ``playtime_history.json``: that file is user-writable, and a stored budget
|
||||
read from it would let anyone grant themselves unlimited time.
|
||||
There is no carry-over: unspent time is dropped at 06:00, every day. The
|
||||
Friday-Monday carry ledger was removed on 2026-10-18, after a Sunday ran
|
||||
past the 6.0h the i3 bar showed because 2h47m had been carried into it.
|
||||
``load_state`` takes a legacy ledger's share back out of the stored
|
||||
high-water mark.
|
||||
|
||||
Everything here is pure: it works on primitives handed to it, so it imports
|
||||
nothing from the playtime modules and cannot close an import cycle.
|
||||
@ -26,7 +15,7 @@ nothing from the playtime modules and cannot close an import cycle.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -37,10 +26,6 @@ if TYPE_CHECKING:
|
||||
# The gaming day starts here, local time. 05:59 belongs to the previous day.
|
||||
_DAY_BOUNDARY_HOURS: Final = 6
|
||||
|
||||
# Weekday (Monday == 0) whose leftover is passed on -> how many later days of
|
||||
# the Friday-Monday block share it.
|
||||
_SHARES: Final = {4: 3, 5: 2, 6: 1}
|
||||
|
||||
|
||||
def gaming_day_key(now: datetime) -> str:
|
||||
"""Return the ``YYYY-MM-DD`` gaming day containing *now*.
|
||||
@ -56,97 +41,6 @@ def gaming_day_key(now: datetime) -> str:
|
||||
return (now - timedelta(hours=_DAY_BOUNDARY_HOURS)).date().isoformat()
|
||||
|
||||
|
||||
def pass_on(
|
||||
carry: dict[str, float],
|
||||
*,
|
||||
outgoing_day: str,
|
||||
budget: float,
|
||||
spent: float,
|
||||
new_day: str,
|
||||
) -> dict[str, float]:
|
||||
"""Return the carry ledger as it stands once *new_day* begins.
|
||||
|
||||
Shares owed to days before *new_day* are dropped, which both bounds the
|
||||
ledger and means a day the daemon never saw simply forfeits its share.
|
||||
|
||||
Args:
|
||||
carry: Day key to seconds owed, as held by the outgoing day.
|
||||
outgoing_day: The gaming day that just ended; ``""`` if none.
|
||||
budget: The outgoing day's budget, carry included.
|
||||
spent: Seconds billed during the outgoing day.
|
||||
new_day: The gaming day now starting.
|
||||
|
||||
Returns:
|
||||
The ledger for *new_day*, the outgoing day's leftover added.
|
||||
"""
|
||||
owed = {day: seconds for day, seconds in carry.items() if day >= new_day}
|
||||
if not outgoing_day:
|
||||
return owed
|
||||
ended = date.fromisoformat(outgoing_day)
|
||||
shares = _SHARES.get(ended.weekday(), 0)
|
||||
leftover = max(0.0, budget - spent)
|
||||
for offset in range(1, shares + 1):
|
||||
target = (ended + timedelta(days=offset)).isoformat()
|
||||
if target >= new_day:
|
||||
owed[target] = owed.get(target, 0.0) + leftover / shares
|
||||
return owed
|
||||
|
||||
|
||||
def carry_due(
|
||||
carry: dict[str, float],
|
||||
*,
|
||||
stored_day: str,
|
||||
budget: float,
|
||||
spent: float,
|
||||
today: str,
|
||||
) -> float:
|
||||
"""Return the seconds carried into *today*.
|
||||
|
||||
The stored record may still describe yesterday -- the first tick after
|
||||
06:00 resolves the budget before rolling the state over -- so the ledger is
|
||||
projected forward exactly as ``roll_over`` will do it.
|
||||
|
||||
Args:
|
||||
carry: The stored ledger.
|
||||
stored_day: The gaming day the stored record covers.
|
||||
budget: That day's budget, carry included.
|
||||
spent: Seconds billed during that day.
|
||||
today: The current gaming day.
|
||||
|
||||
Returns:
|
||||
Seconds inherited by *today*, or 0.
|
||||
"""
|
||||
owed = (
|
||||
carry
|
||||
if stored_day == today
|
||||
else pass_on(
|
||||
carry, outgoing_day=stored_day, budget=budget, spent=spent, new_day=today
|
||||
)
|
||||
)
|
||||
return owed.get(today, 0.0)
|
||||
|
||||
|
||||
def carry_into(stored: PlaytimeState | None, now: datetime) -> float:
|
||||
"""Return the seconds carried into the gaming day containing *now*.
|
||||
|
||||
Args:
|
||||
stored: The persisted production state, or ``None`` if unreadable.
|
||||
now: Local timestamp for this tick.
|
||||
|
||||
Returns:
|
||||
The carry owed to today; 0 when no state is recorded.
|
||||
"""
|
||||
if stored is None:
|
||||
return 0.0
|
||||
return carry_due(
|
||||
stored.carry,
|
||||
stored_day=stored.day_key,
|
||||
budget=stored.budget_seconds,
|
||||
spent=stored.seconds,
|
||||
today=gaming_day_key(now),
|
||||
)
|
||||
|
||||
|
||||
def held_today(stored: PlaytimeState | None, now: datetime) -> float:
|
||||
"""Return the budget already granted earlier in *now*'s gaming day.
|
||||
|
||||
|
||||
@ -56,10 +56,9 @@ class ResetOutcome:
|
||||
def reset_today(*, source: str) -> ResetOutcome:
|
||||
"""Zero today's billed time, drop the block and record the adjustment.
|
||||
|
||||
The earned-time bookkeeping (``carry`` from earlier free days, the day's
|
||||
``budget_seconds`` high-water mark) is kept: resetting spent time must not
|
||||
also revoke time already earned. Warnings re-arm because ``seconds`` is
|
||||
back at zero.
|
||||
The day's ``budget_seconds`` high-water mark is kept: resetting spent
|
||||
time must not also revoke time already earned. Warnings re-arm because
|
||||
``seconds`` is back at zero.
|
||||
|
||||
The reset is written to the budget audit log as a ``manual_adjustment``
|
||||
(with what was billed before, per game); that record is the auditable
|
||||
|
||||
@ -56,9 +56,8 @@ def get_gaming_time() -> dict[str, Any]:
|
||||
"seconds_used": today["seconds_used"],
|
||||
"budget_seconds": today["budget_seconds"],
|
||||
"seconds_remaining": today["seconds_remaining"],
|
||||
# What earned budget_seconds, Friday-Monday carry-over included.
|
||||
# What earned budget_seconds.
|
||||
"budget_reason": rules["budget_reason"],
|
||||
"carry_seconds": rules["carry_seconds"],
|
||||
"next_warning_seconds": today["next_warning_seconds"],
|
||||
"blocked": today["blocked"],
|
||||
"enforcement": rules["enforcement"],
|
||||
|
||||
@ -11,7 +11,6 @@ from dataclasses import replace
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._gaming_days import pass_on
|
||||
from steam_backlog_enforcer._playtime_state import (
|
||||
_DELTA_CLAMP_FACTOR,
|
||||
PlaytimeRules,
|
||||
@ -80,9 +79,8 @@ def roll_over(state: PlaytimeState, *, day_key: str) -> PlaytimeState:
|
||||
"""Reset *state* if the gaming day has changed.
|
||||
|
||||
Pure. ``last_tick_at`` carries across the boundary so the first tick of the
|
||||
new day measures a sane delta rather than restarting from zero, and the carry
|
||||
ledger crosses it with the outgoing day's leftover added (see
|
||||
``_gaming_days``).
|
||||
new day measures a sane delta rather than restarting from zero. Nothing else
|
||||
does: the outgoing day's unspent time is dropped.
|
||||
|
||||
Args:
|
||||
state: Current accounting state.
|
||||
@ -93,17 +91,7 @@ def roll_over(state: PlaytimeState, *, day_key: str) -> PlaytimeState:
|
||||
"""
|
||||
if state.day_key == day_key:
|
||||
return state
|
||||
return PlaytimeState(
|
||||
day_key=day_key,
|
||||
last_tick_at=state.last_tick_at,
|
||||
carry=pass_on(
|
||||
state.carry,
|
||||
outgoing_day=state.day_key,
|
||||
budget=state.budget_seconds,
|
||||
spent=state.seconds,
|
||||
new_day=day_key,
|
||||
),
|
||||
)
|
||||
return PlaytimeState(day_key=day_key, last_tick_at=state.last_tick_at)
|
||||
|
||||
|
||||
def pending_warning(state: PlaytimeState, rules: PlaytimeRules) -> int | None:
|
||||
|
||||
@ -65,10 +65,14 @@ def _warn(state: PlaytimeState, rules: PlaytimeRules) -> PlaytimeState:
|
||||
if threshold is None:
|
||||
return state
|
||||
|
||||
notify_desktop_user(
|
||||
"Gaming time running out",
|
||||
f"{_humanise(threshold)} of today's gaming budget left.",
|
||||
left = f"{_humanise(threshold)} of today's gaming budget left."
|
||||
logger.info(
|
||||
"Gaming warning: %s (billed %.0fs of %.0fs)",
|
||||
left,
|
||||
state.seconds,
|
||||
rules.budget_seconds,
|
||||
)
|
||||
notify_desktop_user("Gaming time running out", left)
|
||||
return replace(state, warned_seconds=[*state.warned_seconds, threshold])
|
||||
|
||||
|
||||
|
||||
44
steam_backlog_enforcer/_playtime_legacy.py
Normal file
44
steam_backlog_enforcer/_playtime_legacy.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""Read-side compatibility for the removed Friday-Monday carry-over.
|
||||
|
||||
Exists only to read ``playtime_state.json`` records written before carry-over
|
||||
was removed on 2026-10-18. Such a record lives at most one gaming day (the
|
||||
next ``save_state`` drops the ``carry`` key), so this module can be deleted
|
||||
once no such record can remain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Type-only: _playtime_state imports this module at runtime.
|
||||
from steam_backlog_enforcer._playtime_state import PlaytimeState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def without_legacy_carry(state: PlaytimeState, carry: object) -> PlaytimeState:
|
||||
"""Take a removed carry ledger's share for the day out of its high-water.
|
||||
|
||||
Records written before 2026-10-18 folded the Friday-Monday carry into
|
||||
``budget_seconds``, and ``held_today`` would keep paying it out for the
|
||||
rest of that day. The key itself is dropped by the next ``save_state``.
|
||||
|
||||
Args:
|
||||
state: The record as loaded.
|
||||
carry: The raw ``carry`` value from the file, if any.
|
||||
|
||||
Returns:
|
||||
*state*, its ``budget_seconds`` lowered by the day's carried share.
|
||||
"""
|
||||
if not isinstance(carry, dict):
|
||||
return state
|
||||
share = carry.get(state.day_key, 0.0)
|
||||
if not isinstance(share, int | float) or share <= 0:
|
||||
return state
|
||||
logger.warning(
|
||||
"Dropping %.0fs of legacy carry-over from %s's budget.", share, state.day_key
|
||||
)
|
||||
return replace(state, budget_seconds=max(0.0, state.budget_seconds - share))
|
||||
@ -223,5 +223,6 @@ def _snapshot(
|
||||
"qualifying": sorted(qualifying),
|
||||
"day_key": state.day_key,
|
||||
"billed_seconds": round(state.seconds, 3),
|
||||
"budget_seconds": round(rules.budget_seconds, 3),
|
||||
"remaining_seconds": round(rules.budget_seconds - state.seconds, 3),
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""The daily playtime budget: its state, its rules, and its persistence.
|
||||
|
||||
Gaming days and the Friday-Monday carry-over live in ``_gaming_days``. State
|
||||
Gaming days and the held budget live in ``_gaming_days``. State
|
||||
is written atomically and re-read every tick, which is what lets the enforcer
|
||||
survive a restart mid-session.
|
||||
"""
|
||||
@ -14,7 +14,8 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._budget_resolve import resolve_budget
|
||||
from steam_backlog_enforcer._gaming_days import carry_into, held_today
|
||||
from steam_backlog_enforcer._gaming_days import held_today
|
||||
from steam_backlog_enforcer._playtime_legacy import without_legacy_carry
|
||||
from steam_backlog_enforcer._whitelist_locking import (
|
||||
_try_set_immutable,
|
||||
unlock_for_write,
|
||||
@ -95,12 +96,10 @@ class PlaytimeState:
|
||||
warned_seconds: list[int] = field(default_factory=list)
|
||||
"""Warning thresholds already fired during ``day_key``."""
|
||||
budget_seconds: float = 0.0
|
||||
"""Highest budget in force during ``day_key``, carry included; ``0.0`` if unset.
|
||||
"""Highest budget in force during ``day_key``; ``0.0`` if unset.
|
||||
|
||||
What ``roll_over`` measures the outgoing day's leftover against.
|
||||
What ``held_today`` holds the day at when a live re-resolution dips.
|
||||
"""
|
||||
carry: dict[str, float] = field(default_factory=dict)
|
||||
"""Day key to seconds carried into it from earlier days of the block."""
|
||||
|
||||
def is_blocked(self) -> bool:
|
||||
"""Whether the cutoff has engaged for this gaming day.
|
||||
@ -133,7 +132,6 @@ class PlaytimeRules:
|
||||
reading_seconds: float = 0.0
|
||||
earned_seconds: dict[str, float] = field(default_factory=dict)
|
||||
budget_reason: str = ""
|
||||
carry_seconds: float = 0.0
|
||||
|
||||
|
||||
def rules_for(config: Config, *, demo: bool) -> PlaytimeRules:
|
||||
@ -153,12 +151,10 @@ def rules_for(config: Config, *, demo: bool) -> PlaytimeRules:
|
||||
# Resolved *here*, not per caller, so the daemon (one Config for life) and
|
||||
# _budget_view (Config per request) can never report different budgets.
|
||||
resolved = None if demo else resolve_budget(config)
|
||||
# Carry sits on top of the 8h cap (earned, not spent: no strings). The
|
||||
# day never re-prices below what it already granted -- see held_today.
|
||||
# The day never re-prices below what it already granted -- see held_today.
|
||||
now = datetime.now().astimezone()
|
||||
stored = None if demo else load_state(demo=False)
|
||||
carry = 0.0 if demo else carry_into(stored, now)
|
||||
live = 0.0 if resolved is None else resolved.seconds + carry
|
||||
live = 0.0 if resolved is None else resolved.seconds
|
||||
held = held_today(stored, now)
|
||||
held_note = f"; held at {held / 3600:.1f}h (earned earlier today)"
|
||||
return PlaytimeRules(
|
||||
@ -178,11 +174,8 @@ def rules_for(config: Config, *, demo: bool) -> PlaytimeRules:
|
||||
budget_reason=(
|
||||
"demo run"
|
||||
if resolved is None
|
||||
else resolved.reason
|
||||
+ (f", +{carry / 60:.0f}m carried over" if carry > 0 else "")
|
||||
+ (held_note if held > live else "")
|
||||
else resolved.reason + (held_note if held > live else "")
|
||||
),
|
||||
carry_seconds=carry,
|
||||
)
|
||||
|
||||
|
||||
@ -227,7 +220,8 @@ def load_state(*, demo: bool) -> PlaytimeState | None:
|
||||
logger.warning("Playtime state at %s has an unknown schema.", path)
|
||||
return None
|
||||
known = {f.name for f in fields(PlaytimeState)}
|
||||
return PlaytimeState(**{k: v for k, v in data.items() if k in known})
|
||||
state = PlaytimeState(**{k: v for k, v in data.items() if k in known})
|
||||
return without_legacy_carry(state, data.get("carry"))
|
||||
|
||||
|
||||
def save_state(state: PlaytimeState, *, demo: bool) -> None:
|
||||
|
||||
@ -15,7 +15,7 @@ from unittest.mock import patch
|
||||
import earned_time
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _leetcode_bonus, _workout_budget
|
||||
from steam_backlog_enforcer import _budget_resolve, _leetcode_bonus, _workout_budget
|
||||
from steam_backlog_enforcer._budget_resolve import resolve_budget
|
||||
from steam_backlog_enforcer.config import Config
|
||||
from steam_backlog_enforcer.tests._budget_dates import AFTER_CUT, pin_today
|
||||
@ -41,9 +41,11 @@ def _clear_caches() -> Iterator[None]:
|
||||
"""
|
||||
_workout_budget.reset_cache()
|
||||
_leetcode_bonus.reset_cache()
|
||||
_budget_resolve._last_logged.clear()
|
||||
yield
|
||||
_workout_budget.reset_cache()
|
||||
_leetcode_bonus.reset_cache()
|
||||
_budget_resolve._last_logged.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -202,3 +204,20 @@ class TestTheExplanation:
|
||||
assert "no counted workout" in caplog.text
|
||||
assert "no LeetCode solve recorded" in caplog.text
|
||||
assert "no reading credited" in caplog.text
|
||||
|
||||
def test_an_unchanged_budget_is_logged_once(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""The daemon resolves every 3 s; only a change is worth a journal line.
|
||||
|
||||
Args:
|
||||
caplog: pytest's log capture.
|
||||
"""
|
||||
with (
|
||||
caplog.at_level(logging.INFO),
|
||||
patch(_WORKOUT, return_value=False),
|
||||
patch(_LEETCODE, return_value=False),
|
||||
):
|
||||
resolve_budget(Config())
|
||||
resolve_budget(Config())
|
||||
assert caplog.text.count("Gaming budget") == 1
|
||||
|
||||
@ -28,11 +28,10 @@ from steam_backlog_enforcer.tests._no_workout_http import earned_budget, fixed_b
|
||||
# effect yet -- without this patch, collecting this module makes a real HTTP
|
||||
# call to the screen locker and pins RULES to whatever budget today happens to
|
||||
# have earned. Stub it explicitly so the constant is the earned budget always.
|
||||
# The carry is pinned for the same reason: conftest's redirects are not active
|
||||
# at import either, so rules_for would read the LIVE playtime_state.json and
|
||||
# fold whatever Fri-Mon carry it holds today into RULES.
|
||||
# The held budget is pinned for the same reason: conftest's redirects are not
|
||||
# active at import either, so rules_for would read the LIVE playtime_state.json
|
||||
# and hold RULES at whatever high-water mark it records today.
|
||||
with (
|
||||
patch("steam_backlog_enforcer._playtime_state.carry_into", return_value=0.0),
|
||||
patch("steam_backlog_enforcer._playtime_state.held_today", return_value=0.0),
|
||||
patch(
|
||||
"steam_backlog_enforcer._playtime_state.resolve_budget",
|
||||
@ -224,7 +223,6 @@ class TestBuildBudgetSnapshot:
|
||||
"demo",
|
||||
"masked_launchers",
|
||||
"budget_reason",
|
||||
"carry_seconds",
|
||||
"bonuses",
|
||||
"earners",
|
||||
}
|
||||
|
||||
@ -1,19 +1,23 @@
|
||||
"""Tests for the Friday-Monday carry-over in _gaming_days."""
|
||||
"""Tests for _gaming_days: the day boundary, the held budget, no carry-over."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from dataclasses import fields
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer._gaming_days import (
|
||||
carry_due,
|
||||
carry_into,
|
||||
held_today,
|
||||
pass_on,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer._gaming_days import held_today
|
||||
from steam_backlog_enforcer._playtime_budget import roll_over
|
||||
from steam_backlog_enforcer._playtime_state import PlaytimeState, rules_for
|
||||
from steam_backlog_enforcer._playtime_state import (
|
||||
PlaytimeRules,
|
||||
PlaytimeState,
|
||||
load_state,
|
||||
rules_for,
|
||||
state_path,
|
||||
)
|
||||
from steam_backlog_enforcer.config import Config
|
||||
|
||||
HOUR = 3600.0
|
||||
@ -22,105 +26,52 @@ LOCAL = timezone(timedelta(hours=2))
|
||||
THU, FRI, SAT, SUN, MON, TUE = (f"2026-10-{d:02d}" for d in range(1, 7))
|
||||
|
||||
|
||||
def _leftover(day: str, *, budget: float, spent: float, new_day: str) -> dict:
|
||||
return pass_on({}, outgoing_day=day, budget=budget, spent=spent, new_day=new_day)
|
||||
class TestNoCarryOver:
|
||||
"""Unspent time never moves to a later day (carry-over dropped 2026-10-18)."""
|
||||
|
||||
|
||||
class TestPassOn:
|
||||
"""Who inherits a day's unspent budget."""
|
||||
|
||||
def test_friday_splits_three_ways(self) -> None:
|
||||
owed = _leftover(FRI, budget=8 * HOUR, spent=7 * HOUR, new_day=SAT)
|
||||
assert owed == {SAT: 1200.0, SUN: 1200.0, MON: 1200.0}
|
||||
|
||||
def test_saturday_splits_two_ways(self) -> None:
|
||||
owed = _leftover(SAT, budget=8 * HOUR, spent=7 * HOUR, new_day=SUN)
|
||||
assert owed == {SUN: 1800.0, MON: 1800.0}
|
||||
|
||||
def test_sunday_goes_to_monday(self) -> None:
|
||||
assert _leftover(SUN, budget=HOUR, spent=0.0, new_day=MON) == {MON: HOUR}
|
||||
|
||||
def test_monday_and_weekdays_drop_their_leftover(self) -> None:
|
||||
assert _leftover(MON, budget=HOUR, spent=0.0, new_day=TUE) == {}
|
||||
assert _leftover(THU, budget=HOUR, spent=0.0, new_day=FRI) == {}
|
||||
|
||||
def test_overspent_day_carries_nothing(self) -> None:
|
||||
owed = _leftover(SUN, budget=HOUR, spent=2 * HOUR, new_day=MON)
|
||||
assert owed == {MON: 0.0}
|
||||
|
||||
def test_no_outgoing_day_keeps_only_future_shares(self) -> None:
|
||||
owed = pass_on(
|
||||
{FRI: 5.0, SUN: 7.0}, outgoing_day="", budget=0.0, spent=0.0, new_day=SAT
|
||||
def test_a_saturday_leftover_is_not_passed_on(self) -> None:
|
||||
sun = roll_over(
|
||||
PlaytimeState(day_key=SAT, seconds=HOUR, budget_seconds=8 * HOUR),
|
||||
day_key=SUN,
|
||||
)
|
||||
assert owed == {SUN: 7.0}
|
||||
assert sun == PlaytimeState(day_key=SUN)
|
||||
assert "carry" not in vars(sun)
|
||||
|
||||
def test_a_skipped_day_forfeits_its_share(self) -> None:
|
||||
owed = _leftover(FRI, budget=3 * HOUR, spent=0.0, new_day=SUN)
|
||||
assert owed == {SUN: HOUR, MON: HOUR}
|
||||
def test_rules_have_no_carry(self) -> None:
|
||||
assert "carry_seconds" not in {f.name for f in fields(PlaytimeRules)}
|
||||
|
||||
def test_shares_accumulate_with_earlier_ones(self) -> None:
|
||||
owed = pass_on(
|
||||
{SUN: 600.0, MON: 600.0},
|
||||
outgoing_day=SAT,
|
||||
budget=HOUR,
|
||||
spent=0.0,
|
||||
new_day=SUN,
|
||||
)
|
||||
assert owed == {SUN: 2400.0, MON: 2400.0}
|
||||
def test_a_legacy_carry_is_taken_out_of_todays_high_water(self) -> None:
|
||||
# The live file on 2026-10-18 held 6.0h earned + 9994s carried into the
|
||||
# day's high-water mark; held_today would otherwise keep paying it out.
|
||||
legacy = {
|
||||
"schema_version": 1,
|
||||
"day_key": SUN,
|
||||
"seconds": 6 * HOUR,
|
||||
"budget_seconds": 6 * HOUR + 9994.0,
|
||||
"carry": {SUN: 9994.0, MON: 9994.0},
|
||||
}
|
||||
state_path(demo=False).write_text(json.dumps(legacy), encoding="utf-8")
|
||||
stored = load_state(demo=False)
|
||||
assert stored is not None
|
||||
assert stored.budget_seconds == 6 * HOUR
|
||||
assert "carry" not in vars(stored)
|
||||
|
||||
|
||||
class TestCarryDue:
|
||||
"""Today's share, whether or not the stored record has rolled over."""
|
||||
|
||||
def test_same_day_reads_the_ledger(self) -> None:
|
||||
due = carry_due({SAT: 60.0}, stored_day=SAT, budget=0.0, spent=0.0, today=SAT)
|
||||
assert due == 60.0
|
||||
|
||||
def test_stale_record_is_projected_forward(self) -> None:
|
||||
due = carry_due({}, stored_day=FRI, budget=4 * HOUR, spent=HOUR, today=SAT)
|
||||
assert due == HOUR
|
||||
|
||||
|
||||
class TestCarryInto:
|
||||
"""Reading the carry off a stored state."""
|
||||
|
||||
def test_no_state_means_no_carry(self) -> None:
|
||||
assert carry_into(None, datetime(2026, 10, 3, 10, tzinfo=LOCAL)) == 0.0
|
||||
|
||||
def test_reads_the_projected_share(self) -> None:
|
||||
stored = PlaytimeState(day_key=FRI, seconds=HOUR, budget_seconds=4 * HOUR)
|
||||
assert carry_into(stored, datetime(2026, 10, 3, 10, tzinfo=LOCAL)) == HOUR
|
||||
|
||||
|
||||
class TestRollOverCarry:
|
||||
"""roll_over threads the ledger across the 06:00 boundary."""
|
||||
|
||||
def test_unspent_carry_moves_on(self) -> None:
|
||||
sat = roll_over(
|
||||
PlaytimeState(day_key=FRI, seconds=7 * HOUR, budget_seconds=8 * HOUR),
|
||||
day_key=SAT,
|
||||
)
|
||||
sat = replace(sat, seconds=8 * HOUR, budget_seconds=8 * HOUR + 1200.0)
|
||||
sun = roll_over(sat, day_key=SUN)
|
||||
assert sun.carry == {SUN: 1800.0, MON: 1800.0}
|
||||
|
||||
|
||||
class TestRulesCarry:
|
||||
"""rules_for adds the carry above the cap and says so."""
|
||||
|
||||
def test_carry_lands_in_budget_and_reason(self) -> None:
|
||||
with patch(
|
||||
"steam_backlog_enforcer._playtime_state.carry_into", return_value=1200.0
|
||||
):
|
||||
rules = rules_for(Config(), demo=False)
|
||||
assert rules.carry_seconds == 1200.0
|
||||
assert rules.budget_reason.endswith(", +20m carried over")
|
||||
|
||||
def test_demo_never_carries(self) -> None:
|
||||
with patch(
|
||||
"steam_backlog_enforcer._playtime_state.carry_into", return_value=1200.0
|
||||
):
|
||||
assert rules_for(Config(), demo=True).carry_seconds == 0.0
|
||||
@pytest.mark.parametrize(
|
||||
"carry", [{MON: 9994.0}, {SUN: "9994"}, {SUN: 0.0}, [9994.0]]
|
||||
)
|
||||
def test_a_ledger_owing_today_nothing_leaves_the_budget(
|
||||
self, carry: object
|
||||
) -> None:
|
||||
legacy = {
|
||||
"schema_version": 1,
|
||||
"day_key": SUN,
|
||||
"budget_seconds": 6 * HOUR,
|
||||
"carry": carry,
|
||||
}
|
||||
state_path(demo=False).write_text(json.dumps(legacy), encoding="utf-8")
|
||||
stored = load_state(demo=False)
|
||||
assert stored is not None
|
||||
assert stored.budget_seconds == 6 * HOUR
|
||||
|
||||
|
||||
class TestHeldToday:
|
||||
|
||||
@ -37,7 +37,6 @@ def _spent_state(day_key: str) -> PlaytimeState:
|
||||
last_credited_key="1",
|
||||
warned_seconds=[600],
|
||||
budget_seconds=1800.0,
|
||||
carry={"2999-01-01": 120.0},
|
||||
)
|
||||
|
||||
|
||||
@ -58,7 +57,7 @@ class TestResetToday:
|
||||
assert stored is not None
|
||||
assert (stored.seconds, stored.blocked_at, stored.per_game) == (0.0, 0.0, {})
|
||||
assert (stored.last_credited_key, stored.warned_seconds) == ("", [])
|
||||
assert (stored.budget_seconds, stored.carry) == (1800.0, {"2999-01-01": 120.0})
|
||||
assert stored.budget_seconds == 1800.0
|
||||
assert stored.last_tick_at > 0
|
||||
|
||||
def test_audit_record_has_the_before_picture(self) -> None:
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
from contextlib import ExitStack
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import logging
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@ -196,6 +197,15 @@ class TestWarn:
|
||||
assert out.warned_seconds == [3600, 1800, 600, 300]
|
||||
assert "5 minutes" in mock_notify.call_args.args[1]
|
||||
|
||||
def test_a_fired_warning_is_logged(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
state = PlaytimeState(seconds=8 * 3600 - 300, warned_seconds=[3600, 1800, 600])
|
||||
with (
|
||||
caplog.at_level(logging.INFO),
|
||||
patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"),
|
||||
):
|
||||
_warn(state, _rules())
|
||||
assert "5 minutes" in caplog.text
|
||||
|
||||
def test_no_op_when_nothing_is_due(self) -> None:
|
||||
state = PlaytimeState(seconds=0.0)
|
||||
with patch(
|
||||
|
||||
@ -176,3 +176,4 @@ class TestTickJournal:
|
||||
assert record["qualifying"] == [11, 22]
|
||||
assert record["state"] == "engaged"
|
||||
assert record["remaining_seconds"] == 28700.0
|
||||
assert record["budget_seconds"] == 28800.0
|
||||
|
||||
@ -79,7 +79,6 @@ export function budget(demo: boolean): BudgetSnapshot {
|
||||
warn_at: [3600, 1800, 600, 300],
|
||||
demo,
|
||||
masked_launchers: ['heroic', 'lutris'],
|
||||
carry_seconds: demo ? 0 : 1200,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,13 +12,6 @@ describe('BudgetRulesCard', () => {
|
||||
expect(screen.getByText(/1h 0m, 30m, 10m, 5m remaining/)).toBeInTheDocument()
|
||||
expect(screen.queryByText(/demo/)).toBeNull()
|
||||
expect(screen.queryByText('Masked now')).toBeNull()
|
||||
expect(screen.queryByText('Carried over')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows time carried over from earlier free days', () => {
|
||||
render(<BudgetRulesCard rules={makeBudgetRules({ carry_seconds: 1200 })} />)
|
||||
expect(screen.getByText('Carried over')).toBeInTheDocument()
|
||||
expect(screen.getByText(/\+20m unspent from earlier free days/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the demo mode and masked launchers when they apply', () => {
|
||||
|
||||
@ -16,12 +16,6 @@ export function BudgetRulesCard({ rules }: Props) {
|
||||
<dl className="budget-rules-list">
|
||||
<dt>Daily budget</dt>
|
||||
<dd>{fmtDuration(rules.budget_seconds)}</dd>
|
||||
{rules.carry_seconds > 0 && (
|
||||
<>
|
||||
<dt>Carried over</dt>
|
||||
<dd>+{fmtDuration(rules.carry_seconds)} unspent from earlier free days</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>Enforcement</dt>
|
||||
<dd>{onOff(rules.enforcement)}</dd>
|
||||
<dt>Counts launchers</dt>
|
||||
|
||||
@ -142,7 +142,6 @@ export function makeBudgetRules(over: Partial<BudgetRules> = {}): BudgetRules {
|
||||
warn_at: [3600, 1800, 600, 300],
|
||||
demo: false,
|
||||
masked_launchers: [],
|
||||
carry_seconds: 0,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
@ -173,8 +173,6 @@ export interface BudgetRules {
|
||||
warn_at: number[]
|
||||
demo: boolean
|
||||
masked_launchers: string[]
|
||||
/** Unspent Fri-Sun time inherited by today; already inside budget_seconds. */
|
||||
carry_seconds: number
|
||||
}
|
||||
|
||||
export interface BudgetSnapshot {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user