mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 21:27:03 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
140 lines
4.6 KiB
Python
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")
|