airbrake-ruby 3.1.0 → 3.2.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/lib/airbrake-ruby.rb +197 -43
  3. data/lib/airbrake-ruby/config.rb +43 -11
  4. data/lib/airbrake-ruby/deploy_notifier.rb +47 -0
  5. data/lib/airbrake-ruby/filter_chain.rb +32 -50
  6. data/lib/airbrake-ruby/filters/git_repository_filter.rb +9 -1
  7. data/lib/airbrake-ruby/filters/sql_filter.rb +104 -0
  8. data/lib/airbrake-ruby/hash_keyable.rb +37 -0
  9. data/lib/airbrake-ruby/ignorable.rb +44 -0
  10. data/lib/airbrake-ruby/notice.rb +2 -22
  11. data/lib/airbrake-ruby/{notifier.rb → notice_notifier.rb} +66 -46
  12. data/lib/airbrake-ruby/performance_notifier.rb +161 -0
  13. data/lib/airbrake-ruby/stat.rb +56 -0
  14. data/lib/airbrake-ruby/tdigest.rb +393 -0
  15. data/lib/airbrake-ruby/time_truncate.rb +17 -0
  16. data/lib/airbrake-ruby/version.rb +1 -1
  17. data/spec/airbrake_spec.rb +57 -13
  18. data/spec/async_sender_spec.rb +0 -2
  19. data/spec/backtrace_spec.rb +0 -2
  20. data/spec/code_hunk_spec.rb +0 -2
  21. data/spec/config/validator_spec.rb +0 -2
  22. data/spec/config_spec.rb +16 -4
  23. data/spec/deploy_notifier_spec.rb +41 -0
  24. data/spec/file_cache.rb +0 -2
  25. data/spec/filter_chain_spec.rb +1 -7
  26. data/spec/filters/context_filter_spec.rb +0 -2
  27. data/spec/filters/dependency_filter_spec.rb +0 -2
  28. data/spec/filters/exception_attributes_filter_spec.rb +0 -2
  29. data/spec/filters/gem_root_filter_spec.rb +0 -2
  30. data/spec/filters/git_last_checkout_filter_spec.rb +0 -2
  31. data/spec/filters/git_repository_filter.rb +0 -2
  32. data/spec/filters/git_revision_filter_spec.rb +0 -2
  33. data/spec/filters/keys_blacklist_spec.rb +0 -2
  34. data/spec/filters/keys_whitelist_spec.rb +0 -2
  35. data/spec/filters/root_directory_filter_spec.rb +0 -2
  36. data/spec/filters/sql_filter_spec.rb +219 -0
  37. data/spec/filters/system_exit_filter_spec.rb +0 -2
  38. data/spec/filters/thread_filter_spec.rb +0 -2
  39. data/spec/ignorable_spec.rb +14 -0
  40. data/spec/nested_exception_spec.rb +0 -2
  41. data/spec/{notifier_spec.rb → notice_notifier_spec.rb} +24 -114
  42. data/spec/{notifier_spec → notice_notifier_spec}/options_spec.rb +40 -39
  43. data/spec/notice_spec.rb +2 -4
  44. data/spec/performance_notifier_spec.rb +287 -0
  45. data/spec/promise_spec.rb +0 -2
  46. data/spec/response_spec.rb +0 -2
  47. data/spec/stat_spec.rb +35 -0
  48. data/spec/sync_sender_spec.rb +0 -2
  49. data/spec/tdigest_spec.rb +230 -0
  50. data/spec/time_truncate_spec.rb +13 -0
  51. data/spec/truncator_spec.rb +0 -2
  52. metadata +34 -15
  53. data/lib/airbrake-ruby/route_sender.rb +0 -175
  54. data/spec/route_sender_spec.rb +0 -130
data/spec/notice_spec.rb CHANGED
@@ -1,5 +1,3 @@
1
- require 'spec_helper'
2
-
3
1
  RSpec.describe Airbrake::Notice do
4
2
  let(:notice) do
5
3
  described_class.new(Airbrake::Config.new, AirbrakeTestError.new, bingo: '1')
@@ -261,7 +259,7 @@ RSpec.describe Airbrake::Notice do
261
259
  it "raises error if notice is ignored" do
262
260
  notice.ignore!
263
261
  expect { notice[:params] }.
264
- to raise_error(Airbrake::Error, 'cannot access ignored notice')
262
+ to raise_error(Airbrake::Error, 'cannot access ignored Airbrake::Notice')
265
263
  end
266
264
  end
267
265
 
@@ -275,7 +273,7 @@ RSpec.describe Airbrake::Notice do
275
273
  it "raises error if notice is ignored" do
276
274
  notice.ignore!
277
275
  expect { notice[:params] = {} }.
278
- to raise_error(Airbrake::Error, 'cannot access ignored notice')
276
+ to raise_error(Airbrake::Error, 'cannot access ignored Airbrake::Notice')
279
277
  end
280
278
 
281
279
  it "raises error when trying to assign unrecognized key" do
@@ -0,0 +1,287 @@
1
+ RSpec.describe Airbrake::PerformanceNotifier do
2
+ let(:routes) { 'https://api.airbrake.io/api/v5/projects/1/routes-stats' }
3
+ let(:queries) { 'https://api.airbrake.io/api/v5/projects/1/queries-stats' }
4
+
5
+ let(:config) do
6
+ Airbrake::Config.new(
7
+ project_id: 1,
8
+ project_key: 'banana',
9
+ performance_stats: true,
10
+ performance_stats_flush_period: 0
11
+ )
12
+ end
13
+
14
+ subject { described_class.new(config) }
15
+
16
+ before do
17
+ stub_request(:put, routes).to_return(status: 200, body: '')
18
+ stub_request(:put, queries).to_return(status: 200, body: '')
19
+ end
20
+
21
+ describe "#notify" do
22
+ it "rounds time to the floor minute" do
23
+ subject.notify(
24
+ Airbrake::Request.new(
25
+ method: 'GET',
26
+ route: '/foo',
27
+ status_code: 200,
28
+ start_time: Time.new(2018, 1, 1, 0, 0, 20, 0)
29
+ )
30
+ )
31
+ expect(
32
+ a_request(:put, routes).with(body: /"time":"2018-01-01T00:00:00\+00:00"/)
33
+ ).to have_been_made
34
+ end
35
+
36
+ it "increments routes with the same key" do
37
+ subject.notify(
38
+ Airbrake::Request.new(
39
+ method: 'GET',
40
+ route: '/foo',
41
+ status_code: 200,
42
+ start_time: Time.new(2018, 1, 1, 0, 0, 20, 0)
43
+ )
44
+ )
45
+ subject.notify(
46
+ Airbrake::Request.new(
47
+ method: 'GET',
48
+ route: '/foo',
49
+ status_code: 200,
50
+ start_time: Time.new(2018, 1, 1, 0, 0, 50, 0)
51
+ )
52
+ )
53
+ expect(
54
+ a_request(:put, routes).with(body: /"count":2/)
55
+ ).to have_been_made
56
+ end
57
+
58
+ it "groups routes by time" do
59
+ subject.notify(
60
+ Airbrake::Request.new(
61
+ method: 'GET',
62
+ route: '/foo',
63
+ status_code: 200,
64
+ start_time: Time.new(2018, 1, 1, 0, 0, 49, 0),
65
+ end_time: Time.new(2018, 1, 1, 0, 0, 50, 0)
66
+ )
67
+ )
68
+ subject.notify(
69
+ Airbrake::Request.new(
70
+ method: 'GET',
71
+ route: '/foo',
72
+ status_code: 200,
73
+ start_time: Time.new(2018, 1, 1, 0, 1, 49, 0),
74
+ end_time: Time.new(2018, 1, 1, 0, 1, 55, 0)
75
+ )
76
+ )
77
+ expect(
78
+ a_request(:put, routes).with(
79
+ body: %r|\A
80
+ {"routes":\[
81
+ {"method":"GET","route":"/foo","statusCode":200,
82
+ "time":"2018-01-01T00:00:00\+00:00","count":1,"sum":1000.0,
83
+ "sumsq":1000000.0,"tdigest":"AAAAAkA0AAAAAAAAAAAAAUR6AAAB"},
84
+ {"method":"GET","route":"/foo","statusCode":200,
85
+ "time":"2018-01-01T00:01:00\+00:00","count":1,"sum":6000.0,
86
+ "sumsq":36000000.0,"tdigest":"AAAAAkA0AAAAAAAAAAAAAUW7gAAB"}\]}
87
+ \z|x
88
+ )
89
+ ).to have_been_made
90
+ end
91
+
92
+ it "groups routes by route key" do
93
+ subject.notify(
94
+ Airbrake::Request.new(
95
+ method: 'GET',
96
+ route: '/foo',
97
+ status_code: 200,
98
+ start_time: Time.new(2018, 1, 1, 0, 49, 0, 0),
99
+ end_time: Time.new(2018, 1, 1, 0, 50, 0, 0)
100
+ )
101
+ )
102
+ subject.notify(
103
+ Airbrake::Request.new(
104
+ method: 'POST',
105
+ route: '/foo',
106
+ status_code: 200,
107
+ start_time: Time.new(2018, 1, 1, 0, 49, 0, 0),
108
+ end_time: Time.new(2018, 1, 1, 0, 50, 0, 0)
109
+ )
110
+ )
111
+ expect(
112
+ a_request(:put, routes).with(
113
+ body: %r|\A
114
+ {"routes":\[
115
+ {"method":"GET","route":"/foo","statusCode":200,
116
+ "time":"2018-01-01T00:49:00\+00:00","count":1,"sum":60000.0,
117
+ "sumsq":3600000000.0,"tdigest":"AAAAAkA0AAAAAAAAAAAAAUdqYAAB"},
118
+ {"method":"POST","route":"/foo","statusCode":200,
119
+ "time":"2018-01-01T00:49:00\+00:00","count":1,"sum":60000.0,
120
+ "sumsq":3600000000.0,"tdigest":"AAAAAkA0AAAAAAAAAAAAAUdqYAAB"}\]}
121
+ \z|x
122
+ )
123
+ ).to have_been_made
124
+ end
125
+
126
+ it "returns a promise" do
127
+ promise = subject.notify(
128
+ Airbrake::Request.new(
129
+ method: 'GET',
130
+ route: '/foo',
131
+ status_code: 200,
132
+ start_time: Time.new(2018, 1, 1, 0, 49, 0, 0)
133
+ )
134
+ )
135
+ expect(promise).to be_an(Airbrake::Promise)
136
+ expect(promise.value).to eq('' => nil)
137
+ end
138
+
139
+ it "doesn't send route stats when performance stats are disabled" do
140
+ notifier = described_class.new(
141
+ Airbrake::Config.new(
142
+ project_id: 1, project_key: '2', performance_stats: false
143
+ )
144
+ )
145
+ promise = notifier.notify(
146
+ Airbrake::Request.new(
147
+ method: 'GET', route: '/foo', status_code: 200, start_time: Time.new
148
+ )
149
+ )
150
+ expect(a_request(:put, routes)).not_to have_been_made
151
+ expect(promise.value).to eq(
152
+ 'error' => "The Performance Stats feature is disabled"
153
+ )
154
+ end
155
+
156
+ it "doesn't send route stats when current environment is ignored" do
157
+ notifier = described_class.new(
158
+ Airbrake::Config.new(
159
+ project_id: 1, project_key: '2', performance_stats: true,
160
+ environment: 'test', ignore_environments: %w[test]
161
+ )
162
+ )
163
+ promise = notifier.notify(
164
+ Airbrake::Request.new(
165
+ method: 'GET', route: '/foo', status_code: 200, start_time: Time.new
166
+ )
167
+ )
168
+ expect(a_request(:put, routes)).not_to have_been_made
169
+ expect(promise.value).to eq('error' => "The 'test' environment is ignored")
170
+ end
171
+
172
+ describe "payload grouping" do
173
+ let(:flush_period) { 0.5 }
174
+
175
+ let(:config) do
176
+ Airbrake::Config.new(
177
+ project_id: 1,
178
+ project_key: 'banana',
179
+ performance_stats: true,
180
+ performance_stats_flush_period: flush_period
181
+ )
182
+ end
183
+
184
+ it "groups payload by performance name and sends it separately" do
185
+ subject.notify(
186
+ Airbrake::Request.new(
187
+ method: 'GET',
188
+ route: '/foo',
189
+ status_code: 200,
190
+ start_time: Time.new(2018, 1, 1, 0, 49, 0, 0)
191
+ )
192
+ )
193
+
194
+ subject.notify(
195
+ Airbrake::Query.new(
196
+ method: 'POST',
197
+ route: '/foo',
198
+ query: 'SELECT * FROM things',
199
+ start_time: Time.new(2018, 1, 1, 0, 49, 0, 0)
200
+ )
201
+ )
202
+
203
+ sleep(flush_period + 0.5)
204
+
205
+ expect(a_request(:put, routes)).to have_been_made
206
+ expect(a_request(:put, queries)).to have_been_made
207
+ end
208
+ end
209
+
210
+ context "when an ignore filter was defined" do
211
+ before { subject.add_filter(&:ignore!) }
212
+
213
+ it "doesn't notify airbrake of requests" do
214
+ subject.notify(
215
+ Airbrake::Request.new(
216
+ method: 'GET',
217
+ route: '/foo',
218
+ status_code: 200,
219
+ start_time: Time.new(2018, 1, 1, 0, 49, 0, 0)
220
+ )
221
+ )
222
+ expect(a_request(:put, routes)).not_to have_been_made
223
+ end
224
+
225
+ it "doesn't notify airbrake of queries" do
226
+ subject.notify(
227
+ Airbrake::Query.new(
228
+ method: 'POST',
229
+ route: '/foo',
230
+ query: 'SELECT * FROM things',
231
+ start_time: Time.new(2018, 1, 1, 0, 49, 0, 0)
232
+ )
233
+ )
234
+ expect(a_request(:put, queries)).not_to have_been_made
235
+ end
236
+ end
237
+
238
+ context "when a filter that modifies payload was defined" do
239
+ before do
240
+ subject.add_filter do |resource|
241
+ resource.route = '[Filtered]'
242
+ end
243
+ end
244
+
245
+ it "notifies airbrake with modified payload" do
246
+ subject.notify(
247
+ Airbrake::Query.new(
248
+ method: 'POST',
249
+ route: '/foo',
250
+ query: 'SELECT * FROM things',
251
+ start_time: Time.new(2018, 1, 1, 0, 49, 0, 0)
252
+ )
253
+ )
254
+ expect(
255
+ a_request(:put, queries).with(
256
+ body: /\A{"queries":\[{"method":"POST","route":"\[Filtered\]"/
257
+ )
258
+ ).to have_been_made
259
+ end
260
+ end
261
+ end
262
+
263
+ describe "#delete_filter" do
264
+ let(:filter) do
265
+ Class.new do
266
+ def call(resource)
267
+ resource.ignore!
268
+ end
269
+ end
270
+ end
271
+
272
+ before { subject.add_filter(filter.new) }
273
+
274
+ it "deletes a filter" do
275
+ subject.delete_filter(filter)
276
+ subject.notify(
277
+ Airbrake::Request.new(
278
+ method: 'POST',
279
+ route: '/foo',
280
+ status_code: 200,
281
+ start_time: Time.new(2018, 1, 1, 0, 49, 0, 0)
282
+ )
283
+ )
284
+ expect(a_request(:put, routes)).to have_been_made
285
+ end
286
+ end
287
+ end
data/spec/promise_spec.rb CHANGED
@@ -1,5 +1,3 @@
1
- require 'spec_helper'
2
-
3
1
  RSpec.describe Airbrake::Promise do
4
2
  describe ".then" do
5
3
  let(:resolved_with) { [] }
@@ -1,5 +1,3 @@
1
- require 'spec_helper'
2
-
3
1
  RSpec.describe Airbrake::Response do
4
2
  describe ".parse" do
5
3
  let(:out) { StringIO.new }
data/spec/stat_spec.rb ADDED
@@ -0,0 +1,35 @@
1
+ RSpec.describe Airbrake::Stat do
2
+ describe "#to_h" do
3
+ it "converts to a hash" do
4
+ expect(subject.to_h).to eq(
5
+ 'count' => 0,
6
+ 'sum' => 0.0,
7
+ 'sumsq' => 0.0,
8
+ 'tdigest' => 'AAAAAkA0AAAAAAAAAAAAAA=='
9
+ )
10
+ end
11
+ end
12
+
13
+ describe "#increment" do
14
+ let(:start_time) { Time.new(2018, 1, 1, 0, 0, 20, 0) }
15
+ let(:end_time) { Time.new(2018, 1, 1, 0, 0, 21, 0) }
16
+
17
+ before { subject.increment(start_time, end_time) }
18
+
19
+ it "increments count" do
20
+ expect(subject.count).to eq(1)
21
+ end
22
+
23
+ it "updates sum" do
24
+ expect(subject.sum).to eq(1000)
25
+ end
26
+
27
+ it "updates sumsq" do
28
+ expect(subject.sumsq).to eq(1000000)
29
+ end
30
+
31
+ it "updates tdigest" do
32
+ expect(subject.tdigest.size).to eq(1)
33
+ end
34
+ end
35
+ end
@@ -1,5 +1,3 @@
1
- require 'spec_helper'
2
-
3
1
  RSpec.describe Airbrake::SyncSender do
4
2
  describe "#build_https" do
5
3
  it "overrides Net::HTTP's open_timeout and read_timeout if timeout is specified" do
@@ -0,0 +1,230 @@
1
+ RSpec.describe Airbrake::TDigest do
2
+ describe "byte serialization" do
3
+ it "loads serialized data" do
4
+ subject.push(60, 100)
5
+ 10.times { subject.push(rand * 100) }
6
+ bytes = subject.as_bytes
7
+ new_tdigest = described_class.from_bytes(bytes)
8
+ expect(new_tdigest.percentile(0.9)).to eq(subject.percentile(0.9))
9
+ expect(new_tdigest.as_bytes).to eq(bytes)
10
+ end
11
+
12
+ it "handles zero size" do
13
+ bytes = subject.as_bytes
14
+ expect(described_class.from_bytes(bytes).size).to be_zero
15
+ end
16
+
17
+ it "preserves compression" do
18
+ td = described_class.new(0.001)
19
+ bytes = td.as_bytes
20
+ new_tdigest = described_class.from_bytes(bytes)
21
+ expect(new_tdigest.compression).to eq(td.compression)
22
+ end
23
+ end
24
+
25
+ describe "small byte serialization" do
26
+ it "loads serialized data" do
27
+ 10.times { subject.push(10) }
28
+ bytes = subject.as_small_bytes
29
+ new_tdigest = described_class.from_bytes(bytes)
30
+ # Expect some rounding error due to compression
31
+ expect(new_tdigest.percentile(0.9).round(5)).to eq(
32
+ subject.percentile(0.9).round(5)
33
+ )
34
+ expect(new_tdigest.as_small_bytes).to eq(bytes)
35
+ end
36
+
37
+ it "handles zero size" do
38
+ bytes = subject.as_small_bytes
39
+ expect(described_class.from_bytes(bytes).size).to be_zero
40
+ end
41
+ end
42
+
43
+ describe "JSON serialization" do
44
+ it "loads serialized data" do
45
+ subject.push(60, 100)
46
+ json = subject.as_json
47
+ new_tdigest = described_class.from_json(json)
48
+ expect(new_tdigest.percentile(0.9)).to eq(subject.percentile(0.9))
49
+ end
50
+ end
51
+
52
+ describe "#percentile" do
53
+ it "returns nil if empty" do
54
+ expect(subject.percentile(0.90)).to be_nil # This should not crash
55
+ end
56
+
57
+ it "raises ArgumentError of input not between 0 and 1" do
58
+ expect { subject.percentile(1.1) }.to raise_error(ArgumentError)
59
+ end
60
+
61
+ describe "with only single value" do
62
+ it "returns the value" do
63
+ subject.push(60, 100)
64
+ expect(subject.percentile(0.90)).to eq(60)
65
+ end
66
+
67
+ it "returns 0 for all percentiles when only 0 present" do
68
+ subject.push(0)
69
+ expect(subject.percentile([0.0, 0.5, 1.0])).to eq([0, 0, 0])
70
+ end
71
+ end
72
+
73
+ describe "with alot of uniformly distributed points" do
74
+ it "has minimal error" do
75
+ seed = srand(1234) # Makes the values a proper fixture
76
+ N = 100_000
77
+ maxerr = 0
78
+ values = Array.new(N).map { rand }
79
+ srand(seed)
80
+
81
+ subject.push(values)
82
+ subject.compress!
83
+
84
+ 0.step(1, 0.1).each do |i|
85
+ q = subject.percentile(i)
86
+ maxerr = [maxerr, (i - q).abs].max
87
+ end
88
+
89
+ expect(maxerr).to be < 0.01
90
+ end
91
+ end
92
+ end
93
+
94
+ describe "#push" do
95
+ it "calls _cumulate so won't crash because of uninitialized mean_cumn" do
96
+ subject.push(
97
+ [
98
+ 125000000.0,
99
+ 104166666.66666666,
100
+ 135416666.66666666,
101
+ 104166666.66666666,
102
+ 104166666.66666666,
103
+ 93750000.0,
104
+ 125000000.0,
105
+ 62500000.0,
106
+ 114583333.33333333,
107
+ 156250000.0,
108
+ 124909090.90909092,
109
+ 104090909.0909091,
110
+ 135318181.81818184,
111
+ 104090909.0909091,
112
+ 104090909.0909091,
113
+ 93681818.18181819,
114
+ 124909090.90909092,
115
+ 62454545.45454546,
116
+ 114500000.00000001,
117
+ 156136363.63636366,
118
+ 123567567.56756756,
119
+ 102972972.97297296,
120
+ 133864864.86486486,
121
+ 102972972.97297296,
122
+ 102972972.97297296,
123
+ 92675675.67567568,
124
+ 123567567.56756756,
125
+ 61783783.78378378,
126
+ 113270270.27027026,
127
+ 154459459.45945945,
128
+ 123829787.23404256,
129
+ 103191489.36170213
130
+ ]
131
+ )
132
+ end
133
+
134
+ it "does not blow up if data comes in sorted" do
135
+ subject.push(0..10_000)
136
+ expect(subject.centroids.size).to be < 5_000
137
+ subject.compress!
138
+ expect(subject.centroids.size).to be < 1_000
139
+ end
140
+ end
141
+
142
+ describe "#size" do
143
+ it "reports the number of observations" do
144
+ n = 10_000
145
+ n.times { subject.push(rand) }
146
+ subject.compress!
147
+ expect(subject.size).to eq(n)
148
+ end
149
+ end
150
+
151
+ describe "#+" do
152
+ it "works with empty tdigests" do
153
+ other = described_class.new(0.001, 50, 1.2)
154
+ expect((subject + other).centroids.size).to eq(0)
155
+ end
156
+
157
+ describe "adding two tdigests" do
158
+ before do
159
+ @other = described_class.new(0.001, 50, 1.2)
160
+ [subject, @other].each do |td|
161
+ td.push(60, 100)
162
+ 10.times { td.push(rand * 100) }
163
+ end
164
+ end
165
+
166
+ it "has the parameters of the left argument (the calling tdigest)" do
167
+ new_tdigest = subject + @other
168
+ expect(new_tdigest.instance_variable_get(:@delta)).to eq(
169
+ subject.instance_variable_get(:@delta)
170
+ )
171
+ expect(new_tdigest.instance_variable_get(:@k)).to eq(
172
+ subject.instance_variable_get(:@k)
173
+ )
174
+ expect(new_tdigest.instance_variable_get(:@cx)).to eq(
175
+ subject.instance_variable_get(:@cx)
176
+ )
177
+ end
178
+
179
+ it "returns a tdigest with less than or equal centroids" do
180
+ new_tdigest = subject + @other
181
+ expect(new_tdigest.centroids.size).
182
+ to be <= subject.centroids.size + @other.centroids.size
183
+ end
184
+
185
+ it "has the size of the two digests combined" do
186
+ new_tdigest = subject + @other
187
+ expect(new_tdigest.size).to eq(subject.size + @other.size)
188
+ end
189
+ end
190
+ end
191
+
192
+ describe "#merge!" do
193
+ it "works with empty tdigests" do
194
+ other = described_class.new(0.001, 50, 1.2)
195
+ subject.merge!(other)
196
+ expect(subject.centroids.size).to be_zero
197
+ end
198
+
199
+ describe "with populated tdigests" do
200
+ before do
201
+ @other = described_class.new(0.001, 50, 1.2)
202
+ [subject, @other].each do |td|
203
+ td.push(60, 100)
204
+ 10.times { td.push(rand * 100) }
205
+ end
206
+ end
207
+
208
+ it "has the parameters of the calling tdigest" do
209
+ vars = %i[@delta @k @cx]
210
+ expected = Hash[vars.map { |v| [v, subject.instance_variable_get(v)] }]
211
+ subject.merge!(@other)
212
+ vars.each do |v|
213
+ expect(subject.instance_variable_get(v)).to eq(expected[v])
214
+ end
215
+ end
216
+
217
+ it "returns a tdigest with less than or equal centroids" do
218
+ combined_size = subject.centroids.size + @other.centroids.size
219
+ subject.merge!(@other)
220
+ expect(subject.centroids.size).to be <= combined_size
221
+ end
222
+
223
+ it "has the size of the two digests combined" do
224
+ combined_size = subject.size + @other.size
225
+ subject.merge!(@other)
226
+ expect(subject.size).to eq(combined_size)
227
+ end
228
+ end
229
+ end
230
+ end