jsonstructure 0.5.1

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.
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'json'
5
+
6
+ # Test against the shared test-assets to ensure consistency with other SDKs
7
+ RSpec.describe 'Test Assets Conformance' do
8
+ # Find test-assets directory relative to this file
9
+ def find_test_assets_path
10
+ current = File.dirname(__FILE__)
11
+
12
+ # Try different relative paths
13
+ candidates = [
14
+ File.join(current, '..', '..', 'test-assets'),
15
+ File.join(current, '..', '..', '..', 'test-assets'),
16
+ File.join(current, '..', '..', 'sdk', 'test-assets')
17
+ ]
18
+
19
+ candidates.each do |path|
20
+ expanded = File.expand_path(path)
21
+ return expanded if File.directory?(expanded)
22
+ end
23
+
24
+ nil
25
+ end
26
+
27
+ let(:test_assets_path) { find_test_assets_path }
28
+
29
+ before do
30
+ skip 'test-assets directory not found' unless test_assets_path
31
+ end
32
+
33
+ describe 'Invalid Schemas' do
34
+ let(:invalid_schemas_path) { File.join(test_assets_path, 'schemas', 'invalid') }
35
+
36
+ it 'rejects all invalid schemas' do
37
+ skip 'invalid schemas directory not found' unless File.directory?(invalid_schemas_path)
38
+
39
+ schema_files = Dir.glob(File.join(invalid_schemas_path, '*.struct.json'))
40
+ skip 'no invalid schema files found' if schema_files.empty?
41
+
42
+ passed = 0
43
+ failed = []
44
+
45
+ schema_files.each do |file|
46
+ schema_content = File.read(file)
47
+ result = JsonStructure::SchemaValidator.validate(schema_content)
48
+
49
+ if result.invalid?
50
+ passed += 1
51
+ else
52
+ failed << File.basename(file)
53
+ end
54
+ end
55
+
56
+ expect(failed).to be_empty,
57
+ "Expected these schemas to be invalid but they were valid: #{failed.join(', ')}"
58
+
59
+ puts " Tested #{passed} invalid schemas - all correctly rejected"
60
+ end
61
+ end
62
+
63
+ describe 'Valid Schemas (validation extension)' do
64
+ let(:valid_schemas_path) { File.join(test_assets_path, 'schemas', 'validation') }
65
+
66
+ it 'accepts all validation extension schemas' do
67
+ skip 'validation schemas directory not found' unless File.directory?(valid_schemas_path)
68
+
69
+ schema_files = Dir.glob(File.join(valid_schemas_path, '*.struct.json'))
70
+ skip 'no validation schema files found' if schema_files.empty?
71
+
72
+ passed = 0
73
+ failed = []
74
+
75
+ schema_files.each do |file|
76
+ schema_content = File.read(file)
77
+ result = JsonStructure::SchemaValidator.validate(schema_content)
78
+
79
+ # Check for errors only, warnings are acceptable
80
+ errors = result.errors.select { |e| e.severity == :error }
81
+ if errors.empty?
82
+ passed += 1
83
+ else
84
+ failed << "#{File.basename(file)}: #{result.error_messages.join(', ')}"
85
+ end
86
+ end
87
+
88
+ expect(failed).to be_empty,
89
+ "Expected these schemas to be valid but got errors:\n #{failed.join("\n ")}"
90
+
91
+ puts " Tested #{passed} validation schemas - all accepted"
92
+ end
93
+ end
94
+
95
+ describe 'Instance Validation against test schemas' do
96
+ let(:validation_instances_path) { File.join(test_assets_path, 'instances', 'validation') }
97
+ let(:validation_schemas_path) { File.join(test_assets_path, 'schemas', 'validation') }
98
+
99
+ # Helper to extract the actual value from a test instance
100
+ # Test instances may have metadata fields like _description, _expectedError, _comment
101
+ # These need to be stripped before validation, or use the "value" field if present
102
+ def extract_instance_value(instance_json)
103
+ instance = JSON.parse(instance_json)
104
+
105
+ # If there's a "value" field, use that
106
+ return JSON.generate(instance['value']) if instance.key?('value')
107
+
108
+ # Otherwise, remove metadata fields and use the rest
109
+ instance.delete('_description')
110
+ instance.delete('_expectedError')
111
+ instance.delete('_comment')
112
+ JSON.generate(instance)
113
+ end
114
+
115
+ it 'rejects invalid instances against their schemas' do
116
+ skip 'validation instances directory not found' unless File.directory?(validation_instances_path)
117
+
118
+ # Each schema has a corresponding directory with invalid instances
119
+ schema_dirs = Dir.glob(File.join(validation_instances_path, '*')).select { |f| File.directory?(f) }
120
+ skip 'no instance directories found' if schema_dirs.empty?
121
+
122
+ passed = 0
123
+ failed = []
124
+
125
+ schema_dirs.each do |instance_dir|
126
+ schema_name = File.basename(instance_dir)
127
+ schema_file = File.join(validation_schemas_path, "#{schema_name}.struct.json")
128
+
129
+ next unless File.exist?(schema_file)
130
+
131
+ schema_content = File.read(schema_file)
132
+
133
+ # Test invalid instances (files directly in the schema's instance directory)
134
+ instance_files = Dir.glob(File.join(instance_dir, '*.json'))
135
+ instance_files.each do |instance_file|
136
+ instance_content = File.read(instance_file)
137
+ # Extract the actual value, stripping metadata fields
138
+ instance_value = extract_instance_value(instance_content)
139
+ result = JsonStructure::InstanceValidator.validate(instance_value, schema_content)
140
+
141
+ if result.invalid?
142
+ passed += 1
143
+ else
144
+ failed << "#{schema_name}/#{File.basename(instance_file)}"
145
+ end
146
+ end
147
+ end
148
+
149
+ expect(failed).to be_empty,
150
+ "Expected these instances to be invalid but they were valid:\n #{failed.join("\n ")}"
151
+
152
+ puts " Tested #{passed} invalid instances - all correctly rejected"
153
+ end
154
+ end
155
+
156
+ describe 'Primer sample schemas' do
157
+ def find_primer_samples_path
158
+ current = File.dirname(__FILE__)
159
+
160
+ candidates = [
161
+ File.join(current, '..', '..', 'primer-and-samples', 'samples', 'core'),
162
+ File.join(current, '..', '..', '..', 'primer-and-samples', 'samples', 'core')
163
+ ]
164
+
165
+ candidates.each do |path|
166
+ expanded = File.expand_path(path)
167
+ return expanded if File.directory?(expanded)
168
+ end
169
+
170
+ nil
171
+ end
172
+
173
+ let(:primer_path) { find_primer_samples_path }
174
+
175
+ it 'accepts all primer sample schemas' do
176
+ skip 'primer samples directory not found' unless primer_path
177
+
178
+ # Find all .struct.json files recursively
179
+ schema_files = Dir.glob(File.join(primer_path, '**', '*.struct.json'))
180
+ skip 'no primer schema files found' if schema_files.empty?
181
+
182
+ passed = 0
183
+ failed = []
184
+
185
+ schema_files.each do |file|
186
+ schema_content = File.read(file)
187
+ result = JsonStructure::SchemaValidator.validate(schema_content)
188
+
189
+ # Check for errors only
190
+ errors = result.errors.select { |e| e.severity == :error }
191
+ if errors.empty?
192
+ passed += 1
193
+ else
194
+ relative_path = file.sub(primer_path + '/', '')
195
+ failed << "#{relative_path}: #{result.error_messages.join(', ')}"
196
+ end
197
+ end
198
+
199
+ expect(failed).to be_empty,
200
+ "Expected these primer schemas to be valid but got errors:\n #{failed.join("\n ")}"
201
+
202
+ puts " Tested #{passed} primer sample schemas - all accepted"
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe 'Thread Safety' do
6
+ let(:valid_schema) { '{"type": "object", "properties": {"name": {"type": "string"}}}' }
7
+ let(:valid_instance) { '{"name": "test"}' }
8
+ let(:invalid_instance) { '{"name": 123}' }
9
+
10
+ describe 'concurrent schema validation' do
11
+ it 'handles multiple threads validating schemas concurrently' do
12
+ thread_count = 10
13
+ iterations = 50
14
+ errors = []
15
+ mutex = Mutex.new
16
+
17
+ threads = thread_count.times.map do |thread_id|
18
+ Thread.new do
19
+ iterations.times do |i|
20
+ begin
21
+ result = JsonStructure::SchemaValidator.validate(valid_schema)
22
+ unless result.valid?
23
+ mutex.synchronize { errors << "Thread #{thread_id}, iteration #{i}: expected valid, got invalid" }
24
+ end
25
+ rescue => e
26
+ mutex.synchronize { errors << "Thread #{thread_id}, iteration #{i}: #{e.class} - #{e.message}" }
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ threads.each(&:join)
33
+ expect(errors).to be_empty, "Errors occurred during concurrent validation:\n#{errors.join("\n")}"
34
+ end
35
+
36
+ it 'handles mixed valid and invalid schemas concurrently' do
37
+ thread_count = 10
38
+ iterations = 20
39
+ errors = []
40
+ mutex = Mutex.new
41
+
42
+ schemas = [
43
+ { json: '{"type": "string"}', valid: true },
44
+ { json: '{"type": "invalid_type"}', valid: false },
45
+ { json: '{"type": "object", "properties": {"a": {"type": "integer"}}}', valid: true },
46
+ { json: '{"minimum": "not_a_number"}', valid: false }
47
+ ]
48
+
49
+ threads = thread_count.times.map do |thread_id|
50
+ Thread.new do
51
+ iterations.times do |i|
52
+ schema_info = schemas[(thread_id + i) % schemas.length]
53
+ begin
54
+ result = JsonStructure::SchemaValidator.validate(schema_info[:json])
55
+ if result.valid? != schema_info[:valid]
56
+ mutex.synchronize do
57
+ errors << "Thread #{thread_id}, iteration #{i}: expected #{schema_info[:valid]}, got #{result.valid?}"
58
+ end
59
+ end
60
+ rescue => e
61
+ mutex.synchronize { errors << "Thread #{thread_id}, iteration #{i}: #{e.class} - #{e.message}" }
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ threads.each(&:join)
68
+ expect(errors).to be_empty, "Errors occurred during concurrent validation:\n#{errors.join("\n")}"
69
+ end
70
+ end
71
+
72
+ describe 'concurrent instance validation' do
73
+ it 'handles multiple threads validating instances concurrently' do
74
+ thread_count = 10
75
+ iterations = 50
76
+ errors = []
77
+ mutex = Mutex.new
78
+
79
+ threads = thread_count.times.map do |thread_id|
80
+ Thread.new do
81
+ iterations.times do |i|
82
+ begin
83
+ result = JsonStructure::InstanceValidator.validate(valid_instance, valid_schema)
84
+ unless result.valid?
85
+ mutex.synchronize { errors << "Thread #{thread_id}, iteration #{i}: expected valid, got invalid" }
86
+ end
87
+ rescue => e
88
+ mutex.synchronize { errors << "Thread #{thread_id}, iteration #{i}: #{e.class} - #{e.message}" }
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ threads.each(&:join)
95
+ expect(errors).to be_empty, "Errors occurred during concurrent validation:\n#{errors.join("\n")}"
96
+ end
97
+
98
+ it 'handles mixed valid and invalid instances concurrently' do
99
+ thread_count = 10
100
+ iterations = 20
101
+ errors = []
102
+ mutex = Mutex.new
103
+
104
+ test_cases = [
105
+ { instance: '{"name": "Alice"}', valid: true },
106
+ { instance: '{"name": 123}', valid: false },
107
+ { instance: '{"name": "Bob", "age": 30}', valid: true },
108
+ { instance: '{}', valid: true }
109
+ ]
110
+
111
+ threads = thread_count.times.map do |thread_id|
112
+ Thread.new do
113
+ iterations.times do |i|
114
+ test_case = test_cases[(thread_id + i) % test_cases.length]
115
+ begin
116
+ result = JsonStructure::InstanceValidator.validate(test_case[:instance], valid_schema)
117
+ if result.valid? != test_case[:valid]
118
+ mutex.synchronize do
119
+ errors << "Thread #{thread_id}, iteration #{i}: expected #{test_case[:valid]}, got #{result.valid?}"
120
+ end
121
+ end
122
+ rescue => e
123
+ mutex.synchronize { errors << "Thread #{thread_id}, iteration #{i}: #{e.class} - #{e.message}" }
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ threads.each(&:join)
130
+ expect(errors).to be_empty, "Errors occurred during concurrent validation:\n#{errors.join("\n")}"
131
+ end
132
+ end
133
+
134
+ describe 'mixed concurrent operations' do
135
+ it 'handles schema and instance validations running concurrently' do
136
+ thread_count = 10
137
+ iterations = 30
138
+ errors = []
139
+ mutex = Mutex.new
140
+
141
+ threads = thread_count.times.map do |thread_id|
142
+ Thread.new do
143
+ iterations.times do |i|
144
+ begin
145
+ if i.even?
146
+ # Schema validation
147
+ result = JsonStructure::SchemaValidator.validate(valid_schema)
148
+ unless result.valid?
149
+ mutex.synchronize { errors << "Thread #{thread_id}, iteration #{i} (schema): expected valid" }
150
+ end
151
+ else
152
+ # Instance validation
153
+ result = JsonStructure::InstanceValidator.validate(valid_instance, valid_schema)
154
+ unless result.valid?
155
+ mutex.synchronize { errors << "Thread #{thread_id}, iteration #{i} (instance): expected valid" }
156
+ end
157
+ end
158
+ rescue => e
159
+ mutex.synchronize { errors << "Thread #{thread_id}, iteration #{i}: #{e.class} - #{e.message}" }
160
+ end
161
+ end
162
+ end
163
+ end
164
+
165
+ threads.each(&:join)
166
+ expect(errors).to be_empty, "Errors occurred during concurrent validation:\n#{errors.join("\n")}"
167
+ end
168
+ end
169
+
170
+ describe 'validation tracking' do
171
+ it 'properly tracks active validations' do
172
+ # Initially no validations should be active
173
+ expect(JsonStructure.validations_active?).to be false
174
+ end
175
+
176
+ it 'handles validation_started and validation_completed correctly' do
177
+ # Start a validation
178
+ JsonStructure.validation_started
179
+ expect(JsonStructure.validations_active?).to be true
180
+
181
+ # Complete the validation
182
+ JsonStructure.validation_completed
183
+ expect(JsonStructure.validations_active?).to be false
184
+ end
185
+
186
+ it 'handles multiple concurrent validation tracking' do
187
+ # Start multiple validations
188
+ 5.times { JsonStructure.validation_started }
189
+ expect(JsonStructure.validations_active?).to be true
190
+
191
+ # Complete them all
192
+ 5.times { JsonStructure.validation_completed }
193
+ expect(JsonStructure.validations_active?).to be false
194
+ end
195
+ end
196
+
197
+ describe 'stress test' do
198
+ it 'survives high-concurrency stress test' do
199
+ thread_count = 20
200
+ iterations = 100
201
+ errors = []
202
+ mutex = Mutex.new
203
+
204
+ schemas = [
205
+ '{"type": "string"}',
206
+ '{"type": "integer", "minimum": 0}',
207
+ '{"type": "object", "properties": {"id": {"type": "integer"}}}',
208
+ '{"type": "array", "items": {"type": "string"}}'
209
+ ]
210
+
211
+ instances = [
212
+ ['"hello"', '{"type": "string"}', true],
213
+ ['42', '{"type": "integer", "minimum": 0}', true],
214
+ ['{"id": 123}', '{"type": "object", "properties": {"id": {"type": "integer"}}}', true],
215
+ ['["a", "b", "c"]', '{"type": "array", "items": {"type": "string"}}', true],
216
+ ['"hello"', '{"type": "integer"}', false],
217
+ ['-5', '{"type": "integer", "minimum": 0}', false]
218
+ ]
219
+
220
+ threads = thread_count.times.map do |thread_id|
221
+ Thread.new do
222
+ iterations.times do |i|
223
+ begin
224
+ case i % 3
225
+ when 0
226
+ # Schema validation
227
+ schema = schemas[rand(schemas.length)]
228
+ JsonStructure::SchemaValidator.validate(schema)
229
+ when 1
230
+ # Instance validation with expected result
231
+ instance_info = instances[rand(instances.length)]
232
+ result = JsonStructure::InstanceValidator.validate(instance_info[0], instance_info[1])
233
+ if result.valid? != instance_info[2]
234
+ mutex.synchronize do
235
+ errors << "Thread #{thread_id}, iteration #{i}: unexpected result for #{instance_info[0]}"
236
+ end
237
+ end
238
+ when 2
239
+ # validate! with exception handling
240
+ begin
241
+ JsonStructure::SchemaValidator.validate!('{"type": "string"}')
242
+ rescue JsonStructure::SchemaValidationError
243
+ mutex.synchronize { errors << "Thread #{thread_id}, iteration #{i}: unexpected exception" }
244
+ end
245
+ end
246
+ rescue => e
247
+ mutex.synchronize { errors << "Thread #{thread_id}, iteration #{i}: #{e.class} - #{e.message}" }
248
+ end
249
+ end
250
+ end
251
+ end
252
+
253
+ threads.each(&:join)
254
+ expect(errors).to be_empty, "Errors occurred during stress test:\n#{errors.first(10).join("\n")}"
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe JsonStructure::ValidationResult do
6
+ describe '#valid?' do
7
+ it 'returns true for valid result' do
8
+ result = described_class.new(true, [])
9
+ expect(result).to be_valid
10
+ end
11
+
12
+ it 'returns false for invalid result' do
13
+ result = described_class.new(false, [])
14
+ expect(result).to be_invalid
15
+ end
16
+ end
17
+
18
+ describe '#errors' do
19
+ it 'returns empty array for valid result' do
20
+ result = described_class.new(true, [])
21
+ expect(result.errors).to be_empty
22
+ end
23
+
24
+ it 'returns error array for invalid result' do
25
+ error = JsonStructure::ValidationError.new(
26
+ code: 1,
27
+ severity: JsonStructure::FFI::JS_SEVERITY_ERROR,
28
+ path: '/test',
29
+ message: 'Test error',
30
+ location: { line: 1, column: 1, offset: 0 }
31
+ )
32
+ result = described_class.new(false, [error])
33
+
34
+ expect(result.errors).not_to be_empty
35
+ expect(result.errors.first).to be_a(JsonStructure::ValidationError)
36
+ end
37
+ end
38
+
39
+ describe '#error_messages' do
40
+ it 'returns only error-level messages' do
41
+ errors = [
42
+ JsonStructure::ValidationError.new(
43
+ code: 1,
44
+ severity: JsonStructure::FFI::JS_SEVERITY_ERROR,
45
+ path: '/test',
46
+ message: 'Error message',
47
+ location: { line: 1, column: 1, offset: 0 }
48
+ ),
49
+ JsonStructure::ValidationError.new(
50
+ code: 2,
51
+ severity: JsonStructure::FFI::JS_SEVERITY_WARNING,
52
+ path: '/test',
53
+ message: 'Warning message',
54
+ location: { line: 1, column: 1, offset: 0 }
55
+ )
56
+ ]
57
+ result = described_class.new(false, errors)
58
+
59
+ expect(result.error_messages).to eq(['Error message'])
60
+ end
61
+ end
62
+
63
+ describe '#warning_messages' do
64
+ it 'returns only warning-level messages' do
65
+ errors = [
66
+ JsonStructure::ValidationError.new(
67
+ code: 1,
68
+ severity: JsonStructure::FFI::JS_SEVERITY_ERROR,
69
+ path: '/test',
70
+ message: 'Error message',
71
+ location: { line: 1, column: 1, offset: 0 }
72
+ ),
73
+ JsonStructure::ValidationError.new(
74
+ code: 2,
75
+ severity: JsonStructure::FFI::JS_SEVERITY_WARNING,
76
+ path: '/test',
77
+ message: 'Warning message',
78
+ location: { line: 1, column: 1, offset: 0 }
79
+ )
80
+ ]
81
+ result = described_class.new(false, errors)
82
+
83
+ expect(result.warning_messages).to eq(['Warning message'])
84
+ end
85
+ end
86
+ end
87
+
88
+ RSpec.describe JsonStructure::ValidationError do
89
+ describe '#error?' do
90
+ it 'returns true for error severity' do
91
+ error = described_class.new(
92
+ code: 1,
93
+ severity: JsonStructure::FFI::JS_SEVERITY_ERROR,
94
+ path: '/test',
95
+ message: 'Test',
96
+ location: { line: 1, column: 1, offset: 0 }
97
+ )
98
+
99
+ expect(error).to be_error
100
+ end
101
+
102
+ it 'returns false for non-error severity' do
103
+ warning = described_class.new(
104
+ code: 1,
105
+ severity: JsonStructure::FFI::JS_SEVERITY_WARNING,
106
+ path: '/test',
107
+ message: 'Test',
108
+ location: { line: 1, column: 1, offset: 0 }
109
+ )
110
+
111
+ expect(warning).not_to be_error
112
+ end
113
+ end
114
+
115
+ describe '#warning?' do
116
+ it 'returns true for warning severity' do
117
+ warning = described_class.new(
118
+ code: 1,
119
+ severity: JsonStructure::FFI::JS_SEVERITY_WARNING,
120
+ path: '/test',
121
+ message: 'Test',
122
+ location: { line: 1, column: 1, offset: 0 }
123
+ )
124
+
125
+ expect(warning).to be_warning
126
+ end
127
+ end
128
+
129
+ describe '#to_s' do
130
+ it 'includes path when present' do
131
+ error = described_class.new(
132
+ code: 1,
133
+ severity: JsonStructure::FFI::JS_SEVERITY_ERROR,
134
+ path: '/test/path',
135
+ message: 'Test error',
136
+ location: { line: 1, column: 1, offset: 0 }
137
+ )
138
+
139
+ expect(error.to_s).to include('Test error')
140
+ expect(error.to_s).to include('/test/path')
141
+ end
142
+
143
+ it 'works without path' do
144
+ error = described_class.new(
145
+ code: 1,
146
+ severity: JsonStructure::FFI::JS_SEVERITY_ERROR,
147
+ path: nil,
148
+ message: 'Test error',
149
+ location: { line: 1, column: 1, offset: 0 }
150
+ )
151
+
152
+ expect(error.to_s).to eq('Test error')
153
+ end
154
+ end
155
+ end