From ec10f0d04a993e886f51f2dd52e960097780596d Mon Sep 17 00:00:00 2001 From: Michael Lazebny Date: Sat, 7 Sep 2024 19:15:57 +0200 Subject: [PATCH 01/12] Improvements for Control library --- .fvmrc | 3 + example/ios/Flutter/Debug.xcconfig | 1 + example/ios/Flutter/Release.xcconfig | 1 + example/ios/Podfile | 44 ++++ example/macos/Flutter/Flutter-Debug.xcconfig | 1 + .../macos/Flutter/Flutter-Release.xcconfig | 1 + example/macos/Podfile | 43 ++++ example/pubspec.lock | 44 ++-- lib/control.dart | 17 +- lib/src/concurrent_controller_handler.dart | 108 ---------- lib/src/controller.dart | 152 -------------- lib/src/core/controller.dart | 155 ++++++++++++++ lib/src/{ => core}/registry.dart | 4 +- lib/src/{ => core}/state_controller.dart | 2 +- lib/src/droppable_controller_handler.dart | 70 ------- .../concurrent_controller_handler.dart | 56 +++++ .../droppable_controller_handler.dart | 62 ++++++ .../sequential_controller_handler.dart | 150 ++++++++++++++ lib/src/sequential_controller_handler.dart | 143 ------------- lib/src/{ => widget}/controller_scope.dart | 13 +- lib/src/{ => widget}/state_consumer.dart | 4 +- pubspec.yaml | 2 + test/control_test.dart | 16 -- test/src/core/state_controller_test.dart | 84 ++++++++ .../concurrent_controller_handler_test.dart | 64 ++++++ .../sequential_controller_handler_test.dart | 192 ++++++++++++++++++ ...uential_controller_handler_test.mocks.dart | 80 ++++++++ .../widget/controller_scope_test.dart | 8 +- test/unit/state_controller_test.dart | 150 -------------- 29 files changed, 994 insertions(+), 676 deletions(-) create mode 100644 .fvmrc create mode 100644 example/ios/Podfile create mode 100644 example/macos/Podfile delete mode 100644 lib/src/concurrent_controller_handler.dart delete mode 100644 lib/src/controller.dart create mode 100644 lib/src/core/controller.dart rename lib/src/{ => core}/registry.dart (95%) rename lib/src/{ => core}/state_controller.dart (98%) delete mode 100644 lib/src/droppable_controller_handler.dart create mode 100644 lib/src/handlers/concurrent_controller_handler.dart create mode 100644 lib/src/handlers/droppable_controller_handler.dart create mode 100644 lib/src/handlers/sequential_controller_handler.dart delete mode 100644 lib/src/sequential_controller_handler.dart rename lib/src/{ => widget}/controller_scope.dart (95%) rename lib/src/{ => widget}/state_consumer.dart (97%) delete mode 100644 test/control_test.dart create mode 100644 test/src/core/state_controller_test.dart create mode 100644 test/src/handlers/concurrent_controller_handler_test.dart create mode 100644 test/src/handlers/sequential_controller_handler_test.dart create mode 100644 test/src/handlers/sequential_controller_handler_test.mocks.dart rename test/{ => src}/widget/controller_scope_test.dart (94%) delete mode 100644 test/unit/state_controller_test.dart diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..c300356 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "stable" +} \ No newline at end of file diff --git a/example/ios/Flutter/Debug.xcconfig b/example/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/example/ios/Flutter/Debug.xcconfig +++ b/example/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/example/ios/Flutter/Release.xcconfig b/example/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/example/ios/Flutter/Release.xcconfig +++ b/example/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/example/ios/Podfile b/example/ios/Podfile new file mode 100644 index 0000000..d97f17e --- /dev/null +++ b/example/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '12.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/example/macos/Flutter/Flutter-Debug.xcconfig b/example/macos/Flutter/Flutter-Debug.xcconfig index c2efd0b..4b81f9b 100644 --- a/example/macos/Flutter/Flutter-Debug.xcconfig +++ b/example/macos/Flutter/Flutter-Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Flutter/Flutter-Release.xcconfig b/example/macos/Flutter/Flutter-Release.xcconfig index c2efd0b..5caa9d1 100644 --- a/example/macos/Flutter/Flutter-Release.xcconfig +++ b/example/macos/Flutter/Flutter-Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/example/macos/Podfile b/example/macos/Podfile new file mode 100644 index 0000000..c795730 --- /dev/null +++ b/example/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/example/pubspec.lock b/example/pubspec.lock index 5b0599c..8cab3ae 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -330,26 +330,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa" + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" url: "https://pub.dev" source: hosted - version: "10.0.0" + version: "10.0.4" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0 + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.3" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47 + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "3.0.1" lints: dependency: transitive description: @@ -386,10 +386,10 @@ packages: dependency: "direct main" description: name: meta - sha256: d584fa6707a52763a52446f02cc621b077888fb63b93bbcb1143a7be5a0c0c04 + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.12.0" mime: dependency: transitive description: @@ -398,6 +398,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + mockito: + dependency: transitive + description: + name: mockito + sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" + url: "https://pub.dev" + source: hosted + version: "5.4.4" package_config: dependency: transitive description: @@ -563,6 +571,14 @@ packages: description: flutter source: sdk version: "0.0.99" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" + url: "https://pub.dev" + source: hosted + version: "1.5.0" source_span: dependency: transitive description: @@ -623,10 +639,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.7.0" timing: dependency: transitive description: @@ -655,10 +671,10 @@ packages: dependency: transitive description: name: vm_service - sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957 + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" url: "https://pub.dev" source: hosted - version: "13.0.0" + version: "14.2.1" watcher: dependency: transitive description: @@ -716,5 +732,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.2.0 <4.0.0" - flutter: ">=3.16.0" + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/lib/control.dart b/lib/control.dart index 1541dc1..55e9c82 100644 --- a/lib/control.dart +++ b/lib/control.dart @@ -1,9 +1,12 @@ library control; -export 'package:control/src/concurrent_controller_handler.dart'; -export 'package:control/src/controller.dart' hide IController; -export 'package:control/src/controller_scope.dart' hide ControllerScope$Element; -export 'package:control/src/droppable_controller_handler.dart'; -export 'package:control/src/sequential_controller_handler.dart'; -export 'package:control/src/state_consumer.dart'; -export 'package:control/src/state_controller.dart' hide IStateController; +/* Core */ +export 'package:control/src/core/controller.dart' hide IController; +export 'package:control/src/core/state_controller.dart' hide IStateController; +/* Handlers */ +export 'package:control/src/handlers/concurrent_controller_handler.dart'; +export 'package:control/src/handlers/droppable_controller_handler.dart'; +export 'package:control/src/handlers/sequential_controller_handler.dart'; +/* Widget */ +export 'package:control/src/widget/controller_scope.dart'; +export 'package:control/src/widget/state_consumer.dart'; diff --git a/lib/src/concurrent_controller_handler.dart b/lib/src/concurrent_controller_handler.dart deleted file mode 100644 index c5864ba..0000000 --- a/lib/src/concurrent_controller_handler.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'dart:async'; - -import 'package:control/src/controller.dart'; -import 'package:flutter/foundation.dart' show SynchronousFuture; -import 'package:meta/meta.dart'; - -/// Sequential controller concurrency -base mixin ConcurrentControllerHandler on Controller { - @override - @nonVirtual - bool get isProcessing => _$processingCalls > 0; - int _$processingCalls = 0; - - @override - Future get done => _done?.future ?? SynchronousFuture(null); - Completer? _done; - - @override - @protected - @mustCallSuper - Future handle( - Future Function() handler, { - Future Function(Object error, StackTrace stackTrace)? error, - Future Function()? done, - }) { - if (isDisposed) return Future.value(null); - _$processingCalls++; - final completer = _done ??= Completer(); - var isDone = false; // ignore error callback after done - - Future onError(Object e, StackTrace st) async { - try { - super.onError(e, st); - if (isDone || isDisposed || completer.isCompleted) return; - await error?.call(e, st); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } - } - - void onDone() { - if (completer.isCompleted) return; - _$processingCalls--; - if (_$processingCalls != 0) return; - completer.complete(); - _done = null; - } - - runZonedGuarded( - () async { - try { - await handler(); - } on Object catch (error, stackTrace) { - await onError(error, stackTrace); - } finally { - isDone = true; - try { - await done?.call(); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } - onDone(); - } - }, - onError, - ); - - return completer.future; - } - - /* @override - @protected - @mustCallSuper - Future handle( - Future Function() handler, { - Future Function(Object error, StackTrace stackTrace)? error, - Future Function()? done, - }) => - runZonedGuarded( - () async { - if (isDisposed) return; - _$processingCalls++; - _done ??= Completer.sync(); - try { - await handler(); - } on Object catch (e, st) { - onError(e, st); - await Future(() async { - await error?.call(e, st); - }).catchError(onError); - } finally { - isDone = true; - await Future(() async { - await done?.call(); - }).catchError(onError); - _$processingCalls--; - if (_$processingCalls == 0) { - final completer = _done; - if (completer != null && !completer.isCompleted) { - completer.complete(); - } - _done = null; - } - } - }, - onError, - ); */ -} diff --git a/lib/src/controller.dart b/lib/src/controller.dart deleted file mode 100644 index 2fa6ca1..0000000 --- a/lib/src/controller.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'dart:async'; - -import 'package:control/control.dart'; -import 'package:control/src/registry.dart'; -import 'package:flutter/foundation.dart' - show ChangeNotifier, Listenable, VoidCallback; -import 'package:meta/meta.dart'; - -/// The controller responsible for processing the logic, -/// the connection of widgets and the date of the layer. -/// -/// Do not implement this interface directly, instead extend [Controller]. -@internal -abstract interface class IController implements Listenable { - /// Whether the controller is permanently disposed - bool get isDisposed; - - /// The number of subscribers to the controller - int get subscribers; - - /// Whether any listeners are currently registered. - bool get hasListeners; - - /// Whether the controller is currently handling a requests - bool get isProcessing; - - /// A future that completes when the controller is done processing. - Future get done; - - /// Discards any resources used by the object. - /// - /// This method should only be called by the object's owner. - void dispose(); - - /// Handles invocation in the controller. - /// - /// Depending on the implementation, the handler may be executed - /// sequentially, concurrently, dropped and etc. - /// - /// See: - /// - [ConcurrentControllerHandler] - handler that executes concurrently - /// - [SequentialControllerHandler] - handler that executes sequentially - /// - [DroppableControllerHandler] - handler that drops the request when busy - void handle(Future Function() handler); -} - -/// Controller observer -abstract interface class IControllerObserver { - /// Called when the controller is created. - void onCreate(Controller controller); - - /// Called when the controller is disposed. - void onDispose(Controller controller); - - /// Called on any state change in the controller. - void onStateChanged( - StateController controller, S prevState, S nextState); - - /// Called on any error in the controller. - void onError(Controller controller, Object error, StackTrace stackTrace); -} - -/// {@template controller} -/// The controller responsible for processing the logic, -/// the connection of widgets and the date of the layer. -/// {@endtemplate} -abstract base class Controller with ChangeNotifier implements IController { - /// {@macro controller} - Controller() { - ControllerRegistry().insert(this); - runZonedGuarded( - () => Controller.observer?.onCreate(this), - (error, stackTrace) {/* ignore */}, - ); - } - - /// Controller observer - static IControllerObserver? observer; - - /// Return a [Listenable] that triggers when any of the given [Listenable]s - /// themselves trigger. - static Listenable merge(Iterable listenables) => - Listenable.merge( - List.unmodifiable(listenables.whereType()), - ); - - @override - bool get isDisposed => _$isDisposed; - bool _$isDisposed = false; - - @override - int get subscribers => _$subscribers; - int _$subscribers = 0; - - /// Error handling callback - @protected - void onError(Object error, StackTrace stackTrace) => runZonedGuarded( - () => Controller.observer?.onError(this, error, stackTrace), - (error, stackTrace) {/* ignore */}, - ); - - @protected - @override - Future handle(Future Function() handler); - - @protected - @nonVirtual - @override - void notifyListeners() { - if (isDisposed) { - assert(false, 'A $runtimeType was already disposed.'); - return; - } - super.notifyListeners(); - } - - @override - @mustCallSuper - void addListener(VoidCallback listener) { - if (isDisposed) { - assert(false, 'A $runtimeType was already disposed.'); - return; - } - super.addListener(listener); - _$subscribers++; - } - - @override - @mustCallSuper - void removeListener(VoidCallback listener) { - super.removeListener(listener); - if (isDisposed) return; - _$subscribers--; - } - - @override - @mustCallSuper - void dispose() { - if (_$isDisposed) { - assert(false, 'A $runtimeType was already disposed.'); - return; - } - _$isDisposed = true; - _$subscribers = 0; - runZonedGuarded( - () => Controller.observer?.onDispose(this), - (error, stackTrace) {/* ignore */}, - ); - ControllerRegistry().remove(); - super.dispose(); - } -} diff --git a/lib/src/core/controller.dart b/lib/src/core/controller.dart new file mode 100644 index 0000000..ec7e0ee --- /dev/null +++ b/lib/src/core/controller.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:control/control.dart'; +import 'package:control/src/core/registry.dart'; +import 'package:flutter/foundation.dart' + show ChangeNotifier, Listenable, VoidCallback; +import 'package:meta/meta.dart'; + +/// The interface for controllers responsible for processing logic, +/// connecting widgets, and managing data layers. +/// +/// This interface defines the core functionality that all controllers +/// should implement. It extends [Listenable] to allow widgets to listen +/// for changes in the controller's state. +@internal +abstract interface class IController implements Listenable { + /// Handles invocation in the controller. + /// + /// Depending on the implementation, the handler may be executed + /// sequentially, concurrently, or dropped when busy. + /// + /// See: + /// - [ConcurrentControllerHandler] - handler that executes concurrently + /// - [SequentialControllerHandler] - handler that executes sequentially + /// - [DroppableControllerHandler] - handler that drops the request when busy + void handle(Future Function() handler); + + /// Whether the controller has been permanently disposed. + bool get isDisposed; + + /// Whether the controller is currently processing an operation. + bool get isProcessing; + + /// Discards any resources used by the object. + /// + /// This method should only be called by the object's owner. + void dispose(); +} + +/// Observer interface for monitoring controller lifecycle and state changes. +abstract interface class IControllerObserver { + /// Called when a controller is created. + void onCreate(Controller controller); + + /// Called when a controller is disposed. + void onDispose(Controller controller); + + /// Called on any state change in a [StateController]. + void onStateChanged( + StateController controller, S prevState, S nextState); + + /// Called on any error in a controller. + void onError(Controller controller, Object error, StackTrace stackTrace); +} + +/// {@template controller} +/// The base class for controllers responsible for processing logic, +/// connecting widgets, and managing data layers. +/// +/// This class provides core functionality for state management and +/// lifecycle handling. It implements [ChangeNotifier] to allow widgets +/// to listen for changes in the controller's state. +/// {@endtemplate} +abstract base class Controller with ChangeNotifier implements IController { + /// {@macro controller} + Controller() { + ControllerRegistry().insert(this); + _runSafely(() => observer?.onCreate(this)); + } + + /// Global observer for all controllers. + /// + /// This can be set to monitor the lifecycle and state changes of all + /// controllers. + static IControllerObserver? observer; + + bool _isDisposed = false; + + @override + bool get isDisposed => _isDisposed; + + int _subscribers = 0; + + /// The number of subscribers listening to the controller. + int get subscribers => _subscribers; + + /// Error handling callback. + /// + /// This method is called when an error occurs in the controller. + /// It can be overridden to provide custom error handling. + @protected + @mustCallSuper + void onError(Object error, StackTrace stackTrace) => + _runSafely(() => observer?.onError(this, error, stackTrace)); + + @protected + @nonVirtual + @override + void notifyListeners() { + if (isDisposed) { + assert( + false, + 'A $runtimeType called notifyListeners after being disposed.', + ); + return; + } + super.notifyListeners(); + } + + @override + @mustCallSuper + void addListener(VoidCallback listener) { + if (isDisposed) { + assert( + false, + 'A $runtimeType called addListener after being disposed.', + ); + return; + } + super.addListener(listener); + _subscribers++; + } + + @override + @mustCallSuper + void removeListener(VoidCallback listener) { + super.removeListener(listener); + if (!isDisposed) _subscribers--; + } + + @override + @mustCallSuper + void dispose() { + if (_isDisposed) { + assert(false, 'A $runtimeType has been disposed multiple times.'); + return; + } + _isDisposed = true; + _subscribers = 0; + + ControllerRegistry().remove(); + + _runSafely(() { + observer?.onDispose(this); + }); + super.dispose(); + } + + void _runSafely(void Function() handler) { + runZonedGuarded( + handler, + (error, stackTrace) {/* ignore */}, + ); + } +} diff --git a/lib/src/registry.dart b/lib/src/core/registry.dart similarity index 95% rename from lib/src/registry.dart rename to lib/src/core/registry.dart index 29e0657..f43343c 100644 --- a/lib/src/registry.dart +++ b/lib/src/core/registry.dart @@ -1,5 +1,5 @@ -import 'package:control/src/controller.dart'; -import 'package:control/src/state_controller.dart'; +import 'package:control/src/core/controller.dart'; +import 'package:control/src/core/state_controller.dart'; import 'package:flutter/foundation.dart'; import 'package:meta/meta.dart'; diff --git a/lib/src/state_controller.dart b/lib/src/core/state_controller.dart similarity index 98% rename from lib/src/state_controller.dart rename to lib/src/core/state_controller.dart index 3b3b08d..9c43258 100644 --- a/lib/src/state_controller.dart +++ b/lib/src/core/state_controller.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'package:control/src/controller.dart'; +import 'package:control/src/core/controller.dart'; import 'package:flutter/foundation.dart'; import 'package:meta/meta.dart'; diff --git a/lib/src/droppable_controller_handler.dart b/lib/src/droppable_controller_handler.dart deleted file mode 100644 index 42bee35..0000000 --- a/lib/src/droppable_controller_handler.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'dart:async'; - -import 'package:control/src/controller.dart'; -import 'package:flutter/foundation.dart' show SynchronousFuture; -import 'package:meta/meta.dart'; - -/// Droppable controller concurrency -base mixin DroppableControllerHandler on Controller { - @override - @nonVirtual - bool get isProcessing => _$processingCalls > 0; - int _$processingCalls = 0; - - @override - Future get done => _done?.future ?? SynchronousFuture(null); - Completer? _done; - - @override - @protected - @mustCallSuper - Future handle( - Future Function() handler, { - Future Function(Object error, StackTrace stackTrace)? error, - Future Function()? done, - }) { - if (isDisposed || isProcessing) return Future.value(null); - _$processingCalls++; - final completer = _done ??= Completer(); - var isDone = false; // ignore error callback after done - - Future onError(Object e, StackTrace st) async { - try { - super.onError(e, st); - if (isDone || isDisposed || completer.isCompleted) return; - await error?.call(e, st); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } - } - - void onDone() { - if (completer.isCompleted) return; - _$processingCalls--; - if (_$processingCalls != 0) return; - completer.complete(); - _done = null; - } - - runZonedGuarded( - () async { - try { - await handler(); - } on Object catch (error, stackTrace) { - await onError(error, stackTrace); - } finally { - isDone = true; - try { - await done?.call(); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } - onDone(); - } - }, - onError, - ); - - return completer.future; - } -} diff --git a/lib/src/handlers/concurrent_controller_handler.dart b/lib/src/handlers/concurrent_controller_handler.dart new file mode 100644 index 0000000..ee7a1d2 --- /dev/null +++ b/lib/src/handlers/concurrent_controller_handler.dart @@ -0,0 +1,56 @@ +import 'dart:async'; + +import 'package:control/src/core/controller.dart'; +import 'package:meta/meta.dart'; + +/// A mixin that provides sequential controller concurrency handling. +/// This mixin should be used on classes that extend [Controller]. +base mixin ConcurrentControllerHandler on Controller { + @override + bool get isProcessing => _processingCalls > 0; + + /// Tracks the number of ongoing processing calls. + int _processingCalls = 0; + + /// Handles a given operation with error handling and completion tracking. + /// + /// [handler] is the main operation to be executed. + /// [onError] is an optional error handler. + /// [onDone] is an optional callback to be executed when the operation is done + @override + @protected + @mustCallSuper + Future handle( + Future Function() handler, { + Future Function(Object error, StackTrace stackTrace)? onError, + Future Function()? onDone, + }) async { + if (isDisposed) return; + + _processingCalls++; + + Future handleError(Object error, StackTrace stackTrace) async { + if (isDisposed) return; + super.onError(error, stackTrace); + try { + await onError?.call(error, stackTrace); + } on Object catch (secondaryError, secondaryStackTrace) { + super.onError(secondaryError, secondaryStackTrace); + } + } + + try { + await handler(); + } on Object catch (error, stackTrace) { + await handleError(error, stackTrace); + } finally { + try { + await onDone?.call(); + } on Object catch (error, stackTrace) { + super.onError(error, stackTrace); + } + } + + _processingCalls--; + } +} diff --git a/lib/src/handlers/droppable_controller_handler.dart b/lib/src/handlers/droppable_controller_handler.dart new file mode 100644 index 0000000..58c0b84 --- /dev/null +++ b/lib/src/handlers/droppable_controller_handler.dart @@ -0,0 +1,62 @@ +import 'dart:async'; + +import 'package:control/src/core/controller.dart'; +import 'package:meta/meta.dart'; + +/// A mixin that provides droppable controller concurrency handling. +/// This mixin should be used on classes that extend [Controller]. +/// It allows only one operation to be processed at a time, dropping +/// new requests if one is already in progress. +base mixin DroppableControllerHandler on Controller { + /// Indicates whether the controller is currently processing an operation. + @override + @nonVirtual + bool get isProcessing => _processingCalls > 0; + + /// Tracks the number of ongoing processing calls (should be 0 or 1). + int _processingCalls = 0; + + /// Handles a given operation with error handling and completion tracking. + /// If an operation is already in progress, this method returns immediately + /// without starting a new operation. + /// + /// [handler] is the main operation to be executed. + /// [onError] is an optional error handler. + /// [onDone] is an optional callback to be executed when the operation is done + @override + @protected + @mustCallSuper + Future handle( + Future Function() handler, { + Future Function(Object error, StackTrace stackTrace)? onError, + Future Function()? onDone, + }) async { + if (isDisposed || isProcessing) return; + + _processingCalls++; + + Future handleError(Object error, StackTrace stackTrace) async { + if (isDisposed) return; + super.onError(error, stackTrace); + try { + await onError?.call(error, stackTrace); + } on Object catch (secondaryError, secondaryStackTrace) { + super.onError(secondaryError, secondaryStackTrace); + } + } + + try { + await handler(); + } on Object catch (error, stackTrace) { + await handleError(error, stackTrace); + } finally { + try { + await onDone?.call(); + } on Object catch (error, stackTrace) { + super.onError(error, stackTrace); + } + } + + _processingCalls--; + } +} diff --git a/lib/src/handlers/sequential_controller_handler.dart b/lib/src/handlers/sequential_controller_handler.dart new file mode 100644 index 0000000..47f8a9f --- /dev/null +++ b/lib/src/handlers/sequential_controller_handler.dart @@ -0,0 +1,150 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:control/src/core/controller.dart'; +import 'package:meta/meta.dart'; + +/// A mixin that provides sequential controller concurrency handling. +/// This mixin should be used on classes that extend [Controller]. +base mixin SequentialControllerHandler on Controller { + // The event queue for sequential execution. + final _ControllerEventQueue _eventQueue = _ControllerEventQueue(); + + @override + @nonVirtual + bool get isProcessing => _eventQueue.length > 0; + + /// Handles a given operation with error handling and queues it for + /// sequential execution. + /// + /// [handler] is the main operation to be executed. + /// [onError] is an optional error handler. + /// [onDone] is an optional callback to be executed when the operation is done + @override + @protected + @mustCallSuper + Future handle( + Future Function() handler, { + Future Function(Object error, StackTrace stackTrace)? onError, + Future Function()? onDone, + }) => + _eventQueue.push( + () async { + if (isDisposed) return; + + /// Function that is called when an error occurs during handler + /// execution. + Future handleError(Object error, StackTrace stackTrace) async { + if (isDisposed) return; + super.onError(error, stackTrace); + + try { + await onError?.call(error, stackTrace); + } on Object catch (secondaryError, secondaryStackTrace) { + super.onError(secondaryError, secondaryStackTrace); + } + } + + try { + await handler(); + } on Object catch (error, stackTrace) { + await handleError(error, stackTrace); + } finally { + try { + await onDone?.call(); + } on Object catch (error, stackTrace) { + super.onError(error, stackTrace); + } + } + }, + ); + + @override + void dispose() { + _eventQueue.close(); + super.dispose(); + } +} + +/// A queue for managing sequential execution of controller events. +class _ControllerEventQueue { + final DoubleLinkedQueue<_SequentialTask> _queue = + DoubleLinkedQueue<_SequentialTask>(); + Future? _processing; + bool _isClosed = false; + + /// Event queue length. + int get length => _queue.length; + + /// The current processing future, if any. + Future? get processing => _processing; + + /// Pushes a new task to the end of the queue. + /// + /// Returns a [Future] that completes with the result of the task. + /// Throws a [StateError] if the queue is closed. + Future push(Future Function() task) { + if (_isClosed) { + throw StateError('Cannot push to a closed queue'); + } + + final sequentialTask = _SequentialTask(task); + _queue.add(sequentialTask); + _startProcessing(); + + return sequentialTask.future; + } + + /// Marks the queue as closed. + /// + /// The queue will be processed until it's empty. + /// All new push attempts will be rejected with [StateError]. + Future close() async { + _isClosed = true; + await _processing; + } + + /// Starts processing the queue if it's not already being processed. + void _startProcessing() { + _processing ??= _processQueue(); + } + + /// Processes the queue sequentially. + Future _processQueue() async { + while (_queue.isNotEmpty) { + final task = _queue.first; + try { + await task(); + } on Object catch (error, stackTrace) { + task.reject(error, stackTrace); + } finally { + _queue.removeFirst(); + } + } + _processing = null; + } +} + +/// Represents a task in the sequential queue. +class _SequentialTask { + _SequentialTask(this._task); + final Future Function() _task; + final _completer = Completer(); + + Future get future => _completer.future; + + Future call() async { + try { + final result = await _task(); + _completer.complete(result); + } on Object catch (error, stackTrace) { + _completer.completeError(error, stackTrace); + } + } + + void reject(Object error, StackTrace stackTrace) { + if (!_completer.isCompleted) { + _completer.completeError(error, stackTrace); + } + } +} diff --git a/lib/src/sequential_controller_handler.dart b/lib/src/sequential_controller_handler.dart deleted file mode 100644 index 271cfd4..0000000 --- a/lib/src/sequential_controller_handler.dart +++ /dev/null @@ -1,143 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; - -import 'package:control/src/controller.dart'; -import 'package:flutter/foundation.dart' show SynchronousFuture; -import 'package:meta/meta.dart'; - -/// Sequential controller concurrency -base mixin SequentialControllerHandler on Controller { - final _ControllerEventQueue _eventQueue = _ControllerEventQueue(); - - @override - @nonVirtual - bool get isProcessing => _eventQueue.length > 0; - - @override - Future get done => - _eventQueue._processing ?? SynchronousFuture(null); - - @override - @protected - @mustCallSuper - Future handle( - Future Function() handler, { - Future Function(Object error, StackTrace stackTrace)? error, - Future Function()? done, - }) => - _eventQueue.push( - () { - final completer = Completer(); - var isDone = false; // ignore error callback after done - - Future onError(Object e, StackTrace st) async { - try { - super.onError(e, st); - if (isDone || isDisposed || completer.isCompleted) return; - await error?.call(e, st); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } - } - - runZonedGuarded( - () async { - if (isDisposed) return; - try { - await handler(); - } on Object catch (error, stackTrace) { - await onError(error, stackTrace); - } finally { - isDone = true; - try { - await done?.call(); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } - if (!completer.isCompleted) completer.complete(); - } - }, - onError, - ); - - return completer.future; - }, - ).catchError((_, __) => null); -} - -final class _ControllerEventQueue { - _ControllerEventQueue(); - - final DoubleLinkedQueue<_SequentialTask> _queue = - DoubleLinkedQueue<_SequentialTask>(); - Future? _processing; - bool _isClosed = false; - - /// Event queue length. - int get length => _queue.length; - - /// Push it at the end of the queue. - Future push(Future Function() fn) { - final task = _SequentialTask(fn); - _queue.add(task); - _exec(); - return task.future; - } - - /// Mark the queue as closed. - /// The queue will be processed until it's empty. - /// But all new and current events will be rejected with [WSClientClosed]. - Future close() async { - _isClosed = true; - await _processing; - } - - /// Execute the queue. - void _exec() => _processing ??= Future.doWhile(() async { - final event = _queue.first; - try { - if (_isClosed) { - event.reject(StateError('Controller\'s event queue are disposed'), - StackTrace.current); - } else { - await event(); - } - } on Object catch (error, stackTrace) { - /* warning( - error, - stackTrace, - 'Error while processing event "${event.id}"', - ); */ - Future.sync(() => event.reject(error, stackTrace)).ignore(); - } - _queue.removeFirst(); - final isEmpty = _queue.isEmpty; - if (isEmpty) _processing = null; - return !isEmpty; - }); -} - -class _SequentialTask { - _SequentialTask(Future Function() fn) - : _fn = fn, - _completer = Completer(); - - final Completer _completer; - - final Future Function() _fn; - - Future get future => _completer.future; - - Future call() async { - final result = await _fn(); - if (!_completer.isCompleted) { - _completer.complete(result); - } - return result; - } - - void reject(Object error, [StackTrace? stackTrace]) { - if (_completer.isCompleted) return; - _completer.completeError(error, stackTrace); - } -} diff --git a/lib/src/controller_scope.dart b/lib/src/widget/controller_scope.dart similarity index 95% rename from lib/src/controller_scope.dart rename to lib/src/widget/controller_scope.dart index 1b3f676..03b9774 100644 --- a/lib/src/controller_scope.dart +++ b/lib/src/widget/controller_scope.dart @@ -1,5 +1,5 @@ -import 'package:control/src/controller.dart'; -import 'package:control/src/state_controller.dart'; +import 'package:control/src/core/controller.dart'; +import 'package:control/src/core/state_controller.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; @@ -51,7 +51,7 @@ class ControllerScope extends InheritedWidget { final element = context.getElementForInheritedWidgetOfExactType>(); if (listen && element != null) context.dependOnInheritedElement(element); - return element is ControllerScope$Element ? element.controller : null; + return element is _ControllerScope$Element ? element.controller : null; } static Never _notFoundInheritedWidgetOfExactType() => throw ArgumentError( @@ -73,13 +73,12 @@ class ControllerScope extends InheritedWidget { _dependency != oldWidget._dependency; @override - InheritedElement createElement() => ControllerScope$Element(this); + InheritedElement createElement() => _ControllerScope$Element(this); } -@internal -final class ControllerScope$Element +final class _ControllerScope$Element extends InheritedElement { - ControllerScope$Element(ControllerScope widget) : super(widget); + _ControllerScope$Element(ControllerScope widget) : super(widget); @nonVirtual _ControllerDependency get _dependency => diff --git a/lib/src/state_consumer.dart b/lib/src/widget/state_consumer.dart similarity index 97% rename from lib/src/state_consumer.dart rename to lib/src/widget/state_consumer.dart index 577e5e2..7c004ef 100644 --- a/lib/src/state_consumer.dart +++ b/lib/src/widget/state_consumer.dart @@ -1,5 +1,5 @@ -import 'package:control/src/controller_scope.dart'; -import 'package:control/src/state_controller.dart'; +import 'package:control/src/core/state_controller.dart'; +import 'package:control/src/widget/controller_scope.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 9af1540..eeae72d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,8 +41,10 @@ dependencies: flutter: sdk: flutter meta: ^1.0.0 + mockito: ^5.4.4 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.0 + build_runner: ^2.4.11 diff --git a/test/control_test.dart b/test/control_test.dart deleted file mode 100644 index 7291809..0000000 --- a/test/control_test.dart +++ /dev/null @@ -1,16 +0,0 @@ -// ignore_for_file: unnecessary_lambdas - -import 'package:flutter_test/flutter_test.dart'; - -import 'unit/state_controller_test.dart' as state_controller_test; -import 'widget/controller_scope_test.dart' as state_scope_test; - -void main() { - group('unit', () { - state_controller_test.main(); - }); - - group('widget', () { - state_scope_test.main(); - }); -} diff --git a/test/src/core/state_controller_test.dart b/test/src/core/state_controller_test.dart new file mode 100644 index 0000000..59287ef --- /dev/null +++ b/test/src/core/state_controller_test.dart @@ -0,0 +1,84 @@ +// ignore_for_file: unnecessary_lambdas, unused_element + +import 'package:control/control.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() => group('StateController', () { + group('concurrency', () { + test('sequential', () async { + final controller = _FakeControllerSequential(); + expect(controller.isProcessing, isFalse); + expect(controller.state, equals(0)); + expect(controller.subscribers, equals(0)); + expect(controller.isDisposed, isFalse); + controller + ..add(1) + ..subtract(2) + ..add(4); + expect(controller.isProcessing, isTrue); + }); + + test('droppable', () async { + final controller = _FakeControllerDroppable(); + expect(controller.isProcessing, isFalse); + expect(controller.state, equals(0)); + expect(controller.subscribers, equals(0)); + expect(controller.isDisposed, isFalse); + controller + ..add(1) + ..subtract(2) + ..add(4); + expect(controller.isProcessing, isTrue); + }); + + test('concurrent', () async { + final controller = _FakeControllerConcurrent(); + expect(controller.isProcessing, isFalse); + expect(controller.state, equals(0)); + expect(controller.subscribers, equals(0)); + expect(controller.isDisposed, isFalse); + controller + ..add(1) + ..subtract(2) + ..add(4); + expect(controller.isProcessing, isTrue); + }); + }); + + group('methods', () { + test('toValueListenable', () async { + final controller = _FakeControllerConcurrent(); + final listenable = controller.toValueListenable(); + expect(listenable, isA>()); + expect(listenable.value, equals(controller.state)); + controller + ..add(2) + ..subtract(1); + }); + }); + }); + +abstract base class _FakeControllerBase extends StateController { + _FakeControllerBase({int? initialState}) + : super(initialState: initialState ?? 0); + + void add(int value) => handle(() async { + await Future.delayed(Duration.zero); + setState(state + value); + }); + + void subtract(int value) => handle(() async { + await Future.delayed(Duration.zero); + setState(state - value); + }); +} + +final class _FakeControllerSequential = _FakeControllerBase + with SequentialControllerHandler; + +final class _FakeControllerDroppable = _FakeControllerBase + with DroppableControllerHandler; + +final class _FakeControllerConcurrent = _FakeControllerBase + with ConcurrentControllerHandler; diff --git a/test/src/handlers/concurrent_controller_handler_test.dart b/test/src/handlers/concurrent_controller_handler_test.dart new file mode 100644 index 0000000..a951495 --- /dev/null +++ b/test/src/handlers/concurrent_controller_handler_test.dart @@ -0,0 +1,64 @@ +import 'package:control/control.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group( + 'ConcurrentControllerHandler', + () { + test( + 'should execute operations concurrently', + () async { + final controller = _FakeController(); + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + final stopwatch = Stopwatch()..start(); + + final futures = >[ + controller.increment(), + controller.increment(), + controller.increment(), + controller.increment(), + controller.decrement(), + controller.decrement(), + controller.decrement(), + controller.decrement(), + ]; + + expect(controller.isProcessing, isTrue); + expect(controller.state, 0); + + await expectLater(Future.wait(futures), completes); + + stopwatch.stop(); + + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + expect(stopwatch.elapsedMilliseconds, lessThan(200)); + }, + ); + }, + ); +} + +final class _FakeController extends Controller + with ConcurrentControllerHandler { + int _state = 0; + + int get state => _state; + + Future increment() => handle(() async { + await Future.delayed(const Duration(milliseconds: 100)); + _state++; + }); + + Future decrement() => handle(() async { + await Future.delayed(const Duration(milliseconds: 100)); + _state--; + }); + + Future throwError() => handle(() async { + await Future.delayed(const Duration(milliseconds: 100)); + throw Exception('Error'); + }); +} diff --git a/test/src/handlers/sequential_controller_handler_test.dart b/test/src/handlers/sequential_controller_handler_test.dart new file mode 100644 index 0000000..3ab5549 --- /dev/null +++ b/test/src/handlers/sequential_controller_handler_test.dart @@ -0,0 +1,192 @@ +import 'package:control/control.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +@GenerateNiceMocks([ + MockSpec(), +]) +import 'sequential_controller_handler_test.mocks.dart'; + +void main() { + group( + 'SequentialControllerHandler', + () { + late MockIControllerObserver observer; + late _FakeController controller; + + setUp(() { + controller = _FakeController(); + observer = MockIControllerObserver(); + Controller.observer = observer; + }); + + tearDown(() { + reset(observer); + Controller.observer = null; + controller.dispose(); + }); + + test( + 'should execute operations sequentially', + () async { + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + final future1 = controller.increment(); + expect(controller.isProcessing, isTrue); + expect(controller.state, 0); + + final future2 = controller.decrement(); + expect(controller.isProcessing, isTrue); + expect(controller.state, 0); + + await expectLater(future1, completes); + expect(controller.isProcessing, isTrue); + expect(controller.state, 1); + + await expectLater(future2, completes); + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + }, + ); + + test( + 'handles error and reports to bloc observer', + () async { + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + final future = controller.throwError(); + expect(controller.isProcessing, isTrue); + expect(controller.state, 0); + + await expectLater(future, completes); + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + verify( + observer.onError(controller, any, any), + ).called(1); + }, + ); + + test( + 'handles error in onDone', + () async { + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + final incrementFuture = controller.increment(); + expect(controller.isProcessing, isTrue); + expect(controller.state, 0); + + await expectLater(incrementFuture, completes); + expect(controller.isProcessing, isFalse); + expect(controller.state, 1); + + final throwFuture = controller.throwErrorOnDone(); + + expect(controller.isProcessing, isTrue); + expect(controller.state, 1); + + await expectLater(throwFuture, completes); + expect(controller.isProcessing, isFalse); + expect(controller.state, 1); + + verify( + observer.onError(controller, any, any), + ).called(2); + }, + ); + + test( + 'handles an error when observer throws', + () async { + when( + observer.onError(controller, any, any), + ).thenThrow(Exception('Error')); + + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + final future = controller.throwError(); + expect(controller.isProcessing, isTrue); + expect(controller.state, 0); + + await expectLater(future, completes); + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + }, + ); + + test( + 'handles an error when observer throws everywhere', + () async { + when( + observer.onError(controller, any, any), + ).thenThrow(Exception('Error')); + + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + final future = controller.throwErrorEverywhere(); + expect(controller.isProcessing, isTrue); + expect(controller.state, 0); + + await expectLater(future, completes); + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + }, + ); + }, + ); +} + +final class _FakeController extends StateController + with SequentialControllerHandler { + _FakeController() : super(initialState: 0); + + /// Increments the state by one. + Future increment() => handle(() async { + await Future.delayed(const Duration(milliseconds: 100)); + setState(state + 1); + }); + + /// Decrements the state by one. + Future decrement() => handle(() async { + await Future.delayed(const Duration(milliseconds: 100)); + setState(state - 1); + }); + + /// Throws an error. + Future throwError() => handle(() async { + await Future.delayed(const Duration(milliseconds: 100)); + throw Exception('Error'); + }); + + /// Throws an error in onDone. + Future throwErrorOnDone() => handle( + () async { + await Future.delayed(const Duration(milliseconds: 100)); + throw Exception('Error'); + }, + onDone: () async { + throw Exception('Error in onDone'); + }, + ); + + /// Throws an error in onError, onDone, and the main handler. + Future throwErrorEverywhere() => handle( + () async { + await Future.delayed(const Duration(milliseconds: 100)); + throw Exception('Error'); + }, + onError: (error, stackTrace) async { + throw Exception('Error in onError'); + }, + onDone: () async { + throw Exception('Error in onDone'); + }, + ); +} diff --git a/test/src/handlers/sequential_controller_handler_test.mocks.dart b/test/src/handlers/sequential_controller_handler_test.mocks.dart new file mode 100644 index 0000000..32c7a8a --- /dev/null +++ b/test/src/handlers/sequential_controller_handler_test.mocks.dart @@ -0,0 +1,80 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in control/test/src/handlers/sequential_controller_handler_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:control/control.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [IControllerObserver]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockIControllerObserver extends _i1.Mock + implements _i2.IControllerObserver { + @override + void onCreate(_i2.Controller? controller) => super.noSuchMethod( + Invocation.method( + #onCreate, + [controller], + ), + returnValueForMissingStub: null, + ); + + @override + void onDispose(_i2.Controller? controller) => super.noSuchMethod( + Invocation.method( + #onDispose, + [controller], + ), + returnValueForMissingStub: null, + ); + + @override + void onStateChanged( + _i2.StateController? controller, + S? prevState, + S? nextState, + ) => + super.noSuchMethod( + Invocation.method( + #onStateChanged, + [ + controller, + prevState, + nextState, + ], + ), + returnValueForMissingStub: null, + ); + + @override + void onError( + _i2.Controller? controller, + Object? error, + StackTrace? stackTrace, + ) => + super.noSuchMethod( + Invocation.method( + #onError, + [ + controller, + error, + stackTrace, + ], + ), + returnValueForMissingStub: null, + ); +} diff --git a/test/widget/controller_scope_test.dart b/test/src/widget/controller_scope_test.dart similarity index 94% rename from test/widget/controller_scope_test.dart rename to test/src/widget/controller_scope_test.dart index d7409c2..6f9398d 100644 --- a/test/widget/controller_scope_test.dart +++ b/test/src/widget/controller_scope_test.dart @@ -1,7 +1,7 @@ -import 'package:control/src/controller_scope.dart'; -import 'package:control/src/sequential_controller_handler.dart'; -import 'package:control/src/state_consumer.dart'; -import 'package:control/src/state_controller.dart'; +import 'package:control/src/core/state_controller.dart'; +import 'package:control/src/handlers/sequential_controller_handler.dart'; +import 'package:control/src/widget/controller_scope.dart'; +import 'package:control/src/widget/state_consumer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/test/unit/state_controller_test.dart b/test/unit/state_controller_test.dart deleted file mode 100644 index 5518241..0000000 --- a/test/unit/state_controller_test.dart +++ /dev/null @@ -1,150 +0,0 @@ -// ignore_for_file: unnecessary_lambdas, unused_element - -import 'dart:async'; - -import 'package:control/control.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() => group('StateController', () { - _$concurrencyGroup(); - _$methodsGroup(); - }); - -void _$concurrencyGroup() => group('concurrency', () { - test('sequential', () async { - final controller = _FakeControllerSequential(); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(0)); - expect(controller.subscribers, equals(0)); - expect(controller.isDisposed, isFalse); - controller - ..add(1) - ..subtract(2) - ..add(4); - expect(controller.isProcessing, isTrue); - await expectLater(controller.done, completes); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(3)); - expect(controller.subscribers, equals(0)); - expect(() => controller.addListener(() {}), returnsNormally); - expect(controller.subscribers, equals(1)); - controller.dispose(); - expect(controller.subscribers, equals(0)); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(3)); - expect(controller.isDisposed, isTrue); - expect(() => controller.removeListener(() {}), returnsNormally); - }); - - test('droppable', () async { - final controller = _FakeControllerDroppable(); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(0)); - expect(controller.subscribers, equals(0)); - expect(controller.isDisposed, isFalse); - controller - ..add(1) - ..subtract(2) - ..add(4); - expect(controller.isProcessing, isTrue); - await expectLater(controller.done, completes); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(1)); - expect(controller.subscribers, equals(0)); - expect(() => controller.addListener(() {}), returnsNormally); - expect(controller.subscribers, equals(1)); - controller.dispose(); - expect(controller.subscribers, equals(0)); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(1)); - expect(controller.isDisposed, isTrue); - expect(() => controller.removeListener(() {}), returnsNormally); - }); - - test('concurrent', () async { - final controller = _FakeControllerConcurrent(); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(0)); - expect(controller.subscribers, equals(0)); - expect(controller.isDisposed, isFalse); - controller - ..add(1) - ..subtract(2) - ..add(4); - expect(controller.isProcessing, isTrue); - await expectLater(controller.done, completes); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(3)); - expect(controller.subscribers, equals(0)); - expect(() => controller.addListener(() {}), returnsNormally); - expect(controller.subscribers, equals(1)); - controller.dispose(); - expect(controller.subscribers, equals(0)); - expect(controller.isProcessing, isFalse); - expect(controller.state, equals(3)); - expect(controller.isDisposed, isTrue); - expect(() => controller.removeListener(() {}), returnsNormally); - }); - }); - -void _$methodsGroup() => group('methods', () { - test('toStream', () async { - final controller = _FakeControllerConcurrent(); - expect(controller.toStream(), isA>()); - // ignore: unawaited_futures - expectLater( - controller.toStream(), - emitsInOrder([1, 0, -1, 2, emitsDone]), - ); - controller - ..add(1) - ..subtract(1) - ..subtract(1) - ..add(3); - await expectLater(controller.done, completes); - controller.dispose(); - }); - - test('toValueListenable', () async { - final controller = _FakeControllerConcurrent(); - final listenable = controller.toValueListenable(); - expect(listenable, isA>()); - expect(listenable.value, equals(controller.state)); - controller - ..add(2) - ..subtract(1); - await expectLater(controller.done, completes); - expect(listenable.value, equals(controller.state)); - final completer = Completer(); - listenable.addListener(completer.complete); - controller.add(1); - await expectLater(completer.future, completes); - expect(completer.isCompleted, isTrue); - controller.dispose(); - }); - }); - -abstract base class _FakeControllerBase extends StateController { - _FakeControllerBase({int? initialState}) - : super(initialState: initialState ?? 0); - - void add(int value) => handle(() async { - await Future.delayed(Duration.zero); - setState(state + value); - }); - - void subtract(int value) => handle(() async { - await Future.delayed(Duration.zero); - setState(state - value); - }); -} - -final class _FakeControllerSequential = _FakeControllerBase - with SequentialControllerHandler; - -final class _FakeControllerDroppable = _FakeControllerBase - with DroppableControllerHandler; - -final class _FakeControllerConcurrent = _FakeControllerBase - with ConcurrentControllerHandler; From 3a9a7bec6b595aebbbcea01ebb757c11c303f58e Mon Sep 17 00:00:00 2001 From: Michael Lazebny Date: Sat, 7 Sep 2024 19:17:02 +0200 Subject: [PATCH 02/12] Remove .fvmrc --- .fvmrc | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .fvmrc diff --git a/.fvmrc b/.fvmrc deleted file mode 100644 index c300356..0000000 --- a/.fvmrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "flutter": "stable" -} \ No newline at end of file From b53dcf53a403093d24e7defe04b3876d39084f0f Mon Sep 17 00:00:00 2001 From: Michael Lazebny Date: Sat, 7 Sep 2024 19:19:02 +0200 Subject: [PATCH 03/12] Update tests --- .github/workflows/checkout.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checkout.yml b/.github/workflows/checkout.yml index 35d248b..94447ce 100644 --- a/.github/workflows/checkout.yml +++ b/.github/workflows/checkout.yml @@ -71,7 +71,7 @@ jobs: - name: ๐Ÿงช Run tests timeout-minutes: 2 run: | - flutter test -r github -j 6 --coverage test/control_test.dart + flutter test -r github -j 6 --coverage - name: ๐Ÿ“ฅ Upload coverage to Codecov timeout-minutes: 1 From 86ec1dfb270844cb61388a6d6c725c8213321705 Mon Sep 17 00:00:00 2001 From: Michael Lazebny Date: Sat, 7 Sep 2024 19:29:55 +0200 Subject: [PATCH 04/12] Updated test --- .../concurrent_controller_handler_test.dart | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/test/src/handlers/concurrent_controller_handler_test.dart b/test/src/handlers/concurrent_controller_handler_test.dart index a951495..609a677 100644 --- a/test/src/handlers/concurrent_controller_handler_test.dart +++ b/test/src/handlers/concurrent_controller_handler_test.dart @@ -5,10 +5,19 @@ void main() { group( 'ConcurrentControllerHandler', () { + late _FakeController controller; + + setUp(() { + controller = _FakeController(); + }); + + tearDown(() { + controller.dispose(); + }); + test( 'should execute operations concurrently', () async { - final controller = _FakeController(); expect(controller.isProcessing, isFalse); expect(controller.state, 0); @@ -37,6 +46,36 @@ void main() { expect(stopwatch.elapsedMilliseconds, lessThan(200)); }, ); + + test('should maintain correct state after mixed operations', () async { + final futures = >[ + controller.increment(), + controller.increment(), + controller.decrement(), + controller.increment(), + ]; + + await Future.wait(futures); + + expect(controller.state, 2); + }); + + test('should handle rapid successive calls', () async { + for (var i = 0; i < 100; i++) { + controller.increment().ignore(); + } + + await Future.delayed(const Duration(milliseconds: 200)); + expect(controller.state, 100); + }); + + test('should reset isProcessing after all operations complete', () async { + final future = controller.increment(); + expect(controller.isProcessing, isTrue); + + await expectLater(future, completes); + expect(controller.isProcessing, isFalse); + }); }, ); } From b22b66f1a1e877513acbd680841d97fa725228ed Mon Sep 17 00:00:00 2001 From: Michael Lazebny Date: Sat, 7 Sep 2024 19:47:16 +0200 Subject: [PATCH 05/12] Remove generated files --- .gitignore | 5 +- .../concurrent_controller_handler_test.dart | 37 ++++++++- ...uential_controller_handler_test.mocks.dart | 80 ------------------- 3 files changed, 40 insertions(+), 82 deletions(-) delete mode 100644 test/src/handlers/sequential_controller_handler_test.mocks.dart diff --git a/.gitignore b/.gitignore index cf99b8b..1fcaad7 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,7 @@ coverage/ /temp # FVM -.fvm/flutter_sdk \ No newline at end of file +.fvm/flutter_sdk + +# Generated files +*.*.dart \ No newline at end of file diff --git a/test/src/handlers/concurrent_controller_handler_test.dart b/test/src/handlers/concurrent_controller_handler_test.dart index 609a677..ba322ad 100644 --- a/test/src/handlers/concurrent_controller_handler_test.dart +++ b/test/src/handlers/concurrent_controller_handler_test.dart @@ -1,18 +1,30 @@ import 'package:control/control.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +@GenerateNiceMocks([ + MockSpec(), +]) +import 'concurrent_controller_handler_test.mocks.dart'; void main() { group( 'ConcurrentControllerHandler', () { late _FakeController controller; + late MockIControllerObserver observer; setUp(() { controller = _FakeController(); + observer = MockIControllerObserver(); + Controller.observer = observer; }); tearDown(() { controller.dispose(); + Controller.observer = null; + reset(observer); }); test( @@ -56,7 +68,6 @@ void main() { ]; await Future.wait(futures); - expect(controller.state, 2); }); @@ -76,6 +87,30 @@ void main() { await expectLater(future, completes); expect(controller.isProcessing, isFalse); }); + + test('should handle errors', () async { + final future = controller.throwError(); + expect(controller.isProcessing, isTrue); + + await expectLater(future, completes); + expect(controller.isProcessing, isFalse); + + verify(observer.onError(controller, any, any)).called(1); + }); + + test('should handle errors when observer throws', () async { + when( + observer.onError(controller, any, any), + ).thenThrow(Exception('Error')); + + final future = controller.throwError(); + expect(controller.isProcessing, isTrue); + + await expectLater(future, completes); + expect(controller.isProcessing, isFalse); + + verify(observer.onError(controller, any, any)).called(1); + }); }, ); } diff --git a/test/src/handlers/sequential_controller_handler_test.mocks.dart b/test/src/handlers/sequential_controller_handler_test.mocks.dart deleted file mode 100644 index 32c7a8a..0000000 --- a/test/src/handlers/sequential_controller_handler_test.mocks.dart +++ /dev/null @@ -1,80 +0,0 @@ -// Mocks generated by Mockito 5.4.4 from annotations -// in control/test/src/handlers/sequential_controller_handler_test.dart. -// Do not manually edit this file. - -// ignore_for_file: no_leading_underscores_for_library_prefixes -import 'package:control/control.dart' as _i2; -import 'package:mockito/mockito.dart' as _i1; - -// ignore_for_file: type=lint -// ignore_for_file: avoid_redundant_argument_values -// ignore_for_file: avoid_setters_without_getters -// ignore_for_file: comment_references -// ignore_for_file: deprecated_member_use -// ignore_for_file: deprecated_member_use_from_same_package -// ignore_for_file: implementation_imports -// ignore_for_file: invalid_use_of_visible_for_testing_member -// ignore_for_file: prefer_const_constructors -// ignore_for_file: unnecessary_parenthesis -// ignore_for_file: camel_case_types -// ignore_for_file: subtype_of_sealed_class - -/// A class which mocks [IControllerObserver]. -/// -/// See the documentation for Mockito's code generation for more information. -class MockIControllerObserver extends _i1.Mock - implements _i2.IControllerObserver { - @override - void onCreate(_i2.Controller? controller) => super.noSuchMethod( - Invocation.method( - #onCreate, - [controller], - ), - returnValueForMissingStub: null, - ); - - @override - void onDispose(_i2.Controller? controller) => super.noSuchMethod( - Invocation.method( - #onDispose, - [controller], - ), - returnValueForMissingStub: null, - ); - - @override - void onStateChanged( - _i2.StateController? controller, - S? prevState, - S? nextState, - ) => - super.noSuchMethod( - Invocation.method( - #onStateChanged, - [ - controller, - prevState, - nextState, - ], - ), - returnValueForMissingStub: null, - ); - - @override - void onError( - _i2.Controller? controller, - Object? error, - StackTrace? stackTrace, - ) => - super.noSuchMethod( - Invocation.method( - #onError, - [ - controller, - error, - stackTrace, - ], - ), - returnValueForMissingStub: null, - ); -} From aaab0c5bc2859ffc27d489355e97a6c4e812980e Mon Sep 17 00:00:00 2001 From: Michael Lazebny Date: Sat, 7 Sep 2024 19:48:30 +0200 Subject: [PATCH 06/12] Updated tests --- test/src/widget/controller_scope_test.dart | 165 ++++++++++----------- 1 file changed, 81 insertions(+), 84 deletions(-) diff --git a/test/src/widget/controller_scope_test.dart b/test/src/widget/controller_scope_test.dart index 6f9398d..3e44303 100644 --- a/test/src/widget/controller_scope_test.dart +++ b/test/src/widget/controller_scope_test.dart @@ -6,98 +6,95 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() => group('ControllerScope', () { - _$valueGroup(); - _$createGroup(); - }); - -void _$valueGroup() => group('ControllerScope.value', () { - test('constructor', () { - expect( - () => ControllerScope(_FakeController.new), - returnsNormally, - ); - expect( - ControllerScope(_FakeController.new), - isA(), - ); - }); + group('ControllerScope.value', () { + test('constructor', () { + expect( + () => ControllerScope(_FakeController.new), + returnsNormally, + ); + expect( + ControllerScope(_FakeController.new), + isA(), + ); + }); - testWidgets( - 'inject_and_recive', - (tester) async { - final controller = _FakeController(); - await tester.pumpWidget( - _appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Text('$state'), + testWidgets( + 'inject_and_recive', + (tester) async { + final controller = _FakeController(); + await tester.pumpWidget( + _appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + controller: controller, + builder: (context, state, child) => Text('$state'), + ), ), ), - ), - ); - await tester.pumpAndSettle(); - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - controller.add(1); - await tester.pumpAndSettle(); - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - controller.subtract(2); - await tester.pumpAndSettle(); - expect(controller.state, equals(-1)); - expect(find.text('-1'), findsOneWidget); - controller.dispose(); - }, - ); - }); - -void _$createGroup() => group('ControllerScope.create', () { - test('constructor', () { - expect( - () => ControllerScope(_FakeController.new), - returnsNormally, - ); - expect( - ControllerScope(_FakeController.new), - isA(), + ); + await tester.pumpAndSettle(); + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + controller.add(1); + await tester.pumpAndSettle(); + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + controller.subtract(2); + await tester.pumpAndSettle(); + expect(controller.state, equals(-1)); + expect(find.text('-1'), findsOneWidget); + controller.dispose(); + }, ); }); - testWidgets( - 'inject_and_recive', - (tester) async { - await tester.pumpWidget( - _appContext( - child: ControllerScope<_FakeController>( - _FakeController.new, - child: StateConsumer<_FakeController, int>( - builder: (context, state, child) => Text('$state'), - ), - ), - ), + group('ControllerScope.create', () { + test('constructor', () { + expect( + () => ControllerScope(_FakeController.new), + returnsNormally, ); - await tester.pumpAndSettle(); - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - final context = tester - .firstElement(find.byType(ControllerScope<_FakeController>)); - final controller = ControllerScope.of<_FakeController>(context); expect( - controller, - isA<_FakeController>() - .having((c) => c.state, 'state', equals(0))); - controller.add(1); - await tester.pumpAndSettle(); - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - controller.subtract(2); - await tester.pumpAndSettle(); - expect(controller.state, equals(-1)); - expect(find.text('-1'), findsOneWidget); - }, - ); + ControllerScope(_FakeController.new), + isA(), + ); + }); + + testWidgets( + 'inject_and_recive', + (tester) async { + await tester.pumpWidget( + _appContext( + child: ControllerScope<_FakeController>( + _FakeController.new, + child: StateConsumer<_FakeController, int>( + builder: (context, state, child) => Text('$state'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + final context = tester + .firstElement(find.byType(ControllerScope<_FakeController>)); + final controller = ControllerScope.of<_FakeController>(context); + expect( + controller, + isA<_FakeController>() + .having((c) => c.state, 'state', equals(0))); + controller.add(1); + await tester.pumpAndSettle(); + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + controller.subtract(2); + await tester.pumpAndSettle(); + expect(controller.state, equals(-1)); + expect(find.text('-1'), findsOneWidget); + }, + ); + }); }); /// Basic wrapper for the current widgets. From 21941345e3e3372d226c94c2f73ba349e9505028 Mon Sep 17 00:00:00 2001 From: Michael Lazebny Date: Sat, 7 Sep 2024 19:50:39 +0200 Subject: [PATCH 07/12] Added code generation step --- .github/workflows/checkout.yml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/.github/workflows/checkout.yml b/.github/workflows/checkout.yml index 94447ce..5443d4e 100644 --- a/.github/workflows/checkout.yml +++ b/.github/workflows/checkout.yml @@ -59,10 +59,13 @@ jobs: timeout-minutes: 1 run: | flutter pub get + + - name: ๐Ÿฆ„ Generate Code + run: | + dart run build_runner build -d - - name: ๐Ÿ”Ž Check format - timeout-minutes: 1 - run: dart format --set-exit-if-changed -l 80 -o none lib/ + - name: โœจ Check Formatting + run: find lib test -name "*.dart" ! -name "*.*.dart" -print0 | xargs -0 dart format --set-exit-if-changed --line-length 80 -o none - name: ๐Ÿ“ˆ Check analyzer timeout-minutes: 1 From b984602f078145e7993ac9838e84a6cabb0aac6a Mon Sep 17 00:00:00 2001 From: Michael Lazebny Date: Sat, 7 Sep 2024 19:53:28 +0200 Subject: [PATCH 08/12] Moved mockito to dev dependencies --- example/pubspec.lock | 16 ---------------- pubspec.yaml | 2 +- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/example/pubspec.lock b/example/pubspec.lock index 8cab3ae..c172bde 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -398,14 +398,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - mockito: - dependency: transitive - description: - name: mockito - sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917" - url: "https://pub.dev" - source: hosted - version: "5.4.4" package_config: dependency: transitive description: @@ -571,14 +563,6 @@ packages: description: flutter source: sdk version: "0.0.99" - source_gen: - dependency: transitive - description: - name: source_gen - sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832" - url: "https://pub.dev" - source: hosted - version: "1.5.0" source_span: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index eeae72d..595f077 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,10 +41,10 @@ dependencies: flutter: sdk: flutter meta: ^1.0.0 - mockito: ^5.4.4 dev_dependencies: flutter_test: sdk: flutter + mockito: ^5.4.4 flutter_lints: ^2.0.0 build_runner: ^2.4.11 From f70e0bb20f6a7ad530dba7f5e0d8fd7bb324f68b Mon Sep 17 00:00:00 2001 From: Michael Lazebny Date: Sun, 8 Sep 2024 08:14:28 +0200 Subject: [PATCH 09/12] Handle unawaited futures --- lib/src/core/controller.dart | 6 +- .../concurrent_controller_handler.dart | 41 +++++-- .../droppable_controller_handler.dart | 41 +++++-- .../sequential_controller_handler.dart | 49 ++++++--- .../concurrent_controller_handler_test.dart | 83 +++++++++++--- test/src/handlers/fake_controller.dart | 43 ++++++++ .../sequential_controller_handler_test.dart | 103 ++++++------------ 7 files changed, 242 insertions(+), 124 deletions(-) create mode 100644 test/src/handlers/fake_controller.dart diff --git a/lib/src/core/controller.dart b/lib/src/core/controller.dart index ec7e0ee..6bb0704 100644 --- a/lib/src/core/controller.dart +++ b/lib/src/core/controller.dart @@ -23,7 +23,11 @@ abstract interface class IController implements Listenable { /// - [ConcurrentControllerHandler] - handler that executes concurrently /// - [SequentialControllerHandler] - handler that executes sequentially /// - [DroppableControllerHandler] - handler that drops the request when busy - void handle(Future Function() handler); + Future handle( + Future Function() handler, { + Future Function(Object error, StackTrace stackTrace)? onError, + Future Function()? onDone, + }); /// Whether the controller has been permanently disposed. bool get isDisposed; diff --git a/lib/src/handlers/concurrent_controller_handler.dart b/lib/src/handlers/concurrent_controller_handler.dart index ee7a1d2..9c38e66 100644 --- a/lib/src/handlers/concurrent_controller_handler.dart +++ b/lib/src/handlers/concurrent_controller_handler.dart @@ -39,18 +39,39 @@ base mixin ConcurrentControllerHandler on Controller { } } - try { - await handler(); - } on Object catch (error, stackTrace) { - await handleError(error, stackTrace); - } finally { - try { - await onDone?.call(); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } + Future handleZoneError( + Object error, + StackTrace stackTrace, + ) async { + if (isDisposed) return; + super.onError(error, stackTrace); + + assert( + false, + 'A zone error occurred during controller event handling. ' + 'This may be caused by an unawaited future. ' + 'Make sure to await all futures in the controller ' + 'event handlers.', + ); } + await runZonedGuarded( + () async { + try { + await handler(); + } on Object catch (error, stackTrace) { + await handleError(error, stackTrace); + } finally { + try { + await onDone?.call(); + } on Object catch (error, stackTrace) { + super.onError(error, stackTrace); + } + } + }, + handleZoneError, + ); + _processingCalls--; } } diff --git a/lib/src/handlers/droppable_controller_handler.dart b/lib/src/handlers/droppable_controller_handler.dart index 58c0b84..f3d0253 100644 --- a/lib/src/handlers/droppable_controller_handler.dart +++ b/lib/src/handlers/droppable_controller_handler.dart @@ -45,18 +45,39 @@ base mixin DroppableControllerHandler on Controller { } } - try { - await handler(); - } on Object catch (error, stackTrace) { - await handleError(error, stackTrace); - } finally { - try { - await onDone?.call(); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } + Future handleZoneError( + Object error, + StackTrace stackTrace, + ) async { + if (isDisposed) return; + super.onError(error, stackTrace); + + assert( + false, + 'A zone error occurred during controller event handling. ' + 'This may be caused by an unawaited future. ' + 'Make sure to await all futures in the controller ' + 'event handlers.', + ); } + await runZonedGuarded( + () async { + try { + await handler(); + } on Object catch (error, stackTrace) { + await handleError(error, stackTrace); + } finally { + try { + await onDone?.call(); + } on Object catch (error, stackTrace) { + super.onError(error, stackTrace); + } + } + }, + handleZoneError, + ); + _processingCalls--; } } diff --git a/lib/src/handlers/sequential_controller_handler.dart b/lib/src/handlers/sequential_controller_handler.dart index 47f8a9f..21bd875 100644 --- a/lib/src/handlers/sequential_controller_handler.dart +++ b/lib/src/handlers/sequential_controller_handler.dart @@ -45,17 +45,38 @@ base mixin SequentialControllerHandler on Controller { } } - try { - await handler(); - } on Object catch (error, stackTrace) { - await handleError(error, stackTrace); - } finally { - try { - await onDone?.call(); - } on Object catch (error, stackTrace) { - super.onError(error, stackTrace); - } + Future handleZoneError( + Object error, + StackTrace stackTrace, + ) async { + if (isDisposed) return; + super.onError(error, stackTrace); + + assert( + false, + 'A zone error occurred during controller event handling. ' + 'This may be caused by an unawaited future. ' + 'Make sure to await all futures in the controller ' + 'event handlers.', + ); } + + await runZonedGuarded( + () async { + try { + await handler(); + } on Object catch (error, stackTrace) { + await handleError(error, stackTrace); + } finally { + try { + await onDone?.call(); + } on Object catch (error, stackTrace) { + super.onError(error, stackTrace); + } + } + }, + handleZoneError, + ); }, ); @@ -134,12 +155,8 @@ class _SequentialTask { Future get future => _completer.future; Future call() async { - try { - final result = await _task(); - _completer.complete(result); - } on Object catch (error, stackTrace) { - _completer.completeError(error, stackTrace); - } + final result = await _task(); + _completer.complete(result); } void reject(Object error, StackTrace stackTrace) { diff --git a/test/src/handlers/concurrent_controller_handler_test.dart b/test/src/handlers/concurrent_controller_handler_test.dart index ba322ad..19a46b1 100644 --- a/test/src/handlers/concurrent_controller_handler_test.dart +++ b/test/src/handlers/concurrent_controller_handler_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:control/control.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; @@ -7,6 +9,7 @@ import 'package:mockito/mockito.dart'; MockSpec(), ]) import 'concurrent_controller_handler_test.mocks.dart'; +import 'fake_controller.dart'; void main() { group( @@ -111,28 +114,76 @@ void main() { verify(observer.onError(controller, any, any)).called(1); }); - }, - ); -} -final class _FakeController extends Controller - with ConcurrentControllerHandler { - int _state = 0; + test('should handle mixed operations with errors', () async { + final futures = >[ + controller.increment(), + controller.throwError(), + controller.decrement(), + controller.throwError(), + ]; - int get state => _state; + await expectLater(Future.wait(futures), completes); + expect(controller.state, 0); + expect(controller.isProcessing, isFalse); - Future increment() => handle(() async { - await Future.delayed(const Duration(milliseconds: 100)); - _state++; + verify(observer.onError(controller, any, any)).called(2); }); - Future decrement() => handle(() async { - await Future.delayed(const Duration(milliseconds: 100)); - _state--; + test('should not process operations after disposal', () async { + final controller = _FakeController()..dispose(); + + final future = controller.increment(); + await expectLater(future, completes); + + expect(controller.state, 0); + expect(controller.isProcessing, isFalse); }); - Future throwError() => handle(() async { - await Future.delayed(const Duration(milliseconds: 100)); - throw Exception('Error'); + test('should handle errors in onError and onDone', () async { + final future = controller.throwErrorEverywhere(); + expect(controller.isProcessing, isTrue); + + await expectLater(future, completes); + expect(controller.isProcessing, isFalse); + + verify(observer.onError(controller, any, any)).called(3); }); + + test( + 'handles an error when handle spawns unawaited future', + () async { + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + Object zoneError = 0; + + await runZonedGuarded( + controller.throwUnawaited, + (err, stack) => zoneError = err, + ); + + await Future.delayed(const Duration(milliseconds: 200)); + + expect( + zoneError, + isA(), + reason: 'The error must be raised when an unawaited future ' + 'is spawned', + ); + + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + // Reported to observer once + verify( + observer.onError(controller, any, any), + ).called(1); + }, + ); + }, + ); } + +final class _FakeController = FakeTestController + with ConcurrentControllerHandler; diff --git a/test/src/handlers/fake_controller.dart b/test/src/handlers/fake_controller.dart new file mode 100644 index 0000000..7966f63 --- /dev/null +++ b/test/src/handlers/fake_controller.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:control/control.dart'; + +abstract base class FakeTestController extends Controller { + int _state = 0; + + int get state => _state; + + Future increment() => handle(() async { + await Future.delayed(const Duration(milliseconds: 100)); + _state++; + }); + + Future decrement() => handle(() async { + await Future.delayed(const Duration(milliseconds: 100)); + _state--; + }); + + Future throwError() => handle(() async { + await Future.delayed(const Duration(milliseconds: 100)); + throw Exception('Error'); + }); + + Future throwUnawaited() => handle(() async { + Future.delayed(const Duration(milliseconds: 100), () { + throw Exception('Error'); + }); + }); + + Future throwErrorEverywhere() => handle( + () async { + await Future.delayed(const Duration(milliseconds: 100)); + throw Exception('Error'); + }, + onError: (error, stackTrace) async { + throw Exception('Error'); + }, + onDone: () async { + throw Exception('Error'); + }, + ); +} diff --git a/test/src/handlers/sequential_controller_handler_test.dart b/test/src/handlers/sequential_controller_handler_test.dart index 3ab5549..3e06521 100644 --- a/test/src/handlers/sequential_controller_handler_test.dart +++ b/test/src/handlers/sequential_controller_handler_test.dart @@ -1,8 +1,11 @@ +import 'dart:async'; + import 'package:control/control.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'fake_controller.dart'; @GenerateNiceMocks([ MockSpec(), ]) @@ -71,35 +74,6 @@ void main() { }, ); - test( - 'handles error in onDone', - () async { - expect(controller.isProcessing, isFalse); - expect(controller.state, 0); - - final incrementFuture = controller.increment(); - expect(controller.isProcessing, isTrue); - expect(controller.state, 0); - - await expectLater(incrementFuture, completes); - expect(controller.isProcessing, isFalse); - expect(controller.state, 1); - - final throwFuture = controller.throwErrorOnDone(); - - expect(controller.isProcessing, isTrue); - expect(controller.state, 1); - - await expectLater(throwFuture, completes); - expect(controller.isProcessing, isFalse); - expect(controller.state, 1); - - verify( - observer.onError(controller, any, any), - ).called(2); - }, - ); - test( 'handles an error when observer throws', () async { @@ -139,54 +113,41 @@ void main() { expect(controller.state, 0); }, ); - }, - ); -} -final class _FakeController extends StateController - with SequentialControllerHandler { - _FakeController() : super(initialState: 0); + test( + 'handles an error when handle spawns unawaited future', + () async { + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); - /// Increments the state by one. - Future increment() => handle(() async { - await Future.delayed(const Duration(milliseconds: 100)); - setState(state + 1); - }); + Object zoneError = 0; - /// Decrements the state by one. - Future decrement() => handle(() async { - await Future.delayed(const Duration(milliseconds: 100)); - setState(state - 1); - }); + await runZonedGuarded( + controller.throwUnawaited, + (err, stack) => zoneError = err, + ); - /// Throws an error. - Future throwError() => handle(() async { - await Future.delayed(const Duration(milliseconds: 100)); - throw Exception('Error'); - }); + await Future.delayed(const Duration(milliseconds: 200)); - /// Throws an error in onDone. - Future throwErrorOnDone() => handle( - () async { - await Future.delayed(const Duration(milliseconds: 100)); - throw Exception('Error'); - }, - onDone: () async { - throw Exception('Error in onDone'); - }, - ); + expect( + zoneError, + isA(), + reason: 'The error must be raised when an unawaited future ' + 'is spawned', + ); - /// Throws an error in onError, onDone, and the main handler. - Future throwErrorEverywhere() => handle( - () async { - await Future.delayed(const Duration(milliseconds: 100)); - throw Exception('Error'); - }, - onError: (error, stackTrace) async { - throw Exception('Error in onError'); - }, - onDone: () async { - throw Exception('Error in onDone'); + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + // Reported to observer once + verify( + observer.onError(controller, any, any), + ).called(1); }, ); + }, + ); } + +final class _FakeController = FakeTestController + with SequentialControllerHandler; From a8fc0c6147e77a906936b6e7efaf3c82e381a084 Mon Sep 17 00:00:00 2001 From: Michael Lazebny Date: Sun, 8 Sep 2024 08:42:07 +0200 Subject: [PATCH 10/12] Updated tests --- lib/control.dart | 9 +- .../sequential_controller_handler.dart | 15 +- .../concurrent_controller_handler_test.dart | 7 +- .../droppable_controller_handler_test.dart | 241 ++++++++++++++++++ ...ake_controller.dart => handler_utils.dart} | 6 + .../sequential_controller_handler_test.dart | 22 +- 6 files changed, 278 insertions(+), 22 deletions(-) create mode 100644 test/src/handlers/droppable_controller_handler_test.dart rename test/src/handlers/{fake_controller.dart => handler_utils.dart} (89%) diff --git a/lib/control.dart b/lib/control.dart index 55e9c82..a442af0 100644 --- a/lib/control.dart +++ b/lib/control.dart @@ -4,9 +4,12 @@ library control; export 'package:control/src/core/controller.dart' hide IController; export 'package:control/src/core/state_controller.dart' hide IStateController; /* Handlers */ -export 'package:control/src/handlers/concurrent_controller_handler.dart'; -export 'package:control/src/handlers/droppable_controller_handler.dart'; -export 'package:control/src/handlers/sequential_controller_handler.dart'; +export 'package:control/src/handlers/concurrent_controller_handler.dart' + show ConcurrentControllerHandler; +export 'package:control/src/handlers/droppable_controller_handler.dart' + show DroppableControllerHandler; +export 'package:control/src/handlers/sequential_controller_handler.dart' + show SequentialControllerHandler; /* Widget */ export 'package:control/src/widget/controller_scope.dart'; export 'package:control/src/widget/state_consumer.dart'; diff --git a/lib/src/handlers/sequential_controller_handler.dart b/lib/src/handlers/sequential_controller_handler.dart index 21bd875..0bd6116 100644 --- a/lib/src/handlers/sequential_controller_handler.dart +++ b/lib/src/handlers/sequential_controller_handler.dart @@ -8,7 +8,8 @@ import 'package:meta/meta.dart'; /// This mixin should be used on classes that extend [Controller]. base mixin SequentialControllerHandler on Controller { // The event queue for sequential execution. - final _ControllerEventQueue _eventQueue = _ControllerEventQueue(); + final SequentialControllerEventQueue _eventQueue = + SequentialControllerEventQueue(); @override @nonVirtual @@ -88,9 +89,9 @@ base mixin SequentialControllerHandler on Controller { } /// A queue for managing sequential execution of controller events. -class _ControllerEventQueue { - final DoubleLinkedQueue<_SequentialTask> _queue = - DoubleLinkedQueue<_SequentialTask>(); +class SequentialControllerEventQueue { + final DoubleLinkedQueue> _queue = + DoubleLinkedQueue>(); Future? _processing; bool _isClosed = false; @@ -109,7 +110,7 @@ class _ControllerEventQueue { throw StateError('Cannot push to a closed queue'); } - final sequentialTask = _SequentialTask(task); + final sequentialTask = SequentialEventQueueTask(task); _queue.add(sequentialTask); _startProcessing(); @@ -147,8 +148,8 @@ class _ControllerEventQueue { } /// Represents a task in the sequential queue. -class _SequentialTask { - _SequentialTask(this._task); +class SequentialEventQueueTask { + SequentialEventQueueTask(this._task); final Future Function() _task; final _completer = Completer(); diff --git a/test/src/handlers/concurrent_controller_handler_test.dart b/test/src/handlers/concurrent_controller_handler_test.dart index 19a46b1..54e27d4 100644 --- a/test/src/handlers/concurrent_controller_handler_test.dart +++ b/test/src/handlers/concurrent_controller_handler_test.dart @@ -2,14 +2,9 @@ import 'dart:async'; import 'package:control/control.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -@GenerateNiceMocks([ - MockSpec(), -]) -import 'concurrent_controller_handler_test.mocks.dart'; -import 'fake_controller.dart'; +import 'handler_utils.dart'; void main() { group( diff --git a/test/src/handlers/droppable_controller_handler_test.dart b/test/src/handlers/droppable_controller_handler_test.dart new file mode 100644 index 0000000..e88cd30 --- /dev/null +++ b/test/src/handlers/droppable_controller_handler_test.dart @@ -0,0 +1,241 @@ +import 'dart:async'; + +import 'package:control/control.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import 'handler_utils.dart'; + +void main() { + group( + 'DroppableControllerHandler', + () { + late MockIControllerObserver observer; + late _FakeController controller; + + setUp(() { + controller = _FakeController(); + observer = MockIControllerObserver(); + Controller.observer = observer; + }); + + tearDown(() { + reset(observer); + Controller.observer = null; + controller.dispose(); + }); + + test( + 'should drop operations when busy', + () async { + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + final stopwatch = Stopwatch()..start(); + + final futures = >[ + controller.increment(), + controller.increment(), + controller.increment(), + controller.increment(), + controller.decrement(), + controller.decrement(), + controller.decrement(), + controller.decrement(), + ]; + + expect(controller.isProcessing, isTrue); + expect(controller.state, 0); + + await expectLater(Future.wait(futures), completes); + + stopwatch.stop(); + + expect(controller.isProcessing, isFalse); + expect( + controller.state, + 1, + reason: 'Only first increment should be executed', + ); + expect(stopwatch.elapsedMilliseconds, lessThan(200)); + }, + ); + + test( + 'should drop operations when busy 2', + () async { + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + final future1 = controller.increment(); + final future2 = controller.throwError(); + + expect(controller.isProcessing, isTrue); + expect(controller.state, 0); + + await expectLater(future1, completes); + await expectLater(future2, completes); + + verifyNever(observer.onError(controller, any, any)); + + expect(controller.isProcessing, isFalse); + expect(controller.state, 1); + }, + ); + + test( + 'should drop operations when busy 3', + () async { + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + final future1 = controller.increment(); + final future2 = controller.throwErrorEverywhere(); + + expect(controller.isProcessing, isTrue); + expect(controller.state, 0); + + await expectLater(future1, completes); + await expectLater(future2, completes); + + verifyNever(observer.onError(controller, any, any)); + + expect(controller.isProcessing, isFalse); + expect(controller.state, 1); + }, + ); + + test( + 'should drop operations when busy 4', + () async { + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + final future1 = controller.throwError(); + final future2 = controller.increment(); + + expect(controller.isProcessing, isTrue); + expect(controller.state, 0); + + await expectLater(future1, completes); + await expectLater(future2, completes); + + verify(observer.onError(controller, any, any)).called(1); + + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + }, + ); + + test( + 'should drop and when finished start new operation', + () async { + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + final future1 = controller.increment(); + + expect(controller.isProcessing, isTrue); + expect(controller.state, 0); + + await expectLater(future1, completes); + + expect(controller.isProcessing, isFalse); + expect(controller.state, 1); + + final future2 = controller.increment(); + + await expectLater(future2, completes); + expect(controller.isProcessing, false); + expect(controller.state, 2); + }, + ); + + test( + 'should handle error and drop operations', + () async { + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + final future = controller.throwError(); + + expect(controller.isProcessing, isTrue); + expect(controller.state, 0); + + await expectLater(future, completes); + + verify(observer.onError(controller, any, any)).called(1); + }, + ); + + test('should handle errors when observer throws', () async { + when( + observer.onError(controller, any, any), + ).thenThrow(Exception('Error')); + + final future = controller.throwError(); + expect(controller.isProcessing, isTrue); + + await expectLater(future, completes); + expect(controller.isProcessing, isFalse); + + verify(observer.onError(controller, any, any)).called(1); + }); + + test('should not process operations after disposal', () async { + final controller = _FakeController()..dispose(); + + final future = controller.increment(); + await expectLater(future, completes); + + expect(controller.state, 0); + expect(controller.isProcessing, isFalse); + }); + + test('should handle errors in onError and onDone', () async { + final future = controller.throwErrorEverywhere(); + expect(controller.isProcessing, isTrue); + + await expectLater(future, completes); + expect(controller.isProcessing, isFalse); + + verify(observer.onError(controller, any, any)).called(3); + }); + + test( + 'handles an error when handle spawns unawaited future', + () async { + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + Object zoneError = 0; + + await runZonedGuarded( + controller.throwUnawaited, + (err, stack) => zoneError = err, + ); + + await Future.delayed(const Duration(milliseconds: 200)); + + expect( + zoneError, + isA(), + reason: 'The error must be raised when an unawaited future ' + 'is spawned', + ); + + expect(controller.isProcessing, isFalse); + expect(controller.state, 0); + + // Reported to observer once + verify( + observer.onError(controller, any, any), + ).called(1); + }, + ); + }, + ); +} + +final class _FakeController = FakeTestController + with DroppableControllerHandler; diff --git a/test/src/handlers/fake_controller.dart b/test/src/handlers/handler_utils.dart similarity index 89% rename from test/src/handlers/fake_controller.dart rename to test/src/handlers/handler_utils.dart index 7966f63..eb1181e 100644 --- a/test/src/handlers/fake_controller.dart +++ b/test/src/handlers/handler_utils.dart @@ -1,6 +1,12 @@ import 'dart:async'; import 'package:control/control.dart'; +import 'package:mockito/annotations.dart'; + +@GenerateNiceMocks([ + MockSpec(), +]) +export 'handler_utils.mocks.dart'; abstract base class FakeTestController extends Controller { int _state = 0; diff --git a/test/src/handlers/sequential_controller_handler_test.dart b/test/src/handlers/sequential_controller_handler_test.dart index 3e06521..e4289d3 100644 --- a/test/src/handlers/sequential_controller_handler_test.dart +++ b/test/src/handlers/sequential_controller_handler_test.dart @@ -1,15 +1,11 @@ import 'dart:async'; import 'package:control/control.dart'; +import 'package:control/src/handlers/sequential_controller_handler.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; -import 'fake_controller.dart'; -@GenerateNiceMocks([ - MockSpec(), -]) -import 'sequential_controller_handler_test.mocks.dart'; +import 'handler_utils.dart'; void main() { group( @@ -145,6 +141,20 @@ void main() { ).called(1); }, ); + + test( + 'error in SequentialControllerEventQueue', + () async { + final eventQueue = SequentialControllerEventQueue(); + + Future task() => Future.delayed( + const Duration(milliseconds: 100), + () => throw Exception('Error'), + ); + + await expectLater(eventQueue.push(task), throwsA(isA())); + }, + ); }, ); } From ee12a5ee45333112b6c015e95756665e9b54e395 Mon Sep 17 00:00:00 2001 From: Michael Lazebny Date: Sun, 8 Sep 2024 08:42:30 +0200 Subject: [PATCH 11/12] added comments --- lib/src/handlers/sequential_controller_handler.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/src/handlers/sequential_controller_handler.dart b/lib/src/handlers/sequential_controller_handler.dart index 0bd6116..2ae18e4 100644 --- a/lib/src/handlers/sequential_controller_handler.dart +++ b/lib/src/handlers/sequential_controller_handler.dart @@ -149,17 +149,21 @@ class SequentialControllerEventQueue { /// Represents a task in the sequential queue. class SequentialEventQueueTask { + /// Creates a new [SequentialEventQueueTask] with the given task. SequentialEventQueueTask(this._task); final Future Function() _task; final _completer = Completer(); + /// The future that completes when the task is done. Future get future => _completer.future; + /// Calls the task and completes the future with the result. Future call() async { final result = await _task(); _completer.complete(result); } + /// Completes the future with an error. void reject(Object error, StackTrace stackTrace) { if (!_completer.isCompleted) { _completer.completeError(error, stackTrace); From 1a20a060a4b22d8533f3ece968b25a3a10464c17 Mon Sep 17 00:00:00 2001 From: Michael Lazebny Date: Sun, 8 Sep 2024 08:45:24 +0200 Subject: [PATCH 12/12] Added test --- .../sequential_controller_handler_test.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/test/src/handlers/sequential_controller_handler_test.dart b/test/src/handlers/sequential_controller_handler_test.dart index e4289d3..53386e5 100644 --- a/test/src/handlers/sequential_controller_handler_test.dart +++ b/test/src/handlers/sequential_controller_handler_test.dart @@ -155,6 +155,22 @@ void main() { await expectLater(eventQueue.push(task), throwsA(isA())); }, ); + + test( + 'when processing in queue finishes, it should be empty', + () async { + final eventQueue = SequentialControllerEventQueue(); + + unawaited(eventQueue.push(() async {})); + unawaited(eventQueue.push(() async {})); + + expect(eventQueue.length, 2); + + await eventQueue.processing; + + expect(eventQueue.length, 0); + }, + ); }, ); }