diff --git a/.github/workflows/checkout.yml b/.github/workflows/checkout.yml index 35d248b..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 @@ -71,7 +74,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 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/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..c172bde 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: @@ -623,10 +623,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 +655,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 +716,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..a442af0 100644 --- a/lib/control.dart +++ b/lib/control.dart @@ -1,9 +1,15 @@ 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' + 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/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..6bb0704 --- /dev/null +++ b/lib/src/core/controller.dart @@ -0,0 +1,159 @@ +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 + 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; + + /// 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..9c38e66 --- /dev/null +++ b/lib/src/handlers/concurrent_controller_handler.dart @@ -0,0 +1,77 @@ +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); + } + } + + 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 new file mode 100644 index 0000000..f3d0253 --- /dev/null +++ b/lib/src/handlers/droppable_controller_handler.dart @@ -0,0 +1,83 @@ +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); + } + } + + 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 new file mode 100644 index 0000000..2ae18e4 --- /dev/null +++ b/lib/src/handlers/sequential_controller_handler.dart @@ -0,0 +1,172 @@ +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 SequentialControllerEventQueue _eventQueue = + SequentialControllerEventQueue(); + + @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); + } + } + + 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, + ); + }, + ); + + @override + void dispose() { + _eventQueue.close(); + super.dispose(); + } +} + +/// A queue for managing sequential execution of controller events. +class SequentialControllerEventQueue { + final DoubleLinkedQueue> _queue = + DoubleLinkedQueue>(); + 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 = SequentialEventQueueTask(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 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); + } + } +} 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..595f077 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,4 +45,6 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + mockito: ^5.4.4 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..54e27d4 --- /dev/null +++ b/test/src/handlers/concurrent_controller_handler_test.dart @@ -0,0 +1,184 @@ +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( + 'ConcurrentControllerHandler', + () { + late _FakeController controller; + late MockIControllerObserver observer; + + setUp(() { + controller = _FakeController(); + observer = MockIControllerObserver(); + Controller.observer = observer; + }); + + tearDown(() { + controller.dispose(); + Controller.observer = null; + reset(observer); + }); + + test( + 'should execute operations concurrently', + () 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, 0); + 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); + }); + + 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); + }); + + test('should handle mixed operations with errors', () async { + final futures = >[ + controller.increment(), + controller.throwError(), + controller.decrement(), + controller.throwError(), + ]; + + await expectLater(Future.wait(futures), completes); + expect(controller.state, 0); + expect(controller.isProcessing, isFalse); + + verify(observer.onError(controller, any, any)).called(2); + }); + + 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 ConcurrentControllerHandler; 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/handler_utils.dart b/test/src/handlers/handler_utils.dart new file mode 100644 index 0000000..eb1181e --- /dev/null +++ b/test/src/handlers/handler_utils.dart @@ -0,0 +1,49 @@ +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; + + 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 new file mode 100644 index 0000000..53386e5 --- /dev/null +++ b/test/src/handlers/sequential_controller_handler_test.dart @@ -0,0 +1,179 @@ +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/mockito.dart'; + +import 'handler_utils.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 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); + }, + ); + + 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); + }, + ); + + 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())); + }, + ); + + 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); + }, + ); + }, + ); +} + +final class _FakeController = FakeTestController + with SequentialControllerHandler; diff --git a/test/src/widget/controller_scope_test.dart b/test/src/widget/controller_scope_test.dart new file mode 100644 index 0000000..3e44303 --- /dev/null +++ b/test/src/widget/controller_scope_test.dart @@ -0,0 +1,146 @@ +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'; + +void main() => group('ControllerScope', () { + 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'), + ), + ), + ), + ); + 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(); + }, + ); + }); + + group('ControllerScope.create', () { + test('constructor', () { + expect( + () => ControllerScope(_FakeController.new), + returnsNormally, + ); + expect( + 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. +Widget _appContext({required Widget child, Size? size}) => MediaQuery( + data: MediaQueryData( + size: size ?? const Size(800, 600), + ), + child: Directionality( + textDirection: TextDirection.ltr, + child: Material( + elevation: 0, + child: DefaultSelectionStyle( + child: ScaffoldMessenger( + child: HeroControllerScope.none( + child: Navigator( + pages: >[ + MaterialPage( + child: Scaffold( + body: SafeArea( + child: Center( + child: child, + ), + ), + ), + ), + ], + onPopPage: (route, result) => route.didPop(result), + ), + ), + ), + ), + ), + ), + ); + +final class _FakeController extends StateController + with SequentialControllerHandler { + _FakeController({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); + }); +} 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; diff --git a/test/widget/controller_scope_test.dart b/test/widget/controller_scope_test.dart deleted file mode 100644 index d7409c2..0000000 --- a/test/widget/controller_scope_test.dart +++ /dev/null @@ -1,149 +0,0 @@ -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: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(), - ); - }); - - 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(), - ); - }); - - 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. -Widget _appContext({required Widget child, Size? size}) => MediaQuery( - data: MediaQueryData( - size: size ?? const Size(800, 600), - ), - child: Directionality( - textDirection: TextDirection.ltr, - child: Material( - elevation: 0, - child: DefaultSelectionStyle( - child: ScaffoldMessenger( - child: HeroControllerScope.none( - child: Navigator( - pages: >[ - MaterialPage( - child: Scaffold( - body: SafeArea( - child: Center( - child: child, - ), - ), - ), - ), - ], - onPopPage: (route, result) => route.didPop(result), - ), - ), - ), - ), - ), - ), - ); - -final class _FakeController extends StateController - with SequentialControllerHandler { - _FakeController({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); - }); -}