mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-07 21:13:41 +08:00
178 lines
9.4 KiB
Python
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
|