airbrake-ruby 3.1.0 → 3.2.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.
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