feat: 实现 Android 设备搜索 (#21)
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
"""通过 ADB 查询当前电脑已经识别的 Android 设备。"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import subprocess
|
||||
from typing import Callable, List, Optional, Sequence
|
||||
|
||||
|
||||
DEFAULT_ADB_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
class AndroidDeviceSearchError(RuntimeError):
|
||||
"""ADB 无法完成设备搜索时抛出的可读错误。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AndroidDevice:
|
||||
"""一台由 ``adb devices -l`` 返回的设备。"""
|
||||
|
||||
serial: str
|
||||
connection_type: str
|
||||
model: str = "—"
|
||||
android_version: str = "—"
|
||||
status: str = "device"
|
||||
|
||||
|
||||
CommandRunner = Callable[[Sequence[str], float], subprocess.CompletedProcess]
|
||||
|
||||
|
||||
def run_adb_command(
|
||||
command: Sequence[str], timeout_seconds: float
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""执行一条隐藏窗口的 ADB 命令,返回标准输出和错误输出。"""
|
||||
|
||||
return subprocess.run(
|
||||
list(command),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
|
||||
|
||||
class AndroidDeviceService:
|
||||
"""查询 ADB 设备列表;本类不依赖 Qt,也不创建 uiautomator2 设备。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command_runner: CommandRunner = run_adb_command,
|
||||
timeout_seconds: float = DEFAULT_ADB_TIMEOUT_SECONDS,
|
||||
):
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("ADB 超时时间必须大于 0")
|
||||
self._run_command = command_runner
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
def search(
|
||||
self, is_cancelled: Optional[Callable[[], bool]] = None
|
||||
) -> List[AndroidDevice]:
|
||||
"""返回当前 ADB 设备;取消后停止补充设备属性。"""
|
||||
|
||||
result = self._run(["adb", "devices", "-l"], "搜索设备")
|
||||
devices = self.parse_devices(result.stdout or "")
|
||||
|
||||
enriched = []
|
||||
for device in devices:
|
||||
if is_cancelled is not None and is_cancelled():
|
||||
break
|
||||
if device.status != "device":
|
||||
enriched.append(device)
|
||||
continue
|
||||
|
||||
model = device.model
|
||||
if model == "—":
|
||||
model = self._read_property(device.serial, "ro.product.model")
|
||||
if is_cancelled is not None and is_cancelled():
|
||||
break
|
||||
android_version = self._read_property(
|
||||
device.serial, "ro.build.version.release"
|
||||
)
|
||||
enriched.append(
|
||||
AndroidDevice(
|
||||
serial=device.serial,
|
||||
connection_type=device.connection_type,
|
||||
model=model,
|
||||
android_version=android_version,
|
||||
status=device.status,
|
||||
)
|
||||
)
|
||||
return enriched
|
||||
|
||||
@staticmethod
|
||||
def parse_devices(output: str) -> List[AndroidDevice]:
|
||||
"""解析 ``adb devices -l`` 输出,不依赖操作系统换行格式。"""
|
||||
|
||||
devices = []
|
||||
for raw_line in output.splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("List of devices") or line.startswith("*"):
|
||||
continue
|
||||
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
|
||||
serial, status = parts[0], parts[1]
|
||||
fields = {}
|
||||
for token in parts[2:]:
|
||||
if ":" not in token:
|
||||
continue
|
||||
key, value = token.split(":", 1)
|
||||
fields[key] = value
|
||||
|
||||
model = fields.get("model", "—").replace("_", " ") or "—"
|
||||
devices.append(
|
||||
AndroidDevice(
|
||||
serial=serial,
|
||||
connection_type="Wi-Fi" if ":" in serial else "USB",
|
||||
model=model,
|
||||
status=status,
|
||||
)
|
||||
)
|
||||
return devices
|
||||
|
||||
def _read_property(self, serial: str, property_name: str) -> str:
|
||||
"""读取单个属性;失败只返回占位符,不丢掉已经发现的设备。"""
|
||||
|
||||
try:
|
||||
result = self._run(
|
||||
["adb", "-s", serial, "shell", "getprop", property_name],
|
||||
"读取设备信息",
|
||||
)
|
||||
except AndroidDeviceSearchError:
|
||||
return "—"
|
||||
value = (result.stdout or "").strip()
|
||||
return value or "—"
|
||||
|
||||
def _run(self, command: Sequence[str], action: str):
|
||||
try:
|
||||
result = self._run_command(command, self._timeout_seconds)
|
||||
except FileNotFoundError as exc:
|
||||
raise AndroidDeviceSearchError(
|
||||
"未找到 adb,请安装 Android platform-tools 并把 adb 加入 PATH 后重试"
|
||||
) from exc
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise AndroidDeviceSearchError(
|
||||
f"ADB {action}超时,请检查设备连接或重启 ADB 后重试"
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise AndroidDeviceSearchError(
|
||||
f"无法启动 adb:{str(exc) or '系统拒绝执行'}"
|
||||
) from exc
|
||||
|
||||
if result.returncode != 0:
|
||||
detail = (result.stderr or result.stdout or "未知错误").strip()
|
||||
raise AndroidDeviceSearchError(f"ADB {action}失败:{detail}")
|
||||
return result
|
||||
@@ -206,6 +206,7 @@ class SettingsPage(QWidget):
|
||||
parent=None,
|
||||
settings_repository=None,
|
||||
admin_gateway=None,
|
||||
android_device_service=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("settingsPage")
|
||||
@@ -218,6 +219,7 @@ class SettingsPage(QWidget):
|
||||
self,
|
||||
settings_repository=settings_repository,
|
||||
admin_gateway=admin_gateway,
|
||||
android_device_service=android_device_service,
|
||||
)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
@@ -277,6 +279,7 @@ class SettingsPage(QWidget):
|
||||
header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
||||
|
||||
self.deviceStatusLabel = CaptionLabel("当前使用设备:未选择", self)
|
||||
self.deviceStatusLabel.setAccessibleName("Android 设备搜索和选择状态")
|
||||
self.androidDeviceCard = self._build_android_device_card()
|
||||
|
||||
content = QWidget(self)
|
||||
|
||||
+158
-11
@@ -1,13 +1,17 @@
|
||||
"""设置页事件、当前 Client 本地保存和后台登记。
|
||||
"""设置页事件、ADB 搜索、当前 Client 本地保存和后台登记。
|
||||
|
||||
本文件不直接写 SQL。SQLite 保存和 Admin HTTP 请求由 Worker 在线程中执行,
|
||||
后台结果只通过信号返回主线程更新页面。
|
||||
本文件不直接写 SQL。ADB、SQLite 保存和 Admin HTTP 请求由 Worker 在线程中
|
||||
执行,后台结果只通过信号返回主线程更新页面。
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PyQt5.QtCore import QCoreApplication, QObject, QThread, pyqtSignal, pyqtSlot
|
||||
|
||||
from .android_device_service import (
|
||||
AndroidDeviceSearchError,
|
||||
AndroidDeviceService,
|
||||
)
|
||||
from .admin_gateway import (
|
||||
AdminGatewayError,
|
||||
AndroidDeviceInfo,
|
||||
@@ -21,6 +25,7 @@ from .current_client_service import (
|
||||
)
|
||||
from .http_admin_gateway import DEFAULT_ADMIN_BASE_URL, HttpAdminGateway
|
||||
from .settings_repository import SettingsRepository
|
||||
from .settings_ui import AndroidDeviceRow
|
||||
|
||||
DEVICE_ID_PLACEHOLDER = "待生成"
|
||||
|
||||
@@ -89,6 +94,41 @@ class CurrentClientSaveWorker(QObject):
|
||||
self.completed.emit()
|
||||
|
||||
|
||||
class AndroidDeviceSearchWorker(QObject):
|
||||
"""在后台线程查询 ADB,结果只通过信号返回主线程。"""
|
||||
|
||||
succeeded = pyqtSignal(object)
|
||||
failed = pyqtSignal(str)
|
||||
completed = pyqtSignal()
|
||||
|
||||
def __init__(self, service: AndroidDeviceService):
|
||||
super().__init__()
|
||||
self._service = service
|
||||
self._cancelled = False
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""停止后续属性查询;正在执行的命令等待自身超时结束。"""
|
||||
|
||||
self._cancelled = True
|
||||
|
||||
@pyqtSlot()
|
||||
def run(self) -> None:
|
||||
try:
|
||||
try:
|
||||
devices = self._service.search(lambda: self._cancelled)
|
||||
except AndroidDeviceSearchError as exc:
|
||||
if not self._cancelled:
|
||||
self.failed.emit(str(exc))
|
||||
except Exception as exc:
|
||||
if not self._cancelled:
|
||||
self.failed.emit(str(exc) or "无法搜索 Android 设备")
|
||||
else:
|
||||
if not self._cancelled:
|
||||
self.succeeded.emit(devices)
|
||||
finally:
|
||||
self.completed.emit()
|
||||
|
||||
|
||||
class SettingsPageEventBinder(QObject):
|
||||
"""绑定设备管理控件,并向应用层发出稳定事件。"""
|
||||
|
||||
@@ -103,6 +143,7 @@ class SettingsPageEventBinder(QObject):
|
||||
page,
|
||||
settings_repository: Optional[SettingsRepository] = None,
|
||||
admin_gateway: Optional[ClientRegistrationGateway] = None,
|
||||
android_device_service: Optional[AndroidDeviceService] = None,
|
||||
):
|
||||
super().__init__(page)
|
||||
self._page = page
|
||||
@@ -111,6 +152,12 @@ class SettingsPageEventBinder(QObject):
|
||||
self._closing = False
|
||||
self._thread: Optional[QThread] = None
|
||||
self._worker: Optional[CurrentClientSaveWorker] = None
|
||||
self._search_busy = False
|
||||
self._search_thread: Optional[QThread] = None
|
||||
self._search_worker: Optional[AndroidDeviceSearchWorker] = None
|
||||
self._android_device_service = (
|
||||
android_device_service or AndroidDeviceService()
|
||||
)
|
||||
|
||||
repository = settings_repository or SettingsRepository()
|
||||
self._client_service = CurrentClientService(repository)
|
||||
@@ -135,6 +182,7 @@ class SettingsPageEventBinder(QObject):
|
||||
self._start_save_current_device
|
||||
)
|
||||
page.searchButton.clicked.connect(self._request_search)
|
||||
self.searchRequested.connect(self._start_search)
|
||||
page.connectButton.clicked.connect(self._request_connect)
|
||||
page.saveButton.clicked.connect(self._request_save)
|
||||
page.deleteButton.clicked.connect(self._request_delete)
|
||||
@@ -150,7 +198,7 @@ class SettingsPageEventBinder(QObject):
|
||||
|
||||
@pyqtSlot()
|
||||
def _request_save_current_device(self) -> None:
|
||||
if self._busy or self._current_device_busy:
|
||||
if self._busy or self._current_device_busy or self._search_busy:
|
||||
return
|
||||
|
||||
device_id = self._page.deviceIdInput.text().strip()
|
||||
@@ -162,7 +210,7 @@ class SettingsPageEventBinder(QObject):
|
||||
def _start_save_current_device(
|
||||
self, _displayed_device_id: str, device_name: str
|
||||
) -> None:
|
||||
if self._closing or self._current_device_busy:
|
||||
if self._closing or self._current_device_busy or self._search_busy:
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -202,10 +250,40 @@ class SettingsPageEventBinder(QObject):
|
||||
|
||||
@pyqtSlot()
|
||||
def _request_search(self) -> None:
|
||||
if self._busy:
|
||||
if self._busy or self._current_device_busy or self._search_busy:
|
||||
return
|
||||
self.searchRequested.emit()
|
||||
|
||||
@pyqtSlot()
|
||||
def _start_search(self) -> None:
|
||||
if (
|
||||
self._closing
|
||||
or self._busy
|
||||
or self._current_device_busy
|
||||
or self._search_busy
|
||||
):
|
||||
return
|
||||
|
||||
self._search_busy = True
|
||||
self._sync_button_state()
|
||||
self._page.deviceStatusLabel.setText("正在搜索 Android 设备…")
|
||||
|
||||
thread = QThread(self)
|
||||
worker = AndroidDeviceSearchWorker(self._android_device_service)
|
||||
worker.moveToThread(thread)
|
||||
|
||||
thread.started.connect(worker.run)
|
||||
worker.succeeded.connect(self._on_search_succeeded)
|
||||
worker.failed.connect(self._on_search_failed)
|
||||
worker.completed.connect(thread.quit)
|
||||
worker.completed.connect(worker.deleteLater)
|
||||
thread.finished.connect(self._on_search_thread_finished)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
|
||||
self._search_thread = thread
|
||||
self._search_worker = worker
|
||||
thread.start()
|
||||
|
||||
@pyqtSlot()
|
||||
def _request_connect(self) -> None:
|
||||
if self._busy:
|
||||
@@ -251,12 +329,23 @@ class SettingsPageEventBinder(QObject):
|
||||
serial = self._page.deviceTableModel.checked_serial
|
||||
has_address = bool(self._page.addressInput.text().strip())
|
||||
self._page.currentDeviceSaveButton.setEnabled(
|
||||
not self._busy and not self._current_device_busy
|
||||
not self._busy
|
||||
and not self._current_device_busy
|
||||
and not self._search_busy
|
||||
)
|
||||
device_commands_enabled = not self._busy and not self._search_busy
|
||||
self._page.searchButton.setEnabled(
|
||||
device_commands_enabled and not self._current_device_busy
|
||||
)
|
||||
self._page.connectButton.setEnabled(
|
||||
device_commands_enabled and has_address
|
||||
)
|
||||
self._page.saveButton.setEnabled(
|
||||
device_commands_enabled and bool(serial)
|
||||
)
|
||||
self._page.deleteButton.setEnabled(
|
||||
device_commands_enabled and bool(serial)
|
||||
)
|
||||
self._page.searchButton.setEnabled(not self._busy)
|
||||
self._page.connectButton.setEnabled(not self._busy and has_address)
|
||||
self._page.saveButton.setEnabled(not self._busy and bool(serial))
|
||||
self._page.deleteButton.setEnabled(not self._busy and bool(serial))
|
||||
|
||||
def set_busy(self, busy: bool, message: str = "") -> None:
|
||||
"""切换界面忙碌状态,防止用户重复提交设备命令。"""
|
||||
@@ -285,6 +374,47 @@ class SettingsPageEventBinder(QObject):
|
||||
serial = self._page.deviceTableModel.checked_serial.strip()
|
||||
return AndroidDeviceInfo(serial) if serial else None
|
||||
|
||||
@pyqtSlot(object)
|
||||
def _on_search_succeeded(self, devices) -> None:
|
||||
if self._closing:
|
||||
return
|
||||
|
||||
rows = [
|
||||
AndroidDeviceRow(
|
||||
serial=device.serial,
|
||||
connection_type=device.connection_type,
|
||||
model=device.model,
|
||||
android_version=device.android_version,
|
||||
status=device.status,
|
||||
)
|
||||
for device in devices
|
||||
]
|
||||
self._page.set_android_devices(rows)
|
||||
if rows:
|
||||
self._page.deviceStatusLabel.setText(
|
||||
f"搜索完成:找到 {len(rows)} 台 Android 设备"
|
||||
)
|
||||
else:
|
||||
self._page.deviceStatusLabel.setText(
|
||||
"未找到 Android 设备,请检查 USB 调试或无线连接后重试"
|
||||
)
|
||||
|
||||
@pyqtSlot(str)
|
||||
def _on_search_failed(self, message: str) -> None:
|
||||
if self._closing:
|
||||
return
|
||||
self._page.deviceStatusLabel.setText(
|
||||
f"搜索失败:{message};设备列表未更改"
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
def _on_search_thread_finished(self) -> None:
|
||||
self._search_worker = None
|
||||
self._search_thread = None
|
||||
self._search_busy = False
|
||||
if not self._closing:
|
||||
self._sync_button_state()
|
||||
|
||||
@pyqtSlot(str, str)
|
||||
def _on_local_saved(self, client_id: str, client_name: str) -> None:
|
||||
if self._closing:
|
||||
@@ -348,3 +478,20 @@ class SettingsPageEventBinder(QObject):
|
||||
if thread is not None and thread.isRunning():
|
||||
thread.quit()
|
||||
thread.wait(4000)
|
||||
|
||||
search_worker = self._search_worker
|
||||
search_thread = self._search_thread
|
||||
if search_worker is not None:
|
||||
search_worker.cancel()
|
||||
for signal, slot in (
|
||||
(search_worker.succeeded, self._on_search_succeeded),
|
||||
(search_worker.failed, self._on_search_failed),
|
||||
):
|
||||
try:
|
||||
signal.disconnect(slot)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
if search_thread is not None and search_thread.isRunning():
|
||||
search_thread.quit()
|
||||
search_thread.wait(6000)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""ADB Android 设备搜索服务测试。"""
|
||||
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from src.android_device_service import (
|
||||
AndroidDeviceSearchError,
|
||||
AndroidDeviceService,
|
||||
)
|
||||
|
||||
|
||||
def completed(command, stdout="", stderr="", returncode=0):
|
||||
"""创建一条假的 ADB 命令结果。"""
|
||||
|
||||
return subprocess.CompletedProcess(command, returncode, stdout, stderr)
|
||||
|
||||
|
||||
class AndroidDeviceServiceTest(unittest.TestCase):
|
||||
def test_parse_usb_wifi_and_unavailable_devices(self):
|
||||
output = """List of devices attached
|
||||
USB-001 device product:p model:Pixel_8 device:husky transport_id:1
|
||||
192.168.0.173:5555 offline transport_id:2
|
||||
USB-002 unauthorized usb:1-2 transport_id:3
|
||||
"""
|
||||
|
||||
devices = AndroidDeviceService.parse_devices(output)
|
||||
|
||||
self.assertEqual(len(devices), 3)
|
||||
self.assertEqual(devices[0].connection_type, "USB")
|
||||
self.assertEqual(devices[0].model, "Pixel 8")
|
||||
self.assertEqual(devices[1].connection_type, "Wi-Fi")
|
||||
self.assertEqual(devices[1].status, "offline")
|
||||
self.assertEqual(devices[2].status, "unauthorized")
|
||||
|
||||
def test_search_reads_version_only_for_connected_device(self):
|
||||
commands = []
|
||||
|
||||
def runner(command, _timeout):
|
||||
commands.append(list(command))
|
||||
if command == ["adb", "devices", "-l"]:
|
||||
return completed(
|
||||
command,
|
||||
"List of devices attached\n"
|
||||
"USB-001 device model:Pixel_8 transport_id:1\n"
|
||||
"192.168.0.173:5555 offline transport_id:2\n",
|
||||
)
|
||||
return completed(command, "14\n")
|
||||
|
||||
devices = AndroidDeviceService(runner).search()
|
||||
|
||||
self.assertEqual(devices[0].android_version, "14")
|
||||
self.assertEqual(devices[1].android_version, "—")
|
||||
self.assertEqual(len(commands), 2)
|
||||
self.assertIn("ro.build.version.release", commands[1])
|
||||
|
||||
def test_missing_model_is_read_from_device(self):
|
||||
def runner(command, _timeout):
|
||||
if command == ["adb", "devices", "-l"]:
|
||||
return completed(
|
||||
command,
|
||||
"List of devices attached\nUSB-001 device transport_id:1\n",
|
||||
)
|
||||
if command[-1] == "ro.product.model":
|
||||
return completed(command, "Redmi K70\n")
|
||||
return completed(command, "15\n")
|
||||
|
||||
device = AndroidDeviceService(runner).search()[0]
|
||||
|
||||
self.assertEqual(device.model, "Redmi K70")
|
||||
self.assertEqual(device.android_version, "15")
|
||||
|
||||
def test_property_failure_keeps_discovered_device(self):
|
||||
def runner(command, _timeout):
|
||||
if command == ["adb", "devices", "-l"]:
|
||||
return completed(
|
||||
command,
|
||||
"List of devices attached\nUSB-001 device model:Phone\n",
|
||||
)
|
||||
return completed(command, stderr="device disconnected", returncode=1)
|
||||
|
||||
device = AndroidDeviceService(runner).search()[0]
|
||||
|
||||
self.assertEqual(device.serial, "USB-001")
|
||||
self.assertEqual(device.android_version, "—")
|
||||
|
||||
def test_missing_adb_has_recovery_message(self):
|
||||
def runner(_command, _timeout):
|
||||
raise FileNotFoundError("adb")
|
||||
|
||||
with self.assertRaisesRegex(AndroidDeviceSearchError, "未找到 adb"):
|
||||
AndroidDeviceService(runner).search()
|
||||
|
||||
def test_timeout_has_recovery_message(self):
|
||||
def runner(command, timeout):
|
||||
raise subprocess.TimeoutExpired(command, timeout)
|
||||
|
||||
with self.assertRaisesRegex(AndroidDeviceSearchError, "搜索设备超时"):
|
||||
AndroidDeviceService(runner).search()
|
||||
|
||||
def test_nonzero_exit_includes_adb_error(self):
|
||||
def runner(command, _timeout):
|
||||
return completed(command, stderr="ADB server didn't ACK", returncode=1)
|
||||
|
||||
with self.assertRaisesRegex(AndroidDeviceSearchError, "didn't ACK"):
|
||||
AndroidDeviceService(runner).search()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,13 +8,17 @@ from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt5.QtCore import QTimer
|
||||
from PyQt5.QtCore import Qt, QTimer
|
||||
from PyQt5.QtTest import QTest
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from src.android_device_service import (
|
||||
AndroidDevice,
|
||||
AndroidDeviceSearchError,
|
||||
)
|
||||
from src.mock_admin_gateway import MockAdminGateway
|
||||
from src.settings_repository import SettingsRepository
|
||||
from src.settings_ui import SettingsPage
|
||||
from src.settings_ui import AndroidDeviceRow, SettingsPage
|
||||
|
||||
|
||||
class SlowMockAdminGateway(MockAdminGateway):
|
||||
@@ -25,6 +29,34 @@ class SlowMockAdminGateway(MockAdminGateway):
|
||||
return super().register_client(client, capabilities)
|
||||
|
||||
|
||||
class SequenceAndroidDeviceService:
|
||||
"""按顺序返回搜索结果,便于测试刷新行为。"""
|
||||
|
||||
def __init__(self, results):
|
||||
self._results = list(results)
|
||||
self.call_count = 0
|
||||
|
||||
def search(self, _is_cancelled=None):
|
||||
result = self._results[min(self.call_count, len(self._results) - 1)]
|
||||
self.call_count += 1
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
|
||||
|
||||
class SlowAndroidDeviceService:
|
||||
"""模拟较慢的 ADB 搜索。"""
|
||||
|
||||
def __init__(self, delay=0.08):
|
||||
self.delay = delay
|
||||
self.call_count = 0
|
||||
|
||||
def search(self, _is_cancelled=None):
|
||||
self.call_count += 1
|
||||
time.sleep(self.delay)
|
||||
return [AndroidDevice("USB-001", "USB", "Pixel 8", "14")]
|
||||
|
||||
|
||||
class SettingsPageEventTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
@@ -144,6 +176,151 @@ class SettingsPageEventTest(unittest.TestCase):
|
||||
self.assertEqual(page.currentDeviceStatusLabel.text(), status_before_close)
|
||||
page.deleteLater()
|
||||
|
||||
def test_search_updates_table_and_device_status(self):
|
||||
service = SequenceAndroidDeviceService(
|
||||
[[
|
||||
AndroidDevice("USB-001", "USB", "Pixel 8", "14"),
|
||||
AndroidDevice(
|
||||
"192.168.0.173:5555",
|
||||
"Wi-Fi",
|
||||
status="offline",
|
||||
),
|
||||
]]
|
||||
)
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=MockAdminGateway(),
|
||||
android_device_service=service,
|
||||
)
|
||||
|
||||
page.searchButton.click()
|
||||
self.assertFalse(page.searchButton.isEnabled())
|
||||
self._wait_until(lambda: page.eventBinder._search_thread is None)
|
||||
|
||||
model = page.deviceTableModel
|
||||
self.assertEqual(model.rowCount(), 2)
|
||||
self.assertEqual(model.device_for_serial("USB-001").model, "Pixel 8")
|
||||
self.assertEqual(
|
||||
model.device_for_serial("192.168.0.173:5555").status,
|
||||
"offline",
|
||||
)
|
||||
offline_index = model.index(
|
||||
model.row_for_serial("192.168.0.173:5555"), 0
|
||||
)
|
||||
self.assertFalse(
|
||||
model.setData(offline_index, Qt.Checked, Qt.CheckStateRole)
|
||||
)
|
||||
self.assertEqual(
|
||||
page.deviceStatusLabel.text(),
|
||||
"搜索完成:找到 2 台 Android 设备",
|
||||
)
|
||||
self.assertTrue(page.searchButton.isEnabled())
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_empty_search_keeps_placeholder_rows(self):
|
||||
service = SequenceAndroidDeviceService([[]])
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=MockAdminGateway(),
|
||||
android_device_service=service,
|
||||
)
|
||||
|
||||
page.searchButton.click()
|
||||
self._wait_until(lambda: page.eventBinder._search_thread is None)
|
||||
|
||||
self.assertGreaterEqual(page.deviceTableModel.rowCount(), 1)
|
||||
self.assertIn("未找到 Android 设备", page.deviceStatusLabel.text())
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_search_failure_preserves_previous_rows(self):
|
||||
service = SequenceAndroidDeviceService(
|
||||
[AndroidDeviceSearchError("未找到 adb,请检查 PATH 后重试")]
|
||||
)
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=MockAdminGateway(),
|
||||
android_device_service=service,
|
||||
)
|
||||
page.set_android_devices(
|
||||
[AndroidDeviceRow("USB-OLD", "USB", "Old Phone", "12")]
|
||||
)
|
||||
|
||||
page.searchButton.click()
|
||||
self._wait_until(lambda: page.eventBinder._search_thread is None)
|
||||
|
||||
self.assertIsNotNone(
|
||||
page.deviceTableModel.device_for_serial("USB-OLD")
|
||||
)
|
||||
self.assertIn("搜索失败:未找到 adb", page.deviceStatusLabel.text())
|
||||
self.assertIn("设备列表未更改", page.deviceStatusLabel.text())
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_search_preserves_or_clears_checked_device_by_serial(self):
|
||||
connected = AndroidDevice("USB-001", "USB", "Pixel 8", "14")
|
||||
service = SequenceAndroidDeviceService([[connected], [connected], []])
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=MockAdminGateway(),
|
||||
android_device_service=service,
|
||||
)
|
||||
|
||||
page.searchButton.click()
|
||||
self._wait_until(lambda: page.eventBinder._search_thread is None)
|
||||
page.deviceTableModel.set_checked_serial("USB-001")
|
||||
|
||||
page.searchButton.click()
|
||||
self._wait_until(lambda: page.eventBinder._search_thread is None)
|
||||
self.assertEqual(page.deviceTableModel.checked_serial, "USB-001")
|
||||
|
||||
page.searchButton.click()
|
||||
self._wait_until(lambda: page.eventBinder._search_thread is None)
|
||||
self.assertEqual(page.deviceTableModel.checked_serial, "")
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_slow_search_does_not_block_or_start_twice(self):
|
||||
service = SlowAndroidDeviceService()
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=MockAdminGateway(),
|
||||
android_device_service=service,
|
||||
)
|
||||
timer_fired = []
|
||||
QTimer.singleShot(10, lambda: timer_fired.append(True))
|
||||
|
||||
page.searchButton.click()
|
||||
page.eventBinder._request_search()
|
||||
self._wait_until(lambda: bool(timer_fired), timeout_ms=500)
|
||||
self._wait_until(lambda: page.eventBinder._search_thread is None)
|
||||
|
||||
self.assertTrue(timer_fired)
|
||||
self.assertEqual(service.call_count, 1)
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_shutdown_ignores_late_search_result(self):
|
||||
service = SlowAndroidDeviceService()
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=MockAdminGateway(),
|
||||
android_device_service=service,
|
||||
)
|
||||
|
||||
page.searchButton.click()
|
||||
self._wait_until(lambda: page.eventBinder._search_thread is not None)
|
||||
status_before_close = page.deviceStatusLabel.text()
|
||||
page.eventBinder.shutdown()
|
||||
QTest.qWait(120)
|
||||
|
||||
self.assertEqual(page.deviceStatusLabel.text(), status_before_close)
|
||||
self.assertIsNone(
|
||||
page.deviceTableModel.device_for_serial("USB-001")
|
||||
)
|
||||
page.deleteLater()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user