Skip to content

Commit

Permalink
Add the ability to abort transitions from the onBeforeTransition ev…
Browse files Browse the repository at this point in the history
…ent. (#10)
  • Loading branch information
renggli committed Jul 3, 2022
1 parent bb30885 commit 36dfe9e
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 37 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 3.3.0 (unpublished)

* Dart 2.17 requirement.
* Add the ability to abort transitions from the `onBeforeTransition` event.

## 3.2.0

Expand Down
25 changes: 21 additions & 4 deletions lib/src/events.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import 'machine.dart';
import 'state.dart';

/// Transition event.
class TransitionEvent<T> {
abstract class TransitionEvent<T> {
/// Constructs a transition event.
TransitionEvent(this.machine, this.source, this.target,
[this.errors = const []]);
TransitionEvent(this.machine, this.source, this.target);

/// The state machine triggering this event.
final Machine<T> machine;
Expand All @@ -15,13 +14,31 @@ class TransitionEvent<T> {

/// The target state of the transition.
final State<T>? target;
}

/// Transition event emitted before a transition starts, can be aborted.
class BeforeTransitionEvent<T> extends TransitionEvent<T> {
BeforeTransitionEvent(super.machine, super.source, super.target);

bool _aborted = false;

/// Returns true, if the transition was aborted.
bool get isAborted => _aborted;

/// Marks the transition as aborted.
void abort() => _aborted = true;
}

/// Transition event emitted after a transition completed.
class AfterTransitionEvent<T> extends TransitionEvent<T> {
AfterTransitionEvent(super.machine, super.source, super.target, this.errors);

/// List of errors triggered during the transition.
final List<Object> errors;
}

/// Transition error thrown at the end of a failing transition.
class TransitionError<T> extends TransitionEvent<T> implements Exception {
class TransitionError<T> extends AfterTransitionEvent<T> implements Exception {
/// Constructs a transition error.
TransitionError(super.machine, super.source, super.target, super.errors);
}
31 changes: 20 additions & 11 deletions lib/src/machine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ class Machine<T> {
State<T>? _current;

/// Stream controller for events triggered before each transition.
final StreamController<TransitionEvent<T>> _beforeTransitionController =
final StreamController<BeforeTransitionEvent<T>> _beforeTransitionController =
StreamController.broadcast(sync: true);

/// Stream controller for events triggered after each transition.
final StreamController<TransitionEvent<T>> _afterTransitionController =
final StreamController<AfterTransitionEvent<T>> _afterTransitionController =
StreamController.broadcast(sync: true);

/// Internal helper that can be overridden by subclasses to customize the
Expand Down Expand Up @@ -65,11 +65,11 @@ class Machine<T> {
identifier, 'identifier', 'Unknown identifier');

/// Returns an event stream that is triggered before each transition.
Stream<TransitionEvent<T>> get onBeforeTransition =>
Stream<BeforeTransitionEvent<T>> get onBeforeTransition =>
_beforeTransitionController.stream;

/// Returns an event stream that is triggered after each transition.
Stream<TransitionEvent<T>> get onAfterTransition =>
Stream<AfterTransitionEvent<T>> get onAfterTransition =>
_afterTransitionController.stream;

/// Returns the current state of this machine, or `null`.
Expand All @@ -81,10 +81,14 @@ class Machine<T> {
/// Throws an [ArgumentError], if the state is unknown or from a different
/// [Machine].
///
/// Triggers an [onBeforeTransition] event before the transition starts, and
/// an [onAfterTransition] after the transition completes. Errors during the
/// transition phase are collected, included in the [onAfterTransition] event,
/// and rethrown at the end of the state change as a single [TransitionError].
/// Triggers an [onBeforeTransition] event before the transition starts. This
/// gives listeners the opportunity to abort the transition before it is
/// started.
///
/// Triggers an [onAfterTransition] event after completion. Errors during the
/// transition phase are collected and included in the [onAfterTransition]
/// event. A single [TransitionError] is rethrown at the end of the state
/// change.
set current(/*State<T>|T|Null*/ Object? state) {
// Find and validate the target state.
final target = state is State<T>
Expand All @@ -97,10 +101,15 @@ class Machine<T> {
if (target != null && target.machine != this) {
throw ArgumentError.value(state, 'state', 'Invalid machine');
}
// Notify listeners about the upcoming transition.
// Notify listeners about the upcoming transition. Check if any of the
// listeners wish to abort the transition.
final source = _current;
if (_beforeTransitionController.hasListener) {
_beforeTransitionController.add(TransitionEvent<T>(this, source, target));
final transitionEvent = BeforeTransitionEvent<T>(this, source, target);
_beforeTransitionController.add(transitionEvent);
if (transitionEvent.isAborted) {
return;
}
}
// Deactivate the source state.
final errors = <Object>[];
Expand Down Expand Up @@ -128,7 +137,7 @@ class Machine<T> {
// Notify listeners about the completed transition.
if (_afterTransitionController.hasListener) {
_afterTransitionController
.add(TransitionEvent<T>(this, source, target, errors));
.add(AfterTransitionEvent<T>(this, source, target, errors));
}
// Rethrow any transition errors at the end.
if (errors.isNotEmpty) {
Expand Down
91 changes: 69 additions & 22 deletions test/statemachine_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,29 @@ import 'dart:async';
import 'package:statemachine/statemachine.dart';
import 'package:test/test.dart';

TypeMatcher<TransitionEvent<T>> isTransitionEvent<T>(
{required Machine<T> machine,
State<T>? source,
State<T>? target,
List<Object> errors = const []}) =>
isA<TransitionEvent<T>>()
.having((error) => error.machine, 'machine', machine)
.having((error) => error.source, 'source', source)
.having((error) => error.target, 'target', target)
.having((error) => error.errors, 'errors', errors);
TypeMatcher<BeforeTransitionEvent<T>> isBeforeTransitionEvent<T>({
required Machine<T> machine,
State<T>? source,
State<T>? target,
bool? isAborted = false,
}) =>
isA<BeforeTransitionEvent<T>>()
.having((event) => event.machine, 'machine', machine)
.having((event) => event.source, 'source', source)
.having((event) => event.target, 'target', target)
.having((event) => event.isAborted, 'isAborted', isAborted);

TypeMatcher<AfterTransitionEvent<T>> isAfterTransitionEvent<T>({
required Machine<T> machine,
State<T>? source,
State<T>? target,
List<Object> errors = const [],
}) =>
isA<AfterTransitionEvent<T>>()
.having((event) => event.machine, 'machine', machine)
.having((event) => event.source, 'source', source)
.having((event) => event.target, 'target', target)
.having((event) => event.errors, 'errors', errors);

void main() {
late Machine<int> machine;
Expand Down Expand Up @@ -250,12 +263,18 @@ void main() {
late State<Symbol> other;
late State<Symbol> entryError;
late State<Symbol> exitError;
late State<Symbol> entryAbort;
late State<Symbol> exitAbort;
setUp(() {
machine = Machine<Symbol>();
machine.onBeforeTransition.forEach((event) {
expect(event.machine, machine);
expect(event.source, machine.current);
expect(event.errors, isEmpty);
expect(event.isAborted, isFalse);
if (event.target == entryAbort || event.source == exitAbort) {
event.abort();
expect(event.isAborted, isTrue);
}
});
machine.onAfterTransition.forEach((event) {
expect(event.machine, machine);
Expand All @@ -269,19 +288,21 @@ void main() {
exitError = machine.newState(#exitError);
exitError.onExit(() => throw 'Exit 1');
exitError.onExit(() => throw 'Exit 2');
entryAbort = machine.newState(#entryAbort);
exitAbort = machine.newState(#exitAbort);
machine.start();
});
test('no errors', () {
expectLater(
machine.onBeforeTransition,
emits(isTransitionEvent(
emits(isBeforeTransitionEvent(
machine: machine,
source: start,
target: other,
)));
expectLater(
machine.onAfterTransition,
emits(isTransitionEvent(
emits(isAfterTransitionEvent(
machine: machine,
source: start,
target: other,
Expand All @@ -293,22 +314,22 @@ void main() {
machine.current = other;
expectLater(
machine.onBeforeTransition,
emits(isTransitionEvent(
emits(isBeforeTransitionEvent(
machine: machine,
source: other,
target: entryError,
)));
expectLater(
machine.onAfterTransition,
emits(isTransitionEvent(
emits(isAfterTransitionEvent(
machine: machine,
source: other,
target: entryError,
errors: ['Entry 1', 'Entry 2'],
)));
expect(
() => machine.current = entryError,
throwsA(isTransitionEvent(
throwsA(isAfterTransitionEvent(
machine: machine,
source: other,
target: entryError,
Expand All @@ -320,22 +341,22 @@ void main() {
machine.current = exitError;
expectLater(
machine.onBeforeTransition,
emits(isTransitionEvent(
emits(isBeforeTransitionEvent(
machine: machine,
source: exitError,
target: other,
)));
expectLater(
machine.onAfterTransition,
emits(isTransitionEvent(
emits(isAfterTransitionEvent(
machine: machine,
source: exitError,
target: other,
errors: ['Exit 1', 'Exit 2'],
)));
expect(
() => machine.current = other,
throwsA(isTransitionEvent(
throwsA(isAfterTransitionEvent(
machine: machine,
source: exitError,
target: other,
Expand All @@ -347,28 +368,54 @@ void main() {
machine.current = exitError;
expectLater(
machine.onBeforeTransition,
emits(isTransitionEvent(
emits(isBeforeTransitionEvent(
machine: machine,
source: exitError,
target: entryError,
)));
expectLater(
machine.onAfterTransition,
emits(isTransitionEvent(
emits(isAfterTransitionEvent(
machine: machine,
source: exitError,
target: entryError,
errors: ['Exit 1', 'Exit 2', 'Entry 1', 'Entry 2'],
)));
expect(
() => machine.current = entryError,
throwsA(isTransitionEvent(
throwsA(isAfterTransitionEvent(
machine: machine,
source: exitError,
target: entryError,
errors: ['Exit 1', 'Exit 2', 'Entry 1', 'Entry 2'],
)));
expect(machine.current, entryError);
});
test('abort on entry', () {
machine.current = other;
expectLater(
machine.onBeforeTransition,
emits(isBeforeTransitionEvent(
machine: machine,
source: other,
target: entryAbort,
isAborted: true,
)));
machine.current = entryAbort;
expect(machine.current, other);
});
test('abort on exit', () {
machine.current = exitAbort;
expectLater(
machine.onBeforeTransition,
emits(isBeforeTransitionEvent(
machine: machine,
source: exitAbort,
target: other,
isAborted: true,
)));
machine.current = other;
expect(machine.current, exitAbort);
});
});
}

0 comments on commit 36dfe9e

Please sign in to comment.