Files
Bolt-Pet/tests/test_pet_window_features.py
T
themajesticmagician 80bef6f524 Upload
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 06:25:41 -06:00

182 lines
6.5 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, PetWindow, emote_transform
@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