steam-backlog-enforcer/steam_backlog_enforcer/tests/test_playtime_part2.py
Krzysztof kuhy Rudnicki d2565416aa Pylint gate covers tests, no suppressions
Tests are now linted by the 10.00 gate (ignore-paths dropped), with no
per-test disables. CtlServer/DaemonControl members the tests drive are
public (renamed, behaviour unchanged); SteamAPIClient._get takes its
params explicitly, as every caller already did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:17:07 +01:00

170 lines
6.7 KiB
Python

"""Tests for playtime accumulation, rollover, warnings and /proc scanning."""
from datetime import datetime, timedelta, timezone
from steam_backlog_enforcer._playtime_budget import (
accumulate,
pending_warning,
roll_over,
)
from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
rules_for,
)
from steam_backlog_enforcer.config import Config
PKG = "steam_backlog_enforcer._playtime"
LOCAL = timezone(timedelta(hours=2))
NOW = datetime(2026, 7, 27, 20, 0, tzinfo=LOCAL)
INTERVAL = 3.0
def _state_ticked(seconds_ago: float, **kwargs: object) -> PlaytimeState:
"""Return a state whose last tick was seconds_ago before NOW."""
return PlaytimeState(last_tick_at=NOW.timestamp() - seconds_ago, **kwargs)
class TestAccumulate:
"""Each tick credits the time since the last one, clamped and never negative."""
def test_first_ever_tick_credits_nothing(self) -> None:
"""The first tick ever only starts the clock."""
out = accumulate(
PlaytimeState(last_tick_at=0.0),
now=NOW,
qualifying={1},
interval=INTERVAL,
)
assert out.seconds == 0.0
assert out.last_tick_at == NOW.timestamp()
def test_normal_tick_credits_the_delta(self) -> None:
"""A regular qualifying tick credits the elapsed seconds."""
out = accumulate(_state_ticked(3.0), now=NOW, qualifying={1}, interval=INTERVAL)
assert out.seconds == 3.0
def test_idle_tick_credits_nothing_but_advances_the_clock(self) -> None:
"""With no qualifying game the clock moves but nothing is credited."""
out = accumulate(
_state_ticked(3.0), now=NOW, qualifying=set(), interval=INTERVAL
)
assert out.seconds == 0.0
assert out.last_tick_at == NOW.timestamp()
def test_long_gap_is_clamped(self) -> None:
"""A suspend must not credit hours of gaming that never happened."""
out = accumulate(
_state_ticked(3600.0), now=NOW, qualifying={1}, interval=INTERVAL
)
assert out.seconds == 6.0
def test_gap_exactly_at_the_clamp(self) -> None:
"""A gap equal to the clamp is credited in full."""
out = accumulate(_state_ticked(6.0), now=NOW, qualifying={1}, interval=INTERVAL)
assert out.seconds == 6.0
def test_backward_clock_step_credits_nothing(self) -> None:
"""An NTP step backwards must never refund time already spent."""
out = accumulate(
_state_ticked(-10.0),
now=NOW,
qualifying={1},
interval=INTERVAL,
)
assert out.seconds == 0.0
assert out.last_tick_at == NOW.timestamp()
def test_zero_delta(self) -> None:
"""A zero-length tick credits nothing."""
out = accumulate(_state_ticked(0.0), now=NOW, qualifying={1}, interval=INTERVAL)
assert out.seconds == 0.0
def test_accrues_across_ticks(self) -> None:
"""Credit adds to the seconds already counted."""
state = PlaytimeState(seconds=100.0, last_tick_at=NOW.timestamp() - 3.0)
out = accumulate(state, now=NOW, qualifying={1}, interval=INTERVAL)
assert out.seconds == 103.0
def test_is_pure(self) -> None:
"""accumulate returns a new state and leaves its input alone."""
state = _state_ticked(3.0)
accumulate(state, now=NOW, qualifying={1}, interval=INTERVAL)
assert state.seconds == 0.0
class TestRollOver:
"""A new day resets the counter, block and warnings."""
def test_same_day_is_unchanged(self) -> None:
"""The same day key returns the very same state."""
state = PlaytimeState(day_key="2026-07-27", seconds=50.0, blocked_at=9.0)
assert roll_over(state, day_key="2026-07-27") is state
def test_new_day_resets_counter_and_block(self) -> None:
"""A new day key clears seconds, block and fired warnings."""
state = PlaytimeState(
day_key="2026-07-27",
seconds=50.0,
blocked_at=9.0,
warned_seconds=[600],
)
out = roll_over(state, day_key="2026-07-28")
assert out.day_key == "2026-07-28"
assert out.seconds == 0.0
assert out.is_blocked() is False
assert out.warned_seconds == []
def test_last_tick_carries_across_the_boundary(self) -> None:
"""Otherwise the first tick of the new day measures a bogus delta."""
state = PlaytimeState(day_key="2026-07-27", last_tick_at=1234.0)
assert roll_over(state, day_key="2026-07-28").last_tick_at == 1234.0
def test_empty_day_key_rolls_over(self) -> None:
"""A state with no day key adopts the new day."""
assert roll_over(PlaytimeState(), day_key="2026-07-28").day_key == "2026-07-28"
class TestPendingWarning:
"""The next warning is the largest threshold crossed and not yet fired."""
def _rules(self, *, demo: bool = False) -> object:
"""Return the default production or demo rules."""
return rules_for(Config(), demo=demo)
def test_none_when_far_from_the_budget(self) -> None:
"""Far from the budget no warning is due."""
state = PlaytimeState(seconds=0.0)
assert pending_warning(state, self._rules()) is None
def test_fires_at_the_first_threshold(self) -> None:
"""Reaching the one-hour mark fires the one-hour warning."""
state = PlaytimeState(seconds=8 * 3600 - 3600)
assert pending_warning(state, self._rules()) == 3600
def test_skips_already_fired_thresholds(self) -> None:
"""A threshold already warned about is skipped."""
state = PlaytimeState(seconds=8 * 3600 - 1800, warned_seconds=[3600])
assert pending_warning(state, self._rules()) == 1800
def test_returns_largest_uncrossed_when_two_are_skipped(self) -> None:
"""A missed tick crossing two thresholds must warn once, not twice."""
state = PlaytimeState(seconds=8 * 3600 - 400)
assert pending_warning(state, self._rules()) == 3600
def test_none_when_all_fired(self) -> None:
"""Once every threshold fired nothing is due."""
state = PlaytimeState(
seconds=8 * 3600 - 100, warned_seconds=[3600, 1800, 600, 300]
)
assert pending_warning(state, self._rules()) is None
def test_demo_thresholds(self) -> None:
"""Demo rules use their own short thresholds."""
state = PlaytimeState(seconds=35.0)
assert pending_warning(state, self._rules(demo=True)) == 30
def test_over_budget_still_reports(self) -> None:
"""Past the budget the last unfired threshold still fires."""
state = PlaytimeState(seconds=8 * 3600 + 500, warned_seconds=[3600, 1800, 600])
assert pending_warning(state, self._rules()) == 300