jim800121chen 12b1fe3bad fix(local-agent): Rescan 序號身分比對替換 stale session + 鎖外 Disconnect
- 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>
2026-07-10 09:44:47 +08:00

372 lines
12 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
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, "")
// 無硬體時應回傳空 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)
}
}
// ==========================================================================
// 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")
}
}