steam-backlog-enforcer/steam_backlog_enforcer/tests/test_playtime.py
Krzysztof kuhy Rudnicki 63321f16cc
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Pay an hour of gaming budget for a LeetCode solve
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
2026-08-30 15:22:18 +02:00

219 lines
8.0 KiB
Python

"""Tests for playtime day keys, state persistence and rule resolution."""
from __future__ import annotations
from dataclasses import FrozenInstanceError
from datetime import datetime, timedelta, timezone
import json
from unittest.mock import patch
import pytest
from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
gaming_day_key,
load_state,
rules_for,
save_state,
state_path,
)
from steam_backlog_enforcer.config import Config
PKG = "steam_backlog_enforcer._playtime"
LOCAL = timezone(timedelta(hours=2))
def _at(year: int, month: int, day: int, hour: int, minute: int = 0) -> datetime:
return datetime(year, month, day, hour, minute, tzinfo=LOCAL)
class TestGamingDayKey:
def test_just_before_the_boundary_is_the_previous_day(self) -> None:
assert gaming_day_key(_at(2026, 7, 27, 5, 59)) == "2026-07-26"
def test_exactly_at_the_boundary_starts_the_new_day(self) -> None:
assert gaming_day_key(_at(2026, 7, 27, 6, 0)) == "2026-07-27"
def test_evening_is_the_same_day(self) -> None:
assert gaming_day_key(_at(2026, 7, 27, 23, 30)) == "2026-07-27"
def test_after_midnight_belongs_to_the_day_it_started(self) -> None:
assert gaming_day_key(_at(2026, 7, 28, 1, 15)) == "2026-07-27"
def test_exact_midnight(self) -> None:
assert gaming_day_key(_at(2026, 7, 28, 0, 0)) == "2026-07-27"
def test_month_boundary(self) -> None:
assert gaming_day_key(_at(2026, 8, 1, 3, 0)) == "2026-07-31"
def test_dst_transition_day_still_yields_a_key(self) -> None:
"""The gaming day is 23h or 25h across DST; the budget is not rescaled."""
warsaw_spring_forward = datetime(
2026, 3, 29, 12, 0, tzinfo=timezone(timedelta(hours=2))
)
assert gaming_day_key(warsaw_spring_forward) == "2026-03-29"
class TestStatePath:
def test_production_and_demo_differ(self) -> None:
assert state_path(demo=False) != state_path(demo=True)
def test_demo_path_is_named_demo(self) -> None:
assert "demo" in state_path(demo=True).name
class TestPlaytimeState:
def test_not_blocked_by_default(self) -> None:
assert PlaytimeState().is_blocked() is False
def test_blocked_when_stamped(self) -> None:
assert PlaytimeState(blocked_at=1.0).is_blocked() is True
def test_is_blocked_is_not_serialised(self) -> None:
"""A derived field would be persisted and could drift from blocked_at."""
assert "is_blocked" not in PlaytimeState().__dict__
class TestSaveLoadRoundTrip:
def test_round_trip(self) -> None:
original = PlaytimeState(
day_key="2026-07-27",
seconds=123.5,
last_tick_at=99.0,
blocked_at=42.0,
warned_seconds=[600, 300],
)
save_state(original, demo=False)
loaded = load_state(demo=False)
assert loaded == original
def test_written_world_readable(self) -> None:
# The daemon runs as root and mkstemp would leave this 0600, which made
# every unprivileged reader see "no state recorded yet".
save_state(PlaytimeState(day_key="d", seconds=1.0), demo=False)
assert state_path(demo=False).stat().st_mode & 0o777 == 0o644
def test_demo_and_production_are_independent(self) -> None:
save_state(PlaytimeState(day_key="prod", seconds=10.0), demo=False)
save_state(PlaytimeState(day_key="demo", seconds=20.0), demo=True)
assert load_state(demo=False).day_key == "prod"
assert load_state(demo=True).day_key == "demo"
def test_missing_file_is_none(self) -> None:
assert load_state(demo=False) is None
def test_corrupt_json_is_none(self) -> None:
state_path(demo=False).write_text("{not json", encoding="utf-8")
assert load_state(demo=False) is None
def test_unreadable_file_is_none(self) -> None:
save_state(PlaytimeState(day_key="x"), demo=False)
with patch("pathlib.Path.read_text", side_effect=OSError("nope")):
assert load_state(demo=False) is None
def test_wrong_schema_version_is_none(self) -> None:
state_path(demo=False).write_text(
json.dumps({"schema_version": 99, "day_key": "x"}), encoding="utf-8"
)
assert load_state(demo=False) is None
def test_non_dict_json_is_none(self) -> None:
state_path(demo=False).write_text("[1, 2, 3]", encoding="utf-8")
assert load_state(demo=False) is None
def test_unknown_keys_are_dropped(self) -> None:
state_path(demo=False).write_text(
json.dumps({"schema_version": 1, "day_key": "d", "bogus": 1}),
encoding="utf-8",
)
loaded = load_state(demo=False)
assert loaded is not None
assert loaded.day_key == "d"
class TestSaveStateImmutability:
def test_production_save_unlocks_then_relocks(self) -> None:
"""rename(2) onto an immutable file is EPERM, so the unlock is required."""
with (
patch(
"steam_backlog_enforcer._playtime_state.unlock_for_write"
) as mock_unlock,
patch(
"steam_backlog_enforcer._playtime_state._try_set_immutable"
) as mock_lock,
):
save_state(PlaytimeState(day_key="x"), demo=False)
mock_unlock.assert_called_once_with(state_path(demo=False))
mock_lock.assert_called_once_with(state_path(demo=False), immutable=True)
def test_demo_save_is_left_mutable(self) -> None:
"""An immutable demo file could not be deleted during cleanup."""
with (
patch(
"steam_backlog_enforcer._playtime_state.unlock_for_write"
) as mock_unlock,
patch(
"steam_backlog_enforcer._playtime_state._try_set_immutable"
) as mock_lock,
):
save_state(PlaytimeState(day_key="x"), demo=True)
mock_unlock.assert_not_called()
mock_lock.assert_not_called()
class TestRulesFor:
def test_production_budget_comes_from_config(self) -> None:
rules = rules_for(
Config(
base_gaming_seconds=100,
workout_bonus_seconds=0,
leetcode_bonus_seconds=0,
),
demo=False,
)
assert rules.budget_seconds == 100.0
assert rules.demo is False
def test_demo_budget_is_sixty_seconds(self) -> None:
rules = rules_for(Config(), demo=True)
assert rules.budget_seconds == 60.0
assert rules.demo is True
def test_demo_warns_in_seconds_not_minutes(self) -> None:
assert rules_for(Config(), demo=True).warn_at == (30, 10)
assert rules_for(Config(), demo=False).warn_at == (3600, 1800, 600, 300)
def test_demo_escalates_to_sigkill_sooner(self) -> None:
assert rules_for(Config(), demo=True).sigkill_after == 10.0
assert rules_for(Config(), demo=False).sigkill_after == 30.0
def test_launcher_toggle_is_carried_through(self) -> None:
assert (
rules_for(
Config(count_launcher_processes=False), demo=False
).count_launchers
is False
)
assert (
rules_for(Config(count_launcher_processes=True), demo=False).count_launchers
is True
)
def test_enforcement_toggle_is_carried_through(self) -> None:
assert (
rules_for(Config(playtime_enforcement=False), demo=False).enforcement
is False
)
def test_demo_uses_the_same_qualifying_predicate(self) -> None:
"""Demo may differ in budget and warnings only."""
prod = rules_for(Config(count_launcher_processes=True), demo=False)
demo = rules_for(Config(count_launcher_processes=True), demo=True)
assert prod.count_launchers == demo.count_launchers
def test_rules_are_frozen(self) -> None:
"""Frozen so the decision helpers stay pure and cheap to reason about."""
rules = rules_for(Config(), demo=False)
with pytest.raises(FrozenInstanceError):
rules.budget_seconds = 1.0