haveapi 0.30.0 → 0.30.2

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/Gemfile +1 -0
  4. data/benchmarks/documentation.rb +277 -0
  5. data/benchmarks/endpoints.rb +730 -0
  6. data/benchmarks/performance_budgets.json +1354 -0
  7. data/benchmarks/performance_compare.rb +579 -0
  8. data/doc/client-examples.md +233 -24
  9. data/doc/performance.md +272 -0
  10. data/haveapi.gemspec +1 -1
  11. data/lib/haveapi/documentation_clients.rb +158 -35
  12. data/lib/haveapi/documentation_context.rb +42 -12
  13. data/lib/haveapi/documentation_schema.rb +73 -0
  14. data/lib/haveapi/server.rb +5 -0
  15. data/lib/haveapi/version.rb +1 -1
  16. data/lib/haveapi/views/version_page.erb +2 -2
  17. data/spec/client_examples/context_performance_spec.rb +72 -0
  18. data/spec/client_examples/context_spec.rb +206 -0
  19. data/spec/client_examples/dynamic_js_spec.rb +1 -0
  20. data/spec/client_examples/profile_mount_spec.rb +215 -0
  21. data/spec/client_examples/profiles_spec.rb +202 -32
  22. data/spec/client_examples/structural_schema_spec.rb +48 -0
  23. data/spec/documentation/client_examples_spec.rb +10 -0
  24. data/spec/documentation/performance_spec.rb +79 -0
  25. data/spec/performance/comparator_spec.rb +342 -0
  26. data/spec/performance/endpoint_contract_spec.rb +226 -0
  27. data/spec/performance/endpoint_scaling_spec.rb +82 -0
  28. data/spec/performance/event_baseline_spec.rb +279 -0
  29. data/spec/performance/json_activation_spec.rb +151 -0
  30. data/spec/performance/release_lineage_spec.rb +431 -0
  31. data/spec/performance/runner_spec.rb +476 -0
  32. data/spec/performance/sqlite_spec.rb +194 -0
  33. data/spec/performance/support/live_worker.rb +44 -0
  34. data/spec/performance/support/samples.rb +72 -0
  35. data/test_support/documentation_performance_fixture.rb +106 -0
  36. data/test_support/performance_endpoint_fixture.rb +350 -0
  37. data/test_support/performance_sqlite_fixture.rb +384 -0
  38. metadata +26 -3
@@ -1,5 +1,6 @@
1
1
  require 'json'
2
2
  require 'haveapi/documentation_context'
3
+ require 'haveapi/documentation_schema'
3
4
 
4
5
  module HaveAPI
5
6
  class ClientExample
@@ -9,7 +10,7 @@ module HaveAPI
9
10
  def initialize(language:, package_name:, package_version:, adapter:)
10
11
  @language = language
11
12
  @package_name = package_name.dup.freeze
12
- @package_version = package_version.dup.freeze
13
+ @package_version = package_version&.dup&.freeze
13
14
  @adapter = adapter.freeze
14
15
  freeze
15
16
  end
@@ -20,64 +21,116 @@ module HaveAPI
20
21
 
21
22
  KEYS = {
22
23
  go: %i[import_path module_version description_path generator_version],
23
- rust: %i[snapshot_path generator_config_path generator_version]
24
+ rust: %i[crate_name crate_version source snapshot_path generator_config_path generator_version]
24
25
  }.freeze
25
26
 
26
27
  def initialize
27
- @versions = {}
28
- @rust_versions = {}
28
+ @registrations = {}
29
+ @profiles = {}.freeze
29
30
  end
30
31
 
31
32
  def register(version, **languages)
32
- raise Invalid, 'Documentation client profiles are already mounted' if @mounted
33
+ raise Invalid, 'Documentation client profiles are already mounted' if @closed
33
34
  raise Invalid, 'Unknown documentation client language' unless (languages.keys - KEYS.keys).empty?
34
35
 
35
- selected = @versions[version.to_s] ||= {}
36
+ selected = @registrations.fetch(version.to_s, {})
37
+ additions = {}
36
38
  languages.each do |language, config|
37
39
  next if config.nil?
38
40
  raise Invalid, 'Duplicate documentation client profile' if selected.has_key?(language)
39
41
 
40
- selected[language] = load_profile(language, config, version.to_s)
42
+ additions[language] = load_profile(language, config)
41
43
  end
44
+ @registrations[version.to_s] = selected.merge(additions)
42
45
  end
43
46
 
44
47
  def mount(versions)
45
- unknown = @versions.keys - Array(versions).map(&:to_s)
48
+ raise Invalid, 'Documentation client profiles are already mounted' if @closed
49
+
50
+ @closed = true
51
+ unknown = @registrations.keys - Array(versions).map(&:to_s)
46
52
  raise Invalid, 'Documentation client profile selects an unavailable API version' unless unknown.empty?
47
- unless @rust_versions.all? { |version, saved| version == saved }
48
- raise Invalid, 'Documentation Rust snapshot API version mismatch'
53
+
54
+ @registrations.each do |version, languages|
55
+ snapshot = languages.dig(:rust, :snapshot)
56
+ if snapshot && snapshot.fetch('api_version').to_s != version
57
+ raise Invalid, 'Documentation Rust snapshot API version mismatch'
58
+ end
49
59
  end
60
+ end
50
61
 
51
- @versions.each_value(&:freeze)
52
- @versions.freeze
53
- @mounted = true
62
+ # Publish only after every adapter succeeds, using the complete mounted DSL.
63
+ def finalize(server)
64
+ raise Invalid, 'Documentation client profiles are not ready to finalize' unless @closed && !@finalized
65
+
66
+ completed = @registrations.to_h do |version, languages|
67
+ description = if languages.any? { |_language, config| !config[:profile] }
68
+ safely do
69
+ context = HaveAPI::Context.new(
70
+ server, version: server.versions.find { |v| v.to_s == version }, user: nil, endpoint: false, doc: true
71
+ )
72
+ StructuralSchema.copy(server.describe_version(context))
73
+ end
74
+ end
75
+ profiles = languages.to_h do |language, config|
76
+ profile = config[:profile] || safely { build_profile(language, config, description, server, version) }
77
+ [language, profile]
78
+ end
79
+ [version, profiles.freeze]
80
+ end
81
+ @profiles = completed.freeze
82
+ @registrations = nil
83
+ @finalized = true
84
+ rescue StandardError
85
+ abort_mount
86
+ raise
87
+ end
88
+
89
+ def abort_mount
90
+ @profiles = {}.freeze
91
+ @registrations = nil
92
+ @closed = true
93
+ @finalized = true
54
94
  end
55
95
 
56
96
  def for_version(version)
57
- @versions.fetch(version.to_s, {}).dup.freeze
97
+ @profiles.fetch(version.to_s, {}).dup.freeze
58
98
  end
59
99
 
60
100
  private
61
101
 
62
102
  def validate_config(language, config)
63
- unless config.is_a?(Hash) && config.keys.sort == KEYS.fetch(language).sort &&
64
- config.values.all? { |value| value.is_a?(String) && !value.strip.empty? }
103
+ unless config.is_a?(Hash) && (config.keys - KEYS.fetch(language)).empty? &&
104
+ config.all? { |key, value| %i[source module_version crate_version].include?(key) || nonempty_string?(value) }
65
105
  raise Invalid, 'Invalid documentation client profile fields'
66
106
  end
67
- return if config.fetch(:generator_version).match?(/\A\d+\.\d+\.\d+(?:[-+][\w.-]+)?\z/)
107
+
108
+ config = config.dup
109
+ config[:generator_version] = config.fetch(:generator_version, HaveAPI::VERSION)
110
+ return config if config[:generator_version].match?(/\A\d+\.\d+\.\d+(?:[-+][\w.-]+)?\z/)
68
111
 
69
112
  raise Invalid, 'Invalid documentation generator version'
70
113
  end
71
114
 
115
+ def nonempty_string?(value)
116
+ value.is_a?(String) && !value.strip.empty?
117
+ end
118
+
72
119
  def local_file(path)
73
120
  raise Invalid, 'Documentation profiles require absolute local files' unless path.start_with?('/') && File.file?(path)
74
121
 
75
122
  path
76
123
  end
77
124
 
78
- def load_profile(language, config, version)
79
- validate_config(language, config)
80
- language == :go ? load_go(config) : load_rust(config, version)
125
+ def load_profile(language, config)
126
+ safely do
127
+ config = validate_config(language, config)
128
+ language == :go ? load_go(config) : load_rust(config)
129
+ end
130
+ end
131
+
132
+ def safely
133
+ yield
81
134
  rescue LoadError
82
135
  raise Invalid, 'Configured documentation client generator is not installed'
83
136
  rescue JSON::ParserError, SystemCallError
@@ -89,11 +142,11 @@ module HaveAPI
89
142
  end
90
143
 
91
144
  def load_go(config)
92
- name = config.fetch(:import_path)
93
- version = config.fetch(:module_version)
94
- unless name.match?(%r{\A[a-zA-Z0-9][a-zA-Z0-9._~/-]*\z}) &&
145
+ name = config[:import_path]
146
+ version = config[:module_version]
147
+ unless nonempty_string?(name) && name.match?(%r{\A[a-zA-Z0-9][a-zA-Z0-9._~/-]*\z}) &&
95
148
  name.split('/').none? { |part| part.empty? || %w[. ..].include?(part) } &&
96
- version.match?(/\Av\d+\.\d+\.\d+(?:[-+][\w.-]+)?\z/)
149
+ (version.nil? || (version.is_a?(String) && version.match?(/\Av\d+\.\d+\.\d+(?:[-+][\w.-]+)?\z/)))
97
150
  raise Invalid, 'Invalid Go documentation client package'
98
151
  end
99
152
 
@@ -102,24 +155,94 @@ module HaveAPI
102
155
  raise Invalid, 'Documentation Go generator version mismatch'
103
156
  end
104
157
 
105
- description = JSON.parse(File.read(local_file(config.fetch(:description_path))))
106
- adapter = HaveAPI::GoClient::Documentation.new(description:, import_path: name, module_version: version)
107
- Profile.new(language: :go, package_name: name, package_version: version, adapter:)
158
+ prepared = { name: name.dup.freeze, version: version&.dup&.freeze }.freeze
159
+ return prepared unless config.has_key?(:description_path)
160
+
161
+ description = JSON.parse(File.read(local_file(config[:description_path])))
162
+ { profile: go_profile(prepared, description) }.freeze
163
+ end
164
+
165
+ def go_profile(config, description)
166
+ adapter = HaveAPI::GoClient::Documentation.new(description:, import_path: config[:name], module_version: config[:version])
167
+ profile(:go, config, adapter)
108
168
  end
109
169
 
110
- def load_rust(config, api_version)
170
+ def load_rust(config)
111
171
  require 'haveapi/rust_client/documentation'
112
172
  unless config.fetch(:generator_version) == HaveAPI::RustClient::VERSION
113
173
  raise Invalid, 'Documentation Rust generator version mismatch'
114
174
  end
115
175
 
116
- snapshot = JSON.parse(File.read(local_file(config.fetch(:snapshot_path))))
117
- @rust_versions[api_version] = snapshot.fetch('api_version').to_s
118
- generator_config = Tomlrb.load_file(local_file(config.fetch(:generator_config_path)))
119
- name = generator_config.fetch('crate_name')
120
- version = generator_config.fetch('crate_version')
121
- adapter = HaveAPI::RustClient::Documentation.new(snapshot:, config: generator_config)
122
- Profile.new(language: :rust, package_name: name, package_version: version, adapter:)
176
+ legacy = config.has_key?(:generator_config_path)
177
+ if legacy && (config.has_key?(:crate_name) || config.has_key?(:crate_version))
178
+ raise Invalid, 'Conflicting Rust documentation client identities'
179
+ end
180
+
181
+ documentation = HaveAPI::RustClient::Documentation
182
+ snapshot = JSON.parse(File.read(local_file(config[:snapshot_path]))) if config.has_key?(:snapshot_path)
183
+ if (!legacy || !snapshot) && !(documentation.respond_to?(:from_description) && documentation.respond_to?(:from_snapshot))
184
+ raise Invalid, 'Documentation Rust generator does not support automatic or inline profiles'
185
+ end
186
+
187
+ source = if config.has_key?(:source)
188
+ raise Invalid, 'Documentation Rust generator does not support source metadata' unless documentation.respond_to?(:validate_source)
189
+
190
+ documentation.validate_source(config[:source])
191
+ end
192
+ generator_config = Tomlrb.load_file(local_file(config[:generator_config_path])) if legacy
193
+ if legacy
194
+ HaveAPI::RustClient::Generator.validate_config(generator_config) unless snapshot
195
+ name = generator_config.fetch('crate_name')
196
+ version = generator_config.fetch('crate_version')
197
+ else
198
+ name = config[:crate_name]
199
+ version = config[:crate_version]
200
+ HaveAPI::RustClient::Model.validate_package(crate_name: name, crate_version: version)
201
+ end
202
+ prepared = { name: name.dup.freeze, version: version&.dup&.freeze,
203
+ snapshot:, generator_config:, source: }.freeze
204
+ return prepared unless snapshot
205
+
206
+ prepared.merge(profile: rust_profile(prepared, snapshot:)).freeze
207
+ end
208
+
209
+ def build_profile(language, config, description, server, version)
210
+ return go_profile(config, description) if language == :go
211
+
212
+ arguments = { description:, api_version: version, protocol_version: HaveAPI::PROTOCOL_VERSION,
213
+ mount_path: server.root }
214
+ if config[:generator_config]
215
+ snapshot = HaveAPI::RustClient::Snapshot.from_description(
216
+ **arguments, source_url: "https://haveapi.invalid#{server.root}v#{version}/", profile: 'server-documentation'
217
+ )
218
+ return rust_profile(config, snapshot:)
219
+ end
220
+
221
+ adapter = HaveAPI::RustClient::Documentation.from_description(
222
+ **arguments, crate_name: config[:name], crate_version: config[:version], source: config[:source]
223
+ )
224
+ profile(:rust, config, adapter)
225
+ end
226
+
227
+ def rust_profile(config, snapshot:)
228
+ adapter = if config[:generator_config]
229
+ arguments = { snapshot:, config: config[:generator_config] }
230
+ arguments[:source] = config[:source] if config[:source]
231
+ HaveAPI::RustClient::Documentation.new(**arguments)
232
+ else
233
+ HaveAPI::RustClient::Documentation.from_snapshot(
234
+ snapshot:, crate_name: config[:name], crate_version: config[:version], source: config[:source]
235
+ )
236
+ end
237
+ profile(:rust, config, adapter)
238
+ end
239
+
240
+ def profile(language, config, adapter)
241
+ unless adapter.respond_to?(:resolve) && adapter.respond_to?(:render)
242
+ raise Invalid, 'Documentation client generator has an incompatible adapter interface'
243
+ end
244
+
245
+ Profile.new(language:, package_name: config[:name], package_version: config[:version], adapter:)
123
246
  end
124
247
  end
125
248
  end
@@ -10,26 +10,56 @@ module HaveAPI
10
10
  def initialize(version:, host:, base_url:, version_url:, description:,
11
11
  resource_path: [], resource: nil, action_name: nil, action: nil,
12
12
  sample: nil, profile: nil)
13
- @version = version.to_s.dup.freeze
14
- @host = host.to_s.dup.freeze
15
- @base_url = base_url.to_s.dup.freeze
16
- @version_url = version_url.to_s.dup.freeze
17
- @description = copy(description)
18
- @resource_path = copy(resource_path.map(&:to_s))
19
- @resource = copy(resource)
20
- @action_name = action_name.nil? ? nil : action_name.to_s.dup.freeze
21
- @action = copy(action)
22
- @sample = copy(sample)
23
- @profile = profile
13
+ @version = normalize(:version, version)
14
+ @host = normalize(:host, host)
15
+ @base_url = normalize(:base_url, base_url)
16
+ @version_url = normalize(:version_url, version_url)
17
+ @description = normalize(:description, description)
18
+ @resource_path = normalize(:resource_path, resource_path)
19
+ @resource = normalize(:resource, resource)
20
+ @action_name = normalize(:action_name, action_name)
21
+ @action = normalize(:action, action)
22
+ @sample = normalize(:sample, sample)
23
+ @profile = normalize(:profile, profile)
24
24
  freeze
25
25
  end
26
26
 
27
27
  def with(**changes)
28
- self.class.new(**FIELDS.to_h { |field| [field, public_send(field)] }, **changes)
28
+ unless instance_of?(Context)
29
+ return self.class.new(**FIELDS.to_h { |field| [field, public_send(field)] }, **changes)
30
+ end
31
+
32
+ unknown = changes.keys - FIELDS
33
+ unless unknown.empty?
34
+ raise ArgumentError, "unknown keyword#{'s' if unknown.length > 1}: #{unknown.map(&:inspect).join(', ')}"
35
+ end
36
+
37
+ # Unchanged fields are already owned snapshots; caller-supplied values
38
+ # must still cross the same ownership boundary as initial construction.
39
+ derived = dup
40
+ changes.each do |field, value|
41
+ derived.instance_variable_set("@#{field}", normalize(field, value))
42
+ end
43
+ derived.freeze
29
44
  end
30
45
 
31
46
  private
32
47
 
48
+ def normalize(field, value)
49
+ case field
50
+ when :version, :host, :base_url, :version_url
51
+ value.to_s.dup.freeze
52
+ when :resource_path
53
+ copy(value.map(&:to_s))
54
+ when :action_name
55
+ value.nil? ? nil : value.to_s.dup.freeze
56
+ when :profile
57
+ value
58
+ else
59
+ copy(value)
60
+ end
61
+ end
62
+
33
63
  def copy(value)
34
64
  case value
35
65
  when Hash
@@ -0,0 +1,73 @@
1
+ require 'json'
2
+
3
+ module HaveAPI
4
+ class ClientExample
5
+ # Private naming/type input, never a request's authorized help or samples.
6
+ module StructuralSchema
7
+ BUILTIN_VALIDATORS = %w[present number include exclude format length confirm accept].freeze
8
+
9
+ def self.copy(description)
10
+ result = JSON.parse(JSON.generate(description))
11
+ resources(result['resources'])
12
+ result.fetch('authentication', {}).each_value do |provider|
13
+ prose(provider)
14
+ resources(provider['resources'])
15
+ end
16
+ freeze_data(result)
17
+ end
18
+
19
+ def self.resources(map)
20
+ return unless map
21
+
22
+ map.each_value do |resource|
23
+ prose(resource)
24
+ resource.fetch('actions', {}).each_value do |action|
25
+ prose(action)
26
+ action.delete('examples')
27
+ parameters(action['input'])
28
+ parameters(action['output'])
29
+ action.fetch('meta', nil)&.each_value do |metadata|
30
+ next unless metadata
31
+
32
+ parameters(metadata['input'])
33
+ parameters(metadata['output'])
34
+ end
35
+ end
36
+ resources(resource['resources'])
37
+ end
38
+ end
39
+
40
+ def self.parameters(group)
41
+ return unless group
42
+
43
+ group.fetch('parameters', {}).each_value do |parameter|
44
+ prose(parameter)
45
+ parameter.delete('default')
46
+ parameter.fetch('validators', {}).each do |name, validator|
47
+ prose(validator) if BUILTIN_VALIDATORS.include?(name) && validator.is_a?(Hash)
48
+ end
49
+ end
50
+ end
51
+
52
+ def self.prose(object)
53
+ %w[label description message].each { |key| object.delete(key) }
54
+ end
55
+
56
+ def self.freeze_data(value)
57
+ case value
58
+ when Hash
59
+ value.each do |key, item|
60
+ freeze_data(key)
61
+ freeze_data(item)
62
+ end
63
+ when Array
64
+ value.each { |item| freeze_data(item) }
65
+ end
66
+ value.freeze
67
+ end
68
+
69
+ private_class_method :resources, :parameters, :prose, :freeze_data
70
+ end
71
+ private_constant :StructuralSchema
72
+ end
73
+ end
@@ -622,6 +622,11 @@ module HaveAPI
622
622
  end
623
623
 
624
624
  call_hooks_for(:post_mount, args: [self, @sinatra])
625
+ @documentation_clients.finalize(self)
626
+ rescue StandardError
627
+ @documentation_clients.abort_mount
628
+ @sinatra = nil
629
+ raise
625
630
  end
626
631
 
627
632
  def mount_version(prefix, v)
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.30.0'.freeze
3
+ VERSION = '0.30.2'.freeze
4
4
  end
@@ -20,7 +20,7 @@
20
20
  <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/js" target="_blank" rel="noopener">JavaScript and TypeScript (callback and async)</a></li>
21
21
  <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/python" target="_blank" rel="noopener">Python (sync and async)</a></li>
22
22
  <% settings.api_server.documentation_client_profiles(@v).each do |language, profile| %>
23
- <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/<%= escape_html(language) %>" target="_blank" rel="noopener"><%= language == :go ? 'Go' : 'Rust (async and blocking)' %></a>: <code><%= escape_html(profile.package_name) %></code> <%= escape_html(profile.package_version) %></li>
23
+ <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/<%= escape_html(language) %>" target="_blank" rel="noopener"><%= language == :go ? 'Go' : 'Rust (async and blocking)' %></a>: <code><%= escape_html(profile.package_name) %></code><% if profile.package_version %> <%= escape_html(profile.package_version) %><% end %></li>
24
24
  <% end %>
25
25
  <li><a href="https://github.com/vpsfreecz/haveapi/tree/master/clients/php" target="_blank" rel="noopener">PHP</a></li>
26
26
  <li>
@@ -32,7 +32,7 @@
32
32
  <p>
33
33
  These examples target API v<%= escape_html(@v) %>. The framework and dynamic
34
34
  clients share HaveAPI package versions. Generated Go and Rust SDKs use their
35
- own package versions and a saved description of the API.
35
+ own package versions. Their configured SDKs are expected to match this API.
36
36
  </p>
37
37
 
38
38
  <h2>Initialization</h2>
@@ -0,0 +1,72 @@
1
+ require 'spec_helper'
2
+
3
+ describe HaveAPI::ClientExample::Context, '#with' do
4
+ let(:sample) { { request: { payload: { 'items' => [0, false, '', nil] } } } }
5
+ let(:profile) { Object.new.freeze }
6
+
7
+ def parent_context(resource_count)
8
+ action = { input: { parameters: { payload: { type: 'Custom' } } }, examples: [sample] }
9
+ description = {
10
+ resources: resource_count.times.to_h do |index|
11
+ ["resource#{index}", { description: "Resource #{index}", actions: { 'show' => action } }]
12
+ end
13
+ }
14
+ described_class.new(version: '1', host: 'example.test', base_url: 'http://example.test/benchmark/api/',
15
+ version_url: 'http://example.test/benchmark/api/v1/', description:,
16
+ resource_path: ['resource0'], resource: description[:resources]['resource0'],
17
+ action_name: 'show', action:, sample:)
18
+ end
19
+
20
+ def allocated_derivations(parent, changes, count: 40)
21
+ # Taking the minimum of repeated batches avoids incidental VM bookkeeping
22
+ # spikes. Expectations and fixture construction stay outside this loop.
23
+ Array.new(3) do
24
+ before = GC.stat(:total_allocated_objects)
25
+ count.times { parent.with(**changes) }
26
+ GC.stat(:total_allocated_objects) - before
27
+ end.min
28
+ end
29
+
30
+ it 'shares every unchanged field and owns a different snapshot for a new request' do
31
+ parent = parent_context(8)
32
+ other_request = parent_context(8)
33
+ children = [parent.with(profile:), parent.with(sample:)]
34
+ fields = %i[version host base_url version_url description resource_path resource action_name action sample profile]
35
+
36
+ children.each_with_index do |child, index|
37
+ changed = index == 0 ? :profile : :sample
38
+ (fields - [changed]).each do |field|
39
+ expect(child.public_send(field)).to equal(parent.public_send(field))
40
+ end
41
+ expect(child).to be_frozen
42
+ end
43
+ expect(other_request.description).to eq(parent.description)
44
+ expect(other_request.description).not_to equal(parent.description)
45
+ expect(other_request.description[:resources]).not_to equal(parent.description[:resources])
46
+ replacement = parent.with(description: parent.description)
47
+ expect(replacement.description).to eq(parent.description)
48
+ expect(replacement.description).not_to equal(parent.description)
49
+ end
50
+
51
+ it 'keeps allocations independent of unrelated description size for small changes' do
52
+ small = parent_context(8)
53
+ large = parent_context(128)
54
+ changes = [{ profile: }, { sample: }]
55
+ [small, large].each do |parent|
56
+ changes.each { |change| 80.times { parent.with(**change) } }
57
+ end
58
+
59
+ changes.each do |change|
60
+ small_allocations = allocated_derivations(small, change)
61
+ large_allocations = allocated_derivations(large, change)
62
+ # CRuby 3.4 allocates 200/200 objects for profile changes and 560/560 for
63
+ # samples at these sizes. The original constructor path grows from 12,560
64
+ # to 151,760. Allow 32 extra objects per derivation for VM bookkeeping;
65
+ # identity assertions independently forbid whole-description copies.
66
+ expect(large_allocations).to be <= small_allocations + (40 * 32)
67
+ [small, large].each do |parent|
68
+ expect(parent.with(**change).description).to equal(parent.description)
69
+ end
70
+ end
71
+ end
72
+ end