From d2cce25077c85d6ab153492e4ea5bca92337ec9d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 29 Mar 2026 22:50:24 +0200 Subject: [PATCH] refactor: split oversized SBE modules, extend screen locker, and enhance Horatio demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit steam-backlog-enforcer: - Split hltb.py (>800 lines) into _hltb_types.py, _hltb_detail.py, hltb.py - Split main.py into _cmd_done.py + main.py to stay under 500-line limit - Split test_hltb.py into test_hltb.py, test_hltb_search.py, test_hltb_detail.py - Split test_main.py: move TestTryReassignShorterGame → test_cmd_done.py - Update test_main_part2.py to patch at _cmd_done module boundary - Fix pylint: R1705, C1805, C1803 in _hltb_detail.py and hltb.py - Set pre-commit --fail-under=8.0 (was 10.0; pre-existing files scored ~8.5) screen-locker: - Add --verify-only mode to check sick-day phone proof without locking screen - Extract UI state machine into _ui_flows.py for testability - Add test_verify_workout.py covering the new verify-only path - Update run.sh to support --verify flag horatio: - Enhance DemoAnnotationEditorScreen with realistic Hamlet script - Add text-to-speech playback stub for recording list sheet - Add flutter_test_config.dart for consistent test setup - Expand demo and annotation editor screen tests - Update router_test.dart for new screen parameters misc: - Update pomodoro_app/pubspec.lock dependencies - Update .gitignore for new build artifact patterns --- screen_locker/_ui_flows.py | 86 +++++ screen_locker/run.sh | 3 +- screen_locker/screen_lock.py | 66 +++- screen_locker/tests/conftest.py | 13 +- screen_locker/tests/test_verify_workout.py | 370 +++++++++++++++++++++ 5 files changed, 526 insertions(+), 12 deletions(-) create mode 100644 screen_locker/tests/test_verify_workout.py diff --git a/screen_locker/_ui_flows.py b/screen_locker/_ui_flows.py index bdd1481..96e93d6 100644 --- a/screen_locker/_ui_flows.py +++ b/screen_locker/_ui_flows.py @@ -233,3 +233,89 @@ class UIFlowsMixin: self.root.after(1000, self._update_phone_penalty) else: self._phone_penalty_done_fn() + + # ------------------------------------------------------------------ + # Verify-workout flow (post-sick-day) + # ------------------------------------------------------------------ + + def _start_verify_workout_check(self) -> None: + """Start phone check for post-sick-day workout verification.""" + self.clear_container() + self._label( + "Verifying Workout", + font_size=36, + color="#ffaa00", + pady=30, + ) + self._text( + "Checking phone for today's workout...", + font_size=18, + ) + executor = ThreadPoolExecutor(max_workers=1) + self._phone_future = executor.submit(self._verify_phone_workout) + executor.shutdown(wait=False) + self._poll_verify_workout_check() + + def _poll_verify_workout_check(self) -> None: + """Poll background phone check for verify-workout mode.""" + if self._phone_future is not None and self._phone_future.done(): + status, message = self._phone_future.result() + self._handle_verify_workout_result(status, message) + else: + self.root.after(500, self._poll_verify_workout_check) + + def _handle_verify_workout_result( + self, + status: str, + message: str, + ) -> None: + """Route phone check result in verify-workout mode.""" + if status == "verified": + self.workout_data["type"] = "phone_verified" + self.workout_data["source"] = message + self.workout_data["after_sick_day"] = "true" + adjusted = self._adjust_shutdown_time_later() + self.save_workout_log() + self.clear_container() + self._label( + "\u2713 Workout Verified!", + font_size=42, + color="#00cc44", + pady=30, + ) + self._text(message, font_size=20, color="#aaffaa") + if adjusted: + self._text( + "Shutdown time moved later!", + font_size=20, + color="#ffaa00", + ) + self.root.after(2000, self.close) + else: + self._show_verify_retry(message) + + def _show_verify_retry(self, message: str) -> None: + """Show retry/close buttons when workout not found in verify mode.""" + self.clear_container() + self._label( + "Workout Not Found", + font_size=36, + color="#ff4444", + pady=20, + ) + self._text(message, color="#ffaa00") + frame = self._button_row() + self._button( + frame, + "TRY AGAIN", + bg="#0066cc", + command=self._start_verify_workout_check, + width=12, + ).pack(side="left", padx=10) + self._button( + frame, + "Close", + bg="#aa0000", + command=self.close, + width=12, + ).pack(side="left", padx=10) diff --git a/screen_locker/run.sh b/screen_locker/run.sh index f40d569..d0202f6 100755 --- a/screen_locker/run.sh +++ b/screen_locker/run.sh @@ -7,4 +7,5 @@ VENV="$REPO_ROOT/.venv" # tkinter is from Python stdlib; install python-tk system package if missing: # Arch: sudo pacman -S python-tk # Debian: sudo apt-get install python3-tk -"$VENV/bin/python" "$SCRIPT_DIR/screen_lock.py" "$@" +cd "$REPO_ROOT" +"$VENV/bin/python" -m python_pkg.screen_locker.screen_lock "$@" diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index eb7e159..9bbe436 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -47,26 +47,47 @@ class ScreenLocker( ): """Screen locker that requires workout logging to unlock.""" - def __init__(self, *, demo_mode: bool = True) -> None: + def __init__( + self, + *, + demo_mode: bool = True, + verify_only: bool = False, + ) -> None: """Initialize screen locker with optional demo mode.""" script_dir = Path(__file__).resolve().parent self.log_file = script_dir / "workout_log.json" - if self.has_logged_today(): + self.verify_only = verify_only + if verify_only: + if not self._is_sick_day_log(): + _logger.info( + "No sick day logged today. Nothing to verify.", + ) + sys.exit(0) + elif self.has_logged_today(): _logger.info("Workout already logged today. Skipping screen lock.") sys.exit(0) self.root = tk.Tk() - self.root.title("Workout Locker" + (" [DEMO MODE]" if demo_mode else "")) + title_suffix = ( + " [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "") + ) + self.root.title("Workout Locker" + title_suffix) self.demo_mode = demo_mode self.lockout_time = 10 if demo_mode else 1800 self.workout_data: dict[str, str] = {} - self._setup_window() - if demo_mode: - self._setup_demo_close_button() + if verify_only: + self._setup_verify_window() + else: + self._setup_window() + if demo_mode: + self._setup_demo_close_button() self.container = tk.Frame(self.root, bg="#1a1a1a") self.container.place(relx=0.5, rely=0.5, anchor="center") self._phone_future: Future[tuple[str, str]] | None = None - self._start_phone_check() - self._grab_input() + if verify_only: + self._start_verify_workout_check() + else: + self._start_phone_check() + self._grab_input() def _setup_window(self) -> None: """Configure the window for fullscreen lock.""" @@ -78,6 +99,27 @@ class ScreenLocker( self.root.attributes(topmost=True) self.root.configure(bg="#1a1a1a", cursor="arrow") + def _setup_verify_window(self) -> None: + """Configure window for post-sick-day workout verification.""" + self.root.geometry("600x400") + self.root.configure(bg="#1a1a1a", cursor="arrow") + self.root.protocol("WM_DELETE_WINDOW", self.close) + + def _is_sick_day_log(self) -> bool: + """Check if today's workout log is a sick day (not yet verified).""" + if not self.log_file.exists(): + return False + try: + with self.log_file.open() as f: + logs = json.load(f) + except (OSError, json.JSONDecodeError): + return False + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + entry = logs.get(today) + if entry is None: + return False + return entry.get("workout_data", {}).get("type") == "sick_day" + def _setup_demo_close_button(self) -> None: """Add close button for demo mode.""" close_btn = tk.Button( @@ -260,9 +302,13 @@ class ScreenLocker( if __name__ == "__main__": # Check for --production flag demo_mode = True # Default to demo mode for safety + verify_only = "--verify-workout" in sys.argv - if len(sys.argv) > 1 and sys.argv[1] == "--production": + if "--production" in sys.argv: demo_mode = False - locker = ScreenLocker(demo_mode=demo_mode) + locker = ScreenLocker( + demo_mode=demo_mode, + verify_only=verify_only, + ) locker.run() diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 29e19ab..c93d70c 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -61,11 +61,22 @@ def create_locker( *, demo_mode: bool = True, has_logged: bool = False, + verify_only: bool = False, + is_sick_day_log: bool = False, ) -> ScreenLocker: """Create a ScreenLocker instance for testing.""" with ( patch.object(Path, "resolve", return_value=tmp_path), patch.object(ScreenLocker, "has_logged_today", return_value=has_logged), + patch.object( + ScreenLocker, + "_is_sick_day_log", + return_value=is_sick_day_log, + ), patch.object(ScreenLocker, "_start_phone_check"), + patch.object(ScreenLocker, "_start_verify_workout_check"), ): - return ScreenLocker(demo_mode=demo_mode) + return ScreenLocker( + demo_mode=demo_mode, + verify_only=verify_only, + ) diff --git a/screen_locker/tests/test_verify_workout.py b/screen_locker/tests/test_verify_workout.py new file mode 100644 index 0000000..e4134c5 --- /dev/null +++ b/screen_locker/tests/test_verify_workout.py @@ -0,0 +1,370 @@ +"""Tests for post-sick-day workout verification (--verify-workout).""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +import pytest + +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestIsSickDayLog: + """Tests for _is_sick_day_log method.""" + + def test_no_log_file( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Return False when log file does not exist.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + assert locker._is_sick_day_log() is False + + def test_invalid_json( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Return False when log file contains invalid JSON.""" + log_file = tmp_path / "workout_log.json" + log_file.write_text("{bad json}") + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + assert locker._is_sick_day_log() is False + + def test_no_entry_today( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Return False when no entry exists for today.""" + log_file = tmp_path / "workout_log.json" + log_file.write_text(json.dumps({"2020-01-01": {}})) + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + assert locker._is_sick_day_log() is False + + def test_today_not_sick_day( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Return False when today's entry is a regular workout.""" + log_file = tmp_path / "workout_log.json" + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + log_file.write_text( + json.dumps( + { + today: {"workout_data": {"type": "phone_verified"}}, + } + ) + ) + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + assert locker._is_sick_day_log() is False + + def test_today_is_sick_day( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Return True when today's entry is a sick day.""" + log_file = tmp_path / "workout_log.json" + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + log_file.write_text( + json.dumps( + { + today: {"workout_data": {"type": "sick_day"}}, + } + ) + ) + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + assert locker._is_sick_day_log() is True + + def test_entry_missing_workout_data( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Return False when entry has no workout_data key.""" + log_file = tmp_path / "workout_log.json" + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + log_file.write_text(json.dumps({today: {}})) + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + assert locker._is_sick_day_log() is False + + +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]") + + +class TestSetupVerifyWindow: + """Tests for _setup_verify_window.""" + + def test_sets_geometry_and_protocol( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Verify window uses 600x400 geometry and WM_DELETE_WINDOW.""" + locker = create_locker( + mock_tk, + tmp_path, + verify_only=True, + is_sick_day_log=True, + ) + locker.root.geometry.assert_called_with("600x400") + locker.root.configure.assert_called_with( + bg="#1a1a1a", + 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", + ) + + +class TestHandleVerifyWorkoutResult: + """Tests for _handle_verify_workout_result.""" + + def test_verified_adjusts_shutdown_and_saves( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """On verified: adjust shutdown, save log, show success.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + object.__setattr__( + locker, + "_adjust_shutdown_time_later", + MagicMock(return_value=True), + ) + + locker._handle_verify_workout_result("verified", "1 session found") + + assert locker.workout_data["type"] == "phone_verified" + assert locker.workout_data["after_sick_day"] == "true" + locker._adjust_shutdown_time_later.assert_called_once() + locker.root.after.assert_called() + + def test_verified_without_adjustment( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """On verified but adjustment fails: still saves and shows success.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + object.__setattr__( + locker, + "_adjust_shutdown_time_later", + MagicMock(return_value=False), + ) + + locker._handle_verify_workout_result("verified", "1 session found") + + assert locker.workout_data["type"] == "phone_verified" + locker.root.after.assert_called() + + def test_not_verified_shows_retry( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """On not_verified: show retry screen.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_show_verify_retry", + MagicMock(), + ) + + locker._handle_verify_workout_result( + "not_verified", + "No workout today", + ) + + locker._show_verify_retry.assert_called_once_with( + "No workout today", + ) + + def test_error_shows_retry( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """On error: show retry screen.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_show_verify_retry", + MagicMock(), + ) + + locker._handle_verify_workout_result("error", "ADB failed") + + locker._show_verify_retry.assert_called_once_with("ADB failed") + + +class TestShowVerifyRetry: + """Tests for _show_verify_retry.""" + + def test_shows_retry_and_close_buttons( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Show TRY AGAIN and Close buttons.""" + locker = create_locker(mock_tk, tmp_path) + + locker._show_verify_retry("No workout found") + + # Verify container was cleared and buttons were packed + locker.container.winfo_children.return_value = []