Files
Anthias/tests/test_viewer.py
Viktor Petersson bf6b4f0cc5 fix(viewer): emit 270° eglfs rotation as -90 so portrait-inverted isn't stretched (#2973)
QT_QPA_EGLFS_ROTATION only accepts 180, 90 and -90. A literal 270 hits
the "Invalid rotation" default branch in QEglFSScreen::geometry(): the
QOpenGLCompositor still rotates the content, but the screen geometry
never swaps to portrait, so the window lays out landscape and renders
stretched (issue #2970 follow-up to #2971).

- map 270 -> -90 in _build_webview_env (same orientation mod 360)
- extend the eglfs rotation test to assert the -90 spelling

Validated on the Pi 4 testbed: at 270° the webview viewport went from
1920x1080 (stretched) to 1080x1920 with the fix.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 12:48:08 +02:00

1413 lines
50 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
import logging
import os
from collections.abc import Iterator
from time import sleep
from typing import Any
from unittest import mock
import pytest
import anthias_viewer as viewer
from anthias_server.settings import settings
from anthias_viewer.scheduling import Scheduler
logging.disable(logging.CRITICAL)
class _ViewerFixtures:
u: Any
m_scheduler: mock.Mock
p_scheduler: Any
m_cmd: mock.Mock
p_cmd: Any
m_killall: mock.Mock
p_killall: Any
m_reload: mock.Mock
p_reload: Any
m_sleep: mock.Mock
p_sleep: Any
m_loadb: mock.Mock
p_loadb: Any
@pytest.fixture
def viewer_fixtures() -> Iterator[_ViewerFixtures]:
fixtures = _ViewerFixtures()
original_splash_delay = viewer.SPLASH_DELAY
viewer.SPLASH_DELAY = 0
fixtures.u = viewer
fixtures.m_scheduler = mock.Mock(name='m_scheduler')
fixtures.p_scheduler = mock.patch.object(
fixtures.u, 'Scheduler', fixtures.m_scheduler
)
fixtures.m_cmd = mock.Mock(name='m_cmd')
fixtures.p_cmd = mock.patch.object(
fixtures.u.sh, 'Command', fixtures.m_cmd
)
fixtures.m_killall = mock.Mock(name='killall')
fixtures.p_killall = mock.patch.object(
fixtures.u.sh, 'killall', fixtures.m_killall
)
fixtures.m_reload = mock.Mock(name='reload')
fixtures.p_reload = mock.patch.object(
fixtures.u, 'load_settings', fixtures.m_reload
)
fixtures.m_sleep = mock.Mock(name='sleep')
fixtures.p_sleep = mock.patch.object(fixtures.u, 'sleep', fixtures.m_sleep)
fixtures.m_loadb = mock.Mock(name='load_browser')
fixtures.p_loadb = mock.patch.object(
fixtures.u, 'load_browser', fixtures.m_loadb
)
try:
yield fixtures
finally:
fixtures.u.SPLASH_DELAY = original_splash_delay
def noop(*a: Any, **k: Any) -> None:
return None
@mock.patch('anthias_viewer.constants.SERVER_WAIT_TIMEOUT', 0)
def test_empty(viewer_fixtures: _ViewerFixtures) -> None:
m_asset_list = mock.Mock()
m_asset_list.return_value = ([], None)
with mock.patch(
'anthias_viewer.scheduling.generate_asset_list', m_asset_list
):
setattr(viewer_fixtures.u, 'scheduler', Scheduler())
m_asset_list.assert_called_once()
@mock.patch('pydbus.SessionBus', mock.MagicMock())
def test_setup(viewer_fixtures: _ViewerFixtures) -> None:
viewer_fixtures.p_loadb.start()
try:
viewer_fixtures.u.setup()
finally:
viewer_fixtures.p_loadb.stop()
def _stub_browser_stdout_static(
browser_proc: mock.Mock,
value: bytes,
) -> None:
"""
sh.RunningCommand.process.stdout is a @property that returns the
latest accumulated buffer on each access. Use PropertyMock so
the test exercises the same poll-and-decode pattern as the
production loop. Static variant: every read returns the same
bytes value, suitable for cases where the loop doesn't depend
on stdout changing across iterations (early-exit, timeout).
"""
type(browser_proc.process).stdout = mock.PropertyMock(return_value=value)
def _stub_browser_stdout_chunks(
browser_proc: mock.Mock,
chunks: list[bytes],
) -> None:
"""As above, but advance through `chunks` across reads — for
the success case where the handshake appears in a later poll."""
type(browser_proc.process).stdout = mock.PropertyMock(side_effect=chunks)
def test_load_browser(viewer_fixtures: _ViewerFixtures) -> None:
browser_proc = viewer_fixtures.m_cmd.return_value.return_value
# Two stdout reads: an empty buffer on the first poll, then the
# handshake line appended on the second. Verifies that the
# polling loop actually re-reads stdout each iteration.
_stub_browser_stdout_chunks(
browser_proc,
[b'starting up\n', b'starting up\nAnthias service start\n'],
)
browser_proc.is_alive.return_value = True
viewer_fixtures.p_cmd.start()
viewer_fixtures.p_sleep.start()
try:
viewer_fixtures.u.load_browser()
finally:
viewer_fixtures.p_sleep.stop()
viewer_fixtures.p_cmd.stop()
viewer_fixtures.m_cmd.assert_called_once_with('AnthiasViewer')
def test_spawn_webview_once_raises_on_early_exit(
viewer_fixtures: _ViewerFixtures,
) -> None:
"""A process that exits before the handshake surfaces as a
WebviewLaunchError (a RuntimeError subclass) and does NOT terminate
(it's already dead)."""
browser_proc = viewer_fixtures.m_cmd.return_value.return_value
# The error message also reads stdout, so use the static stub.
_stub_browser_stdout_static(browser_proc, b'')
browser_proc.is_alive.return_value = False
viewer_fixtures.p_cmd.start()
viewer_fixtures.p_sleep.start()
try:
with pytest.raises(viewer_fixtures.u.WebviewLaunchError):
viewer_fixtures.u._spawn_webview_once(30)
finally:
viewer_fixtures.p_sleep.stop()
viewer_fixtures.p_cmd.stop()
browser_proc.terminate.assert_not_called()
def test_spawn_webview_once_terminates_on_timeout(
viewer_fixtures: _ViewerFixtures,
) -> None:
"""When the handshake never arrives, the half-started process is
torn down (terminate) before the error is raised — otherwise a retry
would leak a second AnthiasViewer contending for the framebuffer /
D-Bus name."""
browser_proc = viewer_fixtures.m_cmd.return_value.return_value
_stub_browser_stdout_static(browser_proc, b'irrelevant noise')
# is_alive False so _terminate_webview reaps immediately without a
# busy-wait; the 0s timeout drives the deadline straight past.
browser_proc.is_alive.return_value = False
viewer_fixtures.p_cmd.start()
viewer_fixtures.p_sleep.start()
try:
with pytest.raises(viewer_fixtures.u.WebviewLaunchError):
viewer_fixtures.u._spawn_webview_once(0)
finally:
viewer_fixtures.p_sleep.stop()
viewer_fixtures.p_cmd.stop()
browser_proc.terminate.assert_called_once()
def test_spawn_webview_once_missing_binary(
viewer_fixtures: _ViewerFixtures,
) -> None:
"""A missing AnthiasViewer binary raises WebviewBinaryMissingError
(permanent), which the retry loop must short-circuit on."""
viewer_fixtures.m_cmd.side_effect = viewer_fixtures.u.sh.CommandNotFound(
'AnthiasViewer'
)
viewer_fixtures.p_cmd.start()
try:
with pytest.raises(viewer_fixtures.u.WebviewBinaryMissingError):
viewer_fixtures.u._spawn_webview_once(30)
finally:
viewer_fixtures.p_cmd.stop()
def test_load_browser_retries_then_succeeds(
viewer_fixtures: _ViewerFixtures,
) -> None:
"""A board that crashes the webview on the first launches but comes
up on a later one must self-heal in-process — the whole point of the
spawn retry (the pi3 Qt5/WebEngine heap-corruption crash). Also
asserts the backoff actually grows (1s then 2s) rather than hammering."""
attempts = {'n': 0}
def fake_spawn(_startup_timeout: float) -> mock.Mock:
attempts['n'] += 1
if attempts['n'] < 3:
raise viewer_fixtures.u.WebviewLaunchError('init crash')
return mock.Mock(name='browser')
viewer_fixtures.p_sleep.start()
try:
with mock.patch.object(
viewer_fixtures.u, '_spawn_webview_once', side_effect=fake_spawn
):
viewer_fixtures.u.load_browser()
finally:
viewer_fixtures.p_sleep.stop()
assert attempts['n'] == 3
assert viewer_fixtures.u.browser is not None
# The two failed attempts must have slept with growing backoff —
# guards against a regression that drops the sleep (tight loop).
backoff_sleeps = [
c.args[0] for c in viewer_fixtures.m_sleep.call_args_list if c.args
]
assert backoff_sleeps == [1, 2]
def test_load_browser_raises_after_exhausting_retries(
viewer_fixtures: _ViewerFixtures,
) -> None:
"""When every attempt fails, load_browser still raises — but only
after spending the retry budget, so the fall-through to a container
restart is slow, not a tight loop."""
viewer_fixtures.p_sleep.start()
try:
with (
mock.patch.object(
viewer_fixtures.u,
'_spawn_webview_once',
side_effect=viewer_fixtures.u.WebviewLaunchError('crash'),
),
mock.patch.object(
viewer_fixtures.u, 'BROWSER_SPAWN_MAX_ATTEMPTS', 4
),
):
with pytest.raises(viewer_fixtures.u.WebviewLaunchError):
viewer_fixtures.u.load_browser()
finally:
viewer_fixtures.p_sleep.stop()
def test_load_browser_missing_binary_short_circuits(
viewer_fixtures: _ViewerFixtures,
) -> None:
"""A permanent failure (missing binary) must NOT consume the retry
budget — it raises on the first attempt with no backoff."""
calls = {'n': 0}
def fake_spawn(_startup_timeout: float) -> mock.Mock:
calls['n'] += 1
raise viewer_fixtures.u.WebviewBinaryMissingError('nope')
viewer_fixtures.p_sleep.start()
try:
with mock.patch.object(
viewer_fixtures.u, '_spawn_webview_once', side_effect=fake_spawn
):
with pytest.raises(viewer_fixtures.u.WebviewBinaryMissingError):
viewer_fixtures.u.load_browser()
finally:
viewer_fixtures.p_sleep.stop()
assert calls['n'] == 1
def test_load_browser_inline_budget_limits_attempts(
viewer_fixtures: _ViewerFixtures,
) -> None:
"""The mid-playback respawn path passes a small budget so a
persistent failure can't freeze the asset_loop for minutes — the
explicit max_attempts caps the spawn count."""
calls = {'n': 0}
def fake_spawn(_startup_timeout: float) -> mock.Mock:
calls['n'] += 1
raise viewer_fixtures.u.WebviewLaunchError('crash')
viewer_fixtures.p_sleep.start()
try:
with mock.patch.object(
viewer_fixtures.u, '_spawn_webview_once', side_effect=fake_spawn
):
with pytest.raises(viewer_fixtures.u.WebviewLaunchError):
viewer_fixtures.u.load_browser(
max_attempts=2, backoff_cap=2, startup_timeout=5
)
finally:
viewer_fixtures.p_sleep.stop()
assert calls['n'] == 2
def test_view_webpage_arms_reload_interval(
viewer_fixtures: _ViewerFixtures,
) -> None:
"""``view_webpage`` must always call ``setReloadInterval`` after
``loadPage`` — even when the URI is unchanged from the previous
tick, since an asset edit that flips only the interval (no URI
change) needs the new value to take effect on the next rotation.
Feature #2813."""
fake_bus = mock.Mock()
fake_browser = mock.Mock()
fake_browser.is_alive.return_value = True
with (
mock.patch.object(viewer_fixtures.u, 'browser_bus', fake_bus),
mock.patch.object(viewer_fixtures.u, 'browser', fake_browser),
mock.patch.object(viewer_fixtures.u, 'current_browser_url', None),
):
viewer_fixtures.u.view_webpage('https://example.com', 30)
fake_bus.loadPage.assert_called_once_with('https://example.com')
fake_bus.setReloadInterval.assert_called_once_with(30)
def test_view_webpage_default_zero_interval(
viewer_fixtures: _ViewerFixtures,
) -> None:
"""Splash / fallback callers pass no interval, which becomes 0 —
the C++ side treats that as "disable the timer", so this is the
no-auto-refresh contract for legacy code paths."""
fake_bus = mock.Mock()
fake_browser = mock.Mock()
fake_browser.is_alive.return_value = True
with (
mock.patch.object(viewer_fixtures.u, 'browser_bus', fake_bus),
mock.patch.object(viewer_fixtures.u, 'browser', fake_browser),
mock.patch.object(viewer_fixtures.u, 'current_browser_url', None),
):
viewer_fixtures.u.view_webpage('https://example.com')
fake_bus.setReloadInterval.assert_called_once_with(0)
@pytest.mark.parametrize(
'error_message,expected_call_count',
[
# An older AnthiasViewer without setReloadInterval raises
# UnknownMethod — viewer must latch the capability flag off
# so the next rotation skips the D-Bus hop instead of
# refilling journald.
(
'GDBus.Error:org.freedesktop.DBus.Error.UnknownMethod: '
"No such method 'setReloadInterval'",
1,
),
# Transient D-Bus error (bus disconnect, timeout, race during
# webview restart) must NOT permanently disable auto-refresh:
# the method exists, the call just failed once. Next rotation
# retries and the warning is debug-level so journald isn't
# flooded.
('Connection closed by peer', 2),
],
ids=['unknown-method-latches', 'transient-error-retries'],
)
def test_view_webpage_setreloadinterval_failure_modes(
viewer_fixtures: _ViewerFixtures,
error_message: str,
expected_call_count: int,
) -> None:
fake_bus = mock.Mock()
fake_bus.setReloadInterval.side_effect = RuntimeError(error_message)
fake_browser = mock.Mock()
fake_browser.is_alive.return_value = True
with (
mock.patch.object(viewer_fixtures.u, 'browser_bus', fake_bus),
mock.patch.object(viewer_fixtures.u, 'browser', fake_browser),
mock.patch.object(viewer_fixtures.u, 'current_browser_url', None),
mock.patch.object(
viewer_fixtures.u,
'_webview_supports_set_reload_interval',
True,
),
):
viewer_fixtures.u.view_webpage('https://example.com', 30)
viewer_fixtures.u.view_webpage('https://example.com', 60)
assert fake_bus.setReloadInterval.call_count == expected_call_count
def test_load_browser_resets_set_reload_interval_capability(
viewer_fixtures: _ViewerFixtures,
) -> None:
"""If the webview process crashed and we latched off, the next
``load_browser()`` should re-enable capability detection — the
fresh process might be a newer build that supports the slot,
and we shouldn't leave auto-refresh permanently disabled because
the *old* process didn't have it."""
browser_proc = viewer_fixtures.m_cmd.return_value.return_value
_stub_browser_stdout_chunks(
browser_proc,
[b'Anthias service start\n'],
)
browser_proc.is_alive.return_value = True
viewer_fixtures.p_cmd.start()
viewer_fixtures.p_sleep.start()
try:
with mock.patch.object(
viewer_fixtures.u,
'_webview_supports_set_reload_interval',
False,
):
viewer_fixtures.u.load_browser()
assert (
viewer_fixtures.u._webview_supports_set_reload_interval is True
)
finally:
viewer_fixtures.p_sleep.stop()
viewer_fixtures.p_cmd.stop()
def test_view_webpage_resets_interval_on_unchanged_url(
viewer_fixtures: _ViewerFixtures,
) -> None:
"""When the URI matches ``current_browser_url`` we skip the
``loadPage`` D-Bus call (cheap no-op), but ``setReloadInterval``
still has to fire so an interval-only edit takes effect without
a URI change."""
fake_bus = mock.Mock()
fake_browser = mock.Mock()
fake_browser.is_alive.return_value = True
uri = 'https://example.com'
with (
mock.patch.object(viewer_fixtures.u, 'browser_bus', fake_bus),
mock.patch.object(viewer_fixtures.u, 'browser', fake_browser),
mock.patch.object(viewer_fixtures.u, 'current_browser_url', uri),
):
viewer_fixtures.u.view_webpage(uri, 90)
fake_bus.loadPage.assert_not_called()
fake_bus.setReloadInterval.assert_called_once_with(90)
def test_watchdog_should_create_file_if_not_exists(
viewer_fixtures: _ViewerFixtures,
) -> None:
try:
os.remove(viewer_fixtures.u.utils.WATCHDOG_PATH)
except OSError:
pass
viewer_fixtures.u.watchdog()
assert os.path.exists(viewer_fixtures.u.utils.WATCHDOG_PATH) is True
def test_watchdog_should_update_mtime(
viewer_fixtures: _ViewerFixtures,
) -> None:
# for watchdog file creation
viewer_fixtures.u.watchdog()
mtime = os.path.getmtime(viewer_fixtures.u.utils.WATCHDOG_PATH)
# Python is too fast?
sleep(0.01)
viewer_fixtures.u.watchdog()
mtime2 = os.path.getmtime(viewer_fixtures.u.utils.WATCHDOG_PATH)
assert mtime2 > mtime
# ---------------------------------------------------------------------------
# _asset_is_displayable
# ---------------------------------------------------------------------------
#
# The viewer used to call url_fails(uri) on every play, which blocked
# the loop for 1-15s on streams. Reachability is now owned by the
# server (celery beat sweep + on-demand recheck endpoint), and the
# viewer just consults Asset.is_reachable.
def test_displayable_skip_asset_check_short_circuits() -> None:
"""skip_asset_check=True means the operator opted out of any
gating — display unconditionally, even if is_reachable=False."""
asset = {
'asset_id': 'a',
'uri': 'http://example.com/x', # NOSONAR
'skip_asset_check': True,
'is_reachable': False,
}
assert viewer._asset_is_displayable(asset)
def test_displayable_local_file_existence_check() -> None:
"""Local URIs hit the filesystem directly (cheap, no roundtrip
to the server's view of the world)."""
import tempfile
with tempfile.NamedTemporaryFile(delete=False) as fh:
local_path = fh.name
try:
assert viewer._asset_is_displayable(
{
'asset_id': 'a',
'uri': local_path,
'skip_asset_check': False,
'is_reachable': True,
}
)
finally:
os.unlink(local_path)
# Same path, file gone.
assert not viewer._asset_is_displayable(
{
'asset_id': 'a',
'uri': local_path,
'skip_asset_check': False,
'is_reachable': True,
}
)
def test_displayable_remote_uri_consults_is_reachable() -> None:
ok = {
'asset_id': 'a',
'uri': 'http://example.com/x', # NOSONAR
'skip_asset_check': False,
'is_reachable': True,
}
bad = {**ok, 'is_reachable': False}
assert viewer._asset_is_displayable(ok)
assert not viewer._asset_is_displayable(bad)
def test_displayable_missing_is_reachable_defaults_to_displayable() -> None:
"""Backstop for legacy rows / serializers that don't include the
field. Don't silently freeze a playlist on an upgrade."""
asset = {
'asset_id': 'a',
'uri': 'http://example.com/x', # NOSONAR
'skip_asset_check': False,
}
assert viewer._asset_is_displayable(asset)
# ---------------------------------------------------------------------------
# _trigger_asset_recheck
# ---------------------------------------------------------------------------
def test_trigger_recheck_posts_to_recheck_endpoint() -> None:
"""The viewer's job is to send whatever ``internal_auth_token``
yields as a header; the HMAC derivation itself is exercised in
``lib/internal_auth`` and through the recheck-endpoint tests.
Mocking the token-derivation here keeps this test independent of
settings state, which has bitten us under pytest-xdist + Docker
test-image conftest configurations."""
from anthias_common.internal_auth import INTERNAL_AUTH_HEADER
with (
mock.patch(
'anthias_viewer.internal_auth_token', return_value='deadbeef'
),
mock.patch('anthias_viewer.requests.post') as m,
):
viewer._trigger_asset_recheck('abc')
m.assert_called_once()
url = m.call_args.args[0]
assert '/api/v2/assets/abc/recheck' in url
assert m.call_args.kwargs['headers'] == {INTERNAL_AUTH_HEADER: 'deadbeef'}
def test_trigger_recheck_no_op_on_missing_asset_id() -> None:
with mock.patch('anthias_viewer.requests.post') as m:
viewer._trigger_asset_recheck(None)
m.assert_not_called()
def test_trigger_recheck_no_op_when_internal_token_missing() -> None:
"""When ``internal_auth_token`` returns '' (no secret available
in settings or env), the request would be a guaranteed 403 — so
the viewer skips it rather than burning an HTTP round-trip."""
with (
mock.patch('anthias_viewer.internal_auth_token', return_value=''),
mock.patch('anthias_viewer.requests.post') as m,
):
viewer._trigger_asset_recheck('abc')
m.assert_not_called()
def test_trigger_recheck_swallows_request_errors() -> None:
"""Best-effort: a server hiccup must not interrupt the asset loop."""
import requests as _requests
with (
mock.patch(
'anthias_viewer.requests.post',
side_effect=_requests.ConnectionError('boom'),
),
mock.patch(
'anthias_viewer.internal_auth_token', return_value='deadbeef'
),
):
# Must not raise.
viewer._trigger_asset_recheck('abc')
def test_asset_loop_does_not_recheck_missing_local_asset() -> None:
scheduler = mock.Mock()
scheduler.get_next_asset.return_value = {
'asset_id': 'local',
'name': 'local',
'uri': '/tmp/anthias-missing-local-asset',
'mimetype': 'image',
'duration': 10,
'skip_asset_check': False,
'is_reachable': True,
}
skip_event = mock.Mock()
skip_event.wait.return_value = False
with (
mock.patch('anthias_viewer._trigger_asset_recheck') as trigger,
mock.patch('anthias_viewer.get_skip_event', return_value=skip_event),
):
viewer.asset_loop(scheduler)
trigger.assert_not_called()
def test_asset_loop_rechecks_unreachable_remote_asset() -> None:
scheduler = mock.Mock()
scheduler.get_next_asset.return_value = {
'asset_id': 'remote',
'name': 'remote',
'uri': 'https://example.com/offline.png',
'mimetype': 'image',
'duration': 10,
'skip_asset_check': False,
'is_reachable': False,
}
skip_event = mock.Mock()
skip_event.wait.return_value = False
with (
mock.patch('anthias_viewer._trigger_asset_recheck') as trigger,
mock.patch('anthias_viewer.get_skip_event', return_value=skip_event),
):
viewer.asset_loop(scheduler)
trigger.assert_called_once_with('remote')
# ---------------------------------------------------------------------------
# _handle_reload / _skip_if_current_asset_inactive — issue #2430
# ---------------------------------------------------------------------------
#
# The viewer used to ignore playlist mutations while an asset was on
# screen — a 1-hour image kept showing for the rest of the hour even
# after delete/deactivate. The ``reload`` command now also signals a
# skip when the displayed asset is no longer active.
def test_handle_reload_runs_load_settings() -> None:
"""``reload`` must still reload settings — that path is exercised
by the settings.patch() endpoint and predates the skip behaviour."""
scheduler = mock.Mock()
scheduler.current_asset_id = None
with (
mock.patch.object(viewer, 'scheduler', scheduler),
mock.patch.object(viewer, 'load_settings') as load,
):
viewer._handle_reload()
load.assert_called_once()
def test_skip_when_current_asset_deleted() -> None:
"""Deleting the currently-displayed asset must set the skip event."""
scheduler = mock.Mock()
scheduler.current_asset_id = 'gone'
skip_event = mock.Mock()
with (
mock.patch.object(viewer, 'scheduler', scheduler),
mock.patch('anthias_viewer.get_skip_event', return_value=skip_event),
mock.patch('anthias_viewer.Asset.objects.filter') as objects_filter,
):
# ``filter().first()`` returns None for a deleted row.
objects_filter.return_value.first.return_value = None
viewer._skip_if_current_asset_inactive()
skip_event.set.assert_called_once()
def test_skip_when_current_asset_deactivated() -> None:
"""Toggling is_enabled off on the displayed asset must skip."""
scheduler = mock.Mock()
scheduler.current_asset_id = 'asset-1'
skip_event = mock.Mock()
inactive_asset = mock.Mock()
inactive_asset.is_active.return_value = False
with (
mock.patch.object(viewer, 'scheduler', scheduler),
mock.patch('anthias_viewer.get_skip_event', return_value=skip_event),
mock.patch('anthias_viewer.Asset.objects.filter') as objects_filter,
):
objects_filter.return_value.first.return_value = inactive_asset
viewer._skip_if_current_asset_inactive()
skip_event.set.assert_called_once()
def test_no_skip_when_current_asset_still_active() -> None:
"""Unrelated edits (e.g. duration on a different asset) shouldn't
interrupt the displayed asset."""
scheduler = mock.Mock()
scheduler.current_asset_id = 'asset-1'
skip_event = mock.Mock()
active_asset = mock.Mock()
active_asset.is_active.return_value = True
with (
mock.patch.object(viewer, 'scheduler', scheduler),
mock.patch('anthias_viewer.get_skip_event', return_value=skip_event),
mock.patch('anthias_viewer.Asset.objects.filter') as objects_filter,
):
objects_filter.return_value.first.return_value = active_asset
viewer._skip_if_current_asset_inactive()
skip_event.set.assert_not_called()
def test_skip_noop_when_no_current_asset() -> None:
"""Empty playlist → no displayed asset → no DB hit, no skip."""
scheduler = mock.Mock()
scheduler.current_asset_id = None
skip_event = mock.Mock()
with (
mock.patch.object(viewer, 'scheduler', scheduler),
mock.patch('anthias_viewer.get_skip_event', return_value=skip_event),
mock.patch('anthias_viewer.Asset.objects.filter') as objects_filter,
):
viewer._skip_if_current_asset_inactive()
objects_filter.assert_not_called()
skip_event.set.assert_not_called()
def test_skip_noop_before_scheduler_initialised() -> None:
"""A ``reload`` can arrive during the pre-Scheduler wait window
(``wait_for_server`` etc.) — must not AttributeError."""
skip_event = mock.Mock()
with (
mock.patch.object(viewer, 'scheduler', None),
mock.patch('anthias_viewer.get_skip_event', return_value=skip_event),
):
# Must not raise.
viewer._skip_if_current_asset_inactive()
skip_event.set.assert_not_called()
def test_skip_swallows_db_errors() -> None:
"""A transient DB failure must not interrupt the asset loop — we
just leave the rotation alone for this tick."""
scheduler = mock.Mock()
scheduler.current_asset_id = 'asset-1'
skip_event = mock.Mock()
with (
mock.patch.object(viewer, 'scheduler', scheduler),
mock.patch('anthias_viewer.get_skip_event', return_value=skip_event),
mock.patch(
'anthias_viewer.Asset.objects.filter',
side_effect=RuntimeError('boom'),
),
):
# Must not raise.
viewer._skip_if_current_asset_inactive()
skip_event.set.assert_not_called()
# ---------------------------------------------------------------------------
# Screen rotation (issue #2856)
@pytest.fixture
def reset_rotation_state() -> Iterator[None]:
"""_last_applied_rotation + _rotation_bounce_pending are module
state — snapshot and restore so rotation tests don't bleed into
each other (or into the prior reload tests that don't mock
_apply_wlr_transform)."""
prior_rot = viewer._last_applied_rotation
prior_url = viewer.current_browser_url
prior_pending = viewer._rotation_bounce_pending
try:
viewer._last_applied_rotation = 0
viewer._rotation_bounce_pending = False
yield
finally:
viewer._last_applied_rotation = prior_rot
viewer.current_browser_url = prior_url
viewer._rotation_bounce_pending = prior_pending
@pytest.mark.parametrize(
'raw, expected',
[
(0, 0),
(90, 90),
(180, 180),
(270, 270),
('90', 90),
# Anything outside the cardinal set collapses to 0 — the v2
# serializer and form handler already filter on write, but the
# viewer reads an arbitrary file off disk and must not propagate
# garbage values (CLI argv for mpv/VLC, env value for Qt) into
# the playback layer.
(45, 0),
(-1, 0),
('garbage', 0),
(None, 0),
],
)
def test_rotation_value_clamps(raw: Any, expected: int) -> None:
with mock.patch.dict(settings, {'screen_rotation': raw}):
assert viewer._rotation_value() == expected
def test_build_webview_env_appends_rotation_on_linuxfb() -> None:
"""Pi boards (linuxfb) get the rotation baked into QT_QPA_PLATFORM
so the Qt plugin rotates the framebuffer for free."""
with (
mock.patch.dict(settings, {'screen_rotation': 90}),
mock.patch.dict(
os.environ,
{'DEVICE_TYPE': 'pi5', 'QT_QPA_PLATFORM': 'linuxfb'},
clear=False,
),
):
env = viewer._build_webview_env()
assert env['QT_QPA_PLATFORM'] == 'linuxfb:rotation=90'
def test_build_webview_env_strips_existing_rotation_suffix() -> None:
"""If a prior launch (or the Dockerfile) baked in a rotation, the
helper must not double-append."""
with (
mock.patch.dict(settings, {'screen_rotation': 180}),
mock.patch.dict(
os.environ,
{
'DEVICE_TYPE': 'pi5',
'QT_QPA_PLATFORM': 'linuxfb:rotation=90',
},
clear=False,
),
):
env = viewer._build_webview_env()
assert env['QT_QPA_PLATFORM'] == 'linuxfb:rotation=180'
def test_build_webview_env_no_op_on_wayland() -> None:
"""x86 runs Qt wayland under cage; rotation goes through wlr-randr,
NOT through the QPA plugin which has no rotation= option."""
with (
mock.patch.dict(settings, {'screen_rotation': 90}),
mock.patch.dict(
os.environ,
{'DEVICE_TYPE': 'x86', 'QT_QPA_PLATFORM': 'wayland'},
clear=False,
),
):
env = viewer._build_webview_env()
assert env['QT_QPA_PLATFORM'] == 'wayland'
def test_build_webview_env_no_suffix_at_zero_rotation() -> None:
"""Default-orientation displays should stay on the existing
QT_QPA_PLATFORM string so we don't change behavior for the 99%
case who never touches the Settings dropdown."""
with (
mock.patch.dict(settings, {'screen_rotation': 0}),
mock.patch.dict(
os.environ,
{'DEVICE_TYPE': 'pi5', 'QT_QPA_PLATFORM': 'linuxfb'},
clear=False,
),
):
env = viewer._build_webview_env()
assert env['QT_QPA_PLATFORM'] == 'linuxfb'
def test_build_webview_env_preserves_other_qpa_options() -> None:
"""An operator who set QT_QPA_PLATFORM=linuxfb:fb=/dev/fb1 must
keep that option through a rotation change — Copilot review of
#2882 flagged that the previous naive split dropped it."""
with (
mock.patch.dict(settings, {'screen_rotation': 90}),
mock.patch.dict(
os.environ,
{
'DEVICE_TYPE': 'pi5',
'QT_QPA_PLATFORM': 'linuxfb:fb=/dev/fb1,tty=/dev/tty1',
},
clear=False,
),
):
env = viewer._build_webview_env()
qpa = env['QT_QPA_PLATFORM']
plugin, _, opts = qpa.partition(':')
options = set(opts.split(','))
assert plugin == 'linuxfb'
assert options == {'fb=/dev/fb1', 'tty=/dev/tty1', 'rotation=90'}
def test_build_webview_env_removes_stale_rotation_when_dialed_to_zero() -> (
None
):
"""If a previous launch set rotation=90 in QT_QPA_PLATFORM and the
operator now picks 0° from the dropdown, the rotation= option
must come back out — otherwise the screen stays rotated after a
webview respawn. Copilot review of #2882."""
with (
mock.patch.dict(settings, {'screen_rotation': 0}),
mock.patch.dict(
os.environ,
{
'DEVICE_TYPE': 'pi5',
'QT_QPA_PLATFORM': 'linuxfb:fb=/dev/fb1,rotation=90',
},
clear=False,
),
):
env = viewer._build_webview_env()
assert env['QT_QPA_PLATFORM'] == 'linuxfb:fb=/dev/fb1'
@pytest.mark.parametrize(
('rotation', 'expected'),
[(90, '90'), (180, '180'), (270, '-90')],
)
def test_build_webview_env_sets_eglfs_rotation(
rotation: int, expected: str
) -> None:
"""Pi 4 runs eglfs, which ignores the linuxfb ``:rotation=N`` plugin
option (that silent no-op was the 2026.06.0 bug). eglfs reads
QT_QPA_EGLFS_ROTATION at QPA init instead, so we set that and leave
QT_QPA_PLATFORM untouched. eglfs only accepts 180/90/-90 — a literal
270 rotates the content without swapping the screen geometry to
portrait, rendering everything stretched (issue #2970) — so 270°
must be emitted as -90."""
with (
mock.patch.dict(settings, {'screen_rotation': rotation}),
mock.patch.dict(
os.environ,
{'DEVICE_TYPE': 'pi4-64', 'QT_QPA_PLATFORM': 'eglfs'},
clear=False,
),
):
env = viewer._build_webview_env()
assert env['QT_QPA_EGLFS_ROTATION'] == expected
# The platform string must stay a bare plugin — appending
# ``:rotation=N`` here is exactly the no-op that broke Pi 4.
assert env['QT_QPA_PLATFORM'] == 'eglfs'
def test_build_webview_env_eglfs_zero_omits_rotation() -> None:
"""Default orientation must not set QT_QPA_EGLFS_ROTATION at all."""
with (
mock.patch.dict(settings, {'screen_rotation': 0}),
mock.patch.dict(
os.environ,
{'DEVICE_TYPE': 'pi4-64', 'QT_QPA_PLATFORM': 'eglfs'},
clear=False,
),
):
# Guard against a stray value leaking in from the real env.
os.environ.pop('QT_QPA_EGLFS_ROTATION', None)
env = viewer._build_webview_env()
assert 'QT_QPA_EGLFS_ROTATION' not in env
assert env['QT_QPA_PLATFORM'] == 'eglfs'
def test_build_webview_env_eglfs_clears_stale_rotation() -> None:
"""Dialling back to 0° after a rotated launch must drop a stale
QT_QPA_EGLFS_ROTATION so the respawned webview un-rotates."""
with (
mock.patch.dict(settings, {'screen_rotation': 0}),
mock.patch.dict(
os.environ,
{
'DEVICE_TYPE': 'pi4-64',
'QT_QPA_PLATFORM': 'eglfs',
'QT_QPA_EGLFS_ROTATION': '270',
},
clear=False,
),
):
env = viewer._build_webview_env()
assert 'QT_QPA_EGLFS_ROTATION' not in env
def test_apply_wlr_transform_skipped_on_linuxfb() -> None:
"""The wlr-randr binary isn't even shipped on Pi boards — make
sure we never call it from a non-wayland viewer."""
with (
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'pi5'}, clear=False),
mock.patch('anthias_viewer.subprocess.run') as run,
):
viewer._apply_wlr_transform(90)
run.assert_not_called()
def test_apply_wlr_transform_invokes_wlr_randr_per_output() -> None:
"""On x86, list enabled outputs then push the transform to each."""
def _fake_run(argv: Any, **kwargs: Any) -> mock.Mock:
result = mock.Mock()
result.returncode = 0
# The first call lists outputs; subsequent calls apply.
if argv == ['wlr-randr']:
result.stdout = (
'HDMI-A-1 "Foo Display"\n'
' Enabled: yes\n'
' Modes:\n'
'HDMI-A-2 "Bar"\n'
' Enabled: yes\n'
)
else:
result.stdout = ''
result.stderr = ''
return result
with (
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'x86'}, clear=False),
mock.patch(
'anthias_viewer.subprocess.run', side_effect=_fake_run
) as run,
):
viewer._apply_wlr_transform(180)
transform_calls = [
c
for c in run.call_args_list
if c.args
and c.args[0][:1] == ['wlr-randr']
and '--transform' in c.args[0]
]
assert len(transform_calls) == 2
for call in transform_calls:
assert '--transform' in call.args[0]
assert call.args[0][call.args[0].index('--transform') + 1] == '180'
def test_wlr_output_names_skips_disabled_outputs() -> None:
"""``wlr-randr --output X --transform ...`` on a disabled
connector fails noisily and changes nothing — Copilot review of
#2882 flagged that we were trying anyway. Parser must drop
blocks whose ``Enabled:`` line reads ``no``."""
def _fake_run(argv: Any, **kwargs: Any) -> mock.Mock:
result = mock.Mock()
result.returncode = 0
result.stdout = (
'HDMI-A-1 "Foo"\n'
' Enabled: yes\n'
' Modes:\n'
'HDMI-A-2 "Bar"\n'
' Enabled: no\n'
'DP-1 "Baz"\n'
' Enabled: yes\n'
)
result.stderr = ''
return result
with (
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'x86'}, clear=False),
mock.patch('anthias_viewer.subprocess.run', side_effect=_fake_run),
):
names = viewer._wlr_output_names()
assert names == ['HDMI-A-1', 'DP-1']
def test_apply_wlr_transform_logs_warning_on_nonzero_exit(
caplog: Any,
) -> None:
"""wlr-randr's exit code is informative — cage may not be ready
yet, the output name can vanish between list and apply, etc. The
helper must surface stderr on failure rather than blanket-logging
"Applied" on every invocation. Copilot review of #2882."""
def _fake_run(argv: Any, **kwargs: Any) -> mock.Mock:
result = mock.Mock()
if argv == ['wlr-randr']:
result.returncode = 0
result.stdout = 'HDMI-A-1\n Enabled: yes\n'
result.stderr = ''
else:
result.returncode = 1
result.stdout = ''
result.stderr = 'invalid output\n'
return result
with (
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'x86'}, clear=False),
mock.patch('anthias_viewer.subprocess.run', side_effect=_fake_run),
caplog.at_level(logging.WARNING, logger='root'),
):
viewer._apply_wlr_transform(90)
warning_records = [
r for r in caplog.records if r.levelno >= logging.WARNING
]
assert any('invalid output' in r.getMessage() for r in warning_records)
assert not any(
'Applied wlroots transform' in r.getMessage() for r in caplog.records
)
def test_handle_reload_reapplies_rotation_when_changed(
reset_rotation_state: None,
) -> None:
"""The reload pub/sub message must re-push wlr-randr when the
operator changes rotation in Settings — that's the whole point of
issue #2856's UI-driven knob.
Wayland-side rotation change must NOT call MediaPlayerProxy.reset()
— mpv's wayland VO inherits the compositor transform, so killing
the in-flight mpv would just leave the screen black until the
asset's original duration elapses (Copilot review of #2882).
"""
with (
mock.patch.dict(settings, {'screen_rotation': 90}),
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'x86'}, clear=False),
mock.patch.object(viewer, 'load_settings'),
mock.patch.object(viewer, '_skip_if_current_asset_inactive'),
mock.patch('anthias_viewer.MediaPlayerProxy.reset') as reset,
mock.patch.object(
viewer, '_apply_wlr_transform', return_value=True
) as apply,
):
viewer._handle_reload()
apply.assert_called_once_with(90)
reset.assert_not_called()
assert viewer._last_applied_rotation == 90
def test_handle_reload_resets_media_player_on_linuxfb_rotation_change(
reset_rotation_state: None,
) -> None:
"""On linuxfb (Pi) the rotation change DOES need MediaPlayerProxy
reset, because VLC bakes the transform filter into the instance
at construction. Bound for the pi1/2/3 boards specifically."""
fake_browser = mock.Mock()
skip = mock.Mock()
viewer._rotation_bounce_pending = False
with (
mock.patch.dict(settings, {'screen_rotation': 90}),
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'pi3'}, clear=False),
mock.patch.object(viewer, 'load_settings'),
mock.patch.object(viewer, '_skip_if_current_asset_inactive'),
mock.patch.object(viewer, 'browser', fake_browser),
mock.patch('anthias_viewer.MediaPlayerProxy.reset') as reset,
mock.patch('anthias_viewer.get_skip_event', return_value=skip),
):
viewer._handle_reload()
reset.assert_called_once()
def test_handle_reload_does_not_latch_when_wlr_transform_fails(
reset_rotation_state: None,
) -> None:
"""Issue #2856 (Copilot review #2): if wlr-randr can't apply the
transform (cage not ready, no outputs, every output failed), we
must NOT latch the new rotation as ``applied`` — the next reload
should retry rather than leaving the display stuck unrotated
until the user changes the setting again."""
viewer._last_applied_rotation = 0
with (
mock.patch.dict(settings, {'screen_rotation': 90}),
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'x86'}, clear=False),
mock.patch.object(viewer, 'load_settings'),
mock.patch.object(viewer, '_skip_if_current_asset_inactive'),
mock.patch('anthias_viewer.MediaPlayerProxy.reset'),
mock.patch.object(viewer, '_apply_wlr_transform', return_value=False),
):
viewer._handle_reload()
# Latch unchanged — next reload retries.
assert viewer._last_applied_rotation == 0
def test_handle_reload_no_rotation_change_is_no_op(
reset_rotation_state: None,
) -> None:
"""Most reload traffic (asset edits, etc.) must NOT blank the
screen — the rotation-change path is keyed on a genuine delta."""
viewer._last_applied_rotation = 90
with (
mock.patch.dict(settings, {'screen_rotation': 90}),
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'x86'}, clear=False),
mock.patch.object(viewer, 'load_settings'),
mock.patch.object(viewer, '_skip_if_current_asset_inactive'),
mock.patch('anthias_viewer.MediaPlayerProxy.reset') as reset,
mock.patch.object(viewer, '_apply_wlr_transform') as apply,
):
viewer._handle_reload()
apply.assert_not_called()
reset.assert_not_called()
def test_handle_reload_queues_bounce_on_linuxfb_rotation_change(
reset_rotation_state: None,
) -> None:
"""linuxfb only reads :rotation=N at QPA init, so the live-rotation
path has to bounce AnthiasViewer. _handle_reload runs on the
subscriber thread and MUST NOT terminate the browser directly —
that would race a concurrent view_*() call mid-D-Bus on the main
thread (Copilot review of #2882). It only sets a flag; the main
thread consumes it via _consume_pending_rotation_bounce()."""
fake_browser = mock.Mock()
skip = mock.Mock()
viewer._rotation_bounce_pending = False
with (
mock.patch.dict(settings, {'screen_rotation': 270}),
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'pi5'}, clear=False),
mock.patch.object(viewer, 'load_settings'),
mock.patch.object(viewer, '_skip_if_current_asset_inactive'),
mock.patch.object(viewer, 'browser', fake_browser),
mock.patch.object(viewer, 'MediaPlayerProxy'),
mock.patch('anthias_viewer.get_skip_event', return_value=skip),
):
viewer._handle_reload()
# NOT called from the subscriber thread.
fake_browser.terminate.assert_not_called()
# Flag set so the next asset_loop tick consumes it on the main
# thread.
assert viewer._rotation_bounce_pending is True
skip.set.assert_called_once()
# _last_applied_rotation is latched immediately, NOT only after
# load_browser() respawns the webview. Otherwise a second `reload`
# arriving in the gap would treat rotation as still-changed and
# spam terminate() flags on an already-pending bounce.
assert viewer._last_applied_rotation == 270
def test_consume_pending_rotation_bounce_terminates_browser(
reset_rotation_state: None,
) -> None:
"""The main-thread half of the handoff: when the flag is set,
terminate the webview and clear current_browser_url so the next
view_*() call respawns it via load_browser()."""
fake_browser = mock.Mock()
viewer._rotation_bounce_pending = True
with mock.patch.object(viewer, 'browser', fake_browser):
viewer._consume_pending_rotation_bounce()
fake_browser.terminate.assert_called_once()
assert viewer.current_browser_url is None
# Flag cleared so a subsequent tick (with no new pending bounce)
# doesn't terminate the freshly-spawned process.
assert viewer._rotation_bounce_pending is False
def test_consume_pending_rotation_bounce_no_op_when_flag_clear(
reset_rotation_state: None,
) -> None:
"""Most ticks have no pending bounce — must not touch the
browser; otherwise every asset_loop iteration would kill it."""
fake_browser = mock.Mock()
viewer._rotation_bounce_pending = False
with mock.patch.object(viewer, 'browser', fake_browser):
viewer._consume_pending_rotation_bounce()
fake_browser.terminate.assert_not_called()
def test_asset_loop_consumes_pending_rotation_bounce(
reset_rotation_state: None,
) -> None:
"""asset_loop must consume the subscriber-set flag at the top of
each tick — that's the main-thread side of the cross-thread
handoff that keeps view_*() and rotation-change from racing on
``browser``."""
fake_scheduler = mock.Mock()
fake_scheduler.get_next_asset.return_value = None
skip = mock.Mock()
skip.wait.return_value = False
with (
mock.patch.object(
viewer, '_consume_pending_rotation_bounce'
) as consume,
mock.patch.object(viewer, '_retry_wayland_rotation_if_pending'),
mock.patch.object(viewer, 'view_image'),
mock.patch('anthias_viewer.get_skip_event', return_value=skip),
):
viewer.asset_loop(fake_scheduler)
consume.assert_called_once()
def test_asset_loop_retries_wayland_rotation(
reset_rotation_state: None,
) -> None:
"""asset_loop must drive the Wayland startup-failure retry —
Copilot review of #2882 flagged that without it, an early-boot
wlr-randr failure (cage's wayland socket not yet up) leaves the
display unrotated forever, since no reload arrives unattended."""
fake_scheduler = mock.Mock()
fake_scheduler.get_next_asset.return_value = None
skip = mock.Mock()
skip.wait.return_value = False
with (
mock.patch.object(viewer, '_consume_pending_rotation_bounce'),
mock.patch.object(viewer, '_retry_wayland_rotation_if_pending') as r,
mock.patch.object(viewer, 'view_image'),
mock.patch('anthias_viewer.get_skip_event', return_value=skip),
):
viewer.asset_loop(fake_scheduler)
r.assert_called_once()
def test_retry_wayland_rotation_skips_when_already_applied(
reset_rotation_state: None,
) -> None:
"""Cheap early-return when rotation is already where it should
be — otherwise asset_loop would push wlr-randr on every tick."""
viewer._last_applied_rotation = 90
with (
mock.patch.dict(settings, {'screen_rotation': 90}),
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'x86'}, clear=False),
mock.patch.object(viewer, '_apply_wlr_transform') as apply,
):
viewer._retry_wayland_rotation_if_pending()
apply.assert_not_called()
def test_retry_wayland_rotation_recovers_from_startup_failure(
reset_rotation_state: None,
) -> None:
"""Sentinel -1 (set by load_browser when the boot apply failed)
must trigger a retry. On success the latch advances to the real
angle so subsequent ticks early-return."""
viewer._last_applied_rotation = -1
with (
mock.patch.dict(settings, {'screen_rotation': 270}),
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'x86'}, clear=False),
mock.patch.object(
viewer, '_apply_wlr_transform', return_value=True
) as apply,
):
viewer._retry_wayland_rotation_if_pending()
apply.assert_called_once_with(270)
assert viewer._last_applied_rotation == 270
def test_retry_wayland_rotation_keeps_sentinel_on_failure(
reset_rotation_state: None,
) -> None:
"""A second-attempt failure must NOT latch the new angle —
otherwise we'd silently give up on rotation."""
viewer._last_applied_rotation = -1
with (
mock.patch.dict(settings, {'screen_rotation': 270}),
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'x86'}, clear=False),
mock.patch.object(viewer, '_apply_wlr_transform', return_value=False),
):
viewer._retry_wayland_rotation_if_pending()
assert viewer._last_applied_rotation == -1
def test_retry_wayland_rotation_skipped_on_linuxfb(
reset_rotation_state: None,
) -> None:
"""Linuxfb rotation is applied synchronously at QPA init via
QT_QPA_PLATFORM, so there's no analogous failure mode — the
retry helper must short-circuit on Pi boards."""
viewer._last_applied_rotation = -1
with (
mock.patch.dict(settings, {'screen_rotation': 90}),
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'pi5'}, clear=False),
mock.patch.object(viewer, '_apply_wlr_transform') as apply,
):
viewer._retry_wayland_rotation_if_pending()
apply.assert_not_called()
def test_handle_reload_linuxfb_idempotent_under_repeat(
reset_rotation_state: None,
) -> None:
"""Two ``reload`` messages back-to-back with the same rotation must
not flap the pending-bounce flag or set the skip_event twice on
an already-pending bounce — issue raised by Copilot in review of
#2882. After the first reload latches the new rotation, the
second sees it unchanged and short-circuits."""
fake_browser = mock.Mock()
skip = mock.Mock()
viewer._rotation_bounce_pending = False
with (
mock.patch.dict(settings, {'screen_rotation': 90}),
mock.patch.dict(os.environ, {'DEVICE_TYPE': 'pi5'}, clear=False),
mock.patch.object(viewer, 'load_settings'),
mock.patch.object(viewer, '_skip_if_current_asset_inactive'),
mock.patch.object(viewer, 'browser', fake_browser),
mock.patch.object(viewer, 'MediaPlayerProxy'),
mock.patch('anthias_viewer.get_skip_event', return_value=skip),
):
viewer._handle_reload()
viewer._handle_reload()
fake_browser.terminate.assert_not_called()
skip.set.assert_called_once()
assert viewer._rotation_bounce_pending is True