Skip to content

Instantly share code, notes, and snippets.

@shaps80
Last active May 17, 2023 12:27
Show Gist options
  • Save shaps80/fb81ada03c3669a5df66be95ab60de04 to your computer and use it in GitHub Desktop.
Save shaps80/fb81ada03c3669a5df66be95ab60de04 to your computer and use it in GitHub Desktop.

Revisions

  1. shaps80 revised this gist Aug 18, 2018. 1 changed file with 8 additions and 2 deletions.
    10 changes: 8 additions & 2 deletions 1. StateMachine.swift
    Original file line number Diff line number Diff line change
    @@ -212,8 +212,14 @@ private extension StateMachine {

    func log(_ kind: Log, from: Delegate.StateType, to: Delegate.StateType) {
    guard isLoggingEnabled else { return }
    os_log(.debug, log: .state, "%{public}@ transition from %{public}@ to %{public}@",
    kind.rawValue, String(describing: from), String(describing: to))

    if #available(iOS 12.0, *) {
    os_log(.debug, log: .state, "%{public}@ transition from %{public}@ to %{public}@",
    kind.rawValue, String(describing: from), String(describing: to))
    } else {
    NSLog("%@ transition from %@ to %@",
    kind.rawValue, String(describing: from), String(describing: to))
    }
    }

    }
  2. shaps80 revised this gist Aug 18, 2018. 1 changed file with 4 additions and 0 deletions.
    4 changes: 4 additions & 0 deletions 1. StateMachine.swift
    Original file line number Diff line number Diff line change
    @@ -195,6 +195,10 @@ open class StateMachine<Delegate> where Delegate: StateMachineDelegate {

    }

    private extension OSLog {
    static let state = OSLog(subsystem: "com.152percent", category: "state-machine")
    }

    private extension StateMachine {

    enum Log: String {
  3. shaps80 revised this gist Aug 18, 2018. 1 changed file with 6 additions and 0 deletions.
    6 changes: 6 additions & 0 deletions 3. StateMachine_Spec.swift
    Original file line number Diff line number Diff line change
    @@ -37,6 +37,12 @@ final class StateMachine_Spec: QuickSpec, StateMachineDelegate {
    .to( throwError(StateMachineError.illegalTransition) )
    expect(machine.currentState).to(equal(.initial))
    }

    it("it should succeed when transitioning to the same state") {
    expect { try machine.transition(to: .initial) }
    .toNot( throwError() )
    expect(machine.currentState).to(equal(.initial))
    }
    }
    }

  4. shaps80 revised this gist Aug 18, 2018. 2 changed files with 14 additions and 29 deletions.
    6 changes: 6 additions & 0 deletions 1. StateMachine.swift
    Original file line number Diff line number Diff line change
    @@ -150,8 +150,12 @@ open class StateMachine<Delegate> where Delegate: StateMachineDelegate {
    log(.request, from: fromState, to: toState)
    try validateTransition(from: fromState, to: toState)

    log(.will, from: fromState, to: toState)
    delegate?.willTransition(from: fromState, to: toState)

    currentState = toState

    log(.did, from: fromState, to: toState)
    delegate?.didTransition(from: fromState, to: toState)
    }

    @@ -172,6 +176,8 @@ open class StateMachine<Delegate> where Delegate: StateMachineDelegate {
    return
    }

    log(.rejected, from: fromState, to: toState)

    guard let delegate = delegate else {
    throw StateMachineError.illegalTransition
    }
    37 changes: 8 additions & 29 deletions 3. StateMachine_Spec.swift
    Original file line number Diff line number Diff line change
    @@ -5,6 +5,8 @@
    import Quick
    import Nimble

    @testable import DataController

    final class StateMachine_Spec: QuickSpec, StateMachineDelegate {

    typealias StateType = State
    @@ -20,17 +22,20 @@ final class StateMachine_Spec: QuickSpec, StateMachineDelegate {
    it("it should succeed when performing an valid transition") {
    expect { try machine.transition(to: .preparing) }
    .toNot( throwError(StateMachineError.illegalTransition) )
    expect(machine.currentState).to(equal(.preparing))
    }

    it("it should throw when performing an invalid transition") {
    expect { try machine.transition(to: .refreshing) }
    .to( throwError(StateMachineError.illegalTransition) )
    expect(machine.currentState).to(equal(.initial))
    }

    it("it should throw when performing a valid transition that was rejected") {
    machine.delegate = self
    expect { try machine.transition(to: .preparing) }
    .to( throwError(StateMachineError.illegalTransition) )
    expect(machine.currentState).to(equal(.initial))
    }
    }
    }
    @@ -43,40 +48,14 @@ final class StateMachine_Spec: QuickSpec, StateMachineDelegate {

    extension StateMachine_Spec {

    /// Defines the various states our DataSource can be in.
    ///
    /// - initial: The initial state is before any data has been loaded
    /// - preparing: Content is being prepared for the first time
    /// - ready: Content is available and ready
    /// - refreshing: Content is being refreshed
    /// - empty: No content is available
    /// - error: An error occurred
    enum State: String {
    /// The initial state before any content has been loaded
    case initial
    /// Content is being prepared for the first time
    case preparing
    /// Content is available and ready
    case ready
    /// Content is being refreshed
    case refreshing
    /// No content is available
    case empty
    /// An error occurred
    case error
    }

    }

    extension StateMachine_Spec.State {

    static var validTransitions: [StateMachine_Spec.State: [StateMachine_Spec.State]] {
    return [
    .initial: [.preparing],
    .preparing: [.ready, .empty, .error],
    .ready: [.refreshing],
    .refreshing: [.ready, .empty, .error]
    ]
    static var validTransitions: [StateMachine_Spec.State: [StateMachine_Spec.State]] {
    return [ .initial: [.preparing] ]
    }
    }

    }
  5. shaps80 revised this gist Aug 18, 2018. No changes.
  6. shaps80 revised this gist Aug 18, 2018. No changes.
  7. shaps80 revised this gist Aug 18, 2018. 3 changed files with 0 additions and 0 deletions.
    File renamed without changes.
    File renamed without changes.
    File renamed without changes.
  8. shaps80 revised this gist Aug 18, 2018. 1 changed file with 19 additions and 0 deletions.
    19 changes: 19 additions & 0 deletions Lock.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,19 @@
    // MARK: - Locks
    public protocol Lock {
    func lock()
    func unlock()
    }

    public final class SpinLock: Lock {
    private var unfairLock = os_unfair_lock_s()

    public init() { }

    public func lock() {
    os_unfair_lock_lock(&unfairLock)
    }

    public func unlock() {
    os_unfair_lock_unlock(&unfairLock)
    }
    }
  9. shaps80 created this gist Aug 18, 2018.
    209 changes: 209 additions & 0 deletions StateMachine.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,209 @@
    import Foundation
    import os.log

    public protocol StateMachineDelegate: class {

    associatedtype StateType: Hashable

    /// Invoked before a transition is about to occur, allowing you to reject even a valid transition. Defaults to true
    ///
    /// - Parameters:
    /// - source: The state before the transition
    /// - destination: The state after the transition
    /// - Returns: True if the transition is allowed, false otherwise
    func shouldTransition(from source: StateType, to destination: StateType) -> Bool

    /// Invoked before the state machine transitions from `source` to `destination`
    ///
    /// - Parameters:
    /// - source: The state before the transition
    /// - destination: The state after the transition
    func willTransition(from source: StateType, to destination: StateType)

    /// Invoked after the state machine transitions from `source` to `destination`
    ///
    /// - Parameters:
    /// - source: The state before the transition
    /// - destination: The state after the transition
    func didTransition(from source: StateType, to destination: StateType)

    /// Invoked if the transition was invalid or rejected. By default this always throws
    ///
    /// - Parameters:
    /// - source: The state before the transition
    /// - destination: The state after the transition
    /// - Throws: `illegalTransition` if the transition was invalid or rejected
    func missingTransition(from source: StateType, to destination: StateType) throws

    }

    public extension StateMachineDelegate {

    func shouldTransition(from source: StateType, to destination: StateType) -> Bool { return true }
    func willTransition(from source: StateType, to destination: StateType) { }
    func didTransition(from source: StateType, to destination: StateType) { }
    func missingTransition(from source: StateType, to destination: StateType) throws { throw StateMachineError.illegalTransition }

    }

    /// Defines the errors that can be thrown from a state machine
    ///
    /// - illegalTransition: An illegal transition attempt was made
    public enum StateMachineError: Error {
    /// An illegal transition attempt was made
    case illegalTransition
    }

    /**
    A generic state machine implementation. It is generally not necessary to subclass. Instead, set the delegate property and implement state transition methods as appropriate.

    Example:

    enum State {
    case initial
    case ready

    static var validTransitions: [State: [State]] {
    return [.initial: [.ready]]
    }
    }

    class StateDelegate {
    associatedtype StateType = State
    }

    let machine = StateMachine<StateDelegate>(initial: .initial, validTransitions: State.validTransitions)

    */
    open class StateMachine<Delegate> where Delegate: StateMachineDelegate {

    /**
    If specified, the state machine invokes transition methods on this delegate instead of itself.
    */
    public weak var delegate: Delegate?

    /**
    Uses OSLog to output state transitions; useful for debugging, but can be noisy.
    Defaults to true for DEBUG builds. False otherwise
    */
    public var isLoggingEnabled: Bool = false

    /**
    The current state of the state machine
    */
    public private(set) var currentState: Delegate.StateType {
    get {
    lock.lock()
    let state = _currentState
    lock.unlock()
    return state
    } set {
    lock.lock()
    _currentState = newValue
    lock.unlock()
    }
    }

    /**
    Definition of the valid transitions for this state machine. This is a dictionary where the keys are the state and the value for each key is an array of the valid next state.
    */
    public let validTransitions: [Delegate.StateType: [Delegate.StateType]]

    private let lock: SpinLock
    private var _currentState: Delegate.StateType

    /**
    Makes a new state machine.

    - Parameters:
    - initial: The initial state of this machine
    - validTransitions: A dictionary of valid transitions that can be performed

    Example:

    StateMachine<StateDelegate>(initial: .initial, validTransitions: [
    .initial: [.preparing],
    .preparing: [.ready, .empty, .error],
    .ready: [.refreshing],
    .refreshing: [.ready, .empty, .error]
    ])
    */
    public init(initial: Delegate.StateType, validTransitions: [Delegate.StateType: [Delegate.StateType]]) {
    self._currentState = initial
    self.validTransitions = validTransitions
    self.lock = SpinLock()

    #if DEBUG
    isLoggingEnabled = true
    #endif
    }

    /**
    Attempts to transitions to the specified state.

    This does not bypass `missingTransition(from:to:)` – if you invoke this with an invalid transition an illegal error will be thrown
    */
    public func transition(to state: Delegate.StateType) throws {
    let fromState = currentState
    let toState = state

    log(.request, from: fromState, to: toState)
    try validateTransition(from: fromState, to: toState)

    delegate?.willTransition(from: fromState, to: toState)
    currentState = toState
    delegate?.didTransition(from: fromState, to: toState)
    }

    /// Validates the transition between two states
    ///
    /// Transitioning to the same state is always allowed. If its explicity defined as a valid transition, the standard methods calls will be invoked, otherwise if will succeed silently.
    ///
    /// - Parameters:
    /// - fromState: The state to transition from
    /// - toState: The state to transition to
    /// - Throws: An `illegalTransition` error is thrown if the transition is invalid or rejected
    open func validateTransition(from fromState: Delegate.StateType, to toState: Delegate.StateType) throws {
    let isValid = validTransitions[fromState]?.contains(toState) == true

    guard isValid else {
    if fromState == toState {
    log(.ignore, from: fromState, to: toState)
    return
    }

    guard let delegate = delegate else {
    throw StateMachineError.illegalTransition
    }

    try delegate.missingTransition(from: fromState, to: toState)
    return
    }

    guard delegate?.shouldTransition(from: fromState, to: toState) == true else {
    log(.rejected, from: fromState, to: toState)
    try delegate?.missingTransition(from: fromState, to: toState)
    return
    }
    }

    }

    private extension StateMachine {

    enum Log: String {
    case request = "Request"
    case will = "Will"
    case did = "Did"
    case ignore = "Ignoring"
    case rejected = "Rejected"
    case illegal = "Illegal"
    }

    func log(_ kind: Log, from: Delegate.StateType, to: Delegate.StateType) {
    guard isLoggingEnabled else { return }
    os_log(.debug, log: .state, "%{public}@ transition from %{public}@ to %{public}@",
    kind.rawValue, String(describing: from), String(describing: to))
    }

    }
    82 changes: 82 additions & 0 deletions StateMachine_Spec.swift
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,82 @@
    /**
    Depends on Quick & Nimble
    */

    import Quick
    import Nimble

    final class StateMachine_Spec: QuickSpec, StateMachineDelegate {

    typealias StateType = State

    override func spec() {
    var machine: StateMachine<StateMachine_Spec>!

    context("Given a state machine") {
    beforeEach {
    machine = StateMachine<StateMachine_Spec>(initial: .initial, validTransitions: State.validTransitions)
    }

    it("it should succeed when performing an valid transition") {
    expect { try machine.transition(to: .preparing) }
    .toNot( throwError(StateMachineError.illegalTransition) )
    }

    it("it should throw when performing an invalid transition") {
    expect { try machine.transition(to: .refreshing) }
    .to( throwError(StateMachineError.illegalTransition) )
    }

    it("it should throw when performing a valid transition that was rejected") {
    machine.delegate = self
    expect { try machine.transition(to: .preparing) }
    .to( throwError(StateMachineError.illegalTransition) )
    }
    }
    }

    func shouldTransition(from source: StateMachine_Spec.State, to destination: StateMachine_Spec.State) -> Bool {
    return false
    }

    }

    extension StateMachine_Spec {

    /// Defines the various states our DataSource can be in.
    ///
    /// - initial: The initial state is before any data has been loaded
    /// - preparing: Content is being prepared for the first time
    /// - ready: Content is available and ready
    /// - refreshing: Content is being refreshed
    /// - empty: No content is available
    /// - error: An error occurred
    enum State: String {
    /// The initial state before any content has been loaded
    case initial
    /// Content is being prepared for the first time
    case preparing
    /// Content is available and ready
    case ready
    /// Content is being refreshed
    case refreshing
    /// No content is available
    case empty
    /// An error occurred
    case error
    }

    }

    extension StateMachine_Spec.State {

    static var validTransitions: [StateMachine_Spec.State: [StateMachine_Spec.State]] {
    return [
    .initial: [.preparing],
    .preparing: [.ready, .empty, .error],
    .ready: [.refreshing],
    .refreshing: [.ready, .empty, .error]
    ]
    }

    }