- Mi-3: insecure Transport 改用 DefaultTransport.Clone() 只覆寫 TLSConfig (保留 proxy/timeout,消除「開 skip 順便改掉 proxy 行為」副作用) - Mi-4: exchange 200 分支檢查 Success 欄位(避免 200+success:false 落到 誤導性的 missing session_token;既有回歸測試改用直接斷言防護不減反增) - S-3: collectLocalDevices 全空 serial 濾掉不送 devices 陣列(payload 對稱) - S-4: 假序號比對統一用 EqualFold(防未來 bridge 輸出 casing 變化) Reviewer 通過(0C/0M/1Mi/3Sug)。兩 module build/vet/test + -race 綠、 gitleaks 0、TLS 行為級測試全 PASS。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
397 lines
13 KiB
Go
397 lines
13 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)
|
||
}
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
}
|