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>
335 lines
12 KiB
Python
335 lines
12 KiB
Python
"""Window-side behaviour for the newer features: emote curves, petctl
|
|
actions, edge snapping, napping, click-through.
|
|
|
|
Needs a QApplication — run with QT_QPA_PLATFORM=offscreen.
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from PySide6.QtCore import QPoint
|
|
from PySide6.QtWidgets import QApplication
|
|
|
|
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, _WALK_PIXELS_PER_FRAME, PetWindow, emote_transform,
|
|
)
|
|
from bolt_pet.ui.sprite import WALK
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def qt_app():
|
|
yield QApplication.instance() or QApplication([])
|
|
|
|
|
|
@pytest.fixture
|
|
def pet(qt_app):
|
|
window = PetWindow()
|
|
yield window
|
|
window.close()
|
|
|
|
|
|
# ── emote curves (pure maths) ───────────────────────────────────────────────
|
|
|
|
@pytest.mark.parametrize("emote", ["wave", "hop", "bounce", "spin", "nod", "shake", "wiggle"])
|
|
def test_every_emote_returns_the_sprite_to_rest(emote):
|
|
# Anything that doesn't land back at the identity transform leaves the pet
|
|
# permanently askew. (approx: the sine curves land on ~1e-16, not 0.0.)
|
|
rest = pytest.approx((0.0, 0.0, 0.0, 1.0), abs=1e-9)
|
|
assert emote_transform(emote, 1.0) == rest
|
|
assert emote_transform(emote, 0.0) == rest
|
|
|
|
|
|
def test_emotes_actually_move_the_sprite_mid_animation():
|
|
for emote in ("wave", "hop", "spin", "nod", "shake"):
|
|
samples = [emote_transform(emote, i / 20) for i in range(1, 20)]
|
|
assert any(sample != (0.0, 0.0, 0.0, 1.0) for sample in samples), emote
|
|
|
|
|
|
def test_an_unknown_emote_is_a_no_op_not_a_crash():
|
|
assert emote_transform("moonwalk", 0.5) == (0.0, 0.0, 0.0, 1.0)
|
|
|
|
|
|
def test_progress_is_clamped():
|
|
assert emote_transform("hop", 5.0) == emote_transform("hop", 1.0)
|
|
assert emote_transform("hop", -3.0) == emote_transform("hop", 0.0)
|
|
|
|
|
|
def test_an_emote_finishes_and_clears_itself(pet):
|
|
pet.start_emote("spin")
|
|
assert pet._emote == "spin"
|
|
for _ in range(_EMOTE_TICKS + 2):
|
|
pet._advance_emote()
|
|
assert pet._emote is None
|
|
|
|
|
|
# ── petctl actions ──────────────────────────────────────────────────────────
|
|
|
|
def test_move_action_sets_a_walk_target(pet):
|
|
pet.apply_action({"action": "move", "anchor": "top-left"})
|
|
assert pet._wander_target is not None
|
|
assert pet._commanded_move is True
|
|
|
|
|
|
def test_commanded_moves_happen_even_while_talking(pet, monkeypatch):
|
|
monkeypatch.setattr(config, "PET_EDGE_SNAP", False) # snapping would move it again on arrival
|
|
pet.set_state(PetState.TALKING)
|
|
pet.apply_action({"action": "move", "anchor": "top-left"})
|
|
target = pet._wander_target
|
|
if target is None:
|
|
pytest.skip("no usable screen geometry on this host")
|
|
for _ in range(2000):
|
|
pet._wander_tick()
|
|
if pet._wander_target is None:
|
|
break
|
|
assert pet.pos() == target
|
|
|
|
|
|
def test_a_commanded_move_survives_the_reply_arriving(pet):
|
|
# Real ordering: `petctl move` comes back as a tool call mid-turn, then
|
|
# the reply flips the pet to TALKING a moment later. That must not cancel
|
|
# the walk it was just told to make.
|
|
pet.apply_action({"action": "move", "anchor": "center"})
|
|
pet.set_state(PetState.TALKING)
|
|
assert pet._wander_target is not None
|
|
|
|
|
|
def test_move_to_explicit_coordinates_is_clamped_on_screen(pet):
|
|
pet.apply_action({"action": "move", "x": -5000, "y": -5000})
|
|
geo = pet._screen_geometry()
|
|
if geo is None:
|
|
pytest.skip("no usable screen geometry on this host")
|
|
assert pet._wander_target.x() >= geo.left()
|
|
assert pet._wander_target.y() >= geo.top()
|
|
|
|
|
|
def test_say_action_shows_the_bubble(pet):
|
|
pet.apply_action({"action": "say", "text": "build is green"})
|
|
assert pet._bubble.text == "build is green"
|
|
|
|
|
|
def test_wander_and_nap_actions(pet):
|
|
pet.apply_action({"action": "wander", "enabled": False})
|
|
assert pet._wander_enabled is False
|
|
pet.apply_action({"action": "nap", "enabled": True})
|
|
assert pet.napping is True
|
|
|
|
|
|
def test_emote_action_starts_the_emote(pet):
|
|
pet.apply_action({"action": "emote", "emote": "wave"})
|
|
assert pet._emote == "wave"
|
|
|
|
|
|
# ── napping ─────────────────────────────────────────────────────────────────
|
|
|
|
def test_napping_dims_the_pet_and_stops_it_wandering(pet):
|
|
pet.set_napping(True)
|
|
assert pet.windowOpacity() < 1.0
|
|
start = pet.pos()
|
|
pet.wander_now()
|
|
for _ in range(60):
|
|
pet._wander_tick()
|
|
assert pet.pos() == start
|
|
|
|
pet.set_napping(False)
|
|
assert pet.windowOpacity() == 1.0
|
|
|
|
|
|
# ── edge snapping ───────────────────────────────────────────────────────────
|
|
|
|
def test_snaps_flush_when_parked_near_an_edge(pet, monkeypatch):
|
|
geo = pet._screen_geometry()
|
|
if geo is None:
|
|
pytest.skip("no usable screen geometry on this host")
|
|
monkeypatch.setattr(config, "PET_EDGE_SNAP", True)
|
|
pet.move(geo.left() + 10, geo.top() + 10)
|
|
assert pet.snap_to_edge() is True
|
|
assert pet.pos() == QPoint(geo.left(), geo.top())
|
|
|
|
|
|
def test_does_not_snap_from_the_middle_of_the_screen(pet, monkeypatch):
|
|
geo = pet._screen_geometry()
|
|
if geo is None:
|
|
pytest.skip("no usable screen geometry on this host")
|
|
monkeypatch.setattr(config, "PET_EDGE_SNAP", True)
|
|
middle = QPoint(geo.left() + geo.width() // 2, geo.top() + geo.height() // 2)
|
|
pet.move(middle)
|
|
assert pet.snap_to_edge() is False
|
|
assert pet.pos() == middle
|
|
|
|
|
|
def test_snapping_can_be_turned_off(pet, monkeypatch):
|
|
geo = pet._screen_geometry()
|
|
if geo is None:
|
|
pytest.skip("no usable screen geometry on this host")
|
|
monkeypatch.setattr(config, "PET_EDGE_SNAP", False)
|
|
pet.move(geo.left() + 10, geo.top() + 10)
|
|
assert pet.snap_to_edge() is False
|
|
|
|
|
|
# ── click-through ───────────────────────────────────────────────────────────
|
|
|
|
def test_click_through_toggles_mouse_transparency(pet):
|
|
from PySide6.QtCore import Qt
|
|
|
|
pet.set_click_through(True)
|
|
assert pet.click_through is True
|
|
assert pet.testAttribute(Qt.WA_TransparentForMouseEvents) is True
|
|
|
|
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()
|