"""Protected Settings root coordinates and guarded large child actions.""" import unittest from dataclasses import replace from unittest.mock import patch from pathlib import Path import tempfile from types import SimpleNamespace as NS from openpilot.common.params import Params from openpilot.starpilot.longitudinal.profile_document import migrate_profile_document from openpilot.starpilot.ui import feature_settings_compact as compact from openpilot.starpilot.ui.feature_settings_owner import FeatureSettingsOwner from openpilot.starpilot.ui.feature_settings_state import ( FEATURE_ROW_TOP, FEATURE_ROW_HEIGHT, feature_row_top, FeatureInput, FeatureRow, FeatureSettingsState, FeatureUiAction, row_change, FEATURE_PAGE_COUNTER_WIDTH, feature_page_counter_left, FEATURE_FOOTER_BOTTOM, feature_footer_buttons, feature_footer_top, ) from openpilot.starpilot.ui.presentation import Profile from openpilot.starpilot.ui.settings_state import Destination, SettingsInput, SettingsState, tile_rects from opendbc.car.honda.interface import CarInterface as HondaCarInterface from opendbc.car.honda.values import CAR as HONDA_CAR class FeatureNavigationTests(unittest.TestCase): def test_page_counter_is_inert_and_footer_buttons_remain_tappable(self): for expanded in (True, False): state = FeatureSettingsState(sidebar_expanded=expanded) counter_left = feature_page_counter_left(state) actions = [] controller = FeatureInput(actions.append) for x in (counter_left, counter_left + FEATURE_PAGE_COUNTER_WIDTH / 2, counter_left + FEATURE_PAGE_COUNTER_WIDTH): with self.subTest(expanded=expanded, x=x): controller.press(x, 1015, state) controller.release(x, 1015, state) self.assertFalse(actions) self.assertIsNone(controller.held) left = 520 if expanded else 20 for x, direction in (((left + 25 + 1320) / 2, -1), (1720, 1)): controller.press(x, 1015, state) controller.release(x, 1015, state) self.assertEqual(actions.pop(), FeatureUiAction("scroll", direction=direction)) def test_footer_touch_zones_cover_the_full_band_around_the_drawn_buttons(self): for expanded in (True, False): for subtitle in ("", "Panel description"): state = FeatureSettingsState(sidebar_expanded=expanded, subtitle=subtitle) left = 520 if expanded else 20 top = feature_footer_top(state) counter_left = feature_page_counter_left(state) counter_right = counter_left + FEATURE_PAGE_COUNTER_WIDTH buttons = feature_footer_buttons(state) for direction, (bx, by, width, height) in zip((-1, 1), buttons, strict=True): self.assertGreaterEqual(by, top) self.assertLessEqual(by + height, FEATURE_FOOTER_BOTTOM) self.assertTrue(bx + width < counter_left if direction == -1 else bx > counter_right) for x in (bx, bx + width / 2, bx + width): for y in (by, by + height / 2, by + height): with self.subTest(expanded=expanded, subtitle=subtitle, x=x, y=y): self.assertEqual(FeatureInput.target(x, y, state), FeatureUiAction("scroll", direction=direction)) for x, y, direction in ((left, top, -1), (counter_left - 1, FEATURE_FOOTER_BOTTOM, -1), (counter_right + 1, top, 1), (2140, FEATURE_FOOTER_BOTTOM, 1)): with self.subTest(expanded=expanded, subtitle=subtitle, x=x, y=y): self.assertEqual(FeatureInput.target(x, y, state), FeatureUiAction("scroll", direction=direction)) self.assertIsNone(FeatureInput.target(1720, FEATURE_FOOTER_BOTTOM + 1, state)) self.assertIsNone(FeatureInput.target(1720, FEATURE_FOOTER_BOTTOM - 5, replace(state, editor=True))) actions = [] controller = FeatureInput(actions.append) bx, by, width, height = buttons[1] controller.press(bx + width / 2, by + 4, state) controller.move(bx + width / 2 + 20, by - 26, state) controller.release(bx + width / 2 + 20, by - 26, state) self.assertEqual(actions, [FeatureUiAction("scroll", direction=1)]) def test_drag_tracks_only_horizontal_body_movement(self): state = FeatureSettingsState() controller = FeatureInput(lambda _action: None) for dx, dy, expected, canceled in ((20, 0, 0, False), (-80, 0, -80, False), (80, 10, 80, False), (80, 41, 0, False), (0, 80, 0, True)): with self.subTest(dx=dx, dy=dy): controller.press(1000, 200, state) controller.move(1000 + dx, 200 + dy, state) self.assertEqual(controller.drag_x, expected) self.assertEqual(controller.held is None, canceled) controller.press(1000, 200, state) controller.move(920, 200, state) controller.move(980, 200, state) self.assertEqual(controller.drag_x, 0) controller.move(1080, 200, state) self.assertEqual(controller.drag_x, 80) def test_drag_resets_through_existing_cancel_paths(self): state = FeatureSettingsState() controller = FeatureInput(lambda _action: None) resets = (lambda: controller.cancel(), lambda: controller.press(1000, 200, state), lambda: controller.release(920, 200, state), lambda: controller.move(920, 80, state), lambda: controller.move(920, 200, replace(state, scroll=5)), lambda: controller.move(920, 200, replace(state, page="child")), lambda: controller.move(920, 200, replace(state, sidebar_expanded=False))) for reset in resets: controller.press(1000, 200, state) controller.move(920, 200, state) self.assertEqual(controller.drag_x, -80) reset() self.assertEqual(controller.drag_x, 0) def test_native_toyota_swap_has_separate_authority_on_both_profiles(self): from opendbc.car.toyota.interface import CarInterface as ToyotaInterface from opendbc.car.toyota.values import CAR as ToyotaCAR from openpilot.starpilot.controllers.toyota_cruise import ToyotaCruisePreference from openpilot.starpilot.ui import runtime_app vehicle = ToyotaInterface.get_non_essential_params(ToyotaCAR.TOYOTA_COROLLA_TSS2) self.assertTrue(vehicle.openpilotLongitudinalControl and vehicle.pcmCruise) with tempfile.TemporaryDirectory() as directory: params = Params(directory) for profile in (Profile.LARGE, Profile.COMPACT): with self.subTest(profile=profile): session = runtime_app.StarShellSession.__new__(runtime_app.StarShellSession) session._mode, session.profile = runtime_app.ShellMode.SETTINGS, profile self.enterContext(patch.object(session, '_unavailable', lambda _reason: None)) session.feature_owner = FeatureSettingsOwner(params, session._feature_authority, vehicle_fingerprint=lambda: vehicle.carFingerprint, vehicle_params=lambda: vehicle) with patch.object(runtime_app, 'ui_state', NS(CP=vehicle)): self.assertTrue(session._feature_authority('long')) self.assertTrue(session._feature_authority('toyota_cruise')) for expected in (True, False): state = session.feature_owner.snapshot('profiles', parked=True, system_long=False, lateral_context=False, metric=False) rows = {row.key: row for row in state.rows} self.assertNotIn('CustomCruise', rows) self.assertNotIn('CustomCruiseLong', rows) self.assertTrue(rows['ReverseCruise'].available) request = row_change(rows['ReverseCruise']) assert request is not None self.assertTrue(session.feature_request(request)) self.assertEqual(params.get_bool('ReverseCruise'), expected) self.assertEqual(ToyotaCruisePreference(vehicle, params).update(), expected) session._mode = runtime_app.ShellMode.ONROAD with patch.object(session, '_favorite_authority', return_value=False): self.assertFalse(session._feature_authority('toyota_cruise')) session._mode = runtime_app.ShellMode.SETTINGS other = HondaCarInterface.get_non_essential_params(HONDA_CAR.HONDA_CIVIC) with patch.object(runtime_app, 'ui_state', NS(CP=other)): self.assertFalse(session._feature_authority('toyota_cruise')) self.assertTrue(session._feature_authority('long')) def test_native_saved_preferences_need_settings_or_favorite_context_not_cp(self): from openpilot.starpilot.ui import runtime_app cp = HondaCarInterface.get_non_essential_params(HONDA_CAR.HONDA_CIVIC) self.assertTrue(cp.openpilotLongitudinalControl and cp.pcmCruise) session = runtime_app.StarShellSession.__new__(runtime_app.StarShellSession) session._mode = runtime_app.ShellMode.SETTINGS with patch.object(session, "confirmed_offroad", return_value=True), patch.object(runtime_app, "ui_state", NS(CP=cp)): self.assertTrue(session._feature_authority("long")) self.assertTrue(session._feature_authority("preferences")) cp.openpilotLongitudinalControl = False self.assertFalse(session._feature_authority("long")) self.assertTrue(session._feature_authority("preferences")) cp.openpilotLongitudinalControl = True cp.passive = True self.assertFalse(session._feature_authority("long")) self.assertTrue(session._feature_authority("preferences")) with patch.object(session, "confirmed_offroad", return_value=True), patch.object(runtime_app, "ui_state", NS(CP=None)): self.assertTrue(session._feature_authority("preferences")) with patch.object(session, "confirmed_offroad", return_value=False), patch.object(runtime_app, "ui_state", NS(CP=None)): self.assertTrue(session._feature_authority("preferences")) self.assertFalse(session._feature_authority("lane")) session._mode = runtime_app.ShellMode.ONROAD with patch.object(session, "_favorite_authority", return_value=False): self.assertFalse(session._feature_authority("preferences")) with patch.object(session, "_favorite_authority", return_value=True): self.assertTrue(session._feature_authority("preferences")) self.assertFalse(session._feature_authority("parked_preferences")) def test_aol_save_does_not_block_navigation_or_queue_duplicate_requests(self): import fcntl from typing import Any import threading import time from openpilot.starpilot.ui import runtime_app from openpilot.starpilot.ui.feature_settings_owner import FeatureSettingsRequest from openpilot.starpilot import saved_document from openpilot.starpilot.ui.feature_settings import FeatureSettingsView from openpilot.starpilot.ui.presentation import BitmapFonts for profile, withdrawal in ((profile, withdrawal) for profile in (Profile.LARGE, Profile.COMPACT) for withdrawal in ("back", "cancel", "close")): with self.subTest(profile=profile, withdrawal=withdrawal), tempfile.TemporaryDirectory() as directory: params = Params(directory) params.put_bool("AlwaysOnLateral", True, block=True) session: Any = runtime_app.StarShellSession.__new__(runtime_app.StarShellSession) session.profile = profile session._mode = runtime_app.ShellMode.SETTINGS session.selected = Destination.DRIVING_CONTROLS session.feature_page, session.feature_root_page = "aol", "hub" session.feature_scroll = 0 session.input = NS(cancel=lambda: None) session.favorites = NS(cancel=lambda: None) fonts = BitmapFonts.__new__(BitmapFonts) fonts.profile = profile session.view = NS(features=FeatureSettingsView(fonts) if profile == Profile.LARGE else None, onroad=NS(navigation=NS(cancel=lambda: None)), close=lambda: None) session.drive_state = NS(physical=NS(close=lambda: None)) session.galaxy_flow = session.pip_warning = session.pip_renderer = session.fonts = NS(close=lambda: None) session.bluetooth_source = session.model_source = session.map_source = None session.feature_owner = FeatureSettingsOwner(params, lambda _: True, vehicle_fingerprint=lambda: None) request = FeatureSettingsRequest("AlwaysOnLateral", b"1", "Off") entered = threading.Event() acquire = saved_document.acquire_native_lock def blocked(fd, entered=entered, acquire=acquire): entered.set() acquire(fd) root = Path(params.get_param_path("AlwaysOnLateral")).parent.parent with (root / ".lock").open("a") as lock, patch.object(session, "_feature_authority", return_value=True), \ patch.object(session, "feature_snapshot", return_value=FeatureSettingsState(page="aol")), \ patch.object(saved_document, "acquire_native_lock", blocked): fcntl.flock(lock, fcntl.LOCK_EX) started = time.monotonic() self.assertFalse(session.feature_request(request)) self.assertLess(time.monotonic() - started, .2) pending = session._aol_save self.assertTrue(entered.wait(1.)) self.assertEqual(Path(params.get_param_path("AlwaysOnLateral")).read_bytes(), b"1") self.assertIn("Saving", session.notice) self.assertFalse(session.feature_request(request)) self.assertIs(session._aol_save, pending) if withdrawal == "back": session._feature_ui(FeatureUiAction("back")) self.assertEqual(session.feature_page, "hub") else: getattr(session, withdrawal)() self.assertTrue(pending.cancel.is_set()) fcntl.flock(lock, fcntl.LOCK_UN) self.assertTrue(pending.done.wait(1.)) session._poll_aol_save() self.assertEqual(Path(params.get_param_path("AlwaysOnLateral")).read_bytes(), b"1") self.assertIn("not saved", session.notice) if withdrawal == "close": self.assertFalse(session.feature_request(request)) self.assertIsNone(session._aol_save) continue session.feature_page = "aol" self.assertFalse(session.feature_request(request)) pending = session._aol_save self.assertTrue(pending.done.wait(1.)) session._poll_aol_save() self.assertEqual(Path(params.get_param_path("AlwaysOnLateral")).read_bytes(), b"0") self.assertEqual(session.notice, "AOL preference saved") self.assertIsNone(session._aol_save) def test_aol_enable_worker_rechecks_live_vehicle_after_native_lock(self): import fcntl import threading from typing import Any from openpilot.starpilot import saved_document from openpilot.starpilot.ui import runtime_app from opendbc.car import gen_empty_fingerprint from opendbc.car.gm.interface import CarInterface from opendbc.car.gm.values import CAR fingerprint = gen_empty_fingerprint() fingerprint[0][0x201] = 6 fingerprint[2][0x320] = 8 fingerprint[2][0x180] = 4 cp = CarInterface.get_params(CAR.CHEVROLET_BOLT_ACC_2022_2023_PEDAL, fingerprint, [], True, False, False) current = [cp] with tempfile.TemporaryDirectory() as directory: params = Params(directory) params.put_bool("AlwaysOnLateral", False, block=True) session: Any = runtime_app.StarShellSession.__new__(runtime_app.StarShellSession) session._mode = runtime_app.ShellMode.SETTINGS session.selected = Destination.DRIVING_CONTROLS session.feature_page = "aol" session.feature_owner = FeatureSettingsOwner(params, lambda _: True, vehicle_fingerprint=lambda: cp.carFingerprint, vehicle_params=lambda: current[0]) def request(): state = session.feature_owner.snapshot("aol", parked=True, system_long=True, lateral_context=True, metric=False) row = next(row for row in state.rows if row.key == "AlwaysOnLateral") self.assertTrue(row.available) return row_change(row) with patch.object(session, "_feature_authority", return_value=True): self.assertFalse(session.feature_request(request())) self.assertTrue(session._aol_save.done.wait(1.)) session._poll_aol_save() self.assertTrue(params.get_bool("AlwaysOnLateral")) self.assertEqual(session.notice, "AOL preference saved") params.put_bool("AlwaysOnLateral", False, block=True) entered = threading.Event() acquire = saved_document.acquire_native_lock def blocked(fd): entered.set() acquire(fd) root = Path(params.get_param_path("AlwaysOnLateral")).parent.parent with (root / ".lock").open("a") as lock, patch.object(saved_document, "acquire_native_lock", blocked): fcntl.flock(lock, fcntl.LOCK_EX) self.assertFalse(session.feature_request(request())) pending = session._aol_save self.assertTrue(entered.wait(1.)) current[0] = None fcntl.flock(lock, fcntl.LOCK_UN) self.assertTrue(pending.done.wait(1.)) session._poll_aol_save() self.assertFalse(params.get_bool("AlwaysOnLateral")) self.assertIn("not saved", session.notice) def test_large_root_tile_geometry_and_destination(self): state = SettingsState() self.assertEqual(tile_rects(state)[2], (1611, 112, 529, 461)) actions = [] controller = SettingsInput(Profile.LARGE, actions.append) controller.press(1800, 300, state) controller.release(1800, 300, state) self.assertEqual(actions[0].destination.destination, Destination.DRIVING_CONTROLS) def test_press_release_cancels_on_drag_or_changed_source(self): actions = [] controller = FeatureInput(actions.append) source = FeatureRow("LaneCentering", "Enable Lane Centering", "Off", b"0", ("Off", "On"), available=True) state = FeatureSettingsState(page="lane", rows=(source,)) controller.press(1990, feature_row_top(state) + 92, state) controller.move(1800, feature_row_top(state) + 92, state) controller.release(1800, feature_row_top(state) + 92, state) self.assertFalse(actions) controller.press(1990, feature_row_top(state) + 92, state) changed = FeatureSettingsState(page="lane", rows=(FeatureRow("LaneCentering", "Enable Lane Centering", "On", b"1", ("Off", "On"), available=True),)) controller.release(1990, feature_row_top(state) + 92, changed) self.assertFalse(actions) controller.press(1990, feature_row_top(state) + 92, state) controller.release(1990, feature_row_top(state) + 92, state) self.assertEqual(row_change(actions[0].row).expected, b"0") def test_swipes_page_from_controls_disabled_rows_and_blank_space(self): controls = (FeatureRow("switch", "Switch", "Off", choices=("Off", "On"), available=True), FeatureRow("number", "Number", "50", step=5, available=True), FeatureRow("", "Child", "", page="child", available=True), FeatureRow("pip:reset", "Reset", "", available=True), FeatureRow("action", "Action", "", actions=(("OPEN", True),), available=True), FeatureRow("disabled", "Disabled", "", actions=(("OPEN", False),))) for expanded in (True, False): for subtitle in ("", "Description"): for row in controls: for scroll, start, end, direction in ((0, 2000, 1700, 1), (5, 1800, 2100, -1)): with self.subTest(expanded=expanded, subtitle=subtitle, row=row.key, direction=direction): state = FeatureSettingsState(rows=(row,) * 7, scroll=scroll, sidebar_expanded=expanded, subtitle=subtitle) actions = [] controller = FeatureInput(actions.append) y = feature_row_top(state) + 102 controller.press(start, y, state) controller.move(end, y, state) self.assertFalse(actions) controller.release(end, y, state) controller.release(end, y, state) self.assertEqual(actions, [FeatureUiAction("scroll", direction=direction)]) state = FeatureSettingsState(rows=(controls[0],) * 7, scroll=5, sidebar_expanded=expanded, subtitle=subtitle) actions = [] controller = FeatureInput(actions.append) y = feature_row_top(state) + 3.5 * FEATURE_ROW_HEIGHT controller.press(1500, y, state) controller.release(1800, y, state) self.assertEqual(actions, [FeatureUiAction("scroll", direction=-1)]) def test_swipe_rejections_never_restore_a_canceled_tap(self): row = FeatureRow("", "Child", "", page="child", available=True) for expanded in (True, False): state = FeatureSettingsState(rows=(row,) * 7, sidebar_expanded=expanded) cases = (((1500, 200), [(1619, 200)]), # short drag ((1500, 200), [(1620, 261)]), # diagonal ((1500, 200), [(1500, 280), (1800, 280)]), # vertical first ((1500, 200), [(1800, 200), (1500, 200)]), # return to origin ((1500, 200), [(1500, 80), (1800, 200)]), # leave vertically and reenter ((700, 50), [(1000, 50)]), # header ((1000, 1015), [(1300, 1015)]), # footer ((30, 580), [(330, 580)]), # collapse handle ((200, 200), [(1000, 200)]) if expanded else ((10, 200), [(1000, 200)])) for start, moves in cases: with self.subTest(expanded=expanded, start=start, moves=moves): actions = [] controller = FeatureInput(actions.append) controller.press(*start, state) for position in moves: controller.move(*position, state) controller.release(*moves[-1], state) self.assertFalse(actions) for scroll, count, end in ((0, 7, 1800), (5, 7, 1200), (0, 5, 1200)): actions = [] controller = FeatureInput(actions.append) boundary = replace(state, scroll=scroll, rows=(row,) * count) controller.press(1500, 200, boundary) controller.release(end, 200, boundary) self.assertFalse(actions) def test_canceled_footer_tap_cannot_become_a_body_swipe(self): for expanded in (True, False): with self.subTest(expanded=expanded): state = FeatureSettingsState(rows=(FeatureRow("row", "Row", ""),) * 7, scroll=5, sidebar_expanded=expanded) actions = [] controller = FeatureInput(actions.append) controller.press(900, 1015, state) self.assertIsNotNone(controller.held) controller.cancel_tap() controller.release(1800, 800, state) self.assertFalse(actions) self.assertIsNone(controller.held) def test_flicks_use_recent_movement_and_reject_noise_pauses_and_reversals(self): row = FeatureRow("", "Child", "", page="child", available=True) cases = ( ("fast", ((0, 0), (.02, 22), (.05, 55), (.075, 80)), (.08, 80), True), ("hold then flick", ((0, 0), (.5, 0), (.52, 24), (.55, 58), (.575, 80)), (.58, 80), True), ("long slow drag", ((0, 0), (.15, 40), (.3, 90), (.5, 125)), (.51, 125), True), ("short slow drag", ((0, 0), (.15, 25), (.3, 55), (.5, 80)), (.51, 80), False), ("below flick speed", ((0, 0), (.04, 25), (.08, 55), (.12, 80)), (.13, 80), False), ("too short", ((0, 0), (.02, 25), (.04, 45)), (.05, 45), False), ("pause before lift", ((0, 0), (.02, 22), (.05, 55), (.075, 80)), (.18, 80), False), ("lift position spike", ((0, 0), (.02, 22), (.05, 55)), (.06, 80), False), ("single movement spike", ((0, 0), (.05, 80)), (.06, 80), False), ("reversal", ((0, 0), (.02, 40), (.05, 110), (.075, 70)), (.08, 70), False), ("duplicate timestamps", ((0, 0), (0, 22), (0, 55), (0, 80)), (.01, 80), False), ("out of order timestamps", ((0, 0), (.02, 22), (.01, 55), (.04, 80)), (.05, 80), False), ) for scroll, start, direction in ((0, 2000, 1), (5, 1800, -1), (5, 519, -1)): state = FeatureSettingsState(rows=(row,) * 13, scroll=scroll) for name, samples, release, accepted in cases: with self.subTest(direction=direction, gesture=name): actions = [] controller = FeatureInput(actions.append) controller.press(start, 200, state) for timestamp, distance in samples: controller.move(start - direction * distance, 200, state, now=timestamp) self.assertFalse(actions) timestamp, distance = release controller.release(start - direction * distance, 200, state, now=timestamp) controller.release(start - direction * distance, 200, state, now=timestamp) self.assertEqual(actions, [FeatureUiAction("scroll", direction=direction)] if accepted else []) def test_sidebar_swipes_require_horizontal_travel_and_a_release_in_the_body(self): row = FeatureRow("action", "Action", "", actions=(("OPEN", True),), available=True) for subtitle in ("", "Description"): state = FeatureSettingsState(rows=(row,) * 7, scroll=5, subtitle=subtitle) y = feature_row_top(state) + 80 cases = (((400, y), ((900, y),), True), ((519, y), ((638, y),), False), # below distance threshold ((519, y), ((639, y),), True), ((100, y), ((500, y),), False), # remains in sidebar ((400, y), ((900, y), (400, y)), False), ((400, y), ((2145, y),), False), ((400, y), ((900, y + 250),), True), # horizontal ratio threshold ((400, y), ((900, y + 251),), False), ((400, y), ((400, y + 80), (900, y + 80)), False), ((400, y), ((400, 80), (900, y)), False), ((400, 50), ((900, y),), False), ((400, 1015), ((900, y),), False), ((-1, y), ((900, y),), False)) for start, moves, accepted in cases: with self.subTest(subtitle=subtitle, start=start, moves=moves): actions = [] controller = FeatureInput(actions.append) controller.press(*start, state) for position in moves: controller.move(*position, state) self.assertFalse(actions) controller.release(*moves[-1], state) controller.release(*moves[-1], state) self.assertEqual(actions, [FeatureUiAction("scroll", direction=-1)] if accepted else []) self.assertIsNone(controller.held) for changed in (replace(state, page="child"), replace(state, scroll=0), replace(state, sidebar_expanded=False)): actions = [] controller = FeatureInput(actions.append) controller.press(400, y, state) controller.move(900, y, changed) controller.release(900, y, state) self.assertFalse(actions) def test_flick_history_does_not_leak_into_a_new_press_or_canceled_touch(self): state = FeatureSettingsState(rows=(FeatureRow("", "Child", "", page="child", available=True),) * 7) for reset in (lambda controller: controller.cancel(), lambda controller: controller.press(2000, 200, state)): actions = [] controller = FeatureInput(actions.append) controller.press(2000, 200, state) for timestamp, x in ((0, 2000), (.03, 1960), (.06, 1920)): controller.move(x, 200, state, now=timestamp) reset(controller) controller.release(1920, 200, state, now=.07) self.assertFalse(actions) def test_swipe_context_changes_cancel_but_row_value_refresh_does_not(self): row = FeatureRow("switch", "Switch", "Off", b"0", ("Off", "On"), available=True) state = FeatureSettingsState(rows=(row,) * 7) for changed in (replace(state, page="child"), replace(state, scroll=5), replace(state, sidebar_expanded=False)): actions = [] controller = FeatureInput(actions.append) controller.press(2000, 204, state) controller.move(1850, 204, changed) controller.release(1700, 204, state) self.assertFalse(actions) actions = [] controller = FeatureInput(actions.append) changed = replace(state, rows=(replace(row, value="On", source=b"1"),) * 7) controller.press(2000, 204, state) controller.move(1850, 204, changed) controller.release(1700, 204, changed) self.assertEqual(actions, [FeatureUiAction("scroll")]) actions.clear() controller.press(2000, 204, state) controller.cancel() controller.release(1700, 204, state) self.assertFalse(actions) def test_swipe_clears_touch_before_dispatch(self): state = FeatureSettingsState(rows=(FeatureRow("", "Child", "", page="child", available=True),) * 7) actions = [] def emit(action): self.assertIsNone(controller.held) actions.append(action) controller = FeatureInput(emit) controller.press(1500, 200, state) controller.release(1380, 260, state) self.assertEqual(actions, [FeatureUiAction("scroll")]) def test_boolean_both_action_halves_preserve_requests_and_source_evidence(self): for value, source, desired in (("Off", b"0", "On"), ("On", b"1", "Off"), ("Off (saved mode 0 or 1)", b"1", "On")): for x in (1800, 1990): with self.subTest(value=value, x=x): row = FeatureRow("SLCFallback" if value.startswith("Off (") else "LaneCentering", "Switch", value, source, ("Off", "On"), available=True, dependencies=(("master", b"1"),)) state = FeatureSettingsState(page="lane", rows=(row,)) actions = [] controller = FeatureInput(actions.append) controller.press(x, feature_row_top(state) + 92, state) controller.release(x, feature_row_top(state) + 92, state) self.assertEqual(len(actions), 1) request = row_change(actions[0].row, actions[0].direction) self.assertIsNotNone(request) self.assertEqual((request.value, request.expected, request.dependencies), (desired, source, row.dependencies)) actions.clear() unavailable = replace(row, available=False) state = replace(state, rows=(unavailable,)) controller.press(x, feature_row_top(state) + 92, state) controller.release(x, feature_row_top(state) + 92, state) self.assertFalse(actions) self.assertIsNone(row_change(unavailable)) def test_curve_page_uses_shared_large_input_rows(self): with tempfile.TemporaryDirectory() as directory: params = Params(directory) owner = FeatureSettingsOwner(params, lambda group: group == "long", vehicle_fingerprint=lambda: "TOYOTA COROLLA TSS2") hub = owner.snapshot("hub", parked=True, system_long=True, lateral_context=True, metric=False) self.assertIn("curve", [row.page for row in hub.rows]) page = owner.snapshot("curve", parked=True, system_long=True, lateral_context=True, metric=False) master = page.rows[0] self.assertEqual(master.key, "CurveSpeedController") request = row_change(master) self.assertIsNotNone(request) if request is None: self.fail("the curve master has no action") self.assertTrue(owner.apply(request)) self.assertTrue(params.get_bool("CurveSpeedController")) def test_compact_entry_opens_child_scroller(self): class Button: def __init__(self, text, value): self.text, self.value, self.click = text, value, None def set_click_callback(self, callback): self.click = callback class Scroller: def __init__(self): self.cards = [] self.items = self.cards self._scroller = self def add_widgets(self, cards): self.cards.extend(cards) class Session: def feature_snapshot(self, page): return FeatureSettingsState(page=page, title="Driving Controls", rows=(FeatureRow("", "Speed Limit Controller", "Saved settings", page="slc", available=True),)) def feature_request(self, request): return False pushed = [] with patch.object(compact, "BigButton", Button), patch.object(compact, "GreyBigButton", Button), \ patch.object(compact, "NavScroller", Scroller), patch.object(compact.gui_app, "push_widget", pushed.append): entry = compact.FeatureSettingsCompact(Session()).entry_button() self.assertEqual(entry.text, "driving controls") entry.click() self.assertEqual([card.text for card in pushed[0].cards], ["driving controls", "speed limit controller"]) def test_compact_dependent_cards_and_confirmed_reset(self): class Button: def __init__(self, text, value): self.text, self.value, self.click = text, value, None def set_click_callback(self, callback): self.click = callback def set_enabled(self, enabled): self.enabled = enabled class ReadButton(Button): pass class Scroller: def __init__(self): self.items = [] self._scroller = self def add_widgets(self, cards): self.items.extend(cards) class Dialog: def __init__(self, title, icon, confirm_callback, **kwargs): self.confirm = confirm_callback class Session: def __init__(self, owner): self.owner = owner def feature_snapshot(self, page): return self.owner.snapshot(page, parked=True, system_long=True, lateral_context=True, metric=False) def feature_request(self, request): return self.owner.apply(request) def card(page, text): return next(item for item in page.items if item.text == text) with tempfile.TemporaryDirectory() as directory: params = Params(directory) cp = HondaCarInterface.get_non_essential_params("HONDA_CIVIC_BOSCH") cp.openpilotLongitudinalControl = True cp.pcmCruise = False cp.carVin = "TEST" owner = FeatureSettingsOwner(params, lambda group: True, vehicle_fingerprint=lambda: "TOYOTA COROLLA TSS2", vehicle_params=lambda: cp) adapter = compact.FeatureSettingsCompact(Session(owner)) pushed = [] with patch.object(compact, "BigButton", Button), patch.object(compact, "GreyBigButton", ReadButton), \ patch.object(compact, "NavScroller", Scroller), patch.object(compact, "BigConfirmationDialog", Dialog), \ patch.object(compact.gui_app, "push_widget", pushed.append), patch.object(compact.gui_app, "texture", lambda *args: None): adapter.open("slc") slc = pushed[-1] self.assertFalse(any(item.text in ('confirm lower limits', 'confirm higher limits') for item in slc.items)) adoption = card(slc, "automatic adoption") self.assertFalse(params.get_bool('SLCConfirmation')) adoption.click() self.assertTrue(params.get_bool('SLCConfirmation')) self.assertFalse(any(item.text in ('confirm lower limits', 'confirm higher limits') for item in slc.items)) adapter.open("aggressive/acceleration") curve = pushed[-1] for _ in range(6): card(curve, "preset").click() self.assertEqual(len([item for item in curve.items if item.text.endswith("mph point +")]), 10) params.put_bool("CustomPersonalities", True, block=True) path = Path(params.get_param_path("LongitudinalPersonalityProfiles")) path.write_bytes(b"{invalid") adapter.open("profiles") profiles = pushed[-1] card(profiles, "reset invalid profiles").click() pushed[-1].confirm() self.assertFalse(params.get_bool("CustomPersonalities")) self.assertIsNotNone(migrate_profile_document(path.read_bytes())) self.assertFalse(any(item.text == "reset invalid profiles" for item in profiles.items)) def test_large_hub_back_returns_to_settings_root(self): from openpilot.starpilot.ui.runtime_app import StarShellSession from openpilot.starpilot.ui.shell import ShellMode session = StarShellSession.__new__(StarShellSession) session._mode = ShellMode.SETTINGS session.selected = Destination.DRIVING_CONTROLS session.feature_page = "hub" session.feature_scroll = 0 self.enterContext(patch.object(session, "feature_snapshot", lambda: FeatureSettingsState())) self.enterContext(patch.object(session, "input", type("Input", (), {"cancel": lambda self: None})(), create=True)) session._feature_ui(FeatureUiAction("back")) self.assertEqual(session.selected, Destination.STAR) def test_curve_reset_confirmation_on_both_native_pages(self): from openpilot.starpilot.ui.runtime_app import StarShellSession from openpilot.system.ui.widgets import DialogResult class Dialog: def __init__(self, _question, *_args, callback=None, **_kwargs): self.confirm = callback or _args[-1] with tempfile.TemporaryDirectory() as directory: params = Params(directory) legacy = Path(params.get_param_path("CurvatureData")) legacy.write_bytes(b'{"0.001":{"average":2.0,"count":2}}') owner = FeatureSettingsOwner(params, lambda _group: True, vehicle_fingerprint=lambda: "TOYOTA COROLLA TSS2") row = next(row for row in owner.snapshot("curve", parked=True, system_long=True, lateral_context=True, metric=False).rows if row.key == "curve_reset") self.assertEqual(FeatureInput.target(1900, FEATURE_ROW_TOP + 77, FeatureSettingsState(page="curve", rows=(row,))).kind, "reset") large = StarShellSession.__new__(StarShellSession) self.enterContext(patch.object(large, "feature_request", owner.apply)) pushed = [] with patch("openpilot.system.ui.widgets.confirm_dialog.ConfirmDialog", Dialog), \ patch("openpilot.starpilot.ui.runtime_app.gui_app.push_widget", pushed.append): large._confirm_feature_reset(row) self.assertIsNone(params.get("CurveComfortData")) pushed[-1].confirm(DialogResult.CONFIRM) self.assertIsNotNone(params.get("CurveComfortData")) params.remove("CurveComfortData") row = next(row for row in owner.snapshot("curve", parked=True, system_long=True, lateral_context=True, metric=False).rows if row.key == "curve_reset") class Session: def feature_snapshot(self, page): return owner.snapshot(page, parked=True, system_long=True, lateral_context=True, metric=False) def feature_request(self, request): return owner.apply(request) adapter = compact.FeatureSettingsCompact(Session()) pushed = [] with patch.object(compact, "BigConfirmationDialog", Dialog), \ patch.object(compact.gui_app, "push_widget", pushed.append), \ patch.object(compact.gui_app, "texture", lambda *_args: None): adapter._confirm_reset(row, lambda: None) legacy.write_bytes(b'{"0.001":{"average":2.0,"count":2}}') pushed[-1].confirm() self.assertEqual(params.get("CurveComfortData"), {"version": 1, "buckets": {}}) row = next(row for row in owner.snapshot("curve", parked=True, system_long=True, lateral_context=True, metric=False).rows if row.key == "curve_reset") adapter._confirm_reset(row, lambda: None) pushed[-1].confirm() self.assertIsNotNone(params.get("CurveComfortData")) if __name__ == "__main__": unittest.main()