From 8450644a5655d95c6283c1370702c5f6d6ebcd2a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 17 Oct 2026 14:06:22 +0200 Subject: [PATCH] =?UTF-8?q?Lock=20screen:=20"choose=20lockers=E2=80=A6"=20?= =?UTF-8?q?opens=20sickdays'=20KeepPanel=20in=20the=20lock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next to the one-tap "I'm sick" (unchanged: keeps nothing, unlocks), a small secondary link lays sickdays 0.3.0's embeddable KeepPanel over every surface, in-process so it gets input under the lock's grab. Closing the overlay reveals the untouched screen underneath, so no countdown or phone check is restarted or skipped. After the panel's own write + sync: sick with the workout lock standing down -> sick floor, decision, close (shared with the one-tap path, no second mark or push); sick with the workout kept -> sick floor and a note, lock stays; cleared / unchanged / cancel -> the panel just closes. Tests stub KeepPanel and redirect resolve_paths and sync_quietly in every sickdays.* module, sickdays.gui._panel included. The fit check measures the panel on a throwaway pool. Pins bumped to sickdays-v0.3.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PAhbNszs5JKdtnD4uBi3xp --- .pre-commit-config.yaml | 4 +- pyproject.toml | 2 +- requirements.txt | 2 +- screen_locker/_ui_flows.py | 3 +- screen_locker/_ui_flows_sick.py | 30 ++- screen_locker/_ui_flows_sick_panel.py | 161 +++++++++++++ screen_locker/tests/_real_state_fixtures.py | 28 ++- screen_locker/tests/_tk_mocks.py | 1 + screen_locker/tests/test_sick_panel.py | 250 ++++++++++++++++++++ scripts/_fit_extra_screens.py | 62 +++++ scripts/verify_screen_fits.py | 17 +- 11 files changed, 527 insertions(+), 33 deletions(-) create mode 100644 screen_locker/_ui_flows_sick_panel.py create mode 100644 screen_locker/tests/test_sick_panel.py create mode 100644 scripts/_fit_extra_screens.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4471e54..0b34e79 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -138,7 +138,7 @@ repos: - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays - - sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays + - sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays - mcp>=2,<3 - pydantic - id: pylint @@ -160,7 +160,7 @@ repos: - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays - - sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays + - sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays - mcp>=2,<3 - pydantic diff --git a/pyproject.toml b/pyproject.toml index b012e5f..fdd332b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ dependencies = [ "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync", "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays", "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.11.0#subdirectory=earned_time", - "sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays", + "sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays", ] [project.scripts] diff --git a/requirements.txt b/requirements.txt index 36414d6..0e28630 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,7 +21,7 @@ pytest-xdist==3.8.0 # module imports it itself, so it is declared rather than assumed. requests==2.34.2 ruff==0.17.1 -sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.2.0#subdirectory=sickdays +sickdays @ git+https://github.com/kuhyx/utils@sickdays-v0.3.0#subdirectory=sickdays types-requests==2.33.0.20261006 # Direct dependency since the Firebase session stream (_session_stream) catches # urllib3's own errors on the long-lived SSE read; requests pulls it in, but diff --git a/screen_locker/_ui_flows.py b/screen_locker/_ui_flows.py index 466f89a..dc07eff 100644 --- a/screen_locker/_ui_flows.py +++ b/screen_locker/_ui_flows.py @@ -9,12 +9,13 @@ from screen_locker import _manual_workout from screen_locker._source_health import collect_source_findings, explain_findings from screen_locker._ui_flows_lockout import LockoutFlowMixin from screen_locker._ui_flows_sick import SickDayFlowMixin +from screen_locker._ui_flows_sick_panel import SickPanelFlowMixin if TYPE_CHECKING: from collections.abc import Callable -class UIFlowsMixin(SickDayFlowMixin, LockoutFlowMixin): +class UIFlowsMixin(SickDayFlowMixin, SickPanelFlowMixin, LockoutFlowMixin): """Mixin providing UI flow logic for the screen locker. The sick-day and lockout/penalty flows live in sibling modules and are diff --git a/screen_locker/_ui_flows_sick.py b/screen_locker/_ui_flows_sick.py index 9e8b5f2..d989a9a 100644 --- a/screen_locker/_ui_flows_sick.py +++ b/screen_locker/_ui_flows_sick.py @@ -13,6 +13,10 @@ the retry screen, the no-phone penalty screen and both relaxed-day screens. A refusal (a free day, an unreadable pool) is shown on screen and the lock stays up. +Next to the button sits a small "choose lockers…" link: the same day, but +with some lockers kept (``_ui_flows_sick_panel``). The button itself still +keeps nothing. + Split out of :mod:`screen_locker._ui_flows` to keep every file under the 250-line cap. Composed back into ``UIFlowsMixin`` there. """ @@ -41,6 +45,8 @@ class SickDayFlowMixin: command=self.mark_sick_and_unlock, width=12, ).pack(side="left", padx=self._colors.space("sm")) + # The keep-aware alternative (``_ui_flows_sick_panel``), next to it. + self._choose_lockers_link(frame) def mark_sick_and_unlock(self) -> None: """Mark today sick, lift tonight's shutdown to 20:00 and unlock.""" @@ -48,17 +54,27 @@ class SickDayFlowMixin: if not result.ok: self._show_sick_refused(result.message) return + self._raise_sick_floor() + # Best effort and off the UI thread: the unlock never waits for it. + push_in_background() + self._unlock_sick_day( + f"Marked today sick from the lock screen. {result.message}" + ) + + def _raise_sick_floor(self) -> None: + """Lift tonight's shutdown to the sick floor (a no-op unless sick).""" # Now, not at the next 5-minute tick: while this window is up that # tick stands down at the instance lock. with locked_shutdown_state(SHUTDOWN_BASE_FILE): self._apply_sick_floor() - # Best effort and off the UI thread: the unlock never waits for it. - push_in_background() - self._record_decision( - locked=False, - reason="sick_day_marked", - detail=f"Marked today sick from the lock screen. {result.message}", - ) + + def _unlock_sick_day(self, detail: str) -> None: + """Record the sick-day unlock and close the lock. + + Shared by the one-tap button and the keep panel; neither pushes here + (the panel starts its own sync). + """ + self._record_decision(locked=False, reason="sick_day_marked", detail=detail) self.close() def _show_sick_refused(self, message: str) -> None: diff --git a/screen_locker/_ui_flows_sick_panel.py b/screen_locker/_ui_flows_sick_panel.py new file mode 100644 index 0000000..15e7a9c --- /dev/null +++ b/screen_locker/_ui_flows_sick_panel.py @@ -0,0 +1,161 @@ +"""The "choose lockers…" link: sickdays' KeepPanel, inside the lock window. + +The one-tap "I'm sick" button (``_ui_flows_sick``) keeps nothing. This link, +next to it, opens sickdays' embeddable :class:`~sickdays.gui.KeepPanel` so +today can be marked sick with some lockers still on. It is built in-process +on the lock's own surfaces: a separate window would get no input under the +lock's X grab. + +The panel covers each surface on a full-size backdrop instead of replacing +the screen under it, so closing it reveals exactly the screen it was opened +from -- a running penalty countdown or phone check is neither restarted nor +skipped. The panel does the write and starts its own background sync; what +the lock does afterwards depends on the pool, not on the entry: + +* sick and the workout lock stands down -> the one-tap path's after-effects + (sick floor, decision, close), without a second mark or push; +* sick but the workout lock kept -> sick floor (20:00 applies on any sick + day), the lock stays up with a short note; +* cleared, or an unchanged Save -> the panel closes, nothing else. +""" + +from __future__ import annotations + +import logging +import tkinter as tk +from typing import TYPE_CHECKING + +from gatelock import bind_activate +import sickdays +from sickdays.gui import KeepPanel + +from screen_locker._sick import stands_down_workout +from screen_locker._surface_group import FrameGroup + +if TYPE_CHECKING: + from collections.abc import Callable + + from gatelock import LockConfig + +_logger = logging.getLogger(__name__) + +LINK_TEXT = "choose lockers…" +KEPT_NOTE = "Workout lock kept — finish the workout to unlock." + + +def build_sick_panel( + parent: tk.Misc, + colors: LockConfig, + *, + on_saved: Callable[[sickdays.SickDay | None], None], + on_cancel: Callable[[], None], + paths: sickdays.Paths | None = None, +) -> tk.Frame: + """Centre today's KeepPanel in ``parent`` and return the frame holding it. + + Separate from the mixin so ``scripts/verify_screen_fits.py`` can measure + it on a throwaway pool (``paths``) instead of the live one. + """ + outer = tk.Frame(parent, bg=colors.palette.bg) + outer.place(relx=0.5, rely=0.5, anchor="center") + KeepPanel( + outer, sickdays.today(), on_saved=on_saved, on_cancel=on_cancel, paths=paths + ).pack() + return outer + + +class SickPanelFlowMixin: + """The keep-aware sick-day panel, layered over the current lock screen.""" + + _sick_overlay: FrameGroup | None = None + + def _choose_lockers_link(self, frame: FrameGroup) -> None: + """Pack the small secondary link that opens the panel into ``frame``.""" + palette = self._colors.palette + links = frame.child_widgets( + tk.Button, + text=LINK_TEXT, + font=self._colors.font("label"), + bg=palette.bg, + fg=palette.muted, + activebackground=palette.field_bg, + activeforeground=palette.fg, + command=self.open_sick_panel, + cursor="hand2" if self.demo_mode else "", + relief="flat", + borderwidth=0, + padx=self._colors.space("sm"), + pady=self._colors.space("xs"), + **self._colors.focus_kwargs(), + ) + for link in links: + bind_activate(link) + links.pack(side="left", padx=self._colors.space("sm")) + + def open_sick_panel(self) -> None: + """Lay today's KeepPanel over every monitor's surface.""" + overlay = self._new_sick_overlay() + for backdrop in overlay: + build_sick_panel( + backdrop, + self._colors, + on_saved=self._on_sick_panel_saved, + on_cancel=self._close_sick_panel, + ) + + def _new_sick_overlay(self) -> FrameGroup: + """Replace any open overlay with a fresh full-surface backdrop group.""" + self._close_sick_panel() + backdrops: list[tk.Frame] = [] + for surface in self.container.surfaces: + backdrop = tk.Frame(surface.winfo_toplevel(), bg=self._colors.palette.bg) + backdrop.place(relx=0, rely=0, relwidth=1, relheight=1) + backdrop.lift() + backdrops.append(backdrop) + self._sick_overlay = FrameGroup(backdrops) + return self._sick_overlay + + def _close_sick_panel(self) -> None: + """Remove the overlay; the lock screen under it was never touched.""" + if self._sick_overlay is not None: + self._sick_overlay.destroy() + self._sick_overlay = None + + def _on_sick_panel_saved(self, entry: sickdays.SickDay | None) -> None: + """React to the panel's write; the pool is already up to date.""" + self._close_sick_panel() + if entry is None or not entry.is_sick: + _logger.warning( + "Sick panel saved without a sick day (%s); the lock stays as it was", + "unchanged" if entry is None else "cleared", + ) + return + self._raise_sick_floor() + kept = ", ".join(sorted(entry.keep)) or "nothing" + if stands_down_workout(): + self._unlock_sick_day( + f"Marked today sick from the lock screen's keep panel (kept: {kept})." + ) + return + _logger.warning("Sick day keeps the workout lock (kept: %s); staying up", kept) + self._show_kept_note() + + def _show_kept_note(self) -> None: + """Say the lock stays because the workout locker was kept.""" + box = self._new_sick_overlay().child_frame(bg=self._colors.palette.bg) + for frame in box: + frame.place(relx=0.5, rely=0.5, anchor="center") + box.child_widgets( + tk.Label, + text=KEPT_NOTE, + font=self._colors.font("title", bold=True), + fg=self._colors.palette.warning, + bg=self._colors.palette.bg, + ).pack(pady=self._colors.space("md")) + self._button( + box, + "Back", + bg=self._colors.palette.accent, + command=self._close_sick_panel, + width=12, + ).pack(pady=self._colors.space("sm")) diff --git a/screen_locker/tests/_real_state_fixtures.py b/screen_locker/tests/_real_state_fixtures.py index 1b70f95..c960f26 100644 --- a/screen_locker/tests/_real_state_fixtures.py +++ b/screen_locker/tests/_real_state_fixtures.py @@ -63,16 +63,34 @@ def _no_sick_days_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> background push never reaches Firebase. """ import sickdays + import sickdays.gui # so its modules are in sys.modules below pool = sickdays.Paths.under(tmp_path / "sickdays") # Every sickdays module that bound ``resolve_paths`` by name: the readers - # (``_api``, which ``stands_down`` / ``kept_lockers`` load through) and the - # writers (``set_kept_lockers`` lives in ``_keep_api``). Patching only - # ``_api`` once let a keep edit land in the developer's real pool. + # (``_api``, which ``stands_down`` / ``kept_lockers`` load through), the + # writers (``set_kept_lockers`` lives in ``_keep_api``) and the embedded + # KeepPanel (``sickdays.gui._panel``). Patching only ``_api`` once let a + # keep edit land in the developer's real pool. ``sync_quietly`` is bound + # by name the same way (the panel's background push), so it goes too. for name, module in list(sys.modules.items()): - if name.startswith("sickdays.") and hasattr(module, "resolve_paths"): + if not name.startswith("sickdays"): + continue + if hasattr(module, "resolve_paths"): monkeypatch.setattr(module, "resolve_paths", lambda paths: paths or pool) - monkeypatch.setattr(sickdays, "sync_quietly", lambda **_kw: False) + if hasattr(module, "sync_quietly"): + monkeypatch.setattr(module, "sync_quietly", lambda **_kw: False) + + +@pytest.fixture(autouse=True) +def _no_real_keep_panel() -> Iterator[None]: + """Never build sickdays' real ``KeepPanel`` (a real ``tk.Frame``) in a test. + + The lock's "choose lockers" overlay embeds it; under the mocked ``tk`` a + real one would still reach Tcl and write the pool on a click. Tests that + drive the panel swap in their own recording fake on top of this one. + """ + with patch("screen_locker._ui_flows_sick_panel.KeepPanel"): + yield @pytest.fixture(autouse=True) diff --git a/screen_locker/tests/_tk_mocks.py b/screen_locker/tests/_tk_mocks.py index d7362f8..10a1021 100644 --- a/screen_locker/tests/_tk_mocks.py +++ b/screen_locker/tests/_tk_mocks.py @@ -31,6 +31,7 @@ _TK_MODULES = ( "screen_locker.status_view", "screen_locker._heat_skip", "screen_locker._surface_group", + "screen_locker._ui_flows_sick_panel", # The scroll viewport the lock surfaces are built from lives in gatelock # and imports tkinter independently, so it needs the same mock -- otherwise # container.first is a *real* tk.Frame whose winfo_children() cannot be diff --git a/screen_locker/tests/test_sick_panel.py b/screen_locker/tests/test_sick_panel.py new file mode 100644 index 0000000..4a61423 --- /dev/null +++ b/screen_locker/tests/test_sick_panel.py @@ -0,0 +1,250 @@ +"""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) + assert "I'm sick" in texts + assert LINK_TEXT in texts + 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() + assert len(panels) == len(locker.container.surfaces) + 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( + self, locker: ScreenLocker, panels: list[FakePanel] + ) -> None: + locker.open_sick_panel() + assert len(panels) == 2 + + 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) + 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 diff --git a/scripts/_fit_extra_screens.py b/scripts/_fit_extra_screens.py new file mode 100644 index 0000000..faaa90e --- /dev/null +++ b/scripts/_fit_extra_screens.py @@ -0,0 +1,62 @@ +"""Lock screens the fit check measures through their own builders. + +Split out of ``verify_screen_fits`` for the 250-line cap. These screens are +not painted by a ``ScreenHost`` flow: heat-skip owns a fullscreen ``tk.Tk()`` +of its own in production, and the "choose lockers" panel is sickdays' +KeepPanel laid over the lock rather than a repaint of it. +""" + +from __future__ import annotations + +from pathlib import Path +import tempfile +from typing import TYPE_CHECKING + +from gatelock import LockConfig +import sickdays + +from screen_locker._heat_skip import build_heat_skip_content +from screen_locker._ui_flows_sick_panel import build_sick_panel + +if TYPE_CHECKING: + import tkinter as tk + + from gatelock import ScrollableSurface + + +def _build_heat_skip(surface: ScrollableSurface) -> tk.Misc: + """Paint the heat-skip screen and return the frame to measure. + + Built through its extracted content builder because in production it owns + a fullscreen ``tk.Tk()`` of its own, which this harness cannot measure. + """ + return build_heat_skip_content( + surface.content, 34.0, on_skip=lambda: None, on_decline=lambda: None + ) + + +def _build_sick_panel(surface: ScrollableSurface) -> tk.Misc: + """Paint the "choose lockers" panel on a throwaway pool and return it. + + Today is marked sick there first, because that variant is the tallest + (Save + Clear + Cancel) and because a pool that does not exist keeps the + measurement independent of the live sick-day and free-day pools. The panel + reads the pool once, while it is built, so the directory can go after. + """ + with tempfile.TemporaryDirectory(prefix="fit-sickdays-") as tmp: + pool = sickdays.Paths.under(Path(tmp)) + sickdays.add_sick_day(sickdays.today(), is_free=lambda _day: False, paths=pool) + return build_sick_panel( + surface.content, + LockConfig(), + on_saved=lambda _entry: None, + on_cancel=lambda: None, + paths=pool, + ) + + +# Screens built without a ScreenHost, measured through their own builder. +EXTRA_SCREENS: tuple[tuple[str, object], ...] = ( + ("heat-skip", _build_heat_skip), + ("sick-keep-panel", _build_sick_panel), +) diff --git a/scripts/verify_screen_fits.py b/scripts/verify_screen_fits.py index 85251cb..3efa4ce 100755 --- a/scripts/verify_screen_fits.py +++ b/scripts/verify_screen_fits.py @@ -32,17 +32,16 @@ from typing import TYPE_CHECKING from gatelock import LockConfig, measure_fit, report_fit from screen_locker._constants import MANUAL_WORKOUT_REFLECTION_MIN_CHARS -from screen_locker._heat_skip import build_heat_skip_content from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin from screen_locker._surface_group import FrameGroup from screen_locker._ui_flows import UIFlowsMixin from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin from screen_locker._ui_widgets import UIWidgetsMixin +from scripts._fit_extra_screens import EXTRA_SCREENS from scripts._xvfb_reexec import reexec_under_xvfb if TYPE_CHECKING: from collections.abc import Callable - import tkinter as tk from gatelock import FitResult, ScrollableSurface @@ -180,17 +179,6 @@ class Screen: paint: Callable[[ScreenHost], None] -def _build_heat_skip(surface: ScrollableSurface) -> tk.Misc: - """Paint the heat-skip screen and return the frame to measure. - - Built through its extracted content builder because in production it owns - a fullscreen ``tk.Tk()`` of its own, which this harness cannot measure. - """ - return build_heat_skip_content( - surface.content, 34.0, on_skip=lambda: None, on_decline=lambda: None - ) - - SCREENS: tuple[Screen, ...] = ( Screen("phone-check", ScreenHost.paint_phone_check), Screen("no-workout-retry", ScreenHost.paint_retry), @@ -203,9 +191,6 @@ SCREENS: tuple[Screen, ...] = ( Screen("relaxed-day-retry", ScreenHost.paint_relaxed_retry), ) -# Screens built without a ScreenHost, measured through their own builder. -EXTRA_SCREENS: tuple[tuple[str, object], ...] = (("heat-skip", _build_heat_skip),) - def measure_all() -> list[FitResult]: """Measure every screen at every supported size."""