from types import SimpleNamespace from typing import Any, cast from unittest.mock import Mock import pytest from openpilot.system.ui.lib import application @pytest.fixture def frame(monkeypatch): clock = SimpleNamespace(wall=100.0, cpu=20.0) def advance(wall, cpu): clock.wall += wall clock.cpu += cpu monkeypatch.setattr(application.time, "monotonic", lambda: clock.wall) thread_clock = Mock(side_effect=lambda: clock.cpu) monkeypatch.setattr(application.time, "thread_time", thread_clock) monkeypatch.setattr(application, "PC", False) monkeypatch.setattr(application, "RECORD", False) monkeypatch.setattr(application, "STRICT_MODE", False) app = application.GuiApplication() app._mouse = Mock() app._mouse.get_events.return_value = [] app._scale = 1 app._profile_render_frames = 0 app._target_fps = 60 app._last_fps_log_time = 0 app._nav_stack_ticks = [Mock()] widget = Mock() widget.render.side_effect = lambda _: advance(0.010, 0.003) app._nav_stack = [widget] app._show_fps = app._show_touches = False app._grid_size = 0 monkeypatch.setattr(application.rl, "window_should_close", Mock(side_effect=[False, True])) monkeypatch.setattr(application.rl, "begin_drawing", lambda: advance(0.004, 0.002)) monkeypatch.setattr(application.rl, "clear_background", lambda _: None) monkeypatch.setattr(application.rl, "end_drawing", lambda: advance(0.100, 0.001)) monkeypatch.setattr(application.rl, "get_frame_time", lambda: 1 / 60) monkeypatch.setattr(application.rl, "get_fps", lambda: 10) warning = Mock() monkeypatch.setattr(application.cloudlog, "warning", warning) return app, advance, thread_clock, warning def test_tuple_lifecycle_separates_draw_update_and_presentation(frame): app, advance, _, warning = frame app._frame_timing_enabled = True loop = app.render() should_render, frame_time, cpu_time = next(loop) assert should_render and frame_time == 1 / 60 assert cpu_time == pytest.approx(0.014) assert app.frame_timing is None # Complete only after the caller resumes. advance(0.020, 0.006) with pytest.raises(StopIteration): next(loop) assert tuple(app.frame_timing) == pytest.approx((14, 5, 20, 6, 100, 1)) app._nav_stack_ticks[0].assert_called_once_with() app._nav_stack[0].render.assert_called_once() assert app.frame == 1 assert "present wall/cpu 100.0/1.0ms" in warning.call_args.args[0] def test_disabled_timing_keeps_tuple_and_original_warning_without_cpu_clock(frame): app, advance, thread_clock, warning = frame app._frame_timing_enabled = False assert app.measure_frame_phase("camera", lambda: "rendered") == "rendered" loop = app.render() assert next(loop) == pytest.approx((True, 1 / 60, 0.014)) advance(0.020, 0.006) with pytest.raises(StopIteration): next(loop) thread_clock.assert_not_called() assert app.frame_timing is None warning.assert_called_once_with("FPS dropped below 60: 10") def test_nested_phases_record_only_this_frame_and_keep_fixed_names(frame, monkeypatch): app, advance, _, warning = frame app._frame_timing_enabled = True monkeypatch.setattr(application.rl, "window_should_close", Mock(side_effect=[False, False, True])) def first_draw(_): app.measure_frame_phase("camera", lambda: advance(0.030, 0.002)) app.measure_frame_phase("camera", lambda: advance(0.004, 0.001)) app.measure_frame_phase("model", lambda: advance(0.006, 0.004)) app._nav_stack[0].render.side_effect = first_draw loop = app.render() assert next(loop)[0] app.measure_frame_phase("ui_state", lambda: app.measure_frame_phase("transition", lambda: advance(0.020, 0.001))) app.measure_frame_phase("controllers", lambda: advance(0.002, 0.001)) assert next(loop)[0] assert set(app._frame_phases) == {"camera", "model"} # Previous update phases were reset. with pytest.raises(StopIteration): next(loop) logged = warning.call_args.args[0] assert "camera wall/cpu 34.0/3.0ms" in logged assert "model wall/cpu 6.0/4.0ms" in logged assert "ui_state wall/cpu 20.0/1.0ms" in logged assert "transition wall/cpu 20.0/1.0ms" in logged assert "controllers wall/cpu 2.0/1.0ms" in logged assert len(app._frame_phases) <= len(application.FRAME_PHASES) with pytest.raises(ValueError): app.measure_frame_phase("arbitrary", lambda: None) def test_screen_off_clears_stale_timing_without_render_or_warning(frame, monkeypatch): app, _, _, warning = frame app._frame_timing_enabled = True app.frame_timing = application.FrameTiming(1, 2, 3, 4, 5, 6) app._should_render = False monkeypatch.setattr(application.time, "sleep", lambda _: None) loop = app.render() assert next(loop) == (False, 0.0, 0.0) assert app.frame_timing is None with pytest.raises(StopIteration): next(loop) app._nav_stack[0].render.assert_not_called() app._nav_stack_ticks[0].assert_not_called() warning.assert_not_called() def test_timing_warning_retains_existing_rate_limit(frame): app, _, _, warning = frame app.frame_timing = application.FrameTiming(1, 2, 3, 4, 5, 6) app._monitor_fps() app._monitor_fps() warning.assert_called_once() def test_fps_debug_enables_phase_diagnostics_without_restarting(frame, monkeypatch): app, _, _, _ = frame monkeypatch.setattr(application, "UI_FRAME_TIMING", False) app.set_show_fps(True) assert app._show_fps and app._frame_timing_enabled app.set_show_fps(False) assert not app._show_fps and not app._frame_timing_enabled @pytest.mark.parametrize("awake", [True, False]) def test_before_frame_runs_outside_drawing_even_asleep(frame, monkeypatch, awake): app, _, _, _ = frame app._should_render = awake events = [] monkeypatch.setattr(application.time, "sleep", lambda _: None) monkeypatch.setattr(application.rl, "begin_drawing", lambda: events.append("draw")) loop = app.render(before_frame=lambda: events.append("callback")) assert next(loop)[0] is awake assert events == (["callback", "draw"] if awake else ["callback"]) with pytest.raises(StopIteration): next(loop) @pytest.mark.parametrize("awake", [True, False]) def test_ui_poll_precedes_every_frame_including_wake(frame, monkeypatch, awake): from openpilot.selfdrive.ui import ui app, _, _, _ = frame app._should_render = awake events = [] monkeypatch.setattr(application.time, "sleep", lambda _: None) monkeypatch.setattr(ui, "gui_app", app) monkeypatch.setattr(ui, "ui_state", SimpleNamespace(update=lambda: events.append("messages"))) controllers = SimpleNamespace(poll=lambda: events.append("controls")) preview = SimpleNamespace(poll=lambda: events.append("preview")) star = SimpleNamespace(_poll_projection_bookmark=lambda now: events.append("bookmark"), _poll_projection_slc=lambda now: events.append("slc")) app._nav_stack_ticks = [lambda: events.append("transition")] app._nav_stack[0].render.side_effect = lambda _: events.append("draw") loop = app.render(before_frame=lambda: ui.update_frame(preview, controllers, star)) assert next(loop)[0] is awake assert events == ["messages", "bookmark", "slc", "controls", "preview"] + (["transition", "draw"] if awake else []) with pytest.raises(StopIteration): next(loop) @pytest.mark.parametrize("awake", [True, False]) def test_projection_bookmark_is_saved_once_without_rendering_starpilot(frame, monkeypatch, awake): import ast import tempfile import time from pathlib import Path from openpilot.selfdrive.ui import ui from openpilot.starpilot.system.starport.projection_bookmark import BookmarkReceiver, BookmarkSender app, _, _, _ = frame app._should_render = awake monkeypatch.setattr(application.time, "sleep", lambda _: None) monkeypatch.setattr(application.rl, "window_should_close", Mock(side_effect=[False, False, True])) monkeypatch.setattr(ui, "gui_app", app) sm = SimpleNamespace(logMonoTime={'carState': 1}) def update(): sm.logMonoTime['carState'] = time.monotonic_ns() state = SimpleNamespace(started=True, started_frame=1, sm=sm, update=update) monkeypatch.setattr(ui, "ui_state", state) # Load the production dispatch without importing unrelated device-only UI libraries. source = Path(__file__).parents[3] / 'starpilot/ui/runtime_app.py' method = next(node for node in ast.walk(ast.parse(source.read_text())) if isinstance(node, ast.FunctionDef) and node.name == '_poll_projection_bookmark') namespace: dict[str, Any] = {'ui_state': state, 'display_message': Mock(return_value=object()), 'cast': cast} exec(compile(ast.Module(body=[method], type_ignores=[]), str(source), 'exec'), namespace) # Short Unix socket path for macOS. The real transport and native dispatch run # while the top widget is unrelated to StarPilot, or no widget is drawn at all. with tempfile.TemporaryDirectory(prefix='aa-bm-', dir='/tmp') as directory: path = Path(directory) / 'bookmark.sock' receiver, sender = BookmarkReceiver(path), BookmarkSender(path) publish = Mock() star = SimpleNamespace(_projection_bookmarks=receiver, _publish_bookmark=publish, _poll_projection_slc=Mock()) star._poll_projection_bookmark = lambda now: namespace['_poll_projection_bookmark'](star, now) loop = app.render(before_frame=lambda: ui.update_frame(star=star)) try: assert sender.send(time.monotonic_ns()) assert next(loop)[0] is awake publish.assert_called_once_with() assert app._nav_stack[0].render.call_count == int(awake) app._should_render = True assert next(loop)[0] publish.assert_called_once_with() # Waking must not replay the request. with pytest.raises(StopIteration): next(loop) finally: loop.close() sender.close() receiver.close() @pytest.mark.parametrize("initial", [False, True]) @pytest.mark.parametrize("during_update", [False, True]) def test_timing_toggle_uses_one_decision_for_entire_frame(frame, monkeypatch, initial, during_update): app, _, thread_clock, _ = frame monkeypatch.setattr(application, "UI_FRAME_TIMING", False) monkeypatch.setattr(application.rl, "draw_fps", lambda *_: None) app.set_show_fps(initial) before = None if during_update else lambda: app.set_show_fps(not initial) loop = app.render(before_frame=before) next(loop) if during_update: app.set_show_fps(not initial) app.measure_frame_phase("camera", lambda: None) assert bool(app._frame_phases) is initial with pytest.raises(StopIteration): next(loop) assert (app.frame_timing is not None) is initial assert bool(thread_clock.call_count) is initial assert app._collect_frame_timing is None def test_automatic_diagnostics_are_bounded_and_survive_fps_recovery(frame, monkeypatch): app, advance, _, warning = frame app._monitor_fps() # Existing warning starts capture; preferences stay unchanged. assert not app._show_fps and not app._frame_timing_enabled assert app._auto_timing_end == 106 app.frame_timing = application.FrameTiming(10, 2, 3, 1, 20, 1) app._frame_phases = {"camera": (5, 1)} for _ in range(application.AUTO_TIMING_MAX_SAMPLES + 10): app._monitor_fps() assert len(app._auto_timing_samples) == application.AUTO_TIMING_MAX_SAMPLES monkeypatch.setattr(application.rl, "get_fps", lambda: 60) advance(6.1, 0) app._monitor_fps() assert warning.call_count == 2 summary = warning.call_args.args[0] assert "360 slow samples" in summary assert "camera wall/cpu median 5.0/1.0ms" in summary assert "nested phases are not additive" in summary assert app._auto_timing_end == 0 and not app._auto_timing_samples monkeypatch.setattr(application.rl, "get_fps", lambda: 10) app._monitor_fps() assert app._auto_timing_end == 0 # Cooldown applies despite a new warning. def test_screen_off_abandons_automatic_window_without_summary(frame, monkeypatch): app, _, _, warning = frame app._monitor_fps() app._auto_timing_samples.append({"camera": (1, 1)}) warning.reset_mock() app._should_render = False monkeypatch.setattr(application.time, "sleep", lambda _: None) loop = app.render() next(loop) with pytest.raises(StopIteration): next(loop) assert not app._auto_timing_end and not app._auto_timing_samples assert app.frame_timing is None warning.assert_not_called() def test_auto_window_collects_real_frame_with_debug_disabled(frame): app, _, thread_clock, _ = frame app._frame_timing_enabled = False app._monitor_fps() app._nav_stack[0].render.side_effect = lambda _: app.measure_frame_phase("camera", lambda: None) loop = app.render() next(loop) with pytest.raises(StopIteration): next(loop) assert app.frame_timing is not None assert "camera" in app._auto_timing_samples[0] assert thread_clock.call_count > 0 assert not app._frame_timing_enabled and not app._show_fps def test_following_unmeasured_frame_clears_prior_payload(frame, monkeypatch): app, _, _, _ = frame monkeypatch.setattr(application.rl, "window_should_close", Mock(side_effect=[False, False, True])) monkeypatch.setattr(application.rl, "get_fps", lambda: 60) app._frame_timing_enabled = True loop = app.render() next(loop) app._frame_timing_enabled = False next(loop) assert app.frame_timing is None and not app._frame_phases with pytest.raises(StopIteration): next(loop) assert app.frame_timing is None