From f83799887e7c3396fe8ef200979129b5b595df66 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Tue, 11 Nov 2025 21:08:33 +0400 Subject: [PATCH 01/20] Refactor controller and registry: Remove ControllerRegistry and related logic from Controller --- example/pubspec.lock | 60 ++++++++++++++--------------- lib/src/controller.dart | 3 -- lib/src/registry.dart | 84 ----------------------------------------- 3 files changed, 30 insertions(+), 117 deletions(-) delete mode 100644 lib/src/registry.dart diff --git a/example/pubspec.lock b/example/pubspec.lock index 095c9a0..1f853b3 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -109,10 +109,10 @@ packages: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" checked_yaml: dependency: transitive description: @@ -125,10 +125,10 @@ packages: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" code_builder: dependency: transitive description: @@ -141,10 +141,10 @@ packages: dependency: "direct main" description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" control: dependency: "direct main" description: @@ -188,10 +188,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" ffi: dependency: transitive description: @@ -330,26 +330,26 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "11.0.2" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.10" leak_tracker_testing: dependency: transitive description: name: leak_tracker_testing - sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.0.2" lints: dependency: transitive description: @@ -370,10 +370,10 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -386,10 +386,10 @@ packages: dependency: "direct main" description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" mime: dependency: transitive description: @@ -410,10 +410,10 @@ packages: dependency: "direct main" description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" path_provider_linux: dependency: transitive description: @@ -562,7 +562,7 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: @@ -575,18 +575,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" stream_transform: dependency: transitive description: @@ -623,10 +623,10 @@ packages: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.6" timing: dependency: transitive description: @@ -647,10 +647,10 @@ packages: dependency: transitive description: name: vector_math - sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" vm_service: dependency: transitive description: @@ -716,5 +716,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.4.0 <4.0.0" + dart: ">=3.8.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/lib/src/controller.dart b/lib/src/controller.dart index 340ed7a..898fda7 100644 --- a/lib/src/controller.dart +++ b/lib/src/controller.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:control/src/handler_context.dart'; -import 'package:control/src/registry.dart'; import 'package:control/src/state_controller.dart'; import 'package:flutter/foundation.dart' show ChangeNotifier, Listenable, VoidCallback; @@ -81,7 +80,6 @@ abstract interface class IControllerObserver { abstract base class Controller with ChangeNotifier implements IController { /// {@macro controller} Controller() { - ControllerRegistry().insert(this); runZonedGuarded( () => Controller.observer?.onCreate(this), (error, stackTrace) {/* ignore */}, // coverage:ignore-line @@ -175,7 +173,6 @@ abstract base class Controller with ChangeNotifier implements IController { () => Controller.observer?.onDispose(this), (error, stackTrace) {/* ignore */}, // coverage:ignore-line ); - ControllerRegistry().remove(); super.dispose(); } } diff --git a/lib/src/registry.dart b/lib/src/registry.dart deleted file mode 100644 index 29e0657..0000000 --- a/lib/src/registry.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:control/src/controller.dart'; -import 'package:control/src/state_controller.dart'; -import 'package:flutter/foundation.dart'; -import 'package:meta/meta.dart'; - -/// StateRegistry Singleton class -/// Used to register and retrieve the state controllers at debug mode -/// to track the state of the controllers and leaks in the application. -@internal -final class ControllerRegistry with ControllerRegistry$Global { - factory ControllerRegistry() => _internalSingleton; - ControllerRegistry._internal(); - static final ControllerRegistry _internalSingleton = - ControllerRegistry._internal(); -} - -@internal -base mixin ControllerRegistry$Global { - late final Map>> _globalRegistry = - >>{}; - - @internal - List getAll() { - if (!kDebugMode) return const []; - final result = []; - for (final list in _globalRegistry.values) { - var j = 0; - for (var i = 0; i < list.length; i++) { - final wr = list[i]; - final target = wr.target; - if (target == null || target.isDisposed) continue; - if (i != j) list[j] = wr; - if (target is Controller) result.add(target); - j++; - } - list.length = j; - } - return result; - } - - /// Get the controller from the registry. - @internal - List get() { - if (!kDebugMode) return []; - final result = []; - final list = _globalRegistry[C]; - if (list == null) return result; - var j = 0; - for (var i = 0; i < list.length; i++) { - final wr = list[i]; - final target = wr.target; - if (target == null || target.isDisposed) continue; - if (i != j) list[j] = wr; - if (target is C) result.add(target); - j++; - } - list.length = j; - return result; - } - - /// Upsert the controller in the registry. - @internal - void insert(Controller controller) { - if (!kDebugMode) return; - remove(); - (_globalRegistry[Controller] ??= >[]) - .add(WeakReference(controller)); - } - - /// Remove the controller from the registry. - @internal - void remove() { - if (!kDebugMode) return; - final list = _globalRegistry[Controller]; - if (list == null) return; - var j = 0; - for (var i = 0; i < list.length; i++) { - if (list[i] is WeakReference) continue; - if (i != j) list[j] = list[i]; - j++; - } - list.length = j; - } -} From eed25432d9a53252bb0310f6d17d26b3e84bcaaa Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Tue, 11 Nov 2025 22:38:22 +0400 Subject: [PATCH 02/20] Update version to 1.0.0-dev and add Mutex implementation for concurrency control --- example/pubspec.lock | 2 +- lib/control.dart | 2 ++ lib/src/mutex.dart | 70 ++++++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 7 +++-- 4 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 lib/src/mutex.dart diff --git a/example/pubspec.lock b/example/pubspec.lock index 1f853b3..46ca6de 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -151,7 +151,7 @@ packages: path: ".." relative: true source: path - version: "0.2.0" + version: "1.0.0-dev" convert: dependency: "direct main" description: diff --git a/lib/control.dart b/lib/control.dart index c07da81..7b2da35 100644 --- a/lib/control.dart +++ b/lib/control.dart @@ -3,6 +3,8 @@ library; export 'package:control/src/concurrency/concurrency.dart'; export 'package:control/src/controller.dart' hide IController; export 'package:control/src/controller_scope.dart' hide ControllerScope$Element; +export 'package:control/src/fast_mutex.dart'; export 'package:control/src/handler_context.dart' show HandlerContext; +export 'package:control/src/mutex.dart'; export 'package:control/src/state_consumer.dart'; export 'package:control/src/state_controller.dart' hide IStateController; diff --git a/lib/src/mutex.dart b/lib/src/mutex.dart new file mode 100644 index 0000000..d0d0da4 --- /dev/null +++ b/lib/src/mutex.dart @@ -0,0 +1,70 @@ +import 'dart:async'; +import 'dart:collection'; + +/// A simple mutex implementation using a linked list of completers. +/// This allows for synchronizing access to a critical section of code, +/// ensuring that only one task can execute the critical section at a time. +class Mutex { + /// Creates a new instance of the mutex. + Mutex(); + + /// Queue of completers representing tasks waiting for the mutex. + final DoubleLinkedQueue> _queue = + DoubleLinkedQueue>(); + + /// Check if the mutex is currently locked. + bool get isLocked => _queue.isNotEmpty; + + /// Returns the number of tasks waiting for the mutex. + int get tasks => _queue.length; + + /// Locks the mutex and returns + /// a future that completes when the lock is acquired. + /// The returned function can be called to unlock the mutex, + /// but it should only be called once and relatively expensive to call. + Future lock() { + final previous = _queue.lastOrNull?.future ?? Future.value(); + _queue.add(Completer.sync()); + return previous; + } + + /// Unlocks the mutex, allowing the next waiting task to proceed. + void unlock() { + if (_queue.isEmpty) { + assert(false, 'Mutex unlock called when no tasks are waiting.'); + return; + } + final completer = _queue.removeFirst(); // Remove the current lock holder + if (completer.isCompleted) { + assert(false, + 'Mutex unlock called when the completer is already completed.'); + return; + } + completer.complete(); + } + + /// Synchronizes the execution of a function, ensuring that only one + /// task can execute the function at a time. + Future synchronize(Future Function() action) async { + await lock(); + try { + return await action(); + } finally { + unlock(); + } + } +} + +/* void main() async { + final mutext = Mutext(); + + // Simulate a critical section + Future criticalSection(int id) async { + //print('Task $id is in the critical section'); + await Future.delayed(const Duration(seconds: 1)); + //print('Task $id is leaving the critical section'); + } + + // Start multiple tasks + for (var i = 0; i < 5; i++) mutext.synchronize(() => criticalSection(i)); +} */ diff --git a/pubspec.yaml b/pubspec.yaml index 308e5ff..1a027fa 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: control description: "Simple state management for Flutter with concurrency support." -version: 0.2.0 +version: 1.0.0-dev homepage: https://github.com/PlugFox/control @@ -34,7 +34,7 @@ platforms: # path: example.png environment: - sdk: '>=3.4.0 <4.0.0' + sdk: ">=3.4.0 <4.0.0" flutter: ">=3.16.0" dependencies: @@ -45,4 +45,5 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 + #benchmark_harness: any + flutter_lints: ^6.0.0 From 8a931a5181efe270c30c72c4cc442052d8303531 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 12 Nov 2025 02:22:18 +0400 Subject: [PATCH 03/20] Add linked, queue, and simple mutex implementations for concurrency control --- benchmark/mutex/linked_mutex.dart | 88 ++++++++++++ benchmark/mutex/queue_mutex.dart | 68 +++++++++ benchmark/mutex/simple_mutex.dart | 36 +++++ benchmark/mutex_benchmark.dart | 231 ++++++++++++++++++++++++++++++ lib/control.dart | 1 - lib/src/mutex.dart | 88 ++++-------- lib/src/util/linked_mutex.dart | 93 ++++++++++++ pubspec.yaml | 2 + 8 files changed, 547 insertions(+), 60 deletions(-) create mode 100644 benchmark/mutex/linked_mutex.dart create mode 100644 benchmark/mutex/queue_mutex.dart create mode 100644 benchmark/mutex/simple_mutex.dart create mode 100644 benchmark/mutex_benchmark.dart create mode 100644 lib/src/util/linked_mutex.dart diff --git a/benchmark/mutex/linked_mutex.dart b/benchmark/mutex/linked_mutex.dart new file mode 100644 index 0000000..b3cd84b --- /dev/null +++ b/benchmark/mutex/linked_mutex.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +/// {@template linked_mutex} +/// A mutex implementation using a linked list of tasks. +/// This allows for synchronizing access to a critical section of code, +/// ensuring that only one task can execute the critical section at a time. +/// {@endtemplate} +class LinkedMutex { + /// Creates a new instance of the mutex. + /// + /// {@macro linked_mutex} + LinkedMutex(); + + /// The head of the linked list of mutex tasks. + _MutexTask? _head; + + /// Check if the mutex is currently locked. + bool get locked => _head != null; + + /// Locks the mutex and returns + /// a future that completes when the lock is acquired. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// Future(() async { + /// final unlock = await mutex.lock(); + /// try { + /// await criticalSection(i); + /// } finally { + /// unlock(); + /// } + /// }); + /// ``` + Future lock() async { + final prior = _head; + final node = _head = _MutexTask.sync(); + if (prior != null) { + try { + prior.next = node; + await prior.future; + } on Object {/* Ignore errors */} + } + return node.complete; + } + + /// Synchronizes the execution of a function, ensuring that only one + /// task can execute the function at a time. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// mutex.synchronize(() => criticalSection(i)); + /// ``` + Future synchronize(Future Function() action) async { + final prior = _head; + final node = _head = _MutexTask.sync(); + if (prior != null) { + prior.next = node; + await prior.future; + } + try { + final result = await action(); + node.complete(); + return result; + } on Object { + node.complete(); + rethrow; + } finally { + if (identical(_head, node)) _head = null; + } + } +} + +/// A task in the linked list of mutex tasks. +final class _MutexTask { + _MutexTask.sync() : _completer = Completer.sync(); + + final Completer _completer; + + /// The future that completes when the task is done. + Future get future => _completer.future; + + /// Executes the task. + /// After completion, it triggers the execution of the next task in the queue. + void complete() => _completer.complete(); + + /// Next task in the mutex queue. + _MutexTask? next; +} diff --git a/benchmark/mutex/queue_mutex.dart b/benchmark/mutex/queue_mutex.dart new file mode 100644 index 0000000..d28dc18 --- /dev/null +++ b/benchmark/mutex/queue_mutex.dart @@ -0,0 +1,68 @@ +import 'dart:async'; +import 'dart:collection'; + +/// {@template queue_mutex} +/// A mutex implementation that uses a queue to manage access +/// to a critical section of code. +/// This ensures that only one task can execute the critical section at a time. +/// {@endtemplate} +class QueueMutex { + /// Creates a new instance of the mutex. + /// + /// {@macro queue_mutex} + QueueMutex(); + + /// Initial completed future used to represent an unlocked state. + static final Future _empty = Future.value(); + + /// Queue of completers representing tasks waiting for the mutex. + final DoubleLinkedQueue> _queue = + DoubleLinkedQueue>(); + + /// Check if the mutex is currently locked. + bool get locked => _queue.isNotEmpty; + + /// Returns the number of tasks waiting for the mutex. + int get tasks => _queue.length; + + /// Locks the mutex and returns + /// a future that completes when the lock is acquired. + /// The returned function can be called to unlock the mutex, + /// but it should only be called once and relatively expensive to call. + Future lock() { + final previous = _queue.lastOrNull?.future ?? _empty; + _queue.addLast(Completer.sync()); + return previous; + } + + /// Unlocks the mutex, allowing the next waiting task to proceed. + void unlock() { + if (_queue.isEmpty) { + assert(false, 'Mutex unlock called when no tasks are waiting.'); + return; + } + final completer = _queue.removeFirst(); // Remove the current lock holder + if (completer.isCompleted) { + assert(false, + 'Mutex unlock called when the completer is already completed.'); + return; + } + completer.complete(); + } + + /// Synchronizes the execution of a function, ensuring that only one + /// task can execute the function at a time. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// mutex.synchronize(() => criticalSection(i)); + /// ``` + Future synchronize(Future Function() action) async { + await lock(); + try { + return await action(); + } finally { + unlock(); + } + } +} diff --git a/benchmark/mutex/simple_mutex.dart b/benchmark/mutex/simple_mutex.dart new file mode 100644 index 0000000..ff6716f --- /dev/null +++ b/benchmark/mutex/simple_mutex.dart @@ -0,0 +1,36 @@ +/// {@template simple_mutex} +/// A simple mutex implementation that serializes access to a critical section +/// of code using a single future chain. +/// {@endtemplate} +class SimpleMutex { + /// Creates a new instance of the mutex. + /// + /// {@macro simple_mutex} + SimpleMutex(); + + /// The initial completed future used to represent an unlocked state. + static final Future _initial = Future.value(); + + Future _task = _initial; + + /// Indicates whether the mutex is currently locked. + bool get locked => !identical(_task, _initial); + + /// Executes a function while holding the mutex lock. + /// Beware that [action] should never throw exceptions. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// mutex.synchronize(() => criticalSection(i)); + /// ``` + Future synchronize(Future Function() action) async { + final prior = _task; + final task = _task = Future(() async { + if (!identical(_task, _initial)) await prior; + return action(); + }); + final result = await task; + if (identical(_task, task)) _task = _initial; + return result; + } +} diff --git a/benchmark/mutex_benchmark.dart b/benchmark/mutex_benchmark.dart new file mode 100644 index 0000000..5e80b77 --- /dev/null +++ b/benchmark/mutex_benchmark.dart @@ -0,0 +1,231 @@ +import 'dart:async'; +import 'dart:io' as io; + +import 'package:meta/meta.dart'; + +//import 'package:synchronized/synchronized.dart' as synchronized; + +import 'mutex/linked_mutex.dart'; +import 'mutex/queue_mutex.dart'; +import 'mutex/simple_mutex.dart'; + +void main() => runZonedGuarded( + () async { + io.stdout.writeln('Starting mutex benchmark...'); + final benchmarks = [ + WithoutMutexBenchmark(), + SimpleMutexBenchmark(), + //SynchronizedBenchmark(), + QueueMutexBenchmark(), + LinkedMutexBenchmark(), + LinkedLockBenchmark(), + ]; + + final ranks = + <({double score, int iterations, int elapsed, String name})>[]; + + for (final benchmark in benchmarks) { + final result = await benchmark.measure(); + final iterations = result.iterations; + final elapsed = result.elapsed; + final score = iterations / (elapsed / 1000); + ranks.add(( + score: score, + iterations: iterations, + elapsed: elapsed, + name: benchmark.name + )); + } + + final buffer = StringBuffer('Mutex Benchmark Results:\n'); + ranks.sort((a, b) => b.score.compareTo(a.score)); + for (final rank in ranks) { + buffer.writeln('${rank.name.padLeft(16, ' ')}: ' + '${rank.score.toStringAsFixed(2)} ops/sec ' + '(${rank.iterations} iterations in ${rank.elapsed} µs)'); + } + io.stdout.writeln(buffer.toString()); + await io.stdout.flush(); + io.exit(0); + }, + (error, stack) async { + io.stderr.writeln('Error in mutex benchmark: $error\n$stack'); + await io.stderr.flush(); + io.exit(1); + }, + ); + +abstract class BenchmarkBase { + BenchmarkBase() : _counter = 0; + + /// The name of the benchmark. + String get name; + + /// Internal counter for executed iterations. + int _counter; + + /// Measures the score for this benchmark by executing it repeatedly until + /// time minimum has been reached. + static Future<({int iterations, int elapsed})> _measureFor( + Future Function() f, int minimumMillis) async { + const batchSize = 100000; + final futures = List>.filled(batchSize, Future.value()); + final minimumMicros = minimumMillis * 1000; + var iter = 0; + var elapsed = 0; + try { + final watch = Stopwatch()..start(); + while (elapsed < minimumMicros) { + for (var i = 0; i < batchSize; i++) { + futures[i] = f(); + iter++; + } + await Future.wait(futures); + elapsed = watch.elapsedMicroseconds; + } + } on Object catch (e) { + io.stderr.writeln('Error during benchmark measurement: $e'); + rethrow; + } + return (iterations: iter, elapsed: elapsed); + } + + /// Resets the benchmark state. + void reset() { + _counter = 0; + } + + Future run(); + + @mustCallSuper + void test(int iterations) { + if (iterations <= 0) + throw StateError('Invalid iterations count in test: $iterations.'); + if (_counter != iterations) + throw StateError('Test for $name mismatch: ' + 'expected $iterations, got $_counter.'); + } + + /// Measures the score for the benchmark and returns it. + @mustCallSuper + Future<({int iterations, int elapsed})> measure() async { + // Warmup for at least 100ms. Discard result. + await _measureFor(run, 100); + // Reset state + reset(); + // Run the benchmark for at least 2000ms. + final result = await _measureFor(run, 2000); + // Test result + test(result.iterations); + return result; + } +} + +class WithoutMutexBenchmark extends BenchmarkBase { + WithoutMutexBenchmark(); + + @override + String get name => 'WithoutMutex'; + + @override + Future run() => Future.delayed(Duration.zero, () { + _counter++; + }); +} + +class QueueMutexBenchmark extends BenchmarkBase { + QueueMutexBenchmark(); + + @override + String get name => 'QueueMutex'; + + final QueueMutex _m = QueueMutex(); + + @override + Future run() => _m.synchronize(() async { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + }); +} + +class SimpleMutexBenchmark extends BenchmarkBase { + SimpleMutexBenchmark(); + + @override + String get name => 'SimpleMutex'; + + final SimpleMutex _m = SimpleMutex(); + + @override + Future run() => _m.synchronize(() async { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + }); + + @override + void test(int iterations) { + super.test(iterations); + if (_m.locked) throw StateError('SimpleMutex is still locked.'); + } +} + +/* class SynchronizedBenchmark extends BenchmarkBase { + SynchronizedBenchmark(); + + @override + String get name => 'Synchronized'; + + final synchronized.Lock _lock = synchronized.Lock(); + + @override + Future run() => _lock.synchronized(() async { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + }); + + @override + void test(int iterations) { + super.test(iterations); + if (_lock.locked) throw StateError('Synchronized lock is still locked.'); + } +} */ + +class LinkedMutexBenchmark extends BenchmarkBase { + LinkedMutexBenchmark(); + + @override + String get name => 'LinkedMutex'; + + final LinkedMutex _m = LinkedMutex(); + + @override + Future run() => _m.synchronize(() async { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + }); +} + +class LinkedLockBenchmark extends BenchmarkBase { + LinkedLockBenchmark(); + + @override + String get name => 'LinkedLock'; + + final LinkedMutex _m = LinkedMutex(); + + @override + Future run() async { + final unlock = await _m.lock(); + try { + final value = _counter; + await Future.delayed(Duration.zero); + _counter = value + 1; + } finally { + unlock(); + } + } +} diff --git a/lib/control.dart b/lib/control.dart index 7b2da35..fa857c8 100644 --- a/lib/control.dart +++ b/lib/control.dart @@ -3,7 +3,6 @@ library; export 'package:control/src/concurrency/concurrency.dart'; export 'package:control/src/controller.dart' hide IController; export 'package:control/src/controller_scope.dart' hide ControllerScope$Element; -export 'package:control/src/fast_mutex.dart'; export 'package:control/src/handler_context.dart' show HandlerContext; export 'package:control/src/mutex.dart'; export 'package:control/src/state_consumer.dart'; diff --git a/lib/src/mutex.dart b/lib/src/mutex.dart index d0d0da4..3e463be 100644 --- a/lib/src/mutex.dart +++ b/lib/src/mutex.dart @@ -1,70 +1,40 @@ -import 'dart:async'; -import 'dart:collection'; +import 'package:control/src/util/linked_mutex.dart'; -/// A simple mutex implementation using a linked list of completers. -/// This allows for synchronizing access to a critical section of code, -/// ensuring that only one task can execute the critical section at a time. -class Mutex { +/// {@template mutex} +/// A mutex (mutual exclusion) is a synchronization primitive +/// that is used to protect shared resources from concurrent access. +/// {@endtemplate} +abstract class Mutex { /// Creates a new instance of the mutex. - Mutex(); - - /// Queue of completers representing tasks waiting for the mutex. - final DoubleLinkedQueue> _queue = - DoubleLinkedQueue>(); + /// + /// {@macro mutex} + factory Mutex() => LinkedMutex(); /// Check if the mutex is currently locked. - bool get isLocked => _queue.isNotEmpty; - - /// Returns the number of tasks waiting for the mutex. - int get tasks => _queue.length; + bool get locked; /// Locks the mutex and returns /// a future that completes when the lock is acquired. - /// The returned function can be called to unlock the mutex, - /// but it should only be called once and relatively expensive to call. - Future lock() { - final previous = _queue.lastOrNull?.future ?? Future.value(); - _queue.add(Completer.sync()); - return previous; - } - - /// Unlocks the mutex, allowing the next waiting task to proceed. - void unlock() { - if (_queue.isEmpty) { - assert(false, 'Mutex unlock called when no tasks are waiting.'); - return; - } - final completer = _queue.removeFirst(); // Remove the current lock holder - if (completer.isCompleted) { - assert(false, - 'Mutex unlock called when the completer is already completed.'); - return; - } - completer.complete(); - } + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// Future(() async { + /// final unlock = await mutex.lock(); + /// try { + /// await criticalSection(i); + /// } finally { + /// unlock(); + /// } + /// }); + /// ``` + Future lock(); /// Synchronizes the execution of a function, ensuring that only one /// task can execute the function at a time. - Future synchronize(Future Function() action) async { - await lock(); - try { - return await action(); - } finally { - unlock(); - } - } + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// mutex.synchronize(() => criticalSection(i)); + /// ``` + Future synchronize(Future Function() action); } - -/* void main() async { - final mutext = Mutext(); - - // Simulate a critical section - Future criticalSection(int id) async { - //print('Task $id is in the critical section'); - await Future.delayed(const Duration(seconds: 1)); - //print('Task $id is leaving the critical section'); - } - - // Start multiple tasks - for (var i = 0; i < 5; i++) mutext.synchronize(() => criticalSection(i)); -} */ diff --git a/lib/src/util/linked_mutex.dart b/lib/src/util/linked_mutex.dart new file mode 100644 index 0000000..05b8558 --- /dev/null +++ b/lib/src/util/linked_mutex.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:control/src/mutex.dart'; + +/// {@template linked_mutex} +/// A mutex implementation using a linked list of tasks. +/// This allows for synchronizing access to a critical section of code, +/// ensuring that only one task can execute the critical section at a time. +/// {@endtemplate} +class LinkedMutex implements Mutex { + /// Creates a new instance of the mutex. + /// + /// {@macro linked_mutex} + LinkedMutex(); + + /// The head of the linked list of mutex tasks. + _MutexTask? _head; + + /// Check if the mutex is currently locked. + @override + bool get locked => _head != null; + + /// Locks the mutex and returns + /// a future that completes when the lock is acquired. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// Future(() async { + /// final unlock = await mutex.lock(); + /// try { + /// await criticalSection(i); + /// } finally { + /// unlock(); + /// } + /// }); + /// ``` + @override + Future lock() async { + final prior = _head; + final node = _head = _MutexTask.sync(); + if (prior != null) { + try { + prior.next = node; + await prior.future; + } on Object {/* Ignore errors */} + } + return node.complete; + } + + /// Synchronizes the execution of a function, ensuring that only one + /// task can execute the function at a time. + /// + /// ```dart + /// for (var i = 3; i > 0; i--) + /// mutex.synchronize(() => criticalSection(i)); + /// ``` + @override + Future synchronize(Future Function() action) async { + final prior = _head; + final node = _head = _MutexTask.sync(); + if (prior != null) { + prior.next = node; + await prior.future; + } + try { + final result = await action(); + node.complete(); + return result; + } on Object { + node.complete(); + rethrow; + } finally { + if (identical(_head, node)) _head = null; + } + } +} + +/// A task in the linked list of mutex tasks. +final class _MutexTask { + _MutexTask.sync() : _completer = Completer.sync(); + + final Completer _completer; + + /// The future that completes when the task is done. + Future get future => _completer.future; + + /// Executes the task. + /// After completion, it triggers the execution of the next task in the queue. + void complete() => _completer.complete(); + + /// Next task in the mutex queue. + _MutexTask? next; +} diff --git a/pubspec.yaml b/pubspec.yaml index 1a027fa..2ea0f22 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,4 +46,6 @@ dev_dependencies: flutter_test: sdk: flutter #benchmark_harness: any + #synchronized: any + #ffi: ^2.1.0 flutter_lints: ^6.0.0 From 12e099c4c48a712e90eb8fdc5f7fbcd717f7f403 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 12 Nov 2025 02:25:58 +0400 Subject: [PATCH 04/20] Enhance LinkedMutex: Ensure task completion is checked before finalizing and improve synchronization logic --- lib/src/util/linked_mutex.dart | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/src/util/linked_mutex.dart b/lib/src/util/linked_mutex.dart index 05b8558..fbae7ef 100644 --- a/lib/src/util/linked_mutex.dart +++ b/lib/src/util/linked_mutex.dart @@ -44,7 +44,10 @@ class LinkedMutex implements Mutex { await prior.future; } on Object {/* Ignore errors */} } - return node.complete; + return () { + if (node.isCompleted) return; + node.complete(); + }; } /// Synchronizes the execution of a function, ensuring that only one @@ -64,12 +67,11 @@ class LinkedMutex implements Mutex { } try { final result = await action(); - node.complete(); return result; } on Object { - node.complete(); rethrow; } finally { + node.complete(); if (identical(_head, node)) _head = null; } } @@ -81,6 +83,9 @@ final class _MutexTask { final Completer _completer; + /// Whether the task has been completed. + bool get isCompleted => _completer.isCompleted; + /// The future that completes when the task is done. Future get future => _completer.future; From f7906403bfb0c6ac39138f86aff9339053b6a6a9 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 12 Nov 2025 02:29:33 +0400 Subject: [PATCH 05/20] Fix LinkedMutex: Remove error handling in lock method and ensure head is updated correctly --- lib/src/util/linked_mutex.dart | 7 +++---- test_race_condition.dart | 31 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) create mode 100644 test_race_condition.dart diff --git a/lib/src/util/linked_mutex.dart b/lib/src/util/linked_mutex.dart index fbae7ef..2ed3d5d 100644 --- a/lib/src/util/linked_mutex.dart +++ b/lib/src/util/linked_mutex.dart @@ -39,14 +39,13 @@ class LinkedMutex implements Mutex { final prior = _head; final node = _head = _MutexTask.sync(); if (prior != null) { - try { - prior.next = node; - await prior.future; - } on Object {/* Ignore errors */} + prior.next = node; + await prior.future; } return () { if (node.isCompleted) return; node.complete(); + if (identical(_head, node)) _head = null; }; } diff --git a/test_race_condition.dart b/test_race_condition.dart new file mode 100644 index 0000000..d1ca9a6 --- /dev/null +++ b/test_race_condition.dart @@ -0,0 +1,31 @@ +import 'dart:async'; +import 'package:control/src/util/linked_mutex.dart'; + +void main() async { + final mutex = LinkedMutex(); + var counter = 0; + + print('Starting race condition test...'); + + // Запускаем 1000 конкурентных задач + final futures = List.generate(1000, (i) { + return mutex.synchronize(() async { + final value = counter; + // Имитируем асинхронную работу + await Future.delayed(Duration.zero); + counter = value + 1; + }); + }); + + await Future.wait(futures); + + print('Expected: 1000'); + print('Actual: $counter'); + print('Mutex locked: ${mutex.locked}'); + + if (counter != 1000) { + print('❌ RACE CONDITION DETECTED!'); + } else { + print('✅ Test passed'); + } +} From 4c449255d006f4e66551a4e3dac978392e67141c Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 12 Nov 2025 02:29:55 +0400 Subject: [PATCH 06/20] Cleanup test_race_condition.dart: Remove unnecessary whitespace for improved readability --- test_race_condition.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/test_race_condition.dart b/test_race_condition.dart index d1ca9a6..b27654b 100644 --- a/test_race_condition.dart +++ b/test_race_condition.dart @@ -1,12 +1,13 @@ import 'dart:async'; + import 'package:control/src/util/linked_mutex.dart'; void main() async { final mutex = LinkedMutex(); var counter = 0; - + print('Starting race condition test...'); - + // Запускаем 1000 конкурентных задач final futures = List.generate(1000, (i) { return mutex.synchronize(() async { @@ -16,13 +17,13 @@ void main() async { counter = value + 1; }); }); - + await Future.wait(futures); - + print('Expected: 1000'); print('Actual: $counter'); print('Mutex locked: ${mutex.locked}'); - + if (counter != 1000) { print('❌ RACE CONDITION DETECTED!'); } else { From 3a0c8a7de6a5eacbc1a06adc6ce42fde52083d41 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 12 Nov 2025 02:30:57 +0400 Subject: [PATCH 07/20] Remove race condition test: Delete test_race_condition.dart as it is no longer needed --- test_race_condition.dart | 32 -------------------------------- 1 file changed, 32 deletions(-) delete mode 100644 test_race_condition.dart diff --git a/test_race_condition.dart b/test_race_condition.dart deleted file mode 100644 index b27654b..0000000 --- a/test_race_condition.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:async'; - -import 'package:control/src/util/linked_mutex.dart'; - -void main() async { - final mutex = LinkedMutex(); - var counter = 0; - - print('Starting race condition test...'); - - // Запускаем 1000 конкурентных задач - final futures = List.generate(1000, (i) { - return mutex.synchronize(() async { - final value = counter; - // Имитируем асинхронную работу - await Future.delayed(Duration.zero); - counter = value + 1; - }); - }); - - await Future.wait(futures); - - print('Expected: 1000'); - print('Actual: $counter'); - print('Mutex locked: ${mutex.locked}'); - - if (counter != 1000) { - print('❌ RACE CONDITION DETECTED!'); - } else { - print('✅ Test passed'); - } -} From 2ccbfb8d44bbd30d94576c9469ab9f0c751b2ad5 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 12 Nov 2025 02:44:17 +0400 Subject: [PATCH 08/20] Add mutex tests: Implement comprehensive unit tests for Mutex functionality --- test/control_test.dart | 2 + test/unit/mutex_test.dart | 1129 +++++++++++++++++++++++++++++++++++++ 2 files changed, 1131 insertions(+) create mode 100644 test/unit/mutex_test.dart diff --git a/test/control_test.dart b/test/control_test.dart index 6fc123e..a0c9d2b 100644 --- a/test/control_test.dart +++ b/test/control_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'unit/handler_context_test.dart' as handler_context_test; +import 'unit/mutex_test.dart' as mutex_test; import 'unit/state_controller_test.dart' as state_controller_test; import 'widget/controller_scope_test.dart' as state_scope_test; import 'widget/state_consumer_test.dart' as state_consumer_test; @@ -11,6 +12,7 @@ void main() { group('unit', () { state_controller_test.main(); handler_context_test.main(); + mutex_test.main(); }); group('widget', () { diff --git a/test/unit/mutex_test.dart b/test/unit/mutex_test.dart new file mode 100644 index 0000000..b1961e5 --- /dev/null +++ b/test/unit/mutex_test.dart @@ -0,0 +1,1129 @@ +import 'dart:async'; + +import 'package:control/control.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() => group('Mutex', () { + test('Creation', () { + expect(Mutex.new, returnsNormally); + expect( + Mutex(), + isA() + .having((m) => m.locked, 'locked', isFalse) + .having((m) => m.synchronize, 'synchronize', isA()) + .having((m) => m.lock, 'lock', isA()), + ); + }); + + group('synchronize', () { + test('executes action', () async { + final mutex = Mutex(); + var executed = false; + + await mutex.synchronize(() async { + executed = true; + }); + + expect(executed, isTrue); + expect(mutex.locked, isFalse); + }); + + test('returns action result', () async { + final mutex = Mutex(); + + final result = await mutex.synchronize(() async => 42); + + expect(result, equals(42)); + }); + + test('serializes multiple calls', () async { + final mutex = Mutex(); + var counter = 0; + final results = []; + + final futures = List.generate( + 100, + (i) => mutex.synchronize(() async { + final value = counter; + await Future.delayed(Duration.zero); + counter = value + 1; + results.add(counter); + })); + + await Future.wait(futures); + + expect(counter, equals(100)); + expect(results.length, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('maintains FIFO order', () async { + final mutex = Mutex(); + final order = []; + + final futures = List.generate( + 10, + (i) => mutex.synchronize(() async { + await Future.delayed( + const Duration(microseconds: 10)); + order.add(i); + })); + + await Future.wait(futures); + + expect(order, equals([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])); + expect(mutex.locked, isFalse); + }); + + test('unlocks on exception', () async { + final mutex = Mutex(); + + expect( + () => mutex.synchronize(() async { + throw Exception('test error'); + }), + throwsException, + ); + + await Future.delayed(const Duration(milliseconds: 10)); + + expect(mutex.locked, isFalse); + + // Should work after exception + var recovered = false; + await mutex.synchronize(() async { + recovered = true; + }); + expect(recovered, isTrue); + }); + + test('propagates exception', () async { + final mutex = Mutex(); + final exception = Exception('test error'); + + expect( + mutex.synchronize(() async => throw exception), + throwsA(equals(exception)), + ); + }); + + test('handles synchronous exceptions', () async { + final mutex = Mutex(); + + expect( + mutex.synchronize(() => throw StateError('sync error')), + throwsStateError, + ); + + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isFalse); + }); + + test('queues waiting tasks', () async { + final mutex = Mutex(); + final events = []; + + // Start first task + final future1 = mutex.synchronize(() async { + events.add('start-1'); + await Future.delayed(const Duration(milliseconds: 50)); + events.add('end-1'); + }); + + // Give it time to start + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isTrue); + + // Queue second task + final future2 = mutex.synchronize(() async { + events.add('start-2'); + await Future.delayed(const Duration(milliseconds: 10)); + events.add('end-2'); + }); + + // Second task should be waiting + expect(mutex.locked, isTrue); + + await Future.wait([future1, future2]); + + expect(events, equals(['start-1', 'end-1', 'start-2', 'end-2'])); + expect(mutex.locked, isFalse); + }); + + test('handles nested synchronize', () async { + final mutex = Mutex(); + var counter = 0; + + await mutex.synchronize(() async { + counter++; + // This will deadlock, but that's expected behavior + // Just test single level + }); + + expect(counter, equals(1)); + expect(mutex.locked, isFalse); + }); + + test('completes in order with different execution times', () async { + final mutex = Mutex(); + final completionOrder = []; + + final futures = >[ + mutex.synchronize(() async { + await Future.delayed(const Duration(milliseconds: 30)); + completionOrder.add(1); + }), + mutex.synchronize(() async { + await Future.delayed(const Duration(milliseconds: 10)); + completionOrder.add(2); + }), + mutex.synchronize(() async { + await Future.delayed(const Duration(milliseconds: 20)); + completionOrder.add(3); + }), + ]; + + await Future.wait(futures); + + expect(completionOrder, equals([1, 2, 3])); + expect(mutex.locked, isFalse); + }); + }); + + group('lock', () { + test('returns unlock function', () async { + final mutex = Mutex(); + + final unlock = await mutex.lock(); + + expect(unlock, isA()); + expect(mutex.locked, isTrue); + + unlock(); + expect(mutex.locked, isFalse); + }); + + test('allows manual lock/unlock', () async { + final mutex = Mutex(); + var counter = 0; + + final unlock = await mutex.lock(); + try { + counter++; + await Future.delayed(const Duration(milliseconds: 10)); + counter++; + } finally { + unlock(); + } + + expect(counter, equals(2)); + expect(mutex.locked, isFalse); + }); + + test('serializes multiple locks', () async { + final mutex = Mutex(); + var counter = 0; + + final futures = List.generate(100, (i) async { + final unlock = await mutex.lock(); + try { + final value = counter; + await Future.delayed(Duration.zero); + counter = value + 1; + } finally { + unlock(); + } + }); + + await Future.wait(futures); + + expect(counter, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('unlock is idempotent', () async { + final mutex = Mutex(); + + final unlock = await mutex.lock(); + expect(mutex.locked, isTrue); + + unlock(); + expect(mutex.locked, isFalse); + + unlock(); // Second call + expect(mutex.locked, isFalse); + + unlock(); // Third call + expect(mutex.locked, isFalse); + + // Should still work + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); + + test('maintains FIFO order', () async { + final mutex = Mutex(); + final lockOrder = []; + + final unlockA = await mutex.lock(); + lockOrder.add('A'); + + final futureB = () async { + final unlock = await mutex.lock(); + lockOrder.add('B'); + await Future.delayed(const Duration(microseconds: 10)); + unlock(); + }(); + + final futureC = () async { + final unlock = await mutex.lock(); + lockOrder.add('C'); + await Future.delayed(const Duration(microseconds: 10)); + unlock(); + }(); + + await Future.delayed(const Duration(milliseconds: 10)); + + unlockA(); + + await Future.wait([futureB, futureC]); + + expect(lockOrder, equals(['A', 'B', 'C'])); + expect(mutex.locked, isFalse); + }); + + test('handles exception with finally unlock', () async { + final mutex = Mutex(); + + try { + final unlock = await mutex.lock(); + try { + throw Exception('test error'); + } finally { + unlock(); + } + } on Object catch (_) { + // Expected + } + + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isFalse); + + // Should work after exception + final unlock = await mutex.lock(); + expect(mutex.locked, isTrue); + unlock(); + }); + + test('works after forgotten unlock', () async { + final mutex = Mutex(); + + // Forget to unlock (bad practice, but should handle) + await mutex.lock(); + expect(mutex.locked, isTrue); + + // New lock should wait + var acquired = false; + unawaited(mutex.lock().then((unlock) { + acquired = true; + unlock(); + })); + + await Future.delayed(const Duration(milliseconds: 10)); + expect(acquired, isFalse); // Still waiting + + // This would hang forever in real code + // In tests we just verify the behavior + }); + + test('unlock after lock allows immediate re-lock', () async { + final mutex = Mutex(); + + final unlock1 = await mutex.lock(); + unlock1(); + + final unlock2 = await mutex.lock(); + expect(mutex.locked, isTrue); + unlock2(); + expect(mutex.locked, isFalse); + }); + }); + + group('mixed lock and synchronize', () { + test('interleaves correctly', () async { + final mutex = Mutex(); + final order = []; + + final futures = >[ + mutex.synchronize(() async { + order.add('sync-1'); + await Future.delayed(const Duration(microseconds: 10)); + }), + () async { + final unlock = await mutex.lock(); + try { + order.add('lock-1'); + await Future.delayed(const Duration(microseconds: 10)); + } finally { + unlock(); + } + }(), + mutex.synchronize(() async { + order.add('sync-2'); + await Future.delayed(const Duration(microseconds: 10)); + }), + () async { + final unlock = await mutex.lock(); + try { + order.add('lock-2'); + await Future.delayed(const Duration(microseconds: 10)); + } finally { + unlock(); + } + }(), + ]; + + await Future.wait(futures); + + expect(order, equals(['sync-1', 'lock-1', 'sync-2', 'lock-2'])); + expect(mutex.locked, isFalse); + }); + + test('maintains single execution guarantee', () async { + final mutex = Mutex(); + var concurrent = 0; + var maxConcurrent = 0; + + final futures = >[]; + + for (var i = 0; i < 50; i++) { + if (i % 2 == 0) { + futures.add(mutex.synchronize(() async { + concurrent++; + maxConcurrent = + maxConcurrent > concurrent ? maxConcurrent : concurrent; + await Future.delayed(const Duration(microseconds: 10)); + concurrent--; + })); + } else { + futures.add(() async { + final unlock = await mutex.lock(); + try { + concurrent++; + maxConcurrent = + maxConcurrent > concurrent ? maxConcurrent : concurrent; + await Future.delayed(const Duration(microseconds: 10)); + concurrent--; + } finally { + unlock(); + } + }()); + } + } + + await Future.wait(futures); + + expect(maxConcurrent, equals(1)); + expect(concurrent, equals(0)); + expect(mutex.locked, isFalse); + }); + }); + + group('edge cases', () { + test('handles immediate completion', () async { + final mutex = Mutex(); + + await mutex.synchronize(() async {}); + + expect(mutex.locked, isFalse); + }); + + test('handles synchronous action', () async { + final mutex = Mutex(); + var value = 0; + + await mutex.synchronize(() => Future.value(42)); + value = await mutex.synchronize(() => Future.value(100)); + + expect(value, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('handles multiple rapid synchronizations', () async { + final mutex = Mutex(); + final results = []; + + for (var i = 0; i < 1000; i++) { + unawaited(mutex.synchronize(() async { + results.add(i); + })); + } + + // Wait for all to complete + await Future.delayed(const Duration(milliseconds: 100)); + await mutex.synchronize(() async {}); // Barrier + + expect(results.length, equals(1000)); + expect(mutex.locked, isFalse); + }); + + test('handles zero delay', () async { + final mutex = Mutex(); + var counter = 0; + + final futures = List.generate( + 100, + (i) => mutex.synchronize(() async { + await Future.delayed(Duration.zero); + counter++; + })); + + await Future.wait(futures); + + expect(counter, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('expectLater locked status during execution', () async { + final mutex = Mutex(); + + final future = mutex.synchronize(() async { + await Future.delayed(const Duration(milliseconds: 50)); + }); + + await Future.delayed(const Duration(milliseconds: 10)); + await expectLater(mutex.locked, isTrue); + + await future; + await expectLater(mutex.locked, isFalse); + }); + + test('expectLater sequential execution', () async { + final mutex = Mutex(); + final stream = StreamController(); + + mutex + ..synchronize(() async { + stream.add(1); + await Future.delayed(const Duration(milliseconds: 20)); + stream.add(2); + }).ignore() + ..synchronize(() async { + stream.add(3); + await Future.delayed(const Duration(milliseconds: 20)); + stream.add(4); + }).ignore(); + + await expectLater( + stream.stream, + emitsInOrder([1, 2, 3, 4]), + ); + + await stream.close(); + }); + + test('handles null result', () async { + final mutex = Mutex(); + + final result = await mutex.synchronize(() async => null); + + expect(result, isNull); + }); + + test('preserves generic type', () async { + final mutex = Mutex(); + + final stringResult = + await mutex.synchronize(() async => 'test'); + final intResult = await mutex.synchronize(() async => 42); + final boolResult = await mutex.synchronize(() async => true); + + expect(stringResult, isA()); + expect(intResult, isA()); + expect(boolResult, isA()); + }); + + test('completes with void result', () async { + final mutex = Mutex(); + + await mutex.synchronize(() async {}); + + expect(mutex.locked, isFalse); + }); + + test('survives rapid lock/unlock cycles', () async { + final mutex = Mutex(); + + for (var i = 0; i < 100; i++) { + final unlock = await mutex.lock(); + unlock(); + } + + expect(mutex.locked, isFalse); + + // Should still work + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); + }); + + group('stress tests', () { + test('handles 1000 concurrent operations', () async { + final mutex = Mutex(); + var counter = 0; + + final futures = List.generate( + 1000, + (i) => mutex.synchronize(() async { + final value = counter; + await Future.delayed(Duration.zero); + counter = value + 1; + })); + + await Future.wait(futures); + + expect(counter, equals(1000)); + expect(mutex.locked, isFalse); + }); + + test('handles mixed operations under load', () async { + final mutex = Mutex(); + var syncCounter = 0; + var lockCounter = 0; + + final futures = List.generate(500, (i) { + if (i % 2 == 0) { + return mutex.synchronize(() async { + await Future.delayed(Duration.zero); + syncCounter++; + }); + } else { + return () async { + final unlock = await mutex.lock(); + try { + await Future.delayed(Duration.zero); + lockCounter++; + } finally { + unlock(); + } + }(); + } + }); + + await Future.wait(futures); + + expect(syncCounter, equals(250)); + expect(lockCounter, equals(250)); + expect(mutex.locked, isFalse); + }); + + test('handles rapid lock/unlock without delays', () async { + final mutex = Mutex(); + var counter = 0; + + final futures = List.generate(200, (i) async { + final unlock = await mutex.lock(); + counter++; + unlock(); + }); + + await Future.wait(futures); + + expect(counter, equals(200)); + expect(mutex.locked, isFalse); + }); + + test('handles alternating sync and lock patterns', () async { + final mutex = Mutex(); + final pattern = []; + + final futures = >[]; + for (var i = 0; i < 100; i++) { + if (i % 3 == 0) { + futures.add(mutex.synchronize(() async { + pattern.add('S'); + })); + } else if (i % 3 == 1) { + futures.add(() async { + final unlock = await mutex.lock(); + pattern.add('L'); + unlock(); + }()); + } else { + futures.add(mutex.synchronize(() async { + await Future.delayed(Duration.zero); + pattern.add('S'); + })); + } + } + + await Future.wait(futures); + + expect(pattern.length, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('concurrent stress with exceptions', () async { + final mutex = Mutex(); + var successCount = 0; + var errorCount = 0; + + final futures = List.generate(100, (i) async { + try { + await mutex.synchronize(() async { + if (i % 10 == 0) { + throw Exception('Intentional error $i'); + } + successCount++; + }); + } on Object catch (_) { + errorCount++; + } + }); + + await Future.wait(futures); + + expect(successCount, equals(90)); + expect(errorCount, equals(10)); + expect(mutex.locked, isFalse); + }); + }); + + group('timeout and cancellation', () { + test('handles timeout on synchronize', () async { + final mutex = Mutex(); + + // Lock mutex + final unlock = await mutex.lock(); + + var timedOut = false; + try { + await mutex + .synchronize(() async => 42) + .timeout(const Duration(milliseconds: 50)); + } on TimeoutException { + timedOut = true; + } + + expect(timedOut, isTrue); + + // Unlock and verify recovery + unlock(); + await Future.delayed(const Duration(milliseconds: 10)); + + final result = await mutex.synchronize(() async => 100); + expect(result, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('handles multiple timeouts', () async { + final mutex = Mutex(); + + final unlock = await mutex.lock(); + + var timeoutCount = 0; + for (var i = 0; i < 5; i++) { + try { + await mutex + .synchronize(() async => i) + .timeout(const Duration(milliseconds: 50)); + } on TimeoutException { + timeoutCount++; + } + } + + expect(timeoutCount, equals(5)); + + unlock(); + + // Should still work after timeouts + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); + }); + + group('complex scenarios', () { + test('producer-consumer pattern', () async { + final mutex = Mutex(); + final queue = []; + var produced = 0; + + // Producer + final producer = Future(() async { + for (var i = 0; i < 50; i++) { + await mutex.synchronize(() async { + queue.add(i); + produced++; + }); + } + }); + + // Consumer + final consumer = Future(() async { + for (var i = 0; i < 50; i++) { + await mutex.synchronize(() async { + if (queue.isNotEmpty) { + queue.removeAt(0); + } + }); + await Future.delayed(Duration.zero); + } + }); + + await Future.wait([producer, consumer]); + + expect(produced, equals(50)); + expect(mutex.locked, isFalse); + }); + + test('reader-writer simulation with single mutex', () async { + final mutex = Mutex(); + var data = 0; + final readValues = []; + + final writers = List.generate( + 10, + (i) => mutex.synchronize(() async { + await Future.delayed(const Duration(microseconds: 10)); + data++; + }), + ); + + final readers = List.generate( + 20, + (i) => mutex.synchronize(() async { + await Future.delayed(const Duration(microseconds: 5)); + readValues.add(data); + }), + ); + + await Future.wait([...writers, ...readers]); + + expect(data, equals(10)); + expect(readValues.length, equals(20)); + expect(mutex.locked, isFalse); + }); + + test('cascading lock acquisitions', () async { + final mutex = Mutex(); + final order = []; + + Future cascade(int depth) async { + if (depth <= 0) return; + await mutex.synchronize(() async { + order.add(depth); + await Future.delayed(const Duration(microseconds: 10)); + }); + unawaited(cascade(depth - 1)); + } + + await cascade(10); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(order.length, equals(10)); + expect(mutex.locked, isFalse); + }); + + test('mutex with conditional logic', () async { + final mutex = Mutex(); + var counter = 0; + + final futures = List.generate( + 100, + (i) => mutex.synchronize(() async { + if (counter % 2 == 0) { + counter += 2; + } else { + counter += 1; + } + await Future.delayed(Duration.zero); + })); + + await Future.wait(futures); + + expect(counter, greaterThan(0)); + expect(mutex.locked, isFalse); + }); + + test('batch processing pattern', () async { + final mutex = Mutex(); + final batches = >[]; + final items = List.generate(100, (i) => i); + + const batchSize = 10; + for (var i = 0; i < items.length; i += batchSize) { + await mutex.synchronize(() async { + final end = (i + batchSize).clamp(0, items.length); + batches.add(items.sublist(i, end)); + await Future.delayed(const Duration(microseconds: 10)); + }); + } + + expect(batches.length, equals(10)); + expect(batches.expand((b) => b).length, equals(100)); + expect(mutex.locked, isFalse); + }); + + test('interleaved fast and slow operations', () async { + final mutex = Mutex(); + var fastCount = 0; + var slowCount = 0; + + final futures = List.generate(50, (i) { + if (i % 2 == 0) { + // Fast operation + return mutex.synchronize(() async { + fastCount++; + }); + } else { + // Slow operation + return mutex.synchronize(() async { + await Future.delayed(const Duration(microseconds: 50)); + slowCount++; + }); + } + }); + + await Future.wait(futures); + + expect(fastCount, equals(25)); + expect(slowCount, equals(25)); + expect(mutex.locked, isFalse); + }); + + test('lock acquisition with early completion', () async { + final mutex = Mutex(); + final completions = []; + + final future1 = mutex.synchronize(() async { + completions + ..add('start-1') + ..add('end-1'); + }); + + final future2 = mutex.synchronize(() async { + completions.add('start-2'); + await Future.delayed(const Duration(milliseconds: 20)); + completions.add('end-2'); + }); + + final future3 = mutex.synchronize(() async { + completions + ..add('start-3') + ..add('end-3'); + }); + + await Future.wait([future1, future2, future3]); + + expect( + completions, + equals([ + 'start-1', + 'end-1', + 'start-2', + 'end-2', + 'start-3', + 'end-3', + ]), + ); + expect(mutex.locked, isFalse); + }); + + test('multiple unlocks from different contexts', () async { + final mutex = Mutex(); + final unlocks = []; + + for (var i = 0; i < 5; i++) { + final unlock = await mutex.lock(); + unlocks.add(unlock); + expect(mutex.locked, isTrue); + unlock(); + expect(mutex.locked, isFalse); + } + + // Call old unlocks (should be safe) + for (final unlock in unlocks) { + unlock(); + } + + expect(mutex.locked, isFalse); + + // Should still work + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); + }); + + group('state verification', () { + test('locked state transitions', () async { + final mutex = Mutex(); + final states = [mutex.locked]; // false + + final unlock = await mutex.lock(); + states.add(mutex.locked); // true + + unlock(); + states.add(mutex.locked); // false + + await mutex.synchronize(() async { + states.add(mutex.locked); // true (during execution) + }); + states.add(mutex.locked); // false + + expect(states, equals([false, true, false, true, false])); + }); + + test('locked during nested operations', () async { + final mutex = Mutex(); + + await mutex.synchronize(() async { + expect(mutex.locked, isTrue); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isTrue); + }); + + expect(mutex.locked, isFalse); + }); + + test('locked with concurrent waiters', () async { + final mutex = Mutex(); + + final unlock1 = await mutex.lock(); + expect(mutex.locked, isTrue); + + final future2 = mutex.lock(); + final future3 = mutex.lock(); + + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isTrue); + + unlock1(); + final unlock2 = await future2; + expect(mutex.locked, isTrue); + + unlock2(); + final unlock3 = await future3; + expect(mutex.locked, isTrue); + + unlock3(); + expect(mutex.locked, isFalse); + }); + + test('state consistency after errors', () async { + final mutex = Mutex(); + + // Error in synchronize + try { + await mutex.synchronize(() async { + throw StateError('test'); + }); + } on Object catch (_) { + // Expected + } + expect(mutex.locked, isFalse); + + // Error in lock + try { + final unlock = await mutex.lock(); + try { + throw Exception('error'); + } finally { + unlock(); + } + } on Object catch (_) { + // Expected + } + expect(mutex.locked, isFalse); + + // Should still work + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); + + test('multiple mutex instances are independent', () async { + final mutex1 = Mutex(); + final mutex2 = Mutex(); + + final unlock1 = await mutex1.lock(); + expect(mutex1.locked, isTrue); + expect(mutex2.locked, isFalse); + + final unlock2 = await mutex2.lock(); + expect(mutex1.locked, isTrue); + expect(mutex2.locked, isTrue); + + unlock1(); + expect(mutex1.locked, isFalse); + expect(mutex2.locked, isTrue); + + unlock2(); + expect(mutex1.locked, isFalse); + expect(mutex2.locked, isFalse); + }); + }); + + group('error propagation', () { + test('preserves stack trace', () async { + final mutex = Mutex(); + + try { + await mutex.synchronize(() async { + throw Exception('original error'); + }); + } on Object catch (e, stackTrace) { + expect(e.toString(), contains('original error')); + expect(stackTrace.toString(), isNotEmpty); + return; // Expected error + } + }); + + test('different error types', () async { + final mutex = Mutex(); + + expect( + mutex.synchronize(() async => throw ArgumentError('test')), + throwsArgumentError, + ); + + expect( + mutex.synchronize(() async => throw StateError('test')), + throwsStateError, + ); + + expect( + mutex.synchronize(() async => throw const FormatException('test')), + throwsFormatException, + ); + + await Future.delayed(const Duration(milliseconds: 50)); + expect(mutex.locked, isFalse); + }); + + test('error does not affect queued operations', () async { + final mutex = Mutex(); + final results = []; + + final futures = >[ + mutex.synchronize(() async { + results.add('ok-1'); + }), + () async { + results.add('error'); + try { + throw Exception('test error'); + } on Object catch (_) { + // Ignore + } + }(), + mutex.synchronize(() async { + results.add('ok-2'); + }), + ]; + + await Future.wait(futures); + + expect(results, equals(['ok-1', 'error', 'ok-2'])); + expect(mutex.locked, isFalse); + }); + }); + }); From 4bfba5cc4c389300da9002187f6ce3abf5f61f11 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 12 Nov 2025 02:46:29 +0400 Subject: [PATCH 09/20] Add library declaration and timeout for mutex tests --- test/unit/mutex_test.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/unit/mutex_test.dart b/test/unit/mutex_test.dart index b1961e5..6be0e46 100644 --- a/test/unit/mutex_test.dart +++ b/test/unit/mutex_test.dart @@ -1,3 +1,6 @@ +@Timeout(Duration(milliseconds: 1000)) +library; + import 'dart:async'; import 'package:control/control.dart'; From 7281c6b27f4aa7eb1d085620146a8f088ef2b899 Mon Sep 17 00:00:00 2001 From: Plague Fox Date: Wed, 12 Nov 2025 02:49:10 +0400 Subject: [PATCH 10/20] Increase timeout duration for mutex tests to improve reliability --- test/unit/mutex_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/mutex_test.dart b/test/unit/mutex_test.dart index 6be0e46..bc7ad97 100644 --- a/test/unit/mutex_test.dart +++ b/test/unit/mutex_test.dart @@ -1,4 +1,4 @@ -@Timeout(Duration(milliseconds: 1000)) +@Timeout(Duration(milliseconds: 1500)) library; import 'dart:async'; From 1be520c2b9c1123f201b5c290f2c9b54d25ad888 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Fri, 6 Feb 2026 09:02:43 +0400 Subject: [PATCH 11/20] Update version and environment constraints in pubspec.yaml --- pubspec.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pubspec.yaml b/pubspec.yaml index 2ea0f22..6dcb9b4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: control description: "Simple state management for Flutter with concurrency support." -version: 1.0.0-dev +version: 1.0.0-dev.1 homepage: https://github.com/PlugFox/control @@ -34,8 +34,8 @@ platforms: # path: example.png environment: - sdk: ">=3.4.0 <4.0.0" - flutter: ">=3.16.0" + sdk: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" dependencies: flutter: From 266df05303b6fb140247bc0cc301ab8a4603bcb5 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Fri, 6 Feb 2026 09:29:10 +0400 Subject: [PATCH 12/20] Refactor concurrency handling in Controller - Introduced Mutex-based synchronization in SequentialControllerHandler to ensure sequential execution of operations. - Removed the custom event queue implementation, simplifying the concurrency model. - Updated Controller class to track processing calls and provide a clearer isProcessing state. - Enhanced error handling and completion tracking in the handle method of Controller. - Updated tests to reflect changes in concurrency behavior and ensure proper functionality across sequential, droppable, and concurrent controllers. --- .gitignore | 6 +- CHANGELOG.md | 52 ++ IDEAS.md | 265 ++++++ MIGRATION.md | 294 ++++++ README.md | 296 +++++- example/analysis_options.yaml | 3 - example/lib/main.dart | 4 +- example/pubspec.lock | 14 +- .../concurrent_controller_handler.dart | 165 +--- .../droppable_controller_handler.dart | 113 +-- .../sequential_controller_handler.dart | 190 +--- lib/src/controller.dart | 93 +- lib/src/state_controller.dart | 3 +- test/unit/handler_context_test.dart | 187 ++-- test/unit/state_controller_test.dart | 863 +++++++++--------- 15 files changed, 1599 insertions(+), 949 deletions(-) create mode 100644 IDEAS.md create mode 100644 MIGRATION.md diff --git a/.gitignore b/.gitignore index 1fcaad7..78011ac 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ log.pana.json coverage/ /test/**/*.json /test/.test_coverage.dart +reports/ # Temp /tmp @@ -50,4 +51,7 @@ coverage/ .fvm/flutter_sdk # Generated files -*.*.dart \ No newline at end of file +*.*.dart + +# LLMS +.claude/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d8eec..2ad91c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,55 @@ +## 1.0.0-dev.1 + +### Breaking Changes + +- **REMOVED**: `base` modifier from `Controller`, `StateController`, and concurrency handler mixins + - Controllers no longer require `final` or `base` in user code + - Provides more flexibility for inheritance patterns +- **CHANGED**: `Controller.handle()` now has a default concurrent implementation + - No longer abstract - controllers can be used without choosing a concurrency mixin + - Operations execute concurrently by default + - Provides zone for error catching, HandlerContext, observer notifications, and callbacks +- **CHANGED**: Concurrency handler mixins simplified to wrap `super.handle()` with `Mutex` + - `SequentialControllerHandler` - wraps handle with mutex for sequential execution + - `DroppableControllerHandler` - wraps handle with mutex and drops new operations if busy + - `ConcurrentControllerHandler` - **deprecated** (base behavior is already concurrent) +- **CHANGED**: `Controller.handle()` signature now includes `error` and `done` callbacks + - Before: `handle(handler, {name, meta})` + - After: `handle(handler, {error, done, name, meta})` + +### Added + +- **ADDED**: Base `handle()` implementation in `Controller` class + - Concurrent execution by default + - Zone-based error catching (including unawaited futures) + - HandlerContext for debugging + - Observer notifications + - Error and done callbacks +- **ADDED**: `Mutex` is now the core primitive for concurrency control + - Can be used directly in controllers for custom concurrency patterns + - Sequential and droppable mixins built on top of Mutex + +### Improved + +- **IMPROVED**: Reduced code complexity + - Concurrency handler mixins reduced from ~300 lines to ~90 lines + - Removed `_ControllerEventQueue` class (no longer needed) + - Simplified architecture easier to understand and maintain +- **IMPROVED**: More flexibility in concurrency control + - Use mixins for simple cases (sequential/droppable) + - Use Mutex directly for custom patterns + - Mix and match strategies in the same controller + +### Migration Guide + +See [MIGRATION.md](MIGRATION.md) for detailed migration instructions from 0.x to 1.0.0. + +**Quick migration:** +- Remove `base` from your controller classes +- `ConcurrentControllerHandler` can be removed (controllers are concurrent by default) +- `SequentialControllerHandler` and `DroppableControllerHandler` work the same way +- For custom concurrency, use `Mutex` directly instead of creating custom mixins + ## 0.2.0 - **ADDED**: `HandlerContext` to handlers, available at zone and observer. diff --git a/IDEAS.md b/IDEAS.md new file mode 100644 index 0000000..326bd71 --- /dev/null +++ b/IDEAS.md @@ -0,0 +1,265 @@ +# Future Improvements and Ideas + +This document contains ideas and recommendations for future improvements to the Control library. + +## Architecture Improvements + +### Phase 1: MVP (Implemented in v1.0.0) + +#### 1. Remove `base` Modifiers ✅ +**Problem:** The `base` modifier forces users to use `final` or `base` on their controller classes, which creates unnecessary restrictions. + +**Solution:** Remove `base` from: +- `Controller` class +- `StateController` class +- Concurrency handler mixins + +**Benefits:** +- More flexibility for users +- No forced inheritance patterns +- Simpler API + +#### 2. Base `handle()` Implementation ✅ +**Problem:** Currently `handle()` is abstract in `Controller`, forcing users to choose a concurrency strategy via mixins. + +**Solution:** Implement base `handle()` in `Controller` with concurrent behavior by default. The method provides: +- Zone for error catching (including unawaited futures) +- HandlerContext for debugging +- Observer notifications +- error/done callbacks +- isProcessing tracking + +**Benefits:** +- No forced mixin selection +- Concurrent by default (most common case) +- Mixins become optional + +#### 3. Simplify Concurrency Handler Mixins ✅ +**Problem:** Current mixins have ~100 lines each with duplicated error handling logic. + +**Solution:** Make mixins simple wrappers around `super.handle()` + `Mutex`: + +```dart +mixin SequentialControllerHandler on Controller { + final Mutex _$mutex = Mutex(); + + @override + Future handle(...) => + _$mutex.synchronize(() => super.handle(...)); +} +``` + +**Benefits:** +- Reduces code from ~300 lines to ~90 lines +- Eliminates duplication +- Mixins are now just 10-15 lines each +- Easier to understand and maintain + +### Phase 2: Enhancements (Future) + +#### 4. Generic `handle()` ⭐ +**Current:** `handle()` only returns `Future` + +**Proposal:** Make it generic to support return values: + +```dart +Future handle(Future Function() handler, {...}); + +// Usage +Future fetchUser(String id) => handle(() async { + final user = await api.getUser(id); + setState(state.copyWith(user: user)); + return user; // Can return values! +}); +``` + +**Benefits:** +- More flexible API +- Better composition +- Type-safe return values + +#### 5. `tryLock()` Method for Mutex ⭐ +**Proposal:** Add non-blocking lock attempt: + +```dart +abstract class Mutex { + /// Attempts to acquire lock without waiting + /// Returns unlock function if successful, null if already locked + void Function()? tryLock(); +} + +// Usage - droppable pattern without mixin +void operation() { + final unlock = _mutex.tryLock(); + if (unlock == null) return; // Already running, drop + try { + // critical section + } finally { + unlock(); + } +} +``` + +**Benefits:** +- Enables droppable pattern without mixin +- More control over locking behavior +- No waiting if lock unavailable + +#### 6. `isIdle` Getter ⭐ +**Proposal:** Add convenience getter: + +```dart +abstract class Controller { + bool get isProcessing; + bool get isIdle => !isProcessing; // Opposite of isProcessing +} +``` + +**Benefits:** +- More readable in UI code +- Natural language + +#### 7. Extension Methods 💡 +**Proposal:** Add convenience extensions: + +```dart +extension MutexControllerExtension on Mutex { + Future handleWith( + Controller controller, + Future Function() handler, {...} + ) => synchronize(() => controller.handle(handler, ...)); +} + +// Usage +void operation() => _mutex.handleWith(this, () async {...}); +``` + +**Benefits:** +- Shorter, more readable code +- Composable helpers + +#### 8. Debounce/Throttle Utilities 💡 +**Proposal:** Add common patterns: + +```dart +class ControllerUtils { + static Future Function() debounce( + Duration duration, + Future Function() action, + ) {...} + + static Future Function() throttle( + Duration duration, + Future Function() action, + ) {...} +} + +// Usage +class SearchController extends StateController { + late final _debouncedSearch = ControllerUtils.debounce( + const Duration(milliseconds: 300), + _performSearch, + ); + + void search(String query) => handle(_debouncedSearch); +} +``` + +**Benefits:** +- Common use cases covered +- Less boilerplate +- Reusable patterns + +### Phase 3: Polish (Future) + +#### 9. Integration Tests +Add comprehensive integration tests for: +- Concurrent behavior +- Sequential behavior +- Droppable behavior +- Mixed strategies +- Error handling across all strategies + +#### 10. Performance Benchmarks +Add benchmarks comparing: +- Different concurrency strategies +- Mutex implementations +- Handler overhead + +#### 11. Enhanced Documentation +- Add dartdoc examples to all public APIs +- Create cookbook with common patterns +- Add diagrams showing concurrency flows +- Video tutorials + +#### 12. Performance Optimizations +- Cache `isProcessing` flag in sequential handler +- Optimize zone creation +- Consider object pooling for handler contexts + +## API Stability + +### Stable APIs (keep unchanged) +- `StateController.state` +- `StateController.setState()` +- `Controller.addListener()` +- `Controller.dispose()` +- `Mutex.lock()` +- `Mutex.synchronize()` + +### Evolving APIs (may change) +- Handler mixins (simplified in 1.0.0) +- `handle()` signature (may become generic) +- Observer interface (may add more hooks) + +## Breaking Changes for 1.0.0 + +### Removed +- `base` modifiers on classes and mixins + +### Changed +- `Controller.handle()` now has a default implementation (concurrent) +- Concurrency handler mixins simplified (now just wrap `super.handle()` + mutex) +- `ConcurrentControllerHandler` is now redundant (base behavior) + +### Migration +See MIGRATION.md for detailed migration guide from 0.x to 1.0.0 + +## Naming Considerations + +### Current Names (Keep) +- `handle()` - short, clear, conventional +- `Controller` - standard pattern name +- `StateController` - descriptive +- `Mutex` - well-known CS term + +### Potential Alternatives (Not Recommended) +- `handle()` → `execute()` / `runHandler()` - too verbose +- `Controller` → `Bloc` / `Manager` - different patterns +- `Mutex` → `Lock` - less precise + +## Questions for Community + +1. Should `handle()` be generic `` or stay ``? +2. Is `tryLock()` needed or is checking `locked` sufficient? +3. Should debounce/throttle be in core or separate package? +4. What other concurrency patterns are needed? (semaphore, rwlock, etc.) + +## References + +- [Mutex tests](test/unit/mutex_test.dart) - comprehensive test coverage +- [Controller tests](test/unit/state_controller_test.dart) - concurrency tests +- [Example app](example/lib/main.dart) - real-world usage + +## Contributing + +Feel free to: +- Open issues discussing these ideas +- Submit PRs implementing phase 2/3 features +- Share your use cases and patterns +- Suggest new ideas + +--- + +**Last Updated:** 2026-02-06 +**Status:** Phase 1 (MVP) implemented in v1.0.0-dev.1 diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..817529c --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,294 @@ +# Migration Guide: 0.x to 1.0.0 + +This guide will help you migrate from Control 0.x to 1.0.0. + +## Overview of Changes + +Version 1.0.0 introduces significant architectural improvements: + +1. **Removed `base` modifiers** - More flexibility in inheritance +2. **Default concurrent behavior** - No forced mixin selection +3. **Simplified mixins** - Built on top of Mutex +4. **New `handle()` signature** - Added `error` and `done` callbacks + +## Breaking Changes + +### 1. Remove `base` from Controller Classes + +**Before (0.x):** +```dart +final class MyController extends StateController + with SequentialControllerHandler { + // ... +} +``` + +**After (1.0.0):** +```dart +class MyController extends StateController + with SequentialControllerHandler { + // ... +} +``` + +**Why:** The `base` modifier forced users to use `final` or `base` on their controller classes. Removing it provides more flexibility. + +**Action Required:** Remove `base` or `final` modifiers from your controller class declarations if you want more flexibility. You can keep `final` if you prefer. + +### 2. Remove ConcurrentControllerHandler Mixin + +**Before (0.x):** +```dart +class MyController extends StateController + with ConcurrentControllerHandler { + void operation() => handle(() async { ... }); +} +``` + +**After (1.0.0):** +```dart +class MyController extends StateController { + // Concurrent by default - no mixin needed! + void operation() => handle(() async { ... }); +} +``` + +**Why:** The base `Controller` class now provides concurrent behavior by default. The mixin is redundant. + +**Action Required:** Remove `with ConcurrentControllerHandler` from your controller declarations. The mixin is deprecated but still available for backwards compatibility. + +### 3. Update handle() Calls with Error Handling + +The `handle()` method signature has been extended: + +**Before (0.x):** +```dart +void operation() => handle( + () async { ... }, + name: 'operation', + meta: {'key': 'value'}, +); +``` + +**After (1.0.0):** +```dart +void operation() => handle( + () async { ... }, + error: (error, stackTrace) async { + // Optional: handle errors + }, + done: () async { + // Optional: cleanup or completion logic + }, + name: 'operation', + meta: {'key': 'value'}, +); +``` + +**Why:** The new parameters were always available in mixins but not in the base interface. Now they're standardized. + +**Action Required:** +- No action required - `error` and `done` are optional +- If you were using custom error handling in mixins, you can now use it in all controllers +- Update your code if you want to use the new error handling capabilities + +## Migration Scenarios + +### Scenario 1: Simple Concurrent Controller + +**Before:** +```dart +final class MyController extends StateController + with ConcurrentControllerHandler { + MyController() : super(initialState: MyState.initial()); + + void fetchData() => handle(() async { + final data = await api.fetch(); + setState(state.copyWith(data: data)); + }); +} +``` + +**After:** +```dart +class MyController extends StateController { + MyController() : super(initialState: MyState.initial()); + + void fetchData() => handle(() async { + final data = await api.fetch(); + setState(state.copyWith(data: data)); + }); +} +``` + +**Changes:** +- Removed `final` modifier +- Removed `with ConcurrentControllerHandler` + +### Scenario 2: Sequential Controller + +**Before:** +```dart +final class MyController extends StateController + with SequentialControllerHandler { + void operation1() => handle(() async { ... }); + void operation2() => handle(() async { ... }); +} +``` + +**After:** +```dart +class MyController extends StateController + with SequentialControllerHandler { + void operation1() => handle(() async { ... }); + void operation2() => handle(() async { ... }); +} +``` + +**Changes:** +- Only removed `final` modifier (optional) +- Mixin works the same way + +### Scenario 3: Droppable Controller + +**Before:** +```dart +final class MyController extends StateController + with DroppableControllerHandler { + void operation() => handle(() async { ... }); +} +``` + +**After:** +```dart +class MyController extends StateController + with DroppableControllerHandler { + void operation() => handle(() async { ... }); +} +``` + +**Changes:** +- Only removed `final` modifier (optional) +- Mixin works the same way + +### Scenario 4: Mixed Concurrency Patterns + +**New in 1.0.0:** You can now use Mutex directly for custom patterns: + +```dart +class MyController extends StateController { + MyController() : super(initialState: MyState.initial()); + + final _criticalMutex = Mutex(); + final _batchMutex = Mutex(); + + // Sequential critical operations + void saveToDB() => _criticalMutex.synchronize( + () => handle(() async { + // Critical database operation + }), + ); + + // Sequential batch operations + void processBatch() => _batchMutex.synchronize( + () => handle(() async { + // Batch processing + }), + ); + + // Concurrent queries (default behavior) + void fetchData() => handle(() async { + // Fast concurrent query + }); + + // Droppable UI actions + void onButtonTap() { + final unlock = _criticalMutex.tryLock(); + if (unlock == null) return; // Already running, drop + + handle(() async { + // Handle button tap + }).whenComplete(unlock); + } +} +``` + +## Step-by-Step Migration Checklist + +1. **Update dependency in pubspec.yaml:** + ```yaml + dependencies: + control: ^1.0.0 + ``` + +2. **Run Flutter pub get:** + ```bash + flutter pub get + ``` + +3. **Find all controllers:** + ```bash + grep -r "extends.*Controller" lib/ + ``` + +4. **For each controller:** + - [ ] Remove `base` or `final` modifier (optional but recommended) + - [ ] Remove `with ConcurrentControllerHandler` if present + - [ ] Keep `with SequentialControllerHandler` or `with DroppableControllerHandler` + - [ ] Consider using Mutex directly for complex scenarios + +5. **Run tests:** + ```bash + flutter test + ``` + +6. **Check for deprecation warnings:** + ```bash + flutter analyze + ``` + +## Common Issues and Solutions + +### Issue 1: "The member 'handle' overrides an inherited member" + +**Solution:** This is just an informational message. The code works correctly. The IDE may show this while it updates its cache. + +### Issue 2: "ConcurrentControllerHandler is deprecated" + +**Solution:** Remove `with ConcurrentControllerHandler` from your controller. Concurrent is now the default behavior. + +### Issue 3: Tests failing after migration + +**Solution:** +- Ensure all test controllers are updated +- Check if tests rely on specific timing - concurrent behavior may differ +- Update mocks if using custom mixins + +## Benefits After Migration + +After migrating to 1.0.0, you'll benefit from: + +1. **More flexibility** - No forced `final` modifier +2. **Cleaner code** - No redundant `ConcurrentControllerHandler` +3. **Better control** - Use Mutex directly for custom patterns +4. **Simpler architecture** - Less boilerplate, easier to understand +5. **Same guarantees** - All existing tests pass + +## Need Help? + +If you encounter issues during migration: + +1. Check the [examples](example/) directory for updated patterns +2. Review [IDEAS.md](IDEAS.md) for architecture explanation +3. Open an issue on [GitHub](https://github.com/PlugFox/control/issues) + +## Rollback + +If you need to rollback to 0.x: + +```yaml +dependencies: + control: ^0.2.0 +``` + +Then restore your `base`/`final` modifiers and `ConcurrentControllerHandler` mixins. diff --git a/README.md b/README.md index e602e77..ea9d4c2 100644 --- a/README.md +++ b/README.md @@ -7,43 +7,315 @@ [![Linter](https://img.shields.io/badge/style-linter-40c4ff.svg)](https://pub.dev/packages/linter) [![GitHub stars](https://img.shields.io/github/stars/plugfox/control?style=social)](https://github.com/plugfox/control/) +A simple, flexible state management library for Flutter with built-in concurrency support. + --- +## Features + +- 🎯 **Simple API** - Easy to learn and use +- 🔄 **Flexible Concurrency** - Sequential, concurrent, or droppable operation handling +- 🛡️ **Type Safe** - Full type safety with Dart's type system +- 🔍 **Observable** - Built-in observer for debugging and logging +- 🧪 **Well Tested** - Comprehensive test coverage +- 📦 **Lightweight** - Minimal dependencies +- 🔧 **Customizable** - Use Mutex for custom concurrency patterns + ## Installation Add the following dependency to your `pubspec.yaml` file: ```yaml dependencies: - control: + control: ^1.0.0 ``` -## Example +## Quick Start + +### Basic Example ```dart -/// Counter state for [CounterController] +/// Counter state typedef CounterState = ({int count, bool idle}); -/// Counter controller -final class CounterController extends StateController - with SequentialControllerHandler { +/// Counter controller - concurrent by default +class CounterController extends StateController { CounterController({CounterState? initialState}) : super(initialState: initialState ?? (idle: true, count: 0)); - void add(int value) => handle(() async { + void increment() => handle(() async { setState((idle: false, count: state.count)); - await Future.delayed(const Duration(milliseconds: 1500)); - setState((idle: true, count: state.count + value)); + await Future.delayed(const Duration(milliseconds: 500)); + setState((idle: true, count: state.count + 1)); }); - void subtract(int value) => handle(() async { + void decrement() => handle(() async { setState((idle: false, count: state.count)); - await Future.delayed(const Duration(milliseconds: 1500)); - setState((idle: true, count: state.count - value)); + await Future.delayed(const Duration(milliseconds: 500)); + setState((idle: true, count: state.count - 1)); }); } ``` +## Concurrency Strategies + +### 1. Concurrent (Default) + +Operations execute in parallel without waiting for each other: + +```dart +class MyController extends StateController { + MyController() : super(initialState: MyState.initial()); + + // These operations run concurrently + void operation1() => handle(() async { ... }); + void operation2() => handle(() async { ... }); +} +``` + +### 2. Sequential (with Mixin) + +Operations execute one after another in FIFO order: + +```dart +class MyController extends StateController + with SequentialControllerHandler { + MyController() : super(initialState: MyState.initial()); + + // These operations run sequentially + void operation1() => handle(() async { ... }); + void operation2() => handle(() async { ... }); +} +``` + +### 3. Droppable (with Mixin) + +New operations are dropped if one is already running: + +```dart +class MyController extends StateController + with DroppableControllerHandler { + MyController() : super(initialState: MyState.initial()); + + // If operation1 is running, operation2 is dropped + void operation1() => handle(() async { ... }); + void operation2() => handle(() async { ... }); +} +``` + +### 4. Custom (with Mutex) + +Use `Mutex` directly for fine-grained control: + +```dart +class MyController extends StateController { + MyController() : super(initialState: MyState.initial()); + + final _criticalMutex = Mutex(); + final _batchMutex = Mutex(); + + // Sequential critical operations + void criticalOperation() => _criticalMutex.synchronize( + () => handle(() async { ... }), + ); + + // Sequential batch operations (different queue) + void batchOperation() => _batchMutex.synchronize( + () => handle(() async { ... }), + ); + + // Concurrent fast operations + void fastOperation() => handle(() async { ... }); +} +``` + +## Usage in Flutter + +### Inject Controller + +Use `ControllerScope` to provide controller to widget tree: + +```dart +class App extends StatelessWidget { + @override + Widget build(BuildContext context) => MaterialApp( + home: ControllerScope( + CounterController.new, + child: const CounterScreen(), + ), + ); +} +``` + +### Consume State + +Use `StateConsumer` to rebuild widgets when state changes: + +```dart +class CounterScreen extends StatelessWidget { + @override + Widget build(BuildContext context) => Scaffold( + body: StateConsumer( + builder: (context, state, _) => Text('Count: ${state.count}'), + ), + floatingActionButton: FloatingActionButton( + onPressed: () => context.controllerOf().increment(), + child: Icon(Icons.add), + ), + ); +} +``` + +### Use ValueListenable + +Convert state to `ValueListenable` for granular updates: + +```dart +ValueListenableBuilder( + valueListenable: controller.select((state) => state.idle), + builder: (context, isIdle, _) => ElevatedButton( + onPressed: isIdle ? () => controller.increment() : null, + child: Text('Increment'), + ), +) +``` + +## Advanced Features + +### Error Handling + +The `handle()` method provides built-in error handling: + +```dart +void riskyOperation() => handle( + () async { + // Your operation + throw Exception('Something went wrong'); + }, + error: (error, stackTrace) async { + // Handle error + print('Error: $error'); + }, + done: () async { + // Always called, even if error occurs + print('Operation completed'); + }, + name: 'riskyOperation', // For debugging +); +``` + +### Observer Pattern + +Monitor all controller events for debugging: + +```dart +class MyObserver implements IControllerObserver { + @override + void onCreate(Controller controller) { + print('Controller created: ${controller.name}'); + } + + @override + void onHandler(HandlerContext context) { + print('Handler started: ${context.name}'); + } + + @override + void onStateChanged( + StateController controller, + S prevState, + S nextState, + ) { + print('State changed: $prevState -> $nextState'); + } + + @override + void onError(Controller controller, Object error, StackTrace stackTrace) { + print('Error in ${controller.name}: $error'); + } + + @override + void onDispose(Controller controller) { + print('Controller disposed: ${controller.name}'); + } +} + +void main() { + Controller.observer = MyObserver(); + runApp(MyApp()); +} +``` + +### Mutex + +Use `Mutex` for custom synchronization: + +```dart +final mutex = Mutex(); + +// Method 1: synchronize (automatic unlock) +await mutex.synchronize(() async { + // Critical section +}); + +// Method 2: lock/unlock (manual control) +final unlock = await mutex.lock(); +try { + // Critical section + if (someCondition) { + unlock(); + return; // Early exit + } + // More code +} finally { + unlock(); +} + +// Check if locked +if (mutex.locked) { + print('Mutex is currently locked'); +} +``` + +## Migration from 0.x to 1.0.0 + +See [MIGRATION.md](MIGRATION.md) for detailed migration guide. + +**Key changes:** +- Remove `base` from controller classes +- `ConcurrentControllerHandler` is deprecated (remove it) +- Controllers are concurrent by default +- Use `Mutex` for custom concurrency patterns + +## Best Practices + +1. **Choose the right concurrency strategy:** + - Default (concurrent) for independent operations + - Sequential for operations that must complete in order + - Droppable for operations that should cancel if busy + - Custom Mutex for complex scenarios + +2. **Use `handle()` for all async operations:** + - Automatic error catching + - Observer notifications + - Proper disposal handling + +3. **Keep state immutable:** + - Use records or immutable classes for state + - Always create new state instances + +4. **Dispose controllers:** + - Controllers are automatically disposed by `ControllerScope` + - Manual disposal only needed for manually created controllers + +## Examples + +See [example/](example/) directory for complete examples: +- Basic counter +- Advanced concurrency patterns +- Error handling +- Custom observers + ## Coverage [![](https://codecov.io/gh/PlugFox/control/branch/master/graphs/sunburst.svg)](https://codecov.io/gh/PlugFox/control/branch/master) diff --git a/example/analysis_options.yaml b/example/analysis_options.yaml index dda2873..9b6c13b 100644 --- a/example/analysis_options.yaml +++ b/example/analysis_options.yaml @@ -52,7 +52,6 @@ analyzer: always_declare_return_types: info # Warning - unsafe_html: warning missing_return: warning missing_required_param: warning no_logic_in_create_state: warning @@ -153,7 +152,6 @@ linter: unnecessary_statements: true unnecessary_string_escapes: true unnecessary_string_interpolations: true - unsafe_html: true use_full_hex_values_for_flutter_colors: true use_raw_strings: true use_string_buffers: true @@ -208,7 +206,6 @@ linter: non_constant_identifier_names: true constant_identifier_names: true directives_ordering: true - package_api_docs: true implementation_imports: true prefer_interpolation_to_compose_strings: true unnecessary_brace_in_string_interps: true diff --git a/example/lib/main.dart b/example/lib/main.dart index 7bdefdf..33f1982 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -97,8 +97,8 @@ void main() => runZonedGuarded>( /// Counter state for [CounterController] typedef CounterState = ({int count, bool idle}); -/// Counter controller -final class CounterController extends StateController +/// Counter controller with sequential handler +class CounterController extends StateController with SequentialControllerHandler { CounterController({CounterState? initialState}) : super(initialState: initialState ?? (idle: true, count: 0)); diff --git a/example/pubspec.lock b/example/pubspec.lock index 46ca6de..b8d4bef 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -151,7 +151,7 @@ packages: path: ".." relative: true source: path - version: "1.0.0-dev" + version: "1.0.0-dev.1" convert: dependency: "direct main" description: @@ -386,10 +386,10 @@ packages: dependency: "direct main" description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -623,10 +623,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" timing: dependency: transitive description: @@ -716,5 +716,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.8.0-0 <4.0.0" - flutter: ">=3.18.0-18.0.pre.54" + dart: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" diff --git a/lib/src/concurrency/concurrent_controller_handler.dart b/lib/src/concurrency/concurrent_controller_handler.dart index b2597aa..1380d26 100644 --- a/lib/src/concurrency/concurrent_controller_handler.dart +++ b/lib/src/concurrency/concurrent_controller_handler.dart @@ -1,142 +1,31 @@ -import 'dart:async'; - import 'package:control/src/controller.dart'; -import 'package:control/src/handler_context.dart'; -import 'package:meta/meta.dart'; /// A mixin that provides concurrent controller concurrency handling. -/// This mixin should be used on classes that extend [Controller]. -base mixin ConcurrentControllerHandler on Controller { - @override - @nonVirtual - 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. - /// [error] is an optional error handler. - /// [done] is an optional callback to be executed when the operation is done. - /// [name] is an optional name for the operation, used for debugging. - /// [meta] is an optional HashMap of context data to be passed to the zone. - @override - @protected - @mustCallSuper - Future handle( - Future Function() handler, { - Future Function(Object error, StackTrace stackTrace)? error, - Future Function()? done, - String? name, - Map? meta, - }) { - if (isDisposed) return Future.value(null); - _$processingCalls++; - final completer = Completer(); - var isDone = false; // ignore error callback after done - - Future onError(Object e, StackTrace st) async { - if (isDisposed) return; - 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); - } - } - - 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.', - ); - } - - void onDone() { - if (completer.isCompleted) return; - _$processingCalls--; - completer.complete(); - } - - final handlerContext = HandlerContextImpl( - controller: this, - name: name ?? 'handler#${handler.runtimeType}', - completer: completer, - meta: { - ...?meta, - }, - ); - - runZonedGuarded( - () async { - try { - if (isDisposed) return; - Controller.observer?.onHandler(handlerContext); - 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); - } finally { - onDone(); - } - } - }, - handleZoneError, - zoneValues: { - HandlerContext.key: handlerContext, - }, - ); - - 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, - ); */ +/// +/// **Deprecated:** This mixin is no longer needed as [Controller] handles +/// operations concurrently by default. Simply remove this mixin from your +/// controller declarations. +/// +/// Before: +/// ```dart +/// class MyController extends StateController +/// with ConcurrentControllerHandler { +/// // ... +/// } +/// ``` +/// +/// After: +/// ```dart +/// class MyController extends StateController { +/// // Operations execute concurrently by default +/// } +/// ``` +@Deprecated( + 'ConcurrentControllerHandler is no longer needed. ' + 'Controller handles operations concurrently by default. ' + 'Remove this mixin from your controller declarations.', +) +mixin ConcurrentControllerHandler on Controller { + // Empty mixin - base Controller behavior is already concurrent + // This is kept for backwards compatibility only } diff --git a/lib/src/concurrency/droppable_controller_handler.dart b/lib/src/concurrency/droppable_controller_handler.dart index 4f034b1..fd90d48 100644 --- a/lib/src/concurrency/droppable_controller_handler.dart +++ b/lib/src/concurrency/droppable_controller_handler.dart @@ -1,23 +1,33 @@ import 'dart:async'; import 'package:control/src/controller.dart'; -import 'package:control/src/handler_context.dart'; +import 'package:control/src/mutex.dart'; import 'package:meta/meta.dart'; -/// Droppable controller concurrency -base mixin DroppableControllerHandler on Controller { +/// Droppable controller concurrency handler. +/// +/// This mixin drops new operations if one is already running. +/// When an operation is in progress, new calls to [handle] return immediately +/// without executing the handler. +/// +/// Example: +/// ```dart +/// class MyController extends StateController +/// with DroppableControllerHandler { +/// void operation1() => handle(() async { ... }); +/// void operation2() => handle(() async { ... }); +/// // If operation1 is running, operation2 is dropped +/// } +/// ``` +mixin DroppableControllerHandler on Controller { + final Mutex _$mutex = Mutex(); + @override - @nonVirtual - bool get isProcessing => _$processingCalls > 0; - int _$processingCalls = 0; + bool get isProcessing => _$mutex.locked; - /// Handles a given operation with error handling and completion tracking. + /// Handles a given operation with droppable behavior. /// - /// [handler] is the main operation to be executed. - /// [error] is an optional error handler. - /// [done] is an optional callback to be executed when the operation is done. - /// [name] is an optional name for the operation, used for debugging. - /// [meta] is an optional HashMap of context data to be passed to the zone. + /// If an operation is already running, the new one is dropped. @override @protected @mustCallSuper @@ -28,74 +38,17 @@ base mixin DroppableControllerHandler on Controller { String? name, Map? meta, }) { - if (isDisposed || isProcessing) return Future.value(null); - _$processingCalls++; - final completer = Completer(); - var isDone = false; // ignore error callback after done - - Future onError(Object e, StackTrace st) async { - if (isDisposed) return; - 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); - } - } - - 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.', - ); - } - - void onDone() { - if (completer.isCompleted) return; - _$processingCalls--; - completer.complete(); - } - - final handlerContext = HandlerContextImpl( - controller: this, - name: name ?? 'handler#${handler.runtimeType}', - completer: completer, - meta: { - ...?meta, - }, - ); - - runZonedGuarded( - () async { - try { - if (isDisposed) return; - Controller.observer?.onHandler(handlerContext); - 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); - } finally { - onDone(); - } - } - }, - handleZoneError, - zoneValues: { - HandlerContext.key: handlerContext, - }, + // If already locked, drop this operation + if (_$mutex.locked) return Future.value(null); + + return _$mutex.synchronize( + () => super.handle( + handler, + error: error, + done: done, + name: name, + meta: meta, + ), ); - - return completer.future; } } diff --git a/lib/src/concurrency/sequential_controller_handler.dart b/lib/src/concurrency/sequential_controller_handler.dart index 41f505a..363e40e 100644 --- a/lib/src/concurrency/sequential_controller_handler.dart +++ b/lib/src/concurrency/sequential_controller_handler.dart @@ -1,25 +1,32 @@ import 'dart:async'; -import 'dart:collection'; import 'package:control/src/controller.dart'; -import 'package:control/src/handler_context.dart'; +import 'package:control/src/mutex.dart'; import 'package:meta/meta.dart'; -/// Sequential controller concurrency -base mixin SequentialControllerHandler on Controller { - final _ControllerEventQueue _eventQueue = _ControllerEventQueue(); +/// Sequential controller concurrency handler. +/// +/// This mixin ensures that all operations execute sequentially (one at a time) +/// by wrapping the base [Controller.handle] method with a [Mutex]. +/// +/// Example: +/// ```dart +/// class MyController extends StateController +/// with SequentialControllerHandler { +/// void operation1() => handle(() async { ... }); +/// void operation2() => handle(() async { ... }); +/// // Operations execute one after another, never in parallel +/// } +/// ``` +mixin SequentialControllerHandler on Controller { + final Mutex _$mutex = Mutex(); @override - @nonVirtual - bool get isProcessing => _eventQueue.length > 0; + bool get isProcessing => _$mutex.locked; - /// Handles a given operation with error handling and completion tracking. + /// Handles a given operation sequentially. /// - /// [handler] is the main operation to be executed. - /// [error] is an optional error handler. - /// [done] is an optional callback to be executed when the operation is done. - /// [name] is an optional name for the operation, used for debugging. - /// [meta] is an optional HashMap of context data to be passed to the zone. + /// Operations are queued and executed one at a time. @override @protected @mustCallSuper @@ -30,152 +37,13 @@ base mixin SequentialControllerHandler on Controller { String? name, Map? meta, }) => - _eventQueue.push( - () { - final completer = Completer(); - var isDone = false; // ignore error callback after done - - Future onError(Object e, StackTrace st) async { - if (isDisposed) return; - 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); - } - } - - 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.', - ); - } - - final handlerContext = HandlerContextImpl( - controller: this, - name: name ?? 'handler#${handler.runtimeType}', - completer: completer, - meta: { - ...?meta, - }, - ); - - void onDone() { - if (completer.isCompleted) return; - completer.complete(); - } - - runZonedGuarded( - () async { - try { - if (isDisposed) return; - Controller.observer?.onHandler(handlerContext); - 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); - } finally { - onDone(); - } - } - }, - handleZoneError, - zoneValues: { - HandlerContext.key: handlerContext, - }, - ); - - 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); - } + _$mutex.synchronize( + () => super.handle( + handler, + error: error, + done: done, + name: name, + meta: meta, + ), + ); } diff --git a/lib/src/controller.dart b/lib/src/controller.dart index 898fda7..bfbfba9 100644 --- a/lib/src/controller.dart +++ b/lib/src/controller.dart @@ -77,7 +77,7 @@ abstract interface class IControllerObserver { /// 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 { +abstract class Controller with ChangeNotifier implements IController { /// {@macro controller} Controller() { runZonedGuarded( @@ -110,6 +110,10 @@ abstract base class Controller with ChangeNotifier implements IController { int get subscribers => _$subscribers; int _$subscribers = 0; + @override + bool get isProcessing => _$processingCalls > 0; + int _$processingCalls = 0; + /// Error handling callback @protected void onError(Object error, StackTrace stackTrace) => runZonedGuarded( @@ -119,16 +123,101 @@ abstract base class Controller with ChangeNotifier implements IController { /// Handles a given operation with error handling and completion tracking. /// + /// By default, operations execute concurrently. To change this behavior, + /// use concurrency handler mixins like [SequentialControllerHandler] + /// or [DroppableControllerHandler], or use [Mutex] for custom control. + /// + /// This method provides: + /// - Zone for error catching (including unawaited futures) + /// - HandlerContext for debugging + /// - Observer notifications + /// - error/done callbacks + /// /// [handler] is the main operation to be executed. + /// [error] is an optional error handler. + /// [done] is an optional callback to be executed when the operation is done. /// [name] is an optional name for the operation, used for debugging. /// [meta] is an optional HashMap of context data to be passed to the zone. @protected + @mustCallSuper @override Future handle( Future Function() handler, { + Future Function(Object error, StackTrace stackTrace)? error, + Future Function()? done, String? name, Map? meta, - }); + }) { + if (isDisposed) return Future.value(null); + _$processingCalls++; + final completer = Completer(); + var isDone = false; // ignore error callback after done + + Future onError(Object e, StackTrace st) async { + if (isDisposed) return; + try { + this.onError(e, st); + if (isDone || isDisposed || completer.isCompleted) return; + await error?.call(e, st); + } on Object catch (error, stackTrace) { + this.onError(error, stackTrace); + } + } + + Future handleZoneError(Object error, StackTrace stackTrace) async { + if (isDisposed) return; + this.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.', + ); + } + + void onDone() { + if (completer.isCompleted) return; + _$processingCalls--; + completer.complete(); + } + + final handlerContext = HandlerContextImpl( + controller: this, + name: name ?? 'handler#${handler.runtimeType}', + completer: completer, + meta: { + ...?meta, + }, + ); + + runZonedGuarded( + () async { + try { + if (isDisposed) return; + Controller.observer?.onHandler(handlerContext); + await handler(); + } on Object catch (error, stackTrace) { + await onError(error, stackTrace); + } finally { + isDone = true; + try { + await done?.call(); + } on Object catch (error, stackTrace) { + this.onError(error, stackTrace); + } finally { + onDone(); + } + } + }, + handleZoneError, + zoneValues: { + HandlerContext.key: handlerContext, + }, + ); + + return completer.future; + } @protected @nonVirtual diff --git a/lib/src/state_controller.dart b/lib/src/state_controller.dart index 0dfb6e6..f4a9ab9 100644 --- a/lib/src/state_controller.dart +++ b/lib/src/state_controller.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:control/src/controller.dart'; import 'package:control/src/handler_context.dart'; import 'package:flutter/foundation.dart'; -import 'package:meta/meta.dart'; /// Selector from [StateController] typedef StateControllerSelector = Value Function( @@ -24,7 +23,7 @@ abstract interface class IStateController } /// State controller -abstract base class StateController extends Controller +abstract class StateController extends Controller implements IStateController { /// State controller StateController({required S initialState}) : _$state = initialState; diff --git a/test/unit/handler_context_test.dart b/test/unit/handler_context_test.dart index 4dce995..8c900f4 100644 --- a/test/unit/handler_context_test.dart +++ b/test/unit/handler_context_test.dart @@ -1,88 +1,73 @@ +// ignore_for_file: deprecated_member_use_from_same_package + import 'dart:math' as math; import 'package:control/control.dart'; import 'package:flutter_test/flutter_test.dart'; void main() => group('HandlerContext', () { - test('FakeControllers', () async { - final controllers = <_FakeControllerBase>[ - _FakeControllerSequential(), - _FakeControllerDroppable(), - _FakeControllerConcurrent(), - ]; - for (final controller in controllers) { - final observer = Controller.observer = _FakeControllerObserver(); - expect(controller.isProcessing, isFalse); - expect(observer.lastContext, isNull); - expect(observer.lastStateContext, isNull); - expect(observer.lastErrorContext, isNull); - expect(Controller.context, isNull); - - // After the normal event is called, the context should be available. - final value = math.Random().nextDouble(); - HandlerContext? lastContext; - controller.event( + test('FakeControllers', () async { + final controllers = <_FakeControllerBase>[ + _FakeControllerSequential(), + _FakeControllerDroppable(), + _FakeControllerConcurrent(), + ]; + for (final controller in controllers) { + final observer = Controller.observer = _FakeControllerObserver(); + expect(controller.isProcessing, isFalse); + expect(observer.lastContext, isNull); + expect(observer.lastStateContext, isNull); + expect(observer.lastErrorContext, isNull); + expect(Controller.context, isNull); + + // After the normal event is called, the context should be available. + final value = math.Random().nextDouble(); + HandlerContext? lastContext; + controller + .event( meta: {'double': value}, out: (ctx) => lastContext = ctx, - ).ignore(); - expect(controller.isProcessing, isTrue); - expect(lastContext, isNotNull); - await expectLater(lastContext!.done, completes); - // Event should be done by now. - expect(lastContext!.isDone, isTrue); - expect( - lastContext, - allOf( - isNotNull, - same(observer.lastContext), - same(observer.lastStateContext), - isA() - .having( - (ctx) => ctx.name, - 'name', - 'event', - ) - .having( - (ctx) => ctx.meta['double'], - 'meta should contain double', - equals(value), - ) - .having( - (ctx) => ctx.meta['started_at'], - 'meta should contain started_at', - allOf( - isNotNull, - isA(), - ), - ) - .having( - (ctx) => ctx.meta['duration'], - 'meta should contain duration', - allOf( - isNotNull, - isA(), - isNot(Duration.zero), - ), - ) - .having( - (ctx) => ctx.controller, - 'controller', - same(controller), - ) - .having( - (ctx) => ctx.isDone, - 'isDone', - isTrue, - ), - ), - ); - expect(observer.lastErrorContext, isNull); - expect(Controller.context, isNull); - - controller.dispose(); - } - }); - }); + ) + .ignore(); + expect(controller.isProcessing, isTrue); + expect(lastContext, isNotNull); + await expectLater(lastContext!.done, completes); + // Event should be done by now. + expect(lastContext!.isDone, isTrue); + expect( + lastContext, + allOf( + isNotNull, + same(observer.lastContext), + same(observer.lastStateContext), + isA() + .having((ctx) => ctx.name, 'name', 'event') + .having( + (ctx) => ctx.meta['double'], + 'meta should contain double', + equals(value), + ) + .having( + (ctx) => ctx.meta['started_at'], + 'meta should contain started_at', + allOf(isNotNull, isA()), + ) + .having( + (ctx) => ctx.meta['duration'], + 'meta should contain duration', + allOf(isNotNull, isA(), isNot(Duration.zero)), + ) + .having((ctx) => ctx.controller, 'controller', same(controller)) + .having((ctx) => ctx.isDone, 'isDone', isTrue), + ), + ); + expect(observer.lastErrorContext, isNull); + expect(Controller.context, isNull); + + controller.dispose(); + } + }); +}); final class _FakeControllerObserver implements IControllerObserver { HandlerContext? lastContext; @@ -90,10 +75,14 @@ final class _FakeControllerObserver implements IControllerObserver { HandlerContext? lastErrorContext; @override - void onCreate(Controller controller) {/* ignore */} + void onCreate(Controller controller) { + /* ignore */ + } @override - void onDispose(Controller controller) {/* ignore */} + void onDispose(Controller controller) { + /* ignore */ + } @override void onHandler(HandlerContext context) { @@ -121,29 +110,25 @@ abstract base class _FakeControllerBase extends StateController { Future event({ Map? meta, void Function(HandlerContext context)? out, - }) => - handle( - () async { + }) => handle( + () async { + out?.call(Controller.context!); + final stopwatch = Stopwatch()..start(); + try { + setState(false); + await Future.delayed(Duration.zero); + () { out?.call(Controller.context!); - final stopwatch = Stopwatch()..start(); - try { - setState(false); - await Future.delayed(Duration.zero); - () { - out?.call(Controller.context!); - }(); - setState(true); - Controller.context?.meta['duration'] = stopwatch.elapsed; - } finally { - stopwatch.stop(); - } - }, - name: 'event', - meta: { - ...?meta, - 'started_at': DateTime.now(), - }, - ); + }(); + setState(true); + Controller.context?.meta['duration'] = stopwatch.elapsed; + } finally { + stopwatch.stop(); + } + }, + name: 'event', + meta: {...?meta, 'started_at': DateTime.now()}, + ); } final class _FakeControllerSequential = _FakeControllerBase diff --git a/test/unit/state_controller_test.dart b/test/unit/state_controller_test.dart index 1bdbb16..4fb7edf 100644 --- a/test/unit/state_controller_test.dart +++ b/test/unit/state_controller_test.dart @@ -1,4 +1,5 @@ // ignore_for_file: unnecessary_lambdas, unused_element +// ignore_for_file: deprecated_member_use_from_same_package import 'dart:async'; @@ -7,431 +8,425 @@ import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; void main() => group('StateController', () { - _$concurrencyGroup(); - _$exceptionalGroup(); - _$assertionGroup(); - _$methodsGroup(); - _$onErrorGroup(); - }); + _$concurrencyGroup(); + _$exceptionalGroup(); + _$assertionGroup(); + _$methodsGroup(); + _$onErrorGroup(); +}); 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); - final done = Future.wait(>[ - controller.add(1), - controller.subtract(2), - controller.add(4), - ]); - expect(controller.isProcessing, isTrue); - await expectLater(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); - final done = Future.wait(>[ - controller.add(1), - controller.subtract(2), - controller.add(4), - ]); - expect(controller.isProcessing, isTrue); - await expectLater(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); - final done = Future.wait(>[ - controller.add(1), - controller.subtract(2), - controller.add(4), - ]); - expect(controller.isProcessing, isTrue); - await expectLater(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('sequential', () async { + final controller = _FakeControllerSequential(); + expect(controller.isProcessing, isFalse); + expect(controller.state, equals(0)); + expect(controller.subscribers, equals(0)); + expect(controller.isDisposed, isFalse); + final done = Future.wait(>[ + controller.add(1), + controller.subtract(2), + controller.add(4), + ]); + expect(controller.isProcessing, isTrue); + await expectLater(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); + final done = Future.wait(>[ + controller.add(1), + controller.subtract(2), + controller.add(4), + ]); + expect(controller.isProcessing, isTrue); + await expectLater(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); + final done = Future.wait(>[ + controller.add(1), + controller.subtract(2), + controller.add(4), + ]); + expect(controller.isProcessing, isTrue); + await expectLater(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 _$exceptionalGroup() => group('exceptional', () { - test('throws if dispose called multiple times', () { - final controller = _FakeControllerConcurrent()..dispose(); - expect(() => controller.dispose(), throwsA(isA())); - }); - - test('handles edge case of adding large values', () async { - const largeValue = 9223372036854775807; - final controller = _FakeControllerConcurrent(); - await expectLater(controller.add(largeValue), completes); - expect(controller.state, equals(largeValue)); - controller.dispose(); - }); - - test('handles edge case of subtracting large values', () async { - const largeNegativeValue = 9223372036854775807; - final controller = _FakeControllerConcurrent(); - await expectLater(controller.subtract(largeNegativeValue), completes); - expect(controller.state, equals(-largeNegativeValue)); - controller.dispose(); - }); - - test('processes multiple operations efficiently', () async { - final stopwatch = Stopwatch()..start(); - try { - final controller = _FakeControllerConcurrent(); - final done = Future.wait( - >[for (var i = 0; i < 1000; i++) controller.add(1)]); - await expectLater(done, completes); - expect(controller.state, equals(1000)); - controller.dispose(); - } finally { - debugPrint('${(stopwatch..stop()).elapsedMicroseconds} μs'); - } - }); - - test('should correctly manage multiple listeners', () { - final controller = _FakeControllerConcurrent(); - - void listener1() {} - void listener2() {} - - expect(controller.subscribers, equals(0)); - - controller - ..addListener(listener1) - ..addListener(listener2); - expect(controller.subscribers, equals(2)); - - controller.removeListener(listener1); - expect(controller.subscribers, equals(1)); - - controller.removeListener(listener2); - expect(controller.subscribers, equals(0)); - }); - }); + test('throws if dispose called multiple times', () { + final controller = _FakeControllerConcurrent()..dispose(); + expect(() => controller.dispose(), throwsA(isA())); + }); + + test('handles edge case of adding large values', () async { + const largeValue = 9223372036854775807; + final controller = _FakeControllerConcurrent(); + await expectLater(controller.add(largeValue), completes); + expect(controller.state, equals(largeValue)); + controller.dispose(); + }); + + test('handles edge case of subtracting large values', () async { + const largeNegativeValue = 9223372036854775807; + final controller = _FakeControllerConcurrent(); + await expectLater(controller.subtract(largeNegativeValue), completes); + expect(controller.state, equals(-largeNegativeValue)); + controller.dispose(); + }); + + test('processes multiple operations efficiently', () async { + final stopwatch = Stopwatch()..start(); + try { + final controller = _FakeControllerConcurrent(); + final done = Future.wait(>[ + for (var i = 0; i < 1000; i++) controller.add(1), + ]); + await expectLater(done, completes); + expect(controller.state, equals(1000)); + controller.dispose(); + } finally { + debugPrint('${(stopwatch..stop()).elapsedMicroseconds} μs'); + } + }); + + test('should correctly manage multiple listeners', () { + final controller = _FakeControllerConcurrent(); + + void listener1() {} + void listener2() {} + + expect(controller.subscribers, equals(0)); + + controller + ..addListener(listener1) + ..addListener(listener2); + expect(controller.subscribers, equals(2)); + + controller.removeListener(listener1); + expect(controller.subscribers, equals(1)); + + controller.removeListener(listener2); + expect(controller.subscribers, equals(0)); + }); +}); void _$assertionGroup() => group('assertion', () { - test('should assert when notifyListeners called on disposed controller', - () { - final controller = _FakeControllerSequential(); - controller.dispose(); // ignore: cascade_invocations - - expect(controller.isDisposed, isTrue); - - expect( - () => controller.addWithNotifyListeners(1), - throwsA(isA().having( - (e) => e.message, - 'message', - contains('A _FakeControllerSequential was already disposed.'), - )), - ); - }); - test('should assert when addListener called on disposed controller', () { - final controller = _FakeControllerSequential(); - controller.dispose(); // ignore: cascade_invocations - - expect(controller.isDisposed, isTrue); - - void listener() {} - - expect( - () => controller.addListener(listener), - throwsA(isA().having( - (e) => e.message, - 'message', - contains('A _FakeControllerSequential was already disposed.'), - )), - ); - }); - }); + test('should assert when notifyListeners called on disposed controller', () { + final controller = _FakeControllerSequential(); + controller.dispose(); // ignore: cascade_invocations + + expect(controller.isDisposed, isTrue); + + expect( + () => controller.addWithNotifyListeners(1), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('A _FakeControllerSequential was already disposed.'), + ), + ), + ); + }); + test('should assert when addListener called on disposed controller', () { + final controller = _FakeControllerSequential(); + controller.dispose(); // ignore: cascade_invocations + + expect(controller.isDisposed, isTrue); + + void listener() {} + + expect( + () => controller.addListener(listener), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('A _FakeControllerSequential was already disposed.'), + ), + ), + ); + }); +}); void _$methodsGroup() => group('methods', () { - test('merge', () async { - final controllerOne = _FakeControllerSequential(); - final controllerTwo = _FakeControllerSequential(); - - final mergedListenable = - Controller.merge([controllerOne, controllerTwo]); - - // Check that the result is an object of type Listenable - expect(mergedListenable, isA()); - - // Check that subscribers to mergedListenable listen for changes - // in both controllers - var listenerCalled = 0; - mergedListenable.addListener(() => listenerCalled++); - - controllerOne.add(1).ignore(); - await Future.delayed(Duration.zero); - expect(listenerCalled, equals(1)); - - controllerTwo.add(1).ignore(); - await Future.delayed(Duration.zero); - expect(listenerCalled, equals(2)); - }); - - test('toStream', () async { - final controller = _FakeControllerConcurrent(); - expect(controller.toStream(), isA>()); - // ignore: unawaited_futures - expectLater( - controller.toStream(), - emitsInOrder([1, 0, -1, 2, emitsDone]), - ); - await expectLater( - Future.wait([ - controller.add(1), - controller.subtract(1), - controller.subtract(1), - controller.add(3), - ]), - completes, - ); - controller.dispose(); - }); - - test('toValueListenable', () async { - final controller = _FakeControllerConcurrent(); - final listenable = controller.toValueListenable(); - expect(listenable, isA>()); - expect(listenable.value, equals(controller.state)); - await expectLater( - Future.wait([ - controller.add(2), - controller.subtract(1), - ]), - completes, - ); - expect(listenable.value, equals(controller.state)); - final completer = Completer(); - listenable.addListener(completer.complete); - controller.add(1).ignore(); - await expectLater(completer.future, completes); - expect(completer.isCompleted, isTrue); - controller.dispose(); - }); - }); + test('merge', () async { + final controllerOne = _FakeControllerSequential(); + final controllerTwo = _FakeControllerSequential(); + + final mergedListenable = Controller.merge([controllerOne, controllerTwo]); + + // Check that the result is an object of type Listenable + expect(mergedListenable, isA()); + + // Check that subscribers to mergedListenable listen for changes + // in both controllers + var listenerCalled = 0; + mergedListenable.addListener(() => listenerCalled++); + + controllerOne.add(1).ignore(); + await Future.delayed(Duration.zero); + expect(listenerCalled, equals(1)); + + controllerTwo.add(1).ignore(); + await Future.delayed(Duration.zero); + expect(listenerCalled, equals(2)); + }); + + test('toStream', () async { + final controller = _FakeControllerConcurrent(); + expect(controller.toStream(), isA>()); + // ignore: unawaited_futures + expectLater( + controller.toStream(), + emitsInOrder([1, 0, -1, 2, emitsDone]), + ); + await expectLater( + Future.wait([ + controller.add(1), + controller.subtract(1), + controller.subtract(1), + controller.add(3), + ]), + completes, + ); + controller.dispose(); + }); + + test('toValueListenable', () async { + final controller = _FakeControllerConcurrent(); + final listenable = controller.toValueListenable(); + expect(listenable, isA>()); + expect(listenable.value, equals(controller.state)); + await expectLater( + Future.wait([controller.add(2), controller.subtract(1)]), + completes, + ); + expect(listenable.value, equals(controller.state)); + final completer = Completer(); + listenable.addListener(completer.complete); + controller.add(1).ignore(); + await expectLater(completer.future, completes); + expect(completer.isCompleted, isTrue); + controller.dispose(); + }); +}); void _$onErrorGroup() => group('onError', () { - group('sequential', () { - test( - 'should call onError and error callback ' - 'when an exception is thrown', () async { - final controller = _FakeControllerSequential(); - - var errorCalled = 0; - void onError() { - errorCalled++; - throw Exception(); - } - - var doneCalled = 0; - void onDone() => doneCalled++; - - controller.makeError( - onError: () async { - onError(); - throw Exception(); - }, - onDone: () async => onDone(), - ); - await Future.delayed(Duration.zero); - - expect(errorCalled, same(1)); - expect(doneCalled, same(1)); - }); - test( - 'should execute handler, handle errors, ' - 'and call done callback within runZonedGuarded', () async { - final controller = _FakeControllerSequential(); - - var errorCalled = 0; - void onError() { - errorCalled++; - throw Exception(); - } - - var doneCalled = 0; - void onDone() { - doneCalled++; - throw Exception(); - } - - controller.makeError( - onError: () async => onError(), - onDone: () async => onDone(), - ); - await Future.delayed(Duration.zero); - - expect(errorCalled, same(1)); - expect(doneCalled, same(1)); - }); - }); - group('droppable', () { - test( - 'should call onError and error callback ' - 'when an exception is thrown', () async { - final controller = _FakeControllerDroppable(); - - var errorCalled = 0; - void onError() { - errorCalled++; - throw Exception(); - } - - var doneCalled = 0; - void onDone() => doneCalled++; - - controller.makeError( - onError: () async { - onError(); - throw Exception(); - }, - onDone: () async => onDone(), - ); - await Future.delayed(Duration.zero); - - expect(errorCalled, same(1)); - expect(doneCalled, same(1)); - }); - test( - 'should execute handler, handle errors, ' - 'and call done callback within runZonedGuarded', () async { - final controller = _FakeControllerDroppable(); - - var errorCalled = 0; - void onError() { - errorCalled++; - throw Exception(); - } - - var doneCalled = 0; - void onDone() { - doneCalled++; - throw Exception(); - } - - controller.makeError( - onError: () async => onError(), - onDone: () async => onDone(), - ); - await Future.delayed(Duration.zero); - - expect(errorCalled, same(1)); - expect(doneCalled, same(1)); - }); - }); - group('concurrent', () { - test( - 'should call onError and error callback ' - 'when an exception is thrown', () async { - final controller = _FakeControllerConcurrent(); - - var errorCalled = 0; - void onError() { - errorCalled++; - throw Exception(); - } - - var doneCalled = 0; - void onDone() => doneCalled++; - - controller.makeError( - onError: () async { - onError(); - throw Exception(); - }, - onDone: () async => onDone(), - ); - await Future.delayed(Duration.zero); - - expect(errorCalled, same(1)); - expect(doneCalled, same(1)); - }); - test( - 'should execute handler, handle errors, ' - 'and call done callback within runZonedGuarded', () async { - final controller = _FakeControllerConcurrent(); - - var errorCalled = 0; - void onError() { - errorCalled++; - throw Exception(); - } - - var doneCalled = 0; - void onDone() { - doneCalled++; - throw Exception(); - } - - controller.makeError( - onError: () async => onError(), - onDone: () async => onDone(), - ); - await Future.delayed(Duration.zero); - - expect(errorCalled, same(1)); - expect(doneCalled, same(1)); - }); - }); + group('sequential', () { + test('should call onError and error callback ' + 'when an exception is thrown', () async { + final controller = _FakeControllerSequential(); + + var errorCalled = 0; + void onError() { + errorCalled++; + throw Exception(); + } + + var doneCalled = 0; + void onDone() => doneCalled++; + + controller.makeError( + onError: () async { + onError(); + throw Exception(); + }, + onDone: () async => onDone(), + ); + await Future.delayed(Duration.zero); + + expect(errorCalled, same(1)); + expect(doneCalled, same(1)); }); + test('should execute handler, handle errors, ' + 'and call done callback within runZonedGuarded', () async { + final controller = _FakeControllerSequential(); + + var errorCalled = 0; + void onError() { + errorCalled++; + throw Exception(); + } + + var doneCalled = 0; + void onDone() { + doneCalled++; + throw Exception(); + } + + controller.makeError( + onError: () async => onError(), + onDone: () async => onDone(), + ); + await Future.delayed(Duration.zero); + + expect(errorCalled, same(1)); + expect(doneCalled, same(1)); + }); + }); + group('droppable', () { + test('should call onError and error callback ' + 'when an exception is thrown', () async { + final controller = _FakeControllerDroppable(); + + var errorCalled = 0; + void onError() { + errorCalled++; + throw Exception(); + } + + var doneCalled = 0; + void onDone() => doneCalled++; + + controller.makeError( + onError: () async { + onError(); + throw Exception(); + }, + onDone: () async => onDone(), + ); + await Future.delayed(Duration.zero); + + expect(errorCalled, same(1)); + expect(doneCalled, same(1)); + }); + test('should execute handler, handle errors, ' + 'and call done callback within runZonedGuarded', () async { + final controller = _FakeControllerDroppable(); + + var errorCalled = 0; + void onError() { + errorCalled++; + throw Exception(); + } + + var doneCalled = 0; + void onDone() { + doneCalled++; + throw Exception(); + } + + controller.makeError( + onError: () async => onError(), + onDone: () async => onDone(), + ); + await Future.delayed(Duration.zero); + + expect(errorCalled, same(1)); + expect(doneCalled, same(1)); + }); + }); + group('concurrent', () { + test('should call onError and error callback ' + 'when an exception is thrown', () async { + final controller = _FakeControllerConcurrent(); + + var errorCalled = 0; + void onError() { + errorCalled++; + throw Exception(); + } + + var doneCalled = 0; + void onDone() => doneCalled++; + + controller.makeError( + onError: () async { + onError(); + throw Exception(); + }, + onDone: () async => onDone(), + ); + await Future.delayed(Duration.zero); + + expect(errorCalled, same(1)); + expect(doneCalled, same(1)); + }); + test('should execute handler, handle errors, ' + 'and call done callback within runZonedGuarded', () async { + final controller = _FakeControllerConcurrent(); + + var errorCalled = 0; + void onError() { + errorCalled++; + throw Exception(); + } + + var doneCalled = 0; + void onDone() { + doneCalled++; + throw Exception(); + } + + controller.makeError( + onError: () async => onError(), + onDone: () async => onDone(), + ); + await Future.delayed(Duration.zero); + + expect(errorCalled, same(1)); + expect(doneCalled, same(1)); + }); + }); +}); abstract base class _FakeControllerBase extends StateController { _FakeControllerBase({int? initialState}) - : super(initialState: initialState ?? 0); + : super(initialState: initialState ?? 0); Future add(int value) => handle(() async { - await Future.delayed(Duration.zero); - setState(state + value); - }); + await Future.delayed(Duration.zero); + setState(state + value); + }); Future subtract(int value) => handle(() async { - await Future.delayed(Duration.zero); - setState(state - value); - }); + await Future.delayed(Duration.zero); + setState(state - value); + }); } final class _FakeControllerSequential extends _FakeControllerBase @@ -441,45 +436,33 @@ final class _FakeControllerSequential extends _FakeControllerBase notifyListeners(); } - void makeError({ - void Function()? onError, - void Function()? onDone, - }) => - handle( - () async { - throw Exception(); - }, - error: (_, __) async => onError?.call(), - done: () async => onDone?.call(), - ); + void makeError({void Function()? onError, void Function()? onDone}) => handle( + () async { + throw Exception(); + }, + error: (_, _) async => onError?.call(), + done: () async => onDone?.call(), + ); } final class _FakeControllerDroppable extends _FakeControllerBase with DroppableControllerHandler { - void makeError({ - void Function()? onError, - void Function()? onDone, - }) => - handle( - () async { - throw Exception(); - }, - error: (_, __) async => onError?.call(), - done: () async => onDone?.call(), - ); + void makeError({void Function()? onError, void Function()? onDone}) => handle( + () async { + throw Exception(); + }, + error: (_, _) async => onError?.call(), + done: () async => onDone?.call(), + ); } final class _FakeControllerConcurrent extends _FakeControllerBase with ConcurrentControllerHandler { - void makeError({ - void Function()? onError, - void Function()? onDone, - }) => - handle( - () async { - throw Exception(); - }, - error: (_, __) async => onError?.call(), - done: () async => onDone?.call(), - ); + void makeError({void Function()? onError, void Function()? onDone}) => handle( + () async { + throw Exception(); + }, + error: (_, _) async => onError?.call(), + done: () async => onDone?.call(), + ); } From 822c8554b335ec50492c4050b3e5446b3ed99161 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Fri, 6 Feb 2026 09:52:45 +0400 Subject: [PATCH 13/20] format --- example/ios/Flutter/AppFrameworkInfo.plist | 2 +- example/ios/Podfile | 2 +- example/ios/Podfile.lock | 10 +- example/ios/Runner.xcodeproj/project.pbxproj | 6 +- .../xcshareddata/xcschemes/Runner.xcscheme | 3 + example/macos/Podfile | 2 +- example/macos/Podfile.lock | 8 +- .../macos/Runner.xcodeproj/project.pbxproj | 6 +- .../xcshareddata/xcschemes/Runner.xcscheme | 1 + .../sequential_controller_handler.dart | 14 +- lib/src/controller.dart | 29 +- lib/src/controller_scope.dart | 114 +- lib/src/handler_context.dart | 18 +- lib/src/state_consumer.dart | 38 +- lib/src/state_controller.dart | 11 +- test/unit/mutex_test.dart | 1823 +++++++++-------- test/util/test_util.dart | 54 +- test/widget/controller_scope_test.dart | 443 ++-- test/widget/state_consumer_test.dart | 535 +++-- 19 files changed, 1556 insertions(+), 1563 deletions(-) diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 7c56964..1dc6cf7 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 12.0 + 13.0 diff --git a/example/ios/Podfile b/example/ios/Podfile index d97f17e..e51a31d 100644 --- a/example/ios/Podfile +++ b/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +# platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index 9242e33..0f755c4 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -20,10 +20,10 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" SPEC CHECKSUMS: - Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e + shared_preferences_foundation: 4e65c567e7877037d328829a522222c938bf308c -PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 +PODFILE CHECKSUM: 4f1c12611da7338d21589c0b2ecd6bd20b109694 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index e82cf5e..86ff267 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -453,7 +453,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -580,7 +580,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -629,7 +629,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 8e3ca5d..e3773d4 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> diff --git a/example/macos/Podfile b/example/macos/Podfile index c795730..b52666a 100644 --- a/example/macos/Podfile +++ b/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/example/macos/Podfile.lock b/example/macos/Podfile.lock index 687eba4..205e9b0 100644 --- a/example/macos/Podfile.lock +++ b/example/macos/Podfile.lock @@ -15,9 +15,9 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin SPEC CHECKSUMS: - FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 - shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 + shared_preferences_foundation: 4e65c567e7877037d328829a522222c938bf308c -PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/macos/Runner.xcodeproj/project.pbxproj b/example/macos/Runner.xcodeproj/project.pbxproj index 1587ee1..c790b60 100644 --- a/example/macos/Runner.xcodeproj/project.pbxproj +++ b/example/macos/Runner.xcodeproj/project.pbxproj @@ -553,7 +553,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -632,7 +632,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -679,7 +679,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.14; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; diff --git a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 15368ec..ac78810 100644 --- a/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -59,6 +59,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/lib/src/concurrency/sequential_controller_handler.dart b/lib/src/concurrency/sequential_controller_handler.dart index 363e40e..fb5f97c 100644 --- a/lib/src/concurrency/sequential_controller_handler.dart +++ b/lib/src/concurrency/sequential_controller_handler.dart @@ -36,14 +36,8 @@ mixin SequentialControllerHandler on Controller { Future Function()? done, String? name, Map? meta, - }) => - _$mutex.synchronize( - () => super.handle( - handler, - error: error, - done: done, - name: name, - meta: meta, - ), - ); + }) => _$mutex.synchronize( + () => + super.handle(handler, error: error, done: done, name: name, meta: meta), + ); } diff --git a/lib/src/controller.dart b/lib/src/controller.dart index bfbfba9..ef55ae7 100644 --- a/lib/src/controller.dart +++ b/lib/src/controller.dart @@ -67,7 +67,10 @@ abstract interface class IControllerObserver { /// Called on any state change in the [StateController]. void onStateChanged( - StateController controller, S prevState, S nextState); + StateController controller, + S prevState, + S nextState, + ); /// Called on any error in the controller. void onError(Controller controller, Object error, StackTrace stackTrace); @@ -82,7 +85,9 @@ abstract class Controller with ChangeNotifier implements IController { Controller() { runZonedGuarded( () => Controller.observer?.onCreate(this), - (error, stackTrace) {/* ignore */}, // coverage:ignore-line + (error, stackTrace) { + /* ignore */ + }, // coverage:ignore-line ); } @@ -117,9 +122,11 @@ abstract class Controller with ChangeNotifier implements IController { /// Error handling callback @protected void onError(Object error, StackTrace stackTrace) => runZonedGuarded( - () => Controller.observer?.onError(this, error, stackTrace), - (error, stackTrace) {/* ignore */}, // coverage:ignore-line - ); + () => Controller.observer?.onError(this, error, stackTrace), + (error, stackTrace) { + /* ignore */ + }, // coverage:ignore-line + ); /// Handles a given operation with error handling and completion tracking. /// @@ -186,9 +193,7 @@ abstract class Controller with ChangeNotifier implements IController { controller: this, name: name ?? 'handler#${handler.runtimeType}', completer: completer, - meta: { - ...?meta, - }, + meta: {...?meta}, ); runZonedGuarded( @@ -211,9 +216,7 @@ abstract class Controller with ChangeNotifier implements IController { } }, handleZoneError, - zoneValues: { - HandlerContext.key: handlerContext, - }, + zoneValues: {HandlerContext.key: handlerContext}, ); return completer.future; @@ -260,7 +263,9 @@ abstract class Controller with ChangeNotifier implements IController { _$subscribers = 0; runZonedGuarded( () => Controller.observer?.onDispose(this), - (error, stackTrace) {/* ignore */}, // coverage:ignore-line + (error, stackTrace) { + /* ignore */ + }, // coverage:ignore-line ); super.dispose(); } diff --git a/lib/src/controller_scope.dart b/lib/src/controller_scope.dart index 0d3d190..e52231b 100644 --- a/lib/src/controller_scope.dart +++ b/lib/src/controller_scope.dart @@ -25,19 +25,16 @@ class ControllerScope extends InheritedWidget { Widget? child, bool lazy = true, super.key, - }) : _dependency = _ControllerDependency$Create( - create: create, - lazy: lazy, - ), - super(child: child ?? const SizedBox.shrink()); + }) : _dependency = _ControllerDependency$Create( + create: create, + lazy: lazy, + ), + super(child: child ?? const SizedBox.shrink()); /// {@macro controller_scope} - ControllerScope.value( - C controller, { - Widget? child, - super.key, - }) : _dependency = _ControllerDependency$Value(controller: controller), - super(child: child ?? const SizedBox.shrink()); + ControllerScope.value(C controller, {Widget? child, super.key}) + : _dependency = _ControllerDependency$Value(controller: controller), + super(child: child ?? const SizedBox.shrink()); final _ControllerDependency _dependency; @@ -48,17 +45,17 @@ class ControllerScope extends InheritedWidget { BuildContext context, { bool listen = false, }) { - final element = - context.getElementForInheritedWidgetOfExactType>(); + final element = context + .getElementForInheritedWidgetOfExactType>(); if (listen && element != null) context.dependOnInheritedElement(element); return element is ControllerScope$Element ? element.controller : null; } static Never _notFoundInheritedWidgetOfExactType() => throw ArgumentError( - 'Out of scope, not found inherited widget ' - 'a ControllerScope of the exact type', - 'out_of_scope', - ); + 'Out of scope, not found inherited widget ' + 'a ControllerScope of the exact type', + 'out_of_scope', + ); /// The state from the closest instance of this class /// that encloses the given context. @@ -89,10 +86,9 @@ final class ControllerScope$Element @override void debugFillProperties(DiagnosticPropertiesBuilder properties) => - super.debugFillProperties(_debugFillPropertiesBuilder( - _controller, - properties, - )); + super.debugFillProperties( + _debugFillPropertiesBuilder(_controller, properties), + ); @nonVirtual C? _controller; @@ -210,7 +206,8 @@ final class ControllerScope$Element _subscribed = false; // Dispose controller if it was created by this scope if (_dependency is _ControllerDependency$Create && - listenable is ChangeNotifier) listenable.dispose(); + listenable is ChangeNotifier) + listenable.dispose(); super.unmount(); } @@ -270,46 +267,55 @@ DiagnosticPropertiesBuilder _debugFillPropertiesBuilder( switch (controller) { case StateController sc: properties - ..add(DiagnosticsProperty>( - 'StateController', - sc, - )) + ..add( + DiagnosticsProperty>('StateController', sc), + ) ..add(StringProperty('State', sc.state.toString())) ..add(IntProperty('Subscribers', sc.subscribers)) - ..add(FlagProperty( - 'isDisposed', - value: sc.isDisposed, - ifTrue: 'Disposed', - ifFalse: 'Not disposed', - )) - ..add(FlagProperty( - 'isProcessing', - value: sc.isProcessing, - ifTrue: 'Processing', - ifFalse: 'Idle', - )); + ..add( + FlagProperty( + 'isDisposed', + value: sc.isDisposed, + ifTrue: 'Disposed', + ifFalse: 'Not disposed', + ), + ) + ..add( + FlagProperty( + 'isProcessing', + value: sc.isProcessing, + ifTrue: 'Processing', + ifFalse: 'Idle', + ), + ); case Controller c: properties ..add(DiagnosticsProperty.lazy('Controller', () => c)) ..add(IntProperty('Subscribers', c.subscribers)) - ..add(FlagProperty( - 'isDisposed', - value: c.isDisposed, - ifTrue: 'Disposed', - ifFalse: 'Not disposed', - )) - ..add(FlagProperty( - 'isProcessing', - value: c.isProcessing, - ifTrue: 'Processing', - ifFalse: 'Idle', - )); + ..add( + FlagProperty( + 'isDisposed', + value: c.isDisposed, + ifTrue: 'Disposed', + ifFalse: 'Not disposed', + ), + ) + ..add( + FlagProperty( + 'isProcessing', + value: c.isProcessing, + ifTrue: 'Processing', + ifFalse: 'Idle', + ), + ); case ValueListenable vl: properties - ..add(DiagnosticsProperty>.lazy( - 'ValueListenable', - () => vl, - )) + ..add( + DiagnosticsProperty>.lazy( + 'ValueListenable', + () => vl, + ), + ) ..add(StringProperty('Value', vl.value?.toString() ?? 'null')); case ChangeNotifier cn: properties.add(DiagnosticsProperty('ChangeNotifier', cn)); diff --git a/lib/src/handler_context.dart b/lib/src/handler_context.dart index d53ad99..a6231dc 100644 --- a/lib/src/handler_context.dart +++ b/lib/src/handler_context.dart @@ -10,9 +10,9 @@ abstract interface class HandlerContext { /// Get the handler's context from the current zone. static HandlerContext? zoned() => switch (Zone.current[HandlerContext.key]) { - HandlerContext context => context, - _ => null, - }; + HandlerContext context => context, + _ => null, + }; /// Controller that the handler is attached to. abstract final Controller controller; @@ -32,12 +32,12 @@ abstract interface class HandlerContext { @internal final class HandlerContextImpl implements HandlerContext { - HandlerContextImpl( - {required this.controller, - required this.name, - required this.meta, - required Completer completer}) - : _completer = completer; + HandlerContextImpl({ + required this.controller, + required this.name, + required this.meta, + required Completer completer, + }) : _completer = completer; @override final Controller controller; diff --git a/lib/src/state_consumer.dart b/lib/src/state_consumer.dart index 577e5e2..f1dc071 100644 --- a/lib/src/state_consumer.dart +++ b/lib/src/state_consumer.dart @@ -5,16 +5,16 @@ import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; /// Fire when the state changes. -typedef StateConsumerListener, S extends Object> - = void Function(BuildContext context, C controller, S previous, S current); +typedef StateConsumerListener, S extends Object> = + void Function(BuildContext context, C controller, S previous, S current); /// Build when the method returns true. -typedef StateConsumerCondition = bool Function( - S previous, S current); +typedef StateConsumerCondition = + bool Function(S previous, S current); /// Rebuild the widget when the state changes. -typedef StateConsumerBuilder = Widget Function( - BuildContext context, S state, Widget? child); +typedef StateConsumerBuilder = + Widget Function(BuildContext context, S state, Widget? child); /// {@template state_consumer} /// StateConsumer widget. @@ -80,7 +80,8 @@ class _StateConsumerState, S extends Object> final oldController = oldWidget.controller, newController = widget.controller; if (identical(oldController, newController) || - oldController == newController) return; + oldController == newController) + return; _unsubscribe(); _controller = newController ?? ControllerScope.of(context, listen: false); @@ -125,14 +126,21 @@ class _StateConsumerState, S extends Object> @override void debugFillProperties(DiagnosticPropertiesBuilder properties) => - super.debugFillProperties(properties - ..add( - DiagnosticsProperty>('Controller', _controller)) - ..add(DiagnosticsProperty('State', _controller.state)) - ..add(FlagProperty('isProcessing', - value: _controller.isProcessing, - ifTrue: 'Processing', - ifFalse: 'Idle'))); + super.debugFillProperties( + properties + ..add( + DiagnosticsProperty>('Controller', _controller), + ) + ..add(DiagnosticsProperty('State', _controller.state)) + ..add( + FlagProperty( + 'isProcessing', + value: _controller.isProcessing, + ifTrue: 'Processing', + ifFalse: 'Idle', + ), + ), + ); @override Widget build(BuildContext context) => diff --git a/lib/src/state_controller.dart b/lib/src/state_controller.dart index f4a9ab9..3687612 100644 --- a/lib/src/state_controller.dart +++ b/lib/src/state_controller.dart @@ -5,8 +5,8 @@ import 'package:control/src/handler_context.dart'; import 'package:flutter/foundation.dart'; /// Selector from [StateController] -typedef StateControllerSelector = Value Function( - S state); +typedef StateControllerSelector = + Value Function(S state); /// Filter for [StateController] typedef StateControllerFilter = bool Function(Value prev, Value next); @@ -42,7 +42,9 @@ abstract class StateController extends Controller void setState(S state) { runZonedGuarded( () => Controller.observer?.onStateChanged(this, _$state, state), - (error, stackTrace) {/* ignore */}, // coverage:ignore-line + (error, stackTrace) { + /* ignore */ + }, // coverage:ignore-line ); _$state = state; if (isDisposed) return; @@ -80,8 +82,7 @@ abstract class StateController extends Controller ValueListenable select( StateControllerSelector selector, [ StateControllerFilter? test, - ]) => - _StateController$ValueListenableSelect(this, selector, test); + ]) => _StateController$ValueListenableSelect(this, selector, test); @override void dispose() { diff --git a/test/unit/mutex_test.dart b/test/unit/mutex_test.dart index bc7ad97..f33a8d9 100644 --- a/test/unit/mutex_test.dart +++ b/test/unit/mutex_test.dart @@ -7,1126 +7,1131 @@ import 'package:control/control.dart'; import 'package:flutter_test/flutter_test.dart'; void main() => group('Mutex', () { - test('Creation', () { - expect(Mutex.new, returnsNormally); - expect( - Mutex(), - isA() - .having((m) => m.locked, 'locked', isFalse) - .having((m) => m.synchronize, 'synchronize', isA()) - .having((m) => m.lock, 'lock', isA()), - ); + test('Creation', () { + expect(Mutex.new, returnsNormally); + expect( + Mutex(), + isA() + .having((m) => m.locked, 'locked', isFalse) + .having((m) => m.synchronize, 'synchronize', isA()) + .having((m) => m.lock, 'lock', isA()), + ); + }); + + group('synchronize', () { + test('executes action', () async { + final mutex = Mutex(); + var executed = false; + + await mutex.synchronize(() async { + executed = true; }); - group('synchronize', () { - test('executes action', () async { - final mutex = Mutex(); - var executed = false; + expect(executed, isTrue); + expect(mutex.locked, isFalse); + }); - await mutex.synchronize(() async { - executed = true; - }); + test('returns action result', () async { + final mutex = Mutex(); - expect(executed, isTrue); - expect(mutex.locked, isFalse); - }); + final result = await mutex.synchronize(() async => 42); - test('returns action result', () async { - final mutex = Mutex(); + expect(result, equals(42)); + }); - final result = await mutex.synchronize(() async => 42); + test('serializes multiple calls', () async { + final mutex = Mutex(); + var counter = 0; + final results = []; + + final futures = List.generate( + 100, + (i) => mutex.synchronize(() async { + final value = counter; + await Future.delayed(Duration.zero); + counter = value + 1; + results.add(counter); + }), + ); + + await Future.wait(futures); + + expect(counter, equals(100)); + expect(results.length, equals(100)); + expect(mutex.locked, isFalse); + }); - expect(result, equals(42)); - }); + test('maintains FIFO order', () async { + final mutex = Mutex(); + final order = []; - test('serializes multiple calls', () async { - final mutex = Mutex(); - var counter = 0; - final results = []; - - final futures = List.generate( - 100, - (i) => mutex.synchronize(() async { - final value = counter; - await Future.delayed(Duration.zero); - counter = value + 1; - results.add(counter); - })); - - await Future.wait(futures); - - expect(counter, equals(100)); - expect(results.length, equals(100)); - expect(mutex.locked, isFalse); - }); + final futures = List.generate( + 10, + (i) => mutex.synchronize(() async { + await Future.delayed(const Duration(microseconds: 10)); + order.add(i); + }), + ); - test('maintains FIFO order', () async { - final mutex = Mutex(); - final order = []; + await Future.wait(futures); - final futures = List.generate( - 10, - (i) => mutex.synchronize(() async { - await Future.delayed( - const Duration(microseconds: 10)); - order.add(i); - })); + expect(order, equals([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])); + expect(mutex.locked, isFalse); + }); - await Future.wait(futures); + test('unlocks on exception', () async { + final mutex = Mutex(); - expect(order, equals([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])); - expect(mutex.locked, isFalse); - }); + expect( + () => mutex.synchronize(() async { + throw Exception('test error'); + }), + throwsException, + ); - test('unlocks on exception', () async { - final mutex = Mutex(); + await Future.delayed(const Duration(milliseconds: 10)); - expect( - () => mutex.synchronize(() async { - throw Exception('test error'); - }), - throwsException, - ); + expect(mutex.locked, isFalse); - await Future.delayed(const Duration(milliseconds: 10)); + // Should work after exception + var recovered = false; + await mutex.synchronize(() async { + recovered = true; + }); + expect(recovered, isTrue); + }); - expect(mutex.locked, isFalse); + test('propagates exception', () async { + final mutex = Mutex(); + final exception = Exception('test error'); - // Should work after exception - var recovered = false; - await mutex.synchronize(() async { - recovered = true; - }); - expect(recovered, isTrue); - }); + expect( + mutex.synchronize(() async => throw exception), + throwsA(equals(exception)), + ); + }); - test('propagates exception', () async { - final mutex = Mutex(); - final exception = Exception('test error'); + test('handles synchronous exceptions', () async { + final mutex = Mutex(); - expect( - mutex.synchronize(() async => throw exception), - throwsA(equals(exception)), - ); - }); + expect( + mutex.synchronize(() => throw StateError('sync error')), + throwsStateError, + ); - test('handles synchronous exceptions', () async { - final mutex = Mutex(); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isFalse); + }); - expect( - mutex.synchronize(() => throw StateError('sync error')), - throwsStateError, - ); + test('queues waiting tasks', () async { + final mutex = Mutex(); + final events = []; - await Future.delayed(const Duration(milliseconds: 10)); - expect(mutex.locked, isFalse); - }); + // Start first task + final future1 = mutex.synchronize(() async { + events.add('start-1'); + await Future.delayed(const Duration(milliseconds: 50)); + events.add('end-1'); + }); - test('queues waiting tasks', () async { - final mutex = Mutex(); - final events = []; + // Give it time to start + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isTrue); - // Start first task - final future1 = mutex.synchronize(() async { - events.add('start-1'); - await Future.delayed(const Duration(milliseconds: 50)); - events.add('end-1'); - }); + // Queue second task + final future2 = mutex.synchronize(() async { + events.add('start-2'); + await Future.delayed(const Duration(milliseconds: 10)); + events.add('end-2'); + }); - // Give it time to start - await Future.delayed(const Duration(milliseconds: 10)); - expect(mutex.locked, isTrue); + // Second task should be waiting + expect(mutex.locked, isTrue); - // Queue second task - final future2 = mutex.synchronize(() async { - events.add('start-2'); - await Future.delayed(const Duration(milliseconds: 10)); - events.add('end-2'); - }); + await Future.wait([future1, future2]); - // Second task should be waiting - expect(mutex.locked, isTrue); + expect(events, equals(['start-1', 'end-1', 'start-2', 'end-2'])); + expect(mutex.locked, isFalse); + }); - await Future.wait([future1, future2]); + test('handles nested synchronize', () async { + final mutex = Mutex(); + var counter = 0; - expect(events, equals(['start-1', 'end-1', 'start-2', 'end-2'])); - expect(mutex.locked, isFalse); - }); + await mutex.synchronize(() async { + counter++; + // This will deadlock, but that's expected behavior + // Just test single level + }); - test('handles nested synchronize', () async { - final mutex = Mutex(); - var counter = 0; + expect(counter, equals(1)); + expect(mutex.locked, isFalse); + }); - await mutex.synchronize(() async { - counter++; - // This will deadlock, but that's expected behavior - // Just test single level - }); + test('completes in order with different execution times', () async { + final mutex = Mutex(); + final completionOrder = []; - expect(counter, equals(1)); - expect(mutex.locked, isFalse); - }); + final futures = >[ + mutex.synchronize(() async { + await Future.delayed(const Duration(milliseconds: 30)); + completionOrder.add(1); + }), + mutex.synchronize(() async { + await Future.delayed(const Duration(milliseconds: 10)); + completionOrder.add(2); + }), + mutex.synchronize(() async { + await Future.delayed(const Duration(milliseconds: 20)); + completionOrder.add(3); + }), + ]; + + await Future.wait(futures); + + expect(completionOrder, equals([1, 2, 3])); + expect(mutex.locked, isFalse); + }); + }); - test('completes in order with different execution times', () async { - final mutex = Mutex(); - final completionOrder = []; + group('lock', () { + test('returns unlock function', () async { + final mutex = Mutex(); - final futures = >[ - mutex.synchronize(() async { - await Future.delayed(const Duration(milliseconds: 30)); - completionOrder.add(1); - }), - mutex.synchronize(() async { - await Future.delayed(const Duration(milliseconds: 10)); - completionOrder.add(2); - }), - mutex.synchronize(() async { - await Future.delayed(const Duration(milliseconds: 20)); - completionOrder.add(3); - }), - ]; + final unlock = await mutex.lock(); - await Future.wait(futures); + expect(unlock, isA()); + expect(mutex.locked, isTrue); - expect(completionOrder, equals([1, 2, 3])); - expect(mutex.locked, isFalse); - }); - }); + unlock(); + expect(mutex.locked, isFalse); + }); - group('lock', () { - test('returns unlock function', () async { - final mutex = Mutex(); + test('allows manual lock/unlock', () async { + final mutex = Mutex(); + var counter = 0; + + final unlock = await mutex.lock(); + try { + counter++; + await Future.delayed(const Duration(milliseconds: 10)); + counter++; + } finally { + unlock(); + } + + expect(counter, equals(2)); + expect(mutex.locked, isFalse); + }); - final unlock = await mutex.lock(); + test('serializes multiple locks', () async { + final mutex = Mutex(); + var counter = 0; + + final futures = List.generate(100, (i) async { + final unlock = await mutex.lock(); + try { + final value = counter; + await Future.delayed(Duration.zero); + counter = value + 1; + } finally { + unlock(); + } + }); - expect(unlock, isA()); - expect(mutex.locked, isTrue); + await Future.wait(futures); - unlock(); - expect(mutex.locked, isFalse); - }); + expect(counter, equals(100)); + expect(mutex.locked, isFalse); + }); - test('allows manual lock/unlock', () async { - final mutex = Mutex(); - var counter = 0; + test('unlock is idempotent', () async { + final mutex = Mutex(); - final unlock = await mutex.lock(); - try { - counter++; - await Future.delayed(const Duration(milliseconds: 10)); - counter++; - } finally { - unlock(); - } + final unlock = await mutex.lock(); + expect(mutex.locked, isTrue); - expect(counter, equals(2)); - expect(mutex.locked, isFalse); - }); + unlock(); + expect(mutex.locked, isFalse); - test('serializes multiple locks', () async { - final mutex = Mutex(); - var counter = 0; + unlock(); // Second call + expect(mutex.locked, isFalse); - final futures = List.generate(100, (i) async { - final unlock = await mutex.lock(); - try { - final value = counter; - await Future.delayed(Duration.zero); - counter = value + 1; - } finally { - unlock(); - } - }); + unlock(); // Third call + expect(mutex.locked, isFalse); - await Future.wait(futures); + // Should still work + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); - expect(counter, equals(100)); - expect(mutex.locked, isFalse); - }); + test('maintains FIFO order', () async { + final mutex = Mutex(); + final lockOrder = []; - test('unlock is idempotent', () async { - final mutex = Mutex(); + final unlockA = await mutex.lock(); + lockOrder.add('A'); - final unlock = await mutex.lock(); - expect(mutex.locked, isTrue); + final futureB = () async { + final unlock = await mutex.lock(); + lockOrder.add('B'); + await Future.delayed(const Duration(microseconds: 10)); + unlock(); + }(); - unlock(); - expect(mutex.locked, isFalse); + final futureC = () async { + final unlock = await mutex.lock(); + lockOrder.add('C'); + await Future.delayed(const Duration(microseconds: 10)); + unlock(); + }(); - unlock(); // Second call - expect(mutex.locked, isFalse); + await Future.delayed(const Duration(milliseconds: 10)); - unlock(); // Third call - expect(mutex.locked, isFalse); + unlockA(); - // Should still work - await mutex.synchronize(() async {}); - expect(mutex.locked, isFalse); - }); + await Future.wait([futureB, futureC]); - test('maintains FIFO order', () async { - final mutex = Mutex(); - final lockOrder = []; + expect(lockOrder, equals(['A', 'B', 'C'])); + expect(mutex.locked, isFalse); + }); - final unlockA = await mutex.lock(); - lockOrder.add('A'); + test('handles exception with finally unlock', () async { + final mutex = Mutex(); - final futureB = () async { - final unlock = await mutex.lock(); - lockOrder.add('B'); - await Future.delayed(const Duration(microseconds: 10)); - unlock(); - }(); + try { + final unlock = await mutex.lock(); + try { + throw Exception('test error'); + } finally { + unlock(); + } + } on Object catch (_) { + // Expected + } + + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isFalse); + + // Should work after exception + final unlock = await mutex.lock(); + expect(mutex.locked, isTrue); + unlock(); + }); - final futureC = () async { - final unlock = await mutex.lock(); - lockOrder.add('C'); - await Future.delayed(const Duration(microseconds: 10)); - unlock(); - }(); + test('works after forgotten unlock', () async { + final mutex = Mutex(); - await Future.delayed(const Duration(milliseconds: 10)); + // Forget to unlock (bad practice, but should handle) + await mutex.lock(); + expect(mutex.locked, isTrue); - unlockA(); + // New lock should wait + var acquired = false; + unawaited( + mutex.lock().then((unlock) { + acquired = true; + unlock(); + }), + ); - await Future.wait([futureB, futureC]); + await Future.delayed(const Duration(milliseconds: 10)); + expect(acquired, isFalse); // Still waiting - expect(lockOrder, equals(['A', 'B', 'C'])); - expect(mutex.locked, isFalse); - }); + // This would hang forever in real code + // In tests we just verify the behavior + }); - test('handles exception with finally unlock', () async { - final mutex = Mutex(); + test('unlock after lock allows immediate re-lock', () async { + final mutex = Mutex(); + final unlock1 = await mutex.lock(); + unlock1(); + + final unlock2 = await mutex.lock(); + expect(mutex.locked, isTrue); + unlock2(); + expect(mutex.locked, isFalse); + }); + }); + + group('mixed lock and synchronize', () { + test('interleaves correctly', () async { + final mutex = Mutex(); + final order = []; + + final futures = >[ + mutex.synchronize(() async { + order.add('sync-1'); + await Future.delayed(const Duration(microseconds: 10)); + }), + () async { + final unlock = await mutex.lock(); try { - final unlock = await mutex.lock(); - try { - throw Exception('test error'); - } finally { - unlock(); - } - } on Object catch (_) { - // Expected + order.add('lock-1'); + await Future.delayed(const Duration(microseconds: 10)); + } finally { + unlock(); } - - await Future.delayed(const Duration(milliseconds: 10)); - expect(mutex.locked, isFalse); - - // Should work after exception + }(), + mutex.synchronize(() async { + order.add('sync-2'); + await Future.delayed(const Duration(microseconds: 10)); + }), + () async { final unlock = await mutex.lock(); - expect(mutex.locked, isTrue); - unlock(); - }); - - test('works after forgotten unlock', () async { - final mutex = Mutex(); - - // Forget to unlock (bad practice, but should handle) - await mutex.lock(); - expect(mutex.locked, isTrue); - - // New lock should wait - var acquired = false; - unawaited(mutex.lock().then((unlock) { - acquired = true; + try { + order.add('lock-2'); + await Future.delayed(const Duration(microseconds: 10)); + } finally { unlock(); - })); - - await Future.delayed(const Duration(milliseconds: 10)); - expect(acquired, isFalse); // Still waiting + } + }(), + ]; - // This would hang forever in real code - // In tests we just verify the behavior - }); + await Future.wait(futures); - test('unlock after lock allows immediate re-lock', () async { - final mutex = Mutex(); + expect(order, equals(['sync-1', 'lock-1', 'sync-2', 'lock-2'])); + expect(mutex.locked, isFalse); + }); - final unlock1 = await mutex.lock(); - unlock1(); + test('maintains single execution guarantee', () async { + final mutex = Mutex(); + var concurrent = 0; + var maxConcurrent = 0; - final unlock2 = await mutex.lock(); - expect(mutex.locked, isTrue); - unlock2(); - expect(mutex.locked, isFalse); - }); - }); + final futures = >[]; - group('mixed lock and synchronize', () { - test('interleaves correctly', () async { - final mutex = Mutex(); - final order = []; - - final futures = >[ + for (var i = 0; i < 50; i++) { + if (i % 2 == 0) { + futures.add( mutex.synchronize(() async { - order.add('sync-1'); + concurrent++; + maxConcurrent = maxConcurrent > concurrent + ? maxConcurrent + : concurrent; await Future.delayed(const Duration(microseconds: 10)); + concurrent--; }), - () async { - final unlock = await mutex.lock(); - try { - order.add('lock-1'); - await Future.delayed(const Duration(microseconds: 10)); - } finally { - unlock(); - } - }(), - mutex.synchronize(() async { - order.add('sync-2'); + ); + } else { + futures.add(() async { + final unlock = await mutex.lock(); + try { + concurrent++; + maxConcurrent = maxConcurrent > concurrent + ? maxConcurrent + : concurrent; await Future.delayed(const Duration(microseconds: 10)); - }), - () async { - final unlock = await mutex.lock(); - try { - order.add('lock-2'); - await Future.delayed(const Duration(microseconds: 10)); - } finally { - unlock(); - } - }(), - ]; - - await Future.wait(futures); - - expect(order, equals(['sync-1', 'lock-1', 'sync-2', 'lock-2'])); - expect(mutex.locked, isFalse); - }); - - test('maintains single execution guarantee', () async { - final mutex = Mutex(); - var concurrent = 0; - var maxConcurrent = 0; - - final futures = >[]; - - for (var i = 0; i < 50; i++) { - if (i % 2 == 0) { - futures.add(mutex.synchronize(() async { - concurrent++; - maxConcurrent = - maxConcurrent > concurrent ? maxConcurrent : concurrent; - await Future.delayed(const Duration(microseconds: 10)); - concurrent--; - })); - } else { - futures.add(() async { - final unlock = await mutex.lock(); - try { - concurrent++; - maxConcurrent = - maxConcurrent > concurrent ? maxConcurrent : concurrent; - await Future.delayed(const Duration(microseconds: 10)); - concurrent--; - } finally { - unlock(); - } - }()); + concurrent--; + } finally { + unlock(); } - } + }()); + } + } - await Future.wait(futures); + await Future.wait(futures); - expect(maxConcurrent, equals(1)); - expect(concurrent, equals(0)); - expect(mutex.locked, isFalse); - }); - }); + expect(maxConcurrent, equals(1)); + expect(concurrent, equals(0)); + expect(mutex.locked, isFalse); + }); + }); - group('edge cases', () { - test('handles immediate completion', () async { - final mutex = Mutex(); + group('edge cases', () { + test('handles immediate completion', () async { + final mutex = Mutex(); - await mutex.synchronize(() async {}); + await mutex.synchronize(() async {}); - expect(mutex.locked, isFalse); - }); + expect(mutex.locked, isFalse); + }); - test('handles synchronous action', () async { - final mutex = Mutex(); - var value = 0; + test('handles synchronous action', () async { + final mutex = Mutex(); + var value = 0; - await mutex.synchronize(() => Future.value(42)); - value = await mutex.synchronize(() => Future.value(100)); + await mutex.synchronize(() => Future.value(42)); + value = await mutex.synchronize(() => Future.value(100)); - expect(value, equals(100)); - expect(mutex.locked, isFalse); - }); + expect(value, equals(100)); + expect(mutex.locked, isFalse); + }); - test('handles multiple rapid synchronizations', () async { - final mutex = Mutex(); - final results = []; + test('handles multiple rapid synchronizations', () async { + final mutex = Mutex(); + final results = []; - for (var i = 0; i < 1000; i++) { - unawaited(mutex.synchronize(() async { - results.add(i); - })); - } + for (var i = 0; i < 1000; i++) { + unawaited( + mutex.synchronize(() async { + results.add(i); + }), + ); + } - // Wait for all to complete - await Future.delayed(const Duration(milliseconds: 100)); - await mutex.synchronize(() async {}); // Barrier + // Wait for all to complete + await Future.delayed(const Duration(milliseconds: 100)); + await mutex.synchronize(() async {}); // Barrier - expect(results.length, equals(1000)); - expect(mutex.locked, isFalse); - }); + expect(results.length, equals(1000)); + expect(mutex.locked, isFalse); + }); - test('handles zero delay', () async { - final mutex = Mutex(); - var counter = 0; + test('handles zero delay', () async { + final mutex = Mutex(); + var counter = 0; - final futures = List.generate( - 100, - (i) => mutex.synchronize(() async { - await Future.delayed(Duration.zero); - counter++; - })); + final futures = List.generate( + 100, + (i) => mutex.synchronize(() async { + await Future.delayed(Duration.zero); + counter++; + }), + ); - await Future.wait(futures); + await Future.wait(futures); - expect(counter, equals(100)); - expect(mutex.locked, isFalse); - }); + expect(counter, equals(100)); + expect(mutex.locked, isFalse); + }); - test('expectLater locked status during execution', () async { - final mutex = Mutex(); + test('expectLater locked status during execution', () async { + final mutex = Mutex(); - final future = mutex.synchronize(() async { - await Future.delayed(const Duration(milliseconds: 50)); - }); + final future = mutex.synchronize(() async { + await Future.delayed(const Duration(milliseconds: 50)); + }); - await Future.delayed(const Duration(milliseconds: 10)); - await expectLater(mutex.locked, isTrue); + await Future.delayed(const Duration(milliseconds: 10)); + await expectLater(mutex.locked, isTrue); - await future; - await expectLater(mutex.locked, isFalse); - }); + await future; + await expectLater(mutex.locked, isFalse); + }); - test('expectLater sequential execution', () async { - final mutex = Mutex(); - final stream = StreamController(); - - mutex - ..synchronize(() async { - stream.add(1); - await Future.delayed(const Duration(milliseconds: 20)); - stream.add(2); - }).ignore() - ..synchronize(() async { - stream.add(3); - await Future.delayed(const Duration(milliseconds: 20)); - stream.add(4); - }).ignore(); - - await expectLater( - stream.stream, - emitsInOrder([1, 2, 3, 4]), - ); + test('expectLater sequential execution', () async { + final mutex = Mutex(); + final stream = StreamController(); + + mutex + ..synchronize(() async { + stream.add(1); + await Future.delayed(const Duration(milliseconds: 20)); + stream.add(2); + }).ignore() + ..synchronize(() async { + stream.add(3); + await Future.delayed(const Duration(milliseconds: 20)); + stream.add(4); + }).ignore(); + + await expectLater(stream.stream, emitsInOrder([1, 2, 3, 4])); + + await stream.close(); + }); - await stream.close(); - }); + test('handles null result', () async { + final mutex = Mutex(); - test('handles null result', () async { - final mutex = Mutex(); + final result = await mutex.synchronize(() async => null); - final result = await mutex.synchronize(() async => null); + expect(result, isNull); + }); - expect(result, isNull); - }); + test('preserves generic type', () async { + final mutex = Mutex(); - test('preserves generic type', () async { - final mutex = Mutex(); + final stringResult = await mutex.synchronize(() async => 'test'); + final intResult = await mutex.synchronize(() async => 42); + final boolResult = await mutex.synchronize(() async => true); - final stringResult = - await mutex.synchronize(() async => 'test'); - final intResult = await mutex.synchronize(() async => 42); - final boolResult = await mutex.synchronize(() async => true); + expect(stringResult, isA()); + expect(intResult, isA()); + expect(boolResult, isA()); + }); - expect(stringResult, isA()); - expect(intResult, isA()); - expect(boolResult, isA()); - }); + test('completes with void result', () async { + final mutex = Mutex(); - test('completes with void result', () async { - final mutex = Mutex(); + await mutex.synchronize(() async {}); - await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); - expect(mutex.locked, isFalse); - }); + test('survives rapid lock/unlock cycles', () async { + final mutex = Mutex(); - test('survives rapid lock/unlock cycles', () async { - final mutex = Mutex(); + for (var i = 0; i < 100; i++) { + final unlock = await mutex.lock(); + unlock(); + } - for (var i = 0; i < 100; i++) { - final unlock = await mutex.lock(); - unlock(); - } + expect(mutex.locked, isFalse); - expect(mutex.locked, isFalse); + // Should still work + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); + }); + + group('stress tests', () { + test('handles 1000 concurrent operations', () async { + final mutex = Mutex(); + var counter = 0; + + final futures = List.generate( + 1000, + (i) => mutex.synchronize(() async { + final value = counter; + await Future.delayed(Duration.zero); + counter = value + 1; + }), + ); + + await Future.wait(futures); + + expect(counter, equals(1000)); + expect(mutex.locked, isFalse); + }); - // Should still work - await mutex.synchronize(() async {}); - expect(mutex.locked, isFalse); - }); - }); + test('handles mixed operations under load', () async { + final mutex = Mutex(); + var syncCounter = 0; + var lockCounter = 0; - group('stress tests', () { - test('handles 1000 concurrent operations', () async { - final mutex = Mutex(); - var counter = 0; + final futures = List.generate(500, (i) { + if (i % 2 == 0) { + return mutex.synchronize(() async { + await Future.delayed(Duration.zero); + syncCounter++; + }); + } else { + return () async { + final unlock = await mutex.lock(); + try { + await Future.delayed(Duration.zero); + lockCounter++; + } finally { + unlock(); + } + }(); + } + }); - final futures = List.generate( - 1000, - (i) => mutex.synchronize(() async { - final value = counter; - await Future.delayed(Duration.zero); - counter = value + 1; - })); + await Future.wait(futures); - await Future.wait(futures); + expect(syncCounter, equals(250)); + expect(lockCounter, equals(250)); + expect(mutex.locked, isFalse); + }); - expect(counter, equals(1000)); - expect(mutex.locked, isFalse); - }); + test('handles rapid lock/unlock without delays', () async { + final mutex = Mutex(); + var counter = 0; - test('handles mixed operations under load', () async { - final mutex = Mutex(); - var syncCounter = 0; - var lockCounter = 0; - - final futures = List.generate(500, (i) { - if (i % 2 == 0) { - return mutex.synchronize(() async { - await Future.delayed(Duration.zero); - syncCounter++; - }); - } else { - return () async { - final unlock = await mutex.lock(); - try { - await Future.delayed(Duration.zero); - lockCounter++; - } finally { - unlock(); - } - }(); - } - }); + final futures = List.generate(200, (i) async { + final unlock = await mutex.lock(); + counter++; + unlock(); + }); - await Future.wait(futures); + await Future.wait(futures); - expect(syncCounter, equals(250)); - expect(lockCounter, equals(250)); - expect(mutex.locked, isFalse); - }); + expect(counter, equals(200)); + expect(mutex.locked, isFalse); + }); - test('handles rapid lock/unlock without delays', () async { - final mutex = Mutex(); - var counter = 0; + test('handles alternating sync and lock patterns', () async { + final mutex = Mutex(); + final pattern = []; - final futures = List.generate(200, (i) async { + final futures = >[]; + for (var i = 0; i < 100; i++) { + if (i % 3 == 0) { + futures.add( + mutex.synchronize(() async { + pattern.add('S'); + }), + ); + } else if (i % 3 == 1) { + futures.add(() async { final unlock = await mutex.lock(); - counter++; + pattern.add('L'); unlock(); - }); - - await Future.wait(futures); - - expect(counter, equals(200)); - expect(mutex.locked, isFalse); - }); - - test('handles alternating sync and lock patterns', () async { - final mutex = Mutex(); - final pattern = []; - - final futures = >[]; - for (var i = 0; i < 100; i++) { - if (i % 3 == 0) { - futures.add(mutex.synchronize(() async { - pattern.add('S'); - })); - } else if (i % 3 == 1) { - futures.add(() async { - final unlock = await mutex.lock(); - pattern.add('L'); - unlock(); - }()); - } else { - futures.add(mutex.synchronize(() async { - await Future.delayed(Duration.zero); - pattern.add('S'); - })); - } - } + }()); + } else { + futures.add( + mutex.synchronize(() async { + await Future.delayed(Duration.zero); + pattern.add('S'); + }), + ); + } + } - await Future.wait(futures); + await Future.wait(futures); - expect(pattern.length, equals(100)); - expect(mutex.locked, isFalse); - }); + expect(pattern.length, equals(100)); + expect(mutex.locked, isFalse); + }); - test('concurrent stress with exceptions', () async { - final mutex = Mutex(); - var successCount = 0; - var errorCount = 0; + test('concurrent stress with exceptions', () async { + final mutex = Mutex(); + var successCount = 0; + var errorCount = 0; - final futures = List.generate(100, (i) async { - try { - await mutex.synchronize(() async { - if (i % 10 == 0) { - throw Exception('Intentional error $i'); - } - successCount++; - }); - } on Object catch (_) { - errorCount++; + final futures = List.generate(100, (i) async { + try { + await mutex.synchronize(() async { + if (i % 10 == 0) { + throw Exception('Intentional error $i'); } + successCount++; }); + } on Object catch (_) { + errorCount++; + } + }); - await Future.wait(futures); + await Future.wait(futures); - expect(successCount, equals(90)); - expect(errorCount, equals(10)); - expect(mutex.locked, isFalse); - }); - }); + expect(successCount, equals(90)); + expect(errorCount, equals(10)); + expect(mutex.locked, isFalse); + }); + }); - group('timeout and cancellation', () { - test('handles timeout on synchronize', () async { - final mutex = Mutex(); + group('timeout and cancellation', () { + test('handles timeout on synchronize', () async { + final mutex = Mutex(); - // Lock mutex - final unlock = await mutex.lock(); + // Lock mutex + final unlock = await mutex.lock(); - var timedOut = false; - try { - await mutex - .synchronize(() async => 42) - .timeout(const Duration(milliseconds: 50)); - } on TimeoutException { - timedOut = true; - } + var timedOut = false; + try { + await mutex + .synchronize(() async => 42) + .timeout(const Duration(milliseconds: 50)); + } on TimeoutException { + timedOut = true; + } - expect(timedOut, isTrue); + expect(timedOut, isTrue); - // Unlock and verify recovery - unlock(); - await Future.delayed(const Duration(milliseconds: 10)); + // Unlock and verify recovery + unlock(); + await Future.delayed(const Duration(milliseconds: 10)); - final result = await mutex.synchronize(() async => 100); - expect(result, equals(100)); - expect(mutex.locked, isFalse); - }); + final result = await mutex.synchronize(() async => 100); + expect(result, equals(100)); + expect(mutex.locked, isFalse); + }); - test('handles multiple timeouts', () async { - final mutex = Mutex(); + test('handles multiple timeouts', () async { + final mutex = Mutex(); - final unlock = await mutex.lock(); + final unlock = await mutex.lock(); - var timeoutCount = 0; - for (var i = 0; i < 5; i++) { - try { - await mutex - .synchronize(() async => i) - .timeout(const Duration(milliseconds: 50)); - } on TimeoutException { - timeoutCount++; - } - } + var timeoutCount = 0; + for (var i = 0; i < 5; i++) { + try { + await mutex + .synchronize(() async => i) + .timeout(const Duration(milliseconds: 50)); + } on TimeoutException { + timeoutCount++; + } + } - expect(timeoutCount, equals(5)); + expect(timeoutCount, equals(5)); - unlock(); + unlock(); - // Should still work after timeouts - await mutex.synchronize(() async {}); - expect(mutex.locked, isFalse); - }); - }); + // Should still work after timeouts + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); + }); - group('complex scenarios', () { - test('producer-consumer pattern', () async { - final mutex = Mutex(); - final queue = []; - var produced = 0; - - // Producer - final producer = Future(() async { - for (var i = 0; i < 50; i++) { - await mutex.synchronize(() async { - queue.add(i); - produced++; - }); - } + group('complex scenarios', () { + test('producer-consumer pattern', () async { + final mutex = Mutex(); + final queue = []; + var produced = 0; + + // Producer + final producer = Future(() async { + for (var i = 0; i < 50; i++) { + await mutex.synchronize(() async { + queue.add(i); + produced++; }); + } + }); - // Consumer - final consumer = Future(() async { - for (var i = 0; i < 50; i++) { - await mutex.synchronize(() async { - if (queue.isNotEmpty) { - queue.removeAt(0); - } - }); - await Future.delayed(Duration.zero); + // Consumer + final consumer = Future(() async { + for (var i = 0; i < 50; i++) { + await mutex.synchronize(() async { + if (queue.isNotEmpty) { + queue.removeAt(0); } }); + await Future.delayed(Duration.zero); + } + }); - await Future.wait([producer, consumer]); - - expect(produced, equals(50)); - expect(mutex.locked, isFalse); - }); - - test('reader-writer simulation with single mutex', () async { - final mutex = Mutex(); - var data = 0; - final readValues = []; + await Future.wait([producer, consumer]); - final writers = List.generate( - 10, - (i) => mutex.synchronize(() async { - await Future.delayed(const Duration(microseconds: 10)); - data++; - }), - ); + expect(produced, equals(50)); + expect(mutex.locked, isFalse); + }); - final readers = List.generate( - 20, - (i) => mutex.synchronize(() async { - await Future.delayed(const Duration(microseconds: 5)); - readValues.add(data); - }), - ); + test('reader-writer simulation with single mutex', () async { + final mutex = Mutex(); + var data = 0; + final readValues = []; + + final writers = List.generate( + 10, + (i) => mutex.synchronize(() async { + await Future.delayed(const Duration(microseconds: 10)); + data++; + }), + ); + + final readers = List.generate( + 20, + (i) => mutex.synchronize(() async { + await Future.delayed(const Duration(microseconds: 5)); + readValues.add(data); + }), + ); + + await Future.wait([...writers, ...readers]); + + expect(data, equals(10)); + expect(readValues.length, equals(20)); + expect(mutex.locked, isFalse); + }); - await Future.wait([...writers, ...readers]); + test('cascading lock acquisitions', () async { + final mutex = Mutex(); + final order = []; - expect(data, equals(10)); - expect(readValues.length, equals(20)); - expect(mutex.locked, isFalse); + Future cascade(int depth) async { + if (depth <= 0) return; + await mutex.synchronize(() async { + order.add(depth); + await Future.delayed(const Duration(microseconds: 10)); }); + unawaited(cascade(depth - 1)); + } - test('cascading lock acquisitions', () async { - final mutex = Mutex(); - final order = []; + await cascade(10); + await Future.delayed(const Duration(milliseconds: 100)); - Future cascade(int depth) async { - if (depth <= 0) return; - await mutex.synchronize(() async { - order.add(depth); - await Future.delayed(const Duration(microseconds: 10)); - }); - unawaited(cascade(depth - 1)); + expect(order.length, equals(10)); + expect(mutex.locked, isFalse); + }); + + test('mutex with conditional logic', () async { + final mutex = Mutex(); + var counter = 0; + + final futures = List.generate( + 100, + (i) => mutex.synchronize(() async { + if (counter % 2 == 0) { + counter += 2; + } else { + counter += 1; } + await Future.delayed(Duration.zero); + }), + ); - await cascade(10); - await Future.delayed(const Duration(milliseconds: 100)); + await Future.wait(futures); - expect(order.length, equals(10)); - expect(mutex.locked, isFalse); - }); + expect(counter, greaterThan(0)); + expect(mutex.locked, isFalse); + }); - test('mutex with conditional logic', () async { - final mutex = Mutex(); - var counter = 0; - - final futures = List.generate( - 100, - (i) => mutex.synchronize(() async { - if (counter % 2 == 0) { - counter += 2; - } else { - counter += 1; - } - await Future.delayed(Duration.zero); - })); - - await Future.wait(futures); - - expect(counter, greaterThan(0)); - expect(mutex.locked, isFalse); + test('batch processing pattern', () async { + final mutex = Mutex(); + final batches = >[]; + final items = List.generate(100, (i) => i); + + const batchSize = 10; + for (var i = 0; i < items.length; i += batchSize) { + await mutex.synchronize(() async { + final end = (i + batchSize).clamp(0, items.length); + batches.add(items.sublist(i, end)); + await Future.delayed(const Duration(microseconds: 10)); }); + } - test('batch processing pattern', () async { - final mutex = Mutex(); - final batches = >[]; - final items = List.generate(100, (i) => i); - - const batchSize = 10; - for (var i = 0; i < items.length; i += batchSize) { - await mutex.synchronize(() async { - final end = (i + batchSize).clamp(0, items.length); - batches.add(items.sublist(i, end)); - await Future.delayed(const Duration(microseconds: 10)); - }); - } + expect(batches.length, equals(10)); + expect(batches.expand((b) => b).length, equals(100)); + expect(mutex.locked, isFalse); + }); - expect(batches.length, equals(10)); - expect(batches.expand((b) => b).length, equals(100)); - expect(mutex.locked, isFalse); - }); + test('interleaved fast and slow operations', () async { + final mutex = Mutex(); + var fastCount = 0; + var slowCount = 0; - test('interleaved fast and slow operations', () async { - final mutex = Mutex(); - var fastCount = 0; - var slowCount = 0; - - final futures = List.generate(50, (i) { - if (i % 2 == 0) { - // Fast operation - return mutex.synchronize(() async { - fastCount++; - }); - } else { - // Slow operation - return mutex.synchronize(() async { - await Future.delayed(const Duration(microseconds: 50)); - slowCount++; - }); - } + final futures = List.generate(50, (i) { + if (i % 2 == 0) { + // Fast operation + return mutex.synchronize(() async { + fastCount++; }); + } else { + // Slow operation + return mutex.synchronize(() async { + await Future.delayed(const Duration(microseconds: 50)); + slowCount++; + }); + } + }); - await Future.wait(futures); - - expect(fastCount, equals(25)); - expect(slowCount, equals(25)); - expect(mutex.locked, isFalse); - }); + await Future.wait(futures); - test('lock acquisition with early completion', () async { - final mutex = Mutex(); - final completions = []; + expect(fastCount, equals(25)); + expect(slowCount, equals(25)); + expect(mutex.locked, isFalse); + }); - final future1 = mutex.synchronize(() async { - completions - ..add('start-1') - ..add('end-1'); - }); + test('lock acquisition with early completion', () async { + final mutex = Mutex(); + final completions = []; - final future2 = mutex.synchronize(() async { - completions.add('start-2'); - await Future.delayed(const Duration(milliseconds: 20)); - completions.add('end-2'); - }); + final future1 = mutex.synchronize(() async { + completions + ..add('start-1') + ..add('end-1'); + }); - final future3 = mutex.synchronize(() async { - completions - ..add('start-3') - ..add('end-3'); - }); + final future2 = mutex.synchronize(() async { + completions.add('start-2'); + await Future.delayed(const Duration(milliseconds: 20)); + completions.add('end-2'); + }); - await Future.wait([future1, future2, future3]); - - expect( - completions, - equals([ - 'start-1', - 'end-1', - 'start-2', - 'end-2', - 'start-3', - 'end-3', - ]), - ); - expect(mutex.locked, isFalse); - }); + final future3 = mutex.synchronize(() async { + completions + ..add('start-3') + ..add('end-3'); + }); - test('multiple unlocks from different contexts', () async { - final mutex = Mutex(); - final unlocks = []; + await Future.wait([future1, future2, future3]); - for (var i = 0; i < 5; i++) { - final unlock = await mutex.lock(); - unlocks.add(unlock); - expect(mutex.locked, isTrue); - unlock(); - expect(mutex.locked, isFalse); - } + expect( + completions, + equals(['start-1', 'end-1', 'start-2', 'end-2', 'start-3', 'end-3']), + ); + expect(mutex.locked, isFalse); + }); - // Call old unlocks (should be safe) - for (final unlock in unlocks) { - unlock(); - } + test('multiple unlocks from different contexts', () async { + final mutex = Mutex(); + final unlocks = []; - expect(mutex.locked, isFalse); + for (var i = 0; i < 5; i++) { + final unlock = await mutex.lock(); + unlocks.add(unlock); + expect(mutex.locked, isTrue); + unlock(); + expect(mutex.locked, isFalse); + } - // Should still work - await mutex.synchronize(() async {}); - expect(mutex.locked, isFalse); - }); - }); + // Call old unlocks (should be safe) + for (final unlock in unlocks) { + unlock(); + } - group('state verification', () { - test('locked state transitions', () async { - final mutex = Mutex(); - final states = [mutex.locked]; // false + expect(mutex.locked, isFalse); - final unlock = await mutex.lock(); - states.add(mutex.locked); // true + // Should still work + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); + }); - unlock(); - states.add(mutex.locked); // false + group('state verification', () { + test('locked state transitions', () async { + final mutex = Mutex(); + final states = [mutex.locked]; // false - await mutex.synchronize(() async { - states.add(mutex.locked); // true (during execution) - }); - states.add(mutex.locked); // false + final unlock = await mutex.lock(); + states.add(mutex.locked); // true - expect(states, equals([false, true, false, true, false])); - }); + unlock(); + states.add(mutex.locked); // false - test('locked during nested operations', () async { - final mutex = Mutex(); + await mutex.synchronize(() async { + states.add(mutex.locked); // true (during execution) + }); + states.add(mutex.locked); // false - await mutex.synchronize(() async { - expect(mutex.locked, isTrue); - await Future.delayed(const Duration(milliseconds: 10)); - expect(mutex.locked, isTrue); - }); + expect(states, equals([false, true, false, true, false])); + }); - expect(mutex.locked, isFalse); - }); + test('locked during nested operations', () async { + final mutex = Mutex(); - test('locked with concurrent waiters', () async { - final mutex = Mutex(); + await mutex.synchronize(() async { + expect(mutex.locked, isTrue); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isTrue); + }); - final unlock1 = await mutex.lock(); - expect(mutex.locked, isTrue); + expect(mutex.locked, isFalse); + }); - final future2 = mutex.lock(); - final future3 = mutex.lock(); + test('locked with concurrent waiters', () async { + final mutex = Mutex(); - await Future.delayed(const Duration(milliseconds: 10)); - expect(mutex.locked, isTrue); + final unlock1 = await mutex.lock(); + expect(mutex.locked, isTrue); - unlock1(); - final unlock2 = await future2; - expect(mutex.locked, isTrue); + final future2 = mutex.lock(); + final future3 = mutex.lock(); - unlock2(); - final unlock3 = await future3; - expect(mutex.locked, isTrue); + await Future.delayed(const Duration(milliseconds: 10)); + expect(mutex.locked, isTrue); - unlock3(); - expect(mutex.locked, isFalse); - }); + unlock1(); + final unlock2 = await future2; + expect(mutex.locked, isTrue); - test('state consistency after errors', () async { - final mutex = Mutex(); + unlock2(); + final unlock3 = await future3; + expect(mutex.locked, isTrue); - // Error in synchronize - try { - await mutex.synchronize(() async { - throw StateError('test'); - }); - } on Object catch (_) { - // Expected - } - expect(mutex.locked, isFalse); + unlock3(); + expect(mutex.locked, isFalse); + }); - // Error in lock - try { - final unlock = await mutex.lock(); - try { - throw Exception('error'); - } finally { - unlock(); - } - } on Object catch (_) { - // Expected - } - expect(mutex.locked, isFalse); + test('state consistency after errors', () async { + final mutex = Mutex(); - // Should still work - await mutex.synchronize(() async {}); - expect(mutex.locked, isFalse); + // Error in synchronize + try { + await mutex.synchronize(() async { + throw StateError('test'); }); + } on Object catch (_) { + // Expected + } + expect(mutex.locked, isFalse); + + // Error in lock + try { + final unlock = await mutex.lock(); + try { + throw Exception('error'); + } finally { + unlock(); + } + } on Object catch (_) { + // Expected + } + expect(mutex.locked, isFalse); + + // Should still work + await mutex.synchronize(() async {}); + expect(mutex.locked, isFalse); + }); - test('multiple mutex instances are independent', () async { - final mutex1 = Mutex(); - final mutex2 = Mutex(); + test('multiple mutex instances are independent', () async { + final mutex1 = Mutex(); + final mutex2 = Mutex(); - final unlock1 = await mutex1.lock(); - expect(mutex1.locked, isTrue); - expect(mutex2.locked, isFalse); + final unlock1 = await mutex1.lock(); + expect(mutex1.locked, isTrue); + expect(mutex2.locked, isFalse); - final unlock2 = await mutex2.lock(); - expect(mutex1.locked, isTrue); - expect(mutex2.locked, isTrue); + final unlock2 = await mutex2.lock(); + expect(mutex1.locked, isTrue); + expect(mutex2.locked, isTrue); - unlock1(); - expect(mutex1.locked, isFalse); - expect(mutex2.locked, isTrue); + unlock1(); + expect(mutex1.locked, isFalse); + expect(mutex2.locked, isTrue); - unlock2(); - expect(mutex1.locked, isFalse); - expect(mutex2.locked, isFalse); - }); - }); + unlock2(); + expect(mutex1.locked, isFalse); + expect(mutex2.locked, isFalse); + }); + }); - group('error propagation', () { - test('preserves stack trace', () async { - final mutex = Mutex(); + group('error propagation', () { + test('preserves stack trace', () async { + final mutex = Mutex(); - try { - await mutex.synchronize(() async { - throw Exception('original error'); - }); - } on Object catch (e, stackTrace) { - expect(e.toString(), contains('original error')); - expect(stackTrace.toString(), isNotEmpty); - return; // Expected error - } + try { + await mutex.synchronize(() async { + throw Exception('original error'); }); + } on Object catch (e, stackTrace) { + expect(e.toString(), contains('original error')); + expect(stackTrace.toString(), isNotEmpty); + return; // Expected error + } + }); - test('different error types', () async { - final mutex = Mutex(); + test('different error types', () async { + final mutex = Mutex(); - expect( - mutex.synchronize(() async => throw ArgumentError('test')), - throwsArgumentError, - ); + expect( + mutex.synchronize(() async => throw ArgumentError('test')), + throwsArgumentError, + ); - expect( - mutex.synchronize(() async => throw StateError('test')), - throwsStateError, - ); + expect( + mutex.synchronize(() async => throw StateError('test')), + throwsStateError, + ); - expect( - mutex.synchronize(() async => throw const FormatException('test')), - throwsFormatException, - ); + expect( + mutex.synchronize(() async => throw const FormatException('test')), + throwsFormatException, + ); - await Future.delayed(const Duration(milliseconds: 50)); - expect(mutex.locked, isFalse); - }); + await Future.delayed(const Duration(milliseconds: 50)); + expect(mutex.locked, isFalse); + }); - test('error does not affect queued operations', () async { - final mutex = Mutex(); - final results = []; + test('error does not affect queued operations', () async { + final mutex = Mutex(); + final results = []; - final futures = >[ - mutex.synchronize(() async { - results.add('ok-1'); - }), - () async { - results.add('error'); - try { - throw Exception('test error'); - } on Object catch (_) { - // Ignore - } - }(), - mutex.synchronize(() async { - results.add('ok-2'); - }), - ]; + final futures = >[ + mutex.synchronize(() async { + results.add('ok-1'); + }), + () async { + results.add('error'); + try { + throw Exception('test error'); + } on Object catch (_) { + // Ignore + } + }(), + mutex.synchronize(() async { + results.add('ok-2'); + }), + ]; - await Future.wait(futures); + await Future.wait(futures); - expect(results, equals(['ok-1', 'error', 'ok-2'])); - expect(mutex.locked, isFalse); - }); - }); + expect(results, equals(['ok-1', 'error', 'ok-2'])); + expect(mutex.locked, isFalse); }); + }); +}); diff --git a/test/util/test_util.dart b/test/util/test_util.dart index 26875f8..92a88d4 100644 --- a/test/util/test_util.dart +++ b/test/util/test_util.dart @@ -6,34 +6,30 @@ import 'package:flutter/material.dart'; abstract final class TestUtil { /// Basic wrapper for the current widgets. static 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, - ), - ), - ), - ), - ], - onDidRemovePage: (route) => route.canPop, + 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)), + ), ), - ), + ], + onDidRemovePage: (route) => route.canPop, ), ), ), ), - ); + ), + ), + ); } /// Base fake controller for testing. @@ -42,12 +38,12 @@ final class FakeController extends StateController FakeController({int? initialState}) : super(initialState: initialState ?? 0); void add(int value) => handle(() async { - await Future.delayed(Duration.zero); - setState(state + value); - }); + await Future.delayed(Duration.zero); + setState(state + value); + }); void subtract(int value) => handle(() async { - await Future.delayed(Duration.zero); - setState(state - value); - }); + await Future.delayed(Duration.zero); + setState(state - value); + }); } diff --git a/test/widget/controller_scope_test.dart b/test/widget/controller_scope_test.dart index 685937f..d687e6b 100644 --- a/test/widget/controller_scope_test.dart +++ b/test/widget/controller_scope_test.dart @@ -7,241 +7,226 @@ import 'package:flutter_test/flutter_test.dart'; import '../util/test_util.dart'; void main() => group('ControllerScope', () { - _$valueGroup(); - _$createGroup(); - _$additionalGroup(); - }); + _$valueGroup(); + _$createGroup(); + _$additionalGroup(); +}); 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( - TestUtil.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(); - }, - ); - }); + test('constructor', () { + expect(() => ControllerScope(FakeController.new), returnsNormally); + expect(ControllerScope(FakeController.new), isA()); + }); + + testWidgets('inject_and_recive', (tester) async { + final controller = FakeController(); + await tester.pumpWidget( + TestUtil.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( - TestUtil.appContext( - child: ControllerScope( - FakeController.new, - child: StateConsumer( - 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)); - final controller = ControllerScope.of(context); - expect( - controller, - isA().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); - }, - ); - }); + test('constructor', () { + expect(() => ControllerScope(FakeController.new), returnsNormally); + expect(ControllerScope(FakeController.new), isA()); + }); + + testWidgets('inject_and_recive', (tester) async { + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope( + FakeController.new, + child: StateConsumer( + 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), + ); + final controller = ControllerScope.of(context); + expect( + controller, + isA().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); + }); +}); void _$additionalGroup() => group('ControllerScope.additional', () { - testWidgets('controllerOf should return the correct controller', - (tester) async { - final controller = FakeController(); - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Text('$state'), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - final context = - tester.firstElement(find.byType(ControllerScope)); - - final foundController = context.controllerOf(); - expect(foundController, equals(controller)); - }); - - testWidgets('maybeOf should return null if no controller is found', - (tester) async { - final controller = FakeController(); - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Text('$state'), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - final context = tester.firstElement(find.byType(HeroControllerScope)); - final foundController = - ControllerScope.maybeOf(context); - expect(foundController, isNull); - }); - - testWidgets('maybeOf should return controller if present', - (tester) async { - final controller = FakeController(); - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Text('$state'), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - final context = - tester.firstElement(find.byType(ControllerScope)); - - final foundController = - ControllerScope.maybeOf(context, listen: true); - expect(foundController, equals(controller)); - }); - - testWidgets('_notFoundInheritedWidgetOfExactType should throw error', - (tester) async { - await tester - .pumpWidget(TestUtil.appContext(child: const SizedBox.shrink())); - await tester.pumpAndSettle(); - - final context = tester.firstElement(find.byType(HeroControllerScope)); - expect( - () => ControllerScope.of(context), - throwsArgumentError, - ); - }); - - test('updateShouldNotify should return true for different dependencies', - () { - final controller1 = FakeController(); - final controller2 = FakeController(); - final widget1 = ControllerScope.value( - controller1, - child: const SizedBox.shrink(), - ); - final widget2 = ControllerScope.value( - controller2, - child: const SizedBox.shrink(), - ); - - expect(widget1.updateShouldNotify(widget2), isTrue); - }); - - test('debugFillProperties should correctly fill debug information', () { - final controller = FakeController(); - final widget = ControllerScope.value( + testWidgets('controllerOf should return the correct controller', ( + tester, + ) async { + final controller = FakeController(); + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( controller, - child: const SizedBox.shrink(), - ); - final element = widget.createElement(); - final properties = DiagnosticPropertiesBuilder(); - - element.debugFillProperties(properties); - - expect(properties.properties, isNotEmpty); - }); - - test('_initController should initialize correctly', () { - final controller = FakeController(); - final widget = ControllerScope.value( + child: StateConsumer( + controller: controller, + builder: (context, state, child) => Text('$state'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final context = tester.firstElement( + find.byType(ControllerScope), + ); + + final foundController = context.controllerOf(); + expect(foundController, equals(controller)); + }); + + testWidgets('maybeOf should return null if no controller is found', ( + tester, + ) async { + final controller = FakeController(); + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( controller, - child: const SizedBox.shrink(), - ); - final element = - widget.createElement() as ControllerScope$Element; - - final initializedController = element.controller; - expect(initializedController, equals(controller)); - }); - - test('_initController should throw error on reinitialization', () { - final controller = FakeController(); - final widget = ControllerScope.value( + child: StateConsumer( + controller: controller, + builder: (context, state, child) => Text('$state'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final context = tester.firstElement(find.byType(HeroControllerScope)); + final foundController = ControllerScope.maybeOf(context); + expect(foundController, isNull); + }); + + testWidgets('maybeOf should return controller if present', (tester) async { + final controller = FakeController(); + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( controller, - child: const SizedBox.shrink(), - ); - final element = - widget.createElement() as ControllerScope$Element; - - // Initialize first time - final initializedController = element.controller; - expect(initializedController, equals(controller)); - - // Trying to reinitialize should cause an error - // expect(element.controller., throwsAssertionError); - }); - }); + child: StateConsumer( + controller: controller, + builder: (context, state, child) => Text('$state'), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final context = tester.firstElement( + find.byType(ControllerScope), + ); + + final foundController = ControllerScope.maybeOf( + context, + listen: true, + ); + expect(foundController, equals(controller)); + }); + + testWidgets('_notFoundInheritedWidgetOfExactType should throw error', ( + tester, + ) async { + await tester.pumpWidget( + TestUtil.appContext(child: const SizedBox.shrink()), + ); + await tester.pumpAndSettle(); + + final context = tester.firstElement(find.byType(HeroControllerScope)); + expect(() => ControllerScope.of(context), throwsArgumentError); + }); + + test('updateShouldNotify should return true for different dependencies', () { + final controller1 = FakeController(); + final controller2 = FakeController(); + final widget1 = ControllerScope.value( + controller1, + child: const SizedBox.shrink(), + ); + final widget2 = ControllerScope.value( + controller2, + child: const SizedBox.shrink(), + ); + + expect(widget1.updateShouldNotify(widget2), isTrue); + }); + + test('debugFillProperties should correctly fill debug information', () { + final controller = FakeController(); + final widget = ControllerScope.value( + controller, + child: const SizedBox.shrink(), + ); + final element = widget.createElement(); + final properties = DiagnosticPropertiesBuilder(); + + element.debugFillProperties(properties); + + expect(properties.properties, isNotEmpty); + }); + + test('_initController should initialize correctly', () { + final controller = FakeController(); + final widget = ControllerScope.value( + controller, + child: const SizedBox.shrink(), + ); + final element = + widget.createElement() as ControllerScope$Element; + + final initializedController = element.controller; + expect(initializedController, equals(controller)); + }); + + test('_initController should throw error on reinitialization', () { + final controller = FakeController(); + final widget = ControllerScope.value( + controller, + child: const SizedBox.shrink(), + ); + final element = + widget.createElement() as ControllerScope$Element; + + // Initialize first time + final initializedController = element.controller; + expect(initializedController, equals(controller)); + + // Trying to reinitialize should cause an error + // expect(element.controller., throwsAssertionError); + }); +}); diff --git a/test/widget/state_consumer_test.dart b/test/widget/state_consumer_test.dart index 68880b5..67d1d31 100644 --- a/test/widget/state_consumer_test.dart +++ b/test/widget/state_consumer_test.dart @@ -6,310 +6,299 @@ import 'package:flutter_test/flutter_test.dart'; import '../util/test_util.dart'; void main() => group('StateConsumer - ', () { - _$baseGroup(); - _$didUpdateWidgetGroup(); - _$debugFillPropertiesGroup(); - }); + _$baseGroup(); + _$didUpdateWidgetGroup(); + _$debugFillPropertiesGroup(); +}); void _$baseGroup() => group('base - ', () { - testWidgets('should update controller when widget controller changes', - (tester) async { - final controller1 = FakeController(); - final controller2 = FakeController(); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller1, - child: StateConsumer( - controller: controller1, - builder: (context, state, child) => Text('$state'), - ), - ), + testWidgets('should update controller when widget controller changes', ( + tester, + ) async { + final controller1 = FakeController(); + final controller2 = FakeController(); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller1, + child: StateConsumer( + controller: controller1, + builder: (context, state, child) => Text('$state'), ), - ); - - controller1.add(1); - await tester.pumpAndSettle(); - - expect(find.text('1'), findsOneWidget); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller2, - child: StateConsumer( - controller: controller2, - builder: (context, state, child) => Text('$state'), - ), - ), + ), + ), + ); + + controller1.add(1); + await tester.pumpAndSettle(); + + expect(find.text('1'), findsOneWidget); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller2, + child: StateConsumer( + controller: controller2, + builder: (context, state, child) => Text('$state'), ), - ); + ), + ), + ); - expect(find.text('0'), findsOneWidget); + expect(find.text('0'), findsOneWidget); - controller2.add(2); - await tester.pumpAndSettle(); + controller2.add(2); + await tester.pumpAndSettle(); - expect(find.text('2'), findsOneWidget); + expect(find.text('2'), findsOneWidget); - controller1.add(1); - await tester.pumpAndSettle(); + controller1.add(1); + await tester.pumpAndSettle(); - expect(find.text('3'), findsNothing); - expect(find.text('2'), findsOneWidget); - }); + expect(find.text('3'), findsNothing); + expect(find.text('2'), findsOneWidget); + }); - testWidgets( - 'should not rebuild when states are identical in _valueChanged', - (tester) async { - final controller = FakeController(); + testWidgets('should not rebuild when states are identical in _valueChanged', ( + tester, + ) async { + final controller = FakeController(); - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Text('$state'), - ), - ), + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + controller: controller, + builder: (context, state, child) => Text('$state'), ), - ); + ), + ), + ); - expect(find.text('0'), findsOneWidget); + expect(find.text('0'), findsOneWidget); - controller.add(1); + controller.add(1); - await tester.pumpAndSettle(); + await tester.pumpAndSettle(); - expect(find.text('1'), findsOneWidget); - }); + expect(find.text('1'), findsOneWidget); + }); - testWidgets('should not rebuild when buildWhen returns false', - (tester) async { - final controller = FakeController(); + testWidgets('should not rebuild when buildWhen returns false', ( + tester, + ) async { + final controller = FakeController(); - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - buildWhen: (previous, current) => false, // No rebuild - builder: (context, state, child) => Text('$state'), - ), - ), + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + controller: controller, + buildWhen: (previous, current) => false, // No rebuild + builder: (context, state, child) => Text('$state'), ), - ); - - expect(find.text('0'), findsOneWidget); - - controller.add(1); - - await tester.pumpAndSettle(); // Rebuild should not happen - - expect(find.text('1'), findsNothing); // Should still show 0 - expect(find.text('0'), findsOneWidget); - }); - - testWidgets('should use child if builder is not provided', - (tester) async { - const childWidget = Text('Child Widget'); - final controller = FakeController(); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - child: childWidget, - ), - ), + ), + ), + ); + + expect(find.text('0'), findsOneWidget); + + controller.add(1); + + await tester.pumpAndSettle(); // Rebuild should not happen + + expect(find.text('1'), findsNothing); // Should still show 0 + expect(find.text('0'), findsOneWidget); + }); + + testWidgets('should use child if builder is not provided', (tester) async { + const childWidget = Text('Child Widget'); + final controller = FakeController(); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer(controller: controller, child: childWidget), + ), + ), + ); + + expect(find.text('Child Widget'), findsOneWidget); + }); + + testWidgets('should rebuild with widget child ' + 'if both builder and child are provided', (tester) async { + const childWidget = Text('Child Widget'); + final controller = FakeController(); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + controller: controller, + builder: (context, state, child) => + Column(children: [Text('State: $state'), child ?? childWidget]), + child: childWidget, ), - ); - - expect(find.text('Child Widget'), findsOneWidget); - }); - - testWidgets( - 'should rebuild with widget child ' - 'if both builder and child are provided', (tester) async { - const childWidget = Text('Child Widget'); - final controller = FakeController(); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Column( - children: [ - Text('State: $state'), - child ?? childWidget, - ], - ), - child: childWidget, - ), - ), - ), - ); + ), + ), + ); - // Initial state - await tester.pumpAndSettle(); - expect(find.text('Child Widget'), findsOneWidget); - expect(find.text('State: 0'), findsOneWidget); + // Initial state + await tester.pumpAndSettle(); + expect(find.text('Child Widget'), findsOneWidget); + expect(find.text('State: 0'), findsOneWidget); - // Update state - controller.add(1); - await tester.pumpAndSettle(); + // Update state + controller.add(1); + await tester.pumpAndSettle(); - expect(find.text('Child Widget'), findsOneWidget); - expect(find.text('State: 1'), findsOneWidget); - }); - }); + expect(find.text('Child Widget'), findsOneWidget); + expect(find.text('State: 1'), findsOneWidget); + }); +}); void _$debugFillPropertiesGroup() => group('debugFillProperties - ', () { - testWidgets('should fill full debug properties correctly', - (tester) async { - final controller = FakeController(); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: controller, - builder: (context, state, child) => Text('$state'), - ), - ), + testWidgets('should fill full debug properties correctly', (tester) async { + final controller = FakeController(); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + controller: controller, + builder: (context, state, child) => Text('$state'), ), - ); - - final context = - tester.firstElement(find.byType(ControllerScope)); - - // Filling in debugging properties - final properties = DiagnosticPropertiesBuilder(); - context.debugFillProperties(properties); - - // Check all expected properties are filled - final propertyNames = properties.properties.map((p) => p.name).toList(); - expect( - propertyNames, - containsAll([ - 'StateController', - 'State', - 'Subscribers', - 'isDisposed', - 'isProcessing', - 'depth', - 'widget', - 'key', - 'dirty', - ]), - ); - }); - - testWidgets('should fill debug properties correctly', (tester) async { - final stateConsumerKey = GlobalKey>(); - final controller = FakeController(); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - key: stateConsumerKey, - controller: controller, - builder: (context, state, child) => Text('$state'), - ), - ), + ), + ), + ); + + final context = tester.firstElement( + find.byType(ControllerScope), + ); + + // Filling in debugging properties + final properties = DiagnosticPropertiesBuilder(); + context.debugFillProperties(properties); + + // Check all expected properties are filled + final propertyNames = properties.properties.map((p) => p.name).toList(); + expect( + propertyNames, + containsAll([ + 'StateController', + 'State', + 'Subscribers', + 'isDisposed', + 'isProcessing', + 'depth', + 'widget', + 'key', + 'dirty', + ]), + ); + }); + + testWidgets('should fill debug properties correctly', (tester) async { + final stateConsumerKey = GlobalKey>(); + final controller = FakeController(); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + key: stateConsumerKey, + controller: controller, + builder: (context, state, child) => Text('$state'), ), - ); + ), + ), + ); - // Filling in debugging properties - final properties = DiagnosticPropertiesBuilder(); - stateConsumerKey.currentState?.debugFillProperties(properties); + // Filling in debugging properties + final properties = DiagnosticPropertiesBuilder(); + stateConsumerKey.currentState?.debugFillProperties(properties); - // Check all expected properties are filled - final propertyNames = properties.properties.map((p) => p.name).toList(); - expect( - propertyNames, - containsAll(['Controller', 'State', 'isProcessing']), - ); - }); - }); + // Check all expected properties are filled + final propertyNames = properties.properties.map((p) => p.name).toList(); + expect(propertyNames, containsAll(['Controller', 'State', 'isProcessing'])); + }); +}); void _$didUpdateWidgetGroup() => group('didUpdateWidget - ', () { - testWidgets( - 'should use controller from ControllerScope ' - 'when newController is null', (tester) async { - final controller = FakeController(); - - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - // В первый раз указываем начальный контроллер - controller: controller, - builder: (context, state, child) => Text('State: $state'), - ), - ), + testWidgets('should use controller from ControllerScope ' + 'when newController is null', (tester) async { + final controller = FakeController(); + + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + // В первый раз указываем начальный контроллер + controller: controller, + builder: (context, state, child) => Text('State: $state'), ), - ); - - // Change the current controller's state to check - // that the widget is updating correctly - controller.add(1); - await tester.pumpAndSettle(); - expect(find.text('State: 1'), findsOneWidget); - - // Rebuild the widget without a controller, - // check that the controller from ControllerScope is used - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - // Здесь передаем null контроллер - controller: null, - builder: (context, state, child) => Text('State: $state'), - ), - ), + ), + ), + ); + + // Change the current controller's state to check + // that the widget is updating correctly + controller.add(1); + await tester.pumpAndSettle(); + expect(find.text('State: 1'), findsOneWidget); + + // Rebuild the widget without a controller, + // check that the controller from ControllerScope is used + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + // Здесь передаем null контроллер + controller: null, + builder: (context, state, child) => Text('State: $state'), ), - ); - - // Check that the controller state is taken from ControllerScope - // and is displayed correctly - expect(find.text('State: 1'), findsOneWidget); - - // Change the state of the controller in ControllerScope - // and check that the widget is updated correctly - controller.add(2); - await tester.pumpAndSettle(); - expect(find.text('State: 3'), findsOneWidget); // Было 1, добавили 2 - - // Additionally, we check that a new controller is not created - // and is used only from Scope - final newController = FakeController(); - await tester.pumpWidget( - TestUtil.appContext( - child: ControllerScope.value( - controller, - child: StateConsumer( - controller: newController, - builder: (context, state, child) => Text('State: $state'), - ), - ), + ), + ), + ); + + // Check that the controller state is taken from ControllerScope + // and is displayed correctly + expect(find.text('State: 1'), findsOneWidget); + + // Change the state of the controller in ControllerScope + // and check that the widget is updated correctly + controller.add(2); + await tester.pumpAndSettle(); + expect(find.text('State: 3'), findsOneWidget); // Было 1, добавили 2 + + // Additionally, we check that a new controller is not created + // and is used only from Scope + final newController = FakeController(); + await tester.pumpWidget( + TestUtil.appContext( + child: ControllerScope.value( + controller, + child: StateConsumer( + controller: newController, + builder: (context, state, child) => Text('State: $state'), ), - ); - - // The new controller has an initial value of 0 - // and the widget will update to show this. - expect(find.text('State: 0'), findsOneWidget); - }); - }); + ), + ), + ); + + // The new controller has an initial value of 0 + // and the widget will update to show this. + expect(find.text('State: 0'), findsOneWidget); + }); +}); From 677c92e3e59bf6206766ebb532fc108e7ed4544c Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Fri, 6 Feb 2026 09:59:30 +0400 Subject: [PATCH 14/20] Update dependencies and environment constraints in pubspec.yaml and pubspec.lock --- example/pubspec.lock | 84 ++++++++++++-------------------------------- example/pubspec.yaml | 14 ++++---- pubspec.yaml | 2 +- 3 files changed, 30 insertions(+), 70 deletions(-) diff --git a/example/pubspec.lock b/example/pubspec.lock index b8d4bef..eb8e84d 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "64.0.0" + version: "93.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "6.2.0" + version: "10.0.1" args: dependency: transitive description: name: args - sha256: eef6c46b622e0494a36c5a12d10d77fb4e855501a91c1b9ef9339326e58f0596 + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.7.0" async: dependency: "direct main" description: @@ -45,18 +45,18 @@ packages: dependency: transitive description: name: build - sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + sha256: "275bf6bb2a00a9852c28d4e0b410da1d833a734d57d39d44f94bfc895a484ec3" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "4.0.4" build_config: dependency: transitive description: name: build_config - sha256: bf80fcfb46a29945b423bd9aad884590fb1dc69b330a4d4700cac476af1708d1 + sha256: "4f64382b97504dc2fcdf487d5aae33418e08b4703fc21249e4db6d804a4d0187" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.0" build_daemon: dependency: transitive description: @@ -65,30 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.1" - build_resolvers: - dependency: transitive - description: - name: build_resolvers - sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" - url: "https://pub.dev" - source: hosted - version: "2.4.2" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "67d591d602906ef9201caf93452495ad1812bea2074f04e25dbd7c133785821b" + sha256: ac78098de97893812b7aff1154f29008fa2464cad9e8e7044d39bc905dad4fbc url: "https://pub.dev" source: hosted - version: "2.4.7" - build_runner_core: - dependency: transitive - description: - name: build_runner_core - sha256: c9e32d21dd6626b5c163d48b037ce906bbe428bc23ab77bcd77bb21e593b6185 - url: "https://pub.dev" - source: hosted - version: "7.2.11" + version: "2.11.0" built_collection: dependency: transitive description: @@ -101,10 +85,10 @@ packages: dependency: transitive description: name: built_value - sha256: c9aabae0718ec394e5bc3c7272e6bb0dc0b32201a08fe185ec1d8401d3e39309 + sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" url: "https://pub.dev" source: hosted - version: "8.8.1" + version: "8.12.3" characters: dependency: transitive description: @@ -180,10 +164,10 @@ packages: dependency: transitive description: name: dart_style - sha256: "40ae61a5d43feea6d24bd22c0537a6629db858963b99b4bc1c3db80676f32368" + sha256: "15a7db352c8fc6a4d2bc475ba901c25b39fe7157541da4c16eacce6f8be83e49" url: "https://pub.dev" source: hosted - version: "2.3.4" + version: "3.1.5" fake_async: dependency: transitive description: @@ -230,10 +214,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "6.0.0" flutter_test: dependency: "direct dev" description: flutter @@ -244,14 +228,6 @@ packages: description: flutter source: sdk version: "0.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: "408e3ca148b31c20282ad6f37ebfa6f4bdc8fede5b74bc2f08d9d92b55db3612" - url: "https://pub.dev" - source: hosted - version: "3.2.0" fuchsia_remote_debug_protocol: dependency: transitive description: flutter @@ -302,14 +278,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" json_annotation: dependency: transitive description: @@ -322,10 +290,10 @@ packages: dependency: "direct main" description: name: l - sha256: c015d97a7d1552706be9b2367facd2c379ffdabf1e104bc19d284ae775fc9016 + sha256: "48db3024c2f74e1bc84b753b17d6754a066969c246de59505d6306f5154cbc86" url: "https://pub.dev" source: hosted - version: "5.0.0-pre.2" + version: "5.0.1" leak_tracker: dependency: transitive description: @@ -354,10 +322,10 @@ packages: dependency: transitive description: name: lints - sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "6.1.0" logging: dependency: transitive description: @@ -627,14 +595,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.7" - timing: - dependency: transitive - description: - name: timing - sha256: "70a3b636575d4163c477e6de42f247a23b315ae20e86442bebe32d3cabf61c32" - url: "https://pub.dev" - source: hosted - version: "1.0.1" typed_data: dependency: transitive description: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index b0f003d..a9fcdc1 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,7 +1,7 @@ name: example description: "Control example" -publish_to: 'none' +publish_to: "none" homepage: https://github.com/PlugFox/control @@ -32,8 +32,8 @@ platforms: version: 1.0.0+1 environment: - sdk: '>=3.4.0 <4.0.0' - flutter: ">=3.16.0" + sdk: ">=3.10.0 <4.0.0" + flutter: ">=3.38.0" dependencies: # Flutter SDK @@ -48,7 +48,7 @@ dependencies: convert: any # Logger - l: ^5.0.0-pre.2 + l: ^5.0.1 # Storage shared_preferences: ^2.2.2 @@ -69,11 +69,11 @@ dev_dependencies: sdk: flutter # Linting - flutter_lints: ^2.0.1 + flutter_lints: ^6.0.0 # Code generation - build_runner: ^2.4.6 + build_runner: ^2.10.0 flutter: generate: true - uses-material-design: true \ No newline at end of file + uses-material-design: true diff --git a/pubspec.yaml b/pubspec.yaml index 6dcb9b4..61e4c01 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,7 +40,7 @@ environment: dependencies: flutter: sdk: flutter - meta: ^1.0.0 + meta: ^1.17.0 dev_dependencies: flutter_test: From 6512986abac2962ce11eb1c38ac2eafaabbd33c1 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Fri, 6 Feb 2026 11:01:12 +0400 Subject: [PATCH 15/20] Enhance handle() method to support type-safe return values across concurrency strategies, update documentation, and adjust migration guides accordingly --- CHANGELOG.md | 13 +- IDEAS.md | 21 +- MIGRATION.md | 56 +++- README.md | 242 ++++++++++++++++++ example/lib/main.dart | 206 ++++++++------- example/test/widget_test.dart | 5 +- .../droppable_controller_handler.dart | 17 +- .../sequential_controller_handler.dart | 15 +- lib/src/controller.dart | 29 ++- test/unit/state_controller_test.dart | 64 ++++- 10 files changed, 538 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ad91c4..3967c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## 1.0.0-dev.1 +### Features + +- **ADDED**: Generic `handle()` method - now supports return values + - Example: `Future fetchUser(String id) => handle(() async { ... return user; })` + - Type-safe return values from controller operations + - Works with all concurrency strategies (sequential, concurrent, droppable) + ### Breaking Changes - **REMOVED**: `base` modifier from `Controller`, `StateController`, and concurrency handler mixins @@ -15,7 +22,11 @@ - `ConcurrentControllerHandler` - **deprecated** (base behavior is already concurrent) - **CHANGED**: `Controller.handle()` signature now includes `error` and `done` callbacks - Before: `handle(handler, {name, meta})` - - After: `handle(handler, {error, done, name, meta})` + - After: `handle(handler, {error, done, name, meta})` +- **CHANGED**: `DroppableControllerHandler` now returns `null` when dropping operations + - Handle method returns `Future` to support nullable return values + - Dropped operations return `null` instead of throwing errors + - This is the expected behavior for droppable operations ### Added diff --git a/IDEAS.md b/IDEAS.md index 326bd71..674709a 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -55,12 +55,12 @@ mixin SequentialControllerHandler on Controller { - Mixins are now just 10-15 lines each - Easier to understand and maintain -### Phase 2: Enhancements (Future) +### Phase 2: Enhancements -#### 4. Generic `handle()` ⭐ -**Current:** `handle()` only returns `Future` +#### 4. Generic `handle()` ✅ **(Implemented in v1.0.0-dev.1)** +**Status:** Implemented -**Proposal:** Make it generic to support return values: +**Implementation:** ```dart Future handle(Future Function() handler, {...}); @@ -74,9 +74,12 @@ Future fetchUser(String id) => handle(() async { ``` **Benefits:** -- More flexible API -- Better composition -- Type-safe return values +- ✅ More flexible API +- ✅ Better composition +- ✅ Type-safe return values +- ✅ Works with all concurrency strategies + +**Breaking Change:** `DroppableControllerHandler` now throws `StateError` when operations are dropped instead of silently completing. #### 5. `tryLock()` Method for Mutex ⭐ **Proposal:** Add non-blocking lock attempt: @@ -262,4 +265,6 @@ Feel free to: --- **Last Updated:** 2026-02-06 -**Status:** Phase 1 (MVP) implemented in v1.0.0-dev.1 +**Status:** +- Phase 1 (MVP) - ✅ Implemented in v1.0.0-dev.1 +- Phase 2 Item 4 (Generic handle) - ✅ Implemented in v1.0.0-dev.1 diff --git a/MIGRATION.md b/MIGRATION.md index 817529c..fc91cfa 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -57,7 +57,61 @@ class MyController extends StateController { **Action Required:** Remove `with ConcurrentControllerHandler` from your controller declarations. The mixin is deprecated but still available for backwards compatibility. -### 3. Update handle() Calls with Error Handling +### 3. Handle Generic Return Values + +The `handle()` method is now generic and can return values: + +**Before (0.x):** +```dart +void fetchData() => handle(() async { + final data = await api.fetch(); + setState(state.copyWith(data: data)); + // No return value +}); +``` + +**After (1.0.0):** +```dart +// Still works without return value +void fetchData() => handle(() async { + final data = await api.fetch(); + setState(state.copyWith(data: data)); +}); + +// NEW: Can now return values +Future fetchUser(String id) => handle(() async { + final user = await api.getUser(id); + setState(state.copyWith(user: user)); + return user; // Type-safe return value! +}); +``` + +**Action Required:** None for existing code. This is backward compatible. + +### 4. DroppableControllerHandler with Generic Return Values + +**Before (0.x):** +```dart +// Dropped operations completed with Future.value() +await controller.operation(); // void +``` + +**After (1.0.0):** +```dart +// Dropped operations return null with Future.value(null) +final result = await controller.operation(); // null if dropped +if (result != null) { + // Operation completed successfully + print('Result: $result'); +} else { + // Operation was dropped + print('Operation dropped: controller is busy'); +} +``` + +**Action Required:** None for existing code. The behavior is backward compatible - dropped operations return `null` (expected behavior). If you need to distinguish between dropped operations and successful operations, check for `null` return values. + +### 5. Update handle() Calls with Error Handling The `handle()` method signature has been extended: diff --git a/README.md b/README.md index ea9d4c2..730aadd 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,47 @@ class MyController extends StateController { } ``` +## Return Values from Operations + +The `handle()` method is generic and can return values: + +```dart +class UserController extends StateController { + UserController(this.api) : super(initialState: UserState.initial()); + + final UserApi api; + + /// Fetch user and return the user object + Future fetchUser(String id) => handle(() async { + final user = await api.getUser(id); + setState(state.copyWith(user: user, loading: false)); + return user; // Type-safe return value + }); + + /// Update user and return success status + Future updateUser(User user) => handle(() async { + try { + await api.updateUser(user); + setState(state.copyWith(user: user)); + return true; + } catch (e) { + return false; + } + }); +} + +// Usage +final user = await controller.fetchUser('123'); +print('Fetched: ${user.name}'); + +final success = await controller.updateUser(updatedUser); +if (success) { + print('User updated successfully'); +} +``` + +**Note:** With `DroppableControllerHandler`, dropped operations return `null` instead of executing. + ## Usage in Flutter ### Inject Controller @@ -308,6 +349,207 @@ See [MIGRATION.md](MIGRATION.md) for detailed migration guide. - Controllers are automatically disposed by `ControllerScope` - Manual disposal only needed for manually created controllers +## Advanced Usage + +### UI Feedback with Callbacks + +Use `error` and `done` callbacks to provide user feedback through SnackBars, dialogs, or notifications: + +```dart +class UserController extends StateController { + UserController(this.api) : super(initialState: UserState.initial()); + + final UserApi api; + + Future updateProfile( + User user, { + void Function(User user)? onSuccess, + void Function(Object error)? onError, + }) => handle( + () async { + final updatedUser = await api.updateUser(user); + setState(state.copyWith(user: updatedUser)); + onSuccess?.call(updatedUser); + return updatedUser; + }, + error: (error, stackTrace) async { + onError?.call(error); + }, + name: 'updateProfile', + meta: {'userId': user.id}, + ); +} + +// Usage in UI +ElevatedButton( + onPressed: () => controller.updateProfile( + updatedUser, + onSuccess: (user) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Profile updated: ${user.name}')), + ); + }, + onError: (error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $error'), + backgroundColor: Colors.red, + ), + ); + }, + ), + child: const Text('Update Profile'), +) +``` + +### Interactive Dialogs During Processing + +Add interactive dialogs in the middle of processing for user input: + +```dart +class AuthController extends StateController { + AuthController(this.api) : super(initialState: AuthState.initial()); + + final AuthApi api; + + Future login( + String email, + String password, { + required Future Function() requestSmsCode, + }) => handle( + () async { + // Step 1: Initial login + final session = await api.login(email, password); + + // Step 2: Check if 2FA is required + if (session.requires2FA) { + // Request SMS code from user via dialog + final smsCode = await requestSmsCode(); + + // Step 3: Verify SMS code + await api.verify2FA(session.id, smsCode); + } + + setState(state.copyWith(isAuthenticated: true)); + return true; + }, + error: (error, stackTrace) async { + setState(state.copyWith(error: error.toString())); + }, + name: 'login', + meta: {'email': email, 'requires2FA': true}, + ); +} + +// Usage in UI +ElevatedButton( + onPressed: () => controller.login( + email, + password, + requestSmsCode: () async { + // Show dialog and wait for user input + final code = await showDialog( + context: context, + builder: (context) => SmsCodeDialog(), + ); + return code ?? ''; + }, + ), + child: const Text('Login'), +) +``` + +### Debugging and Observability + +Use `name` and `meta` parameters for debugging, logging, and integration with error tracking services like Sentry or Crashlytics: + +```dart +class ControllerObserver implements IControllerObserver { + const ControllerObserver(); + + @override + void onHandler(HandlerContext context) { + // Log operation start with metadata + print('START | ${context.controller.name}.${context.name}'); + print('META | ${context.meta}'); + + final stopwatch = Stopwatch()..start(); + + context.done.whenComplete(() { + // Log operation completion with duration + stopwatch.stop(); + print('DONE | ${context.controller.name}.${context.name} | ' + 'duration: ${stopwatch.elapsed}'); + }); + } + + @override + void onError(Controller controller, Object error, StackTrace stackTrace) { + final context = Controller.context; + + if (context != null) { + // Send breadcrumbs to Sentry/Crashlytics + Sentry.addBreadcrumb(Breadcrumb( + message: '${controller.name}.${context.name}', + data: context.meta, + level: SentryLevel.error, + )); + + // Report error with full context + Sentry.captureException( + error, + stackTrace: stackTrace, + hint: Hint.withMap({ + 'controller': controller.name, + 'operation': context.name, + 'metadata': context.meta, + }), + ); + } + } + + @override + void onStateChanged( + StateController controller, + S prevState, + S nextState, + ) { + final context = Controller.context; + + // Log state changes with operation context + if (context != null) { + print('STATE | ${controller.name}.${context.name} | ' + '$prevState -> $nextState'); + print('META | ${context.meta}'); + } + } + + @override + void onCreate(Controller controller) { + print('CREATE | ${controller.name}'); + } + + @override + void onDispose(Controller controller) { + print('DISPOSE | ${controller.name}'); + } +} + +// Setup in main +void main() { + Controller.observer = const ControllerObserver(); + runApp(const App()); +} +``` + +**Benefits of using `name` and `meta`:** +- **Debugging**: Easily track which operation is executing +- **Logging**: Add context to logs for better traceability +- **Profiling**: Measure operation duration and performance +- **Error tracking**: Send rich context to Sentry/Crashlytics +- **Analytics**: Track user actions with metadata +- **Breadcrumbs**: Build execution trail for debugging crashes + ## Examples See [example/](example/) directory for complete examples: diff --git a/example/lib/main.dart b/example/lib/main.dart index 33f1982..cabf472 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -22,7 +22,8 @@ final class ControllerObserver implements IControllerObserver { void onHandler(HandlerContext context) { final stopwatch = Stopwatch()..start(); l.d( - 'Controller | ' '${context.controller.name}.${context.name}', + 'Controller | ' + '${context.controller.name}.${context.name}', context.meta, ); context.done.whenComplete(() { @@ -85,14 +86,11 @@ final class ControllerObserver implements IControllerObserver { } } -void main() => runZonedGuarded>( - () async { - // Setup controller observer - Controller.observer = const ControllerObserver(); - runApp(const App()); - }, - (error, stackTrace) => l.e('Top level exception: $error', stackTrace), - ); +void main() => runZonedGuarded>(() async { + // Setup controller observer + Controller.observer = const ControllerObserver(); + runApp(const App()); +}, (error, stackTrace) => l.e('Top level exception: $error', stackTrace)); /// Counter state for [CounterController] typedef CounterState = ({int count, bool idle}); @@ -100,20 +98,57 @@ typedef CounterState = ({int count, bool idle}); /// Counter controller with sequential handler class CounterController extends StateController with SequentialControllerHandler { + /// Creates a [CounterController] with an optional initial state. CounterController({CounterState? initialState}) - : super(initialState: initialState ?? (idle: true, count: 0)); - - void add(int value) => handle(() async { - setState((idle: false, count: state.count)); - await Future.delayed(const Duration(milliseconds: 1500)); - setState((idle: true, count: state.count + value)); - }); - - void subtract(int value) => handle(() async { - setState((idle: false, count: state.count)); - await Future.delayed(const Duration(milliseconds: 1500)); - setState((idle: true, count: state.count - value)); - }); + : super(initialState: initialState ?? (idle: true, count: 0)); + + /// Adds a value to the current count. + Future add( + int value, { + void Function(int result)? onSuccess, + void Function(Object error, StackTrace stackTrace)? onError, + }) => handle( + () async { + setState((idle: false, count: state.count)); + final result = await Future.delayed( + const Duration(milliseconds: 1500), + () => state.count + value, + ); + setState((idle: true, count: result)); + onSuccess?.call(result); + return result; + }, + error: (error, stackTrace) async { + onError?.call(error, stackTrace); + }, + done: () async {}, + name: 'add', + meta: {'operation': 'add', 'value': value}, + ); + + /// Subtracts a value from the current count. + Future subtract( + int value, { + void Function(int result)? onSuccess, + void Function(Object error, StackTrace stackTrace)? onError, + }) => handle( + () async { + setState((idle: false, count: state.count)); + final result = await Future.delayed( + const Duration(milliseconds: 1500), + () => state.count - value, + ); + onSuccess?.call(result); + setState((idle: true, count: result)); + return result; + }, + error: (error, stackTrace) async { + onError?.call(error, stackTrace); + }, + done: () async {}, + name: 'subtract', + meta: {'operation': 'subtract', 'value': value}, + ); } class App extends StatelessWidget { @@ -121,15 +156,13 @@ class App extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( - title: 'StateController example', - theme: ThemeData.dark(), - home: const CounterScreen(), - builder: (context, child) => - // Create and inject the controller into the element tree. - ControllerScope( - CounterController.new, - child: child, - )); + title: 'StateController example', + theme: ThemeData.dark(), + home: const CounterScreen(), + builder: (context, child) => + // Create and inject the controller into the element tree. + ControllerScope(CounterController.new, child: child), + ); } class CounterScreen extends StatelessWidget { @@ -137,22 +170,14 @@ class CounterScreen extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Counter'), - ), - floatingActionButton: const CounterScreen$Buttons(), - body: const SafeArea( - child: Center( - child: CounterScreen$Text(), - ), - ), - ); + appBar: AppBar(title: const Text('Counter')), + floatingActionButton: const CounterScreen$Buttons(), + body: const SafeArea(child: Center(child: CounterScreen$Text())), + ); } class CounterScreen$Text extends StatelessWidget { - const CounterScreen$Text({ - super.key, - }); + const CounterScreen$Text({super.key}); @override Widget build(BuildContext context) { @@ -163,10 +188,7 @@ class CounterScreen$Text extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - 'Count: ', - style: style, - ), + Text('Count: ', style: style), SizedBox.square( dimension: 64, child: Center( @@ -182,10 +204,7 @@ class CounterScreen$Text extends StatelessWidget { duration: const Duration(milliseconds: 500), transitionBuilder: (child, animation) => ScaleTransition( scale: animation, - child: FadeTransition( - opacity: animation, - child: child, - ), + child: FadeTransition(opacity: animation, child: child), ), child: state.idle ? Text(text, style: style, overflow: TextOverflow.fade) @@ -201,43 +220,58 @@ class CounterScreen$Text extends StatelessWidget { } class CounterScreen$Buttons extends StatelessWidget { - const CounterScreen$Buttons({ - super.key, - }); + const CounterScreen$Buttons({super.key}); + + /// Show a message using a [SnackBar]. + static void showMessage(BuildContext context, String message) { + if (!context.mounted) return; + ScaffoldMessenger.maybeOf(context) + ?..clearSnackBars() + ..showSnackBar( + SnackBar(content: Text(message), duration: const Duration(seconds: 2)), + ); + } @override Widget build(BuildContext context) => ValueListenableBuilder( - // Transform [StateController] in to [ValueListenable] - valueListenable: context - .controllerOf() - .select((state) => state.idle), - builder: (context, idle, _) => IgnorePointer( - ignoring: !idle, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 350), - opacity: idle ? 1 : .25, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - FloatingActionButton( - key: ValueKey('add#${idle ? 'enabled' : 'disabled'}'), - onPressed: idle - ? () => context.controllerOf().add(1) - : null, - child: const Icon(Icons.add), - ), - const SizedBox(height: 8), - FloatingActionButton( - key: ValueKey('subtract#${idle ? 'enabled' : 'disabled'}'), - onPressed: idle - ? () => - context.controllerOf().subtract(1) - : null, - child: const Icon(Icons.remove), - ), - ], + // Transform [StateController] in to [ValueListenable] + valueListenable: context.controllerOf().select( + (state) => state.idle, + ), + builder: (context, idle, _) => IgnorePointer( + ignoring: !idle, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 350), + opacity: idle ? 1 : .25, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton( + key: ValueKey('add#${idle ? 'enabled' : 'disabled'}'), + onPressed: idle + ? () => context.controllerOf().add( + 1, + onSuccess: (result) => + showMessage(context, 'Result: $result'), + ) + : null, + child: const Icon(Icons.add), ), - ), + const SizedBox(height: 8), + FloatingActionButton( + key: ValueKey('subtract#${idle ? 'enabled' : 'disabled'}'), + onPressed: idle + ? () => context.controllerOf().subtract( + 1, + onSuccess: (result) => + showMessage(context, 'Result: $result'), + ) + : null, + child: const Icon(Icons.remove), + ), + ], ), - ); + ), + ), + ); } diff --git a/example/test/widget_test.dart b/example/test/widget_test.dart index 85de676..99180b2 100644 --- a/example/test/widget_test.dart +++ b/example/test/widget_test.dart @@ -9,9 +9,6 @@ import 'package:flutter_test/flutter_test.dart'; void main() { testWidgets('placeholder', (tester) async { - expect( - true, - isTrue, - ); + expect(true, isTrue); }); } diff --git a/lib/src/concurrency/droppable_controller_handler.dart b/lib/src/concurrency/droppable_controller_handler.dart index fd90d48..af173dd 100644 --- a/lib/src/concurrency/droppable_controller_handler.dart +++ b/lib/src/concurrency/droppable_controller_handler.dart @@ -7,7 +7,7 @@ import 'package:meta/meta.dart'; /// Droppable controller concurrency handler. /// /// This mixin drops new operations if one is already running. -/// When an operation is in progress, new calls to [handle] return immediately +/// When an operation is in progress, new calls to [handle] return null /// without executing the handler. /// /// Example: @@ -27,22 +27,23 @@ mixin DroppableControllerHandler on Controller { /// Handles a given operation with droppable behavior. /// - /// If an operation is already running, the new one is dropped. + /// If an operation is already running, the new one is dropped and null + /// is returned. @override @protected @mustCallSuper - Future handle( - Future Function() handler, { + Future handle( + Future Function() handler, { Future Function(Object error, StackTrace stackTrace)? error, Future Function()? done, String? name, Map? meta, }) { - // If already locked, drop this operation - if (_$mutex.locked) return Future.value(null); + // If already locked, drop this operation and return null + if (_$mutex.locked) return Future.value(null); - return _$mutex.synchronize( - () => super.handle( + return _$mutex.synchronize( + () => super.handle( handler, error: error, done: done, diff --git a/lib/src/concurrency/sequential_controller_handler.dart b/lib/src/concurrency/sequential_controller_handler.dart index fb5f97c..bdb0e66 100644 --- a/lib/src/concurrency/sequential_controller_handler.dart +++ b/lib/src/concurrency/sequential_controller_handler.dart @@ -30,14 +30,19 @@ mixin SequentialControllerHandler on Controller { @override @protected @mustCallSuper - Future handle( - Future Function() handler, { + Future handle( + Future Function() handler, { Future Function(Object error, StackTrace stackTrace)? error, Future Function()? done, String? name, Map? meta, - }) => _$mutex.synchronize( - () => - super.handle(handler, error: error, done: done, name: name, meta: meta), + }) => _$mutex.synchronize( + () => super.handle( + handler, + error: error, + done: done, + name: name, + meta: meta, + ), ); } diff --git a/lib/src/controller.dart b/lib/src/controller.dart index ef55ae7..308e68e 100644 --- a/lib/src/controller.dart +++ b/lib/src/controller.dart @@ -39,6 +39,12 @@ abstract interface class IController implements Listenable { /// Depending on the implementation, the handler may be executed /// sequentially, concurrently, dropped and etc. /// + /// Returns [Future] where null indicates the operation was + /// cancelled or dropped (e.g., controller disposed or busy). + /// + /// The [handler] can return a value of type [T]. + /// The [error] callback is called when an error occurs. + /// The [done] callback is called when the operation completes. /// The [name] parameter is used to identify the handler. /// The [meta] parameter is used to pass additional /// information to the handler's zone. @@ -47,8 +53,10 @@ 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)? error, + Future Function()? done, String? name, Map? meta, }); @@ -148,16 +156,16 @@ abstract class Controller with ChangeNotifier implements IController { @protected @mustCallSuper @override - Future handle( - Future Function() handler, { + Future handle( + Future Function() handler, { Future Function(Object error, StackTrace stackTrace)? error, Future Function()? done, String? name, Map? meta, }) { - if (isDisposed) return Future.value(null); + if (isDisposed) return Future.value(null); _$processingCalls++; - final completer = Completer(); + final completer = Completer(); var isDone = false; // ignore error callback after done Future onError(Object e, StackTrace st) async { @@ -183,10 +191,10 @@ abstract class Controller with ChangeNotifier implements IController { ); } - void onDone() { + void onDone(T? result) { if (completer.isCompleted) return; _$processingCalls--; - completer.complete(); + completer.complete(result); } final handlerContext = HandlerContextImpl( @@ -198,10 +206,11 @@ abstract class Controller with ChangeNotifier implements IController { runZonedGuarded( () async { + T? result; try { if (isDisposed) return; Controller.observer?.onHandler(handlerContext); - await handler(); + result = await handler(); } on Object catch (error, stackTrace) { await onError(error, stackTrace); } finally { @@ -211,7 +220,7 @@ abstract class Controller with ChangeNotifier implements IController { } on Object catch (error, stackTrace) { this.onError(error, stackTrace); } finally { - onDone(); + onDone(result); } } }, diff --git a/test/unit/state_controller_test.dart b/test/unit/state_controller_test.dart index 4fb7edf..150f2f4 100644 --- a/test/unit/state_controller_test.dart +++ b/test/unit/state_controller_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; void main() => group('StateController', () { _$concurrencyGroup(); + _$genericHandleGroup(); _$exceptionalGroup(); _$assertionGroup(); _$methodsGroup(); @@ -48,15 +49,23 @@ void _$concurrencyGroup() => group('concurrency', () { expect(controller.state, equals(0)); expect(controller.subscribers, equals(0)); expect(controller.isDisposed, isFalse); - final done = Future.wait(>[ - controller.add(1), - controller.subtract(2), - controller.add(4), - ]); + + // Start first operation (will succeed) + final first = controller.add(1); expect(controller.isProcessing, isTrue); - await expectLater(done, completes); + + // These will be dropped (controller is busy) + final dropped1 = controller.subtract(2); + final dropped2 = controller.add(4); + + // Dropped operations complete with null + await expectLater(dropped1, completion(isNull)); + await expectLater(dropped2, completion(isNull)); + + // First operation completes + await expectLater(first, completes); expect(controller.isProcessing, isFalse); - expect(controller.state, equals(1)); + expect(controller.state, equals(1)); // Only first operation executed expect(controller.subscribers, equals(0)); expect(() => controller.addListener(() {}), returnsNormally); expect(controller.subscribers, equals(1)); @@ -192,6 +201,41 @@ void _$assertionGroup() => group('assertion', () { }); }); +void _$genericHandleGroup() => group('generic handle', () { + test('returns value from handler', () async { + final controller = _FakeControllerConcurrent(); + final result = await controller.getValue(42); + expect(result, equals(42)); + controller.dispose(); + }); + + test('sequential returns value', () async { + final controller = _FakeControllerSequential(); + final result = await controller.getValue(100); + expect(result, equals(100)); + controller.dispose(); + }); + + test('droppable returns value when not busy', () async { + final controller = _FakeControllerDroppable(); + final result = await controller.getValue(77); + expect(result, equals(77)); + controller.dispose(); + }); + + test('droppable returns null when busy', () async { + final controller = _FakeControllerDroppable(); + // Start first operation + final first = controller.getValue(1); + // Try second operation while first is running - returns null + final second = await controller.getValue(2); + expect(second, isNull); // Dropped, returns null + // First operation completes with value + await expectLater(first, completion(1)); + controller.dispose(); + }); +}); + void _$methodsGroup() => group('methods', () { test('merge', () async { final controllerOne = _FakeControllerSequential(); @@ -427,6 +471,12 @@ abstract base class _FakeControllerBase extends StateController { await Future.delayed(Duration.zero); setState(state - value); }); + + /// Test generic handle - returns a value + Future getValue(int value) => handle(() async { + await Future.delayed(Duration.zero); + return value; + }); } final class _FakeControllerSequential extends _FakeControllerBase From 45208de9be146a1879462559288c2b260048c4f1 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Fri, 6 Feb 2026 11:19:50 +0400 Subject: [PATCH 16/20] Add select method tests for concurrent controller with various scenarios --- test/unit/state_controller_test.dart | 196 +++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/test/unit/state_controller_test.dart b/test/unit/state_controller_test.dart index 150f2f4..93395d9 100644 --- a/test/unit/state_controller_test.dart +++ b/test/unit/state_controller_test.dart @@ -297,6 +297,124 @@ void _$methodsGroup() => group('methods', () { expect(completer.isCompleted, isTrue); controller.dispose(); }); + + test('select', () async { + final controller = _FakeControllerConcurrent(); + + // Test selector without filter + final selected = controller.select((state) => 'value:$state'); + expect(selected, isA>()); + expect(selected.value, equals('value:0')); + + // Test that selector updates on state change + var listenerCallCount = 0; + void listener() => listenerCallCount++; + selected.addListener(listener); + + await controller.add(5); + expect(selected.value, equals('value:5')); + expect(listenerCallCount, equals(1)); + + await controller.subtract(3); + expect(selected.value, equals('value:2')); + expect(listenerCallCount, equals(2)); + + selected.removeListener(listener); + await controller.add(1); + expect(listenerCallCount, equals(2)); // Listener not called after removal + + controller.dispose(); + }); + + test('select with filter', () async { + final controller = _FakeControllerConcurrent(); + + // Test selector with filter - only notify if value changes + final selected = controller.select( + (state) => state > 0, + (prev, next) => prev != next, // Only notify if boolean value changes + ); + + expect(selected.value, equals(false)); // Initial state is 0 + + var listenerCallCount = 0; + void listener() => listenerCallCount++; + selected.addListener(listener); + + // Change from 0 to 5 (false to true) - should notify + await controller.add(5); + expect(selected.value, equals(true)); + expect(listenerCallCount, equals(1)); + + // Change from 5 to 10 (true to true) - should NOT notify due to filter + await controller.add(5); + expect(selected.value, equals(true)); + expect(listenerCallCount, equals(1)); // No change in boolean value + + // Change from 10 to -5 (true to false) - should notify + await controller.subtract(15); + expect(selected.value, equals(false)); + expect(listenerCallCount, equals(2)); + + selected.removeListener(listener); + controller.dispose(); + }); + + test('select on disposed controller', () async { + final controller = _FakeControllerConcurrent(); + final selected = controller.select((state) => 'value:$state'); + + void listener() {} + selected.addListener(listener); + + controller.dispose(); + + // Should not throw when adding listener to disposed controller's selected + expect(() => selected.addListener(() {}), returnsNormally); + expect(() => selected.removeListener(listener), returnsNormally); + }); + + test('select value accessed without subscription', () async { + final controller = _FakeControllerConcurrent(); + final selected = controller.select((state) => state * 2); + + // Access value before subscribing - should compute on-demand + expect(selected.value, equals(0)); + + await controller.add(5); + expect(selected.value, equals(10)); // Should compute from current state + + controller.dispose(); + }); + + test('select disposes cleanly', () async { + final controller = _FakeControllerConcurrent(); + final selected = controller.select((state) => state * 2); + + var callCount = 0; + void listener() => callCount++; + + // Add and remove listener multiple times + selected.addListener(listener); + await controller.add(1); + expect(callCount, equals(1)); + + selected.removeListener(listener); + await controller.add(2); + expect(callCount, equals(1)); // No change after removal + + // Add listener again + selected.addListener(listener); + await controller.add(3); + expect(callCount, equals(2)); + + // Dispose selected - should clean up subscription + if (selected is ChangeNotifier) { + (selected as ChangeNotifier).dispose(); + } + + controller.dispose(); + }); }); void _$onErrorGroup() => group('onError', () { @@ -455,6 +573,47 @@ void _$onErrorGroup() => group('onError', () { expect(errorCalled, same(1)); expect(doneCalled, same(1)); }); + + test('should call observer onCreate with error handling', () async { + final oldObserver = Controller.observer; + var createCalled = false; + + // Observer that throws on onCreate + Controller.observer = _SimpleTestObserver( + onCreateCallback: () { + createCalled = true; + throw Exception('onCreate error'); + }, + ); + + // Create controller - should not throw despite observer error + final controller = _FakeControllerConcurrent(); + expect(createCalled, isTrue); + + Controller.observer = oldObserver; + controller.dispose(); + }); + + test('should call observer onDispose with error handling', () async { + final controller = _FakeControllerConcurrent(); + + final oldObserver = Controller.observer; + var disposeCalled = false; + + // Observer that throws on onDispose + Controller.observer = _SimpleTestObserver( + onDisposeCallback: () { + disposeCalled = true; + throw Exception('onDispose error'); + }, + ); + + // Dispose controller - should not throw despite observer error + controller.dispose(); + expect(disposeCalled, isTrue); + + Controller.observer = oldObserver; + }); }); }); @@ -516,3 +675,40 @@ final class _FakeControllerConcurrent extends _FakeControllerBase done: () async => onDone?.call(), ); } + +final class _SimpleTestObserver implements IControllerObserver { + _SimpleTestObserver({ + this.onCreateCallback, + this.onDisposeCallback, + this.onErrorCallback, + }); + + final void Function()? onCreateCallback; + final void Function()? onDisposeCallback; + final void Function(Object error)? onErrorCallback; + + @override + void onCreate(Controller controller) { + onCreateCallback?.call(); + } + + @override + void onDispose(Controller controller) { + onDisposeCallback?.call(); + } + + @override + void onHandler(HandlerContext context) {} + + @override + void onStateChanged( + StateController controller, + S prevState, + S nextState, + ) {} + + @override + void onError(Controller controller, Object error, StackTrace stackTrace) { + onErrorCallback?.call(error); + } +} From 379e9e0977a0fa73e8a56cc0ee097874b69b01c5 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Fri, 6 Feb 2026 11:20:30 +0400 Subject: [PATCH 17/20] Remove unused parameter warning in _SimpleTestObserver constructor --- test/unit/state_controller_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/state_controller_test.dart b/test/unit/state_controller_test.dart index 93395d9..204e94e 100644 --- a/test/unit/state_controller_test.dart +++ b/test/unit/state_controller_test.dart @@ -680,7 +680,7 @@ final class _SimpleTestObserver implements IControllerObserver { _SimpleTestObserver({ this.onCreateCallback, this.onDisposeCallback, - this.onErrorCallback, + this.onErrorCallback, // ignore: unused_element_parameter }); final void Function()? onCreateCallback; From 42f17c5600b159fd17e2e8dd2246e59edc8a50e3 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Fri, 6 Feb 2026 11:23:16 +0400 Subject: [PATCH 18/20] Update Flutter version to 3.38.0 in setup actions and workflows --- .github/actions/setup/action.yaml | 8 ++++---- .github/workflows/checkout.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/actions/setup/action.yaml b/.github/actions/setup/action.yaml index e9b5728..5069ea9 100644 --- a/.github/actions/setup/action.yaml +++ b/.github/actions/setup/action.yaml @@ -3,11 +3,11 @@ description: Sets up the Flutter environment inputs: flutter-version: - description: 'The version of Flutter to use' + description: "The version of Flutter to use" required: false - default: '3.24.3' + default: "3.38.0" pub-cache: - description: 'The name of the pub cache variable' + description: "The name of the pub cache variable" required: false default: control @@ -32,7 +32,7 @@ runs: - name: 🚂 Setup Flutter uses: subosito/flutter-action@v2 with: - flutter-version: '${{ inputs.flutter-version }}' + flutter-version: "${{ inputs.flutter-version }}" channel: "stable" - name: 📤 Restore Pub modules diff --git a/.github/workflows/checkout.yml b/.github/workflows/checkout.yml index 77ad758..5caaacc 100644 --- a/.github/workflows/checkout.yml +++ b/.github/workflows/checkout.yml @@ -43,7 +43,7 @@ jobs: - name: 🚂 Setup Flutter and dependencies uses: ./.github/actions/setup with: - flutter-version: 3.24.3 + flutter-version: 3.38.0 - name: 👷 Install Dependencies timeout-minutes: 1 @@ -76,7 +76,7 @@ jobs: - name: 📥 Upload test report uses: actions/upload-artifact@v4 - if: (success() || failure()) && ${{ github.actor != 'dependabot[bot]' }} + if: ${{ (success() || failure()) && github.actor != 'dependabot[bot]' }} with: name: test-results path: reports/tests.json From be0af6eed981448c046b3846299e8ff7b61043e0 Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Fri, 6 Feb 2026 11:26:23 +0400 Subject: [PATCH 19/20] Update Dart SDK constraint to support version 3.9.0 in pubspec files --- example/pubspec.lock | 2 +- example/pubspec.yaml | 2 +- pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/example/pubspec.lock b/example/pubspec.lock index eb8e84d..5b90c59 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -676,5 +676,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.10.0 <4.0.0" + dart: ">=3.9.0 <4.0.0" flutter: ">=3.38.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index a9fcdc1..0100901 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -32,7 +32,7 @@ platforms: version: 1.0.0+1 environment: - sdk: ">=3.10.0 <4.0.0" + sdk: ">=3.9.0 <4.0.0" flutter: ">=3.38.0" dependencies: diff --git a/pubspec.yaml b/pubspec.yaml index 61e4c01..3ece184 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,7 +34,7 @@ platforms: # path: example.png environment: - sdk: ">=3.10.0 <4.0.0" + sdk: ">=3.9.0 <4.0.0" flutter: ">=3.38.0" dependencies: From dbb4abec0eb9ad531142418960971b6982035dda Mon Sep 17 00:00:00 2001 From: Mike Matiunin Date: Fri, 6 Feb 2026 11:27:34 +0400 Subject: [PATCH 20/20] Refactor _FakeControllerBase to remove 'base' keyword for Dart compatibility --- test/unit/handler_context_test.dart | 2 +- test/unit/state_controller_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/handler_context_test.dart b/test/unit/handler_context_test.dart index 8c900f4..bb9f079 100644 --- a/test/unit/handler_context_test.dart +++ b/test/unit/handler_context_test.dart @@ -104,7 +104,7 @@ final class _FakeControllerObserver implements IControllerObserver { } } -abstract base class _FakeControllerBase extends StateController { +abstract class _FakeControllerBase extends StateController { _FakeControllerBase() : super(initialState: false); Future event({ diff --git a/test/unit/state_controller_test.dart b/test/unit/state_controller_test.dart index 204e94e..7ca3c73 100644 --- a/test/unit/state_controller_test.dart +++ b/test/unit/state_controller_test.dart @@ -617,7 +617,7 @@ void _$onErrorGroup() => group('onError', () { }); }); -abstract base class _FakeControllerBase extends StateController { +abstract class _FakeControllerBase extends StateController { _FakeControllerBase({int? initialState}) : super(initialState: initialState ?? 0);