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