nimboids-workflow 0.8.0
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 +8 -0
- data/MIT-LICENSE +20 -0
- data/README.markdown +550 -0
- data/Rakefile +44 -0
- data/VERSION +1 -0
- data/lib/workflow.rb +376 -0
- data/test/advanced_hooks_and_validation_test.rb +118 -0
- data/test/couchtiny_example.rb +46 -0
- data/test/main_test.rb +483 -0
- data/test/multiple_workflows_test.rb +84 -0
- data/test/readme_example.rb +37 -0
- data/test/test_helper.rb +39 -0
- data/test/without_active_record_test.rb +54 -0
- data/workflow.rb +1 -0
- metadata +86 -0
data/Rakefile
ADDED
@@ -0,0 +1,44 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake/gempackagetask'
|
3
|
+
require 'rake/testtask'
|
4
|
+
require 'rake/rdoctask'
|
5
|
+
|
6
|
+
task :default => [:test]
|
7
|
+
|
8
|
+
Rake::TestTask.new do |t|
|
9
|
+
t.verbose = true
|
10
|
+
t.warning = true
|
11
|
+
t.pattern = 'test/*_test.rb'
|
12
|
+
end
|
13
|
+
|
14
|
+
Rake::RDocTask.new do |rdoc|
|
15
|
+
rdoc.rdoc_files.include("lib/**/*.rb")
|
16
|
+
rdoc.options << "-S"
|
17
|
+
end
|
18
|
+
|
19
|
+
begin
|
20
|
+
require 'jeweler'
|
21
|
+
Jeweler::Tasks.new do |gemspec|
|
22
|
+
gemspec.name = "nimboids-workflow"
|
23
|
+
gemspec.rubyforge_project = 'workflow'
|
24
|
+
gemspec.email = "vladimir@geekq.net"
|
25
|
+
gemspec.homepage = "http://www.geekq.net/workflow/"
|
26
|
+
gemspec.authors = ["Vladimir Dobriakov"]
|
27
|
+
gemspec.summary = "A replacement for acts_as_state_machine."
|
28
|
+
gemspec.description = <<-EOS
|
29
|
+
Workflow is a finite-state-machine-inspired API for modeling and interacting
|
30
|
+
with what we tend to refer to as 'workflow'.
|
31
|
+
|
32
|
+
* nice DSL to describe your states, events and transitions
|
33
|
+
* robust integration with ActiveRecord and non relational data stores
|
34
|
+
* various hooks for single transitions, entering state etc.
|
35
|
+
* convenient access to the workflow specification: list states, possible events
|
36
|
+
for particular state
|
37
|
+
EOS
|
38
|
+
|
39
|
+
Jeweler::GemcutterTasks.new
|
40
|
+
end
|
41
|
+
rescue LoadError
|
42
|
+
puts "Jeweler not available. Install it with: sudo gem install technicalpickles-jeweler -s http://gems.github.com"
|
43
|
+
end
|
44
|
+
|
data/VERSION
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
0.8.0
|
data/lib/workflow.rb
ADDED
@@ -0,0 +1,376 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
|
3
|
+
# See also README.markdown for documentation
|
4
|
+
module Workflow
|
5
|
+
|
6
|
+
class Specification
|
7
|
+
|
8
|
+
attr_accessor :states, :initial_state, :meta, :on_transition_proc
|
9
|
+
|
10
|
+
def initialize(meta = {}, &specification)
|
11
|
+
@states = Hash.new
|
12
|
+
@meta = meta
|
13
|
+
instance_eval(&specification)
|
14
|
+
end
|
15
|
+
|
16
|
+
def state_names
|
17
|
+
states.keys
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def state(name, meta = {:meta => {}}, &events_and_etc)
|
23
|
+
# meta[:meta] to keep the API consistent..., gah
|
24
|
+
new_state = Workflow::State.new(name, meta[:meta])
|
25
|
+
@initial_state = new_state if @states.empty?
|
26
|
+
@states[name.to_sym] = new_state
|
27
|
+
@scoped_state = new_state
|
28
|
+
instance_eval(&events_and_etc) if events_and_etc
|
29
|
+
end
|
30
|
+
|
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?
|
36
|
+
@scoped_state.events[name.to_sym] =
|
37
|
+
Workflow::Event.new(name, target, (args[:meta] or {}), &action)
|
38
|
+
end
|
39
|
+
|
40
|
+
def on_entry(&proc)
|
41
|
+
@scoped_state.on_entry = proc
|
42
|
+
end
|
43
|
+
|
44
|
+
def on_exit(&proc)
|
45
|
+
@scoped_state.on_exit = proc
|
46
|
+
end
|
47
|
+
|
48
|
+
def on_transition(&proc)
|
49
|
+
@on_transition_proc = proc
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
class TransitionHalted < Exception
|
54
|
+
|
55
|
+
attr_reader :halted_because
|
56
|
+
|
57
|
+
def initialize(msg = nil)
|
58
|
+
@halted_because = msg
|
59
|
+
super msg
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
class NoTransitionAllowed < Exception; end
|
65
|
+
|
66
|
+
class WorkflowError < Exception; end
|
67
|
+
|
68
|
+
class WorkflowDefinitionError < Exception; end
|
69
|
+
|
70
|
+
class State
|
71
|
+
|
72
|
+
attr_accessor :name, :events, :meta, :on_entry, :on_exit
|
73
|
+
|
74
|
+
def initialize(name, meta = {})
|
75
|
+
@name, @events, @meta = name, Hash.new, meta
|
76
|
+
end
|
77
|
+
|
78
|
+
def to_s
|
79
|
+
"#{name}"
|
80
|
+
end
|
81
|
+
|
82
|
+
def to_sym
|
83
|
+
name.to_sym
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
class Event
|
88
|
+
|
89
|
+
attr_accessor :name, :transitions_to, :meta, :action
|
90
|
+
|
91
|
+
def initialize(name, transitions_to, meta = {}, &action)
|
92
|
+
@name, @transitions_to, @meta, @action = name, transitions_to.to_sym, meta, action
|
93
|
+
end
|
94
|
+
|
95
|
+
end
|
96
|
+
|
97
|
+
module WorkflowClassMethods
|
98
|
+
attr_reader :workflow_spec
|
99
|
+
|
100
|
+
def workflow_column(column_name=nil)
|
101
|
+
if column_name
|
102
|
+
@workflow_state_column_name = column_name.to_sym
|
103
|
+
end
|
104
|
+
if !@workflow_state_column_name && superclass.respond_to?(:workflow_column)
|
105
|
+
@workflow_state_column_name = superclass.workflow_column
|
106
|
+
end
|
107
|
+
@workflow_state_column_name ||= :workflow_state
|
108
|
+
end
|
109
|
+
|
110
|
+
def workflow(&specification)
|
111
|
+
@workflow_spec = Specification.new(Hash.new, &specification)
|
112
|
+
@workflow_spec.states.values.each do |state|
|
113
|
+
state_name = state.name
|
114
|
+
module_eval do
|
115
|
+
define_method "#{state_name}?" do
|
116
|
+
state_name == current_state.name
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
state.events.values.each do |event|
|
121
|
+
event_name = event.name
|
122
|
+
module_eval do
|
123
|
+
define_method "#{event_name}!".to_sym do |*args|
|
124
|
+
process_event!(event_name, *args)
|
125
|
+
end
|
126
|
+
|
127
|
+
define_method "can_#{event_name}?" do
|
128
|
+
return self.current_state.events.include? event_name
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
module WorkflowInstanceMethods
|
137
|
+
def current_state
|
138
|
+
loaded_state = load_workflow_state
|
139
|
+
res = spec.states[loaded_state.to_sym] if loaded_state
|
140
|
+
res || spec.initial_state
|
141
|
+
end
|
142
|
+
|
143
|
+
# See the 'Guards' section in the README
|
144
|
+
# @return true if the last transition was halted by one of the transition callbacks.
|
145
|
+
def halted?
|
146
|
+
@halted
|
147
|
+
end
|
148
|
+
|
149
|
+
# @return the reason of the last transition abort as set by the previous
|
150
|
+
# call of `halt` or `halt!` method.
|
151
|
+
def halted_because
|
152
|
+
@halted_because
|
153
|
+
end
|
154
|
+
|
155
|
+
def process_event!(name, *args)
|
156
|
+
event = current_state.events[name.to_sym]
|
157
|
+
raise NoTransitionAllowed.new(
|
158
|
+
"There is no event #{name.to_sym} defined for the #{current_state} state") \
|
159
|
+
if event.nil?
|
160
|
+
@halted_because = nil
|
161
|
+
@halted = false
|
162
|
+
return_value = run_action(event.action, *args) || run_action_callback(event.name, *args)
|
163
|
+
if @halted
|
164
|
+
return false
|
165
|
+
else
|
166
|
+
check_transition(event)
|
167
|
+
run_on_transition(current_state, spec.states[event.transitions_to], name, *args)
|
168
|
+
transition_value = transition(
|
169
|
+
current_state, spec.states[event.transitions_to], name, *args
|
170
|
+
)
|
171
|
+
return_value.nil? ? transition_value : return_value
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
def halt(reason = nil)
|
176
|
+
@halted_because = reason
|
177
|
+
@halted = true
|
178
|
+
end
|
179
|
+
|
180
|
+
def halt!(reason = nil)
|
181
|
+
@halted_because = reason
|
182
|
+
@halted = true
|
183
|
+
raise TransitionHalted.new(reason)
|
184
|
+
end
|
185
|
+
|
186
|
+
def spec
|
187
|
+
# check the singleton class first
|
188
|
+
class << self
|
189
|
+
return workflow_spec if workflow_spec
|
190
|
+
end
|
191
|
+
|
192
|
+
c = self.class
|
193
|
+
# using a simple loop instead of class_inheritable_accessor to avoid
|
194
|
+
# dependency on Rails' ActiveSupport
|
195
|
+
until c.workflow_spec || !(c.include? Workflow)
|
196
|
+
c = c.superclass
|
197
|
+
end
|
198
|
+
c.workflow_spec
|
199
|
+
end
|
200
|
+
|
201
|
+
private
|
202
|
+
|
203
|
+
def check_transition(event)
|
204
|
+
# Create a meaningful error message instead of
|
205
|
+
# "undefined method `on_entry' for nil:NilClass"
|
206
|
+
# Reported by Kyle Burton
|
207
|
+
if !spec.states[event.transitions_to]
|
208
|
+
raise WorkflowError.new("Event[#{event.name}]'s " +
|
209
|
+
"transitions_to[#{event.transitions_to}] is not a declared state.")
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
def transition(from, to, name, *args)
|
214
|
+
run_on_exit(from, to, name, *args)
|
215
|
+
val = persist_workflow_state to.to_s
|
216
|
+
run_on_entry(to, from, name, *args)
|
217
|
+
val
|
218
|
+
end
|
219
|
+
|
220
|
+
def run_on_transition(from, to, event, *args)
|
221
|
+
instance_exec(from.name, to.name, event, *args, &spec.on_transition_proc) if spec.on_transition_proc
|
222
|
+
end
|
223
|
+
|
224
|
+
def run_action(action, *args)
|
225
|
+
instance_exec(*args, &action) if action
|
226
|
+
end
|
227
|
+
|
228
|
+
def run_action_callback(action_name, *args)
|
229
|
+
self.send action_name.to_sym, *args if self.respond_to?(action_name.to_sym)
|
230
|
+
end
|
231
|
+
|
232
|
+
def run_on_entry(state, prior_state, triggering_event, *args)
|
233
|
+
if state.on_entry
|
234
|
+
instance_exec(prior_state.name, triggering_event, *args, &state.on_entry)
|
235
|
+
else
|
236
|
+
hook_name = "on_#{state}_entry"
|
237
|
+
self.send hook_name, prior_state, triggering_event, *args if self.respond_to? hook_name
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
def run_on_exit(state, new_state, triggering_event, *args)
|
242
|
+
if state
|
243
|
+
if state.on_exit
|
244
|
+
instance_exec(new_state.name, triggering_event, *args, &state.on_exit)
|
245
|
+
else
|
246
|
+
hook_name = "on_#{state}_exit"
|
247
|
+
self.send hook_name, new_state, triggering_event, *args if self.respond_to? hook_name
|
248
|
+
end
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
# load_workflow_state and persist_workflow_state
|
253
|
+
# can be overriden to handle the persistence of the workflow state.
|
254
|
+
#
|
255
|
+
# Default (non ActiveRecord) implementation stores the current state
|
256
|
+
# in a variable.
|
257
|
+
#
|
258
|
+
# Default ActiveRecord implementation uses a 'workflow_state' database column.
|
259
|
+
def load_workflow_state
|
260
|
+
@workflow_state if instance_variable_defined? :@workflow_state
|
261
|
+
end
|
262
|
+
|
263
|
+
def persist_workflow_state(new_value)
|
264
|
+
@workflow_state = new_value
|
265
|
+
end
|
266
|
+
end
|
267
|
+
|
268
|
+
module ActiveRecordInstanceMethods
|
269
|
+
def load_workflow_state
|
270
|
+
read_attribute(self.class.workflow_column)
|
271
|
+
end
|
272
|
+
|
273
|
+
# On transition the new workflow state is immediately saved in the
|
274
|
+
# database.
|
275
|
+
def persist_workflow_state(new_value)
|
276
|
+
update_attribute self.class.workflow_column, new_value
|
277
|
+
end
|
278
|
+
|
279
|
+
private
|
280
|
+
|
281
|
+
# Motivation: even if NULL is stored in the workflow_state database column,
|
282
|
+
# the current_state is correctly recognized in the Ruby code. The problem
|
283
|
+
# arises when you want to SELECT records filtering by the value of initial
|
284
|
+
# state. That's why it is important to save the string with the name of the
|
285
|
+
# initial state in all the new records.
|
286
|
+
def write_initial_state
|
287
|
+
write_attribute self.class.workflow_column, current_state.to_s
|
288
|
+
end
|
289
|
+
end
|
290
|
+
|
291
|
+
module RemodelInstanceMethods
|
292
|
+
def load_workflow_state
|
293
|
+
send(self.class.workflow_column)
|
294
|
+
end
|
295
|
+
|
296
|
+
def persist_workflow_state(new_value)
|
297
|
+
update(self.class.workflow_column => new_value)
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
def self.included(klass)
|
302
|
+
klass.send :include, WorkflowInstanceMethods
|
303
|
+
klass.extend WorkflowClassMethods
|
304
|
+
if Object.const_defined?(:ActiveRecord)
|
305
|
+
if klass < ActiveRecord::Base
|
306
|
+
klass.send :include, ActiveRecordInstanceMethods
|
307
|
+
klass.before_validation :write_initial_state
|
308
|
+
end
|
309
|
+
elsif Object.const_defined?(:Remodel)
|
310
|
+
if klass < Remodel::Entity
|
311
|
+
klass.send :include, RemodelInstanceMethods
|
312
|
+
end
|
313
|
+
end
|
314
|
+
end
|
315
|
+
|
316
|
+
# Generates a `dot` graph of the workflow.
|
317
|
+
# Prerequisite: the `dot` binary. (Download from http://www.graphviz.org/)
|
318
|
+
# You can use this method in your own Rakefile like this:
|
319
|
+
#
|
320
|
+
# namespace :doc do
|
321
|
+
# desc "Generate a graph of the workflow."
|
322
|
+
# task :workflow => :environment do # needs access to the Rails environment
|
323
|
+
# Workflow::create_workflow_diagram(Order)
|
324
|
+
# end
|
325
|
+
# end
|
326
|
+
#
|
327
|
+
# You can influence the placement of nodes by specifying
|
328
|
+
# additional meta information in your states and transition descriptions.
|
329
|
+
# You can assign higher `doc_weight` value to the typical transitions
|
330
|
+
# in your workflow. All other states and transitions will be arranged
|
331
|
+
# around that main line. See also `weight` in the graphviz documentation.
|
332
|
+
# Example:
|
333
|
+
#
|
334
|
+
# state :new do
|
335
|
+
# event :approve, :transitions_to => :approved, :meta => {:doc_weight => 8}
|
336
|
+
# end
|
337
|
+
#
|
338
|
+
#
|
339
|
+
# @param klass A class with the Workflow mixin, for which you wish the graphical workflow representation
|
340
|
+
# @param [String] target_dir Directory, where to save the dot and the pdf files
|
341
|
+
# @param [String] graph_options You can change graph orientation, size etc. See graphviz documentation
|
342
|
+
def self.create_workflow_diagram(klass, target_dir='.', graph_options='rankdir="LR", size="7,11.6", ratio="fill"')
|
343
|
+
workflow_name = "#{klass.name.tableize}_workflow".gsub('/', '_')
|
344
|
+
fname = File.join(target_dir, "generated_#{workflow_name}")
|
345
|
+
File.open("#{fname}.dot", 'w') do |file|
|
346
|
+
file.puts %Q|
|
347
|
+
digraph #{workflow_name} {
|
348
|
+
graph [#{graph_options}];
|
349
|
+
node [shape=box];
|
350
|
+
edge [len=1];
|
351
|
+
|
|
352
|
+
|
353
|
+
klass.workflow_spec.states.each do |state_name, state|
|
354
|
+
file.puts %Q{ #{state.name} [label="#{state.name}"];}
|
355
|
+
state.events.each do |event_name, event|
|
356
|
+
meta_info = event.meta
|
357
|
+
if meta_info[:doc_weight]
|
358
|
+
weight_prop = ", weight=#{meta_info[:doc_weight]}"
|
359
|
+
else
|
360
|
+
weight_prop = ''
|
361
|
+
end
|
362
|
+
file.puts %Q{ #{state.name} -> #{event.transitions_to} [label="#{event_name.to_s.humanize}" #{weight_prop}];}
|
363
|
+
end
|
364
|
+
end
|
365
|
+
file.puts "}"
|
366
|
+
file.puts
|
367
|
+
end
|
368
|
+
`dot -Tpdf -o'#{fname}.pdf' '#{fname}.dot'`
|
369
|
+
puts "
|
370
|
+
Please run the following to open the generated file:
|
371
|
+
|
372
|
+
open '#{fname}.pdf'
|
373
|
+
|
374
|
+
"
|
375
|
+
end
|
376
|
+
end
|
@@ -0,0 +1,118 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
|
3
|
+
$VERBOSE = false
|
4
|
+
require 'active_record'
|
5
|
+
require 'sqlite3'
|
6
|
+
require 'workflow'
|
7
|
+
|
8
|
+
ActiveRecord::Migration.verbose = false
|
9
|
+
|
10
|
+
# Transition based validation
|
11
|
+
# ---------------------------
|
12
|
+
# If you are using ActiveRecord you might want to define different validations
|
13
|
+
# for different transitions. There is a `validates_presence_of` hook that let's
|
14
|
+
# you specify the attributes that need to be present for an successful transition.
|
15
|
+
# If the object is not valid at the end of the transition event the transition
|
16
|
+
# is halted and a TransitionHalted exception is thrown.
|
17
|
+
#
|
18
|
+
# Here is a sample that illustrates how to use the presence validation:
|
19
|
+
# (use case suggested by http://github.com/southdesign)
|
20
|
+
class Article < ActiveRecord::Base
|
21
|
+
include Workflow
|
22
|
+
workflow do
|
23
|
+
state :new do
|
24
|
+
event :accept, :transitions_to => :accepted, :meta => {:validates_presence_of => [:title, :body]}
|
25
|
+
event :reject, :transitions_to => :rejected
|
26
|
+
end
|
27
|
+
state :accepted do
|
28
|
+
event :blame, :transitions_to => :blamed, :meta => {:validates_presence_of => [:title, :body, :blame_reason]}
|
29
|
+
event :delete, :transitions_to => :deleted
|
30
|
+
end
|
31
|
+
state :rejected do
|
32
|
+
event :delete, :transitions_to => :deleted
|
33
|
+
end
|
34
|
+
state :blamed do
|
35
|
+
event :delete, :transitions_to => :deleted
|
36
|
+
end
|
37
|
+
state :deleted do
|
38
|
+
event :accept, :transitions_to => :accepted
|
39
|
+
end
|
40
|
+
|
41
|
+
on_transition do |from, to, triggering_event, *event_args|
|
42
|
+
if self.class.superclass.to_s.split("::").first == "ActiveRecord"
|
43
|
+
singleton = class << self; self end
|
44
|
+
validations = Proc.new {}
|
45
|
+
|
46
|
+
meta = Article.workflow_spec.states[from].events[triggering_event].meta
|
47
|
+
fields_to_validate = meta[:validates_presence_of]
|
48
|
+
if fields_to_validate
|
49
|
+
validations = Proc.new {
|
50
|
+
errors.add_on_blank(fields_to_validate) if fields_to_validate
|
51
|
+
}
|
52
|
+
end
|
53
|
+
|
54
|
+
singleton.send :define_method, :validate, &validations
|
55
|
+
halt! "Event[#{triggering_event}]'s transitions_to[#{to}] is not valid." if self.invalid?
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
class AdvancedHooksAndValidationTest < ActiveRecordTestCase
|
62
|
+
|
63
|
+
def setup
|
64
|
+
super
|
65
|
+
|
66
|
+
ActiveRecord::Schema.define do
|
67
|
+
create_table :articles do |t|
|
68
|
+
t.string :title
|
69
|
+
t.string :body
|
70
|
+
t.string :blame_reason
|
71
|
+
t.string :reject_reason
|
72
|
+
t.string :workflow_state
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('new1', NULL, NULL, NULL, 'new')"
|
77
|
+
exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('new2', 'some content', NULL, NULL, 'new')"
|
78
|
+
exec "INSERT INTO articles(title, body, blame_reason, reject_reason, workflow_state) VALUES('accepted1', 'some content', NULL, NULL, 'accepted')"
|
79
|
+
|
80
|
+
end
|
81
|
+
|
82
|
+
def assert_state(title, expected_state, klass = Order)
|
83
|
+
o = klass.find_by_title(title)
|
84
|
+
assert_equal expected_state, o.read_attribute(klass.workflow_column)
|
85
|
+
o
|
86
|
+
end
|
87
|
+
|
88
|
+
test 'deny transition from new to accepted because of the missing presence of the body' do
|
89
|
+
a = Article.find_by_title('new1');
|
90
|
+
assert_raise Workflow::TransitionHalted do
|
91
|
+
a.accept!
|
92
|
+
end
|
93
|
+
assert_state 'new1', 'new', Article
|
94
|
+
end
|
95
|
+
|
96
|
+
test 'allow transition from new to accepted because body is present this time' do
|
97
|
+
a = Article.find_by_title('new2');
|
98
|
+
assert a.accept!
|
99
|
+
assert_state 'new2', 'accepted', Article
|
100
|
+
end
|
101
|
+
|
102
|
+
test 'allow transition from accepted to blamed because of a blame_reason' do
|
103
|
+
a = Article.find_by_title('accepted1');
|
104
|
+
a.blame_reason = "Provocant thesis"
|
105
|
+
assert a.blame!
|
106
|
+
assert_state 'accepted1', 'blamed', Article
|
107
|
+
end
|
108
|
+
|
109
|
+
test 'deny transition from accepted to blamed because of no blame_reason' do
|
110
|
+
a = Article.find_by_title('accepted1');
|
111
|
+
assert_raise Workflow::TransitionHalted do
|
112
|
+
assert a.blame!
|
113
|
+
end
|
114
|
+
assert_state 'accepted1', 'accepted', Article
|
115
|
+
end
|
116
|
+
|
117
|
+
end
|
118
|
+
|