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

178 lines
9.4 KiB
Python

"""Layer documents must survive persistence and remain independent per output."""
import copy
import json
from typing import Any
import pytest
from openpilot.starpilot.ui.onroad_customization import (
CLOCK_WIDGET, DEFAULT_WIDGET_ORDER, MODE_WIDGET, PROFILES, customization_metadata, decode_document, default_document, validate_document,
)
from openpilot.starpilot.system.starport.projection_layout import (
CAR_EXIT, default_layout_for_viewport, decode_layout_for_viewport, layout_metadata_for_viewport, projection_customization,
validate_layout_for_viewport,
)
@pytest.mark.parametrize('profile', ['large', 'compact'])
def test_native_order_roundtrip(profile):
document = default_document()
before = copy.deepcopy(document)
order = list(reversed(DEFAULT_WIDGET_ORDER[profile]))
document['widgetOrder'] = {profile: order}
assert decode_document(json.dumps(document).encode()) == document
assert document['layouts'] == before['layouts']
assert customization_metadata()['profiles'][profile]['widgetOrder'] == list(DEFAULT_WIDGET_ORDER[profile])
assert set(order) == set(PROFILES[profile]['widgets'])
@pytest.mark.parametrize('order', [None, {}, [], ['bogus'], ['current_speed'] * 8, [False] * 8])
def test_bad_orders_rejected_by_both_outputs(order):
native = default_document()
native['widgetOrder'] = {'large': order}
with pytest.raises(ValueError):
validate_document(native)
projection = default_layout_for_viewport((2880, 1080))
projection['widgetOrder'] = order
with pytest.raises(ValueError):
validate_layout_for_viewport(projection, (2880, 1080))
def test_projection_order_is_independent_from_native_and_survives_decode():
native = default_document()
native['widgetOrder'] = {profile: list(DEFAULT_WIDGET_ORDER[profile]) for profile in PROFILES}
before = copy.deepcopy(native)
projection = default_layout_for_viewport((2880, 1080))
# Legacy AA layouts must not inherit native large ordering.
assert 'large' not in projection_customization(projection, native)['widgetOrder']
default_order = layout_metadata_for_viewport((2880, 1080))['widgetOrder']
projection['widgetOrder'] = [*reversed([key for key in default_order if key != CAR_EXIT]), CAR_EXIT]
decoded = decode_layout_for_viewport(json.dumps(projection).encode(), (2880, 1080))
assert decoded == projection
converted = projection_customization(decoded, native)
assert converted['widgetOrder']['large'] == projection['widgetOrder']
assert converted['widgetOrder']['compact'] == native['widgetOrder']['compact']
assert native == before
def test_legacy_projection_order_adds_upstream_mode_widget():
projection = default_layout_for_viewport((2880, 1080))
projection['widgets'].pop(MODE_WIDGET)
projection['widgetOrder'] = [key for key in DEFAULT_WIDGET_ORDER['large'] if key not in (MODE_WIDGET, 'jetlink_status')]
migrated = validate_layout_for_viewport(projection, (2880, 1080))
assert migrated['widgets'][MODE_WIDGET]['enabled'] is False
assert migrated['widgetOrder'] == [*projection['widgetOrder'], MODE_WIDGET, 'nav_home', 'nav_work',
'bookmark', 'now_playing', 'jetlink_status', CAR_EXIT]
def test_old_documents_keep_their_original_shape():
assert validate_document(default_document()) == default_document()
assert validate_layout_for_viewport(default_layout_for_viewport((1860, 1080)), (1860, 1080)) == default_layout_for_viewport((1860, 1080))
def test_projection_escape_replaces_native_corner_hint():
# Inspect the real render guard without importing native UI/IPC libraries on CPU-only hosts.
import ast
from pathlib import Path
from types import SimpleNamespace as NS
source = Path(__file__).parents[1] / 'onroad.py'
tree = ast.parse(source.read_text())
method = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == '_large')
parents = {child: parent for parent in ast.walk(method) for child in ast.iter_child_nodes(parent)}
call = next(node for node in ast.walk(method) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == 'render_corner_hint')
guard = parents[call]
while not isinstance(guard, ast.If):
guard = parents[guard]
condition = compile(ast.Expression(guard.test), str(source), 'eval')
def visible(viewport, alert_size):
return eval(condition, {'AlertSize': NS(FULL='full')},
{'self': NS(projection_viewport=viewport), 'state': NS(alert=NS(size=alert_size))})
assert visible(None, 'none')
assert not visible((2880, 1080), 'none')
assert not visible(None, 'full')
@pytest.mark.parametrize('profile,viewport', [('large', None), ('compact', None), ('large', (2880, 1080))])
def test_renderer_dispatches_each_widget_in_saved_order(profile, viewport):
# Load the real composition method without native IPC/Params imports, which
# are unavailable on CPU-only hosts. Widget drawing is replaced with a trace.
import ast
from pathlib import Path
from types import SimpleNamespace as NS
from unittest.mock import Mock
from openpilot.starpilot.ui.onroad_customization import MODE_WIDGET, STOPPED_TIMER_WIDGET, placement, widget_order, RAIL_WIDGETS
source = Path(__file__).parents[1] / 'onroad.py'
tree = ast.parse(source.read_text())
method = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == '_ordered_widgets')
namespace: dict[str, Any] = {'CLOCK_WIDGET': CLOCK_WIDGET, 'MODE_WIDGET': MODE_WIDGET, 'placement': placement, 'widget_order': widget_order,
'RAIL_WIDGETS': RAIL_WIDGETS, 'STOPPED_TIMER_WIDGET': STOPPED_TIMER_WIDGET,
'manual_slc_confirmation': lambda profile, state: False, 'AlertSize': NS(NONE='none', FULL='full'), 'CameraViewChoice': NS(NONE='none'),
'rl': NS(Rectangle=lambda x, y, width, height: NS(x=x, y=y, width=width, height=height))}
exec(compile(ast.Module(body=[method], type_ignores=[]), str(source), 'exec'), namespace)
seen = []
view = NS(fonts=NS(profile=profile), projection_viewport=viewport)
view.pip_layer = Mock(side_effect=lambda rect, state, submit: [submit(key, lambda key=key: seen.append(key))
for key in ('pip_left', 'pip_right')])
view.driver_monitor_layer = lambda *args: seen.append('driver_monitor')
view._driving_mode = lambda *args: seen.append(MODE_WIDGET)
view._clock = lambda *args: seen.append(CLOCK_WIDGET)
view._stopped_timer = lambda *args: seen.append(STOPPED_TIMER_WIDGET)
view._slc_actions = lambda *args: seen.append('speed_limit_actions')
for attr, key in [('unified_speed', 'cruise_limits'), ('current_speed', 'current_speed'), ('cem_gauge', 'cem_gauge'),
('steering_wheel', 'steering_wheel'), ('torque_bar', 'torque_bar')]:
setattr(view, attr, NS(render=lambda *args, key=key: seen.append(key)))
view.compact_hud = NS(_speed_limit_sign=lambda state: seen.append('speed_limit'),
render_max_speed=lambda state: seen.append('max_speed'),
render_steering_wheel=lambda state: seen.append('steering_wheel'))
view.compact_sidebar = NS(render=lambda *args, widget: seen.append(widget))
document = default_document()
order = list(reversed(DEFAULT_WIDGET_ORDER[profile]))
if viewport:
projection = default_layout_for_viewport(viewport)
default_order = layout_metadata_for_viewport(viewport)['widgetOrder']
order = [*reversed([key for key in default_order if key != CAR_EXIT]), CAR_EXIT]
projection['widgetOrder'] = order
document = projection_customization(projection, document)
view.projection_exit_layer = lambda state: seen.append(CAR_EXIT)
if profile == 'large':
view.map_layer = lambda *args: seen.append('nav_map')
view.navigation = NS(render=lambda *args: seen.append('nav_card'))
view.navigation_favorites = NS(render=lambda key, state: seen.append(key))
view.projection_exit_layer = lambda state: seen.append(CAR_EXIT)
view.now_playing = NS(render=lambda state: seen.append('now_playing'))
view.jetlink_status = NS(render=lambda state: seen.append('jetlink_status'))
document['widgetOrder'] = {profile: order}
state = NS(customization=document, camera_available=True, viewport_width=1860, alert=NS(size='none'), stopped_duration_s=65,
appearance=NS(camera_view='road'))
namespace['_ordered_widgets'](view, NS(x=30, y=30, width=1800, height=1020), state)
assert seen == order
view.pip_layer.assert_called_once() # Acquire the camera frame once, then draw each side at its layer.
for selected in order:
seen.clear()
namespace['_ordered_widgets'](view, NS(x=30, y=30, width=1800, height=1020), state, only_widget=selected)
assert seen == [selected]
seen.clear()
namespace['_ordered_widgets'](view, NS(x=30, y=30, width=1800, height=1020), state, only_widget='not_a_widget')
assert seen == []
seen.clear()
state.alert.size = 'full'
namespace['_ordered_widgets'](view, NS(x=30, y=30, width=1800, height=1020), state)
assert 'steering_wheel' not in seen
assert 'speed_limit_actions' not in seen
assert 'cem_gauge' not in seen
assert 'torque_bar' not in seen
assert STOPPED_TIMER_WIDGET not in seen
if profile == 'compact':
seen.clear()
state.alert.size = 'none'
view.compact_sidebar.render_personality = lambda *args: seen.append('following_distance')
stock = Mock()
namespace['_ordered_widgets'](view, NS(x=0, y=0, width=476, height=240), state, stock_layer=stock)
stock.assert_called_once()
assert seen.count('following_distance') == 1