From f36323724e9594b04411ff270fac4cf17842836f Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Thu, 4 Jul 2024 16:49:45 +0100 Subject: [PATCH] Evaluate enable when with newly created repeated groups (#2597) * Update enablement evaluator * Add test case for calculating enable when with new repeated group * Add tests for shouldHaveNestedItemsUnderAnswers --- .../datacapture/QuestionnaireViewModel.kt | 16 ++- .../MoreQuestionnaireItemComponents.kt | 125 +++++++++--------- .../datacapture/QuestionnaireViewModelTest.kt | 96 +++++++++++++- .../MoreQuestionnaireItemComponentsTest.kt | 93 +++++++++++++ 4 files changed, 258 insertions(+), 72 deletions(-) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt index 48adf0a118..260d0269df 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt @@ -347,7 +347,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat Any?, ) -> Unit = { questionnaireItem, questionnaireResponseItem, answers, draftAnswer -> - // TODO(jingtang10): update the questionnaire response item pre-order list and the parent map questionnaireResponseItem.answer = answers.toList() when { (questionnaireResponseItem.answer.isNotEmpty()) -> { @@ -363,6 +362,19 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } if (questionnaireItem.shouldHaveNestedItemsUnderAnswers) { questionnaireResponseItem.addNestedItemsToAnswer(questionnaireItem) + + // If nested items are added to the answer, the enablement evaluator needs to be + // reinitialized in order for it to rebuild the pre-order map and parent map of + // questionnaire response items to reflect the new structure of the questionnaire response + // to correctly calculate calculate enable when statements. + enablementEvaluator = + EnablementEvaluator( + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + questionnaireLaunchContextMap, + xFhirQueryResolver, + ) } modifiedQuestionnaireResponseItemSet.add(questionnaireResponseItem) @@ -380,7 +392,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat xFhirQueryResolver, ) - private val enablementEvaluator: EnablementEvaluator = + private var enablementEvaluator: EnablementEvaluator = EnablementEvaluator( questionnaire, questionnaireResponse, diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt index c7b6b97ba5..fa3f8e6b55 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponents.kt @@ -197,14 +197,14 @@ enum class ItemControlTypes( * The initial-expression extension on [QuestionnaireItemComponent] to allow dynamic selection of * default or initially selected answers */ -val Questionnaire.QuestionnaireItemComponent.initialExpression: Expression? +val QuestionnaireItemComponent.initialExpression: Expression? get() { return this.extension .firstOrNull { it.url == ITEM_INITIAL_EXPRESSION_URL } ?.let { it.value as Expression } } -val Questionnaire.QuestionnaireItemComponent.itemControlCode: String? +val QuestionnaireItemComponent.itemControlCode: String? get() { val codeableConcept = this.extension @@ -227,7 +227,7 @@ val Questionnaire.QuestionnaireItemComponent.itemControlCode: String? * * See http://hl7.org/fhir/R4/extension-questionnaire-itemcontrol.html. */ -val Questionnaire.QuestionnaireItemComponent.itemControl: ItemControlTypes? +val QuestionnaireItemComponent.itemControl: ItemControlTypes? get() = ItemControlTypes.values().firstOrNull { it.extensionCode == itemControlCode } /** @@ -241,7 +241,7 @@ enum class ChoiceOrientationTypes(val extensionCode: String) { } /** Desired orientation to render a list of choices. */ -val Questionnaire.QuestionnaireItemComponent.choiceOrientation: ChoiceOrientationTypes? +val QuestionnaireItemComponent.choiceOrientation: ChoiceOrientationTypes? get() { val code = (this.extension.firstOrNull { it.url == EXTENSION_CHOICE_ORIENTATION_URL }?.value @@ -253,7 +253,7 @@ val Questionnaire.QuestionnaireItemComponent.choiceOrientation: ChoiceOrientatio /** * Whether the QuestionnaireItem should be hidden according to the hidden extension or lack thereof. */ -internal val Questionnaire.QuestionnaireItemComponent.isHidden: Boolean +internal val QuestionnaireItemComponent.isHidden: Boolean get() { val extension = this.extension.singleOrNull { it.url == EXTENSION_HIDDEN_URL } ?: return false val value = extension.value @@ -266,7 +266,7 @@ internal val Questionnaire.QuestionnaireItemComponent.isHidden: Boolean /** * The entry format specified in the extension https://hl7.org/fhir/R4/extension-entryformat.html. */ -val Questionnaire.QuestionnaireItemComponent.entryFormat: String? +val QuestionnaireItemComponent.entryFormat: String? get() { val extension = extension.singleOrNull { it.url == EXTENSION_ENTRY_FORMAT_URL } ?: return null val value = extension.value @@ -280,7 +280,7 @@ val Questionnaire.QuestionnaireItemComponent.entryFormat: String? * The date entry format for the questionnaire item component if one is specified, otherwise, the * system default date entry format. */ -val Questionnaire.QuestionnaireItemComponent.dateEntryFormatOrSystemDefault: String +val QuestionnaireItemComponent.dateEntryFormatOrSystemDefault: String get() { return if (isValidDateEntryFormat(entryFormat)) { entryFormat!! @@ -304,7 +304,7 @@ private fun isValidDateEntryFormat(entryFormat: String?): Boolean { } /** Slider step extension value. */ -val Questionnaire.QuestionnaireItemComponent.sliderStepValue: Int? +val QuestionnaireItemComponent.sliderStepValue: Int? get() { val extension = this.extension.singleOrNull { it.url == EXTENSION_SLIDER_STEP_VALUE_URL } ?: return null @@ -315,16 +315,16 @@ val Questionnaire.QuestionnaireItemComponent.sliderStepValue: Int? return null } -internal val Questionnaire.QuestionnaireItemComponent.minValue +internal val QuestionnaireItemComponent.minValue get() = getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value -internal val Questionnaire.QuestionnaireItemComponent.minValueCqfCalculatedValueExpression +internal val QuestionnaireItemComponent.minValueCqfCalculatedValueExpression get() = getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value?.cqfCalculatedValueExpression -internal val Questionnaire.QuestionnaireItemComponent.maxValue +internal val QuestionnaireItemComponent.maxValue get() = getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value -internal val Questionnaire.QuestionnaireItemComponent.maxValueCqfCalculatedValueExpression +internal val QuestionnaireItemComponent.maxValueCqfCalculatedValueExpression get() = getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value?.cqfCalculatedValueExpression // ********************************************************************************************** // @@ -342,7 +342,7 @@ internal enum class DisplayItemControlType(val extensionCode: String) { } /** Item control to show instruction text */ -internal val Questionnaire.QuestionnaireItemComponent.displayItemControl: DisplayItemControlType? +internal val QuestionnaireItemComponent.displayItemControl: DisplayItemControlType? get() { val codeableConcept = this.extension.firstOrNull { it.url == EXTENSION_ITEM_CONTROL_URL }?.value as CodeableConcept? @@ -352,7 +352,7 @@ internal val Questionnaire.QuestionnaireItemComponent.displayItemControl: Displa } /** Whether any one of the nested display item has [DisplayItemControlType.HELP] control. */ -val Questionnaire.QuestionnaireItemComponent.hasHelpButton: Boolean +val QuestionnaireItemComponent.hasHelpButton: Boolean get() { return item.any { it.isHelpCode } } @@ -366,14 +366,14 @@ internal fun String.toSpanned(): Spanned { * Localized and spanned value of [Questionnaire.QuestionnaireItemComponent.text] if translation is * present. Default value otherwise. */ -val Questionnaire.QuestionnaireItemComponent.localizedTextSpanned: Spanned? +val QuestionnaireItemComponent.localizedTextSpanned: Spanned? get() = textElement?.getLocalizedText()?.toSpanned() /** * Localized and spanned value of [Questionnaire.QuestionnaireItemComponent.prefix] if translation * is present. Default value otherwise. */ -val Questionnaire.QuestionnaireItemComponent.localizedPrefixSpanned: Spanned? +val QuestionnaireItemComponent.localizedPrefixSpanned: Spanned? get() = prefixElement?.getLocalizedText()?.toSpanned() /** @@ -381,7 +381,7 @@ val Questionnaire.QuestionnaireItemComponent.localizedPrefixSpanned: Spanned? * [EXTENSION_DISPLAY_CATEGORY_INSTRUCTIONS] code is used as the instructions of the parent * question. */ -val Questionnaire.QuestionnaireItemComponent.localizedInstructionsSpanned: Spanned +val QuestionnaireItemComponent.localizedInstructionsSpanned: Spanned get() = item.getLocalizedInstructionsSpanned() /** @@ -389,7 +389,7 @@ val Questionnaire.QuestionnaireItemComponent.localizedInstructionsSpanned: Spann * list that are of type `Questionnaire.QuestionnaireItemType.DISPLAY` and have the * `isInstructionsCode` flag set. The instructions are separated by newlines. */ -fun List.getLocalizedInstructionsSpanned( +fun List.getLocalizedInstructionsSpanned( separator: String = "\n", ) = SpannableStringBuilder().apply { @@ -405,11 +405,11 @@ fun List.getLocalizedInstructionsSpann * A nested questionnaire item of type display with code [DisplayItemControlType.FLYOVER] (if * present) is used as the fly-over text of the parent question. */ -internal val Questionnaire.QuestionnaireItemComponent.localizedFlyoverSpanned: Spanned? +internal val QuestionnaireItemComponent.localizedFlyoverSpanned: Spanned? get() = item.localizedFlyoverSpanned /** [localizedFlyoverSpanned] over list of [Questionnaire.QuestionnaireItemComponent] */ -val List.localizedFlyoverSpanned: Spanned? +val List.localizedFlyoverSpanned: Spanned? get() = this.firstOrNull { questionnaireItem -> questionnaireItem.type == Questionnaire.QuestionnaireItemType.DISPLAY && @@ -422,18 +422,18 @@ val List.localizedFlyoverSpanned: Span * [EXTENSION_DISPLAY_CATEGORY_INSTRUCTIONS] code is used as the instructions of the parent * question. */ -val Questionnaire.QuestionnaireItemComponent.localizedHelpSpanned: Spanned? +val QuestionnaireItemComponent.localizedHelpSpanned: Spanned? get() = item.localizedHelpSpanned /** [localizedHelpSpanned] over list of [Questionnaire.QuestionnaireItemComponent] */ -val List.localizedHelpSpanned: Spanned? +val List.localizedHelpSpanned: Spanned? get() { return this.firstOrNull { questionnaireItem -> questionnaireItem.isHelpCode } ?.localizedTextSpanned } /** Returns `true` if extension is display category extension and contains 'instructions' code. */ -internal val Questionnaire.QuestionnaireItemComponent.isInstructionsCode: Boolean +internal val QuestionnaireItemComponent.isInstructionsCode: Boolean get() { return when (type) { Questionnaire.QuestionnaireItemType.DISPLAY -> { @@ -457,7 +457,7 @@ internal val Questionnaire.QuestionnaireItemComponent.isInstructionsCode: Boolea * Returns `true` if item type is display and [displayItemControl] is * [DisplayItemControlType.FLYOVER]. */ -internal val Questionnaire.QuestionnaireItemComponent.isFlyoverCode: Boolean +internal val QuestionnaireItemComponent.isFlyoverCode: Boolean get() { return when (type) { Questionnaire.QuestionnaireItemType.DISPLAY -> { @@ -470,7 +470,7 @@ internal val Questionnaire.QuestionnaireItemComponent.isFlyoverCode: Boolean } /** Whether item type is display and [displayItemControl] is [DisplayItemControlType.HELP]. */ -internal val Questionnaire.QuestionnaireItemComponent.isHelpCode: Boolean +internal val QuestionnaireItemComponent.isHelpCode: Boolean get() { return when (type) { Questionnaire.QuestionnaireItemType.DISPLAY -> { @@ -483,7 +483,7 @@ internal val Questionnaire.QuestionnaireItemComponent.isHelpCode: Boolean } /** Whether item type is display. */ -internal val Questionnaire.QuestionnaireItemComponent.isDisplayItem: Boolean +internal val QuestionnaireItemComponent.isDisplayItem: Boolean get() = (type == Questionnaire.QuestionnaireItemType.DISPLAY && (isInstructionsCode || isFlyoverCode || isHelpCode)) @@ -497,7 +497,7 @@ internal val Questionnaire.QuestionnaireItemComponent.isDisplayItem: Boolean // ********************************************************************************************** // /** Identifies the kinds of attachment allowed to be sent for an element. */ -val Questionnaire.QuestionnaireItemComponent.mimeTypes: List +val QuestionnaireItemComponent.mimeTypes: List get() { return extension .filter { it.url == EXTENSION_MIME_TYPE } @@ -517,17 +517,17 @@ enum class MimeType(val value: String) { private fun getMimeType(mimeType: String): String = mimeType.substringBefore("/") /** Returns true if at least one mime type matches the given type. */ -fun Questionnaire.QuestionnaireItemComponent.hasMimeType(type: String): Boolean { +fun QuestionnaireItemComponent.hasMimeType(type: String): Boolean { return mimeTypes.any { it.substringBefore("/") == type } } /** Returns true if all mime types match the given type. */ -fun Questionnaire.QuestionnaireItemComponent.hasMimeTypeOnly(type: String): Boolean { +fun QuestionnaireItemComponent.hasMimeTypeOnly(type: String): Boolean { return mimeTypes.all { it.substringBefore("/") == type } } /** The maximum size of an attachment in Bytes. */ -internal val Questionnaire.QuestionnaireItemComponent.maxSizeInBytes: BigDecimal? +internal val QuestionnaireItemComponent.maxSizeInBytes: BigDecimal? get() = (extension.firstOrNull { it.url == EXTENSION_MAX_SIZE }?.valueAsPrimitive as DecimalType?) ?.value @@ -535,27 +535,27 @@ internal val Questionnaire.QuestionnaireItemComponent.maxSizeInBytes: BigDecimal private val BYTES_PER_KIB = BigDecimal(1024) /** The maximum size of an attachment in Kibibytes. */ -internal val Questionnaire.QuestionnaireItemComponent.maxSizeInKiBs: BigDecimal? +internal val QuestionnaireItemComponent.maxSizeInKiBs: BigDecimal? get() = maxSizeInBytes?.div(BYTES_PER_KIB) private val BYTES_PER_MIB = BigDecimal(1048576) /** The maximum size of an attachment in Mebibytes. */ -internal val Questionnaire.QuestionnaireItemComponent.maxSizeInMiBs: BigDecimal? +internal val QuestionnaireItemComponent.maxSizeInMiBs: BigDecimal? get() = maxSizeInBytes?.div(BYTES_PER_MIB) /** The default maximum size of an attachment is 1 Mebibytes. */ private val DEFAULT_SIZE = BigDecimal(1048576) /** Returns true if given size is above maximum size allowed. */ -internal fun Questionnaire.QuestionnaireItemComponent.isGivenSizeOverLimit( +internal fun QuestionnaireItemComponent.isGivenSizeOverLimit( size: BigDecimal, ): Boolean { return size > (maxSizeInBytes ?: DEFAULT_SIZE) } /** A media that is attached to a [Questionnaire.QuestionnaireItemComponent]. */ -internal val Questionnaire.QuestionnaireItemComponent.itemMedia: Attachment? +internal val QuestionnaireItemComponent.itemMedia: Attachment? get() = (getExtensionByUrl(EXTENSION_ITEM_MEDIA)?.value as? Attachment)?.takeIf { it.hasContentType() } @@ -594,7 +594,7 @@ private fun ByteArray.decodeToBitmap(): Bitmap? { * * See http://hl7.org/fhir/R4/extension-questionnaire-unit.html. */ -internal val Questionnaire.QuestionnaireItemComponent.unit: Coding? +internal val QuestionnaireItemComponent.unit: Coding? get() { val extension = this.extension.singleOrNull { it.url == EXTENSION_QUESTIONNAIRE_UNIT_URL } ?: return null @@ -610,7 +610,7 @@ internal val Questionnaire.QuestionnaireItemComponent.unit: Coding? * * See http://hl7.org/fhir/R4/extension-questionnaire-unitoption.html. */ -internal val Questionnaire.QuestionnaireItemComponent.unitOption: List +internal val QuestionnaireItemComponent.unitOption: List get() { return this.extension .filter { it.url == EXTENSION_QUESTIONNAIRE_UNIT_OPTION_URL } @@ -632,7 +632,7 @@ internal val Questionnaire.QuestionnaireItemComponent.unitOption: List // // // ********************************************************************************************** // -internal val Questionnaire.QuestionnaireItemComponent.answerOptionsToggleExpressions +internal val QuestionnaireItemComponent.answerOptionsToggleExpressions get() = this.extension .filter { it.url == EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL } @@ -654,14 +654,14 @@ internal val Questionnaire.QuestionnaireItemComponent.answerOptionsToggleExpress } // Return expression if QuestionnaireItemComponent has ENABLE WHEN EXPRESSION URL -val Questionnaire.QuestionnaireItemComponent.enableWhenExpression: Expression? +val QuestionnaireItemComponent.enableWhenExpression: Expression? get() { return this.extension .firstOrNull { it.url == EXTENSION_ENABLE_WHEN_EXPRESSION_URL } ?.let { it.value as Expression } } -internal val Questionnaire.QuestionnaireItemComponent.variableExpressions: List +internal val QuestionnaireItemComponent.variableExpressions: List get() = this.extension.filter { it.url == EXTENSION_VARIABLE_URL }.map { it.castToExpression(it.value) } @@ -672,21 +672,21 @@ internal val Questionnaire.QuestionnaireItemComponent.variableExpressions: List< * @param variableName the [String] to match the variable * @return an [Expression] */ -internal fun Questionnaire.QuestionnaireItemComponent.findVariableExpression( +internal fun QuestionnaireItemComponent.findVariableExpression( variableName: String, ): Expression? { return variableExpressions.find { it.name == variableName } } /** Returns Calculated expression, or null */ -internal val Questionnaire.QuestionnaireItemComponent.calculatedExpression: Expression? +internal val QuestionnaireItemComponent.calculatedExpression: Expression? get() = this.getExtensionByUrl(EXTENSION_CALCULATED_EXPRESSION_URL)?.let { it.castToExpression(it.value) } /** Returns list of extensions whose value is of type [Expression] */ -internal val Questionnaire.QuestionnaireItemComponent.expressionBasedExtensions +internal val QuestionnaireItemComponent.expressionBasedExtensions get() = this.extension.filter { it.value is Expression } /** @@ -694,8 +694,8 @@ internal val Questionnaire.QuestionnaireItemComponent.expressionBasedExtensions * (e.g. if [item] has an expression `%resource.item.where(linkId='this-question')` where * `this-question` is the link ID of the current questionnaire item). */ -internal fun Questionnaire.QuestionnaireItemComponent.isReferencedBy( - item: Questionnaire.QuestionnaireItemComponent, +internal fun QuestionnaireItemComponent.isReferencedBy( + item: QuestionnaireItemComponent, ) = item.expressionBasedExtensions.any { it @@ -705,13 +705,13 @@ internal fun Questionnaire.QuestionnaireItemComponent.isReferencedBy( .contains(Regex(".*linkId='${this.linkId}'.*")) } -internal val Questionnaire.QuestionnaireItemComponent.answerExpression: Expression? +internal val QuestionnaireItemComponent.answerExpression: Expression? get() = ToolingExtensions.getExtension(this, EXTENSION_ANSWER_EXPRESSION_URL)?.value?.let { it.castToExpression(it) } -internal val Questionnaire.QuestionnaireItemComponent.candidateExpression: Expression? +internal val QuestionnaireItemComponent.candidateExpression: Expression? get() = ToolingExtensions.getExtension(this, EXTENSION_CANDIDATE_EXPRESSION_URL)?.value?.let { it.castToExpression(it) @@ -729,7 +729,7 @@ internal val Questionnaire.QuestionnaireItemComponent.candidateExpression: Expre * - label -> the label of given column of table or answerOption * - forDisplay -> if the column should be shown on UI */ -internal val Questionnaire.QuestionnaireItemComponent.choiceColumn: List? +internal val QuestionnaireItemComponent.choiceColumn: List? get() = ToolingExtensions.getExtensions(this, EXTENSION_CHOICE_COLUMN_URL)?.map { extension -> extension.extension.let { nestedExtensions -> @@ -766,7 +766,7 @@ internal data class ChoiceColumn(val path: String, val label: String?, val forDi * resources [Resource], identifiers [Identifier] or codes [Coding] * @return list of answer options [Questionnaire.QuestionnaireItemAnswerOptionComponent] */ -internal fun Questionnaire.QuestionnaireItemComponent.extractAnswerOptions( +internal fun QuestionnaireItemComponent.extractAnswerOptions( dataList: List, ): List { return when (this.type) { @@ -821,11 +821,11 @@ enum class ConstraintSeverityTypes( * QuestionnaireResponseItemComponent with same linkId. So these items are grouped with linkId and * associated with its questionnaire item linkId. */ -internal inline fun List.zipByLinkId( +internal inline fun List.zipByLinkId( questionnaireResponseItemList: List, transform: ( - Questionnaire.QuestionnaireItemComponent, + QuestionnaireItemComponent, QuestionnaireResponse.QuestionnaireResponseItemComponent, ) -> T, ): List { @@ -849,11 +849,11 @@ internal inline fun List.zipByLink * associated with its questionnaire item linkId. */ internal inline fun groupByAndZipByLinkId( - questionnaireItemList: List, + questionnaireItemList: List, questionnaireResponseItemList: List, transform: ( - List, + List, List, ) -> T, ): List { @@ -883,8 +883,8 @@ internal inline fun groupByAndZipByLinkId( * * For background, see https://build.fhir.org/questionnaireresponse.html#link. */ -internal val Questionnaire.QuestionnaireItemComponent.shouldHaveNestedItemsUnderAnswers: Boolean - get() = item.isNotEmpty() && (type != Questionnaire.QuestionnaireItemType.GROUP || !repeats) +internal val QuestionnaireItemComponent.shouldHaveNestedItemsUnderAnswers: Boolean + get() = item.isNotEmpty() && (type != Questionnaire.QuestionnaireItemType.GROUP || repeats) /** * Creates a list of [QuestionnaireResponse.QuestionnaireResponseItemComponent]s from the nested @@ -893,7 +893,7 @@ internal val Questionnaire.QuestionnaireItemComponent.shouldHaveNestedItemsUnder * The hierarchy and order of child items will be retained as specified in the standard. See * https://www.hl7.org/fhir/questionnaireresponse.html#notes for more details. */ -fun Questionnaire.QuestionnaireItemComponent.getNestedQuestionnaireResponseItems() = +fun QuestionnaireItemComponent.getNestedQuestionnaireResponseItems() = item.map { it.createQuestionnaireResponseItem() } /** @@ -903,7 +903,7 @@ fun Questionnaire.QuestionnaireItemComponent.getNestedQuestionnaireResponseItems * The hierarchy and order of child items will be retained as specified in the standard. See * https://www.hl7.org/fhir/questionnaireresponse.html#notes for more details. */ -fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItem(): +fun QuestionnaireItemComponent.createQuestionnaireResponseItem(): QuestionnaireResponse.QuestionnaireResponseItemComponent { return QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = this@createQuestionnaireResponseItem.linkId @@ -927,7 +927,7 @@ fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItem(): * Returns a list of answers from the initial values of the questionnaire item. `null` if no initial * value. */ -private fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItemAnswers(): +private fun QuestionnaireItemComponent.createQuestionnaireResponseItemAnswers(): MutableList? { // TODO https://github.com/google/android-fhir/issues/2161 // The rule can be by-passed if initial value was set by an initial-expression. @@ -974,12 +974,11 @@ private fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponse * Flatten a nested list of [Questionnaire.QuestionnaireItemComponent] recursively and returns a * flat list of all items into list embedded at any level */ -fun List.flattened(): - List = - mutableListOf().also { flattenInto(it) } +fun List.flattened(): List = + mutableListOf().also { flattenInto(it) } -private fun List.flattenInto( - output: MutableList, +private fun List.flattenInto( + output: MutableList, ) { forEach { output.add(it) @@ -987,7 +986,7 @@ private fun List.flattenInto( } } -internal val Questionnaire.QuestionnaireItemComponent.isRepeatedGroup: Boolean +internal val QuestionnaireItemComponent.isRepeatedGroup: Boolean get() = type == Questionnaire.QuestionnaireItemType.GROUP && repeats // TODO: Move this elsewhere. diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt index 6f04e0048a..662773fb31 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt @@ -3174,7 +3174,7 @@ class QuestionnaireViewModelTest { } // ==================================================================== // // // - // Navigation in Long Scroll // + // Navigation in Long Scroll // // // // ==================================================================== // @@ -4561,6 +4561,93 @@ class QuestionnaireViewModelTest { } } + // ==================================================================== // + // // + // Repeated Groups with Enable When // + // // + // ==================================================================== // + + // https://github.com/google/android-fhir/issues/2590 + @Test + fun `should evaluate enable when with new questionnaire response items eg added repeated group`() = + runTest { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + QuestionnaireItemComponent().apply { + linkId = "repeated-group" + type = Questionnaire.QuestionnaireItemType.GROUP + repeats = true + addItem( + QuestionnaireItemComponent().apply { + linkId = "nested-1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + }, + ) + addItem( + QuestionnaireItemComponent().apply { + linkId = "nested-2" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + addEnableWhen().apply { + answer = BooleanType(true) + question = "nested-1" + operator = Questionnaire.QuestionnaireItemOperator.EQUAL + } + }, + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel.getQuestionnaireItemViewItemList().single().asQuestion().apply { + this.answersChangedCallback( + this.questionnaireItem, + this.getQuestionnaireResponseItem(), + listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent(), + ), + null, + ) + } + + assertThat( + viewModel + .getQuestionnaireItemViewItemList() + .filterIsInstance() + .map { it.asQuestion().questionnaireItem.linkId }, + ) + .containsExactly("repeated-group", "nested-1") + + viewModel + .getQuestionnaireItemViewItemList() + .first { it.asQuestionOrNull()?.questionnaireItem?.linkId == "nested-1" } + .asQuestion() + .apply { + this.answersChangedCallback( + this.questionnaireItem, + this.getQuestionnaireResponseItem(), + listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + this.value = valueBooleanType.setValue(true) + }, + ), + null, + ) + } + + assertThat( + viewModel + .getQuestionnaireItemViewItemList() + .filterIsInstance() + .map { it.asQuestion().questionnaireItem.linkId }, + ) + .containsExactly("repeated-group", "nested-1", "nested-2") + } + } + // ==================================================================== // // // // Answer Value Sets // @@ -5074,6 +5161,7 @@ class QuestionnaireViewModelTest { // Answer Options Toggle Expression // // // // ==================================================================== // + @Test fun `only answer options evaluating to true in answerOptionsToggleExpression occurrences should be enabled on initial load`() = runTest { @@ -7314,12 +7402,6 @@ class QuestionnaireViewModelTest { private fun QuestionnaireViewModel.getQuestionnaireItemViewItemList() = questionnaireStateFlow.value.items - private fun QuestionnaireViewItem.getQuestionnaireResponseItem() = - ReflectionHelpers.getField( - this, - "questionnaireResponseItem", - ) - /** * Runs code that relies on the [QuestionnaireViewModel.viewModelScope]. Runs on * [MainDispatcherRule.testDispatcher], so that `ShadowLooper` idle functions are not necessary. diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt index 43c75f665e..beaf16d400 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaireItemComponentsTest.kt @@ -2478,6 +2478,99 @@ class MoreQuestionnaireItemComponentsTest { assertThat(zipList.size).isEqualTo(3) } + @Test + fun `should not nest items if question has no nested items`() { + val question = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + } + + assertThat(question.shouldHaveNestedItemsUnderAnswers).isFalse() + } + + @Test + fun `should not nest items if group has no nested items`() { + val question = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1" + type = Questionnaire.QuestionnaireItemType.GROUP + } + + assertThat(question.shouldHaveNestedItemsUnderAnswers).isFalse() + } + + @Test + fun `should nest items under question with nested questions`() { + val question = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "2" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + }, + ) + } + + assertThat(question.shouldHaveNestedItemsUnderAnswers).isTrue() + } + + @Test + fun `should nest items under each repeated answer of question with nested questions`() { + val question = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + repeats = true + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "2" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + }, + ) + } + + assertThat(question.shouldHaveNestedItemsUnderAnswers).isTrue() + } + + @Test + fun `should not nest items under non repeated group`() { + val question = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1" + type = Questionnaire.QuestionnaireItemType.GROUP + repeats = false + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "2" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + }, + ) + } + + assertThat(question.shouldHaveNestedItemsUnderAnswers).isFalse() + } + + @Test + fun `should nest items under repeated group`() { + val question = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "1" + type = Questionnaire.QuestionnaireItemType.GROUP + repeats = true + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "2" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + }, + ) + } + + assertThat(question.shouldHaveNestedItemsUnderAnswers).isTrue() + } + @Test fun `test questionnaireItemComponent is repeatedGroup`() { val question =