workflow 0.3.0 → 0.4.1

Sign up to get free protection for your applications and to get access to all the features.
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
+