package usb import ( "context" "errors" "runtime" "sync/atomic" "testing" "time" ) // mockEnumerator returns predefined snapshots per call number. // Each call to next() advances the snapshot index. type mockEnumerator struct { snapshots []map[string]string idx atomic.Int32 } func newMockEnumerator(snapshots ...map[string]string) *mockEnumerator { return &mockEnumerator{snapshots: snapshots} } func (m *mockEnumerator) next() (map[string]string, error) { i := int(m.idx.Add(1)) - 1 if i >= len(m.snapshots) { // Return last snapshot forever after exhaustion. i = len(m.snapshots) - 1 } // Deep copy to prevent mutation. out := make(map[string]string, len(m.snapshots[i])) for k, v := range m.snapshots[i] { out[k] = v } return out, nil } // noopSerialOpener is a mock serial opener that does nothing (no hardware needed). // It returns a mockPort that satisfies the serialPort interface. func noopSerialOpener(path string, baud int) (serialPort, error) { return newMockPort(), nil } // --- Test 1: Start with one device → exactly 1 device goroutine spawned --- func TestManagerStartSpawnsOneGoroutine(t *testing.T) { enum := newMockEnumerator( map[string]string{"0525:a4a7": "/dev/cu.mock0"}, // device present ) m := newManagerForTest(enum.next, noopSerialOpener, 20*time.Millisecond) baseline := runtime.NumGoroutine() ctx, cancel := context.WithCancel(context.Background()) defer cancel() m.Start(ctx) time.Sleep(60 * time.Millisecond) // allow poll loop and device goroutine to start after := runtime.NumGoroutine() // We expect at least 1 new goroutine (the device loop) + poll loop goroutine. // Baseline can fluctuate ±2 so check at least 1 extra goroutine appeared. if after <= baseline { t.Errorf("expected goroutine count to increase after Start(); baseline=%d after=%d", baseline, after) } m.Stop() } // --- Test 2: Device disconnect then reconnect emits Disconnected then Connected events --- func TestManagerDisconnectReconnectEvents(t *testing.T) { enum := newMockEnumerator( map[string]string{"0525:a4a7": "/dev/cu.mock0"}, // call 1: present map[string]string{}, // call 2: absent map[string]string{"0525:a4a7": "/dev/cu.mock0"}, // call 3: present again ) m := newManagerForTest(enum.next, noopSerialOpener, 20*time.Millisecond) ctx, cancel := context.WithCancel(context.Background()) defer cancel() m.Start(ctx) defer m.Stop() var events []DeviceEvent done := make(chan struct{}) go func() { defer close(done) for e := range m.Events() { events = append(events, e) if len(events) >= 3 { // Connected, Disconnected, Connected return } } }() select { case <-done: case <-time.After(2 * time.Second): t.Fatalf("timeout waiting for 3 events; got %d: %v", len(events), events) } if len(events) < 3 { t.Fatalf("expected at least 3 events, got %d: %v", len(events), events) } if events[0].State != StateConnected { t.Errorf("event[0]: expected Connected, got %v", events[0].State) } if events[1].State != StateDisconnected { t.Errorf("event[1]: expected Disconnected, got %v", events[1].State) } if events[2].State != StateConnected { t.Errorf("event[2]: expected Connected, got %v", events[2].State) } } // --- Test 3: Stop causes all goroutines to exit within 500ms --- func TestManagerStopGoroutineLeak(t *testing.T) { enum := newMockEnumerator( map[string]string{"0525:a4a7": "/dev/cu.mock0"}, ) m := newManagerForTest(enum.next, noopSerialOpener, 20*time.Millisecond) ctx, cancel := context.WithCancel(context.Background()) defer cancel() baseline := runtime.NumGoroutine() m.Start(ctx) time.Sleep(60 * time.Millisecond) // let goroutines spin up m.Stop() deadline := time.Now().Add(500 * time.Millisecond) for time.Now().Before(deadline) { current := runtime.NumGoroutine() // Allow ±2 goroutines for runtime background goroutines. if current <= baseline+2 { return // passed } time.Sleep(10 * time.Millisecond) } t.Errorf("goroutine leak: baseline=%d, after Stop()=%d (expected within ±2)", baseline, runtime.NumGoroutine()) } // --- Test 4: Send to absent device returns ErrDeviceNotConnected --- func TestManagerSendToAbsentDevice(t *testing.T) { // Empty snapshot — no devices connected. enum := newMockEnumerator(map[string]string{}) m := newManagerForTest(enum.next, noopSerialOpener, 20*time.Millisecond) ctx, cancel := context.WithCancel(context.Background()) defer cancel() m.Start(ctx) defer m.Stop() time.Sleep(40 * time.Millisecond) // let poll loop run replyCh := make(chan error, 1) err := m.Send("0525:a4a7", Command{ Type: CmdWrite, Reply: replyCh, }) if !errors.Is(err, ErrDeviceNotConnected) { t.Errorf("expected ErrDeviceNotConnected, got %v", err) } } // --- Test 5: Goroutine count stable across 5 simulated unplug/replug cycles --- func TestGoroutineStability(t *testing.T) { // Build snapshots: 5 cycles of present/absent/present snapshots := []map[string]string{} snapshots = append(snapshots, map[string]string{}) // initial empty for i := 0; i < 5; i++ { snapshots = append(snapshots, map[string]string{"0525:a4a7": "/dev/cu.mock0"}) // plug in snapshots = append(snapshots, map[string]string{}) // unplug } // Final state: device connected snapshots = append(snapshots, map[string]string{"0525:a4a7": "/dev/cu.mock0"}) enum := newMockEnumerator(snapshots...) m := newManagerForTest(enum.next, noopSerialOpener, 20*time.Millisecond) ctx, cancel := context.WithCancel(context.Background()) defer cancel() baseline := runtime.NumGoroutine() m.Start(ctx) // Wait long enough for all snapshots to be polled through. // 12 snapshots * 20ms poll interval = ~240ms minimum; give 2x buffer. time.Sleep(500 * time.Millisecond) m.Stop() // Give goroutines up to 500ms to settle. deadline := time.Now().Add(500 * time.Millisecond) var finalCount int for time.Now().Before(deadline) { finalCount = runtime.NumGoroutine() if finalCount <= baseline+2 { return // stable } time.Sleep(10 * time.Millisecond) } t.Errorf("goroutine count unstable after 5 replug cycles: baseline=%d, final=%d (max allowed %d)", baseline, finalCount, baseline+2) }