inferno_core 0.6.4 → 0.6.5

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/lib/inferno/apps/cli/evaluate.rb +1 -30
  3. data/lib/inferno/apps/cli/new.rb +1 -2
  4. data/lib/inferno/apps/cli/templates/.env.development +1 -0
  5. data/lib/inferno/apps/cli/templates/.env.production +1 -0
  6. data/lib/inferno/apps/cli/templates/.gitignore +1 -0
  7. data/lib/inferno/apps/cli/templates/data/igs/.keep +0 -0
  8. data/lib/inferno/apps/cli/templates/docker-compose.background.yml.tt +2 -2
  9. data/lib/inferno/apps/web/controllers/controller.rb +3 -1
  10. data/lib/inferno/apps/web/router.rb +12 -6
  11. data/lib/inferno/config/boot/ig_files.rb +47 -0
  12. data/lib/inferno/config/boot/validator.rb +1 -0
  13. data/lib/inferno/config/boot/web.rb +6 -2
  14. data/lib/inferno/dsl/assertions.rb +26 -0
  15. data/lib/inferno/dsl/fhir_client_builder.rb +1 -0
  16. data/lib/inferno/dsl/fhir_evaluation/rules/all_must_supports_present.rb +13 -308
  17. data/lib/inferno/dsl/fhir_resource_validation.rb +34 -2
  18. data/lib/inferno/dsl/fhir_validation.rb +13 -0
  19. data/lib/inferno/dsl/must_support_assessment.rb +365 -0
  20. data/lib/inferno/dsl/results.rb +36 -4
  21. data/lib/inferno/dsl/runnable.rb +71 -0
  22. data/lib/inferno/dsl.rb +3 -1
  23. data/lib/inferno/entities/ig.rb +4 -1
  24. data/lib/inferno/exceptions.rb +6 -0
  25. data/lib/inferno/public/bundle.js +34 -34
  26. data/lib/inferno/public/bundle.js.LICENSE.txt +3 -3
  27. data/lib/inferno/repositories/igs.rb +122 -0
  28. data/lib/inferno/repositories/in_memory_repository.rb +7 -0
  29. data/lib/inferno/utils/ig_downloader.rb +17 -6
  30. data/lib/inferno/version.rb +1 -1
  31. data/spec/shared/test_kit_examples.rb +69 -0
  32. metadata +5 -2
@@ -9,7 +9,7 @@ module Inferno
9
9
  #
10
10
  # @example
11
11
  #
12
- # validator do
12
+ # fhir_resource_validator do
13
13
  # url 'http://example.com/validator'
14
14
  # exclude_message { |message| message.type == 'info' }
15
15
  # perform_additional_validation do |resource, profile_url|
@@ -19,12 +19,24 @@ module Inferno
19
19
  # { type: 'info', message: 'everything is ok' }
20
20
  # end
21
21
  # end
22
+ # cli_context do
23
+ # noExtensibleBindingMessages true
24
+ # allowExampleUrls true
25
+ # txServer nil
26
+ # end
22
27
  # end
23
28
  module FHIRResourceValidation
24
29
  def self.included(klass)
25
30
  klass.extend ClassMethods
26
31
  end
27
32
 
33
+ # Find a particular profile StructureDefinition and the IG it belongs to.
34
+ # Looks through a runnable's parents up to the suite to find a validator with a particular name,
35
+ # then finds the profile by looking through its defined igs.
36
+ def find_ig_and_profile(profile_url, validator_name)
37
+ self.class.find_ig_and_profile(profile_url, validator_name)
38
+ end
39
+
28
40
  class Validator
29
41
  attr_reader :requirements
30
42
  attr_accessor :session_id, :name, :test_suite_id
@@ -62,7 +74,7 @@ module Inferno
62
74
  # igs("hl7.fhir.us.core#3.1.1", "hl7.fhir.us.core#6.0.0")
63
75
  # @param validator_igs [Array<String>]
64
76
  def igs(*validator_igs)
65
- cli_context(igs: validator_igs) if validator_igs
77
+ cli_context(igs: validator_igs) if validator_igs.any?
66
78
 
67
79
  cli_context.igs
68
80
  end
@@ -193,8 +205,14 @@ module Inferno
193
205
  'Error occurred in the validator. Review Messages tab or validator service logs for more information.'
194
206
  end
195
207
 
208
+ # @private
209
+ def exclude_unresolved_url_message
210
+ proc { |message| message.message.match?(/\A\S+: [^:]+: URL value '.*' does not resolve/) }
211
+ end
212
+
196
213
  # @private
197
214
  def filter_messages(message_hashes)
215
+ message_hashes.reject! { |message| exclude_unresolved_url_message.call(Entities::Message.new(message)) }
198
216
  message_hashes.reject! { |message| exclude_message.call(Entities::Message.new(message)) } if exclude_message
199
217
  end
200
218
 
@@ -392,6 +410,20 @@ module Inferno
392
410
 
393
411
  fhir_validators[name] = current_validators
394
412
  end
413
+
414
+ # @private
415
+ def find_ig_and_profile(profile_url, validator_name)
416
+ validator = find_validator(validator_name)
417
+ if validator.is_a? Inferno::DSL::FHIRResourceValidation::Validator
418
+ validator.igs.each do |ig_id|
419
+ ig = Inferno::Repositories::IGs.new.find_or_load(ig_id)
420
+ profile = ig.profile_by_url(profile_url)
421
+ return ig, profile if profile
422
+ end
423
+ end
424
+
425
+ raise "Unable to find profile #{profile_url} in any IG defined for validator #{validator_name}"
426
+ end
395
427
  end
396
428
  end
397
429
  end
@@ -7,6 +7,7 @@ module Inferno
7
7
  # `assert_valid_resource` for validation rather than directly calling
8
8
  # methods on a validator.
9
9
  #
10
+ # @deprecated Use {Inferno::DSL::FHIRResourceValidation} instead
10
11
  # @example
11
12
  #
12
13
  # validator do
@@ -152,8 +153,14 @@ module Inferno
152
153
  'Error occurred in the validator. Review Messages tab or validator service logs for more information.'
153
154
  end
154
155
 
156
+ # @private
157
+ def exclude_unresolved_url_message
158
+ proc { |message| message.message.match?(/\A\S+: [^:]+: URL value '.*' does not resolve/) }
159
+ end
160
+
155
161
  # @private
156
162
  def filter_messages(message_hashes)
163
+ message_hashes.reject! { |message| exclude_unresolved_url_message.call(Entities::Message.new(message)) }
157
164
  message_hashes.reject! { |message| exclude_message.call(Entities::Message.new(message)) } if exclude_message
158
165
  end
159
166
 
@@ -243,6 +250,8 @@ module Inferno
243
250
  end
244
251
 
245
252
  # Define a validator
253
+ # @deprecated Use
254
+ # {Inferno::DSL::FHIRResourceValidation::ClassMethods#fhir_resource_validator} instead
246
255
  # @example
247
256
  # validator do
248
257
  # url 'http://example.com/validator'
@@ -261,6 +270,10 @@ module Inferno
261
270
  # @param required_suite_options [Hash] suite options that must be
262
271
  # selected in order to use this validator
263
272
  def validator(name = :default, required_suite_options: nil, &)
273
+ Inferno::Application['logger'].warn(
274
+ "'validator' in '#{suite.id}' TestSuite is deprecated and will be removed in an upcoming release. " \
275
+ "Use 'fhir_resource_validator' instead."
276
+ )
264
277
  current_validators = fhir_validators[name] || []
265
278
 
266
279
  new_validator = Inferno::DSL::FHIRValidation::Validator.new(required_suite_options, &)
@@ -0,0 +1,365 @@
1
+ module Inferno
2
+ module DSL
3
+ # The MustSupportAssessment module contains the logic for tests
4
+ # that check "All Must Support elements are present".
5
+ # Generally, test authors should use `assert_must_support_elements_present`
6
+ # or `missing_must_support_elements` DSL methods.
7
+ # A few additional methods are exposed to support the transition of existing tests that
8
+ # call into these methods directly.
9
+ module MustSupportAssessment
10
+ # Find any Must Support elements defined on the given profile that are missing in the given resources.
11
+ # Must Support elements are identified on the profile StructureDefinition and pre-parsed into metadata,
12
+ # which may be customized prior to the check by passing a block. Alternate metadata may be provided directly.
13
+ # Set test suite config flag debug_must_support_metadata: true to log the metadata to a file for debugging.
14
+ #
15
+ # @param resources [Array<FHIR::Resource>]
16
+ # @param profile_url [String]
17
+ # @param validator_name [Symbol] Name of the FHIR Validator that references the IG the profile is in
18
+ # @param metadata [Hash] MustSupport Metadata (optional),
19
+ # if provided the check will use this instead of re-generating metadata from the profile
20
+ # @param requirement_extension [String] Extension URL that implies "required" as an alternative to the MS flag
21
+ # @yield [Metadata] Customize the metadata before running the test
22
+ # @return [Array<String>] List of missing elements
23
+ def missing_must_support_elements(resources, profile_url, validator_name: :default, metadata: nil,
24
+ requirement_extension: nil, &)
25
+ debug_metadata = config.options[:debug_must_support_metadata]
26
+
27
+ if metadata.present?
28
+ InternalMustSupportLogic.new.perform_must_support_test_with_metadata(resources, metadata, debug_metadata:)
29
+ else
30
+ ig, profile = find_ig_and_profile(profile_url, validator_name)
31
+ perform_must_support_assessment(profile, resources, ig, debug_metadata:, requirement_extension:, &)
32
+ end
33
+ end
34
+
35
+ # perform_must_support_assessment allows customizing the metadata with a block.
36
+ # Customizing the metadata may add, modify, or remove items.
37
+ # For instance, US Core 3.1.1 Patient "Previous Name" is defined as MS only in narrative.
38
+ # Choices are also defined only in narrative.
39
+ # @param profile [FHIR::StructureDefinition]
40
+ # @param resources [Array<FHIR::Model>]
41
+ # @param ig [Inferno::Entities::IG]
42
+ # @param debug_metadata [Boolean] if true, write out the final metadata used to a temporary file
43
+ # @param requirement_extension [String] Extension URL that implies "required" as an alternative to the MS flag
44
+ # @yield [Metadata] Customize the metadata before running the test
45
+ # @return [Array<String>] list of elements that were not found in the provided resources
46
+ def perform_must_support_assessment(profile, resources, ig, debug_metadata: false, requirement_extension: nil)
47
+ test_impl = InternalMustSupportLogic.new
48
+ profile_metadata = test_impl.extract_metadata(profile, ig, requirement_extension:)
49
+ yield profile_metadata if block_given?
50
+
51
+ test_impl.perform_must_support_test_with_metadata(resources, profile_metadata, debug_metadata:)
52
+ end
53
+
54
+ def find_missing_elements(resources, must_support_elements)
55
+ InternalMustSupportLogic.new(metadata).find_missing_elements(resources, must_support_elements)
56
+ end
57
+
58
+ def missing_element_string(element_definition)
59
+ InternalMustSupportLogic.new.missing_element_string(element_definition)
60
+ end
61
+
62
+ # @private
63
+ class InternalMustSupportLogic
64
+ include FHIRResourceNavigation
65
+
66
+ attr_accessor :metadata
67
+
68
+ def initialize(metadata = nil)
69
+ @metadata = metadata
70
+ end
71
+
72
+ # perform_must_support_test_with_metadata is invoked from check and perform_must_support_test,
73
+ # with the metadata to be used as the basis for the test.
74
+ # It may also be invoked directly from a test if you want to completely overwrite the metadata.
75
+ # @param resources [Array<FHIR::Model>]
76
+ # @param profile_metadata [Metadata] Metadata object with must_supports field
77
+ # @param debug_metadata [Boolean] if true, write out the final metadata used to a temporary file
78
+ # @return [Array<String>] list of elements that were not found in the provided resources
79
+ def perform_must_support_test_with_metadata(resources, profile_metadata, debug_metadata: false)
80
+ return if resources.blank?
81
+
82
+ @metadata = profile_metadata
83
+
84
+ write_metadata_for_debugging if debug_metadata
85
+
86
+ perform_test(resources)
87
+ end
88
+
89
+ def extract_metadata(profile, ig, requirement_extension: nil)
90
+ MustSupportMetadataExtractor.new(profile.snapshot.element, profile, profile.type, ig, requirement_extension)
91
+ end
92
+
93
+ def write_metadata_for_debugging
94
+ outfile = "#{metadata.profile&.id}-#{SecureRandom.uuid}.yml"
95
+
96
+ File.open(File.join(Dir.tmpdir, outfile), 'w') do |f|
97
+ writable_metadata = { must_supports: @metadata.must_supports.to_hash }
98
+ f.write(YAML.dump(writable_metadata))
99
+ puts "Wrote MustSupport metadata to #{f.path}"
100
+ end
101
+ end
102
+
103
+ def perform_test(resources)
104
+ missing_elements(resources)
105
+ missing_slices(resources)
106
+ missing_extensions(resources)
107
+
108
+ handle_must_support_choices if metadata.must_supports[:choices].present?
109
+
110
+ missing_must_support_strings
111
+ end
112
+
113
+ def handle_must_support_choices
114
+ handle_must_support_element_choices
115
+ handle_must_support_extension_choices
116
+ handle_must_support_slice_choices
117
+ end
118
+
119
+ def handle_must_support_element_choices
120
+ missing_elements.delete_if do |element|
121
+ choices = metadata.must_supports[:choices].find do |choice|
122
+ choice[:paths]&.include?(element[:path]) ||
123
+ choice[:elements]&.any? { |ms_element| ms_element[:path] == element[:path] }
124
+ end
125
+ any_choice_supported?(choices)
126
+ end
127
+ end
128
+
129
+ def handle_must_support_extension_choices
130
+ missing_extensions.delete_if do |extension|
131
+ choices = metadata.must_supports[:choices].find do |choice|
132
+ choice[:extension_ids]&.include?(extension[:id])
133
+ end
134
+ any_choice_supported?(choices)
135
+ end
136
+ end
137
+
138
+ def handle_must_support_slice_choices
139
+ missing_slices.delete_if do |slice|
140
+ choices = metadata.must_supports[:choices].find { |choice| choice[:slice_names]&.include?(slice[:name]) }
141
+ any_choice_supported?(choices)
142
+ end
143
+ end
144
+
145
+ def any_choice_supported?(choices)
146
+ return false unless choices.present?
147
+
148
+ any_path_choice_supported?(choices) ||
149
+ any_extension_ids_choice_supported?(choices) ||
150
+ any_slice_names_choice_supported?(choices) ||
151
+ any_elements_choice_supported?(choices)
152
+ end
153
+
154
+ def any_path_choice_supported?(choices)
155
+ return false unless choices[:paths].present?
156
+
157
+ choices[:paths].any? { |path| missing_elements.none? { |element| element[:path] == path } }
158
+ end
159
+
160
+ def any_extension_ids_choice_supported?(choices)
161
+ return false unless choices[:extension_ids].present?
162
+
163
+ choices[:extension_ids].any? do |extension_id|
164
+ missing_extensions.none? { |extension| extension[:id] == extension_id }
165
+ end
166
+ end
167
+
168
+ def any_slice_names_choice_supported?(choices)
169
+ return false unless choices[:slice_names].present?
170
+
171
+ choices[:slice_names].any? { |slice_name| missing_slices.none? { |slice| slice[:name] == slice_name } }
172
+ end
173
+
174
+ def any_elements_choice_supported?(choices)
175
+ return false unless choices[:elements].present?
176
+
177
+ choices[:elements].any? do |choice|
178
+ missing_elements.none? do |element|
179
+ element[:path] == choice[:path] && element[:fixed_value] == choice[:fixed_value]
180
+ end
181
+ end
182
+ end
183
+
184
+ def missing_must_support_strings
185
+ missing_elements.map { |element_definition| missing_element_string(element_definition) } +
186
+ missing_slices.map { |slice_definition| slice_definition[:slice_id] } +
187
+ missing_extensions.map { |extension_definition| extension_definition[:id] }
188
+ end
189
+
190
+ def missing_element_string(element_definition)
191
+ if element_definition[:fixed_value].present?
192
+ "#{element_definition[:path]}:#{element_definition[:fixed_value]}"
193
+ else
194
+ element_definition[:path]
195
+ end
196
+ end
197
+
198
+ def must_support_extensions
199
+ metadata.must_supports[:extensions]
200
+ end
201
+
202
+ def missing_extensions(resources = [])
203
+ @missing_extensions ||=
204
+ must_support_extensions.select do |extension_definition|
205
+ resources.none? do |resource|
206
+ path = extension_definition[:path]
207
+
208
+ if path == 'extension'
209
+ resource.extension.any? { |extension| extension.url == extension_definition[:url] }
210
+ else
211
+ extension = find_a_value_at(resource, path) do |el|
212
+ el.url == extension_definition[:url]
213
+ end
214
+
215
+ extension.present?
216
+ end
217
+ end
218
+ end
219
+ end
220
+
221
+ def must_support_elements
222
+ metadata.must_supports[:elements]
223
+ end
224
+
225
+ def missing_elements(resources = [])
226
+ @missing_elements ||= find_missing_elements(resources, must_support_elements)
227
+ end
228
+
229
+ def find_missing_elements(resources, must_support_elements)
230
+ must_support_elements.select do |element_definition|
231
+ resources.none? { |resource| resource_populates_element?(resource, element_definition) }
232
+ end
233
+ end
234
+
235
+ def resource_populates_element?(resource, element_definition)
236
+ path = element_definition[:path]
237
+ ms_extension_urls = must_support_extensions.select { |ex| ex[:path] == "#{path}.extension" }
238
+ .map { |ex| ex[:url] }
239
+
240
+ value_found = find_a_value_at(resource, path) do |potential_value|
241
+ matching_without_extensions?(potential_value, ms_extension_urls, element_definition[:fixed_value])
242
+ end
243
+
244
+ # Note that false.present? => false, which is why we need to add this extra check
245
+ value_found.present? || value_found == false
246
+ end
247
+
248
+ def matching_without_extensions?(value, ms_extension_urls, fixed_value)
249
+ if value.instance_of?(Inferno::DSL::PrimitiveType)
250
+ urls = value.extension&.map(&:url)
251
+ has_ms_extension = (urls & ms_extension_urls).present?
252
+ value = value.value
253
+ end
254
+
255
+ return false unless has_ms_extension || value_without_extensions?(value)
256
+
257
+ matches_fixed_value?(value, fixed_value)
258
+ end
259
+
260
+ def matches_fixed_value?(value, fixed_value)
261
+ fixed_value.blank? || value == fixed_value
262
+ end
263
+
264
+ def value_without_extensions?(value)
265
+ value_without_extensions = value.respond_to?(:to_hash) ? value.to_hash.except('extension') : value
266
+ value_without_extensions.present? || value_without_extensions == false
267
+ end
268
+
269
+ def must_support_slices
270
+ metadata.must_supports[:slices]
271
+ end
272
+
273
+ def missing_slices(resources = [])
274
+ @missing_slices ||=
275
+ must_support_slices.select do |slice|
276
+ resources.none? do |resource|
277
+ path = slice[:path]
278
+ find_slice(resource, path, slice[:discriminator]).present?
279
+ end
280
+ end
281
+ end
282
+
283
+ def find_slice(resource, path, discriminator)
284
+ # TODO: there is a lot of similarity
285
+ # between this and FHIRResourceNavigation.matching_slice?
286
+ # Can these be combined?
287
+ find_a_value_at(resource, path) do |element|
288
+ case discriminator[:type]
289
+ when 'patternCodeableConcept'
290
+ find_pattern_codeable_concept_slice(element, discriminator)
291
+ when 'patternCoding'
292
+ find_pattern_coding_slice(element, discriminator)
293
+ when 'patternIdentifier'
294
+ find_pattern_identifier_slice(element, discriminator)
295
+ when 'value'
296
+ find_value_slice(element, discriminator)
297
+ when 'type'
298
+ find_type_slice(element, discriminator)
299
+ when 'requiredBinding'
300
+ find_required_binding_slice(element, discriminator)
301
+ end
302
+ end
303
+ end
304
+
305
+ def find_pattern_codeable_concept_slice(element, discriminator)
306
+ coding_path = discriminator[:path].present? ? "#{discriminator[:path]}.coding" : 'coding'
307
+ find_a_value_at(element, coding_path) do |coding|
308
+ coding.code == discriminator[:code] && coding.system == discriminator[:system]
309
+ end
310
+ end
311
+
312
+ def find_pattern_coding_slice(element, discriminator)
313
+ coding_path = discriminator[:path].present? ? discriminator[:path] : ''
314
+ find_a_value_at(element, coding_path) do |coding|
315
+ coding.code == discriminator[:code] && coding.system == discriminator[:system]
316
+ end
317
+ end
318
+
319
+ def find_pattern_identifier_slice(element, discriminator)
320
+ find_a_value_at(element, discriminator[:path]) do |identifier|
321
+ identifier.system == discriminator[:system]
322
+ end
323
+ end
324
+
325
+ def find_value_slice(element, discriminator)
326
+ values = discriminator[:values].map { |value| value.merge(path: value[:path].split('.')) }
327
+ find_slice_by_values(element, values)
328
+ end
329
+
330
+ def find_type_slice(element, discriminator)
331
+ case discriminator[:code]
332
+ when 'Date'
333
+ begin
334
+ Date.parse(element)
335
+ rescue ArgumentError
336
+ false
337
+ end
338
+ when 'DateTime'
339
+ begin
340
+ DateTime.parse(element)
341
+ rescue ArgumentError
342
+ false
343
+ end
344
+ when 'String'
345
+ element.is_a? String
346
+ else
347
+ element.is_a? FHIR.const_get(discriminator[:code])
348
+ end
349
+ end
350
+
351
+ def find_required_binding_slice(element, discriminator)
352
+ coding_path = discriminator[:path].present? ? "#{discriminator[:path]}.coding" : 'coding'
353
+
354
+ find_a_value_at(element, coding_path) do |coding|
355
+ discriminator[:values].any? { |value| value[:system] == coding.system && value[:code] == coding.code }
356
+ end
357
+ end
358
+
359
+ def find_slice_by_values(element, value_definitions)
360
+ Array.wrap(element).find { |el| verify_slice_by_values(el, value_definitions) }
361
+ end
362
+ end
363
+ end
364
+ end
365
+ end
@@ -20,12 +20,28 @@ module Inferno
20
20
  raise Exceptions::PassException, message if test
21
21
  end
22
22
 
23
- # Halt execution of the current test and mark it as skipped.
23
+ # Halt execution of the current test and mark it as skipped. This method
24
+ # can also take a block with an assertion, and if the assertion fails, the
25
+ # test will skip rather than fail. The message parameter is ignored if a
26
+ # block is provided.
24
27
  #
25
28
  # @param message [String]
26
29
  # @return [void]
30
+ #
31
+ # @example
32
+ # if some_precondition_not_met?
33
+ # skip('Some precondition was not met.')
34
+ # end
35
+ #
36
+ # skip do
37
+ # assert false, 'This test will skip rather than fail'
38
+ # end
27
39
  def skip(message = '')
28
- raise Exceptions::SkipException, message
40
+ raise Exceptions::SkipException, message unless block_given?
41
+
42
+ yield
43
+ rescue Exceptions::AssertionException => e
44
+ raise Exceptions::SkipException, e.message
29
45
  end
30
46
 
31
47
  # Halt execution of the current test and mark it as skipped if a condition
@@ -38,12 +54,28 @@ module Inferno
38
54
  raise Exceptions::SkipException, message if test
39
55
  end
40
56
 
41
- # Halt execution of the current test and mark it as omitted.
57
+ # Halt execution of the current test and mark it as omitted. This method
58
+ # can also take a block with an assertion, and if the assertion fails, the
59
+ # test will omit rather than fail. The message parameter is ignored if a
60
+ # block is provided.
42
61
  #
43
62
  # @param message [String]
44
63
  # @return [void]
64
+ #
65
+ # @example
66
+ # if behavior_does_not_need_to_be_tested?
67
+ # omit('Behavior does not need to be tested.')
68
+ # end
69
+ #
70
+ # omit do
71
+ # assert false, 'This test will omit rather than fail'
72
+ # end
45
73
  def omit(message = '')
46
- raise Exceptions::OmitException, message
74
+ raise Exceptions::OmitException, message unless block_given?
75
+
76
+ yield
77
+ rescue Exceptions::AssertionException => e
78
+ raise Exceptions::OmitException, e.message
47
79
  end
48
80
 
49
81
  # Halt execution of the current test and mark it as omitted if a condition
@@ -81,6 +81,12 @@ module Inferno
81
81
  repository.insert(self)
82
82
  end
83
83
 
84
+ # @private
85
+ def remove_self_from_repository
86
+ repository.remove(self)
87
+ children.each(&:remove_self_from_repository)
88
+ end
89
+
84
90
  # An instance of the repository for the class using this module
85
91
  # @private
86
92
  def repository
@@ -464,6 +470,71 @@ module Inferno
464
470
  end
465
471
  end
466
472
 
473
+ # Move a child test/group to a new position within the children list.
474
+ #
475
+ # @param child_id [Symbol, String] The ID of the child to be moved.
476
+ # @param new_index [Integer] The new position for the child.
477
+ # @example
478
+ # reorder(:test3, 1) # Moves `test3` to index 1
479
+ #
480
+ def reorder(child_id, new_index)
481
+ index = children.find_index { |child| child.id.to_s.end_with? child_id.to_s }
482
+ raise Exceptions::RunnableChildNotFoundException.new(child_id, self) unless index
483
+
484
+ unless new_index.between?(0, children.length - 1)
485
+ Inferno::Application[:logger].error <<~ERROR
486
+ Error trying to reorder children for #{self}:
487
+ new_index #{new_index} for #{child_id} is out of range
488
+ (must be between 0 and #{children.length - 1})
489
+ ERROR
490
+ return
491
+ end
492
+
493
+ child = children.delete_at(index)
494
+ children.insert(new_index, child)
495
+ end
496
+
497
+ # Replace a child test/group
498
+ #
499
+ # @param id_to_replace [Symbol, String] The ID of the child to be replaced.
500
+ # @param replacement_id [Symbol, String] The global ID of the group/test that will take the
501
+ # place of the child being replaced.
502
+ # @yield [Inferno::TestGroup, Inferno::Test] Optional block executed in the
503
+ # context of the replacement child for additional configuration.
504
+ # @example
505
+ # replace :test2, :test4 do
506
+ # id :new_test_id
507
+ # config(...)
508
+ # end
509
+ def replace(id_to_replace, replacement_id, &)
510
+ index = children.find_index { |child| child.id.to_s.end_with? id_to_replace.to_s }
511
+ raise Exceptions::RunnableChildNotFoundException.new(id_to_replace, self) unless index
512
+
513
+ if children[index] < Inferno::TestGroup
514
+ group(from: replacement_id, &)
515
+ else
516
+ test(from: replacement_id, &)
517
+ end
518
+
519
+ remove(id_to_replace)
520
+ children.insert(index, children.pop)
521
+ end
522
+
523
+ # Remove a child test/group
524
+ #
525
+ # @param id_to_remove [Symbol, String]
526
+ # @example
527
+ # test from: :test1
528
+ # test from: :test2
529
+ # test from: :test3
530
+ #
531
+ # remove :test2
532
+ def remove(id_to_remove)
533
+ removed = children.select { |child| child.id.to_s.end_with? id_to_remove.to_s }
534
+ children.reject! { |child| child.id.to_s.end_with? id_to_remove.to_s }
535
+ removed.each(&:remove_self_from_repository)
536
+ end
537
+
467
538
  # @private
468
539
  def children(selected_suite_options = [])
469
540
  return all_children if selected_suite_options.blank?
data/lib/inferno/dsl.rb CHANGED
@@ -6,6 +6,7 @@ require_relative 'dsl/fhir_evaluation/evaluator'
6
6
  require_relative 'dsl/fhir_resource_validation'
7
7
  require_relative 'dsl/fhirpath_evaluation'
8
8
  require_relative 'dsl/http_client'
9
+ require_relative 'dsl/must_support_assessment'
9
10
  require_relative 'dsl/results'
10
11
  require_relative 'dsl/runnable'
11
12
  require_relative 'dsl/suite_endpoint'
@@ -23,7 +24,8 @@ module Inferno
23
24
  FHIREvaluation,
24
25
  FHIRResourceValidation,
25
26
  FhirpathEvaluation,
26
- Messages
27
+ Messages,
28
+ MustSupportAssessment
27
29
  ].freeze
28
30
 
29
31
  EXTENDABLE_DSL_MODULES = [
@@ -15,7 +15,8 @@ module Inferno
15
15
  ATTRIBUTES = [
16
16
  :id,
17
17
  :resources_by_type,
18
- :examples
18
+ :examples,
19
+ :source_path
19
20
  ].freeze
20
21
 
21
22
  include Inferno::Entities::Attributes
@@ -66,6 +67,7 @@ module Inferno
66
67
  end
67
68
 
68
69
  ig.id = extract_package_id(ig.ig_resource)
70
+ ig.source_path = ig_path
69
71
 
70
72
  ig
71
73
  end
@@ -89,6 +91,7 @@ module Inferno
89
91
  end
90
92
 
91
93
  ig.id = extract_package_id(ig.ig_resource)
94
+ ig.source_path = ig_directory
92
95
 
93
96
  ig
94
97
  end
@@ -125,5 +125,11 @@ module Inferno
125
125
  super("ID '#{id}' already exists. Ensure the uniqueness of the IDs.")
126
126
  end
127
127
  end
128
+
129
+ class RunnableChildNotFoundException < StandardError
130
+ def initialize(id, runnable)
131
+ super("Could not find a child with an ID ending in '#{id}' for '#{runnable}'.")
132
+ end
133
+ end
128
134
  end
129
135
  end