mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 22:27:12 +01:00
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
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
150 lines
5.7 KiB
Python
150 lines
5.7 KiB
Python
"""Tests for _queue_state and _queue_wait: making a blocked run visible.
|
|
|
|
On 2026-08-30 the locker decided to lock at 09:01, sat in gatelock's queue
|
|
behind ``wake_alarm`` until 11:59, and nothing anywhere said so -- the unit
|
|
was ``active``, no window was up, and the status page read a bare "Lock would
|
|
fire". These cover the file that closes that blind spot.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from gatelock import QueueResult
|
|
|
|
from screen_locker._queue_state import (
|
|
clear_queue_wait,
|
|
publish_queue_wait,
|
|
read_queue_wait,
|
|
)
|
|
from screen_locker._queue_wait import wait_for_screen
|
|
|
|
_PKG = "screen_locker._queue_state"
|
|
|
|
|
|
class TestPublishAndRead:
|
|
"""A blocked run cannot answer for itself, so it writes the answer down."""
|
|
|
|
def test_a_published_wait_reads_back(self) -> None:
|
|
"""The round trip a status page depends on."""
|
|
publish_queue_wait(("wake_alarm",), 10716.0)
|
|
record = read_queue_wait()
|
|
assert record is not None
|
|
assert record["blocked_by"] == ["wake_alarm"]
|
|
assert record["elapsed_seconds"] == 10716.0
|
|
|
|
def test_no_file_means_not_queued(self) -> None:
|
|
"""Absence is the normal case and must not read as a failure."""
|
|
clear_queue_wait()
|
|
assert read_queue_wait() is None
|
|
|
|
def test_an_empty_blocker_set_clears_the_wait(self) -> None:
|
|
"""gatelock signals "clear" with an empty tuple."""
|
|
publish_queue_wait(("wake_alarm",), 5.0)
|
|
publish_queue_wait((), 10.0)
|
|
assert read_queue_wait() is None
|
|
|
|
def test_clearing_twice_is_harmless(self) -> None:
|
|
"""The locker clears on every exit path, armed or not."""
|
|
clear_queue_wait()
|
|
clear_queue_wait()
|
|
assert read_queue_wait() is None
|
|
|
|
|
|
class TestUnreadableState:
|
|
"""Every failure says why: a silent None is a lie about enforcement."""
|
|
|
|
def test_an_unwritable_path_warns(self, caplog: MagicMock) -> None:
|
|
"""A status page that cannot learn of a wait must say so."""
|
|
with patch.object(Path, "write_text", side_effect=OSError("readonly")):
|
|
publish_queue_wait(("wake_alarm",), 1.0)
|
|
assert "Could not publish the queue wait" in caplog.text
|
|
|
|
def test_an_unremovable_path_warns(self, caplog: MagicMock) -> None:
|
|
"""Otherwise the page keeps claiming a wait that ended."""
|
|
with patch.object(Path, "unlink", side_effect=OSError("busy")):
|
|
clear_queue_wait()
|
|
assert "Could not clear the queue wait" in caplog.text
|
|
|
|
def test_an_unreadable_file_warns(self, caplog: MagicMock) -> None:
|
|
""" "Not queued" is a claim, so a failed read cannot make it quietly."""
|
|
publish_queue_wait(("wake_alarm",), 1.0)
|
|
with patch.object(Path, "read_text", side_effect=OSError("eio")):
|
|
assert read_queue_wait() is None
|
|
assert "Could not read the queue wait" in caplog.text
|
|
|
|
def test_unparsable_json_warns(self, caplog: MagicMock, tmp_path: Path) -> None:
|
|
"""A truncated write must not crash the status server."""
|
|
target = tmp_path / "queue_state.json"
|
|
target.write_text("{not json", encoding="utf-8")
|
|
with patch(f"{_PKG}.QUEUE_STATE_FILE", target):
|
|
assert read_queue_wait() is None
|
|
assert "unparsable" in caplog.text
|
|
|
|
def test_a_non_object_payload_warns(
|
|
self, caplog: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
"""Valid JSON is not automatically the right shape."""
|
|
target = tmp_path / "queue_state.json"
|
|
target.write_text(json.dumps([1, 2, 3]), encoding="utf-8")
|
|
with patch(f"{_PKG}.QUEUE_STATE_FILE", target):
|
|
assert read_queue_wait() is None
|
|
assert "not an object" in caplog.text
|
|
|
|
|
|
class TestWaitForScreen:
|
|
"""The wait itself: published while it happens, recorded when it ends."""
|
|
|
|
@staticmethod
|
|
def _wait(result: QueueResult) -> MagicMock:
|
|
"""Run wait_for_screen with a canned queue result.
|
|
|
|
Args:
|
|
result: What gatelock's wait_for_turn should return.
|
|
|
|
Returns:
|
|
The patched ``record_run_aborted`` mock.
|
|
"""
|
|
with (
|
|
patch(
|
|
"screen_locker._queue_wait.wait_for_turn", return_value=result
|
|
) as turn,
|
|
patch("screen_locker._queue_wait.record_run_aborted") as aborted,
|
|
):
|
|
wait_for_screen(MagicMock())
|
|
# The publisher must be handed to gatelock, or the wait stays invisible
|
|
# for its whole duration -- only its end would ever be recorded.
|
|
assert turn.call_args.kwargs["on_state"] is publish_queue_wait
|
|
return aborted
|
|
|
|
def test_an_unblocked_run_records_nothing(self) -> None:
|
|
"""The common case must not add a row to every single locker run."""
|
|
aborted = self._wait(
|
|
QueueResult(waited_seconds=0.0, blocked_by=(), timed_out=False)
|
|
)
|
|
aborted.assert_not_called()
|
|
|
|
def test_a_real_wait_is_recorded_with_its_length(self) -> None:
|
|
"""The 2h58m gap between "enforced" and a window on screen."""
|
|
aborted = self._wait(
|
|
QueueResult(
|
|
waited_seconds=10716.0, blocked_by=("wake_alarm",), timed_out=False
|
|
)
|
|
)
|
|
reason, detail = aborted.call_args.args
|
|
assert reason == "queued_behind_holder"
|
|
assert "10716s" in detail
|
|
assert "wake_alarm" in detail
|
|
assert "deadline hit" not in detail
|
|
|
|
def test_hitting_the_deadline_is_named(self) -> None:
|
|
"""Arming anyway is a different event from the holder standing down."""
|
|
aborted = self._wait(
|
|
QueueResult(
|
|
waited_seconds=21600.0, blocked_by=("wake_alarm",), timed_out=True
|
|
)
|
|
)
|
|
assert "deadline hit" in aborted.call_args.args[1]
|