screen-locker/screen_locker/tests/test_temperature.py
Krzysztof kuhy Rudnicki 1d5965b9a7
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
Close the four gaps that let a decided lock never reach the screen
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 15:24:45 +02:00

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()