steam-backlog-enforcer/steam_backlog_enforcer/tests/test_ctl_context.py
2026-10-10 16:07:42 +02:00

140 lines
4.6 KiB
Python

"""Tests for ``_ctl_context``: the tick lock, argument readers and lock gate."""
from __future__ import annotations
from contextlib import AbstractContextManager, ExitStack
from unittest.mock import patch
import pytest
from steam_backlog_enforcer import _ctl_context
from steam_backlog_enforcer._ctl_protocol import CtlError
from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx
def _code(exc: pytest.ExceptionInfo[CtlError]) -> str:
return exc.value.code
class TestHoldTick:
"""The lock is held for the body and always released."""
def test_holds_and_releases(self) -> None:
ctx = make_ctx()
with _ctl_context.hold_tick(ctx):
assert ctx.tick_lock.locked()
assert not ctx.tick_lock.locked()
def test_releases_after_error(self) -> None:
ctx = make_ctx()
with pytest.raises(RuntimeError), _ctl_context.hold_tick(ctx):
msg = "boom"
raise RuntimeError(msg)
assert not ctx.tick_lock.locked()
def test_busy_when_loop_keeps_the_lock(self) -> None:
ctx = make_ctx()
ctx.tick_lock.acquire()
with (
patch.object(_ctl_context, "LOCK_WAIT_SECONDS", 0.01),
pytest.raises(CtlError) as caught,
_ctl_context.hold_tick(ctx),
):
pass
assert _code(caught) == "busy"
class TestRequirePhrase:
"""The typed confirmation phrase."""
def test_accepts_exact_phrase_with_padding(self) -> None:
_ctl_context.require_phrase(
"block-gaming", " block all gaming for 3 days ", days=3
)
def test_non_string(self) -> None:
with pytest.raises(CtlError) as caught:
_ctl_context.require_phrase("gaming-unblock", 5)
assert _code(caught) == "invalid_params"
def test_wrong_phrase(self) -> None:
with pytest.raises(CtlError) as caught:
_ctl_context.require_phrase("gaming-unblock", "nope")
assert _code(caught) == "wrong_phrase"
def test_command_without_phrase_is_never_accepted(self) -> None:
with pytest.raises(CtlError) as caught:
_ctl_context.require_phrase("status", "")
assert _code(caught) == "wrong_phrase"
class TestIntArg:
"""Bounded integer arguments."""
def test_valid(self) -> None:
assert _ctl_context.int_arg({"n": 5}, "n", 1, 10) == 5
@pytest.mark.parametrize("value", [True, "5", 5.0, None])
def test_wrong_type(self, value: object) -> None:
with pytest.raises(CtlError, match="must be an integer"):
_ctl_context.int_arg({"n": value}, "n", 1, 10)
def test_missing(self) -> None:
with pytest.raises(CtlError, match="must be an integer"):
_ctl_context.int_arg({}, "n", 1, 10)
@pytest.mark.parametrize("value", [0, 11])
def test_out_of_range(self, value: int) -> None:
with pytest.raises(CtlError, match="between 1 and 10"):
_ctl_context.int_arg({"n": value}, "n", 1, 10)
class TestStrArg:
"""Non-empty string arguments."""
def test_valid(self) -> None:
assert _ctl_context.str_arg({"s": "abc"}, "s") == "abc"
@pytest.mark.parametrize("value", ["", 5, None])
def test_invalid(self, value: object) -> None:
with pytest.raises(CtlError, match="non-empty string"):
_ctl_context.str_arg({"s": value}, "s")
def _locks(*, block: bool, manual: bool) -> AbstractContextManager[object]:
"""Pin both lock predicates."""
stack = ExitStack()
stack.enter_context(
patch.object(_ctl_context, "is_total_block_active", return_value=block)
)
stack.enter_context(
patch.object(_ctl_context, "is_manual_pick_locked", return_value=manual)
)
return stack
class TestCheckLocks:
"""The socket honours the same locks as the CLI."""
def test_nothing_locked(self) -> None:
with _locks(block=False, manual=False):
_ctl_context.check_locks("block-gaming")
def test_total_block_refuses(self) -> None:
with _locks(block=True, manual=False), pytest.raises(CtlError) as caught:
_ctl_context.check_locks("gaming-reset")
assert _code(caught) == "locked"
def test_total_block_exempt_command(self) -> None:
with _locks(block=True, manual=False):
_ctl_context.check_locks("gaming-unblock")
def test_manual_pick_lock_refuses(self) -> None:
with _locks(block=False, manual=True), pytest.raises(CtlError) as caught:
_ctl_context.check_locks("unblock")
assert _code(caught) == "locked"
def test_manual_pick_exempt_command(self) -> None:
with _locks(block=False, manual=True):
_ctl_context.check_locks("gaming-reset")