diff --git a/packages/common_client/lib/launchdarkly_common_client.dart b/packages/common_client/lib/launchdarkly_common_client.dart index 3bd6cbb..323cf0c 100644 --- a/packages/common_client/lib/launchdarkly_common_client.dart +++ b/packages/common_client/lib/launchdarkly_common_client.dart @@ -75,6 +75,12 @@ export 'src/data_sources/fdv2/mode_resolution.dart' ModeResolutionEntry, resolveMode, flutterDefaultResolutionTable; +export 'src/data_sources/fdv2/state_debounce_manager.dart' + show + DebouncedState, + OnDebounceReconcile, + DebounceTimerFactory, + StateDebounceManager; export 'src/data_sources/data_source_status.dart' show DataSourceStatusErrorInfo, DataSourceStatus, DataSourceState; diff --git a/packages/common_client/lib/src/data_sources/fdv2/state_debounce_manager.dart b/packages/common_client/lib/src/data_sources/fdv2/state_debounce_manager.dart new file mode 100644 index 0000000..447b2c7 --- /dev/null +++ b/packages/common_client/lib/src/data_sources/fdv2/state_debounce_manager.dart @@ -0,0 +1,132 @@ +import 'dart:async'; + +import '../../fdv2_connection_mode.dart'; + +/// Snapshot of the desired state accumulated within a debounce window. +/// +/// Each field is one of the axes that participate in debouncing per the +/// FDv2 connection-mode resolution spec: network availability, application +/// lifecycle, and the user-requested mode (when set via the public +/// `setMode` API). `identify` calls intentionally do not participate. +final class DebouncedState { + final bool networkAvailable; + final bool inForeground; + final FDv2ConnectionMode? requestedMode; + + const DebouncedState({ + required this.networkAvailable, + required this.inForeground, + required this.requestedMode, + }); + + DebouncedState _copyWith({ + bool? networkAvailable, + bool? inForeground, + Object? requestedMode = _sentinel, + }) { + return DebouncedState( + networkAvailable: networkAvailable ?? this.networkAvailable, + inForeground: inForeground ?? this.inForeground, + requestedMode: identical(requestedMode, _sentinel) + ? this.requestedMode + : requestedMode as FDv2ConnectionMode?, + ); + } + + static const _sentinel = Object(); +} + +/// Callback fired when the debounce window closes with the final +/// accumulated [DebouncedState]. +typedef OnDebounceReconcile = void Function(DebouncedState state); + +/// Factory that produces a one-shot timer used to schedule the debounce +/// fire. Exists primarily so tests can substitute a controllable +/// implementation (e.g. via `fake_async`). +typedef DebounceTimerFactory = Timer Function( + Duration duration, void Function() callback); + +Timer _defaultTimerFactory(Duration d, void Function() cb) => Timer(d, cb); + +/// Debounces network availability, lifecycle, and user-requested mode +/// signals into a single reconciliation callback, per CSFDV2 CONNMODE +/// section 3.5. +/// +/// Each `setX` call updates the relevant component of the pending state +/// and resets the debounce timer. When the timer fires, [onReconcile] is +/// invoked with the final [DebouncedState]. Per-setter early-return +/// suppresses unchanged values; the consumer is responsible for deciding +/// whether the resolved state requires action. +/// +/// A [debounceWindow] of [Duration.zero] bypasses the timer entirely: +/// state changes fire [onReconcile] synchronously. Useful for tests and +/// FDv1-style immediate-application paths. +final class StateDebounceManager { + final Duration _debounceWindow; + final OnDebounceReconcile _onReconcile; + final DebounceTimerFactory _timerFactory; + + DebouncedState _pending; + Timer? _timer; + bool _closed = false; + + StateDebounceManager({ + required DebouncedState initialState, + required Duration debounceWindow, + required OnDebounceReconcile onReconcile, + DebounceTimerFactory? timerFactory, + }) : _pending = initialState, + _debounceWindow = debounceWindow, + _onReconcile = onReconcile, + _timerFactory = timerFactory ?? _defaultTimerFactory; + + void setNetworkAvailable(bool available) { + if (_pending.networkAvailable == available) { + return; + } + _pending = _pending._copyWith(networkAvailable: available); + _scheduleOrFire(); + } + + void setInForeground(bool inForeground) { + if (_pending.inForeground == inForeground) { + return; + } + _pending = _pending._copyWith(inForeground: inForeground); + _scheduleOrFire(); + } + + void setRequestedMode(FDv2ConnectionMode? mode) { + if (_pending.requestedMode == mode) { + return; + } + _pending = _pending._copyWith(requestedMode: mode); + _scheduleOrFire(); + } + + void close() { + _closed = true; + _timer?.cancel(); + _timer = null; + } + + void _scheduleOrFire() { + if (_closed) { + return; + } + if (_debounceWindow == Duration.zero) { + _onReconcile(_pending); + return; + } + _timer?.cancel(); + _timer = _timerFactory(_debounceWindow, _onTimer); + } + + void _onTimer() { + _timer = null; + if (_closed) { + return; + } + _onReconcile(_pending); + } +} diff --git a/packages/common_client/pubspec.yaml b/packages/common_client/pubspec.yaml index 1780929..42f03ed 100644 --- a/packages/common_client/pubspec.yaml +++ b/packages/common_client/pubspec.yaml @@ -18,3 +18,4 @@ dev_dependencies: test: ^1.24.3 lints: ^3.0.0 mocktail: ^1.0.1 + fake_async: ^1.3.1 diff --git a/packages/common_client/test/data_sources/fdv2/state_debounce_manager_test.dart b/packages/common_client/test/data_sources/fdv2/state_debounce_manager_test.dart new file mode 100644 index 0000000..86ec359 --- /dev/null +++ b/packages/common_client/test/data_sources/fdv2/state_debounce_manager_test.dart @@ -0,0 +1,214 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:launchdarkly_common_client/src/data_sources/fdv2/state_debounce_manager.dart'; +import 'package:launchdarkly_common_client/src/fdv2_connection_mode.dart'; +import 'package:test/test.dart'; + +const _initial = DebouncedState( + networkAvailable: true, + inForeground: true, + requestedMode: null, +); + +const _debounceWindow = Duration(seconds: 1); + +void main() { + group('default window', () { + test('does not fire when state never changes', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(true); + manager.setInForeground(true); + + async.elapse(const Duration(seconds: 5)); + expect(calls, isEmpty); + manager.close(); + }); + }); + + test('fires once after the window closes following a single change', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(false); + async.elapse(_debounceWindow); + + expect(calls, hasLength(1)); + expect(calls.single.networkAvailable, isFalse); + manager.close(); + }); + }); + + test('rapid changes reset the timer; one fire after final quiet', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(false); + async.elapse(const Duration(milliseconds: 500)); + manager.setNetworkAvailable(true); + async.elapse(const Duration(milliseconds: 500)); + manager.setNetworkAvailable(false); + async.elapse(const Duration(milliseconds: 500)); + + expect(calls, isEmpty); + + async.elapse(_debounceWindow); + expect(calls, hasLength(1)); + expect(calls.single.networkAvailable, isFalse); + manager.close(); + }); + }); + + test('flap-and-return fires the resolved (matching-actual) state', () { + // Spec 3.5 example 1: starting from {online,...}, network flaps offline + // and back to online; resolved state matches the starting actual state. + // The debouncer still fires (its job is to deliver the resolved tuple); + // the consumer is responsible for the no-op-if-no-change check. + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(false); + manager.setNetworkAvailable(true); + manager.setNetworkAvailable(false); + manager.setNetworkAvailable(true); + + async.elapse(_debounceWindow); + expect(calls, hasLength(1)); + expect(calls.single.networkAvailable, isTrue); + manager.close(); + }); + }); + + test('combined lifecycle + network change fires a single reconcile', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(false); + async.elapse(const Duration(milliseconds: 100)); + manager.setInForeground(false); + + async.elapse(_debounceWindow); + expect(calls, hasLength(1)); + expect(calls.single.networkAvailable, isFalse); + expect(calls.single.inForeground, isFalse); + manager.close(); + }); + }); + + test('requested mode change debounces along with the other axes', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setRequestedMode(const FDv2Polling()); + async.elapse(_debounceWindow); + + expect(calls, hasLength(1)); + expect(calls.single.requestedMode, const FDv2Polling()); + manager.close(); + }); + }); + + test('close cancels a pending timer', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(false); + manager.close(); + + async.elapse(const Duration(seconds: 5)); + expect(calls, isEmpty); + }); + }); + + test('setters after close do not schedule a new fire', () { + fakeAsync((async) { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: _debounceWindow, + onReconcile: calls.add, + ); + + manager.close(); + manager.setNetworkAvailable(false); + manager.setInForeground(false); + manager.setRequestedMode(const FDv2Offline()); + + async.elapse(const Duration(seconds: 5)); + expect(calls, isEmpty); + }); + }); + }); + + group('zero window (immediate mode)', () { + test('changes fire synchronously without a timer', () { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: Duration.zero, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(false); + expect(calls, hasLength(1)); + expect(calls.single.networkAvailable, isFalse); + + manager.setInForeground(false); + expect(calls, hasLength(2)); + expect(calls.last.inForeground, isFalse); + + manager.close(); + }); + + test('unchanged setters do not fire even in immediate mode', () { + final calls = []; + final manager = StateDebounceManager( + initialState: _initial, + debounceWindow: Duration.zero, + onReconcile: calls.add, + ); + + manager.setNetworkAvailable(true); + manager.setInForeground(true); + manager.setRequestedMode(null); + + expect(calls, isEmpty); + manager.close(); + }); + }); +} diff --git a/packages/flutter_client_sdk/lib/src/connection_manager.dart b/packages/flutter_client_sdk/lib/src/connection_manager.dart index 142fa48..fd64e93 100644 --- a/packages/flutter_client_sdk/lib/src/connection_manager.dart +++ b/packages/flutter_client_sdk/lib/src/connection_manager.dart @@ -101,12 +101,19 @@ final class ConnectionManagerConfig { /// retry logic. final bool disableAutomaticBackgroundHandling; + /// Window across which lifecycle, network, and user-mode-override signals + /// are debounced before automatic resolution runs. A value of + /// [Duration.zero] disables debouncing (signals apply synchronously). + /// Defaults to one second, per CSFDV2 CONNMODE specification. + final Duration debounceWindow; + ConnectionManagerConfig({ this.initialConnectionMode = ConnectionMode.streaming, this.backgroundConnectionMode = const FDv2Offline(), this.runInBackground = true, this.disableAutomaticBackgroundHandling = false, this.disableAutomaticNetworkHandling = false, + this.debounceWindow = const Duration(seconds: 1), }); } @@ -127,6 +134,7 @@ final class ConnectionManager { final StateDetector _detector; final ConnectionDestination _destination; final List _resolutionTable; + late final StateDebounceManager _debouncer; StreamSubscription? _applicationStateSub; StreamSubscription? _networkStateSub; @@ -160,26 +168,51 @@ final class ConnectionManager { _applicationState = ApplicationState.foreground, _networkState = NetworkState.available, _detector = detector { + _debouncer = StateDebounceManager( + initialState: const DebouncedState( + networkAvailable: true, + inForeground: true, + requestedMode: null, + ), + debounceWindow: config.debounceWindow, + onReconcile: _onDebounceReconcile, + ); + if (!_config.disableAutomaticBackgroundHandling) { _applicationStateSub = - detector.applicationState.listen((applicationState) { - // TODO (SDK-2187): plumb in debouncer here - - _applicationState = applicationState; - _handleState(); - }); + detector.applicationState.listen(_onApplicationStateChanged); } if (!_config.disableAutomaticNetworkHandling) { - _networkStateSub = detector.networkState.listen((networkState) { - // TODO (SDK-2187): plumb in debouncer here - - _networkState = networkState; - _destination - .setNetworkAvailability(networkState == NetworkState.available); - _handleState(); - }); + _networkStateSub = detector.networkState.listen(_onNetworkStateChanged); + } + } + + void _onApplicationStateChanged(ApplicationState newState) { + // Spec CONNMODE 3.3.1: flushing on transition to background must not be + // debounced -- the process may be killed before the window closes. + if (newState == ApplicationState.background && + _applicationState == ApplicationState.foreground && + !_offline) { + _destination.flush(); } + _applicationState = newState; + _debouncer.setInForeground(newState == ApplicationState.foreground); + } + + void _onNetworkStateChanged(NetworkState newState) { + _networkState = newState; + // Network-availability propagation to the destination is not debounced. + // It informs the underlying client's analytics-sending state, separate + // from the mode-resolution decision that the debouncer governs. + _destination.setNetworkAvailability(newState == NetworkState.available); + _debouncer.setNetworkAvailable(newState == NetworkState.available); + } + + void _onDebounceReconcile(DebouncedState _) { + // The debouncer's snapshot is intentionally ignored; this manager owns + // the canonical view of lifecycle, network, override, and offline state. + _handleState(); } void _handleState() { @@ -228,14 +261,17 @@ final class ConnectionManager { void dispose() { _applicationStateSub?.cancel(); _networkStateSub?.cancel(); + _debouncer.close(); _detector.dispose(); } - /// Set the desired connection mode for the SDK. Passing null clears the - /// override and resumes automatic mode resolution. + /// Set the desired connection mode for the SDK. Setting an override takes + /// effect synchronously so subsequent automatic transitions are suppressed + /// immediately; applying the resolved mode is debounced. Passing null + /// clears the override and resumes automatic mode resolution. void setMode(FDv2ConnectionMode? mode) { _modeOverride = mode; - _handleState(); + _debouncer.setRequestedMode(mode); } } diff --git a/packages/flutter_client_sdk/pubspec.yaml b/packages/flutter_client_sdk/pubspec.yaml index 7354b7e..11e2506 100644 --- a/packages/flutter_client_sdk/pubspec.yaml +++ b/packages/flutter_client_sdk/pubspec.yaml @@ -24,6 +24,7 @@ dev_dependencies: test: ^1.24.3 lints: ^3.0.0 mocktail: ^1.0.1 + fake_async: ^1.3.1 # The following section is specific to Flutter. flutter: diff --git a/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart b/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart index 2b77d3b..0914ef5 100644 --- a/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart +++ b/packages/flutter_client_sdk/test/persistence/connection_manager_test.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:fake_async/fake_async.dart'; import 'package:launchdarkly_common_client/launchdarkly_common_client.dart'; import 'package:launchdarkly_flutter_client_sdk/src/connection_manager.dart'; import 'package:test/test.dart'; @@ -69,7 +70,8 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(runInBackground: false); + final config = ConnectionManagerConfig( + runInBackground: false, debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -102,7 +104,9 @@ void main() { final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); final config = ConnectionManagerConfig( - runInBackground: false, initialConnectionMode: initialMode); + runInBackground: false, + initialConnectionMode: initialMode, + debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -145,7 +149,8 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(runInBackground: true); + final config = ConnectionManagerConfig( + runInBackground: true, debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -177,6 +182,7 @@ void main() { final config = ConnectionManagerConfig( runInBackground: true, backgroundConnectionMode: const FDv2Background(), + debounceWindow: Duration.zero, ); final mockDetector = MockStateDetector(); @@ -206,6 +212,7 @@ void main() { final config = ConnectionManagerConfig( runInBackground: true, backgroundConnectionMode: const FDv2Streaming(), + debounceWindow: Duration.zero, ); final mockDetector = MockStateDetector(); @@ -232,7 +239,8 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(runInBackground: true); + final config = ConnectionManagerConfig( + runInBackground: true, debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -260,7 +268,8 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(runInBackground: true); + final config = ConnectionManagerConfig( + runInBackground: true, debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -299,6 +308,7 @@ void main() { final config = ConnectionManagerConfig( runInBackground: true, backgroundConnectionMode: const FDv2Background(), + debounceWindow: Duration.zero, ); final mockDetector = MockStateDetector(); @@ -336,6 +346,7 @@ void main() { final config = ConnectionManagerConfig( initialConnectionMode: ConnectionMode.polling, runInBackground: true, + debounceWindow: Duration.zero, ); final mockDetector = MockStateDetector(); @@ -373,6 +384,7 @@ void main() { final config = ConnectionManagerConfig( initialConnectionMode: ConnectionMode.polling, runInBackground: true, + debounceWindow: Duration.zero, ); final mockDetector = MockStateDetector(); @@ -417,6 +429,7 @@ void main() { final config = ConnectionManagerConfig( initialConnectionMode: ConnectionMode.polling, runInBackground: true, + debounceWindow: Duration.zero, ); final mockDetector = MockStateDetector(); @@ -443,7 +456,7 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(); + final config = ConnectionManagerConfig(debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -459,8 +472,11 @@ void main() { verify(() => destination.setEventSendingEnabled(false, flush: false)); reset(destination); - mockDetector.setApplicationState(ApplicationState.foreground); - mockDetector.setNetworkAvailable(true); + // Push genuine state changes (defaults are foreground+available); the + // SDK should remain offline because the offline flag overrides automatic + // resolution. + mockDetector.setApplicationState(ApplicationState.background); + mockDetector.setNetworkAvailable(false); // Wait for the state to propagate. await mockDetector.applicationState.first; @@ -468,7 +484,7 @@ void main() { verify( () => destination.setMode(const ResolvedOffline(OfflineSetOffline()))); - verify(() => destination.setNetworkAvailability(true)); + verify(() => destination.setNetworkAvailability(false)); verify(() => destination.setEventSendingEnabled(false, flush: false)); connectionManager.dispose(); }); @@ -482,7 +498,9 @@ void main() { final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); final config = ConnectionManagerConfig( - runInBackground: false, disableAutomaticBackgroundHandling: true); + runInBackground: false, + disableAutomaticBackgroundHandling: true, + debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -511,7 +529,9 @@ void main() { final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); final config = ConnectionManagerConfig( - runInBackground: false, disableAutomaticNetworkHandling: true); + runInBackground: false, + disableAutomaticNetworkHandling: true, + debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -539,7 +559,8 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(runInBackground: true); + final config = ConnectionManagerConfig( + runInBackground: true, debounceWindow: Duration.zero); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -579,7 +600,10 @@ void main() { final destination = MockDestination(); final logAdapter = MockLogAdapter(); final logger = LDLogger(adapter: logAdapter); - final config = ConnectionManagerConfig(runInBackground: false); + final config = ConnectionManagerConfig( + runInBackground: false, + debounceWindow: Duration.zero, + ); final mockDetector = MockStateDetector(); final connectionManager = ConnectionManager( @@ -598,4 +622,102 @@ void main() { }); } }); + + group('debounce window', () { + test('rapid network changes settle to one reconcile', () { + fakeAsync((async) { + registerFallbackValue(ConnectionMode.streaming); + registerFallbackValue(const FDv2Streaming()); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + runInBackground: true, + debounceWindow: const Duration(seconds: 1), + ); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector); + + mockDetector.setNetworkAvailable(false); + async.flushMicrotasks(); + async.elapse(const Duration(milliseconds: 200)); + mockDetector.setNetworkAvailable(true); + async.flushMicrotasks(); + async.elapse(const Duration(milliseconds: 200)); + mockDetector.setNetworkAvailable(false); + async.flushMicrotasks(); + async.elapse(const Duration(milliseconds: 200)); + + verifyNever(() => destination.setMode(any())); + + async.elapse(const Duration(seconds: 1)); + verify(() => destination.setMode(any())).called(1); + + connectionManager.dispose(); + }); + }); + + test('background transition flushes immediately, not debounced', () { + fakeAsync((async) { + registerFallbackValue(ConnectionMode.streaming); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + runInBackground: false, + debounceWindow: const Duration(seconds: 1), + ); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector); + + mockDetector.setApplicationState(ApplicationState.background); + async.flushMicrotasks(); + // Flush is synchronous on foreground->background transition. + verify(() => destination.flush()).called(1); + + connectionManager.dispose(); + }); + }); + + test('setMode debounces the resolved-mode application', () { + fakeAsync((async) { + registerFallbackValue(ConnectionMode.streaming); + + final destination = MockDestination(); + final logAdapter = MockLogAdapter(); + final logger = LDLogger(adapter: logAdapter); + final config = ConnectionManagerConfig( + runInBackground: true, + debounceWindow: const Duration(seconds: 1), + ); + final mockDetector = MockStateDetector(); + + final connectionManager = ConnectionManager( + logger: logger, + config: config, + destination: destination, + detector: mockDetector); + + connectionManager.setMode(const FDv2Polling()); + verifyNever(() => destination.setMode(any())); + + async.elapse(const Duration(seconds: 1)); + verify(() => destination.setMode(const ResolvedPolling())).called(1); + + connectionManager.dispose(); + }); + }); + }); }