Multi-monitor jumps, screen OCR, and generated sprite art
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>
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
"""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"
|
||||
@@ -15,7 +15,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import config
|
||||
from bolt_pet.state import PetState
|
||||
from bolt_pet.ui.pet_window import _EMOTE_TICKS, PetWindow, emote_transform
|
||||
from bolt_pet.ui.pet_window import (
|
||||
_EMOTE_TICKS, _WALK_PIXELS_PER_FRAME, PetWindow, emote_transform,
|
||||
)
|
||||
from bolt_pet.ui.sprite import WALK
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@@ -179,3 +182,153 @@ def test_click_through_toggles_mouse_transparency(pet):
|
||||
|
||||
pet.set_click_through(False)
|
||||
assert pet.testAttribute(Qt.WA_TransparentForMouseEvents) is False
|
||||
|
||||
|
||||
# ── monitors ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_window_publishes_a_monitor_list(pet):
|
||||
"""Whatever the test host's screen setup is, the window must describe it
|
||||
in the shape the controller expects."""
|
||||
monitors = pet.monitors()
|
||||
assert monitors, "offscreen Qt still reports at least one screen"
|
||||
assert [m.index for m in monitors] == list(range(len(monitors)))
|
||||
assert all(m.width > 0 and m.height > 0 for m in monitors)
|
||||
assert all(m.name for m in monitors)
|
||||
assert sum(1 for m in monitors if m.primary) <= 1
|
||||
|
||||
|
||||
def test_publish_monitors_re_emits_when_forced(pet):
|
||||
"""ui/app.py relies on this: the window is built before the controller
|
||||
exists, so its constructor's publish reaches nobody and has to be redone."""
|
||||
seen = []
|
||||
pet.monitors_changed.connect(seen.append)
|
||||
pet.publish_monitors() # force defaults to True
|
||||
assert len(seen) == 1
|
||||
pet.publish_monitors(force=False) # nothing changed -> stays quiet
|
||||
assert len(seen) == 1
|
||||
|
||||
|
||||
def test_pet_reports_which_monitor_it_is_on(pet):
|
||||
seen = []
|
||||
pet.pet_monitor_changed.connect(seen.append)
|
||||
pet.publish_monitors()
|
||||
assert seen and seen[-1] == pet.current_monitor_index()
|
||||
assert 0 <= seen[-1] < len(pet.monitors())
|
||||
|
||||
|
||||
def test_jump_moves_the_window_onto_the_target_screen(pet):
|
||||
monitors = pet.monitors()
|
||||
target = len(monitors) - 1
|
||||
pet.apply_action({"action": "jump", "monitor": target})
|
||||
assert pet.current_monitor_index() == target
|
||||
# a jump lands with a hop rather than sliding there
|
||||
assert pet._emote == "hop"
|
||||
|
||||
|
||||
def test_jump_cancels_a_stroll_so_it_does_not_walk_back(pet):
|
||||
pet.apply_action({"action": "move", "anchor": "top-left"})
|
||||
assert pet._wander_target is not None
|
||||
pet.apply_action({"action": "jump", "monitor": 0})
|
||||
assert pet._wander_target is None
|
||||
assert pet._commanded_move is False
|
||||
|
||||
|
||||
def test_jump_to_a_bogus_index_is_a_no_op(pet):
|
||||
before = pet.pos()
|
||||
pet.apply_action({"action": "jump", "monitor": 99})
|
||||
pet.apply_action({"action": "jump", "monitor": -1})
|
||||
assert pet.pos() == before
|
||||
|
||||
|
||||
# ── walk cycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_walk_art_loads_as_a_non_state_animation(pet):
|
||||
"""Walking is a property of movement, not a PetState, so it lives outside
|
||||
the state machine but still loads like any other animation."""
|
||||
assert pet.sprites.has(WALK)
|
||||
assert len(pet.sprites.get(WALK).frames) == 8
|
||||
assert pet.sprites.get("nonsense") is pet.sprites.get(PetState.IDLE)
|
||||
assert not pet.sprites.has("nonsense")
|
||||
|
||||
|
||||
def test_walking_overrides_the_state_animation(pet):
|
||||
assert pet._animation_key() == pet._current_state
|
||||
pet._advance_walk(50, 0, 1.0)
|
||||
assert pet._animation_key() == WALK
|
||||
|
||||
|
||||
def test_walk_cycle_advances_by_distance_not_by_the_clock(pet):
|
||||
"""The planted paw tracks backwards at the speed the window moves
|
||||
forwards; drive it off the animation timer instead and the feet skate."""
|
||||
anim = pet.sprites.get(WALK)
|
||||
anim.reset()
|
||||
pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 3)
|
||||
assert anim._index == 3
|
||||
# a step too small to cross the threshold banks the distance instead
|
||||
pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 0.5)
|
||||
assert anim._index == 3
|
||||
pet._advance_walk(100, 0, _WALK_PIXELS_PER_FRAME * 0.5)
|
||||
assert anim._index == 4
|
||||
|
||||
|
||||
def test_the_animation_timer_does_not_double_step_the_walk(pet):
|
||||
anim = pet.sprites.get(WALK)
|
||||
pet._advance_walk(50, 0, 1.0)
|
||||
anim.reset()
|
||||
pet._advance_frame()
|
||||
assert anim._index == 0
|
||||
|
||||
|
||||
def test_facing_follows_horizontal_travel(pet):
|
||||
pet._advance_walk(50, 0, 1.0)
|
||||
assert pet._facing == 1
|
||||
pet._advance_walk(-50, 0, 1.0)
|
||||
assert pet._facing == -1
|
||||
|
||||
|
||||
def test_a_near_vertical_stroll_does_not_flip_him(pet):
|
||||
"""Rounding noise on dx would otherwise flip him back and forth every
|
||||
tick on a straight-up walk."""
|
||||
pet._facing = 1
|
||||
pet._advance_walk(0.4, 60, 1.0)
|
||||
assert pet._facing == 1
|
||||
|
||||
|
||||
def test_walking_left_paints_a_mirrored_frame(pet):
|
||||
frame = pet.sprites.get(WALK).current()
|
||||
pet._facing = 1
|
||||
assert pet._oriented(frame) is frame # art is drawn facing right
|
||||
pet._facing = -1
|
||||
flipped = pet._oriented(frame)
|
||||
assert flipped is not frame
|
||||
assert flipped.size() == frame.size()
|
||||
assert pet._oriented(frame) is flipped # cached, not re-flipped per paint
|
||||
|
||||
|
||||
def test_stopping_resets_the_cycle_to_a_standing_frame(pet):
|
||||
pet._advance_walk(50, 0, _WALK_PIXELS_PER_FRAME * 2)
|
||||
assert pet._walking
|
||||
pet._stop_walking()
|
||||
assert not pet._walking
|
||||
assert pet._walk_distance == 0.0
|
||||
assert pet.sprites.get(WALK)._index == 0
|
||||
assert pet._animation_key() == pet._current_state
|
||||
|
||||
|
||||
def test_walk_art_suppresses_the_hard_coded_bob(pet):
|
||||
"""The frames carry their own weight shift — bobbing the window as well
|
||||
would double it up."""
|
||||
pet._advance_walk(50, 0, 5.0)
|
||||
assert pet._bob_offset == 0
|
||||
|
||||
|
||||
def test_without_walk_art_it_falls_back_to_the_old_bob(qt_app, tmp_path):
|
||||
window = PetWindow(sprite_dir=tmp_path)
|
||||
try:
|
||||
assert not window.sprites.has(WALK)
|
||||
window._advance_walk(50, 0, 5.0)
|
||||
assert window._walking
|
||||
assert window._animation_key() == window._current_state
|
||||
assert window._bob_offset < 0 # still visibly moving
|
||||
finally:
|
||||
window.close()
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""OCR plumbing for `petctl read` — engine selection and output cleanup.
|
||||
|
||||
Only the pure half is covered, per the testing conventions: capture and the
|
||||
OCR call itself need a real screen and a real engine. Engine probes are
|
||||
injected so these pass on a machine with a different set installed (or none).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from bolt_pet import screen_text
|
||||
from bolt_pet.monitors import Monitor
|
||||
|
||||
|
||||
def probes(modules=(), binaries=()):
|
||||
return (lambda name: name in modules), (
|
||||
lambda name: f"/usr/bin/{name}" if name in binaries else None
|
||||
)
|
||||
|
||||
|
||||
def test_prefers_tesseract_when_fully_installed():
|
||||
has_module, which = probes({"pytesseract"}, {"tesseract"})
|
||||
assert screen_text.resolve_engine(has_module, which) == ("pytesseract", "")
|
||||
|
||||
|
||||
def test_falls_back_to_rapidocr_when_tesseract_binary_is_absent():
|
||||
has_module, which = probes({"pytesseract", "rapidocr_onnxruntime"}, set())
|
||||
engine, reason = screen_text.resolve_engine(has_module, which)
|
||||
assert engine == "rapidocr"
|
||||
assert reason == ""
|
||||
|
||||
|
||||
def test_pytesseract_without_the_binary_says_which_half_is_missing():
|
||||
"""The commonest broken setup: `pip install pytesseract` and stop, not
|
||||
realising the actual engine is a system package."""
|
||||
has_module, which = probes({"pytesseract"}, set())
|
||||
engine, reason = screen_text.resolve_engine(has_module, which)
|
||||
assert engine is None
|
||||
assert "tesseract binary" in reason
|
||||
assert "apt install tesseract-ocr" in reason
|
||||
|
||||
|
||||
def test_nothing_installed_explains_how_to_fix_it():
|
||||
has_module, which = probes(set(), set())
|
||||
engine, reason = screen_text.resolve_engine(has_module, which)
|
||||
assert engine is None
|
||||
assert "pip install" in reason
|
||||
|
||||
|
||||
def test_capture_availability_follows_mss():
|
||||
assert screen_text.capture_available(lambda name: name == "mss")
|
||||
assert not screen_text.capture_available(lambda name: False)
|
||||
|
||||
|
||||
def test_clean_drops_ocr_noise_and_blank_runs():
|
||||
raw = "Firefox\n\n\n |\n .\nBuild failed\n~\n"
|
||||
assert screen_text.clean_ocr_text(raw) == "Firefox\nBuild failed"
|
||||
|
||||
|
||||
def test_clean_collapses_whitespace_but_keeps_line_structure():
|
||||
raw = " File edit view \nline\ttwo "
|
||||
assert screen_text.clean_ocr_text(raw) == "File edit view\nline two"
|
||||
|
||||
|
||||
def test_clean_drops_consecutive_duplicates_only():
|
||||
raw = "Terminal\nTerminal\nEditor\nTerminal"
|
||||
assert screen_text.clean_ocr_text(raw) == "Terminal\nEditor\nTerminal"
|
||||
|
||||
|
||||
def test_clean_keeps_short_but_real_tokens():
|
||||
# two alphanumerics is the bar — "ok" and "42" survive, "-" doesn't
|
||||
assert screen_text.clean_ocr_text("ok\n-\n42") == "ok\n42"
|
||||
|
||||
|
||||
def test_clean_truncates_and_says_so():
|
||||
out = screen_text.clean_ocr_text("word " * 500, max_chars=100)
|
||||
assert out.endswith("[truncated]")
|
||||
# the cap applies to the text, before the marker is appended
|
||||
assert len(out.split("\n[truncated]")[0]) <= 100
|
||||
|
||||
|
||||
def test_clean_handles_empty_input():
|
||||
assert screen_text.clean_ocr_text("") == ""
|
||||
assert screen_text.clean_ocr_text(None) == ""
|
||||
|
||||
|
||||
def test_format_reading_names_the_monitor():
|
||||
monitor = Monitor(1, "HDMI-1", 1920, 0, 1920, 1080)
|
||||
out = screen_text.format_reading(monitor, "Build failed")
|
||||
assert "monitor 2 (HDMI-1)" in out
|
||||
assert out.endswith("Build failed")
|
||||
|
||||
|
||||
def test_format_reading_when_nothing_was_recognised():
|
||||
monitor = Monitor(0, "DP-0", 0, 0, 1920, 1080)
|
||||
assert "no text recognised" in screen_text.format_reading(monitor, " ")
|
||||
|
||||
|
||||
def test_read_monitor_never_raises_without_an_engine(monkeypatch):
|
||||
"""Its return value goes back to the server as command output, so every
|
||||
failure has to come back as a sentence rather than an exception."""
|
||||
monkeypatch.setattr(screen_text, "capture_available", lambda *a, **k: True)
|
||||
monkeypatch.setattr(
|
||||
screen_text, "resolve_engine", lambda *a, **k: (None, "no engine here")
|
||||
)
|
||||
out = screen_text.read_monitor(Monitor(0, "DP-0", 0, 0, 1920, 1080))
|
||||
assert out.startswith("[pet]")
|
||||
assert "no engine here" in out
|
||||
|
||||
|
||||
def test_read_monitor_reports_a_failed_capture(monkeypatch):
|
||||
monkeypatch.setattr(screen_text, "capture_available", lambda *a, **k: True)
|
||||
monkeypatch.setattr(screen_text, "resolve_engine", lambda *a, **k: ("pytesseract", ""))
|
||||
monkeypatch.setattr(screen_text, "capture", lambda monitor: None)
|
||||
out = screen_text.read_monitor(Monitor(0, "DP-0", 0, 0, 1920, 1080))
|
||||
assert "couldn't capture" in out and "Wayland" in out
|
||||
|
||||
|
||||
def test_read_monitor_survives_an_exploding_engine(monkeypatch):
|
||||
monkeypatch.setattr(screen_text, "capture_available", lambda *a, **k: True)
|
||||
monkeypatch.setattr(screen_text, "resolve_engine", lambda *a, **k: ("pytesseract", ""))
|
||||
monkeypatch.setattr(screen_text, "capture", lambda monitor: object())
|
||||
monkeypatch.setattr(
|
||||
screen_text, "_ocr", lambda image, engine: (_ for _ in ()).throw(RuntimeError("boom"))
|
||||
)
|
||||
out = screen_text.read_monitor(Monitor(0, "DP-0", 0, 0, 1920, 1080))
|
||||
assert "OCR failed" in out and "boom" in out
|
||||
|
||||
|
||||
def test_read_monitors_splits_the_budget(monkeypatch):
|
||||
seen = []
|
||||
|
||||
def fake_read(monitor, max_chars):
|
||||
seen.append((monitor.number, max_chars))
|
||||
return f"screen {monitor.number}"
|
||||
|
||||
monkeypatch.setattr(screen_text, "read_monitor", fake_read)
|
||||
screens = [
|
||||
Monitor(0, "A", 0, 0, 100, 100),
|
||||
Monitor(1, "B", 100, 0, 100, 100),
|
||||
Monitor(2, "C", 200, 0, 100, 100),
|
||||
]
|
||||
out = screen_text.read_monitors(screens, 3000)
|
||||
assert [n for n, _ in seen] == [1, 2, 3]
|
||||
assert all(limit == 1000 for _, limit in seen)
|
||||
assert out.count("screen ") == 3
|
||||
|
||||
|
||||
def test_read_monitors_keeps_a_floor_on_the_budget(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
screen_text, "read_monitor", lambda monitor, max_chars: str(max_chars)
|
||||
)
|
||||
screens = [Monitor(i, str(i), 0, 0, 10, 10) for i in range(20)]
|
||||
# 100/20 would be 5 characters per screen, which is useless — floor wins
|
||||
assert "400" in screen_text.read_monitors(screens, 100)
|
||||
|
||||
|
||||
def test_read_monitors_with_one_screen_uses_the_whole_budget(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
screen_text, "read_monitor", lambda monitor, max_chars: str(max_chars)
|
||||
)
|
||||
assert screen_text.read_monitors([Monitor(0, "A", 0, 0, 10, 10)], 4000) == "4000"
|
||||
|
||||
|
||||
def test_read_monitors_with_no_screens():
|
||||
assert "no monitor information" in screen_text.read_monitors([], 4000)
|
||||
@@ -0,0 +1,233 @@
|
||||
"""End-to-end wiring for the multi-monitor features: `petctl jump`, `petctl
|
||||
monitors`, `petctl read`, and the screen-layout note that rides along with
|
||||
each utterance.
|
||||
|
||||
Needs a QApplication (signals), so run with QT_QPA_PLATFORM=offscreen.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from bolt_pet import controller as controller_mod
|
||||
from bolt_pet import pet_actions
|
||||
from bolt_pet.monitors import Monitor
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
_app = QApplication.instance() or QApplication(["test"])
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_screen_probes(monkeypatch):
|
||||
monkeypatch.setattr(controller_mod.screen_context, "is_fullscreen_active", lambda: False)
|
||||
monkeypatch.setattr(controller_mod.screen_context, "context_for", lambda text: text)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ctrl():
|
||||
controller = controller_mod.PetController()
|
||||
controller.set_monitors(
|
||||
[
|
||||
Monitor(0, "HDMI-0", 0, 0, 1920, 1080),
|
||||
Monitor(1, "HDMI-1", 1920, 0, 1920, 1080),
|
||||
Monitor(2, "DP-0", 0, 1080, 1920, 1080, primary=True),
|
||||
]
|
||||
)
|
||||
controller.set_pet_monitor(0)
|
||||
return controller
|
||||
|
||||
|
||||
def _capture(signal):
|
||||
events = []
|
||||
signal.connect(lambda *a: events.append(a[0] if len(a) == 1 else a))
|
||||
return events
|
||||
|
||||
|
||||
# ── parsing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_jump_parses_without_validating_the_target():
|
||||
"""Which monitors exist is a runtime fact, so the pure parser passes the
|
||||
spec through and monitors.resolve() judges it later."""
|
||||
assert pet_actions.parse("petctl jump 2") == {"action": "jump", "target": "2"}
|
||||
assert pet_actions.parse("petctl jump next") == {"action": "jump", "target": "next"}
|
||||
assert pet_actions.parse("petctl monitor left") == {"action": "jump", "target": "left"}
|
||||
assert pet_actions.parse("petctl screen HDMI-1") == {"action": "jump", "target": "HDMI-1"}
|
||||
# a nonsense target is still parsed — it fails at resolve time, with a
|
||||
# message listing the real monitors
|
||||
assert pet_actions.parse("petctl jump sideways") == {
|
||||
"action": "jump", "target": "sideways",
|
||||
}
|
||||
|
||||
|
||||
def test_jump_needs_a_target():
|
||||
with pytest.raises(pet_actions.ActionError, match="needs a monitor"):
|
||||
pet_actions.parse("petctl jump")
|
||||
|
||||
|
||||
def test_read_defaults_to_the_current_screen():
|
||||
assert pet_actions.parse("petctl read") == {"action": "read", "target": "here"}
|
||||
assert pet_actions.parse("petctl read all") == {"action": "read", "target": "all"}
|
||||
assert pet_actions.parse("petctl look 2") == {"action": "read", "target": "2"}
|
||||
assert pet_actions.parse("petctl see here") == {"action": "read", "target": "here"}
|
||||
|
||||
|
||||
def test_monitors_verb():
|
||||
for spelling in ("monitors", "screens", "displays"):
|
||||
assert pet_actions.parse(f"petctl {spelling}") == {"action": "monitors"}
|
||||
|
||||
|
||||
def test_help_mentions_the_new_verbs():
|
||||
assert "petctl jump" in pet_actions.HELP
|
||||
assert "petctl read" in pet_actions.HELP
|
||||
assert "petctl monitors" in pet_actions.HELP
|
||||
|
||||
|
||||
# ── jump ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_jump_resolves_to_an_index_before_reaching_the_window(ctrl):
|
||||
"""The controller resolves and emits a concrete index, so the window can't
|
||||
re-resolve the spec against a different screen ordering."""
|
||||
actions = _capture(ctrl.action)
|
||||
out = ctrl._handle_command("petctl jump next")
|
||||
assert actions == [{"action": "jump", "monitor": 1}]
|
||||
assert "monitor 2: HDMI-1" in out
|
||||
|
||||
|
||||
def test_jump_by_direction_uses_the_published_layout(ctrl):
|
||||
actions = _capture(ctrl.action)
|
||||
ctrl._handle_command("petctl jump down")
|
||||
assert actions == [{"action": "jump", "monitor": 2}]
|
||||
|
||||
|
||||
def test_jump_tracks_where_the_pet_actually_is(ctrl):
|
||||
ctrl.set_pet_monitor(1)
|
||||
actions = _capture(ctrl.action)
|
||||
ctrl._handle_command("petctl jump left")
|
||||
assert actions == [{"action": "jump", "monitor": 0}]
|
||||
|
||||
|
||||
def test_jump_to_a_nonexistent_monitor_reports_back_and_moves_nothing(ctrl):
|
||||
actions = _capture(ctrl.action)
|
||||
out = ctrl._handle_command("petctl jump 7")
|
||||
assert actions == []
|
||||
assert "no monitor 7" in out
|
||||
assert "you have 3" in out
|
||||
|
||||
|
||||
def test_jump_never_reaches_the_shell(monkeypatch, ctrl):
|
||||
ran = []
|
||||
monkeypatch.setattr(controller_mod.server_client, "run_local_command", ran.append)
|
||||
ctrl._handle_command("petctl jump 2")
|
||||
ctrl._handle_command("petctl monitors")
|
||||
ctrl._handle_command("petctl read")
|
||||
assert ran == []
|
||||
|
||||
|
||||
# ── monitors ────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_monitors_lists_the_layout_and_where_the_pet_is(ctrl):
|
||||
ctrl.set_pet_monitor(2)
|
||||
out = ctrl._handle_command("petctl monitors")
|
||||
assert "3 monitor(s)" in out
|
||||
assert "HDMI-0" in out and "DP-0" in out
|
||||
assert out.count("Bolt is here") == 1
|
||||
|
||||
|
||||
def test_monitors_before_the_ui_has_published_anything():
|
||||
fresh = controller_mod.PetController()
|
||||
assert "no monitor information" in fresh._handle_command("petctl monitors")
|
||||
|
||||
|
||||
# ── read ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_read_here_uses_the_pets_own_screen(monkeypatch, ctrl):
|
||||
seen = []
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitor",
|
||||
lambda monitor, limit: seen.append(monitor.number) or "text",
|
||||
)
|
||||
ctrl.set_pet_monitor(1)
|
||||
assert ctrl._handle_command("petctl read") == "text"
|
||||
assert seen == [2]
|
||||
|
||||
|
||||
def test_read_a_named_screen(monkeypatch, ctrl):
|
||||
seen = []
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitor",
|
||||
lambda monitor, limit: seen.append(monitor.name) or "text",
|
||||
)
|
||||
ctrl._handle_command("petctl read DP-0")
|
||||
assert seen == ["DP-0"]
|
||||
|
||||
|
||||
def test_read_all_goes_through_the_multi_screen_path(monkeypatch, ctrl):
|
||||
seen = []
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitors",
|
||||
lambda monitors, limit: seen.append(len(monitors)) or "everything",
|
||||
)
|
||||
assert ctrl._handle_command("petctl read all") == "everything"
|
||||
assert seen == [3]
|
||||
|
||||
|
||||
def test_read_an_unknown_screen_explains_rather_than_raising(ctrl):
|
||||
out = ctrl._handle_command("petctl read 9")
|
||||
assert out.startswith("[pet]")
|
||||
assert "no monitor 9" in out
|
||||
|
||||
|
||||
def test_read_respects_the_kill_switch(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "SCREEN_TEXT", False)
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitor",
|
||||
lambda *a, **k: called.append(1) or "text",
|
||||
)
|
||||
out = ctrl._handle_command("petctl read")
|
||||
assert called == []
|
||||
assert "disabled" in out and "SCREEN_TEXT" in out
|
||||
|
||||
|
||||
def test_read_passes_the_character_cap_through(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "SCREEN_TEXT_MAX_CHARS", 123)
|
||||
seen = []
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitor",
|
||||
lambda monitor, limit: seen.append(limit) or "text",
|
||||
)
|
||||
ctrl._handle_command("petctl read")
|
||||
assert seen == [123]
|
||||
|
||||
|
||||
# ── per-turn context ────────────────────────────────────────────────────────
|
||||
|
||||
def test_layout_rides_along_with_each_utterance(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "MONITOR_CONTEXT", True)
|
||||
out = ctrl._with_context("what's on the other screen?")
|
||||
assert out.startswith("what's on the other screen?")
|
||||
assert "3 monitors:" in out
|
||||
assert "Bolt is on 1" in out
|
||||
|
||||
|
||||
def test_layout_context_can_be_switched_off(monkeypatch, ctrl):
|
||||
monkeypatch.setattr(controller_mod.config, "MONITOR_CONTEXT", False)
|
||||
assert ctrl._with_context("hello") == "hello"
|
||||
|
||||
|
||||
def test_screen_text_never_rides_along_automatically(monkeypatch, ctrl):
|
||||
"""The layout is free; the *contents* cost an OCR pass and a lot of
|
||||
privacy, so they only ever move on an explicit petctl read."""
|
||||
monkeypatch.setattr(controller_mod.config, "MONITOR_CONTEXT", True)
|
||||
called = []
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitor", lambda *a, **k: called.append(1)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
controller_mod.screen_text, "read_monitors", lambda *a, **k: called.append(1)
|
||||
)
|
||||
ctrl._with_context("hello")
|
||||
assert called == []
|
||||
Reference in New Issue
Block a user