mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
a82935f326
Integrate onroad views, configurable widgets, local settings, steering-profile contracts, Bluetooth and Android Auto with their device and runtime services. Co-authored-by: Adeeb Shihadeh <adeebshihadeh@gmail.com> Co-authored-by: Daniel Koepping <elkoled@gmail.com> Co-authored-by: Danny <dannyirwin31@yahoo.com> Co-authored-by: DimitriChrysafis <Dimitri.chrysafis@gmail.com> Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com> Co-authored-by: Harald Schäfer <harald.the.engineer@gmail.com> Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu> Co-authored-by: Shane Smiskol <shane@smiskol.com> Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com> Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com> Co-authored-by: stef <19478336+stefpi@users.noreply.github.com> Co-authored-by: whoisdomi <dcantu83@gmail.com> Co-authored-by: Intelli <contact@intelli.software> Co-authored-by: pharmacomaniac <blittle65@gmail.com> Co-authored-by: prabhaavp <prabhaav@msu.edu>
364 lines
20 KiB
Python
364 lines
20 KiB
Python
"""Offline shell requests and supplied-observation semantics."""
|
|
|
|
import unittest
|
|
from dataclasses import replace
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock, patch
|
|
from pathlib import Path
|
|
|
|
from openpilot.starpilot.ui.onroad_state import ObservationKind, speed_limit_from_message
|
|
from openpilot.starpilot.ui.onroad_state import OnroadState, SpeedLimitObservation
|
|
from openpilot.starpilot.ui.device_state import DeviceRequest
|
|
from openpilot.starpilot.ui.presentation import BitmapFonts, Profile
|
|
from openpilot.starpilot.ui.preview_home import reference_state
|
|
from openpilot.starpilot.ui.settings_state import Destination, SettingsState
|
|
from openpilot.starpilot.ui.shell import ShellInput, ShellMode, ShellSnapshot, ShellView
|
|
from openpilot.starpilot.ui.toggles_state import Personality, ToggleKey, TogglesInput, TogglesState
|
|
|
|
|
|
def make_feature_view(profile: Profile):
|
|
fonts = BitmapFonts.__new__(BitmapFonts)
|
|
fonts.profile = profile
|
|
from openpilot.starpilot.ui.feature_settings import FeatureSettingsView
|
|
return FeatureSettingsView(fonts)
|
|
|
|
|
|
class ShellActionTests(unittest.TestCase):
|
|
def test_sidebar_swipes_page_each_feature_pane_and_preserve_navigation_taps(self):
|
|
from openpilot.starpilot.ui.feature_settings_state import FeatureRow, FeatureSettingsState
|
|
from openpilot.starpilot.ui.settings_state import SettingsActionKind
|
|
|
|
rows = (FeatureRow("action", "Action", "", actions=(("OPEN", True),), available=True),) * 7
|
|
base = ShellSnapshot(ShellMode.SETTINGS, reference_state(), SettingsState(),
|
|
OnroadState(False, False, None, None, SpeedLimitObservation()))
|
|
for destination, pane in ((Destination.DRIVING_CONTROLS, "features"), (Destination.SOUNDS, "sounds"),
|
|
(Destination.APPEARANCE, "appearance"), (Destination.SYSTEM, "display"),
|
|
(Destination.DRIVING_MODEL, "models")):
|
|
with self.subTest(destination=destination):
|
|
snapshot = replace(base, selected=destination, **{pane: FeatureSettingsState(rows=rows, scroll=5)})
|
|
requests = []
|
|
touch = ShellInput(Profile.LARGE, requests.append)
|
|
touch.press(200, 450, 1, snapshot)
|
|
touch.move(900, 450, 1.05, snapshot)
|
|
touch.release(900, 450, 1.1, snapshot)
|
|
touch.release(900, 450, 1.1, snapshot)
|
|
self.assertEqual([(r.source, r.action.kind, r.action.direction) for r in requests], [(pane, "scroll", -1)])
|
|
for end in ((220, 470), (350, 450)):
|
|
requests.clear()
|
|
touch.press(200, 450, 2, snapshot)
|
|
touch.move(*end, 2.05, snapshot)
|
|
self.assertEqual(getattr(touch, pane).drag_x, 0)
|
|
touch.release(*end, 2.1, snapshot)
|
|
self.assertEqual(len(requests), 1)
|
|
self.assertEqual(requests[0].action.kind, SettingsActionKind.REQUEST_DESTINATION)
|
|
self.assertEqual(requests[0].action.destination.destination, Destination.DEVICE)
|
|
requests.clear()
|
|
touch.press(200, 450, 3, snapshot)
|
|
touch.move(900, 450, 3.05, replace(snapshot, selected=Destination.STAR))
|
|
touch.release(900, 450, 3.1, snapshot)
|
|
self.assertFalse(requests)
|
|
|
|
def test_runtime_transition_interruptions_preserve_row_bindings_for_every_feature_pane(self):
|
|
from openpilot.starpilot.ui import runtime_app
|
|
from openpilot.starpilot.ui.feature_settings_state import FeatureRow, FeatureSettingsState
|
|
|
|
session = runtime_app.StarShellSession.__new__(runtime_app.StarShellSession)
|
|
|
|
session.slc_actions = None
|
|
session.profile = Profile.LARGE
|
|
session.favorites = Mock()
|
|
session.view = ShellView.__new__(ShellView)
|
|
session.view.onroad = Mock()
|
|
requests = []
|
|
session.input = ShellInput(Profile.LARGE, requests.append)
|
|
rows = (FeatureRow("action", "Action", "", actions=(("OPEN", True),), available=True),) * 15
|
|
start = FeatureSettingsState(rows=rows)
|
|
end = replace(start, scroll=5)
|
|
base = ShellSnapshot(ShellMode.SETTINGS, reference_state(), SettingsState(),
|
|
OnroadState(False, False, None, None, SpeedLimitObservation()))
|
|
with patch("openpilot.starpilot.ui.feature_settings.time.monotonic", return_value=0):
|
|
for destination, (state_name, _, input_name) in runtime_app._FEATURE_SETTINGS_PANES.items():
|
|
with self.subTest(destination=destination):
|
|
feature_view = make_feature_view(Profile.LARGE)
|
|
setattr(session.view, state_name, feature_view)
|
|
snapshot = replace(base, selected=destination, **{state_name: end})
|
|
session.selected = destination
|
|
session._settings_press_snapshot = Mock(return_value=snapshot)
|
|
session._settings_gesture_snapshot = Mock(return_value=snapshot)
|
|
feature_view._page_frame(start, 0, False)
|
|
feature_view._page_frame(end, 0, False)
|
|
requests.clear()
|
|
session.press(ShellMode.SETTINGS, 1930, 200)
|
|
session.release(ShellMode.SETTINGS, 1930, 200)
|
|
self.assertFalse(requests)
|
|
self.assertIsNone(getattr(session.input, input_name).held)
|
|
self.assertIsNone(feature_view._transition)
|
|
feature_view._page_frame(end, 0, False)
|
|
session.press(ShellMode.SETTINGS, 1930, 200)
|
|
session.release(ShellMode.SETTINGS, 1930, 200)
|
|
self.assertEqual([(request.source, request.action.kind) for request in requests], [(input_name, "action")])
|
|
for direction in (1, -1):
|
|
for distance, duration, accepted in ((200, .5, True), (80, .075, True), (80, .5, False)):
|
|
with self.subTest(direction=direction, distance=distance, duration=duration):
|
|
requests.clear()
|
|
feature_view.reset()
|
|
feature_view._page_frame(replace(start, scroll=0 if direction == 1 else 10), 0, False)
|
|
feature_view._page_frame(end, 0, False)
|
|
self.assertIsNotNone(feature_view._transition)
|
|
x = 1930 if direction == 1 else 1800
|
|
session.press(ShellMode.SETTINGS, x, 200)
|
|
for timestamp, fraction in ((0, 0), (duration / 2, .5), (duration, 1)):
|
|
session.move(ShellMode.SETTINGS, x - direction * distance * fraction, 200, timestamp=timestamp)
|
|
session.release(ShellMode.SETTINGS, x - direction * distance, 200, timestamp=duration + .01)
|
|
self.assertEqual([(request.source, request.action.kind, request.action.direction) for request in requests],
|
|
[(input_name, "scroll", direction)] if accepted else [])
|
|
feature_view._page_frame(start, 0, False)
|
|
feature_view._page_frame(end, 0, False)
|
|
session.cancel()
|
|
self.assertIsNone(feature_view._last_state)
|
|
|
|
def test_runtime_and_shared_feature_view_forward_selected_drag(self):
|
|
import pyray as rl
|
|
from openpilot.starpilot.ui import runtime_app
|
|
from openpilot.starpilot.ui.feature_settings import FeatureSettingsView
|
|
from openpilot.starpilot.ui.feature_settings_state import FeatureSettingsState
|
|
|
|
session = runtime_app.StarShellSession.__new__(runtime_app.StarShellSession)
|
|
|
|
session.slc_actions = None
|
|
session.profile = Profile.LARGE
|
|
session.favorites, session.pip_warning = Mock(), Mock()
|
|
session.notice = ""
|
|
session.settings_layer = session.network_layer = None
|
|
session.input = ShellInput(Profile.LARGE, Mock())
|
|
session.view = ShellView.__new__(ShellView)
|
|
session.view.profile = Profile.LARGE
|
|
session.view.onroad, session.view.settings, session.view.device = Mock(), Mock(), Mock()
|
|
panes = runtime_app._FEATURE_SETTINGS_PANES
|
|
for state_name, _, _ in panes.values():
|
|
setattr(session.view, state_name, make_feature_view(Profile.LARGE))
|
|
base = ShellSnapshot(ShellMode.SETTINGS, reference_state(), SettingsState(),
|
|
OnroadState(False, False, None, None, SpeedLimitObservation()))
|
|
# Each controller carries a distinct displacement so a wrong pane cannot pass.
|
|
for index, (_state_name, _, input_name) in enumerate(panes.values()):
|
|
controller = getattr(session.input, input_name)
|
|
controller.press(1000, 200, FeatureSettingsState())
|
|
controller.move(920 - index, 200, FeatureSettingsState())
|
|
with patch.object(runtime_app, "placed_at"), patch.object(FeatureSettingsView, "render", autospec=True) as feature_render, \
|
|
patch.object(ShellView, "render", autospec=True, side_effect=ShellView.render) as shell_render:
|
|
for destination, (state_name, _, input_name) in panes.items():
|
|
with self.subTest(destination=destination):
|
|
snapshot = replace(base, selected=destination)
|
|
session.snapshot = Mock(return_value=snapshot)
|
|
session.render(ShellMode.SETTINGS, rl.Rectangle(0, 0, 2160, 1080))
|
|
drag = getattr(session.input, input_name).drag_x
|
|
self.assertEqual(shell_render.call_args.kwargs["drag_x"], drag)
|
|
self.assertEqual(getattr(session.view, state_name).pressed_action, getattr(session.input, input_name).pressed_action)
|
|
feature_render.assert_called_with(getattr(session.view, state_name), getattr(snapshot, state_name), drag)
|
|
feature_render.reset_mock()
|
|
for destination in (Destination.STAR, Destination.DEVICE):
|
|
session.snapshot = Mock(return_value=replace(base, selected=destination))
|
|
session.render(ShellMode.SETTINGS, rl.Rectangle(0, 0, 2160, 1080))
|
|
self.assertEqual(shell_render.call_args.kwargs["drag_x"], 0)
|
|
feature_render.assert_not_called()
|
|
session.profile = session.view.profile = Profile.COMPACT
|
|
for state_name, _, _ in panes.values():
|
|
setattr(session.view, state_name, None)
|
|
for destination in panes:
|
|
session.snapshot = Mock(return_value=replace(base, selected=destination))
|
|
session.render(ShellMode.SETTINGS, rl.Rectangle(0, 0, 536, 240))
|
|
feature_render.assert_not_called()
|
|
|
|
def test_adapted_panels_draw_before_placed_navigation_and_notices(self):
|
|
import pyray as rl
|
|
from openpilot.starpilot.ui import clip, runtime_app
|
|
from openpilot.starpilot.ui.settings_state import DestinationAvailability
|
|
|
|
rect = rl.Rectangle(20, 30, 2160, 1080)
|
|
parent = rl.Rectangle(25, 35, 2140, 1060)
|
|
for destination in (*runtime_app.NATIVE_SETTINGS_PANELS, Destination.NETWORK):
|
|
for expanded in (True, False):
|
|
with self.subTest(destination=destination, expanded=expanded):
|
|
session = runtime_app.StarShellSession.__new__(runtime_app.StarShellSession)
|
|
session.profile, session.slc_actions = Profile.LARGE, None
|
|
session.favorites, session.pip_warning = Mock(), Mock()
|
|
session.input, session.view, session.fonts = Mock(), Mock(), Mock()
|
|
session.settings_layer, session.network_layer = Mock(), Mock(return_value=True)
|
|
session.notice, session.notice_until = "Test notice", float("inf")
|
|
settings = SettingsState(sidebar_expanded=expanded, availability=(DestinationAvailability(Destination.NETWORK, True),))
|
|
snapshot = ShellSnapshot(ShellMode.SETTINGS, reference_state(), settings,
|
|
OnroadState(False, False, None, None, SpeedLimitObservation()), selected=destination)
|
|
session.snapshot = Mock(return_value=snapshot)
|
|
def rail(*_args, **_kwargs):
|
|
placement = clip._placement.get()
|
|
self.assertIsNotNone(placement)
|
|
assert placement is not None
|
|
ox, oy, viewport, parent_clip = placement
|
|
self.assertEqual((ox, oy, viewport.x, viewport.y, viewport.width, viewport.height), (20, 30, 25, 35, 2140, 1060))
|
|
self.assertIs(parent_clip, parent)
|
|
session.view.settings.render_rail.side_effect = rail
|
|
order = Mock()
|
|
order.attach_mock(session.view.render, "view")
|
|
panel = session.network_layer if destination == Destination.NETWORK else session.settings_layer
|
|
order.attach_mock(panel, "panel")
|
|
order.attach_mock(session.view.settings.render_rail, "rail")
|
|
order.attach_mock(session.fonts.draw, "notice")
|
|
with patch.object(rl, "rl_push_matrix"), patch.object(rl, "rl_pop_matrix"), patch.object(rl, "rl_translatef"), \
|
|
patch.object(rl, "begin_scissor_mode"), patch.object(rl, "end_scissor_mode"), patch.object(rl, "draw_rectangle"):
|
|
session.render(ShellMode.SETTINGS, rect, parent)
|
|
self.assertEqual([call[0] for call in order.mock_calls], ["view", "panel", "rail", "notice"])
|
|
session.view.settings.render_rail.assert_called_once_with(settings, selected=destination)
|
|
content = panel.call_args.args[-1]
|
|
self.assertEqual((content.x, content.y, content.width, content.height),
|
|
(570 if expanded else 70, 55, 1560 if expanded else 2060, 1030))
|
|
self.assertIsNone(clip._placement.get())
|
|
|
|
def test_toggle_request_needs_same_displayed_value_on_release(self) -> None:
|
|
requests = []
|
|
touch = TogglesInput(requests.append)
|
|
state = TogglesState()
|
|
touch.press(2000, 120, state)
|
|
touch.release(2000, 120, replace(state, enabled=False))
|
|
self.assertEqual(requests, [])
|
|
touch.press(2000, 120, state)
|
|
touch.release(2000, 120, state)
|
|
self.assertEqual(len(requests), 1)
|
|
self.assertEqual(requests[0].key, ToggleKey.ENABLED)
|
|
self.assertIs(requests[0].value, False)
|
|
self.assertIs(state.enabled, True)
|
|
|
|
def test_personality_request_is_inert(self) -> None:
|
|
requests = []
|
|
touch = TogglesInput(requests.append)
|
|
state = TogglesState()
|
|
touch.press(1350, 800, state)
|
|
touch.release(1350, 800, state)
|
|
self.assertEqual(requests[0].personality, Personality.AGGRESSIVE)
|
|
self.assertEqual(state.personality, Personality.STANDARD)
|
|
|
|
def test_below_fold_toggle_uses_supplied_scroll_and_value(self) -> None:
|
|
requests = []
|
|
touch = TogglesInput(requests.append)
|
|
state = TogglesState(scroll_y=855)
|
|
touch.press(2000, 240, state)
|
|
touch.release(2000, 240, state)
|
|
self.assertEqual(requests[-1].key, ToggleKey.ALWAYS_ON_DM)
|
|
self.assertIs(requests[-1].value, True)
|
|
self.assertIs(state.always_on_dm, False)
|
|
|
|
def test_slc_numeric_defaults_are_not_observations(self) -> None:
|
|
base = {"observationKind": "unknown", "source": "none", "speedLimit": 0.0, "offset": 0.0,
|
|
"pendingSpeedLimit": 0.0, "effectiveCap": 0.0, "acceptedSpeedLimit": 0.0,
|
|
"hasPending": False, "hasCeiling": False, "hasAccepted": False,
|
|
"sessionId": "7", "decisionId": 8, "presentationId": 9, "status": "unavailable"}
|
|
unknown = speed_limit_from_message(SimpleNamespace(**base))
|
|
self.assertEqual(unknown.kind, ObservationKind.UNKNOWN)
|
|
self.assertIsNone(unknown.speed_limit_mps)
|
|
self.assertIsNone(unknown.pending_speed_limit_mps)
|
|
valid = speed_limit_from_message(SimpleNamespace(**(base | {"observationKind": "valid", "source": "map",
|
|
"speedLimit": 13.4, "hasPending": True,
|
|
"pendingSpeedLimit": 14.1})))
|
|
self.assertEqual(valid.speed_limit_mps, 13.4)
|
|
self.assertEqual(valid.pending_speed_limit_mps, 14.1)
|
|
self.assertIsNone(valid.effective_cap_mps)
|
|
|
|
def test_malformed_native_slc_values_never_render_as_a_limit(self) -> None:
|
|
base = {"observationKind": "valid", "source": "map", "speedLimit": float("nan"), "offset": 0.0,
|
|
"pendingSpeedLimit": 0.0, "effectiveCap": 0.0, "acceptedSpeedLimit": 0.0,
|
|
"hasPending": False, "hasCeiling": False, "hasAccepted": False,
|
|
"sessionId": "abc123", "decisionId": 8, "presentationId": 9, "status": "live"}
|
|
for speed in (float("nan"), float("inf"), 0.0, -1.0):
|
|
observation = speed_limit_from_message(SimpleNamespace(**(base | {"speedLimit": speed})))
|
|
self.assertEqual(observation.kind, ObservationKind.UNKNOWN)
|
|
self.assertIsNone(observation.speed_limit_mps)
|
|
stale = speed_limit_from_message(SimpleNamespace(**(base | {"observationKind": "stale"})))
|
|
self.assertEqual(stale.kind, ObservationKind.STALE)
|
|
self.assertIsNone(stale.speed_limit_mps)
|
|
valid = speed_limit_from_message(SimpleNamespace(**(base | {"speedLimit": 13.4})))
|
|
self.assertEqual(valid.session_id, "abc123")
|
|
with self.assertRaises(ValueError):
|
|
OnroadState(False, False, float("nan"), None, SpeedLimitObservation())
|
|
|
|
def test_shell_navigation_cancels_press_across_panes(self) -> None:
|
|
requests = []
|
|
touch = ShellInput(Profile.LARGE, requests.append)
|
|
snapshot = ShellSnapshot(ShellMode.HOME, reference_state(), SettingsState(),
|
|
OnroadState(False, False, 0.0, None, SpeedLimitObservation()))
|
|
touch.press(100, 100, 0.0, snapshot)
|
|
touch.release(100, 100, 0.1, snapshot)
|
|
self.assertEqual(requests[-1].source, "home")
|
|
settings = replace(snapshot, mode=ShellMode.SETTINGS)
|
|
touch.press(200, 450, 0.2, settings)
|
|
touch.release(200, 450, 0.3, settings)
|
|
self.assertEqual(requests[-1].source, "settings")
|
|
self.assertEqual(requests[-1].action.destination.destination, Destination.DEVICE)
|
|
device = replace(settings, selected=Destination.DEVICE)
|
|
touch.press(1900, 600, 0.4, device)
|
|
touch.release(1900, 600, 0.5, replace(device, mode=ShellMode.ONROAD))
|
|
self.assertEqual(len([request for request in requests if request.source == "device"]), 0)
|
|
touch.press(1900, 600, 0.6, device)
|
|
touch.release(1900, 600, 0.7, device)
|
|
self.assertEqual(requests[-1].source, "device")
|
|
self.assertEqual(requests[-1].action.request, DeviceRequest.PREVIEW_DRIVER_CAMERA)
|
|
|
|
def test_onroad_wheel_request_requires_unchanged_supplied_state(self) -> None:
|
|
requests = []
|
|
touch = ShellInput(Profile.LARGE, requests.append)
|
|
snapshot = ShellSnapshot(ShellMode.ONROAD, reference_state(), SettingsState(),
|
|
OnroadState(True, False, 0.0, None, SpeedLimitObservation(),
|
|
experimental_available=True, experimental_enabled=False, viewport_width=1860))
|
|
touch.press(1670, 170, 0.0, snapshot)
|
|
touch.release(1670, 170, 0.1, replace(snapshot, onroad=replace(snapshot.onroad, experimental_enabled=True)))
|
|
self.assertEqual(requests, [])
|
|
touch.press(1670, 170, 0.2, snapshot)
|
|
touch.release(1670, 170, 0.3, snapshot)
|
|
self.assertEqual(requests[-1].source, "onroad")
|
|
self.assertIs(requests[-1].action.value, True)
|
|
|
|
def test_compact_never_dispatches_large_leaf_controls(self) -> None:
|
|
requests = []
|
|
touch = ShellInput(Profile.COMPACT, requests.append)
|
|
snapshot = ShellSnapshot(ShellMode.SETTINGS, reference_state(), SettingsState(),
|
|
OnroadState(False, False, 0.0, None, SpeedLimitObservation()),
|
|
selected=Destination.TOGGLES)
|
|
with (patch.object(touch.toggles, 'press') as toggles,
|
|
patch.object(touch.device, 'press') as device,
|
|
patch.object(touch.software, 'press') as software):
|
|
for destination in (Destination.TOGGLES, Destination.DEVICE, Destination.SOFTWARE):
|
|
injected = replace(snapshot, selected=destination)
|
|
touch.press(510, 100, 0.0, injected)
|
|
touch.release(510, 100, 0.1, injected)
|
|
for control in (toggles, device, software):
|
|
control.assert_not_called()
|
|
self.assertFalse(any(request.source in {"toggles", "device", "software"} for request in requests))
|
|
|
|
def test_compact_wheel_icon_is_not_a_large_button(self) -> None:
|
|
requests = []
|
|
touch = ShellInput(Profile.COMPACT, requests.append)
|
|
snapshot = ShellSnapshot(ShellMode.ONROAD, reference_state(), SettingsState(),
|
|
OnroadState(True, False, 0.0, None, SpeedLimitObservation(),
|
|
experimental_available=True))
|
|
for x, y in ((46, 200), (1670, 170)):
|
|
touch.press(x, y, 0.0, snapshot)
|
|
touch.release(x, y, 0.1, snapshot)
|
|
self.assertEqual(requests, [])
|
|
|
|
def test_failed_shell_construction_closes_acquired_views(self) -> None:
|
|
with (patch("openpilot.starpilot.ui.shell.HomeView") as home,
|
|
patch("openpilot.starpilot.ui.shell.SettingsView") as settings,
|
|
patch("openpilot.starpilot.ui.shell.OnroadView") as onroad,
|
|
patch("openpilot.starpilot.ui.shell.DeviceView"),
|
|
patch("openpilot.starpilot.ui.shell.SoftwareView"),
|
|
patch("openpilot.starpilot.ui.shell.TogglesView") as toggles):
|
|
settings.return_value.prepare.side_effect = RuntimeError("asset validation failed")
|
|
with self.assertRaisesRegex(RuntimeError, "asset validation failed"):
|
|
ShellView(Mock(profile=Profile.LARGE), Path("/unused"))
|
|
home.return_value.close.assert_called_once()
|
|
settings.return_value.close.assert_called_once()
|
|
onroad.return_value.close.assert_called_once()
|
|
toggles.return_value.close.assert_called_once()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|