screen-locker/screen_locker/tests/test_sick_panel.py
Krzysztof kuhy Rudnicki 3478ee1d2a Sick panel: move keyboard focus into the overlay
Left on the link, Tab and Enter reached the hidden buttons of the screen
under the backdrop. The primary panel's first switch takes focus once laid
out (Tk skips unmapped widgets when traversing), and the kept-workout note
focuses its Back button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp
2026-10-17 14:11:15 +02:00

251 lines
9.0 KiB
Python

"""The lock's "choose lockers" link: sickdays' KeepPanel, embedded in the lock.
A recording fake replaces the panel. The pool is written for real before
``on_saved`` fires: the lock branches on the pool, not on the entry.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
import sickdays
import sickdays.gui._panel
from screen_locker import _ui_flows_sick, _ui_flows_sick_panel
from screen_locker._ui_flows_sick_panel import KEPT_NOTE, LINK_TEXT, build_sick_panel
from screen_locker.tests._sick_helpers import mark_sick
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
from datetime import date
from pathlib import Path
from screen_locker.screen_lock import ScreenLocker
_MADE: list[FakePanel] = []
class FakePanel:
"""Records how the lock built it; ``on_saved``/``on_cancel`` are driven."""
def __init__(
self,
parent: MagicMock,
day: date,
*,
on_saved: Callable[[sickdays.SickDay | None], None],
on_cancel: Callable[[], None] | None = None,
paths: sickdays.Paths | None = None,
) -> None:
"""Keep every argument for the assertions."""
self.parent = parent
self.day = day
self.on_saved = on_saved
self.on_cancel = on_cancel
self.paths = paths
self.packed = False
_MADE.append(self)
def pack(self, **_kwargs: object) -> None:
"""Record that the lock showed it."""
self.packed = True
def _frame(*args: object, **_kwargs: object) -> MagicMock:
"""A distinct mock frame that remembers its parent, like Tk's ``master``."""
frame = MagicMock()
frame.master = args[0] if args else None
frame.winfo_children.return_value = []
return frame
@pytest.fixture
def panels() -> Iterator[list[FakePanel]]:
"""Every KeepPanel the lock builds, in order."""
_MADE.clear()
with patch.object(_ui_flows_sick_panel, "KeepPanel", FakePanel):
yield _MADE
@pytest.fixture
def locker(
mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> ScreenLocker:
"""A retry-screen lock whose floor and close are observable."""
built = create_locker(mock_tk, tmp_path)
mock_tk.Frame.side_effect = _frame
object.__setattr__(built, "_apply_sick_floor", MagicMock())
object.__setattr__(built, "close", MagicMock())
return built
def _backdrop(panel: FakePanel) -> MagicMock:
"""The full-surface frame the panel's centred box sits on."""
return panel.parent.master
def _texts(mock: MagicMock) -> list[str]:
return [str(c.kwargs.get("text", "")) for c in mock.call_args_list]
class TestLink:
def test_the_link_sits_next_to_the_one_tap_button(
self, mock_tk: MagicMock, locker: ScreenLocker
) -> None:
locker._show_retry_and_sick("nope")
buttons = mock_tk.Button.call_args_list
texts = _texts(mock_tk.Button)
sick = next(c for c in buttons if c.kwargs.get("text") == "I'm sick")
link = next(c for c in buttons if c.kwargs.get("text") == LINK_TEXT)
assert sick.kwargs["command"] == locker.mark_sick_and_unlock
assert link.kwargs["command"] == locker.open_sick_panel
assert texts.index(LINK_TEXT) == texts.index("I'm sick") + 1
def test_opening_builds_todays_panel_on_a_backdrop(
self, locker: ScreenLocker, panels: list[FakePanel]
) -> None:
locker.open_sick_panel()
panel = panels[0]
assert panel.day == sickdays.today()
assert panel.packed
assert panel.on_cancel is not None
_backdrop(panel).place.assert_called_once_with(
relx=0, rely=0, relwidth=1, relheight=1
)
@pytest.mark.usefixtures("dual_output")
def test_every_monitor_gets_a_panel_and_the_primary_gets_focus(
self, locker: ScreenLocker, panels: list[FakePanel]
) -> None:
locker.open_sick_panel()
assert len(panels) == 2
panels[0].parent.tk_focusNext.return_value.focus_set.assert_called_once_with()
panels[1].parent.tk_focusNext.return_value.focus_set.assert_not_called()
def test_reopening_closes_the_previous_panel(
self, locker: ScreenLocker, panels: list[FakePanel]
) -> None:
locker.open_sick_panel()
first = _backdrop(panels[0])
locker.open_sick_panel()
first.destroy.assert_called_once_with()
def test_build_sick_panel_passes_paths_through(
self, mock_tk: MagicMock, panels: list[FakePanel], tmp_path: Path
) -> None:
paths = sickdays.Paths.under(tmp_path / "pool")
saved, cancel = MagicMock(), MagicMock()
build_sick_panel(
MagicMock(), MagicMock(), on_saved=saved, on_cancel=cancel, paths=paths
)
assert panels[0].paths is paths
assert panels[0].on_saved is saved
assert panels[0].on_cancel is cancel
class TestSaved:
@pytest.fixture(autouse=True)
def _no_mark_no_push(self) -> Iterator[tuple[MagicMock, MagicMock]]:
with (
patch.object(_ui_flows_sick, "mark_today_sick") as mark,
patch.object(_ui_flows_sick, "push_in_background") as push,
):
yield mark, push
# The panel already wrote and started its own sync.
mark.assert_not_called()
push.assert_not_called()
def test_sick_and_standing_down_unlocks_like_the_one_tap(
self, locker: ScreenLocker, panels: list[FakePanel]
) -> None:
locker.open_sick_panel()
day = mark_sick(keep=[sickdays.LOCKER_LEETCODE])
entry = sickdays.SickDay(
day=day, is_sick=True, keep=frozenset({sickdays.LOCKER_LEETCODE})
)
with patch("screen_locker._startup_checks.record_decision") as record:
panels[0].on_saved(entry)
locker._apply_sick_floor.assert_called_once_with()
assert record.call_args.args[0].reason == "sick_day_marked"
locker.close.assert_called_once_with()
def test_sick_but_workout_kept_stays_locked_with_a_note(
self, mock_tk: MagicMock, locker: ScreenLocker, panels: list[FakePanel]
) -> None:
locker.open_sick_panel()
backdrop = _backdrop(panels[0])
day = mark_sick(keep=[sickdays.LOCKER_WORKOUT])
entry = sickdays.SickDay(
day=day, is_sick=True, keep=frozenset({sickdays.LOCKER_WORKOUT})
)
panels[0].on_saved(entry)
# The 20:00 floor applies on any sick day, keep or not.
locker._apply_sick_floor.assert_called_once_with()
locker.close.assert_not_called()
backdrop.destroy.assert_called_once_with()
assert KEPT_NOTE in _texts(mock_tk.Label)
mock_tk.Button.return_value.focus_set.assert_called_once_with()
back = next(
c for c in mock_tk.Button.call_args_list if c.kwargs.get("text") == "Back"
)
back.kwargs["command"]()
assert locker._sick_overlay is None
def test_cleared_closes_the_panel_and_leaves_the_lock(
self, locker: ScreenLocker, panels: list[FakePanel]
) -> None:
locker.open_sick_panel()
backdrop = _backdrop(panels[0])
entry = sickdays.SickDay(day=sickdays.today(), is_sick=False)
panels[0].on_saved(entry)
backdrop.destroy.assert_called_once_with()
locker._apply_sick_floor.assert_not_called()
locker.close.assert_not_called()
def test_unchanged_save_closes_the_panel_only(
self, locker: ScreenLocker, panels: list[FakePanel]
) -> None:
locker.open_sick_panel()
backdrop = _backdrop(panels[0])
panels[0].on_saved(None)
backdrop.destroy.assert_called_once_with()
locker._apply_sick_floor.assert_not_called()
locker.close.assert_not_called()
def test_cancel_closes_the_panel_only(
self, locker: ScreenLocker, panels: list[FakePanel]
) -> None:
locker.open_sick_panel()
backdrop = _backdrop(panels[0])
assert panels[0].on_cancel is not None
panels[0].on_cancel()
backdrop.destroy.assert_called_once_with()
assert locker._sick_overlay is None
locker._apply_sick_floor.assert_not_called()
locker.close.assert_not_called()
class TestOneTapUnchanged:
def test_one_tap_still_keeps_nothing_and_builds_no_panel(
self, locker: ScreenLocker, panels: list[FakePanel]
) -> None:
with patch.object(_ui_flows_sick, "push_in_background") as push:
locker.mark_sick_and_unlock()
assert sickdays.is_sick_day() is True
assert sickdays.kept_lockers() == frozenset()
push.assert_called_once_with()
locker.close.assert_called_once_with()
assert panels == []
def test_the_pool_fixture_reaches_the_embedded_panel(tmp_path: Path) -> None:
"""``sickdays.gui._panel`` binds its own ``resolve_paths``/``sync_quietly``."""
assert sickdays.gui._panel.resolve_paths(None).log == (
sickdays.Paths.under(tmp_path / "sickdays").log
)
assert sickdays.gui._panel.sync_quietly() is False