mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
379 lines
19 KiB
Python
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()
|