strict_machine 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.3.1
5
+ before_install: gem install bundler -v 1.12.4
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "https://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in strict_machine.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 Bruno Antunes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # StrictMachine
2
+
3
+ Easily add state-machine functionality to your Ruby classes.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'strict_machine'
11
+ ```
12
+
13
+ And then execute:
14
+
15
+ $ bundle
16
+
17
+ Or install it yourself as:
18
+
19
+ $ gem install strict_machine
20
+
21
+ ## Usage
22
+
23
+ Still working on this one...
24
+
25
+ ## Development
26
+
27
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
28
+
29
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
30
+
31
+ ## Contributing
32
+
33
+ Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/strict_machine.
34
+
35
+
36
+ ## License
37
+
38
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
39
+
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+ require "bundler/gem_tasks"
3
+
4
+ Bundler::GemHelper.install_tasks
5
+
6
+ require "rspec/core/rake_task"
7
+ RSpec::Core::RakeTask.new(:spec)
8
+ task default: :spec
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "strict_machine"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/lib/ext/object.rb ADDED
@@ -0,0 +1,5 @@
1
+ class Object
2
+ def metaclass
3
+ class << self; self; end
4
+ end
5
+ end
@@ -0,0 +1,93 @@
1
+ require_relative "mount_state_machine"
2
+ require_relative "definition_context"
3
+
4
+ module StrictMachine
5
+ class Base
6
+ attr_accessor :mounted_on, :state_attr
7
+
8
+ def self.strict_machine(&block)
9
+ dc = DefinitionContext.new
10
+ dc.instance_eval(&block)
11
+
12
+ metaclass.instance_eval do
13
+ define_method(:definition) { dc }
14
+ end
15
+ end
16
+
17
+ attr_writer :state_attr
18
+
19
+ def self.states
20
+ definition.states
21
+ end
22
+
23
+ def boot!
24
+ @state_attr = :status if @state_attr.nil?
25
+
26
+ change_state(self.class.states.first.name)
27
+ end
28
+
29
+ def current_state
30
+ instance_variable_get "@#{@state_attr}"
31
+ end
32
+
33
+ def definition
34
+ self.class.definition
35
+ end
36
+
37
+ def transition?(meth, state)
38
+ definition.transition?(meth, state)
39
+ end
40
+
41
+ def trigger_transition(trigger, stored = self)
42
+ dt = Time.now
43
+
44
+ is_bang = !trigger.to_s.index("!").nil?
45
+ transition = from_state.get_transition(trigger, is_bang)
46
+
47
+ if transition.guarded? && !is_bang
48
+ raise GuardedTransitionError unless stored.public_send(
49
+ transition.guard
50
+ )
51
+ end
52
+
53
+ new_state = definition.get_state_by_name(transition.to)
54
+ new_state.on_entry.each do |proc|
55
+ stored.instance_exec(current_state, trigger.to_sym, &proc)
56
+ end
57
+
58
+ duration = Time.now - dt
59
+
60
+ definition.transitions.each do |proc|
61
+ stored.instance_exec(
62
+ current_state, new_state.name, trigger.to_sym, duration, &proc
63
+ )
64
+ end
65
+
66
+ change_state(new_state.name)
67
+ end
68
+
69
+ ###
70
+
71
+ def respond_to?(meth, _include_private = false)
72
+ transition?(meth, current_state)
73
+ end
74
+
75
+ def method_missing(meth, *_args)
76
+ if transition?(meth, current_state)
77
+ trigger_transition(meth, mounted_on || self)
78
+ else
79
+ raise TransitionNotFoundError, meth
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ def from_state
86
+ definition.get_state_by_name(current_state)
87
+ end
88
+
89
+ def change_state(new_state, _is_initial = false)
90
+ instance_variable_set "@#{@state_attr}".to_sym, new_state
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,33 @@
1
+ require_relative "transition_definition"
2
+
3
+ module StrictMachine
4
+ class State
5
+ attr_reader :name, :transition_definitions, :on_entry
6
+
7
+ def initialize(name)
8
+ @name = name.to_sym
9
+ @transition_definitions = []
10
+ @on_entry = []
11
+ end
12
+
13
+ def add_transition(transition_definition)
14
+ @transition_definitions << TransitionDefinition.new(
15
+ transition_definition
16
+ )
17
+ end
18
+
19
+ def get_transition(name, is_bang)
20
+ name = name [0..-2] if is_bang
21
+
22
+ @transition_definitions.each do |this_transition|
23
+ return this_transition if this_transition.name == name.to_sym
24
+ end
25
+
26
+ raise TransitionNotFoundError, name
27
+ end
28
+
29
+ def add_on_entry(proc)
30
+ @on_entry << proc
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ module StrictMachine
2
+ class TransitionDefinition
3
+ attr_reader :name, :to, :guard
4
+
5
+ def initialize(definition)
6
+ definition.each_pair do |k, v|
7
+ if k == :if
8
+ @guard = v.to_sym
9
+ else
10
+ @name = k.to_sym
11
+ @to = v.to_sym
12
+ end
13
+ end
14
+ end
15
+
16
+ def guarded?
17
+ !@guard.nil?
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,57 @@
1
+ require_relative "definition/state"
2
+
3
+ module StrictMachine
4
+ class DefinitionContext
5
+ attr_reader :states, :transitions
6
+ attr_accessor :mounted_on
7
+
8
+ def initialize
9
+ @states = []
10
+ @transitions = []
11
+ end
12
+
13
+ def transition?(name, state)
14
+ is_bang = (name[-1] == "!")
15
+ name = is_bang ? name[0..-2] : name
16
+
17
+ @states.each do |this_state|
18
+ next unless this_state.name.to_sym == state.to_sym
19
+
20
+ this_state.transition_definitions.each do |this_transition|
21
+ return true if this_transition.name == name.to_sym
22
+ end
23
+ end
24
+
25
+ false
26
+ end
27
+
28
+ def get_state_by_name(name)
29
+ @states.each do |this_state|
30
+ return this_state if this_state.name == name
31
+ end
32
+
33
+ raise StateNotFoundError, name
34
+ end
35
+
36
+ ###
37
+
38
+ def state(name, &block)
39
+ @states << State.new(name)
40
+ instance_eval(&block) if block_given?
41
+ end
42
+
43
+ def on(hash)
44
+ @states.last.add_transition hash
45
+ end
46
+
47
+ def on_entry(&block)
48
+ @states.last.add_on_entry(block)
49
+ end
50
+
51
+ def on_transition(&block)
52
+ @transitions << block
53
+ end
54
+
55
+ ###
56
+ end
57
+ end
@@ -0,0 +1,12 @@
1
+ module StrictMachine
2
+ module MountStateMachine
3
+ module ClassMethods
4
+ def mount_state_machine(klass, options = {})
5
+ metaclass.instance_eval do
6
+ define_method(:strict_machine_class) { klass }
7
+ define_method(:strict_machine_options) { options }
8
+ end
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,18 @@
1
+ module StrictMachine
2
+ module MountStateMachine
3
+ module Initializer
4
+ def initialize
5
+ @state_machine = self.class.strict_machine_class.new
6
+ @state_machine.mounted_on = self
7
+
8
+ options = self.class.strict_machine_options
9
+ state_attr = options.fetch(:state, :status)
10
+ @state_machine.state_attr = state_attr
11
+
12
+ @state_machine.boot!
13
+
14
+ super
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,33 @@
1
+ require_relative "mount_state_machine/initializer"
2
+ require_relative "mount_state_machine/class_methods"
3
+
4
+ module StrictMachine
5
+ module MountStateMachine
6
+ def self.included(base)
7
+ base.extend ClassMethods
8
+ base.public_send :prepend, Initializer
9
+ end
10
+
11
+ def current_state
12
+ @state_machine.current_state
13
+ end
14
+
15
+ def state_attr
16
+ @state_machine.state_attr
17
+ end
18
+
19
+ ###
20
+
21
+ def method_missing(meth, *args, &block)
22
+ if @state_machine.transition?(meth, current_state)
23
+ @state_machine.trigger_transition(meth, self)
24
+ else
25
+ super
26
+ end
27
+ end
28
+
29
+ def respond_to?(meth, _include_private = false)
30
+ @state_machine.transition?(meth, current_state)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,14 @@
1
+ require_relative "ext/object"
2
+
3
+ module StrictMachine
4
+ VERSION = "0.1.0".freeze
5
+
6
+ class << self; attr_accessor :list; end
7
+ end
8
+
9
+ class StateNotFoundError < StandardError; end
10
+ class InvalidTransitionError < StandardError; end
11
+ class TransitionNotFoundError < StandardError; end
12
+ class GuardedTransitionError < StandardError; end
13
+
14
+ require_relative "strict_machine/base"
data/spec/dsl_spec.rb ADDED
@@ -0,0 +1,155 @@
1
+ require "spec_helper"
2
+ include StrictMachine
3
+
4
+ describe DefinitionContext do
5
+ describe '#state' do
6
+ it "adds a new state to the definition" do
7
+ klass = Class.new(StrictMachine::Base) do
8
+ strict_machine do
9
+ state :initial
10
+ end
11
+ end
12
+
13
+ expect(klass.states.size).to eq(1)
14
+ expect(klass.states.first.name).to eq(:initial)
15
+ end
16
+ end
17
+
18
+ describe '#on' do
19
+ it "triggers a state change" do
20
+ klass = Class.new(StrictMachine::Base) do
21
+ strict_machine do
22
+ state :initial do
23
+ on hop: :done
24
+ end
25
+ state :done
26
+ end
27
+ end
28
+
29
+ machine = klass.new
30
+ machine.boot!
31
+ expect(machine.current_state).to eq(:initial)
32
+ machine.hop
33
+ expect(machine.current_state).to eq(:done)
34
+ end
35
+
36
+ it "honors guard statements" do
37
+ klass = Class.new(StrictMachine::Base) do
38
+ strict_machine do
39
+ state :initial do
40
+ on hop: :done, if: :guarded?
41
+ end
42
+ state :done
43
+ end
44
+
45
+ def guarded?
46
+ false
47
+ end
48
+ end
49
+
50
+ machine = klass.new
51
+ machine.boot!
52
+ expect(machine.current_state).to eq(:initial)
53
+ expect { machine.hop }.to raise_error(GuardedTransitionError)
54
+ end
55
+
56
+ it "bypasses guard statements with a bang" do
57
+ klass = Class.new(StrictMachine::Base) do
58
+ strict_machine do
59
+ state :initial do
60
+ on hop: :done, if: :guarded?
61
+ end
62
+ state :done
63
+ end
64
+
65
+ def guarded?
66
+ false
67
+ end
68
+ end
69
+
70
+ machine = klass.new
71
+ machine.boot!
72
+ machine.hop!
73
+ expect(machine.current_state).to eql(:done)
74
+ end
75
+
76
+ it "raises error on invalid state transitions" do
77
+ klass = Class.new(StrictMachine::Base) do
78
+ strict_machine do
79
+ state :initial do
80
+ on hop: :dinn
81
+ end
82
+ end
83
+ end
84
+
85
+ machine = klass.new
86
+ machine.boot!
87
+ expect { machine.hop }.to raise_error(StateNotFoundError)
88
+ expect { machine.zing }.to raise_error(TransitionNotFoundError)
89
+ end
90
+ end
91
+
92
+ describe '#on_entry' do
93
+ it "gets called upon entering a state" do
94
+ klass = Class.new(StrictMachine::Base) do
95
+ strict_machine do
96
+ state :initial do
97
+ on hop: :done
98
+ end
99
+ state :done do
100
+ on_entry do |previous, trigger|
101
+ log(previous, trigger)
102
+ end
103
+ on_entry do |previous, trigger|
104
+ log2(previous, trigger)
105
+ end
106
+ end
107
+ end
108
+
109
+ def log(_, _); end
110
+
111
+ def log2(_, _); end
112
+ end
113
+
114
+ machine = klass.new
115
+ expect_any_instance_of(klass).to receive(:log).with(:initial, :hop)
116
+ expect_any_instance_of(klass).to receive(:log2).with(:initial, :hop)
117
+
118
+ machine.boot!
119
+ machine.hop
120
+ end
121
+ end
122
+
123
+ describe '#on_transition' do
124
+ it "gets called upon entering any state" do
125
+ klass = Class.new(StrictMachine::Base) do
126
+ strict_machine do
127
+ state :initial do
128
+ on hop: :middle
129
+ end
130
+ state :middle do
131
+ on hop: :final
132
+ end
133
+ state :final
134
+ on_transition do |from, to, trigger_event, duration|
135
+ log from, to, trigger_event, duration
136
+ end
137
+ end
138
+
139
+ def log(_, _, _, _); end
140
+ end
141
+
142
+ machine = klass.new
143
+ expect_any_instance_of(klass).to receive(:log).with(
144
+ :initial, :middle, :hop, any_args
145
+ )
146
+ expect_any_instance_of(klass).to receive(:log).with(
147
+ :middle, :final, :hop, any_args
148
+ )
149
+
150
+ machine.boot!
151
+ machine.hop
152
+ machine.hop
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,19 @@
1
+ class Dummy
2
+ include StrictMachine::MountStateMachine
3
+
4
+ mount_state_machine DummyStateMachine, state: "meh"
5
+
6
+ def cool_article?
7
+ true
8
+ end
9
+
10
+ def bad_article?
11
+ false
12
+ end
13
+
14
+ def log(_, _, _, _); end
15
+
16
+ def log2(_, _); end
17
+
18
+ def send_reports; end
19
+ end
@@ -0,0 +1,24 @@
1
+ class DummyStateMachine < StrictMachine::Base
2
+ strict_machine do
3
+ state :new do
4
+ on submit: :awaiting_review
5
+ end
6
+
7
+ state :awaiting_review do
8
+ on review: :under_review
9
+ end
10
+
11
+ state :under_review do
12
+ on_entry { |previous, trigger| log2(previous, trigger) }
13
+ on accept: :accepted, if: :cool_article?
14
+ on reject: :rejected, if: :bad_article?
15
+ end
16
+
17
+ state :accepted
18
+ state :rejected
19
+
20
+ on_transition do |from, to, trigger_event, duration|
21
+ log from, to, trigger_event, duration
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,47 @@
1
+ require "spec_helper"
2
+
3
+ describe "mounted StrictMachine" do
4
+ context "state shifting" do
5
+ require_relative "dummy/dummy_state_machine"
6
+ require_relative "dummy/dummy"
7
+
8
+ let!(:dummy) { Dummy.new }
9
+
10
+ it "has an initial state" do
11
+ expect(dummy.current_state).to eq(:new)
12
+ end
13
+
14
+ it "shifts state" do
15
+ dummy.submit
16
+
17
+ expect(dummy.current_state).to eq(:awaiting_review)
18
+ end
19
+
20
+ it "honors guard statements" do
21
+ dummy.submit
22
+ dummy.review
23
+
24
+ expect { dummy.reject }.to raise_error(GuardedTransitionError)
25
+
26
+ expect(dummy.current_state).to eq(:under_review)
27
+ end
28
+
29
+ it "bypasses guard statements with bangs" do
30
+ dummy.submit
31
+ dummy.review
32
+ dummy.reject!
33
+
34
+ expect(dummy.current_state).to eq(:rejected)
35
+ end
36
+
37
+ it "calls on_transition blocks" do
38
+ expect(dummy).to receive(:log)
39
+
40
+ dummy.submit
41
+ end
42
+
43
+ it "sets state to the given status option name" do
44
+ expect(dummy.state_attr).to eq("meh")
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,16 @@
1
+ require "bundler/setup"
2
+ require "byebug"
3
+
4
+ require "strict_machine"
5
+
6
+ Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each { |f| require f }
7
+
8
+ RSpec.configure do |config|
9
+ config.expect_with :rspec do |c|
10
+ c.syntax = :expect
11
+ end
12
+
13
+ config.include RSpec::Matchers
14
+ config.mock_with :rspec
15
+ config.order = "random"
16
+ end