"""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