Skip to content

Instantly share code, notes, and snippets.

@cmorss
Created May 19, 2021 22:29
Show Gist options
  • Save cmorss/b3c68ff8565c274f1e7789e48356e0c5 to your computer and use it in GitHub Desktop.
Save cmorss/b3c68ff8565c274f1e7789e48356e0c5 to your computer and use it in GitHub Desktop.
Stateful specs
require 'rails_helper'
RSpec.describe Stateful do
let(:model_class) do
Struct.new(:status, :saved, :scopes, :running_at, :finished_at, :finished_count, :privileged) do
include Stateful
def self.scope(*args)
define_singleton_method(args.first) {}
end
def has_attribute?(name)
%w[finished_at running_at finished_count].include?(name)
end
def transitions
@transitions ||= []
end
def save!
@saved = true
end
end
end
describe 'methods' do
it 'has the stateful methods' do
expect(model_class.methods).to include :stateful
end
it 'defines states' do
model_class.stateful(column: :status) do
state :started
state :running
state :finished
end
instance = model_class.new('instance')
expect(model_class.methods).to include :started
expect(model_class.methods).to include :not_started
expect(instance.methods).to include :started?
expect(model_class.methods).to include :running
expect(model_class.methods).to include :not_running
expect(instance.methods).to include :running?
end
end
describe 'initial state' do
it 'has correct initial state' do
model_class.stateful(column: :status) do
state :started, initial: true
state :finished
end
instance = model_class.new
expect(instance.started?).to eq true
end
it 'raises on multiple initial states' do
expect do
model_class.stateful(column: :status) do
state :started, initial: true
state :finished, initial: true
end
end.to raise_error(ArgumentError)
end
end
describe 'transitions' do
before do
model_class.stateful(column: :status) do
state :started, initial: true
state :running, log_transition_time: :first_only
state :finished
state :paused
on :start do
move started: :running
end
on :stop do
move any => :finished
end
on :restart do
move any => :running
end
on :pause do
move running: :paused
move paused: :paused, no_op: true
move any => :paused, if: ->(i) { i.privileged }
end
end
end
it 'from started to running' do
instance = model_class.new
expect(instance.can_start?).to be_truthy
instance.start!
expect(instance.running?).to eq true
end
it 'from started to paused' do
instance = model_class.new
expect(instance.can_start?).to be_truthy
instance.start!
expect(instance.can_pause?).to be_truthy
instance.pause!
expect(instance.running?).to eq false
expect(instance.paused?).to eq true
end
it 'from paused to paused is a no op' do
instance = model_class.new
expect(instance.can_start?).to be_truthy
instance.start!
expect(instance.can_pause?).to be_truthy
instance.pause!
expect(instance.running?).to eq false
expect(instance.paused?).to eq true
expect(instance.can_pause?).to be_truthy
instance.pause!
expect(instance.paused?).to eq true
end
it 'from finished to paused for privileged' do
instance = model_class.new
instance.privileged = true
expect(instance.can_start?).to be_truthy
instance.start!
expect(instance.can_stop?).to be_truthy
instance.stop!
expect(instance.can_pause?).to be_truthy
instance.pause!
expect(instance.running?).to eq false
expect(instance.paused?).to eq true
end
it 'from stopped to paused to paused for privileged' do
instance = model_class.new
instance.privileged = true
expect(instance.can_start?).to be_truthy
instance.start!
expect(instance.can_stop?).to be_truthy
instance.stop!
expect(instance.can_pause?).to be_truthy
instance.pause!
expect(instance.can_pause?).to be_truthy
instance.pause!
expect(instance.running?).to eq false
expect(instance.paused?).to eq true
end
it 'from any to finished' do
instance = model_class.new
expect(instance.can_stop?).to be_truthy
instance.stop!
expect(instance.running?).to eq false
expect(instance.finished?).to eq true
end
it 'raises on start! when in finished' do
instance = model_class.new
instance.stop!
expect(instance.can_start?).to be_falsey
expect { instance.start! }.to raise_error(Stateful::TransitionError, /Cannot \[start\] while in \[finished\]/)
end
it 'records transition time' do
instance = model_class.new
travel_to '2020-01-01' do
instance.start!
instance.stop!
end
expect(instance.running_at).to eq Time.local(2020)
expect(instance.finished_at).to eq Time.local(2020)
end
context 'when log_transition_time: :first_only' do
it 'records transition time only once' do
instance = model_class.new
travel_to '2020-01-01' do
instance.start!
end
expect(instance.running_at).to eq Time.local(2020)
instance.status = :started
travel_to '2022-01-01' do
instance.start!
end
# The running_at timestamp should *not* have been updated.
expect(instance.running_at).to eq Time.local(2020)
end
end
it 'records transition counts' do
instance = model_class.new
instance.start!
expect(instance.finished_count).to eq nil
instance.stop!
expect(instance.finished_count).to eq 1
instance.restart!
expect(instance.finished_count).to eq 1
instance.stop!
expect(instance.finished_count).to eq 2
end
it 'allows transitions when the criteria has been met' do
model_class.stateful(column: :status) do
allows :start do
true
end
end
instance = model_class.new
expect(instance.can_start?).to be_truthy
instance.start!
expect(instance.running?).to be_truthy
end
it 'disallows transitions when the criteria has not been met' do
model_class.stateful(column: :status) do
allows :start do
false
end
end
instance = model_class.new
expect(instance.can_start?).to be_falsey
expect { instance.start! }.to raise_error(
Stateful::TransitionError, /Cannot \[start\] while transition criteria not met/
)
expect(instance.running?).to be_falsey
end
end
describe 'entering, entered, etc' do
before do
model_class.stateful(column: :status) do
state :started, initial: true
state :running
state :finished
on :start do
move started: :running
move running: :running, no_op: true
end
on :stop do
move any => :finished
end
entering :running do
transitions << :entering_running
end
entered :running do
transitions << :entered_running
end
entered :finished do
transitions << :entered_finished
end
persisted do
transitions << Stateful::Transitions::PERSISTED
end
end
end
it 'from started to running' do
instance = model_class.new
expect(instance.can_start?).to be_truthy
instance.start!
expect(instance.running?).to eq true
expect(instance.transitions).to eq %i[entering_running entered_running persisted]
end
it 'from running to running no op does not call transitions' do
instance = model_class.new
instance.start!
expect(instance.running?).to eq true
expect(instance.transitions).to eq %i[entering_running entered_running persisted]
instance.start!
expect(instance.running?).to eq true
expect(instance.transitions).to eq %i[entering_running entered_running persisted]
end
it 'from any to finished' do
instance = model_class.new
expect(instance.can_stop?).to be_truthy
instance.stop!
expect(instance.running?).to eq false
expect(instance.finished?).to eq true
end
it 'raises on start! when in finished' do
instance = model_class.new
expect(instance.can_stop?).to be_truthy
instance.stop!
expect(instance.can_start?).to be_falsey
expect { instance.start! }.to raise_error(Stateful::TransitionError, /Cannot \[start\] while in \[finished\]/)
end
end
describe 'state validation' do
it 'raises when given an invalid state on the entered event' do
expect do
model_class.stateful(column: :status) do
state :a, initial: true
entered :unknown_state1 do
continue
end
end
end.to raise_error(Stateful::StateNotFoundError)
end
it 'raises when given an invalid state on the entering event' do
expect do
model_class.stateful(column: :status) do
state :a, initial: true
entering :unknown_state2 do
continue
end
end
end.to raise_error(Stateful::StateNotFoundError)
end
it 'raises when given an invalid state on the persisted event' do
expect do
model_class.stateful(column: :status) do
state :a, initial: true
persisted :unknown_state3 do
continue
end
end
end.to raise_error(Stateful::StateNotFoundError)
end
end
describe 'delegation' do
before do
# Define with a real class so args are handled correctly
parent_class = Class.new do
def transition_fired(*_args); end
end
@parent = parent = parent_class.new
model_class.define_method(:parent) do
parent
end
@instance = model_class.new
end
it 'listens on everything' do
model_class.stateful(column: :status) do
state :created, initial: true
state :running
state :finished
on :start do
move created: :running
end
on :finish do
move running: :finished
end
listener :parent, method: :transition_fired
end
args = { originator: @instance, event: :start, source: :created, destination: :running }
expect(@parent).to receive(:transition_fired).with(args.merge(transition: Stateful::Transitions::ENTERING))
expect(@parent).to receive(:transition_fired).with(args.merge(transition: Stateful::Transitions::ENTERED))
expect(@parent).to receive(:transition_fired).with(args.merge(transition: Stateful::Transitions::PERSISTED))
@instance.start!
end
it 'listens on specific event' do
model_class.stateful(column: :status) do
state :created, initial: true
state :running
state :finished
on :start do
move created: :running
end
on :finish do
move running: :finished
end
listener :parent, method: :transition_fired, on: :finish
end
args = { originator: @instance, event: :finish, source: :running, destination: :finished }
expect(@parent).to receive(:transition_fired).with(args.merge(transition: Stateful::Transitions::ENTERING))
expect(@parent).to receive(:transition_fired).with(args.merge(transition: Stateful::Transitions::ENTERED))
expect(@parent).to receive(:transition_fired).with(args.merge(transition: Stateful::Transitions::PERSISTED))
@instance.start!
@instance.finish!
end
it 'listens on specific transition to' do
model_class.stateful(column: :status) do
state :created, initial: true
state :running
state :finished
on :start do
move created: :running
end
on :finish do
move running: :finished
end
listener :parent, method: :transition_fired, entered: :running
end
args = {
originator: @instance,
event: :start,
transition: Stateful::Transitions::ENTERED,
source: :created,
destination: :running
}
expect(@parent).to receive(:transition_fired).with(args)
@instance.start!
@instance.finish!
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment