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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user