Last active
August 29, 2015 14:04
-
-
Save jimsynz/5286d2d5e58622bee4b9 to your computer and use it in GitHub Desktop.
Source code for our No More Mr State Machine talk.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
I haven't even given the talk yet!