From 0d5944589e1fb6851b184ea81b53e93551cc2d1d Mon Sep 17 00:00:00 2001 From: Derek Clarkson Date: Sun, 24 Mar 2019 22:00:58 +1100 Subject: [PATCH] Final and background states. --- Machinus.playground/Contents.swift | 8 +- Machinus.xcodeproj/project.pbxproj | 22 ++-- Machinus/Info.plist | 2 +- Machinus/Machinus.swift | 47 +++++--- Machinus/MachinusError.swift | 3 + Machinus/{State.swift => StateConfig.swift} | 69 +++++++---- Machinus/StateMachine.swift | 5 +- MachinusTests/MachinusTests.swift | 60 ++++++++-- MachinusTests/NotificationTests.swift | 15 ++- MachinusTests/StateConfigTests.swift | 92 +++++++++++++++ MachinusTests/StateTests.swift | 62 ---------- README.md | 124 ++++++++++++-------- 12 files changed, 325 insertions(+), 184 deletions(-) rename Machinus/{State.swift => StateConfig.swift} (71%) create mode 100644 MachinusTests/StateConfigTests.swift delete mode 100644 MachinusTests/StateTests.swift diff --git a/Machinus.playground/Contents.swift b/Machinus.playground/Contents.swift index 1123ecd..6b83c1d 100644 --- a/Machinus.playground/Contents.swift +++ b/Machinus.playground/Contents.swift @@ -11,19 +11,19 @@ enum UserState: StateIdentifier { case loggedOut } -let initialising = State(withIdentifier: .initialising, allowedTransitions: .registering, .loggedOut) +let initialising = StateConfig(identifier: .initialising, allowedTransitions: .registering, .loggedOut) -let registering = State(withIdentifier: .registering, allowedTransitions: .loggedIn) +let registering = StateConfig(identifier: .registering, allowedTransitions: .loggedIn) .afterEntering { _ in registerUser() } -let loggedIn = State(withIdentifier: .loggedIn, allowedTransitions: .loggedOut) +let loggedIn = StateConfig(identifier: .loggedIn, allowedTransitions: .loggedOut) .afterEntering { _ in displayUserHome() } -let loggedOut = State(withIdentifier: .loggedOut, allowedTransitions: .loggedIn) +let loggedOut = StateConfig(identifier: .loggedOut, allowedTransitions: .loggedIn) .afterEntering { _ in displayEnterPassword() } diff --git a/Machinus.xcodeproj/project.pbxproj b/Machinus.xcodeproj/project.pbxproj index 2e5a268..8f59288 100644 --- a/Machinus.xcodeproj/project.pbxproj +++ b/Machinus.xcodeproj/project.pbxproj @@ -13,9 +13,9 @@ 6C920DD5221187630084E6E8 /* Machinus.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6C920DCB221187630084E6E8 /* Machinus.framework */; }; 6C920DDA221187630084E6E8 /* MachinusTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C920DD9221187630084E6E8 /* MachinusTests.swift */; }; 6C920DDC221187630084E6E8 /* Machinus.h in Headers */ = {isa = PBXBuildFile; fileRef = 6C920DCE221187630084E6E8 /* Machinus.h */; settings = {ATTRIBUTES = (Public, ); }; }; - 6C920DE622118BBF0084E6E8 /* State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C920DE522118BBF0084E6E8 /* State.swift */; }; + 6C920DE622118BBF0084E6E8 /* StateConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C920DE522118BBF0084E6E8 /* StateConfig.swift */; }; 6C920DE822118BCB0084E6E8 /* Machinus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C920DE722118BCB0084E6E8 /* Machinus.swift */; }; - 6C920DEA22118CB20084E6E8 /* StateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C920DE922118CB20084E6E8 /* StateTests.swift */; }; + 6C920DEA22118CB20084E6E8 /* StateConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C920DE922118CB20084E6E8 /* StateConfigTests.swift */; }; 6C920DEE221190B60084E6E8 /* Nimble.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6C920DED221190B60084E6E8 /* Nimble.framework */; }; 6CFBB324221D8B08007E6722 /* Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CFBB323221D8B08007E6722 /* Notifications.swift */; }; /* End PBXBuildFile section */ @@ -42,9 +42,9 @@ 6C920DD4221187630084E6E8 /* MachinusTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MachinusTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 6C920DD9221187630084E6E8 /* MachinusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MachinusTests.swift; sourceTree = ""; }; 6C920DDB221187630084E6E8 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 6C920DE522118BBF0084E6E8 /* State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = State.swift; sourceTree = ""; }; + 6C920DE522118BBF0084E6E8 /* StateConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateConfig.swift; sourceTree = ""; }; 6C920DE722118BCB0084E6E8 /* Machinus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Machinus.swift; sourceTree = ""; }; - 6C920DE922118CB20084E6E8 /* StateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateTests.swift; sourceTree = ""; }; + 6C920DE922118CB20084E6E8 /* StateConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StateConfigTests.swift; sourceTree = ""; }; 6C920DEB221190110084E6E8 /* Cartfile.private */ = {isa = PBXFileReference; lastKnownFileType = text; path = Cartfile.private; sourceTree = ""; }; 6C920DED221190B60084E6E8 /* Nimble.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Nimble.framework; path = Carthage/Build/iOS/Nimble.framework; sourceTree = ""; }; 6CFBB323221D8B08007E6722 /* Notifications.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifications.swift; sourceTree = ""; }; @@ -102,7 +102,7 @@ 6C920DE722118BCB0084E6E8 /* Machinus.swift */, 6C258BE022439DA40033F66E /* MachinusError.swift */, 6CFBB323221D8B08007E6722 /* Notifications.swift */, - 6C920DE522118BBF0084E6E8 /* State.swift */, + 6C920DE522118BBF0084E6E8 /* StateConfig.swift */, 6C258BE222439DEF0033F66E /* StateMachine.swift */, ); path = Machinus; @@ -111,10 +111,10 @@ 6C920DD8221187630084E6E8 /* MachinusTests */ = { isa = PBXGroup; children = ( - 6C920DD9221187630084E6E8 /* MachinusTests.swift */, 6C920DDB221187630084E6E8 /* Info.plist */, - 6C920DE922118CB20084E6E8 /* StateTests.swift */, + 6C920DD9221187630084E6E8 /* MachinusTests.swift */, 6C83733A222D3C39001C57E6 /* NotificationTests.swift */, + 6C920DE922118CB20084E6E8 /* StateConfigTests.swift */, ); path = MachinusTests; sourceTree = ""; @@ -261,7 +261,7 @@ files = ( 6C258BE122439DA40033F66E /* MachinusError.swift in Sources */, 6CFBB324221D8B08007E6722 /* Notifications.swift in Sources */, - 6C920DE622118BBF0084E6E8 /* State.swift in Sources */, + 6C920DE622118BBF0084E6E8 /* StateConfig.swift in Sources */, 6C258BE322439DEF0033F66E /* StateMachine.swift in Sources */, 6C920DE822118BCB0084E6E8 /* Machinus.swift in Sources */, ); @@ -273,7 +273,7 @@ files = ( 6C920DDA221187630084E6E8 /* MachinusTests.swift in Sources */, 6C83733B222D3C39001C57E6 /* NotificationTests.swift in Sources */, - 6C920DEA22118CB20084E6E8 /* StateTests.swift in Sources */, + 6C920DEA22118CB20084E6E8 /* StateConfigTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -419,7 +419,7 @@ DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 3MHZ7974Y8; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 0.0.4; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Machinus/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -446,7 +446,7 @@ DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 3MHZ7974Y8; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 1; + DYLIB_CURRENT_VERSION = 0.0.4; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = Machinus/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; diff --git a/Machinus/Info.plist b/Machinus/Info.plist index e1fe4cf..f5411b1 100644 --- a/Machinus/Info.plist +++ b/Machinus/Info.plist @@ -15,7 +15,7 @@ CFBundlePackageType FMWK CFBundleShortVersionString - 1.0 + 0.0.4 CFBundleVersion $(CURRENT_PROJECT_VERSION) diff --git a/Machinus/Machinus.swift b/Machinus/Machinus.swift index 57f4e1c..87358f6 100644 --- a/Machinus/Machinus.swift +++ b/Machinus/Machinus.swift @@ -11,8 +11,8 @@ import os /// A generalised implementation of the `StateMachine` protocol. public class Machinus: StateMachine where T: StateIdentifier { - private var current: State - private var states: [State] + private var current: StateConfig + private var states: [StateConfig] private var beforeTransition: ((T, T) -> Void)? private var afterTransition: ((T, T) -> Void)? @@ -31,9 +31,9 @@ public class Machinus: StateMachine where T: StateIdentifier { return current.identifier } - public var sameStateAsError: Bool = false - - public var postNotifications: Bool = false + public var enableSameStateError = false + public var enableFinalStateTransitionError = false + public var postNotifications = false public var transitionQ: DispatchQueue = DispatchQueue.main @@ -44,9 +44,14 @@ public class Machinus: StateMachine where T: StateIdentifier { return } + // Validate the state is known and not final + if state(forIdentifier: backgroundState).isFinal { + fatalError("More than one state is using the same identifier") + } + os_log("🤖 Setting .%@ as the background state.", type: .debug, String(describing: backgroundState)) - _ = state(forIdentifier: backgroundState) + // Adding notification watching for backgrounding and foregrounding. backgroundObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in guard let self = self else { return } os_log("🤖 Transitioning to background state .%@", type: .debug, String(describing: backgroundState)) @@ -71,13 +76,13 @@ public class Machinus: StateMachine where T: StateIdentifier { } public init(name: String = UUID().uuidString + "<" + String(describing: T.self) + ">", - withStates firstState: State, - _ secondState: State, - _ thirdState: State, - _ otherStates: State...) { + withStates firstState: StateConfig, + _ secondState: StateConfig, + _ thirdState: StateConfig, + _ otherStates: StateConfig...) { self.name = name - let states:[State] = [firstState, secondState, thirdState] + otherStates + let states:[StateConfig] = [firstState, secondState, thirdState] + otherStates self.states = states self.current = firstState @@ -146,9 +151,9 @@ public class Machinus: StateMachine where T: StateIdentifier { } - private func state(forIdentifier identifier: T) -> State { + private func state(forIdentifier identifier: T) -> StateConfig { guard let state = states.first(where: { $0.identifier == identifier }) else { - fatalError("🤖 .\(identifier) is an unknown state.") + fatalError("🤖 State .\(identifier) not registered.") } return state } @@ -158,7 +163,7 @@ public class Machinus: StateMachine where T: StateIdentifier { return state == backgroundState || current == backgroundState } - private func preflightTransition(toState toStateIdentifier: T, completion: @escaping (_ previousState: T?, _ error: Error?) -> Void) -> State? { + private func preflightTransition(toState toStateIdentifier: T, completion: @escaping (_ previousState: T?, _ error: Error?) -> Void) -> StateConfig? { os_log("🤖 Pre-flighting transition ...", type: .debug) @@ -167,16 +172,24 @@ public class Machinus: StateMachine where T: StateIdentifier { // If the state is the same state then do nothing. guard current != toStateIdentifier else { os_log("🤖 Already in state", type: .debug) - completion(nil, sameStateAsError ? MachinusError.alreadyInState : nil) + completion(nil, enableSameStateError ? MachinusError.alreadyInState : nil) return nil } // Ignore the rest of the pre-flight if we are about to transition to or from the background state. if isBackgroundTransition(toOrFromState: toStateIdentifier) { - os_log("🤖 Transitioning to or from background state .%@", type: .debug, String(describing: backgroundState)) + os_log("🤖 Transitioning to or from background state .%@, ignoring allowed and barriers.", type: .debug, String(describing: backgroundState!)) return newState } + // Check for a final state transition + if current.isFinal { + os_log("🤖 Final state, cannot transition", type: .error) + completion(nil, enableFinalStateTransitionError ? MachinusError.finalState : nil) + return nil + } + + guard newState.transitionBarrier() else { os_log("🤖 Transition barrier blocked transition", type: .debug) completion(nil, MachinusError.transitionDenied) @@ -192,7 +205,7 @@ public class Machinus: StateMachine where T: StateIdentifier { return newState } - private func executeTransition(toState: State, completion: @escaping (_ previousState: T?, _ error: Error?) -> Void) { + private func executeTransition(toState: StateConfig, completion: @escaping (_ previousState: T?, _ error: Error?) -> Void) { os_log("🤖 Executing transition ...", type: .debug) diff --git a/Machinus/MachinusError.swift b/Machinus/MachinusError.swift index 10a5205..f8bdc4e 100644 --- a/Machinus/MachinusError.swift +++ b/Machinus/MachinusError.swift @@ -20,4 +20,7 @@ public enum MachinusError: Error { /// Thrown when there is no dynamic transition defined on the current state. case dynamicTransitionNotDefined + + /// Thrown if youu attempt to transition from a final state. + case finalState } diff --git a/Machinus/State.swift b/Machinus/StateConfig.swift similarity index 71% rename from Machinus/State.swift rename to Machinus/StateConfig.swift index e1d07e8..86cf5f2 100644 --- a/Machinus/State.swift +++ b/Machinus/StateConfig.swift @@ -14,22 +14,23 @@ public protocol StateIdentifier: Hashable {} /** Defines the setup of an individual state. */ -open class State where T: StateIdentifier { +open class StateConfig where T: StateIdentifier { /// The unique identifier used to define this state. This will be used in all `Equatable` tests. public let identifier: T + // Accessed during transitions + private(set) var isFinal = false + private(set) var beforeLeaving: ((T) -> Void)? + private(set) var afterLeaving: ((T) -> Void)? + private(set) var beforeEntering: ((T) -> Void)? + private(set) var afterEntering: ((T) -> Void)? + private(set) var dynamicTransition: (() -> T)? + private(set) var transitionBarrier: () -> Bool = { true } + private let allowedTransitions: [T] private var isGlobal = false - var beforeLeaving: ((T) -> Void)? - var afterLeaving: ((T) -> Void)? - var beforeEntering: ((T) -> Void)? - var afterEntering: ((T) -> Void)? - var dynamicTransition: (() -> T)? - - var transitionBarrier: () -> Bool = { true } - // MARK: - Lifecycle /** @@ -38,7 +39,7 @@ open class State where T: StateIdentifier { - Parameter identifier: The unique identifier of the state. - Parameter allowedTransitions: A list of state identifiers for states that can be transitioned to. */ - public init(withIdentifier identifier: T, allowedTransitions: T...) { + public init(identifier: T, allowedTransitions: T...) { self.identifier = identifier self.allowedTransitions = allowedTransitions } @@ -48,8 +49,8 @@ open class State where T: StateIdentifier { - Parameter toState: The state that is being queried. - Returns: true if a transition from this state to the other state is allowed. - */ - func canTransition(toState: State) -> Bool { + */ + func canTransition(toState: StateConfig) -> Bool { return toState.isGlobal || allowedTransitions.contains(toState.identifier) } @@ -77,6 +78,7 @@ open class State where T: StateIdentifier { */ @discardableResult public func beforeLeaving(_ beforeLeaving: @escaping (_ nextState: T) -> Void) -> Self { self.beforeLeaving = beforeLeaving + validateFinalState() return self } @@ -89,6 +91,7 @@ open class State where T: StateIdentifier { */ @discardableResult public func afterLeaving(_ afterLeaving: @escaping (_ nextState: T) -> Void) -> Self { self.afterLeaving = afterLeaving + validateFinalState() return self } @@ -121,9 +124,10 @@ open class State where T: StateIdentifier { - Parameter dynamicTransition: A closure whose return value defines the next state to transition to. - Returns: self - */ + */ @discardableResult public func withDynamicTransitions( _ dynamicTransition: @escaping () -> T) -> Self { self.dynamicTransition = dynamicTransition + validateFinalState() return self } @@ -134,38 +138,63 @@ open class State where T: StateIdentifier { Global states are suitable for things like errors. - Returns: self. - */ + */ @discardableResult public func makeGlobal() -> Self { isGlobal = true return self } + + /** + When set, defines a state as being a final state. + + One a final state has been entered it cannot be left. Only a machine reset gets you out of a final start. Final states cannot have exit actions or dynamic transitions. + + - Returns: self. + */ + @discardableResult public func makeFinal() -> Self { + isFinal = true + validateFinalState() + return self + } + + // MARK: - Internal + + private func validateFinalState() { + if isFinal && ( + !allowedTransitions.isEmpty + || beforeLeaving != nil + || afterLeaving != nil + || dynamicTransition != nil) { + fatalError("🤖 Illegal config, final state .\(identifier) cannot have allowedTransitions, leaving or dynamic transition closures.") + } + } } // MARK: - Hashable -extension State: Hashable { +extension StateConfig: Hashable { public var hashValue: Int { return identifier.hashValue } - public static func == (lhs: State, rhs: State) -> Bool { + public static func == (lhs: StateConfig, rhs: StateConfig) -> Bool { return lhs.identifier == rhs.identifier } - public static func == (lhs: T, rhs: State) -> Bool { + public static func == (lhs: T, rhs: StateConfig) -> Bool { return lhs == rhs.identifier } - public static func == (lhs: State, rhs: T) -> Bool { + public static func == (lhs: StateConfig, rhs: T) -> Bool { return lhs.identifier == rhs } - public static func != (lhs: T, rhs: State) -> Bool { + public static func != (lhs: T, rhs: StateConfig) -> Bool { return lhs != rhs.identifier } - public static func != (lhs: State, rhs: T) -> Bool { + public static func != (lhs: StateConfig, rhs: T) -> Bool { return lhs.identifier != rhs } } diff --git a/Machinus/StateMachine.swift b/Machinus/StateMachine.swift index 19009a8..b48736e 100644 --- a/Machinus/StateMachine.swift +++ b/Machinus/StateMachine.swift @@ -21,7 +21,10 @@ public protocol StateMachine { var transitionQ: DispatchQueue { get set } /// If true and a transition to the same state is requested an error will be thrown. Otherwise the completion is called with both values as nil. - var sameStateAsError: Bool { get set } + var enableSameStateError: Bool { get set } + + /// If true and a transition from a final state. Otherwise the completion is called with both values as nil. + var enableFinalStateTransitionError: Bool { get set } /// If enabled, the engine will send notifications of a state change. var postNotifications: Bool { get set } diff --git a/MachinusTests/MachinusTests.swift b/MachinusTests/MachinusTests.swift index 0bcc517..ca0543e 100644 --- a/MachinusTests/MachinusTests.swift +++ b/MachinusTests/MachinusTests.swift @@ -18,24 +18,27 @@ class MachinusTests: XCTestCase { case ccc case xxx case background + case final } - private var stateA: State! - private var stateB: State! - private var stateC: State! - private var backgroundState: State! + private var stateA: StateConfig! + private var stateB: StateConfig! + private var stateC: StateConfig! + private var backgroundState: StateConfig! + private var finalState: StateConfig! private var machine: Machinus! override func setUp() { super.setUp() - self.stateA = State(withIdentifier: .aaa, allowedTransitions: .bbb) - self.stateB = State(withIdentifier: .bbb) - self.stateC = State(withIdentifier: .ccc) - self.backgroundState = State(withIdentifier: .background) + self.stateA = StateConfig(identifier: .aaa, allowedTransitions: .bbb, .final) + self.stateB = StateConfig(identifier: .bbb) + self.stateC = StateConfig(identifier: .ccc) + self.backgroundState = StateConfig(identifier: .background) + self.finalState = StateConfig(identifier: .final).makeFinal() - self.machine = Machinus(withStates: stateA, stateB, stateC, backgroundState) + self.machine = Machinus(withStates: stateA, stateB, stateC, backgroundState, finalState) self.machine.backgroundState = .background } @@ -49,7 +52,7 @@ class MachinusTests: XCTestCase { } func testInitDetectsDuplicateStates() { - let stateAA = State(withIdentifier: .aaa) + let stateAA = StateConfig(identifier: .aaa) expect(_ = Machinus(withStates: self.stateA, self.stateB, self.stateC, stateAA)).to(throwAssertion()) } @@ -137,7 +140,7 @@ class MachinusTests: XCTestCase { var beforeTransitionCalled = false var completed = false - machine.sameStateAsError = true + machine.enableSameStateError = true machine .beforeTransition { _, _ in beforeTransitionCalled = true } .transition(toState: .aaa) { previousState, error in @@ -219,6 +222,10 @@ class MachinusTests: XCTestCase { // MARK: - Background transitions + func testBackgroundStateMustBeKnown() { + expect(self.machine.backgroundState = .xxx).to(throwAssertion()) + } + func testToBackgroundIsSuccessful() { var called = false machine.transition(toState: .background) { @@ -257,4 +264,35 @@ class MachinusTests: XCTestCase { NotificationCenter.default.post(name: UIApplication.willEnterForegroundNotification, object: self) expect(self.machine.state).toEventually(equal(.bbb)) } + + // MARK: - Final states + + func testFinalStateCannotBeBackgroundState() { + expect(self.machine.backgroundState = .final).to(throwAssertion()) + } + + func testFinalStateSilentyNOPs() { + machine.testSet(toState: .final) + var called = false + machine.transition(toState: .aaa) { result, error in + called = true + expect(result).to(beNil()) + expect(error).to(beNil()) + } + + expect(called).toEventually(beTrue()) + } + + func testFinalStateThrows() { + machine.testSet(toState: .final) + machine.enableFinalStateTransitionError = true + var called = false + machine.transition(toState: .aaa) { result, error in + called = true + expect(result).to(beNil()) + expect(error as? MachinusError) == MachinusError.finalState + } + + expect(called).toEventually(beTrue()) + } } diff --git a/MachinusTests/NotificationTests.swift b/MachinusTests/NotificationTests.swift index 3477e42..f7bd870 100644 --- a/MachinusTests/NotificationTests.swift +++ b/MachinusTests/NotificationTests.swift @@ -16,26 +16,25 @@ class NotificationTests: XCTestCase { case aaa case bbb case ccc - case xxx } - private var stateA: State! - private var stateB: State! - private var stateC: State! + private var stateA: StateConfig! + private var stateB: StateConfig! + private var stateC: StateConfig! // Because machines must have 3 states. private var machine: Machinus! override func setUp() { super.setUp() - self.stateA = State(withIdentifier: .aaa, allowedTransitions: .bbb) - self.stateB = State(withIdentifier: .bbb) - self.stateC = State(withIdentifier: .ccc) + self.stateA = StateConfig(identifier: .aaa, allowedTransitions: .bbb) + self.stateB = StateConfig(identifier: .bbb) + self.stateC = StateConfig(identifier: .ccc) self.machine = Machinus(withStates: stateA, stateB, stateC) } - func testSendingStateChangeNotification() { + func testWatchingStateChanges() { let exp = expectation(description: "Waiting for notification") var observer: Any? diff --git a/MachinusTests/StateConfigTests.swift b/MachinusTests/StateConfigTests.swift new file mode 100644 index 0000000..54f1d87 --- /dev/null +++ b/MachinusTests/StateConfigTests.swift @@ -0,0 +1,92 @@ +// +// StateTests.swift +// MachinusTests +// +// Created by Derek Clarkson on 11/2/19. +// Copyright © 2019 Derek Clarkson. All rights reserved. +// + +import XCTest +@testable import Machinus +import Nimble + +class StateTests: XCTestCase { + + enum MyState: StateIdentifier { + case aaa + case bbb + } + + private var stateA: StateConfig! + private var stateAA: StateConfig! + private var stateB: StateConfig! + + override func setUp() { + self.stateA = StateConfig(identifier: .aaa, allowedTransitions: .bbb) + self.stateAA = StateConfig(identifier: .aaa) + self.stateB = StateConfig(identifier: .bbb) + } + + // MARK: - Hashable + + func testHashValue() { + expect(self.stateA!.hashValue) == MyState.aaa.hashValue + } + + func testEquatableStateStateEquatable() { + expect(self.stateA == self.stateAA).to(beTrue()) + expect(self.stateA != self.stateB).to(beTrue()) + } + + func testEquatableStateIdentifier() { + expect(self.stateA == MyState.aaa).to(beTrue()) + expect(self.stateA! != MyState.bbb).to(beTrue()) + } + + func testEquatableIentifierState() { + expect(MyState.aaa == self.stateA).to(beTrue()) + expect(MyState.bbb != self.stateA!).to(beTrue()) + } + + // MARK: - State properties + + func testCanTransition() { + expect(self.stateA.canTransition(toState: self.stateB)).to(beTrue()) + expect(self.stateAA.canTransition(toState: self.stateB)).to(beFalse()) + } + + func testCanTransitionHonoursGlobal() { + stateA.makeGlobal() + expect(self.stateB.canTransition(toState: self.stateA)).to(beTrue()) + } + + // MARK: - Final states + + func testFinalAndAllowedTransitionsThrowsFatalError() { + expect(_ = self.stateA.makeFinal()).to(throwAssertion()) + } + + func testFinalAndDynamicThrowsFatalError() { + expect(_ = self.stateA.makeFinal().withDynamicTransitions { return .bbb }).to(throwAssertion()) + } + + func testDynamicAndFinalThrowsFatalError() { + expect(_ = self.stateA.withDynamicTransitions { return .bbb }.makeFinal()).to(throwAssertion()) + } + + func testFinalAndBeforeLeavingThrowsFatalError() { + expect(_ = self.stateA.makeFinal().beforeLeaving { _ in }).to(throwAssertion()) + } + + func testBeforeLeavingAndFinalThrowsFatalError() { + expect(_ = self.stateA.beforeLeaving { _ in }.makeFinal()).to(throwAssertion()) + } + + func testFinalAndAfterLeavingThrowsFatalError() { + expect(_ = self.stateA.makeFinal().afterLeaving { _ in }).to(throwAssertion()) + } + + func testAfterLeavingAndFinalThrowsFatalError() { + expect(_ = self.stateA.afterLeaving { _ in }.makeFinal()).to(throwAssertion()) + } +} diff --git a/MachinusTests/StateTests.swift b/MachinusTests/StateTests.swift deleted file mode 100644 index eab3159..0000000 --- a/MachinusTests/StateTests.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// StateTests.swift -// MachinusTests -// -// Created by Derek Clarkson on 11/2/19. -// Copyright © 2019 Derek Clarkson. All rights reserved. -// - -import XCTest -@testable import Machinus -import Nimble - -class StateTests: XCTestCase { - - enum MyState: StateIdentifier { - case aaa - case bbb - } - - private var stateA: State! - private var stateAA: State! - private var stateB: State! - - override func setUp() { - self.stateA = State(withIdentifier: .aaa, allowedTransitions: .bbb) - self.stateAA = State(withIdentifier: .aaa) - self.stateB = State(withIdentifier: .bbb) - } - - // MARK: - Hashable - - func testHashValue() { - expect(self.stateA!.hashValue) == MyState.aaa.hashValue - } - - func testEquatableStateStateEquatable() { - expect(self.stateA == self.stateAA).to(beTrue()) - expect(self.stateA != self.stateB).to(beTrue()) - } - - func testEquatableStateIdentifier() { - expect(self.stateA == MyState.aaa).to(beTrue()) - expect(self.stateA! != MyState.bbb).to(beTrue()) - } - - func testEquatableIentifierState() { - expect(MyState.aaa == self.stateA).to(beTrue()) - expect(MyState.bbb != self.stateA!).to(beTrue()) - } - - // MARK: - State properties - - func testCanTransition() { - expect(self.stateA.canTransition(toState: self.stateB)).to(beTrue()) - expect(self.stateAA.canTransition(toState: self.stateB)).to(beFalse()) - } - - func testCanTransitionHonoursGlobal() { - stateA.makeGlobal() - expect(self.stateB.canTransition(toState: self.stateA)).to(beTrue()) - } -} diff --git a/README.md b/README.md index ea2980c..dfb7138 100644 --- a/README.md +++ b/README.md @@ -2,24 +2,26 @@ [![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) [![Build Status](https://travis-ci.com/drekka/Machinus.svg?branch=master)](https://travis-ci.com/drekka/Machinus) -* [Quick guide][quickguide] -* [States][states] - * [Adding actions to states][addingactionstostates] - * [Global states][globalstates] - * [Transition barriers][transitionbarriers] -* [The state machine][thestatemachine] - * [Options][options] - * [Checking the engine's state][checkingtheenginesstate] - * [Transitions][transitions] - * [Transition execution][transitionexecution] - * [Manual transitions][manualtransitions] - * [Dynamic transitions][dynamictransitions] - * [Transition errors][transitionerrors] - * [Transition actions][transitionactions] - * [Listening to transition notifications][listeningtotransitionnotifications] - * [Resetting the engine][resettingtheengine] - -# What's a state machine? +* [Quick guide](#quickguide) +* [States](#states) + * [Adding actions to states](#addingactionstostates) + * [Global states](#globalstates) + * [Final states](#finalstates) + * [Transition barriers](#transitionbarriers) + * [The background state](#thebackgroundstate) +* [The state machine](#thestatemachine) + * [Options](#options) + * [Checking the engine's state](#checkingtheenginesstate) + * [Transitions](#transitions) + * [Transition execution](#transitionexecution) + * [Manual transitions](#manualtransitions) + * [Dynamic transitions](#dynamictransitions) + * [Transition errors](#transitionerrors) + * [Transition actions](#transitionactions) + * [Listening to transition notifications](#listeningtotransitionnotifications) + * [Resetting the engine](#resettingtheengine) + +# What is a state machine? Often there are things in your app that have a number of unique *'states'*. For example - a user's states include being registered, logged out or logged in. You represent these states with booleans or enums, then add code which looks at these values to decide what to do. Displaying a home screen when the user logs in for example. @@ -29,35 +31,15 @@ This is where a state machine can help. Basically a state machine provides a sim # Machinus? -If you look around [Github](https://www.github.com) you'll find plenty of state machine implementations so why write another? +If you look around [Github](https://www.github.com) you'll find plenty of state machine implementations so why write another? Simply put - Because none of them worked the way I wanted and... because I could. -Simply put - Because none of them worked the way I wanted and... because I could. - -Generally speaking I found two different types of state machines implementations on Github - Those that defined states using enums and those that defined states using classes. - -Enum based machines tend to have limited functionality because of the limitations of enums, but be easy to setup and work with. Machines where state's were defined by creating classes tended to require more code and not be as easy to work with as enum based ones. +Generally speaking I found two different types of state machines implementations on Github - Those that defined states using enums and those that defined states using classes. Enum based machines tend to have limited functionality because of the limitations of enums, but be easy to setup and work with. Machines where state's were defined by creating classes tended to require more code and not be as easy to work with as enum based ones. With Machinus I decided to take a different approach. I use a protocol to provide the unique identities of the states, and then a single class to setup the functionality of those states. I found this gives me the best of both styles of machines. -## Machinus features - -* The flexibility of a class based state machine with the ease of an enum design. -* Allowed transitions lists to define what are legal transitions. -* Optional global states which any state can transition to. Great for error states or similar. -* Multiple points where you can attach code (actions) to be executed when a state change occurs: - * Before and after leaving a state. - * Before and after entering a state. - * Before and after a transition. -* Optional barrier closures which can allow or deny transitions to new state. -* 2 transition styles: - * Manual where calling code passes the next state to transition to. - * Automatic where a pre-defined closure returns the next state. -* Transition notifications. -* Custom dispatch queue for transition execution. - # Quick guide -## 1. Install +## 1. Installing Machinus is [Carthage](https://github.com/Carthage/Carthage) friendly. Just add this to your `Cartfile`: @@ -179,6 +161,21 @@ let initialising = State(withIdentifier: .appError) .makeGlobal() ``` +## Final states + +Sometimes you might want to have a state that cannot be exited. For example, where the application has got itself into some wierd state of confusion and cannot continue. Ok, it's a stretch, but I have had a requirement for this. + +```swift +let systemError = State(withIdentifier: .systemError) + .makeFinal() +``` + +Anyway, if you designate a state and being 'final', then once entered, the machine cannot exit it. Final states: + +* Cannot be transitioned from. +* Do not allow any of the 'leaving' actions to be set. A swift `fatalError` will be generated. +* The only way out of a final state is to `reset()` the state machine. + ## Transition barriers Transition barriers are simple closures that can cancel a transition. They are most useful where you have some criteria for allowing a change to a state, but don't want to repeat that criteria on all the other states that can transition to it. @@ -196,6 +193,32 @@ let loggedIn = State(withIdentifier: .loggedIn, allowedTransitions: . Just return false to deny the transition. +## The background state + +Something that often occurs is a need for the state machine to know when the application has been put into the background. For example, if the application has sensitive data, it might want to add and remove a privacy screen when the application goes to and from the background. Machinus supports this and will automatically track the application by transitioning to and from a specific state as the application is moved to and from the background. + +Here's an example of how this is setup: + +```swift +self.backgroundState = StateConfig(identifier: .background) + .beforeEntering { previousState in + addPrivacyScreen() + } + .afterLeaving { nextState in + removePrivacyScreen() +} + +self.machine = Machinus(withStates: initialising, registering, loggedIn, loggedOut, backgroundState) +self.machine.backgroundState = .background +``` + +That's all you have to do. Note: + +* You don't need to add the background state to the `allowedTransition` lists of other states. Machinus bypasses this checking for transitions to and from the background state. +* Transition barriers are not tested when entering or leaving the background state. +* Machinus stores the current state before transitioning to the background state. It then automatically returns to that state when the application enters the foreground again. +* All the normal actions are executed. + # The state machine Creating a state machine looks like this. @@ -212,9 +235,11 @@ You cannot create a state machine without at least 3 states. This is simple logi ## Options -* `sameStateAsError: Bool` (property) - Default false. If true and you request a transition to a state when the machine is already in that state then the `MachinusError.alreadyInState` is passed to the completion closure. \ +* `enableSameStateError: Bool` (property) - Default false. If true and you request a transition to a state when the machine is already in that state then the `MachinusError.alreadyInState` is passed to the completion closure. \ When false (the default) the machine simply calls the transition completion with a `nil` previous state and a `nil` error. No transition or transition closures are executed. +* `enableFinalStateTransitionError: Bool` (property) - Default false. If true and you request a transition from a state that has the final flag. + * `postNotifications: Bool` (property) - Default false. If true, every time a transition is done, a matching state change notification will be sent. This allows objects which cannot directly access the machine to still know about state changes. * `transitionQueue: DispatchQueue` (property) - Default `DispatchQueue.main`. The dispatch queue that transition will be queued on. @@ -237,10 +262,11 @@ The execution of the actions for a transition follow a specific order. Here's an * The transition is requested and queued on the transition queue. 1. The transition is pre-flighted and errors returned if any of these conditions fail: + * The current state is not a final state. * The new state has not been registered. * The new state is not global or in the list of allowed transitions of the current state. * The new state's transition barrier denies the transition. - * The new state and the old state are the same. Error optionsl. + * The new state and the old state are the same. Error optional, see properties. 1. The transition is executed: 1. The machines `beforeTransition` closure is called. 1. The old state's `beforeLeaving` closure is called. @@ -272,7 +298,7 @@ machine.transition(toState: .registering) { previousState, error in } ``` -If the transition was successful the completion closure is passed the previous state of the machine. If there was a problem, an error is passed instead. +If the transition was successful the completion closure is passed the previous state of the machine. If there was a problem, an error is passed instead. Depending on the setup of your machine it may also be possible for the closure to get both a nil previous state and a nil error. This can occur if you have told the machine to transition to the state it's already in, and not set or from a final state, in which case the default is to do nothing. ### Dynamic transitions @@ -300,16 +326,16 @@ Notice how we trigger the dynamic transition by not specifying the new state. Transitions can return the following `MachinusError` errors: -* **.alreadyInState** - Returned if a state change is requested, the requested state is the same as the current state and the `sameStateAsError`` flag is set. - -* **.unregisteredState** - Returned when a transition is requested to a state that was not registered on the state machine's intialiser. +* **.alreadyInState** - Returned if a state change is requested, the requested state is the same as the current state and the `enableSameStateError` flag is set. * **.transitionDenied** - Returned when a transition barrier rejects a transition. * **.illegalTransition** - Returned when the target state is not in the current state's allowed transition list. * **.dynamicTransitionNotDefined** - Returned when a dynamic transition is requested and there is no dynamic transition defined on the current state. - + +* **.finalState** - Returned when the `enableFinalStateTransitionError`flag is turned on and you attempt to transition from a state that is marked as final. + ### Transition actions In addition to actions which are attached to states, you can add the following actions to the state machine itself. @@ -340,7 +366,7 @@ It's important to get the type of the states right in the closure as the observe ## Resetting the engine -Resetting the state machine to it's initial state is quite simple. This is a hard reset which does not execute any actions. +Resetting the state machine to it's initial state is quite simple. This is a hard reset which does not execute any actions or rules. ```swift machine.reset()