diff --git a/README.md b/README.md index 2f1588c..b51e0be 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); +``` + --- ## Getters for State Checks @@ -116,7 +142,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 @@ -145,7 +190,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 @@ -184,7 +229,7 @@ Widget build(BuildContext context) { --- -### Example 4: Cancellation +### Example 5: Cancellation Cancel long-running commands gracefully: ```dart @@ -212,6 +257,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. @@ -240,4 +316,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..ae4cb48 100644 --- a/coverage/lcov.info +++ b/coverage/lcov.info @@ -1,42 +1,80 @@ -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:98,1 -DA:110,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,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:117,4 -DA:126,2 +DA:118,2 +DA:120,3 +DA:124,2 +DA:125,1 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:133,2 +DA:134,1 +DA:144,1 +DA:145,1 +DA:148,3 +DA:154,3 +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:177,0 -DA:186,1 -LF:38 -LH:26 +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 7a29c5c..36d7ad9 100644 --- a/lib/src/command.dart +++ b/lib/src/command.dart @@ -1,53 +1,78 @@ 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); + } + } +} + +/// 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 + 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(); bool get isIdle => value is IdleCommand; - bool get isRunning => value is RuningCommand; + bool get isRunning => value is RunningCommand; bool get isCancelled => value is CancelledCommand; @@ -60,47 +85,62 @@ 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() { + void cancel({Map? metadata}) { if (isRunning) { - onCancel?.call(); - _setValue(CancelledCommand()); + 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 (isRunning) 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 (isRunning) { + return; + } // Prevent multiple concurrent executions. + _setValue(RunningCommand(), metadata: {'status': 'Execution started'}); + bool hasError = false; - Result? result; + late Result result; try { - result = await action(); - } finally { - if (result == null) { - _setValue(IdleCommand()); + if (timeout != null) { + result = await action().timeout(timeout, onTimeout: () { + cancel(metadata: {'reason': 'Execution timed out'}); + return Failure(Exception("Command timed out")); + }); } else { - final newValue = result // + result = await action(); + } + } catch (e, stackTrace) { + hasError = true; + _setValue(FailureCommand(Exception('Unexpected error: $e')), + metadata: {'error': '$e', 'stackTrace': stackTrace.toString()}); + return; + } finally { + if (!hasError) { + final newValue = result .map(SuccessCommand.new) .mapError(FailureCommand.new) .fold(identity, identity); @@ -110,54 +150,63 @@ abstract class Command extends ChangeNotifier // } } } + + /// Sets the current state of the command and notifies listeners. + /// + /// 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)); + notifyListeners(); // Notify listeners using ChangeNotifier. + } } /// 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); } } @@ -177,8 +226,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. @@ -198,3 +247,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..07da437 100644 --- a/test/src/command_test.dart +++ b/test/src/command_test.dart @@ -1,67 +1,412 @@ -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); + final states = >[]; + + command.addListener(() { + states.add(command.value); + }); + + await command.execute(); + expect(states, [ + isA>(), + isA>(), + ]); + + 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); + final states = >[]; + + 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'); + + // 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'); + }); + final states = >[]; + + 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 + 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); + final states = >[]; + + 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')); + + // 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); + final states = >[]; + + 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')); + + // 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 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); -// 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; - } + 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)); + return const Success('success'); + } + + final command = Command0(action); + final states = >[]; + + command.addListener(() { + states.add(command.value); + }); + + await command.execute(timeout: const Duration(milliseconds: 500)); + expect(states.last, isA>()); + }); + + test('Command resets state', () async { + AsyncResult action() async { + await Future.delayed(const Duration(milliseconds: 100)); + return const Success('success'); + } + + final command = Command0(action); + final states = >[]; + + command.addListener(() { + states.add(command.value); + }); + + 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); + final states = >[]; + + command.addListener(() { + states.add(command.value); + }); + + 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); + final states = >[]; + + command.addListener(() { + states.add(command.value); + }); + + 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 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)); + return const Success('success'); + } + + final command = Command0(action, maxHistoryLength: 2); + final states = >[]; + + command.addListener(() { + states.add(command.value); + }); + + 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); + }); + + 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); + }); + }); }