screen-locker/screen_locker/tests/test_verify_workout.py
Krzysztof kuhy Rudnicki ec0946f64e Grade against 3.14 too, not just run on it
`requires-python` and the CI pin already said 3.14, but ruff, mypy and pylint
were still grading against 3.10 -- three tools judging an interpreter nothing
runs any more, and ruff withholding the pyupgrade fixes that only become safe
at the newer target. `pin_ci_python.py` rewrites exactly these three knobs.

What the stale target was hiding: 118 sites on `datetime.timezone.utc` instead
of `datetime.UTC` (UP017), two classes still written with `Generic[...]` rather
than PEP 695 syntax, and ruff's stable formatter applying PEP 758 --
`except (A, B):` becomes `except A, B:`. That last one makes the source
3.14-only at the syntax level, which is what `requires-python = ">=3.14"`
already promised; kuhy chose it deliberately over holding ruff a release back
(2026-08-29).

The PEP 695 fix needed a hand: ruff rewrote `class WidgetGroup(Generic[_W])`
to `class WidgetGroup[W: tk.Misc]` but left the body annotated with the
module-level `_W`, a *different* type variable -- mypy caught it as "a function
returning TypeVar should receive at least one argument containing the same
TypeVar". Renamed the body to the new parameter, gave `FrameGroup.__init__`
the concrete `list[tk.Frame]` it always wrapped, and deleted the two now-dead
module-level TypeVars.

ruff, ruff-format, mypy --strict and both pylint passes clean at the new target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SfnJmxxsZ534AKk16rC2Gr
2026-08-29 11:42:47 +02:00

247 lines
7.4 KiB
Python

"""Tests for post-sick-day workout verification (--verify-workout)."""
from __future__ import annotations
from datetime import UTC, datetime
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import pytest
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestIsSickDayToday:
"""Tests for _is_sick_day_today method.
sick_day is tracked in sick_history.json (via _sick_tracker.py) as the
sole source of truth -- not in log.json. The autouse
_isolate_sick_history fixture redirects SICK_HISTORY_FILE to
tmp_path/sick_history.json for every test.
"""
def test_no_history_file(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return False when sick_history.json does not exist."""
locker = create_locker(mock_tk, tmp_path)
assert locker._is_sick_day_today() is False
def test_today_not_sick_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return False when today is not in sick_history's sick_days list."""
locker = create_locker(mock_tk, tmp_path)
history_file = tmp_path / "sick_history.json"
history_file.write_text(json.dumps({"sick_days": ["2020-01-01"]}))
assert locker._is_sick_day_today() is False
def test_today_is_sick_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return True when today is in sick_history's sick_days list."""
locker = create_locker(mock_tk, tmp_path)
today = datetime.now(tz=UTC).strftime("%Y-%m-%d")
history_file = tmp_path / "sick_history.json"
history_file.write_text(json.dumps({"sick_days": [today]}))
assert locker._is_sick_day_today() is True
class TestVerifyOnlyInit:
"""Tests for ScreenLocker initialization with verify_only=True."""
def test_verify_only_exits_when_no_sick_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Exit when verify_only but no sick day logged today."""
mock_sys_exit.side_effect = SystemExit(0)
with pytest.raises(SystemExit):
create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=False,
)
mock_sys_exit.assert_called_once_with(0)
def test_verify_only_starts_when_sick_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Start verification window when sick day is logged."""
locker = create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=True,
)
assert locker.verify_only is True
mock_sys_exit.assert_not_called()
def test_verify_only_sets_title(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Verify window title includes [VERIFY]."""
locker = create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=True,
)
locker.root.title.assert_called_with("Workout Locker [VERIFY]")
def test_close_and_run_use_root_directly(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No LockWindow is built for verify_only; close()/run() use root."""
locker = create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=True,
)
assert locker._lock is None
locker.run()
locker.root.mainloop.assert_called_once()
locker.close()
locker.root.destroy.assert_called_once()
class TestSetupVerifyWindow:
"""Tests for _setup_verify_window."""
def test_sizes_itself_to_content_with_a_floor(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Verify window fits its content, with 600x400 as a floor not a cap.
This asserted ``geometry("600x400")``, which pinned the window to a size
smaller than what it displays -- the same defect that left the
relaxed-day prompt's 862px button row sheared inside a 700px window.
``geometry("")`` hands sizing to the geometry manager, ``minsize`` keeps
the old dimensions as a floor, and ``maxsize`` stops a wide screen's
content pushing the window off-display.
"""
locker = create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=True,
)
locker.root.geometry.assert_called_with("")
locker.root.minsize.assert_called_once()
locker.root.maxsize.assert_called_once()
locker.root.configure.assert_called_with(
bg=locker._colors.bg,
cursor="arrow",
)
locker.root.protocol.assert_called_with(
"WM_DELETE_WINDOW",
locker.close,
)
class TestStartVerifyWorkoutCheck:
"""Tests for _start_verify_workout_check."""
def test_starts_phone_check_and_polls(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Start phone verification and begin polling."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("verified", "ok")),
)
object.__setattr__(
locker,
"_poll_verify_workout_check",
MagicMock(),
)
locker._start_verify_workout_check()
assert locker._phone_future is not None
locker._poll_verify_workout_check.assert_called_once()
class TestPollVerifyWorkoutCheck:
"""Tests for _poll_verify_workout_check."""
def test_schedules_retry_when_not_done(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Re-schedule polling when future is not done."""
locker = create_locker(mock_tk, tmp_path)
mock_future = MagicMock()
mock_future.done.return_value = False
locker._phone_future = mock_future
locker._poll_verify_workout_check()
locker.root.after.assert_called_with(
500,
locker._poll_verify_workout_check,
)
def test_handles_result_when_done(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Route to result handler when future is done."""
locker = create_locker(mock_tk, tmp_path)
mock_future = MagicMock()
mock_future.done.return_value = True
mock_future.result.return_value = ("verified", "Found workout")
locker._phone_future = mock_future
object.__setattr__(
locker,
"_handle_verify_workout_result",
MagicMock(),
)
locker._poll_verify_workout_check()
locker._handle_verify_workout_result.assert_called_once_with(
"verified",
"Found workout",
)