Skip to content

Commit 318f1b0

Browse files
committed
Add onEnter and onExit events to states
1 parent 0f79642 commit 318f1b0

File tree

4 files changed

+127
-19
lines changed

4 files changed

+127
-19
lines changed

Swift/Sources/StateMachine/StateMachine.swift

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,16 +56,37 @@ open class StateMachine<State: StateMachineHashable, Event: StateMachineHashable
5656
private let states: States
5757
private var observers: [Observer] = []
5858

59+
private typealias EnterExitAction = () -> Void
60+
61+
private var onEnterActions: [State.HashableIdentifier: EnterExitAction]
62+
private var onExitActions: [State.HashableIdentifier: EnterExitAction]
63+
5964
private var isNotifying: Bool = false
6065

6166
public init(@DefinitionBuilder build: () -> Definition) {
6267
let definition: Definition = build()
6368
state = definition.initialState.state
64-
states = definition.states.reduce(into: States()) {
65-
$0[$1.state] = $1.events.reduce(into: Events()) {
66-
$0[$1.event] = $1.action
69+
var enterActions: [State.HashableIdentifier: EnterExitAction] = [:]
70+
var exitActions: [State.HashableIdentifier: EnterExitAction] = [:]
71+
states = definition.states.reduce(into: States()) { result, tuple in
72+
let (state, events) = tuple
73+
result[state] = events.reduce(into: Events()) {
74+
if let event = $1.event {
75+
$0[event] = $1.action
76+
} else {
77+
switch $1.eventType {
78+
case .onEnter(let action):
79+
enterActions[state] = action
80+
case .onExit(let action):
81+
exitActions[state] = action
82+
default:
83+
break
84+
}
85+
}
6786
}
6887
}
88+
onEnterActions = enterActions
89+
onExitActions = exitActions
6990
observers = definition.callbacks.map {
7091
Observer(object: self, callback: $0)
7192
}
@@ -109,6 +130,12 @@ open class StateMachine<State: StateMachineHashable, Event: StateMachineHashable
109130
if let toState: State = action.toState {
110131
state = toState
111132
}
133+
134+
if stateIdentifier != state.hashableIdentifier {
135+
onExitActions[stateIdentifier]?()
136+
onEnterActions[state.hashableIdentifier]?()
137+
}
138+
112139
result = .success(transition)
113140
} else {
114141
result = .failure(Transition.Invalid())
@@ -174,6 +201,14 @@ extension StateMachineBuilder {
174201
.state(state: state, events: build())
175202
}
176203

204+
public static func onEnter(_ perform: @escaping () -> Void) -> [EventHandler] {
205+
[EventHandler(event: nil, action: nil, eventType: .onEnter(perform))]
206+
}
207+
208+
public static func onExit(_ perform: @escaping () -> Void) -> [EventHandler] {
209+
[EventHandler(event: nil, action: nil, eventType: .onExit(perform))]
210+
}
211+
177212
public static func on(
178213
_ event: Event.HashableIdentifier,
179214
perform: @escaping (State, Event) throws -> Action
@@ -277,10 +312,15 @@ public enum StateMachineTypes {
277312
}
278313
}
279314

315+
fileprivate enum EventType {
316+
case normal, onEnter(() -> Void), onExit(() -> Void)
317+
}
318+
280319
public struct EventHandler<State: StateMachineHashable, Event: StateMachineHashable, SideEffect> {
281320

282-
fileprivate let event: Event.HashableIdentifier
283-
fileprivate let action: Action<State, Event, SideEffect>.Factory
321+
fileprivate let event: Event.HashableIdentifier?
322+
fileprivate let action: Action<State, Event, SideEffect>.Factory?
323+
fileprivate var eventType = EventType.normal
284324
}
285325

286326
public struct Action<State: StateMachineHashable, Event: StateMachineHashable, SideEffect> {

Swift/Tests/StateMachineTests/StateMachineTests.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,13 @@ func log(_ expectedMessages: String...) -> Predicate<Logger> {
212212
return PredicateResult(bool: actualMessages == expectedMessages, message: message)
213213
}
214214
}
215+
216+
func noLog() -> Predicate<Logger> {
217+
return Predicate {
218+
let actualMessages: [String]? = try $0.evaluate()?.messages
219+
let actualString: String = stringify(actualMessages?.joined(separator: "\\n"))
220+
let message: ExpectationMessage = .expectedCustomValueTo("no logs",
221+
actual: "<\(actualString)>")
222+
return PredicateResult(bool: actualString.count == 0, message: message)
223+
}
224+
}

Swift/Tests/StateMachineTests/StateMachine_Matter_Tests.swift

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,23 +28,41 @@ final class StateMachine_Matter_Tests: XCTestCase, StateMachineBuilder {
2828
typealias ValidTransition = MatterStateMachine.Transition.Valid
2929
typealias InvalidTransition = MatterStateMachine.Transition.Invalid
3030

31-
enum Message {
32-
33-
static let melted: String = "I melted"
34-
static let frozen: String = "I froze"
35-
static let vaporized: String = "I vaporized"
36-
static let condensed: String = "I condensed"
31+
enum Message: String {
32+
33+
case melted = "I melted"
34+
case frozen = "I froze"
35+
case vaporized = "I vaporized"
36+
case condensed = "I condensed"
37+
case enteredSolid
38+
case exitSolid
39+
case enteredLiquid
40+
case exitLiquid
41+
case enteredGas
42+
case exitGas
3743
}
3844

3945
static func matterStateMachine(withInitialState _state: State, logger: Logger) -> MatterStateMachine {
4046
MatterStateMachine {
4147
initialState(_state)
4248
state(.solid) {
49+
onEnter {
50+
logger.log(Message.enteredSolid.rawValue)
51+
}
52+
onExit {
53+
logger.log(Message.exitSolid.rawValue)
54+
}
4355
on(.melt) {
4456
transition(to: .liquid, emit: .logMelted)
4557
}
4658
}
4759
state(.liquid) {
60+
onEnter {
61+
logger.log(Message.enteredLiquid.rawValue)
62+
}
63+
onExit {
64+
logger.log(Message.exitLiquid.rawValue)
65+
}
4866
on(.freeze) {
4967
transition(to: .solid, emit: .logFrozen)
5068
}
@@ -53,17 +71,23 @@ final class StateMachine_Matter_Tests: XCTestCase, StateMachineBuilder {
5371
}
5472
}
5573
state(.gas) {
74+
onEnter {
75+
logger.log(Message.enteredGas.rawValue)
76+
}
77+
onExit {
78+
logger.log(Message.exitGas.rawValue)
79+
}
5680
on(.condense) {
5781
transition(to: .liquid, emit: .logCondensed)
5882
}
5983
}
6084
onTransition {
6185
guard case let .success(transition) = $0, let sideEffect = transition.sideEffect else { return }
6286
switch sideEffect {
63-
case .logMelted: logger.log(Message.melted)
64-
case .logFrozen: logger.log(Message.frozen)
65-
case .logVaporized: logger.log(Message.vaporized)
66-
case .logCondensed: logger.log(Message.condensed)
87+
case .logMelted: logger.log(Message.melted.rawValue)
88+
case .logFrozen: logger.log(Message.frozen.rawValue)
89+
case .logVaporized: logger.log(Message.vaporized.rawValue)
90+
case .logCondensed: logger.log(Message.condensed.rawValue)
6791
}
6892
}
6993
}
@@ -101,7 +125,7 @@ final class StateMachine_Matter_Tests: XCTestCase, StateMachineBuilder {
101125
event: .melt,
102126
toState: .liquid,
103127
sideEffect: .logMelted)))
104-
expect(self.logger).to(log(Message.melted))
128+
expect(self.logger).to(log(Message.exitSolid.rawValue, Message.enteredLiquid.rawValue, Message.melted.rawValue))
105129
}
106130

107131
func test_givenStateIsSolid_whenFrozen_shouldThrowInvalidTransitionError() throws {
@@ -134,7 +158,7 @@ final class StateMachine_Matter_Tests: XCTestCase, StateMachineBuilder {
134158
event: .freeze,
135159
toState: .solid,
136160
sideEffect: .logFrozen)))
137-
expect(self.logger).to(log(Message.frozen))
161+
expect(self.logger).to(log(Message.exitLiquid.rawValue, Message.enteredSolid.rawValue, Message.frozen.rawValue))
138162
}
139163

140164
func test_givenStateIsLiquid_whenVaporized_shouldTransitionToGasState() throws {
@@ -151,7 +175,7 @@ final class StateMachine_Matter_Tests: XCTestCase, StateMachineBuilder {
151175
event: .vaporize,
152176
toState: .gas,
153177
sideEffect: .logVaporized)))
154-
expect(self.logger).to(log(Message.vaporized))
178+
expect(self.logger).to(log(Message.exitLiquid.rawValue, Message.enteredGas.rawValue, Message.vaporized.rawValue))
155179
}
156180

157181
func test_givenStateIsGas_whenCondensed_shouldTransitionToLiquidState() throws {
@@ -168,6 +192,6 @@ final class StateMachine_Matter_Tests: XCTestCase, StateMachineBuilder {
168192
event: .condense,
169193
toState: .liquid,
170194
sideEffect: .logCondensed)))
171-
expect(self.logger).to(log(Message.condensed))
195+
expect(self.logger).to(log(Message.exitGas.rawValue, Message.enteredLiquid.rawValue, Message.condensed.rawValue))
172196
}
173197
}

Swift/Tests/StateMachineTests/StateMachine_Turnstile_Tests.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,25 @@ final class StateMachine_Turnstile_Tests: XCTestCase, StateMachineBuilder {
3232
typealias TurnstileStateMachine = StateMachine<State, Event, SideEffect>
3333
typealias ValidTransition = TurnstileStateMachine.Transition.Valid
3434

35+
enum Message: String {
36+
case enterLocked
37+
case exitLocked
38+
case enterUnlocked
39+
case exitUnlocked
40+
case enterBroken
41+
case exitBroken
42+
}
43+
3544
static func turnstileStateMachine(withInitialState _state: State, logger: Logger) -> TurnstileStateMachine {
3645
TurnstileStateMachine {
3746
initialState(_state)
3847
state(.locked) {
48+
onEnter {
49+
logger.log(Message.enterLocked.rawValue)
50+
}
51+
onExit {
52+
logger.log(Message.exitLocked.rawValue)
53+
}
3954
on(.insertCoin) { locked, insertCoin in
4055
let newCredit: Int = try locked.credit() + insertCoin.value()
4156
if newCredit >= Constant.farePrice {
@@ -52,11 +67,23 @@ final class StateMachine_Turnstile_Tests: XCTestCase, StateMachineBuilder {
5267
}
5368
}
5469
state(.unlocked) {
70+
onEnter {
71+
logger.log(Message.enterUnlocked.rawValue)
72+
}
73+
onExit {
74+
logger.log(Message.exitUnlocked.rawValue)
75+
}
5576
on(.admitPerson) {
5677
transition(to: .locked(credit: 0), emit: .closeDoors)
5778
}
5879
}
5980
state(.broken) {
81+
onEnter {
82+
logger.log(Message.enterBroken.rawValue)
83+
}
84+
onExit {
85+
logger.log(Message.exitBroken.rawValue)
86+
}
6087
on(.machineRepairDidComplete) { broken in
6188
transition(to: try broken.oldState())
6289
}
@@ -96,6 +123,7 @@ final class StateMachine_Turnstile_Tests: XCTestCase, StateMachineBuilder {
96123
event: .insertCoin(10),
97124
toState: .locked(credit: 10),
98125
sideEffect: nil)))
126+
expect(self.logger).to(noLog())
99127
}
100128

101129
func test_givenStateIsLocked_whenInsertCoin_andCreditEqualsFarePrice_shouldTransitionToUnlockedStateAndOpenDoors() throws {
@@ -112,6 +140,7 @@ final class StateMachine_Turnstile_Tests: XCTestCase, StateMachineBuilder {
112140
event: .insertCoin(15),
113141
toState: .unlocked,
114142
sideEffect: .openDoors)))
143+
expect(self.logger).to(log(Message.exitLocked.rawValue, Message.enterUnlocked.rawValue))
115144
}
116145

117146
func test_givenStateIsLocked_whenInsertCoin_andCreditMoreThanFarePrice_shouldTransitionToUnlockedStateAndOpenDoors() throws {
@@ -128,6 +157,7 @@ final class StateMachine_Turnstile_Tests: XCTestCase, StateMachineBuilder {
128157
event: .insertCoin(20),
129158
toState: .unlocked,
130159
sideEffect: .openDoors)))
160+
expect(self.logger).to(log(Message.exitLocked.rawValue, Message.enterUnlocked.rawValue))
131161
}
132162

133163
func test_givenStateIsLocked_whenAdmitPerson_shouldTransitionToLockedStateAndSoundAlarm() throws {
@@ -144,6 +174,7 @@ final class StateMachine_Turnstile_Tests: XCTestCase, StateMachineBuilder {
144174
event: .admitPerson,
145175
toState: .locked(credit: 35),
146176
sideEffect: .soundAlarm)))
177+
expect(self.logger).to(noLog())
147178
}
148179

149180
func test_givenStateIsLocked_whenMachineDidFail_shouldTransitionToBrokenStateAndOrderRepair() throws {
@@ -160,6 +191,7 @@ final class StateMachine_Turnstile_Tests: XCTestCase, StateMachineBuilder {
160191
event: .machineDidFail,
161192
toState: .broken(oldState: .locked(credit: 15)),
162193
sideEffect: .orderRepair)))
194+
expect(self.logger).to(log(Message.exitLocked.rawValue, Message.enterBroken.rawValue))
163195
}
164196

165197
func test_givenStateIsUnlocked_whenAdmitPerson_shouldTransitionToLockedStateAndCloseDoors() throws {
@@ -176,6 +208,7 @@ final class StateMachine_Turnstile_Tests: XCTestCase, StateMachineBuilder {
176208
event: .admitPerson,
177209
toState: .locked(credit: 0),
178210
sideEffect: .closeDoors)))
211+
expect(self.logger).to(log(Message.exitUnlocked.rawValue, Message.enterLocked.rawValue))
179212
}
180213

181214
func test_givenStateIsBroken_whenMachineRepairDidComplete_shouldTransitionToLockedState() throws {
@@ -192,6 +225,7 @@ final class StateMachine_Turnstile_Tests: XCTestCase, StateMachineBuilder {
192225
event: .machineRepairDidComplete,
193226
toState: .locked(credit: 15),
194227
sideEffect: nil)))
228+
expect(self.logger).to(log(Message.exitBroken.rawValue, Message.enterLocked.rawValue))
195229
}
196230
}
197231

0 commit comments

Comments
 (0)