From 52837a2a634cced6dca6e8b4be25e543cd07ccb1 Mon Sep 17 00:00:00 2001 From: andre-djsystem Date: Fri, 13 Dec 2024 00:19:49 -0300 Subject: [PATCH 1/4] Add CommandHistoryManager, CommandStateNotifier, Timeout support for execute method, fix RunningCommand name, exception handling in execute and cancel commands, New tests --- README.md | 90 +++++++++- coverage/lcov.info | 112 +++++++----- lib/src/command.dart | 253 ++++++++++++++++++--------- test/src/command_test.dart | 342 ++++++++++++++++++++++++++++++------- 4 files changed, 610 insertions(+), 187 deletions(-) diff --git a/README.md b/README.md index 6e3f763..dfcccb9 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ - # Result Command **Result Command** is a lightweight package that brings the **Command Pattern** to Flutter, allowing you to encapsulate actions, track their execution state, and manage results with clarity. Perfect for simplifying complex workflows, ensuring robust error handling, and keeping your UI reactive. @@ -10,8 +9,10 @@ 1. **Encapsulation**: Wrap your business logic into reusable commands. 2. **State Tracking**: Automatically manage states like `Idle`, `Running`, `Success`, `Failure`, and `Cancelled`. 3. **Error Handling**: Centralize how you handle successes and failures using the intuitive `Result` API. -4. **Cancellation Support**: Cancel long-running tasks when needed. -5. **UI Integration**: React to command state changes directly in your Flutter widgets. +4. **State History**: Track state transitions with optional metadata. +5. **Timeout Support**: Specify execution time limits for commands. +6. **Cancellation Support**: Cancel long-running tasks when needed. +7. **UI Integration**: React to command state changes directly in your Flutter widgets. --- @@ -22,6 +23,7 @@ At its core, **Result Command** lets you define reusable actions (commands) that - **Executes an action** (e.g., API call, user input validation). - **Tracks its state** (`Idle`, `Running`, etc.). - **Notifies listeners** when the state changes. +- **Maintains a history** of states and transitions. - **Returns a result** (`Success` or `Failure`) using the `Result` API. --- @@ -52,6 +54,30 @@ The state updates automatically as the command executes: - Use `addListener` for manual handling. - Use `ValueListenableBuilder` to bind the state to your UI. +## State History (`CommandHistory`) + +Each command tracks a configurable history of its states, useful for debugging, auditing, and behavioral analysis. + +### Configuring the History + +Set the maximum length of the history when creating a command: + +```dart +final command = Command0( + () async => const Success('Done'), + maxHistoryLength: 5, +); +``` + +### Accessing the History + +Access the history with `stateHistory`: + +```dart +final history = command.stateHistory; +history.forEach(print); +``` + --- ## Examples @@ -83,7 +109,26 @@ fetchGreetingCommand.execute(); --- -### Example 2: Command with Arguments +### Example 2: Simple Command with Timeout + +Commands now support a timeout for execution: + +```dart +final fetchGreetingCommand = Command0( + () async { + await Future.delayed(Duration(seconds: 5)); // Simulating a delay. + return Success('Hello, World!'); + }, +); + +fetchGreetingCommand.execute(timeout: Duration(seconds: 2)).catchError((error) { + print('Error: $error'); // "Error: Command timed out" +}); +``` + +--- + +### Example 3: Command with Arguments Pass input to your command's action: ```dart @@ -112,7 +157,7 @@ calculateSquareCommand.execute(4); --- -### Example 3: Binding State to the UI +### Example 4: Binding State to the UI Use `ValueListenableBuilder` to update the UI automatically: ```dart @@ -151,7 +196,7 @@ Widget build(BuildContext context) { --- -### Example 4: Cancellation +### Example 5: Cancellation Cancel long-running commands gracefully: ```dart @@ -179,6 +224,37 @@ Future.delayed(Duration(seconds: 3), () { --- +### Example 6: Using Stream + +Using Stream to Monitor State Changes: +```dart + + final command = Command0( + () async { + await Future.delayed(Duration(seconds: 2)); + return Result.success("Action completed successfully"); + }, + ); + + command.stateStream.listen((state) { + if (state is IdleCommand) { + print("Command is idle."); + } else if (state is RunningCommand) { + print("Command is running."); + } else if (state is SuccessCommand) { + print("Command succeeded with result: ${state.value}"); + } else if (state is FailureCommand) { + print("Command failed with error: ${state.error}"); + } else if (state is CancelledCommand) { + print("Command was cancelled."); + } + }); + + command.execute(); +``` + +--- + ## Benefits for Your Team - **Simplified Collaboration**: Encapsulation makes it easier for teams to work independently on UI and business logic. @@ -207,4 +283,4 @@ Future.delayed(Duration(seconds: 3), () { We’d love your help in improving **Result Command**! Feel free to report issues, suggest features, or submit pull requests. ---- +--- \ No newline at end of file diff --git a/coverage/lcov.info b/coverage/lcov.info index 55ba111..3c0a3e9 100644 --- a/coverage/lcov.info +++ b/coverage/lcov.info @@ -1,42 +1,76 @@ -SF:lib/src/command.dart -DA:40,1 -DA:48,1 -DA:49,1 -DA:55,0 -DA:56,0 -DA:57,0 -DA:58,0 -DA:63,1 -DA:64,2 -DA:65,1 +SF:lib\src\command.dart +DA:17,1 +DA:21,1 +DA:23,1 +DA:25,4 +DA:38,1 +DA:39,1 +DA:43,1 +DA:44,2 +DA:47,1 +DA:48,2 +DA:49,4 +DA:50,2 +DA:63,3 DA:66,1 -DA:72,0 -DA:73,0 -DA:82,1 -DA:83,2 -DA:84,2 -DA:88,1 -DA:91,0 -DA:94,1 -DA:95,1 -DA:96,1 -DA:97,2 +DA:67,2 +DA:68,2 +DA:69,1 +DA:73,1 +DA:75,2 +DA:76,1 +DA:90,2 +DA:91,1 +DA:92,3 DA:98,1 -DA:110,2 -DA:116,1 -DA:117,4 -DA:126,2 -DA:132,1 -DA:133,4 -DA:142,0 -DA:149,0 -DA:150,0 -DA:156,1 -DA:161,1 -DA:166,0 -DA:171,1 -DA:177,0 -DA:186,1 -LF:38 -LH:26 +DA:99,1 +DA:105,1 +DA:106,2 +DA:108,2 +DA:110,3 +DA:114,2 +DA:115,0 +DA:122,1 +DA:123,2 +DA:124,1 +DA:134,1 +DA:135,2 +DA:138,3 +DA:144,3 +DA:145,2 +DA:146,2 +DA:149,1 +DA:153,4 +DA:154,3 +DA:158,0 +DA:162,1 +DA:163,1 +DA:164,1 +DA:165,2 +DA:166,1 +DA:177,1 +DA:178,2 +DA:179,1 +DA:180,2 +DA:181,1 +DA:191,1 +DA:192,1 +DA:195,1 +DA:196,4 +DA:206,1 +DA:207,1 +DA:210,1 +DA:211,4 +DA:221,1 +DA:222,1 +DA:226,1 +DA:227,4 +DA:233,1 +DA:238,1 +DA:243,1 +DA:248,1 +DA:254,1 +DA:263,1 +LF:72 +LH:70 end_of_record diff --git a/lib/src/command.dart b/lib/src/command.dart index d897b3a..c59bb4f 100644 --- a/lib/src/command.dart +++ b/lib/src/command.dart @@ -1,47 +1,97 @@ import 'dart:async'; - import 'package:flutter/foundation.dart'; import 'package:result_dart/functions.dart'; import 'package:result_dart/result_dart.dart'; -/// A function that defines a command action with no arguments. -/// The action returns an [AsyncResult] of type [T]. -typedef CommandAction0 = AsyncResult Function(); +/// Represents a command history entry with timestamp and metadata. +class CommandHistoryEntry { + /// The state of the command at this point in time. + final CommandState state; -/// A function that defines a command action with one argument of type [A]. -/// The action returns an [AsyncResult] of type [T]. -typedef CommandAction1 = AsyncResult Function(A); + /// The timestamp when the state change occurred. + final DateTime timestamp; -/// A function that defines a command action with two arguments of types [A] and [B]. -/// The action returns an [AsyncResult] of type [T]. -typedef CommandAction2 = AsyncResult Function(A, B); + /// Optional additional metadata about the state change. + final Map? metadata; -/// Represents a command that encapsulates a specific action to be executed. -/// -/// A [Command] maintains state transitions during its lifecycle, such as: -/// - Idle: The command is not running. -/// - Running: The command is currently executing an action. -/// - Success: The action completed successfully with a result. -/// - Failure: The action failed with an error. -/// - Cancelled: The action was explicitly cancelled. -/// -/// Commands can notify listeners about state changes and handle cancellations. -/// -/// Use [Command0] for actions with no arguments, -/// [Command1] for actions with one argument, and [Command2] for actions with two arguments. -/// -/// The generic parameter [T] defines the type of result returned by the command. -abstract class Command extends ChangeNotifier // - implements - ValueListenable> { - /// Creates a new [Command] with an optional [onCancel] callback. - /// - /// The [onCancel] callback is invoked when the command is explicitly cancelled. - Command([this.onCancel]); + CommandHistoryEntry({ + required this.state, + DateTime? timestamp, + this.metadata, + }) : timestamp = timestamp ?? DateTime.now(); + + @override + String toString() { + return 'CommandHistoryEntry(state: $state, timestamp: $timestamp, metadata: $metadata)'; + } +} + +/// Manages the history of command states. +mixin CommandHistoryManager { + /// The maximum length of the state history. + late int maxHistoryLength; + /// A list to maintain the history of state changes. + final List> _stateHistory = []; + + /// Initializes the history manager with a maximum length. + void initializeHistoryManager(int maxHistoryLength) { + this.maxHistoryLength = maxHistoryLength; + } + + /// Provides read-only access to the state change history. + List> get stateHistory => + List.unmodifiable(_stateHistory); + + /// Adds a new entry to the history and ensures the history length limit. + void addHistoryEntry(CommandHistoryEntry entry) { + _stateHistory.add(entry); + if (_stateHistory.length > maxHistoryLength) { + _stateHistory.removeAt(0); + } + } +} + +/// Notifies state changes of the command. +mixin CommandStateNotifier on ChangeNotifier { + /// A [StreamController] that broadcasts state changes to external observers. + final StreamController> _stateController = + StreamController>.broadcast(); + + /// Provides a stream of [CommandState] changes, allowing external listeners + /// to react to state updates in real-time. + Stream> get stateStream => _stateController.stream; + + /// Notifies listeners and external observers of a state change. + void notifyStateChange(CommandState state) { + if (!_stateController.isClosed) { + _stateController.add(state); + notifyListeners(); + } + } + + @override + void dispose() { + _stateController.close(); // Close the stream when the object is disposed. + super.dispose(); + } +} + +/// Represents a generic command with lifecycle and execution. +/// +/// This class supports state management, notifications, and execution +/// with optional cancellation and history tracking. +abstract class Command extends ChangeNotifier + with CommandHistoryManager, CommandStateNotifier + implements ValueListenable> { /// Callback executed when the command is cancelled. final void Function()? onCancel; + Command([this.onCancel, int maxHistoryLength = 10]) : super() { + initializeHistoryManager(maxHistoryLength); + _setValue(IdleCommand(), metadata: {'reason': 'Command created'}); + } + /// The current state of the command. CommandState _value = IdleCommand(); @@ -50,104 +100,131 @@ abstract class Command extends ChangeNotifier // /// Cancels the execution of the command. /// - /// If the command is in the [RuningCommand] state, the [onCancel] callback is invoked, + /// If the command is in the [RunningCommand] state, the [onCancel] callback is invoked, /// and the state transitions to [CancelledCommand]. - void cancel() { - if (value is RuningCommand) { - onCancel?.call(); - _setValue(CancelledCommand()); + void cancel({Map? metadata}) { + if (value is RunningCommand) { + try { + onCancel?.call(); + } catch (e) { + _setValue(FailureCommand(e is Exception ? e : Exception('$e')), + metadata: metadata); + return; + } + _setValue(CancelledCommand(), + metadata: metadata ?? {'reason': 'Manually cancelled'}); } } - /// Sets the current state of the command and notifies listeners. - void _setValue(CommandState newValue) { - if (newValue == _value) return; - _value = newValue; - notifyListeners(); - } - /// Resets the command state to [IdleCommand]. /// /// This clears the current state, allowing the command to be reused. - void reset() { - _setValue(IdleCommand()); + void reset({Map? metadata}) { + _setValue(IdleCommand(), + metadata: metadata ?? {'reason': 'Command reset'}); } /// Executes the given [action] and updates the command state accordingly. /// - /// The state transitions to [RuningCommand] during execution, + /// The state transitions to [RunningCommand] during execution, /// and to either [SuccessCommand] or [FailureCommand] upon completion. /// - /// If the command is cancelled during execution, the result is ignored. - Future _execute(CommandAction0 action) async { - if (value is RuningCommand) return; // Prevent multiple concurrent executions. - _setValue(RuningCommand()); + /// Optionally accepts a [timeout] duration to limit the execution time of the action. + /// If the action times out, the command is cancelled and transitions to [FailureCommand]. + Future _execute(CommandAction0 action, {Duration? timeout}) async { + if (value is RunningCommand) { + return; + } // Prevent multiple concurrent executions. + _setValue(RunningCommand(), metadata: {'status': 'Execution started'}); + bool hasError = false; Result? result; try { - result = await action(); + if (timeout != null) { + result = await action().timeout(timeout, onTimeout: () { + cancel(metadata: {'reason': 'Execution timed out'}); + return Failure(Exception("Command timed out")); + }); + } else { + result = await action(); + } + } catch (e, stackTrace) { + hasError = true; + _setValue(FailureCommand(Exception('Unexpected error: $e')), + metadata: {'error': '$e', 'stackTrace': stackTrace.toString()}); + return; } finally { - if (result == null) { + if ((result == null) && !hasError) { _setValue(IdleCommand()); } else { - final newValue = result // - .map(SuccessCommand.new) - .mapError(FailureCommand.new) - .fold(identity, identity); - if (value is RuningCommand) { - _setValue(newValue); + if (!hasError) { + final newValue = result! + .map(SuccessCommand.new) + .mapError(FailureCommand.new) + .fold(identity, identity); + if (value is RunningCommand) { + _setValue(newValue); + } } } } } + + /// Sets the current state of the command and notifies listeners. + /// + /// Additionally, emits the new state to the [stateStream] for external observers + /// and records the change in the state history with optional metadata. + void _setValue(CommandState newValue, {Map? metadata}) { + if ((newValue == _value) && stateHistory.isNotEmpty) return; + _value = newValue; + addHistoryEntry(CommandHistoryEntry(state: newValue, metadata: metadata)); + notifyStateChange(newValue); + } } /// A command that executes an action without any arguments. -/// -/// The generic parameter [T] defines the type of result returned by the action. final class Command0 extends Command { - /// Creates a [Command0] with the specified [action] and optional [onCancel] callback. - Command0(this._action, {void Function()? onCancel}) : super(onCancel); - /// The action to be executed. final CommandAction0 _action; + /// Creates a [Command0] with the specified [action] and optional [onCancel] callback. + Command0(this._action, {void Function()? onCancel, int maxHistoryLength = 10}) + : super(onCancel, maxHistoryLength); + /// Executes the action and updates the command state. - Future execute() async { - await _execute(() => _action()); + Future execute({Duration? timeout}) async { + await _execute(() => _action(), timeout: timeout); } } /// A command that executes an action with one argument. -/// -/// The generic parameters [T] and [A] define the result type and the argument type, respectively. final class Command1 extends Command { - /// Creates a [Command1] with the specified [action] and optional [onCancel] callback. - Command1(this._action, {void Function()? onCancel}) : super(onCancel); - /// The action to be executed. final CommandAction1 _action; + /// Creates a [Command1] with the specified [action] and optional [onCancel] callback. + Command1(this._action, {void Function()? onCancel, int maxHistoryLength = 10}) + : super(onCancel, maxHistoryLength); + /// Executes the action with the given [argument] and updates the command state. - Future execute(A argument) async { - await _execute(() => _action(argument)); + Future execute(A argument, {Duration? timeout}) async { + await _execute(() => _action(argument), timeout: timeout); } } /// A command that executes an action with two arguments. -/// -/// The generic parameters [T], [A], and [B] define the result type and the types of the two arguments. final class Command2 extends Command { - /// Creates a [Command2] with the specified [action] and optional [onCancel] callback. - Command2(this._action, {void Function()? onCancel}) : super(onCancel); - /// The action to be executed. final CommandAction2 _action; + /// Creates a [Command2] with the specified [action] and optional [onCancel] callback. + Command2(this._action, {void Function()? onCancel, int maxHistoryLength = 10}) + : super(onCancel, maxHistoryLength); + /// Executes the action with the given [argument1] and [argument2], /// and updates the command state. - Future execute(A argument1, B argument2) async { - await _execute(() => _action(argument1, argument2)); + Future execute(A argument1, B argument2, {Duration? timeout}) async { + await _execute(() => _action(argument1, argument2), timeout: timeout); } } @@ -167,8 +244,8 @@ final class CancelledCommand extends CommandState { } /// Represents the running state of a command. -final class RuningCommand extends CommandState { - const RuningCommand(); +final class RunningCommand extends CommandState { + const RunningCommand(); } /// Represents a command that failed to execute successfully. @@ -188,3 +265,15 @@ final class SuccessCommand extends CommandState { /// The result of the successful execution. final T value; } + +/// A function that defines a command action with no arguments. +/// The action returns an [AsyncResult] of type [T]. +typedef CommandAction0 = AsyncResult Function(); + +/// A function that defines a command action with one argument of type [A]. +/// The action returns an [AsyncResult] of type [T]. +typedef CommandAction1 = AsyncResult Function(A); + +/// A function that defines a command action with two arguments of types [A] and [B]. +/// The action returns an [AsyncResult] of type [T]. +typedef CommandAction2 = AsyncResult Function(A, B); diff --git a/test/src/command_test.dart b/test/src/command_test.dart index 45b80b8..1a8c26e 100644 --- a/test/src/command_test.dart +++ b/test/src/command_test.dart @@ -1,67 +1,291 @@ -import 'dart:async'; - import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:result_command/src/command.dart'; import 'package:result_dart/result_dart.dart'; void main() { - test('command ...', () async { - AsyncResult action() async { - await Future.delayed(const Duration(seconds: 2)); - return const Success('success'); - } - - final command = Command0(action); - expect( - command.toStream(), - emitsInOrder([ - isA(), - isA(), - isA(), - ])); - - command.execute(); - }); - test('command1 ...', () async { - AsyncResult action(String value) async { - await Future.delayed(const Duration(seconds: 2)); - return Success(value); - } - - final command = Command1(action); - expect( - command.toStream(), - emitsInOrder([ - isA(), - isA(), - isA(), - ])); - - command.execute('Test'); - }); -} + group('Command tests', () { + test('Command0 executes successfully', () async { + AsyncResult action() async { + await Future.delayed(const Duration(milliseconds: 100)); + return const Success('success'); + } + + final command = Command0(action); + + expect( + command.stateStream, + emitsInOrder([ + isA>(), + isA>(), + ])); + + await command.execute(); + expect(command.value, isA>()); + + // Verify history + final history = command.stateHistory; + expect(history.length, 3); + expect(history[0].state, isA>()); + expect(history[1].state, isA>()); + expect(history[2].state, isA>()); + + // Verify toString() + final historyString = history.map((e) => e.toString()).toList(); + expect(historyString[0], contains('IdleCommand')); + expect(historyString[1], contains('RunningCommand')); + expect(historyString[2], contains('SuccessCommand')); + }); + + test('Command1 executes successfully with argument', () async { + AsyncResult action(String value) async { + await Future.delayed(const Duration(milliseconds: 100)); + return Success(value); + } + + final command = Command1(action); + + expect( + command.stateStream, + emitsInOrder([ + isA>(), + isA>(), + ])); + + await command.execute('Test'); + expect(command.value, isA>()); + expect((command.value as SuccessCommand).value, 'Test'); + + // Verify history + final history = command.stateHistory; + expect(history.length, 3); + expect(history[0].state, isA>()); + expect(history[1].state, isA>()); + expect(history[2].state, isA>()); + + // Verify toString() + final historyString = history.map((e) => e.toString()).toList(); + expect(historyString[0], contains('IdleCommand')); + expect(historyString[1], contains('RunningCommand')); + expect(historyString[2], contains('SuccessCommand')); + }); + + test('Command cancels execution and catches exception', () async { + AsyncResult action() async { + await Future.delayed(const Duration(seconds: 1)); + return const Success('success'); + } + + final command = Command0(action, onCancel: () { + throw Exception('Cancel exception'); + }); + + expect( + command.stateStream, + emitsInOrder([ + isA>(), + isA>(), + ])); + + Future.delayed(const Duration(milliseconds: 100), () => command.cancel()); + + await command.execute(); + expect(command.value, isA>()); + + // Verify history + final history = command.stateHistory; + expect(history.length, 3); + expect(history[0].state, isA>()); + expect(history[1].state, isA>()); + expect(history[2].state, isA>()); + }); + + test('Command fails gracefully', () async { + AsyncResult action() async { + await Future.delayed(const Duration(milliseconds: 100)); + return Failure(Exception('failure')); + } + + final command = Command0(action); + + expect( + command.stateStream, + emitsInOrder([ + isA>(), + isA>(), + ])); + + await command.execute(); + expect(command.value, isA>()); + expect((command.value as FailureCommand).error.toString(), + contains('failure')); + + // Verify history + final history = command.stateHistory; + expect(history.length, 3); + expect(history[0].state, isA>()); + expect(history[1].state, isA>()); + expect(history[2].state, isA>()); + + // Verify toString() + final historyString = history.map((e) => e.toString()).toList(); + expect(historyString[2], contains('FailureCommand')); + }); + + test('Command throws exception', () async { + AsyncResult action() async { + await Future.delayed(const Duration(milliseconds: 100)); + throw Exception('Unexpected exception'); + } + + final command = Command0(action); + + expect( + command.stateStream, + emitsInOrder([ + isA>(), + isA>(), + ])); -// convert ValueListenable in Stream extension - -extension ValueListenableStream on ValueListenable { - Stream toStream() { - late final StreamController controller; - void listener() { - controller.add(value); - } - - controller = StreamController.broadcast( - onListen: () { - controller.add(value); - addListener(listener); - }, - onCancel: () { - removeListener(listener); - controller.close(); - }, - ); - - return controller.stream; - } + await command.execute(); + expect(command.value, isA>()); + expect((command.value as FailureCommand).error.toString(), + contains('Unexpected exception')); + + // Verify history + final history = command.stateHistory; + expect(history.length, 3); + expect(history[0].state, isA>()); + expect(history[1].state, isA>()); + expect(history[2].state, isA>()); + + // Verify toString() + final historyString = history.map((e) => e.toString()).toList(); + expect(historyString[2], contains('FailureCommand')); + expect(historyString[2], contains('Unexpected exception')); + }); + + test('Command with timeout in _execute', () async { + AsyncResult action() async { + await Future.delayed(const Duration(seconds: 2)); + return const Success('success'); + } + + final command = Command0(action); + + expect( + command.stateStream, + emitsInOrder([ + isA>(), + isA>(), + ])); + + await command.execute(timeout: const Duration(milliseconds: 500)); + expect(command.value, isA>()); + }); + + test('Command resets state', () async { + AsyncResult action() async { + await Future.delayed(const Duration(milliseconds: 100)); + return const Success('success'); + } + + final command = Command0(action); + + await command.execute(); + expect(command.value, isA>()); + + command.reset(); + expect(command.value, isA>()); + + // Verify history after reset + final history = command.stateHistory; + expect(history.length, + 4); // Includes initial idle, running, success, and reset idle + expect(history[3].state, isA>()); + + // Verify toString() + final historyString = history.map((e) => e.toString()).toList(); + expect(historyString[3], contains('IdleCommand')); + }); + + test('Command1 handles arguments correctly', () async { + AsyncResult action(String value) async { + await Future.delayed(const Duration(milliseconds: 100)); + return Success(value.toUpperCase()); + } + + final command = Command1(action); + + await command.execute('input'); + expect(command.value, isA>()); + expect((command.value as SuccessCommand).value, 'INPUT'); + + // Verify history + final history = command.stateHistory; + expect(history.length, 3); + expect(history[0].state, isA>()); + expect(history[1].state, isA>()); + expect(history[2].state, isA>()); + + // Verify toString() + final historyString = history.map((e) => e.toString()).toList(); + expect(historyString[2], contains('SuccessCommand')); + }); + + test('Command2 executes successfully with two arguments', () async { + AsyncResult action(String value1, int value2) async { + await Future.delayed(const Duration(milliseconds: 100)); + return Success('$value1 $value2'); + } + + final command = Command2(action); + + await command.execute('Value', 42); + expect(command.value, isA>()); + expect((command.value as SuccessCommand).value, 'Value 42'); + + // Verify history + final history = command.stateHistory; + expect(history.length, 3); + expect(history[0].state, isA>()); + expect(history[1].state, isA>()); + expect(history[2].state, isA>()); + + // Verify toString() + final historyString = history.map((e) => e.toString()).toList(); + expect(historyString[2], contains('SuccessCommand')); + }); + + test('Command history respects maxHistoryLength', () async { + AsyncResult action() async { + await Future.delayed(const Duration(milliseconds: 100)); + return const Success('success'); + } + + final command = Command0(action, maxHistoryLength: 2); + + await command.execute(); + command.reset(); + await command.execute(); + + // Verify history respects max length + final history = command.stateHistory; + expect(history.length, 2); + expect(history[0].state, isA>()); + expect(history[1].state, isA>()); + + // Verify toString() + final historyString = history.map((e) => e.toString()).toList(); + expect(historyString[0], contains('RunningCommand')); + expect(historyString[1], contains('SuccessCommand')); + }); + + test('CommandStateNotifier dispose is called', () { + final notifier = Command0(() async => const Success('disposed')); + + // Dispose the notifier and verify no exceptions occur + expect(() => notifier.dispose(), returnsNormally); + }); + }); } From 621c11a29bdfd5c9213ccc6e3c23bd54906982e9 Mon Sep 17 00:00:00 2001 From: andre-djsystem Date: Fri, 13 Dec 2024 08:26:01 -0300 Subject: [PATCH 2/4] Fix isRunning --- lib/src/command.dart | 2 +- test/src/command_test.dart | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/src/command.dart b/lib/src/command.dart index 88ed8ad..af7df06 100644 --- a/lib/src/command.dart +++ b/lib/src/command.dart @@ -97,7 +97,7 @@ abstract class Command extends ChangeNotifier bool get isIdle => value is IdleCommand; - bool get isRunning => value is RuningCommand; + bool get isRunning => value is RunningCommand; bool get isCancelled => value is CancelledCommand; diff --git a/test/src/command_test.dart b/test/src/command_test.dart index 1a8c26e..2bc4423 100644 --- a/test/src/command_test.dart +++ b/test/src/command_test.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:result_command/src/command.dart'; import 'package:result_dart/result_dart.dart'; From 279123b553dcd8acf25b05860c08c39a4ef39a8d Mon Sep 17 00:00:00 2001 From: andre-djsystem Date: Fri, 13 Dec 2024 11:00:07 -0300 Subject: [PATCH 3/4] New tests for 100% coverage --- coverage/lcov.info | 102 +++++++++++++++++++------------------ lib/src/command.dart | 24 ++++----- test/src/command_test.dart | 90 ++++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+), 62 deletions(-) diff --git a/coverage/lcov.info b/coverage/lcov.info index 3c0a3e9..ae4cb48 100644 --- a/coverage/lcov.info +++ b/coverage/lcov.info @@ -22,55 +22,59 @@ DA:76,1 DA:90,2 DA:91,1 DA:92,3 -DA:98,1 -DA:99,1 -DA:105,1 -DA:106,2 -DA:108,2 -DA:110,3 -DA:114,2 -DA:115,0 -DA:122,1 -DA:123,2 -DA:124,1 +DA:98,3 +DA:100,3 +DA:102,3 +DA:104,3 +DA:106,3 +DA:108,1 +DA:109,1 +DA:115,1 +DA:116,1 +DA:118,2 +DA:120,3 +DA:124,2 +DA:125,1 +DA:132,1 +DA:133,2 DA:134,1 -DA:135,2 -DA:138,3 -DA:144,3 -DA:145,2 -DA:146,2 -DA:149,1 -DA:153,4 +DA:144,1 +DA:145,1 +DA:148,3 DA:154,3 -DA:158,0 -DA:162,1 -DA:163,1 -DA:164,1 -DA:165,2 -DA:166,1 -DA:177,1 -DA:178,2 -DA:179,1 -DA:180,2 -DA:181,1 -DA:191,1 -DA:192,1 -DA:195,1 -DA:196,4 -DA:206,1 -DA:207,1 -DA:210,1 -DA:211,4 -DA:221,1 -DA:222,1 -DA:226,1 -DA:227,4 -DA:233,1 -DA:238,1 -DA:243,1 -DA:248,1 -DA:254,1 -DA:263,1 -LF:72 -LH:70 +DA:155,2 +DA:156,2 +DA:159,1 +DA:163,4 +DA:164,3 +DA:169,1 +DA:170,1 +DA:171,1 +DA:172,1 +DA:173,1 +DA:183,1 +DA:184,6 +DA:187,1 +DA:188,2 +DA:189,1 +DA:199,1 +DA:200,1 +DA:203,1 +DA:204,4 +DA:214,1 +DA:215,1 +DA:218,1 +DA:219,4 +DA:229,1 +DA:230,1 +DA:234,1 +DA:235,4 +DA:241,1 +DA:246,1 +DA:251,1 +DA:256,1 +DA:262,1 +DA:271,1 +LF:76 +LH:76 end_of_record diff --git a/lib/src/command.dart b/lib/src/command.dart index af7df06..27ee810 100644 --- a/lib/src/command.dart +++ b/lib/src/command.dart @@ -148,7 +148,7 @@ abstract class Command extends ChangeNotifier _setValue(RunningCommand(), metadata: {'status': 'Execution started'}); bool hasError = false; - Result? result; + late Result result; try { if (timeout != null) { result = await action().timeout(timeout, onTimeout: () { @@ -164,17 +164,13 @@ abstract class Command extends ChangeNotifier metadata: {'error': '$e', 'stackTrace': stackTrace.toString()}); return; } finally { - if ((result == null) && !hasError) { - _setValue(IdleCommand()); - } else { - if (!hasError) { - final newValue = result! - .map(SuccessCommand.new) - .mapError(FailureCommand.new) - .fold(identity, identity); - if (isRunning) { - _setValue(newValue); - } + if (!hasError) { + final newValue = result + .map(SuccessCommand.new) + .mapError(FailureCommand.new) + .fold(identity, identity); + if (isRunning) { + _setValue(newValue); } } } @@ -185,7 +181,9 @@ abstract class Command extends ChangeNotifier /// Additionally, emits the new state to the [stateStream] for external observers /// and records the change in the state history with optional metadata. void _setValue(CommandState newValue, {Map? metadata}) { - if ((newValue == _value) && stateHistory.isNotEmpty) return; + if (newValue.runtimeType == _value.runtimeType && stateHistory.isNotEmpty) { + return; + } _value = newValue; addHistoryEntry(CommandHistoryEntry(state: newValue, metadata: metadata)); notifyStateChange(newValue); diff --git a/test/src/command_test.dart b/test/src/command_test.dart index 2bc4423..c1eaf79 100644 --- a/test/src/command_test.dart +++ b/test/src/command_test.dart @@ -164,6 +164,37 @@ void main() { expect(historyString[2], contains('Unexpected exception')); }); + test('Command sets state to CancelledCommand with metadata', () async { + AsyncResult action() async { + await Future.delayed(const Duration(seconds: 2)); + return const Success('cancelled with metadata'); + } + + final command = Command0(action); + + command.execute(); + + command.cancel(metadata: {'customKey': 'customValue'}); + expect(command.value, isA>()); + expect(command.stateHistory.last.metadata, + containsPair('customKey', 'customValue')); + }); + + test('Command cancels manually and updates state to CancelledCommand', + () async { + AsyncResult action() async { + await Future.delayed(const Duration(seconds: 2)); + return const Success('manual cancellation'); + } + + final command = Command0(action); + + command.execute(); + command.cancel(); + expect(command.value, isA>()); + expect(command.stateHistory.last.state, isA>()); + }); + test('Command with timeout in _execute', () async { AsyncResult action() async { await Future.delayed(const Duration(seconds: 2)); @@ -256,6 +287,17 @@ void main() { expect(historyString[2], contains('SuccessCommand')); }); + test('Command does not add duplicate states to history', () async { + final command = + Command0(() async => const Success('duplicate state')); + + command.reset(); + command.reset(); + + expect(command.stateHistory.length, 1); + expect(command.stateHistory.first.state, isA>()); + }); + test('Command history respects maxHistoryLength', () async { AsyncResult action() async { await Future.delayed(const Duration(milliseconds: 100)); @@ -286,5 +328,53 @@ void main() { // Dispose the notifier and verify no exceptions occur expect(() => notifier.dispose(), returnsNormally); }); + + test('Command isIdle returns true when state is IdleCommand', () { + final command = Command0(() async => const Success('idle')); + expect(command.isIdle, isTrue); + expect(command.isCancelled, isFalse); + expect(command.isSuccess, isFalse); + expect(command.isFailure, isFalse); + }); + + test('Command isCancelled returns true when state is CancelledCommand', + () async { + AsyncResult action() async { + await Future.delayed(const Duration(seconds: 2)); + return const Success('CancelledCommand'); + } + + final command = Command0(action); + + command.execute(); + command.cancel(); + expect(command.isIdle, isFalse); + expect(command.isCancelled, isTrue); + expect(command.isSuccess, isFalse); + expect(command.isFailure, isFalse); + }); + + test('Command isSuccess returns true when state is SuccessCommand', + () async { + final command = Command0(() async => const Success('success')); + + await command.execute(); + expect(command.isIdle, isFalse); + expect(command.isCancelled, isFalse); + expect(command.isSuccess, isTrue); + expect(command.isFailure, isFalse); + }); + + test('Command isFailure returns true when state is FailureCommand', + () async { + final command = + Command0(() async => Failure(Exception('failure'))); + + await command.execute(); + expect(command.isIdle, isFalse); + expect(command.isCancelled, isFalse); + expect(command.isSuccess, isFalse); + expect(command.isFailure, isTrue); + }); }); } From f2c38bf8ac8e2ae9f782beb152032df236ae17be Mon Sep 17 00:00:00 2001 From: andre-djsystem Date: Fri, 13 Dec 2024 12:28:21 -0300 Subject: [PATCH 4/4] StreamController removed --- lib/src/command.dart | 32 ++--------- test/src/command_test.dart | 106 ++++++++++++++++++++++++------------- 2 files changed, 72 insertions(+), 66 deletions(-) diff --git a/lib/src/command.dart b/lib/src/command.dart index 27ee810..36d7ad9 100644 --- a/lib/src/command.dart +++ b/lib/src/command.dart @@ -52,37 +52,12 @@ mixin CommandHistoryManager { } } -/// Notifies state changes of the command. -mixin CommandStateNotifier on ChangeNotifier { - /// A [StreamController] that broadcasts state changes to external observers. - final StreamController> _stateController = - StreamController>.broadcast(); - - /// Provides a stream of [CommandState] changes, allowing external listeners - /// to react to state updates in real-time. - Stream> get stateStream => _stateController.stream; - - /// Notifies listeners and external observers of a state change. - void notifyStateChange(CommandState state) { - if (!_stateController.isClosed) { - _stateController.add(state); - notifyListeners(); - } - } - - @override - void dispose() { - _stateController.close(); // Close the stream when the object is disposed. - super.dispose(); - } -} - /// Represents a generic command with lifecycle and execution. /// /// This class supports state management, notifications, and execution /// with optional cancellation and history tracking. abstract class Command extends ChangeNotifier - with CommandHistoryManager, CommandStateNotifier + with CommandHistoryManager implements ValueListenable> { /// Callback executed when the command is cancelled. final void Function()? onCancel; @@ -178,15 +153,14 @@ abstract class Command extends ChangeNotifier /// Sets the current state of the command and notifies listeners. /// - /// Additionally, emits the new state to the [stateStream] for external observers - /// and records the change in the state history with optional metadata. + /// Additionally, records the change in the state history with optional metadata. void _setValue(CommandState newValue, {Map? metadata}) { if (newValue.runtimeType == _value.runtimeType && stateHistory.isNotEmpty) { return; } _value = newValue; addHistoryEntry(CommandHistoryEntry(state: newValue, metadata: metadata)); - notifyStateChange(newValue); + notifyListeners(); // Notify listeners using ChangeNotifier. } } diff --git a/test/src/command_test.dart b/test/src/command_test.dart index c1eaf79..07da437 100644 --- a/test/src/command_test.dart +++ b/test/src/command_test.dart @@ -11,15 +11,18 @@ void main() { } final command = Command0(action); + final states = >[]; - expect( - command.stateStream, - emitsInOrder([ - isA>(), - isA>(), - ])); + command.addListener(() { + states.add(command.value); + }); await command.execute(); + expect(states, [ + isA>(), + isA>(), + ]); + expect(command.value, isA>()); // Verify history @@ -43,15 +46,18 @@ void main() { } final command = Command1(action); + final states = >[]; - expect( - command.stateStream, - emitsInOrder([ - isA>(), - isA>(), - ])); + command.addListener(() { + states.add(command.value); + }); await command.execute('Test'); + expect(states, [ + isA>(), + isA>(), + ]); + expect(command.value, isA>()); expect((command.value as SuccessCommand).value, 'Test'); @@ -78,17 +84,20 @@ void main() { final command = Command0(action, onCancel: () { throw Exception('Cancel exception'); }); + final states = >[]; - expect( - command.stateStream, - emitsInOrder([ - isA>(), - isA>(), - ])); + command.addListener(() { + states.add(command.value); + }); Future.delayed(const Duration(milliseconds: 100), () => command.cancel()); await command.execute(); + expect(states, [ + isA>(), + isA>(), + ]); + expect(command.value, isA>()); // Verify history @@ -106,15 +115,18 @@ void main() { } final command = Command0(action); + final states = >[]; - expect( - command.stateStream, - emitsInOrder([ - isA>(), - isA>(), - ])); + command.addListener(() { + states.add(command.value); + }); await command.execute(); + expect(states, [ + isA>(), + isA>(), + ]); + expect(command.value, isA>()); expect((command.value as FailureCommand).error.toString(), contains('failure')); @@ -138,15 +150,17 @@ void main() { } final command = Command0(action); + final states = >[]; - expect( - command.stateStream, - emitsInOrder([ - isA>(), - isA>(), - ])); + command.addListener(() { + states.add(command.value); + }); await command.execute(); + expect(states, [ + isA>(), + isA>(), + ]); expect(command.value, isA>()); expect((command.value as FailureCommand).error.toString(), contains('Unexpected exception')); @@ -202,16 +216,14 @@ void main() { } final command = Command0(action); + final states = >[]; - expect( - command.stateStream, - emitsInOrder([ - isA>(), - isA>(), - ])); + command.addListener(() { + states.add(command.value); + }); await command.execute(timeout: const Duration(milliseconds: 500)); - expect(command.value, isA>()); + expect(states.last, isA>()); }); test('Command resets state', () async { @@ -221,6 +233,11 @@ void main() { } final command = Command0(action); + final states = >[]; + + command.addListener(() { + states.add(command.value); + }); await command.execute(); expect(command.value, isA>()); @@ -246,6 +263,11 @@ void main() { } final command = Command1(action); + final states = >[]; + + command.addListener(() { + states.add(command.value); + }); await command.execute('input'); expect(command.value, isA>()); @@ -270,6 +292,11 @@ void main() { } final command = Command2(action); + final states = >[]; + + command.addListener(() { + states.add(command.value); + }); await command.execute('Value', 42); expect(command.value, isA>()); @@ -305,6 +332,11 @@ void main() { } final command = Command0(action, maxHistoryLength: 2); + final states = >[]; + + command.addListener(() { + states.add(command.value); + }); await command.execute(); command.reset();