Files
StarPilot/openpilot/starpilot/ui/tests/test_unified_speed_native.py
T
2026-10-27 04:54:30 -04:00

379 lines
19 KiB
Python

"""One set speed and a shared, latest-limit sign in the large and compact UIs."""
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pyray as rl
import pytest
from openpilot.starpilot.ui.onroad_compact_widgets import CompactHudRenderer
from openpilot.starpilot.ui.onroad_large_widgets import UnifiedSpeedWidget
from openpilot.starpilot.ui.onroad import OnroadView
from openpilot.starpilot.ui.onroad_state import ObservationKind, OnroadState, SpeedLimitObservation, slc_controls, speed_limit_bounds
from openpilot.starpilot.ui.presentation import Profile
from openpilot.starpilot.ui.speed_limit_widget import INK, MUTED, SpeedLimitWidget, draw_text
from openpilot.starpilot.ui.speed_limit_shapes import SpeedLimitShapes
from openpilot.starpilot.ui.unified_speed_presentation import displayed_limit_mps, resolve_unified_speed
@pytest.fixture(autouse=True)
def drawing(monkeypatch):
calls = {}
for name in ('draw_rectangle_rounded_lines_ex', 'draw_rectangle_rounded', 'draw_line_ex', 'draw_circle',
'draw_ring', 'draw_circle_gradient', 'draw_rectangle_rec', 'draw_circle_v', 'draw_rectangle_gradient_ex'):
calls[name] = Mock()
monkeypatch.setattr(rl, name, calls[name])
for name in ('circle', 'arrow', 'outline', 'confirmation', 'wheel_key'):
calls[name] = Mock()
monkeypatch.setattr(SpeedLimitShapes, name, calls[name])
monkeypatch.setattr('openpilot.starpilot.ui.onroad_large_widgets.draw_control_card', Mock())
return calls
def state(**changes):
observation = SpeedLimitObservation(ObservationKind.VALID, 'map', 50 / 3.6,
accepted_speed_limit_mps=40 / 3.6, accepted_adjusted_limit_mps=45 / 3.6,
pending_speed_limit_mps=50 / 3.6, pending_adjusted_limit_mps=55 / 3.6,
presentation_adjusted_limit_mps=55 / 3.6, accepted_source='dashboard', action_enabled=True,
session_id='drive', presentation_id=2, decision_id=1, pending_since_ns=10_000_000_000)
return replace(OnroadState(True, False, 45 / 3.6, 100., observation, metric=True, longitudinal_active=True,
observed_ns=10_000_000_000), **changes)
def fonts(profile=Profile.LARGE):
return SimpleNamespace(profile=profile, draw=Mock(), vertical_ink=lambda text, role, size: (0, size * .75),
horizontal_ink=lambda text, role, size: (0, len(text) * size * .5),
measure=lambda text, role, size: SimpleNamespace(width=len(text) * size * .5, height=size))
def slc_view(font):
view = OnroadView.__new__(OnroadView)
view.fonts = font
if font.profile == Profile.LARGE:
view.unified_speed = UnifiedSpeedWidget(font)
else:
view.compact_hud = CompactHudRenderer(font, Mock())
return view
def render_sign(sign, current):
sign.render(current)
def foreground_calls(draw):
return [call for call in draw.call_args_list if (call.args[-1].r, call.args[-1].g, call.args[-1].b) not in ((6, 14, 10), (7, 16, 12))]
def rgba_tuple(color):
return (color.r, color.g, color.b, color.a)
def test_latest_limit_and_offset_replace_accepted_sign_while_waiting():
shown = resolve_unified_speed(state())
assert (shown.max_text, shown.posted_text, shown.offset_text, shown.source) == ('100', '50', '+5', 'map')
assert displayed_limit_mps(state().speed_limit) == 50 / 3.6
@pytest.mark.parametrize('profile', list(Profile))
def test_both_renderers_show_small_latest_sign_and_offset(profile):
font = fonts(profile)
current = state()
if profile == Profile.LARGE:
widget = UnifiedSpeedWidget(font)
widget.render(rl.Rectangle(30, 30, 1800, 1020), current)
widget.sign.render_confirmation(current)
texts = [call.args[0] for call in foreground_calls(font.draw)]
assert texts.count('100') == 1
assert 'SET' in texts
assert 'SLC LIMIT' not in texts
else:
widget = CompactHudRenderer(font, Mock())
widget._speed_limit_sign(current)
widget._speed_limit_widget.render_confirmation(current)
texts = [call.args[0] for call in foreground_calls(font.draw)]
assert '50' in texts and '+5' in texts and '40' not in texts
@pytest.mark.parametrize('profile', list(Profile))
@pytest.mark.parametrize('pending', [False, True])
@pytest.mark.parametrize('vienna', [False, True])
@pytest.mark.parametrize('offset', [False, True])
@pytest.mark.parametrize('source, heading', [
('dashboard', 'DASH'), ('map', 'OSM'), ('vision', 'VISION'), ('mapbox', 'NAV'),
('online', 'SPEED'), ('unknown', 'SPEED'), ('none', 'SPEED'),
])
def test_sign_heading_identifies_the_displayed_source(profile, pending, vienna, offset, source, heading):
current = state(show_slc_offset=offset)
current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna))
current = replace(current, speed_limit=replace(current.speed_limit, source=source,
accepted_source='vision' if source == 'dashboard' else 'dashboard',
pending_speed_limit_mps=current.speed_limit.pending_speed_limit_mps if pending else None))
font = fonts(profile)
render_sign(SpeedLimitWidget(font, profile), current)
expected = [heading, '50']
assert [call.args[0] for call in font.draw.call_args_list] == expected + (['+5'] if offset else []) + (['New Limit'] if pending else [])
if vienna and not pending:
assert rgba_tuple(font.draw.call_args_list[0].args[-1]) == (0, 0, 0, 255)
@pytest.mark.parametrize('metric', [False, True])
@pytest.mark.parametrize('vienna', [False, True])
@pytest.mark.parametrize('offset', [None, 0., .01, -.01])
def test_compact_sign_omits_missing_zero_and_rounded_zero_offsets(metric, vienna, offset):
current = state(metric=metric)
factor = 3.6 if metric else 2.2369362921
current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna),
speed_limit=SpeedLimitObservation(ObservationKind.VALID, 'map', 65 / factor, offset_mps=offset,
action_enabled=True))
assert resolve_unified_speed(current).offset_text is None
font = fonts(Profile.COMPACT)
CompactHudRenderer(font, Mock())._speed_limit_sign(current)
assert [call.args[0] for call in font.draw.call_args_list] == ['OSM', '65']
@pytest.mark.parametrize('profile', list(Profile))
@pytest.mark.parametrize('vienna', [False, True])
def test_pending_notice_pulses_without_changing_ink_and_arrow_follows_speed(profile, vienna, drawing):
font = fonts(profile)
sign = SpeedLimitWidget(font, profile)
current = state()
current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna))
render_sign(sign, current)
before = {call.args[0]: call.args for call in foreground_calls(font.draw)}
strength = drawing['confirmation'].call_args.kwargs['strength']
assert drawing['arrow'].call_args.args[0] == 'confirmation'
assert drawing['arrow'].call_args.args[3] == -1
render_sign(sign, replace(current, observed_ns=10_350_000_000, speed_mps=60 / 3.6))
after = {call.args[0]: call.args for call in foreground_calls(font.draw)}
assert set(before) == {'OSM', '50', '+5', 'New Limit'}
assert all(after[text][3:5] == args[3:5] and rgba_tuple(after[text][-1]) == rgba_tuple(args[-1]) for text, args in before.items())
assert drawing['confirmation'].call_args.kwargs['strength'] != strength
drawing['circle'].assert_not_called()
drawing['outline'].assert_not_called()
assert drawing['arrow'].call_args.args[3] == 1
@pytest.mark.parametrize('profile', list(Profile))
@pytest.mark.parametrize('vienna', [False, True])
def test_regular_sign_keeps_the_outlined_arrow(profile, vienna, drawing):
current = state()
current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna),
speed_limit=replace(current.speed_limit, decision_id=0))
SpeedLimitWidget(fonts(profile), profile).render(current)
assert drawing['arrow'].call_args.args[0] == 'compact'
@pytest.mark.parametrize('profile', list(Profile))
@pytest.mark.parametrize('available', [False, True])
def test_wheel_cues_follow_confirmation_availability(profile, available, drawing):
from openpilot.starpilot.ui.large_speed_geometry import CONFIRMATION_SCALE
current = state(slc_wheel_confirmation_available=available)
sign = SpeedLimitWidget(fonts(profile), profile)
sign.render_confirmation(current)
calls = drawing['wheel_key'].call_args_list
assert [call.args[0] for call in calls] == ([True, False] if available else [])
if available:
bounds = speed_limit_bounds(profile, current)
assert bounds is not None
assert all(call.args[2] == pytest.approx(bounds[1] + 135.5 * CONFIRMATION_SCALE[profile]) for call in calls)
assert bounds[0] < calls[0].args[1] < calls[1].args[1] < bounds[2]
@pytest.mark.parametrize('profile', list(Profile))
@pytest.mark.parametrize('vienna', [False, True])
@pytest.mark.parametrize('reason', ['steady', 'automatic', 'equal_speed', 'unknown_speed'])
def test_sign_text_stays_centered_without_an_arrow(profile, vienna, reason, drawing):
current = state()
current = replace(current, appearance=replace(current.appearance, use_vienna_sign=vienna))
if reason == 'steady':
current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
elif reason == 'automatic':
current = replace(current, speed_limit=replace(current.speed_limit, automatic_adoption_pending=True))
else:
current = replace(current, speed_mps=55 / 3.6 if reason == 'equal_speed' else None)
font = fonts(profile)
render_sign(SpeedLimitWidget(font, profile), current)
drawing['arrow'].assert_not_called()
for call in font.draw.call_args_list:
text, role, size, x, _, _ = call.args
if reason in ('equal_speed', 'unknown_speed'):
continue
left, right = font.horizontal_ink(text, role, size)
from openpilot.starpilot.ui.large_speed_geometry import LIMIT_RECTS, LARGE_SIGN_SCALE
center = LIMIT_RECTS[profile][0] + 59 * (1 if profile == Profile.COMPACT else LARGE_SIGN_SCALE)
assert abs(x + (left + right) / 2 - center) <= .5
@pytest.mark.parametrize('profile', list(Profile))
def test_only_set_speed_override_draws_cross(profile, drawing):
sign = SpeedLimitWidget(fonts(profile), profile)
current = state()
current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
sign.render(replace(current, longitudinal_overridden=True))
assert drawing['draw_line_ex'].call_count == 0
sign.render(replace(current, speed_limit=replace(current.speed_limit, driver_override_active=True)))
assert len(foreground_calls(drawing['draw_line_ex'])) == 2
@pytest.mark.parametrize('profile', list(Profile))
@pytest.mark.parametrize('vienna', [False, True])
@pytest.mark.parametrize('latest_received', [False, True])
@pytest.mark.parametrize('offset', [False, True])
def test_retained_limits_are_marked_last_and_have_no_actions(profile, vienna, latest_received, offset, drawing):
current = state(show_slc_offset=offset)
observation = replace(current.speed_limit, kind=ObservationKind.STALE, speed_limit_mps=None,
pending_speed_limit_mps=None, last_received_speed_limit_mps=50/3.6 if latest_received else None,
last_received_adjusted_limit_mps=55/3.6 if latest_received else None, effective_cluster_target_mps=45/3.6)
current = replace(current, speed_limit=observation, appearance=replace(current.appearance, use_vienna_sign=vienna))
font = fonts(profile)
render_sign(SpeedLimitWidget(font, profile), current)
texts = [call.args[0] for call in font.draw.call_args_list]
assert texts.count('LAST') == 1
assert ('50' if latest_received else '40') in texts
assert not {'SPEED', 'OSM', 'DASH', 'VISION', 'NAV'}.intersection(texts)
drawing['arrow'].assert_not_called()
assert slc_controls(profile, current) == ()
def test_compact_set_speed_fades_after_adjustment_and_reappears_on_change():
renderer = CompactHudRenderer(fonts(), Mock())
current = state(drive_frame=1)
current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
for now in range(10_000_000_000, 20_000_000_000, 100_000_000):
alpha = renderer._set_speed_opacity(current, now)
assert alpha < .01
changed = replace(current, cruise_kph=current.cruise_kph + 10)
assert renderer._set_speed_opacity(changed, 20_000_000_000) < .01
assert renderer._set_speed_opacity(changed, 20_020_000_000) > .01
assert renderer._set_speed_opacity(replace(current, longitudinal_active=False), 20_000_000_000) == 0
def test_compact_set_speed_stays_visible_during_confirmation_then_fades():
renderer = CompactHudRenderer(fonts(Profile.COMPACT), Mock())
current = state(drive_frame=1)
for now in range(10_000_000_000, 20_000_000_000, 100_000_000):
alpha = renderer._set_speed_opacity(current, now)
assert alpha > .99
resolved = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
for now in range(20_000_000_000, 21_000_000_000, 16_666_667):
alpha = renderer._set_speed_opacity(resolved, now)
assert alpha < .01
def test_compact_max_restores_dinish_number_label_and_shadow(drawing):
from openpilot.starpilot.ui.presentation import FontRole, font_filename
font = fonts(Profile.COMPACT)
hud = CompactHudRenderer(font, Mock())
current = state(drive_frame=1, cruise_kph=80)
current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
hud.render_max_speed(current)
hud.render_max_speed(replace(current, observed_ns=current.observed_ns + 20_000_000))
assert [(call.args[0], call.args[1]) for call in font.draw.call_args_list] == [
('80', FontRole.SET_SPEED), ('MAX', FontRole.SEMI_BOLD)]
assert font_filename(Profile.COMPACT, FontRole.SET_SPEED) == 'DINish-Speed.fnt'
assert font.draw.call_args_list[0].args[3:5] == (17, -4)
assert font.draw.call_args_list[1].args[2:5] == (36, 25, 109)
drawing['draw_circle_gradient'].assert_called_once()
def test_large_limit_keeps_a_unit_when_set_speed_is_hidden():
profile = Profile.LARGE
current = state()
current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
current = replace(current, speed_limit=replace(current.speed_limit, pending_speed_limit_mps=None))
current.customization['showMaxSpeed'] = False
current.customization['layouts']['compact']['max_speed']['enabled'] = False
font = fonts(profile)
UnifiedSpeedWidget(font).render(rl.Rectangle(30, 30, 1800, 1020), current)
assert [call.args[0] for call in foreground_calls(font.draw)].count('km/h') == 1
@pytest.mark.parametrize('profile', list(Profile))
def test_chooser_has_separate_tonal_actions_and_immediate_pressed_feedback(profile, drawing):
view = OnroadView.__new__(OnroadView)
view.fonts = fonts(profile)
if profile == Profile.LARGE:
view.unified_speed = UnifiedSpeedWidget(view.fonts)
else:
view.compact_hud = CompactHudRenderer(view.fonts, Mock())
current = state(slc_prompt_open=True)
view._slc_actions(current)
assert [call.args[0] for call in foreground_calls(view.fonts.draw)] == ['55', 'km/h', 'OSM 50 +5', 'Accept', 'Reject']
backgrounds = [rgba_tuple(call.args[-1]) for call in drawing['draw_rectangle_rounded'].call_args_list]
assert backgrounds[1] == (35, 74, 57, 255)
assert backgrounds[3] == (77, 46, 57, 255)
bounds = speed_limit_bounds(profile, current)
drawing['confirmation'].assert_called_once_with(bounds, opened=True)
assert bounds is not None
x, y, right, bottom = bounds
panel = rl.Rectangle(x, y, right - x, bottom - y)
accept, _, reject, _ = [call.args[0] for call in drawing['draw_rectangle_rounded'].call_args_list]
margin = accept.x - panel.x
assert margin > 0
assert reject.x - accept.x - accept.width == pytest.approx(margin, abs=.001)
assert panel.x + panel.width - reject.x - reject.width == pytest.approx(margin, abs=.001)
assert panel.y + panel.height - accept.y - accept.height == pytest.approx(margin, abs=.001)
assert accept.y == reject.y and accept.height == reject.height
drawing['draw_line_ex'].assert_not_called()
view.pressed_action = lambda: slc_controls(profile, current)[0].request
drawing['draw_rectangle_rounded'].reset_mock()
view._slc_actions(current)
pressed = [rgba_tuple(call.args[-1]) for call in drawing['draw_rectangle_rounded'].call_args_list]
assert pressed[1] != backgrounds[1] and pressed[3] == backgrounds[3]
@pytest.mark.parametrize('profile', list(Profile))
@pytest.mark.parametrize('alpha', [96, 255])
def test_text_contour_is_bounded_and_preserves_foreground_opacity(profile, alpha):
from openpilot.starpilot.ui.presentation import FontRole
font = fonts(profile)
color = rl.Color(188, 132, 255, alpha)
draw_text(font, '65 +5', FontRole.MEDIUM, 36, 100, 50, color)
calls = [call.args for call in font.draw.call_args_list]
assert len(calls) == 6
assert all(call[:3] == ('65 +5', FontRole.MEDIUM, 36) for call in calls)
assert calls[-1][3:5] == (100, 50) and calls[-1][-1] is color
radius = 1.15 if profile == Profile.LARGE else 1.
assert {(round(call[3]-100, 2), round(call[4]-50, 2)) for call in calls[:4]} == {
(-radius, 0), (radius, 0), (0, -radius), (0, radius)}
assert all(call[-1].a == round(alpha*.90) for call in calls[:4])
assert calls[-2][3:5] == (100, 50+(1.5 if profile == Profile.LARGE else 1.2))
assert calls[-2][-1].a == round(alpha*.32)
def test_full_coverage_contour_protects_base_muted_and_peak_purple_on_white():
from openpilot.starpilot.ui.presentation import FontRole
def luminance(channels):
linear = [((x/255+.055)/1.055)**2.4 if x/255 > .04045 else x/255/12.92 for x in channels]
return sum(x*weight for x, weight in zip(linear, (.2126, .7152, .0722), strict=True))
for color in (INK, MUTED, rl.Color(188, 132, 255, 255), rl.Color(170, 115, 235, 255)):
font = fonts()
draw_text(font, '65', FontRole.BOLD, 70, 100, 50, color)
contour = font.draw.call_args_list[0].args[-1]
background = [value*contour.a/255+255-contour.a for value in (contour.r, contour.g, contour.b)]
assert (luminance((color.r, color.g, color.b))+.05)/(luminance(background)+.05) >= 4.5
def test_contour_accepts_raylib_standard_color_tuples():
from openpilot.starpilot.ui.presentation import FontRole
font = fonts()
draw_text(font, 'Dash', FontRole.NORMAL, 22, 100, 50, rl.WHITE)
color = font.draw.call_args.args[-1]
assert (color.r, color.g, color.b, color.a) == (255, 255, 255, 255)
def test_retained_compact_fade_still_validates_before_decoding(tmp_path, monkeypatch):
from openpilot.starpilot.ui.onroad import _load_fade
directory = Path(__file__).parents[3] / 'selfdrive/assets'
path = directory / 'icons_mici/onroad/onroad_fade.png'
decoder = Mock(return_value=SimpleNamespace(id=1))
monkeypatch.setattr(rl, 'load_texture', decoder)
assert _load_fade(path) is decoder.return_value
decoder.reset_mock()
invalid = tmp_path / path.name
invalid.write_bytes(b'invalid PNG')
with pytest.raises(ValueError, match='Unreviewed'):
_load_fade(invalid)
decoder.assert_not_called()