screen-locker/screen_locker/tests/test_focus_and_scroll.py
Krzysztof kuhy Rudnicki 7d8daca111 sick days: one shared sickdays pool, 20:00 shutdown floor, one-tap lock button
A sick day is now a date in the shared sickdays pool (utils sickdays 0.1.0)
and nothing else. Removed: the 7/30/90-day budget, the escalating lockout
countdown, the justification form, the commitment prompt and its double
debt, the workout-debt counter, the -60 min shutdown cut and its
sick_day_state.json, the post-sick-day --verify-workout mode, the sick
auto-upgrade, scripts/revoke_sick_day.py and their tests.

- Lock screen: "I'm sick" on the retry, no-phone and both Tue-Thu screens;
  one tap -> sickdays.add_sick_day(today) -> sick floor -> best-effort
  background sickdays.sync_quietly -> unlock. Refusals (free day,
  unreadable pool) are shown and the lock stays.
- Shutdown: the reset resolves with earned_time.resolve(..., sick=True)
  (max(20:00, ladder), capped 23:00) and saves the sick lift with the grace
  lift, so later credits absorb it instead of stacking on 20:00. A day
  marked after the reset is lifted by the new _sick_floor live pass (every
  5-min tick, --sync-only, --apply-bonuses, and the tap itself).
- Workout lock and grace floor stand down on a sick day; workouts that day
  credit normally through the sync paths.
- wake-alarm workday_penalty.json is void when its issued_date or
  penalty_date is a sick day.
- Status window, i3 line, CLI --status, MCP flags and web reasons read
  sickdays.
- scripts/migrate_sick_history.py imports sick_history.json's dates via
  sickdays.import_legacy, verifies and removes the file; idempotent, run by
  install.sh.
- Pins: earned-time 0.11.0, freedays 0.4.0, sickdays 0.1.0 (tags pending);
  pre-commit 4.7.0, ruff 0.17.1 (freshness gate).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xfj1sxKH9VBPqpcAni8Jry
2026-10-16 13:30:39 +02:00

176 lines
6.5 KiB
Python

"""Keyboard focus and viewport settling on the lock surfaces.
The lock holds a *global* grab with VT switching disabled: a surface where
nothing is focused leaves a keyboard-only user typing into the void, and a
viewport that is never re-settled after a repaint reports the wrong size for
the screen it is showing. Both were shipped without tests in 3e77f4e, and the
scroll behaviour they drive is what moved the screen on its own on
2026-08-03 (see ``gatelock/tests/test_scrollable.py`` for that half).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import pytest
from screen_locker._surface_group import FrameGroup
from screen_locker._window_setup import _first_focusable
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
def _widget(cls: str, *, takefocus: str = "", children: list | None = None):
"""A widget stand-in that answers only what ``_first_focusable`` asks."""
widget = MagicMock()
widget.winfo_class.return_value = cls
widget.cget.return_value = takefocus
widget.winfo_children.return_value = children or []
return widget
class TestFirstFocusable:
"""Which widget the lock hands the keyboard to when a screen appears."""
def test_picks_the_first_focusable_child(self) -> None:
"""A button after a label is still the first thing focus can land on."""
label = _widget("Label")
button = _widget("Button")
parent = _widget("Frame", children=[label, button])
assert _first_focusable(parent) is button
def test_descends_into_containers(self) -> None:
"""Buttons live inside a row frame, not directly on the surface."""
button = _widget("Button")
row = _widget("Frame", children=[button])
parent = _widget("Frame", children=[row])
assert _first_focusable(parent) is button
def test_skips_widgets_that_opted_out(self) -> None:
"""``takefocus=0`` means the widget is not in the tab ring at all."""
opted_out = _widget("Button", takefocus="0")
real = _widget("Entry")
parent = _widget("Frame", children=[opted_out, real])
assert _first_focusable(parent) is real
def test_returns_none_when_nothing_can_take_focus(self) -> None:
"""A caller must be able to tell "nothing to focus" from "focused"."""
parent = _widget("Frame", children=[_widget("Label")])
assert _first_focusable(parent) is None
@pytest.mark.usefixtures("mock_sys_exit")
class TestFocusOnAppear:
"""``on_focus_ready`` is what makes a fresh screen keyboard-operable."""
def test_focuses_the_first_focusable_widget(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""The hook exists so a button-only screen does not start unfocused."""
locker = create_locker(mock_tk, tmp_path)
button = _widget("Button")
locker.container = FrameGroup([_widget("Frame", children=[button])])
locker.on_focus_ready(MagicMock())
button.focus_set.assert_called_once_with()
def test_no_live_output_is_not_an_error(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""The lock is still held with every monitor dark; nothing to focus."""
locker = create_locker(mock_tk, tmp_path)
locker.on_focus_ready(None) # must not raise
def test_a_surface_with_nothing_focusable_is_left_alone(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""A pure status screen has no controls, and that is a valid state."""
locker = create_locker(mock_tk, tmp_path)
locker.container = FrameGroup([_widget("Frame", children=[_widget("Label")])])
locker.on_focus_ready(MagicMock()) # must not raise
def test_no_surfaces_at_all(self, mock_tk: MagicMock, tmp_path: Path) -> None:
"""Focus is requested before any surface exists during startup races."""
locker = create_locker(mock_tk, tmp_path)
locker.container = FrameGroup([])
locker.on_focus_ready(MagicMock()) # must not raise
def test_focus_first_button_takes_the_primary_surface(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""One screen paints N monitors; the keyboard is at exactly one."""
locker = create_locker(mock_tk, tmp_path)
button = _widget("Button")
row = FrameGroup([_widget("Frame", children=[_widget("Label"), button])])
locker._focus_first_button(row)
button.focus_set.assert_called_once_with()
def test_focus_first_button_without_a_button(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""A row of plain labels must not raise looking for a button."""
locker = create_locker(mock_tk, tmp_path)
row = FrameGroup([_widget("Frame", children=[_widget("Label")])])
locker._focus_first_button(row) # must not raise
@pytest.mark.usefixtures("mock_sys_exit")
class TestSurfacesSettle:
"""Every repaint has to re-settle the viewport it painted into."""
def test_settle_finalizes_every_scroller(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""One monitor per scroller, and each one measures its own screen."""
locker = create_locker(mock_tk, tmp_path)
first, second = MagicMock(), MagicMock()
locker._scrollers.clear()
locker._scrollers.update({"eDP-1": first, "HDMI-1": second})
locker.settle_surfaces()
first.finalize.assert_called_once_with()
second.finalize.assert_called_once_with()
def test_clearing_the_container_schedules_a_settle(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""The hook that makes settling unforgettable across ~19 repaint sites.
Scheduled rather than run inline: running it here would measure an
empty container, since the caller has not painted the new screen yet.
"""
locker = create_locker(mock_tk, tmp_path)
locker.root.after_idle.reset_mock()
locker.clear_container()
locker.root.after_idle.assert_called_once_with(locker.settle_surfaces)
def test_a_dark_output_drops_its_scroller(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""A monitor that goes away must not be settled or painted again."""
locker = create_locker(mock_tk, tmp_path)
surface = MagicMock()
surface.output_name = "HDMI-1"
locker._scrollers["HDMI-1"] = MagicMock()
locker.teardown_surface(surface)
assert "HDMI-1" not in locker._scrollers