mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 14:27:40 +01:00
The manual-workout sport selector was a `tk.OptionMenu`, and while the screen
was locked it could not be changed. A posted Tk menu is a separate
override-redirect toplevel that takes the Tk grab for itself, and gatelock's
1s recovery tick attacks it from both sides: `_surfaces.enforce()` lifts the
lock surface over the menu, and `_reassert_grab()` read the grab sitting on
the menu as "the grab was lost" and yanked it back. The menu opened on click
and died within a second. On 2026-07-26 that forced an hour-long walk to be
logged through the table-tennis form, with the reason typed into `racket`.
Radio buttons instead. They are ordinary children of the surface, so they
lift with it and live inside the root's grab tree -- neither mechanism can
reach them. That is a structural guarantee, not a tuned workaround; the
alternative, teaching gatelock to skip its lift while a popup is posted,
would open a window where something could cover the lock. They are also the
first version of this control styled from the shared palette, where the
OptionMenu was default grey on the dark overlay.
Nothing on a lock surface may open a popup, so two halves enforce it. The
static half walks each module's AST for popup-creating names -- AST rather
than a source regex because the modules explain this bug in prose that names
the very widgets being banned. Its module list is derived ("imports tkinter",
minus a short reasoned exemption list) rather than hardcoded, so a new lock
screen is in scope the day it is written; a hardcoded list drafted today
would already have missed `_sick_dialog`. The behavioural half is
`scripts/verify_lock_popup_safety.py`: it builds the real form with real Tk,
replays both hostile behaviours for five ticks, and re-runs the original
failure so the mechanism stays demonstrated rather than described. It
re-execs itself under Xvfb -- a global grab on the developer's own session
would freeze it -- and a CI job runs it, because the pytest suite replaces
the whole `tk` module and so cannot host it.
Separately, no field ever took keyboard focus. Under a global grab that means
typing goes nowhere with no other window on screen to reveal why. The
manual-workout and sick-justification forms now each name their own first
field via a `focus=` flag; `on_focus_ready` genuinely has nothing to focus
(it fires before those screens exist) and its docstring now says that instead
of claiming the lock window has no typed input at all.
`scripts/fix_2026_07_26_sport.py` re-files that walk as sport "other",
rebuilt through the form's own draft/validate/build path so it carries
exactly the writer's key set, and re-signed. `start_time` and the date are
untouched so `workout_id` stays stable and the phone converges instead of
gaining a duplicate. Kept as the audit trail for the edit; the walk's details
come from the entry and `--details`, so nothing personal lands in a tracked
file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
251 lines
9.9 KiB
Python
251 lines
9.9 KiB
Python
"""Tests for the manual-workout evidence-form dialog mixin."""
|
|
# pylint: disable=protected-access
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from screen_locker import _manual_workout
|
|
from screen_locker.tests.conftest import create_locker
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
class TestShowManualWorkoutForm:
|
|
"""Tests for _show_manual_workout_form."""
|
|
|
|
def test_renders_form_when_budget_available(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
with patch.object(_manual_workout, "is_budget_exhausted", return_value=False):
|
|
locker._show_manual_workout_form()
|
|
button_texts = {c.kwargs.get("text") for c in mock_tk.Button.call_args_list}
|
|
assert "SUBMIT" in button_texts
|
|
assert "BACK" in button_texts
|
|
|
|
def test_shows_exhausted_message_and_hides_submit(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
with patch.object(_manual_workout, "is_budget_exhausted", return_value=True):
|
|
locker._show_manual_workout_form()
|
|
button_texts = {c.kwargs.get("text") for c in mock_tk.Button.call_args_list}
|
|
assert "BACK" in button_texts
|
|
assert "SUBMIT" not in button_texts
|
|
text_calls = [c.kwargs.get("text", "") for c in mock_tk.Label.call_args_list]
|
|
assert any("exhausted" in t for t in text_calls)
|
|
|
|
|
|
class TestSportToggle:
|
|
"""Tests for _on_mw_sport_changed and _current_mw_sport."""
|
|
|
|
def test_selecting_other_shows_other_frame(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
locker._mw_tt_frame = MagicMock()
|
|
locker._mw_other_frame = MagicMock()
|
|
locker._mw_sport_var = MagicMock()
|
|
locker._on_mw_sport_changed("Other")
|
|
locker._mw_tt_frame.grid_remove.assert_called_once()
|
|
locker._mw_other_frame.grid.assert_called_once_with()
|
|
|
|
def test_selecting_table_tennis_shows_tt_frame(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
locker._mw_tt_frame = MagicMock()
|
|
locker._mw_other_frame = MagicMock()
|
|
locker._mw_sport_var = MagicMock()
|
|
locker._on_mw_sport_changed("Table tennis")
|
|
locker._mw_other_frame.grid_remove.assert_called_once()
|
|
locker._mw_tt_frame.grid.assert_called_once_with()
|
|
|
|
def test_unknown_label_defaults_to_table_tennis(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
locker._mw_tt_frame = MagicMock()
|
|
locker._mw_other_frame = MagicMock()
|
|
locker._mw_sport_var = MagicMock()
|
|
locker._mw_sport_var.get.return_value = "not a real label"
|
|
assert locker._current_mw_sport() == _manual_workout.SPORT_TABLE_TENNIS
|
|
|
|
|
|
class TestGridCursor:
|
|
"""Tests for the two-column grid cursor helper _mw_next_full_row."""
|
|
|
|
def test_full_row_from_even_cursor_advances_by_a_row(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
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 locker._mw_next_full_row(parent) == 1
|
|
|
|
def test_full_row_from_odd_cursor_bumps_to_next_row(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
parent = object()
|
|
# An odd cursor (a half-width cell already placed on the left) must be
|
|
# bumped to a fresh row before a full-width item is placed.
|
|
locker._mw_grid_counters = {id(parent): 1}
|
|
assert locker._mw_next_full_row(parent) == 1
|
|
|
|
|
|
class TestSubmitManualWorkoutForm:
|
|
"""Tests for _submit_manual_workout_form."""
|
|
|
|
def _setup_locker(
|
|
self,
|
|
mock_tk: MagicMock,
|
|
tmp_path: Path,
|
|
*,
|
|
sport_label: str = "Table tennis",
|
|
fields: dict[str, object] | None = None,
|
|
) -> object:
|
|
defaults: dict[str, object] = {
|
|
"start_time": "12:00",
|
|
"end_time": "14:00",
|
|
"location_name": "Osrodek Solec",
|
|
"transport_method": "bike",
|
|
"cost": "60 PLN",
|
|
"reservation_phone": "",
|
|
"techniques_practiced": "",
|
|
"warm_up_minutes": "",
|
|
"pain_or_injury": "none",
|
|
"racket": "pro spin",
|
|
"balls": "nittaku",
|
|
"activity_type_other": "squash",
|
|
"went_well": "x" * 30,
|
|
"to_improve": "y" * 30,
|
|
"overall_feeling": "z" * 30,
|
|
"activity_details": "q" * 45,
|
|
}
|
|
if fields:
|
|
defaults.update(fields)
|
|
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
locker._mw_sport_var = MagicMock()
|
|
locker._mw_sport_var.get.return_value = sport_label
|
|
locker._mw_rpe_var = MagicMock()
|
|
locker._mw_rpe_var.get.return_value = 6
|
|
|
|
locker._mw_vars = {}
|
|
for key in (
|
|
"start_time",
|
|
"end_time",
|
|
"location_name",
|
|
"transport_method",
|
|
"cost",
|
|
"reservation_phone",
|
|
"techniques_practiced",
|
|
"warm_up_minutes",
|
|
"pain_or_injury",
|
|
"racket",
|
|
"balls",
|
|
"activity_type_other",
|
|
"equipment",
|
|
):
|
|
var = MagicMock()
|
|
var.get.return_value = defaults.get(key, "")
|
|
locker._mw_vars[key] = var
|
|
|
|
locker._mw_int_vars = {}
|
|
for key, default_val in (
|
|
("matches_won", 1),
|
|
("matches_lost", 4),
|
|
("sets_won", 2),
|
|
("sets_lost", 9),
|
|
):
|
|
var = MagicMock()
|
|
var.get.return_value = defaults.get(key, default_val)
|
|
locker._mw_int_vars[key] = var
|
|
|
|
locker._mw_text_widgets = {}
|
|
for key in ("went_well", "to_improve", "overall_feeling", "activity_details"):
|
|
widget = MagicMock()
|
|
widget.get.return_value = defaults[key]
|
|
locker._mw_text_widgets[key] = widget
|
|
|
|
locker._mw_error_label = MagicMock()
|
|
object.__setattr__(locker, "_on_manual_workout_saved", MagicMock())
|
|
return locker
|
|
|
|
def test_valid_table_tennis_calls_saved_hook(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = self._setup_locker(mock_tk, tmp_path, sport_label="Table tennis")
|
|
locker._submit_manual_workout_form()
|
|
locker._on_manual_workout_saved.assert_called_once()
|
|
entry = locker._on_manual_workout_saved.call_args.args[0]
|
|
assert entry["sport"] == "table_tennis"
|
|
assert entry["matches_lost"] == 4
|
|
locker._mw_error_label.config.assert_not_called()
|
|
|
|
def test_valid_other_sport_calls_saved_hook(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = self._setup_locker(mock_tk, tmp_path, sport_label="Other")
|
|
locker._submit_manual_workout_form()
|
|
locker._on_manual_workout_saved.assert_called_once()
|
|
entry = locker._on_manual_workout_saved.call_args.args[0]
|
|
assert entry["sport"] == "other"
|
|
assert entry["activity_type"] == "squash"
|
|
|
|
def test_validation_failure_shows_error_and_skips_hook(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = self._setup_locker(mock_tk, tmp_path, fields={"location_name": " "})
|
|
locker._submit_manual_workout_form()
|
|
locker._mw_error_label.config.assert_called_once()
|
|
locker._on_manual_workout_saved.assert_not_called()
|
|
|
|
def test_rpe_value_error_treated_as_invalid(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = self._setup_locker(mock_tk, tmp_path)
|
|
locker._mw_rpe_var.get.side_effect = ValueError("bad")
|
|
locker._submit_manual_workout_form()
|
|
locker._mw_error_label.config.assert_called_once()
|
|
locker._on_manual_workout_saved.assert_not_called()
|
|
|
|
def test_int_field_value_error_treated_as_zero(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = self._setup_locker(mock_tk, tmp_path)
|
|
locker._mw_int_vars["matches_won"].get.side_effect = ValueError("bad")
|
|
locker._mw_int_vars["matches_lost"].get.return_value = 0
|
|
locker._submit_manual_workout_form()
|
|
# matches_won=0 (from the ValueError fallback) and matches_lost=0
|
|
# -> "zero matches played" validation error.
|
|
locker._mw_error_label.config.assert_called_once()
|
|
locker._on_manual_workout_saved.assert_not_called()
|
|
|
|
|
|
class TestInitialKeyboardFocus:
|
|
"""Exactly one field takes focus when a typed-input screen is built."""
|
|
|
|
def test_manual_workout_form_focuses_one_entry(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
with patch.object(_manual_workout, "is_budget_exhausted", return_value=False):
|
|
locker._show_manual_workout_form()
|
|
focused = mock_tk.Entry.return_value.focus_set.call_count
|
|
assert focused == 1, f"expected exactly one focused entry, got {focused}"
|
|
|
|
def test_sick_justification_form_focuses_one_entry(
|
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
|
) -> None:
|
|
locker = create_locker(mock_tk, tmp_path)
|
|
locker._build_justification_form(had_commitment=False)
|
|
focused = mock_tk.Entry.return_value.focus_set.call_count
|
|
assert focused == 1, f"expected exactly one focused entry, got {focused}"
|