jim800121chen b9ee184586 feat(device): WP-0 序號串通 + serial 路由(ADR-018 走向 A' 第一階段)
- local-agent/server:detector 保留 kn_number(parseScanDevices 可測化、
  合成 ID 語意不動)+ DeviceInfo.SerialNumber + Manager serialToLocalID
  反查表 + GetDevice 雙查(sessions 先、serial 後,純加法)
- visiona-agent:pairing exchange 帶本地裝置清單(DeviceLister 失敗不中斷
  配對、timeout 2s、omitempty 舊版相容)
- visionA-backend:exchange 收 devices —— R1 取第一顆可用序號、R2 假序號
  0x00000000 寫 NULL、R4 同 owner 同序號復用既有 device_id(防 23505)、
  pg+mem 兩實作對齊
- 五個 proxy 操作(flash/inference/camera/connect/disconnect)收斂於
  GetDevice 單一入口,serial 路由一處涵蓋
- docs:api-spec.md §2 增補 POST /api/pairing/exchange(schema + R1/R2/R4)
- 測試:行為級四環節鏈 + dbtest 130 實跑 6/6 + DBOn 回歸 4/4;
  三 module build/vet/test 全綠
- review:通過 0C/0M/6Mi/5Sug(.autoflow/05-implementation/review/
  wp0-serial-routing-review.md);Minor #1 Rescan stale session 掛 WP-C 前置

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 09:31:51 +08:00

214 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package device
import (
"testing"
"visiona-agent/server/internal/driver"
)
type testDriver struct {
info driver.DeviceInfo
connected bool
}
func (d *testDriver) Info() driver.DeviceInfo { return d.info }
func (d *testDriver) Connect() error {
d.connected = true
d.info.Status = driver.StatusConnected
return nil
}
func (d *testDriver) Disconnect() error {
d.connected = false
d.info.Status = driver.StatusDisconnected
return nil
}
func (d *testDriver) IsConnected() bool { return d.connected }
func (d *testDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil }
func (d *testDriver) StartInference() error { return nil }
func (d *testDriver) StopInference() error { return nil }
func (d *testDriver) ReadInference() (*driver.InferenceResult, error) { return nil, nil }
func (d *testDriver) RunInference(_ []byte) (*driver.InferenceResult, error) { return nil, nil }
func (d *testDriver) GetModelInfo() (*driver.ModelInfo, error) { return nil, nil }
func TestManager_ListDevices(t *testing.T) {
registry := NewRegistry()
mgr := NewManager(registry, "")
mgr.sessions["test-1"] = NewSession(&testDriver{
info: driver.DeviceInfo{ID: "test-1", Name: "Test Device", Type: "KL720", Status: driver.StatusDetected},
})
devices := mgr.ListDevices()
if len(devices) != 1 {
t.Errorf("ListDevices() = %d, want 1", len(devices))
}
}
func TestManager_ListDevices_Empty(t *testing.T) {
registry := NewRegistry()
mgr := NewManager(registry, "")
// 無硬體時應回傳空 listR5-5a沒插硬體就空白不給 Mock 資料)
devices := mgr.ListDevices()
if len(devices) != 0 {
t.Errorf("ListDevices() with no hardware = %d, want 0", len(devices))
}
}
func TestManager_GetDevice(t *testing.T) {
registry := NewRegistry()
mgr := NewManager(registry, "")
mgr.sessions["test-1"] = NewSession(&testDriver{
info: driver.DeviceInfo{ID: "test-1"},
})
t.Run("existing device", func(t *testing.T) {
s, err := mgr.GetDevice("test-1")
if err != nil {
t.Errorf("GetDevice() error = %v", err)
}
if s == nil {
t.Error("GetDevice() returned nil session")
}
})
t.Run("non-existing device", func(t *testing.T) {
_, err := mgr.GetDevice("test-999")
if err == nil {
t.Error("GetDevice() expected error for non-existing device")
}
})
}
func TestManager_Connect(t *testing.T) {
registry := NewRegistry()
mgr := NewManager(registry, "")
td := &testDriver{info: driver.DeviceInfo{ID: "test-1", Status: driver.StatusDetected}}
mgr.sessions["test-1"] = NewSession(td)
// Drain event bus in background
go func() {
for range mgr.Events() {
}
}()
err := mgr.Connect("test-1")
if err != nil {
t.Errorf("Connect() error = %v", err)
}
if !td.connected {
t.Error("Connect() did not connect device")
}
}
// ==========================================================================
// Serial routing (ADR-018 WP-0): serialToLocalID + GetDevice dual lookup
// ==========================================================================
// seedDevice registers a test driver session and rebuilds the serial index,
// mimicking what Start()/Rescan() do after registration.
func seedDevice(mgr *Manager, info driver.DeviceInfo) *testDriver {
td := &testDriver{info: info}
mgr.mu.Lock()
mgr.sessions[info.ID] = NewSession(td)
mgr.rebuildSerialIndexLocked()
mgr.mu.Unlock()
return td
}
func TestManager_GetDevice_BySerial(t *testing.T) {
mgr := NewManager(NewRegistry(), "")
seedDevice(mgr, driver.DeviceInfo{ID: "kl520-0", SerialNumber: "0x1A2B3C4D"})
t.Run("serial hits the physical device", func(t *testing.T) {
s, err := mgr.GetDevice("0x1A2B3C4D")
if err != nil {
t.Fatalf("GetDevice(serial) error = %v", err)
}
if got := s.Driver.Info().ID; got != "kl520-0" {
t.Errorf("GetDevice(serial) resolved ID = %q, want kl520-0", got)
}
})
t.Run("local synthetic id still works (backward compat)", func(t *testing.T) {
s, err := mgr.GetDevice("kl520-0")
if err != nil {
t.Fatalf("GetDevice(localID) error = %v", err)
}
if s == nil {
t.Fatal("GetDevice(localID) returned nil session")
}
})
t.Run("unknown id (e.g. cloud UUID) still misses", func(t *testing.T) {
if _, err := mgr.GetDevice("d76718a9-cf15-4795-914e-df5ae46ee536"); err == nil {
t.Error("GetDevice(UUID) expected error, got nil")
}
})
}
func TestManager_GetDevice_MultipleSerials(t *testing.T) {
mgr := NewManager(NewRegistry(), "")
mgr.mu.Lock()
mgr.sessions["kl520-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl520-0", SerialNumber: "0xAAAA0001"}})
mgr.sessions["kl720-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl720-0", SerialNumber: "0xBBBB0002"}})
mgr.rebuildSerialIndexLocked()
mgr.mu.Unlock()
s1, err := mgr.GetDevice("0xAAAA0001")
if err != nil {
t.Fatalf("GetDevice(0xAAAA0001) error = %v", err)
}
if got := s1.Driver.Info().ID; got != "kl520-0" {
t.Errorf("serial 0xAAAA0001 resolved to %q, want kl520-0", got)
}
s2, err := mgr.GetDevice("0xBBBB0002")
if err != nil {
t.Fatalf("GetDevice(0xBBBB0002) error = %v", err)
}
if got := s2.Driver.Info().ID; got != "kl720-0" {
t.Errorf("serial 0xBBBB0002 resolved to %q, want kl720-0", got)
}
}
func TestManager_SerialIndex_SkipsEmptyAndFakeSerial(t *testing.T) {
mgr := NewManager(NewRegistry(), "")
mgr.mu.Lock()
// Empty serial (bridge could not read kn_number).
mgr.sessions["kl520-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl520-0"}})
// Fake serial from pyusb fallback (no SDK) — must not be routable.
mgr.sessions["kl720-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl720-0", SerialNumber: fakeSerialNumber}})
mgr.rebuildSerialIndexLocked()
mgr.mu.Unlock()
if _, err := mgr.GetDevice(""); err == nil {
t.Error("GetDevice(\"\") expected error (empty serial must not be indexed)")
}
if _, err := mgr.GetDevice(fakeSerialNumber); err == nil {
t.Errorf("GetDevice(%s) expected error (fake serial must not be routable)", fakeSerialNumber)
}
// Devices themselves remain reachable by local id.
if _, err := mgr.GetDevice("kl520-0"); err != nil {
t.Errorf("GetDevice(kl520-0) error = %v", err)
}
if _, err := mgr.GetDevice("kl720-0"); err != nil {
t.Errorf("GetDevice(kl720-0) error = %v", err)
}
}
func TestManager_SerialIndex_RemovedDeviceUnroutable(t *testing.T) {
mgr := NewManager(NewRegistry(), "")
seedDevice(mgr, driver.DeviceInfo{ID: "kl520-0", SerialNumber: "0x1A2B3C4D"})
// Simulate Rescan removing the device: delete session + rebuild index.
mgr.mu.Lock()
delete(mgr.sessions, "kl520-0")
mgr.rebuildSerialIndexLocked()
mgr.mu.Unlock()
if _, err := mgr.GetDevice("0x1A2B3C4D"); err == nil {
t.Error("GetDevice(serial) expected error after device removal")
}
}