jim800121chen 26b433eb10 fix(local-agent): review follow-up 小批(TLS Mi-3/4 + WP-0 S-3/4)
- 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>
2026-07-16 11:40:35 +08:00

397 lines
13 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)
}
}
// 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")
}
}