Skip to content

Instantly share code, notes, and snippets.

@jimsynz
Last active August 29, 2015 14:04
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save jimsynz/5286d2d5e58622bee4b9 to your computer and use it in GitHub Desktop.
Save jimsynz/5286d2d5e58622bee4b9 to your computer and use it in GitHub Desktop.
Source code for our No More Mr State Machine talk.
# Our simple example of a Turnstile state machine.
class Turnstile
def initialize
@state = "Locked"
end
def push!
@state = "Locked" if unlocked?
end
def pay!
@state = "Unlocked" if locked?
end
def locked?
@state == "Locked"
end
def unlocked?
@state = "Unlocked"
end
end
require './01_turnstile'
describe Turnstile do
subject { described_class.new }
it { should be_locked }
context "When it is locked" do
context "And we push it" do
before { subject.push! }
it { should be_locked }
end
context "And we pay it" do
before { subject.pay! }
it { should be_unlocked }
end
end
context "When it is unlocked" do
before { subject.pay! }
context "And we push it" do
before { subject.push! }
it { should be_locked }
end
context "And we pay it" do
before { subject.pay! }
it { should be_unlocked }
end
end
end
# This is the final version of our ShoppingCartState code as presented.
module ShoppingCartState
StateError = Class.new(RuntimeError)
class Base
def initialize cart
@cart = cart
end
%w| add_item! pay! cancel! ship! fulfil! |.each do |action|
define_method action do |*_|
raise StateError, "Can't call #{action} from #{@cart.state}"
end
end
end
class Cancellable < Base
def cancel!
@cart.update_attributes! state: 'Cancelled'
end
end
class New < Cancellable
def add_item! item
@cart.line_items << item
end
def pay!
@cart.update_attributes! state: 'Paid'
end
end
class Paid < Cancellable
def fulfil!
if @cart.all_in_stock?
@cart.update_attributes! state: 'ReadyToShip'
else
@cart.update_attributes! state: 'Backorder'
end
end
end
class Backorder < Cancellable
def fulfil!
@cart.update_attributes! state: 'ReadyToShip'
end
end
class ReadyToShip < Cancellable
def ship!
yield if block_given?
@cart.update_attributes! state: 'Shipped'
end
end
class Cancelled < Base
end
class Shipped < Base
end
end
require './03_shopping_cart_state'
require './09_mock_update_attributes'
describe ShoppingCartState do
include MockUpdateAttributes
let(:cart) { mock_update_attributes_on Struct.new(:state).new }
subject { described_class.new cart }
describe ShoppingCartState::Base do
%w| add_item! pay! cancel! ship! fulfil! |.each do |action|
describe "##{action}" do
it 'raises a StateError' do
expect { subject.public_send action }.to raise_error ShoppingCartState::StateError
end
end
end
end
describe ShoppingCartState::Cancellable do
it { should be_a ShoppingCartState::Base }
describe '#cancel!' do
it 'changes the cart state to Cancelled' do
expect { subject.cancel! }.to change { cart.state }.to('Cancelled')
end
end
end
describe ShoppingCartState::New do
it { should be_a ShoppingCartState::Cancellable }
describe '#add_item!' do
it 'adds an item to the cart' do
allow(cart).to receive(:line_items).and_return([])
expect { subject.add_item! :item }.to change { cart.line_items.length }.by(1)
end
end
describe '#pay!' do
it 'changes the cart state to Paid' do
expect { subject.pay! }.to change { cart.state }.to('Paid')
end
end
end
describe ShoppingCartState::Paid do
it { should be_a ShoppingCartState::Cancellable }
describe '#fulfil!' do
context "When all line items are in stock" do
it 'changes the cart state to ReadyToShip' do
allow(cart).to receive(:all_in_stock?).and_return(true)
expect { subject.fulfil! }.to change { cart.state }.to('ReadyToShip')
end
end
context "When all line items are not in stock" do
it 'changes the cart state to Backorder' do
allow(cart).to receive(:all_in_stock?).and_return(false)
expect { subject.fulfil! }.to change { cart.state }.to('Backorder')
end
end
end
end
describe ShoppingCartState::Backorder do
it { should be_a ShoppingCartState::Cancellable }
describe '#fulfil!' do
it 'changes the cart state to ReadyToShip' do
expect { subject.fulfil! }.to change { cart.state }.to('ReadyToShip')
end
end
end
describe ShoppingCartState::ReadyToShip do
it { should be_a ShoppingCartState::Cancellable }
describe '#ship!' do
it 'changes the cart state to Shipped' do
expect { subject.ship! }.to change { cart.state }.to('Shipped')
end
end
end
describe ShoppingCartState::Cancelled do
it { should be_a ShoppingCartState::Base }
it { should_not be_a ShoppingCartState::Cancellable }
end
describe ShoppingCartState::Shipped do
it { should be_a ShoppingCartState::Base }
it { should_not be_a ShoppingCartState::Cancellable }
end
end
require './03_shopping_cart_state'
require './99_ar'
# The final version of our ShoppingCart class, as described in the talk.
class ShoppingCart < AR::Base
has_many :line_items
%w| add_item! cancel! pay! fulfil! |.each do |action|
define_method action do |*args|
current_state.public_send action, *args
end
end
def ship!
current_state.ship! do
get_tracking_ticket_no
end
send_successful_shipping_email
end
private
def get_tracking_ticket_no
# noop
end
def send_successful_shipping_email
# noop
end
def current_state
ShoppingCartState.const_get(state).new(self)
end
end
require './05_shopping_cart.rb'
describe ShoppingCart do
%w| line_items add_item! cancel! pay! ship! fulfil! state state= |.each do |method|
it { should respond_to method }
end
describe 'State delegation' do
%w| add_item! cancel! pay! ship! fulfil! |.each do |action|
let(:current_state) { double :current_state }
before do
allow(subject).to receive(:current_state).and_return(current_state)
end
describe "##{action}" do
it 'delegates to current state' do
expect(current_state).to receive(action)
subject.public_send(action)
end
end
end
end
describe '#ship!' do
before { subject.state = 'ReadyToShip' }
it 'retrieves tracking info' do
expect(subject).to receive(:get_tracking_ticket_no)
subject.ship!
end
it 'sends a shipping email' do
expect(subject).to receive(:send_successful_shipping_email)
subject.ship!
end
end
end
# Mix this module into your model to automatically create delegates
# to the relevant state class.
# This module assumes that if you are mixing it into the `Order` class
# then it will be delegating to the `OrderState` class heirarchy.
module StateDelegator
def self.included model_class
model_name = model_class.to_s
state_name = "#{model_name}State"
state_base = const_get "#{state_name}::Base"
state_methods = state_base.public_instance_methods(false)
mixin = Module.new do
state_methods.each do |state_method|
define_method state_method do |*args|
current_state.public_send(state_method, *args)
end
end
define_method :current_state do
Object.const_get("::#{state_name}").const_get(state).new(self)
end
private :current_state
end
model_class.send :include, mixin
end
end
require './07_state_delegator'
describe StateDelegator do
before do
class DemoState
class Base
def initialize stateful; end
def foo?; false; end
def bar?; false; end
end
end
class Demo < Struct.new(:state)
include StateDelegator
end
end
after do
DemoState.send :remove_const, :Base
Object.send :remove_const, :DemoState
Object.send :remove_const, :Demo
end
subject { Demo.new 'Base' }
describe 'delegator definition' do
%w| foo? bar? |.each do |predicate|
describe "##{predicate}" do
it { should respond_to predicate }
it 'delegates to the state instance' do
expect_any_instance_of(DemoState::Base).to receive(predicate)
subject.public_send predicate
end
end
end
end
describe 'state finder method' do
it 'has the current_state method' do
expect(subject.private_methods).to include(:current_state)
end
it 'delegates to the state class' do
expect(subject.send :current_state).to be_a(DemoState::Base)
end
end
end
require './03_shopping_cart_state'
require './07_state_delegator'
require './99_ar'
# This is the ShoppingCart using the StateDelegator mixin.
# As mentioned in the improvements section of the talk.
class ShoppingCart < AR::Base
include StateDelegator
has_many :line_items
def ship!
current_state.ship! do
get_tracking_ticket_no
end
send_successful_shipping_email
end
private
def get_tracking_ticket_no
# noop
end
def send_successful_shipping_email
# noop
end
end
require './09_shopping_cart.rb'
# Exactly the same specs as in `06_shopping_cart_spec.rb` except we're testing
# the StateDelegator version of the ShoppingCart.
describe ShoppingCart do
%w| line_items add_item! cancel! pay! ship! fulfil! state state= |.each do |method|
it { should respond_to method }
end
describe 'State delegation' do
%w| add_item! cancel! pay! ship! fulfil! |.each do |action|
let(:current_state) { double :current_state }
before do
allow(subject).to receive(:current_state).and_return(current_state)
end
describe "##{action}" do
it 'delegates to current state' do
expect(current_state).to receive(action)
subject.public_send(action)
end
end
end
end
describe '#ship!' do
before { subject.state = 'ReadyToShip' }
it 'retrieves tracking info' do
expect(subject).to receive(:get_tracking_ticket_no)
subject.ship!
end
it 'sends a shipping email' do
expect(subject).to receive(:send_successful_shipping_email)
subject.ship!
end
end
end
# This is code I pulled in from another project to mock the
# `update_attributes!` method. Sorry about the noise.
module MockUpdateAttributes
def mock_update_attributes_on(model)
update_proc = proc do |attrs|
attrs.each do |attr,value|
setter = "#{attr}=".to_sym
expect(model).to respond_to attr
expect(model).to respond_to setter
model.public_send setter, value
end
end
allow(model).to receive(:update_attributes!, &update_proc)
model
end
end
# This is a fake AR base class sufficient for our purposes.
module AR
class Base
attr_accessor :state, :line_items
def initialize
@state = 'New'
@line_items = []
end
def update_attributes! attrs={}
attrs.each do |attr, value|
public_send "#{attr}=", value
end
end
def self.has_many _; end
end
end
@Aupajo
Copy link

Aupajo commented Jul 23, 2014

👍 Looks good.

@jimsynz
Copy link
Author

jimsynz commented Jul 23, 2014

I haven't even given the talk yet!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment