apollo 1.0.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 +21 -0
- data/LICENSE +201 -0
- data/MIT-LICENSE +20 -0
- data/README.markdown +437 -0
- data/Rakefile +48 -0
- data/VERSION +1 -0
- data/apollo.gemspec +60 -0
- data/lib/apollo.rb +263 -0
- data/lib/apollo/active_record_instance_methods.rb +24 -0
- data/lib/apollo/event.rb +9 -0
- data/lib/apollo/specification.rb +43 -0
- data/lib/apollo/state.rb +17 -0
- data/test/couchtiny_example.rb +46 -0
- data/test/main_test.rb +418 -0
- data/test/readme_example.rb +37 -0
- data/test/test_helper.rb +16 -0
- data/test/without_active_record_test.rb +54 -0
- metadata +83 -0
data/lib/apollo/state.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
module Apollo
|
2
|
+
class State
|
3
|
+
attr_accessor :name, :events, :meta, :on_entry, :on_exit
|
4
|
+
|
5
|
+
def initialize(name, meta = {})
|
6
|
+
@name, @events, @meta = name, Hash.new, meta
|
7
|
+
end
|
8
|
+
|
9
|
+
def to_s
|
10
|
+
"#{name}"
|
11
|
+
end
|
12
|
+
|
13
|
+
def to_sym
|
14
|
+
name.to_sym
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
require 'couchtiny'
|
3
|
+
require 'couchtiny/document'
|
4
|
+
require 'apollo'
|
5
|
+
|
6
|
+
class User < CouchTiny::Document
|
7
|
+
include Apollo
|
8
|
+
apollo do
|
9
|
+
state :submitted do
|
10
|
+
event :activate_via_link, :to => :proved_email
|
11
|
+
end
|
12
|
+
state :proved_email
|
13
|
+
end
|
14
|
+
|
15
|
+
def load_apollo_state
|
16
|
+
self[:apollo_state]
|
17
|
+
end
|
18
|
+
|
19
|
+
def persist_apollo_state(new_value)
|
20
|
+
self[:apollo_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-apollo")
|
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 apollo state'
|
45
|
+
end
|
46
|
+
end
|
data/test/main_test.rb
ADDED
@@ -0,0 +1,418 @@
|
|
1
|
+
require File.join(File.dirname(__FILE__), 'test_helper')
|
2
|
+
|
3
|
+
$VERBOSE = false
|
4
|
+
require 'active_record'
|
5
|
+
require 'sqlite3'
|
6
|
+
require 'apollo'
|
7
|
+
require 'mocha'
|
8
|
+
#require 'ruby-debug'
|
9
|
+
|
10
|
+
ActiveRecord::Migration.verbose = false
|
11
|
+
|
12
|
+
class Order < ActiveRecord::Base
|
13
|
+
include Apollo
|
14
|
+
apollo do
|
15
|
+
state :submitted do
|
16
|
+
event :accept, :to => :accepted, :meta => {:doc_weight => 8} do |reviewer, args|
|
17
|
+
end
|
18
|
+
end
|
19
|
+
state :accepted do
|
20
|
+
event :ship, :to => :shipped
|
21
|
+
end
|
22
|
+
state :shipped
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
|
27
|
+
class LegacyOrder < ActiveRecord::Base
|
28
|
+
include Apollo
|
29
|
+
|
30
|
+
apollo_column :foo_bar # use this legacy database column for persistence
|
31
|
+
|
32
|
+
apollo do
|
33
|
+
state :submitted do
|
34
|
+
event :accept, :to => :accepted, :meta => {:doc_weight => 8} do |reviewer, args|
|
35
|
+
end
|
36
|
+
end
|
37
|
+
state :accepted do
|
38
|
+
event :ship, :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 :apollo_state
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
exec "INSERT INTO orders(title, apollo_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.apollo_column)
|
86
|
+
o
|
87
|
+
end
|
88
|
+
|
89
|
+
test 'immediately save the new apollo_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 apollo_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 apollo_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(:apollo_state)
|
111
|
+
end
|
112
|
+
|
113
|
+
test 'persist apollo_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 apollo column should be apollo_state' do
|
126
|
+
o = assert_state 'some order', 'accepted'
|
127
|
+
assert_equal :apollo_state, o.class.apollo_column
|
128
|
+
end
|
129
|
+
|
130
|
+
test 'custom apollo column should be foo_bar' do
|
131
|
+
o = assert_state 'some order', 'accepted', LegacyOrder
|
132
|
+
assert_equal :foo_bar, o.class.apollo_column
|
133
|
+
end
|
134
|
+
|
135
|
+
test 'access apollo specification' do
|
136
|
+
assert_equal 3, Order.apollo_spec.states.length
|
137
|
+
end
|
138
|
+
|
139
|
+
test 'current state object' do
|
140
|
+
o = assert_state 'some order', 'accepted'
|
141
|
+
assert_equal 'accepted', o.current_state.to_s
|
142
|
+
assert_equal 1, o.current_state.events.length
|
143
|
+
end
|
144
|
+
|
145
|
+
test 'on_entry and on_exit invoked' do
|
146
|
+
c = Class.new
|
147
|
+
callbacks = mock()
|
148
|
+
callbacks.expects(:my_on_exit_new).once
|
149
|
+
callbacks.expects(:my_on_entry_old).once
|
150
|
+
c.class_eval do
|
151
|
+
include Apollo
|
152
|
+
apollo do
|
153
|
+
state :new do
|
154
|
+
event :age, :to => :old
|
155
|
+
end
|
156
|
+
on_exit do
|
157
|
+
callbacks.my_on_exit_new
|
158
|
+
end
|
159
|
+
state :old
|
160
|
+
on_entry do
|
161
|
+
callbacks.my_on_entry_old
|
162
|
+
end
|
163
|
+
on_exit do
|
164
|
+
fail "wrong on_exit executed"
|
165
|
+
end
|
166
|
+
end
|
167
|
+
end
|
168
|
+
|
169
|
+
o = c.new
|
170
|
+
assert_equal 'new', o.current_state.to_s
|
171
|
+
o.age!
|
172
|
+
end
|
173
|
+
|
174
|
+
test 'on_transition invoked' do
|
175
|
+
callbacks = mock()
|
176
|
+
callbacks.expects(:on_tran).once # this is validated at the end
|
177
|
+
c = Class.new
|
178
|
+
c.class_eval do
|
179
|
+
include Apollo
|
180
|
+
apollo do
|
181
|
+
state :one do
|
182
|
+
event :increment, :to => :two
|
183
|
+
end
|
184
|
+
state :two
|
185
|
+
on_transition do |from, to, triggering_event, *event_args|
|
186
|
+
callbacks.on_tran
|
187
|
+
end
|
188
|
+
end
|
189
|
+
end
|
190
|
+
assert_not_nil c.apollo_spec.on_transition_proc
|
191
|
+
c.new.increment!
|
192
|
+
end
|
193
|
+
|
194
|
+
test 'access event meta information' do
|
195
|
+
c = Class.new
|
196
|
+
c.class_eval do
|
197
|
+
include Apollo
|
198
|
+
apollo do
|
199
|
+
state :main, :meta => {:importance => 8}
|
200
|
+
state :supplemental, :meta => {:importance => 1}
|
201
|
+
end
|
202
|
+
end
|
203
|
+
assert_equal 1, c.apollo_spec.states[:supplemental].meta[:importance]
|
204
|
+
end
|
205
|
+
|
206
|
+
test 'initial state' do
|
207
|
+
c = Class.new
|
208
|
+
c.class_eval do
|
209
|
+
include Apollo
|
210
|
+
apollo { state :one; state :two }
|
211
|
+
end
|
212
|
+
assert_equal 'one', c.new.current_state.to_s
|
213
|
+
end
|
214
|
+
|
215
|
+
test 'nil as initial state' do
|
216
|
+
exec "INSERT INTO orders(title, apollo_state) VALUES('nil state', NULL)"
|
217
|
+
o = Order.find_by_title('nil state')
|
218
|
+
assert o.submitted?, 'if apollo_state is nil, the initial state should be assumed'
|
219
|
+
assert !o.shipped?
|
220
|
+
end
|
221
|
+
|
222
|
+
test 'initial state immediately set as ActiveRecord attribute for new objects' do
|
223
|
+
o = Order.create(:title => 'new object')
|
224
|
+
assert_equal 'submitted', o.read_attribute(:apollo_state)
|
225
|
+
end
|
226
|
+
|
227
|
+
test 'question methods for state' do
|
228
|
+
o = assert_state 'some order', 'accepted'
|
229
|
+
assert o.accepted?
|
230
|
+
assert !o.shipped?
|
231
|
+
end
|
232
|
+
|
233
|
+
test 'correct exception for event, that is not allowed in current state' do
|
234
|
+
o = assert_state 'some order', 'accepted'
|
235
|
+
assert_raise Apollo::NoTransitionAllowed do
|
236
|
+
o.accept!
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
test 'multiple events with the same name and different arguments lists from different states'
|
241
|
+
|
242
|
+
test 'implicit transition callback' do
|
243
|
+
args = mock()
|
244
|
+
args.expects(:my_tran).once # this is validated at the end
|
245
|
+
c = Class.new
|
246
|
+
c.class_eval do
|
247
|
+
include Apollo
|
248
|
+
def my_transition(args)
|
249
|
+
args.my_tran
|
250
|
+
end
|
251
|
+
apollo do
|
252
|
+
state :one do
|
253
|
+
event :my_transition, :to => :two
|
254
|
+
end
|
255
|
+
state :two
|
256
|
+
end
|
257
|
+
end
|
258
|
+
c.new.my_transition!(args)
|
259
|
+
end
|
260
|
+
|
261
|
+
test 'Single table inheritance (STI)' do
|
262
|
+
class BigOrder < Order
|
263
|
+
end
|
264
|
+
|
265
|
+
bo = BigOrder.new
|
266
|
+
assert bo.submitted?
|
267
|
+
assert !bo.accepted?
|
268
|
+
end
|
269
|
+
|
270
|
+
test 'Two-level inheritance' do
|
271
|
+
class BigOrder < Order
|
272
|
+
end
|
273
|
+
|
274
|
+
class EvenBiggerOrder < BigOrder
|
275
|
+
end
|
276
|
+
|
277
|
+
assert EvenBiggerOrder.new.submitted?
|
278
|
+
end
|
279
|
+
|
280
|
+
test 'Iheritance with apollo definition override' do
|
281
|
+
class BigOrder < Order
|
282
|
+
end
|
283
|
+
|
284
|
+
class SpecialBigOrder < BigOrder
|
285
|
+
apollo do
|
286
|
+
state :start_big
|
287
|
+
end
|
288
|
+
end
|
289
|
+
|
290
|
+
special = SpecialBigOrder.new
|
291
|
+
assert_equal 'start_big', special.current_state.to_s
|
292
|
+
end
|
293
|
+
|
294
|
+
test 'Better error message for missing target state' do
|
295
|
+
class Problem
|
296
|
+
include Apollo
|
297
|
+
apollo do
|
298
|
+
state :initial do
|
299
|
+
event :solve, :to => :solved
|
300
|
+
end
|
301
|
+
end
|
302
|
+
end
|
303
|
+
assert_raise Apollo::ApolloError do
|
304
|
+
Problem.new.solve!
|
305
|
+
end
|
306
|
+
end
|
307
|
+
|
308
|
+
# Intermixing of transition graph definition (states, transitions)
|
309
|
+
# on the one side and implementation of the actions on the other side
|
310
|
+
# for a bigger state machine can introduce clutter.
|
311
|
+
#
|
312
|
+
# To reduce this clutter it is now possible to use state entry- and
|
313
|
+
# exit- hooks defined through a naming convention. For example, if there
|
314
|
+
# is a state :pending, then you can hook in by defining method
|
315
|
+
# `def on_pending_exit(new_state, event, *args)` instead of using a
|
316
|
+
# block:
|
317
|
+
#
|
318
|
+
# state :pending do
|
319
|
+
# on_entry do
|
320
|
+
# # your implementation here
|
321
|
+
# end
|
322
|
+
# end
|
323
|
+
#
|
324
|
+
# If both a function with a name according to naming convention and the
|
325
|
+
# on_entry/on_exit block are given, then only on_entry/on_exit block is used.
|
326
|
+
test 'on_entry and on_exit hooks in separate methods' do
|
327
|
+
c = Class.new
|
328
|
+
c.class_eval do
|
329
|
+
include Apollo
|
330
|
+
attr_reader :history
|
331
|
+
def initialize
|
332
|
+
@history = []
|
333
|
+
end
|
334
|
+
apollo do
|
335
|
+
state :new do
|
336
|
+
event :next, :to => :next_state
|
337
|
+
end
|
338
|
+
state :next_state
|
339
|
+
end
|
340
|
+
|
341
|
+
def on_next_state_entry(prior_state, event, *args)
|
342
|
+
@history << "on_next_state_entry #{event} #{prior_state} ->"
|
343
|
+
end
|
344
|
+
|
345
|
+
def on_new_exit(new_state, event, *args)
|
346
|
+
@history << "on_new_exit #{event} -> #{new_state}"
|
347
|
+
end
|
348
|
+
end
|
349
|
+
|
350
|
+
o = c.new
|
351
|
+
assert_equal 'new', o.current_state.to_s
|
352
|
+
assert_equal [], o.history
|
353
|
+
o.next!
|
354
|
+
assert_equal ['on_new_exit next -> next_state', 'on_next_state_entry next new ->'], o.history
|
355
|
+
|
356
|
+
end
|
357
|
+
|
358
|
+
test 'diagram generation' do
|
359
|
+
begin
|
360
|
+
$stdout = StringIO.new('', 'w')
|
361
|
+
Apollo::create_apollo_diagram(Order, 'doc')
|
362
|
+
assert_match(/open.+\.pdf/, $stdout.string,
|
363
|
+
'PDF should be generate and a hint be given to the user.')
|
364
|
+
ensure
|
365
|
+
$stdout = STDOUT
|
366
|
+
end
|
367
|
+
end
|
368
|
+
|
369
|
+
test 'halt stops the transition' do
|
370
|
+
c = Class.new do
|
371
|
+
include Apollo
|
372
|
+
apollo do
|
373
|
+
state :young do
|
374
|
+
event :age, :to => :old
|
375
|
+
end
|
376
|
+
state :old
|
377
|
+
end
|
378
|
+
|
379
|
+
def age(by=1)
|
380
|
+
halt 'too fast' if by > 100
|
381
|
+
end
|
382
|
+
end
|
383
|
+
|
384
|
+
joe = c.new
|
385
|
+
assert joe.young?
|
386
|
+
joe.age! 120
|
387
|
+
assert joe.young?, 'Transition should have been halted'
|
388
|
+
assert_equal 'too fast', joe.halted_because
|
389
|
+
end
|
390
|
+
|
391
|
+
test 'halt! raises exception' do
|
392
|
+
article_class = Class.new do
|
393
|
+
include Apollo
|
394
|
+
apollo do
|
395
|
+
state :new do
|
396
|
+
event :reject, :to => :rejected
|
397
|
+
end
|
398
|
+
state :rejected
|
399
|
+
end
|
400
|
+
|
401
|
+
def reject(reason)
|
402
|
+
halt! 'We do not reject articles unless the reason is important' \
|
403
|
+
unless reason =~ /important/i
|
404
|
+
end
|
405
|
+
end
|
406
|
+
|
407
|
+
article = article_class.new
|
408
|
+
assert article.new?
|
409
|
+
assert_raise Apollo::TransitionHalted do
|
410
|
+
article.reject! 'Too funny'
|
411
|
+
end
|
412
|
+
assert article.new?, 'Transition should have been halted'
|
413
|
+
article.reject! 'Important: too short'
|
414
|
+
assert article.rejected?, 'Transition should happen now'
|
415
|
+
end
|
416
|
+
|
417
|
+
end
|
418
|
+
|