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

328 lines
13 KiB
Python

from typing import Any
import ast
from concurrent.futures import Future
from pathlib import Path
import re
import time
import unittest
import uuid
from unittest.mock import Mock, patch
class Widget:
def show_event(self):
pass
def hide_event(self):
pass
class Scroller(Widget):
def __init__(self, rows, **kwargs):
self.rows = rows
self.scroll_panel = Scroll()
class Row(tuple):
def set_touch_valid_callback(self, callback):
pass
class Scroll:
def is_touch_valid(self):
return True
class App:
def __init__(self):
self.widgets = []
self.mouse_events = []
self.ticks = []
def add_nav_stack_tick(self, callback):
self.ticks.append(callback)
def remove_nav_stack_tick(self, callback):
self.ticks.remove(callback)
def push_widget(self, widget):
self.widgets.append(widget)
def get_active_widget(self):
return self.widgets[-1] if self.widgets else None
def pop_widget(self):
self.widgets.pop()
class Executor:
def __init__(self, **kwargs):
self.jobs = []
def submit(self, callback, *args, **kwargs):
future = Future()
self.jobs.append((future, callback, args, kwargs))
return future
def shutdown(self, **kwargs):
pass
class Owner:
def __init__(self, authority, session_valid):
self.authority = authority
self.session_valid = session_valid
def snapshot(self, **kwargs):
pass
def request(self, *args, **kwargs):
pass
def close(self):
pass
class Dialog:
def __init__(self, *args, **kwargs):
self.callback = kwargs.get('callback')
self.text = ''
def set_title(self, *args):
pass
def set_callback(self, callback):
self.callback = callback
def clear(self):
self.text = ''
class TestBluetoothLarge(unittest.TestCase):
def setUp(self):
path = Path(__file__).parents[1] / 'bluetooth_large.py'
tree = ast.parse(path.read_text())
tree.body = [node for node in tree.body if not isinstance(node, (ast.Import, ast.ImportFrom))]
self.app = App()
self.forgotten: list[str] = []
scope: dict[str, Any] = {'Callable': object, 'Future': Future, 'ThreadPoolExecutor': Executor, 're': re, 'time': time, 'uuid': uuid,
'Widget': Widget, 'Scroller': Scroller, 'BluetoothOwner': Owner, 'BluetoothRejected': ValueError,
'BluetoothUnavailable': RuntimeError, 'gui_app': self.app, 'button_item': lambda *a, **kw: Row((a, kw)),
'text_item': lambda *a, **kw: Row((a, kw)), 'ConfirmDialog': Dialog, 'alert_dialog': Dialog,
'DialogResult': type('Result', (), {'CONFIRM': 1}), 'Keyboard': Dialog,
'setup_status': lambda: 'Ready to pair', 'large_phone_rows': lambda allowed: [],
'forget_phone_keys': lambda allowed, address: self.forgotten.append(address),
'is_app_link': lambda device: bool(device.get('connected')) and not device.get('paired') and not device.get('trusted')}
tree.body.insert(0, ast.ImportFrom(module='__future__', names=[ast.alias(name='annotations')], level=0))
exec(compile(ast.fix_missing_locations(tree), str(path), 'exec'), scope)
self.panel_type = scope['BluetoothLarge']
self.panel = self.panel_type(lambda: True)
def test_constructor_with_menu_mouse_edge_creates_scroller(self):
self.app.mouse_events = [object()]
panel = self.panel_type(lambda: True)
self.assertIsInstance(panel._scroller, Scroller)
panel.show_event()
panel.hide_event()
def test_show_submits_snapshot_without_running_owner(self):
self.panel.show_event()
self.assertFalse(self.panel.pending.done())
self.assertEqual(self.panel.executor.jobs[0][2], ())
self.assertTrue(self.panel.owner.session_valid(self.panel.session))
def test_large_device_buttons_reach_galaxy_when_projection_owns_bluetooth(self):
from openpilot.starpilot.bluetooth.native_owner import NativeBluetoothOwner
from openpilot.starpilot.bluetooth.owner import AdapterBusyElsewhere, BluetoothOwner
self.panel.owner = NativeBluetoothOwner(lambda: True)
self.panel.active = True
self.panel.session = ('large', 'test')
self.panel.executor = Executor()
call = Mock(return_value=(200, {'devices': [], 'available': True, 'powered': True}))
with patch.object(BluetoothOwner, 'request', side_effect=AdapterBusyElsewhere('AA holds adapter')), \
patch('openpilot.starpilot.galaxy_bluetooth.phone_ui.galaxy_call', return_value=call):
for operation in ('scan', 'pair', 'connect', 'disconnect', 'forget'):
address = None if operation == 'scan' else 'AA:BB:CC:DD:EE:FF'
self.panel._submit_request(operation, address, None, False, self.panel.session, None, None, '')
future, callback, args, kwargs = self.panel.executor.jobs[-1]
future.set_result(callback(*args, **kwargs))
payload = {'operation': operation, **({'address': address} if address else {})}
call.assert_called_with('POST', '/api/bluetooth/action', payload)
def test_open_powered_panel_scans_once_and_stops_on_hide(self):
self.panel.show_event()
self.panel.pending.set_result({'available': True, 'powered': True, 'parked': True, 'discovering': False})
self.panel._tick()
self.assertEqual(self.panel.operation, 'auto_scan')
self.assertEqual(self.panel.executor.jobs[-1][3], {'delegate': False})
self.panel.pending.set_result({'available': True, 'powered': True, 'parked': True, 'discovering': False})
self.panel._tick()
self.assertEqual(sum(job[2] == ('scan',) for job in self.panel.executor.jobs), 1)
executor = self.panel.executor
self.panel.hide_event()
self.assertEqual(executor.jobs[-1][1], self.panel.owner.close)
def test_opening_the_page_never_scans_through_starport(self):
from openpilot.starpilot.bluetooth.native_owner import NativeBluetoothOwner
from openpilot.starpilot.bluetooth.owner import AdapterBusyElsewhere, BluetoothOwner
owner = NativeBluetoothOwner(lambda: True)
local = {'available': True, 'powered': True, 'parked': True, 'discovering': False, 'devices': []}
with patch.object(BluetoothOwner, 'request', side_effect=AdapterBusyElsewhere('AA holds adapter')), \
patch.object(BluetoothOwner, 'snapshot', return_value=local), \
patch('openpilot.starpilot.galaxy_bluetooth.phone_ui.galaxy_call') as factory:
self.assertEqual(owner.request('scan', delegate=False), local)
factory.assert_not_called()
self.assertIsNone(owner.galaxy)
def test_pairing_prompt_opens_once_and_expired_prompt_is_retired(self):
prompt = {'id': 'a' * 32, 'kind': 'confirmation', 'value': '123456', 'displayOnly': False}
status: dict[str, Any] = {'available': True, 'powered': True, 'parked': True, 'discovering': False,
'pairing': {'state': 'pairing', 'prompt': prompt}}
self.panel.show_event()
self.panel.pending.set_result(status)
self.panel._tick()
self.assertEqual(len(self.app.widgets), 1)
self.panel._snapshot()
self.panel.pending.set_result(status)
self.panel._tick()
self.assertEqual(len(self.app.widgets), 1)
self.panel._snapshot()
self.panel.pending.set_result({'available': True, 'powered': True, 'parked': True})
self.panel._tick()
self.assertEqual(len(self.app.widgets), 0)
def test_pin_cancel_rejects_prompt_and_clears_keyboard(self):
prompt = {'id': 'a' * 32, 'kind': 'pin', 'value': '', 'displayOnly': False}
self.panel.show_event()
self.panel.pending.set_result({'available': True, 'powered': True, 'parked': True,
'pairing': {'state': 'pairing', 'prompt': prompt}})
self.panel._tick()
keyboard = self.app.widgets[-1]
keyboard.text = '1234'
keyboard.callback(0)
self.assertEqual(keyboard.text, '')
job = self.panel.executor.jobs[-1]
self.assertEqual(job[2], ('pairing_response',))
self.assertFalse(job[3]['accepted'])
self.assertEqual(job[3]['value'], '')
def test_named_devices_and_disconnect_forget_presented(self):
self.panel.status = {'available': True, 'powered': True, 'parked': True, 'devices': [
{'address': 'AA:BB', 'name': 'Headphones', 'paired': True, 'connected': True}]}
self.panel._scroller = None
self.panel._rebuild()
rows = self.panel._scroller.rows
self.assertIn(('Headphones', 'Disconnect'), [row[0] for row in rows])
self.assertIn(('Forget Headphones', 'Forget'), [row[0] for row in rows])
def test_app_connections_are_grouped_apart_from_bluetooth_devices(self):
self.panel.status = {'available': True, 'powered': True, 'parked': True, 'devices': [
{'address': 'AA:BB', 'name': 'Headphones', 'paired': True, 'connected': True},
{'address': 'CC:DD', 'name': 'iPhone', 'paired': False, 'trusted': False, 'connected': True}]}
self.panel._scroller = None
self.panel._rebuild()
names = [row[0] for row in self.panel._scroller.rows]
self.assertIn(('Headphones', 'Disconnect'), names)
self.assertNotIn(('iPhone', 'Pair'), names)
self.assertNotIn(('iPhone', 'Disconnect'), names)
self.assertIn('App pairings', [name[0] for name in names])
self.assertIn(('iPhone', 'Forget'), names)
def test_forgetting_a_device_also_forgets_its_app_key(self):
self.panel.show_event()
self.panel.status = {'available': True, 'powered': True, 'parked': True}
self.panel._signature = repr((False, True, True, True, None, None, None, ()))
self.panel._forget('AA:BB')
self.assertEqual(self.forgotten, [])
self.assertIsNotNone(self.panel.queued)
self.panel.pending.set_result(self.panel.status)
self.panel._tick()
self.assertEqual(self.forgotten, [])
self.panel.pending.set_result(self.panel.status)
self.panel._tick()
self.assertEqual(self.forgotten, ['AA:BB'])
def test_failed_forget_keeps_its_app_key(self):
self.panel.show_event()
self.panel.status = {'available': True, 'powered': True, 'parked': True}
self.panel._forget('AA:BB')
self.panel.pending.set_result(self.panel.status)
self.panel._tick()
self.panel.pending.set_exception(ValueError('Bluetooth forget failed'))
self.panel._tick()
self.assertEqual(self.forgotten, [])
def test_request_waits_for_snapshot_then_submits_async(self):
self.panel.show_event()
self.panel.status = {'available': True, 'powered': True, 'parked': True}
self.panel._signature = repr((False, True, True, True, None, None, None, ()))
self.panel._request('connect', address='AA:BB')
self.assertIsNotNone(self.panel.queued)
self.panel.pending.set_result(self.panel.status)
self.panel._tick()
self.assertEqual(self.panel.operation, 'connect')
self.assertEqual(self.panel.executor.jobs[-1][2], ('connect',))
self.assertFalse(self.panel.pending.done())
def test_refresh_preserves_scroller_and_defers_active_touch(self):
self.panel.status = {'available': True, 'powered': True, 'parked': True, 'devices': []}
self.panel._rebuild()
scroller = self.panel._scroller
scroll = scroller.scroll_panel
updated: dict[str, Any] = dict(self.panel.status)
updated['unshownTelemetry'] = 123
self.panel.status = updated
self.panel._rebuild()
self.assertIs(self.panel._scroller, scroller)
self.panel._touch_held = True
self.panel.status['powered'] = False
self.panel._rebuild()
self.assertIs(self.panel._scroller, scroller)
self.panel._touch_held = False
self.panel._rebuild()
self.assertIs(self.panel._scroller.scroll_panel, scroll)
def test_paginated_host_can_refresh_rows_without_rendering_the_native_scroller(self):
self.panel.status = {'available': True, 'powered': True, 'parked': True, 'devices': []}
self.panel._update_state()
self.assertEqual(len(self.panel._scroller.rows), 2)
self.panel._touch_held = True
self.app.mouse_events = [type('Event', (), {'slot': 0, 'left_down': False})()]
self.panel._update_state()
self.assertFalse(self.panel._touch_held)
self.app.mouse_events = []
self.panel.status['powered'] = False
self.panel._update_state()
self.assertEqual(len(self.panel._scroller.rows), 1)
def test_unnamed_unpaired_devices_not_presented(self):
self.panel.status = {'available': True, 'powered': True, 'parked': True, 'devices': [
{'address': 'AA:BB:CC:DD:EE:FF', 'name': 'AA:BB:CC:DD:EE:FF', 'paired': False},
{'address': '11:22:33:44:55:66', 'name': 'Bluetooth Device · 55:66', 'paired': False}]}
self.panel._rebuild()
self.assertEqual(len(self.panel._scroller.rows), 2)
def test_hidden_session_cannot_confirm_forget(self):
self.panel.show_event()
calls = []
self.panel._confirm('Forget?', lambda: calls.append(True))
dialog = self.app.widgets[-1]
self.panel.hide_event()
dialog.callback(1)
self.assertEqual(calls, [])
self.assertFalse(self.panel.owner.session_valid(('large', 'old')))
def test_stale_completion_not_applied_on_return(self):
self.panel.show_event()
self.panel.hide_event()
close_future = self.panel.pending
self.panel.show_event()
close_future.set_result(None)
self.panel._tick()
self.assertIsNone(self.panel.status)
self.assertEqual(self.panel.operation, 'snapshot')
if __name__ == '__main__':
unittest.main()