diff --git a/.swiftlint.yml b/.swiftlint.yml index dbf2f16..d3f12b3 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -371,6 +371,9 @@ only_rules: # The variable should be placed on the left, the constant on the right of a comparison operator. - yoda_condition +attributes: + attributes_with_arguments_always_on_line_above: false + deployment_target: # Availability checks or attributes shouldn’t be using older versions that are satisfied by the deployment target. iOSApplicationExtension_deployment_target: 16.0 iOS_deployment_target: 16.0 diff --git a/Example/ExampleApp/Responses/QuestionnaireResponseStorage.swift b/Example/ExampleApp/Responses/QuestionnaireResponseStorage.swift index 4995964..1a276fd 100644 --- a/Example/ExampleApp/Responses/QuestionnaireResponseStorage.swift +++ b/Example/ExampleApp/Responses/QuestionnaireResponseStorage.swift @@ -11,8 +11,7 @@ import SwiftUI class QuestionnaireResponseStorage: ObservableObject { - @Published - private var responses: [URL: [QuestionnaireResponse]] = [:] + @Published private var responses: [URL: [QuestionnaireResponse]] = [:] func append(_ response: QuestionnaireResponse, for identifier: URL) { diff --git a/Example/ExampleUITests/ExampleUITests.swift b/Example/ExampleUITests/ExampleUITests.swift index 6f9280c..ec292f6 100644 --- a/Example/ExampleUITests/ExampleUITests.swift +++ b/Example/ExampleUITests/ExampleUITests.swift @@ -6,6 +6,8 @@ // SPDX-License-Identifier: MIT // +// We disable file length because this is a test +// swiftlint:disable file_length import XCTest // We disable type body length rule because this is a test @@ -301,6 +303,36 @@ final class ExampleUITests: XCTestCase { app.swipeDown(velocity: XCUIGestureVelocity.fast) } + func testSliderExample() throws { + let app = XCUIApplication() + app.launch() + + let sliderExampleButton = app.collectionViews.buttons["Slider Example"] + + // Open questionnaire and start + sliderExampleButton.tap() + + // Access the slider + let slider = app.sliders.firstMatch + XCTAssertTrue(slider.exists, "The slider does not exist") + + // Calculate normalized position for the desired value + let desiredValue: CGFloat = 5 + let sliderRange: CGFloat = 10 + let normalizedPosition = desiredValue / sliderRange + + // Adjust the slider's value + slider.adjust(toNormalizedSliderPosition: normalizedPosition) + + // Check that the slider's value is now equal to the desired value + if let valueString = slider.value as? String, let value = Double(valueString) { + let sliderValue = CGFloat(value) + XCTAssertEqual(sliderValue, desiredValue, accuracy: 1) + } else { + XCTFail("Slider value is not a readable number.") + } + } + // MARK: UI Tests for Clinical Questionnaires func testPHQ9Example() throws { diff --git a/Package.swift b/Package.swift index f424890..2cf2c03 100644 --- a/Package.swift +++ b/Package.swift @@ -49,7 +49,8 @@ let package = Package( .copy("Resources/IPSS.json"), .copy("Resources/FormExample.json"), .copy("Resources/MultipleEnableWhen.json"), - .copy("Resources/ImageCapture.json") + .copy("Resources/ImageCapture.json"), + .copy("Resources/SliderExample.json") ] ), .testTarget( diff --git a/Sources/FHIRQuestionnaires/Questionnaire+Resources.swift b/Sources/FHIRQuestionnaires/Questionnaire+Resources.swift index 9caf303..3876197 100644 --- a/Sources/FHIRQuestionnaires/Questionnaire+Resources.swift +++ b/Sources/FHIRQuestionnaires/Questionnaire+Resources.swift @@ -36,6 +36,9 @@ extension Questionnaire { /// A FHIR questionnaire demonstrating an image capture step public static var imageCaptureExample: Questionnaire = loadQuestionnaire(withName: "ImageCapture") + + /// A FHIR questionnaire demonstrating a slider + public static var sliderExample: Questionnaire = loadQuestionnaire(withName: "SliderExample") /// A collection of example `Questionnaire`s provided by the FHIRQuestionnaires target to demonstrate functionality public static var exampleQuestionnaires: [Questionnaire] = [ @@ -46,7 +49,8 @@ extension Questionnaire { .dateTimeExample, .formExample, .multipleEnableWhen, - .imageCaptureExample + .imageCaptureExample, + .sliderExample ] // MARK: Examples of clinical research FHIR Questionnaires diff --git a/Sources/FHIRQuestionnaires/Resources/SliderExample.json b/Sources/FHIRQuestionnaires/Resources/SliderExample.json new file mode 100644 index 0000000..0182385 --- /dev/null +++ b/Sources/FHIRQuestionnaires/Resources/SliderExample.json @@ -0,0 +1,42 @@ +{ + "resourceType": "Questionnaire", + "language": "en-US", + "id": "Stanford University-slider-example", + "title": "Slider Example", + "status": "draft", + "publisher": "Stanford University", + "url": "http://biodesign.stanford.edu/questionnaires/sliderexample", + "item": [ + { + "linkId": "1", + "text": "How bad is the pain on a scale of 0-10?", + "type": "integer", + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "slider", + "display": "Slider" + } + ] + } + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/minValue", + "valueInteger": 0 + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/maxValue", + "valueInteger": 10 + }, + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-sliderStepValue", + "valueInteger": 1 + } + ] + } + ] +} diff --git a/Sources/FHIRQuestionnaires/Resources/SliderExample.json.license b/Sources/FHIRQuestionnaires/Resources/SliderExample.json.license new file mode 100644 index 0000000..fa7bcbe --- /dev/null +++ b/Sources/FHIRQuestionnaires/Resources/SliderExample.json.license @@ -0,0 +1,6 @@ + +This source file is part of the ResearchKitOnFHIR open source project + +SPDX-FileCopyrightText: 2023 Stanford University and the project authors (see CONTRIBUTORS.md) + +SPDX-License-Identifier: MIT diff --git a/Sources/ResearchKitOnFHIR/FHIRExtensions/FHIRExtensions.swift b/Sources/ResearchKitOnFHIR/FHIRExtensions/FHIRExtensions.swift index 34419e5..7d0907a 100644 --- a/Sources/ResearchKitOnFHIR/FHIRExtensions/FHIRExtensions.swift +++ b/Sources/ResearchKitOnFHIR/FHIRExtensions/FHIRExtensions.swift @@ -13,8 +13,10 @@ import ModelsR4 extension QuestionnaireItem { /// Supported FHIR extensions for QuestionnaireItems private enum SupportedExtensions { + static let itemControl = "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl" static let questionnaireUnit = "http://hl7.org/fhir/StructureDefinition/questionnaire-unit" static let regex = "http://hl7.org/fhir/StructureDefinition/regex" + static let sliderStepValue = "http://hl7.org/fhir/StructureDefinition/questionnaire-sliderStepValue" static let validationMessage = "http://biodesign.stanford.edu/fhir/StructureDefinition/validationtext" static let maxDecimalPlaces = "http://hl7.org/fhir/StructureDefinition/maxDecimalPlaces" static let minValue = "http://hl7.org/fhir/StructureDefinition/minValue" @@ -33,6 +35,17 @@ extension QuestionnaireItem { } return isHidden } + + /// Defines the control type for the answer for a question + /// - Returns: A code representing the control type (i.e. slider) + var itemControl: String? { + guard let itemControlExtension = getExtensionInQuestionnaireItem(url: SupportedExtensions.itemControl), + case let .codeableConcept(concept) = itemControlExtension.value, + let itemControlCode = concept.coding?.first?.code?.value?.string else { + return nil + } + return itemControlCode + } /// The minimum value for a numerical answer. /// - Returns: An optional `NSNumber` containing the minimum value allowed. @@ -66,6 +79,17 @@ extension QuestionnaireItem { } return NSNumber(value: maxDecimalPlaces) } + + /// The offset between numbers on a numerical slider + /// - Returns: An optional `NSNumber` representing the size of each discrete offset on the scale. + var sliderStepValue: NSNumber? { + guard let sliderStepValueExtension = getExtensionInQuestionnaireItem(url: SupportedExtensions.sliderStepValue), + case let .integer(integerValue) = sliderStepValueExtension.value, + let sliderStepValue = integerValue.value?.integer as? Int32 else { + return nil + } + return NSNumber(value: sliderStepValue) + } /// The unit of a quantity answer type. /// - Returns: An optional `String` containing the unit (i.e. cm) if it was provided. diff --git a/Sources/ResearchKitOnFHIR/FHIRToResearchKit/QuestionnaireItem+ResearchKit.swift b/Sources/ResearchKitOnFHIR/FHIRToResearchKit/QuestionnaireItem+ResearchKit.swift index 4b56e1f..7fd9094 100644 --- a/Sources/ResearchKitOnFHIR/FHIRToResearchKit/QuestionnaireItem+ResearchKit.swift +++ b/Sources/ResearchKitOnFHIR/FHIRToResearchKit/QuestionnaireItem+ResearchKit.swift @@ -142,7 +142,7 @@ extension QuestionnaireItem { /// - Returns: An object of type `ORKAnswerFormat` representing the type of answer this question accepts. private func toORKAnswerFormat(valueSets: [ValueSet]) throws -> ORKAnswerFormat { // swiftlint:disable:previous cyclomatic_complexity - // We have to cover all the switch cases in the following statement driving up the overal comlexity. + // We have to cover all the switch cases in the following statement driving up the overall complexity. switch type.value { case .boolean: return ORKBooleanAnswerFormat.booleanAnswerFormat() @@ -165,6 +165,16 @@ extension QuestionnaireItem { answerFormat.maximum = maxValue return answerFormat case .integer: + if itemControl == "slider" { + let answerFormat = ORKScaleAnswerFormat( + maximumValue: maxValue?.intValue ?? 0, + minimumValue: minValue?.intValue ?? 0, + defaultValue: minValue?.intValue ?? 0, + step: Int(truncating: sliderStepValue ?? 1) + ) + return answerFormat + } + let answerFormat = ORKNumericAnswerFormat.integerAnswerFormat(withUnit: nil) answerFormat.minimum = minValue answerFormat.maximum = maxValue diff --git a/Sources/ResearchKitOnFHIR/ResearchKitToFHIR/ORKTaskResult+FHIR.swift b/Sources/ResearchKitOnFHIR/ResearchKitToFHIR/ORKTaskResult+FHIR.swift index 9e9c147..5c8747c 100644 --- a/Sources/ResearchKitOnFHIR/ResearchKitToFHIR/ORKTaskResult+FHIR.swift +++ b/Sources/ResearchKitOnFHIR/ResearchKitToFHIR/ORKTaskResult+FHIR.swift @@ -52,16 +52,18 @@ extension ORKTaskResult { responseAnswer.value = createBooleanResponse(result) case let result as ORKChoiceQuestionResult: responseAnswer.value = createChoiceResponse(result) + case let result as ORKFileResult: + responseAnswer.value = createAttachmentResponse(result) case let result as ORKNumericQuestionResult: responseAnswer.value = createNumericResponse(result) case let result as ORKDateQuestionResult: responseAnswer.value = createDateResponse(result) - case let result as ORKTimeOfDayQuestionResult: - responseAnswer.value = createTimeResponse(result) + case let result as ORKScaleQuestionResult: + responseAnswer.value = createScaleResponse(result) case let result as ORKTextQuestionResult: responseAnswer.value = createTextResponse(result) - case let result as ORKFileResult: - responseAnswer.value = createAttachmentResponse(result) + case let result as ORKTimeOfDayQuestionResult: + responseAnswer.value = createTimeResponse(result) default: // Unsupported result type responseAnswer.value = nil @@ -92,7 +94,15 @@ extension ORKTaskResult { return .decimal(FHIRPrimitive(FHIRDecimal(value.decimalValue))) } } - + + private func createScaleResponse(_ result: ORKScaleQuestionResult) -> QuestionnaireResponseItemAnswer.ValueX? { + guard let value = result.scaleAnswer else { + return nil + } + + return .integer(FHIRPrimitive(FHIRInteger(value.int32Value))) + } + private func createTextResponse(_ result: ORKTextQuestionResult) -> QuestionnaireResponseItemAnswer.ValueX? { guard let text = result.textAnswer else { return nil diff --git a/Tests/ResearchKitOnFHIRTests/FHIRToResearchKitTests.swift b/Tests/ResearchKitOnFHIRTests/FHIRToResearchKitTests.swift index 22717b6..84d18de 100644 --- a/Tests/ResearchKitOnFHIRTests/FHIRToResearchKitTests.swift +++ b/Tests/ResearchKitOnFHIRTests/FHIRToResearchKitTests.swift @@ -52,6 +52,12 @@ final class FHIRToResearchKitTests: XCTestCase { let valueSets = Questionnaire.containedValueSetExample.getContainedValueSets() XCTAssertEqual(valueSets.count, 1) } + + func testItemControlExtension() throws { + let testItemControl = Questionnaire.sliderExample.item?.first?.itemControl + let itemControlValue = try XCTUnwrap(testItemControl) + XCTAssertEqual(itemControlValue, "slider") + } func testRegexExtension() throws { let testRegex = Questionnaire.textValidationExample.item?.first?.validationRegularExpression @@ -59,6 +65,12 @@ final class FHIRToResearchKitTests: XCTestCase { let regex = try NSRegularExpression(pattern: "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$") XCTAssertEqual(regex, testRegex) } + + func testSliderStepValueExtension() throws { + let testSliderStepValue = Questionnaire.sliderExample.item?.first?.sliderStepValue + let sliderStepValue = try XCTUnwrap(testSliderStepValue) + XCTAssertEqual(sliderStepValue, 1) + } func testValidationMessageExtension() throws { let testValidationMessage = Questionnaire.textValidationExample.item?.first?.validationMessage diff --git a/Tests/ResearchKitOnFHIRTests/ResearchKitToFHIRTests.swift b/Tests/ResearchKitOnFHIRTests/ResearchKitToFHIRTests.swift index e495231..ed6364b 100644 --- a/Tests/ResearchKitOnFHIRTests/ResearchKitToFHIRTests.swift +++ b/Tests/ResearchKitOnFHIRTests/ResearchKitToFHIRTests.swift @@ -98,6 +98,24 @@ final class ResearchKitToFHIRTests: XCTestCase { } XCTAssertEqual(testValue, responseValue) } + + func testScaleResponse() { + let testValue = 1 + var responseValue: Int? + + let scaleResult = ORKScaleQuestionResult(identifier: "scaleResult") + scaleResult.scaleAnswer = testValue as NSNumber + let taskResult = createTaskResult(scaleResult) + + let fhirResponse = taskResult.fhirResponse + let answer = fhirResponse.item?.first?.answer?.first?.value + + if case let .integer(value) = answer, + let unwrappedValue = value.value?.integer { + responseValue = Int(unwrappedValue) + } + XCTAssertEqual(testValue, responseValue) + } func testQuantityResponse() { let testValue: Decimal = 1.5