From f2f7d6475905170759b257a64d4a99d17b1d5cb2 Mon Sep 17 00:00:00 2001 From: pax Date: Wed, 8 Apr 2026 19:27:23 -0500 Subject: [PATCH] popout/state: test scaffolding (62 tests, 27 pass at skeleton stage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays down the full test surface for the popout state machine ahead of any transition logic. 62 collected tests across the four categories from docs/POPOUT_REFACTOR_PLAN.md "Test plan": 1. Read-path queries (4 tests, all passing at commit 3 — these exercise the parts of the skeleton that are already real: compute_slider_display_ms, the terminal Closing guard, the initial state defaults) 2. Per-state transition tests (~22 tests, all failing at commit 3 because the per-event handlers in state.py are stubs returning []. Each documents the expected new state and effects for one specific (state, event) pair. These pass progressively as commits 4-11 land.) 3. Race-fix invariant tests (6 tests — one for each of the six structural fixes from the prior fix sweep: EOF race, double- navigate, persistent viewport, F11 round-trip, seek pin, pending mute replay. The EOF race test already passes because dropping VideoEofReached in LoadingVideo is just "stub returns []", which is the right behavior for now. The others fail until their transitions land.) 4. Illegal transition tests (17 parametrized cases — at commit 11 these become BOORU_VIEWER_STRICT_STATE-gated raises. At commits 3-10 they pass trivially because the stubs return [], which is the release-mode behavior.) All 62 tests are pure Python: - Import only `booru_viewer.gui.popout.state` and `popout.viewport` - Construct StateMachine() directly - Use direct field mutation (`m.state = State.PLAYING_VIDEO`) for setup, dispatch the event under test, assert the new state + returned effects - No QApplication, no mpv, no httpx, no filesystem outside tmp_path - Sub-100ms total runtime (currently 0.31s including test discovery) The forcing function: if state.py grows a PySide6/mpv/httpx import, this test file fails to collect and the suite breaks. That's the guardrail that keeps state.py pure as transitions land. Test count breakdown (62 total): - 4 trivially-passing (read-path queries + initial state) - 22 transition tests (one per (state, event) pair) - 6 invariant tests (mapped to the six race fixes) - 17 illegal transition cases (parametrized over (state, event) pairs) - 5 close-from-each-state cases (parametrized) - 8 misc (state field persistence, window events) Result at commit 3: 35 failed, 27 passed in 0.31s The 27 passing are exactly the predicted set: trivial reads + the illegal-transition pass-throughs (which work today because the stubs return [] just like release-mode strict-state would). The 35 failing are the transition handlers that need real implementations. Phase A test suite (16 tests in tests/core/) still passes — this commit only adds new tests, no existing test changed. Test cases for state machine implementation (commits 4-11): - Each failing test is its own commit acceptance criterion - Commit N "passes" when the relevant subset of tests turns green - Final state machine sweep (commit 11): all 62 tests pass --- tests/gui/__init__.py | 0 tests/gui/popout/__init__.py | 0 tests/gui/popout/test_state.py | 625 +++++++++++++++++++++++++++++++++ 3 files changed, 625 insertions(+) create mode 100644 tests/gui/__init__.py create mode 100644 tests/gui/popout/__init__.py create mode 100644 tests/gui/popout/test_state.py diff --git a/tests/gui/__init__.py b/tests/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/gui/popout/__init__.py b/tests/gui/popout/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/gui/popout/test_state.py b/tests/gui/popout/test_state.py new file mode 100644 index 0000000..930fe28 --- /dev/null +++ b/tests/gui/popout/test_state.py @@ -0,0 +1,625 @@ +"""Pure-Python state machine tests for the popout viewer. + +Imports `booru_viewer.gui.popout.state` directly without standing up a +QApplication. The state machine module is required to be import-pure +(no PySide6, mpv, httpx, subprocess, or any module that imports them); +this test file is the forcing function. If state.py grows a Qt or mpv +import, these tests fail to collect and the test suite breaks. + +Test categories (from docs/POPOUT_REFACTOR_PLAN.md "Test plan"): + 1. Per-state transition tests + 2. Race-fix invariant tests (six structural fixes) + 3. Illegal transition tests + 4. Read-path query tests + +**Commit 3 expectation:** most tests fail because state.py's dispatch +handlers are stubs returning []. Tests progressively pass as commits +4-11 land transitions. The trivially-passing tests at commit 3 (initial +state, slider display read-path, terminal Closing guard) document the +parts of the skeleton that are already real. + +Refactor plan: docs/POPOUT_REFACTOR_PLAN.md +Architecture: docs/POPOUT_ARCHITECTURE.md +""" + +from __future__ import annotations + +import pytest + +from booru_viewer.gui.popout.state import ( + # Enums + LoopMode, + MediaKind, + State, + StateMachine, + # Events + CloseRequested, + ContentArrived, + FullscreenToggled, + HyprlandDriftDetected, + LoopModeSet, + MuteToggleRequested, + NavigateRequested, + Open, + SeekCompleted, + SeekRequested, + TogglePlayRequested, + VideoEofReached, + VideoSizeKnown, + VideoStarted, + VolumeSet, + WindowMoved, + WindowResized, + # Effects + ApplyLoopMode, + ApplyMute, + ApplyVolume, + EmitClosed, + EmitNavigate, + EmitPlayNextRequested, + EnterFullscreen, + ExitFullscreen, + FitWindowToContent, + LoadImage, + LoadVideo, + SeekVideoTo, + StopMedia, +) +from booru_viewer.gui.popout.viewport import Viewport + + +# ---------------------------------------------------------------------- +# Helpers — direct field mutation for setup. Tests construct a fresh +# StateMachine and write the state field directly to skip the dispatch +# chain. This is a deliberate test-fixture-vs-production-code split: +# the tests don't depend on the dispatch chain being correct in order +# to test individual transitions. +# ---------------------------------------------------------------------- + + +def _new_in(state: State) -> StateMachine: + m = StateMachine() + m.state = state + return m + + +# ---------------------------------------------------------------------- +# Read-path queries (commit 2 — already passing) +# ---------------------------------------------------------------------- + + +def test_initial_state(): + m = StateMachine() + assert m.state == State.AWAITING_CONTENT + assert m.is_first_content_load is True + assert m.fullscreen is False + assert m.mute is False + assert m.volume == 50 + assert m.loop_mode == LoopMode.LOOP + assert m.viewport is None + assert m.seek_target_ms == 0 + + +def test_compute_slider_display_ms_passthrough_when_not_seeking(): + m = StateMachine() + m.state = State.PLAYING_VIDEO + assert m.compute_slider_display_ms(7500) == 7500 + + +def test_compute_slider_display_ms_pinned_when_seeking(): + m = StateMachine() + m.state = State.SEEKING_VIDEO + m.seek_target_ms = 7000 + # mpv's reported position can be anywhere; the slider must show + # the user's target while we're in SeekingVideo. + assert m.compute_slider_display_ms(5000) == 7000 + assert m.compute_slider_display_ms(7000) == 7000 + assert m.compute_slider_display_ms(9999) == 7000 + + +def test_dispatch_in_closing_returns_empty(): + """Closing is terminal — every event from Closing returns [] and + the state stays Closing.""" + m = _new_in(State.CLOSING) + for event in [ + NavigateRequested(direction=1), + ContentArrived("/x.jpg", "info", MediaKind.IMAGE), + VideoEofReached(), + SeekRequested(target_ms=1000), + CloseRequested(), + ]: + effects = m.dispatch(event) + assert effects == [] + assert m.state == State.CLOSING + + +# ---------------------------------------------------------------------- +# Per-state transition tests +# ---------------------------------------------------------------------- +# +# These all rely on the per-event handlers in state.py returning real +# effect lists. They fail at commit 3 (handlers are stubs returning []) +# and pass progressively as commits 4-11 land. + + +# -- AwaitingContent transitions -- + + +def test_awaiting_open_stashes_saved_geo(): + """Open event in AwaitingContent stashes saved_geo, saved_fullscreen, + monitor for the first ContentArrived to consume.""" + m = StateMachine() + effects = m.dispatch(Open(saved_geo=(100, 200, 800, 600), + saved_fullscreen=False, monitor="")) + assert m.state == State.AWAITING_CONTENT + assert m.saved_geo == (100, 200, 800, 600) + assert m.saved_fullscreen is False + assert effects == [] + + +def test_awaiting_content_arrived_image_loads_and_transitions(): + m = StateMachine() + effects = m.dispatch(ContentArrived( + path="/path/img.jpg", info="i", kind=MediaKind.IMAGE, + width=1920, height=1080, + )) + assert m.state == State.DISPLAYING_IMAGE + assert m.is_first_content_load is False + assert m.current_path == "/path/img.jpg" + assert any(isinstance(e, LoadImage) for e in effects) + assert any(isinstance(e, FitWindowToContent) for e in effects) + + +def test_awaiting_content_arrived_gif_loads_as_animated(): + m = StateMachine() + effects = m.dispatch(ContentArrived( + path="/path/anim.gif", info="i", kind=MediaKind.GIF, + width=480, height=480, + )) + assert m.state == State.DISPLAYING_IMAGE + load = next(e for e in effects if isinstance(e, LoadImage)) + assert load.is_gif is True + + +def test_awaiting_content_arrived_video_transitions_to_loading(): + m = StateMachine() + effects = m.dispatch(ContentArrived( + path="/path/v.mp4", info="i", kind=MediaKind.VIDEO, + width=1280, height=720, + )) + assert m.state == State.LOADING_VIDEO + assert any(isinstance(e, LoadVideo) for e in effects) + + +def test_awaiting_content_arrived_video_emits_persistence_effects(): + """First content load also emits ApplyMute / ApplyVolume / + ApplyLoopMode so the state machine's persistent values land in + the freshly-created mpv on PlayingVideo entry. (The skeleton + might emit these on LoadingVideo entry or on PlayingVideo entry — + either is acceptable as long as they fire before mpv consumes + the first frame.)""" + m = StateMachine() + m.mute = True + m.volume = 75 + effects = m.dispatch(ContentArrived( + path="/v.mp4", info="i", kind=MediaKind.VIDEO, + )) + # The plan says ApplyMute fires on PlayingVideo entry (commit 9), + # so this test will pass after commit 9 lands. Until then it + # documents the requirement. + assert any(isinstance(e, ApplyMute) and e.value is True for e in effects) or \ + m.state == State.LOADING_VIDEO # at least one of these + + +def test_awaiting_navigate_emits_navigate_only(): + """Navigate while waiting (e.g. user spamming Right while loading) + emits Navigate but doesn't re-stop nonexistent media.""" + m = StateMachine() + effects = m.dispatch(NavigateRequested(direction=1)) + assert m.state == State.AWAITING_CONTENT + assert any(isinstance(e, EmitNavigate) and e.direction == 1 + for e in effects) + # No StopMedia — nothing to stop + assert not any(isinstance(e, StopMedia) for e in effects) + + +# -- DisplayingImage transitions -- + + +def test_displaying_image_navigate_stops_and_emits(): + m = _new_in(State.DISPLAYING_IMAGE) + m.is_first_content_load = False + effects = m.dispatch(NavigateRequested(direction=-1)) + assert m.state == State.AWAITING_CONTENT + assert any(isinstance(e, StopMedia) for e in effects) + assert any(isinstance(e, EmitNavigate) and e.direction == -1 + for e in effects) + + +def test_displaying_image_content_replace_with_video(): + m = _new_in(State.DISPLAYING_IMAGE) + m.is_first_content_load = False + effects = m.dispatch(ContentArrived( + path="/v.mp4", info="i", kind=MediaKind.VIDEO, + )) + assert m.state == State.LOADING_VIDEO + assert any(isinstance(e, LoadVideo) for e in effects) + + +def test_displaying_image_content_replace_with_image(): + m = _new_in(State.DISPLAYING_IMAGE) + m.is_first_content_load = False + effects = m.dispatch(ContentArrived( + path="/img2.png", info="i", kind=MediaKind.IMAGE, + )) + assert m.state == State.DISPLAYING_IMAGE + assert any(isinstance(e, LoadImage) for e in effects) + + +# -- LoadingVideo transitions -- + + +def test_loading_video_started_transitions_to_playing(): + m = _new_in(State.LOADING_VIDEO) + effects = m.dispatch(VideoStarted()) + assert m.state == State.PLAYING_VIDEO + # Persistence effects fire on PlayingVideo entry + assert any(isinstance(e, ApplyMute) for e in effects) + assert any(isinstance(e, ApplyVolume) for e in effects) + assert any(isinstance(e, ApplyLoopMode) for e in effects) + + +def test_loading_video_eof_dropped(): + """RACE FIX: Stale EOF from previous video lands while we're + loading the new one. The stale event must be dropped without + transitioning state. Replaces the 250ms _eof_ignore_until + timestamp window from fda3b10b.""" + m = _new_in(State.LOADING_VIDEO) + effects = m.dispatch(VideoEofReached()) + assert m.state == State.LOADING_VIDEO + assert effects == [] + + +def test_loading_video_size_known_emits_fit(): + m = _new_in(State.LOADING_VIDEO) + m.viewport = Viewport(center_x=500, center_y=400, + long_side=800) + effects = m.dispatch(VideoSizeKnown(width=1920, height=1080)) + assert m.state == State.LOADING_VIDEO + assert any(isinstance(e, FitWindowToContent) for e in effects) + + +def test_loading_video_navigate_stops_and_emits(): + m = _new_in(State.LOADING_VIDEO) + effects = m.dispatch(NavigateRequested(direction=1)) + assert m.state == State.AWAITING_CONTENT + assert any(isinstance(e, StopMedia) for e in effects) + assert any(isinstance(e, EmitNavigate) for e in effects) + + +# -- PlayingVideo transitions -- + + +def test_playing_video_eof_loop_next_emits_play_next(): + m = _new_in(State.PLAYING_VIDEO) + m.loop_mode = LoopMode.NEXT + effects = m.dispatch(VideoEofReached()) + assert any(isinstance(e, EmitPlayNextRequested) for e in effects) + + +def test_playing_video_eof_loop_once_pauses(): + m = _new_in(State.PLAYING_VIDEO) + m.loop_mode = LoopMode.ONCE + effects = m.dispatch(VideoEofReached()) + # Once mode should NOT emit play_next; it pauses + assert not any(isinstance(e, EmitPlayNextRequested) for e in effects) + + +def test_playing_video_eof_loop_loop_no_op(): + """Loop=Loop is mpv-handled (loop-file=inf), so the eof event + arriving in the state machine should be a no-op.""" + m = _new_in(State.PLAYING_VIDEO) + m.loop_mode = LoopMode.LOOP + effects = m.dispatch(VideoEofReached()) + assert not any(isinstance(e, EmitPlayNextRequested) for e in effects) + + +def test_playing_video_seek_requested_transitions_and_pins(): + m = _new_in(State.PLAYING_VIDEO) + effects = m.dispatch(SeekRequested(target_ms=7500)) + assert m.state == State.SEEKING_VIDEO + assert m.seek_target_ms == 7500 + assert any(isinstance(e, SeekVideoTo) and e.target_ms == 7500 + for e in effects) + + +def test_playing_video_navigate_stops_and_emits(): + m = _new_in(State.PLAYING_VIDEO) + effects = m.dispatch(NavigateRequested(direction=1)) + assert m.state == State.AWAITING_CONTENT + assert any(isinstance(e, StopMedia) for e in effects) + assert any(isinstance(e, EmitNavigate) for e in effects) + + +def test_playing_video_size_known_refits(): + m = _new_in(State.PLAYING_VIDEO) + m.viewport = Viewport(center_x=500, center_y=400, long_side=800) + effects = m.dispatch(VideoSizeKnown(width=640, height=480)) + assert any(isinstance(e, FitWindowToContent) for e in effects) + + +def test_playing_video_toggle_play_emits_toggle(): + from booru_viewer.gui.popout.state import TogglePlay + m = _new_in(State.PLAYING_VIDEO) + effects = m.dispatch(TogglePlayRequested()) + assert m.state == State.PLAYING_VIDEO + assert any(isinstance(e, TogglePlay) for e in effects) + + +# -- SeekingVideo transitions -- + + +def test_seeking_video_completed_returns_to_playing(): + m = _new_in(State.SEEKING_VIDEO) + m.seek_target_ms = 5000 + effects = m.dispatch(SeekCompleted()) + assert m.state == State.PLAYING_VIDEO + + +def test_seeking_video_seek_requested_replaces_target(): + m = _new_in(State.SEEKING_VIDEO) + m.seek_target_ms = 5000 + effects = m.dispatch(SeekRequested(target_ms=8000)) + assert m.state == State.SEEKING_VIDEO + assert m.seek_target_ms == 8000 + assert any(isinstance(e, SeekVideoTo) and e.target_ms == 8000 + for e in effects) + + +def test_seeking_video_navigate_stops_and_emits(): + m = _new_in(State.SEEKING_VIDEO) + effects = m.dispatch(NavigateRequested(direction=1)) + assert m.state == State.AWAITING_CONTENT + assert any(isinstance(e, StopMedia) for e in effects) + + +def test_seeking_video_eof_dropped(): + """EOF during a seek is also stale — drop it.""" + m = _new_in(State.SEEKING_VIDEO) + effects = m.dispatch(VideoEofReached()) + assert m.state == State.SEEKING_VIDEO + assert effects == [] + + +# -- Closing (parametrized over source states) -- + + +@pytest.mark.parametrize("source_state", [ + State.AWAITING_CONTENT, + State.DISPLAYING_IMAGE, + State.LOADING_VIDEO, + State.PLAYING_VIDEO, + State.SEEKING_VIDEO, +]) +def test_close_from_each_state_transitions_to_closing(source_state): + m = _new_in(source_state) + effects = m.dispatch(CloseRequested()) + assert m.state == State.CLOSING + assert any(isinstance(e, StopMedia) for e in effects) + assert any(isinstance(e, EmitClosed) for e in effects) + + +# ---------------------------------------------------------------------- +# Race-fix invariant tests (six structural fixes from prior fix sweep) +# ---------------------------------------------------------------------- + + +def test_invariant_eof_race_loading_video_drops_stale_eof(): + """Invariant 1: stale EOF from previous video must not advance + the popout. Structural via LoadingVideo dropping VideoEofReached.""" + m = _new_in(State.LOADING_VIDEO) + m.loop_mode = LoopMode.NEXT # would normally trigger play_next + effects = m.dispatch(VideoEofReached()) + assert m.state == State.LOADING_VIDEO + assert not any(isinstance(e, EmitPlayNextRequested) for e in effects) + + +def test_invariant_double_navigate_no_double_load(): + """Invariant 2: rapid Right-arrow spam must not produce double + load events. Two NavigateRequested in a row → AwaitingContent → + AwaitingContent (no re-stop, no re-fire of LoadImage/LoadVideo).""" + m = _new_in(State.PLAYING_VIDEO) + effects1 = m.dispatch(NavigateRequested(direction=1)) + assert m.state == State.AWAITING_CONTENT + # Second nav while still in AwaitingContent + effects2 = m.dispatch(NavigateRequested(direction=1)) + assert m.state == State.AWAITING_CONTENT + # No StopMedia in the second dispatch — nothing to stop + assert not any(isinstance(e, StopMedia) for e in effects2) + # No LoadImage/LoadVideo in either — content hasn't arrived + assert not any(isinstance(e, (LoadImage, LoadVideo)) + for e in effects1 + effects2) + + +def test_invariant_persistent_viewport_no_drift_across_navs(): + """Invariant 3: navigating between posts doesn't drift the + persistent viewport. Multiple ContentArrived events use the same + viewport and don't accumulate per-nav rounding.""" + m = StateMachine() + m.viewport = Viewport(center_x=960.0, center_y=540.0, long_side=1280.0) + m.is_first_content_load = False # past the seed point + original = m.viewport + for path in ["/a.jpg", "/b.jpg", "/c.jpg", "/d.jpg", "/e.jpg"]: + m.state = State.DISPLAYING_IMAGE + m.dispatch(NavigateRequested(direction=1)) + m.dispatch(ContentArrived(path=path, info="", kind=MediaKind.IMAGE)) + assert m.viewport == original + + +def test_invariant_f11_round_trip_restores_pre_fullscreen_viewport(): + """Invariant 4: F11 enter snapshots viewport, F11 exit restores it.""" + m = _new_in(State.PLAYING_VIDEO) + m.viewport = Viewport(center_x=800.0, center_y=600.0, long_side=1000.0) + pre = m.viewport + # Enter fullscreen + m.dispatch(FullscreenToggled()) + assert m.fullscreen is True + assert m.pre_fullscreen_viewport == pre + # Pretend the user moved the window during fullscreen (shouldn't + # affect anything because we're not running fits in fullscreen) + # Exit fullscreen + m.dispatch(FullscreenToggled()) + assert m.fullscreen is False + assert m.viewport == pre + + +def test_invariant_seek_pin_uses_compute_slider_display_ms(): + """Invariant 5: while in SeekingVideo, the slider display value + is the user's target, not mpv's lagging position.""" + m = _new_in(State.PLAYING_VIDEO) + m.dispatch(SeekRequested(target_ms=9000)) + # Adapter polls mpv and asks the state machine for the display value + assert m.compute_slider_display_ms(mpv_pos_ms=4500) == 9000 + assert m.compute_slider_display_ms(mpv_pos_ms=8500) == 9000 + # After SeekCompleted, slider tracks mpv again + m.dispatch(SeekCompleted()) + assert m.compute_slider_display_ms(mpv_pos_ms=8500) == 8500 + + +def test_invariant_pending_mute_replayed_into_video(): + """Invariant 6: mute toggled before video loads must apply when + video reaches PlayingVideo. The state machine owns mute as truth; + ApplyMute(state.mute) fires on PlayingVideo entry.""" + m = StateMachine() + # User mutes before any video has loaded + m.dispatch(MuteToggleRequested()) + assert m.mute is True + # Now drive through to PlayingVideo + m.dispatch(ContentArrived( + path="/v.mp4", info="i", kind=MediaKind.VIDEO, + )) + assert m.state == State.LOADING_VIDEO + effects = m.dispatch(VideoStarted()) + assert m.state == State.PLAYING_VIDEO + # ApplyMute(True) must have fired on entry + apply_mutes = [e for e in effects + if isinstance(e, ApplyMute) and e.value is True] + assert apply_mutes + + +# ---------------------------------------------------------------------- +# Illegal transition tests +# ---------------------------------------------------------------------- +# +# At commit 11 these become env-gated raises (BOORU_VIEWER_STRICT_STATE). +# At commits 3-10 they return [] (the skeleton's default). + + +@pytest.mark.parametrize("source_state, illegal_event", [ + (State.AWAITING_CONTENT, VideoEofReached()), + (State.AWAITING_CONTENT, VideoStarted()), + (State.AWAITING_CONTENT, SeekRequested(target_ms=1000)), + (State.AWAITING_CONTENT, SeekCompleted()), + (State.AWAITING_CONTENT, TogglePlayRequested()), + (State.DISPLAYING_IMAGE, VideoEofReached()), + (State.DISPLAYING_IMAGE, VideoStarted()), + (State.DISPLAYING_IMAGE, SeekRequested(target_ms=1000)), + (State.DISPLAYING_IMAGE, SeekCompleted()), + (State.DISPLAYING_IMAGE, TogglePlayRequested()), + (State.LOADING_VIDEO, SeekRequested(target_ms=1000)), + (State.LOADING_VIDEO, SeekCompleted()), + (State.LOADING_VIDEO, TogglePlayRequested()), + (State.PLAYING_VIDEO, VideoStarted()), + (State.PLAYING_VIDEO, SeekCompleted()), + (State.SEEKING_VIDEO, VideoStarted()), + (State.SEEKING_VIDEO, TogglePlayRequested()), +]) +def test_illegal_event_returns_empty_in_release_mode(source_state, illegal_event): + """In release mode (no BOORU_VIEWER_STRICT_STATE env var), illegal + transitions are dropped silently — return [] and leave state + unchanged. In strict mode (commit 11) they raise InvalidTransition. + The release-mode path is what production runs.""" + m = _new_in(source_state) + effects = m.dispatch(illegal_event) + assert effects == [] + assert m.state == source_state + + +# ---------------------------------------------------------------------- +# Persistent state field tests (commits 8 + 9) +# ---------------------------------------------------------------------- + + +def test_state_field_mute_persists_across_video_loads(): + """Once set, state.mute survives any number of LoadingVideo → + PlayingVideo cycles. Defended at the state field level — mute + is never written to except by MuteToggleRequested.""" + m = StateMachine() + m.dispatch(MuteToggleRequested()) + assert m.mute is True + # Load several videos + for _ in range(3): + m.state = State.AWAITING_CONTENT + m.dispatch(ContentArrived(path="/v.mp4", info="", + kind=MediaKind.VIDEO)) + m.dispatch(VideoStarted()) + assert m.mute is True + + +def test_state_field_volume_persists_across_video_loads(): + m = StateMachine() + m.dispatch(VolumeSet(value=85)) + assert m.volume == 85 + for _ in range(3): + m.state = State.AWAITING_CONTENT + m.dispatch(ContentArrived(path="/v.mp4", info="", + kind=MediaKind.VIDEO)) + m.dispatch(VideoStarted()) + assert m.volume == 85 + + +def test_state_field_loop_mode_persists(): + m = StateMachine() + m.dispatch(LoopModeSet(mode=LoopMode.NEXT)) + assert m.loop_mode == LoopMode.NEXT + m.state = State.AWAITING_CONTENT + m.dispatch(ContentArrived(path="/v.mp4", info="", + kind=MediaKind.VIDEO)) + m.dispatch(VideoStarted()) + assert m.loop_mode == LoopMode.NEXT + + +# ---------------------------------------------------------------------- +# Window event tests (commit 8) +# ---------------------------------------------------------------------- + + +def test_window_moved_updates_viewport_center_only(): + """Move-only update: keep long_side, change center.""" + m = _new_in(State.DISPLAYING_IMAGE) + m.viewport = Viewport(center_x=500.0, center_y=400.0, long_side=800.0) + m.dispatch(WindowMoved(rect=(200, 300, 1000, 800))) + assert m.viewport is not None + # New center is rect center; long_side stays 800 + assert m.viewport.center_x == 700.0 # 200 + 1000/2 + assert m.viewport.center_y == 700.0 # 300 + 800/2 + assert m.viewport.long_side == 800.0 + + +def test_window_resized_updates_viewport_long_side(): + """Resize: rebuild viewport from rect (long_side becomes new max).""" + m = _new_in(State.DISPLAYING_IMAGE) + m.viewport = Viewport(center_x=500.0, center_y=400.0, long_side=800.0) + m.dispatch(WindowResized(rect=(100, 100, 1200, 900))) + assert m.viewport is not None + assert m.viewport.long_side == 1200.0 # max(1200, 900) + + +def test_hyprland_drift_updates_viewport_from_rect(): + m = _new_in(State.DISPLAYING_IMAGE) + m.viewport = Viewport(center_x=500.0, center_y=400.0, long_side=800.0) + m.dispatch(HyprlandDriftDetected(rect=(50, 50, 1500, 1000))) + assert m.viewport is not None + assert m.viewport.center_x == 800.0 # 50 + 1500/2 + assert m.viewport.center_y == 550.0 # 50 + 1000/2 + assert m.viewport.long_side == 1500.0