mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 13:47:31 +01:00
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
224 lines
7.8 KiB
Python
224 lines
7.8 KiB
Python
"""Tests for _budget_resolve: the floor plus what today earned.
|
|
|
|
The matrix is the point of this file. Three independent earners give eight
|
|
days, and the two properties that must hold across all of them are that the sum is
|
|
additive (never a choice between two absolutes) and that *not knowing* an
|
|
answer is worth exactly as much as a "no" -- never more.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import patch
|
|
|
|
import earned_time
|
|
import pytest
|
|
|
|
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
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
_WORKOUT = "steam_backlog_enforcer._workout_budget._fetch_workout_today"
|
|
_LEETCODE = "steam_backlog_enforcer._leetcode_bonus.read_ledger_solved_today"
|
|
_LEETCODE_HTTP = "steam_backlog_enforcer._leetcode_bonus._fetch_leetcode_today"
|
|
_READING = "steam_backlog_enforcer._budget_resolve.read_today"
|
|
_LEDGER = "steam_backlog_enforcer._budget_resolve.ledger_answer"
|
|
|
|
_HOUR = 3600.0
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_caches() -> Iterator[None]:
|
|
"""Keep both module memos from leaking answers between tests.
|
|
|
|
Yields:
|
|
None, with both caches empty before and after.
|
|
"""
|
|
_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)
|
|
def _after_the_cut() -> Iterator[None]:
|
|
"""Resolve as on a day past the 5h -> 4h cut, whatever the real date.
|
|
|
|
Yields:
|
|
None, with today's date pinned inside ``_budget_resolve``.
|
|
"""
|
|
with pin_today(AFTER_CUT):
|
|
yield
|
|
|
|
|
|
class TestTheEightDays:
|
|
"""Every combination of the three earners, in hours."""
|
|
|
|
@pytest.mark.parametrize(
|
|
("earners", "expected_hours"),
|
|
[
|
|
((False, False, False), 4.0),
|
|
((False, False, True), 5.0),
|
|
((False, True, False), 5.0),
|
|
((False, True, True), 6.0),
|
|
((True, False, False), 6.0),
|
|
((True, False, True), 7.0),
|
|
((True, True, False), 7.0),
|
|
((True, True, True), 8.0),
|
|
],
|
|
)
|
|
def test_the_budget_is_the_sum(
|
|
self,
|
|
earners: tuple[bool, bool, bool],
|
|
expected_hours: float,
|
|
) -> None:
|
|
"""4h floor, +2h workout, +1h solve, +1h reading, in every combination.
|
|
|
|
Args:
|
|
earners: Whether the workout, the solve and the reading happened.
|
|
expected_hours: The budget those three should produce.
|
|
"""
|
|
workout, leetcode, reading = earners
|
|
with (
|
|
patch(_WORKOUT, return_value=workout),
|
|
patch(_LEETCODE, return_value=leetcode),
|
|
patch(_READING, return_value=reading),
|
|
):
|
|
resolved = resolve_budget(Config())
|
|
assert resolved.seconds == expected_hours * _HOUR
|
|
|
|
def test_the_breakdown_matches_the_total(self) -> None:
|
|
"""The parts a caller displays must add up to the number enforced."""
|
|
with (
|
|
patch(_WORKOUT, return_value=True),
|
|
patch(_LEETCODE, return_value=True),
|
|
patch(_READING, return_value=True),
|
|
):
|
|
resolved = resolve_budget(Config())
|
|
assert (
|
|
resolved.base_seconds
|
|
+ resolved.workout_seconds
|
|
+ resolved.leetcode_seconds
|
|
+ resolved.reading_seconds
|
|
== resolved.seconds
|
|
)
|
|
|
|
def test_every_earner_is_in_the_generic_breakdown(self) -> None:
|
|
"""``earned_seconds`` carries the same terms as the named fields."""
|
|
with (
|
|
patch(_WORKOUT, return_value=True),
|
|
patch(_LEETCODE, return_value=False),
|
|
patch(_READING, return_value=True),
|
|
):
|
|
resolved = resolve_budget(Config())
|
|
# Every other registered earner has no ledger here, so earns nothing.
|
|
others = {e.name: 0.0 for e in earned_time.EARNERS}
|
|
assert resolved.earned_seconds == {
|
|
**others,
|
|
"workout": 2 * _HOUR,
|
|
"leetcode": 0.0,
|
|
"reading": _HOUR,
|
|
}
|
|
|
|
|
|
class TestFailingClosed:
|
|
"""An answer that could not be obtained is worth nothing, and says so."""
|
|
|
|
def test_an_unreachable_locker_costs_only_the_workout_bonus(self) -> None:
|
|
"""A dead screen locker must not also cost the LeetCode hour."""
|
|
with (
|
|
patch(_WORKOUT, side_effect=OSError("refused")),
|
|
patch(_LEETCODE, return_value=True),
|
|
):
|
|
resolved = resolve_budget(Config())
|
|
assert resolved.seconds == 5.0 * _HOUR
|
|
assert resolved.leetcode_seconds == _HOUR
|
|
|
|
def test_an_unreadable_ledger_costs_only_the_leetcode_bonus(self) -> None:
|
|
"""The whole point of the split: LeetCode failing leaves 6h, not 4h."""
|
|
with (
|
|
patch(_WORKOUT, return_value=True),
|
|
patch(_LEETCODE, return_value=None),
|
|
patch(_LEETCODE_HTTP, side_effect=OSError("refused")),
|
|
):
|
|
resolved = resolve_budget(Config())
|
|
assert resolved.seconds == 6.0 * _HOUR
|
|
assert resolved.workout_seconds == 2 * _HOUR
|
|
|
|
def test_both_unavailable_leaves_the_bare_floor(self) -> None:
|
|
"""Neither earner answering can never grant more than the floor."""
|
|
with (
|
|
patch(_WORKOUT, side_effect=OSError("refused")),
|
|
patch(_LEETCODE, return_value=None),
|
|
patch(_LEETCODE_HTTP, side_effect=OSError("refused")),
|
|
):
|
|
resolved = resolve_budget(Config())
|
|
assert resolved.seconds == 4.0 * _HOUR
|
|
|
|
def test_unknown_is_reported_differently_from_no(self) -> None:
|
|
""" "Could not check" and "not earned" must not read the same."""
|
|
with (
|
|
patch(_WORKOUT, return_value=False),
|
|
patch(_LEETCODE, return_value=None),
|
|
patch(_LEETCODE_HTTP, side_effect=OSError("refused")),
|
|
patch(_LEDGER, return_value=False),
|
|
):
|
|
unknown = resolve_budget(Config()).reason
|
|
_leetcode_bonus.reset_cache()
|
|
_workout_budget.reset_cache()
|
|
with (
|
|
patch(_WORKOUT, return_value=False),
|
|
patch(_LEETCODE, return_value=False),
|
|
patch(_LEDGER, return_value=False),
|
|
):
|
|
answered = resolve_budget(Config()).reason
|
|
assert unknown != answered
|
|
assert "unknown" in unknown
|
|
assert "unknown" not in answered
|
|
|
|
|
|
class TestTheExplanation:
|
|
"""The reason string and the per-earner breakdown."""
|
|
|
|
def test_the_budget_is_explained(self, caplog: pytest.LogCaptureFixture) -> None:
|
|
"""A three-hour swing between days should never be silent.
|
|
|
|
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())
|
|
assert "4.0h" in caplog.text
|
|
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
|