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) } } // TestManager_SerialIndex_FakeSerialCaseInsensitive verifies S-4: the fake // serial is matched case-insensitively (EqualFold), aligned with the backend. // A hex-casing variant like "0X00000000" must still be treated as "no serial" // and never routable, guarding against future bridge output casing changes. func TestManager_SerialIndex_FakeSerialCaseInsensitive(t *testing.T) { const fakeUpper = "0X00000000" // same value, different hex casing mgr := NewManager(NewRegistry(), "") mgr.mu.Lock() mgr.sessions["kl520-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl520-0", SerialNumber: fakeUpper}}) mgr.rebuildSerialIndexLocked() mgr.mu.Unlock() if _, err := mgr.GetDevice(fakeUpper); err == nil { t.Errorf("GetDevice(%s) expected error (fake serial variant must not be routable)", fakeUpper) } // serialIdentity should also treat the variant as no-identity. if got := serialIdentity(fakeUpper); got != "" { t.Errorf("serialIdentity(%s) = %q, want \"\" (fake serial variant = no identity)", fakeUpper, got) } // The device remains reachable by its local id. if _, err := mgr.GetDevice("kl520-0"); err != nil { t.Errorf("GetDevice(kl520-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") } }