Files
StarPilot/openpilot/starpilot/ui/tests/test_replay_settings.py
T

1132 lines
49 KiB
Python

"""Fake transport exercises native ownership, cancellation and passive input."""
from collections.abc import Callable
from types import SimpleNamespace as NS
import threading
import time
from typing import Any, NoReturn, cast
from unittest.mock import Mock
import pyray as rl
import pytest
from openpilot.starpilot.ui.replay_settings import ReplayCompactPage, ReplayController, ReplayPage, duration, route_date
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.feature_settings_state import FEATURE_ROW_HEIGHT, feature_row_top
ROUTE = {'routeId': '00000001--0123456789', 'displayName': 'Recorded route', 'segmentCount': 2,
'availability': 'ready', 'playable': True, 'reason': None, 'warnings': []}
STAMP = 1_791_986_730 # 2026-10-14 14:05:30 UTC
@pytest.fixture
def utc(monkeypatch):
with monkeypatch.context() as patch:
patch.setenv('TZ', 'UTC')
time.tzset()
yield
time.tzset()
@pytest.mark.parametrize('profile', [Profile.LARGE, Profile.COMPACT])
def test_route_date_prefers_recorded_start_and_formats_native_clock(utc, profile):
route = {'startTime': STAMP, 'fileTime': STAMP + 60}
calendar, clock, zone, source = route_date(route, profile)
assert calendar == ('Wed, Oct 14, 2026' if profile == Profile.LARGE else 'Oct 14, 2026')
assert (clock, zone, source) == ('2:05 PM', 'UTC', '')
assert route_date(route, profile, True)[1] == '14:05'
@pytest.mark.parametrize('invalid', [None, True, '2026-10-14', 0, -1, float('nan'), float('inf'), 10**400])
def test_route_date_falls_back_and_handles_missing_or_unrepresentable_dates(utc, invalid):
route = {'startTime': invalid, 'fileTime': STAMP + 60}
assert route_date(route, Profile.COMPACT)[1:] == ('2:06 PM', 'UTC', 'File date')
route['fileTime'] = invalid
assert route_date(route, Profile.COMPACT) == ('Date unavailable', '', '', '')
@pytest.mark.parametrize('profile', [Profile.LARGE, Profile.COMPACT])
@pytest.mark.parametrize('named', [False, True])
def test_route_rows_prioritize_dates_preserve_names_and_keep_route_identity(utc, profile, named):
page, owner = page_for_input()
page.profile = profile
owner.routes = [{**ROUTE, 'displayName': 'School run' if named else ROUTE['routeId'],
'startTime': STAMP}]
row = page._rows()[0]
assert row.key == ROUTE['routeId']
assert row.label == ('School run' if named else 'Wed, Oct 14, 2026 - 2:05 PM' if profile == Profile.LARGE else 'Oct 14, 2026')
assert '2:05 PM' in row.label + row.value
assert row.reason == ''
if profile == Profile.COMPACT:
assert row.value.count('\n') == 1
assert '2 segments' in row.value
else:
assert row.segment_count == 2
assert row.page == 'detail' and not row.actions
assert ROUTE['routeId'] in row.value
assert page._rows()[0] is row
@pytest.mark.parametrize('profile', [Profile.LARGE, Profile.COMPACT])
def test_route_rows_show_newest_first_without_reordering_catalog(profile):
page, owner = page_for_input()
page.profile = profile
owner.routes = [{**ROUTE, 'routeId': '00000001--0123456789', 'startTime': STAMP, 'fileTime': STAMP + 120},
{**ROUTE, 'routeId': '00000002--0123456789', 'startTime': STAMP + 60},
{**ROUTE, 'routeId': '00000003--0123456789', 'fileTime': STAMP - 60},
{**ROUTE, 'routeId': '00000004--0123456789'}]
identifiers = [route['routeId'] for route in owner.routes]
rows = page._rows()
assert [row.key for row in rows] == [identifiers[1], identifiers[0], identifiers[2], identifiers[3]]
assert [route['routeId'] for route in owner.routes] == identifiers
assert page._rows() is rows
def test_compact_file_date_is_explicit_and_unavailable_reason_is_retained(utc):
page, owner = page_for_input()
page.profile = Profile.COMPACT
owner.routes = [{**ROUTE, 'displayName': ROUTE['routeId'], 'fileTime': STAMP,
'availability': 'unavailable', 'reason': 'No road camera footage'}]
row = page._rows()[0]
assert row.value == '2:05 PM\nFile date - 2 segments'
assert row.reason == 'No road camera footage'
owner.routes = [{**ROUTE, 'displayName': 'School run', 'fileTime': STAMP, 'availability': 'limited'}]
row = page._rows()[0]
assert row.value == 'File date: Oct 14, 2026\n2:05 PM - 2 segments'
assert row.reason == ''
@pytest.mark.parametrize('profile', [Profile.LARGE, Profile.COMPACT])
@pytest.mark.parametrize('named', [False, True])
def test_details_show_source_timezone_and_full_id_without_reformatting_each_frame(utc, route_detail, monkeypatch, profile, named):
from openpilot.starpilot.ui import replay_settings as module
page, owner = route_detail
page.profile = profile
owner.detail = {**owner.detail, 'displayName': 'School run' if named else ROUTE['routeId'], 'fileTime': STAMP}
formatter = Mock(wraps=route_date)
monkeypatch.setattr(module, 'route_date', formatter)
monkeypatch.setattr(module, 'draw_settings_header', Mock())
page._button = Mock()
page._render_detail()
page._render_detail()
formatter.assert_called_once()
texts = [call.args[0] for call in page.fonts.draw.call_args_list]
assert ROUTE['routeId'] in texts
assert any('File date' in text and '2:05 PM UTC' in text for text in texts)
assert ('School run' if named else 'Wed, Oct 14, 2026' if profile == Profile.LARGE else 'Oct 14, 2026') in texts
class Backend:
def __init__(self):
self.calls = []
self.status: dict[str, Any] = {'schemaVersion': 1, 'ownerId': 'a' * 32, 'sessionId': None, 'state': 'idle',
'routeId': None, 'profile': 'large', 'revision': 0, 'epoch': 0,
'position': 0.0, 'start': 0.0, 'end': 120.0, 'rate': 1.0, 'paused': True,
'frameReady': False, 'errorCode': None, 'owned': False, 'offroad': True}
self.start_gate = None
def __call__(self, payload):
self.calls.append(payload)
operation = payload['op']
if operation == 'catalog':
result = {'schemaVersion': 1, 'routes': [dict(ROUTE)]}
elif operation == 'detail':
result = dict(ROUTE)
else:
if operation == 'start':
if self.start_gate is not None:
self.start_gate.wait(2)
self.status.update(sessionId='b' * 32, state='playing', routeId=ROUTE['routeId'],
revision=1, epoch=1, paused=False, owned=True, frameReady=True, framePath='/tmp/private-replay-frame')
elif operation == 'control':
assert payload['sessionId'] == self.status['sessionId']
assert payload['expectedRevision'] == self.status['revision']
self.status['revision'] += 1
action = payload['action']
if action == 'seek':
self.status.update(position=payload['position'], epoch=self.status['epoch'] + 1)
elif action == 'pause':
self.status.update(paused=payload['paused'], state='paused' if payload['paused'] else 'playing')
else:
self.status['rate'] = payload['rate']
elif operation == 'stop':
self.status.update(sessionId=None, state='idle', frameReady=False)
result = dict(self.status)
return {'ok': True, 'result': result}
def pump(owner, predicate):
deadline = time.monotonic() + 2
while time.monotonic() < deadline:
owner.tick()
if predicate():
return
time.sleep(0.001)
cast(Callable[[str], NoReturn], pytest.fail)('fake replay operation did not finish')
@pytest.fixture
def playback():
backend = Backend()
frames = []
reader = NS(latest=lambda: frames.pop(0) if frames else None, close=Mock())
owner = ReplayController(backend, lambda *_: reader)
pump(owner, lambda: owner.available and owner.pending is None)
owner.select(ROUTE)
pump(owner, lambda: owner.pending is None)
owner.start()
pump(owner, lambda: owner.active)
yield owner, backend, frames, reader
owner.close()
owner.executor.shutdown(wait=True)
def test_owned_start_and_controls_bind_revision_and_client(playback):
owner, backend, _, _ = playback
start = next(call for call in backend.calls if call['op'] == 'start')
assert start['expectedOwnerId'] == 'a' * 32
assert start['clientId'] == owner.client_id
assert owner.seek(1000)
pump(owner, lambda: owner.status['epoch'] == 2)
command = next(call for call in backend.calls if call['op'] == 'control')
assert command['position'] == 120
assert command['expectedRevision'] == 1
assert command['sessionId'] == owner.own_session
assert not owner.seek(float('nan'))
@pytest.mark.parametrize('broken', [False, True])
def test_timeline_is_optional_reused_on_seek_and_cleared_on_authority_loss(playback, broken):
from openpilot.starpilot.replay.timeline import LEGEND
owner, backend, _, _ = playback
original = owner.requester
def requester(payload):
if payload['op'] == 'timeline':
backend.calls.append(payload)
if broken:
return {'ok': False, 'error': {'message': 'history unavailable'}}
return {'ok': True, 'result': {'schemaVersion': 1, 'ownerId': owner.status['ownerId'], 'sessionId': owner.own_session,
'rowOffset': 0, 'totalRows': 1, 'source': 'qlog', 'rows': [[0, 1, 'chill']],
'nextCursor': None, 'legend': LEGEND}}
return original(payload)
owner.requester = requester
backend.status['timelineState'] = 'ready'
owner.next_status = 0
pump(owner, lambda: bool(owner.timeline_error) if broken else owner.timeline is not None and owner.timeline.rows is not None)
history = owner.timeline
assert owner.error == '' and owner.available
assert owner.seek(30)
pump(owner, lambda: owner.status['epoch'] == 2)
assert owner.timeline is history
assert len([call for call in backend.calls if call['op'] == 'timeline']) == 1
backend.status['offroad'] = False
owner.next_status = 0
pump(owner, lambda: not owner.available)
assert owner.timeline is None
@pytest.mark.parametrize('bounds', [(None, 120), (0, None), (False, 120), (0, True), ('0', 120), (120, 0)])
def test_seek_rejects_missing_or_invalid_bounds_without_sending_controls(playback, bounds):
owner, backend, _, _ = playback
owner.status['start'], owner.status['end'] = bounds
count = len(backend.calls)
assert not owner.seek(30)
assert len(backend.calls) == count
@pytest.fixture(params=[Profile.LARGE, Profile.COMPACT])
def prepared_playback(request):
backend = Backend()
backend.status['profile'] = request.param
def requester(payload):
response = backend(payload)
if payload['op'] == 'start':
backend.status.update(state='paused', paused=True)
response['result'] = dict(backend.status)
return response
frames = []
reader = NS(latest=lambda: frames.pop(0) if frames else None, close=Mock())
factory = Mock(return_value=reader)
clock = NS(now=0.0)
owner = ReplayController(requester, factory, clock=lambda: clock.now)
pump(owner, lambda: owner.available and owner.pending is None)
owner.select(ROUTE)
pump(owner, lambda: owner.pending is None)
assert owner.start()
pump(owner, lambda: owner.active)
yield owner, backend, frames, clock, factory
owner.close()
owner.executor.shutdown(wait=True)
def test_buffering_preserves_published_native_frame(prepared_playback):
owner, backend, frames, _, factory = prepared_playback
frame = NS(session_id=owner.own_session, epoch=1, frame_id=1)
frames.append(frame)
owner.next_frame = 0
pump(owner, lambda: owner.frame is frame)
generation = owner.reader_generation
backend.status.update(state='buffering', paused=False)
owner.next_status = 0
pump(owner, lambda: owner.status['state'] == 'buffering')
assert owner.frame is frame and owner.reader_generation == generation
assert factory.call_count == 1
backend.status['offroad'] = False
owner.next_status = 0
pump(owner, lambda: not owner.available)
assert owner.frame is None
def test_new_frame_is_polled_on_next_ui_frame_after_an_unchanged_read(prepared_playback):
owner, _, frames, clock, _ = prepared_playback
owner.autoplay_at = None
owner.next_status = 100
owner.next_frame = 0
owner.tick()
pump(owner, lambda: owner.pending is None)
frame = NS(session_id=owner.own_session, epoch=1, frame_id=1)
frames.append(frame)
clock.now += 1 / 60
pump(owner, lambda: owner.frame is frame)
def test_autoplay_waits_one_second_after_first_valid_frame_and_runs_only_once(prepared_playback):
owner, backend, frames, clock, factory = prepared_playback
clock.now = 10
pump(owner, lambda: owner.available and owner.pending is None)
assert not any(call['op'] == 'control' for call in backend.calls)
frames.append(NS(session_id=owner.own_session, epoch=0, frame_id=1))
owner.next_frame = 0
pump(owner, lambda: not frames and owner.pending is None)
assert owner.autoplay_at == float('inf')
frames.append(NS(session_id=owner.own_session, epoch=1, frame_id=2))
owner.next_frame = 0
pump(owner, lambda: owner.frame is not None)
assert factory.call_args.args[2] == backend.status['profile']
assert owner.autoplay_at == 11
clock.now = 10.99
pump(owner, lambda: owner.pending is None)
assert backend.status['paused']
clock.now = 11
pump(owner, lambda: backend.status['paused'] is False)
controls = [call for call in backend.calls if call['op'] == 'control']
assert len(controls) == 1
assert controls[0]['action'] == 'pause' and controls[0]['paused'] is False
pump(owner, lambda: owner.status['paused'] is False)
assert owner.control('pause', paused=True)
pump(owner, lambda: owner.status['paused'] is True)
clock.now = 12
pump(owner, lambda: owner.available and owner.pending is None)
assert backend.status['paused'] # Later pauses must never trigger another autoplay.
@pytest.mark.parametrize('action', ['pause', 'seek', 'stop', 'close', 'offroad'])
def test_autoplay_respects_user_actions_and_offroad_authority(prepared_playback, action):
owner, backend, frames, clock, _ = prepared_playback
frames.append(NS(session_id=owner.own_session, epoch=1, frame_id=1))
owner.next_frame = 0
pump(owner, lambda: owner.frame is not None)
if action == 'pause':
assert owner.control('pause', paused=True)
elif action == 'seek':
assert owner.seek(30)
elif action == 'stop':
assert owner.stop()
elif action == 'close':
owner.close()
else:
backend.status['offroad'] = False
owner.next_status = 0
pump(owner, lambda: not owner.available)
if action != 'close':
pump(owner, lambda: owner.pending is None)
clock.now = 1.1
owner.tick()
if action != 'close':
pump(owner, lambda: owner.pending is None)
assert not any(call['op'] == 'control' and call.get('paused') is False for call in backend.calls)
def test_another_client_session_has_no_implicit_native_control():
backend = Backend()
backend.status.update(sessionId='c' * 32, state='playing')
owner = ReplayController(backend)
try:
pump(owner, lambda: owner.available)
assert not owner.active
assert not owner.control('pause', paused=True)
assert not owner.stop()
assert not any(call['op'] in ('start', 'control', 'stop') for call in backend.calls)
finally:
owner.close()
owner.executor.shutdown(wait=True)
def test_close_stops_a_start_that_finishes_after_dismissal():
backend = Backend()
owner = ReplayController(backend)
pump(owner, lambda: owner.available and owner.pending is None)
owner.select(ROUTE)
pump(owner, lambda: owner.pending is None)
backend.start_gate = threading.Event()
assert owner.start()
deadline = time.monotonic() + 2
while not any(call['op'] == 'start' for call in backend.calls) and time.monotonic() < deadline:
time.sleep(0.001)
owner.close()
backend.start_gate.set()
owner.executor.shutdown(wait=True)
assert [call['op'] for call in backend.calls][-2:] == ['start', 'stop']
assert backend.status['sessionId'] is None
def test_old_epoch_frames_are_rejected_and_seek_clears_previous_frame(playback):
owner, _, frames, _ = playback
old = NS(data=b'pixels', session_id=owner.own_session, epoch=1, frame_id=1)
frames.append(old)
pump(owner, lambda: owner.frame is old)
assert owner.seek(60)
pump(owner, lambda: owner.status['epoch'] == 2)
assert owner.frame is None
frames.append(old)
owner.next_frame = 0
pump(owner, lambda: not frames)
assert owner.frame is None
def test_control_waits_behind_one_frame_copy_without_blocking_ui(playback):
owner, backend, _, reader = playback
entered, release = threading.Event(), threading.Event()
def blocked_frame():
entered.set()
release.wait(2)
return None
reader.latest = blocked_frame
owner.next_frame = 0
pump(owner, entered.is_set)
began = time.monotonic()
assert owner.control('pause', paused=True)
assert time.monotonic() - began < .1
assert owner.queued['action'] == 'pause'
release.set()
pump(owner, lambda: backend.status['paused'])
def page_for_input():
controller = NS(status={'epoch': 1, 'start': 0, 'end': 120, 'position': 30, 'rate': 1, 'paused': False},
seek=Mock(), control=Mock(), stop=Mock(), active=True)
fonts = Mock(profile=Profile.LARGE)
page = ReplayPage(NS(fonts=fonts, profile=Profile.LARGE), Mock(), controller)
page.set_rect(rl.Rectangle(0, 0, 2160, 1080))
page.page = 'viewer'
return page, controller
@pytest.mark.parametrize('profile,rail,tick,mark_width', [(Profile.LARGE, 28, 6, 4), (Profile.COMPACT, 16, 4, 3)])
@pytest.mark.parametrize('scale', [1, 2])
def test_timeline_cache_draws_alerts_last_and_keeps_existing_hit_area(monkeypatch, profile, rail, tick, mark_width, scale):
from openpilot.starpilot.ui import replay_settings as module
from openpilot.starpilot.replay.timeline import LEGEND
page, controller = page_for_input()
page.profile = profile
controller.timeline = NS(rows=[[0, 120, 'chill'], [30, 30.001, 'error'], [30, 30, 'bookmark']])
monkeypatch.setattr(module.gui_app, '_scale', scale)
target = NS(id=1)
upload, paint, unload = Mock(return_value=target), Mock(), Mock()
monkeypatch.setattr(rl, 'load_render_texture', upload)
monkeypatch.setattr(rl, 'draw_rectangle', paint)
monkeypatch.setattr(rl, 'unload_render_texture', unload)
for name in ('begin_texture_mode', 'end_texture_mode', 'clear_background'):
monkeypatch.setattr(rl, name, Mock())
page._prepare_rail()
assert page._transport()[0][-1] == 48
assert upload.call_args.args[1] == (rail + tick + 2) * scale
calls = paint.call_args_list
assert calls[-2].args[1:4:2] == ((tick + 2) * scale, rail * scale)
assert calls[-2].args[2] >= 1 and calls[-1].args[2] >= mark_width * scale
for call, kind in zip(calls[-3:], ('chill', 'error', 'bookmark'), strict=True):
color = call.args[-1]
assert [color.r, color.g, color.b] == LEGEND[kind]['rgb']
controller.status.update(position=60, epoch=2)
page._prepare_rail()
upload.assert_called_once()
controller.timeline = None
page._prepare_rail()
unload.assert_called_once_with(target)
def test_timeline_texture_without_cache_key_uses_neutral_rail(monkeypatch):
page, controller = page_for_input()
controller.timeline = NS(rows=[])
page.rail_texture, page.rail_key = NS(id=1), None
paint, draw = Mock(), Mock()
monkeypatch.setattr(rl, 'draw_rectangle', paint)
monkeypatch.setattr(rl, 'draw_texture_pro', draw)
page._timeline_rail(100, 916, 1960, 48, 0, 120, [[0, 120]])
draw.assert_not_called()
paint.assert_called_once()
@pytest.mark.parametrize('profile', [Profile.LARGE, Profile.COMPACT])
@pytest.mark.parametrize('head_x', [16, 270, 2060])
def test_scrub_preview_stays_above_touch_area_and_inside_screen(monkeypatch, profile, head_x):
from openpilot.starpilot.ui import replay_settings as module
from openpilot.starpilot.replay.timeline import LEGEND
page, _ = page_for_input()
page.profile = profile
page.fonts.measure.side_effect = lambda text, _role, size: NS(width=len(text) * size / 2, height=size)
fill, paint = Mock(), Mock()
monkeypatch.setattr(module, 'draw_rounded_fill', fill)
monkeypatch.setattr(module, 'draw_rounded_stroke', Mock())
monkeypatch.setattr(rl, 'draw_line', Mock())
monkeypatch.setattr(rl, 'draw_rectangle', paint)
page._scrub_preview(head_x, 90, 'Experimental', LEGEND['experimental'])
bounds = fill.call_args.args[0]
assert 0 <= bounds.x < bounds.x + bounds.width <= profile.size[0]
assert bounds.y + bounds.height < page._transport()[0][1] - (48 if profile == Profile.LARGE else 8)
color = paint.call_args.args[-1]
assert [color.r, color.g, color.b] == LEGEND['experimental']['rgb']
assert page.fonts.draw.call_args.args[0] == '1:30 - Experimental'
if profile == Profile.COMPACT:
assert (bounds.x, bounds.y, bounds.width, bounds.height) == (8, 8, 520, 42)
page._scrub_preview(head_x, 90, 'Unavailable gap', None)
assert paint.call_args.args[-1] is module.TEXT_MUTED
@pytest.mark.parametrize('profile', [Profile.LARGE, Profile.COMPACT])
def test_timeline_commits_only_on_release_and_epoch_change_cancels(profile):
page, controller = page_for_input()
page.profile = profile
page.set_rect(rl.Rectangle(0, 0, *profile.size))
x, top, width, height = page._transport()[0]
y, destination = top + height / 2, x + width * .75
page._handle_mouse_press(NS(x=x, y=y))
page._handle_mouse_event(mouse_event=NS(left_down=True, left_released=False, pos=NS(x=destination, y=y + 50), t=1))
controller.seek.assert_not_called()
page._handle_mouse_release(NS(x=destination, y=y + 50))
controller.seek.assert_called_once_with(90)
controller.seek.reset_mock()
page._handle_mouse_press(NS(x=x, y=y))
controller.status['epoch'] = 2
page._handle_mouse_release(NS(x=destination, y=y))
controller.seek.assert_not_called()
def test_recorded_driving_hit_areas_are_passive():
page, controller = page_for_input()
page._handle_mouse_press(NS(x=1680, y=150))
page._handle_mouse_release(NS(x=1680, y=150))
controller.control.assert_not_called()
controller.seek.assert_not_called()
controller.stop.assert_not_called()
def test_unavailable_duration_does_not_become_zero():
assert duration(None) == 'Unavailable'
assert duration(float('nan')) == 'Unavailable'
assert duration(65) == '1:05'
@pytest.mark.parametrize('active,available,paused,awake', [(True, True, False, True), (True, True, True, False),
(True, False, False, False), (False, True, False, False)])
def test_only_available_native_playback_keeps_display_awake(monkeypatch, active, available, paused, awake):
from openpilot.starpilot.ui import replay_settings as module
page, owner = page_for_input()
owner.tick = Mock()
owner.active, owner.available = active, available
owner.status['paused'] = paused
reset = Mock()
monkeypatch.setattr(module.device, '_reset_interactive_timeout', reset)
monkeypatch.setattr(module, 'ui_state', NS(started=False))
page._tick()
assert bool(reset.call_count) is awake
def test_status_recovers_owned_session_after_lost_start_response():
backend = Backend()
backend.status.update(sessionId='b' * 32, state='preparing', owned=True)
owner = ReplayController(backend)
try:
pump(owner, lambda: owner.available)
assert owner.own_session == 'b' * 32
assert owner.active
assert owner.stop()
pump(owner, lambda: backend.status['sessionId'] is None)
finally:
owner.close()
owner.executor.shutdown(wait=True)
def test_owned_ended_session_remains_seekable(playback):
owner, backend, _, _ = playback
backend.status.update(state='ended', paused=True, position=120)
owner.next_status = 0
pump(owner, lambda: owner.status['state'] == 'ended')
assert owner.active
assert owner.seek(30)
pump(owner, lambda: backend.status['position'] == 30)
def test_fresh_ignition_denial_hides_frame_and_prevents_controls(playback):
owner, backend, _, _ = playback
backend.status['offroad'] = False
owner.next_status = 0
pump(owner, lambda: owner.status.get('offroad') is False)
assert not owner.available
assert not owner.seek(30)
assert not owner.control('pause', paused=True)
@pytest.mark.parametrize('profile', [Profile.LARGE, Profile.COMPACT])
def test_hud_tap_hides_and_shows_transport(profile):
page, controller = page_for_input()
page.profile = profile
page.set_rect(rl.Rectangle(0, 0, *profile.size))
page.controls = True
page._handle_mouse_press(NS(x=220, y=20))
page._handle_mouse_release(NS(x=220, y=20))
assert page.last_touch == -float('inf')
page.controls = False
page._handle_mouse_press(NS(x=220, y=20))
page._handle_mouse_release(NS(x=220, y=20))
assert page.last_touch > 0
controller.control.assert_not_called()
controller.seek.assert_not_called()
@pytest.mark.parametrize('profile', [Profile.LARGE, Profile.COMPACT])
def test_transport_autohides_only_during_playback_and_tap_does_not_activate_hidden_buttons(monkeypatch, profile):
from openpilot.starpilot.ui import replay_settings as module
page, owner = page_for_input()
page.profile = profile
page.set_rect(rl.Rectangle(0, 0, *profile.size))
page.fonts.measure.return_value = NS(width=20, height=20)
page._button = Mock()
page._scrub_preview = Mock()
monkeypatch.setattr(module, 'draw_rounded_fill', Mock())
owner.available, owner.busy, owner.error = True, False, ''
owner.frame = NS(session_id='b' * 32, epoch=1, frame_id=1, width=profile.size[0], height=profile.size[1], data=b'pixels')
owner.status.update(state='playing', paused=False)
monkeypatch.setattr(module, 'ui_state', NS(started=False))
for name in ('set_texture_filter', 'update_texture', 'draw_texture_pro', 'draw_rectangle', 'draw_circle'):
monkeypatch.setattr(rl, name, Mock())
monkeypatch.setattr(rl, 'load_texture_from_image', Mock(return_value=NS(width=profile.size[0], height=profile.size[1])))
now = 10
monkeypatch.setattr(module.time, 'monotonic', lambda: now)
page.last_touch = 0
page._render_viewer()
assert page.controls # Preparation time does not consume the initial visibility timeout.
if profile == Profile.LARGE:
assert rl.draw_rectangle.call_args_list[0].args[-1].a == 190
else:
backings = [call.args[0] for call in module.draw_rounded_fill.call_args_list]
assert any((rect.x, rect.y, rect.width, rect.height) == (8, 70, 520, 36) for rect in backings)
assert not any(rect.height > 36 for rect in backings)
now = 14
page._button.reset_mock()
page._render_viewer()
assert not page.controls
page._button.assert_called_once_with('Stop', page._collapsed_stop_bounds(), True)
left, top, width, height = page._collapsed_stop_bounds()
assert page._target(left + width / 2, top + height / 2) == 'Stop'
timeline, buttons = page._transport()
x, y, w, h = buttons[1][1]
play_pos = NS(x=x + w / 2, y=y + h / 2)
assert page._target(play_pos.x, play_pos.y) == 'show'
page._handle_mouse_press(play_pos)
page._render_viewer()
page._handle_mouse_release(play_pos)
owner.control.assert_not_called()
owner.seek.assert_not_called()
page._render_viewer()
assert page.controls
now = 16
x, y, _, seek_height = timeline
seek_pos = NS(x=x, y=y + seek_height / 2)
page._handle_mouse_press(seek_pos)
now = 20
page._render_viewer()
assert page.controls # A held seek gesture must not disappear mid-drag.
page._handle_mouse_release(seek_pos)
assert page.last_touch == 20
now = 24
for state in ('paused', 'preparing', 'buffering', 'ended'):
owner.status['state'] = state
page._render_viewer()
assert page.controls
owner.status['state'] = 'playing'
page._render_viewer()
assert not page.controls
page._back = Mock()
pos = NS(x=left + width / 2, y=top + height / 2)
page._handle_mouse_press(pos)
page._render_viewer()
assert not page.controls
page._handle_mouse_release(pos)
page._back.assert_called_once()
def test_gap_seek_snaps_to_nearest_available_footage(playback):
owner, backend, _, _ = playback
owner.status['seekRanges'] = [[0, 20], [80, 120]]
assert owner.seek(35)
pump(owner, lambda: backend.status['position'] == 20)
assert next(call for call in backend.calls if call['op'] == 'control')['position'] == 20
def test_seek_preparation_clears_frame_before_epoch_advances(playback):
owner, backend, _, _ = playback
owner.frame = NS(session_id=owner.own_session, epoch=1, frame_id=1)
backend.status.update(state='seeking', frameReady=False)
owner.next_status = 0
pump(owner, lambda: owner.status['state'] == 'seeking')
assert owner.status['epoch'] == 1
assert owner.frame is None
def test_close_stops_owned_status_that_arrives_after_dismissal():
backend = Backend()
backend.status.update(sessionId='b' * 32, state='playing', owned=True)
entered, release = threading.Event(), threading.Event()
def request(payload):
if payload['op'] == 'status':
entered.set()
release.wait(2)
return backend(payload)
owner = ReplayController(request)
assert owner.submit({'op': 'status', 'clientId': owner.client_id})
assert entered.wait(2)
owner.close()
release.set()
owner.executor.shutdown(wait=True)
assert [call['op'] for call in backend.calls] == ['status', 'stop']
assert backend.status['sessionId'] is None
@pytest.mark.parametrize('profile,size', [(Profile.LARGE, (1600, 800)), (Profile.COMPACT, (536, 240))])
def test_passive_frame_texture_upload_is_cached_and_uses_full_profile(monkeypatch, profile, size):
from openpilot.starpilot.ui import replay_settings as module
page, owner = page_for_input()
page.profile = profile
page._button = Mock()
monkeypatch.setattr(module, 'draw_rounded_fill', Mock())
page.fonts.measure.return_value = NS(width=20, height=20)
owner.available, owner.busy, owner.error = True, False, ''
owner.frame = NS(session_id='b' * 32, epoch=1, frame_id=1, width=size[0], height=size[1],
data=b'\0\xff\x80\x01' * (size[0] * size[1]))
monkeypatch.setattr(module, 'ui_state', NS(started=False))
texture = NS(width=size[0], height=size[1])
monkeypatch.setattr(rl, 'load_texture_from_image', Mock(return_value=texture))
monkeypatch.setattr(rl, 'set_texture_filter', Mock())
def check_upload(actual_texture, pixels):
assert actual_texture is texture
assert rl.ffi.typeof(pixels) == rl.ffi.typeof('void *')
assert bytes(rl.ffi.buffer(pixels, len(owner.frame.data))) == owner.frame.data
upload, draw = Mock(side_effect=check_upload), Mock()
monkeypatch.setattr(rl, 'update_texture', upload)
monkeypatch.setattr(rl, 'draw_texture_pro', draw)
monkeypatch.setattr(rl, 'draw_rectangle', Mock())
monkeypatch.setattr(rl, 'draw_circle', Mock())
monkeypatch.setattr(rl, 'draw_ring', Mock())
page._render_viewer()
page._render_viewer()
rl.draw_ring.assert_not_called()
assert '0:30 / 2:00' in [call.args[0] for call in page.fonts.draw.call_args_list]
assert upload.call_count == 1
rect = draw.call_args.args[2]
assert (rect.x, rect.y, rect.width, rect.height) == (0, 0, *profile.size)
assert page.frame_key == ('b' * 32, 1, 1)
owner.frame = NS(**{**vars(owner.frame), 'epoch': 2, 'data': b'\xff\0\x01\x80' * (size[0] * size[1])})
page._render_viewer()
assert upload.call_count == 2
rl.load_texture_from_image.assert_called_once()
def test_paused_new_epoch_frame_can_be_reread_after_status_catches_up():
backend = Backend()
frame = NS(session_id='b' * 32, epoch=2, frame_id=1, data=b'paused')
readers = []
class FrozenReader:
def __init__(self):
self.used = False
def latest(self):
if self.used:
return None
self.used = True
return frame
def close(self):
pass
def factory(*_):
reader = FrozenReader()
readers.append(reader)
return reader
owner = ReplayController(backend, factory)
try:
pump(owner, lambda: owner.available and owner.pending is None)
owner.select(ROUTE)
pump(owner, lambda: owner.pending is None)
owner.start()
pump(owner, lambda: readers and readers[0].used and owner.pending is None)
assert owner.frame is None # owner is still at epoch 1
backend.status.update(epoch=2, paused=True, state='paused')
owner.next_status = 0
pump(owner, lambda: owner.frame is frame)
assert len(readers) == 2
finally:
owner.close()
owner.executor.shutdown(wait=True)
@pytest.fixture
def route_detail(monkeypatch):
monkeypatch.setattr(rl, 'draw_line', Mock())
page, owner = page_for_input()
page.page = 'detail'
page.fonts.measure.side_effect = lambda text, _role, size: NS(width=len(text) * 8 * size / 18, height=size)
owner.detail = {**ROUTE, 'segmentCount': 3, 'warnings': [f'Warning {number}' for number in range(32)],
'segments': [{'number': number, 'files': {'rlog': number != 3, 'qlog': False, 'fcamera': True, 'qcamera': False}}
for number in (0, 2, 3)]}
owner.selected_route = ROUTE['routeId']
owner.routes, owner.catalog_loaded = [ROUTE], True
owner.available, owner.busy, owner.error = True, False, ''
owner.status['state'] = 'idle'
owner.start = Mock()
return page, owner
@pytest.mark.parametrize('x', [30, 800, 1900, 2130])
def test_large_route_row_opens_from_anywhere_and_back_keeps_catalog_page(route_detail, x):
page, owner = route_detail
page.page, page.scroll = 'routes', 5
owner.routes = [{**ROUTE, 'routeId': f'{number:08d}--0123456789', 'startTime': STAMP - number} for number in range(8)]
owner.select = Mock(return_value=True)
pos = NS(x=x, y=feature_row_top(page._state()) + FEATURE_ROW_HEIGHT / 2)
page._handle_mouse_press(pos)
page._handle_mouse_release(pos)
owner.select.assert_called_once_with(owner.routes[5])
assert page.page == 'detail'
page._back()
assert page.page == 'routes' and page.scroll == 5
def test_route_swipe_pages_without_opening_and_cancels_taps_before_page_settles(route_detail):
page, owner = route_detail
page.page = 'routes'
owner.routes = [{**ROUTE, 'routeId': f'{number:08d}--0123456789'} for number in range(8)]
owner.select = Mock()
page.view._last_state = page._state()
y = feature_row_top(page._state()) + FEATURE_ROW_HEIGHT / 2
page._handle_mouse_press(NS(x=900, y=y))
page._handle_mouse_release(NS(x=650, y=y))
assert page.scroll == 5
owner.select.assert_not_called()
page._handle_mouse_press(NS(x=900, y=y))
page._handle_mouse_release(NS(x=900, y=y))
owner.select.assert_not_called()
def test_large_details_show_route_inventory_without_diagnostic_action(route_detail, monkeypatch):
from openpilot.starpilot.ui import replay_settings as module
page, _ = route_detail
monkeypatch.setattr(module, 'draw_settings_header', Mock())
page._button = Mock()
page._render_detail()
texts = [call.args[0] for call in page.fonts.draw.call_args_list]
assert ROUTE['routeId'] in texts
assert 'Segments' in texts and '3' in texts and '2 available to play' in texts
assert [button[0] for button in page._detail_buttons()] == ['< Back', 'Choose segment', 'Play from beginning']
@pytest.mark.parametrize('profile', [Profile.LARGE, Profile.COMPACT])
@pytest.mark.parametrize('busy,available,playable', [(True, True, True), (False, False, True), (False, True, False)])
def test_detail_playback_actions_disable_together(route_detail, profile, busy, available, playable):
page, owner = route_detail
page.profile = profile
owner.busy, owner.available = busy, available
owner.detail['playable'] = playable
for _, (x, y, width, height), enabled, _ in page._detail_buttons()[1:]:
assert not enabled
assert page._target(x + width / 2, y + height / 2) is None
@pytest.mark.parametrize('profile', [Profile.LARGE, Profile.COMPACT])
def test_details_are_fixed_with_pinned_play_and_do_not_paginate(route_detail, monkeypatch, profile):
from openpilot.starpilot.ui import replay_settings as module
page, owner = route_detail
page.profile = profile
page.set_rect(rl.Rectangle(0, 0, *profile.size))
page._button = Mock()
monkeypatch.setattr(module, 'draw_settings_header', Mock())
page._render_detail()
buttons = page._button.call_args_list
assert len(buttons) == (2 if profile == Profile.LARGE else 4)
label, bounds, enabled = buttons[-1].args
assert label == 'Play from beginning' and enabled
assert bounds == ((744, 900, 1344, 144) if profile == Profile.LARGE else (224, 164, 300, 64))
for _, rect, _, _ in page._detail_buttons():
x, y, w, h = rect
assert 0 <= x < x + w <= profile.size[0] and 0 <= y < y + h <= profile.size[1]
assert not any('Warning 0' in call.args[0] for call in page.fonts.draw.call_args_list)
x, y, w, h = bounds
pos = NS(x=x + w / 2, y=y + h / 2)
page._handle_mouse_press(pos)
page._handle_mouse_release(pos)
owner.start.assert_called_once_with()
owner.start.reset_mock()
owner.available = False
page._handle_mouse_press(pos)
page._handle_mouse_release(pos)
owner.start.assert_not_called()
def test_segment_picker_filters_missing_sources_and_starts_exact_selection(route_detail, monkeypatch):
from openpilot.starpilot.ui import replay_settings as module
from openpilot.system.ui.widgets import DialogResult
page, owner = route_detail
picker = NS(selection='Segment 2', select_button=Mock())
factory = Mock(return_value=picker)
monkeypatch.setattr('openpilot.system.ui.widgets.option_dialog.MultiOptionDialog', factory)
monkeypatch.setattr(module.gui_app, 'push_widget', Mock())
monkeypatch.setattr(module.gui_app, 'get_active_widget', lambda: page)
page._choose_segment()
assert factory.call_args.args[1] == ['Segment 0', 'Segment 2']
callback = factory.call_args.kwargs['callback']
callback(DialogResult.CANCEL)
owner.start.assert_not_called()
callback(DialogResult.CONFIRM)
owner.start.assert_called_once_with(segment=2)
owner.start.reset_mock()
owner.selected_route = 'another-route'
callback(DialogResult.CONFIRM)
owner.start.assert_not_called()
def test_compact_long_message_is_readable_in_full_without_displacing_playback(route_detail, monkeypatch):
from openpilot.starpilot.ui import replay_settings as module
page, owner = route_detail
page.profile = Profile.COMPACT
page.set_rect(rl.Rectangle(0, 0, *page.profile.size))
owner.error = 'A long service error. ' * 30
page._button = Mock()
page._render_detail()
assert page.detail_message.startswith(owner.error.rstrip())
assert 'green Chill' in page.detail_message
status = [call.args for call in page.fonts.draw.call_args_list if call.args[4] in (114, 135)]
assert len(status) == 2
assert status[1][0].endswith('...')
assert page._detail_buttons()[-1][1] == (224, 164, 300, 64)
dialog = NS(_scroller=NS(items=['cover', 'message']))
factory = Mock(return_value=dialog)
monkeypatch.setattr('openpilot.selfdrive.ui.mici.widgets.dialog.SettingDescriptionDialog', factory)
monkeypatch.setattr(module.gui_app, 'font', Mock())
monkeypatch.setattr(module, 'wrap_text', lambda _font, word, *_: [word])
monkeypatch.setattr(module.gui_app, 'push_widget', Mock())
pos = NS(x=472, y=134)
page._handle_mouse_press(pos)
page._handle_mouse_release(pos)
factory.assert_called_once_with('replay', page.detail_message)
assert dialog._scroller.items == ['message']
module.gui_app.push_widget.assert_called_once_with(dialog)
owner.error = 'Short message'
page._render_detail()
assert page.detail_message.startswith('Short message\n\nReplay timeline:')
assert page._target(pos.x, pos.y) == 'More'
def test_compact_full_message_paginates_long_tokens_without_losing_content(route_detail, monkeypatch):
from openpilot.starpilot.ui import replay_settings as module
page, _ = route_detail
page.profile, page.detail_message = Profile.COMPACT, 'W' * 128
font = NS(texture=NS(id=987654321))
monkeypatch.setattr(module.gui_app, 'font', lambda *_: font)
monkeypatch.setattr(module.gui_app, 'texture', Mock(return_value=NS(width=64, height=64)))
monkeypatch.setattr(module.gui_app, 'push_widget', Mock())
monkeypatch.setattr(rl, 'measure_text_ex', lambda _font, text, size, _spacing: rl.Vector2(len(text) * size * .9, size))
page._show_detail_message()
cards = module.gui_app.push_widget.call_args.args[0]._scroller.items
assert len(cards) > 1
assert ''.join(card.get_value().strip() for card in cards) == page.detail_message
for card in cards:
assert card._sub_label.get_content_height(card._subtitle_width_hint()) <= card.rect.height - 2 * card.LABEL_VERTICAL_PADDING
@pytest.mark.parametrize('message', ['No road camera footage. Choose another route.',
'MMMM WWWW iiiii\n' * 20, 'W' * 300])
def test_detail_lines_fit_the_bitmap_font_and_preserve_remaining_content(route_detail, message):
page, _ = route_detail
page.fonts.measure.side_effect = lambda text, _role, _size: NS(width=sum(14 if char == 'W' else 4 for char in text), height=20)
lines, remaining = page._detail_lines(message, 120)
assert 1 <= len(lines) <= 2
assert all(page.fonts.measure(line, None, 18).width <= 120 for line in lines)
assert ''.join(''.join(lines).split()) + ''.join(remaining.split()) == ''.join(message.split())
@pytest.mark.parametrize('state,expected', [('partial', '2 of 3 available to play'),
('loading', 'Loading route details...'),
('unavailable', 'No road camera footage. Choose another route.'),
('unavailable-guidance', 'No road camera footage. Choose another route.'),
('single', '1 segment')])
def test_compact_detail_status_and_counts_do_not_need_diagnostics(route_detail, state, expected):
page, owner = route_detail
page.profile = Profile.COMPACT
page._button = Mock()
if state == 'loading':
owner.busy = True
owner.detail['reason'] = 'No road camera footage'
elif state.startswith('unavailable'):
owner.detail.update(playable=False, reason=expected if state == 'unavailable-guidance' else 'No road camera footage')
elif state == 'single':
owner.detail.update(segmentCount=1, segments=owner.detail['segments'][:1])
page._render_detail()
texts = [call.args[0] for call in page.fonts.draw.call_args_list]
assert expected in texts
assert ROUTE['routeId'] in texts
assert [button[0] for button in page._detail_buttons()] == ['< Back', 'More', 'Choose segment', 'Play from beginning']
assert not any('Warning' in text for text in texts)
@pytest.mark.parametrize('profile', [Profile.LARGE, Profile.COMPACT])
def test_detail_primary_fill_survives_return_from_collapsed_playback(route_detail, monkeypatch, profile):
from openpilot.starpilot.ui import replay_settings as module
page, _ = route_detail
page.profile, page.controls = profile, False
fill = Mock()
monkeypatch.setattr(module, 'draw_rounded_fill', fill)
monkeypatch.setattr(module, 'draw_rounded_stroke', Mock())
page._button(*page._detail_buttons()[-1][:3])
color = fill.call_args.args[1]
expected = rl.color_lerp(module.ACCENT, module.PANEL_BG, .2) if profile == Profile.COMPACT else module.ACCENT
assert (color.r, color.g, color.b, color.a) == (expected.r, expected.g, expected.b, expected.a)
@pytest.mark.parametrize('page_name', ['detail', 'viewer'])
@pytest.mark.parametrize('movement,activates', [(6, True), (12, True), (13, False)])
def test_compact_button_taps_allow_small_drift_but_cancel_drag(route_detail, page_name, movement, activates):
page, owner = route_detail
page.profile, page.page = Profile.COMPACT, page_name
page.set_rect(rl.Rectangle(0, 0, *page.profile.size))
bounds = page._detail_buttons()[-1][1] if page_name == 'detail' else page._transport()[1][1][1]
x, y, width, height = bounds
initial = NS(x=x + width / 2, y=y + height / 2)
final = NS(x=initial.x + movement, y=initial.y)
page._handle_mouse_press(initial)
page._handle_mouse_event(mouse_event=NS(left_down=True, left_released=False, pos=final))
page._handle_mouse_release(final)
if page_name == 'detail':
assert owner.start.call_count == int(activates)
else:
assert owner.control.call_count == int(activates)
if activates:
owner.control.assert_called_once_with('pause', paused=True)
def test_compact_segment_cards_close_chooser_and_start_exact_segment(route_detail, monkeypatch):
from openpilot.starpilot.ui import replay_settings as module
page, owner = route_detail
page.profile = Profile.COMPACT
buttons = []
picker = NS(_scroller=NS(add_widgets=lambda widgets: buttons.extend(widgets)))
active = [page.stack_widget]
monkeypatch.setattr('openpilot.system.ui.widgets.scroller.NavScroller', Mock(return_value=picker))
factory = Mock(side_effect=lambda *_: Mock())
monkeypatch.setattr(module, 'BigButton', factory)
monkeypatch.setattr(module.gui_app, 'push_widget', lambda widget: active.__setitem__(0, widget))
monkeypatch.setattr(module.gui_app, 'pop_widget', lambda: active.__setitem__(0, page.stack_widget))
monkeypatch.setattr(module.gui_app, 'get_active_widget', lambda: active[0])
page._choose_segment()
assert [call.args[0] for call in factory.call_args_list] == ['segment 0', 'segment 2']
assert all(call.args[1] == 'date unavailable\nstart here' for call in factory.call_args_list)
callback = buttons[1].set_click_callback.call_args.args[0]
callback()
assert active[0] is page.stack_widget
owner.start.assert_called_once_with(segment=2)
owner.start.reset_mock()
active[0] = picker
owner.selected_route = 'another-route'
callback()
owner.start.assert_not_called()
@pytest.mark.parametrize('page_name,returns_to_routes,dismisses', [('detail', True, False), ('routes', False, True), ('viewer', False, False)])
def test_compact_swipe_down_goes_back_one_level_and_is_blocked_during_playback(monkeypatch, page_name, returns_to_routes, dismisses):
from openpilot.starpilot.ui import replay_settings as module
monkeypatch.setattr(module.gui_app, 'texture', Mock(return_value=NS(width=96, height=48)))
controller = NS(close=Mock())
page = ReplayCompactPage(NS(fonts=Mock(profile=Profile.COMPACT), profile=Profile.COMPACT), controller)
page.set_rect(rl.Rectangle(0, 0, 536, 240))
page.content.page = page_name
page.content.press = (200, 40, 'held button', 1, True)
for y, pressed, down, released in [(40, True, True, False), (160, False, True, False), (160, False, False, True)]:
page._handle_mouse_event(mouse_event=NS(pos=NS(x=200, y=y), left_pressed=pressed, left_down=down, left_released=released))
assert page._playing_dismiss_animation is dismisses
assert page.content.page == ('routes' if returns_to_routes else page_name)
if returns_to_routes:
assert page.content.press is None
controller.close.assert_not_called()
def test_compact_route_card_shows_segment_count_and_availability(route_detail, monkeypatch):
from openpilot.starpilot.ui import replay_settings as module
page, _ = route_detail
page.page, page.profile = 'routes', Profile.COMPACT
page.cards = NS(_scroller=NS(items=[], add_widgets=Mock()))
factory = Mock()
monkeypatch.setattr(module, 'BigButton', factory)
monkeypatch.setattr(module.gui_app, '_mouse_events', [])
page._compact_cards()
assert factory.call_args.args[1] == 'date unavailable\n2 segments'
factory.return_value._sub_label.set_font_size.assert_called_once_with(30)
def test_compact_underlying_buttons_are_disabled_during_overlays_and_back_gestures(monkeypatch):
from openpilot.starpilot.ui import replay_settings as module
monkeypatch.setattr(module.gui_app, 'texture', Mock(return_value=NS(width=96, height=48)))
page = ReplayCompactPage(NS(fonts=Mock(profile=Profile.COMPACT), profile=Profile.COMPACT), NS(close=Mock()))
assert page.content.enabled and page.content.cards.enabled
page.set_enabled(False)
assert not page.content.enabled and not page.content.cards.enabled
page.set_enabled(True)
page._dragging_down = True
assert not page.content.enabled and not page.content.cards.enabled
page._dragging_down = False
assert page.content.enabled and page.content.cards.enabled
@pytest.mark.parametrize('profile', [Profile.LARGE, Profile.COMPACT])
@pytest.mark.parametrize('state,error', [('preparing', ''), ('seeking', ''), ('buffering', ''),
('playing', ''), ('failed', 'Replay failed')])
def test_loading_is_centered_and_warnings_and_unknown_timing_stay_out_of_viewer(monkeypatch, profile, state, error):
from openpilot.starpilot.ui import replay_settings as module
page, owner = page_for_input()
page.profile = profile
page._button = Mock()
monkeypatch.setattr(module, 'draw_rounded_fill', Mock())
page.fonts.measure.return_value = NS(width=200, height=32)
owner.available, owner.busy, owner.error, owner.frame = True, False, error, None
owner.status.update(state=state, end=None, sourceWarnings=['This route has missing segments'])
monkeypatch.setattr(module, 'ui_state', NS(started=False))
monkeypatch.setattr(rl, 'draw_rectangle', Mock())
monkeypatch.setattr(rl, 'draw_circle', Mock())
ring = Mock()
monkeypatch.setattr(rl, 'draw_ring', ring)
monkeypatch.setattr(module.time, 'monotonic', lambda: 1)
page._render_viewer()
texts = [call.args[0] for call in page.fonts.draw.call_args_list]
assert 'Timing unavailable' not in texts
assert not any('missing segments' in text for text in texts)
if error:
ring.assert_not_called()
assert error in texts
else:
assert ('Replay preparing' if state == 'preparing' else 'Replay loading') in texts
center, _, radius, angle, *_ = ring.call_args.args
assert center.x == profile.size[0] / 2
text = page.fonts.draw.call_args.args
assert text[3] + 100 == center.x
assert text[4] > center.y + radius
monkeypatch.setattr(module.time, 'monotonic', lambda: 1.1)
page._render_viewer()
assert ring.call_args.args[3] != angle