screen-locker/screen_locker/tests/test_ui_and_timers.py
Krzysztof kuhy Rudnicki 075f7bae8c
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Cover the recovery branches, restoring the 100% gate
The previous session reported the new code green, but measured it with
--no-cov (step 1 of finish_2026_08_24.sh passes that flag), so seven branches
in the recovery path had never been exercised. Coverage was 99.78%.

Most of the gap was the auto-recovery itself: whether healing succeeded,
whether the retry after healing succeeded, and what gets recorded when it
does not. That is the code which ran in production for the first time today,
and none of its outcomes were pinned.

Also covers the donor search on a machine with no ~/.config, siblings whose
cache is readable but holds no usable token, and the two healthy paths that
must stay silent — an always-on "this may be wrong" panel is one nobody reads.

Writing the UI test surfaced that collect_source_findings is never empty for
a fresh tmp_path, because an empty sync is itself a finding; the stub is
there so the branch under test is "nothing to report".

1115 passed, coverage back to 100%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EJmPLyT8NU4DzDJPV1KEX5
2026-08-24 13:18:01 +02:00

228 lines
7.5 KiB
Python

"""Tests for UI transitions, timer logic, and sick day screens."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from screen_locker import _sync_client, _ui_flows
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
import pytest
class TestUITransitions:
"""Tests for UI state transitions."""
def test_clear_container(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test clear_container destroys all child widgets."""
locker = create_locker(mock_tk, tmp_path)
# Set up mock children
mock_child1 = MagicMock()
mock_child2 = MagicMock()
locker.container.first.winfo_children.return_value = [
mock_child1,
mock_child2,
]
locker.clear_container()
mock_child1.destroy.assert_called_once()
mock_child2.destroy.assert_called_once()
def test_unlock_screen_saves_and_schedules_close(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test unlock_screen saves log and schedules close."""
locker = create_locker(mock_tk, tmp_path)
locker.log_file = tmp_path / "log.json"
locker.workout_data = {"type": "phone_verified"}
locker.unlock_screen()
# Check that after() was called to schedule close
locker.root.after.assert_called()
def test_lockout_starts_countdown(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test lockout initializes countdown timer."""
locker = create_locker(mock_tk, tmp_path)
locker.lockout()
# lockout() sets remaining_time to lockout_time (10 in demo mode)
# then calls update_lockout_countdown() which decrements it by 1
assert locker.remaining_time == 9 # 10 - 1 after first update
def test_close_destroys_root_and_exits(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test close destroys root window and exits."""
locker = create_locker(mock_tk, tmp_path)
locker.close()
locker.root.destroy.assert_called_once()
mock_sys_exit.assert_called_with(0)
class TestTimerLogic:
"""Tests for timer countdown logic."""
def test_update_lockout_countdown_decrements(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test countdown decrements remaining time."""
locker = create_locker(mock_tk, tmp_path)
locker.remaining_time = 5
locker.countdown_label = MagicMock()
locker.update_lockout_countdown()
assert locker.remaining_time == 4
locker.root.after.assert_called_with(1000, locker.update_lockout_countdown)
def test_update_lockout_countdown_at_zero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test countdown at zero restarts phone check."""
locker = create_locker(mock_tk, tmp_path)
locker.remaining_time = 0
locker.countdown_label = MagicMock()
object.__setattr__(locker, "_start_phone_check", MagicMock())
locker.update_lockout_countdown()
locker._start_phone_check.assert_called_once()
class TestAskIfSick:
"""Tests for ask_if_sick method."""
def test_ask_if_sick_invokes_justification_dialog(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""ask_if_sick now delegates to the structured justification dialog."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_sick_justification", MagicMock())
locker.ask_if_sick()
locker._show_sick_justification.assert_called_once_with()
class TestGetSickDayStatus:
"""Tests for _get_sick_day_status method."""
def test_already_adjusted_today(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test status when sick mode already used today."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_sick_mode_used_today", MagicMock(return_value=True)
)
text, color = locker._get_sick_day_status()
assert "already adjusted" in text
assert color == locker._colors.warning
def test_adjustment_success(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test status when shutdown time adjusted successfully."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_sick_mode_used_today", MagicMock(return_value=False)
)
object.__setattr__(
locker, "_adjust_shutdown_time_earlier", MagicMock(return_value=True)
)
text, color = locker._get_sick_day_status()
assert "earlier" in text
assert color == locker._colors.success
def test_adjustment_failure(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test status when adjustment fails."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_sick_mode_used_today", MagicMock(return_value=False)
)
object.__setattr__(
locker, "_adjust_shutdown_time_earlier", MagicMock(return_value=False)
)
text, color = locker._get_sick_day_status()
assert "Could not adjust" in text
assert color == locker._colors.danger
class TestShowRetryAndSick:
"""Tests for _show_retry_and_sick method."""
def test_displays_buttons(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test _show_retry_and_sick shows retry and sick buttons."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "clear_container", MagicMock())
locker._show_retry_and_sick("Test message")
locker.clear_container.assert_called_once()
mock_tk.Label.assert_called()
mock_tk.Button.assert_called()
def test_healthy_sources_add_no_diagnosis_block(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""With every source healthy the screen must not hedge.
The "why this may be wrong" panel exists for 2026-08-24, when the
machine genuinely could not see the workout. When it *can* see, an
unconditional caveat would teach the user to ignore the one message
that matters.
"""
_sync_client.clear_degraded_sources()
# A healthy machine has a readable backend AND a non-empty log, which
# this tmp_path fixture cannot provide -- an empty sync is itself a
# finding. Stub the collector so the branch under test is "nothing to
# report", not "no workouts synced".
monkeypatch.setattr(_ui_flows, "collect_source_findings", list)
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "clear_container", MagicMock())
locker._show_retry_and_sick("Test message")
shown = " ".join(
str(call.kwargs.get("text", "")) for call in mock_tk.Label.call_args_list
)
assert "may be wrong" not in shown