steam-backlog-enforcer/steam_backlog_enforcer/tests/test_research_window.py
Krzysztof kuhy Rudnicki 87c6f200e3 Research window: keep Steam dir, extend once
kuhy 2026-11-11, multi-day research without re-setup.

W1: once a window was used in a lock, the block's purge spares
~/.local/share/Steam (Librarian's install and local saves) for the
rest of the lock; ~/steam, ~/.steam and the rest still go. The
package stays removed, hosts/iptables are re-applied, and since the
kept install could otherwise be launched without the package, every
Steam game is killed on sight. A record that cannot be read also
keeps the folder (an irreversible delete never rests on unknown
state); the block itself stays fully applied.

W2: the 48 h cap is gone; --until is bounded by the lock's end. An
open window may be extended ONCE, to the lock end at most, never
shortened, never reopened after it ended. The extension is a
separate extended_until field beside until, so a daemon still on
the previous code reads the original window instead of rejecting the
record as corrupt (which would have purged the folder).

Failure modes unchanged: missing/corrupt record = no window, and a
corrupt one refuses a new window or extension.

Checked: 3325 tests at 100% coverage; pylint 10/10 in parts; vmbox:
extension accepted once, refused beyond the lock end, earlier or
equal, second time and non-root; extended end honoured past the
original end; at close the Steam dir and a save survive, ~/steam and
~/.steam go, a game started after close is killed; extension to
exactly the lock end accepted. Not verified on the host.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWWzuCEg1m87jpKyRrw
2026-11-11 16:28:43 +01:00

189 lines
7.4 KiB
Python

"""The research window: lock binding, fail-closed live record, the clock."""
from __future__ import annotations
import json
import logging
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from steam_backlog_enforcer import _research_record as store
from steam_backlog_enforcer import _research_window as window
from steam_backlog_enforcer.tests._research_helpers import (
HOUR,
LOCK_END,
LOCK_START,
NOW,
lock,
make_record,
)
if TYPE_CHECKING:
from steam_backlog_enforcer.tests._total_block_paths import Paths
NOW_PATCH = "steam_backlog_enforcer._research_window.now"
class TestLiveRecord:
"""live_record is the daemon's fail-closed view of the file."""
def test_no_lock_means_no_window(self) -> None:
"""Test that a record without an active total block is ignored."""
window.write_record(make_record())
assert window.live_record() is None
def test_no_record(self, total_block_paths: Paths) -> None:
"""Test that a lock without a record has no window."""
lock(total_block_paths)
with patch(NOW_PATCH, return_value=NOW):
assert window.live_record() is None
def test_matching_record(self, total_block_paths: Paths) -> None:
"""Test that a record for the live lock is returned."""
lock(total_block_paths)
window.write_record(make_record())
with patch(NOW_PATCH, return_value=NOW):
assert window.live_record() == make_record()
def test_other_locks_record_is_ignored(self, total_block_paths: Paths) -> None:
"""Test that last month's record does not open this lock."""
lock(total_block_paths)
window.write_record(make_record(lock_started_at=LOCK_START - 99.0))
with patch(NOW_PATCH, return_value=NOW):
assert window.live_record() is None
def test_corrupt_record_is_no_window_and_logged_once(
self, total_block_paths: Paths, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that corruption fails closed and does not spam every tick."""
lock(total_block_paths)
store.RESEARCH_FILE.parent.mkdir(parents=True)
store.RESEARCH_FILE.write_text("{oops", encoding="utf-8")
with patch(NOW_PATCH, return_value=NOW), caplog.at_level(logging.ERROR):
assert window.live_record() is None
assert window.live_record() is None
assert len([r for r in caplog.records if "research" in r.message]) == 1
def test_corruption_is_logged_again_after_recovery(
self, total_block_paths: Paths, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that a record that heals and breaks again is reported again."""
lock(total_block_paths)
store.RESEARCH_FILE.parent.mkdir(parents=True)
store.RESEARCH_FILE.write_text("{oops", encoding="utf-8")
with patch(NOW_PATCH, return_value=NOW), caplog.at_level(logging.ERROR):
window.live_record()
window.write_record(make_record())
assert window.live_record() is not None
store.RESEARCH_FILE.write_text("{oops", encoding="utf-8")
window.live_record()
assert len([r for r in caplog.records if "research" in r.message]) == 2
class TestOpenNow:
"""live_record plus is_open is the daemon's whole decision."""
@staticmethod
def _open_at(at: float) -> bool:
with patch(NOW_PATCH, return_value=at):
record = window.live_record()
return record is not None and record.is_open(at)
def test_open_window(self, total_block_paths: Paths) -> None:
"""Test that an open window on the live lock is open."""
lock(total_block_paths)
window.write_record(make_record())
assert self._open_at(NOW) is True
def test_expired_window(self, total_block_paths: Paths) -> None:
"""Test that a passed deadline is not open."""
lock(total_block_paths)
window.write_record(make_record())
assert self._open_at(NOW + 2 * HOUR) is False
def test_clock_in_the_past_is_not_open(self, total_block_paths: Paths) -> None:
"""Test that a clock before opening is fully blocked."""
lock(total_block_paths)
window.write_record(make_record())
assert self._open_at(NOW - 3 * HOUR) is False
def test_closed_latch_is_not_open(self, total_block_paths: Paths) -> None:
"""Test that a latched window never reopens."""
lock(total_block_paths)
window.write_record(make_record(closed=True))
assert self._open_at(NOW) is False
def test_missing_state_is_fully_blocked(self, total_block_paths: Paths) -> None:
"""Test that no record at all is the blocked default."""
lock(total_block_paths)
assert self._open_at(NOW) is False
def test_lock_identity_tolerates_float_rounding(
self, total_block_paths: Paths
) -> None:
"""Test that sub-second noise still matches, whole seconds do not."""
lock(total_block_paths)
window.write_record(make_record(lock_started_at=LOCK_START + 0.4))
assert self._open_at(NOW) is True
window.write_record(make_record(lock_started_at=LOCK_START + 2.0))
assert self._open_at(NOW) is False
def test_lock_without_start_is_no_lock(self, total_block_paths: Paths) -> None:
"""Test that a lock file lacking started_at cannot host a window."""
total_block_paths.lock_file.write_text(
json.dumps({"until": LOCK_END}), encoding="utf-8"
)
window.write_record(make_record())
assert self._open_at(NOW) is False
def test_now_is_the_wall_clock(self) -> None:
"""Test that now() reads time.time()."""
with patch("steam_backlog_enforcer._research_window.time.time") as clock:
clock.return_value = 42.5
assert window.now() == 42.5
class TestResearchUsed:
"""research_used decides whether the Steam folder may be purged."""
@staticmethod
def _used() -> bool:
with patch(NOW_PATCH, return_value=NOW):
return window.research_used()
def test_never_used(self, total_block_paths: Paths) -> None:
"""Test that a lock without a record purges as before."""
lock(total_block_paths)
assert self._used() is False
def test_no_lock(self) -> None:
"""Test that without a total block nothing is kept on its account."""
window.write_record(make_record())
assert self._used() is False
@pytest.mark.parametrize("closed", [False, True])
def test_used_for_this_lock(
self, total_block_paths: Paths, *, closed: bool
) -> None:
"""Test that an open or ended window both protect the Steam folder."""
lock(total_block_paths)
window.write_record(make_record(closed=closed))
assert self._used() is True
def test_other_locks_record_does_not_protect(
self, total_block_paths: Paths
) -> None:
"""Test that last lock's window gives this lock no exemption."""
lock(total_block_paths)
window.write_record(make_record(lock_started_at=LOCK_START - 99.0))
assert self._used() is False
def test_unreadable_record_keeps_the_data(self, total_block_paths: Paths) -> None:
"""Test that when the record cannot say, nothing irreversible happens."""
lock(total_block_paths)
store.RESEARCH_FILE.parent.mkdir(parents=True)
store.RESEARCH_FILE.write_text("{oops", encoding="utf-8")
assert self._used() is True