From 7dc09f7acd7fb19673594e0fdd4d72d0869ee006 Mon Sep 17 00:00:00 2001 From: Vishnu Ravi Date: Thu, 24 Aug 2023 20:55:20 -0400 Subject: [PATCH] Add support for multiple selection questions (#64) Co-authored-by: Paul Schmiedmayer --- Example/ExampleUITests/ExampleUITests.swift | 76 +++++++++++++++---- .../Resources/FormExample.json | 16 +++- .../QuestionnaireItem+ResearchKit.swift | 6 +- .../ORKTaskResult+FHIR.swift | 62 ++++++++------- .../ResearchKitToFHIRTests.swift | 68 +++++++++++++++-- 5 files changed, 179 insertions(+), 49 deletions(-) diff --git a/Example/ExampleUITests/ExampleUITests.swift b/Example/ExampleUITests/ExampleUITests.swift index ec292f6..1b7087b 100644 --- a/Example/ExampleUITests/ExampleUITests.swift +++ b/Example/ExampleUITests/ExampleUITests.swift @@ -10,6 +10,7 @@ // swiftlint:disable file_length import XCTest + // We disable type body length rule because this is a test // swiftlint:disable:next type_body_length final class ExampleUITests: XCTestCase { @@ -114,17 +115,27 @@ final class ExampleUITests: XCTestCase { let app = XCUIApplication() app.launch() - let containedValueSetExampleButton = app.collectionViews.buttons["Contained ValueSet Example"] - // Complete questionnaire + let containedValueSetExampleButton = app.collectionViews.buttons["Contained ValueSet Example"] + XCTAssert(containedValueSetExampleButton.waitForExistence(timeout: 2)) containedValueSetExampleButton.tap() - app.tables.staticTexts["Yes"].tap() - app.buttons["Next"].tap() + + let yesButton = app.tables.staticTexts["Yes"] + XCTAssert(yesButton.waitForExistence(timeout: 2)) + yesButton.tap() + + let nextButton = app.buttons["Next"] + XCTAssert(nextButton.waitForExistence(timeout: 2)) + nextButton.tap() // Close the completion step - app.buttons["Done"].tap() + let doneButton = app.buttons["Done"] + XCTAssert(doneButton.waitForExistence(timeout: 2)) + doneButton.tap() // Open context menu and view results + sleep(1) + XCTAssert(containedValueSetExampleButton.waitForExistence(timeout: 2)) containedValueSetExampleButton.press(forDuration: 1.0) app.collectionViews.buttons["View Responses"].tap() @@ -223,8 +234,7 @@ final class ExampleUITests: XCTestCase { app.launch() let dateTimeExampleButton = app.collectionViews.buttons["Date and Time Example"] - - // Open questionnaire + XCTAssert(dateTimeExampleButton.waitForExistence(timeout: 2)) dateTimeExampleButton.tap() let dateFormatter = DateFormatter() @@ -241,6 +251,7 @@ final class ExampleUITests: XCTestCase { app.buttons["Next"].tap() // Chose a date and time + sleep(1) app.pickerWheels.element(boundBy: 0).adjust(toPickerWheelValue: dateFormatOfRandomDateInProximity("MMM d")) app.pickerWheels.element(boundBy: 1).adjust(toPickerWheelValue: dateFormatOfRandomDateInProximity("h")) app.pickerWheels.element(boundBy: 2).adjust(toPickerWheelValue: dateFormatOfRandomDateInProximity("mm")) @@ -248,19 +259,27 @@ final class ExampleUITests: XCTestCase { app.buttons["Next"].tap() // Choose a time + sleep(1) app.pickerWheels.element(boundBy: 0).adjust(toPickerWheelValue: dateFormatOfRandomDateInProximity("h")) app.pickerWheels.element(boundBy: 1).adjust(toPickerWheelValue: dateFormatOfRandomDateInProximity("mm")) app.pickerWheels.element(boundBy: 2).adjust(toPickerWheelValue: dateFormatOfRandomDateInProximity("a")) app.buttons["Next"].tap() // Close the completion step - app.buttons["Done"].tap() + let doneButton = app.buttons["Done"] + XCTAssert(doneButton.waitForExistence(timeout: 2)) + doneButton.tap() // Open context menu and view results + XCTAssert(dateTimeExampleButton.waitForExistence(timeout: 2)) dateTimeExampleButton.press(forDuration: 1.0) - app.collectionViews.buttons["View Responses"].tap() + + let viewReponsesButton = app.collectionViews.buttons["View Responses"] + XCTAssert(viewReponsesButton.waitForExistence(timeout: 2)) + viewReponsesButton.tap() // Check results + sleep(1) let buttonsInResultView = app.collectionViews.allElementsBoundByIndex[1].buttons XCTAssertEqual(buttonsInResultView.count, 1) buttonsInResultView.allElementsBoundByIndex[0].tap() @@ -284,6 +303,7 @@ final class ExampleUITests: XCTestCase { app.tables.staticTexts["Yes"].tap() app.tables.staticTexts["Chocolate"].tap() app.tables.staticTexts["Sprinkles"].tap() + app.tables.staticTexts["Marshmallows"].tap() app.buttons["Next"].tap() // Finish survey @@ -332,7 +352,7 @@ final class ExampleUITests: XCTestCase { XCTFail("Slider value is not a readable number.") } } - + // MARK: UI Tests for Clinical Questionnaires func testPHQ9Example() throws { @@ -450,47 +470,72 @@ final class ExampleUITests: XCTestCase { app.launch() let multipleEnableWhenButton = app.collectionViews.buttons["Multiple EnableWhen Expressions"] + XCTAssert(multipleEnableWhenButton.waitForExistence(timeout: 2)) multipleEnableWhenButton.tap() // We will first answer all questions correctly + XCTAssert(app.tables.staticTexts["Yes"].waitForExistence(timeout: 2)) app.tables.staticTexts["Yes"].tap() + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() + XCTAssert(app.tables.staticTexts["green"].waitForExistence(timeout: 2)) app.tables.staticTexts["green"].tap() + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() + let integerField = app.textFields.element(boundBy: 0) + XCTAssert(integerField.waitForExistence(timeout: 2)) integerField.tap() integerField.typeText("12\n") + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() // First result screen appears if at least one answer is correct. + sleep(1) + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() // Second result screen appears if all answers are correct. + sleep(1) + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() // Now the completion screen will appear with a "Done" button that we can tap + sleep(1) + XCTAssert(app.buttons["Done"].waitForExistence(timeout: 2)) app.buttons["Done"].tap() // Now we relaunch the survey + XCTAssert(multipleEnableWhenButton.waitForExistence(timeout: 2)) multipleEnableWhenButton.tap() // This time we answer only one question correctly + XCTAssert(app.tables.staticTexts["Yes"].waitForExistence(timeout: 2)) app.tables.staticTexts["Yes"].tap() + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() + XCTAssert(app.tables.staticTexts["orange"].waitForExistence(timeout: 2)) app.tables.staticTexts["orange"].tap() + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() + XCTAssert(integerField.waitForExistence(timeout: 2)) integerField.tap() integerField.typeText("2\n") + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() // Only one result screen should appear. + sleep(1) + XCTAssert(app.buttons["Next"].waitForExistence(timeout: 2)) app.buttons["Next"].tap() // Now the completion screen should appear. + sleep(1) + XCTAssert(app.buttons["Done"].waitForExistence(timeout: 2)) app.buttons["Done"].tap() } @@ -499,14 +544,19 @@ final class ExampleUITests: XCTestCase { app.launch() let imageCaptureButton = app.collectionViews.buttons["Image Capture Example"] + XCTAssert(imageCaptureButton.waitForExistence(timeout: 2)) imageCaptureButton.tap() // This example requires access to a device camera, which can't be simulated, // so we will get an error message. - XCTAssert(app.staticTexts["No camera found. This step cannot be completed."].exists) + XCTAssert(app.staticTexts["No camera found. This step cannot be completed."].waitForExistence(timeout: 2)) - app.buttons["Skip"].tap() + let skipButton = app.buttons["Skip"] + XCTAssert(skipButton.waitForExistence(timeout: 2)) + skipButton.tap() - app.buttons["Done"].tap() + let doneButton = app.buttons["Done"] + XCTAssert(doneButton.waitForExistence(timeout: 2)) + doneButton.tap() } } diff --git a/Sources/FHIRQuestionnaires/Resources/FormExample.json b/Sources/FHIRQuestionnaires/Resources/FormExample.json index 723ad11..2569657 100644 --- a/Sources/FHIRQuestionnaires/Resources/FormExample.json +++ b/Sources/FHIRQuestionnaires/Resources/FormExample.json @@ -62,7 +62,7 @@ }, { "linkId": "5ad9456c-1451-4190-fff5-3fd71545a591", - "type": "choice", + "type": "open-choice", "text": "What is your favorite flavor of ice cream?", "required": true, "answerOption": [ @@ -105,6 +105,20 @@ "type": "choice", "text": "What is your favorite topping on ice cream?", "required": false, + "extension": [ + { + "url": "http://hl7.org/fhir/StructureDefinition/questionnaire-itemControl", + "valueCodeableConcept": { + "coding": [ + { + "system": "http://hl7.org/fhir/questionnaire-item-control", + "code": "check-box", + "display": "Checkbox" + } + ] + } + }, + ], "answerOption": [ { "valueCoding": { diff --git a/Sources/ResearchKitOnFHIR/FHIRToResearchKit/QuestionnaireItem+ResearchKit.swift b/Sources/ResearchKitOnFHIR/FHIRToResearchKit/QuestionnaireItem+ResearchKit.swift index 7fd9094..a9ccff5 100644 --- a/Sources/ResearchKitOnFHIR/FHIRToResearchKit/QuestionnaireItem+ResearchKit.swift +++ b/Sources/ResearchKitOnFHIR/FHIRToResearchKit/QuestionnaireItem+ResearchKit.swift @@ -151,7 +151,11 @@ extension QuestionnaireItem { guard !answerOptions.isEmpty else { throw FHIRToResearchKitConversionError.noOptions } - return ORKTextChoiceAnswerFormat(style: ORKChoiceAnswerStyle.singleChoice, textChoices: answerOptions) + var choiceAnswerStyle = ORKChoiceAnswerStyle.singleChoice + if itemControl == "check-box" { + choiceAnswerStyle = .multipleChoice + } + return ORKTextChoiceAnswerFormat(style: choiceAnswerStyle, textChoices: answerOptions) case .date: return ORKDateAnswerFormat(style: ORKDateAnswerStyle.date) case .dateTime: diff --git a/Sources/ResearchKitOnFHIR/ResearchKitToFHIR/ORKTaskResult+FHIR.swift b/Sources/ResearchKitOnFHIR/ResearchKitToFHIR/ORKTaskResult+FHIR.swift index 5c8747c..e65a4be 100644 --- a/Sources/ResearchKitOnFHIR/ResearchKitToFHIR/ORKTaskResult+FHIR.swift +++ b/Sources/ResearchKitOnFHIR/ResearchKitToFHIR/ORKTaskResult+FHIR.swift @@ -43,33 +43,42 @@ extension ORKTaskResult { // MARK: Functions for creating FHIR responses from ResearchKit results + private func appendResponseAnswer(_ value: QuestionnaireResponseItemAnswer.ValueX?, to responseAnswers: inout [QuestionnaireResponseItemAnswer]) { + let responseAnswer = QuestionnaireResponseItemAnswer() + responseAnswer.value = value + responseAnswers.append(responseAnswer) + } + private func createResponse(_ result: ORKResult) -> QuestionnaireResponseItem { let response = QuestionnaireResponseItem(linkId: FHIRPrimitive(FHIRString(result.identifier))) - let responseAnswer = QuestionnaireResponseItemAnswer() + var responseAnswers: [QuestionnaireResponseItemAnswer] = [] switch result { case let result as ORKBooleanQuestionResult: - responseAnswer.value = createBooleanResponse(result) + appendResponseAnswer(createBooleanResponse(result), to: &responseAnswers) case let result as ORKChoiceQuestionResult: - responseAnswer.value = createChoiceResponse(result) + let values = createChoiceResponse(result) + for value in values { + appendResponseAnswer(value, to: &responseAnswers) + } case let result as ORKFileResult: - responseAnswer.value = createAttachmentResponse(result) + appendResponseAnswer(createAttachmentResponse(result), to: &responseAnswers) case let result as ORKNumericQuestionResult: - responseAnswer.value = createNumericResponse(result) + appendResponseAnswer(createNumericResponse(result), to: &responseAnswers) case let result as ORKDateQuestionResult: - responseAnswer.value = createDateResponse(result) + appendResponseAnswer(createDateResponse(result), to: &responseAnswers) case let result as ORKScaleQuestionResult: - responseAnswer.value = createScaleResponse(result) + appendResponseAnswer(createScaleResponse(result), to: &responseAnswers) case let result as ORKTextQuestionResult: - responseAnswer.value = createTextResponse(result) + appendResponseAnswer(createTextResponse(result), to: &responseAnswers) case let result as ORKTimeOfDayQuestionResult: - responseAnswer.value = createTimeResponse(result) + appendResponseAnswer(createTimeResponse(result), to: &responseAnswers) default: // Unsupported result type - responseAnswer.value = nil + appendResponseAnswer(nil, to: &responseAnswers) } - response.answer = [responseAnswer] + response.answer = responseAnswers return response } @@ -110,27 +119,28 @@ extension ORKTaskResult { return .string(FHIRPrimitive(FHIRString(text))) } - private func createChoiceResponse(_ result: ORKChoiceQuestionResult) -> QuestionnaireResponseItemAnswer.ValueX? { + private func createChoiceResponse(_ result: ORKChoiceQuestionResult) -> [QuestionnaireResponseItemAnswer.ValueX] { guard let answerArray = result.answer as? NSArray, answerArray.count > 0 else { // swiftlint:disable:this empty_count - return nil + return [] } - // If the result is a string (i.e. the user selected the "other" option and entered a free-text answer), return a String - if let answerString = answerArray[0] as? String { - return .string(FHIRPrimitive(FHIRString(answerString))) - } + var responses: [QuestionnaireResponseItemAnswer.ValueX] = [] - // If the result is a dictionary containing a code and system, return a Coding - guard let valueCodingString = answerArray[0] as? String, - let valueCoding = ValueCoding(rawValue: valueCodingString) else { - return nil + for answer in answerArray { + // Check if answer can be treated as a ValueCoding first + if let valueCodingString = answer as? String, let valueCoding = ValueCoding(rawValue: valueCodingString) { + let coding = Coding( + code: FHIRPrimitive(FHIRString(valueCoding.code)), + system: FHIRPrimitive(FHIRURI(stringLiteral: valueCoding.system)) + ) + responses += [.coding(coding)] + } else if let answerString = answer as? String { + // If not, fall back to treating it as a regular string + responses += [.string(FHIRPrimitive(FHIRString(answerString)))] + } } - let coding = Coding( - code: FHIRPrimitive(FHIRString(valueCoding.code)), - system: FHIRPrimitive(FHIRURI(stringLiteral: valueCoding.system)) - ) - return .coding(coding) + return responses } private func createBooleanResponse(_ result: ORKBooleanQuestionResult) -> QuestionnaireResponseItemAnswer.ValueX? { diff --git a/Tests/ResearchKitOnFHIRTests/ResearchKitToFHIRTests.swift b/Tests/ResearchKitOnFHIRTests/ResearchKitToFHIRTests.swift index ed6364b..688f4ec 100644 --- a/Tests/ResearchKitOnFHIRTests/ResearchKitToFHIRTests.swift +++ b/Tests/ResearchKitOnFHIRTests/ResearchKitToFHIRTests.swift @@ -182,24 +182,76 @@ final class ResearchKitToFHIRTests: XCTestCase { XCTAssertEqual(testValue, responseValue) } - func testChoiceResponse() { - let testValue = ValueCoding(code: "testCode", system: "testSystem") + func testSingleChoiceResponse() { + let testValue = ValueCoding(code: "testCode", system: "http://biodesign.stanford.edu/test-system") let choiceResult = ORKChoiceQuestionResult(identifier: "choiceResult") choiceResult.choiceAnswers = [testValue.rawValue as NSSecureCoding & NSCopying & NSObjectProtocol] let taskResult = createTaskResult(choiceResult) let fhirResponse = taskResult.fhirResponse - let answer = fhirResponse.item?.first?.answer?.first?.value + guard let answer = fhirResponse.item?.first?.answer?.first?.value else { + XCTFail("Could not find the answer in the FHIR response.") + return + } + + switch answer { + case let .coding(coding): + guard let code = coding.code?.value?.string, + let system = coding.system?.value?.url.absoluteString else { + XCTFail("Could not extract the code and system from the coding.") + return + } + + let valueCoding = ValueCoding(code: code, system: system) + XCTAssertEqual(testValue, valueCoding) + + default: + XCTFail("Expected a coding value.") + } + } + + + func testMultipleChoiceResponse() { + let testValues = [ + ValueCoding(code: "testCode1", system: "http://biodesign.stanford.edu/test-system"), + ValueCoding(code: "testCode2", system: "http://biodesign.stanford.edu/test-system") + ] + + let choiceResult = ORKChoiceQuestionResult(identifier: "choiceResult") + choiceResult.choiceAnswers = testValues.map { $0.rawValue as NSSecureCoding & NSCopying & NSObjectProtocol } + + let taskResult = createTaskResult(choiceResult) - guard case let .string(fhirString) = answer, - let rawValue = fhirString.value?.string, - let valueCoding = ValueCoding(rawValue: rawValue) else { - XCTFail("Could not extract the value coding system.") + let fhirResponse = taskResult.fhirResponse + + guard let firstItem = fhirResponse.item?.first, + let answers = firstItem.answer?.compactMap({ $0.value }) else { + XCTFail("Invalid FHIR response.") return } - XCTAssertEqual(testValue, valueCoding) + guard answers.count == testValues.count else { + XCTFail("Number of returned answers (\(answers.count)) does not match expected (\(testValues.count)).") + return + } + + for (index, answer) in answers.enumerated() { + switch answer { + case let .coding(coding): + guard let code = coding.code?.value?.string, + let system = coding.system?.value?.url.absoluteString else { + XCTFail("Could not extract the code and system from the coding.") + return + } + + let valueCoding = ValueCoding(code: code, system: system) + XCTAssertEqual(testValues[index], valueCoding) + + default: + XCTFail("Expected a coding value.") + } + } } func testAttachmentResult() {