inferno_core 0.6.8 → 0.6.10

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/lib/inferno/apps/cli/evaluate/database.yml +15 -0
  3. data/lib/inferno/apps/cli/evaluate/docker-compose.evaluate.yml +16 -0
  4. data/lib/inferno/apps/cli/evaluate.rb +52 -4
  5. data/lib/inferno/apps/cli/main.rb +5 -1
  6. data/lib/inferno/apps/cli/requirements.rb +28 -0
  7. data/lib/inferno/apps/cli/requirements_exporter.rb +194 -0
  8. data/lib/inferno/apps/cli/suite.rb +21 -0
  9. data/lib/inferno/apps/cli/templates/lib/%library_name%/example_suite/patient_group.rb.tt +141 -0
  10. data/lib/inferno/apps/cli/templates/lib/%library_name%/example_suite.rb.tt +128 -0
  11. data/lib/inferno/apps/cli/templates/lib/%library_name%/metadata.rb.tt +65 -3
  12. data/lib/inferno/apps/cli/templates/lib/%library_name%/suite.rb.tt +2 -2
  13. data/lib/inferno/apps/cli/templates/lib/%library_name%/version.rb.tt +1 -0
  14. data/lib/inferno/apps/cli/templates/lib/%library_name%.rb.tt +1 -1
  15. data/lib/inferno/apps/web/controllers/requirements/show.rb +18 -0
  16. data/lib/inferno/apps/web/controllers/test_suites/requirements/index.rb +29 -0
  17. data/lib/inferno/apps/web/router.rb +7 -0
  18. data/lib/inferno/apps/web/serializers/input.rb +1 -0
  19. data/lib/inferno/apps/web/serializers/requirement.rb +18 -0
  20. data/lib/inferno/apps/web/serializers/requirement_set.rb +13 -0
  21. data/lib/inferno/apps/web/serializers/test.rb +1 -0
  22. data/lib/inferno/apps/web/serializers/test_group.rb +1 -0
  23. data/lib/inferno/apps/web/serializers/test_suite.rb +11 -0
  24. data/lib/inferno/config/boot/requirements.rb +40 -0
  25. data/lib/inferno/config/boot/suites.rb +3 -0
  26. data/lib/inferno/dsl/fhir_evaluation/default.yml +68 -0
  27. data/lib/inferno/dsl/fhir_evaluation/evaluator.rb +3 -5
  28. data/lib/inferno/dsl/fhir_evaluation/rules/all_defined_extensions_have_examples.rb +2 -2
  29. data/lib/inferno/dsl/fhir_evaluation/rules/all_extensions_used.rb +76 -0
  30. data/lib/inferno/dsl/fhir_evaluation/rules/all_must_supports_present.rb +1 -1
  31. data/lib/inferno/dsl/fhir_evaluation/rules/all_profiles_have_examples.rb +1 -1
  32. data/lib/inferno/dsl/fhir_evaluation/rules/all_references_resolve.rb +2 -2
  33. data/lib/inferno/dsl/fhir_evaluation/rules/all_resources_reachable.rb +2 -2
  34. data/lib/inferno/dsl/fhir_evaluation/rules/all_search_parameters_have_examples.rb +22 -11
  35. data/lib/inferno/dsl/fhir_evaluation/rules/differential_content_has_examples.rb +124 -0
  36. data/lib/inferno/dsl/fhir_evaluation/rules/value_sets_demonstrate.rb +233 -0
  37. data/lib/inferno/dsl/fhir_resource_navigation.rb +11 -2
  38. data/lib/inferno/dsl/fhir_resource_validation.rb +25 -3
  39. data/lib/inferno/dsl/fhirpath_evaluation.rb +25 -1
  40. data/lib/inferno/dsl/input_output_handling.rb +1 -0
  41. data/lib/inferno/dsl/must_support_assessment.rb +15 -3
  42. data/lib/inferno/dsl/requirement_set.rb +82 -0
  43. data/lib/inferno/dsl/runnable.rb +27 -0
  44. data/lib/inferno/dsl/short_id_manager.rb +55 -0
  45. data/lib/inferno/dsl/suite_requirements.rb +46 -0
  46. data/lib/inferno/entities/ig.rb +4 -0
  47. data/lib/inferno/entities/input.rb +14 -5
  48. data/lib/inferno/entities/requirement.rb +75 -0
  49. data/lib/inferno/entities/test.rb +3 -1
  50. data/lib/inferno/entities/test_group.rb +3 -1
  51. data/lib/inferno/entities/test_suite.rb +4 -0
  52. data/lib/inferno/exceptions.rb +6 -0
  53. data/lib/inferno/public/237.bundle.js +1 -1
  54. data/lib/inferno/public/bundle.js +54 -54
  55. data/lib/inferno/public/bundle.js.LICENSE.txt +3 -36
  56. data/lib/inferno/repositories/igs.rb +1 -2
  57. data/lib/inferno/repositories/requirements.rb +120 -0
  58. data/lib/inferno/version.rb +1 -1
  59. data/spec/shared/test_kit_examples.rb +32 -0
  60. metadata +36 -3
  61. data/lib/inferno/apps/cli/templates/lib/%library_name%/patient_group.rb.tt +0 -44
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Inferno
4
+ module DSL
5
+ module FHIREvaluation
6
+ module Rules
7
+ class DifferentialContentHasExamples < Rule
8
+ def check(context)
9
+ unused_differential = Hash.new { |field, url| field[url] = Set.new }
10
+ collect_profile_differential_content(unused_differential, context.ig.profiles)
11
+ collect_profile_differential_content(unused_differential, context.ig.extensions)
12
+ remove_found_differential_content(unused_differential, context.data)
13
+
14
+ if unused_differential.any? { |_url, diff| !diff.empty? }
15
+ message = gen_differential_fail_message(unused_differential)
16
+ result = EvaluationResult.new(message, rule: self)
17
+ else
18
+ message = 'All differential fields are used in examples.'
19
+ result = EvaluationResult.new(message, severity: 'success', rule: self)
20
+ end
21
+
22
+ context.add_result result
23
+ end
24
+
25
+ def collect_profile_differential_content(unused_differential, profiles)
26
+ profiles.each do |profile|
27
+ profile.each_element do |value, _metadata, path|
28
+ next unless path.start_with? 'differential'
29
+
30
+ next unless value.is_a? FHIR::ElementDefinition
31
+ next unless value.id.include? '.'
32
+
33
+ # Skip fields that are disallowed by the profile (cardinality 0..0)
34
+ # Note that max is a string to allow for "*", not an int
35
+ next if value.max == '0'
36
+
37
+ # TODO: discriminate between extensions
38
+ # if you have field.extension:A and field.extension:B
39
+ # only field.extension is recorded and checked for
40
+ # if A and B are not defined in the IG,they may be missed
41
+ clean_val = clean_value(value)
42
+
43
+ unused_differential[profile.url].add(clean_val)
44
+ end
45
+ end
46
+ end
47
+
48
+ # rubocop:disable Metrics/CyclomaticComplexity
49
+ def remove_found_differential_content(unused_differential, examples)
50
+ examples.each do |resource|
51
+ extension_base_path = ''
52
+ extension_url = ''
53
+ resource.each_element do |value, _metadata, path|
54
+ profiles = resource&.meta&.profile || []
55
+ profiles.each do |profile|
56
+ processed_path = plain_value(path)
57
+ processed_path = rm_brackets(processed_path)
58
+
59
+ if path.match?('extension\[\d+\]\.url$')
60
+ extension_base_path = path.rpartition('.').first
61
+ extension_url = value
62
+ unused_differential[extension_url].delete('url') if unused_differential.key?(extension_url)
63
+ unused_differential[extension_url].delete('extension') if unused_differential.key?(extension_url)
64
+ unused_differential.delete(extension_url) if unused_differential[extension_url].empty?
65
+ elsif path.start_with?(extension_base_path) && !extension_base_path.empty?
66
+ if unused_differential.key?(extension_url)
67
+ unused_differential[extension_url].delete(processed_path.rpartition('.').last)
68
+ end
69
+ unused_differential.delete(extension_url) if unused_differential[extension_url].empty?
70
+ else
71
+ unused_differential[profile].delete(processed_path) if unused_differential.key?(profile)
72
+ unused_differential.delete(profile) if unused_differential[profile].empty?
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ # rubocop:enable Metrics/CyclomaticComplexity
79
+
80
+ def clean_value(value)
81
+ stripped_val = value.id.partition('.').last
82
+ stripped_val = stripped_val.partition('[').first if stripped_val.end_with? ']'
83
+ stripped_val.split('.').map do |field|
84
+ field = field.partition(':').first if field.include?(':')
85
+ field = field.partition('[').first if field.include?('[')
86
+ field
87
+ end.join('.')
88
+ end
89
+
90
+ def plain_value(path)
91
+ if path.include? '.'
92
+ path_array = path.split('.').map! do |field|
93
+ field.start_with?('value') ? 'value' : field
94
+ end
95
+ path_array.join('.')
96
+ elsif path.start_with?('value')
97
+ 'value'
98
+ elsif path.end_with?(']')
99
+ path.rpartition('[').first
100
+ else
101
+ path
102
+ end
103
+ end
104
+
105
+ def rm_brackets(path)
106
+ path_array = path.split('.').map! do |field|
107
+ field.include?('[') ? field.partition('[').first : field
108
+ end
109
+ path_array.join('.')
110
+ end
111
+
112
+ def gen_differential_fail_message(unused_differential)
113
+ "Found fields highlighted in the differential view, but not used in examples: #{
114
+ unused_differential.map do |url, field|
115
+ next if field.empty?
116
+
117
+ "\n Profile/Extension: #{url} \n\tFields: #{field.join(', ')}"
118
+ end.compact.join}"
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,233 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require 'base64'
7
+
8
+ module Inferno
9
+ module DSL
10
+ module FHIREvaluation
11
+ module Rules
12
+ # This rule evaluates if an IG defines a new valueset, the examples should
13
+ # demonstrate reasonable coverage of that valueset.
14
+ # Note this probably only makes sense for small valuesets
15
+ # such as status options, not something like disease codes from SNOMED.
16
+
17
+ # Algorithm:
18
+ # 1. Extract pairs of system and code from include in value sets in IG
19
+ # 2. If valueSet exists in include, retrieve the value sets from UMLS.
20
+ # Extract pairs of system and code from the result.
21
+ # 3. For each pair of system and code, check if any resources in the IG have instance of them.
22
+ # 4. Count total number of existences.
23
+
24
+ class ValueSetsDemonstrate < Rule
25
+ attr_accessor :config, :value_set_unevaluated, :value_set_used, :value_set_unused
26
+
27
+ def check(context)
28
+ @config = context.config
29
+ @value_set_used = []
30
+ @value_set_unused = []
31
+ @value_set_unevaluated = []
32
+
33
+ classify_valuesets(context)
34
+
35
+ context.add_result create_result_message
36
+ end
37
+
38
+ # rubocop:disable Metrics/CyclomaticComplexity
39
+ def classify_valuesets(context)
40
+ context.ig.value_sets.each do |valueset|
41
+ valueset_used_count = 0
42
+ system_codes = extract_systems_codes_from_valueset(valueset)
43
+
44
+ value_set_unevaluated << "#{valueset.url}: unable to find system and code" if system_codes.none?
45
+ value_set_unevaluated.uniq!
46
+
47
+ next if value_set_unevaluated.any? { |element| element.include?(valueset.url) }
48
+
49
+ resource_used = []
50
+
51
+ context.data.each do |resource|
52
+ system_codes.each do |system_code|
53
+ next unless !system_code.nil? && resource_uses_code(resource.to_hash, system_code[:system],
54
+ system_code[:code])
55
+
56
+ valueset_used_count += 1
57
+ resource_used << resource.id unless resource_used.include?(resource.id)
58
+ end
59
+ end
60
+
61
+ if valueset_used_count.positive?
62
+ # rubocop:disable Layout/LineLength
63
+ value_set_used << "#{valueset.url} is used #{valueset_used_count} times in #{resource_used.count} resources"
64
+ # rubocop:enable Layout/LineLength
65
+ else
66
+ value_set_unused << valueset.url
67
+ end
68
+ end
69
+ end
70
+ # rubocop:enable Metrics/CyclomaticComplexity
71
+
72
+ # rubocop:disable Metrics/CyclomaticComplexity
73
+ def create_result_message
74
+ if value_set_unused.none?
75
+ message = 'All ValueSets are used in examples:'
76
+ value_set_used.map { |value_set| message += "\n\t#{value_set}" }
77
+
78
+ if value_set_unevaluated.any?
79
+ message += "\nThe following Value Sets were not able to be evaluated: "
80
+ value_set_unevaluated.map { |value_set| message += "\n\t#{value_set}" }
81
+ end
82
+
83
+ EvaluationResult.new(message, severity: 'success', rule: self)
84
+ else
85
+ message = 'Found ValueSets with all codes used (at least once) in examples:'
86
+ value_set_used.map { |url| message += "\n\t#{url}" }
87
+
88
+ message += "\nFound unused ValueSets: "
89
+ value_set_unused.map { |url| message += "\n\t#{url}" }
90
+
91
+ if value_set_unevaluated.any?
92
+ message += "\nFound unevaluated ValueSets: "
93
+ value_set_unevaluated.map { |url| message += "\n\t#{url}" }
94
+ end
95
+
96
+ EvaluationResult.new(message, rule: self)
97
+ end
98
+ end
99
+ # rubocop:enable Metrics/CyclomaticComplexity
100
+
101
+ # rubocop:disable Metrics/CyclomaticComplexity
102
+ def extract_systems_codes_from_valueset(valueset)
103
+ system_codes = []
104
+
105
+ if valueset.to_hash['compose']
106
+ valueset.to_hash['compose']['include'].each do |include|
107
+ if include['valueSet']
108
+ include['valueSet'].each do |url|
109
+ retrieve_valueset_from_api(url)&.each { |system_code| system_codes << system_code }
110
+ end
111
+ next
112
+ end
113
+
114
+ system_url = include['system']
115
+
116
+ if system_url && include['concept']
117
+ include['concept'].each do |code|
118
+ system_codes << { system: system_url, code: code.to_hash['code'] }
119
+ end
120
+ next
121
+ end
122
+
123
+ if system_url
124
+ if system_url['http://hl7.org/fhir']
125
+ retrieve_valueset_from_api(system_url)&.each { |vs| system_codes << vs }
126
+ end
127
+ next
128
+ end
129
+
130
+ value_set_unevaluated << "#{valueset.url}: system url not provided" unless system_url
131
+
132
+ # Exclude if system is provided as Uniform Resource Name "urn:"
133
+ # Exclude filter
134
+ # Exclude only system is provided (e.g. http://loing.org)
135
+ exclusions = config.data['Rule']['ValueSetsDemonstrate']['Exclude']
136
+ if exclusions['URL'] && (system_url['urn'])
137
+ value_set_unevaluated << "#{valueset.url}: unable to handle Uniform Resource Name"
138
+ end
139
+
140
+ if exclusions['Filter'] && (system_url && include['filter'])
141
+ value_set_unevaluated << "#{valueset.url}: unable to handle filter"
142
+ end
143
+
144
+ if exclusions['SystemOnly'] && (system_url && !include['concept'] && !include['filter'])
145
+ value_set_unevaluated << "#{valueset.url}: unabe to handle SystemOnly"
146
+ end
147
+ end
148
+ else
149
+ value_set_unevaluated << valueset.url
150
+ end
151
+ system_codes.flatten.uniq
152
+ end
153
+ # rubocop:enable Metrics/CyclomaticComplexity
154
+
155
+ # rubocop:disable Metrics/CyclomaticComplexity
156
+ def resource_uses_code(resource, system, code)
157
+ resource.each do |key, value|
158
+ next unless key == 'code' || ['value', 'valueCodeableConcept', 'valueString',
159
+ 'valueQuantity', 'valueBoolean',
160
+ 'valueInteger', 'valueRange', 'valueRatio',
161
+ 'valueSampleData', 'valueDateTime',
162
+ 'valuePeriod', 'valueTime'].include?(key)
163
+ next unless value.is_a?(Hash)
164
+
165
+ value['coding']&.each do |codeset|
166
+ return true if codeset.to_hash['system'] == system && codeset.to_hash['code'] == code
167
+ end
168
+ end
169
+
170
+ false
171
+ end
172
+ # rubocop:enable Metrics/CyclomaticComplexity
173
+
174
+ def extract_valueset_from_response(response)
175
+ value_set = JSON.parse(response.body)
176
+
177
+ if value_set['compose'] && value_set['compose']['include']
178
+ value_set['compose']['include'].map do |include|
179
+ include['concept']&.map { |concept| { system: include['system'], code: concept['code'] } }
180
+ end.flatten
181
+ else
182
+ puts 'No Value Set found in the response.'
183
+ end
184
+ end
185
+
186
+ def retrieve_valueset_from_api(url)
187
+ url['http:'] = 'https:' if url['http:']
188
+ uri = URI.parse(url)
189
+
190
+ http = Net::HTTP.new(uri.host, uri.port)
191
+ http.use_ssl = (uri.scheme == 'https')
192
+
193
+ request = Net::HTTP::Get.new(uri.request_uri)
194
+
195
+ username = config.data['Environment']['VSAC']['Username']
196
+ password = config.data['Environment']['VSAC']['Password']
197
+ encoded_credentials = Base64.strict_encode64("#{username}:#{password}")
198
+ request['Authorization'] = "Basic #{encoded_credentials}"
199
+
200
+ response = http.request(request)
201
+
202
+ content_type = response['content-type']
203
+ return unless content_type && !content_type.include?('text/html')
204
+
205
+ while response.is_a?(Net::HTTPRedirection)
206
+ redirect_url = response['location']
207
+
208
+ redirect_url['xml'] = 'json'
209
+ uri = URI.parse(redirect_url)
210
+
211
+ http = Net::HTTP.new(uri.host, uri.port)
212
+ http.use_ssl = (uri.scheme == 'https')
213
+
214
+ response = http.request(Net::HTTP::Get.new(uri.request_uri))
215
+ end
216
+
217
+ if response.code.to_i == 200
218
+ extract_valueset_from_response(response)
219
+ else
220
+ unless config.data['Rule']['ValueSetsDemonstrate']['IgnoreUnloadableValueset']
221
+ raise StandardError, "Failed to retrieve external value set: #{url} HTTP Status code: #{response.code}"
222
+ end
223
+
224
+ value_set_unevaluated << "#{url}: Failed to retrieve. HTTP Status code: #{response.code}"
225
+ nil
226
+
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
233
+ end
@@ -177,8 +177,17 @@ module Inferno
177
177
  end
178
178
 
179
179
  def matching_required_binding_slice?(slice, discriminator)
180
- discriminator[:path].present? ? slice.send((discriminator[:path]).to_s).coding : slice.coding
181
- slice_value { |coding| discriminator[:values].include?(coding.code) }
180
+ slice_coding = discriminator[:path].present? ? slice.send((discriminator[:path]).to_s).coding : slice.coding
181
+ slice_coding.any? do |coding|
182
+ discriminator[:values].any? do |value|
183
+ case value
184
+ when String
185
+ value == coding.code
186
+ when Hash
187
+ value[:system] == coding.system && value[:code] == coding.code
188
+ end
189
+ end
190
+ end
182
191
  end
183
192
 
184
193
  def verify_slice_by_values(element, value_definitions)
@@ -175,10 +175,10 @@ module Inferno
175
175
  begin
176
176
  response = call_validator(resource, profile_url)
177
177
  rescue StandardError => e
178
- # This could be a complete failure to connect (validator isn't running)
179
- # or a timeout (validator took too long to respond).
180
178
  runnable.add_message('error', e.message)
181
- raise Inferno::Exceptions::ErrorInValidatorException, "Unable to connect to validator at #{url}."
179
+ Application[:logger].error(e.message)
180
+
181
+ raise Inferno::Exceptions::ErrorInValidatorException, validator_error_message(e)
182
182
  end
183
183
 
184
184
  outcome = operation_outcome_from_validator_response(response, runnable)
@@ -340,6 +340,28 @@ module Inferno
340
340
  'Validator response was an unexpected format. ' \
341
341
  'Review Messages tab or validator service logs for more information.'
342
342
  end
343
+
344
+ # Add a specific error message for specific network problems to help the user
345
+ #
346
+ # @private
347
+ # @param error [Exception] An error exception that happened during evaluator connection
348
+ # @return [String] A readable error message describing the specific network problem
349
+ def validator_error_message(error)
350
+ case error
351
+ when Faraday::ConnectionFailed
352
+ "Connection failed to validator at #{url}."
353
+ when Faraday::TimeoutError
354
+ "Timeout while connecting to validator at #{url}."
355
+ when Faraday::SSLError
356
+ "SSL error connecting to validator at #{url}."
357
+ when Faraday::ClientError # these are 400s
358
+ "Client error (4xx) connecting to validator at #{url}."
359
+ when Faraday::ServerError # these are 500s
360
+ "Server error (5xx) from validator at #{url}."
361
+ else
362
+ "Unable to connect to validator at #{url}."
363
+ end
364
+ end
343
365
  end
344
366
 
345
367
  # @private
@@ -67,7 +67,9 @@ module Inferno
67
67
  # This could be a complete failure to connect (fhirpath service isn't running)
68
68
  # or a timeout (fhirpath service took too long to respond).
69
69
  runnable.add_message('error', e.message)
70
- raise Inferno::Exceptions::ErrorInFhirpathException, "Unable to connect to FHIRPath service at #{url}."
70
+ Application[:logger].error(e.message)
71
+
72
+ raise Inferno::Exceptions::ErrorInFhirpathException, evaluator_error_message(e)
71
73
  end
72
74
 
73
75
  sanitized_body = remove_invalid_characters(response.body)
@@ -108,6 +110,28 @@ module Inferno
108
110
  def remove_invalid_characters(string)
109
111
  string.gsub(/[^[:print:]\r\n]+/, '')
110
112
  end
113
+
114
+ # Add a specific error message for specific network problems to help the user
115
+ #
116
+ # @private
117
+ # @param error [Exception] An error exception that happened during evaluator connection
118
+ # @return [String] A readable error message describing the specific network problem
119
+ def evaluator_error_message(error)
120
+ case error
121
+ when Faraday::ConnectionFailed
122
+ "Connection failed to evaluator at #{url}."
123
+ when Faraday::TimeoutError
124
+ "Timeout while connecting to evaluator at #{url}."
125
+ when Faraday::SSLError
126
+ "SSL error connecting to evaluator at #{url}."
127
+ when Faraday::ClientError # these are 400s
128
+ "Client error (4xx) connecting to evaluator at #{url}."
129
+ when Faraday::ServerError # these are 500s
130
+ "Server error (5xx) from evaluator at #{url}."
131
+ else
132
+ "Unable to connect to FHIRPath service at #{url}."
133
+ end
134
+ end
111
135
  end
112
136
 
113
137
  module ClassMethods
@@ -12,6 +12,7 @@ module Inferno
12
12
  # @option input_params [String] :default The default value for the input
13
13
  # @option input_params [Boolean] :optional Set to true to not require input for test execution
14
14
  # @option input_params [Boolean] :locked If true, the user can not alter the value
15
+ # @option input_params [Boolean] :hidden If true, the input will not be visible to the user in the UI
15
16
  # @option input_params [Hash] :options Possible input option formats based on input type
16
17
  # @option options [Array] :list_options Array of options for input formats
17
18
  # that require a list of possible values (radio and checkbox)
@@ -332,18 +332,23 @@ module Inferno
332
332
  when 'Date'
333
333
  begin
334
334
  Date.parse(element)
335
- rescue ArgumentError
335
+ rescue ArgumentError, TypeError
336
336
  false
337
337
  end
338
338
  when 'DateTime'
339
339
  begin
340
340
  DateTime.parse(element)
341
- rescue ArgumentError
341
+ rescue ArgumentError, TypeError
342
342
  false
343
343
  end
344
344
  when 'String'
345
345
  element.is_a? String
346
346
  else
347
+ if element.is_a? FHIR::Bundle::Entry
348
+ # Special case for type slicing in a Bundle - look at the resource not the entry
349
+ element = element.resource
350
+ end
351
+
347
352
  element.is_a? FHIR.const_get(discriminator[:code])
348
353
  end
349
354
  end
@@ -352,7 +357,14 @@ module Inferno
352
357
  coding_path = discriminator[:path].present? ? "#{discriminator[:path]}.coding" : 'coding'
353
358
 
354
359
  find_a_value_at(element, coding_path) do |coding|
355
- discriminator[:values].any? { |value| value[:system] == coding.system && value[:code] == coding.code }
360
+ discriminator[:values].any? do |value|
361
+ case value
362
+ when String
363
+ value == coding.code
364
+ when Hash
365
+ value[:system] == coding.system && value[:code] == coding.code
366
+ end
367
+ end
356
368
  end
357
369
  end
358
370
 
@@ -0,0 +1,82 @@
1
+ module Inferno
2
+ module DSL
3
+ # A `RequirementSet` represents the set of requirements which are tested by
4
+ # a TestSuite.
5
+ #
6
+ # @!attribute identifier [rw] The unique identifier for the source of
7
+ # requirements included in this `RequirementSet`
8
+ # @!attribute title [rw] A human-readable title for this `RequirementSet`
9
+ # @!attribute actor [rw] The actor whose requirements are included in this
10
+ # `RequirementSet`
11
+ # @!attribute requirements [rw] There are three options:
12
+ # * `"all"` (default) - Include all of the requirements for the specified
13
+ # actor from the requirement source
14
+ # * `"referenced"` - Only include requirements from this source if they
15
+ # are referenced by other included requirements
16
+ # * `"1,3,5-8"` - Only include the requirements from a comma-delimited
17
+ # list
18
+ # @!attribute suite_options [rw] A set of suite options which must be
19
+ # selected in order for this `RequirementSet` to be included
20
+ #
21
+ # @see Inferno::DSL::SuiteRequirements#requirement_sets
22
+ class RequirementSet
23
+ ATTRIBUTES = [
24
+ :identifier,
25
+ :title,
26
+ :actor,
27
+ :requirements,
28
+ :suite_options
29
+ ].freeze
30
+
31
+ include Entities::Attributes
32
+
33
+ def initialize(raw_attributes_hash)
34
+ attributes_hash = raw_attributes_hash.symbolize_keys
35
+
36
+ invalid_keys = attributes_hash.keys - ATTRIBUTES
37
+
38
+ raise Exceptions::UnknownAttributeException.new(invalid_keys, self.class) if invalid_keys.present?
39
+
40
+ attributes_hash.each do |name, value|
41
+ if name == :suite_options
42
+ value = value&.map { |option_id, option_value| SuiteOption.new(id: option_id, value: option_value) }
43
+ end
44
+
45
+ instance_variable_set(:"@#{name}", value)
46
+ end
47
+
48
+ self.suite_options ||= []
49
+ end
50
+
51
+ # Returns true when the `RequirementSet` includes all of the requirements
52
+ # from the source for the specified actor
53
+ #
54
+ # @return [Boolean]
55
+ def complete?
56
+ requirements.blank? || requirements.casecmp?('all')
57
+ end
58
+
59
+ # Returns true when the `RequirementSet` only includes requirements
60
+ # referenced by other `RequirementSet`s
61
+ #
62
+ # @return [Boolean]
63
+ def referenced?
64
+ requirements&.casecmp? 'referenced'
65
+ end
66
+
67
+ # Returns true when the `RequirementSet` only includes requirements
68
+ # specified in a list
69
+ #
70
+ # @return [Boolean]
71
+ def filtered?
72
+ !complete? && !referenced?
73
+ end
74
+
75
+ # Expands the compressed comma-separated requirements list into an Array
76
+ # of full ids
77
+ def expand_requirement_ids
78
+ Entities::Requirement.expand_requirement_ids(requirements, identifier)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -276,6 +276,21 @@ module Inferno
276
276
  @input_instructions = format_markdown(new_input_instructions)
277
277
  end
278
278
 
279
+ # Set/Get the IDs of requirements verified by this runnable
280
+ # Set with [] to clear the list
281
+ #
282
+ # @param requirements [Array<String>]
283
+ # @return [Array<String>] the requirement IDs
284
+ def verifies_requirements(*requirement_ids)
285
+ if requirement_ids.empty?
286
+ @requirement_ids || []
287
+ elsif requirement_ids == [[]]
288
+ @requirement_ids = []
289
+ else
290
+ @requirement_ids = requirement_ids
291
+ end
292
+ end
293
+
279
294
  # Mark as optional. Tests are required by default.
280
295
  #
281
296
  # @param optional [Boolean]
@@ -331,6 +346,11 @@ module Inferno
331
346
  @all_children ||= []
332
347
  end
333
348
 
349
+ # @private
350
+ def all_descendants
351
+ children.flat_map { |child| [child] + child.all_descendants }
352
+ end
353
+
334
354
  # @private
335
355
  def suite
336
356
  return self if ancestors.include? Inferno::Entities::TestSuite
@@ -550,6 +570,13 @@ module Inferno
550
570
  end
551
571
  end
552
572
 
573
+ # @private
574
+ def all_requirements(suite_options = [])
575
+ children(suite_options).flat_map do |child|
576
+ child.verifies_requirements + child.all_requirements(suite_options)
577
+ end
578
+ end
579
+
553
580
  # @private
554
581
  def inspect
555
582
  non_dynamic_ancestor = ancestors.find { |ancestor| !ancestor.to_s.start_with? '#' }