mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
228 lines
14 KiB
Python
228 lines
14 KiB
Python
"""Large value-editor adoption: real owners, touch routing, and unchanged save guards."""
|
|
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
from types import SimpleNamespace as NS
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import Mock, patch
|
|
|
|
from opendbc.car.car_helpers import interfaces
|
|
from opendbc.car.hyundai.values import CAR
|
|
from openpilot.common.params import Params
|
|
from openpilot.starpilot.lateral.lane_change_preferences import read_saved
|
|
from openpilot.starpilot.ui import runtime_app
|
|
from openpilot.starpilot.ui.appearance_owner import AppearanceOwner
|
|
from openpilot.starpilot.ui.feature_settings_owner import FeatureSettingsOwner
|
|
from openpilot.starpilot.ui.feature_settings import FeatureSettingsView
|
|
from openpilot.starpilot.ui.feature_settings_state import (
|
|
FeatureInput, FeaturePage, FeatureRow, FeatureSettingsState, FeatureUiAction, feature_row_top,
|
|
row_change, row_request, value_buttons, value_done_rect, value_editor_rect, value_editor_state, value_request,
|
|
)
|
|
from openpilot.starpilot.ui.pip_owner import PiPOwner
|
|
from openpilot.starpilot.ui.presentation import Profile
|
|
from openpilot.starpilot.ui.settings_state import Destination
|
|
from openpilot.starpilot.ui.shell import ShellMode
|
|
from openpilot.starpilot.ui.tests.test_feature_settings_visuals import fake_fonts
|
|
from openpilot.system.ui.widgets import DialogResult
|
|
|
|
|
|
class ValueEditorTests(unittest.TestCase):
|
|
def setUp(self):
|
|
temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(temporary.cleanup)
|
|
self.params = Params(temporary.name)
|
|
cp = interfaces[CAR.HYUNDAI_IONIQ_6].get_non_essential_params(CAR.HYUNDAI_IONIQ_6)
|
|
cp.openpilotLongitudinalControl = True
|
|
self.owner = FeatureSettingsOwner(self.params, lambda _group: True,
|
|
vehicle_fingerprint=lambda: str(cp.carFingerprint), vehicle_params=lambda: cp)
|
|
self.session = runtime_app.StarShellSession.__new__(runtime_app.StarShellSession)
|
|
self.session.profile = Profile.LARGE
|
|
self.session._mode, self.session.selected = ShellMode.SETTINGS, Destination.DRIVING_CONTROLS
|
|
self.session.feature_page, self.session.feature_root_page = FeaturePage.LANE_CHANGE, FeaturePage.HUB
|
|
self.session.feature_scroll, self.session.feature_edit_key = 5, None
|
|
self.session.feature_owner = self.owner
|
|
self.session.configuration_allowed = Mock(return_value=True)
|
|
self.session._feature_authority = Mock(return_value=True)
|
|
self.session._settings_preference_authority = Mock(return_value=True)
|
|
self.session._snapshot_cache = None
|
|
self.session._lane_change_request_epoch = 0
|
|
self.session.input, self.session.adapter, self.session._unavailable = Mock(), Mock(), Mock()
|
|
self.session.vehicle_selector = Mock(spec=runtime_app.VehicleLarge, rows=Mock(return_value=()))
|
|
self.enterContext(patch.object(runtime_app, "ui_state", NS(is_metric=False)))
|
|
|
|
@staticmethod
|
|
def tap(state, bounds, emit):
|
|
x, y, width, height = bounds
|
|
point = x + width / 2, y + height / 2
|
|
touch = FeatureInput(emit)
|
|
touch.press(*point, state)
|
|
touch.release(*point, state)
|
|
|
|
def open_feature(self, key):
|
|
row = next(row for row in self.session.feature_snapshot().rows if row.key == key)
|
|
self.session._feature_ui(FeatureUiAction("open", row))
|
|
return self.session.feature_snapshot()
|
|
|
|
def test_only_value_controls_change_and_all_request_evidence_is_preserved(self):
|
|
number = FeatureRow("number", "Time", "1.5", b"saved", step=.1, minimum=0., maximum=3., unit="s", available=True,
|
|
default_value="1", related_source=b"units", vehicle_fingerprint="car", capability=("cap",),
|
|
dependencies=(("dependency", b"source"),), display_unit="s")
|
|
unchanged = (FeatureRow("toggle", "Toggle", "On", choices=("Off", "On"), available=True),
|
|
replace(number, page="child"), replace(number, repair_value="0"), replace(number, actions=(("Reset", True),)),
|
|
replace(number, value="Invalid"), FeatureRow("reset", "Reset", "Reset", choices=("Reset",), available=True))
|
|
state = FeatureSettingsState(rows=(number, *unchanged))
|
|
summary = value_editor_state(state, None)
|
|
row = summary.rows[0]
|
|
self.assertEqual(summary.rows[1:], unchanged)
|
|
self.assertEqual(replace(row, presets=(), page=""), number)
|
|
self.assertEqual(tuple(value for value, _ in row.presets), ("0.0", "1.0", "3.0"))
|
|
self.assertIsNone(value_request(summary, FeatureUiAction('change', row, 1)))
|
|
for expanded in (False, True):
|
|
editor = replace(value_editor_state(state, "number"), sidebar_expanded=expanded)
|
|
row = editor.rows[0]
|
|
for index, (value, bounds, enabled) in enumerate(value_buttons(editor, row, feature_row_top(editor))):
|
|
actions = []
|
|
self.tap(editor, bounds, actions.append)
|
|
self.assertEqual(len(actions), int(enabled))
|
|
if enabled:
|
|
expected = row_change(row, -1 if value == "-" else 1) if value in ("-", "+") else row_request(row, value)
|
|
self.assertEqual(value_request(editor, actions[0]), expected)
|
|
self.assertIsNone(value_request(replace(editor, rows=(replace(row, source=b"changed"),)), actions[0]))
|
|
disabled = replace(editor, rows=(replace(row, available=False),))
|
|
self.assertIsNone(value_request(disabled, FeatureUiAction("action", disabled.rows[0], index)))
|
|
|
|
def test_every_choice_remains_reachable_and_quick_picks_are_bounded(self):
|
|
for count in (2, 3, 5, 15):
|
|
choices = tuple(f"Choice {i}" for i in range(count))
|
|
row = FeatureRow("choice", "Choice", choices[0], choices=choices, available=True, default_value=choices[1])
|
|
editor = value_editor_state(FeatureSettingsState(rows=(row,)), row.key)
|
|
current = editor.rows[0]
|
|
self.assertLessEqual(len(current.presets), 3)
|
|
self.assertIn((choices[1], choices[1]), current.presets)
|
|
for choice in choices[1:]:
|
|
request = value_request(replace(editor, rows=(current,)), FeatureUiAction("change", current, 1))
|
|
self.assertEqual(request.value, choice)
|
|
current = replace(current, value=choice)
|
|
self.assertIsNone(value_request(replace(editor, rows=(current,)), FeatureUiAction("change", current, 1)))
|
|
|
|
def test_long_choices_and_precise_numbers_fit_the_existing_editor(self):
|
|
import pyray as rl
|
|
rows = (FeatureRow("choice", "Choice", "Use the selected vehicle controller", available=True,
|
|
choices=("Use the selected vehicle controller", "Use the other controller")),
|
|
FeatureRow("number", "Number", "123.45678901234567", step=.01, minimum=0., maximum=200., available=True, unit="m/s²"))
|
|
for row in rows:
|
|
for expanded in (False, True):
|
|
state = replace(value_editor_state(FeatureSettingsState(rows=(row,)), row.key), sidebar_expanded=expanded)
|
|
fonts = fake_fonts()
|
|
renderer = FeatureSettingsView(fonts)
|
|
renderer._center = Mock()
|
|
with patch("openpilot.starpilot.ui.feature_settings.draw_rounded_fill"), patch("openpilot.starpilot.ui.feature_settings.draw_rounded_stroke"):
|
|
renderer._value_row(state, state.rows[0], rl.Rectangle(*value_editor_rect(state)),
|
|
value_buttons(state, state.rows[0], feature_row_top(state)), None)
|
|
for call in renderer._center.call_args_list:
|
|
text, size, bounds, *_ = call.args
|
|
self.assertLessEqual(fonts.measure(text, None, size).width, bounds.width + .001)
|
|
|
|
def test_lane_speed_presets_confirm_save_and_reject_changed_units_or_abandoned_editor(self):
|
|
dialogs = []
|
|
self.enterContext(patch("openpilot.system.ui.widgets.confirm_dialog.ConfirmDialog",
|
|
side_effect=lambda *args, **kw: NS(callback=kw["callback"])))
|
|
self.enterContext(patch.object(runtime_app.gui_app, "push_widget", dialogs.append))
|
|
for metric in (False, True):
|
|
self.params.put_bool("IsMetric", metric, block=True)
|
|
editor = self.open_feature("lane_change:speed")
|
|
self.assertEqual(editor.save_hint, "Changes require confirmation")
|
|
self.assertEqual(editor.rows[0].unit, "km/h" if metric else "mph")
|
|
self.assertEqual({float(value) for value, _ in editor.rows[0].presets},
|
|
{10, 30, 50, 70, 100} if metric else {5, 15, 30, 45, 60})
|
|
bounds = next(bounds for value, bounds, _ in value_buttons(editor, editor.rows[0], feature_row_top(editor)) if float_or_none(value) == 30.)
|
|
before = read_saved(self.params).raw
|
|
self.tap(editor, bounds, self.session._feature_ui)
|
|
self.assertEqual(read_saved(self.params).raw, before)
|
|
dialogs[-1].callback(DialogResult.CANCEL)
|
|
self.assertEqual(read_saved(self.params).raw, before)
|
|
self.tap(editor, bounds, self.session._feature_ui)
|
|
dialogs[-1].callback(DialogResult.CONFIRM)
|
|
self.assertAlmostEqual(read_saved(self.params).policy.minimum_speed_mps, 30 / 3.6 if metric else 30 * .44704)
|
|
self.session._feature_ui(FeatureUiAction("back"))
|
|
self.assertEqual(self.session.feature_scroll, 5)
|
|
editor = self.open_feature("lane_change:speed")
|
|
plus = value_buttons(editor, editor.rows[0], feature_row_top(editor))[-1][1]
|
|
self.tap(editor, plus, self.session._feature_ui)
|
|
before = read_saved(self.params).raw
|
|
self.params.put_bool("IsMetric", not metric, block=True)
|
|
dialogs[-1].callback(DialogResult.CONFIRM)
|
|
self.assertEqual(read_saved(self.params).raw, before)
|
|
self.params.put_bool("IsMetric", metric, block=True)
|
|
self.tap(editor, plus, self.session._feature_ui)
|
|
self.tap(editor, value_done_rect(editor), self.session._feature_ui)
|
|
dialogs[-1].callback(DialogResult.CONFIRM)
|
|
self.assertEqual(read_saved(self.params).raw, before)
|
|
self.assertEqual(self.session.feature_page, FeaturePage.LANE_CHANGE)
|
|
|
|
def test_all_feature_pages_convert_valid_numbers_and_choices_without_affecting_compact_or_favorites(self):
|
|
for page in (*FeaturePage, "aggressive/acceleration", "standard/braking", "relaxed/following"):
|
|
self.session.feature_page = page
|
|
large = self.session.feature_snapshot()
|
|
original = self.owner.snapshot(page, parked=True, system_long=True, lateral_context=True, metric=False,
|
|
configure_while_driving=True)
|
|
for before, after in zip(original.rows, large.rows, strict=True):
|
|
if before.step and not before.repair_value and float_or_none(before.value) is not None:
|
|
self.assertTrue(after.presets, before.key)
|
|
self.assertEqual(replace(after, page=before.page, presets=before.presets), before)
|
|
self.session.profile = Profile.COMPACT
|
|
self.assertEqual(self.session.feature_snapshot(), original)
|
|
self.session.profile = Profile.LARGE
|
|
self.assertTrue(all(not row.presets for row in self.session.feature_snapshot(page, favorite=True).rows))
|
|
|
|
def test_cruise_interval_editor_keeps_the_owners_required_confirmation(self):
|
|
self.owner.show_cruise_intervals = True
|
|
self.owner.vehicle_params().pcmCruise = False
|
|
self.session.feature_page = FeaturePage.PROFILES
|
|
editor = self.open_feature("CustomCruise")
|
|
dialogs = []
|
|
with patch("openpilot.system.ui.widgets.confirm_dialog.ConfirmDialog", side_effect=lambda *args, **kw: NS(callback=kw["callback"])), \
|
|
patch.object(runtime_app.gui_app, "push_widget", dialogs.append):
|
|
self.tap(editor, value_buttons(editor, editor.rows[0], feature_row_top(editor))[-1][1], self.session._feature_ui)
|
|
self.assertIsNone(self.params.get("CustomCruise"))
|
|
dialogs[-1].callback(DialogResult.CONFIRM)
|
|
self.assertEqual(float(self.params.get("CustomCruise")), 2.)
|
|
self.session._unavailable.assert_not_called()
|
|
|
|
def test_slc_steps_keep_direction_and_presets_keep_exact_source_guards(self):
|
|
self.session.feature_page = FeaturePage.SLC
|
|
adopt = next(row for row in self.session.feature_snapshot().rows if row.key == "slc_adopt")
|
|
self.assertTrue(self.owner.apply(replace(row_request(adopt, "confirm"), confirmation=True)))
|
|
editor = self.open_feature("Offset2")
|
|
row = editor.rows[0]
|
|
self.assertEqual(value_request(editor, FeatureUiAction("change", row, 1)).direction, 1)
|
|
self.tap(editor, value_buttons(editor, row, feature_row_top(editor))[-1][1], self.session._feature_ui)
|
|
self.assertEqual(float(self.session.feature_snapshot().rows[0].value), 1.)
|
|
before = Path(self.params.get_param_path("SLCOffsetSchedule")).read_bytes()
|
|
self.tap(editor, value_buttons(editor, row, feature_row_top(editor))[0][1], self.session._feature_ui)
|
|
self.assertEqual(Path(self.params.get_param_path("SLCOffsetSchedule")).read_bytes(), before)
|
|
self.session._unavailable.assert_not_called()
|
|
|
|
def test_camera_crop_editor_saves_and_done_preserves_parent_position(self):
|
|
self.session.selected = Destination.APPEARANCE
|
|
self.session.appearance_page, self.session.appearance_scroll = "pip", 5
|
|
self.session.appearance_owner = AppearanceOwner(self.params, lambda: True)
|
|
self.session.pip_owner = PiPOwner(self.params, lambda: True, lambda: False)
|
|
row = next(row for row in self.session.appearance_snapshot().rows if row.key == "pip:mask:size")
|
|
self.session._appearance_ui(FeatureUiAction("open", row))
|
|
editor = self.session.appearance_snapshot()
|
|
self.assertTrue(editor.editor)
|
|
self.tap(editor, value_buttons(editor, editor.rows[0], feature_row_top(editor))[0][1], self.session._appearance_ui)
|
|
self.assertEqual(float(self.session.appearance_snapshot().rows[0].value), float(row.value) - row.step)
|
|
self.tap(editor, value_done_rect(editor), self.session._appearance_ui)
|
|
self.assertEqual((self.session.appearance_page, self.session.appearance_scroll), ("pip", 5))
|
|
self.session._unavailable.assert_not_called()
|
|
|
|
|
|
def float_or_none(value):
|
|
try:
|
|
return float(value)
|
|
except ValueError:
|
|
return None
|