b121bbba17
petctl gains screen verbs: `jump` (1-based number, name, next/prev/ primary/other, or a direction resolved from real geometry), `monitors`, and `read` for OCR of a monitor's contents. - monitors.py: pure layout model + jump-target resolution. The monitor list is published by PetWindow from QGuiApplication.screens() over a queued signal, so the controller and window agree on what "monitor 2" means; xrandr and Qt order screens differently on the same machine. - screen_text.py: pull-only OCR (mss capture + Tesseract/RapidOCR). Nothing captures unless the server asks, and the text rides back up the tool-result relay so Bolt can read a screen mid-turn. Both deps optional, soft-failing with a reason. SCREEN_TEXT=false removes it. - Query verbs are answered in controller._handle_command rather than pet_actions.describe(), because their output is the point. - scripts/generate_bolt_sprites.py draws every frame; walk/ is a side-view cycle stepped by distance travelled, not by the animation timer, so the planted paw tracks the window exactly. sprite.py loads it via EXTRA_ANIMATIONS keyed by name, with has() so callers can decline a placeholder blob. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
170 lines
5.7 KiB
Python
170 lines
5.7 KiB
Python
"""Screen layout logic — resolving `petctl jump` targets and describing the
|
|
setup. Pure: the monitor list is normally published by the UI, so none of this
|
|
needs a display."""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from bolt_pet import monitors as m
|
|
|
|
|
|
def grid():
|
|
"""The 2x2 setup this was built against: four 1080p screens.
|
|
|
|
[1 HDMI-0] [2 HDMI-1]
|
|
[3 DP-0 ] [4 DP-2 ]
|
|
"""
|
|
return [
|
|
m.Monitor(0, "HDMI-0", 0, 0, 1920, 1080, primary=False),
|
|
m.Monitor(1, "HDMI-1", 1920, 0, 1920, 1080, primary=False),
|
|
m.Monitor(2, "DP-0", 0, 1080, 1920, 1080, primary=True),
|
|
m.Monitor(3, "DP-2", 1920, 1080, 1920, 1080, primary=False),
|
|
]
|
|
|
|
|
|
def two():
|
|
return [
|
|
m.Monitor(0, "eDP-1", 0, 0, 1920, 1080, primary=True),
|
|
m.Monitor(1, "HDMI-1", 1920, 0, 2560, 1440),
|
|
]
|
|
|
|
|
|
def test_numbers_shown_to_humans_are_one_based():
|
|
left, right = two()
|
|
assert left.index == 0 and left.number == 1
|
|
assert right.index == 1 and right.number == 2
|
|
assert "1: eDP-1 1920x1080 (primary)" == left.label
|
|
|
|
|
|
def test_geometry_helpers():
|
|
screen = m.Monitor(1, "HDMI-1", 1920, 0, 1920, 1080)
|
|
assert screen.right == 3840 and screen.bottom == 1080
|
|
assert screen.center == (2880, 540)
|
|
assert screen.contains(1920, 0)
|
|
assert screen.contains(3839, 1079)
|
|
assert not screen.contains(3840, 0) # right edge is exclusive
|
|
assert not screen.contains(1919, 0)
|
|
|
|
|
|
def test_monitor_containing_and_nearest():
|
|
screens = grid()
|
|
assert m.monitor_containing(screens, 100, 100).name == "HDMI-0"
|
|
assert m.monitor_containing(screens, 2000, 1500).name == "DP-2"
|
|
assert m.monitor_containing(screens, -50, -50) is None
|
|
# off the desktop entirely still resolves to something
|
|
assert m.nearest_monitor(screens, -500, -500).name == "HDMI-0"
|
|
|
|
|
|
def test_resolve_by_number():
|
|
screens = grid()
|
|
assert m.resolve(screens, "3").name == "DP-0"
|
|
with pytest.raises(ValueError, match="no monitor 9"):
|
|
m.resolve(screens, "9")
|
|
with pytest.raises(ValueError):
|
|
m.resolve(screens, "0")
|
|
|
|
|
|
def test_resolve_next_and_prev_wrap():
|
|
screens = grid()
|
|
assert m.resolve(screens, "next", current=3).number == 1
|
|
assert m.resolve(screens, "prev", current=0).number == 4
|
|
assert m.resolve(screens, "next", current=0).number == 2
|
|
|
|
|
|
def test_resolve_primary_and_other():
|
|
screens = grid()
|
|
assert m.resolve(screens, "primary", current=0).name == "DP-0"
|
|
# "other" on a two-screen setup is genuinely the other one
|
|
pair = two()
|
|
assert m.resolve(pair, "other", current=0).number == 2
|
|
assert m.resolve(pair, "other", current=1).number == 1
|
|
|
|
|
|
def test_resolve_directions_on_a_grid():
|
|
screens = grid()
|
|
# from top-left (HDMI-0)
|
|
assert m.resolve(screens, "right", current=0).name == "HDMI-1"
|
|
assert m.resolve(screens, "down", current=0).name == "DP-0"
|
|
# from bottom-right (DP-2)
|
|
assert m.resolve(screens, "left", current=3).name == "DP-0"
|
|
assert m.resolve(screens, "up", current=3).name == "HDMI-1"
|
|
|
|
|
|
def test_direction_prefers_the_best_aligned_screen():
|
|
screens = grid()
|
|
# "right" from DP-0 (bottom-left) must pick DP-2 (same row), not HDMI-1,
|
|
# even though both are to the right.
|
|
assert m.resolve(screens, "right", current=2).name == "DP-2"
|
|
|
|
|
|
def test_resolve_direction_with_nothing_there():
|
|
screens = grid()
|
|
with pytest.raises(ValueError, match="no monitor to the left"):
|
|
m.resolve(screens, "left", current=0)
|
|
|
|
|
|
def test_resolve_by_name_is_fuzzy_but_refuses_ambiguity():
|
|
screens = grid()
|
|
assert m.resolve(screens, "dp-2").name == "DP-2"
|
|
assert m.resolve(screens, "HDMI-0").name == "HDMI-0"
|
|
with pytest.raises(ValueError, match="matches several"):
|
|
m.resolve(screens, "hdmi")
|
|
|
|
|
|
def test_resolve_unknown_spec_lists_the_options():
|
|
screens = two()
|
|
with pytest.raises(ValueError) as excinfo:
|
|
m.resolve(screens, "the big one")
|
|
assert "eDP-1" in str(excinfo.value) and "HDMI-1" in str(excinfo.value)
|
|
|
|
|
|
def test_resolve_without_a_current_screen_falls_back_to_primary():
|
|
screens = grid()
|
|
# primary is index 2, so "next" from nowhere is index 3
|
|
assert m.resolve(screens, "next", current=None).number == 4
|
|
# an out-of-range current is treated the same way rather than exploding
|
|
assert m.resolve(screens, "next", current=99).number == 4
|
|
|
|
|
|
def test_resolve_needs_monitors():
|
|
with pytest.raises(ValueError, match="no monitors"):
|
|
m.resolve([], "next")
|
|
with pytest.raises(ValueError, match="needs a target"):
|
|
m.resolve(grid(), "")
|
|
|
|
|
|
def test_random_always_moves_somewhere_else():
|
|
screens = grid()
|
|
for current in range(4):
|
|
assert m.resolve(screens, "random", current=current).index != current
|
|
|
|
|
|
def test_summary_is_one_line_and_marks_where_the_pet_is():
|
|
line = m.summary(grid(), current=1)
|
|
assert "\n" not in line
|
|
assert line.startswith("4 monitors:")
|
|
assert "Bolt is on 2" in line
|
|
assert m.summary([]) is None
|
|
assert "Bolt is on" not in m.summary(grid(), current=None)
|
|
|
|
|
|
def test_annotate_matches_the_screen_context_style():
|
|
out = m.annotate("what's on the other screen?", grid(), current=0)
|
|
assert out.startswith("what's on the other screen?")
|
|
assert "[4 monitors:" in out
|
|
# nothing to say, nothing added
|
|
assert m.annotate("hello", [], None) == "hello"
|
|
assert m.annotate("", grid(), 0) == ""
|
|
|
|
|
|
def test_describe_lists_every_screen_and_flags_the_pet():
|
|
text = m.describe(grid(), current=2)
|
|
assert text.count("\n") == 4 # header + 4 screens
|
|
assert "DP-0" in text and "+0+1080" in text
|
|
assert text.count("Bolt is here") == 1
|
|
assert m.describe([]) == "[pet] no monitor information available"
|