mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Fix mechanical pylint findings in tests
Booleaness asserts, hoisted imports, fixture(name=...) instead of shadowing arguments, explicit encodings, snake_case names, comparison of bound methods, dead protected-access disables. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
This commit is contained in:
parent
f3875302d7
commit
4a36b6b264
@ -45,8 +45,8 @@ def mock_tk() -> Generator[MagicMock]:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sys_exit() -> Generator[MagicMock]:
|
||||
@pytest.fixture(name="mock_sys_exit")
|
||||
def mock_sys_exit_fixture() -> Generator[MagicMock]:
|
||||
"""Mock sys.exit to prevent test termination."""
|
||||
with patch("screen_locker.screen_lock.sys.exit") as mock:
|
||||
yield mock
|
||||
|
||||
@ -24,7 +24,12 @@ import sys
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import freedays._api
|
||||
import pytest
|
||||
import sickdays
|
||||
import sickdays.gui
|
||||
|
||||
import screen_locker._night_lockdown
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@ -44,7 +49,6 @@ def _no_free_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
|
||||
chain in every test here -- it is the very first check in the chain --
|
||||
and the failures would name early-bird and sick-day logic instead.
|
||||
"""
|
||||
import freedays._api
|
||||
|
||||
monkeypatch.setattr(
|
||||
freedays._api,
|
||||
@ -64,8 +68,6 @@ def _no_sick_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
|
||||
developer's real today sick. ``sync_quietly`` is stubbed so the button's
|
||||
background push never reaches Firebase.
|
||||
"""
|
||||
import sickdays
|
||||
import sickdays.gui # so its modules are in sys.modules below
|
||||
|
||||
pool = sickdays.Paths.under(tmp_path / "sickdays")
|
||||
# Every sickdays module that bound ``resolve_paths`` by name: the readers
|
||||
@ -193,7 +195,6 @@ def _no_real_night_lockdown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) ->
|
||||
this every enforce-path test would skip the lock on any evening after the
|
||||
real lockdown fired -- and pass in the morning.
|
||||
"""
|
||||
import screen_locker._night_lockdown
|
||||
|
||||
monkeypatch.setattr(
|
||||
screen_locker._night_lockdown,
|
||||
|
||||
@ -19,6 +19,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker import _sync_client
|
||||
from screen_locker._degraded_sources import clear_degraded_sources
|
||||
from screen_locker._earned import flat_earners
|
||||
from screen_locker.tests._gatelock_fixtures import (
|
||||
@ -37,7 +38,7 @@ from screen_locker.tests._locker_factories import (
|
||||
)
|
||||
from screen_locker.tests._opt_in_fixtures import (
|
||||
_mock_sys_exit,
|
||||
mock_sys_exit,
|
||||
mock_sys_exit_fixture,
|
||||
mock_tk,
|
||||
temp_log_file,
|
||||
)
|
||||
@ -61,7 +62,7 @@ __all__ = [
|
||||
"create_locker_early_bird",
|
||||
"create_locker_relaxed_day",
|
||||
"dual_output",
|
||||
"mock_sys_exit",
|
||||
"mock_sys_exit_fixture",
|
||||
"mock_tk",
|
||||
"temp_log_file",
|
||||
]
|
||||
@ -153,7 +154,7 @@ def _block_real_network() -> Iterator[None]:
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_degraded_sources() -> Iterator[None]:
|
||||
def _clear_degraded_sources() -> None:
|
||||
"""Reset the process-scoped degraded-backend registry around every test.
|
||||
|
||||
``_degraded_sources._degraded`` is a module-level list, so a test that
|
||||
@ -162,7 +163,6 @@ def _clear_degraded_sources() -> Iterator[None]:
|
||||
the local venvs do not) exposes it -- it failed on push and nowhere else.
|
||||
"""
|
||||
clear_degraded_sources()
|
||||
return
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -245,6 +245,5 @@ def _no_real_firebase_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""
|
||||
# Patch _sync_client's binding: crdt_sync's own CONFIG_FILE is bound to
|
||||
# the real home at import, which _isolate_home cannot redirect.
|
||||
from screen_locker import _sync_client
|
||||
|
||||
monkeypatch.setattr(_sync_client, "CONFIG_FILE", Path("/nonexistent/firebase.json"))
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for ADB commands, phone connection, and database operations."""
|
||||
# pylint: disable=protected-access,unused-argument
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for ADB commands, phone connection, and database operations."""
|
||||
# pylint: disable=protected-access,unused-argument
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@ Before this, both fell through to building the demo lock screen. Split from
|
||||
``test_cli.py`` for the 250-line cap. ``sys.exit`` is a no-op suite-wide (see
|
||||
conftest), so each test installs one that really raises ``SystemExit``.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -47,7 +46,7 @@ def test_help_prints_usage_and_exits_zero(
|
||||
"""Help goes to stdout and the process exits 0 before any UI."""
|
||||
with pytest.raises(SystemExit) as exited:
|
||||
_main(["--production", flag])
|
||||
assert exited.value.code == 0
|
||||
assert not exited.value.code
|
||||
out = capsys.readouterr().out
|
||||
assert out.startswith("usage: python3 -m screen_locker.screen_lock")
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ import freedays
|
||||
import sickdays
|
||||
|
||||
from screen_locker._compliance_state import explain_lock_decision
|
||||
from screen_locker._log_mixin import write_signed_entry
|
||||
from screen_locker.tests._sick_helpers import mark_sick
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -176,7 +177,6 @@ class TestExplainLockDecision:
|
||||
even after the user already dismissed today's prompt, which is
|
||||
misleading — the popup is not about to be shown again.
|
||||
"""
|
||||
from screen_locker._log_mixin import write_signed_entry
|
||||
|
||||
files = self._files(tmp_path)
|
||||
write_signed_entry(files["log_file"], _today(), {"type": "relaxed_day_skip"})
|
||||
|
||||
@ -50,7 +50,7 @@ class TestFindSiblingRefreshToken:
|
||||
expires_at="2026-08-24T12:00:00+00:00",
|
||||
)
|
||||
|
||||
assert find_sibling_refresh_tokens(tmp_path, skip="screen_locker") == []
|
||||
assert not find_sibling_refresh_tokens(tmp_path, skip="screen_locker")
|
||||
|
||||
def test_skips_unreadable_and_malformed_caches(self, tmp_path: Path) -> None:
|
||||
"""A corrupt sibling must not abort the search over the healthy ones."""
|
||||
@ -69,7 +69,7 @@ class TestFindSiblingRefreshToken:
|
||||
|
||||
def test_reports_nothing_when_no_sibling_has_one(self, tmp_path: Path) -> None:
|
||||
"""No source is a real outcome; the caller must hear it, not guess."""
|
||||
assert find_sibling_refresh_tokens(tmp_path, skip="screen_locker") == []
|
||||
assert not find_sibling_refresh_tokens(tmp_path, skip="screen_locker")
|
||||
|
||||
def test_missing_config_root_is_not_an_error(self, tmp_path: Path) -> None:
|
||||
"""A machine with no ~/.config yet has no donor -- and must not crash.
|
||||
@ -78,9 +78,7 @@ class TestFindSiblingRefreshToken:
|
||||
would take the screen for a reason that has nothing to do with
|
||||
working out.
|
||||
"""
|
||||
assert (
|
||||
find_sibling_refresh_tokens(tmp_path / "nope", skip="screen_locker") == []
|
||||
)
|
||||
assert not find_sibling_refresh_tokens(tmp_path / "nope", skip="screen_locker")
|
||||
|
||||
def test_cache_without_a_usable_token_is_skipped(self, tmp_path: Path) -> None:
|
||||
"""Readable JSON is not the same as a usable credential.
|
||||
|
||||
@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker import _sync_client
|
||||
from screen_locker._credential_recovery import recover_session
|
||||
from screen_locker.tests._credential_fixtures import write_cache as _write_cache
|
||||
|
||||
@ -94,7 +95,6 @@ class TestTryRecoverFirebaseSession:
|
||||
instead of clobbering the machine's live credential -- the exact
|
||||
accident this suite caused on 2026-08-27.
|
||||
"""
|
||||
from screen_locker import _sync_client
|
||||
|
||||
_write_cache(
|
||||
tmp_path / ".config" / "diet_guard" / "firebase_auth.json",
|
||||
|
||||
@ -4,7 +4,6 @@ The autouse ``_no_desktop_notifications`` fixture stubs
|
||||
``notify_workout_credit`` for the whole suite; the real one is captured here
|
||||
at import time, before any fixture runs.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -23,7 +22,7 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
_REAL_NOTIFY = _credit_notify.notify_workout_credit
|
||||
_real_notify = _credit_notify.notify_workout_credit
|
||||
_FRIDAY = "2026-10-09" # Thu-Sun: tonight is the second config field
|
||||
_MOD = "screen_locker._credit_notify"
|
||||
|
||||
@ -150,7 +149,7 @@ def test_notify_workout_credit_sends_the_composed_body() -> None:
|
||||
patch(f"{_MOD}.gaming_minutes_today", return_value=60),
|
||||
patch(f"{_MOD}.send_notification", return_value=True) as send,
|
||||
):
|
||||
assert _REAL_NOTIFY((0, 1200, 0), (0, 1260, 0), None)
|
||||
assert _real_notify((0, 1200, 0), (0, 1260, 0), None)
|
||||
send.assert_called_once_with(
|
||||
_credit_notify.TITLE, "Shutdown 21:00 (+1h) \N{MIDDLE DOT} gaming 1.0h"
|
||||
)
|
||||
|
||||
@ -4,7 +4,6 @@ The two StrongLifts ingestion paths of ONE session used to earn +2h and then
|
||||
+1h (2026-10-09). These tests drive the real shutdown arithmetic over a fake
|
||||
config so the minute figures, not just the branch taken, are pinned.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -154,7 +153,7 @@ class TestSlotCreditMinutes:
|
||||
with caplog.at_level(logging.WARNING, logger="screen_locker._workout_credit"):
|
||||
result, notify = _credit(locker, [_entry("pc_workout_verified")])
|
||||
assert result.already_counted_today is True
|
||||
assert result.extra_bonus_delta == 0
|
||||
assert not result.extra_bonus_delta
|
||||
assert locker._read_shutdown_config() == _START # untouched
|
||||
notify.assert_not_called()
|
||||
assert any(
|
||||
|
||||
@ -163,7 +163,7 @@ class TestReadDecisions:
|
||||
) -> None:
|
||||
"""No history yet is normal, but is still stated out loud."""
|
||||
with caplog.at_level("WARNING"):
|
||||
assert read_decisions(log_file=tmp_path / "nope.jsonl") == []
|
||||
assert not read_decisions(log_file=tmp_path / "nope.jsonl")
|
||||
assert any("No decision log" in r.message for r in caplog.records)
|
||||
|
||||
def test_unreadable_file_is_reported(
|
||||
@ -176,7 +176,7 @@ class TestReadDecisions:
|
||||
patch.object(Path, "read_text", side_effect=OSError("locked")),
|
||||
caplog.at_level("WARNING"),
|
||||
):
|
||||
assert read_decisions(log_file=target) == []
|
||||
assert not read_decisions(log_file=target)
|
||||
assert any("Could not read" in r.message for r in caplog.records)
|
||||
|
||||
def test_corrupt_lines_are_skipped_not_fatal(
|
||||
|
||||
@ -75,4 +75,4 @@ class TestReasonsExtra:
|
||||
"workout_logged_today": lambda: False,
|
||||
},
|
||||
)
|
||||
assert extra == {}
|
||||
assert not extra
|
||||
|
||||
@ -45,8 +45,8 @@ _LEDGERED = pytest.mark.parametrize(
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key(tmp_path: Path) -> Iterator[Path]:
|
||||
@pytest.fixture(name="key")
|
||||
def key_fixture(tmp_path: Path) -> Iterator[Path]:
|
||||
with signing_key(tmp_path) as path:
|
||||
yield path
|
||||
|
||||
|
||||
@ -77,7 +77,7 @@ class TestProcessWeekTransition:
|
||||
year, week, _ = now.isocalendar()
|
||||
f = tmp_path / "state.json"
|
||||
f.write_text(json.dumps({"last_processed_iso_week": f"{year}-W{week:02d}"}))
|
||||
assert process_week_transition(tmp_path / "log.json", f) == []
|
||||
assert not process_week_transition(tmp_path / "log.json", f)
|
||||
|
||||
@staticmethod
|
||||
def _current_week_str() -> str:
|
||||
@ -175,7 +175,7 @@ class TestProcessWeekTransition:
|
||||
rewards = process_week_transition(tmp_path / "log.json", f)
|
||||
|
||||
assert any("Streak reset" in r for r in rewards)
|
||||
assert json.loads(f.read_text())["consecutive_5plus_weeks"] == 0
|
||||
assert not json.loads(f.read_text())["consecutive_5plus_weeks"]
|
||||
|
||||
def test_no_reset_message_when_streak_was_zero(self, tmp_path: Path) -> None:
|
||||
"""Zero streak + < 5 workouts: no reset message (line 101 branch False)."""
|
||||
@ -233,7 +233,7 @@ class TestCurrentStreak:
|
||||
|
||||
def test_returns_zero_when_file_missing(self, tmp_path: Path) -> None:
|
||||
"""Missing file falls through _load_state → default 0."""
|
||||
assert current_streak(tmp_path / "nonexistent.json") == 0
|
||||
assert not current_streak(tmp_path / "nonexistent.json")
|
||||
|
||||
def test_returns_stored_streak(self, tmp_path: Path) -> None:
|
||||
"""Stored streak value is returned correctly."""
|
||||
|
||||
@ -65,14 +65,14 @@ class TestBonusDroppedAtW42:
|
||||
# The message the transition produced is kept for the status window,
|
||||
# for the week it was earned in only.
|
||||
assert week_rewards(f, today=w42) == tuple(rewards)
|
||||
assert week_rewards(f, today=datetime(2026, 10, 19, 12, 0, tzinfo=UTC)) == ()
|
||||
assert not week_rewards(f, today=datetime(2026, 10, 19, 12, 0, tzinfo=UTC))
|
||||
|
||||
def test_no_stored_rewards_reads_as_empty(self, tmp_path: Path) -> None:
|
||||
"""Missing file, or a state without the key, has nothing to show."""
|
||||
assert week_rewards(tmp_path / "absent.json") == ()
|
||||
assert not week_rewards(tmp_path / "absent.json")
|
||||
f = tmp_path / "state.json"
|
||||
f.write_text(json.dumps({"consecutive_5plus_weeks": 2}))
|
||||
assert week_rewards(f) == ()
|
||||
assert not week_rewards(f)
|
||||
|
||||
|
||||
class TestHasExtendedEarlyBird:
|
||||
|
||||
@ -149,7 +149,7 @@ class TestApplyCountedBonus:
|
||||
[{"date": "2000-01-01", "units": 9}, {"date": None}, "junk", {"units": "x"}],
|
||||
)
|
||||
def test_a_stale_or_broken_stamp_is_zero(self, stamp: object) -> None:
|
||||
assert applied_units({"blocks_bonus_units": stamp}, BLOCKS, today_str()) == 0
|
||||
assert not applied_units({"blocks_bonus_units": stamp}, BLOCKS, today_str())
|
||||
|
||||
def test_failed_write_keeps_the_stamp(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
@ -158,7 +158,7 @@ class TestApplyCountedBonus:
|
||||
write_ledger(BLOCKS, [_block(1)])
|
||||
adjust = MagicMock(return_value=False)
|
||||
assert not apply_counted_bonus(state, adjust, BLOCKS, today_str())
|
||||
assert state == {}
|
||||
assert not state
|
||||
assert "Blocks bonus: failed to write" in caplog.text
|
||||
|
||||
def test_unknown_earns_nothing_and_says_so(
|
||||
|
||||
@ -10,6 +10,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker import _constants, _instance
|
||||
from screen_locker.screen_lock import _assert_not_under_pytest
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
@ -75,11 +76,8 @@ class TestScreenLockerInit:
|
||||
This is the actual fix for "unlocked, then locked again seconds
|
||||
later" -- see the 2026-08-21 incident notes in screen_lock.py.
|
||||
"""
|
||||
from screen_locker import _instance
|
||||
from screen_locker._constants import INSTANCE_LOCK_FILE
|
||||
|
||||
mock_sys_exit.side_effect = SystemExit(0)
|
||||
held = _instance.acquire(INSTANCE_LOCK_FILE)
|
||||
held = _instance.acquire(_constants.INSTANCE_LOCK_FILE)
|
||||
assert held is not None
|
||||
try:
|
||||
with pytest.raises(SystemExit):
|
||||
|
||||
@ -26,7 +26,7 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
# Captured before conftest pins it to noon for every test.
|
||||
_REAL_NOW = _late_credit._now
|
||||
_real_now = _late_credit._now
|
||||
|
||||
_DAY = date(2026, 10, 17) # a Saturday: the Thu-Sun band is tonight's
|
||||
_ISO = _DAY.isoformat()
|
||||
@ -133,7 +133,7 @@ class TestLateCredit:
|
||||
|
||||
def test_now_is_the_wall_clock(self) -> None:
|
||||
with patch.object(_late_credit.time, "time", return_value=123.0):
|
||||
assert _REAL_NOW() == 123.0
|
||||
assert _real_now() == 123.0
|
||||
|
||||
def test_a_late_credit_clears_the_lift(self) -> None:
|
||||
"""Past the shutdown the floor is spent: nothing is left to absorb."""
|
||||
|
||||
@ -22,8 +22,8 @@ from screen_locker import _earned, _gate_off
|
||||
SAT = date(2026, 10, 24)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pools(monkeypatch: pytest.MonkeyPatch) -> dict[str, bool]:
|
||||
@pytest.fixture(name="pools")
|
||||
def pools_fixture(monkeypatch: pytest.MonkeyPatch) -> dict[str, bool]:
|
||||
"""Fake the free-day and sick-day pools; flip the returned flags."""
|
||||
flags = {"free": False, "sick_off": False}
|
||||
monkeypatch.setattr(freedays, "is_free_day", lambda _day=None: flags["free"])
|
||||
|
||||
@ -38,8 +38,8 @@ LOCAL_DAY = "2026-09-13"
|
||||
UTC_DAY = "2026-09-12"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def warsaw_after_midnight(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
@pytest.fixture(name="warsaw_after_midnight")
|
||||
def warsaw_after_midnight_fixture(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
"""Pin TZ to Europe/Warsaw and the clock to 00:02:35 local on the 13th."""
|
||||
monkeypatch.setenv("TZ", "Europe/Warsaw")
|
||||
time.tzset()
|
||||
|
||||
@ -50,7 +50,7 @@ class TestLoadWorkoutLog:
|
||||
|
||||
def test_missing_file_returns_empty(self, tmp_path: Path) -> None:
|
||||
"""Non-existent file → {}."""
|
||||
assert load_workout_log(tmp_path / "nope.json") == {}
|
||||
assert not load_workout_log(tmp_path / "nope.json")
|
||||
|
||||
def test_legacy_dict_value_is_wrapped_in_a_list(self, tmp_path: Path) -> None:
|
||||
"""The old one-entry-per-day shape reads as a one-element list."""
|
||||
|
||||
@ -95,8 +95,8 @@ def test_an_evidence_less_stub_never_consumes_budget(tmp_path: Path) -> None:
|
||||
)
|
||||
log_file = _log_from([stub], tmp_path / "log.json")
|
||||
|
||||
assert count_in_window(log_file, 7, today=today) == 0
|
||||
assert count_in_window(log_file, 30, today=today) == 0
|
||||
assert not count_in_window(log_file, 7, today=today)
|
||||
assert not count_in_window(log_file, 30, today=today)
|
||||
|
||||
|
||||
def _planned_cases() -> list[tuple[str, dict[str, Any]]]:
|
||||
|
||||
@ -85,7 +85,7 @@ def test_logs_a_valid_workout(tmp_path: Path) -> None:
|
||||
with patch(
|
||||
"screen_locker._log_mixin.compute_entry_hmac", return_value="test-signature"
|
||||
):
|
||||
assert run_manual_log(log_file, _args()) == 0
|
||||
assert not run_manual_log(log_file, _args())
|
||||
|
||||
(entry,) = _entries(log_file)
|
||||
data = entry["workout_data"]
|
||||
@ -142,7 +142,7 @@ def test_rerunning_the_same_workout_logs_it_once(tmp_path: Path) -> None:
|
||||
"""The record id is derived from (date, start), so a repeat is a no-op."""
|
||||
log_file = tmp_path / "log.json"
|
||||
|
||||
assert run_manual_log(log_file, _args()) == 0
|
||||
assert not run_manual_log(log_file, _args())
|
||||
assert run_manual_log(log_file, _args()) == 1
|
||||
assert len(_entries(log_file)) == 1
|
||||
|
||||
@ -151,8 +151,8 @@ def test_the_rolling_budget_still_caps_headless_entries(tmp_path: Path) -> None:
|
||||
"""The third workout in a week is refused, exactly as on the phone."""
|
||||
log_file = tmp_path / "log.json"
|
||||
|
||||
assert run_manual_log(log_file, _args(start="07:00", end="08:30")) == 0
|
||||
assert run_manual_log(log_file, _args(start="10:00", end="11:30")) == 0
|
||||
assert not run_manual_log(log_file, _args(start="07:00", end="08:30"))
|
||||
assert not run_manual_log(log_file, _args(start="10:00", end="11:30"))
|
||||
assert run_manual_log(log_file, _args(start="17:30", end="19:00")) == 1
|
||||
assert len(_entries(log_file)) == 2
|
||||
|
||||
@ -162,7 +162,7 @@ def test_date_defaults_to_today(tmp_path: Path) -> None:
|
||||
log_file = tmp_path / "log.json"
|
||||
args = [a for a in _args() if a not in {"--date", _TODAY}]
|
||||
|
||||
assert run_manual_log(log_file, args) == 0
|
||||
assert not run_manual_log(log_file, args)
|
||||
assert _entries(log_file)
|
||||
|
||||
|
||||
@ -190,5 +190,5 @@ def test_table_tennis_needs_no_activity_name(tmp_path: Path) -> None:
|
||||
"Nittaku",
|
||||
]
|
||||
|
||||
assert run_manual_log(log_file, args) == 0
|
||||
assert not run_manual_log(log_file, args)
|
||||
assert _entries(log_file)[0]["workout_data"]["activity_type"] == "table tennis"
|
||||
|
||||
@ -33,8 +33,8 @@ class Held:
|
||||
push: MagicMock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def held(tmp_path: Path) -> Iterator[Held]:
|
||||
@pytest.fixture(name="held")
|
||||
def held_fixture(tmp_path: Path) -> Iterator[Held]:
|
||||
"""Redirect the pending ledger and stub the publisher: nothing leaves the box."""
|
||||
ledger = tmp_path / "manual_pending.json"
|
||||
with (
|
||||
@ -60,7 +60,7 @@ def test_a_future_date_is_held_not_logged(
|
||||
log_file = tmp_path / "log.json"
|
||||
day = _days_from_today(ahead)
|
||||
|
||||
assert run_manual_log(log_file, _args(date=day)) == 0
|
||||
assert not run_manual_log(log_file, _args(date=day))
|
||||
|
||||
assert _entries(log_file) == []
|
||||
assert list(json.loads(held.ledger.read_text())) == [f"manual:{day}T17:30"]
|
||||
@ -93,8 +93,8 @@ def test_a_future_entry_that_breaks_the_budget_is_refused(
|
||||
"""Scheduling counts the logged credits: a full week cannot take a 3rd."""
|
||||
log_file = tmp_path / "log.json"
|
||||
day = _days_from_today(1)
|
||||
assert run_manual_log(log_file, _args(start="07:00", end="08:30")) == 0
|
||||
assert run_manual_log(log_file, _args(start="10:00", end="11:30")) == 0
|
||||
assert not run_manual_log(log_file, _args(start="07:00", end="08:30"))
|
||||
assert not run_manual_log(log_file, _args(start="10:00", end="11:30"))
|
||||
|
||||
# Today holds 2; tomorrow's 7-day window contains today, so it is full too.
|
||||
assert run_manual_log(log_file, _args(date=day)) == 1
|
||||
@ -106,7 +106,7 @@ def test_scheduling_the_same_entry_twice_is_refused(tmp_path: Path, held: Held)
|
||||
day = _days_from_today(2)
|
||||
log_file = tmp_path / "log.json"
|
||||
|
||||
assert run_manual_log(log_file, _args(date=day)) == 0
|
||||
assert not run_manual_log(log_file, _args(date=day))
|
||||
assert run_manual_log(log_file, _args(date=day)) == 1
|
||||
assert len(json.loads(held.ledger.read_text())) == 1
|
||||
|
||||
@ -116,8 +116,8 @@ def test_a_pending_future_entry_counts_against_today(
|
||||
) -> None:
|
||||
"""Today's entry sees pending future ones: they share a 7-day window."""
|
||||
log_file = tmp_path / "log.json"
|
||||
assert run_manual_log(log_file, _args(date=_days_from_today(1))) == 0
|
||||
assert run_manual_log(log_file, _args(date=_days_from_today(2), start="08:00")) == 0
|
||||
assert not run_manual_log(log_file, _args(date=_days_from_today(1)))
|
||||
assert not run_manual_log(log_file, _args(date=_days_from_today(2), start="08:00"))
|
||||
|
||||
assert run_manual_log(log_file, _args()) == 1
|
||||
assert _entries(log_file) == []
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Wiring of future-dated manual workouts: push, sync pass, poke reset, budget helper."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -120,7 +119,9 @@ class TestSyncPassReadsTheLedger:
|
||||
) as ingest:
|
||||
locker._ingest_due_local_manual_workouts()
|
||||
assert ingest.call_args.args[1] == [(_RID, _PAYLOAD)]
|
||||
assert ingest.call_args.kwargs["on_ingested"] == locker._credit_ingested_workout
|
||||
assert [ingest.call_args.kwargs["on_ingested"]] == [
|
||||
locker._credit_ingested_workout
|
||||
]
|
||||
assert locker.workout_data == {}
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for the pending ledger of future-dated manual workouts held on this PC."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -132,7 +131,7 @@ class TestPendingLedgerRecords:
|
||||
path = _ledger(tmp_path)
|
||||
path.write_text(json.dumps({_RID: {"payload": _PAYLOAD, "hlc": "garbage"}}))
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert pending_ledger_records(path) == {}
|
||||
assert not pending_ledger_records(path)
|
||||
assert "NOT published" in caplog.text
|
||||
|
||||
def test_non_dict_payload_is_skipped(self, tmp_path: Path) -> None:
|
||||
@ -141,7 +140,7 @@ class TestPendingLedgerRecords:
|
||||
raw = json.loads(path.read_text())
|
||||
raw[_RID]["payload"] = "nope"
|
||||
path.write_text(json.dumps(raw))
|
||||
assert pending_ledger_records(path) == {}
|
||||
assert not pending_ledger_records(path)
|
||||
|
||||
|
||||
class TestSettlePending:
|
||||
@ -203,7 +202,7 @@ class TestPendingFutureDates:
|
||||
def test_an_already_logged_id_is_not_pending(self, tmp_path: Path) -> None:
|
||||
log_file = _log_with(tmp_path, _RID, "2026-10-17")
|
||||
found = pending_future_dates(log_file, [(_RID, _PAYLOAD)], today="2026-10-16")
|
||||
assert found == {}
|
||||
assert not found
|
||||
|
||||
def test_defaults_to_the_real_local_day(self, tmp_path: Path) -> None:
|
||||
payload = {**_PAYLOAD, "date": "2999-01-01"}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for pushing the PC's workouts (from log.json) to the sync repo."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -132,7 +131,7 @@ class TestRecordsFromWorkoutLog:
|
||||
log_file,
|
||||
{"2026-07-13": [_entry({"type": "sick_day"}, "2026-07-13T07:00:00+00:00")]},
|
||||
)
|
||||
assert records_from_workout_log(log_file) == {}
|
||||
assert not records_from_workout_log(log_file)
|
||||
|
||||
def test_non_dict_workout_data_is_skipped(self, tmp_path: Path) -> None:
|
||||
"""Non dict workout data is skipped."""
|
||||
@ -145,7 +144,7 @@ class TestRecordsFromWorkoutLog:
|
||||
]
|
||||
},
|
||||
)
|
||||
assert records_from_workout_log(log_file) == {}
|
||||
assert not records_from_workout_log(log_file)
|
||||
|
||||
def test_entry_without_derivable_id_is_skipped(self, tmp_path: Path) -> None:
|
||||
"""A counted type with no id can't be deduped, so it isn't synced."""
|
||||
@ -154,7 +153,7 @@ class TestRecordsFromWorkoutLog:
|
||||
log_file, {"2026-07-13": [_entry(_MANUAL, "2026-07-13T07:00:00+00:00")]}
|
||||
)
|
||||
with patch("screen_locker._manual_push._derive_workout_id", return_value=None):
|
||||
assert records_from_workout_log(log_file) == {}
|
||||
assert not records_from_workout_log(log_file)
|
||||
|
||||
def test_is_deterministic_across_calls(self, tmp_path: Path) -> None:
|
||||
"""Same log → identical records+HLCs, so a re-push is a no-op."""
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
Split out of test_manual_push.py for the 250-line cap; the shared _entry /
|
||||
_write_log helpers stay in that module and are imported here.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -67,7 +66,7 @@ class TestPushPcWorkouts:
|
||||
result = push_pc_workouts(log_file)
|
||||
build.assert_not_called()
|
||||
assert result.pushed is False
|
||||
assert result.record_count == 0
|
||||
assert not result.record_count
|
||||
assert "no counted workouts" in result.reason
|
||||
|
||||
def test_sync_error_reports_why(self, tmp_path: Path) -> None:
|
||||
|
||||
@ -86,7 +86,7 @@ class TestSyncedTwinIsNotReIngested:
|
||||
|
||||
ingested = ingest_manual_records(log_file, [_published_twin()], today=LOCAL_DAY)
|
||||
|
||||
assert ingested == []
|
||||
assert not ingested
|
||||
assert len(json.loads(log_file.read_text())[LOCAL_DAY]) == 1
|
||||
|
||||
def test_ingested_entry_keeps_the_wire_id(self, tmp_path: Path) -> None:
|
||||
|
||||
@ -48,8 +48,8 @@ def _draft(start: str = "18:00") -> ManualWorkoutDraft:
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stubs(tmp_path: Path) -> Iterator[SimpleNamespace]:
|
||||
@pytest.fixture(name="stubs")
|
||||
def stubs_fixture(tmp_path: Path) -> Iterator[SimpleNamespace]:
|
||||
"""Redirect the ledger; stub Firebase pull, the publisher and the identity."""
|
||||
ledger = tmp_path / "pending.json"
|
||||
pulled: list[tuple[str, dict]] = []
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
"""Tests for ingesting synced manual workouts into log.json."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
@ -121,14 +120,13 @@ class TestIngestManualRecords:
|
||||
record = _manual_record(_TT_DRAFT)
|
||||
ingest_manual_records(log_file, [record], today=_DATE)
|
||||
again = ingest_manual_records(log_file, [record], today=_DATE)
|
||||
assert again == []
|
||||
assert not again
|
||||
|
||||
def test_skips_a_non_manual_payload(self, tmp_path: Path) -> None:
|
||||
"""Skips a non manual payload."""
|
||||
log_file = tmp_path / "log.json"
|
||||
assert (
|
||||
assert not (
|
||||
ingest_manual_records(log_file, [("s", {"succeeded": True})], today=_DATE)
|
||||
== []
|
||||
)
|
||||
|
||||
def test_skips_a_record_without_a_date(self, tmp_path: Path) -> None:
|
||||
@ -136,7 +134,7 @@ class TestIngestManualRecords:
|
||||
log_file = tmp_path / "log.json"
|
||||
_, payload = _manual_record(_TT_DRAFT)
|
||||
del payload["date"]
|
||||
assert ingest_manual_records(log_file, [("m", payload)], today=_DATE) == []
|
||||
assert not ingest_manual_records(log_file, [("m", payload)], today=_DATE)
|
||||
|
||||
def test_appends_alongside_an_existing_counted_day(self, tmp_path: Path) -> None:
|
||||
"""A day may hold several workouts: a synced manual is appended next to
|
||||
@ -161,21 +159,21 @@ class TestIngestManualRecords:
|
||||
log_file = tmp_path / "log.json"
|
||||
_, payload = _manual_record(_TT_DRAFT)
|
||||
payload["rpe"] = None # reconstruct_draft -> None
|
||||
assert ingest_manual_records(log_file, [("m", payload)], today=_DATE) == []
|
||||
assert not ingest_manual_records(log_file, [("m", payload)], today=_DATE)
|
||||
|
||||
def test_skips_an_invalid_draft(self, tmp_path: Path) -> None:
|
||||
"""Skips an invalid draft."""
|
||||
log_file = tmp_path / "log.json"
|
||||
_, payload = _manual_record(_TT_DRAFT)
|
||||
payload["location_name"] = " " # fails validate_manual_workout
|
||||
assert ingest_manual_records(log_file, [("m", payload)], today=_DATE) == []
|
||||
assert not ingest_manual_records(log_file, [("m", payload)], today=_DATE)
|
||||
|
||||
def test_skips_when_budget_exhausted(self, tmp_path: Path) -> None:
|
||||
"""Skips when budget exhausted."""
|
||||
log_file = tmp_path / "log.json"
|
||||
record = _manual_record(_TT_DRAFT)
|
||||
with patch.object(_manual_sync, "budget_error_for_date", return_value="full"):
|
||||
assert ingest_manual_records(log_file, [record], today=_DATE) == []
|
||||
assert not ingest_manual_records(log_file, [record], today=_DATE)
|
||||
|
||||
def test_writes_unsigned_entry_when_hmac_key_unavailable(
|
||||
self, tmp_path: Path
|
||||
@ -190,6 +188,5 @@ class TestIngestManualRecords:
|
||||
|
||||
|
||||
def _dt(date: str) -> object:
|
||||
from datetime import datetime
|
||||
|
||||
return datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=UTC)
|
||||
|
||||
@ -44,8 +44,8 @@ class TestDeferral:
|
||||
today=_TODAY,
|
||||
on_ingested=lambda entry, _prior: calls.append(entry),
|
||||
)
|
||||
assert ingested == []
|
||||
assert calls == []
|
||||
assert not ingested
|
||||
assert not calls
|
||||
assert _days(log_file) == {}
|
||||
assert "deferred" in caplog.text
|
||||
|
||||
@ -56,7 +56,7 @@ class TestDeferral:
|
||||
record = _manual_record(_TT_DRAFT, _TOMORROW)
|
||||
calls: list[dict] = []
|
||||
|
||||
assert ingest_manual_records(log_file, [record], today=_TODAY) == []
|
||||
assert not ingest_manual_records(log_file, [record], today=_TODAY)
|
||||
ingested = ingest_manual_records(
|
||||
log_file,
|
||||
[record],
|
||||
@ -74,29 +74,27 @@ class TestDeferral:
|
||||
record_id, payload = _manual_record(_TT_DRAFT, _TODAY)
|
||||
payload["date"] = "2026-13-99"
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert (
|
||||
assert not (
|
||||
ingest_manual_records(
|
||||
tmp_path / "log.json", [(record_id, payload)], today=_TODAY
|
||||
)
|
||||
== []
|
||||
)
|
||||
assert "unparsable date" in caplog.text
|
||||
|
||||
def test_an_empty_batch_touches_nothing(self, tmp_path: Path) -> None:
|
||||
assert ingest_manual_records(tmp_path / "log.json", [], today=_TODAY) == []
|
||||
assert not ingest_manual_records(tmp_path / "log.json", [], today=_TODAY)
|
||||
assert not (tmp_path / "log.json").exists()
|
||||
|
||||
def test_a_non_manual_record_is_ignored(self, tmp_path: Path) -> None:
|
||||
assert (
|
||||
assert not (
|
||||
ingest_manual_records(
|
||||
tmp_path / "log.json", [("s", {"exercises": []})], today=_TODAY
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
def test_defaults_to_the_real_local_day(self, tmp_path: Path) -> None:
|
||||
record = _manual_record(_TT_DRAFT, "2999-01-01")
|
||||
assert ingest_manual_records(tmp_path / "log.json", [record]) == []
|
||||
assert not ingest_manual_records(tmp_path / "log.json", [record])
|
||||
|
||||
|
||||
class TestBudgetAgainstTheWorkoutDate:
|
||||
@ -136,7 +134,7 @@ class TestBudgetAgainstTheWorkoutDate:
|
||||
]
|
||||
with caplog.at_level(logging.INFO):
|
||||
ingested = ingest_manual_records(log_file, [due, *pending], today=_TOMORROW)
|
||||
assert ingested == []
|
||||
assert not ingested
|
||||
assert "budget exhausted" in caplog.text
|
||||
|
||||
def test_a_logged_week_blocks_a_due_record_inside_it(self, tmp_path: Path) -> None:
|
||||
@ -146,5 +144,5 @@ class TestBudgetAgainstTheWorkoutDate:
|
||||
log_file, [_manual_record(draft, day)], today=day
|
||||
)
|
||||
blocked = _manual_record(_TT_DRAFT, _TODAY)
|
||||
assert ingest_manual_records(log_file, [blocked], today=_TODAY) == []
|
||||
assert not ingest_manual_records(log_file, [blocked], today=_TODAY)
|
||||
assert _days(log_file) == {"2026-10-12": 1, "2026-10-13": 1}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for ingesting synced manual workouts into log.json."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for ingesting synced manual workouts into log.json."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -98,7 +97,7 @@ class TestIngestManualRecordsCallback:
|
||||
today=_DATE,
|
||||
on_ingested=lambda entry, prior: calls.append((entry, prior)),
|
||||
)
|
||||
assert calls == []
|
||||
assert not calls
|
||||
|
||||
def test_write_chokepoint_collision_is_not_credited_or_returned(
|
||||
self, tmp_path: Path
|
||||
@ -120,8 +119,8 @@ class TestIngestManualRecordsCallback:
|
||||
today=_DATE,
|
||||
on_ingested=lambda entry, prior: calls.append((entry, prior)),
|
||||
)
|
||||
assert ingested == []
|
||||
assert calls == []
|
||||
assert not ingested
|
||||
assert not calls
|
||||
|
||||
|
||||
class TestEmptyStubRecords:
|
||||
@ -165,7 +164,7 @@ class TestEmptyStubRecords:
|
||||
)
|
||||
],
|
||||
)
|
||||
assert ingested == []
|
||||
assert not ingested
|
||||
stub_records = [r for r in caplog.records if "empty stub" in r.message]
|
||||
assert stub_records, "the stub must still be reported"
|
||||
assert all(r.levelname == "INFO" for r in stub_records)
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for the manual-workout pure-logic module."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -71,13 +70,13 @@ class TestCountInWindow:
|
||||
def test_missing_file_returns_zero(self, tmp_path: Path) -> None:
|
||||
"""Missing file returns zero."""
|
||||
log_file = tmp_path / "does_not_exist.json"
|
||||
assert count_in_window(log_file, 7, today=_TODAY) == 0
|
||||
assert not count_in_window(log_file, 7, today=_TODAY)
|
||||
|
||||
def test_corrupt_json_returns_zero(self, tmp_path: Path) -> None:
|
||||
"""Corrupt JSON returns zero."""
|
||||
log_file = tmp_path / "log.json"
|
||||
log_file.write_text("not json")
|
||||
assert count_in_window(log_file, 7, today=_TODAY) == 0
|
||||
assert not count_in_window(log_file, 7, today=_TODAY)
|
||||
|
||||
def test_skips_invalid_date_keys(self, tmp_path: Path) -> None:
|
||||
"""Skips invalid date keys."""
|
||||
@ -96,12 +95,12 @@ class TestCountInWindow:
|
||||
"""Returns zero when today invalid."""
|
||||
log_file = tmp_path / "log.json"
|
||||
_write_logs(log_file, {"2026-07-04": "manual_workout"})
|
||||
assert count_in_window(log_file, 7, today="bogus") == 0
|
||||
assert not count_in_window(log_file, 7, today="bogus")
|
||||
|
||||
def test_uses_today_default_when_none(self, tmp_path: Path) -> None:
|
||||
"""Uses today default when none."""
|
||||
log_file = tmp_path / "log.json"
|
||||
assert count_in_window(log_file, 7) == 0
|
||||
assert not count_in_window(log_file, 7)
|
||||
|
||||
def test_multiple_same_day_entries_each_count(self, tmp_path: Path) -> None:
|
||||
"""Per-entry counting: same-day manual workouts no longer collapse to
|
||||
@ -152,7 +151,7 @@ class TestIsBudgetExhausted:
|
||||
]
|
||||
entries = dict.fromkeys(dates, "manual_workout")
|
||||
_write_logs(log_file, entries)
|
||||
assert count_in_window(log_file, 7, today=_TODAY) == 0
|
||||
assert not count_in_window(log_file, 7, today=_TODAY)
|
||||
assert is_budget_exhausted(log_file, today=_TODAY) is True
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for the workout-date field and the future-dated submit path."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for the manual-workout evidence-form dialog mixin."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -88,7 +87,7 @@ class TestGridCursor:
|
||||
locker._mw_grid_counters = {}
|
||||
parent = object()
|
||||
# Fresh (even) cursor: reserves row 0, then row 1 on the next call.
|
||||
assert locker._mw_next_full_row(parent) == 0
|
||||
assert not locker._mw_next_full_row(parent)
|
||||
assert locker._mw_next_full_row(parent) == 1
|
||||
|
||||
def test_full_row_from_odd_cursor_bumps_to_next_row(
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for manual-workout integration in _ui_flows.py and screen_lock.py."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for manual-workout integration in _ui_flows.py and screen_lock.py."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -64,7 +63,7 @@ class TestApplyWorkoutCredit:
|
||||
|
||||
assert result.already_counted_today is True
|
||||
assert result.shutdown_adjusted is False
|
||||
assert result.extra_bonus_delta == 0
|
||||
assert not result.extra_bonus_delta
|
||||
locker._try_adjust_shutdown_for_workout.assert_not_called()
|
||||
locker.save_workout_log.assert_called_once() # log entry write attempted
|
||||
|
||||
@ -87,7 +86,7 @@ class TestApplyWorkoutCredit:
|
||||
|
||||
assert result.already_counted_today is False
|
||||
assert result.shutdown_adjusted is True
|
||||
assert result.extra_bonus_delta == 0
|
||||
assert not result.extra_bonus_delta
|
||||
locker._try_adjust_shutdown_for_workout.assert_called_once()
|
||||
|
||||
def test_additional_manual_workout_earns_plus_one_hour(
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
Split out of test_manual_workout_features_part2.py for the 250-line cap.
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -58,7 +57,7 @@ class TestApplyWorkoutCredit:
|
||||
result = locker._apply_workout_credit()
|
||||
|
||||
assert result.shutdown_adjusted is False
|
||||
assert result.extra_bonus_delta == 0
|
||||
assert not result.extra_bonus_delta
|
||||
locker._try_adjust_shutdown_for_workout.assert_not_called()
|
||||
locker._adjust_shutdown_time_by.assert_not_called()
|
||||
|
||||
@ -88,7 +87,7 @@ class TestApplyWorkoutCredit:
|
||||
result = locker._apply_credit_for_written_entry(prior)
|
||||
|
||||
assert result.shutdown_adjusted is False
|
||||
assert result.extra_bonus_delta == 0
|
||||
assert not result.extra_bonus_delta
|
||||
locker._try_adjust_shutdown_for_workout.assert_not_called()
|
||||
locker._adjust_shutdown_time_by.assert_not_called()
|
||||
notify.assert_not_called()
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for the manual-workout pure-logic module."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -140,7 +139,7 @@ class TestBuildEntry:
|
||||
def test_duration_minutes_empty_when_unparsable(self) -> None:
|
||||
"""Duration minutes empty when unparsable."""
|
||||
entry = build_entry(_tt_draft(start_time="bogus"))
|
||||
assert entry["duration_minutes"] == ""
|
||||
assert not entry["duration_minutes"]
|
||||
|
||||
def test_strips_whitespace_fields(self) -> None:
|
||||
"""Strips whitespace fields."""
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for manual-workout integration in status_view.py."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@ -69,7 +69,7 @@ def test_the_retry_budget_is_screen_lockers_own() -> None:
|
||||
"""The conftest zeroes it; raising it here makes the shared reader wait."""
|
||||
naps: list[float] = []
|
||||
assert morning_skip_today(NOW, wait=True, sleep=naps.append) is None
|
||||
assert naps == []
|
||||
assert not naps
|
||||
with patch.object(_morning_session, "MORNING_RETRY_SECONDS", 10.0):
|
||||
assert morning_skip_today(NOW, wait=True, sleep=naps.append) is None
|
||||
assert naps
|
||||
|
||||
@ -31,7 +31,7 @@ EVENING = datetime(2026, 10, 17, 21, 5).astimezone()
|
||||
def _stamp(when: str) -> None:
|
||||
"""Write the redirected stamp for a LOCAL wall-clock time."""
|
||||
epoch = int(datetime.fromisoformat(when).astimezone().timestamp())
|
||||
_night_lockdown.NIGHT_LOCKDOWN_STAMP.write_text(f"{epoch}\n")
|
||||
_night_lockdown.NIGHT_LOCKDOWN_STAMP.write_text(f"{epoch}\n", encoding="utf-8")
|
||||
|
||||
|
||||
class TestTonightsLockdown:
|
||||
@ -53,7 +53,9 @@ class TestTonightsLockdown:
|
||||
assert tonights_lockdown(NOW) is None
|
||||
|
||||
def test_garbage_is_not(self) -> None:
|
||||
_night_lockdown.NIGHT_LOCKDOWN_STAMP.write_text("not an epoch")
|
||||
_night_lockdown.NIGHT_LOCKDOWN_STAMP.write_text(
|
||||
"not an epoch", encoding="utf-8"
|
||||
)
|
||||
assert tonights_lockdown(NOW) is None
|
||||
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for phone workout verification, phone check, and unlock operations."""
|
||||
# pylint: disable=protected-access,unused-argument
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Tests for _start_phone_check, polling, and startup result routing."""
|
||||
# pylint: disable=protected-access,unused-argument
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@ -40,7 +40,7 @@ def _adb_pull_from(path_contents: dict[str, str]) -> MagicMock:
|
||||
content = path_contents.get(remote)
|
||||
if content is None:
|
||||
return False, ""
|
||||
Path(dest).write_text(content)
|
||||
Path(dest).write_text(content, encoding="utf-8")
|
||||
return True, ""
|
||||
|
||||
return MagicMock(side_effect=fake_run_adb)
|
||||
|
||||
@ -39,7 +39,7 @@ class TestTryFillStronglifitsForWeek:
|
||||
log_file = tmp_path / "log.json"
|
||||
log_file.write_text("{}")
|
||||
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=False))
|
||||
assert locker._try_fill_stronglifts_for_week(log_file) == 0
|
||||
assert not locker._try_fill_stronglifts_for_week(log_file)
|
||||
|
||||
def test_returns_zero_when_no_json_pulled(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
@ -51,7 +51,7 @@ class TestTryFillStronglifitsForWeek:
|
||||
object.__setattr__(
|
||||
locker, "_pull_workout_app_json", MagicMock(return_value=None)
|
||||
)
|
||||
assert locker._try_fill_stronglifts_for_week(log_file) == 0
|
||||
assert not locker._try_fill_stronglifts_for_week(log_file)
|
||||
|
||||
def test_returns_zero_when_date_unparsable(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
@ -65,7 +65,7 @@ class TestTryFillStronglifitsForWeek:
|
||||
"_pull_workout_app_json",
|
||||
MagicMock(return_value={"date": "not-a-date", "exercises": ["x"]}),
|
||||
)
|
||||
assert locker._try_fill_stronglifts_for_week(log_file) == 0
|
||||
assert not locker._try_fill_stronglifts_for_week(log_file)
|
||||
|
||||
def test_returns_zero_when_date_outside_current_week(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
@ -88,7 +88,7 @@ class TestTryFillStronglifitsForWeek:
|
||||
}
|
||||
),
|
||||
)
|
||||
assert locker._try_fill_stronglifts_for_week(log_file) == 0
|
||||
assert not locker._try_fill_stronglifts_for_week(log_file)
|
||||
|
||||
def test_returns_zero_when_content_invalid(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
@ -104,7 +104,7 @@ class TestTryFillStronglifitsForWeek:
|
||||
"_pull_workout_app_json",
|
||||
MagicMock(return_value={"date": yesterday, "exercises": []}),
|
||||
)
|
||||
assert locker._try_fill_stronglifts_for_week(log_file) == 0
|
||||
assert not locker._try_fill_stronglifts_for_week(log_file)
|
||||
|
||||
def test_fills_gap_for_past_week_day(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
@ -191,5 +191,5 @@ class TestTryFillStronglifitsForWeek:
|
||||
)
|
||||
|
||||
with patch("screen_locker._phone_verification.datetime", _FakeDatetime):
|
||||
assert locker._try_fill_stronglifts_for_week(log_file) == 0
|
||||
assert not locker._try_fill_stronglifts_for_week(log_file)
|
||||
assert json.loads(log_file.read_text()) == {}
|
||||
|
||||
@ -47,8 +47,8 @@ def _result(*, counted: bool = False) -> WorkoutCreditResult:
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def log_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
@pytest.fixture(name="log_file")
|
||||
def log_file_fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""A fresh PokeLocker on a tmp log; the day reset and the credit stubbed."""
|
||||
log = tmp_path / "poke_log.json"
|
||||
|
||||
@ -64,8 +64,8 @@ def log_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
return log
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def credit(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
@pytest.fixture(name="credit")
|
||||
def credit_fixture(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
"""Replace the shared credit step; the ingest itself stays real."""
|
||||
stub = MagicMock(return_value=_result())
|
||||
monkeypatch.setattr(PokeLocker, "_apply_credit_for_written_entry", stub)
|
||||
|
||||
@ -16,8 +16,8 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def log_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
@pytest.fixture(name="log_file")
|
||||
def log_file_fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""A fresh PokeLocker on a tmp log; the day reset stubbed."""
|
||||
log = tmp_path / "poke_log.json"
|
||||
|
||||
@ -33,8 +33,8 @@ def log_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
return log
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def credit(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
@pytest.fixture(name="credit")
|
||||
def credit_fixture(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
"""Replace the shared credit step."""
|
||||
stub = MagicMock(
|
||||
return_value=WorkoutCreditResult(
|
||||
|
||||
@ -56,8 +56,8 @@ def _daemon_stubs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr(poke_server, "PokeServer", FakeServer)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def stream(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
@pytest.fixture(name="stream", autouse=True)
|
||||
def stream_fixture(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
"""The Firebase stream class; never a real connection."""
|
||||
stub = MagicMock()
|
||||
monkeypatch.setattr(poke_server, "SessionStream", stub)
|
||||
@ -127,8 +127,9 @@ class TestMain:
|
||||
argv = ["--key-file", str(_key_file(tmp_path)), "--port", "1234"]
|
||||
with caplog.at_level(logging.INFO):
|
||||
code = poke_server.main([*argv, "--bind", "127.0.0.1"])
|
||||
(server,) = FakeServer.instances
|
||||
assert code == 0
|
||||
assert len(FakeServer.instances) == 1
|
||||
server = FakeServer.instances[0]
|
||||
assert not code
|
||||
assert server.address == ("127.0.0.1", 1234)
|
||||
assert server.key == bytes.fromhex(_KEY_HEX)
|
||||
assert server.closed
|
||||
@ -156,7 +157,7 @@ class TestMain:
|
||||
code = poke_server.main(
|
||||
["--key-file", str(_key_file(tmp_path)), "--no-stream"]
|
||||
)
|
||||
assert code == 0
|
||||
assert not code
|
||||
stream.assert_not_called()
|
||||
assert "disabled by --no-stream" in caplog.text
|
||||
assert FakeServer.instances[0].closed
|
||||
@ -170,7 +171,7 @@ class TestMain:
|
||||
stream.return_value.start.side_effect = RuntimeError("no network")
|
||||
with caplog.at_level(logging.ERROR):
|
||||
code = poke_server.main(["--key-file", str(_key_file(tmp_path))])
|
||||
assert code == 0
|
||||
assert not code
|
||||
assert "Could not start the Firebase session stream" in caplog.text
|
||||
assert FakeServer.instances[0].closed
|
||||
|
||||
|
||||
@ -33,8 +33,8 @@ def _request(*, sandbox: bool) -> PokeRequest:
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pc(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
|
||||
@pytest.fixture(name="pc")
|
||||
def pc_fixture(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, MagicMock]:
|
||||
"""A real PokeLocker on a tmp log with every state-changing step spied."""
|
||||
|
||||
def headless(locker_cls: type[PokeLocker]) -> PokeLocker:
|
||||
|
||||
@ -22,7 +22,7 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
# conftest stubs socket.create_connection process-wide (see test_web_server).
|
||||
_REAL_CREATE_CONNECTION = socket.create_connection
|
||||
_real_create_connection = socket.create_connection
|
||||
_KEY = bytes(range(32))
|
||||
|
||||
|
||||
@ -47,10 +47,10 @@ def _sign(body: bytes) -> str:
|
||||
return hmac.new(_KEY, body, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def port(monkeypatch: pytest.MonkeyPatch) -> Iterator[int]:
|
||||
@pytest.fixture(name="port")
|
||||
def port_fixture(monkeypatch: pytest.MonkeyPatch) -> Iterator[int]:
|
||||
"""A running PokeServer on 127.0.0.1:0; shut down afterwards."""
|
||||
monkeypatch.setattr(socket, "create_connection", _REAL_CREATE_CONNECTION)
|
||||
monkeypatch.setattr(socket, "create_connection", _real_create_connection)
|
||||
server = PokeServer(("127.0.0.1", 0), _KEY)
|
||||
# poll_interval: shutdown() waits out one poll, 0.5 s per test by default.
|
||||
thread = threading.Thread(target=server.serve_forever, args=(0.01,), daemon=True)
|
||||
@ -63,8 +63,8 @@ def port(monkeypatch: pytest.MonkeyPatch) -> Iterator[int]:
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def credit(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> MagicMock:
|
||||
@pytest.fixture(name="credit")
|
||||
def credit_fixture(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> MagicMock:
|
||||
"""Stub the PC side: tmp locker, fixed snapshot, a spy for the credit."""
|
||||
locker = MagicMock()
|
||||
locker.log_file = tmp_path / "log.json"
|
||||
|
||||
@ -97,10 +97,10 @@ class TestReading:
|
||||
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert rest_day_declarations() == {}
|
||||
assert not rest_day_declarations()
|
||||
assert "signing key" in caplog.text
|
||||
with signing_key(tmp_path, b" "):
|
||||
assert rest_day_declarations() == {}
|
||||
assert not rest_day_declarations()
|
||||
|
||||
@pytest.mark.usefixtures("key")
|
||||
def test_a_hand_edited_row_is_ignored(
|
||||
@ -111,7 +111,7 @@ class TestReading:
|
||||
edited[0]["day"] = "2026-10-14"
|
||||
write(edited)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert rest_day_declarations() == {}
|
||||
assert not rest_day_declarations()
|
||||
assert "fails its signature" in caplog.text
|
||||
|
||||
@pytest.mark.usefixtures("key")
|
||||
@ -124,7 +124,7 @@ class TestReading:
|
||||
"declared_at": 1.0,
|
||||
}
|
||||
write([{**body, "hmac": earned_time.entry_signature(body, KEY)}])
|
||||
assert rest_day_declarations() == {}
|
||||
assert not rest_day_declarations()
|
||||
|
||||
@pytest.mark.usefixtures("key")
|
||||
def test_malformed_and_foreign_rows_are_ignored(
|
||||
@ -144,7 +144,7 @@ class TestReading:
|
||||
]
|
||||
)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert rest_day_declarations() == {}
|
||||
assert not rest_day_declarations()
|
||||
assert "malformed" in caplog.text
|
||||
|
||||
@pytest.mark.usefixtures("key")
|
||||
@ -153,7 +153,7 @@ class TestReading:
|
||||
) -> None:
|
||||
_rest_day.REST_DAY_FILE.write_text("[]")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert rest_day_declarations() == {}
|
||||
assert not rest_day_declarations()
|
||||
assert "no entries array" in caplog.text
|
||||
|
||||
@pytest.mark.usefixtures("key")
|
||||
@ -185,16 +185,16 @@ class TestCli:
|
||||
return run_declare_rest_day(list(argv))
|
||||
|
||||
def test_declare_tomorrow(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert self._run("tomorrow") == 0
|
||||
assert not self._run("tomorrow")
|
||||
assert "2026-10-10 is a rest day" in capsys.readouterr().out
|
||||
|
||||
def test_a_date_and_then_the_list(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert self._run("2026-10-11") == 0
|
||||
assert self._run("--list") == 0
|
||||
assert not self._run("2026-10-11")
|
||||
assert not self._run("--list")
|
||||
assert capsys.readouterr().out.splitlines()[-1] == "2026-10-11"
|
||||
|
||||
def test_an_empty_list(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
assert self._run() == 0
|
||||
assert not self._run()
|
||||
assert capsys.readouterr().out.strip() == "no rest days declared"
|
||||
|
||||
def test_a_bad_date(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
|
||||
@ -45,7 +45,7 @@ class TestParseSse:
|
||||
|
||||
def test_stray_blank_lines_and_comments_yield_nothing(self) -> None:
|
||||
"""Blank separators and ``:`` comments / unknown fields are ignored."""
|
||||
assert list(parse_sse(["", "", ": comment", "id: 7", "retry: 5", ""])) == []
|
||||
assert not list(parse_sse(["", "", ": comment", "id: 7", "retry: 5", ""]))
|
||||
|
||||
def test_back_to_back_events_do_not_leak_state(self) -> None:
|
||||
"""Each event starts with a clean name and data."""
|
||||
@ -57,7 +57,7 @@ class TestParseSse:
|
||||
|
||||
def test_unterminated_event_is_not_emitted(self) -> None:
|
||||
"""Without the closing blank line the event is incomplete: dropped."""
|
||||
assert list(parse_sse(["event: put", "data: 1"])) == []
|
||||
assert not list(parse_sse(["event: put", "data: 1"]))
|
||||
|
||||
|
||||
class TestDeviceUpdatesPut:
|
||||
|
||||
@ -86,7 +86,7 @@ class TestScanAndFillWeekRunnerupSkips:
|
||||
"_find_runnerup_exports_for_date",
|
||||
MagicMock(return_value=[]),
|
||||
)
|
||||
assert locker._scan_and_fill_week_runnerup(log_file) == 0
|
||||
assert not locker._scan_and_fill_week_runnerup(log_file)
|
||||
|
||||
def test_skips_unreadable_tcx(
|
||||
self,
|
||||
@ -105,7 +105,7 @@ class TestScanAndFillWeekRunnerupSkips:
|
||||
MagicMock(return_value=["/sdcard/run.tcx"]),
|
||||
)
|
||||
object.__setattr__(locker, "_pull_and_parse_tcx", MagicMock(return_value=None))
|
||||
assert locker._scan_and_fill_week_runnerup(log_file) == 0
|
||||
assert not locker._scan_and_fill_week_runnerup(log_file)
|
||||
|
||||
def test_skips_not_verified_export(
|
||||
self,
|
||||
@ -136,7 +136,7 @@ class TestScanAndFillWeekRunnerupSkips:
|
||||
return_value={"sport": 0, "duration_seconds": 60, "distance_m": 1000}
|
||||
),
|
||||
)
|
||||
assert locker._scan_and_fill_week_runnerup(log_file) == 0
|
||||
assert not locker._scan_and_fill_week_runnerup(log_file)
|
||||
|
||||
def test_rescanning_an_already_filled_day_is_a_no_op(
|
||||
self,
|
||||
|
||||
@ -122,8 +122,8 @@ class TestBranchCoverageGaps:
|
||||
result = locker._parse_tcx(path)
|
||||
# Should still return a dict (0 seconds, 0 m) not None
|
||||
assert result is not None
|
||||
assert result["duration_seconds"] == 0
|
||||
assert result["distance_m"] == 0.0
|
||||
assert not result["duration_seconds"]
|
||||
assert not result["distance_m"]
|
||||
|
||||
# ---- 161->154: _verify_runnerup_via_files iterates over multiple exports
|
||||
|
||||
@ -184,4 +184,4 @@ class TestBranchCoverageGaps:
|
||||
MagicMock(return_value=[]),
|
||||
)
|
||||
# Should not raise; no exports → 0 filled
|
||||
assert locker._scan_and_fill_week_runnerup(log_file) == 0
|
||||
assert not locker._scan_and_fill_week_runnerup(log_file)
|
||||
|
||||
@ -87,7 +87,7 @@ class TestScanAndFillWeekRunnerupSkips:
|
||||
MagicMock(return_value=[]),
|
||||
)
|
||||
# Should not raise; returns 0 (no exports found).
|
||||
assert locker._scan_and_fill_week_runnerup(log_file) == 0
|
||||
assert not locker._scan_and_fill_week_runnerup(log_file)
|
||||
|
||||
def test_hmac_none_still_fills_entry(
|
||||
self,
|
||||
|
||||
@ -3,15 +3,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
# Minimal valid TCX XML for a 40-minute, 6-km run.
|
||||
_TCX_RUNNING = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@ -207,7 +204,7 @@ class TestPullRunnerupDb:
|
||||
def _fake_run_adb(args: list[str]) -> tuple[bool, str]:
|
||||
if args[0] == "pull" and args[2].endswith(".db"):
|
||||
# Create the local file so the caller finds it.
|
||||
open(args[2], "w").close()
|
||||
Path(args[2]).touch()
|
||||
return True, ""
|
||||
|
||||
object.__setattr__(
|
||||
|
||||
@ -2,15 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import TYPE_CHECKING
|
||||
import time
|
||||
import tkinter as tk
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
# Minimal valid TCX XML for a 40-minute, 6-km run.
|
||||
_TCX_RUNNING = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@ -69,7 +68,6 @@ class TestQueryTodaysRun:
|
||||
|
||||
def _make_db(self, tmp_path: Path) -> str:
|
||||
"""Create a minimal RunnerUp DB with one activity for today."""
|
||||
import time
|
||||
|
||||
db_path = str(tmp_path / "runnerup.db")
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
@ -94,8 +92,6 @@ class TestQueryTodaysRun:
|
||||
"(start_time REAL, distance REAL, time REAL, type INTEGER, deleted INTEGER)"
|
||||
)
|
||||
# Need a locker instance to call the method.
|
||||
import tkinter as tk
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
mock_tk = MagicMock()
|
||||
mock_tk.Tk.return_value = MagicMock()
|
||||
@ -128,7 +124,7 @@ class TestQueryTodaysRun:
|
||||
result = locker._query_todays_run(db_path)
|
||||
assert result is not None
|
||||
assert "sport" in result
|
||||
assert result["sport"] == 0
|
||||
assert not result["sport"]
|
||||
assert result["distance_m"] == 6000.0
|
||||
|
||||
def test_returns_none_on_sqlite_error(
|
||||
@ -140,8 +136,7 @@ class TestQueryTodaysRun:
|
||||
"""Corrupt DB → sqlite3.Error caught; returns None."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
corrupt_db = str(tmp_path / "corrupt.db")
|
||||
with open(corrupt_db, "w") as f:
|
||||
f.write("this is not a sqlite database")
|
||||
Path(corrupt_db).write_text("this is not a sqlite database", encoding="utf-8")
|
||||
result = locker._query_todays_run(corrupt_db)
|
||||
assert result is None
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
@ -78,7 +79,7 @@ class TestScanAndFillWeekRunnerup:
|
||||
log_file = tmp_path / "log.json"
|
||||
log_file.write_text("{}")
|
||||
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=False))
|
||||
assert locker._scan_and_fill_week_runnerup(log_file) == 0
|
||||
assert not locker._scan_and_fill_week_runnerup(log_file)
|
||||
|
||||
def test_returns_zero_when_all_days_already_logged(
|
||||
self,
|
||||
@ -87,7 +88,6 @@ class TestScanAndFillWeekRunnerup:
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""All days this week already have counted workouts → 0 new fills."""
|
||||
from datetime import date, timedelta
|
||||
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
log_file = tmp_path / "log.json"
|
||||
@ -108,7 +108,7 @@ class TestScanAndFillWeekRunnerup:
|
||||
object.__setattr__(
|
||||
locker, "_find_runnerup_exports_for_date", MagicMock(return_value=[])
|
||||
)
|
||||
assert locker._scan_and_fill_week_runnerup(log_file) == 0
|
||||
assert not locker._scan_and_fill_week_runnerup(log_file)
|
||||
|
||||
def test_fills_gap_for_unlogged_day(
|
||||
self,
|
||||
|
||||
@ -78,7 +78,7 @@ class TestParseTcx:
|
||||
path = _write_tcx(tmp_path, _TCX_RUNNING)
|
||||
result = locker._parse_tcx(path)
|
||||
assert result is not None
|
||||
assert result["sport"] == 0 # Running
|
||||
assert not result["sport"] # Running
|
||||
assert result["duration_seconds"] == 2400
|
||||
assert result["distance_m"] == 6000.0
|
||||
|
||||
|
||||
@ -110,7 +110,7 @@ class TestPullAndParseTcx:
|
||||
object.__setattr__(locker, "_run_adb", MagicMock(side_effect=_fake_pull))
|
||||
result = locker._pull_and_parse_tcx("/sdcard/activity.tcx")
|
||||
assert result is not None
|
||||
assert result["sport"] == 0
|
||||
assert not result["sport"]
|
||||
assert result["duration_seconds"] == 2400
|
||||
|
||||
|
||||
|
||||
@ -5,16 +5,13 @@ from __future__ import annotations
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from screen_locker._runnerup_verification import RunnerUpVerificationMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
||||
_DAY = "2026-10-10"
|
||||
|
||||
@ -58,8 +55,8 @@ def drop(folder: Path, name: str, body: str) -> None:
|
||||
(folder / f"RunnerUp_Pixel_6a_{_DAY}-{name}.tcx").write_text(body)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def verifier(tmp_path: Path) -> Verifier:
|
||||
@pytest.fixture(name="verifier")
|
||||
def verifier_fixture(tmp_path: Path) -> Verifier:
|
||||
return Verifier(tmp_path)
|
||||
|
||||
|
||||
@ -77,7 +74,6 @@ class TestParse:
|
||||
body = tcx("Other", 10, "08:00")
|
||||
|
||||
def pull(args: list[str]) -> tuple[bool, str]:
|
||||
from pathlib import Path
|
||||
|
||||
Path(args[2]).write_text(body)
|
||||
return True, ""
|
||||
|
||||
@ -19,7 +19,7 @@ _SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "runnerup_watch.sh"
|
||||
_UNIT = "workout-sync.service"
|
||||
# conftest replaces subprocess.run process-wide for every test; this module
|
||||
# needs the real one, captured at import time before any fixture runs.
|
||||
_REAL_RUN = subprocess.run
|
||||
_real_run = subprocess.run
|
||||
|
||||
|
||||
def _shim(path: Path, body: str) -> None:
|
||||
@ -51,7 +51,7 @@ def _run(
|
||||
"RUNNERUP_WATCH_DIR": str(watch),
|
||||
"RUNNERUP_SYNC_UNIT": _UNIT,
|
||||
}
|
||||
done = _REAL_RUN(
|
||||
done = _real_run(
|
||||
["bash", str(_SCRIPT)],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
|
||||
@ -121,7 +121,7 @@ class TestLocalExportDiscovery:
|
||||
):
|
||||
assert locker._has_runnerup_source() is False
|
||||
status, _ = locker._verify_runnerup_workout()
|
||||
assert locker._scan_and_fill_week_runnerup(tmp_path / "log.json") == 0
|
||||
assert not locker._scan_and_fill_week_runnerup(tmp_path / "log.json")
|
||||
|
||||
assert status == "no_phone"
|
||||
|
||||
|
||||
@ -131,7 +131,7 @@ class TestNoDisplayStandsDown:
|
||||
pytest.raises(SystemExit) as raised,
|
||||
):
|
||||
create_locker(MagicMock(), tmp_path, demo_mode=False)
|
||||
assert raised.value.code == 0
|
||||
assert not raised.value.code
|
||||
return aborted, exit_mock
|
||||
|
||||
def test_it_exits_zero_instead_of_failing(self, tmp_path: Path) -> None:
|
||||
|
||||
@ -72,9 +72,9 @@ class TestIngestSyncedManualWorkouts:
|
||||
) as ingest,
|
||||
):
|
||||
locker._ingest_synced_manual_workouts()
|
||||
assert ingest.call_args.kwargs["on_ingested"] == (
|
||||
assert [ingest.call_args.kwargs["on_ingested"]] == [
|
||||
locker._credit_ingested_workout
|
||||
)
|
||||
]
|
||||
assert locker.workout_data == {}
|
||||
|
||||
def test_credit_ingested_workout_applies_reward(
|
||||
@ -191,5 +191,7 @@ class TestIngestSyncedSessions:
|
||||
) as ingest,
|
||||
):
|
||||
locker._ingest_synced_sessions()
|
||||
assert ingest.call_args.kwargs["on_ingested"] == locker._credit_ingested_workout
|
||||
assert [ingest.call_args.kwargs["on_ingested"]] == [
|
||||
locker._credit_ingested_workout
|
||||
]
|
||||
assert locker.workout_data == {}
|
||||
|
||||
@ -75,8 +75,8 @@ def make_stream(monkeypatch: pytest.MonkeyPatch) -> SessionStream:
|
||||
return SessionStream(threading.Lock())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stream(monkeypatch: pytest.MonkeyPatch) -> SessionStream:
|
||||
@pytest.fixture(name="stream")
|
||||
def stream_fixture(monkeypatch: pytest.MonkeyPatch) -> SessionStream:
|
||||
"""A ``SessionStream`` that never reads or writes the real device id."""
|
||||
return make_stream(monkeypatch)
|
||||
|
||||
@ -165,7 +165,7 @@ class TestConsume:
|
||||
with caplog.at_level(logging.INFO):
|
||||
stream._consume(response, _PAST)
|
||||
assert any("renewing its ID token" in r.getMessage() for r in caplog.records)
|
||||
assert response.raw.chunks == []
|
||||
assert not response.raw.chunks
|
||||
keepalives = [r for r in caplog.records if "keep-alive #" in r.getMessage()]
|
||||
assert len(keepalives) == 1 # stopped after the first event
|
||||
|
||||
|
||||
@ -19,8 +19,8 @@ _RENEW = datetime(2999, 1, 1, tzinfo=UTC)
|
||||
_LONG = 400.0 # longer than the 300 s minimum lifetime
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stream(monkeypatch: pytest.MonkeyPatch) -> SessionStream:
|
||||
@pytest.fixture(name="stream")
|
||||
def stream_fixture(monkeypatch: pytest.MonkeyPatch) -> SessionStream:
|
||||
"""A ``SessionStream`` that never reads or writes the real device id."""
|
||||
return make_stream(monkeypatch)
|
||||
|
||||
|
||||
@ -17,8 +17,8 @@ if TYPE_CHECKING:
|
||||
_LONG = 400.0 # longer than the 300 s minimum lifetime
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stream(monkeypatch: pytest.MonkeyPatch) -> SessionStream:
|
||||
@pytest.fixture(name="stream")
|
||||
def stream_fixture(monkeypatch: pytest.MonkeyPatch) -> SessionStream:
|
||||
"""A ``SessionStream`` that never reads or writes the real device id."""
|
||||
return make_stream(monkeypatch)
|
||||
|
||||
@ -117,7 +117,7 @@ class TestRunForever:
|
||||
) -> None:
|
||||
"""Token renewal after a healthy hour is not a failure: no pause."""
|
||||
script: list[tuple[bool, float, bool]] = [(True, 3600.0, True)] * 2
|
||||
assert self._run(_Loop(stream, monkeypatch, script)) == []
|
||||
assert not self._run(_Loop(stream, monkeypatch, script))
|
||||
|
||||
def test_planned_end_after_a_short_life_still_backs_off(
|
||||
self, stream: SessionStream, monkeypatch: pytest.MonkeyPatch
|
||||
@ -146,7 +146,7 @@ class TestRunForever:
|
||||
"""A set stop flag exits without connecting."""
|
||||
loop = _Loop(stream, monkeypatch, [])
|
||||
stream.run_forever()
|
||||
assert loop.index == 0
|
||||
assert not loop.index
|
||||
|
||||
|
||||
class TestThread:
|
||||
|
||||
@ -116,25 +116,24 @@ class TestIngestSessionRecords:
|
||||
log = tmp_path / "log.json"
|
||||
records = [("r1", _session())]
|
||||
assert ingest_session_records(log, records) == ["r1"]
|
||||
assert ingest_session_records(log, records) == []
|
||||
assert not ingest_session_records(log, records)
|
||||
assert count_weekly_workouts(log, today=_TODAY) == 1
|
||||
|
||||
def test_skips_a_session_with_no_date(self, tmp_path: Path) -> None:
|
||||
payload = _session()
|
||||
del payload["date"]
|
||||
assert ingest_session_records(tmp_path / "log.json", [("r1", payload)]) == []
|
||||
assert not ingest_session_records(tmp_path / "log.json", [("r1", payload)])
|
||||
|
||||
def test_skips_a_session_whose_date_is_not_a_string(self, tmp_path: Path) -> None:
|
||||
assert (
|
||||
assert not (
|
||||
ingest_session_records(tmp_path / "log.json", [("r1", _session(date=7))])
|
||||
== []
|
||||
)
|
||||
|
||||
def test_skips_a_session_that_fails_validation(self, tmp_path: Path) -> None:
|
||||
log = tmp_path / "log.json"
|
||||
short = _session(duration_seconds=60)
|
||||
assert ingest_session_records(log, [("r1", short)]) == []
|
||||
assert count_weekly_workouts(log, today=_TODAY) == 0
|
||||
assert not ingest_session_records(log, [("r1", short)])
|
||||
assert not count_weekly_workouts(log, today=_TODAY)
|
||||
|
||||
def test_calls_back_for_each_newly_ingested_session(self, tmp_path: Path) -> None:
|
||||
seen: list[tuple[dict, list]] = []
|
||||
@ -155,7 +154,7 @@ class TestIngestSessionRecords:
|
||||
ingest_session_records(
|
||||
log, records, on_ingested=lambda entry, _prior: seen.append(entry)
|
||||
)
|
||||
assert seen == []
|
||||
assert not seen
|
||||
|
||||
|
||||
class TestPullAllSessionRecords:
|
||||
|
||||
@ -129,7 +129,7 @@ class TestResetToBaseIfNewDay:
|
||||
assert reset_to_base_if_new_day(f, mixin) is True
|
||||
|
||||
def test_handles_oserror_on_state_write(self, tmp_path: Path) -> None:
|
||||
"""OSError writing the new state file is caught; function still returns True (lines 96-97)."""
|
||||
"""An OSError writing the new state file is caught; the reset still returns True."""
|
||||
# Use a mock path that fails only on "w" opens.
|
||||
state_mock = MagicMock()
|
||||
state_mock.exists.return_value = False # triggers fresh-reset path
|
||||
|
||||
@ -64,10 +64,10 @@ class TestEarnedShutdownBonusMinutes:
|
||||
"""First credit is worth 2h, every further one 1h."""
|
||||
|
||||
def test_no_credits_earn_nothing(self) -> None:
|
||||
assert earned_shutdown_bonus_minutes(0) == 0
|
||||
assert not earned_shutdown_bonus_minutes(0)
|
||||
|
||||
def test_negative_is_treated_as_nothing(self) -> None:
|
||||
assert earned_shutdown_bonus_minutes(-1) == 0
|
||||
assert not earned_shutdown_bonus_minutes(-1)
|
||||
|
||||
def test_first_credit_is_two_hours(self) -> None:
|
||||
assert earned_shutdown_bonus_minutes(1) == 120
|
||||
@ -138,7 +138,7 @@ class TestTodayCreditCount:
|
||||
|
||||
def test_day_absent_from_log_counts_nothing(self, tmp_path: Path) -> None:
|
||||
log = _write_log(tmp_path / "log.json", "2026-09-12", _real_2026_09_13())
|
||||
assert today_credit_count(log, _DAY) == 0
|
||||
assert not today_credit_count(log, _DAY)
|
||||
|
||||
def test_non_dict_entries_are_ignored(self, tmp_path: Path) -> None:
|
||||
log = tmp_path / "log.json"
|
||||
|
||||
@ -7,7 +7,8 @@ four blocks included) must shut down exactly at ``shutdown_ceiling_for(day)``
|
||||
-- never short of it, and never past it. Checked through screen-locker's own
|
||||
paths, not a re-implementation: the daily reset's :func:`derive`, and the live
|
||||
pass (first workout, further workouts, flat gates, counted gate blocks, each
|
||||
timed at ``first`` so a 22:30 one is a late credit) over :class:`ShutdownMixin` with an in-memory config.
|
||||
timed at ``first`` so a 22:30 one is a late credit) over :class:`ShutdownMixin`
|
||||
with an in-memory config.
|
||||
|
||||
Every gate maturity state is covered: ``first_credits`` omitted (0.5.0
|
||||
``penalty_from``), empty (every earner on its ``confirmed_on``), never paid
|
||||
@ -182,8 +183,8 @@ def _live_pass(day: date, case: tuple[str, bool, int, str]) -> int:
|
||||
return host.config[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def frozen_day() -> Iterator[Callable[[date], Any]]:
|
||||
@pytest.fixture(name="frozen_day")
|
||||
def frozen_day_fixture() -> Iterator[Callable[[date], Any]]:
|
||||
"""Point every live path's ``today`` at the simulated day."""
|
||||
|
||||
def at_day(day: date) -> Any:
|
||||
@ -215,4 +216,4 @@ def test_the_live_pass_lands_every_all_done_day_on_the_ceiling(
|
||||
_grace_floor.GRACE_STATE_FILE.unlink(missing_ok=True)
|
||||
if got != ceiling(day):
|
||||
misses.append((day.isoformat(), got, ceiling(day)))
|
||||
assert misses == []
|
||||
assert not misses
|
||||
|
||||
@ -49,8 +49,8 @@ def _mixin() -> MagicMock:
|
||||
return mixin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def answers() -> Iterator[dict[str, bool | None]]:
|
||||
@pytest.fixture(name="answers")
|
||||
def answers_fixture() -> Iterator[dict[str, bool | None]]:
|
||||
"""Every flat earner says no until a test sets it."""
|
||||
given: dict[str, bool | None] = {"leetcode": False, "reading": False}
|
||||
with answering(given):
|
||||
|
||||
@ -40,15 +40,15 @@ def apply_leetcode_bonus_if_new(state: Path, mixin: MagicMock) -> bool:
|
||||
return _apply_flat_bonus(state, mixin, earned_time.LEETCODE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def solved() -> Iterator[MagicMock]:
|
||||
@pytest.fixture(name="solved")
|
||||
def solved_fixture() -> Iterator[MagicMock]:
|
||||
"""LeetCode solved today; reading not, so the stamps here are LeetCode's."""
|
||||
with answering({"leetcode": True, "reading": False}) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unsolved() -> Iterator[MagicMock]:
|
||||
@pytest.fixture(name="unsolved")
|
||||
def unsolved_fixture() -> Iterator[MagicMock]:
|
||||
with answering({"leetcode": False, "reading": False}) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@ -47,8 +47,8 @@ def apply_reading_bonus_if_new(state: Path, mixin: MagicMock) -> bool:
|
||||
return _apply_flat_bonus(state, mixin, earned_time.READING)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def answers() -> Iterator[tuple[dict[str, bool | None], MagicMock]]:
|
||||
@pytest.fixture(name="answers")
|
||||
def answers_fixture() -> Iterator[tuple[dict[str, bool | None], MagicMock]]:
|
||||
"""(answers, earned_today mock): every flat earner says no until set."""
|
||||
given: dict[str, bool | None] = {"leetcode": False, "reading": False}
|
||||
with answering(given) as mock:
|
||||
|
||||
@ -46,7 +46,7 @@ class TestTarget:
|
||||
def test_a_rest_day_stands_in_for_the_first_workout_only(self) -> None:
|
||||
assert workout_units(0, rest_day=True) == 1
|
||||
assert workout_units(2, rest_day=True) == 2
|
||||
assert workout_units(0, rest_day=False) == 0
|
||||
assert not workout_units(0, rest_day=False)
|
||||
|
||||
def test_an_early_first_task_no_longer_lifts_the_sum(self) -> None:
|
||||
"""The grace floor is gone (2026-10-17): late credits replace it."""
|
||||
@ -54,7 +54,7 @@ class TestTarget:
|
||||
DayInputs(_DAY, {**_FLAT, "anki": True}, first_done=at("18:55"))
|
||||
)
|
||||
assert target.minutes == target.earned
|
||||
assert target.lift == 0
|
||||
assert not target.lift
|
||||
|
||||
def test_a_sick_day_has_the_20_00_floor(self) -> None:
|
||||
target = derive(DayInputs(_DAY, _FLAT, first_done=at("18:55"), sick_day=True))
|
||||
@ -63,7 +63,7 @@ class TestTarget:
|
||||
t.gaming_minutes for t in target.resolution.terms
|
||||
)
|
||||
# earned_time 0.13.0: credits stack on 20:00, so there is no lift.
|
||||
assert target.lift == 0
|
||||
assert not target.lift
|
||||
|
||||
def test_a_sick_day_stacks_gaming_pay_on_20_00(self) -> None:
|
||||
busy = dict.fromkeys(_FLAT, True)
|
||||
@ -72,7 +72,7 @@ class TestTarget:
|
||||
assert target.minutes == min(
|
||||
23 * 60, SICK_FLOOR + sum(t.gaming_minutes for t in target.resolution.terms)
|
||||
)
|
||||
assert target.lift == 0
|
||||
assert not target.lift
|
||||
|
||||
def test_gather_keeps_the_sick_floor_when_the_workout_lock_is_kept(self) -> None:
|
||||
"""Rule 2: the 20:00 floor is the day's, whatever the keep set says."""
|
||||
@ -93,7 +93,7 @@ class TestTarget:
|
||||
assert target.minutes == target.earned == target.resolution.shutdown_minutes
|
||||
|
||||
def test_no_log_counts_no_workouts(self) -> None:
|
||||
assert day_credit_count(None, _DAY) == 0
|
||||
assert not day_credit_count(None, _DAY)
|
||||
|
||||
def test_gather_reads_every_real_source(self, tmp_path: Path) -> None:
|
||||
log = log_with(
|
||||
|
||||
@ -195,7 +195,7 @@ class TestTap:
|
||||
back = next(
|
||||
c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == "Back"
|
||||
)
|
||||
assert back.kwargs["command"] == locker._start_phone_check
|
||||
assert [back.kwargs["command"]] == [locker._start_phone_check]
|
||||
|
||||
def test_refusal_on_the_relaxed_prompt_goes_back_to_it(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
@ -205,4 +205,4 @@ class TestTap:
|
||||
back = next(
|
||||
c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == "Back"
|
||||
)
|
||||
assert back.kwargs["command"] == locker._start_relaxed_day_flow
|
||||
assert [back.kwargs["command"]] == [locker._start_relaxed_day_flow]
|
||||
|
||||
@ -63,16 +63,16 @@ def _frame(*args: object, **_kwargs: object) -> MagicMock:
|
||||
return frame
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def panels() -> Iterator[list[FakePanel]]:
|
||||
@pytest.fixture(name="panels")
|
||||
def panels_fixture() -> Iterator[list[FakePanel]]:
|
||||
"""Every KeepPanel the lock builds, in order."""
|
||||
_MADE.clear()
|
||||
with patch.object(_ui_flows_sick_panel, "KeepPanel", FakePanel):
|
||||
yield _MADE
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def locker(
|
||||
@pytest.fixture(name="locker")
|
||||
def locker_fixture(
|
||||
mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> ScreenLocker:
|
||||
"""A retry-screen lock whose floor and close are observable."""
|
||||
|
||||
@ -77,7 +77,7 @@ class TestExplainFindings:
|
||||
|
||||
def test_no_findings_render_nothing(self) -> None:
|
||||
"""A healthy machine must not show a diagnostics block."""
|
||||
assert explain_findings([]) == ""
|
||||
assert not explain_findings([])
|
||||
|
||||
def test_findings_name_the_backend_and_the_consequence(self) -> None:
|
||||
"""The panel must say it is a sync fault, not a missed workout."""
|
||||
|
||||
@ -2,13 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker._status import _load_extra_benefits, run_status
|
||||
from screen_locker.tests._sick_helpers import mark_sick
|
||||
from screen_locker.tests.conftest import _make_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -83,7 +84,6 @@ class TestRunStatusNormal:
|
||||
self, tmp_path: Path, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""Today == the week's Sunday: the per-day loop runs all 7 days, never breaking early."""
|
||||
from datetime import datetime
|
||||
|
||||
fixed_now = datetime(2026, 7, 5, 12, 0, tzinfo=UTC) # a real Sunday
|
||||
eb_file = tmp_path / "eb.json"
|
||||
@ -107,7 +107,6 @@ class TestRunStatusNormal:
|
||||
self, tmp_path: Path, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""A date with no workout_log entry but in the sickdays pool → sick_day."""
|
||||
from screen_locker.tests._sick_helpers import mark_sick
|
||||
|
||||
mark_sick()
|
||||
eb_file = tmp_path / "eb.json"
|
||||
@ -186,7 +185,6 @@ class TestRunStatusWorkoutLog:
|
||||
self, tmp_path: Path, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""Log entry with counted type → ✓ mark printed."""
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now(tz=UTC).astimezone().date().isoformat()
|
||||
log_file = tmp_path / "log.json"
|
||||
@ -221,7 +219,6 @@ class TestRunStatusWorkoutLog:
|
||||
self, tmp_path: Path, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""Log entry with uncounted type → ✗ mark printed."""
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now(tz=UTC).astimezone().date().isoformat()
|
||||
log_file = tmp_path / "log.json"
|
||||
|
||||
@ -32,17 +32,17 @@ class TestGatherStatus:
|
||||
snap = gather_status(**_files(tmp_path), now=_FRIDAY_NOON_UTC)
|
||||
|
||||
assert snap.today.date == "2024-01-05"
|
||||
assert snap.today.entry_types == ()
|
||||
assert snap.today.day_count == 0
|
||||
assert not snap.today.entry_types
|
||||
assert not snap.today.day_count
|
||||
assert snap.today.counted is False
|
||||
assert snap.today.is_sick_day is False
|
||||
assert snap.week.counted_count == 0
|
||||
assert not snap.week.counted_count
|
||||
assert snap.week.remaining == 5
|
||||
assert snap.week.extra == 0
|
||||
assert not snap.week.extra
|
||||
assert snap.shutdown.tonight is None
|
||||
assert snap.shutdown.rest_of_week[0].minutes == 20 * 60
|
||||
assert snap.shutdown.rest_of_week[0].speculative is False
|
||||
assert snap.streak == 0
|
||||
assert not snap.streak
|
||||
assert snap.early_bird_extended is False
|
||||
assert snap.lock_explanation.fired is True
|
||||
assert snap.lock_explanation.stage == "would_lock"
|
||||
@ -121,7 +121,7 @@ class TestGatherStatus:
|
||||
|
||||
assert snap.streak == 2
|
||||
assert snap.early_bird_extended is True
|
||||
assert snap.week_rewards == ()
|
||||
assert not snap.week_rewards
|
||||
|
||||
def test_this_weeks_transition_message_is_surfaced(self, tmp_path: Path) -> None:
|
||||
"""The reward text a week transition produced reaches the snapshot."""
|
||||
@ -166,8 +166,8 @@ class TestGatherStatus:
|
||||
):
|
||||
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
|
||||
|
||||
assert snap.week.counted_count == 0
|
||||
assert snap.today.entry_types == ()
|
||||
assert not snap.week.counted_count
|
||||
assert not snap.today.entry_types
|
||||
|
||||
def test_now_defaults_to_current_time_when_omitted(self, tmp_path: Path) -> None:
|
||||
"""Covers the ``now is None`` branch — just needs to not raise."""
|
||||
@ -187,9 +187,9 @@ class TestDayStatus:
|
||||
def test_no_entries_not_sick(self) -> None:
|
||||
"""No entries not sick."""
|
||||
day = _day_status(date(2024, 1, 5), [], frozenset())
|
||||
assert day.entry_types == ()
|
||||
assert not day.entry_types
|
||||
assert day.counted is False
|
||||
assert day.day_count == 0
|
||||
assert not day.day_count
|
||||
assert day.is_sick_day is False
|
||||
|
||||
def test_no_entries_but_sick(self) -> None:
|
||||
@ -200,10 +200,10 @@ class TestDayStatus:
|
||||
def test_non_dict_entries_dropped(self) -> None:
|
||||
"""Corrupt log data (non-dict entries in the list) are skipped."""
|
||||
day = _day_status(date(2024, 1, 5), ["corrupt-string-entry"], frozenset())
|
||||
assert day.entry_types == ()
|
||||
assert not day.entry_types
|
||||
assert day.counted is False
|
||||
assert day.day_count == 0
|
||||
assert day.source == ""
|
||||
assert not day.day_count
|
||||
assert not day.source
|
||||
|
||||
def test_multiple_entries_each_count_individually(self) -> None:
|
||||
"""Two verified + two manual on one day → day_count 4, all types listed."""
|
||||
|
||||
@ -129,8 +129,8 @@ class TestGatherStatusLockSuppression:
|
||||
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
|
||||
|
||||
assert snap.week.counted_count == 5
|
||||
assert snap.week.remaining == 0
|
||||
assert snap.week.extra == 0
|
||||
assert not snap.week.remaining
|
||||
assert not snap.week.extra
|
||||
assert snap.lock_explanation.stage == "weekly_minimum_met"
|
||||
|
||||
def test_extra_workouts_beyond_minimum(self, tmp_path: Path) -> None:
|
||||
@ -162,4 +162,4 @@ class TestGatherStatusLockSuppression:
|
||||
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
|
||||
|
||||
assert snap.week.extra == 1
|
||||
assert snap.week.remaining == 0
|
||||
assert not snap.week.remaining
|
||||
|
||||
@ -5,7 +5,7 @@ Split from test_status.py to stay under the repo's 400-line file limit.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@ -133,7 +133,6 @@ class TestRunStatusMinimumStatus:
|
||||
self, tmp_path: Path, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""Pin today to Monday so the loop hits d > today on day 2, covering line 64."""
|
||||
from datetime import datetime
|
||||
|
||||
fake_now = datetime(2026, 6, 22, 12, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ Split out of test_status.py for the 250-line cap.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
@ -33,7 +33,6 @@ class TestWeeklyTotalUsesPerWorkoutRule:
|
||||
days are supposed to support. Also covers manual workouts counting
|
||||
individually (no once-per-day collapse), same as verified ones.
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
today = datetime.now(tz=UTC).astimezone().date().isoformat()
|
||||
log_file = tmp_path / "log.json"
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""The status window shows the workout streak and early-bird state it tracks."""
|
||||
# pylint: disable=protected-access
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@ -46,8 +46,8 @@ class _Fakes:
|
||||
self.recover = MagicMock(side_effect=lambda: self.recovery)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fakes(monkeypatch: pytest.MonkeyPatch) -> _Fakes:
|
||||
@pytest.fixture(name="fakes")
|
||||
def fakes_fixture(monkeypatch: pytest.MonkeyPatch) -> _Fakes:
|
||||
"""Patch config loading, the credential store, providers and recovery."""
|
||||
fake = _Fakes()
|
||||
monkeypatch.setattr(
|
||||
|
||||
@ -70,16 +70,16 @@ class Credit:
|
||||
return BatchOutcome(ingested=tuple(ids), credited=len(ids))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def credit(monkeypatch: pytest.MonkeyPatch) -> Credit:
|
||||
@pytest.fixture(name="credit")
|
||||
def credit_fixture(monkeypatch: pytest.MonkeyPatch) -> Credit:
|
||||
"""Replace the real (locker-building) credit step with a recorder."""
|
||||
fake = Credit()
|
||||
monkeypatch.setattr(_stream_cache._poke_credit, "credit_sessions", fake)
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache(credit: Credit) -> DeviceLogCache:
|
||||
@pytest.fixture(name="cache")
|
||||
def cache_fixture(credit: Credit) -> DeviceLogCache:
|
||||
"""A cache whose own device dir is ``pc-own``."""
|
||||
gate = threading.Lock()
|
||||
credit.gate = gate
|
||||
|
||||
@ -20,16 +20,16 @@ from screen_locker.tests.test_stream_cache import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def credit(monkeypatch: pytest.MonkeyPatch) -> Credit:
|
||||
@pytest.fixture(name="credit")
|
||||
def credit_fixture(monkeypatch: pytest.MonkeyPatch) -> Credit:
|
||||
"""Replace the real (locker-building) credit step with a recorder."""
|
||||
fake = Credit()
|
||||
monkeypatch.setattr(_stream_cache._poke_credit, "credit_sessions", fake)
|
||||
return fake
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache(credit: Credit) -> DeviceLogCache:
|
||||
@pytest.fixture(name="cache")
|
||||
def cache_fixture(credit: Credit) -> DeviceLogCache:
|
||||
"""A cache whose own device dir is ``pc-own``."""
|
||||
gate = threading.Lock()
|
||||
credit.gate = gate
|
||||
|
||||
@ -120,7 +120,7 @@ class TestFirebaseDegradationIsVisible:
|
||||
|
||||
_workout_sync.sync_client()
|
||||
|
||||
assert _sync_client.degraded_sources() == []
|
||||
assert not _sync_client.degraded_sources()
|
||||
|
||||
def test_successful_recovery_returns_a_working_client(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
@ -152,7 +152,7 @@ class TestFirebaseDegradationIsVisible:
|
||||
|
||||
recovered = _workout_sync.sync_client()
|
||||
assert isinstance(recovered, ReachableClient)
|
||||
assert _sync_client.degraded_sources() == [], (
|
||||
assert not _sync_client.degraded_sources(), (
|
||||
"a source that recovered is not degraded"
|
||||
)
|
||||
|
||||
@ -226,4 +226,4 @@ class TestFirebaseDegradationIsVisible:
|
||||
|
||||
_workout_sync.sync_client()
|
||||
|
||||
assert _sync_client.degraded_sources() == []
|
||||
assert not _sync_client.degraded_sources()
|
||||
|
||||
@ -64,7 +64,7 @@ class TestRejectedCredentialTriggersRecovery:
|
||||
"a rejected credential must trigger the same self-heal as a "
|
||||
"failed construction"
|
||||
)
|
||||
assert _sync_client.degraded_sources() == []
|
||||
assert not _sync_client.degraded_sources()
|
||||
|
||||
def test_a_still_rejected_credential_is_degraded_loudly(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
|
||||
@ -64,7 +64,7 @@ class TestPushState:
|
||||
status = _gather(tmp_path)
|
||||
|
||||
assert status.pushed is False
|
||||
assert status.peer_count == 0
|
||||
assert not status.peer_count
|
||||
assert status.last_push is None
|
||||
|
||||
def test_a_pushed_rev_counts_as_pushed(self, tmp_path: Path) -> None:
|
||||
@ -86,7 +86,7 @@ class TestPushState:
|
||||
status = _gather(tmp_path)
|
||||
|
||||
assert status.pushed is False
|
||||
assert status.peer_count == 0
|
||||
assert not status.peer_count
|
||||
|
||||
def test_a_non_object_state_file_reads_as_nothing_known(
|
||||
self, tmp_path: Path
|
||||
@ -101,7 +101,7 @@ class TestPushState:
|
||||
json.dumps({"pushed_rev": "abc", "peer_revs": "not-a-map"})
|
||||
)
|
||||
|
||||
assert _gather(tmp_path).peer_count == 0
|
||||
assert not _gather(tmp_path).peer_count
|
||||
|
||||
def test_an_unstattable_state_file_has_no_timestamp(self, tmp_path: Path) -> None:
|
||||
"""A path whose parent is a file cannot be stat'd; that is not fatal."""
|
||||
|
||||
@ -55,7 +55,7 @@ class TestTombstoneRecords:
|
||||
"""Ledger → crdt_sync Records, loud about anything it cannot push."""
|
||||
|
||||
def test_missing_ledger_is_empty(self, tmp_path: Path) -> None:
|
||||
assert tombstone_records(tmp_path / "absent.json") == {}
|
||||
assert not tombstone_records(tmp_path / "absent.json")
|
||||
|
||||
def test_ledgered_id_becomes_a_deleted_record(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "sync_tombstones.json"
|
||||
@ -79,7 +79,7 @@ class TestTombstoneRecords:
|
||||
) -> None:
|
||||
path = tmp_path / "sync_tombstones.json"
|
||||
path.write_text("{not json")
|
||||
assert tombstone_records(path) == {}
|
||||
assert not tombstone_records(path)
|
||||
assert "unreadable" in caplog.text
|
||||
|
||||
def test_non_object_ledger_is_ignored_and_warns(
|
||||
@ -87,20 +87,20 @@ class TestTombstoneRecords:
|
||||
) -> None:
|
||||
path = tmp_path / "sync_tombstones.json"
|
||||
path.write_text("[]")
|
||||
assert tombstone_records(path) == {}
|
||||
assert not tombstone_records(path)
|
||||
assert "not a JSON object" in caplog.text
|
||||
|
||||
def test_non_dict_entries_are_dropped(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "sync_tombstones.json"
|
||||
path.write_text(json.dumps({_RID: "garbage"}))
|
||||
assert tombstone_records(path) == {}
|
||||
assert not tombstone_records(path)
|
||||
|
||||
def test_entry_without_usable_hlc_is_skipped_and_warns(
|
||||
self, tmp_path: Path, caplog
|
||||
) -> None:
|
||||
path = tmp_path / "sync_tombstones.json"
|
||||
path.write_text(json.dumps({_RID: {"reason": "x"}}))
|
||||
assert tombstone_records(path) == {}
|
||||
assert not tombstone_records(path)
|
||||
assert "no usable deleted_hlc" in caplog.text
|
||||
|
||||
|
||||
|
||||
@ -30,8 +30,8 @@ if TYPE_CHECKING:
|
||||
_CUTOVER = date(2026, 10, 10)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tutor(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[Any]:
|
||||
@pytest.fixture(name="tutor")
|
||||
def tutor_fixture(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[Any]:
|
||||
"""The tutor registry from ``_CUTOVER``, its ledger signed, today pinned.
|
||||
|
||||
Args:
|
||||
|
||||
@ -66,13 +66,13 @@ class TestTrackSummary:
|
||||
assert summary["ended_at"] == (_START + timedelta(seconds=600)).timestamp()
|
||||
|
||||
def test_standing_and_vehicles_are_not_moving(self) -> None:
|
||||
assert track_summary(walk(600, speed=0.1))["moving_seconds"] == 0
|
||||
assert track_summary(walk(600, speed=8.0))["moving_seconds"] == 0
|
||||
assert not track_summary(walk(600, speed=0.1))["moving_seconds"]
|
||||
assert not track_summary(walk(600, speed=8.0))["moving_seconds"]
|
||||
|
||||
def test_a_gps_dropout_earns_nothing(self) -> None:
|
||||
"""Two points 100 s apart at walking pace: motion unseen, not counted."""
|
||||
summary = track_summary(activity([(0, "0.1", None), (100, "130", None)]))
|
||||
assert summary["moving_seconds"] == 0
|
||||
assert not summary["moving_seconds"]
|
||||
|
||||
def test_unusable_points_are_skipped(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
|
||||
@ -61,7 +61,7 @@ class TestWorkoutCreditsToday:
|
||||
def test_no_entries_is_zero(self, tmp_path: Path) -> None:
|
||||
"""An untouched day earns nothing."""
|
||||
with _with_log({}, tmp_path):
|
||||
assert workout_credits_today(now=_SATURDAY) == 0
|
||||
assert not workout_credits_today(now=_SATURDAY)
|
||||
|
||||
def test_a_workout_counts(self, tmp_path: Path) -> None:
|
||||
"""A verified workout today earns a credit."""
|
||||
@ -79,19 +79,19 @@ class TestWorkoutCreditsToday:
|
||||
def test_a_relaxed_day_skip_earns_nothing(self, tmp_path: Path) -> None:
|
||||
"""Dismissing the popup is not a workout — so it is a 6h gaming day."""
|
||||
with _with_log({"2026-08-29": [_entry("relaxed_day_skip")]}, tmp_path):
|
||||
assert workout_credits_today(now=_SATURDAY) == 0
|
||||
assert not workout_credits_today(now=_SATURDAY)
|
||||
|
||||
def test_yesterdays_workout_does_not_count_today(self, tmp_path: Path) -> None:
|
||||
"""The budget is per gaming day, so only today's entries count."""
|
||||
with _with_log({"2026-08-28": [_entry("phone_verified")]}, tmp_path):
|
||||
assert workout_credits_today(now=_SATURDAY) == 0
|
||||
assert not workout_credits_today(now=_SATURDAY)
|
||||
|
||||
def test_non_dict_entries_are_ignored(self, tmp_path: Path) -> None:
|
||||
"""A malformed entry must not crash the endpoint."""
|
||||
log = tmp_path / "log.json"
|
||||
log.write_text(json.dumps({"2026-08-29": ["junk"]}), encoding="utf-8")
|
||||
with patch(f"{_PKG}.LOG_FILE", log):
|
||||
assert workout_credits_today(now=_SATURDAY) == 0
|
||||
assert not workout_credits_today(now=_SATURDAY)
|
||||
|
||||
|
||||
class TestStatusPayload:
|
||||
|
||||
@ -32,17 +32,17 @@ _PKG = "screen_locker._web_server"
|
||||
# a server we started ourselves. Captured at import time, before any fixture
|
||||
# runs, which is the only moment the real function is still reachable. The
|
||||
# fixture's own docstring says tests needing a real probe patch it locally.
|
||||
_REAL_CREATE_CONNECTION = socket.create_connection
|
||||
_real_create_connection = socket.create_connection
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_loopback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@pytest.fixture(name="real_loopback")
|
||||
def real_loopback_fixture(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Restore real socket connections for tests that drive the server.
|
||||
|
||||
Args:
|
||||
monkeypatch: pytest's attribute patcher, which undoes this on teardown.
|
||||
"""
|
||||
monkeypatch.setattr(socket, "create_connection", _REAL_CREATE_CONNECTION)
|
||||
monkeypatch.setattr(socket, "create_connection", _real_create_connection)
|
||||
|
||||
|
||||
@contextmanager
|
||||
@ -206,5 +206,5 @@ class TestServeEntryPoints:
|
||||
def test_main_returns_zero(self) -> None:
|
||||
"""The console-script entry point reports success on a clean exit."""
|
||||
with patch(f"{_PKG}.serve") as served:
|
||||
assert main() == 0
|
||||
assert not main()
|
||||
served.assert_called_once()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user