Created
May 19, 2021 22:29
-
-
Save cmorss/b3c68ff8565c274f1e7789e48356e0c5 to your computer and use it in GitHub Desktop.
Stateful specs
This file contains 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 '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