From d8f8b218273b3478e91dd42b91ae796e86b520fc Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 21 Mar 2026 17:51:36 +0100 Subject: [PATCH] test: achieve 100% branch coverage across all python_pkg packages - Add comprehensive tests for all packages (3572 tests, 100% branch coverage) - Split oversized test files to stay under 500-line limit - Add per-file ruff ignores for test-appropriate suppressions - Fix _cache_decks.py to properly convert JSON lists to tuples - Add session-scoped conftest fixture for logging handler cleanup (Python 3.14) - Update ruff pre-commit hook to v0.15.2 - Add codespell ignore words for test data - Add generated output files to .gitignore --- screen_locker/tests/test_init_and_log.py | 13 + .../tests/test_phone_verification_part2.py | 268 +++++++++++ screen_locker/tests/test_shutdown_part2.py | 420 ++++++++++++++++++ screen_locker/tests/test_shutdown_part3.py | 316 +++++++++++++ screen_locker/tests/test_ui_and_timers.py | 72 +++ .../tests/test_ui_and_timers_part2.py | 47 ++ screen_locker/tests/test_ui_flows_part2.py | 35 ++ screen_locker/tests/test_verify_data.py | 34 ++ 8 files changed, 1205 insertions(+) create mode 100644 screen_locker/tests/test_phone_verification_part2.py create mode 100644 screen_locker/tests/test_shutdown_part2.py create mode 100644 screen_locker/tests/test_shutdown_part3.py create mode 100644 screen_locker/tests/test_ui_and_timers_part2.py create mode 100644 screen_locker/tests/test_ui_flows_part2.py diff --git a/screen_locker/tests/test_init_and_log.py b/screen_locker/tests/test_init_and_log.py index 46095a3..ebaae89 100644 --- a/screen_locker/tests/test_init_and_log.py +++ b/screen_locker/tests/test_init_and_log.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import datetime, timezone import json +import tkinter as tk from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock @@ -390,3 +391,15 @@ class TestAdjustShutdownTimeLater: result = locker._adjust_shutdown_time_later() assert result is False + + +class TestGrabInput: + """Tests for _grab_input method.""" + + def test_production_global_grab_tcl_error( + self, mock_tk: MagicMock, _mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Test production mode falls back when global grab fails.""" + mock_tk.Tk.return_value.grab_set_global.side_effect = tk.TclError("grab failed") + locker = create_locker(mock_tk, tmp_path, demo_mode=False) + assert locker.demo_mode is False diff --git a/screen_locker/tests/test_phone_verification_part2.py b/screen_locker/tests/test_phone_verification_part2.py new file mode 100644 index 0000000..6150864 --- /dev/null +++ b/screen_locker/tests/test_phone_verification_part2.py @@ -0,0 +1,268 @@ +"""Tests for phone verification coverage gaps (part 2).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestGetWirelessSerial: + """Tests for _get_wireless_serial method.""" + + def test_returns_wireless_serial( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns ip:port serial for a wireless device.""" + locker = create_locker(mock_tk, tmp_path) + output = "List of devices attached\n192.168.1.42:5555\tdevice\n" + with patch.object(locker, "_run_adb", return_value=(True, output)): + result = locker._get_wireless_serial() + assert result == "192.168.1.42:5555" + + def test_returns_none_when_adb_fails( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns None when adb devices fails.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object(locker, "_run_adb", return_value=(False, "")): + result = locker._get_wireless_serial() + assert result is None + + def test_returns_none_when_no_wireless_device( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns None when only USB devices are connected.""" + locker = create_locker(mock_tk, tmp_path) + output = "List of devices attached\nABC123DEF456\tdevice\n" + with patch.object(locker, "_run_adb", return_value=(True, output)): + result = locker._get_wireless_serial() + assert result is None + + def test_skips_offline_wireless_device( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test skips offline wireless devices.""" + locker = create_locker(mock_tk, tmp_path) + output = "List of devices attached\n192.168.1.42:5555\toffline\n" + with patch.object(locker, "_run_adb", return_value=(True, output)): + result = locker._get_wireless_serial() + assert result is None + + +class TestTryAdbConnect: + """Tests for _try_adb_connect method.""" + + def test_successful_connect( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test successful ADB connect.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object( + locker, "_run_adb", return_value=(True, "connected to 192.168.1.42:5555") + ): + result = locker._try_adb_connect("192.168.1.42:5555") + assert result is True + + def test_failed_connect_unable( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test connect failure with 'unable' in output.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object( + locker, "_run_adb", return_value=(False, "unable to connect") + ): + result = locker._try_adb_connect("192.168.1.42:5555") + assert result is False + + def test_failed_connect_with_failed( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test connect failure with 'failed' in output.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object( + locker, + "_run_adb", + return_value=(False, "connected but failed to authenticate"), + ): + result = locker._try_adb_connect("192.168.1.42:5555") + assert result is False + + def test_no_connected_in_output( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test connect failure when 'connected' not in output.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object( + locker, "_run_adb", return_value=(False, "some random output") + ): + result = locker._try_adb_connect("192.168.1.42:5555") + assert result is False + + +class TestGetLocalSubnetPrefix: + """Tests for _get_local_subnet_prefix method.""" + + def test_returns_prefix( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns first three octets of local IP.""" + locker = create_locker(mock_tk, tmp_path) + mock_sock = MagicMock() + mock_sock.getsockname.return_value = ("192.168.1.100", 12345) + mock_sock.__enter__ = MagicMock(return_value=mock_sock) + mock_sock.__exit__ = MagicMock(return_value=False) + with patch( + "python_pkg.screen_locker._phone_verification.socket.socket", + return_value=mock_sock, + ): + result = locker._get_local_subnet_prefix() + assert result == "192.168.1" + + def test_returns_none_on_oserror( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns None when socket raises OSError.""" + locker = create_locker(mock_tk, tmp_path) + with patch( + "python_pkg.screen_locker._phone_verification.socket.socket", + side_effect=OSError("no network"), + ): + result = locker._get_local_subnet_prefix() + assert result is None + + +class TestTryWirelessReconnect: + """Tests for _try_wireless_reconnect method.""" + + def test_returns_false_when_no_prefix( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when subnet prefix can't be determined.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object(locker, "_get_local_subnet_prefix", return_value=None): + result = locker._try_wireless_reconnect() + assert result is False + + def test_returns_true_when_probe_succeeds( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns True when a probe finds the phone.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), + patch.object(locker, "_try_adb_connect", return_value=True), + patch.object(locker, "_has_adb_device", return_value=True), + patch( + "python_pkg.screen_locker._phone_verification.socket.create_connection", + ) as mock_conn, + ): + mock_sock = MagicMock() + mock_sock.__enter__ = MagicMock(return_value=mock_sock) + mock_sock.__exit__ = MagicMock(return_value=False) + mock_conn.return_value = mock_sock + result = locker._try_wireless_reconnect() + assert result is True + + def test_returns_false_when_no_probe_succeeds( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when no probe finds the phone.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), + patch( + "python_pkg.screen_locker._phone_verification.socket.create_connection", + side_effect=OSError("refused"), + ), + ): + result = locker._try_wireless_reconnect() + assert result is False + + def test_probe_connect_succeeds_but_no_device( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test probe passes socket but adb_connect succeeds without device.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), + patch.object(locker, "_try_adb_connect", return_value=True), + patch.object(locker, "_has_adb_device", return_value=False), + patch( + "python_pkg.screen_locker._phone_verification.socket.create_connection", + ) as mock_conn, + ): + mock_sock = MagicMock() + mock_sock.__enter__ = MagicMock(return_value=mock_sock) + mock_sock.__exit__ = MagicMock(return_value=False) + mock_conn.return_value = mock_sock + result = locker._try_wireless_reconnect() + assert result is False + + def test_probe_adb_connect_fails( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test probe where socket connects but adb connect fails.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"), + patch.object(locker, "_try_adb_connect", return_value=False), + patch( + "python_pkg.screen_locker._phone_verification.socket.create_connection", + ) as mock_conn, + ): + mock_sock = MagicMock() + mock_sock.__enter__ = MagicMock(return_value=mock_sock) + mock_sock.__exit__ = MagicMock(return_value=False) + mock_conn.return_value = mock_sock + result = locker._try_wireless_reconnect() + assert result is False diff --git a/screen_locker/tests/test_shutdown_part2.py b/screen_locker/tests/test_shutdown_part2.py new file mode 100644 index 0000000..f687d1e --- /dev/null +++ b/screen_locker/tests/test_shutdown_part2.py @@ -0,0 +1,420 @@ +"""Tests for shutdown schedule adjustment coverage gaps (part 2).""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestApplyEarlierShutdown: + """Tests for _apply_earlier_shutdown method.""" + + def test_returns_false_when_no_config( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when config can't be read.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object(locker, "_read_shutdown_config", return_value=None): + assert locker._apply_earlier_shutdown("2026-03-21") is False + + def test_returns_false_when_save_state_fails( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when saving state fails.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_read_shutdown_config", return_value=(21, 20, 8)), + patch.object(locker, "_save_sick_day_state", return_value=False), + ): + assert locker._apply_earlier_shutdown("2026-03-21") is False + + def test_success_applies_earlier_hours( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test successful application of earlier shutdown hours.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_read_shutdown_config", return_value=(21, 20, 8)), + patch.object(locker, "_save_sick_day_state", return_value=True), + patch.object( + locker, "_write_shutdown_config", return_value=True + ) as mock_write, + ): + result = locker._apply_earlier_shutdown("2026-03-21") + assert result is True + mock_write.assert_called_once_with(20, 19, 8) + + def test_clamps_to_minimum_18( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test hours are clamped to minimum of 18.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_read_shutdown_config", return_value=(18, 18, 8)), + patch.object(locker, "_save_sick_day_state", return_value=True), + patch.object( + locker, "_write_shutdown_config", return_value=True + ) as mock_write, + ): + locker._apply_earlier_shutdown("2026-03-21") + mock_write.assert_called_once_with(18, 18, 8) + + +class TestAdjustShutdownTimeEarlier: + """Tests for _adjust_shutdown_time_earlier method.""" + + def test_returns_false_when_sick_mode_used_today( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when sick mode already used today.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_restore_original_config_if_needed"), + patch.object(locker, "_sick_mode_used_today", return_value=True), + ): + assert locker._adjust_shutdown_time_earlier() is False + + def test_success( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test successful adjustment.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_restore_original_config_if_needed"), + patch.object(locker, "_sick_mode_used_today", return_value=False), + patch.object(locker, "_apply_earlier_shutdown", return_value=True), + ): + assert locker._adjust_shutdown_time_earlier() is True + + def test_handles_oserror( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test handles OSError during apply.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_restore_original_config_if_needed"), + patch.object(locker, "_sick_mode_used_today", return_value=False), + patch.object( + locker, + "_apply_earlier_shutdown", + side_effect=OSError("fail"), + ), + ): + assert locker._adjust_shutdown_time_earlier() is False + + def test_handles_value_error( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test handles ValueError during apply.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_restore_original_config_if_needed"), + patch.object(locker, "_sick_mode_used_today", return_value=False), + patch.object( + locker, + "_apply_earlier_shutdown", + side_effect=ValueError("bad"), + ), + ): + assert locker._adjust_shutdown_time_earlier() is False + + +class TestAdjustShutdownTimeLater: + """Tests for _adjust_shutdown_time_later method.""" + + def test_returns_false_when_no_config( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when config is missing.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object(locker, "_read_shutdown_config", return_value=None): + assert locker._adjust_shutdown_time_later() is False + + def test_success_applies_later_hours( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test successful later adjustment with restore flag.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_read_shutdown_config", return_value=(20, 19, 8)), + patch.object( + locker, "_write_shutdown_config", return_value=True + ) as mock_write, + ): + result = locker._adjust_shutdown_time_later() + assert result is True + mock_write.assert_called_once_with(22, 21, 8, restore=True) + + def test_clamps_to_max_23( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test hours are clamped to maximum of 23.""" + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_read_shutdown_config", return_value=(22, 23, 8)), + patch.object( + locker, "_write_shutdown_config", return_value=True + ) as mock_write, + ): + locker._adjust_shutdown_time_later() + mock_write.assert_called_once_with(23, 23, 8, restore=True) + + def test_handles_oserror( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test handles OSError.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object( + locker, + "_read_shutdown_config", + side_effect=OSError("fail"), + ): + assert locker._adjust_shutdown_time_later() is False + + +class TestSickModeUsedToday: + """Tests for _sick_mode_used_today method.""" + + def test_returns_false_when_no_file( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when state file doesn't exist.""" + locker = create_locker(mock_tk, tmp_path) + mock_file = MagicMock() + mock_file.exists.return_value = False + with patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + mock_file, + ): + assert locker._sick_mode_used_today() is False + + def test_returns_true_when_used_today( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns True when state matches today.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + with patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ): + from datetime import datetime, timezone + + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + state_file.write_text(json.dumps({"date": today})) + assert locker._sick_mode_used_today() is True + + def test_returns_false_when_different_date( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when state is from different date.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + with patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ): + state_file.write_text(json.dumps({"date": "2020-01-01"})) + assert locker._sick_mode_used_today() is False + + def test_returns_false_on_json_error( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False on JSONDecodeError.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + with patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ): + state_file.write_text("not json{{{") + assert locker._sick_mode_used_today() is False + + +class TestSaveSickDayState: + """Tests for _save_sick_day_state method.""" + + def test_saves_state_successfully( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test saves state file with correct content.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + with patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ): + result = locker._save_sick_day_state("2026-03-21", 21, 20) + assert result is True + data = json.loads(state_file.read_text()) + assert data["date"] == "2026-03-21" + assert data["original_mon_wed_hour"] == 21 + assert data["original_thu_sun_hour"] == 20 + + def test_returns_false_on_oserror( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when write fails.""" + locker = create_locker(mock_tk, tmp_path) + mock_path = MagicMock() + mock_path.open.side_effect = OSError("permission denied") + with patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + mock_path, + ): + result = locker._save_sick_day_state("2026-03-21", 21, 20) + assert result is False + + +class TestLoadSickDayState: + """Tests for _load_sick_day_state method.""" + + def test_loads_valid_state( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test loads state with all fields present.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + state_file.write_text( + json.dumps( + { + "date": "2026-03-20", + "original_mon_wed_hour": 21, + "original_thu_sun_hour": 20, + } + ) + ) + with patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ): + result = locker._load_sick_day_state() + assert result == ("2026-03-20", 21, 20) + + def test_returns_none_when_fields_missing( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns None when required fields are missing.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + state_file.write_text(json.dumps({"date": "2026-03-20"})) + with patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ): + result = locker._load_sick_day_state() + assert result is None + + +class TestWriteRestoredConfig: + """Tests for _write_restored_config method.""" + + def test_restores_config_and_removes_state( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test restores config values and deletes state file.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + state_file.write_text("{}") + with ( + patch.object(locker, "_read_shutdown_config", return_value=(20, 19, 8)), + patch.object( + locker, "_write_shutdown_config", return_value=True + ) as mock_write, + patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ), + ): + locker._write_restored_config(21, 20, "2026-03-20") + mock_write.assert_called_once_with(21, 20, 8, restore=True) + assert not state_file.exists() + + def test_still_removes_state_when_config_read_fails( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test removes state file even when config read returns None.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + state_file.write_text("{}") + with ( + patch.object(locker, "_read_shutdown_config", return_value=None), + patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ), + ): + locker._write_restored_config(21, 20, "2026-03-20") + assert not state_file.exists() diff --git a/screen_locker/tests/test_shutdown_part3.py b/screen_locker/tests/test_shutdown_part3.py new file mode 100644 index 0000000..f35626d --- /dev/null +++ b/screen_locker/tests/test_shutdown_part3.py @@ -0,0 +1,316 @@ +"""Tests for shutdown schedule adjustment coverage gaps (part 3).""" + +from __future__ import annotations + +import json +import subprocess +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from python_pkg.screen_locker._constants import ADJUST_SHUTDOWN_SCRIPT +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestRestoreOriginalConfigIfNeeded: + """Tests for _restore_original_config_if_needed method.""" + + def test_no_state_file_does_nothing( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test does nothing when no state file exists.""" + locker = create_locker(mock_tk, tmp_path) + mock_file = MagicMock() + mock_file.exists.return_value = False + with patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + mock_file, + ): + locker._restore_original_config_if_needed() + + def test_restores_when_state_from_previous_day( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test restores config when state date differs from today.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + state_file.write_text( + json.dumps( + { + "date": "2020-01-01", + "original_mon_wed_hour": 21, + "original_thu_sun_hour": 20, + } + ) + ) + with ( + patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ), + patch.object(locker, "_write_restored_config") as mock_restore, + ): + locker._restore_original_config_if_needed() + mock_restore.assert_called_once_with(21, 20, "2020-01-01") + + def test_does_not_restore_when_state_from_today( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test does not restore when state date matches today.""" + locker = create_locker(mock_tk, tmp_path) + from datetime import datetime, timezone + + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + state_file = tmp_path / "state.json" + state_file.write_text( + json.dumps( + { + "date": today, + "original_mon_wed_hour": 21, + "original_thu_sun_hour": 20, + } + ) + ) + with ( + patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ), + patch.object(locker, "_write_restored_config") as mock_restore, + ): + locker._restore_original_config_if_needed() + mock_restore.assert_not_called() + + def test_returns_when_loaded_state_is_none( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns early when loaded state is None.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + state_file.write_text(json.dumps({"date": "2020-01-01"})) + with ( + patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ), + patch.object(locker, "_write_restored_config") as mock_restore, + ): + locker._restore_original_config_if_needed() + mock_restore.assert_not_called() + + def test_handles_oserror( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test handles OSError when loading state.""" + locker = create_locker(mock_tk, tmp_path) + mock_file = MagicMock() + mock_file.exists.return_value = True + mock_file.open.side_effect = OSError("fail") + with patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + mock_file, + ): + locker._restore_original_config_if_needed() + + def test_handles_json_decode_error( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test handles JSONDecodeError when loading state.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + state_file.write_text("not valid json{{{") + with patch( + "python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ): + locker._restore_original_config_if_needed() + + +class TestReadShutdownConfig: + """Tests for _read_shutdown_config method.""" + + def test_returns_none_when_file_missing( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns None when config file doesn't exist.""" + locker = create_locker(mock_tk, tmp_path) + mock_file = MagicMock() + mock_file.exists.return_value = False + with patch( + "python_pkg.screen_locker._shutdown.SHUTDOWN_CONFIG_FILE", + mock_file, + ): + assert locker._read_shutdown_config() is None + + def test_reads_valid_config( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test reads all three config values from file.""" + locker = create_locker(mock_tk, tmp_path) + config_file = tmp_path / "shutdown.conf" + config_file.write_text("MON_WED_HOUR=21\nTHU_SUN_HOUR=20\nMORNING_END_HOUR=8\n") + with patch( + "python_pkg.screen_locker._shutdown.SHUTDOWN_CONFIG_FILE", + config_file, + ): + result = locker._read_shutdown_config() + assert result == (21, 20, 8) + + def test_returns_none_when_values_missing( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns None when config has missing keys.""" + locker = create_locker(mock_tk, tmp_path) + config_file = tmp_path / "shutdown.conf" + config_file.write_text("MON_WED_HOUR=21\n") + with patch( + "python_pkg.screen_locker._shutdown.SHUTDOWN_CONFIG_FILE", + config_file, + ): + result = locker._read_shutdown_config() + assert result is None + + +class TestBuildShutdownCmd: + """Tests for _build_shutdown_cmd method.""" + + def test_without_restore( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test command without restore flag.""" + locker = create_locker(mock_tk, tmp_path) + cmd = locker._build_shutdown_cmd(21, 20, 8, restore=False) + assert cmd == [ + "/usr/bin/sudo", + str(ADJUST_SHUTDOWN_SCRIPT), + "21", + "20", + "8", + ] + + def test_with_restore( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test command with restore flag.""" + locker = create_locker(mock_tk, tmp_path) + cmd = locker._build_shutdown_cmd(21, 20, 8, restore=True) + assert cmd == [ + "/usr/bin/sudo", + str(ADJUST_SHUTDOWN_SCRIPT), + "--restore", + "21", + "20", + "8", + ] + + +class TestWriteShutdownConfig: + """Tests for _write_shutdown_config method.""" + + def test_returns_false_when_script_missing( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when adjust script doesn't exist.""" + locker = create_locker(mock_tk, tmp_path) + mock_script = MagicMock() + mock_script.exists.return_value = False + with patch( + "python_pkg.screen_locker._shutdown.ADJUST_SHUTDOWN_SCRIPT", + mock_script, + ): + result = locker._write_shutdown_config(21, 20, 8) + assert result is False + + def test_success_calls_run_shutdown_cmd( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test successful config write delegates to _run_shutdown_cmd.""" + locker = create_locker(mock_tk, tmp_path) + mock_script = MagicMock() + mock_script.exists.return_value = True + with ( + patch( + "python_pkg.screen_locker._shutdown.ADJUST_SHUTDOWN_SCRIPT", + mock_script, + ), + patch.object(locker, "_run_shutdown_cmd", return_value=True) as mock_run, + ): + result = locker._write_shutdown_config(21, 20, 8) + assert result is True + mock_run.assert_called_once() + + +class TestRunShutdownCmd: + """Tests for _run_shutdown_cmd method.""" + + def test_success( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test successful command execution.""" + locker = create_locker(mock_tk, tmp_path) + mock_result = MagicMock(stdout="OK\n") + with patch( + "python_pkg.screen_locker._shutdown.subprocess.run", + return_value=mock_result, + ): + result = locker._run_shutdown_cmd(["cmd"], 21, 20) + assert result is True + + def test_returns_false_on_subprocess_error( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False on SubprocessError.""" + locker = create_locker(mock_tk, tmp_path) + with patch( + "python_pkg.screen_locker._shutdown.subprocess.run", + side_effect=subprocess.CalledProcessError(1, "cmd"), + ): + result = locker._run_shutdown_cmd(["cmd"], 21, 20) + assert result is False diff --git a/screen_locker/tests/test_ui_and_timers.py b/screen_locker/tests/test_ui_and_timers.py index cbad5bb..c9e3c99 100644 --- a/screen_locker/tests/test_ui_and_timers.py +++ b/screen_locker/tests/test_ui_and_timers.py @@ -416,3 +416,75 @@ class TestAskWorkoutDone: locker.clear_container.assert_called_once() mock_tk.Label.assert_called() mock_tk.Button.assert_called() + + +class TestAskIfSick: + """Tests for ask_if_sick method.""" + + def test_ask_if_sick_displays_dialog( + self, mock_tk: MagicMock, _mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Test ask_if_sick shows sick day question.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "clear_container", MagicMock()) + locker.ask_if_sick() + locker.clear_container.assert_called_once() + mock_tk.Label.assert_called() + + +class TestSickQuestionButtons: + """Tests for _sick_question_buttons method.""" + + def test_creates_buttons( + self, mock_tk: MagicMock, _mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Test _sick_question_buttons creates yes/no buttons.""" + locker = create_locker(mock_tk, tmp_path) + locker._sick_question_buttons() + mock_tk.Button.assert_called() + + +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 == "#ffaa00" + + 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 == "#00aa00" + + 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 == "#ff4444" diff --git a/screen_locker/tests/test_ui_and_timers_part2.py b/screen_locker/tests/test_ui_and_timers_part2.py new file mode 100644 index 0000000..9764e98 --- /dev/null +++ b/screen_locker/tests/test_ui_and_timers_part2.py @@ -0,0 +1,47 @@ +"""Tests for handle_sick_day and sick day UI.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from python_pkg.screen_locker.screen_lock import ( + SICK_LOCKOUT_SECONDS, +) +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestHandleSickDay: + """Tests for handle_sick_day method.""" + + def test_sets_up_countdown( + self, mock_tk: MagicMock, _mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Test handle_sick_day initializes sick day flow.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "clear_container", MagicMock()) + object.__setattr__( + locker, "_sick_mode_used_today", MagicMock(return_value=False) + ) + object.__setattr__( + locker, "_adjust_shutdown_time_earlier", MagicMock(return_value=True) + ) + locker.handle_sick_day() + locker.clear_container.assert_called_once() + assert locker.sick_remaining_time == SICK_LOCKOUT_SECONDS - 1 + + +class TestShowSickDayUi: + """Tests for _show_sick_day_ui method.""" + + def test_displays_ui( + self, mock_tk: MagicMock, _mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Test _show_sick_day_ui displays labels.""" + locker = create_locker(mock_tk, tmp_path) + locker._show_sick_day_ui("Test status", "#00aa00") + mock_tk.Label.assert_called() + assert hasattr(locker, "sick_countdown_label") diff --git a/screen_locker/tests/test_ui_flows_part2.py b/screen_locker/tests/test_ui_flows_part2.py new file mode 100644 index 0000000..e7a4f06 --- /dev/null +++ b/screen_locker/tests/test_ui_flows_part2.py @@ -0,0 +1,35 @@ +"""Tests for UI flows coverage gaps (part 2).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestUpdateSickCountdownAtZero: + """Tests for _update_sick_countdown at zero remaining.""" + + def test_records_sick_day_and_unlocks_at_zero( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test countdown at zero records sick day and calls unlock.""" + locker = create_locker(mock_tk, tmp_path) + locker.sick_remaining_time = 0 + locker.sick_countdown_label = MagicMock() + locker.workout_data = {} + locker.log_file = tmp_path / "workout_log.json" + object.__setattr__(locker, "unlock_screen", MagicMock()) + + locker._update_sick_countdown() + + assert locker.workout_data["type"] == "sick_day" + assert locker.workout_data["note"] == "Sick day - shutdown moved earlier" + locker.unlock_screen.assert_called_once() diff --git a/screen_locker/tests/test_verify_data.py b/screen_locker/tests/test_verify_data.py index 978539c..e1d6c2d 100644 --- a/screen_locker/tests/test_verify_data.py +++ b/screen_locker/tests/test_verify_data.py @@ -369,3 +369,37 @@ class TestVerifyStrengthData: locker.show_error.assert_called_once() assert "valid data" in locker.show_error.call_args[0][0] + + +class TestVariableReps: + """Tests for variable reps format in strength verification.""" + + def test_valid_variable_reps( + self, mock_tk: MagicMock, _mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Test valid variable reps with + separator.""" + locker = create_locker(mock_tk, tmp_path) + # 3 sets, reps 12+11+12 (3 variable values matching 3 sets), weight 50 + # Total = (12+11+12) * 50 = 1750 + setup_strength_entries( + locker, StrengthData("Squat", "3", "12+11+12", "50", "1750") + ) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "strength"} + object.__setattr__(locker, "_attempt_unlock", MagicMock()) + locker.verify_strength_data() + locker._attempt_unlock.assert_called_once() + + def test_variable_reps_count_mismatch( + self, mock_tk: MagicMock, _mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Test variable reps count not matching sets.""" + locker = create_locker(mock_tk, tmp_path) + # 5 sets but only 3 variable reps + setup_strength_entries( + locker, StrengthData("Squat", "5", "12+11+12", "50", "1750") + ) + object.__setattr__(locker, "show_error", MagicMock()) + locker.verify_strength_data() + locker.show_error.assert_called_once() + assert "variable reps count" in locker.show_error.call_args[0][0]