workflow 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +7 -0
- data/README.markdown +433 -0
- data/Rakefile +26 -26
- data/VERSION +1 -0
- data/lib/workflow.rb +127 -31
- data/test/couchtiny_example.rb +46 -0
- data/test/main_test.rb +420 -0
- data/test/readme_example.rb +37 -0
- data/test/without_active_record_test.rb +54 -0
- data/workflow.rb +1 -0
- metadata +32 -14
- data/README.rdoc +0 -452
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.4.1
|
data/lib/workflow.rb
CHANGED
@@ -1,38 +1,46 @@
|
|
1
1
|
require 'rubygems'
|
2
|
-
require 'active_support'
|
3
2
|
|
3
|
+
# See also README.markdown for documentation
|
4
4
|
module Workflow
|
5
|
-
|
5
|
+
|
6
6
|
class Specification
|
7
|
-
|
7
|
+
|
8
8
|
attr_accessor :states, :initial_state, :meta, :on_transition_proc
|
9
|
-
|
9
|
+
|
10
10
|
def initialize(meta = {}, &specification)
|
11
11
|
@states = Hash.new
|
12
12
|
@meta = meta
|
13
13
|
instance_eval(&specification)
|
14
14
|
end
|
15
|
-
|
15
|
+
|
16
|
+
def state_names
|
17
|
+
states.keys
|
18
|
+
end
|
19
|
+
|
16
20
|
private
|
17
|
-
|
21
|
+
|
18
22
|
def state(name, meta = {:meta => {}}, &events_and_etc)
|
19
23
|
# meta[:meta] to keep the API consistent..., gah
|
20
|
-
new_state = State.new(name, meta[:meta])
|
24
|
+
new_state = Workflow::State.new(name, meta[:meta])
|
21
25
|
@initial_state = new_state if @states.empty?
|
22
26
|
@states[name.to_sym] = new_state
|
23
27
|
@scoped_state = new_state
|
24
28
|
instance_eval(&events_and_etc) if events_and_etc
|
25
29
|
end
|
26
|
-
|
30
|
+
|
27
31
|
def event(name, args = {}, &action)
|
32
|
+
target = args[:transitions_to] || args[:transition_to]
|
33
|
+
raise WorkflowDefinitionError.new(
|
34
|
+
"missing ':transitions_to' in workflow event definition for '#{name}'") \
|
35
|
+
if target.nil?
|
28
36
|
@scoped_state.events[name.to_sym] =
|
29
|
-
Event.new(name,
|
37
|
+
Workflow::Event.new(name, target, (args[:meta] or {}), &action)
|
30
38
|
end
|
31
|
-
|
39
|
+
|
32
40
|
def on_entry(&proc)
|
33
41
|
@scoped_state.on_entry = proc
|
34
42
|
end
|
35
|
-
|
43
|
+
|
36
44
|
def on_exit(&proc)
|
37
45
|
@scoped_state.on_exit = proc
|
38
46
|
end
|
@@ -41,7 +49,7 @@ module Workflow
|
|
41
49
|
@on_transition_proc = proc
|
42
50
|
end
|
43
51
|
end
|
44
|
-
|
52
|
+
|
45
53
|
class TransitionHalted < Exception
|
46
54
|
|
47
55
|
attr_reader :halted_because
|
@@ -57,14 +65,16 @@ module Workflow
|
|
57
65
|
|
58
66
|
class WorkflowError < Exception; end
|
59
67
|
|
68
|
+
class WorkflowDefinitionError < Exception; end
|
69
|
+
|
60
70
|
class State
|
61
|
-
|
71
|
+
|
62
72
|
attr_accessor :name, :events, :meta, :on_entry, :on_exit
|
63
|
-
|
73
|
+
|
64
74
|
def initialize(name, meta = {})
|
65
75
|
@name, @events, @meta = name, Hash.new, meta
|
66
76
|
end
|
67
|
-
|
77
|
+
|
68
78
|
def to_s
|
69
79
|
"#{name}"
|
70
80
|
end
|
@@ -73,20 +83,29 @@ module Workflow
|
|
73
83
|
name.to_sym
|
74
84
|
end
|
75
85
|
end
|
76
|
-
|
86
|
+
|
77
87
|
class Event
|
78
|
-
|
88
|
+
|
79
89
|
attr_accessor :name, :transitions_to, :meta, :action
|
80
|
-
|
90
|
+
|
81
91
|
def initialize(name, transitions_to, meta = {}, &action)
|
82
92
|
@name, @transitions_to, @meta, @action = name, transitions_to.to_sym, meta, action
|
83
93
|
end
|
84
|
-
|
94
|
+
|
85
95
|
end
|
86
|
-
|
96
|
+
|
87
97
|
module WorkflowClassMethods
|
88
98
|
attr_reader :workflow_spec
|
89
99
|
|
100
|
+
def workflow_column(column_name=nil)
|
101
|
+
if column_name
|
102
|
+
@workflow_state_column_name = column_name.to_sym
|
103
|
+
else
|
104
|
+
@workflow_state_column_name ||= :workflow_state
|
105
|
+
end
|
106
|
+
@workflow_state_column_name
|
107
|
+
end
|
108
|
+
|
90
109
|
def workflow(&specification)
|
91
110
|
@workflow_spec = Specification.new(Hash.new, &specification)
|
92
111
|
@workflow_spec.states.values.each do |state|
|
@@ -110,7 +129,7 @@ module Workflow
|
|
110
129
|
end
|
111
130
|
|
112
131
|
module WorkflowInstanceMethods
|
113
|
-
def current_state
|
132
|
+
def current_state
|
114
133
|
loaded_state = load_workflow_state
|
115
134
|
res = spec.states[loaded_state.to_sym] if loaded_state
|
116
135
|
res || spec.initial_state
|
@@ -129,6 +148,8 @@ module Workflow
|
|
129
148
|
raise NoTransitionAllowed.new(
|
130
149
|
"There is no event #{name.to_sym} defined for the #{current_state} state") \
|
131
150
|
if event.nil?
|
151
|
+
# This three member variables are a relict from the old workflow library
|
152
|
+
# TODO: refactor some day
|
132
153
|
@halted_because = nil
|
133
154
|
@halted = false
|
134
155
|
@raise_exception_on_halt = false
|
@@ -150,18 +171,18 @@ module Workflow
|
|
150
171
|
private
|
151
172
|
|
152
173
|
def check_transition(event)
|
153
|
-
# Create a meaningful error message instead of
|
174
|
+
# Create a meaningful error message instead of
|
154
175
|
# "undefined method `on_entry' for nil:NilClass"
|
155
176
|
# Reported by Kyle Burton
|
156
177
|
if !spec.states[event.transitions_to]
|
157
|
-
raise WorkflowError.new("Event[#{event.name}]'s " +
|
178
|
+
raise WorkflowError.new("Event[#{event.name}]'s " +
|
158
179
|
"transitions_to[#{event.transitions_to}] is not a declared state.")
|
159
180
|
end
|
160
181
|
end
|
161
182
|
|
162
183
|
def spec
|
163
184
|
c = self.class
|
164
|
-
# using a simple loop instead of class_inheritable_accessor to avoid
|
185
|
+
# using a simple loop instead of class_inheritable_accessor to avoid
|
165
186
|
# dependency on Rails' ActiveSupport
|
166
187
|
until c.workflow_spec || !(c.include? Workflow)
|
167
188
|
c = c.superclass
|
@@ -199,9 +220,9 @@ module Workflow
|
|
199
220
|
self.send action_name.to_sym, *args if self.respond_to?(action_name.to_sym)
|
200
221
|
end
|
201
222
|
|
202
|
-
def run_on_entry(state, prior_state, triggering_event, *args)
|
223
|
+
def run_on_entry(state, prior_state, triggering_event, *args)
|
203
224
|
if state.on_entry
|
204
|
-
instance_exec(prior_state.name, triggering_event, *args, &state.on_entry)
|
225
|
+
instance_exec(prior_state.name, triggering_event, *args, &state.on_entry)
|
205
226
|
else
|
206
227
|
hook_name = "on_#{state}_entry"
|
207
228
|
self.send hook_name, prior_state, triggering_event, *args if self.respond_to? hook_name
|
@@ -237,13 +258,13 @@ module Workflow
|
|
237
258
|
|
238
259
|
module ActiveRecordInstanceMethods
|
239
260
|
def load_workflow_state
|
240
|
-
read_attribute(
|
261
|
+
read_attribute(self.class.workflow_column)
|
241
262
|
end
|
242
263
|
|
243
264
|
# On transition the new workflow state is immediately saved in the
|
244
265
|
# database.
|
245
266
|
def persist_workflow_state(new_value)
|
246
|
-
update_attribute
|
267
|
+
update_attribute self.class.workflow_column, new_value
|
247
268
|
end
|
248
269
|
|
249
270
|
private
|
@@ -254,7 +275,17 @@ module Workflow
|
|
254
275
|
# state. That's why it is important to save the string with the name of the
|
255
276
|
# initial state in all the new records.
|
256
277
|
def write_initial_state
|
257
|
-
write_attribute
|
278
|
+
write_attribute self.class.workflow_column, current_state.to_s
|
279
|
+
end
|
280
|
+
end
|
281
|
+
|
282
|
+
module RemodelInstanceMethods
|
283
|
+
def load_workflow_state
|
284
|
+
send(self.class.workflow_column)
|
285
|
+
end
|
286
|
+
|
287
|
+
def persist_workflow_state(new_value)
|
288
|
+
update(self.class.workflow_column => new_value)
|
258
289
|
end
|
259
290
|
end
|
260
291
|
|
@@ -263,9 +294,74 @@ module Workflow
|
|
263
294
|
klass.extend WorkflowClassMethods
|
264
295
|
if Object.const_defined?(:ActiveRecord)
|
265
296
|
if klass < ActiveRecord::Base
|
266
|
-
|
267
|
-
|
297
|
+
klass.send :include, ActiveRecordInstanceMethods
|
298
|
+
klass.before_validation :write_initial_state
|
299
|
+
end
|
300
|
+
elsif Object.const_defined?(:Remodel)
|
301
|
+
if klass < Remodel::Entity
|
302
|
+
klass.send :include, RemodelInstanceMethods
|
303
|
+
end
|
304
|
+
end
|
305
|
+
end
|
306
|
+
|
307
|
+
# Generates a `dot` graph of the workflow.
|
308
|
+
# Prerequisite: the `dot` binary.
|
309
|
+
# You can use it in your own Rakefile like this:
|
310
|
+
#
|
311
|
+
# namespace :doc do
|
312
|
+
# desc "Generate a graph of the workflow."
|
313
|
+
# task :workflow do
|
314
|
+
# Workflow::create_workflow_diagram(Order.new)
|
315
|
+
# end
|
316
|
+
# end
|
317
|
+
#
|
318
|
+
# You can influence the placement of nodes by specifying
|
319
|
+
# additional meta information in your states and transition descriptions.
|
320
|
+
# You can assign higher `doc_weight` value to the typical transitions
|
321
|
+
# in your workflow. All other states and transitions will be arranged
|
322
|
+
# around that main line. See also `weight` in the graphviz documentation.
|
323
|
+
# Example:
|
324
|
+
#
|
325
|
+
# state :new do
|
326
|
+
# event :approve, :transitions_to => :approved, :meta => {:doc_weight => 8}
|
327
|
+
# end
|
328
|
+
#
|
329
|
+
#
|
330
|
+
# @param klass A class with the Workflow mixin, for which you wish the graphical workflow representation
|
331
|
+
# @param [String] target_dir Directory, where to save the dot and the pdf files
|
332
|
+
# @param [String] graph_options You can change graph orientation, size etc. See graphviz documentation
|
333
|
+
def self.create_workflow_diagram(klass, target_dir, graph_options='rankdir="LR", size="7,11.6", ratio="fill"')
|
334
|
+
workflow_name = "#{klass.name.tableize}_workflow"
|
335
|
+
fname = File.join(target_dir, "generated_#{workflow_name}")
|
336
|
+
File.open("#{fname}.dot", 'w') do |file|
|
337
|
+
file.puts %Q|
|
338
|
+
digraph #{workflow_name} {
|
339
|
+
graph [#{graph_options}];
|
340
|
+
node [shape=box];
|
341
|
+
edge [len=1];
|
342
|
+
|
|
343
|
+
|
344
|
+
klass.workflow_spec.states.each do |state_name, state|
|
345
|
+
file.puts %Q{ #{state.name} [label="#{state.name}"];}
|
346
|
+
state.events.each do |event_name, event|
|
347
|
+
meta_info = event.meta
|
348
|
+
if meta_info[:doc_weight]
|
349
|
+
weight_prop = ", weight=#{meta_info[:doc_weight]}"
|
350
|
+
else
|
351
|
+
weight_prop = ''
|
352
|
+
end
|
353
|
+
file.puts %Q{ #{state.name} -> #{event.transitions_to} [label="#{event_name.to_s.humanize}" #{weight_prop}];}
|
354
|
+
end
|
268
355
|
end
|
356
|
+
file.puts "}"
|
357
|
+
file.puts
|
269
358
|
end
|
359
|
+
`dot -Tpdf -o#{fname}.pdf #{fname}.dot`
|
360
|
+
puts "
|
361
|
+
Please run the following to open the generated file:
|
362
|
+
|
363
|
+
open #{fname}.pdf
|
364
|
+
|
365
|
+
"
|
270
366
|
end
|
271
367
|
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
require 'couchtiny'
|
3
|
+
require 'couchtiny/document'
|
4
|
+
require 'workflow'
|
5
|
+
|
6
|
+
class User < CouchTiny::Document
|
7
|
+
include Workflow
|
8
|
+
workflow do
|
9
|
+
state :submitted do
|
10
|
+
event :activate_via_link, :transitions_to => :proved_email
|
11
|
+
end
|
12
|
+
state :proved_email
|
13
|
+
end
|
14
|
+
|
15
|
+
def load_workflow_state
|
16
|
+
self[:workflow_state]
|
17
|
+
end
|
18
|
+
|
19
|
+
def persist_workflow_state(new_value)
|
20
|
+
self[:workflow_state] = new_value
|
21
|
+
save!
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
|
26
|
+
class CouchtinyExample < Test::Unit::TestCase
|
27
|
+
|
28
|
+
def setup
|
29
|
+
db = CouchTiny::Database.url("http://127.0.0.1:5984/test-workflow")
|
30
|
+
db.delete_database! rescue nil
|
31
|
+
db.create_database!
|
32
|
+
User.use_database db
|
33
|
+
end
|
34
|
+
|
35
|
+
test 'CouchDB persistence' do
|
36
|
+
user = User.new :email => 'manya@example.com'
|
37
|
+
user.save!
|
38
|
+
assert user.submitted?
|
39
|
+
user.activate_via_link!
|
40
|
+
assert user.proved_email?
|
41
|
+
|
42
|
+
reloaded_user = User.get user.id
|
43
|
+
puts reloaded_user.inspect
|
44
|
+
assert reloaded_user.proved_email?, 'Reloaded user should have the desired workflow state'
|
45
|
+
end
|
46
|
+
end
|
data/test/main_test.rb
ADDED
@@ -0,0 +1,420 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
|
3
|
+
$VERBOSE = false
|
4
|
+
require 'active_record'
|
5
|
+
require 'sqlite3'
|
6
|
+
require 'workflow'
|
7
|
+
require 'mocha'
|
8
|
+
#require 'ruby-debug'
|
9
|
+
|
10
|
+
ActiveRecord::Migration.verbose = false
|
11
|
+
|
12
|
+
class Order < ActiveRecord::Base
|
13
|
+
include Workflow
|
14
|
+
workflow do
|
15
|
+
state :submitted do
|
16
|
+
event :accept, :transitions_to => :accepted, :meta => {:doc_weight => 8} do |reviewer, args|
|
17
|
+
end
|
18
|
+
end
|
19
|
+
state :accepted do
|
20
|
+
event :ship, :transitions_to => :shipped
|
21
|
+
end
|
22
|
+
state :shipped
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
|
27
|
+
class LegacyOrder < ActiveRecord::Base
|
28
|
+
include Workflow
|
29
|
+
|
30
|
+
workflow_column :foo_bar # use this legacy database column for persistence
|
31
|
+
|
32
|
+
workflow do
|
33
|
+
state :submitted do
|
34
|
+
event :accept, :transitions_to => :accepted, :meta => {:doc_weight => 8} do |reviewer, args|
|
35
|
+
end
|
36
|
+
end
|
37
|
+
state :accepted do
|
38
|
+
event :ship, :transitions_to => :shipped
|
39
|
+
end
|
40
|
+
state :shipped
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
|
46
|
+
class MainTest < Test::Unit::TestCase
|
47
|
+
|
48
|
+
def exec(sql)
|
49
|
+
ActiveRecord::Base.connection.execute sql
|
50
|
+
end
|
51
|
+
|
52
|
+
def setup
|
53
|
+
ActiveRecord::Base.establish_connection(
|
54
|
+
:adapter => "sqlite3",
|
55
|
+
:database => ":memory:" #"tmp/test"
|
56
|
+
)
|
57
|
+
ActiveRecord::Base.connection.reconnect! # eliminate ActiveRecord warning. TODO: delete as soon as ActiveRecord is fixed
|
58
|
+
|
59
|
+
ActiveRecord::Schema.define do
|
60
|
+
create_table :orders do |t|
|
61
|
+
t.string :title, :null => false
|
62
|
+
t.string :workflow_state
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
exec "INSERT INTO orders(title, workflow_state) VALUES('some order', 'accepted')"
|
67
|
+
|
68
|
+
ActiveRecord::Schema.define do
|
69
|
+
create_table :legacy_orders do |t|
|
70
|
+
t.string :title, :null => false
|
71
|
+
t.string :foo_bar
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
exec "INSERT INTO legacy_orders(title, foo_bar) VALUES('some order', 'accepted')"
|
76
|
+
|
77
|
+
end
|
78
|
+
|
79
|
+
def teardown
|
80
|
+
ActiveRecord::Base.connection.disconnect!
|
81
|
+
end
|
82
|
+
|
83
|
+
def assert_state(title, expected_state, klass = Order)
|
84
|
+
o = klass.find_by_title(title)
|
85
|
+
assert_equal expected_state, o.read_attribute(klass.workflow_column)
|
86
|
+
o
|
87
|
+
end
|
88
|
+
|
89
|
+
test 'immediately save the new workflow_state on state machine transition' do
|
90
|
+
o = assert_state 'some order', 'accepted'
|
91
|
+
o.ship!
|
92
|
+
assert_state 'some order', 'shipped'
|
93
|
+
end
|
94
|
+
|
95
|
+
test 'immediately save the new workflow_state on state machine transition with custom column name' do
|
96
|
+
o = assert_state 'some order', 'accepted', LegacyOrder
|
97
|
+
o.ship!
|
98
|
+
assert_state 'some order', 'shipped', LegacyOrder
|
99
|
+
end
|
100
|
+
|
101
|
+
test 'persist workflow_state in the db and reload' do
|
102
|
+
o = assert_state 'some order', 'accepted'
|
103
|
+
assert_equal :accepted, o.current_state.name
|
104
|
+
o.ship!
|
105
|
+
o.save!
|
106
|
+
|
107
|
+
assert_state 'some order', 'shipped'
|
108
|
+
|
109
|
+
o.reload
|
110
|
+
assert_equal 'shipped', o.read_attribute(:workflow_state)
|
111
|
+
end
|
112
|
+
|
113
|
+
test 'persist workflow_state in the db with_custom_name and reload' do
|
114
|
+
o = assert_state 'some order', 'accepted', LegacyOrder
|
115
|
+
assert_equal :accepted, o.current_state.name
|
116
|
+
o.ship!
|
117
|
+
o.save!
|
118
|
+
|
119
|
+
assert_state 'some order', 'shipped', LegacyOrder
|
120
|
+
|
121
|
+
o.reload
|
122
|
+
assert_equal 'shipped', o.read_attribute(:foo_bar)
|
123
|
+
end
|
124
|
+
|
125
|
+
test 'default workflow column should be workflow_state' do
|
126
|
+
o = assert_state 'some order', 'accepted'
|
127
|
+
assert_equal :workflow_state, o.class.workflow_column
|
128
|
+
end
|
129
|
+
|
130
|
+
test 'custom workflow column should be foo_bar' do
|
131
|
+
o = assert_state 'some order', 'accepted', LegacyOrder
|
132
|
+
assert_equal :foo_bar, o.class.workflow_column
|
133
|
+
end
|
134
|
+
|
135
|
+
test 'access workflow specification' do
|
136
|
+
assert_equal 3, Order.workflow_spec.states.length
|
137
|
+
assert_equal ['submitted', 'accepted', 'shipped'].sort,
|
138
|
+
Order.workflow_spec.state_names.map{|n| n.to_s}.sort
|
139
|
+
end
|
140
|
+
|
141
|
+
test 'current state object' do
|
142
|
+
o = assert_state 'some order', 'accepted'
|
143
|
+
assert_equal 'accepted', o.current_state.to_s
|
144
|
+
assert_equal 1, o.current_state.events.length
|
145
|
+
end
|
146
|
+
|
147
|
+
test 'on_entry and on_exit invoked' do
|
148
|
+
c = Class.new
|
149
|
+
callbacks = mock()
|
150
|
+
callbacks.expects(:my_on_exit_new).once
|
151
|
+
callbacks.expects(:my_on_entry_old).once
|
152
|
+
c.class_eval do
|
153
|
+
include Workflow
|
154
|
+
workflow do
|
155
|
+
state :new do
|
156
|
+
event :age, :transitions_to => :old
|
157
|
+
end
|
158
|
+
on_exit do
|
159
|
+
callbacks.my_on_exit_new
|
160
|
+
end
|
161
|
+
state :old
|
162
|
+
on_entry do
|
163
|
+
callbacks.my_on_entry_old
|
164
|
+
end
|
165
|
+
on_exit do
|
166
|
+
fail "wrong on_exit executed"
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
o = c.new
|
172
|
+
assert_equal 'new', o.current_state.to_s
|
173
|
+
o.age!
|
174
|
+
end
|
175
|
+
|
176
|
+
test 'on_transition invoked' do
|
177
|
+
callbacks = mock()
|
178
|
+
callbacks.expects(:on_tran).once # this is validated at the end
|
179
|
+
c = Class.new
|
180
|
+
c.class_eval do
|
181
|
+
include Workflow
|
182
|
+
workflow do
|
183
|
+
state :one do
|
184
|
+
event :increment, :transitions_to => :two
|
185
|
+
end
|
186
|
+
state :two
|
187
|
+
on_transition do |from, to, triggering_event, *event_args|
|
188
|
+
callbacks.on_tran
|
189
|
+
end
|
190
|
+
end
|
191
|
+
end
|
192
|
+
assert_not_nil c.workflow_spec.on_transition_proc
|
193
|
+
c.new.increment!
|
194
|
+
end
|
195
|
+
|
196
|
+
test 'access event meta information' do
|
197
|
+
c = Class.new
|
198
|
+
c.class_eval do
|
199
|
+
include Workflow
|
200
|
+
workflow do
|
201
|
+
state :main, :meta => {:importance => 8}
|
202
|
+
state :supplemental, :meta => {:importance => 1}
|
203
|
+
end
|
204
|
+
end
|
205
|
+
assert_equal 1, c.workflow_spec.states[:supplemental].meta[:importance]
|
206
|
+
end
|
207
|
+
|
208
|
+
test 'initial state' do
|
209
|
+
c = Class.new
|
210
|
+
c.class_eval do
|
211
|
+
include Workflow
|
212
|
+
workflow { state :one; state :two }
|
213
|
+
end
|
214
|
+
assert_equal 'one', c.new.current_state.to_s
|
215
|
+
end
|
216
|
+
|
217
|
+
test 'nil as initial state' do
|
218
|
+
exec "INSERT INTO orders(title, workflow_state) VALUES('nil state', NULL)"
|
219
|
+
o = Order.find_by_title('nil state')
|
220
|
+
assert o.submitted?, 'if workflow_state is nil, the initial state should be assumed'
|
221
|
+
assert !o.shipped?
|
222
|
+
end
|
223
|
+
|
224
|
+
test 'initial state immediately set as ActiveRecord attribute for new objects' do
|
225
|
+
o = Order.create(:title => 'new object')
|
226
|
+
assert_equal 'submitted', o.read_attribute(:workflow_state)
|
227
|
+
end
|
228
|
+
|
229
|
+
test 'question methods for state' do
|
230
|
+
o = assert_state 'some order', 'accepted'
|
231
|
+
assert o.accepted?
|
232
|
+
assert !o.shipped?
|
233
|
+
end
|
234
|
+
|
235
|
+
test 'correct exception for event, that is not allowed in current state' do
|
236
|
+
o = assert_state 'some order', 'accepted'
|
237
|
+
assert_raise Workflow::NoTransitionAllowed do
|
238
|
+
o.accept!
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
test 'multiple events with the same name and different arguments lists from different states'
|
243
|
+
|
244
|
+
test 'implicit transition callback' do
|
245
|
+
args = mock()
|
246
|
+
args.expects(:my_tran).once # this is validated at the end
|
247
|
+
c = Class.new
|
248
|
+
c.class_eval do
|
249
|
+
include Workflow
|
250
|
+
def my_transition(args)
|
251
|
+
args.my_tran
|
252
|
+
end
|
253
|
+
workflow do
|
254
|
+
state :one do
|
255
|
+
event :my_transition, :transitions_to => :two
|
256
|
+
end
|
257
|
+
state :two
|
258
|
+
end
|
259
|
+
end
|
260
|
+
c.new.my_transition!(args)
|
261
|
+
end
|
262
|
+
|
263
|
+
test 'Single table inheritance (STI)' do
|
264
|
+
class BigOrder < Order
|
265
|
+
end
|
266
|
+
|
267
|
+
bo = BigOrder.new
|
268
|
+
assert bo.submitted?
|
269
|
+
assert !bo.accepted?
|
270
|
+
end
|
271
|
+
|
272
|
+
test 'Two-level inheritance' do
|
273
|
+
class BigOrder < Order
|
274
|
+
end
|
275
|
+
|
276
|
+
class EvenBiggerOrder < BigOrder
|
277
|
+
end
|
278
|
+
|
279
|
+
assert EvenBiggerOrder.new.submitted?
|
280
|
+
end
|
281
|
+
|
282
|
+
test 'Iheritance with workflow definition override' do
|
283
|
+
class BigOrder < Order
|
284
|
+
end
|
285
|
+
|
286
|
+
class SpecialBigOrder < BigOrder
|
287
|
+
workflow do
|
288
|
+
state :start_big
|
289
|
+
end
|
290
|
+
end
|
291
|
+
|
292
|
+
special = SpecialBigOrder.new
|
293
|
+
assert_equal 'start_big', special.current_state.to_s
|
294
|
+
end
|
295
|
+
|
296
|
+
test 'Better error message for missing target state' do
|
297
|
+
class Problem
|
298
|
+
include Workflow
|
299
|
+
workflow do
|
300
|
+
state :initial do
|
301
|
+
event :solve, :transitions_to => :solved
|
302
|
+
end
|
303
|
+
end
|
304
|
+
end
|
305
|
+
assert_raise Workflow::WorkflowError do
|
306
|
+
Problem.new.solve!
|
307
|
+
end
|
308
|
+
end
|
309
|
+
|
310
|
+
# Intermixing of transition graph definition (states, transitions)
|
311
|
+
# on the one side and implementation of the actions on the other side
|
312
|
+
# for a bigger state machine can introduce clutter.
|
313
|
+
#
|
314
|
+
# To reduce this clutter it is now possible to use state entry- and
|
315
|
+
# exit- hooks defined through a naming convention. For example, if there
|
316
|
+
# is a state :pending, then you can hook in by defining method
|
317
|
+
# `def on_pending_exit(new_state, event, *args)` instead of using a
|
318
|
+
# block:
|
319
|
+
#
|
320
|
+
# state :pending do
|
321
|
+
# on_entry do
|
322
|
+
# # your implementation here
|
323
|
+
# end
|
324
|
+
# end
|
325
|
+
#
|
326
|
+
# If both a function with a name according to naming convention and the
|
327
|
+
# on_entry/on_exit block are given, then only on_entry/on_exit block is used.
|
328
|
+
test 'on_entry and on_exit hooks in separate methods' do
|
329
|
+
c = Class.new
|
330
|
+
c.class_eval do
|
331
|
+
include Workflow
|
332
|
+
attr_reader :history
|
333
|
+
def initialize
|
334
|
+
@history = []
|
335
|
+
end
|
336
|
+
workflow do
|
337
|
+
state :new do
|
338
|
+
event :next, :transitions_to => :next_state
|
339
|
+
end
|
340
|
+
state :next_state
|
341
|
+
end
|
342
|
+
|
343
|
+
def on_next_state_entry(prior_state, event, *args)
|
344
|
+
@history << "on_next_state_entry #{event} #{prior_state} ->"
|
345
|
+
end
|
346
|
+
|
347
|
+
def on_new_exit(new_state, event, *args)
|
348
|
+
@history << "on_new_exit #{event} -> #{new_state}"
|
349
|
+
end
|
350
|
+
end
|
351
|
+
|
352
|
+
o = c.new
|
353
|
+
assert_equal 'new', o.current_state.to_s
|
354
|
+
assert_equal [], o.history
|
355
|
+
o.next!
|
356
|
+
assert_equal ['on_new_exit next -> next_state', 'on_next_state_entry next new ->'], o.history
|
357
|
+
|
358
|
+
end
|
359
|
+
|
360
|
+
test 'diagram generation' do
|
361
|
+
begin
|
362
|
+
$stdout = StringIO.new('', 'w')
|
363
|
+
Workflow::create_workflow_diagram(Order, 'doc')
|
364
|
+
assert_match(/open.+\.pdf/, $stdout.string,
|
365
|
+
'PDF should be generate and a hint be given to the user.')
|
366
|
+
ensure
|
367
|
+
$stdout = STDOUT
|
368
|
+
end
|
369
|
+
end
|
370
|
+
|
371
|
+
test 'halt stops the transition' do
|
372
|
+
c = Class.new do
|
373
|
+
include Workflow
|
374
|
+
workflow do
|
375
|
+
state :young do
|
376
|
+
event :age, :transitions_to => :old
|
377
|
+
end
|
378
|
+
state :old
|
379
|
+
end
|
380
|
+
|
381
|
+
def age(by=1)
|
382
|
+
halt 'too fast' if by > 100
|
383
|
+
end
|
384
|
+
end
|
385
|
+
|
386
|
+
joe = c.new
|
387
|
+
assert joe.young?
|
388
|
+
joe.age! 120
|
389
|
+
assert joe.young?, 'Transition should have been halted'
|
390
|
+
assert_equal 'too fast', joe.halted_because
|
391
|
+
end
|
392
|
+
|
393
|
+
test 'halt! raises exception' do
|
394
|
+
article_class = Class.new do
|
395
|
+
include Workflow
|
396
|
+
workflow do
|
397
|
+
state :new do
|
398
|
+
event :reject, :transitions_to => :rejected
|
399
|
+
end
|
400
|
+
state :rejected
|
401
|
+
end
|
402
|
+
|
403
|
+
def reject(reason)
|
404
|
+
halt! 'We do not reject articles unless the reason is important' \
|
405
|
+
unless reason =~ /important/i
|
406
|
+
end
|
407
|
+
end
|
408
|
+
|
409
|
+
article = article_class.new
|
410
|
+
assert article.new?
|
411
|
+
assert_raise Workflow::TransitionHalted do
|
412
|
+
article.reject! 'Too funny'
|
413
|
+
end
|
414
|
+
assert article.new?, 'Transition should have been halted'
|
415
|
+
article.reject! 'Important: too short'
|
416
|
+
assert article.rejected?, 'Transition should happen now'
|
417
|
+
end
|
418
|
+
|
419
|
+
end
|
420
|
+
|