80bef6f524
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
182 lines
6.5 KiB
Python
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
|