from dataclasses import replace from datetime import datetime from types import SimpleNamespace as NS from unittest.mock import Mock, patch import pyray as rl import pytest from openpilot.cereal import messaging from openpilot.starpilot.ui.navigation_state import NavigationDisplay, distance_text, navigation_display from openpilot.starpilot.ui.onroad_navigation import ASSETS, MANIFEST, NavigationCard, arrival_time, icon_name from openpilot.starpilot.ui.onroad_state import AlertSize, OnroadAlert, OnroadState, SpeedLimitObservation from openpilot.starpilot.ui.presentation import Profile NOW = 10_000_000_000 DRIVE = 1_000_000_000 def message(): result = messaging.new_message('starpilotNavigation').starpilotNavigation.navigation result.version = 2 result.enabled = True result.status = 'guiding' result.startedMonoTime = DRIVE result.frameMonoTime = NOW result.locationMonoTime = NOW result.sessionId = 'session' result.revision = 'rev4' result.instruction = {'text': 'Turn left onto Main Street', 'maneuverType': 'turn', 'maneuverModifier': 'left', 'distanceMeters': 120, 'remainingDistanceMeters': 4300, 'remainingDurationSeconds': 510} return result def state(): return OnroadState(False, False, 10., 50., SpeedLimitObservation(), navigation=navigation_display(message(), now_ns=NOW, drive_id=DRIVE)) def test_large_navigation_optional_widget_controls_draw_and_touch_bounds(): card, observed = NavigationCard(Mock(profile=Profile.LARGE)), replace(state(), viewport_width=1860) placed = observed.customization['layouts']['large']['nav_card'] placed.update(x=900, y=650) bounds = card.bounds(observed) assert (bounds.x, bounds.y, bounds.width, bounds.height) == (900, 650, 560, 195) assert card.bounds(replace(observed, viewport_width=2160)).x == 1200 assert card.press(920, 670, observed) placed['enabled'] = False assert card.bounds(observed) is None assert not card.press(920, 670, observed) def test_actual_instruction_does_not_require_control_authority(): nav = message() nav.controlValid = False observed = navigation_display(nav, now_ns=NOW, drive_id=DRIVE) assert observed == NavigationDisplay(('session', DRIVE), 'Turn left onto Main Street', 'turn', 'left', 120., 4300., 510.) @pytest.mark.parametrize('field,value', [('enabled', False), ('status', 'routing'), ('startedMonoTime', 2), ('frameMonoTime', NOW + 1), ('frameMonoTime', NOW - 3_000_000_001), ('locationMonoTime', NOW - 3_000_000_001), ('sessionId', '')]) def test_invalid_or_stale_navigation_does_not_render(field, value): nav = message() setattr(nav, field, value) assert navigation_display(nav, now_ns=NOW, drive_id=DRIVE) is None def test_bad_distance_and_empty_instruction_do_not_render(): nav = message() nav.instruction.distanceMeters = float('nan') assert navigation_display(nav, now_ns=NOW, drive_id=DRIVE) is None nav.instruction.distanceMeters = 0 nav.instruction.text = ' ' assert navigation_display(nav, now_ns=NOW, drive_id=DRIVE) is None nav.status = 'arrived' assert navigation_display(nav, now_ns=NOW, drive_id=DRIVE).text == 'You have arrived' @pytest.mark.parametrize('meters,metric,expected', [(120, False, '400 ft'), (120, True, '125 m'), (1609.344, False, '1.0 mi'), (2000, True, '2.0 km')]) def test_distance_units(meters, metric, expected): assert distance_text(meters, metric) == expected def test_arrival_time_uses_remaining_route_duration(): assert arrival_time(18 * 60, datetime(2026, 10, 7, 15, 42)) == '4:00 PM' @pytest.mark.parametrize('profile', [Profile.COMPACT, Profile.LARGE]) def test_tap_collapses_and_swipe_never_toggles(profile): card, observed = NavigationCard(Mock(profile=profile)), state() original = card.bounds(observed) assert card.press(original.x + 20, original.y + 20, observed) card.release(original.x + 20, original.y + 20, observed) assert card.collapsed chip = card.bounds(observed) assert chip.width < original.width assert card.press(chip.x + 20, chip.y + 20, observed) card.move(chip.x + 30, chip.y + 20, observed) card.release(chip.x + 20, chip.y + 20, observed) assert card.collapsed # New runtime sessions start expanded; the prior tap cannot act on a restarted owner. assert card.press(chip.x + 20, chip.y + 20, observed) next_route = replace(observed, navigation=replace(observed.navigation, key=('new-session', DRIVE))) card.release(chip.x + 20, chip.y + 20, next_route) assert not card.collapsed @pytest.mark.parametrize('size', [AlertSize.SMALL, AlertSize.MID, AlertSize.FULL]) def test_native_alert_owns_space(size): card = NavigationCard(Mock(profile=Profile.COMPACT)) assert card.bounds(replace(state(), alert=OnroadAlert(size, 'warning'))) is None assert not card.press(120, 40, replace(state(), alert=OnroadAlert(size, 'warning'))) def test_slc_decision_keeps_its_touch_priority_only_where_it_overlaps(): from openpilot.starpilot.ui.onroad_state import ObservationKind card = NavigationCard(Mock(profile=Profile.COMPACT)) pending = SpeedLimitObservation(kind=ObservationKind.VALID, source='map', speed_limit_mps=22., pending_speed_limit_mps=22., session_id='s', decision_id=1, presentation_id=1, action_enabled=True) observed = replace(state(), slc_prompt_open=True, longitudinal_active=True, speed_limit=pending) observed.customization['layouts']['compact']['speed_limit_actions']['y'] = 180 assert card.bounds(observed) is None # The chooser now follows the sign, not this old action placement. observed.customization['layouts']['compact']['speed_limit_actions']['y'] = 4 observed.customization['layouts']['compact']['max_speed']['y'] = 58 assert card.bounds(observed) is None assert card.bounds(replace(observed, longitudinal_active=False)) is None # SLC being on, with no decision on screen, leaves the directions up. assert card.bounds(replace(state(), longitudinal_active=True, speed_limit=SpeedLimitObservation(action_enabled=True))) is not None large = NavigationCard(Mock(profile=Profile.LARGE)) assert large.bounds(replace(state(), longitudinal_active=True, speed_limit=pending)) is not None @pytest.mark.parametrize('profile', [Profile.COMPACT, Profile.LARGE]) def test_renderer_keeps_actual_instruction_and_distance(profile): fonts = Mock(profile=profile) fonts.measure.side_effect = lambda text, role, size: NS(width=len(text) * size * .48) card = NavigationCard(fonts, clock=lambda: datetime(2026, 10, 7, 15, 42)) with patch.object(card, '_icon', return_value=NS(width=100, height=100)), \ patch.object(rl, 'draw_rectangle_rounded'), patch.object(rl, 'draw_rectangle_rounded_lines_ex'), patch.object(rl, 'draw_texture_pro'): card.render(state()) text = ' '.join(call.args[0] for call in fonts.draw.call_args_list) assert 'Turn left onto Main Street' in text assert '400 ft' in text assert 'ETA 3:50 PM' in text def test_original_maneuver_assets_and_fallback_are_bounded(): import hashlib assert icon_name('turn', 'slightRight') == 'direction_turn_slight_right.png' assert icon_name('roundabout', 'left') == 'direction_roundabout_left.png' assert icon_name('unknown/../../', 'anything') == 'direction_turn_straight.png' for name, row in MANIFEST.items(): data = (ASSETS / name).read_bytes() assert len(data) == row['bytes'] assert hashlib.sha256(data).hexdigest() == row['sha256'] def test_native_card_tap_is_consumed_before_favorites_or_background(monkeypatch): from openpilot.starpilot.ui.runtime_app import StarShellSession from openpilot.starpilot.ui.shell import ShellMode card, observed = NavigationCard(Mock(profile=Profile.COMPACT)), state() session = StarShellSession.__new__(StarShellSession) session.snapshot = Mock(return_value=NS(onroad=observed)) session._input_snapshot = session.snapshot session._update_favorites = Mock() session.input = Mock(onroad=NS(claimed=False)) session.favorites = Mock(is_open=False) monkeypatch.setattr(session, 'view', NS(onroad=NS(navigation=card)), raising=False) session.press(ShellMode.ONROAD, 120, 40) session.favorites.press.assert_not_called() assert session.release(ShellMode.ONROAD, 120, 40) assert card.collapsed session.input.release.assert_not_called() def test_existing_slc_or_wheel_touch_owner_keeps_priority(monkeypatch): from openpilot.starpilot.ui.runtime_app import StarShellSession from openpilot.starpilot.ui.shell import ShellMode card = Mock() session = StarShellSession.__new__(StarShellSession) session.snapshot = Mock(return_value=NS(onroad=state())) session._input_snapshot = session.snapshot session._update_favorites = Mock() session.input = Mock(onroad=NS(claimed=True)) session.favorites = Mock(is_open=False) monkeypatch.setattr(session, 'view', NS(onroad=NS(navigation=card)), raising=False) session.press(ShellMode.ONROAD, 120, 40) card.press.assert_not_called() session.favorites.press.assert_not_called() def test_unchanged_instruction_reuses_measured_layout(): fonts = Mock(profile=Profile.COMPACT) fonts.measure.side_effect = lambda text, role, size: NS(width=len(text) * size * .48) card = NavigationCard(fonts) first = card._lines('Turn left onto Main Street', 220, 28) measured = fonts.measure.call_count assert card._lines('Turn left onto Main Street', 220, 28) == first assert fonts.measure.call_count == measured card._lines('Turn right onto Broad Street', 220, 28) assert fonts.measure.call_count > measured def test_saved_favorite_revision_does_not_expand_navigation_card(): card = NavigationCard(Mock(profile=Profile.COMPACT)) observed = state() assert card.press(120, 40, observed) card.release(120, 40, observed) nav = message() nav.revision = 'favorite-added' refreshed = replace(observed, navigation=navigation_display(nav, now_ns=NOW, drive_id=DRIVE)) assert refreshed.navigation.key == observed.navigation.key assert card.bounds(refreshed).width == 72 assert card.collapsed def test_replay_without_wall_anchor_omits_eta_but_keeps_recorded_navigation(): fonts = Mock(profile=Profile.LARGE) fonts.measure.side_effect = lambda text, role, size: NS(width=len(text) * size * .48) card = NavigationCard(fonts, clock=lambda: None) with patch.object(card, '_icon', return_value=NS(width=100, height=100)), \ patch.object(rl, 'draw_rectangle_rounded'), patch.object(rl, 'draw_rectangle_rounded_lines_ex'), patch.object(rl, 'draw_texture_pro'): card.render(state()) text = ' '.join(call.args[0] for call in fonts.draw.call_args_list) assert 'Turn left onto Main Street' in text assert '400 ft' in text assert 'min' in text assert 'ETA' not in text def test_replay_without_wall_anchor_omits_optional_hud_clock(): from openpilot.starpilot.ui.onroad import OnroadView from openpilot.starpilot.ui.onroad_customization import CLOCK_WIDGET, default_document observed = state() document = default_document() document['layouts']['large'][CLOCK_WIDGET]['enabled'] = True observed = replace(observed, customization=document) view = OnroadView.__new__(OnroadView) view.fonts = Mock(profile=Profile.LARGE) view.wall_clock = lambda: None view._clock(observed) view.fonts.draw.assert_not_called()