booru-viewer/tests/gui/popout/test_state.py
pax ecda09152c ship tests/ (81 tests, was gitignored)
Remove tests/ from .gitignore and track the existing test suite:
  tests/core/test_db.py         — DB schema, migration, CRUD
  tests/core/test_cache.py      — cache helpers
  tests/core/test_config.py     — config/path helpers
  tests/core/test_concurrency.py — app loop accessor
  tests/core/api/test_base.py   — Post dataclass, BooruClient
  tests/gui/popout/test_state.py — 57 state machine tests

All pure Python, no secrets, no external deps. Uses temp DBs and
synthetic data. Run with: pytest tests/
2026-04-09 23:55:38 -05:00

662 lines
24 KiB
Python

"""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
InvalidTransition,
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).
def test_strict_mode_raises_invalid_transition(monkeypatch):
"""When BOORU_VIEWER_STRICT_STATE is set, illegal events raise
InvalidTransition instead of dropping silently. This is the
development/debug mode that catches programmer errors at the
dispatch boundary."""
monkeypatch.setenv("BOORU_VIEWER_STRICT_STATE", "1")
m = _new_in(State.PLAYING_VIDEO)
with pytest.raises(InvalidTransition) as exc_info:
m.dispatch(VideoStarted())
assert exc_info.value.state == State.PLAYING_VIDEO
assert isinstance(exc_info.value.event, VideoStarted)
def test_strict_mode_does_not_raise_for_legal_events(monkeypatch):
"""Legal events go through dispatch normally even under strict mode."""
monkeypatch.setenv("BOORU_VIEWER_STRICT_STATE", "1")
m = _new_in(State.PLAYING_VIDEO)
# SeekRequested IS legal in PlayingVideo — no raise
effects = m.dispatch(SeekRequested(target_ms=5000))
assert m.state == State.SEEKING_VIDEO
def test_strict_mode_legal_but_no_op_does_not_raise(monkeypatch):
"""The 'legal-but-no-op' events (e.g. VideoEofReached in
LoadingVideo, the EOF race fix) must NOT raise in strict mode.
They're intentionally accepted and dropped — that's the
structural fix, not a programmer error."""
monkeypatch.setenv("BOORU_VIEWER_STRICT_STATE", "1")
m = _new_in(State.LOADING_VIDEO)
# VideoEofReached in LoadingVideo is legal-but-no-op
effects = m.dispatch(VideoEofReached())
assert effects == []
assert m.state == State.LOADING_VIDEO
@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