groem 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,140 @@
1
+ require 'eventmachine'
2
+ require 'uuidtools'
3
+
4
+ module Groem
5
+
6
+ class Notification < Struct.new(:environment,
7
+ :application_name,
8
+ :name,
9
+ :display_name,
10
+ :enabled,
11
+ :title,
12
+ :text,
13
+ :sticky,
14
+ :priority,
15
+ :coalescing_id,
16
+ :headers
17
+ )
18
+ include Groem::Marshal::Request
19
+
20
+ DEFAULT_ENV = {'protocol' => 'GNTP', 'version' => '1.0',
21
+ 'request_method' => 'NOTIFY', 'encryption_id' => 'NONE'
22
+ }
23
+
24
+ def initialize(name, *args)
25
+ opts = args.last.is_a?(Hash) ? args.pop : {}
26
+ title = args.shift
27
+ self.environment, self.headers, @callback = {}, {}, {}
28
+ self.environment = DEFAULT_ENV.merge(opts.delete(:environment) || {})
29
+ self.name = name.to_s if name
30
+ self.title = title.to_s if title
31
+ self.enabled = 'True'
32
+ self.headers = opts.delete(:headers) || {}
33
+ opts.each_pair do |opt, val|
34
+ if self.respond_to?(:"#{opt}=")
35
+ self.__send__ :"#{opt}=", val.to_s
36
+ else
37
+ header(opt, val.to_s)
38
+ end
39
+ end
40
+ reset!
41
+ end
42
+
43
+ def [](key)
44
+ to_request[key]
45
+ end
46
+
47
+ def reset!
48
+ @to_request = nil
49
+ self
50
+ end
51
+
52
+ def reset_callback!
53
+ @callback = {}
54
+ end
55
+
56
+ def dup
57
+ attrs = {}; self.each_pair {|k, v| attrs[k] = v.dup if v}
58
+ n = self.class.new(self.name, attrs)
59
+ n.callback(:context => callback_context,
60
+ :type => callback_type,
61
+ :target => callback_target) if @callback
62
+ n
63
+ end
64
+
65
+ def header key, value
66
+ reset!
67
+ self.headers[growlify_key(key)] = value
68
+ end
69
+
70
+ def icon(uri_or_file)
71
+ # TODO if not uri
72
+ reset!
73
+ header GNTP_NOTIFICATION_ICON_KEY, uri_or_file
74
+ end
75
+
76
+ # Note defaults name and type to notification name
77
+ def callback *args
78
+ opts = ((Hash === args.last) ? args.pop : {})
79
+ name = args.shift || opts[:context] || self.name
80
+ type = opts[:type] || self.name
81
+ target = opts[:target]
82
+ reset!
83
+ reset_callback!
84
+ @callback[GNTP_NOTIFICATION_CALLBACK_CONTEXT_KEY] = name if name
85
+ @callback[GNTP_NOTIFICATION_CALLBACK_CONTEXT_TYPE_KEY] = type if type
86
+ @callback[GNTP_NOTIFICATION_CALLBACK_TARGET_KEY] = target if target
87
+ @callback
88
+ end
89
+
90
+ def callback_context
91
+ @callback[GNTP_NOTIFICATION_CALLBACK_CONTEXT_KEY]
92
+ end
93
+
94
+ def callback_type
95
+ @callback[GNTP_NOTIFICATION_CALLBACK_CONTEXT_TYPE_KEY]
96
+ end
97
+ alias_method :callback_context_type, :callback_type
98
+
99
+ def callback_target
100
+ @callback[GNTP_NOTIFICATION_CALLBACK_TARGET_KEY]
101
+ end
102
+
103
+ def to_register
104
+ %w{display_name enabled}.inject({}) do |memo, attr|
105
+ if val = self.__send__(:"#{attr}")
106
+ memo["Notification-#{growlify_key(attr)}"] = val
107
+ end
108
+ memo
109
+ end.merge(self.headers)
110
+ end
111
+
112
+ def to_notify
113
+ %w{name title text sticky priority coalescing_id}.inject({}) do |memo, attr|
114
+ if val = self.__send__(:"#{attr}")
115
+ memo["Notification-#{growlify_key(attr)}"] = val
116
+ end
117
+ memo
118
+ end.merge({GNTP_APPLICATION_NAME_KEY => self.application_name}).
119
+ merge({GNTP_NOTIFICATION_ID_KEY => unique_id}).
120
+ merge(@callback).
121
+ merge(self.headers)
122
+ end
123
+
124
+ def to_request
125
+ @to_request ||= \
126
+ {'environment' => environment,
127
+ 'headers' => to_notify,
128
+ 'notifications' => {}
129
+ }
130
+ end
131
+
132
+ protected
133
+
134
+ def unique_id
135
+ UUIDTools::UUID.timestamp_create.to_s
136
+ end
137
+
138
+ end
139
+
140
+ end
@@ -0,0 +1,86 @@
1
+ require 'forwardable'
2
+
3
+ module Groem
4
+ class Response < Struct.new(:status,
5
+ :method,
6
+ :action,
7
+ :notification_id,
8
+ :context,
9
+ :context_type,
10
+ :callback_result,
11
+ :headers)
12
+ include Groem::Marshal::Response
13
+ extend Forwardable
14
+
15
+ def_delegator :@raw, :[]
16
+
17
+ def initialize(resp)
18
+ @raw = resp
19
+ self.status = resp[0]
20
+ self.headers = resp[1]
21
+ self.action = resp[1][GNTP_RESPONSE_ACTION_KEY]
22
+ self.notification_id = resp[1][GNTP_NOTIFICATION_ID_KEY]
23
+ self.context = resp[2][GNTP_NOTIFICATION_CALLBACK_CONTEXT_KEY]
24
+ self.context_type = resp[2][GNTP_NOTIFICATION_CALLBACK_CONTEXT_TYPE_KEY]
25
+ self.callback_result = resp[2][GNTP_NOTIFICATION_CALLBACK_RESULT_KEY]
26
+ self.method = if self.status.to_i == 0
27
+ if self.callback_result
28
+ GNTP_CALLBACK_RESPONSE
29
+ else
30
+ GNTP_OK_RESPONSE
31
+ end
32
+ else
33
+ GNTP_ERROR_RESPONSE
34
+ end
35
+ end
36
+
37
+ def callback_route
38
+ [self.callback_result, self.context, self.context_type]
39
+ end
40
+
41
+ def to_register? &blk
42
+ yield_and_return_if self.action == GNTP_REGISTER_METHOD, &blk
43
+ end
44
+
45
+ def to_notify? &blk
46
+ yield_and_return_if self.action == GNTP_NOTIFY_METHOD, &blk
47
+ end
48
+
49
+ def ok? &blk
50
+ yield_and_return_if self.method == GNTP_OK_RESPONSE, &blk
51
+ end
52
+
53
+ def callback? &blk
54
+ yield_and_return_if self.method == GNTP_CALLBACK_RESPONSE, &blk
55
+ end
56
+
57
+ def error?(code=nil, &blk)
58
+ yield_and_return_if (self.method == GNTP_ERROR_RESPONSE && \
59
+ code.nil? || self.status == code), &blk
60
+ end
61
+
62
+ def clicked? &blk
63
+ yield_and_return_if self.callback_result == GNTP_CLICK_CALLBACK_RESULT, &blk
64
+ end
65
+
66
+ def closed? &blk
67
+ yield_and_return_if self.callback_result == GNTP_CLOSE_CALLBACK_RESULT, &blk
68
+ end
69
+
70
+ def timedout? &blk
71
+ yield_and_return_if self.callback_result == GNTP_TIMEDOUT_CALLBACK_RESULT, &blk
72
+ end
73
+
74
+ alias_method :click?, :clicked?
75
+ alias_method :close?, :closed?
76
+ alias_method :timeout?, :timedout?
77
+
78
+ protected
79
+
80
+ def yield_and_return_if(cond)
81
+ yield if block_given? && cond
82
+ cond
83
+ end
84
+
85
+ end
86
+ end
@@ -0,0 +1,37 @@
1
+
2
+ module Groem
3
+ class Route
4
+
5
+ class << self
6
+ def parse action, *path
7
+ [action] + Array.new(2).fill {|i| path[i] }
8
+ end
9
+
10
+ def matches? pattern, parts
11
+ parts = [parts] unless Array === parts
12
+ pattern.zip(parts).all? do |exp, act|
13
+ exp.nil? || exp == act
14
+ end
15
+ end
16
+
17
+ end
18
+
19
+ attr_reader :pattern
20
+
21
+ def initialize action, *path
22
+ path = path.flatten
23
+ @pattern = self.class.parse action, *path
24
+ end
25
+
26
+ def matches?(*args)
27
+ self.class.matches?(pattern, args.flatten)
28
+ end
29
+
30
+
31
+ # sort nil parts after named parts (unless named parts begin with ~)
32
+ def <=>(other)
33
+ pattern.map {|it| it || '~'} <=> other.pattern.map {|it| it || '~'}
34
+ end
35
+
36
+ end
37
+ end
@@ -0,0 +1,3 @@
1
+ module Groem
2
+ VERSION = '0.0.4'
3
+ end
@@ -0,0 +1,73 @@
1
+ require File.join(File.dirname(__FILE__),'..','spec_helper')
2
+
3
+ describe 'Groem::App #notify with ad-hoc callback' do
4
+
5
+ before do
6
+ @p_svr = DummyServerHelper.fork_server(:callback => ['CLICK', 2])
7
+ Groem::Client.response_class = MarshalHelper.dummy_response_class
8
+ @subject = Groem::App.new('test', :port => DummyServerHelper::DEFAULT_PORT)
9
+ end
10
+
11
+ after do
12
+ DummyServerHelper.kill_server(@p_svr)
13
+ end
14
+
15
+ it 'should receive CLICK callback matching ad-hoc callback spec' do
16
+ ok_count = 0
17
+ cb_count = 0
18
+
19
+ app = @subject
20
+ app.register do
21
+ notification 'Foo' do |n|
22
+ n.display_name = 'Hoo'
23
+ n.callback 'First', :type => '1'
24
+ end
25
+ end
26
+
27
+ app.when_click :type => '2' do |resp|
28
+ cb_count += 1
29
+ end
30
+
31
+ app.when_click 'Second' do |resp|
32
+ cb_count += 1
33
+ end
34
+
35
+ app.when_click 'First' do |resp|
36
+ flunk 'Expected callback context \'Second\', got \'First\''
37
+ end
38
+
39
+ app.when_click :type => '1' do |resp|
40
+ flunk 'Expected callback context type \'2\', got \'1\''
41
+ end
42
+
43
+ app.notify('Foo', :callback => {:context => 'Second', :type => '2'})
44
+
45
+ cb_count.must_equal 2
46
+ end
47
+
48
+ it 'should trigger callback with ad-hoc state, but not change state of registered callback' do
49
+
50
+ app = @subject
51
+ app.register do
52
+ notification 'Foo' do |n|
53
+ n.display_name = 'Hoo'
54
+ n.callback 'First', :type => '1'
55
+ end
56
+ end
57
+
58
+ app.when_click do |resp|
59
+ puts resp[0..2].inspect
60
+ resp.context.must_equal 'Second'
61
+ resp.context_type.must_equal '2'
62
+ end
63
+
64
+ app.notify('Foo', :display_name => 'Who',
65
+ :callback => {:context => 'Second', :type => '2'})
66
+
67
+ app.notifications['Foo'].display_name.must_equal 'Hoo'
68
+ app.notifications['Foo'].callback_context.must_equal 'First'
69
+ app.notifications['Foo'].callback_type.must_equal '1'
70
+
71
+ end
72
+
73
+ end
@@ -0,0 +1,390 @@
1
+ require File.join(File.dirname(__FILE__),'..','spec_helper')
2
+
3
+ describe 'Groem::App #notify without callbacks' do
4
+
5
+ describe 'with one notification' do
6
+
7
+ before do
8
+ @p_svr = DummyServerHelper.fork_server(:notify => '-OK')
9
+ Groem::Client.response_class = MarshalHelper.dummy_response_class
10
+ @subject = Groem::App.new('test', :port => DummyServerHelper::DEFAULT_PORT)
11
+ end
12
+
13
+ after do
14
+ DummyServerHelper.kill_server(@p_svr)
15
+ end
16
+
17
+ it 'should receive back one OK response from notify' do
18
+ count = 0
19
+
20
+ @subject.register do
21
+ notification 'hello' do end
22
+ end
23
+
24
+ @subject.notify('hello') do |resp|
25
+ count += 1
26
+ resp[0].to_i.must_equal 0
27
+ resp[2].must_be_empty
28
+ count.must_equal 1
29
+ end
30
+
31
+ end
32
+
33
+ it 'should return OK response from notify' do
34
+
35
+ @subject.register do
36
+ notification 'hello' do end
37
+ end
38
+
39
+ ret = @subject.notify('hello')
40
+ ret.class.must_be_same_as Groem::Response
41
+ ret[0].to_i.must_equal 0
42
+ ret[2].must_be_empty
43
+
44
+ end
45
+
46
+ end
47
+
48
+ describe 'when notification hasnt been specified' do
49
+
50
+ before do
51
+ @p_svr = DummyServerHelper.fork_server(:notify => '-OK')
52
+ Groem::Client.response_class = MarshalHelper.dummy_response_class
53
+ @subject = Groem::App.new('test', :port => DummyServerHelper::DEFAULT_PORT)
54
+ end
55
+
56
+ after do
57
+ DummyServerHelper.kill_server(@p_svr)
58
+ end
59
+
60
+ it 'should return nil' do
61
+
62
+ @subject.register do
63
+ notification 'hello' do end
64
+ end
65
+
66
+ @subject.notify('goodbye') do end.must_be_nil
67
+
68
+ end
69
+
70
+ end
71
+
72
+ end
73
+
74
+ module AppNotifyCallbacksHelper
75
+
76
+
77
+ def should_receive_ok_and_callback(app, rslt, timeout, opts = {})
78
+ click_name = opts[:click] || 'CLICK'
79
+ close_name = opts[:close] || 'CLOSE'
80
+ timedout_name = opts[:timedout] || 'TIMEDOUT'
81
+ ok_count = 0
82
+ click_count = 0
83
+ close_count = 0
84
+ timedout_count = 0
85
+
86
+ EM.run {
87
+ app.register do
88
+ notification 'Foo' do |n|
89
+ n.callback 'You', :type => 'shiny'
90
+ end
91
+ end
92
+
93
+ app.when_callback click_name do |resp|
94
+ puts "App received callback: #{resp[2]['Notification-Callback-Result']}"
95
+ click_count += 1
96
+ ['CLICK', 'CLICKED'].must_include resp[2]['Notification-Callback-Result']
97
+ end
98
+
99
+ app.when_callback close_name do |resp|
100
+ puts "App received callback: #{resp[2]['Notification-Callback-Result']}"
101
+ close_count += 1
102
+ ['CLOSE', 'CLOSED'].must_include resp[2]['Notification-Callback-Result']
103
+ end
104
+
105
+ app.when_callback timedout_name do |resp|
106
+ puts "App received callback: #{resp[2]['Notification-Callback-Result']}"
107
+ timedout_count += 1
108
+ ['TIMEDOUT', 'TIMEOUT'].must_include resp[2]['Notification-Callback-Result']
109
+ end
110
+
111
+ app.notify('Foo') do |resp|
112
+ ok_count += 1 if resp[0].to_i == 0
113
+ resp[0].to_i.must_equal 0
114
+ end
115
+
116
+ EM.add_timer(timeout + 1) {
117
+ ok_count.must_equal 1
118
+ case rslt
119
+ when 'CLICK'
120
+ click_count.must_equal 1
121
+ close_count.must_equal 0
122
+ timedout_count.must_equal 0
123
+ when 'CLOSE'
124
+ click_count.must_equal 0
125
+ close_count.must_equal 1
126
+ timedout_count.must_equal 0
127
+ when 'TIMEDOUT'
128
+ click_count.must_equal 0
129
+ close_count.must_equal 0
130
+ timedout_count.must_equal 1
131
+ end
132
+ EM.stop
133
+ }
134
+
135
+ }
136
+ end
137
+
138
+ def should_receive_ok_and_callback_outside_reactor(app, rslt, timeout, opts = {})
139
+ click_name = opts[:click] || 'CLICK'
140
+ close_name = opts[:close] || 'CLOSE'
141
+ timedout_name = opts[:timedout] || 'TIMEDOUT'
142
+ ok_count = 0
143
+ click_count = 0
144
+ close_count = 0
145
+ timedout_count = 0
146
+
147
+ app.register do
148
+ notification 'Foo' do |n|
149
+ n.callback 'You', :type => 'shiny'
150
+ end
151
+ end
152
+
153
+ app.when_callback click_name do |resp|
154
+ puts "App received callback: #{resp[2]['Notification-Callback-Result']}"
155
+ click_count += 1
156
+ ['CLICK', 'CLICKED'].must_include resp[2]['Notification-Callback-Result']
157
+ end
158
+
159
+ app.when_callback close_name do |resp|
160
+ puts "App received callback: #{resp[2]['Notification-Callback-Result']}"
161
+ close_count += 1
162
+ ['CLOSE', 'CLOSED'].must_include resp[2]['Notification-Callback-Result']
163
+ end
164
+
165
+ app.when_callback timedout_name do |resp|
166
+ puts "App received callback: #{resp[2]['Notification-Callback-Result']}"
167
+ timedout_count += 1
168
+ ['TIMEDOUT', 'TIMEOUT'].must_include resp[2]['Notification-Callback-Result']
169
+ end
170
+
171
+ app.notify('Foo') do |resp|
172
+ ok_count += 1 if resp[0].to_i == 0
173
+ resp[0].to_i.must_equal 0
174
+ end
175
+
176
+ ok_count.must_equal 1
177
+ case rslt
178
+ when 'CLICK'
179
+ click_count.must_equal 1
180
+ close_count.must_equal 0
181
+ timedout_count.must_equal 0
182
+ when 'CLOSE'
183
+ click_count.must_equal 0
184
+ close_count.must_equal 1
185
+ timedout_count.must_equal 0
186
+ when 'TIMEDOUT'
187
+ click_count.must_equal 0
188
+ close_count.must_equal 0
189
+ timedout_count.must_equal 1
190
+ end
191
+
192
+ end
193
+
194
+
195
+ def should_return_ok_response(app)
196
+
197
+ app.register do
198
+ notification 'Foo' do |n|
199
+ n.callback 'You', :type => 'shiny'
200
+ end
201
+ end
202
+
203
+ ret = app.notify('Foo')
204
+ ret.class.must_be_same_as Groem::Response
205
+ ret[0].to_i.must_equal 0
206
+ ret[2].must_be_empty
207
+ end
208
+
209
+
210
+ end
211
+
212
+
213
+ describe 'Groem::App #notify with simple callbacks' do
214
+
215
+
216
+ describe 'when CLICK callback returned' do
217
+ include AppNotifyCallbacksHelper
218
+
219
+ before do
220
+ @p_svr = DummyServerHelper.fork_server(:callback => ['CLICK', 2])
221
+ Groem::Client.response_class = MarshalHelper.dummy_response_class
222
+ @subject = Groem::App.new('test', :port => DummyServerHelper::DEFAULT_PORT)
223
+ end
224
+
225
+ after do
226
+ DummyServerHelper.kill_server(@p_svr)
227
+ end
228
+
229
+ it 'should receive ok and CLICK callback' do
230
+ should_receive_ok_and_callback(@subject, 'CLICK', 2)
231
+ end
232
+
233
+ it 'should receive ok and CLICK callback outside reactor' do
234
+ should_receive_ok_and_callback_outside_reactor(@subject, 'CLICK', 2)
235
+ end
236
+
237
+ it 'should receive ok and CLICK callback using symbolized actions' do
238
+ should_receive_ok_and_callback(@subject, 'CLICK', 2,
239
+ :click => :click,
240
+ :close => :closed,
241
+ :timedout => :timedout)
242
+ end
243
+
244
+ it 'should receive ok and CLICK callback with alternate action names' do
245
+ should_receive_ok_and_callback(@subject, 'CLICK', 2,
246
+ :click => 'CLICKED',
247
+ :close => 'CLOSED',
248
+ :timedout => 'TIMEOUT')
249
+ end
250
+
251
+ it 'should return ok response' do
252
+ should_return_ok_response(@subject)
253
+ end
254
+
255
+ end
256
+
257
+ describe 'when CLOSE callback returned' do
258
+ include AppNotifyCallbacksHelper
259
+
260
+ before do
261
+ @p_svr = DummyServerHelper.fork_server(:callback => ['CLOSE', 2])
262
+ Groem::Client.response_class = MarshalHelper.dummy_response_class
263
+ @subject = Groem::App.new('test', :port => DummyServerHelper::DEFAULT_PORT)
264
+ end
265
+
266
+ after do
267
+ DummyServerHelper.kill_server(@p_svr)
268
+ end
269
+
270
+ it 'should receive ok and CLOSE callback' do
271
+ should_receive_ok_and_callback(@subject, 'CLOSE', 2)
272
+ end
273
+
274
+ it 'should receive ok and CLOSE callback using symbolized actions' do
275
+ should_receive_ok_and_callback(@subject, 'CLOSE', 2,
276
+ :click => :click,
277
+ :close => :closed,
278
+ :timedout => :timedout)
279
+ end
280
+
281
+ it 'should receive ok and CLOSE callback with alternate action names' do
282
+ should_receive_ok_and_callback(@subject, 'CLOSE', 2,
283
+ :click => 'CLICKED',
284
+ :close => 'CLOSED',
285
+ :timedout => 'TIMEOUT')
286
+ end
287
+
288
+ it 'should return ok response' do
289
+ should_return_ok_response(@subject)
290
+ end
291
+
292
+ end
293
+
294
+ describe 'when TIMEDOUT callback returned' do
295
+ include AppNotifyCallbacksHelper
296
+
297
+ before do
298
+ @p_svr = DummyServerHelper.fork_server(:callback => ['TIMEDOUT', 2])
299
+ Groem::Client.response_class = MarshalHelper.dummy_response_class
300
+ @subject = Groem::App.new('test', :port => DummyServerHelper::DEFAULT_PORT)
301
+ end
302
+
303
+ after do
304
+ DummyServerHelper.kill_server(@p_svr)
305
+ end
306
+
307
+ it 'should receive ok and TIMEDOUT callback' do
308
+ should_receive_ok_and_callback(@subject, 'TIMEDOUT', 2)
309
+ end
310
+
311
+ it 'should receive ok and TIMEDOUT callback using symbolized actions' do
312
+ should_receive_ok_and_callback(@subject, 'TIMEDOUT', 2,
313
+ :click => :click,
314
+ :close => :closed,
315
+ :timedout => :timedout)
316
+ end
317
+
318
+ it 'should receive ok and TIMEDOUT callback with alternate action names' do
319
+ should_receive_ok_and_callback(@subject, 'TIMEDOUT', 2,
320
+ :click => 'CLICKED',
321
+ :close => 'CLOSED',
322
+ :timedout => 'TIMEOUT')
323
+ end
324
+
325
+ it 'should return ok response' do
326
+ should_return_ok_response(@subject)
327
+ end
328
+
329
+ end
330
+
331
+ end
332
+
333
+
334
+ describe 'Groem::App #notify with routed callbacks' do
335
+
336
+ before do
337
+ @p_svr = DummyServerHelper.fork_server(:callback => ['CLICK', 2])
338
+ Groem::Client.response_class = MarshalHelper.dummy_response_class
339
+ @subject = Groem::App.new('test', :port => DummyServerHelper::DEFAULT_PORT)
340
+ end
341
+
342
+ after do
343
+ DummyServerHelper.kill_server(@p_svr)
344
+ end
345
+
346
+ it 'should receive ok and CLICK callback matching multiple routes' do
347
+ ok_count = 0
348
+ cb_count = 0
349
+
350
+ app = @subject
351
+ app.register do
352
+ notification 'Foo' do |n|
353
+ n.callback 'You', :type => 'shiny'
354
+ end
355
+ end
356
+
357
+ app.when_click 'You' do |resp|
358
+ cb_count.must_equal 1
359
+ cb_count += 1
360
+ end
361
+
362
+ app.when_click :context => 'You', :type => 'shiny' do |resp|
363
+ cb_count.must_equal 0
364
+ cb_count += 1
365
+ end
366
+
367
+ app.when_click do |resp|
368
+ cb_count.must_equal 3
369
+ cb_count += 1
370
+ end
371
+
372
+ app.when_click :type => 'shiny' do |resp|
373
+ cb_count.must_equal 2
374
+ cb_count += 1
375
+ end
376
+
377
+ app.notify('Foo') do |resp|
378
+ ok_count += 1 if resp[0].to_i == 0
379
+ resp[0].to_i.must_equal 0
380
+ end
381
+
382
+ ok_count.must_equal 1
383
+ cb_count.must_equal 4
384
+ end
385
+
386
+
387
+ end
388
+
389
+
390
+