composable_state_machine 1.0.2

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.
Files changed (42) hide show
  1. checksums.yaml +15 -0
  2. data/.gitignore +17 -0
  3. data/.rspec +3 -0
  4. data/.simplecov +4 -0
  5. data/.travis.yml +8 -0
  6. data/.yardopts +4 -0
  7. data/Gemfile +4 -0
  8. data/Guardfile +5 -0
  9. data/LICENSE.txt +22 -0
  10. data/README.md +352 -0
  11. data/Rakefile +19 -0
  12. data/assets/class-diagram.yuml +24 -0
  13. data/assets/uml-class-diagram.png +0 -0
  14. data/composable_state_machine.gemspec +35 -0
  15. data/lib/composable_state_machine.rb +45 -0
  16. data/lib/composable_state_machine/behaviors.rb +48 -0
  17. data/lib/composable_state_machine/callback_runner.rb +19 -0
  18. data/lib/composable_state_machine/callbacks.rb +56 -0
  19. data/lib/composable_state_machine/default_callback_runner.rb +16 -0
  20. data/lib/composable_state_machine/invalid_event.rb +7 -0
  21. data/lib/composable_state_machine/invalid_transition.rb +7 -0
  22. data/lib/composable_state_machine/invalid_trigger.rb +7 -0
  23. data/lib/composable_state_machine/machine.rb +21 -0
  24. data/lib/composable_state_machine/machine_with_external_state.rb +41 -0
  25. data/lib/composable_state_machine/model.rb +55 -0
  26. data/lib/composable_state_machine/transitions.rb +73 -0
  27. data/lib/composable_state_machine/version.rb +3 -0
  28. data/spec/integration/auto_update_state_spec.rb +38 -0
  29. data/spec/integration/instance_callbacks_spec.rb +47 -0
  30. data/spec/integration/leave_callbacks_spec.rb +60 -0
  31. data/spec/integration/leave_callbacks_with_composition_spec.rb +68 -0
  32. data/spec/lib/composable_state_machine/behaviors_spec.rb +83 -0
  33. data/spec/lib/composable_state_machine/callback_runner_spec.rb +54 -0
  34. data/spec/lib/composable_state_machine/callbacks_spec.rb +106 -0
  35. data/spec/lib/composable_state_machine/machine_spec.rb +25 -0
  36. data/spec/lib/composable_state_machine/machine_with_external_state_spec.rb +97 -0
  37. data/spec/lib/composable_state_machine/model_spec.rb +76 -0
  38. data/spec/lib/composable_state_machine/transitions_spec.rb +77 -0
  39. data/spec/lib/composable_state_machine_spec.rb +53 -0
  40. data/spec/spec_helper.rb +14 -0
  41. data/spec/support/delegation.rb +196 -0
  42. metadata +218 -0
@@ -0,0 +1,25 @@
1
+ require 'spec_helper'
2
+
3
+ describe ComposableStateMachine::Machine do
4
+
5
+ describe '#initialization' do
6
+ it 'can set the initial state' do
7
+ model = double(callback_runner: proc {})
8
+ machine = described_class.new(model, state: :test_state)
9
+
10
+ machine.should be_kind_of ComposableStateMachine::MachineWithExternalState
11
+ machine.state.should eq :test_state
12
+ end
13
+
14
+ it 'delegates to MachineWithExternalState hooking up #state updates' do
15
+ model = ComposableStateMachine.model(
16
+ initial_state: :first, transitions: {next: {first: :second}})
17
+ machine = described_class.new(model)
18
+
19
+ machine.state.should eq :first
20
+ machine.trigger(:next).should eq :second
21
+ machine.state.should eq :second
22
+ end
23
+ end
24
+
25
+ end
@@ -0,0 +1,97 @@
1
+ require 'spec_helper'
2
+
3
+ describe ComposableStateMachine::MachineWithExternalState do
4
+
5
+ let(:transitions) do
6
+ ComposableStateMachine::Transitions.new(
7
+ {
8
+ hire: {candidate: :hired},
9
+ leave: {hired: :departed},
10
+ fire: {hired: :fired},
11
+ }
12
+ )
13
+ end
14
+
15
+ let(:model) do
16
+ ComposableStateMachine::Model.new(
17
+ initial_state: :candidate,
18
+ transitions: transitions
19
+ )
20
+ end
21
+
22
+ let(:model_with_behavior) do
23
+ ComposableStateMachine::Model.new(
24
+ initial_state: :candidate,
25
+ transitions: transitions,
26
+ behaviors: ComposableStateMachine::Behaviors.new(
27
+ {
28
+ enter: {hired: proc {}}
29
+ }
30
+ )
31
+ )
32
+ end
33
+
34
+ before do
35
+ @state = nil
36
+ @state_reader = lambda { @state }
37
+ @state_writer = lambda { |new_state| @state = new_state }
38
+ end
39
+
40
+ describe '#initialization' do
41
+ it 'sets the state to the initial state of the model by default' do
42
+ described_class.new(model, @state_reader, @state_writer)
43
+
44
+ @state.should eq :candidate
45
+ end
46
+
47
+ it 'can set the initial state' do
48
+ described_class.new(model, @state_reader, @state_writer, state: :hired)
49
+
50
+ @state.should eq :hired
51
+ end
52
+
53
+ it 'can specify the callback runner' do
54
+ runner = double(run_state_machine_callback: nil)
55
+ machine = described_class.new(model_with_behavior, @state_reader, @state_writer,
56
+ callback_runner: runner, state: :candidate)
57
+
58
+ runner.should_receive(:run_state_machine_callback).with(an_instance_of(Proc), :candidate, :hire, :hired)
59
+
60
+ machine.trigger(:hire)
61
+ end
62
+ end
63
+
64
+ describe 'main API' do
65
+ subject { described_class.new(model, @state_reader, @state_writer) }
66
+
67
+ describe '#trigger' do
68
+ it 'transitions via the model' do
69
+ model.should_receive(:transition).with(
70
+ :candidate, :hire, [], ComposableStateMachine::DefaultCallbackRunner).
71
+ and_call_original
72
+
73
+ subject.trigger(:hire).should eq :hired
74
+ end
75
+
76
+ it 'updates the state when a transition is made' do
77
+ subject.trigger(:hire)
78
+
79
+ @state.should eq :hired
80
+ end
81
+
82
+ it 'does not update the state when a transition is not made' do
83
+ subject.trigger(:hire)
84
+
85
+ subject.trigger(:hire).should be_nil
86
+ @state.should eq :hired
87
+ end
88
+ end
89
+
90
+ describe '#==' do
91
+ it 'compares equality based on the state' do
92
+ subject.should == :candidate
93
+ end
94
+ end
95
+ end
96
+
97
+ end
@@ -0,0 +1,76 @@
1
+ require 'spec_helper'
2
+
3
+ describe ComposableStateMachine::Model do
4
+
5
+ let(:transition_map) do
6
+ {
7
+ heat: {
8
+ cold: :warm,
9
+ warm: :hot,
10
+ hot: :hot,
11
+ },
12
+ cool: {
13
+ cold: :cold,
14
+ warm: :cold,
15
+ hot: :warm
16
+ }
17
+ }
18
+ end
19
+ let(:transitions) { ComposableStateMachine::Transitions.new(transition_map) }
20
+ let(:behaviors) { double(call: nil) }
21
+
22
+ subject do
23
+ described_class.new(initial_state: :cold,
24
+ transitions: transitions, behaviors: behaviors)
25
+ end
26
+
27
+ describe '#initial_state' do
28
+ it 'stores the initial state of the model' do
29
+ subject.initial_state.should eq :cold
30
+ end
31
+ end
32
+
33
+ describe '#transition' do
34
+
35
+ it 'forwards to transitions' do
36
+ transitions.should_receive(:transition).with(:cold, :heat)
37
+
38
+ subject.transition(:cold, :heat)
39
+ end
40
+
41
+ it 'executes :enter behaviors with the new state and the event to behaviors' do
42
+ behaviors.should_receive(:call).with(
43
+ ComposableStateMachine::DefaultCallbackRunner,
44
+ :enter, :warm, :cold, :heat, :warm)
45
+
46
+ subject.transition(:cold, :heat).should eq :warm
47
+ end
48
+
49
+ it 'yields the new state on state change' do
50
+ expect do |b|
51
+ subject.transition(:cold, :heat, &b)
52
+ end.to yield_with_args(:warm)
53
+ end
54
+
55
+ it 'does not execute :enter behaviors when the state does not transition' do
56
+ behaviors.should_not_receive(:call)
57
+
58
+ subject.transition(:cold, :cool).should eq :cold
59
+ end
60
+
61
+ it 'does not yield if there is no state change' do
62
+ expect do |b|
63
+ subject.transition(:cold, :cool, &b)
64
+ end.not_to yield_control
65
+ end
66
+
67
+ it 'can send optional event arguments and callback runner' do
68
+ runner = double
69
+
70
+ behaviors.should_receive(:call).with(runner, :enter, :warm, :cold, :heat, :warm, 1, 2, 3)
71
+
72
+ subject.transition(:cold, :heat, [1, 2, 3], runner).should eq :warm
73
+ end
74
+ end
75
+
76
+ end
@@ -0,0 +1,77 @@
1
+ require 'spec_helper'
2
+
3
+ describe ComposableStateMachine::Transitions do
4
+
5
+ let(:transition_map) do
6
+ {
7
+ add: {
8
+ nil => :created
9
+ },
10
+ update: {
11
+ created: :updated,
12
+ updated: :updated,
13
+ },
14
+ remove: {
15
+ created: :removed,
16
+ updated: :removed,
17
+ }
18
+ }
19
+ end
20
+
21
+ subject { described_class.new(transition_map) }
22
+
23
+ describe '#events' do
24
+ it 'returns all events' do
25
+ subject.events == [:add, :update, :remove]
26
+ end
27
+ end
28
+
29
+ describe '#states' do
30
+ it 'returns all states' do
31
+ subject.states =~ [nil, :created, :updated, :removed]
32
+ end
33
+ end
34
+
35
+ describe '#on' do
36
+ it 'adds to the transition map' do
37
+ subject.
38
+ on(:remove, nil => :error).
39
+ on(:restore, :removed => :updated)
40
+
41
+ subject.events.should eq [:add, :update, :remove, :restore]
42
+ subject.states =~ [nil, :created, :updated, :removed, :error]
43
+
44
+ subject.transition(nil, :add).should eq :created
45
+ subject.transition(nil, :remove).should eq :error
46
+ subject.transition(:removed, :restore).should eq :updated
47
+ end
48
+
49
+ it 'raises InvalidTransition on transitions to nil' do
50
+ expect do
51
+ subject.on(:expunge, created: nil)
52
+ end.to raise_error ComposableStateMachine::InvalidTransition
53
+ end
54
+ end
55
+
56
+ describe '#transition' do
57
+ it 'raises InvalidEvent for unknown events' do
58
+ expect do
59
+ subject.transition(:updated, :unknown)
60
+ end.to raise_error ComposableStateMachine::InvalidEvent
61
+ end
62
+
63
+ it 'returns the state to transition to when possible' do
64
+ subject.transition(nil, :add).should eq :created
65
+ subject.transition(:created, :update).should eq :updated
66
+ subject.transition(:updated, :update).should eq :updated
67
+ subject.transition(:created, :remove).should eq :removed
68
+ subject.transition(:updated, :remove).should eq :removed
69
+ end
70
+
71
+ it 'returns nil if no transition is possible' do
72
+ subject.transition(nil, :remove).should be_nil
73
+ subject.transition(:created, :add).should be_nil
74
+ end
75
+ end
76
+
77
+ end
@@ -0,0 +1,53 @@
1
+ require 'spec_helper'
2
+
3
+ describe ComposableStateMachine do
4
+
5
+ let(:options) do
6
+ {
7
+ initial_state: :foo,
8
+ transitions: ComposableStateMachine::Transitions.new({}),
9
+ behaviors: ComposableStateMachine::Behaviors.new({}),
10
+ callback_runner: proc {}
11
+ }
12
+ end
13
+
14
+ describe '.model' do
15
+ it 'creates a model' do
16
+ ComposableStateMachine::Model.should_receive(:new).with(options).and_call_original
17
+
18
+ result = described_class.model(options)
19
+ result.should be_kind_of(ComposableStateMachine::Model)
20
+ end
21
+
22
+ it 'creates a Transitions object if necessary' do
23
+ options[:transitions] = {}
24
+
25
+ ComposableStateMachine::Model.should_receive(:new) do |options|
26
+ options[:transitions].should be_kind_of(ComposableStateMachine::Transitions)
27
+ end
28
+
29
+ described_class.model(options)
30
+ end
31
+
32
+ it 'creates a Behaviors object if necessary' do
33
+ options[:behaviors] = {}
34
+
35
+ ComposableStateMachine::Model.should_receive(:new) do |options|
36
+ options[:behaviors].should be_kind_of(ComposableStateMachine::Behaviors)
37
+ end
38
+
39
+ described_class.model(options)
40
+ end
41
+ end
42
+
43
+ describe '.machine' do
44
+ it 'creates a machine from a model' do
45
+ model = described_class.model(options)
46
+
47
+ ComposableStateMachine::Machine.should_receive(:new).with(model, initial_state: :bar).and_call_original
48
+
49
+ result = described_class.machine(model, initial_state: :bar)
50
+ result.should be_kind_of(ComposableStateMachine::Machine)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,14 @@
1
+ require 'simplecov'
2
+ SimpleCov.start
3
+ SimpleCov.minimum_coverage 100
4
+
5
+ if ENV['TRAVIS']
6
+ require 'coveralls'
7
+ Coveralls.wear!
8
+ end
9
+
10
+ require 'pp'
11
+
12
+ require 'composable_state_machine'
13
+
14
+ Dir['spec/support/**/*.rb'].each { |f| require File.expand_path(f) }
@@ -0,0 +1,196 @@
1
+ class Module
2
+ # Provides a +delegate+ class method to easily expose contained objects'
3
+ # public methods as your own.
4
+ #
5
+ # The macro receives one or more method names (specified as symbols or
6
+ # strings) and the name of the target object via the <tt>:to</tt> option
7
+ # (also a symbol or string).
8
+ #
9
+ # Delegation is particularly useful with Active Record associations:
10
+ #
11
+ # class Greeter < ActiveRecord::Base
12
+ # def hello
13
+ # 'hello'
14
+ # end
15
+ #
16
+ # def goodbye
17
+ # 'goodbye'
18
+ # end
19
+ # end
20
+ #
21
+ # class Foo < ActiveRecord::Base
22
+ # belongs_to :greeter
23
+ # delegate :hello, to: :greeter
24
+ # end
25
+ #
26
+ # Foo.new.hello # => "hello"
27
+ # Foo.new.goodbye # => NoMethodError: undefined method `goodbye' for #<Foo:0x1af30c>
28
+ #
29
+ # Multiple delegates to the same target are allowed:
30
+ #
31
+ # class Foo < ActiveRecord::Base
32
+ # belongs_to :greeter
33
+ # delegate :hello, :goodbye, to: :greeter
34
+ # end
35
+ #
36
+ # Foo.new.goodbye # => "goodbye"
37
+ #
38
+ # Methods can be delegated to instance variables, class variables, or constants
39
+ # by providing them as a symbols:
40
+ #
41
+ # class Foo
42
+ # CONSTANT_ARRAY = [0,1,2,3]
43
+ # @@class_array = [4,5,6,7]
44
+ #
45
+ # def initialize
46
+ # @instance_array = [8,9,10,11]
47
+ # end
48
+ # delegate :sum, to: :CONSTANT_ARRAY
49
+ # delegate :min, to: :@@class_array
50
+ # delegate :max, to: :@instance_array
51
+ # end
52
+ #
53
+ # Foo.new.sum # => 6
54
+ # Foo.new.min # => 4
55
+ # Foo.new.max # => 11
56
+ #
57
+ # It's also possible to delegate a method to the class by using +:class+:
58
+ #
59
+ # class Foo
60
+ # def self.hello
61
+ # "world"
62
+ # end
63
+ #
64
+ # delegate :hello, to: :class
65
+ # end
66
+ #
67
+ # Foo.new.hello # => "world"
68
+ #
69
+ # Delegates can optionally be prefixed using the <tt>:prefix</tt> option. If the value
70
+ # is <tt>true</tt>, the delegate methods are prefixed with the name of the object being
71
+ # delegated to.
72
+ #
73
+ # Person = Struct.new(:name, :address)
74
+ #
75
+ # class Invoice < Struct.new(:client)
76
+ # delegate :name, :address, to: :client, prefix: true
77
+ # end
78
+ #
79
+ # john_doe = Person.new('John Doe', 'Vimmersvej 13')
80
+ # invoice = Invoice.new(john_doe)
81
+ # invoice.client_name # => "John Doe"
82
+ # invoice.client_address # => "Vimmersvej 13"
83
+ #
84
+ # It is also possible to supply a custom prefix.
85
+ #
86
+ # class Invoice < Struct.new(:client)
87
+ # delegate :name, :address, to: :client, prefix: :customer
88
+ # end
89
+ #
90
+ # invoice = Invoice.new(john_doe)
91
+ # invoice.customer_name # => 'John Doe'
92
+ # invoice.customer_address # => 'Vimmersvej 13'
93
+ #
94
+ # If the target is +nil+ and does not respond to the delegated method a
95
+ # +NoMethodError+ is raised, as with any other value. Sometimes, however, it
96
+ # makes sense to be robust to that situation and that is the purpose of the
97
+ # <tt>:allow_nil</tt> option: If the target is not +nil+, or it is and
98
+ # responds to the method, everything works as usual. But if it is +nil+ and
99
+ # does not respond to the delegated method, +nil+ is returned.
100
+ #
101
+ # class User < ActiveRecord::Base
102
+ # has_one :profile
103
+ # delegate :age, to: :profile
104
+ # end
105
+ #
106
+ # User.new.age # raises NoMethodError: undefined method `age'
107
+ #
108
+ # But if not having a profile yet is fine and should not be an error
109
+ # condition:
110
+ #
111
+ # class User < ActiveRecord::Base
112
+ # has_one :profile
113
+ # delegate :age, to: :profile, allow_nil: true
114
+ # end
115
+ #
116
+ # User.new.age # nil
117
+ #
118
+ # Note that if the target is not +nil+ then the call is attempted regardless of the
119
+ # <tt>:allow_nil</tt> option, and thus an exception is still raised if said object
120
+ # does not respond to the method:
121
+ #
122
+ # class Foo
123
+ # def initialize(bar)
124
+ # @bar = bar
125
+ # end
126
+ #
127
+ # delegate :name, to: :@bar, allow_nil: true
128
+ # end
129
+ #
130
+ # Foo.new("Bar").name # raises NoMethodError: undefined method `name'
131
+ #
132
+ def delegate(*methods)
133
+ options = methods.pop
134
+ unless options.is_a?(Hash) && to = options[:to]
135
+ raise ArgumentError, 'Delegation needs a target. Supply an options hash with a :to key as the last argument (e.g. delegate :hello, to: :greeter).'
136
+ end
137
+
138
+ prefix, allow_nil = options.values_at(:prefix, :allow_nil)
139
+
140
+ if prefix == true && to =~ /^[^a-z_]/
141
+ raise ArgumentError, 'Can only automatically set the delegation prefix when delegating to a method.'
142
+ end
143
+
144
+ method_prefix = \
145
+ if prefix
146
+ "#{prefix == true ? to : prefix}_"
147
+ else
148
+ ''
149
+ end
150
+
151
+ file, line = caller.first.split(':', 2)
152
+ line = line.to_i
153
+
154
+ to = to.to_s
155
+ to = 'self.class' if to == 'class'
156
+
157
+ methods.each do |method|
158
+ # Attribute writer methods only accept one argument. Makes sure []=
159
+ # methods still accept two arguments.
160
+ definition = (method =~ /[^\]]=$/) ? 'arg' : '*args, &block'
161
+
162
+ # The following generated methods call the target exactly once, storing
163
+ # the returned value in a dummy variable.
164
+ #
165
+ # Reason is twofold: On one hand doing less calls is in general better.
166
+ # On the other hand it could be that the target has side-effects,
167
+ # whereas conceptualy, from the user point of view, the delegator should
168
+ # be doing one call.
169
+ if allow_nil
170
+ module_eval(<<-EOS, file, line - 3)
171
+ def #{method_prefix}#{method}(#{definition}) # def customer_name(*args, &block)
172
+ _ = #{to} # _ = client
173
+ if !_.nil? || nil.respond_to?(:#{method}) # if !_.nil? || nil.respond_to?(:name)
174
+ _.#{method}(#{definition}) # _.name(*args, &block)
175
+ end # end
176
+ end # end
177
+ EOS
178
+ else
179
+ exception = %(raise "#{self}##{method_prefix}#{method} delegated to #{to}.#{method}, but #{to} is nil: \#{self.inspect}")
180
+
181
+ module_eval(<<-EOS, file, line - 2)
182
+ def #{method_prefix}#{method}(#{definition}) # def customer_name(*args, &block)
183
+ _ = #{to} # _ = client
184
+ _.#{method}(#{definition}) # _.name(*args, &block)
185
+ rescue NoMethodError # rescue NoMethodError
186
+ if _.nil? # if _.nil?
187
+ #{exception} # # add helpful message to the exception
188
+ else # else
189
+ raise # raise
190
+ end # end
191
+ end # end
192
+ EOS
193
+ end
194
+ end
195
+ end
196
+ end