mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-14 05:50:57 +01:00
Each file split at a class or concern boundary, never mechanically: test_arbiter.py 432 -> 222 + _lifecycle 183 + _release 201 test_outputs.py 418 -> 187 + _randr 204 + _enumerator 126 test_scrollable.py 374 -> 231 + _into_view 197 test_escape.py 343 -> 175 + _budget 216 test_window_surfaces 341 -> 110 + _lifecycle 104 + _preempt 182 test_surfaces.py 282 -> 167 + _focus 154 test_window.py 269 -> 143 + _grab 139 Test counts verified against a clean HEAD worktree, per file group rather than in aggregate: arbiter 49/49, outputs+escape 84/84, window+surfaces 72/72, scrollable 18/18. The whole suite is 389 passed at 100% branch coverage, identical to before. The one non-mechanical part: _reply/_randr_display are module-level helpers defined *between* classes, so they did not travel with the header and had to be moved to the RandR file by hand — six tests failed until they did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
198 lines
6.6 KiB
Python
198 lines
6.6 KiB
Python
"""Tests for scrolling a specific widget into view.
|
|
|
|
Split from ``test_scrollable.py`` (250-line cap), which keeps the fit states
|
|
and the scroll gestures themselves. These cover ``scroll_into_view`` and the
|
|
focus-following that calls it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import tkinter as tk
|
|
|
|
import pytest
|
|
|
|
from gatelock._scrollable import ScrollableSurface
|
|
from gatelock._scrollkeys import bind_scroll_keys
|
|
from gatelock._userintent import viewport_targets
|
|
from gatelock._window import LockConfig
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
not os.environ.get("DISPLAY"),
|
|
reason="needs a real X display; run under xvfb-run in a headless checkout",
|
|
)
|
|
|
|
_VIEWPORT_H = 200
|
|
_TALL = 60
|
|
|
|
|
|
@pytest.fixture
|
|
def surface() -> tk.Misc:
|
|
"""A 400x200 viewport holding 20 stacked entries -- deliberately taller."""
|
|
root = tk.Tk()
|
|
# A managed window has its geometry rewritten wholesale by a real window
|
|
# manager (i3 confirmed: a plain geometry() request came back tiled to
|
|
# the pane size instead of 400x200, so nothing ever overflowed and every
|
|
# scroll assertion in this file failed against a real WM session) --
|
|
# exactly the reason production lock windows are overrideredirect too.
|
|
root.overrideredirect(boolean=True)
|
|
root.geometry(f"400x{_VIEWPORT_H}+0+0")
|
|
# No window manager under Xvfb, so the toplevel must claim X input focus
|
|
# before any synthetic key event is delivered anywhere.
|
|
root.focus_force()
|
|
root.update_idletasks()
|
|
made = ScrollableSurface(root, LockConfig())
|
|
made.container.place(relx=0, rely=0, relwidth=1, relheight=1)
|
|
for index in range(_TALL):
|
|
tk.Entry(made.content, name=f"field{index}").pack()
|
|
made.finalize()
|
|
root.update()
|
|
yield made
|
|
root.destroy()
|
|
|
|
|
|
def _offset(made: ScrollableSurface) -> float:
|
|
"""The viewport's current scroll fraction."""
|
|
return made.canvas.yview()[0]
|
|
|
|
|
|
def test_scroll_into_view_ignores_an_unmapped_widget(
|
|
surface: ScrollableSurface,
|
|
) -> None:
|
|
"""A widget Tk has not laid out yet has no position to scroll to."""
|
|
ghost = tk.Entry(surface.content) # never packed: zero height
|
|
before = _offset(surface)
|
|
surface.scroll_into_view(ghost)
|
|
assert _offset(surface) == before
|
|
|
|
|
|
def test_scroll_into_view_leaves_an_already_visible_widget_alone(
|
|
surface: ScrollableSurface,
|
|
) -> None:
|
|
"""Nothing moves when the widget is already fully on screen."""
|
|
before = _offset(surface)
|
|
surface.scroll_into_view(surface.content.winfo_children()[0])
|
|
assert _offset(surface) == before
|
|
|
|
|
|
def test_scroll_into_view_reaches_backwards(surface: ScrollableSurface) -> None:
|
|
"""Revealing a widget above the viewport scrolls up, not down."""
|
|
surface.canvas.yview_moveto(1.0)
|
|
surface.canvas.update()
|
|
surface.scroll_into_view(surface.content.winfo_children()[0])
|
|
assert _offset(surface) == 0.0
|
|
|
|
|
|
def test_paging_degrades_to_the_canvas_without_a_toplevel(
|
|
surface: ScrollableSurface, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
"""A widget tree with no real toplevel still scrolls by keyboard.
|
|
|
|
Reachable under a mocked Tk root. Binding nothing at all would leave the
|
|
overflow unreachable, so it degrades to canvas-local bindings and says so
|
|
-- a silent degradation here is content the user cannot get to.
|
|
"""
|
|
|
|
message = "no toplevel"
|
|
|
|
def _no_toplevel() -> None:
|
|
raise tk.TclError(message)
|
|
|
|
surface.canvas.winfo_toplevel = _no_toplevel
|
|
with caplog.at_level("WARNING"):
|
|
bind_scroll_keys(surface.canvas, surface.content)
|
|
surface._intent.bind(viewport_targets(surface.canvas, surface.content))
|
|
|
|
assert "paging keys bound to the canvas only" in caplog.text
|
|
assert "user-input tracking is bound to the viewport only" in caplog.text
|
|
|
|
surface.canvas.focus_set()
|
|
surface.canvas.event_generate("<Next>", 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 <FocusIn> 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
|
|
|
|
|
|
def test_the_wheel_scrolls_from_over_the_content(surface: ScrollableSurface) -> None:
|
|
"""Wheel-over-text scrolls, which is the only way anyone uses a mouse here.
|
|
|
|
Tk dispatches through *bindtags*, not the parent chain: a child's tags are
|
|
``(itself, its class, the toplevel, "all")``, so a binding on ``content``
|
|
is never consulted for an event over a Label sitting on top of it. Content
|
|
that fills the viewport covers that frame entirely, so binding the wheel
|
|
only on the canvas and the content frame makes wheel scrolling dead
|
|
everywhere the user would actually point.
|
|
"""
|
|
child = surface.content.winfo_children()[10]
|
|
before = surface.canvas.yview()[0]
|
|
|
|
child.event_generate("<Button-5>")
|
|
surface.canvas.update()
|
|
|
|
assert surface.canvas.yview()[0] > before
|
|
|
|
|
|
def test_the_wheel_scrolls_back_up_from_over_the_content(
|
|
surface: ScrollableSurface,
|
|
) -> None:
|
|
"""The up notch is bound on descendants too, not just the down one."""
|
|
child = surface.content.winfo_children()[10]
|
|
child.event_generate("<Button-5>")
|
|
surface.canvas.update()
|
|
scrolled = surface.canvas.yview()[0]
|
|
|
|
child.event_generate("<Button-4>")
|
|
surface.canvas.update()
|
|
|
|
assert surface.canvas.yview()[0] < scrolled
|