- serialIdentity() 三態比對(空/假序號 0x00000000 正規化為無身分、對齊 ADR-018 R2):Rescan 對既存合成 ID 比對身分——同一顆保留 live session、 換位/拔插以新 info 替換 session,serial 索引重建即正確 - stale drivers 改釋放鎖後 Disconnect(不再阻塞 GetDevice 路由)+ Disconnect 錯誤改 WARNING log - 測試 seam:detectFn/newDriverFn 注入 + newSessionDriverLocked 統一建構 (Start 行為零變動);新 4 支行為級測試(拔除位移/換位 rebind/ 同序號保 session/無身分回歸) - evidence:build/vet/test 全綠 + -race ok;review 通過 0C/0M/0Mi/4Sug (wp0-serial-routing-review.md「Minor #1 修復審查」章節) - WP-C 阻擋項解除;觀察項:多裝置實測留意 changed identity log (偵測順序抖動時解法在 detector 端排序) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
372 lines
12 KiB
Go
372 lines
12 KiB
Go
package device
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"visiona-agent/server/internal/driver"
|
||
)
|
||
|
||
type testDriver struct {
|
||
info driver.DeviceInfo
|
||
connected bool
|
||
disconnects int
|
||
}
|
||
|
||
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.disconnects++
|
||
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, "")
|
||
|
||
// 無硬體時應回傳空 list(R5-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)
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// Rescan identity check (WP-0 review Minor #1): positional synthetic IDs vs
|
||
// serial identity — the serial index must reflect current detection.
|
||
// ==========================================================================
|
||
|
||
// newRescanManager returns a Manager whose detection and driver factory are
|
||
// stubbed: Rescan sees whatever *detected currently holds and builds
|
||
// testDrivers instead of spawning the Python bridge.
|
||
func newRescanManager(detected *[]driver.DeviceInfo) *Manager {
|
||
mgr := NewManager(NewRegistry(), "")
|
||
mgr.detectFn = func(string) []driver.DeviceInfo { return *detected }
|
||
mgr.newDriverFn = func(info driver.DeviceInfo, _ string) driver.DeviceDriver {
|
||
return &testDriver{info: info}
|
||
}
|
||
return mgr
|
||
}
|
||
|
||
func TestManager_Rescan_UnplugShiftsPositionalID(t *testing.T) {
|
||
// Two same-chip dongles: kl520-0 = serial A, kl520-1 = serial B.
|
||
detected := []driver.DeviceInfo{
|
||
{ID: "kl520-0", Type: "KL520", SerialNumber: "0xAAAA0001"},
|
||
{ID: "kl520-1", Type: "KL520", SerialNumber: "0xBBBB0002"},
|
||
}
|
||
mgr := newRescanManager(&detected)
|
||
mgr.Rescan()
|
||
|
||
oldSession, err := mgr.GetDevice("kl520-0")
|
||
if err != nil {
|
||
t.Fatalf("GetDevice(kl520-0) error = %v", err)
|
||
}
|
||
oldDriver := oldSession.Driver.(*testDriver)
|
||
|
||
// Unplug A: detection now sees a single device, and the positional ID
|
||
// kl520-0 is occupied by the dongle with serial B.
|
||
detected = []driver.DeviceInfo{
|
||
{ID: "kl520-0", Type: "KL520", SerialNumber: "0xBBBB0002"},
|
||
}
|
||
mgr.Rescan()
|
||
|
||
// Removed serial A must no longer be routable.
|
||
if _, err := mgr.GetDevice("0xAAAA0001"); err == nil {
|
||
t.Error("GetDevice(0xAAAA0001) expected error after unplug, got nil (stale serial route)")
|
||
}
|
||
// Serial B must route to its new positional slot kl520-0.
|
||
s, err := mgr.GetDevice("0xBBBB0002")
|
||
if err != nil {
|
||
t.Fatalf("GetDevice(0xBBBB0002) error = %v", err)
|
||
}
|
||
if got := s.Driver.Info().ID; got != "kl520-0" {
|
||
t.Errorf("serial 0xBBBB0002 resolved to %q, want kl520-0", got)
|
||
}
|
||
if got := s.Driver.Info().SerialNumber; got != "0xBBBB0002" {
|
||
t.Errorf("session kl520-0 serial = %q, want 0xBBBB0002 (Device Info not refreshed)", got)
|
||
}
|
||
// The vacated positional slot kl520-1 must be gone.
|
||
if _, err := mgr.GetDevice("kl520-1"); err == nil {
|
||
t.Error("GetDevice(kl520-1) expected error after unplug, got nil")
|
||
}
|
||
// The replaced stale driver (old occupant of kl520-0) was disconnected.
|
||
if oldDriver.disconnects == 0 {
|
||
t.Error("stale driver for old kl520-0 occupant was not disconnected")
|
||
}
|
||
}
|
||
|
||
func TestManager_Rescan_SwapRebindsSerials(t *testing.T) {
|
||
detected := []driver.DeviceInfo{
|
||
{ID: "kl520-0", Type: "KL520", SerialNumber: "0xAAAA0001"},
|
||
{ID: "kl520-1", Type: "KL520", SerialNumber: "0xBBBB0002"},
|
||
}
|
||
mgr := newRescanManager(&detected)
|
||
mgr.Rescan()
|
||
|
||
// Replug in the opposite order: slots swap occupants.
|
||
detected = []driver.DeviceInfo{
|
||
{ID: "kl520-0", Type: "KL520", SerialNumber: "0xBBBB0002"},
|
||
{ID: "kl520-1", Type: "KL520", SerialNumber: "0xAAAA0001"},
|
||
}
|
||
mgr.Rescan()
|
||
|
||
sA, err := mgr.GetDevice("0xAAAA0001")
|
||
if err != nil {
|
||
t.Fatalf("GetDevice(0xAAAA0001) error = %v", err)
|
||
}
|
||
if got := sA.Driver.Info().ID; got != "kl520-1" {
|
||
t.Errorf("serial 0xAAAA0001 resolved to %q, want kl520-1 (swap not reflected)", got)
|
||
}
|
||
sB, err := mgr.GetDevice("0xBBBB0002")
|
||
if err != nil {
|
||
t.Fatalf("GetDevice(0xBBBB0002) error = %v", err)
|
||
}
|
||
if got := sB.Driver.Info().ID; got != "kl520-0" {
|
||
t.Errorf("serial 0xBBBB0002 resolved to %q, want kl520-0 (swap not reflected)", got)
|
||
}
|
||
}
|
||
|
||
func TestManager_Rescan_SameDeviceKeepsSession(t *testing.T) {
|
||
// Single-dongle regression: same serial across rescans → session (and
|
||
// its connection state) must be left untouched.
|
||
detected := []driver.DeviceInfo{
|
||
{ID: "kl520-0", Type: "KL520", SerialNumber: "0x1A2B3C4D"},
|
||
}
|
||
mgr := newRescanManager(&detected)
|
||
mgr.Rescan()
|
||
|
||
go func() {
|
||
for range mgr.Events() {
|
||
}
|
||
}()
|
||
if err := mgr.Connect("kl520-0"); err != nil {
|
||
t.Fatalf("Connect() error = %v", err)
|
||
}
|
||
before, _ := mgr.GetDevice("kl520-0")
|
||
|
||
mgr.Rescan()
|
||
|
||
after, err := mgr.GetDevice("kl520-0")
|
||
if err != nil {
|
||
t.Fatalf("GetDevice(kl520-0) after rescan error = %v", err)
|
||
}
|
||
if before != after {
|
||
t.Error("session was replaced on rescan although the device did not change")
|
||
}
|
||
if td := after.Driver.(*testDriver); !td.connected {
|
||
t.Error("connection state lost across rescan of an unchanged device")
|
||
}
|
||
if s, err := mgr.GetDevice("0x1A2B3C4D"); err != nil || s != after {
|
||
t.Errorf("serial route after rescan = (%v, %v), want same session", s, err)
|
||
}
|
||
}
|
||
|
||
func TestManager_Rescan_NoSerialSingleDeviceUntouched(t *testing.T) {
|
||
// No-SDK demo regression: empty/fake serials carry no identity, so the
|
||
// existing session must be kept (previous "left untouched" behavior).
|
||
for _, serials := range [][2]string{{"", ""}, {fakeSerialNumber, fakeSerialNumber}, {"", fakeSerialNumber}} {
|
||
detected := []driver.DeviceInfo{
|
||
{ID: "kl520-0", Type: "KL520", SerialNumber: serials[0]},
|
||
}
|
||
mgr := newRescanManager(&detected)
|
||
mgr.Rescan()
|
||
before, _ := mgr.GetDevice("kl520-0")
|
||
|
||
detected = []driver.DeviceInfo{
|
||
{ID: "kl520-0", Type: "KL520", SerialNumber: serials[1]},
|
||
}
|
||
mgr.Rescan()
|
||
|
||
after, err := mgr.GetDevice("kl520-0")
|
||
if err != nil {
|
||
t.Fatalf("serials %v: GetDevice(kl520-0) error = %v", serials, err)
|
||
}
|
||
if before != after {
|
||
t.Errorf("serials %v: session replaced although neither serial carries identity", serials)
|
||
}
|
||
}
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|