mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-07-16 14:52:15 +08:00
193 lines
7.2 KiB
Python
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
|