mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +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>
167 lines
6.7 KiB
Python
167 lines
6.7 KiB
Python
"""Tests for wttr.in temperature fetching used by the heat-skip feature."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import http.client
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
import urllib.error
|
|
|
|
from screen_locker._temperature import (
|
|
HARD_TIMEOUT_SECONDS,
|
|
TemperatureCheck,
|
|
fetch_current_temp_with_status,
|
|
submit_background,
|
|
)
|
|
|
|
|
|
def _mock_urlopen_returning(body: bytes) -> MagicMock:
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = body
|
|
mock_urlopen = MagicMock()
|
|
mock_urlopen.return_value.__enter__.return_value = mock_response
|
|
return mock_urlopen
|
|
|
|
|
|
class TestFetchCurrentTempWithStatus:
|
|
def test_returns_parsed_temperature_on_success(self) -> None:
|
|
body = b'{"current_condition": [{"temp_C": "27"}]}'
|
|
with patch(
|
|
"screen_locker._temperature.urllib.request.urlopen",
|
|
_mock_urlopen_returning(body),
|
|
):
|
|
result = fetch_current_temp_with_status("Warsaw")
|
|
assert result == TemperatureCheck(temp_celsius=27.0, timed_out=False)
|
|
|
|
def test_returns_none_on_url_error(self) -> None:
|
|
with patch(
|
|
"screen_locker._temperature.urllib.request.urlopen",
|
|
side_effect=urllib.error.URLError("unreachable"),
|
|
):
|
|
result = fetch_current_temp_with_status("Warsaw")
|
|
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
|
|
|
|
def test_returns_none_on_timeout_error(self) -> None:
|
|
with patch(
|
|
"screen_locker._temperature.urllib.request.urlopen",
|
|
side_effect=TimeoutError("timed out"),
|
|
):
|
|
result = fetch_current_temp_with_status("Warsaw")
|
|
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
|
|
|
|
def test_returns_none_on_os_error(self) -> None:
|
|
with patch(
|
|
"screen_locker._temperature.urllib.request.urlopen",
|
|
side_effect=OSError("network unreachable"),
|
|
):
|
|
result = fetch_current_temp_with_status("Warsaw")
|
|
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
|
|
|
|
def test_returns_none_on_missing_key(self) -> None:
|
|
body = b'{"unexpected": "shape"}'
|
|
with patch(
|
|
"screen_locker._temperature.urllib.request.urlopen",
|
|
_mock_urlopen_returning(body),
|
|
):
|
|
result = fetch_current_temp_with_status("Warsaw")
|
|
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
|
|
|
|
def test_returns_none_on_empty_current_condition(self) -> None:
|
|
body = b'{"current_condition": []}'
|
|
with patch(
|
|
"screen_locker._temperature.urllib.request.urlopen",
|
|
_mock_urlopen_returning(body),
|
|
):
|
|
result = fetch_current_temp_with_status("Warsaw")
|
|
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
|
|
|
|
def test_returns_none_on_non_numeric_temp(self) -> None:
|
|
body = b'{"current_condition": [{"temp_C": "not-a-number"}]}'
|
|
with patch(
|
|
"screen_locker._temperature.urllib.request.urlopen",
|
|
_mock_urlopen_returning(body),
|
|
):
|
|
result = fetch_current_temp_with_status("Warsaw")
|
|
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
|
|
|
|
def test_returns_none_on_invalid_json(self) -> None:
|
|
with patch(
|
|
"screen_locker._temperature.urllib.request.urlopen",
|
|
_mock_urlopen_returning(b"not json{{{"),
|
|
):
|
|
result = fetch_current_temp_with_status("Warsaw")
|
|
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
|
|
|
|
def test_reports_timed_out_when_fetch_exceeds_hard_timeout(self) -> None:
|
|
def _slow_fetch(_city: str) -> float | None:
|
|
time.sleep(0.3)
|
|
return 27.0
|
|
|
|
with patch(
|
|
"screen_locker._temperature._fetch_current_temp_celsius_unbounded",
|
|
side_effect=_slow_fetch,
|
|
):
|
|
result = fetch_current_temp_with_status("Warsaw", hard_timeout=0.05)
|
|
assert result == TemperatureCheck(temp_celsius=None, timed_out=True)
|
|
|
|
|
|
class TestNothingEscapesTheFetch:
|
|
"""The heat check must never be able to kill the locker.
|
|
|
|
`http.client.HTTPException` inherits Exception, not OSError, so it used to
|
|
escape the fetch, escape `future.result()` (which catches only the
|
|
timeout), and take the process down *before* the "enforced" record was
|
|
written -- a lock that never happened and left no line anywhere.
|
|
"""
|
|
|
|
def test_an_http_exception_is_a_failed_check_not_a_crash(self) -> None:
|
|
"""The specific gap: IncompleteRead is not an OSError."""
|
|
with patch(
|
|
"screen_locker._temperature.urllib.request.urlopen",
|
|
side_effect=http.client.IncompleteRead(b"partial"),
|
|
):
|
|
result = fetch_current_temp_with_status("Warsaw")
|
|
assert result == TemperatureCheck(temp_celsius=None, timed_out=False)
|
|
|
|
def test_a_worker_that_raises_still_resolves_the_future(self) -> None:
|
|
"""A future nobody resolves is a hang, not a failure."""
|
|
with patch(
|
|
"screen_locker._temperature._fetch_current_temp_celsius_unbounded",
|
|
side_effect=http.client.BadStatusLine("garbage"),
|
|
):
|
|
result = fetch_current_temp_with_status("Warsaw")
|
|
assert result.temp_celsius is None
|
|
assert result.timed_out is False
|
|
|
|
def test_the_whole_check_is_bounded_by_the_hard_timeout(self) -> None:
|
|
"""The user's requirement: at most 5s, then lock regardless."""
|
|
with patch(
|
|
"screen_locker._temperature._fetch_current_temp_celsius_unbounded",
|
|
side_effect=lambda _city: time.sleep(30),
|
|
):
|
|
started = time.monotonic()
|
|
result = fetch_current_temp_with_status("Warsaw")
|
|
elapsed = time.monotonic() - started
|
|
assert result.timed_out is True
|
|
assert elapsed <= HARD_TIMEOUT_SECONDS + 0.5
|
|
|
|
|
|
class TestSubmitBackground:
|
|
"""The daemon thread that replaced ThreadPoolExecutor."""
|
|
|
|
def test_it_resolves_with_the_callables_result(self) -> None:
|
|
"""The ordinary path every caller depends on."""
|
|
assert submit_background(lambda: 42).result(timeout=5) == 42
|
|
|
|
def test_the_thread_is_a_daemon(self) -> None:
|
|
"""Non-daemon workers held the process open past sys.exit(0).
|
|
|
|
`concurrent.futures` joins its workers in an interpreter-exit hook, so
|
|
a stuck DNS lookup outlived the hard timeout it was supposed to be
|
|
bounded by. A daemon thread is abandoned at exit instead.
|
|
"""
|
|
with patch("screen_locker._temperature.threading.Thread") as thread_cls:
|
|
submit_background(lambda: 1)
|
|
assert thread_cls.call_args.kwargs["daemon"] is True
|
|
thread_cls.return_value.start.assert_called_once()
|