From 28ced1d39a109a25d77e113d9a601c161db52455 Mon Sep 17 00:00:00 2001 From: Krzysztof Rudnicki Date: Mon, 3 Aug 2026 16:20:11 +0200 Subject: [PATCH] Cover the last branches of the viewport, the density scale and the keys gatelock has never met its own 100% bar; _scrollable.py in particular shipped with no tests at all, which is how a viewport that scrolled itself passed every check for a week. The remaining gaps were the ones a reader would most want proven: * finalize() is documented idempotent -- a repaint calls it again, and an earlier version stacked a handler per call. * scroll_into_view holds still for a widget already fully on screen. Its margin logic has three outcomes and "hold" is the one a jittering viewport gets wrong. * the type scale reads an existing Tk root rather than opening a second one next to a live lock window. * bind_cancel rejects a non-callable at bind time, not on the keypress that finally needed Escape to work. 328 tests, coverage 99.44% -> 100.00%. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01BsXnTHyuuCUZsqetTaajcF --- gatelock/gatelock/tests/test_density.py | 20 +++++++++ gatelock/gatelock/tests/test_keyboard.py | 10 +++++ gatelock/gatelock/tests/test_scrollable.py | 51 ++++++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/gatelock/gatelock/tests/test_density.py b/gatelock/gatelock/tests/test_density.py index cdde6c6..96af833 100644 --- a/gatelock/gatelock/tests/test_density.py +++ b/gatelock/gatelock/tests/test_density.py @@ -185,3 +185,23 @@ def test_forced_restores_a_pre_existing_override( with _density.forced(0.5): assert _density.density() == 0.5 assert _density.density() == 0.9 + + +def test_an_existing_root_is_asked_rather_than_a_new_one( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """With a root already up, the factor comes from it -- no second Tk. + + The apps that build their LockConfig lazily hit this path, and creating a + throwaway Tk alongside a live lock window is not something to do casually. + """ + destroyed: list[bool] = [] + stub = types.SimpleNamespace( + _default_root=_StubRoot(768, destroyed), + Tk=None, + TclError=tk.TclError, + ) + monkeypatch.setattr(_density, "tk", stub) + + assert _density._screen_height() == 768 + assert destroyed == [] diff --git a/gatelock/gatelock/tests/test_keyboard.py b/gatelock/gatelock/tests/test_keyboard.py index fa7a13e..8d1e30c 100644 --- a/gatelock/gatelock/tests/test_keyboard.py +++ b/gatelock/gatelock/tests/test_keyboard.py @@ -100,3 +100,13 @@ def test_shift_tab_goes_back_out_of_a_text_box(root: tk.Tk) -> None: root.update() assert root.focus_get() is before + + +def test_a_non_callable_cancel_is_rejected_at_bind_time(root: tk.Tk) -> None: + """Fail where the mistake is, not on the keypress that needed to work. + + Escape is a lock's sanctioned way back; a binding that raises only when + the user finally presses it fails at the worst possible moment. + """ + with pytest.raises(TypeError, match="callback must be callable"): + bind_cancel(root, "not a function") diff --git a/gatelock/gatelock/tests/test_scrollable.py b/gatelock/gatelock/tests/test_scrollable.py index 31e02c7..c5f01a8 100644 --- a/gatelock/gatelock/tests/test_scrollable.py +++ b/gatelock/gatelock/tests/test_scrollable.py @@ -276,3 +276,54 @@ def test_paging_degrades_to_the_canvas_without_a_toplevel( surface.canvas.event_generate("", when="now") surface.canvas.update() assert _offset(surface) > 0.0 + + +def test_a_second_finalize_does_not_stack_handlers( + surface: ScrollableSurface, +) -> None: + """finalize() is documented idempotent, and a repaint calls it again. + + An earlier version re-bound on every call, so handlers piled up + one per repaint across the ~19 repaint sites of a single lock session. + """ + bound_once = set(surface._focus_bound) + + surface.finalize() + + assert set(surface._focus_bound) == bound_once + + +def test_scroll_into_view_does_nothing_when_everything_fits() -> None: + """Nothing to reveal on a screen that is fully visible already.""" + root = tk.Tk() + try: + root.geometry(f"400x{_VIEWPORT_H}+0+0") + root.focus_force() + root.update_idletasks() + made = ScrollableSurface(root, LockConfig()) + made.container.place(relx=0, rely=0, relwidth=1, relheight=1) + entry = tk.Entry(made.content) + entry.pack() + made.finalize() + root.update() + + made.scroll_into_view(entry) + assert made.canvas.yview() == (0.0, 1.0) + finally: + root.destroy() + + +def test_scroll_into_view_holds_still_for_a_widget_in_the_middle( + surface: ScrollableSurface, +) -> None: + """A widget already fully on screen is not dragged to an edge. + + The margin logic has three outcomes -- scroll up, scroll down, hold -- and + "hold" is the one a viewport that jitters would get wrong. + """ + middle = surface.content.winfo_children()[3] + before = _offset(surface) + + surface.scroll_into_view(middle) + + assert _offset(surface) == before