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/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, args[:transitions_to], (args[:meta] or {}), &action)
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(:workflow_state)
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 :workflow_state, new_value
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 :workflow_state, current_state.to_s
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
- klass.send :include, ActiveRecordInstanceMethods
267
- klass.before_validation :write_initial_state
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
+