generic-state-machine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/dsl/dsl.rb +55 -0
- data/dsl/state_machine_dsl.rb +72 -0
- data/spec/dsl/describe_spec.rb +103 -0
- data/spec/spec_helper.rb +105 -0
- data/spec/unit/generic_state_machine_spec.rb +85 -0
- data/spec/unit/history_spec.rb +12 -0
- data/spec/unit/state_machine_factory_spec.rb +37 -0
- data/spec/unit/transition_spec.rb +29 -0
- data/state_machine/errors.rb +16 -0
- data/state_machine/state_machine.rb +91 -0
- data/state_machine/state_machine_factory.rb +75 -0
- data/state_machine/transition.rb +17 -0
- metadata +59 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 35027276d3d5fcc5399387fa3bef4d380ab472138b322c1a53a02cd3ef925a13
|
4
|
+
data.tar.gz: c3490ced6cf5e6eb9bab74a2f1597fd687c6bad3c71e2e4ffe3b145d32e466f5
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 0ee0898b46875cff5bec19184cf1708f31da6a491050450611d7f5c09d9e37f351aa83820cb588b5f033ca3d8228c813fad9082844d17dc06b90147a6765f9fb
|
7
|
+
data.tar.gz: f2277892803a2e2b80e90b7832bc97fe4eecd7fd1b7db6a469ce999dbe9e3019fc8786b95458bdd82da063b6d7da595460599e201b6721fffe14ed5c0aa6fdaf
|
data/dsl/dsl.rb
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
module GenericStateMachine
|
5
|
+
##
|
6
|
+
module DSL
|
7
|
+
##
|
8
|
+
# Method is used to describe the properties the GSM should have
|
9
|
+
# @raise [GenericStateMachine::Errors::DSLError]
|
10
|
+
#
|
11
|
+
def describe(&block)
|
12
|
+
raise GenericStateMachine::Errors::DSLError, '#describe needs a block' unless block_given?
|
13
|
+
|
14
|
+
dsl = StateMachineDSL.create &block
|
15
|
+
|
16
|
+
_create_state_machine dsl
|
17
|
+
end
|
18
|
+
|
19
|
+
##
|
20
|
+
# Struct describing a transition
|
21
|
+
#
|
22
|
+
Transition = Struct.new(:from, :to, :condition)
|
23
|
+
##
|
24
|
+
# Struct describing a hook
|
25
|
+
#
|
26
|
+
Hook = Struct.new(:name, :handler, :condition)
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
##
|
31
|
+
# Actually create an instance of StateMachine using a DSL object
|
32
|
+
# @param [StateMachineDSL]
|
33
|
+
# @raise [GenericStateMachine::Errors::DSLError]
|
34
|
+
#
|
35
|
+
def _create_state_machine(dsl)
|
36
|
+
transitions = dsl.transitions.collect do |t|
|
37
|
+
_transition_from_struct t
|
38
|
+
end
|
39
|
+
|
40
|
+
GenericStateMachine::StateMachineFactory.create start: dsl.starting,
|
41
|
+
transitions: transitions, hooks: dsl.hooks
|
42
|
+
end
|
43
|
+
|
44
|
+
##
|
45
|
+
# Helper creating a Transition instance
|
46
|
+
# @param [Transition] transition
|
47
|
+
#
|
48
|
+
def _transition_from_struct(transition)
|
49
|
+
GenericStateMachine::Transition.new transition.from, transition.to, transition.condition
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Make DSL available directly under main module
|
55
|
+
GenericStateMachine.extend GenericStateMachine::DSL
|
@@ -0,0 +1,72 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
module GenericStateMachine
|
5
|
+
##
|
6
|
+
module DSL
|
7
|
+
##
|
8
|
+
# StateMachineDSL provides convenient methods for creating a state machine
|
9
|
+
#
|
10
|
+
class StateMachineDSL
|
11
|
+
attr_reader :transitions, :hooks, :starting
|
12
|
+
|
13
|
+
##
|
14
|
+
# Add transition
|
15
|
+
# @param [Symbol] from The current state
|
16
|
+
# @param [Symbol] to The state to transit to
|
17
|
+
# @param [Object] condition An optional condition determining whether the transition should be executed
|
18
|
+
# @raise [GenericStateMachine::Errors::DSLError] on any error
|
19
|
+
#
|
20
|
+
def transition(from:, to:, condition: true)
|
21
|
+
@transitions ||= []
|
22
|
+
@transitions << Transition.new(from, to, condition)
|
23
|
+
rescue StandardError => e
|
24
|
+
raise GenericStateMachine::Errors::DSLError, e
|
25
|
+
end
|
26
|
+
|
27
|
+
##
|
28
|
+
# Add hook
|
29
|
+
# @param [Symbol] hook The name of the hook
|
30
|
+
# @param [Symbol] handler Name of the handler to be executed
|
31
|
+
# @param [Object] condition Optional condition determining whether the hook should be executed
|
32
|
+
# @raise [GenericStateMachine::Errors::DSLError] on any error
|
33
|
+
#
|
34
|
+
def register(hook:, handler:, condition: nil)
|
35
|
+
raise GenericStateMachine::Errors::HookError, "Hook '#{hook}' isn't a valid hook" unless
|
36
|
+
GenericStateMachine::AVAILABLE_HOOKS.include?(hook)
|
37
|
+
raise GenericStateMachine::Errors::HookError, "Can't use value '#{hook}' as hook" if
|
38
|
+
hook.nil? || !hook.is_a?(Symbol)
|
39
|
+
|
40
|
+
@hooks ||= []
|
41
|
+
@hooks << Hook.new(hook, handler, condition)
|
42
|
+
rescue StandardError => e
|
43
|
+
raise GenericStateMachine::Errors::DSLError, e
|
44
|
+
end
|
45
|
+
|
46
|
+
##
|
47
|
+
# Set the starting state
|
48
|
+
# @param [Symbol] from
|
49
|
+
# @raise GenericStateMachine::Errors::DSLError on any error
|
50
|
+
#
|
51
|
+
def start(from:)
|
52
|
+
raise GenericStateMachine::Errors::StateError, "Can't use value '#{from}' as state" if
|
53
|
+
from.nil? || !from.is_a?(Symbol)
|
54
|
+
|
55
|
+
@starting = from
|
56
|
+
rescue StandardError => e
|
57
|
+
raise GenericStateMachine::Errors::DSLError, e
|
58
|
+
end
|
59
|
+
class << self
|
60
|
+
##
|
61
|
+
# Factory method creating the DSL helper object
|
62
|
+
#
|
63
|
+
def create(&block)
|
64
|
+
obj = new
|
65
|
+
obj.instance_eval(&block)
|
66
|
+
|
67
|
+
obj
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../spec_helper'
|
4
|
+
|
5
|
+
describe 'GenericStateMachine::DSL#describe' do
|
6
|
+
context 'GSM factory #describe' do
|
7
|
+
it 'responds to #describe' do
|
8
|
+
expect(GenericStateMachine.respond_to?(:describe)).to be_truthy
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'throws DSLError when used #describe w/o block' do
|
12
|
+
expect { GenericStateMachine.describe }.to raise_error GenericStateMachine::Errors::DSLError,
|
13
|
+
'#describe needs a block'
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
context 'Transitions' do
|
18
|
+
it 'has #transitions' do
|
19
|
+
dsl = GenericStateMachine::DSL::StateMachineDSL.new
|
20
|
+
|
21
|
+
expect(dsl.respond_to?(:transitions)).to be_truthy
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'accepts required parameters in #transition' do
|
25
|
+
dsl = GenericStateMachine::DSL::StateMachineDSL.new
|
26
|
+
expect {
|
27
|
+
dsl.transition from: :state, to: :state
|
28
|
+
}.to_not raise_error
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'accepts optional parameters in #transition' do
|
32
|
+
dsl = GenericStateMachine::DSL::StateMachineDSL.new
|
33
|
+
expect {
|
34
|
+
dsl.transition from: :state, to: :state, condition: true
|
35
|
+
}.to_not raise_error
|
36
|
+
end
|
37
|
+
|
38
|
+
it 'has one valid transition after adding one' do
|
39
|
+
dsl = GenericStateMachine::DSL::StateMachineDSL.new
|
40
|
+
dsl.transition from: :state, to: :state
|
41
|
+
|
42
|
+
expect(dsl.transitions.count).to eq 1
|
43
|
+
expect(dsl.transitions.first).to be_a GenericStateMachine::DSL::Transition
|
44
|
+
end
|
45
|
+
|
46
|
+
it 'added transition has given values' do
|
47
|
+
dsl = GenericStateMachine::DSL::StateMachineDSL.new
|
48
|
+
dsl.transition from: :from_state, to: :to_state, condition: :some_condition
|
49
|
+
transition = dsl.transitions.first
|
50
|
+
|
51
|
+
expect(transition.from).to eq :from_state
|
52
|
+
expect(transition.to).to eq :to_state
|
53
|
+
expect(transition.condition).to eq :some_condition
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
context 'Hooks' do
|
58
|
+
it 'has #hooks' do
|
59
|
+
dsl = GenericStateMachine::DSL::StateMachineDSL.new
|
60
|
+
|
61
|
+
expect(dsl.respond_to?(:hooks)).to be_truthy
|
62
|
+
end
|
63
|
+
|
64
|
+
it 'accepts required parameters in #register' do
|
65
|
+
dsl = GenericStateMachine::DSL::StateMachineDSL.new
|
66
|
+
expect {
|
67
|
+
dsl.register hook: :before_transition, handler: :some_func
|
68
|
+
}.to_not raise_error
|
69
|
+
end
|
70
|
+
|
71
|
+
it 'accepts optional parameters in #register' do
|
72
|
+
dsl = GenericStateMachine::DSL::StateMachineDSL.new
|
73
|
+
expect {
|
74
|
+
dsl.register hook: :before_transition, handler: :some_func, condition: :foo_bar
|
75
|
+
}.to_not raise_error
|
76
|
+
end
|
77
|
+
|
78
|
+
it 'has one valid hook after adding one' do
|
79
|
+
dsl = GenericStateMachine::DSL::StateMachineDSL.new
|
80
|
+
dsl.register hook: :after_transition, handler: :some_func
|
81
|
+
|
82
|
+
expect(dsl.hooks.count).to eq 1
|
83
|
+
expect(dsl.hooks.first).to be_a GenericStateMachine::DSL::Hook
|
84
|
+
end
|
85
|
+
|
86
|
+
it 'raises error if invalid hook is used' do
|
87
|
+
dsl = GenericStateMachine::DSL::StateMachineDSL.new
|
88
|
+
|
89
|
+
expect {
|
90
|
+
dsl.register hook: :some_hook, handler: :some_func
|
91
|
+
}.to raise_error GenericStateMachine::Errors::DSLError
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
context 'Starting state' do
|
96
|
+
it 'has a starting state after setting it' do
|
97
|
+
dsl = GenericStateMachine::DSL::StateMachineDSL.new
|
98
|
+
dsl.start from: :some_state
|
99
|
+
|
100
|
+
expect(dsl.starting).to eq :some_state
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
# This file was generated by the `rspec --init` command. Conventionally, all
|
2
|
+
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
|
3
|
+
# The generated `.rspec` file contains `--require spec_helper` which will cause
|
4
|
+
# this file to always be loaded, without a need to explicitly require it in any
|
5
|
+
# files.
|
6
|
+
#
|
7
|
+
# Given that it is always loaded, you are encouraged to keep this file as
|
8
|
+
# light-weight as possible. Requiring heavyweight dependencies from this file
|
9
|
+
# will add to the boot time of your test suite on EVERY test run, even for an
|
10
|
+
# individual file that may not need all of that loaded. Instead, consider making
|
11
|
+
# a separate helper file that requires the additional dependencies and performs
|
12
|
+
# the additional setup, and require it from the spec files that actually need
|
13
|
+
# it.
|
14
|
+
#
|
15
|
+
# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
|
16
|
+
require 'simplecov'
|
17
|
+
SimpleCov.start
|
18
|
+
|
19
|
+
require_relative '../generic_state_machine'
|
20
|
+
|
21
|
+
RSpec.configure do |config|
|
22
|
+
# rspec-expectations config goes here. You can use an alternate
|
23
|
+
# assertion/expectation library such as wrong or the stdlib/minitest
|
24
|
+
# assertions if you prefer.
|
25
|
+
config.expect_with :rspec do |expectations|
|
26
|
+
# This option will default to `true` in RSpec 4. It makes the `description`
|
27
|
+
# and `failure_message` of custom matchers include text for helper methods
|
28
|
+
# defined using `chain`, e.g.:
|
29
|
+
# be_bigger_than(2).and_smaller_than(4).description
|
30
|
+
# # => "be bigger than 2 and smaller than 4"
|
31
|
+
# ...rather than:
|
32
|
+
# # => "be bigger than 2"
|
33
|
+
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
34
|
+
end
|
35
|
+
|
36
|
+
# rspec-mocks config goes here. You can use an alternate test double
|
37
|
+
# library (such as bogus or mocha) by changing the `mock_with` option here.
|
38
|
+
config.mock_with :rspec do |mocks|
|
39
|
+
# Prevents you from mocking or stubbing a method that does not exist on
|
40
|
+
# a real object. This is generally recommended, and will default to
|
41
|
+
# `true` in RSpec 4.
|
42
|
+
mocks.verify_partial_doubles = true
|
43
|
+
end
|
44
|
+
|
45
|
+
# This option will default to `:apply_to_host_groups` in RSpec 4 (and will
|
46
|
+
# have no way to turn it off -- the option exists only for backwards
|
47
|
+
# compatibility in RSpec 3). It causes shared context metadata to be
|
48
|
+
# inherited by the metadata hash of host groups and examples, rather than
|
49
|
+
# triggering implicit auto-inclusion in groups with matching metadata.
|
50
|
+
config.shared_context_metadata_behavior = :apply_to_host_groups
|
51
|
+
|
52
|
+
# The settings below are suggested to provide a good initial experience
|
53
|
+
# with RSpec, but feel free to customize to your heart's content.
|
54
|
+
=begin
|
55
|
+
# This allows you to limit a spec run to individual examples or groups
|
56
|
+
# you care about by tagging them with `:focus` metadata. When nothing
|
57
|
+
# is tagged with `:focus`, all examples get run. RSpec also provides
|
58
|
+
# aliases for `it`, `describe`, and `context` that include `:focus`
|
59
|
+
# metadata: `fit`, `fdescribe` and `fcontext`, respectively.
|
60
|
+
config.filter_run_when_matching :focus
|
61
|
+
|
62
|
+
# Allows RSpec to persist some state between runs in order to support
|
63
|
+
# the `--only-failures` and `--next-failure` CLI options. We recommend
|
64
|
+
# you configure your source control system to ignore this file.
|
65
|
+
config.example_status_persistence_file_path = "spec/examples.txt"
|
66
|
+
|
67
|
+
# Limits the available syntax to the non-monkey patched syntax that is
|
68
|
+
# recommended. For more details, see:
|
69
|
+
# - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
|
70
|
+
# - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
|
71
|
+
# - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
|
72
|
+
config.disable_monkey_patching!
|
73
|
+
|
74
|
+
# This setting enables warnings. It's recommended, but in some cases may
|
75
|
+
# be too noisy due to issues in dependencies.
|
76
|
+
config.warnings = true
|
77
|
+
|
78
|
+
# Many RSpec users commonly either run the entire suite or an individual
|
79
|
+
# file, and it's useful to allow more verbose output when running an
|
80
|
+
# individual spec file.
|
81
|
+
if config.files_to_run.one?
|
82
|
+
# Use the documentation formatter for detailed output,
|
83
|
+
# unless a formatter has already been configured
|
84
|
+
# (e.g. via a command-line flag).
|
85
|
+
config.default_formatter = "doc"
|
86
|
+
end
|
87
|
+
|
88
|
+
# Print the 10 slowest examples and example groups at the
|
89
|
+
# end of the spec run, to help surface which specs are running
|
90
|
+
# particularly slow.
|
91
|
+
config.profile_examples = 10
|
92
|
+
|
93
|
+
# Run specs in random order to surface order dependencies. If you find an
|
94
|
+
# order dependency and want to debug it, you can fix the order by providing
|
95
|
+
# the seed, which is printed after each run.
|
96
|
+
# --seed 1234
|
97
|
+
config.order = :random
|
98
|
+
|
99
|
+
# Seed global randomization in this process using the `--seed` CLI option.
|
100
|
+
# Setting this allows you to use `--seed` to deterministically reproduce
|
101
|
+
# test failures related to randomization by passing the same `--seed` value
|
102
|
+
# as the one that triggered the failure.
|
103
|
+
Kernel.srand config.seed
|
104
|
+
=end
|
105
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../spec_helper'
|
4
|
+
|
5
|
+
describe GenericStateMachine::StateMachine do
|
6
|
+
context 'API' do
|
7
|
+
let(:gsm) do
|
8
|
+
GenericStateMachine::StateMachine.new :start_state
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'has property #current' do
|
12
|
+
expect(gsm.respond_to?(:current)).to be_truthy
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'has method #next!' do
|
16
|
+
expect(gsm.respond_to?(:next!)).to be_truthy
|
17
|
+
end
|
18
|
+
|
19
|
+
it 'has method #register' do
|
20
|
+
expect(gsm.respond_to?(:register)).to be_truthy
|
21
|
+
end
|
22
|
+
|
23
|
+
it 'has method #add' do
|
24
|
+
expect(gsm.respond_to?(:add)).to be_truthy
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
context 'Transitions' do
|
29
|
+
let(:gsm) do
|
30
|
+
machine = GenericStateMachine::StateMachine.new :start_state
|
31
|
+
machine.add transition: GenericStateMachine::Transition.new(:start_state, :end_state)
|
32
|
+
|
33
|
+
machine
|
34
|
+
end
|
35
|
+
|
36
|
+
it 'can add transition' do
|
37
|
+
expect { gsm }.to_not raise_error
|
38
|
+
end
|
39
|
+
|
40
|
+
it "can't add invalid transition" do
|
41
|
+
expect { gsm.add transition: 'some value' }.to raise_error GenericStateMachine::Errors::StateMachineError
|
42
|
+
end
|
43
|
+
|
44
|
+
it 'next switches current state to ":end_state"' do
|
45
|
+
gsm.next!
|
46
|
+
expect(gsm.current).to eq :end_state
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
context 'Hooks' do
|
51
|
+
let(:gsm) do
|
52
|
+
GenericStateMachine::StateMachine.new :start_state
|
53
|
+
end
|
54
|
+
|
55
|
+
it 'can register a proc as handler' do
|
56
|
+
expect {
|
57
|
+
gsm.register hook: :before_transition, handler: proc { puts 'BEFORE_TRANSITION called' }
|
58
|
+
}.to_not raise_error
|
59
|
+
end
|
60
|
+
|
61
|
+
it 'really executes the hook handler' do
|
62
|
+
$global_value = 'change me'
|
63
|
+
gsm.register hook: :before_transition, handler: proc { $global_value = 'foo bar' }
|
64
|
+
# Call private method for testing purposes
|
65
|
+
gsm.send :emit!, :before_transition
|
66
|
+
|
67
|
+
expect($global_value).to eq 'foo bar'
|
68
|
+
end
|
69
|
+
|
70
|
+
it 'can register an instance method as handler' do
|
71
|
+
class Tester
|
72
|
+
attr_reader :value
|
73
|
+
def initialize; @value = 'change me'; end
|
74
|
+
def change_value; @value = 'foo bar'; end
|
75
|
+
end
|
76
|
+
|
77
|
+
t = Tester.new
|
78
|
+
gsm.register hook: :before_transition, handler: t.method(:change_value)
|
79
|
+
# Call private method for testing purposes
|
80
|
+
gsm.send :emit!, :before_transition
|
81
|
+
|
82
|
+
expect(t.value).to eq 'foo bar'
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../spec_helper'
|
4
|
+
|
5
|
+
describe GenericStateMachine::StateMachineFactory do
|
6
|
+
context 'Object creation' do
|
7
|
+
it 'Creates a factory' do
|
8
|
+
expect { GenericStateMachine::StateMachineFactory.new }.to_not raise_error
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
context 'State machine creation' do
|
13
|
+
let(:factory) do
|
14
|
+
GenericStateMachine::StateMachineFactory
|
15
|
+
end
|
16
|
+
|
17
|
+
it "can't create a GSM w/o starting state" do
|
18
|
+
expect {
|
19
|
+
factory.create start: nil, transitions: [], hooks: []
|
20
|
+
}.to raise_error GenericStateMachine::Errors::GenericStateMachineError,
|
21
|
+
"Can't create state machine w/o starting state"
|
22
|
+
end
|
23
|
+
|
24
|
+
it "can't create a GSM w/o transitions" do
|
25
|
+
expect {
|
26
|
+
factory.create start: :some_start, transitions: [], hooks: []
|
27
|
+
}.to raise_error GenericStateMachine::Errors::GenericStateMachineError,
|
28
|
+
"Can't create state machine w/o transitions"
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'can create a valid GSM' do
|
32
|
+
t = GenericStateMachine::Transition.new :from_state, :to_state
|
33
|
+
|
34
|
+
expect { factory.create start: :from_state, transitions: [t] }.to_not raise_error
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative '../spec_helper'
|
4
|
+
|
5
|
+
describe GenericStateMachine::Transition do
|
6
|
+
context 'Object creation' do
|
7
|
+
it 'Creates a transition' do
|
8
|
+
expect { GenericStateMachine::Transition.new(:from_state, :to_state) }.to_not raise_error
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
context 'Properties' do
|
13
|
+
let(:transition) do
|
14
|
+
GenericStateMachine::Transition.new(:from_state, :to_state)
|
15
|
+
end
|
16
|
+
|
17
|
+
it 'has #from property' do
|
18
|
+
expect(transition.respond_to?(:from)).to be_truthy
|
19
|
+
end
|
20
|
+
|
21
|
+
it 'has #to property' do
|
22
|
+
expect(transition.respond_to?(:to)).to be_truthy
|
23
|
+
end
|
24
|
+
|
25
|
+
it 'has #condition property' do
|
26
|
+
expect(transition.respond_to?(:condition)).to be_truthy
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GenericStateMachine
|
4
|
+
module Errors
|
5
|
+
# Basic error class
|
6
|
+
class GenericStateMachineError < StandardError; end
|
7
|
+
# DSLError indicates problems when using the DSL
|
8
|
+
class DSLError < GenericStateMachineError; end
|
9
|
+
# StateError indicates problems assigning a state
|
10
|
+
class StateError < GenericStateMachineError; end
|
11
|
+
# HookError indicates problems using a hook
|
12
|
+
class HookError < GenericStateMachineError; end
|
13
|
+
# StateMachineError is raised when errors happen inside the state machine
|
14
|
+
class StateMachineError < GenericStateMachineError; end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,91 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
module GenericStateMachine
|
5
|
+
# List available hooks
|
6
|
+
AVAILABLE_HOOKS = %i[before_transition after_transition end_reached].freeze
|
7
|
+
|
8
|
+
##
|
9
|
+
# StateMachine class
|
10
|
+
#
|
11
|
+
class StateMachine
|
12
|
+
attr_reader :current
|
13
|
+
|
14
|
+
##
|
15
|
+
# Create a new state machine
|
16
|
+
# @param [Symbol] start_state The starting state
|
17
|
+
# @raise GenericStateMachine::Errors::StateMachineError
|
18
|
+
#
|
19
|
+
def initialize(start_state)
|
20
|
+
raise GenericStateMachine::Errors::StateMachineError, "Can't create state machine w/o start state" if
|
21
|
+
start_state.nil? || !start_state.is_a?(Symbol)
|
22
|
+
|
23
|
+
initialize_hooks
|
24
|
+
@transitions = {}
|
25
|
+
@current = start_state
|
26
|
+
end
|
27
|
+
|
28
|
+
##
|
29
|
+
# Add transition
|
30
|
+
# @param [GenericStateMachine::Transition] transition
|
31
|
+
# @raise GenericStateMachine::Errors::StateMachineError
|
32
|
+
#
|
33
|
+
def add(transition:)
|
34
|
+
raise GenericStateMachine::Errors::StateMachineError, "Invalid transition '#{transition}'" if
|
35
|
+
transition.nil? || !transition.is_a?(GenericStateMachine::Transition)
|
36
|
+
|
37
|
+
@transitions[transition.from] = transition
|
38
|
+
end
|
39
|
+
|
40
|
+
##
|
41
|
+
# Switch current state
|
42
|
+
# @raise GenericStateMachine::Errors::StateMachineError
|
43
|
+
#
|
44
|
+
def next!
|
45
|
+
raise GenericStateMachine::Errors::StateMachineError, "No transition found for '#{@current}'" unless
|
46
|
+
@transitions.key?(@current)
|
47
|
+
|
48
|
+
# emit :before_transition
|
49
|
+
emit! :before_transition
|
50
|
+
@current = @transitions[@current].to
|
51
|
+
# emit :after_transition
|
52
|
+
emit! :after_transition
|
53
|
+
end
|
54
|
+
|
55
|
+
##
|
56
|
+
# Register a new hook
|
57
|
+
# @param [Symbol] hook
|
58
|
+
# @param [Object] handler The handler called when the hook is reached
|
59
|
+
# @raise GenericStateMachine::Errors::StateMachineError
|
60
|
+
#
|
61
|
+
def register(hook:, handler:)
|
62
|
+
raise GenericStateMachine::Errors::StateMachineError, "Invalid hook '#{hook}'" unless
|
63
|
+
AVAILABLE_HOOKS.include?(hook) || hook.nil? || !hook.is_a?(Symbol)
|
64
|
+
raise GenericStateMachine::Errors::StateMachineError, 'Invalid handler' if
|
65
|
+
handler.nil?
|
66
|
+
|
67
|
+
@hooks[hook] << handler
|
68
|
+
end
|
69
|
+
|
70
|
+
private
|
71
|
+
|
72
|
+
##
|
73
|
+
# Emit hook
|
74
|
+
# @param [Symbol] hook The hook to be emitted
|
75
|
+
#
|
76
|
+
def emit!(hook)
|
77
|
+
@hooks[hook].each(&:call)
|
78
|
+
end
|
79
|
+
|
80
|
+
##
|
81
|
+
# Initialize hooks hash
|
82
|
+
#
|
83
|
+
def initialize_hooks
|
84
|
+
@hooks = {}
|
85
|
+
|
86
|
+
GenericStateMachine::AVAILABLE_HOOKS.each do |hook|
|
87
|
+
@hooks[hook] = []
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
module GenericStateMachine
|
5
|
+
##
|
6
|
+
# Factory class creating StateMachine instances
|
7
|
+
#
|
8
|
+
class StateMachineFactory
|
9
|
+
class << self
|
10
|
+
##
|
11
|
+
# Create a StateMachine instance
|
12
|
+
# @param [Symbol] start The starting state
|
13
|
+
# @param [Array] transitions All available transitions
|
14
|
+
# @param [Array] hooks Optional collection of hooks
|
15
|
+
# @raise GenericStateMachine::Errors::GenericStateMachineError
|
16
|
+
#
|
17
|
+
def create(start:, transitions:, hooks: [])
|
18
|
+
raise GenericStateMachine::Errors::GenericStateMachineError, "Can't create state machine w/o starting state" if
|
19
|
+
start.nil? || !start.is_a?(Symbol)
|
20
|
+
raise GenericStateMachine::Errors::GenericStateMachineError, "Can't create state machine w/o transitions" unless
|
21
|
+
transitions.is_a?(Array)
|
22
|
+
raise GenericStateMachine::Errors::GenericStateMachineError, "Can't create state machine w/o transitions" if
|
23
|
+
transitions.empty?
|
24
|
+
|
25
|
+
_validate_transitions transitions
|
26
|
+
_validate_start_state start, transitions
|
27
|
+
_validate_hooks(hooks) unless hooks.empty?
|
28
|
+
|
29
|
+
GenericStateMachine::StateMachine.new start
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
##
|
35
|
+
# Helper method validating if given start state has a transition
|
36
|
+
# raise GenericStateMachine::Errors::GenericStateMachineError if no transition is found
|
37
|
+
#
|
38
|
+
def _validate_start_state(start, transitions)
|
39
|
+
valid = false
|
40
|
+
|
41
|
+
transitions.each do |t|
|
42
|
+
if t.from == start
|
43
|
+
valid = true
|
44
|
+
break
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
raise GenericStateMachine::Errors::GenericStateMachineError, "State '#{start}' isn't a valid start state" unless
|
49
|
+
valid
|
50
|
+
end
|
51
|
+
|
52
|
+
##
|
53
|
+
# Helper method validating if all elements are transitions
|
54
|
+
# raise GenericStateMachine::Errors::GenericStateMachineError if one of the elements is no Transition instance
|
55
|
+
#
|
56
|
+
def _validate_transitions(transitions)
|
57
|
+
transitions.each do |t|
|
58
|
+
raise GenericStateMachine::Errors::GenericStateMachineError, "Element '#{t}' isn't a transition" unless
|
59
|
+
t.is_a?(GenericStateMachine::Transition)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
##
|
64
|
+
# Helper method validating if all elements are hooks
|
65
|
+
# raise GenericStateMachine::Errors::GenericStateMachineError if one of the elements is no Hook instance
|
66
|
+
#
|
67
|
+
def _validate_hooks(hooks)
|
68
|
+
hooks.each do |t|
|
69
|
+
raise GenericStateMachine::Errors::GenericStateMachineError, "Element '#{t}' isn't a hook" unless
|
70
|
+
t.is_a?(GenericStateMachine::Hook)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
##
|
4
|
+
module GenericStateMachine
|
5
|
+
##
|
6
|
+
# Transition represents one transition of the state
|
7
|
+
#
|
8
|
+
class Transition
|
9
|
+
attr_reader :from, :to, :condition
|
10
|
+
|
11
|
+
def initialize(from, to, condition = nil)
|
12
|
+
@from = from
|
13
|
+
@to = to
|
14
|
+
@condition = condition
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
metadata
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: generic-state-machine
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Thomas Stätter
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2020-10-31 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description: Provides the possibility to create simple state machines
|
14
|
+
email: thomas.staetter@gmail.com
|
15
|
+
executables: []
|
16
|
+
extensions: []
|
17
|
+
extra_rdoc_files: []
|
18
|
+
files:
|
19
|
+
- dsl/dsl.rb
|
20
|
+
- dsl/state_machine_dsl.rb
|
21
|
+
- spec/dsl/describe_spec.rb
|
22
|
+
- spec/spec_helper.rb
|
23
|
+
- spec/unit/generic_state_machine_spec.rb
|
24
|
+
- spec/unit/history_spec.rb
|
25
|
+
- spec/unit/state_machine_factory_spec.rb
|
26
|
+
- spec/unit/transition_spec.rb
|
27
|
+
- state_machine/errors.rb
|
28
|
+
- state_machine/state_machine.rb
|
29
|
+
- state_machine/state_machine_factory.rb
|
30
|
+
- state_machine/transition.rb
|
31
|
+
homepage: https://github.com/tstaetter/generic-state-machine
|
32
|
+
licenses: []
|
33
|
+
metadata: {}
|
34
|
+
post_install_message:
|
35
|
+
rdoc_options: []
|
36
|
+
require_paths:
|
37
|
+
- lib
|
38
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
39
|
+
requirements:
|
40
|
+
- - ">="
|
41
|
+
- !ruby/object:Gem::Version
|
42
|
+
version: 2.7.1
|
43
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
requirements: []
|
49
|
+
rubygems_version: 3.1.2
|
50
|
+
signing_key:
|
51
|
+
specification_version: 4
|
52
|
+
summary: Generic state machine
|
53
|
+
test_files:
|
54
|
+
- spec/spec_helper.rb
|
55
|
+
- spec/unit/state_machine_factory_spec.rb
|
56
|
+
- spec/unit/history_spec.rb
|
57
|
+
- spec/unit/transition_spec.rb
|
58
|
+
- spec/unit/generic_state_machine_spec.rb
|
59
|
+
- spec/dsl/describe_spec.rb
|