├── bin └── smplsm ├── History.txt ├── lib ├── smplsm.rb └── smplsm │ ├── stateful.rb │ └── statemachine.rb ├── Manifest.txt ├── Gemfile ├── Gemfile.lock ├── test ├── test_smplsm.rb ├── stateful_test.rb └── statemachine_test.rb ├── .autotest ├── Rakefile └── README.txt /bin/smplsm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | abort "you need to write me" 4 | -------------------------------------------------------------------------------- /History.txt: -------------------------------------------------------------------------------- 1 | === 1.0.0 / 2014-11-27 2 | 3 | * 1 major enhancement 4 | 5 | * Birthday! 6 | 7 | -------------------------------------------------------------------------------- /lib/smplsm.rb: -------------------------------------------------------------------------------- 1 | require 'smplsm/statemachine' 2 | require 'smplsm/stateful' 3 | module Smplsm 4 | VERSION = "1.0.0" 5 | end 6 | -------------------------------------------------------------------------------- /Manifest.txt: -------------------------------------------------------------------------------- 1 | .autotest 2 | History.txt 3 | Manifest.txt 4 | README.txt 5 | Rakefile 6 | bin/smplsm 7 | lib/smplsm.rb 8 | test/test_smplsm.rb 9 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | group :development do 4 | gem 'hoe' 5 | end 6 | 7 | group :development, :test do 8 | gem 'minitest' 9 | end 10 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | hoe (3.13.0) 5 | rake (>= 0.8, < 11.0) 6 | minitest (5.4.2) 7 | rake (10.4.0) 8 | 9 | PLATFORMS 10 | ruby 11 | 12 | DEPENDENCIES 13 | hoe 14 | minitest 15 | -------------------------------------------------------------------------------- /test/test_smplsm.rb: -------------------------------------------------------------------------------- 1 | gem "minitest" 2 | require "minitest/autorun" 3 | require "smplsm" 4 | 5 | class TestSmplsm < Minitest::Test 6 | 7 | # class EnableDisable < Smplsm::StateMachine 8 | # default :enabled 9 | # event :enable do 10 | # transition :enabled => :disabled 11 | # end 12 | 13 | # event :disable do 14 | # transition :disabled => :enabled 15 | # end 16 | # end 17 | 18 | # class SomeObject 19 | # extend Smplsm::Stateful 20 | # state_on :state, using: EnableDisable 21 | # end 22 | end 23 | -------------------------------------------------------------------------------- /.autotest: -------------------------------------------------------------------------------- 1 | # -*- ruby -*- 2 | 3 | require "autotest/restart" 4 | 5 | # Autotest.add_hook :initialize do |at| 6 | # at.testlib = "minitest/unit" 7 | # 8 | # at.extra_files << "../some/external/dependency.rb" 9 | # 10 | # at.libs << ":../some/external" 11 | # 12 | # at.add_exception "vendor" 13 | # 14 | # at.add_mapping(/dependency.rb/) do |f, _| 15 | # at.files_matching(/test_.*rb$/) 16 | # end 17 | # 18 | # %w(TestA TestB).each do |klass| 19 | # at.extra_class_map[klass] = "test/test_misc.rb" 20 | # end 21 | # end 22 | 23 | # Autotest.add_hook :run_command do |at| 24 | # system "rake build" 25 | # end 26 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # -*- ruby -*- 2 | 3 | require "rubygems" 4 | require "hoe" 5 | 6 | # Hoe.plugin :compiler 7 | # Hoe.plugin :gem_prelude_sucks 8 | # Hoe.plugin :inline 9 | # Hoe.plugin :minitest 10 | # Hoe.plugin :racc 11 | # Hoe.plugin :rcov 12 | # Hoe.plugin :rdoc 13 | 14 | Hoe.spec "smplsm" do 15 | # HEY! If you fill these out in ~/.hoe_template/default/Rakefile.erb then 16 | # you'll never have to touch them again! 17 | # (delete this comment too, of course) 18 | 19 | developer("Chris Saunders", "chris@christophersaunders.ca") 20 | 21 | license "MIT" # this should match the license in the README 22 | end 23 | 24 | # vim: syntax=ruby 25 | -------------------------------------------------------------------------------- /lib/smplsm/stateful.rb: -------------------------------------------------------------------------------- 1 | module Smplsm 2 | module Stateful 3 | class StateRedefinitionError < StandardError; end 4 | class InvalidStateMachineError < StandardError; end 5 | 6 | module ClassMethods 7 | def new *args 8 | super.tap do |instance| 9 | @state_machines.each do |field, machine| 10 | machine.new(instance, field) 11 | end 12 | end 13 | end 14 | 15 | def state_on(state, using: nil) 16 | @state_machines ||= {} 17 | raise StateRedefinitionError, "A State Machine for #{state} is already defined" if @state_machines[state] 18 | raise InvalidStateMachineError, "State Machine cannot be nil" if using.nil? 19 | @state_machines[state] = using 20 | end 21 | 22 | def sm_for(state) 23 | @state_machines[state] 24 | end 25 | end 26 | 27 | def self.included(base) 28 | base.extend ClassMethods 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/stateful_test.rb: -------------------------------------------------------------------------------- 1 | gem 'minitest' 2 | require 'minitest/autorun' 3 | require 'smplsm' 4 | 5 | module Smplsm 6 | class StatefulTest < Minitest::Test 7 | class SM < StateMachine 8 | default :hello 9 | end 10 | 11 | class StatefulObject 12 | attr_accessor :state 13 | 14 | include Stateful 15 | state_on :state, using: SM 16 | end 17 | 18 | def test_defining_a_class_that_is_stateful 19 | state_machine = Class.new 20 | stateful_class = Class.new do 21 | attr_reader :state 22 | 23 | include Stateful 24 | state_on :state, using: state_machine 25 | end 26 | 27 | assert_equal state_machine, stateful_class.sm_for(:state) 28 | end 29 | 30 | def test_using_state_on_with_nil_raises_an_error 31 | assert_raises Stateful::InvalidStateMachineError do 32 | Class.new do 33 | attr_reader :state 34 | include Stateful 35 | state_on :state 36 | end 37 | end 38 | end 39 | 40 | def test_using_state_on_the_same_field_raises_an_error 41 | assert_raises Stateful::StateRedefinitionError do 42 | Class.new do 43 | attr_reader :state 44 | include Stateful 45 | state_on :state, using: Class.new 46 | state_on :state, using: Class.new 47 | end 48 | end 49 | end 50 | 51 | def test_initializing_a_stateful_object_initializes_the_machines_and_sets_the_default 52 | obj = StatefulObject.new 53 | assert_equal :hello, obj.state 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | = smplsm 2 | 3 | home :: FIX (url) 4 | code :: FIX (url) 5 | rdoc :: FIX (url) 6 | bugs :: FIX (url) 7 | ... etc ... 8 | 9 | == DESCRIPTION: 10 | 11 | FIX (describe your package) 12 | 13 | == FEATURES/PROBLEMS: 14 | 15 | * FIX (list of features or problems) 16 | 17 | == SYNOPSIS: 18 | 19 | FIX (code sample of usage) 20 | 21 | == REQUIREMENTS: 22 | 23 | * FIX (list of requirements) 24 | 25 | == INSTALL: 26 | 27 | * FIX (sudo gem install, anything else) 28 | 29 | == DEVELOPERS: 30 | 31 | After checking out the source, run: 32 | 33 | $ rake newb 34 | 35 | This task will install any missing dependencies, run the tests/specs, 36 | and generate the RDoc. 37 | 38 | == LICENSE: 39 | 40 | (The MIT License) 41 | 42 | Copyright (c) 2014 Chris Saunders 43 | 44 | Permission is hereby granted, free of charge, to any person obtaining 45 | a copy of this software and associated documentation files (the 46 | 'Software'), to deal in the Software without restriction, including 47 | without limitation the rights to use, copy, modify, merge, publish, 48 | distribute, sublicense, and/or sell copies of the Software, and to 49 | permit persons to whom the Software is furnished to do so, subject to 50 | the following conditions: 51 | 52 | The above copyright notice and this permission notice shall be 53 | included in all copies or substantial portions of the Software. 54 | 55 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 56 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 57 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 58 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 59 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 60 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 61 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 62 | -------------------------------------------------------------------------------- /test/statemachine_test.rb: -------------------------------------------------------------------------------- 1 | gem "minitest" 2 | require "minitest/autorun" 3 | require "smplsm" 4 | 5 | module Smplsm 6 | class StateMachineTest < Minitest::Test 7 | attr_reader :item 8 | class Item; attr_accessor :state, :info; end 9 | 10 | class AnSM < StateMachine 11 | default :hello 12 | 13 | event :goodbye do 14 | transition :hello, to: :farewell do |obj| 15 | obj.info = 'hello -> farewell' 16 | end 17 | end 18 | 19 | event :hello_again do 20 | transition :farewell, to: :hello 21 | end 22 | end 23 | 24 | def setup 25 | @item = Item.new 26 | @machine = AnSM.new(@item, :state) 27 | end 28 | 29 | def test_creating_a_statemachine_without_a_to_transition_raises_an_error 30 | assert_raises StateMachine::StateDefinitionError do 31 | cls = Class.new(StateMachine) do 32 | event :a do 33 | transition :hello 34 | end 35 | end 36 | end 37 | end 38 | 39 | def test_getting_the_initial_state 40 | assert_equal :hello, @machine.default_state 41 | end 42 | 43 | def test_a_default_state 44 | assert_equal :hello, item.state 45 | end 46 | 47 | def test_a_transition 48 | assert_equal :hello, item.state 49 | item.goodbye 50 | assert_equal :farewell, item.state 51 | end 52 | 53 | def test_transitioning_evaluates_the_provided_block 54 | assert_nil item.info 55 | item.goodbye 56 | assert_equal 'hello -> farewell', item.info 57 | end 58 | 59 | def test_transitioning_does_not_raise_if_the_transition_block_is_nil 60 | item.state = :farewell 61 | item.hello_again 62 | assert_equal :hello, item.state 63 | end 64 | 65 | def test_an_invalid_transition 66 | assert_raises StateMachine::TransitionError do 67 | item.hello_again 68 | end 69 | end 70 | 71 | def test_initializing_a_statemachine_with_an_object_whose_state_is_blank 72 | item = Item.new 73 | AnSM.new(item, :state) 74 | assert_equal :hello, item.state 75 | end 76 | 77 | def test_initializing_a_statemachine_with_an_object_that_already_has_a_state 78 | item = Item.new 79 | item.state = :farewell 80 | AnSM.new(item, :state) 81 | assert_equal :farewell, item.state 82 | end 83 | 84 | def test_initializing_a_statemachine_whose_state_is_invalid 85 | item = Item.new 86 | item.state = :noodle 87 | assert_raises StateMachine::InvalidStateError do 88 | AnSM.new(item, :state) 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/smplsm/statemachine.rb: -------------------------------------------------------------------------------- 1 | module Smplsm 2 | class StateMachine 3 | class StateDefinitionError < StandardError; end 4 | class TransitionError < StandardError; end 5 | class InvalidStateError < StandardError; end 6 | 7 | attr_reader :instance, :state_holder 8 | def initialize(instance, state_holder) 9 | @instance = instance 10 | @state_holder = state_holder 11 | set_default 12 | setup_delegates 13 | verify_state! 14 | end 15 | 16 | def self.default(state) 17 | define_method :default_state do 18 | state 19 | end 20 | 21 | define_method :set_default do 22 | return unless instance.public_send(state_holder).nil? 23 | instance.public_send("#{state_holder}=", state) 24 | end 25 | 26 | define_method :current_state do 27 | instance.public_send("#{state_holder}") 28 | end 29 | 30 | define_method :set_state do |new_state| 31 | instance.public_send("#{state_holder}=", new_state.name) 32 | new_state.proc.call(instance) if new_state.proc 33 | end 34 | end 35 | 36 | def self.transitions 37 | @transitions ||= {} 38 | end 39 | 40 | def self.events 41 | @events ||= {} 42 | end 43 | 44 | def self.event(name) 45 | raise "Invalid event, block required" unless block_given? 46 | events[name] ||= [] 47 | events[name] << yield 48 | define_method name do 49 | end_state = self.class.events[name].find do |state| 50 | start_states = self.class.transitions[state.name] 51 | state if start_states.include?(current_state) 52 | end 53 | raise TransitionError, "Invalid transition '#{name}' for '#{current_state}'" unless end_state 54 | set_state(end_state) 55 | end 56 | end 57 | 58 | def self.transition(from, to: nil, &blk) 59 | raise StateDefinitionError if to.nil? 60 | transitions[to] ||= [] 61 | transitions[to] << from 62 | TransitionDestination.new(to, blk) 63 | end 64 | 65 | private 66 | def setup_delegates 67 | delegate_methods = self.class.events.keys 68 | return if delegate_methods.all? {|m| instance.respond_to?(m)} 69 | code =<<-DEFN 70 | class << instance 71 | extend Forwardable 72 | attr_accessor :delegate 73 | #{delegate_methods.map do |m| 74 | "def_delegator :delegate, :#{m}" 75 | end.join("\n")} 76 | end 77 | DEFN 78 | eval code 79 | instance.delegate = self 80 | end 81 | 82 | def verify_state! 83 | return if current_state == default_state 84 | unless self.class.transitions.keys.include? current_state 85 | raise InvalidStateError 86 | end 87 | end 88 | 89 | class TransitionDestination 90 | attr_reader :name, :proc 91 | def initialize(name, proc) 92 | @name = name 93 | @proc = proc 94 | end 95 | end 96 | end 97 | end 98 | --------------------------------------------------------------------------------