feat: 保存并登记当前 Client (#11)
This commit is contained in:
@@ -60,14 +60,46 @@ class MockAdminGatewayContractTest(unittest.TestCase):
|
||||
"reported_at": "2026-08-06T08:03:00Z",
|
||||
}
|
||||
|
||||
def test_gateway_has_only_three_business_methods(self):
|
||||
def test_gateway_has_only_four_business_methods(self):
|
||||
self.assertEqual(
|
||||
AdminGateway.__abstractmethods__,
|
||||
{"claim_next", "submit_result", "submit_failure"},
|
||||
{
|
||||
"register_client",
|
||||
"claim_next",
|
||||
"submit_result",
|
||||
"submit_failure",
|
||||
},
|
||||
)
|
||||
for forbidden in ("get_status", "heartbeat", "renew_lease"):
|
||||
self.assertFalse(hasattr(AdminGateway, forbidden))
|
||||
|
||||
def test_registration_is_idempotent_and_keeps_latest_profile(self):
|
||||
first = self.gateway.register_client(
|
||||
ClientInfo("client-001", "办公室电脑"), self.all_capabilities
|
||||
)
|
||||
second = self.gateway.register_client(
|
||||
ClientInfo("client-001", "仓库电脑"), self.all_capabilities
|
||||
)
|
||||
|
||||
self.assertTrue(first.registered)
|
||||
self.assertEqual(second.client_id, "client-001")
|
||||
self.assertEqual(self.gateway.registration_count, 1)
|
||||
saved_client, saved_capabilities = self.gateway.registered_client(
|
||||
"client-001"
|
||||
)
|
||||
self.assertEqual(saved_client.name, "仓库电脑")
|
||||
self.assertEqual(saved_capabilities, self.all_capabilities)
|
||||
|
||||
def test_registration_can_simulate_admin_failure(self):
|
||||
self.gateway.fail_next_call_temporarily()
|
||||
|
||||
with self.assertRaises(AdminGatewayError) as context:
|
||||
self.gateway.register_client(self.client, self.all_capabilities)
|
||||
|
||||
self.assertEqual(context.exception.code, "ADMIN_UNAVAILABLE")
|
||||
self.assertTrue(context.exception.retryable)
|
||||
self.assertEqual(self.gateway.registration_count, 0)
|
||||
|
||||
def test_claim_returns_none_when_no_task_exists(self):
|
||||
self.assertIsNone(
|
||||
self.gateway.claim_next(self.client, self.all_capabilities)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""当前 Client 本地身份服务测试。"""
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from src.current_client_service import CurrentClientService
|
||||
from src.settings_repository import SettingsRepository
|
||||
|
||||
|
||||
class CurrentClientServiceTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_directory = tempfile.TemporaryDirectory()
|
||||
path = Path(self.temp_directory.name) / "client.db"
|
||||
self.repository = SettingsRepository(path)
|
||||
self.generated_count = 0
|
||||
|
||||
def generate_id():
|
||||
self.generated_count += 1
|
||||
return f"CLIENT-GENERATED-{self.generated_count}"
|
||||
|
||||
self.service = CurrentClientService(self.repository, generate_id)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp_directory.cleanup()
|
||||
|
||||
def test_first_save_generates_id_and_later_save_reuses_it(self):
|
||||
first = self.service.save(" 办公室电脑 ")
|
||||
second = self.service.save("仓库电脑")
|
||||
|
||||
self.assertEqual(first.client_id, "CLIENT-GENERATED-1")
|
||||
self.assertEqual(second.client_id, first.client_id)
|
||||
self.assertEqual(second.client_name, "仓库电脑")
|
||||
self.assertEqual(self.generated_count, 1)
|
||||
self.assertEqual(self.service.load(), second)
|
||||
|
||||
def test_empty_name_is_allowed_but_more_than_50_chars_is_rejected(self):
|
||||
saved = self.service.save(" ")
|
||||
self.assertEqual(saved.client_name, "")
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "最多 50"):
|
||||
self.service.save("测" * 51)
|
||||
|
||||
self.assertEqual(self.service.load(), saved)
|
||||
|
||||
def test_non_string_corrupted_values_are_treated_as_missing(self):
|
||||
self.repository.set("admin.client_id", {"bad": "value"})
|
||||
self.repository.set("admin.client_name", 123)
|
||||
|
||||
saved = self.service.save("新设备")
|
||||
|
||||
self.assertEqual(saved.client_id, "CLIENT-GENERATED-1")
|
||||
self.assertEqual(saved.client_name, "新设备")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Admin 登记 HTTP 契约测试,不访问真实网络。"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import socket
|
||||
import unittest
|
||||
from http.client import RemoteDisconnected
|
||||
from urllib.error import HTTPError, URLError
|
||||
|
||||
from src.admin_gateway import (
|
||||
AdminGatewayError,
|
||||
AndroidDeviceInfo,
|
||||
ClaimCapabilities,
|
||||
ClientInfo,
|
||||
)
|
||||
from src.http_admin_gateway import HttpAdminGateway
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status: int, payload):
|
||||
self.status = status
|
||||
self._body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
def getcode(self):
|
||||
return self.status
|
||||
|
||||
def read(self):
|
||||
return self._body
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
|
||||
class RecordingOpener:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.request = None
|
||||
self.timeout = None
|
||||
|
||||
def __call__(self, request, timeout):
|
||||
self.request = request
|
||||
self.timeout = timeout
|
||||
if isinstance(self.response, Exception):
|
||||
raise self.response
|
||||
return self.response
|
||||
|
||||
|
||||
class HttpAdminGatewayTest(unittest.TestCase):
|
||||
def _capabilities(self, with_device=True):
|
||||
device = (
|
||||
AndroidDeviceInfo("192.168.0.173:5555") if with_device else None
|
||||
)
|
||||
return ClaimCapabilities(device=device)
|
||||
|
||||
def test_register_sends_confirmed_contract_and_parses_response(self):
|
||||
opener = RecordingOpener(
|
||||
FakeResponse(
|
||||
200,
|
||||
{
|
||||
"registered": True,
|
||||
"client_id": "CLIENT-001",
|
||||
"registered_at": "2026-08-06T10:00:00Z",
|
||||
},
|
||||
)
|
||||
)
|
||||
gateway = HttpAdminGateway(
|
||||
"http://127.0.0.1:8080/", "secret-token", 2.5, opener
|
||||
)
|
||||
|
||||
receipt = gateway.register_client(
|
||||
ClientInfo("CLIENT-001", "办公室电脑"), self._capabilities()
|
||||
)
|
||||
|
||||
self.assertTrue(receipt.registered)
|
||||
self.assertEqual(opener.request.get_method(), "PUT")
|
||||
self.assertEqual(
|
||||
opener.request.full_url,
|
||||
"http://127.0.0.1:8080/api/v1/client/registration",
|
||||
)
|
||||
headers = {
|
||||
key.lower(): value for key, value in opener.request.header_items()
|
||||
}
|
||||
self.assertEqual(headers["x-client-id"], "CLIENT-001")
|
||||
self.assertTrue(headers["x-request-id"])
|
||||
self.assertEqual(headers["authorization"], "Bearer secret-token")
|
||||
body = json.loads(opener.request.data.decode("utf-8"))
|
||||
self.assertEqual(body["client"]["name"], "办公室电脑")
|
||||
self.assertEqual(body["supported_types"], ["collect", "purchase"])
|
||||
self.assertEqual(body["device"]["platform"], "android")
|
||||
self.assertEqual(body["capabilities"]["purchase_mode"], "dry_run")
|
||||
self.assertEqual(opener.timeout, 2.5)
|
||||
|
||||
def test_optional_device_is_omitted(self):
|
||||
opener = RecordingOpener(
|
||||
FakeResponse(
|
||||
200,
|
||||
{
|
||||
"registered": True,
|
||||
"client_id": "CLIENT-001",
|
||||
"registered_at": "2026-08-06T10:00:00Z",
|
||||
},
|
||||
)
|
||||
)
|
||||
gateway = HttpAdminGateway(opener=opener)
|
||||
|
||||
gateway.register_client(
|
||||
ClientInfo("CLIENT-001"), self._capabilities(False)
|
||||
)
|
||||
|
||||
body = json.loads(opener.request.data.decode("utf-8"))
|
||||
self.assertNotIn("device", body)
|
||||
self.assertNotIn("authorization", {
|
||||
key.lower(): value for key, value in opener.request.header_items()
|
||||
})
|
||||
|
||||
def test_admin_error_preserves_code_retry_and_request_id(self):
|
||||
error_body = json.dumps(
|
||||
{
|
||||
"error": {
|
||||
"code": "INVALID_CLIENT_PROFILE",
|
||||
"message": "资料无效",
|
||||
"retryable": False,
|
||||
"request_id": "server-request-id",
|
||||
"details": {"field": "supported_types"},
|
||||
}
|
||||
}
|
||||
).encode("utf-8")
|
||||
error = HTTPError(
|
||||
"http://admin/api/v1/client/registration",
|
||||
422,
|
||||
"Unprocessable Entity",
|
||||
{},
|
||||
io.BytesIO(error_body),
|
||||
)
|
||||
gateway = HttpAdminGateway(opener=RecordingOpener(error))
|
||||
|
||||
with self.assertRaises(AdminGatewayError) as context:
|
||||
gateway.register_client(
|
||||
ClientInfo("CLIENT-001"), self._capabilities(False)
|
||||
)
|
||||
|
||||
self.assertEqual(context.exception.code, "INVALID_CLIENT_PROFILE")
|
||||
self.assertFalse(context.exception.retryable)
|
||||
self.assertEqual(context.exception.request_id, "server-request-id")
|
||||
|
||||
def test_timeout_and_connection_failure_are_retryable(self):
|
||||
for exception, code in (
|
||||
(URLError(socket.timeout()), "ADMIN_TIMEOUT"),
|
||||
(URLError("connection refused"), "ADMIN_UNAVAILABLE"),
|
||||
(RemoteDisconnected("closed"), "ADMIN_UNAVAILABLE"),
|
||||
):
|
||||
with self.subTest(code=code):
|
||||
gateway = HttpAdminGateway(
|
||||
opener=RecordingOpener(exception)
|
||||
)
|
||||
with self.assertRaises(AdminGatewayError) as context:
|
||||
gateway.register_client(
|
||||
ClientInfo("CLIENT-001"), self._capabilities(False)
|
||||
)
|
||||
self.assertEqual(context.exception.code, code)
|
||||
self.assertTrue(context.exception.retryable)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -11,6 +11,8 @@ from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from src.pdd_ui import PDDTaskPage
|
||||
from src.pdd_ui_event import PDDTaskPageEvent, summary_to_row
|
||||
from src.mock_admin_gateway import MockAdminGateway
|
||||
from src.settings_repository import SettingsRepository
|
||||
from src.task_models import NewClaimedTask, TaskStatus, TaskSummary, TaskType
|
||||
from src.task_repository import TaskRepository
|
||||
from src.ui_main import MainWindow
|
||||
@@ -130,7 +132,11 @@ class PDDTaskPageEventTest(unittest.TestCase):
|
||||
self.assertFalse(hasattr(row, "pdd_data"))
|
||||
|
||||
def test_main_window_keeps_event_object_alive(self):
|
||||
window = MainWindow(task_repository=self.repository)
|
||||
window = MainWindow(
|
||||
task_repository=self.repository,
|
||||
settings_repository=SettingsRepository(self.db_path),
|
||||
admin_gateway=MockAdminGateway(),
|
||||
)
|
||||
|
||||
self.assertIsInstance(window.pddTaskPageEvent, PDDTaskPageEvent)
|
||||
self.assertEqual(window.pddTaskPage.taskModel.data_row_count(), 0)
|
||||
|
||||
@@ -48,6 +48,27 @@ class SettingsRepositoryTests(unittest.TestCase):
|
||||
self.assertFalse(self.repository.delete("automation.dry_run"))
|
||||
self.assertIsNone(self.repository.get("automation.dry_run"))
|
||||
|
||||
def test_set_many_saves_values_with_one_timestamp(self) -> None:
|
||||
records = self.repository.set_many(
|
||||
{
|
||||
"admin.client_id": "CLIENT-001",
|
||||
"admin.client_name": "办公室电脑",
|
||||
},
|
||||
"2026-08-06T10:00:00Z",
|
||||
)
|
||||
|
||||
self.assertEqual(len(records), 2)
|
||||
self.assertEqual(
|
||||
self.repository.get("admin.client_id"), "CLIENT-001"
|
||||
)
|
||||
self.assertEqual(
|
||||
self.repository.get("admin.client_name"), "办公室电脑"
|
||||
)
|
||||
self.assertEqual(
|
||||
{record.updated_at for record in records},
|
||||
{"2026-08-06T10:00:00Z"},
|
||||
)
|
||||
|
||||
def test_empty_setting_key_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "setting_key"):
|
||||
self.repository.set(" ", True)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""设置页保存当前 Client 并后台登记的离屏测试。"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
from PyQt5.QtCore import QTimer
|
||||
from PyQt5.QtTest import QTest
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
|
||||
from src.mock_admin_gateway import MockAdminGateway
|
||||
from src.settings_repository import SettingsRepository
|
||||
from src.settings_ui import SettingsPage
|
||||
|
||||
|
||||
class SlowMockAdminGateway(MockAdminGateway):
|
||||
"""让登记停留一小段时间,用来证明主线程仍能处理事件。"""
|
||||
|
||||
def register_client(self, client, capabilities):
|
||||
time.sleep(0.08)
|
||||
return super().register_client(client, capabilities)
|
||||
|
||||
|
||||
class SettingsPageEventTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.app = QApplication.instance() or QApplication([])
|
||||
|
||||
def setUp(self):
|
||||
self.temp_directory = tempfile.TemporaryDirectory()
|
||||
self.db_path = Path(self.temp_directory.name) / "client.db"
|
||||
self.repository = SettingsRepository(self.db_path)
|
||||
|
||||
def tearDown(self):
|
||||
self.temp_directory.cleanup()
|
||||
|
||||
def _wait_until(self, predicate, timeout_ms=2000):
|
||||
elapsed = 0
|
||||
while not predicate() and elapsed < timeout_ms:
|
||||
QTest.qWait(10)
|
||||
elapsed += 10
|
||||
self.assertTrue(predicate(), "等待异步操作超时")
|
||||
|
||||
def test_existing_client_info_is_restored_when_page_opens(self):
|
||||
self.repository.set_many(
|
||||
{
|
||||
"admin.client_id": "CLIENT-EXISTING",
|
||||
"admin.client_name": "办公室电脑",
|
||||
}
|
||||
)
|
||||
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=MockAdminGateway(),
|
||||
)
|
||||
|
||||
self.assertEqual(page.deviceIdInput.text(), "CLIENT-EXISTING")
|
||||
self.assertEqual(page.deviceNameInput.text(), "办公室电脑")
|
||||
self.assertEqual(page.currentDeviceStatusLabel.text(), "本地设备信息已加载")
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_save_persists_locally_and_registers_with_mock(self):
|
||||
gateway = MockAdminGateway()
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=gateway,
|
||||
)
|
||||
page.deviceNameInput.setText(" 办公室电脑 ")
|
||||
|
||||
page.currentDeviceSaveButton.click()
|
||||
self.assertFalse(page.currentDeviceSaveButton.isEnabled())
|
||||
self._wait_until(lambda: gateway.registration_count == 1)
|
||||
self._wait_until(lambda: page.eventBinder._thread is None)
|
||||
|
||||
client_id = self.repository.get("admin.client_id")
|
||||
self.assertTrue(client_id.startswith("CLIENT-"))
|
||||
self.assertEqual(
|
||||
self.repository.get("admin.client_name"), "办公室电脑"
|
||||
)
|
||||
self.assertEqual(page.deviceIdInput.text(), client_id)
|
||||
self.assertEqual(
|
||||
page.currentDeviceStatusLabel.text(),
|
||||
"本地已保存,已登记到 Admin",
|
||||
)
|
||||
self.assertTrue(page.currentDeviceSaveButton.isEnabled())
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_admin_failure_keeps_local_values_and_allows_retry(self):
|
||||
gateway = MockAdminGateway()
|
||||
gateway.fail_next_call_temporarily()
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=gateway,
|
||||
)
|
||||
page.deviceNameInput.setText("仓库电脑")
|
||||
|
||||
page.currentDeviceSaveButton.click()
|
||||
self._wait_until(lambda: page.eventBinder._thread is None)
|
||||
|
||||
self.assertTrue(self.repository.get("admin.client_id"))
|
||||
self.assertEqual(self.repository.get("admin.client_name"), "仓库电脑")
|
||||
self.assertIn("本地已保存,Admin 登记失败", page.currentDeviceStatusLabel.text())
|
||||
self.assertIn("可再次点击保存重试", page.currentDeviceStatusLabel.text())
|
||||
self.assertTrue(page.currentDeviceSaveButton.isEnabled())
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_slow_registration_does_not_block_main_event_loop(self):
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=SlowMockAdminGateway(),
|
||||
)
|
||||
timer_fired = []
|
||||
QTimer.singleShot(10, lambda: timer_fired.append(True))
|
||||
|
||||
page.currentDeviceSaveButton.click()
|
||||
self._wait_until(lambda: bool(timer_fired), timeout_ms=500)
|
||||
self._wait_until(lambda: page.eventBinder._thread is None)
|
||||
|
||||
self.assertTrue(timer_fired)
|
||||
page.eventBinder.shutdown()
|
||||
page.deleteLater()
|
||||
|
||||
def test_shutdown_ignores_late_registration_result(self):
|
||||
page = SettingsPage(
|
||||
settings_repository=self.repository,
|
||||
admin_gateway=SlowMockAdminGateway(),
|
||||
)
|
||||
page.currentDeviceSaveButton.click()
|
||||
self._wait_until(
|
||||
lambda: "正在登记" in page.currentDeviceStatusLabel.text()
|
||||
)
|
||||
status_before_close = page.currentDeviceStatusLabel.text()
|
||||
|
||||
page.eventBinder.shutdown()
|
||||
QTest.qWait(120)
|
||||
|
||||
self.assertEqual(page.currentDeviceStatusLabel.text(), status_before_close)
|
||||
page.deleteLater()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user