mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +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
|
||||||
|
|
||||||
`_gaming_reset.reset_today`, shared with the CLI: zeroes billed time and per-game
|
`_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`,
|
attribution, re-arms warnings, releases the mounts, keeps earned time
|
||||||
`budget_seconds`; the old CLI path dropped both), and writes a `manual_adjustment`
|
(`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
|
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
|
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.
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Total block | yes | lock file, guard-lib |
|
||||||
| Armed gaming reset | no | memory only: arm again (fails closed) |
|
| 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.
|
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).
|
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
|
``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
|
``_gaming_days.held_today`` -- so the daemon, ``/api/budget`` and MCP all see
|
||||||
the held figure. Only the 06:00 roll-over lowers it.
|
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_MINUTE: Final = 60.0
|
||||||
_SECONDS_PER_HOUR: Final = 3600.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).
|
# How each earner reads in the reason string: (earned, missed, unknown).
|
||||||
# An earner registered later without an entry here gets a generic phrase.
|
# An earner registered later without an entry here gets a generic phrase.
|
||||||
@ -173,6 +175,10 @@ def resolve_budget(config: Config) -> BudgetResolution:
|
|||||||
_describe(t.earner.name, t.earner.label, t.answer) for t in day.terms
|
_describe(t.earner.name, t.earner.label, t.answer) for t in day.terms
|
||||||
)
|
)
|
||||||
reason = f"{total / _SECONDS_PER_HOUR:.1f}h: {parts}"
|
reason = f"{total / _SECONDS_PER_HOUR:.1f}h: {parts}"
|
||||||
|
# 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)
|
logger.info("Gaming budget %s", reason)
|
||||||
return BudgetResolution(
|
return BudgetResolution(
|
||||||
seconds=total,
|
seconds=total,
|
||||||
|
|||||||
@ -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
|
# is what would let this view report a budget the daemon is not
|
||||||
# enforcing. See _budget_resolve's "one seam".
|
# enforcing. See _budget_resolve's "one seam".
|
||||||
"budget_reason": rules.budget_reason,
|
"budget_reason": rules.budget_reason,
|
||||||
# Unspent Fri-Sun time inherited by today; already inside budget_seconds.
|
|
||||||
"carry_seconds": rules.carry_seconds,
|
|
||||||
"bonuses": {
|
"bonuses": {
|
||||||
"base": rules.base_seconds,
|
"base": rules.base_seconds,
|
||||||
"workout": rules.workout_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
|
A gaming "day" runs 06:00-05:59 local, so late-night sessions count against
|
||||||
the day they started in.
|
the day they started in.
|
||||||
|
|
||||||
**Free-day carry-over.** Friday to Monday is one block. Whatever a Friday,
|
There is no carry-over: unspent time is dropped at 06:00, every day. The
|
||||||
Saturday or Sunday leaves unspent is split evenly, with no strings attached,
|
Friday-Monday carry ledger was removed on 2026-10-18, after a Sunday ran
|
||||||
across the days still left in that block:
|
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
|
||||||
Fri leftover -> Sat, Sun, Mon (a third each)
|
high-water mark.
|
||||||
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.
|
|
||||||
|
|
||||||
Everything here is pure: it works on primitives handed to it, so it imports
|
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.
|
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 __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, timedelta
|
from datetime import timedelta
|
||||||
from typing import TYPE_CHECKING, Final
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -37,10 +26,6 @@ if TYPE_CHECKING:
|
|||||||
# The gaming day starts here, local time. 05:59 belongs to the previous day.
|
# The gaming day starts here, local time. 05:59 belongs to the previous day.
|
||||||
_DAY_BOUNDARY_HOURS: Final = 6
|
_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:
|
def gaming_day_key(now: datetime) -> str:
|
||||||
"""Return the ``YYYY-MM-DD`` gaming day containing *now*.
|
"""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()
|
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:
|
def held_today(stored: PlaytimeState | None, now: datetime) -> float:
|
||||||
"""Return the budget already granted earlier in *now*'s gaming day.
|
"""Return the budget already granted earlier in *now*'s gaming day.
|
||||||
|
|
||||||
|
|||||||
@ -56,10 +56,9 @@ class ResetOutcome:
|
|||||||
def reset_today(*, source: str) -> ResetOutcome:
|
def reset_today(*, source: str) -> ResetOutcome:
|
||||||
"""Zero today's billed time, drop the block and record the adjustment.
|
"""Zero today's billed time, drop the block and record the adjustment.
|
||||||
|
|
||||||
The earned-time bookkeeping (``carry`` from earlier free days, the day's
|
The day's ``budget_seconds`` high-water mark is kept: resetting spent
|
||||||
``budget_seconds`` high-water mark) is kept: resetting spent time must not
|
time must not also revoke time already earned. Warnings re-arm because
|
||||||
also revoke time already earned. Warnings re-arm because ``seconds`` is
|
``seconds`` is back at zero.
|
||||||
back at zero.
|
|
||||||
|
|
||||||
The reset is written to the budget audit log as a ``manual_adjustment``
|
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
|
(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"],
|
"seconds_used": today["seconds_used"],
|
||||||
"budget_seconds": today["budget_seconds"],
|
"budget_seconds": today["budget_seconds"],
|
||||||
"seconds_remaining": today["seconds_remaining"],
|
"seconds_remaining": today["seconds_remaining"],
|
||||||
# What earned budget_seconds, Friday-Monday carry-over included.
|
# What earned budget_seconds.
|
||||||
"budget_reason": rules["budget_reason"],
|
"budget_reason": rules["budget_reason"],
|
||||||
"carry_seconds": rules["carry_seconds"],
|
|
||||||
"next_warning_seconds": today["next_warning_seconds"],
|
"next_warning_seconds": today["next_warning_seconds"],
|
||||||
"blocked": today["blocked"],
|
"blocked": today["blocked"],
|
||||||
"enforcement": rules["enforcement"],
|
"enforcement": rules["enforcement"],
|
||||||
|
|||||||
@ -11,7 +11,6 @@ from dataclasses import replace
|
|||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from steam_backlog_enforcer._gaming_days import pass_on
|
|
||||||
from steam_backlog_enforcer._playtime_state import (
|
from steam_backlog_enforcer._playtime_state import (
|
||||||
_DELTA_CLAMP_FACTOR,
|
_DELTA_CLAMP_FACTOR,
|
||||||
PlaytimeRules,
|
PlaytimeRules,
|
||||||
@ -80,9 +79,8 @@ def roll_over(state: PlaytimeState, *, day_key: str) -> PlaytimeState:
|
|||||||
"""Reset *state* if the gaming day has changed.
|
"""Reset *state* if the gaming day has changed.
|
||||||
|
|
||||||
Pure. ``last_tick_at`` carries across the boundary so the first tick of the
|
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
|
new day measures a sane delta rather than restarting from zero. Nothing else
|
||||||
ledger crosses it with the outgoing day's leftover added (see
|
does: the outgoing day's unspent time is dropped.
|
||||||
``_gaming_days``).
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
state: Current accounting state.
|
state: Current accounting state.
|
||||||
@ -93,17 +91,7 @@ def roll_over(state: PlaytimeState, *, day_key: str) -> PlaytimeState:
|
|||||||
"""
|
"""
|
||||||
if state.day_key == day_key:
|
if state.day_key == day_key:
|
||||||
return state
|
return state
|
||||||
return PlaytimeState(
|
return PlaytimeState(day_key=day_key, last_tick_at=state.last_tick_at)
|
||||||
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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def pending_warning(state: PlaytimeState, rules: PlaytimeRules) -> int | None:
|
def pending_warning(state: PlaytimeState, rules: PlaytimeRules) -> int | None:
|
||||||
|
|||||||
@ -65,10 +65,14 @@ def _warn(state: PlaytimeState, rules: PlaytimeRules) -> PlaytimeState:
|
|||||||
if threshold is None:
|
if threshold is None:
|
||||||
return state
|
return state
|
||||||
|
|
||||||
notify_desktop_user(
|
left = f"{_humanise(threshold)} of today's gaming budget left."
|
||||||
"Gaming time running out",
|
logger.info(
|
||||||
f"{_humanise(threshold)} of today's gaming budget left.",
|
"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])
|
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),
|
"qualifying": sorted(qualifying),
|
||||||
"day_key": state.day_key,
|
"day_key": state.day_key,
|
||||||
"billed_seconds": round(state.seconds, 3),
|
"billed_seconds": round(state.seconds, 3),
|
||||||
|
"budget_seconds": round(rules.budget_seconds, 3),
|
||||||
"remaining_seconds": round(rules.budget_seconds - state.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.
|
"""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
|
is written atomically and re-read every tick, which is what lets the enforcer
|
||||||
survive a restart mid-session.
|
survive a restart mid-session.
|
||||||
"""
|
"""
|
||||||
@ -14,7 +14,8 @@ import logging
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from steam_backlog_enforcer._budget_resolve import resolve_budget
|
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 (
|
from steam_backlog_enforcer._whitelist_locking import (
|
||||||
_try_set_immutable,
|
_try_set_immutable,
|
||||||
unlock_for_write,
|
unlock_for_write,
|
||||||
@ -95,12 +96,10 @@ class PlaytimeState:
|
|||||||
warned_seconds: list[int] = field(default_factory=list)
|
warned_seconds: list[int] = field(default_factory=list)
|
||||||
"""Warning thresholds already fired during ``day_key``."""
|
"""Warning thresholds already fired during ``day_key``."""
|
||||||
budget_seconds: float = 0.0
|
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:
|
def is_blocked(self) -> bool:
|
||||||
"""Whether the cutoff has engaged for this gaming day.
|
"""Whether the cutoff has engaged for this gaming day.
|
||||||
@ -133,7 +132,6 @@ class PlaytimeRules:
|
|||||||
reading_seconds: float = 0.0
|
reading_seconds: float = 0.0
|
||||||
earned_seconds: dict[str, float] = field(default_factory=dict)
|
earned_seconds: dict[str, float] = field(default_factory=dict)
|
||||||
budget_reason: str = ""
|
budget_reason: str = ""
|
||||||
carry_seconds: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def rules_for(config: Config, *, demo: bool) -> PlaytimeRules:
|
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
|
# Resolved *here*, not per caller, so the daemon (one Config for life) and
|
||||||
# _budget_view (Config per request) can never report different budgets.
|
# _budget_view (Config per request) can never report different budgets.
|
||||||
resolved = None if demo else resolve_budget(config)
|
resolved = None if demo else resolve_budget(config)
|
||||||
# Carry sits on top of the 8h cap (earned, not spent: no strings). The
|
# The day never re-prices below what it already granted -- see held_today.
|
||||||
# day never re-prices below what it already granted -- see held_today.
|
|
||||||
now = datetime.now().astimezone()
|
now = datetime.now().astimezone()
|
||||||
stored = None if demo else load_state(demo=False)
|
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
|
||||||
live = 0.0 if resolved is None else resolved.seconds + carry
|
|
||||||
held = held_today(stored, now)
|
held = held_today(stored, now)
|
||||||
held_note = f"; held at {held / 3600:.1f}h (earned earlier today)"
|
held_note = f"; held at {held / 3600:.1f}h (earned earlier today)"
|
||||||
return PlaytimeRules(
|
return PlaytimeRules(
|
||||||
@ -178,11 +174,8 @@ def rules_for(config: Config, *, demo: bool) -> PlaytimeRules:
|
|||||||
budget_reason=(
|
budget_reason=(
|
||||||
"demo run"
|
"demo run"
|
||||||
if resolved is None
|
if resolved is None
|
||||||
else resolved.reason
|
else resolved.reason + (held_note if held > live else "")
|
||||||
+ (f", +{carry / 60:.0f}m carried over" if carry > 0 else "")
|
|
||||||
+ (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)
|
logger.warning("Playtime state at %s has an unknown schema.", path)
|
||||||
return None
|
return None
|
||||||
known = {f.name for f in fields(PlaytimeState)}
|
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:
|
def save_state(state: PlaytimeState, *, demo: bool) -> None:
|
||||||
|
|||||||
@ -15,7 +15,7 @@ from unittest.mock import patch
|
|||||||
import earned_time
|
import earned_time
|
||||||
import pytest
|
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._budget_resolve import resolve_budget
|
||||||
from steam_backlog_enforcer.config import Config
|
from steam_backlog_enforcer.config import Config
|
||||||
from steam_backlog_enforcer.tests._budget_dates import AFTER_CUT, pin_today
|
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()
|
_workout_budget.reset_cache()
|
||||||
_leetcode_bonus.reset_cache()
|
_leetcode_bonus.reset_cache()
|
||||||
|
_budget_resolve._last_logged.clear()
|
||||||
yield
|
yield
|
||||||
_workout_budget.reset_cache()
|
_workout_budget.reset_cache()
|
||||||
_leetcode_bonus.reset_cache()
|
_leetcode_bonus.reset_cache()
|
||||||
|
_budget_resolve._last_logged.clear()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@ -202,3 +204,20 @@ class TestTheExplanation:
|
|||||||
assert "no counted workout" in caplog.text
|
assert "no counted workout" in caplog.text
|
||||||
assert "no LeetCode solve recorded" in caplog.text
|
assert "no LeetCode solve recorded" in caplog.text
|
||||||
assert "no reading credited" 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
|
# 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
|
# 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.
|
# 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
|
# The held budget is pinned for the same reason: conftest's redirects are not
|
||||||
# at import either, so rules_for would read the LIVE playtime_state.json and
|
# active at import either, so rules_for would read the LIVE playtime_state.json
|
||||||
# fold whatever Fri-Mon carry it holds today into RULES.
|
# and hold RULES at whatever high-water mark it records today.
|
||||||
with (
|
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.held_today", return_value=0.0),
|
||||||
patch(
|
patch(
|
||||||
"steam_backlog_enforcer._playtime_state.resolve_budget",
|
"steam_backlog_enforcer._playtime_state.resolve_budget",
|
||||||
@ -224,7 +223,6 @@ class TestBuildBudgetSnapshot:
|
|||||||
"demo",
|
"demo",
|
||||||
"masked_launchers",
|
"masked_launchers",
|
||||||
"budget_reason",
|
"budget_reason",
|
||||||
"carry_seconds",
|
|
||||||
"bonuses",
|
"bonuses",
|
||||||
"earners",
|
"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 __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import replace
|
from dataclasses import fields
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import json
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from steam_backlog_enforcer._gaming_days import (
|
import pytest
|
||||||
carry_due,
|
|
||||||
carry_into,
|
from steam_backlog_enforcer._gaming_days import held_today
|
||||||
held_today,
|
|
||||||
pass_on,
|
|
||||||
)
|
|
||||||
from steam_backlog_enforcer._playtime_budget import roll_over
|
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
|
from steam_backlog_enforcer.config import Config
|
||||||
|
|
||||||
HOUR = 3600.0
|
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))
|
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:
|
class TestNoCarryOver:
|
||||||
return pass_on({}, outgoing_day=day, budget=budget, spent=spent, new_day=new_day)
|
"""Unspent time never moves to a later day (carry-over dropped 2026-10-18)."""
|
||||||
|
|
||||||
|
def test_a_saturday_leftover_is_not_passed_on(self) -> None:
|
||||||
class TestPassOn:
|
sun = roll_over(
|
||||||
"""Who inherits a day's unspent budget."""
|
PlaytimeState(day_key=SAT, seconds=HOUR, budget_seconds=8 * HOUR),
|
||||||
|
day_key=SUN,
|
||||||
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
|
|
||||||
)
|
)
|
||||||
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:
|
def test_rules_have_no_carry(self) -> None:
|
||||||
owed = _leftover(FRI, budget=3 * HOUR, spent=0.0, new_day=SUN)
|
assert "carry_seconds" not in {f.name for f in fields(PlaytimeRules)}
|
||||||
assert owed == {SUN: HOUR, MON: HOUR}
|
|
||||||
|
|
||||||
def test_shares_accumulate_with_earlier_ones(self) -> None:
|
def test_a_legacy_carry_is_taken_out_of_todays_high_water(self) -> None:
|
||||||
owed = pass_on(
|
# The live file on 2026-10-18 held 6.0h earned + 9994s carried into the
|
||||||
{SUN: 600.0, MON: 600.0},
|
# day's high-water mark; held_today would otherwise keep paying it out.
|
||||||
outgoing_day=SAT,
|
legacy = {
|
||||||
budget=HOUR,
|
"schema_version": 1,
|
||||||
spent=0.0,
|
"day_key": SUN,
|
||||||
new_day=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)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"carry", [{MON: 9994.0}, {SUN: "9994"}, {SUN: 0.0}, [9994.0]]
|
||||||
)
|
)
|
||||||
assert owed == {SUN: 2400.0, MON: 2400.0}
|
def test_a_ledger_owing_today_nothing_leaves_the_budget(
|
||||||
|
self, carry: object
|
||||||
|
) -> None:
|
||||||
class TestCarryDue:
|
legacy = {
|
||||||
"""Today's share, whether or not the stored record has rolled over."""
|
"schema_version": 1,
|
||||||
|
"day_key": SUN,
|
||||||
def test_same_day_reads_the_ledger(self) -> None:
|
"budget_seconds": 6 * HOUR,
|
||||||
due = carry_due({SAT: 60.0}, stored_day=SAT, budget=0.0, spent=0.0, today=SAT)
|
"carry": carry,
|
||||||
assert due == 60.0
|
}
|
||||||
|
state_path(demo=False).write_text(json.dumps(legacy), encoding="utf-8")
|
||||||
def test_stale_record_is_projected_forward(self) -> None:
|
stored = load_state(demo=False)
|
||||||
due = carry_due({}, stored_day=FRI, budget=4 * HOUR, spent=HOUR, today=SAT)
|
assert stored is not None
|
||||||
assert due == HOUR
|
assert stored.budget_seconds == 6 * 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
|
|
||||||
|
|
||||||
|
|
||||||
class TestHeldToday:
|
class TestHeldToday:
|
||||||
|
|||||||
@ -37,7 +37,6 @@ def _spent_state(day_key: str) -> PlaytimeState:
|
|||||||
last_credited_key="1",
|
last_credited_key="1",
|
||||||
warned_seconds=[600],
|
warned_seconds=[600],
|
||||||
budget_seconds=1800.0,
|
budget_seconds=1800.0,
|
||||||
carry={"2999-01-01": 120.0},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -58,7 +57,7 @@ class TestResetToday:
|
|||||||
assert stored is not None
|
assert stored is not None
|
||||||
assert (stored.seconds, stored.blocked_at, stored.per_game) == (0.0, 0.0, {})
|
assert (stored.seconds, stored.blocked_at, stored.per_game) == (0.0, 0.0, {})
|
||||||
assert (stored.last_credited_key, stored.warned_seconds) == ("", [])
|
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
|
assert stored.last_tick_at > 0
|
||||||
|
|
||||||
def test_audit_record_has_the_before_picture(self) -> None:
|
def test_audit_record_has_the_before_picture(self) -> None:
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import logging
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -196,6 +197,15 @@ class TestWarn:
|
|||||||
assert out.warned_seconds == [3600, 1800, 600, 300]
|
assert out.warned_seconds == [3600, 1800, 600, 300]
|
||||||
assert "5 minutes" in mock_notify.call_args.args[1]
|
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:
|
def test_no_op_when_nothing_is_due(self) -> None:
|
||||||
state = PlaytimeState(seconds=0.0)
|
state = PlaytimeState(seconds=0.0)
|
||||||
with patch(
|
with patch(
|
||||||
|
|||||||
@ -176,3 +176,4 @@ class TestTickJournal:
|
|||||||
assert record["qualifying"] == [11, 22]
|
assert record["qualifying"] == [11, 22]
|
||||||
assert record["state"] == "engaged"
|
assert record["state"] == "engaged"
|
||||||
assert record["remaining_seconds"] == 28700.0
|
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],
|
warn_at: [3600, 1800, 600, 300],
|
||||||
demo,
|
demo,
|
||||||
masked_launchers: ['heroic', 'lutris'],
|
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.getByText(/1h 0m, 30m, 10m, 5m remaining/)).toBeInTheDocument()
|
||||||
expect(screen.queryByText(/demo/)).toBeNull()
|
expect(screen.queryByText(/demo/)).toBeNull()
|
||||||
expect(screen.queryByText('Masked now')).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', () => {
|
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">
|
<dl className="budget-rules-list">
|
||||||
<dt>Daily budget</dt>
|
<dt>Daily budget</dt>
|
||||||
<dd>{fmtDuration(rules.budget_seconds)}</dd>
|
<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>
|
<dt>Enforcement</dt>
|
||||||
<dd>{onOff(rules.enforcement)}</dd>
|
<dd>{onOff(rules.enforcement)}</dd>
|
||||||
<dt>Counts launchers</dt>
|
<dt>Counts launchers</dt>
|
||||||
|
|||||||
@ -142,7 +142,6 @@ export function makeBudgetRules(over: Partial<BudgetRules> = {}): BudgetRules {
|
|||||||
warn_at: [3600, 1800, 600, 300],
|
warn_at: [3600, 1800, 600, 300],
|
||||||
demo: false,
|
demo: false,
|
||||||
masked_launchers: [],
|
masked_launchers: [],
|
||||||
carry_seconds: 0,
|
|
||||||
...over,
|
...over,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -173,8 +173,6 @@ export interface BudgetRules {
|
|||||||
warn_at: number[]
|
warn_at: number[]
|
||||||
demo: boolean
|
demo: boolean
|
||||||
masked_launchers: string[]
|
masked_launchers: string[]
|
||||||
/** Unspent Fri-Sun time inherited by today; already inside budget_seconds. */
|
|
||||||
carry_seconds: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BudgetSnapshot {
|
export interface BudgetSnapshot {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user