haveapi 0.30.1 → 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.
@@ -25,6 +25,212 @@ describe HaveAPI::ClientExample::Context do
25
25
  expect(child).to be_frozen
26
26
  end
27
27
 
28
+ describe '#with' do
29
+ let(:fields) do
30
+ {
31
+ version: 1, host: 'api.test', base_url: 'https://api.test/mount/',
32
+ version_url: 'https://api.test/mount/v1/',
33
+ description: { 'resources' => { 'widget' => { 'label' => 'Widget' } } },
34
+ resource_path: [:widget, 2], resource: { 'label' => 'Widget' },
35
+ action_name: :show, action: { 'method' => 'GET' },
36
+ sample: { 'request' => { 'values' => [0, false, '', nil] } }, profile: Object.new
37
+ }
38
+ end
39
+ let(:parent) { described_class.new(**fields) }
40
+ let(:replacements) do
41
+ {
42
+ version: 2, host: 'other.test', base_url: 'https://other.test/api/',
43
+ version_url: 'https://other.test/api/v2/',
44
+ description: { 'resources' => { 'other' => { 'label' => 'Other' } } },
45
+ resource_path: [:other, 3], resource: { 'label' => 'Other' },
46
+ action_name: :update, action: { 'method' => 'POST' },
47
+ sample: { 'request' => { 'values' => [false, 0, nil, ''] } },
48
+ profile: Object.new
49
+ }
50
+ end
51
+
52
+ it 'returns a distinct frozen context sharing every unchanged field' do
53
+ child = parent.with
54
+
55
+ expect(child).not_to equal(parent)
56
+ expect(child).to be_frozen
57
+ described_class::FIELDS.each do |field|
58
+ expect(child.public_send(field)).to equal(parent.public_send(field))
59
+ end
60
+ expect(child.description['resources']['widget']).to equal(parent.description['resources']['widget'])
61
+ end
62
+
63
+ HaveAPI::ClientExample::Context::FIELDS.each do |field|
64
+ it "normalizes an explicit #{field} replacement like the constructor and shares other fields" do
65
+ changed = { field => replacements.fetch(field) }
66
+ child = parent.with(**changed)
67
+ constructor = described_class.new(**fields, **changed)
68
+
69
+ expect(child).to be_frozen
70
+ expect(child.public_send(field)).to eq(constructor.public_send(field))
71
+ (described_class::FIELDS - [field]).each do |unchanged|
72
+ expect(child.public_send(unchanged)).to equal(parent.public_send(unchanged))
73
+ end
74
+ expect(parent.public_send(field)).to eq(described_class.new(**fields).public_send(field))
75
+ end
76
+ end
77
+
78
+ it 'copies supplied nested containers and strings even when their outer hash is frozen' do
79
+ key = ['mutable key']
80
+ source = { key => [{ 'value' => 'safe' }] }.freeze
81
+ child = parent.with(description: source, resource: source, action: source, sample: source)
82
+
83
+ %i[description resource action sample].each do |field|
84
+ owned = child.public_send(field)
85
+ expect(owned).not_to equal(source)
86
+ expect(owned).to be_frozen
87
+ expect(owned.keys.first).not_to equal(key)
88
+ expect(owned.keys.first).to be_frozen
89
+ expect(owned.keys.first.first).to be_frozen
90
+ expect(owned.values.first).to be_frozen
91
+ expect(owned.values.first.first).to be_frozen
92
+ expect(owned.values.first.first.keys.first).to be_frozen
93
+ expect(owned.values.first.first['value']).to be_frozen
94
+ end
95
+ expect(key).not_to be_frozen
96
+ expect(key.first).not_to be_frozen
97
+ expect(source.values.first).not_to be_frozen
98
+ expect(source.values.first.first['value']).not_to be_frozen
99
+ key.first.replace('changed key')
100
+ source.values.first.first['value'].replace('changed')
101
+ %i[description resource action sample].each do |field|
102
+ expect(child.public_send(field)).to eq(['mutable key'] => [{ 'value' => 'safe' }])
103
+ end
104
+ end
105
+
106
+ it 'detaches explicitly supplied strings and converts resource path elements without retaining them' do
107
+ text = +'value'
108
+ path = [text, :widget, 7]
109
+ child = parent.with(version: text, host: text, base_url: text, version_url: text,
110
+ resource_path: path, action_name: text)
111
+
112
+ %i[version host base_url version_url action_name].each do |field|
113
+ expect(child.public_send(field)).to eq('value')
114
+ expect(child.public_send(field)).not_to equal(text)
115
+ expect(child.public_send(field)).to be_frozen
116
+ end
117
+ expect(child.resource_path).to eq(%w[value widget 7])
118
+ expect(child.resource_path).to be_frozen
119
+ expect(child.resource_path).not_to equal(path)
120
+ expect(child.resource_path).to all(be_frozen)
121
+ expect(path).not_to be_frozen
122
+ expect(text).not_to be_frozen
123
+ text.replace('changed')
124
+ path << 'extra'
125
+ expect(child.version).to eq('value')
126
+ expect(child.resource_path).to eq(%w[value widget 7])
127
+ end
128
+
129
+ it 'copies explicitly supplied owned values again instead of treating frozen data as trusted' do
130
+ child = parent.with(description: parent.description, resource_path: parent.resource_path,
131
+ sample: parent.sample)
132
+
133
+ %i[description resource_path sample].each do |field|
134
+ expect(child.public_send(field)).to eq(parent.public_send(field))
135
+ expect(child.public_send(field)).not_to equal(parent.public_send(field))
136
+ end
137
+ expect(child.description['resources']).not_to equal(parent.description['resources'])
138
+ expect(child.sample['request']['values']).not_to equal(parent.sample['request']['values'])
139
+ end
140
+
141
+ it 'keeps parent, sibling and chained derivations detached from their supplied inputs' do
142
+ data = { request: { name: 'safe' } }
143
+ child = parent.with(sample: data)
144
+ sibling = parent.with(sample: data)
145
+ grandchild = child.with(action_name: :update)
146
+ fields[:description]['resources']['widget']['label'].replace('changed source')
147
+ data[:request][:name].replace('changed sample')
148
+
149
+ expect(parent.description['resources']['widget']['label']).to eq('Widget')
150
+ expect(child.description).to equal(parent.description)
151
+ expect(sibling.description).to equal(parent.description)
152
+ expect(grandchild.description).to equal(parent.description)
153
+ expect(parent.sample).to eq('request' => { 'values' => [0, false, '', nil] })
154
+ expect(child.sample).to eq(request: { name: 'safe' })
155
+ expect(sibling.sample).to eq(child.sample)
156
+ expect(sibling.sample).not_to equal(child.sample)
157
+ expect(grandchild.sample).to equal(child.sample)
158
+ expect(child.action_name).to eq('show')
159
+ expect(grandchild.action_name).to eq('update')
160
+ expect { child.sample[:request][:name].replace('forbidden') }.to raise_error(FrozenError)
161
+ end
162
+
163
+ it 'distinguishes omitted fields from explicit nil replacements' do
164
+ child = parent.with(description: nil, resource: nil, action_name: nil, action: nil, sample: nil, profile: nil)
165
+
166
+ %i[description resource action_name action sample profile].each do |field|
167
+ expect(child.public_send(field)).to be_nil
168
+ expect(parent.public_send(field)).not_to be_nil
169
+ end
170
+ expect(child.version).to equal(parent.version)
171
+ expect(parent.with(version: nil, host: nil, base_url: nil, version_url: nil))
172
+ .to have_attributes(version: '', host: '', base_url: '', version_url: '')
173
+ expect(context.with.action_name).to be_nil
174
+ end
175
+
176
+ it 'shares arbitrary profiles and non-container leaves without freezing them' do
177
+ leaf = Object.new
178
+ profile = fields.fetch(:profile)
179
+ child = parent.with(description: { custom: leaf }, profile: leaf)
180
+
181
+ expect(parent.profile).to equal(profile)
182
+ expect(parent.with.profile).to equal(profile)
183
+ expect(profile).not_to be_frozen
184
+ expect(child.profile).to equal(leaf)
185
+ expect(child.description[:custom]).to equal(leaf)
186
+ expect(leaf).not_to be_frozen
187
+ end
188
+
189
+ it 'continues rejecting invalid resource paths' do
190
+ expect { parent.with(resource_path: nil) }.to raise_error(NoMethodError)
191
+ expect { described_class.new(**fields, resource_path: nil) }.to raise_error(NoMethodError)
192
+ expect(parent.resource_path).to eq(%w[widget 2])
193
+ end
194
+
195
+ it 'rejects unknown keywords before normalizing any supplied values' do
196
+ value = Object.new
197
+ allow(value).to receive(:to_s).and_call_original
198
+
199
+ expect { parent.with(version: value, unknown: true) }.to raise_error(ArgumentError, 'unknown keyword: :unknown')
200
+ expect { parent.with(unknown: true, other: false) }
201
+ .to raise_error(ArgumentError, 'unknown keywords: :unknown, :other')
202
+ expect { parent.with('version' => '2') }.to raise_error(ArgumentError, 'unknown keyword: "version"')
203
+ expect(value).not_to have_received(:to_s)
204
+ expect(parent).not_to respond_to(:unknown)
205
+ expect(parent.version).to eq('1')
206
+ end
207
+
208
+ it 'keeps subclass constructors, extra keywords and constructor effects on every derivation' do
209
+ subclass = Class.new(described_class) do
210
+ attr_reader :marker, :initializations
211
+
212
+ def initialize(marker: :default, initializations: 0, **fields)
213
+ @marker = marker
214
+ @initializations = initializations + 1
215
+ super(**fields)
216
+ end
217
+ end
218
+ original = subclass.new(**fields, marker: :original)
219
+ child = original.with(marker: :child, initializations: original.initializations)
220
+ defaulted = original.with
221
+
222
+ expect(child).to be_a(subclass)
223
+ expect(child).to be_frozen
224
+ expect(child.marker).to eq(:child)
225
+ expect(child.initializations).to eq(2)
226
+ expect(defaulted.marker).to eq(:default)
227
+ expect(original.marker).to eq(:original)
228
+ expect(original.initializations).to eq(1)
229
+ expect(child.description).to eq(original.description)
230
+ expect(child.description).not_to equal(original.description)
231
+ end
232
+ end
233
+
28
234
  it 'preserves legacy initialization constructors and wraps plain source' do
29
235
  renderer = Class.new(HaveAPI::ClientExample) do
30
236
  def initialize(host, base_url, version)
@@ -0,0 +1,79 @@
1
+ require 'spec_helper'
2
+ require 'base64'
3
+ require_relative '../../test_support/documentation_performance_fixture'
4
+
5
+ describe HaveAPI::Server, '#mount' do
6
+ let(:server) do
7
+ HaveAPI::DocumentationPerformanceFixture.build(resource_count: 3, actions_per_resource: 2)
8
+ end
9
+ let(:contexts) { [] }
10
+ let(:help_inputs) { [] }
11
+
12
+ before do
13
+ HaveAPI::ClientExample.clients.each do |client|
14
+ allow(client).to receive(:render).and_wrap_original do |original, kind, context:, **arguments|
15
+ contexts << context
16
+ original.call(kind, context:, **arguments)
17
+ end
18
+ end
19
+ allow(server).to receive(:describe_version).and_wrap_original do |original, *arguments|
20
+ original.call(*arguments).tap do |help|
21
+ help_inputs << [help, Marshal.dump(help)]
22
+ end
23
+ end
24
+ end
25
+
26
+ def request_as(user)
27
+ contexts.clear
28
+ Rack::MockRequest.new(server.app).get(
29
+ '/benchmark/api/v1/', 'HTTP_HOST' => 'example.test',
30
+ 'HTTP_AUTHORIZATION' => "Basic #{Base64.strict_encode64("#{user}:fixture-only")}"
31
+ )
32
+ end
33
+
34
+ def expect_shared_request(response)
35
+ expect(response.status).to eq(200)
36
+ expect(response.body).to include('PUBLIC_SAMPLE_0', 'example-resource0-action0-0',
37
+ 'init-javascript-callback', 'init-javascript-async',
38
+ 'init-typescript-callback', 'init-typescript-async',
39
+ 'init-python-sync', 'init-python-async')
40
+ expect(contexts.length).to be > 20
41
+ description = contexts.first.description
42
+ contexts.each { |context| expect(context.description).to equal(description) }
43
+ expect(description).to be_frozen
44
+ description
45
+ end
46
+
47
+ it 'shares one description across real panels and isolates sequential admin/member/admin requests' do
48
+ first_admin = request_as('admin')
49
+ admin_description = expect_shared_request(first_admin)
50
+ privileged_markers = %w[ADMIN_ONLY_ACTION ADMIN_ONLY_DEFAULT ADMIN_ONLY_REQUEST ADMIN_ONLY_RESPONSE
51
+ ADMIN_ONLY_ACTION_SAMPLE admin_field admin_only]
52
+ privileged_markers.each { |marker| expect(first_admin.body).to include(marker) }
53
+
54
+ member = request_as('member')
55
+ member_description = expect_shared_request(member)
56
+ expect(member_description).not_to equal(admin_description)
57
+ privileged_markers.each { |marker| expect(member.body).not_to include(marker) }
58
+ expect(JSON.generate(contexts.map { |context| [context.description, context.action, context.sample] }))
59
+ .not_to match(/ADMIN_ONLY_|admin_field|admin_only/)
60
+ expect(member_description[:resources]['resource0'][:actions]).not_to have_key('admin_only')
61
+ selected = contexts.find { |context| context.action_name == 'action0' }
62
+ expect(selected.action[:input][:parameters]).not_to have_key(:admin_field)
63
+ expect(selected.sample[:request]).to include(enabled: false, count: 0, nullable: nil, value0: '')
64
+
65
+ last_admin = request_as('admin')
66
+ restored_description = expect_shared_request(last_admin)
67
+ expect(restored_description).not_to equal(admin_description)
68
+ expect(restored_description).not_to equal(member_description)
69
+ expect(restored_description).to eq(admin_description)
70
+ privileged_markers.each { |marker| expect(last_admin.body).to include(marker) }
71
+
72
+ help_inputs.each do |help, original|
73
+ expect(Marshal.dump(help)).to eq(original)
74
+ expect(help).not_to be_frozen
75
+ expect(help[:resources]).not_to be_frozen
76
+ expect(help[:resources]['resource0'][:actions]['action0'][:examples].first[:request]).not_to be_frozen
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,342 @@
1
+ require 'spec_helper'
2
+ require 'tmpdir'
3
+ require 'open3'
4
+ require_relative 'support/samples'
5
+
6
+ describe HaveAPI::Performance::Comparator do
7
+ def compare_cli(samples, budgets)
8
+ Dir.mktmpdir('haveapi-comparator-') do |directory|
9
+ input = File.join(directory, 'samples.json')
10
+ policy = File.join(directory, 'budgets.json')
11
+ output = File.join(directory, 'result')
12
+ File.write(input, JSON.generate(samples))
13
+ File.write(policy, budgets)
14
+ _stdout, _stderr, status = Open3.capture3(RbConfig.ruby, File.expand_path('../../benchmarks/endpoints.rb', __dir__),
15
+ 'compare', '--input', input, '--budgets', policy, '--output', output)
16
+ [status.exitstatus, JSON.parse(File.read(File.join(output, 'report.json')))]
17
+ end
18
+ end
19
+
20
+ def replace_raw_body(sample, body)
21
+ output = sample['outputs'].first
22
+ raw = sample['bodies'].delete(output['body_sha256'])
23
+ digest = Digest::SHA256.hexdigest(body)
24
+ sample['bodies'][digest] = raw.merge('body' => body)
25
+ output.merge!('body_sha256' => digest, 'body_bytes' => body.bytesize)
26
+ end
27
+
28
+ def rebind_source(samples, role, sha)
29
+ samples["#{role}_sha"] = sha
30
+ samples['sources'][role]['sha'] = sha
31
+ samples['observations'].each_value do |observation|
32
+ observation['source']['sha'] = sha if observation['source']['root'] == samples['sources'][role]['root']
33
+ end
34
+ end
35
+
36
+ it 'passes controlled AA traces through the actual CLI' do
37
+ samples, budgets = PerformanceSamples.fixture
38
+ code, report = compare_cli(samples, budgets)
39
+ expect(code).to eq(0)
40
+ expect(report['status']).to eq('pass')
41
+ expect(report['cases'].first['timing'].keys).to eq(%w[wall_seconds cpu_seconds])
42
+ expect(report['schema_version']).to eq(2)
43
+ expect(report['cases'].first['correctness']).to include('status' => 'pass', 'operations_per_sample' => 1)
44
+ expect(JSON.generate(report)).not_to include('canonical_body', 'body_sha256', 'self_pairs', 'observations', 'loaded_sources')
45
+ end
46
+
47
+ it 'rejects unsupported sample formats without converting historical evidence' do
48
+ samples, budgets = PerformanceSamples.fixture
49
+ samples['schema_version'] = 1
50
+ code, report = compare_cli(samples, budgets)
51
+ expect(code).to eq(3)
52
+ expect(report.dig('error', 'message')).to include('Unsupported samples schema; expected 2')
53
+ end
54
+
55
+ it 'blocks an agreed meaningful slowdown for either clock independently' do
56
+ %w[wall_seconds cpu_seconds].each do |metric|
57
+ samples, budgets = PerformanceSamples.fixture
58
+ samples['cases'].first['pairs'].each { |pair| pair['candidate'][metric] = 3.0 }
59
+ code, report = compare_cli(samples, budgets)
60
+ expect(code).to eq(1)
61
+ expect(report['cases'].first['timing'][metric]['regression_agreement']).to eq(5)
62
+ end
63
+ end
64
+
65
+ it 'retains noisy inconclusive diagnostics without failing readiness' do
66
+ samples, budgets = PerformanceSamples.fixture
67
+ samples['cases'].first['self_pairs'].first['candidate']['wall_seconds'] = 6.0
68
+ code, report = compare_cli(samples, budgets)
69
+ expect(code).to eq(0)
70
+ expect(report['status']).to eq('inconclusive')
71
+ end
72
+
73
+ it 'requires four of five quick agreements and six of seven weekly agreements' do
74
+ %w[quick weekly].each do |profile|
75
+ samples, budgets = PerformanceSamples.fixture(profile:)
76
+ agreement = HaveAPI::Performance::PROFILES.fetch(profile).fetch(:agreement)
77
+ samples['cases'].first['pairs'].first(agreement - 1).each { |pair| pair['candidate']['wall_seconds'] = 3.0 }
78
+ code, report = compare_cli(samples, budgets)
79
+ expect(code).to eq(0)
80
+ expect(report['status']).to eq('inconclusive')
81
+ samples['cases'].first['pairs'][agreement - 1]['candidate']['wall_seconds'] = 3.0
82
+ expect(compare_cli(samples, budgets).first).to eq(1)
83
+ end
84
+ end
85
+
86
+ it 'uses the stricter weekly budget on the same fifteen percent increase' do
87
+ %w[quick weekly].each do |profile|
88
+ samples, budgets = PerformanceSamples.fixture(profile:)
89
+ samples['cases'].first['pairs'].each { |pair| pair['candidate']['wall_seconds'] = 2.30 }
90
+ expect(compare_cli(samples, budgets).first).to eq(profile == 'quick' ? 0 : 1)
91
+ end
92
+ end
93
+
94
+ it 'detects a cheap-request twenty-five percent regression using the batch floor' do
95
+ samples, budgets = PerformanceSamples.fixture
96
+ item = samples['cases'].first
97
+ item['iterations'] = 25_000
98
+ (item['self_pairs'] + item['pairs']).each do |pair|
99
+ pair.each_value do |sample|
100
+ next unless sample.is_a?(Hash)
101
+
102
+ sample['operations'] = 25_000
103
+ sample['outputs'].first['count'] = 25_000
104
+ sample['counters']['operations'] = 25_000
105
+ sample['wall_seconds'] = sample['cpu_seconds'] = 0.00004
106
+ end
107
+ end
108
+ item['pairs'].each { |pair| pair['candidate']['wall_seconds'] = 0.00005 }
109
+ expect(compare_cli(samples, budgets).first).to eq(1)
110
+ end
111
+
112
+ it 'validates distinct examples and rejects malformed compact counts, identities and counter summaries' do
113
+ samples, json = PerformanceSamples.fixture
114
+ budget = JSON.parse(json)
115
+ spec = samples['cases'].first
116
+ sample = Marshal.load(Marshal.dump(spec['pairs'].first['candidate']))
117
+ sample['operations'] = 4
118
+ sample['outputs'].first['count'] = 4
119
+ sample['counters']['operations'] = 4
120
+ comparator = described_class.new(samples, budget, budget_sha256: samples['budget_sha256'])
121
+ body = sample['bodies'].values.first['body']
122
+ allow(HaveAPI::Performance::Identity).to receive(:parse).and_call_original
123
+ comparator.send(:validate_sample, sample, 4, budget['profiles']['quick'].first, spec)
124
+ expect(HaveAPI::Performance::Identity).to have_received(:parse).with(body).once
125
+ %w[http_status envelope_status body_bytes body_sha256 canonical_sha256 comparison counter missing zero negative float total counter_count duplicate unreferenced].each do |fault|
126
+ broken = Marshal.load(Marshal.dump(sample))
127
+ output = broken['outputs'].last
128
+ case fault
129
+ when 'http_status' then output[fault] = 403
130
+ when 'envelope_status' then output[fault] = false
131
+ when 'body_bytes' then output[fault] += 1
132
+ when 'comparison' then output[fault] = { 'kind' => 'own-webui-href', 'own_version' => '0.30.2' }
133
+ when 'counter' then broken['counters']['record']['authorizations'] = 0
134
+ when 'missing' then broken['counters'].delete('record')
135
+ when 'zero' then output['count'] = 0
136
+ when 'negative' then output['count'] = -1
137
+ when 'float' then output['count'] = 4.0
138
+ when 'total' then output['count'] = 3
139
+ when 'counter_count' then broken['counters']['operations'] = 4.0
140
+ when 'duplicate'
141
+ output['count'] = 2
142
+ broken['outputs'] << output.dup
143
+ when 'unreferenced'
144
+ other_body = '{"status":true}'
145
+ broken['bodies'][Digest::SHA256.hexdigest(other_body)] = { 'body' => other_body, 'canonical_body' => JSON.parse(other_body) }
146
+ else output[fault] = '0' * 64
147
+ end
148
+ expect { comparator.send(:validate_sample, broken, 4, budget['profiles']['quick'].first, spec) }.to raise_error(StandardError)
149
+ end
150
+ # A second sample must independently prove its retained body again.
151
+ comparator.send(:validate_sample, sample, 4, budget['profiles']['quick'].first, spec)
152
+ expect(HaveAPI::Performance::Identity).to have_received(:parse).with(body).at_least(:twice)
153
+ end
154
+
155
+ it 'rejects raw/canonical disagreement and duplicate JSON on a reused body and rejects counts above the shared cap' do
156
+ %w[canonical duplicate cap].each do |fault|
157
+ samples, json = PerformanceSamples.fixture
158
+ spec = samples['cases'].first
159
+ sample = spec['pairs'].first['candidate']
160
+ if fault == 'canonical'
161
+ sample['bodies'].values.first['canonical_body']['response']['zero'] = 1
162
+ elsif fault == 'duplicate'
163
+ body = sample['bodies'].values.first['body'].sub('"status":true', '"status":false,"status":true')
164
+ replace_raw_body(sample, body)
165
+ else
166
+ spec['iterations'] = 100_001
167
+ end
168
+ comparator = described_class.new(samples, JSON.parse(json), budget_sha256: samples['budget_sha256'])
169
+ expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError)
170
+ end
171
+ end
172
+
173
+ it 'compares response occurrence totals across revisions without retaining request order' do
174
+ samples, json = PerformanceSamples.fixture
175
+ spec = samples['cases'].first
176
+ spec['iterations'] = 3
177
+ (spec['self_pairs'] + spec['pairs']).each do |pair|
178
+ %w[baseline candidate].each do |role|
179
+ sample = pair[role]
180
+ output = sample['outputs'].first
181
+ body = sample['bodies'].values.first['body'].sub('"zero":0', '"zero":1')
182
+ canonical = JSON.parse(body)
183
+ digest = Digest::SHA256.hexdigest(body)
184
+ sample['bodies'][digest] = { 'body' => body, 'canonical_body' => canonical }
185
+ sample['operations'] = 3
186
+ sample['outputs'] << output.merge('body_sha256' => digest, 'body_bytes' => body.bytesize,
187
+ 'canonical_sha256' => HaveAPI::Performance::Identity.digest(canonical), 'count' => 2)
188
+ sample['counters']['operations'] = 3
189
+ end
190
+ end
191
+ comparator = described_class.new(samples, JSON.parse(json), budget_sha256: samples['budget_sha256'])
192
+ expect(comparator.compare['status']).to eq('pass')
193
+ spec['pairs'].last['candidate']['outputs'].reverse!
194
+ expect(comparator.compare['status']).to eq('pass')
195
+ spec['pairs'].last['candidate']['outputs'].each { |example| example['count'] = 3 - example['count'] }
196
+ expect { comparator.compare }.to raise_error(HaveAPI::Performance::ContractError, /Response identity/)
197
+ end
198
+
199
+ it 'rejects retained runaway request batches even if both sources are equally slow' do
200
+ samples, budgets = PerformanceSamples.fixture
201
+ item = samples['cases'].first
202
+ (item['self_pairs'] + item['pairs']).each do |pair|
203
+ %w[baseline candidate].each { |role| pair[role]['wall_seconds'] = 100.0 }
204
+ end
205
+ code, report = compare_cli(samples, budgets)
206
+ expect(code).to eq(3)
207
+ expect(report.dig('error', 'message')).to include('Request batch exceeds 30.0s')
208
+ end
209
+
210
+ it 'rejects raw integer/float drift even when forged raw metadata is consistent' do
211
+ [[0, 0.0], [0.0, 0]].each do |original, changed|
212
+ samples, budgets = PerformanceSamples.fixture(zero: original)
213
+ target = samples['cases'].first['pairs'].first['candidate']
214
+ body = JSON.parse(target['bodies'].values.first['body'])
215
+ body['response']['zero'] = changed
216
+ replace_raw_body(target, JSON.generate(body))
217
+ code, report = compare_cli(samples, budgets)
218
+ expect(code).to eq(3)
219
+ expect(report.dig('error', 'message')).to include('Raw/canonical output values differ')
220
+ end
221
+ end
222
+
223
+ it 'allows raw map-order changes through the actual CLI' do
224
+ samples, budgets = PerformanceSamples.fixture
225
+ target = samples['cases'].first['pairs'].first['candidate']
226
+ body = JSON.parse(target['bodies'].values.first['body'])
227
+ body['response'] = body['response'].to_a.reverse.to_h
228
+ replace_raw_body(target, JSON.generate(body.to_a.reverse.to_h))
229
+ expect(compare_cli(samples, budgets).first).to eq(0)
230
+ end
231
+
232
+ it 'rejects identical source SHAs even with consistently rebound provenance' do
233
+ samples, budgets = PerformanceSamples.fixture
234
+ rebind_source(samples, 'candidate', samples['baseline_sha'])
235
+ code, report = compare_cli(samples, budgets)
236
+ expect(code).to eq(3)
237
+ expect(report.dig('error', 'message')).to include('Baseline and candidate must differ')
238
+ end
239
+
240
+ it 'requires the fixed weekly baseline and accepts valid fixed-reference evidence' do
241
+ samples, budgets = PerformanceSamples.fixture(profile: 'weekly')
242
+ expect(compare_cli(samples, budgets).first).to eq(0)
243
+ rebind_source(samples, 'baseline', 'a' * 40)
244
+ code, report = compare_cli(samples, budgets)
245
+ expect(code).to eq(3)
246
+ expect(report.dig('error', 'message')).to include('Weekly baseline must be the fixed reference')
247
+ end
248
+
249
+ it 'requires both the percentage and absolute timing thresholds' do
250
+ samples, budgets = PerformanceSamples.fixture
251
+ samples['cases'].first['pairs'].each do |pair|
252
+ pair['baseline']['wall_seconds'] = 0.0001
253
+ pair['candidate']['wall_seconds'] = 0.00013
254
+ end
255
+ expect(compare_cli(samples, budgets).first).to eq(0)
256
+ end
257
+
258
+ it 'blocks allocation growth and the independent fixed reference ceiling' do
259
+ samples, budgets = PerformanceSamples.fixture
260
+ samples['cases'].first['pairs'].each { |pair| pair['candidate']['allocated_objects'] = 140 }
261
+ expect(compare_cli(samples, budgets).first).to eq(1)
262
+ samples['cases'].first['pairs'].each do |pair|
263
+ pair['baseline']['allocated_objects'] = 180
264
+ pair['candidate']['allocated_objects'] = 180
265
+ end
266
+ expect(compare_cli(samples, budgets).first).to eq(1)
267
+ end
268
+
269
+ it 'requires full Rust cases and their relative allocation and authorization contracts' do
270
+ %w[missing allocations authorization].each do |fault|
271
+ samples, budgets = PerformanceSamples.fixture
272
+ case fault
273
+ when 'missing'
274
+ samples['inventory'].pop
275
+ samples['cases'].pop
276
+ when 'allocations'
277
+ samples['cases'].last['pairs'].each { |pair| pair['candidate']['allocated_objects'] = 200 }
278
+ else
279
+ samples['cases'].last['pairs'].first['candidate']['counters']['record']['authorizations'] -= 1
280
+ end
281
+ expect(compare_cli(samples, budgets).first).to eq(fault == 'allocations' ? 1 : 3)
282
+ end
283
+ end
284
+
285
+ it 'blocks uncalibrated reference budgets' do
286
+ samples, json = PerformanceSamples.fixture
287
+ budgets = JSON.parse(json)
288
+ budgets.delete('calibration')
289
+ json = JSON.generate(budgets)
290
+ samples['budget_sha256'] = Digest::SHA256.hexdigest(json)
291
+ expect(compare_cli(samples, json).first).to eq(3)
292
+ end
293
+
294
+ it 'blocks missing cases, operations, dependency graphs and source provenance' do
295
+ %w[cases outputs inputs sources].each do |missing|
296
+ samples, budgets = PerformanceSamples.fixture
297
+ case missing
298
+ when 'cases' then samples['cases'].clear
299
+ when 'outputs' then samples['cases'].first['pairs'].first['candidate']['outputs'].clear
300
+ else samples.delete(missing)
301
+ end
302
+ expect(compare_cli(samples, budgets).first).to eq(3)
303
+ end
304
+ end
305
+
306
+ it 'rejects missing or changed workload order and runtime policy' do
307
+ %w[workload_order worker_order runtime_policy].each do |fault|
308
+ samples, budgets = PerformanceSamples.fixture
309
+ case fault
310
+ when 'workload_order' then samples['cases'].first['pairs'][1]['workload_order'] = 'forward'
311
+ when 'worker_order' then samples['observations']['candidate-reverse']['workload_order'] = 'forward'
312
+ else samples['observations']['baseline']['runtime_policy']['timezone'] = 'local'
313
+ end
314
+ expect(compare_cli(samples, budgets).first).to eq(3)
315
+ end
316
+ end
317
+
318
+ it 'blocks extra queries, lost authorizations, wrong output and changed dependency graphs' do
319
+ %w[queries authorizations output environment].each do |fault|
320
+ samples, budgets = PerformanceSamples.fixture
321
+ target = samples['cases'].first['pairs'].first['candidate']
322
+ case fault
323
+ when 'queries' then target['counters']['record']['queries'] = 1
324
+ when 'authorizations' then target['counters']['record']['authorizations'] = 0
325
+ when 'output' then target['outputs'].first['canonical_sha256'] = '0' * 64
326
+ else samples['observations']['candidate']['environment'] = {}
327
+ end
328
+ expect(compare_cli(samples, budgets).first).to eq(3)
329
+ end
330
+ end
331
+
332
+ it 'preserves scalar types and array order while ignoring map order only' do
333
+ identity = HaveAPI::Performance::Identity
334
+ expect(identity.digest(a: 0, b: [false, '', nil])).to eq(identity.digest('b' => [false, '', nil], 'a' => 0))
335
+ expect(identity.digest(a: 0)).not_to eq(identity.digest(a: 0.0))
336
+ expect(identity.digest(a: [false, nil])).not_to eq(identity.digest(a: [nil, false]))
337
+ expect { identity.digest(a: 0, 'a' => 1) }.to raise_error(HaveAPI::Performance::ContractError)
338
+ ['{"status":false,"status":true}', '{"a":[{"b":1,"b":2}]}'].each do |json|
339
+ expect { identity.parse(json) }.to raise_error(HaveAPI::Performance::ContractError, /Duplicate/)
340
+ end
341
+ end
342
+ end