Files
StarPilot/selfdrive/ui/layouts/settings/tests/test_software_fast_update.py
T
2026-07-05 02:50:57 -04:00

193 lines
7.2 KiB
Python

import threading
import pytest
from openpilot.selfdrive.ui.layouts.settings import software
@pytest.fixture(autouse=True)
def fast_update_env(mocker):
import pyray as rl
from openpilot.system.ui.lib.application import gui_app as real_gui_app
mocker.patch.object(real_gui_app, "font", return_value=rl.Font())
mocker.patch.object(real_gui_app, "texture", return_value=mocker.MagicMock())
mocker.patch.object(real_gui_app, "push_widget")
mocker.patch.object(software, "ui_state")
software.ui_state.is_onroad.return_value = False
software.ui_state.is_offroad.return_value = True
mocker.patch.object(software, "HARDWARE")
mocker.patch.object(software, "time")
software._set_fast_update_state(stage=software.FastUpdateStage.IDLE, status="", error="")
@pytest.fixture
def sync_thread(mocker):
"""Make Thread run the target synchronously so tests are deterministic."""
def _sync_thread(target, daemon=True):
target()
return mocker.MagicMock()
mocker.patch.object(threading, "Thread", _sync_thread)
def test_fast_update_stages(mocker, sync_thread):
"""Fast update transitions through all stages on success (no submodules)."""
mocker.patch.object(software, "subprocess")
software.subprocess.run.side_effect = [
mocker.MagicMock(returncode=0, stdout="", stderr=""), # pkill
mocker.MagicMock(returncode=0, stdout="main\n", stderr=""), # rev-parse branch
mocker.MagicMock(returncode=0, stdout="abc123\n", stderr=""), # rev-parse HEAD
mocker.MagicMock(returncode=0, stdout="", stderr=""), # fetch
mocker.MagicMock(returncode=0, stdout="", stderr=""), # reset --hard
]
mocker.patch.object(software.Path, "is_file", return_value=False)
layout = software.SoftwareLayout()
layout._execute_fast_update()
state = software._get_fast_update_state()
assert state.stage == software.FastUpdateStage.REBOOTING
assert state.error == ""
assert state.status == "Update applied, rebooting..."
def test_fast_update_with_submodules(mocker, sync_thread):
"""Fast update runs submodule step when .gitmodules exists."""
mocker.patch.object(software, "subprocess")
software.subprocess.run.side_effect = [
mocker.MagicMock(returncode=0, stdout="", stderr=""), # pkill
mocker.MagicMock(returncode=0, stdout="main\n", stderr=""), # rev-parse branch
mocker.MagicMock(returncode=0, stdout="abc123\n", stderr=""), # rev-parse HEAD
mocker.MagicMock(returncode=0, stdout="", stderr=""), # fetch
mocker.MagicMock(returncode=0, stdout="", stderr=""), # reset --hard
mocker.MagicMock(returncode=0, stdout="", stderr=""), # submodule update
]
mocker.patch.object(software.Path, "is_file", return_value=True)
layout = software.SoftwareLayout()
layout._execute_fast_update()
state = software._get_fast_update_state()
assert state.stage == software.FastUpdateStage.REBOOTING
def test_fast_update_error_stores_full_message(mocker, sync_thread):
"""Error message is not truncated to 200 chars."""
long_error = "x" * 500
mocker.patch.object(software, "subprocess")
software.subprocess.run.side_effect = [
mocker.MagicMock(returncode=0, stdout="", stderr=""), # pkill
RuntimeError(long_error), # rev-parse fails
]
layout = software.SoftwareLayout()
layout._execute_fast_update()
state = software._get_fast_update_state()
assert state.stage == software.FastUpdateStage.ERROR
assert len(state.error) == 500
def test_fast_update_blocks_while_onroad(mocker):
"""Fast update is rejected when car is onroad."""
software.ui_state.is_onroad.return_value = True
layout = software.SoftwareLayout()
layout._execute_fast_update()
state = software._get_fast_update_state()
assert state.stage == software.FastUpdateStage.IDLE
def test_fast_update_error_clears_on_tap(mocker):
"""Tapping download button in ERROR state clears back to IDLE."""
software._set_fast_update_state(stage=software.FastUpdateStage.ERROR, error="some error", status="")
layout = software.SoftwareLayout()
layout._on_download_update()
state = software._get_fast_update_state()
assert state.stage == software.FastUpdateStage.IDLE
assert state.error == ""
assert state.status == ""
def test_fast_update_blocks_duplicate_long_press(mocker):
"""Long-press while fast update is active does nothing."""
from openpilot.system.ui.lib.application import gui_app as real_gui_app
software._set_fast_update_state(stage=software.FastUpdateStage.FETCHING, status="busy", error="")
layout = software.SoftwareLayout()
layout._on_long_press_fast_update()
# Confirm dialog should NOT have been pushed
assert real_gui_app.push_widget.call_count == 0
def test_fast_update_duplicate_execution_blocked(mocker):
"""A second _execute_fast_update while one is running is a no-op."""
software._fast_update_lock.acquire(blocking=False)
software._set_fast_update_state(stage=software.FastUpdateStage.FETCHING, status="busy", error="")
layout = software.SoftwareLayout()
layout._execute_fast_update()
# State should be unchanged (not reset to PREPARING)
state = software._get_fast_update_state()
assert state.stage == software.FastUpdateStage.FETCHING
software._fast_update_lock.release()
def test_fast_update_lock_released_on_error(mocker, sync_thread):
"""Lock is released when the worker fails, allowing a retry."""
mocker.patch.object(software, "subprocess")
software.subprocess.run.side_effect = [
mocker.MagicMock(returncode=0, stdout="", stderr=""), # pkill
RuntimeError("boom"), # rev-parse fails
]
layout = software.SoftwareLayout()
layout._execute_fast_update()
state = software._get_fast_update_state()
assert state.stage == software.FastUpdateStage.ERROR
# Lock should be released
assert software._fast_update_lock.acquire(blocking=False)
software._fast_update_lock.release()
def test_fast_update_stages_progress_through_states(mocker, sync_thread):
"""Verify each stage is set during execution by inspecting state after each call."""
stages_seen = []
mocker.patch.object(software, "subprocess")
# Slow down writes so we can observe intermediate state
original_set = software._set_fast_update_state
def tracking_set(**kwargs):
if "stage" in kwargs:
stages_seen.append(kwargs["stage"])
return original_set(**kwargs)
mocker.patch.object(software, "_set_fast_update_state", tracking_set)
software.subprocess.run.side_effect = [
mocker.MagicMock(returncode=0, stdout="", stderr=""), # pkill
mocker.MagicMock(returncode=0, stdout="main\n", stderr=""), # rev-parse branch
mocker.MagicMock(returncode=0, stdout="abc123\n", stderr=""), # rev-parse HEAD
mocker.MagicMock(returncode=0, stdout="", stderr=""), # fetch
mocker.MagicMock(returncode=0, stdout="", stderr=""), # reset --hard
]
layout = software.SoftwareLayout()
layout._execute_fast_update()
assert software.FastUpdateStage.PREPARING in stages_seen
assert software.FastUpdateStage.FETCHING in stages_seen
assert software.FastUpdateStage.APPLYING in stages_seen
assert software.FastUpdateStage.REBOOTING in stages_seen
assert software.FastUpdateStage.ERROR not in stages_seen