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