From c91dc3f449ba769779519c9214309b5092fdbf94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Wed, 8 Nov 2023 08:23:34 +0300 Subject: [PATCH 1/6] Evaluate cqf-calculatedValue expressions using ExpressionEvaluator to add support for variable expressions, launchContexts and %resource expressions --- .../datacapture/QuestionnaireViewModel.kt | 37 +- .../MoreQuestionnaireItemComponents.kt | 3 - .../fhir/datacapture/extensions/MoreTypes.kt | 20 +- .../AnswerExtensionConstraintValidator.kt | 5 +- .../validation/MaxValueValidator.kt | 11 +- .../validation/MinValueValidator.kt | 13 +- .../QuestionnaireResponseValidator.kt | 52 + .../views/QuestionnaireViewItem.kt | 11 +- .../factories/DatePickerViewHolderFactory.kt | 6 +- .../factories/SliderViewHolderFactory.kt | 16 +- .../datacapture/QuestionnaireViewModelTest.kt | 892 +++++++++++++++++- .../datacapture/extensions/MoreTypesTest.kt | 12 + .../validation/MaxLengthValidatorTest.kt | 12 +- .../validation/MaxValueValidatorTest.kt | 145 ++- .../validation/MinLengthValidatorTest.kt | 12 +- .../validation/MinValueValidatorTest.kt | 128 +-- .../QuestionnaireResponseItemValidatorTest.kt | 18 +- .../validation/RegexValidatorTest.kt | 12 +- 18 files changed, 1170 insertions(+), 235 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 8c5d551cca..5d8e480214 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 @@ -27,6 +27,7 @@ import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser import com.google.android.fhir.datacapture.enablement.EnablementEvaluator import com.google.android.fhir.datacapture.expressions.EnabledAnswerOptionsEvaluator +import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.android.fhir.datacapture.extensions.EntryMode import com.google.android.fhir.datacapture.extensions.addNestedItemsToAnswer import com.google.android.fhir.datacapture.extensions.allItems @@ -68,12 +69,14 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.withIndex import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Element +import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.Type import timber.log.Timber internal class QuestionnaireViewModel(application: Application, state: SavedStateHandle) : @@ -579,6 +582,21 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat ) } + private fun resolveCqfCalculatedValueExpression( + questionnaireItem: QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent, + expression: Expression, + ): Type? { + if (!expression.isFhirPath) { + throw UnsupportedOperationException("${expression.language} not supported yet") + } + + return expressionEvaluator + .evaluateExpression(questionnaireItem, questionnaireResponseItem, expression) + .singleOrNull() + ?.let { it as Type } + } + private fun removeDisabledAnswers( questionnaireItem: QuestionnaireItemComponent, questionnaireResponseItem: QuestionnaireResponseItemComponent, @@ -707,6 +725,23 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat restoreFromDisabledQuestionnaireItemAnswersCache(questionnaireResponseItem) + // Evaluate cqf-calculatedValues + questionnaireItem.extension + .filter { it.hasValue() && it.value.hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) } + .forEach { extension -> + val expression = + extension.value.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL).value as Expression + resolveCqfCalculatedValueExpression( + questionnaireItem, + questionnaireResponseItem, + expression, + ) + ?.let { + it.apply { setExtension(extension.value.extension) } + extension.setValue(it) + } + } + // Determine the validation result, which will be displayed on the item itself val validationResult = if ( @@ -717,7 +752,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat QuestionnaireResponseItemValidator.validate( questionnaireItem, questionnaireResponseItem.answer, - this@QuestionnaireViewModel.getApplication(), + context = this@QuestionnaireViewModel.getApplication(), ) } else { NotValidated 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 5ae1cd30b8..9981d805be 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 @@ -82,9 +82,6 @@ internal const val EXTENSION_CHOICE_ORIENTATION_URL = internal const val EXTENSION_CHOICE_COLUMN_URL: String = "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-choiceColumn" -internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String = - "http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue" - internal const val EXTENSION_DISPLAY_CATEGORY_URL = "http://hl7.org/fhir/StructureDefinition/questionnaire-displayCategory" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt index 7df499a385..25dac5004f 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt @@ -97,10 +97,8 @@ private fun getDisplayString(type: Type, context: Context): String? = private fun getValueString(type: Type): String? = when (type) { - is DateType, - is DateTimeType, - is StringType, -> type.asStringValue() - is Quantity -> type.value.toString() + is StringType -> type.getLocalizedText() ?: type.valueAsString + is Quantity -> type.takeIf { it.hasValue() }?.value?.toString() else -> (type as? PrimitiveType<*>)?.valueAsString } @@ -125,15 +123,19 @@ internal fun Coding.toCodeType(): CodeType { } fun Type.valueOrCalculateValue(): Type { - return if (this.hasExtension()) { - this.extension - .firstOrNull { it.url == EXTENSION_CQF_CALCULATED_VALUE_URL } + return if (getValueString(this) != null) { + this + } else { + this.takeIf { hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) } + ?.extension + ?.firstOrNull { it.url == EXTENSION_CQF_CALCULATED_VALUE_URL } ?.let { extension -> val expression = (extension.value as Expression).expression fhirPathEngine.evaluate(this, expression).singleOrNull()?.let { it as Type } } ?: this - } else { - this } } + +internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String = + "http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt index 46928b4435..9696908946 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt @@ -35,7 +35,10 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse internal open class AnswerExtensionConstraintValidator( val url: String, val predicate: - (Extension, QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent) -> Boolean, + ( + Extension, + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, + ) -> Boolean, val messageGenerator: (Extension, Context) -> String, ) : AnswerConstraintValidator { override fun validate( diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt index ba42e702bf..70f9f2c0d2 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt @@ -22,9 +22,7 @@ import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.getValueAsString import com.google.android.fhir.datacapture.extensions.valueOrCalculateValue import org.hl7.fhir.r4.model.Extension -import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse -import org.hl7.fhir.r4.model.Type internal const val MAX_VALUE_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/maxValue" @@ -44,11 +42,4 @@ internal object MaxValueValidator : extension.value?.valueOrCalculateValue()?.getValueAsString(context), ) }, - ) { - - fun getMaxValue(questionnaireItemComponent: Questionnaire.QuestionnaireItemComponent): Type? { - return questionnaireItemComponent.extension - .firstOrNull { it.url == MAX_VALUE_EXTENSION_URL } - ?.let { it.value?.valueOrCalculateValue() } - } -} + ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt index ff44447a59..021975e503 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt @@ -22,9 +22,7 @@ import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.getValueAsString import com.google.android.fhir.datacapture.extensions.valueOrCalculateValue import org.hl7.fhir.r4.model.Extension -import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse -import org.hl7.fhir.r4.model.Type internal const val MIN_VALUE_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/minValue" @@ -44,13 +42,4 @@ internal object MinValueValidator : extension.value?.valueOrCalculateValue()?.getValueAsString(context), ) }, - ) { - - internal fun getMinValue( - questionnaireItemComponent: Questionnaire.QuestionnaireItemComponent, - ): Type? { - return questionnaireItemComponent.extension - .firstOrNull { it.url == MIN_VALUE_EXTENSION_URL } - ?.let { it.value?.valueOrCalculateValue() } - } -} + ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt index 3fb69c03c5..2a54060f62 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt @@ -18,9 +18,15 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import com.google.android.fhir.datacapture.enablement.EnablementEvaluator +import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL +import com.google.android.fhir.datacapture.extensions.isFhirPath import com.google.android.fhir.datacapture.extensions.packRepeatedGroups +import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator +import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.Type @@ -69,6 +75,13 @@ object QuestionnaireResponseValidator { } val linkIdToValidationResultMap = mutableMapOf>() + val expressionEvaluator = + ExpressionEvaluator( + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + launchContextMap, + ) validateQuestionnaireResponseItems( questionnaire.item, @@ -80,17 +93,33 @@ object QuestionnaireResponseValidator { questionnaireItemParentMap, launchContextMap, ), + expressionEvaluator, linkIdToValidationResultMap, ) return linkIdToValidationResultMap } + private fun ExpressionEvaluator.resolveCqfCalculatedValue( + questionnaireItem: QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent, + expression: Expression, + ): Type? { + if (!expression.isFhirPath) { + throw UnsupportedOperationException("${expression.language} not supported yet") + } + + return evaluateExpression(questionnaireItem, questionnaireResponseItem, expression) + .singleOrNull() + ?.let { it as Type } + } + private fun validateQuestionnaireResponseItems( questionnaireItemList: List, questionnaireResponseItemList: List, context: Context, enablementEvaluator: EnablementEvaluator, + expressionEvaluator: ExpressionEvaluator, linkIdToValidationResultMap: MutableMap>, ): Map> { val questionnaireItemListIterator = questionnaireItemList.iterator() @@ -113,11 +142,31 @@ object QuestionnaireResponseValidator { ) if (enabled) { + // Evaluate cqf-calculatedValues + questionnaireItem.extension + .filter { it.hasValue() && it.value.hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) } + .forEach { extension -> + val expression = + extension.value.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL).value + as Expression + expressionEvaluator + .resolveCqfCalculatedValue( + questionnaireItem, + questionnaireResponseItem, + expression, + ) + ?.let { + it.apply { setExtension(extension.value.extension) } + extension.setValue(it) + } + } + validateQuestionnaireResponseItem( questionnaireItem, questionnaireResponseItem, context, enablementEvaluator, + expressionEvaluator, linkIdToValidationResultMap, ) } @@ -130,6 +179,7 @@ object QuestionnaireResponseValidator { questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, context: Context, enablementEvaluator: EnablementEvaluator, + expressionEvaluator: ExpressionEvaluator, linkIdToValidationResultMap: MutableMap>, ): Map> { when (checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" }) { @@ -143,6 +193,7 @@ object QuestionnaireResponseValidator { questionnaireResponseItem.item, context, enablementEvaluator, + expressionEvaluator, linkIdToValidationResultMap, ) else -> { @@ -156,6 +207,7 @@ object QuestionnaireResponseValidator { it.item, context, enablementEvaluator, + expressionEvaluator, linkIdToValidationResultMap, ) } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt index f12af25a1a..4d75941954 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt @@ -23,6 +23,8 @@ import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.displayString import com.google.android.fhir.datacapture.extensions.localizedTextSpanned import com.google.android.fhir.datacapture.extensions.toSpanned +import com.google.android.fhir.datacapture.validation.MAX_VALUE_EXTENSION_URL +import com.google.android.fhir.datacapture.validation.MIN_VALUE_EXTENSION_URL import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult @@ -52,12 +54,9 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse * @param validationResult the [ValidationResult] of the answer(s) against the `questionnaireItem` * @param answersChangedCallback the callback to notify the view model that the answers have been * changed for the [QuestionnaireResponse.QuestionnaireResponseItemComponent] - * @param resolveAnswerValueSet the callback to resolve the answer value set and return the answer - * @param resolveAnswerExpression the callback to resolve answer options when answer-expression - * extension exists options + * @param enabledAnswerOptions the enabled answer options in [questionnaireItem]] * @param draftAnswer the draft input that cannot be stored in the [QuestionnaireResponse]. * @param enabledDisplayItems the enabled display items in the given [questionnaireItem] - * @param showOptionalText the optional text is being added to the end of the question text * @param questionViewTextConfiguration configuration to show asterisk, required and optional text * in the header view. */ @@ -94,6 +93,10 @@ data class QuestionnaireViewItem( val answers: List = questionnaireResponseItem.answer.map { it.copy() } + val minValue by lazy { questionnaireItem.getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value } + + val maxValue by lazy { questionnaireItem.getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value } + /** Updates the answers. This will override any existing answers and removes the draft answer. */ fun setAnswer( vararg questionnaireResponseItemAnswerComponent: diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt index abd1b20836..867df9fd02 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt @@ -32,8 +32,6 @@ import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage import com.google.android.fhir.datacapture.extensions.parseDate import com.google.android.fhir.datacapture.extensions.tryUnwrapContext import com.google.android.fhir.datacapture.validation.Invalid -import com.google.android.fhir.datacapture.validation.MaxValueValidator.getMaxValue -import com.google.android.fhir.datacapture.validation.MinValueValidator.getMinValue import com.google.android.fhir.datacapture.validation.ValidationResult import com.google.android.fhir.datacapture.views.HeaderView import com.google.android.fhir.datacapture.views.QuestionnaireViewItem @@ -163,8 +161,8 @@ internal object DatePickerViewHolderFactory : } private fun getCalenderConstraint(): CalendarConstraints { - val min = (getMinValue(questionnaireViewItem.questionnaireItem) as? DateType)?.value?.time - val max = (getMaxValue(questionnaireViewItem.questionnaireItem) as? DateType)?.value?.time + val min = (questionnaireViewItem.minValue as? DateType)?.value?.time + val max = (questionnaireViewItem.maxValue as? DateType)?.value?.time if (min != null && max != null && min > max) { throw IllegalArgumentException("minValue cannot be greater than maxValue") diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt index 8c92945706..7a4b8a241f 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt @@ -21,8 +21,6 @@ import android.widget.TextView import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.sliderStepValue import com.google.android.fhir.datacapture.validation.Invalid -import com.google.android.fhir.datacapture.validation.MaxValueValidator -import com.google.android.fhir.datacapture.validation.MinValueValidator import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult @@ -30,8 +28,8 @@ import com.google.android.fhir.datacapture.views.HeaderView import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import com.google.android.material.slider.Slider import org.hl7.fhir.r4.model.IntegerType -import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type internal object SliderViewHolderFactory : QuestionnaireItemViewHolderFactory(R.layout.slider_view) { override fun getQuestionnaireItemViewHolderDelegate(): QuestionnaireItemViewHolderDelegate = @@ -52,8 +50,8 @@ internal object SliderViewHolderFactory : QuestionnaireItemViewHolderFactory(R.l header.bind(questionnaireViewItem) header.showRequiredOrOptionalTextInHeaderView(questionnaireViewItem) val answer = questionnaireViewItem.answers.singleOrNull() - val minValue = getMinValue(questionnaireViewItem.questionnaireItem) - val maxValue = getMaxValue(questionnaireViewItem.questionnaireItem) + val minValue = getMinValue(questionnaireViewItem.minValue) + val maxValue = getMaxValue(questionnaireViewItem.maxValue) if (minValue >= maxValue) { throw IllegalStateException("minValue $minValue must be smaller than maxValue $maxValue") } @@ -98,15 +96,15 @@ private const val SLIDER_DEFAULT_STEP_SIZE = 1 private const val SLIDER_DEFAULT_VALUE_FROM = 0.0F private const val SLIDER_DEFAULT_VALUE_TO = 100.0F -private fun getMinValue(questionnaireItem: Questionnaire.QuestionnaireItemComponent) = - when (val minValue = MinValueValidator.getMinValue(questionnaireItem)) { +private fun getMinValue(minValue: Type?) = + when (minValue) { is IntegerType -> minValue.value.toFloat() null -> SLIDER_DEFAULT_VALUE_FROM else -> throw IllegalArgumentException("Cannot support data type: ${minValue.fhirType()}}") } -private fun getMaxValue(questionnaireItem: Questionnaire.QuestionnaireItemComponent) = - when (val maxValue = MaxValueValidator.getMaxValue(questionnaireItem)) { +private fun getMaxValue(maxValue: Type?) = + when (maxValue) { is IntegerType -> maxValue.value.toFloat() null -> SLIDER_DEFAULT_VALUE_TO else -> throw IllegalArgumentException("Cannot support data type: ${maxValue.fhirType()}}") 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 234441c622..630be50ef7 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 @@ -36,6 +36,7 @@ import com.google.android.fhir.datacapture.extensions.EXTENSION_ANSWER_OPTION_TO import com.google.android.fhir.datacapture.extensions.EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_OPTION import com.google.android.fhir.datacapture.extensions.EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_CALCULATED_EXPRESSION_URL +import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_EXPRESSION_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_DISPLAY_CATEGORY_INSTRUCTIONS import com.google.android.fhir.datacapture.extensions.EXTENSION_DISPLAY_CATEGORY_SYSTEM @@ -45,6 +46,8 @@ import com.google.android.fhir.datacapture.extensions.EXTENSION_ENTRY_MODE_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_HIDDEN_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL +import com.google.android.fhir.datacapture.extensions.EXTENSION_LAUNCH_CONTEXT +import com.google.android.fhir.datacapture.extensions.EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT import com.google.android.fhir.datacapture.extensions.EXTENSION_VARIABLE_URL import com.google.android.fhir.datacapture.extensions.EntryMode import com.google.android.fhir.datacapture.extensions.asStringValue @@ -53,9 +56,14 @@ import com.google.android.fhir.datacapture.extensions.getNestedQuestionnaireResp import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.datacapture.testing.DataCaptureTestApplication import com.google.android.fhir.datacapture.validation.Invalid +import com.google.android.fhir.datacapture.validation.MAX_VALUE_EXTENSION_URL +import com.google.android.fhir.datacapture.validation.MIN_VALUE_EXTENSION_URL import com.google.android.fhir.datacapture.validation.NotValidated +import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import com.google.common.truth.Truth.assertThat +import java.time.LocalDate +import java.time.ZoneId import java.util.Calendar import java.util.Date import java.util.UUID @@ -4376,13 +4384,13 @@ class QuestionnaireViewModelTest { extension = listOf( Extension( - "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext", + EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT, ) .apply { addExtension( "name", Coding( - "http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext", + EXTENSION_LAUNCH_CONTEXT, "patient", "Patient", ), @@ -5774,6 +5782,886 @@ class QuestionnaireViewModelTest { .isEqualTo("a-birthdate and a-age-years have cyclic dependency in expression based extension") } + // ==================================================================== // + // // + // cqf-calculatedValue Expression for minValue/maxValue Extension // + // // + // ==================================================================== // + + @Test + fun `should return calculated value for minValue extension with cqf-calculatedValue`() = runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat((minValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + val questionnaireItem = viewModel.questionnaire.item.single() + assertThat( + questionnaireItem.extension + .single() + .value + .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + ) + .isTrue() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + } + } + } + + @Test + fun `should replace value with evaluated cql-calculatedValue for minValue extension`() = runTest { + val lastLocalDate = LocalDate.now().minusMonths(1) + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + value = + Date.from( + lastLocalDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant(), + ) + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat((minValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + val questionnaireItem = viewModel.questionnaire.item.single() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isNotEqualTo(lastLocalDate.toString()) + } + } + } + + @Test + fun `should correctly validate cqf-calculatedValue for minValue extension`() = runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231010") + }, + ) + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat(validationResult) + .isEqualTo(Invalid(listOf("Minimum value allowed is:${LocalDate.now()}"))) + } + } + } + + @Test + fun `should return calculated value for maxValue extension with cqf-calculatedValue`() = runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MAX_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat((maxValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + val questionnaireItem = viewModel.questionnaire.item.single() + assertThat( + questionnaireItem.extension + .single() + .value + .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + ) + .isTrue() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + } + } + } + + @Test + fun `should replace value with evaluated cql-calculatedValue for maxValue extension`() = runTest { + val lastLocalDate = LocalDate.now().minusMonths(1) + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MAX_VALUE_EXTENSION_URL, + DateType().apply { + value = + Date.from( + lastLocalDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant(), + ) + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat((maxValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + val questionnaireItem = viewModel.questionnaire.item.single() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isNotEqualTo(lastLocalDate.toString()) + } + } + } + + @Test + fun `should correctly validate cqf-calculatedValue for maxValue extension`() = runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MAX_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = + DateType().apply { + val tomorrow = LocalDate.now().plusDays(1) + value = + Date.from(tomorrow.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } + }, + ) + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat(validationResult) + .isEqualTo(Invalid(listOf("Maximum value allowed is:${LocalDate.now()}"))) + } + } + } + + @Test + fun `should evaluate cqf-calculatedValue with expression dependent on other question`() = + runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select minimum date" + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "b" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = + "%resource.repeat(item).where(linkId='a' and answer.empty().not()).select(answer.value)" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "b" } + .run { assertThat((minValue as? DateType)?.valueAsString).isNull() } + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231014") + }, + ) + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "b" } + .run { + assertThat((minValue as DateType).valueAsString).isEqualTo("2023-10-14") + val questionnaireItem = + viewModel.questionnaire.item.single { it.linkId == this.questionnaireItem.linkId } + assertThat( + questionnaireItem.extension + .single() + .value + .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + ) + .isTrue() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo("2023-10-14") + } + } + } + + @Test + fun `should correctly validate cqf-calculatedValue expression dependent on other question for maxValue extension`() = + runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select minimum date" + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "b" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MAX_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = + "%resource.repeat(item).where(linkId='a' and answer.empty().not()).select(answer.value)" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231014") + }, + ) + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "b" } + .setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = + DateType().apply { + val tomorrow = LocalDate.now().plusDays(1) + value = + Date.from(tomorrow.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } + }, + ) + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "b" } + .run { + assertThat(validationResult) + .isEqualTo(Invalid(listOf("Maximum value allowed is:2023-10-14"))) + } + } + } + + @Test + fun `should evaluate cqf-calculatedValue with expression dependent on a variable expression`() = + runTest { + val questionnaire = + Questionnaire().apply { + addExtension( + Extension(EXTENSION_VARIABLE_URL).apply { + setValue( + Expression().apply { + name = "dateToday" + expression = "today()" + language = "text/fhirpath" + }, + ) + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "%dateToday" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single() + .run { + assertThat((minValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + val questionnaireItem = + viewModel.questionnaire.item.single { it.linkId == this.questionnaireItem.linkId } + assertThat( + questionnaireItem.extension + .single() + .value + .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + ) + .isTrue() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + } + } + } + + @Test + fun `should evaluate cqf-calculatedValue with expression dependent on x-fhir-query launchContext`() = + runTest { + val testDate = LocalDate.now().minusYears(20) + val patient0 = + Patient().apply { + birthDate = Date.from(testDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } + + val questionnaire = + Questionnaire().apply { + addExtension( + Extension(EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT).apply { + addExtension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "patient", "Patient")) + addExtension("type", CodeType("Patient")) + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "%patient.birthDate" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + state[EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP] = + mapOf("patient" to printer.encodeResourceToString(patient0)) + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single() + .run { + assertThat((minValue as DateType).valueAsString).isEqualTo(testDate.toString()) + val questionnaireItem = + viewModel.questionnaire.item.single { it.linkId == this.questionnaireItem.linkId } + assertThat( + questionnaireItem.extension + .single() + .value + .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + ) + .isTrue() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo(testDate.toString()) + } + } + } + + @Test + fun `should correctly validate cqf-calculatedValue with expression dependent on x-fhir-query launchContext for minValue extension`() = + runTest { + val testDate = LocalDate.now().minusYears(20) + val patient0 = + Patient().apply { + birthDate = Date.from(testDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } + + val questionnaire = + Questionnaire().apply { + addExtension( + Extension(EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT).apply { + addExtension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "patient", "Patient")) + addExtension("type", CodeType("Patient")) + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "%patient.birthDate" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + state[EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP] = + mapOf("patient" to printer.encodeResourceToString(patient0)) + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = + DateType().apply { + value = patient0.birthDate + add(Calendar.MONTH, -1) + } + }, + ) + + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat(validationResult) + .isEqualTo(Invalid(listOf("Minimum value allowed is:$testDate"))) + } + } + } + + @Test + fun `validateQuestionnaireAndUpdateUI should return correct result for cqf-calculatedValue with expression dependent on other question`() = + runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select minimum date" + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "b" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = + "%resource.repeat(item).where(linkId='a' and answer.empty().not()).select(answer.value)" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("a")).apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231108") + }, + ) + }, + ) + + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("b")).apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231010") + }, + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) + viewModel.runViewModelBlocking { + val results = viewModel.validateQuestionnaireAndUpdateUI() + assertThat(results.values).isNotEmpty() + assertThat(results["a"]?.single()).isInstanceOf(Valid::class.java) + assertThat(results["b"]?.single()) + .isEqualTo(Invalid(listOf("Minimum value allowed is:2023-11-08"))) + } + } + + @Test + fun `validateQuestionnaireAndUpdateUI should return correct result for cqf-calculatedValue with variable expression`() = + runTest { + val questionnaire = + Questionnaire().apply { + addExtension( + Extension(EXTENSION_VARIABLE_URL).apply { + setValue( + Expression().apply { + name = "dateToday" + expression = "today()" + language = "text/fhirpath" + }, + ) + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "%dateToday" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("a")).apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231010") + }, + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) + viewModel.runViewModelBlocking { + val results = viewModel.validateQuestionnaireAndUpdateUI() + assertThat(results["a"]?.single()) + .isEqualTo(Invalid(listOf("Minimum value allowed is:${LocalDate.now()}"))) + } + } + + @Test + fun `validateQuestionnaireAndUpdateUI should return correct result for cqf-calculatedValue expression dependent on x-fhir-query launchContext`() = + runTest { + val testDate = LocalDate.now().minusYears(20) + val patient0 = + Patient().apply { + birthDate = Date.from(testDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } + + val questionnaire = + Questionnaire().apply { + addExtension( + Extension(EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT).apply { + addExtension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "patient", "Patient")) + addExtension("type", CodeType("Patient")) + }, + ) + + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "%patient.birthDate" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + state[EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP] = + mapOf("patient" to printer.encodeResourceToString(patient0)) + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("a")).apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20001010") + }, + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) + viewModel.runViewModelBlocking { + val results = viewModel.validateQuestionnaireAndUpdateUI() + assertThat(results["a"]?.single()) + .isEqualTo(Invalid(listOf("Minimum value allowed is:$testDate"))) + } + } + // ==================================================================== // // // // Display Category // diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt index 533acf8488..fdb6decd60 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt @@ -261,6 +261,18 @@ class MoreTypesTest { assertThat(reference.identifierString(context)).isEqualTo("fakeReference") } + @Test + fun `getValueAsString should return 'not answered' for an empty Quantity`() { + val quantity = Quantity() + assertThat(quantity.getValueAsString(context)).isEqualTo("Not Answered") + } + + @Test + fun `getValueAsString should return correct value for a Quantity`() { + val quantity = Quantity(20L) + assertThat(quantity.getValueAsString(context)).isEqualTo("20") + } + @Test fun `should return calculated value for cqf expression`() { val today = LocalDate.now().toString() diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt index 73c49047e6..d134264027 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt @@ -145,7 +145,11 @@ class MaxLengthValidatorTest { val testComponent = createMaxLengthQuestionnaireTestItem(maxLength, value) val validationResult = - MaxLengthValidator.validate(testComponent.requirement, testComponent.answer, context) + MaxLengthValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage) @@ -159,7 +163,11 @@ class MaxLengthValidatorTest { val testComponent = createMaxLengthQuestionnaireTestItem(maxLength, value) val validationResult = - MaxLengthValidator.validate(testComponent.requirement, testComponent.answer, context) + MaxLengthValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt index 9f3d738c4a..e5cce65740 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt @@ -21,8 +21,9 @@ import android.os.Build import androidx.test.core.app.ApplicationProvider import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.common.truth.Truth.assertThat -import java.text.SimpleDateFormat import java.time.LocalDate +import java.time.ZoneId +import java.util.Date import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Extension @@ -91,85 +92,75 @@ class MaxValueValidatorTest { } @Test - fun `should return maxValue date`() { - val dateType = DateType(SimpleDateFormat("yyyy-MM-dd").parse("2023-06-01")) - val questionItem = - listOf( - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MAX_VALUE_EXTENSION_URL - this.setValue(dateType) - }, - ) - }, - ) - - assertThat((MaxValueValidator.getMaxValue(questionItem.first()) as? DateType)?.value) - .isEqualTo(dateType.value) - } + fun `should return invalid result with correct max allowed value if contains only cqf-calculatedValue`() { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + this.url = MAX_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today() - 7 'days'" + language = "text/fhirpath" + }, + ), + ) + }, + ) + }, + ) + } + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType().apply { value = Date() } + } - @Test - fun `should return today's date when expression evaluates to today`() { - val today = LocalDate.now().toString() - val questionItem = - listOf( - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MAX_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - extension = - listOf( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - language = "text/fhirpath" - expression = "today()" - }, - ), - ) - }, - ) - }, - ) - }, - ) - - assertThat((MaxValueValidator.getMaxValue(questionItem.first()) as? DateType)?.valueAsString) - .isEqualTo(today) + val validationResult = MaxValueValidator.validate(questionnaireItem, answer, context) + + assertThat(validationResult.isValid).isFalse() + assertThat(validationResult.errorMessage) + .isEqualTo("Maximum value allowed is:${LocalDate.now().minusDays(7)}") } @Test - fun `should return future's date when expression evaluates`() { - val fiveDaysAhead = LocalDate.now().plusDays(5).toString() - val questionItem = - listOf( - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MAX_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - extension = - listOf( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - language = "text/fhirpath" - expression = "today() + 5 'days' " - }, - ), - ) - }, - ) - }, - ) - }, - ) - - assertThat((MaxValueValidator.getMaxValue(questionItem.first()) as? DateType)?.valueAsString) - .isEqualTo(fiveDaysAhead) + fun `should return invalid result with correct max allowed value if contains both value and cqf-calculatedValue`() { + val tenDaysAgo = LocalDate.now().minusDays(10) + + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + this.url = MAX_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + value = + Date.from(tenDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today() - 7 'days'" + language = "text/fhirpath" + }, + ), + ) + }, + ) + }, + ) + } + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType().apply { value = Date() } + } + + val validationResult = MaxValueValidator.validate(questionnaireItem, answer, context) + + assertThat(validationResult.isValid).isFalse() + assertThat(validationResult.errorMessage).isEqualTo("Maximum value allowed is:$tenDaysAgo") } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt index 3c14cee33b..6f7e452450 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt @@ -154,7 +154,11 @@ class MinLengthValidatorTest { val testComponent = createMaxLengthQuestionnaireTestItem(minLength, value) val validationResult = - MinLengthValidator.validate(testComponent.requirement, testComponent.answer, context) + MinLengthValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -165,7 +169,11 @@ class MinLengthValidatorTest { val testComponent = createMaxLengthQuestionnaireTestItem(minLength, value) val validationResult = - MinLengthValidator.validate(testComponent.requirement, testComponent.answer, context) + MinLengthValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt index b12ecbf2af..c99d4fb0f9 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt @@ -22,16 +22,15 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.platform.app.InstrumentationRegistry import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.common.truth.Truth.assertThat -import java.text.SimpleDateFormat import java.time.LocalDate -import java.util.Calendar +import java.time.ZoneId import java.util.Date -import org.hl7.fhir.r4.model.DateTimeType import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent import org.junit.Before import org.junit.Test @@ -99,7 +98,7 @@ class MinValueValidatorTest { } @Test - fun `should return invalid result when entered value is less than minValue for cqf calculated expression`() { + fun `should return valid result when entered value is greater than minValue for cqf calculated expression`() { val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { addExtension( @@ -122,19 +121,8 @@ class MinValueValidatorTest { }, ) } - val answerDate = - DateType( - SimpleDateFormat("yyyy-MM-dd") - .parse( - (DateTimeType.today() - .apply { - add(Calendar.YEAR, -1) - add(Calendar.DAY_OF_MONTH, -1) - } - .valueAsString), - ), - ) - val answer = QuestionnaireResponseItemAnswerComponent().apply { value = answerDate } + + val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType(Date()) } val validationResult = MinValueValidator.validate( @@ -142,98 +130,50 @@ class MinValueValidatorTest { answer, InstrumentationRegistry.getInstrumentation().context, ) - val expectedDateRange = - (MinValueValidator.getMinValue(questionnaireItem) as? DateType)?.valueAsString - assertThat(validationResult.isValid).isFalse() - assertThat(validationResult.errorMessage) - .isEqualTo("Minimum value allowed is:$expectedDateRange") + + assertThat(validationResult.isValid).isTrue() + assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() } @Test - fun `should return valid result when entered value is greater than minValue for cqf calculated expression`() { + fun `should return invalid result with correct min allowed value if contains both value and cqf-calculatedValue`() { + val threeDaysAgo = LocalDate.now().minusDays(3) + val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { addExtension( Extension().apply { - url = MIN_VALUE_EXTENSION_URL + this.url = MIN_VALUE_EXTENSION_URL this.setValue( DateType().apply { - extension = - listOf( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - language = "text/fhirpath" - expression = "today() - 1 'days'" - }, - ), - ) + value = + Date.from(threeDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today() - 7 'days'" + language = "text/fhirpath" + }, + ), + ) }, ) }, ) } + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = + DateType().apply { + val fiveDaysAgo = LocalDate.now().minusDays(5) + value = Date.from(fiveDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } + } - val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType(Date()) } - - val validationResult = - MinValueValidator.validate( - questionnaireItem, - answer, - InstrumentationRegistry.getInstrumentation().context, - ) - - assertThat(validationResult.isValid).isTrue() - assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() - } - - @Test - fun `should return today's date when expression evaluates to today`() { - val today = LocalDate.now().toString() - val questionItem = - listOf( - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MIN_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - extension = - listOf( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - language = "text/fhirpath" - expression = "today()" - }, - ), - ) - }, - ) - }, - ) - }, - ) - assertThat((MinValueValidator.getMinValue(questionItem.first()) as? DateType)?.valueAsString) - .isEqualTo(today) - } - - @Test - fun `should return minValue date`() { - val dateType = DateType(SimpleDateFormat("yyyy-MM-dd").parse("2021-06-01")) - val questionItem = - listOf( - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MIN_VALUE_EXTENSION_URL - this.setValue(dateType) - }, - ) - }, - ) + val validationResult = MinValueValidator.validate(questionnaireItem, answer, context) - assertThat((MinValueValidator.getMinValue(questionItem.first()) as? DateType)?.value) - .isEqualTo(dateType.value) + assertThat(validationResult.isValid).isFalse() + assertThat(validationResult.errorMessage).isEqualTo("Minimum value allowed is:$threeDaysAgo") } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt index dbfff75c12..c404430fc6 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt @@ -73,7 +73,11 @@ class QuestionnaireResponseItemValidatorTest { ) val validationResult = - QuestionnaireResponseItemValidator.validate(questionnaireItem, answers, context) + QuestionnaireResponseItemValidator.validate( + questionnaireItem, + answers, + context, + ) assertThat(validationResult).isEqualTo(Valid) } @@ -111,7 +115,11 @@ class QuestionnaireResponseItemValidatorTest { ) val validationResult = - QuestionnaireResponseItemValidator.validate(questionnaireItem, answers, context) + QuestionnaireResponseItemValidator.validate( + questionnaireItem, + answers, + context, + ) assertThat(validationResult).isInstanceOf(Invalid::class.java) val invalidValidationResult = validationResult as Invalid @@ -129,7 +137,11 @@ class QuestionnaireResponseItemValidatorTest { val answers = listOf() val validationResult = - QuestionnaireResponseItemValidator.validate(questionnaireItem, answers, context) + QuestionnaireResponseItemValidator.validate( + questionnaireItem, + answers, + context, + ) assertThat(validationResult).isInstanceOf(Invalid::class.java) val invalidValidationResult = validationResult as Invalid diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt index feaa9f63ca..9bfd1b9ad7 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt @@ -175,7 +175,11 @@ class RegexValidatorTest { val testComponent = createRegexQuestionnaireTestItem(regex, value) val validationResult = - RegexValidator.validate(testComponent.requirement, testComponent.answer, context) + RegexValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -186,7 +190,11 @@ class RegexValidatorTest { val testComponent = createRegexQuestionnaireTestItem(regex, value) val validationResult = - RegexValidator.validate(testComponent.requirement, testComponent.answer, context) + RegexValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage) From 3e9a620811f15ca166ea09f649b0a9849e3b2150 Mon Sep 17 00:00:00 2001 From: LZRS <12814349+LZRS@users.noreply.github.com> Date: Tue, 21 Nov 2023 18:50:33 +0300 Subject: [PATCH 2/6] Refactor evaluation of cqf-calculatedValue expressions --- .../datacapture/QuestionnaireViewModel.kt | 79 +++++----- .../MoreQuestionnaireItemComponents.kt | 10 ++ .../fhir/datacapture/extensions/MoreTypes.kt | 24 ++- .../QuestionnaireResponseValidator.kt | 29 ++-- .../views/QuestionnaireViewItem.kt | 8 +- .../factories/DatePickerViewHolderFactory.kt | 6 +- .../factories/SliderViewHolderFactory.kt | 6 +- .../datacapture/QuestionnaireViewModelTest.kt | 145 +++++++++--------- .../datacapture/extensions/MoreTypesTest.kt | 18 +++ 9 files changed, 178 insertions(+), 147 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 5d8e480214..262886baad 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 @@ -27,16 +27,17 @@ import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser import com.google.android.fhir.datacapture.enablement.EnablementEvaluator import com.google.android.fhir.datacapture.expressions.EnabledAnswerOptionsEvaluator -import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.android.fhir.datacapture.extensions.EntryMode import com.google.android.fhir.datacapture.extensions.addNestedItemsToAnswer import com.google.android.fhir.datacapture.extensions.allItems +import com.google.android.fhir.datacapture.extensions.cqfCalculatedValueExpression import com.google.android.fhir.datacapture.extensions.cqfExpression import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem import com.google.android.fhir.datacapture.extensions.entryMode import com.google.android.fhir.datacapture.extensions.filterByCodeInNameExtension import com.google.android.fhir.datacapture.extensions.flattened import com.google.android.fhir.datacapture.extensions.hasDifferentAnswerSet +import com.google.android.fhir.datacapture.extensions.isCqfCalculatedValue import com.google.android.fhir.datacapture.extensions.isDisplayItem import com.google.android.fhir.datacapture.extensions.isFhirPath import com.google.android.fhir.datacapture.extensions.isHidden @@ -68,7 +69,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.withIndex import org.hl7.fhir.r4.model.Base -import org.hl7.fhir.r4.model.Element import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent @@ -565,36 +565,21 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } } - private fun resolveCqfExpression( - questionnaireItem: QuestionnaireItemComponent, - questionnaireResponseItem: QuestionnaireResponseItemComponent, - element: Element, - ): List { - val cqfExpression = element.cqfExpression ?: return emptyList() - - if (!cqfExpression.isFhirPath) { - throw UnsupportedOperationException("${cqfExpression.language} not supported yet") - } - return expressionEvaluator.evaluateExpression( - questionnaireItem, - questionnaireResponseItem, - cqfExpression, - ) - } - - private fun resolveCqfCalculatedValueExpression( + private fun resolveExpression( questionnaireItem: QuestionnaireItemComponent, questionnaireResponseItem: QuestionnaireResponseItemComponent, expression: Expression, - ): Type? { + ): Base? { if (!expression.isFhirPath) { throw UnsupportedOperationException("${expression.language} not supported yet") } - return expressionEvaluator - .evaluateExpression(questionnaireItem, questionnaireResponseItem, expression) + .evaluateExpression( + questionnaireItem, + questionnaireResponseItem, + expression, + ) .singleOrNull() - ?.let { it as Type } } private fun removeDisabledAnswers( @@ -687,6 +672,27 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat ) } + private fun resolveCqfCalculatedValueExpressions( + questionnaireItem: QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent, + ) { + questionnaireItem.extension + .filter { it.hasValue() && it.value.isCqfCalculatedValue } + .forEach { extension -> + val currentExtensionValue = extension.value + val currentExtensionValueExtensions = currentExtensionValue.extension + val evaluatedCqfCalculatedValue = + currentExtensionValue.cqfCalculatedValueExpression?.let { + resolveExpression(questionnaireItem, questionnaireResponseItem, it) as? Type + } + // add previous extensions to the evaluated value + evaluatedCqfCalculatedValue?.setExtension(currentExtensionValueExtensions) + if (evaluatedCqfCalculatedValue != null) { + extension.setValue(evaluatedCqfCalculatedValue) + } + } + } + /** * Returns the list of [QuestionnaireViewItem]s generated for the questionnaire items and * questionnaire response items. @@ -726,21 +732,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat restoreFromDisabledQuestionnaireItemAnswersCache(questionnaireResponseItem) // Evaluate cqf-calculatedValues - questionnaireItem.extension - .filter { it.hasValue() && it.value.hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) } - .forEach { extension -> - val expression = - extension.value.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL).value as Expression - resolveCqfCalculatedValueExpression( - questionnaireItem, - questionnaireResponseItem, - expression, - ) - ?.let { - it.apply { setExtension(extension.value.extension) } - extension.setValue(it) - } - } + resolveCqfCalculatedValueExpressions(questionnaireItem, questionnaireResponseItem) // Determine the validation result, which will be displayed on the item itself val validationResult = @@ -759,11 +751,12 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } // Set question text dynamically from CQL expression - questionnaireResponseItem.apply { - resolveCqfExpression(questionnaireItem, this, questionnaireItem.textElement) - .firstOrNull() - ?.let { text = it.primitiveValue() } + questionnaireItem.textElement.cqfExpression?.let { expression -> + resolveExpression(questionnaireItem, questionnaireResponseItem, expression) + ?.primitiveValue() + ?.let { questionnaireResponseItem.text = it } } + val (enabledQuestionnaireAnswerOptions, disabledQuestionnaireResponseAnswers) = answerOptionsEvaluator.evaluate( questionnaireItem, 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 9981d805be..11147182e9 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 @@ -26,6 +26,8 @@ import ca.uhn.fhir.util.UrlUtil import com.google.android.fhir.datacapture.DataCapture import com.google.android.fhir.datacapture.QuestionnaireViewHolderType import com.google.android.fhir.datacapture.fhirpath.evaluateToDisplay +import com.google.android.fhir.datacapture.validation.MAX_VALUE_EXTENSION_URL +import com.google.android.fhir.datacapture.validation.MIN_VALUE_EXTENSION_URL import com.google.android.fhir.getLocalizedText import java.math.BigDecimal import java.time.LocalDate @@ -290,6 +292,14 @@ val Questionnaire.QuestionnaireItemComponent.sliderStepValue: Int? return null } +/** The inclusive lower bound on the range of allowed answer values. */ +internal val Questionnaire.QuestionnaireItemComponent.minValue + get() = getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value?.valueOrCalculateValue() + +/** The inclusive upper bound on the range of allowed answer values. */ +internal val Questionnaire.QuestionnaireItemComponent.maxValue + get() = getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value?.valueOrCalculateValue() + // ********************************************************************************************** // // // // Additional display utilities: display item control, localized text spanned, // diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt index 25dac5004f..1f36283edf 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt @@ -22,6 +22,7 @@ import com.google.android.fhir.datacapture.fhirpath.fhirPathEngine import com.google.android.fhir.datacapture.views.factories.localDate import com.google.android.fhir.datacapture.views.factories.localTime import com.google.android.fhir.getLocalizedText +import org.hl7.fhir.exceptions.FHIRException import org.hl7.fhir.r4.model.Attachment import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.CodeType @@ -38,6 +39,7 @@ import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.model.Type import org.hl7.fhir.r4.model.UriType +import timber.log.Timber /** * Returns the string representation of a [PrimitiveType]. @@ -126,16 +128,26 @@ fun Type.valueOrCalculateValue(): Type { return if (getValueString(this) != null) { this } else { - this.takeIf { hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) } - ?.extension - ?.firstOrNull { it.url == EXTENSION_CQF_CALCULATED_VALUE_URL } - ?.let { extension -> - val expression = (extension.value as Expression).expression - fhirPathEngine.evaluate(this, expression).singleOrNull()?.let { it as Type } + this.cqfCalculatedValueExpression?.let { expression -> + try { + fhirPathEngine.evaluate(this, expression.expression).singleOrNull() as? Type + } catch (e: FHIRException) { + Timber.w("Could not evaluate expression with FHIRPathEngine", e) + null } + } ?: this } } +internal val Type.isCqfCalculatedValue + get() = this.hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) + +internal val Type.cqfCalculatedValueExpression + get() = + this.takeIf { isCqfCalculatedValue } + ?.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL) + ?.value as? Expression + internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String = "http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt index 2a54060f62..8f55f7cfe9 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt @@ -18,7 +18,8 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import com.google.android.fhir.datacapture.enablement.EnablementEvaluator -import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL +import com.google.android.fhir.datacapture.extensions.cqfCalculatedValueExpression +import com.google.android.fhir.datacapture.extensions.isCqfCalculatedValue import com.google.android.fhir.datacapture.extensions.isFhirPath import com.google.android.fhir.datacapture.extensions.packRepeatedGroups import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator @@ -144,21 +145,19 @@ object QuestionnaireResponseValidator { if (enabled) { // Evaluate cqf-calculatedValues questionnaireItem.extension - .filter { it.hasValue() && it.value.hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) } + .filter { it.hasValue() && it.value.isCqfCalculatedValue } .forEach { extension -> - val expression = - extension.value.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL).value - as Expression - expressionEvaluator - .resolveCqfCalculatedValue( - questionnaireItem, - questionnaireResponseItem, - expression, - ) - ?.let { - it.apply { setExtension(extension.value.extension) } - extension.setValue(it) - } + extension.value.cqfCalculatedValueExpression?.let { expression -> + val previousValueExtensions = extension.value.extension + val evaluatedValue = + expressionEvaluator.resolveCqfCalculatedValue( + questionnaireItem, + questionnaireResponseItem, + expression, + ) + evaluatedValue?.setExtension(previousValueExtensions) + if (evaluatedValue != null) extension.setValue(evaluatedValue) + } } validateQuestionnaireResponseItem( diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt index 4d75941954..da5d3da8d3 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt @@ -23,8 +23,6 @@ import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.displayString import com.google.android.fhir.datacapture.extensions.localizedTextSpanned import com.google.android.fhir.datacapture.extensions.toSpanned -import com.google.android.fhir.datacapture.validation.MAX_VALUE_EXTENSION_URL -import com.google.android.fhir.datacapture.validation.MIN_VALUE_EXTENSION_URL import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult @@ -54,7 +52,7 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse * @param validationResult the [ValidationResult] of the answer(s) against the `questionnaireItem` * @param answersChangedCallback the callback to notify the view model that the answers have been * changed for the [QuestionnaireResponse.QuestionnaireResponseItemComponent] - * @param enabledAnswerOptions the enabled answer options in [questionnaireItem]] + * @param enabledAnswerOptions the enabled answer options in [questionnaireItem] * @param draftAnswer the draft input that cannot be stored in the [QuestionnaireResponse]. * @param enabledDisplayItems the enabled display items in the given [questionnaireItem] * @param questionViewTextConfiguration configuration to show asterisk, required and optional text @@ -93,10 +91,6 @@ data class QuestionnaireViewItem( val answers: List = questionnaireResponseItem.answer.map { it.copy() } - val minValue by lazy { questionnaireItem.getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value } - - val maxValue by lazy { questionnaireItem.getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value } - /** Updates the answers. This will override any existing answers and removes the draft answer. */ fun setAnswer( vararg questionnaireResponseItemAnswerComponent: diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt index 867df9fd02..c679340f47 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt @@ -29,6 +29,8 @@ import com.google.android.fhir.datacapture.extensions.format import com.google.android.fhir.datacapture.extensions.getDateSeparator import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage +import com.google.android.fhir.datacapture.extensions.maxValue +import com.google.android.fhir.datacapture.extensions.minValue import com.google.android.fhir.datacapture.extensions.parseDate import com.google.android.fhir.datacapture.extensions.tryUnwrapContext import com.google.android.fhir.datacapture.validation.Invalid @@ -161,8 +163,8 @@ internal object DatePickerViewHolderFactory : } private fun getCalenderConstraint(): CalendarConstraints { - val min = (questionnaireViewItem.minValue as? DateType)?.value?.time - val max = (questionnaireViewItem.maxValue as? DateType)?.value?.time + val min = (questionnaireViewItem.questionnaireItem.minValue as? DateType)?.value?.time + val max = (questionnaireViewItem.questionnaireItem.maxValue as? DateType)?.value?.time if (min != null && max != null && min > max) { throw IllegalArgumentException("minValue cannot be greater than maxValue") diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt index 7a4b8a241f..013c8d3a24 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt @@ -19,6 +19,8 @@ package com.google.android.fhir.datacapture.views.factories import android.view.View import android.widget.TextView import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.extensions.maxValue +import com.google.android.fhir.datacapture.extensions.minValue import com.google.android.fhir.datacapture.extensions.sliderStepValue import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.NotValidated @@ -50,8 +52,8 @@ internal object SliderViewHolderFactory : QuestionnaireItemViewHolderFactory(R.l header.bind(questionnaireViewItem) header.showRequiredOrOptionalTextInHeaderView(questionnaireViewItem) val answer = questionnaireViewItem.answers.singleOrNull() - val minValue = getMinValue(questionnaireViewItem.minValue) - val maxValue = getMaxValue(questionnaireViewItem.maxValue) + val minValue = getMinValue(questionnaireViewItem.questionnaireItem.minValue) + val maxValue = getMaxValue(questionnaireViewItem.questionnaireItem.maxValue) if (minValue >= maxValue) { throw IllegalStateException("minValue $minValue must be smaller than maxValue $maxValue") } 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 630be50ef7..eab455f424 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 @@ -53,7 +53,10 @@ import com.google.android.fhir.datacapture.extensions.EntryMode import com.google.android.fhir.datacapture.extensions.asStringValue import com.google.android.fhir.datacapture.extensions.entryMode import com.google.android.fhir.datacapture.extensions.getNestedQuestionnaireResponseItems +import com.google.android.fhir.datacapture.extensions.isCqfCalculatedValue import com.google.android.fhir.datacapture.extensions.logicalId +import com.google.android.fhir.datacapture.extensions.maxValue +import com.google.android.fhir.datacapture.extensions.minValue import com.google.android.fhir.datacapture.testing.DataCaptureTestApplication import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.MAX_VALUE_EXTENSION_URL @@ -5824,13 +5827,11 @@ class QuestionnaireViewModelTest { .map { it.asQuestion() } .single { it.questionnaireItem.linkId == "a" } .run { - assertThat((minValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + assertThat((questionnaireItem.minValue as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) val questionnaireItem = viewModel.questionnaire.item.single() assertThat( - questionnaireItem.extension - .single() - .value - .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + questionnaireItem.extension.single().value.isCqfCalculatedValue, ) .isTrue() assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) @@ -5880,7 +5881,8 @@ class QuestionnaireViewModelTest { .map { it.asQuestion() } .single { it.questionnaireItem.linkId == "a" } .run { - assertThat((minValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + assertThat((questionnaireItem.minValue as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) val questionnaireItem = viewModel.questionnaire.item.single() assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) .isEqualTo(LocalDate.now().toString()) @@ -5978,13 +5980,11 @@ class QuestionnaireViewModelTest { .map { it.asQuestion() } .single { it.questionnaireItem.linkId == "a" } .run { - assertThat((maxValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + assertThat((questionnaireItem.maxValue as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) val questionnaireItem = viewModel.questionnaire.item.single() assertThat( - questionnaireItem.extension - .single() - .value - .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + questionnaireItem.extension.single().value.isCqfCalculatedValue, ) .isTrue() assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) @@ -5994,55 +5994,62 @@ class QuestionnaireViewModelTest { } @Test - fun `should replace value with evaluated cql-calculatedValue for maxValue extension`() = runTest { - val lastLocalDate = LocalDate.now().minusMonths(1) - val questionnaire = - Questionnaire().apply { - addItem( - QuestionnaireItemComponent().apply { - linkId = "a" - type = Questionnaire.QuestionnaireItemType.DATE - text = "Select a date" - addExtension( - Extension( - MAX_VALUE_EXTENSION_URL, - DateType().apply { - value = - Date.from( - lastLocalDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant(), + fun `should update initial value on evaluation cql-calculatedValue for maxValue extension`() = + runTest { + val lastLocalDate = LocalDate.now().minusMonths(1) + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MAX_VALUE_EXTENSION_URL, + DateType().apply { + value = + Date.from( + lastLocalDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant(), + ) + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), ) - addExtension( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - expression = "today()" - language = "text/fhirpath" - }, - ), - ) - }, - ), - ) - }, + }, + ), + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + assertThat( + (questionnaire.item.single { it.linkId == "a" }.maxValue as DateType).valueAsString, ) - } + .isEqualTo(lastLocalDate.toString()) - val viewModel = createQuestionnaireViewModel(questionnaire) - viewModel.runViewModelBlocking { - viewModel - .getQuestionnaireItemViewItemList() - .map { it.asQuestion() } - .single { it.questionnaireItem.linkId == "a" } - .run { - assertThat((maxValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) - val questionnaireItem = viewModel.questionnaire.item.single() - assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) - .isEqualTo(LocalDate.now().toString()) - assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) - .isNotEqualTo(lastLocalDate.toString()) - } + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat((questionnaireItem.maxValue as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + val questionnaireItem = viewModel.questionnaire.item.single() + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + .isNotEqualTo(lastLocalDate.toString()) + } + } } - } @Test fun `should correctly validate cqf-calculatedValue for maxValue extension`() = runTest { @@ -6146,7 +6153,7 @@ class QuestionnaireViewModelTest { .getQuestionnaireItemViewItemList() .map { it.asQuestion() } .single { it.questionnaireItem.linkId == "b" } - .run { assertThat((minValue as? DateType)?.valueAsString).isNull() } + .run { assertThat((questionnaireItem.minValue as? DateType)?.valueAsString).isNull() } viewModel .getQuestionnaireItemViewItemList() @@ -6163,14 +6170,12 @@ class QuestionnaireViewModelTest { .map { it.asQuestion() } .single { it.questionnaireItem.linkId == "b" } .run { - assertThat((minValue as DateType).valueAsString).isEqualTo("2023-10-14") + assertThat((questionnaireItem.minValue as DateType).valueAsString) + .isEqualTo("2023-10-14") val questionnaireItem = viewModel.questionnaire.item.single { it.linkId == this.questionnaireItem.linkId } assertThat( - questionnaireItem.extension - .single() - .value - .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + questionnaireItem.extension.single().value.isCqfCalculatedValue, ) .isTrue() assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) @@ -6305,14 +6310,12 @@ class QuestionnaireViewModelTest { .map { it.asQuestion() } .single() .run { - assertThat((minValue as DateType).valueAsString).isEqualTo(LocalDate.now().toString()) + assertThat((questionnaireItem.minValue as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) val questionnaireItem = viewModel.questionnaire.item.single { it.linkId == this.questionnaireItem.linkId } assertThat( - questionnaireItem.extension - .single() - .value - .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + questionnaireItem.extension.single().value.isCqfCalculatedValue, ) .isTrue() assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) @@ -6373,14 +6376,12 @@ class QuestionnaireViewModelTest { .map { it.asQuestion() } .single() .run { - assertThat((minValue as DateType).valueAsString).isEqualTo(testDate.toString()) + assertThat((questionnaireItem.minValue as DateType).valueAsString) + .isEqualTo(testDate.toString()) val questionnaireItem = viewModel.questionnaire.item.single { it.linkId == this.questionnaireItem.linkId } assertThat( - questionnaireItem.extension - .single() - .value - .hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL), + questionnaireItem.extension.single().value.isCqfCalculatedValue, ) .isTrue() assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt index fdb6decd60..9b4d080ffc 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt @@ -292,6 +292,24 @@ class MoreTypesTest { assertThat((type.valueOrCalculateValue() as DateType).valueAsString).isEqualTo(today) } + @Test + fun `should return calculated value for an invalid cqf expression`() { + val type = + DateType().apply { + extension = + listOf( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + language = "text/fhirpath" + expression = "%resource" // Invalid, since no context passed and no focus resource + }, + ), + ) + } + assertThat((type.valueOrCalculateValue() as DateType).valueAsString).isNull() + } + @Test fun `should return calculated value for a non-cqf extension`() { LocalDate.now().toString() From 3cd10dc3f9e1d1a78f7de98bc39011a26f611641 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Fri, 12 Jan 2024 02:08:36 +0300 Subject: [PATCH 3/6] Refactor to not modify extension in questionnaire When evaluating cqf-calculatedValue expression for validation --- .../datacapture/QuestionnaireViewModel.kt | 82 ++-- .../MoreQuestionnaireItemComponents.kt | 14 +- .../fhir/datacapture/extensions/MoreTypes.kt | 37 +- .../fhirpath/ExpressionEvaluator.kt | 21 +- .../validation/AnswerConstraintValidator.kt | 6 +- .../AnswerExtensionConstraintValidator.kt | 33 +- .../validation/MaxDecimalPlacesValidator.kt | 12 +- .../validation/MaxLengthValidator.kt | 6 +- .../validation/MaxValueValidator.kt | 13 +- .../validation/MinLengthValidator.kt | 12 +- .../validation/MinValueValidator.kt | 13 +- .../QuestionnaireResponseItemValidator.kt | 12 +- .../QuestionnaireResponseValidator.kt | 61 +-- .../datacapture/validation/RegexValidator.kt | 16 +- .../views/QuestionnaireViewItem.kt | 9 +- .../factories/DatePickerViewHolderFactory.kt | 8 +- .../factories/SliderViewHolderFactory.kt | 8 +- .../datacapture/QuestionnaireViewModelTest.kt | 354 +++++++++--------- .../MoreQuestionnaireItemComponentsTest.kt | 2 +- .../datacapture/extensions/MoreTypesTest.kt | 68 +--- .../fhirpath/ExpressionEvaluatorTest.kt | 2 +- .../CalculatedValueExpressionEvaluator.kt | 34 ++ .../MaxDecimalPlacesValidatorTest.kt | 14 +- .../validation/MaxLengthValidatorTest.kt | 15 +- .../validation/MaxValueValidatorTest.kt | 110 +++++- .../validation/MinLengthValidatorTest.kt | 15 +- .../validation/MinValueValidatorTest.kt | 112 +++++- .../QuestionnaireResponseItemValidatorTest.kt | 14 +- .../validation/RegexValidatorTest.kt | 15 +- 29 files changed, 652 insertions(+), 466 deletions(-) create mode 100644 datacapture/src/test/java/com/google/android/fhir/datacapture/validation/CalculatedValueExpressionEvaluator.kt 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 3b5f873f4d..8e5650ae4d 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,19 +30,20 @@ import com.google.android.fhir.datacapture.expressions.EnabledAnswerOptionsEvalu import com.google.android.fhir.datacapture.extensions.EntryMode import com.google.android.fhir.datacapture.extensions.addNestedItemsToAnswer import com.google.android.fhir.datacapture.extensions.allItems -import com.google.android.fhir.datacapture.extensions.cqfCalculatedValueExpression import com.google.android.fhir.datacapture.extensions.cqfExpression import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem import com.google.android.fhir.datacapture.extensions.entryMode import com.google.android.fhir.datacapture.extensions.filterByCodeInNameExtension import com.google.android.fhir.datacapture.extensions.flattened import com.google.android.fhir.datacapture.extensions.hasDifferentAnswerSet -import com.google.android.fhir.datacapture.extensions.isCqfCalculatedValue import com.google.android.fhir.datacapture.extensions.isDisplayItem -import com.google.android.fhir.datacapture.extensions.isFhirPath import com.google.android.fhir.datacapture.extensions.isHidden import com.google.android.fhir.datacapture.extensions.isPaginated import com.google.android.fhir.datacapture.extensions.localizedTextSpanned +import com.google.android.fhir.datacapture.extensions.maxValue +import com.google.android.fhir.datacapture.extensions.maxValueCqfCalculatedValueExpression +import com.google.android.fhir.datacapture.extensions.minValue +import com.google.android.fhir.datacapture.extensions.minValueCqfCalculatedValueExpression import com.google.android.fhir.datacapture.extensions.packRepeatedGroups import com.google.android.fhir.datacapture.extensions.questionnaireLaunchContexts import com.google.android.fhir.datacapture.extensions.shouldHaveNestedItemsUnderAnswers @@ -68,15 +69,12 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.withIndex -import org.hl7.fhir.r4.model.Base -import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent import org.hl7.fhir.r4.model.Resource -import org.hl7.fhir.r4.model.Type import timber.log.Timber internal class QuestionnaireViewModel(application: Application, state: SavedStateHandle) : @@ -574,23 +572,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } } - private fun resolveExpression( - questionnaireItem: QuestionnaireItemComponent, - questionnaireResponseItem: QuestionnaireResponseItemComponent, - expression: Expression, - ): Base? { - if (!expression.isFhirPath) { - throw UnsupportedOperationException("${expression.language} not supported yet") - } - return expressionEvaluator - .evaluateExpression( - questionnaireItem, - questionnaireResponseItem, - expression, - ) - .singleOrNull() - } - private fun removeDisabledAnswers( questionnaireItem: QuestionnaireItemComponent, questionnaireResponseItem: QuestionnaireResponseItemComponent, @@ -681,27 +662,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat ) } - private fun resolveCqfCalculatedValueExpressions( - questionnaireItem: QuestionnaireItemComponent, - questionnaireResponseItem: QuestionnaireResponseItemComponent, - ) { - questionnaireItem.extension - .filter { it.hasValue() && it.value.isCqfCalculatedValue } - .forEach { extension -> - val currentExtensionValue = extension.value - val currentExtensionValueExtensions = currentExtensionValue.extension - val evaluatedCqfCalculatedValue = - currentExtensionValue.cqfCalculatedValueExpression?.let { - resolveExpression(questionnaireItem, questionnaireResponseItem, it) as? Type - } - // add previous extensions to the evaluated value - evaluatedCqfCalculatedValue?.setExtension(currentExtensionValueExtensions) - if (evaluatedCqfCalculatedValue != null) { - extension.setValue(evaluatedCqfCalculatedValue) - } - } - } - /** * Returns the list of [QuestionnaireViewItem]s generated for the questionnaire items and * questionnaire response items. @@ -740,9 +700,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat restoreFromDisabledQuestionnaireItemAnswersCache(questionnaireResponseItem) - // Evaluate cqf-calculatedValues - resolveCqfCalculatedValueExpressions(questionnaireItem, questionnaireResponseItem) - // Determine the validation result, which will be displayed on the item itself val validationResult = if ( @@ -754,14 +711,21 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat questionnaireItem, questionnaireResponseItem.answer, context = this@QuestionnaireViewModel.getApplication(), - ) + ) { _, expression -> + expressionEvaluator.evaluateExpressionValue( + questionnaireItem, + questionnaireResponseItem, + expression, + ) + } } else { NotValidated } // Set question text dynamically from CQL expression questionnaireItem.textElement.cqfExpression?.let { expression -> - resolveExpression(questionnaireItem, questionnaireResponseItem, expression) + expressionEvaluator + .evaluateExpressionValue(questionnaireItem, questionnaireResponseItem, expression) ?.primitiveValue() ?.let { questionnaireResponseItem.text = it } } @@ -789,6 +753,24 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat validationResult = validationResult, answersChangedCallback = answersChangedCallback, enabledAnswerOptions = enabledQuestionnaireAnswerOptions, + minAnswerValue = + questionnaireItem.minValueCqfCalculatedValueExpression?.let { + expressionEvaluator.evaluateExpressionValue( + questionnaireItem, + questionnaireResponseItem, + it, + ) + } + ?: questionnaireItem.minValue, + maxAnswerValue = + questionnaireItem.maxValueCqfCalculatedValueExpression?.let { + expressionEvaluator.evaluateExpressionValue( + questionnaireItem, + questionnaireResponseItem, + it, + ) + } + ?: questionnaireItem.maxValue, draftAnswer = draftAnswerMap[questionnaireResponseItem], enabledDisplayItems = questionnaireItem.item.filter { 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 92d5353892..da561a12f7 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -292,13 +292,17 @@ val Questionnaire.QuestionnaireItemComponent.sliderStepValue: Int? return null } -/** The inclusive lower bound on the range of allowed answer values. */ internal val Questionnaire.QuestionnaireItemComponent.minValue - get() = getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value?.valueOrCalculateValue() + get() = getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value + +internal val Questionnaire.QuestionnaireItemComponent.minValueCqfCalculatedValueExpression + get() = getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value?.cqfCalculatedValueExpression -/** The inclusive upper bound on the range of allowed answer values. */ internal val Questionnaire.QuestionnaireItemComponent.maxValue - get() = getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value?.valueOrCalculateValue() + get() = getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value + +internal val Questionnaire.QuestionnaireItemComponent.maxValueCqfCalculatedValueExpression + get() = getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value?.cqfCalculatedValueExpression // ********************************************************************************************** // // // diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt index 37e9dda496..f5a5af943d 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,11 +18,9 @@ package com.google.android.fhir.datacapture.extensions import android.content.Context import com.google.android.fhir.datacapture.R -import com.google.android.fhir.datacapture.fhirpath.evaluateToBase import com.google.android.fhir.datacapture.views.factories.localDate import com.google.android.fhir.datacapture.views.factories.localTime import com.google.android.fhir.getLocalizedText -import org.hl7.fhir.exceptions.FHIRException import org.hl7.fhir.r4.model.Attachment import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.CodeType @@ -39,7 +37,6 @@ import org.hl7.fhir.r4.model.Reference import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.model.Type import org.hl7.fhir.r4.model.UriType -import timber.log.Timber /** * Returns the string representation of a [PrimitiveType]. @@ -97,11 +94,13 @@ private fun getDisplayString(type: Type, context: Context): String? = else -> (type as? PrimitiveType<*>)?.valueAsString } +/** + * Returns the string representation when type is of [PrimitiveType] or [Quantity], otherwise null + */ private fun getValueString(type: Type): String? = when (type) { - is StringType -> type.getLocalizedText() ?: type.valueAsString - is Quantity -> type.takeIf { it.hasValue() }?.value?.toString() - else -> (type as? PrimitiveType<*>)?.valueAsString + is Quantity -> type.value?.toString() + else -> (type as? PrimitiveType<*>)?.asStringValue() } /** Converts StringType to toUriType. */ @@ -124,30 +123,10 @@ internal fun Coding.toCodeType(): CodeType { return CodeType(code) } -fun Type.valueOrCalculateValue(): Type { - return if (getValueString(this) != null) { - this - } else { - this.cqfCalculatedValueExpression?.let { expression -> - try { - evaluateToBase(this, expression.expression).singleOrNull() as? Type - } catch (e: FHIRException) { - Timber.w("Could not evaluate expression with FHIRPathEngine", e) - null - } - } - ?: this - } -} - -internal val Type.isCqfCalculatedValue - get() = this.hasExtension(EXTENSION_CQF_CALCULATED_VALUE_URL) +internal fun Type.hasValue(): Boolean = !getValueString(this).isNullOrBlank() internal val Type.cqfCalculatedValueExpression - get() = - this.takeIf { isCqfCalculatedValue } - ?.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL) - ?.value as? Expression + get() = this.getExtensionByUrl(EXTENSION_CQF_CALCULATED_VALUE_URL)?.value as? Expression internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String = "http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue" diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt index 33abca3a51..8ea1dcf12e 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.fhirpath import com.google.android.fhir.datacapture.extensions.calculatedExpression import com.google.android.fhir.datacapture.extensions.findVariableExpression import com.google.android.fhir.datacapture.extensions.flattened +import com.google.android.fhir.datacapture.extensions.isFhirPath import com.google.android.fhir.datacapture.extensions.isReferencedBy import com.google.android.fhir.datacapture.extensions.variableExpressions import org.hl7.fhir.exceptions.FHIRException @@ -135,6 +136,24 @@ internal class ExpressionEvaluator( ) } + /** Returns the evaluation result of an expression as a [Type] value */ + fun evaluateExpressionValue( + questionnaireItem: QuestionnaireItemComponent, + questionnaireResponseItem: QuestionnaireResponseItemComponent?, + expression: Expression, + ): Type? { + if (!expression.isFhirPath) { + throw UnsupportedOperationException("${expression.language} not supported yet") + } + return try { + evaluateExpression(questionnaireItem, questionnaireResponseItem, expression).singleOrNull() + as? Type + } catch (e: Exception) { + Timber.w("Could not evaluate expression ${expression.expression} with FHIRPathEngine", e) + null + } + } + /** * Returns a list of pair of item and the calculated and evaluated value for all items with * calculated expression extension, which is dependent on value of updated response diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerConstraintValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerConstraintValidator.kt index ba884217ba..c73094790d 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerConstraintValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerConstraintValidator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,8 +17,11 @@ package com.google.android.fhir.datacapture.validation import android.content.Context +import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type /** * Validates [QuestionnaireResponse.QuestionnaireResponseItemComponent] against a particular @@ -39,6 +42,7 @@ internal interface AnswerConstraintValidator { questionnaireItem: Questionnaire.QuestionnaireItemComponent, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, context: Context, + evaluateExtensionCqfCalculatedValue: (Extension, Expression) -> Type?, ): Result /** diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt index 9696908946..1d85275fc7 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,9 +17,13 @@ package com.google.android.fhir.datacapture.validation import android.content.Context +import com.google.android.fhir.datacapture.extensions.cqfCalculatedValueExpression +import com.google.android.fhir.datacapture.extensions.hasValue +import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type /** * Validates [QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent] against a constraint @@ -36,20 +40,39 @@ internal open class AnswerExtensionConstraintValidator( val url: String, val predicate: ( - Extension, + /*extensionValue*/ + Type, + /*answer*/ QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, ) -> Boolean, - val messageGenerator: (Extension, Context) -> String, + val messageGenerator: (Type, Context) -> String, ) : AnswerConstraintValidator { override fun validate( questionnaireItem: Questionnaire.QuestionnaireItemComponent, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, context: Context, + evaluateExtensionCqfCalculatedValue: (Extension, Expression) -> Type?, ): AnswerConstraintValidator.Result { if (questionnaireItem.hasExtension(url)) { val extension = questionnaireItem.getExtensionByUrl(url) - if (predicate(extension, answer)) { - return AnswerConstraintValidator.Result(false, messageGenerator(extension, context)) + val extensionValueType = + extension.value.let { + it.cqfCalculatedValueExpression?.let { expression -> + evaluateExtensionCqfCalculatedValue(extension, expression) + } + ?: it + } + + // Only checks constraint if both extension and answer have a value + if ( + extensionValueType.hasValue() && + answer.value.hasValue() && + predicate(extensionValueType, answer) + ) { + return AnswerConstraintValidator.Result( + false, + messageGenerator(extensionValueType, context), + ) } } return AnswerConstraintValidator.Result(true, null) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt index ab92e39c6b..4b08c5b685 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -25,24 +25,24 @@ package com.google.android.fhir.datacapture.validation */ import android.content.Context import com.google.android.fhir.datacapture.R -import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type internal object MaxDecimalPlacesValidator : AnswerExtensionConstraintValidator( url = MAX_DECIMAL_URL, predicate = { - extension: Extension, + extensionValue: Type, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, -> - val maxDecimalPlaces = (extension.value as? IntegerType)?.value + val maxDecimalPlaces = (extensionValue as? IntegerType)?.value answer.hasValueDecimalType() && maxDecimalPlaces != null && answer.valueDecimalType.valueAsString.substringAfter(".").length > maxDecimalPlaces }, - messageGenerator = { extension: Extension, context: Context -> - context.getString(R.string.max_decimal_validation_error_msg, extension.value.primitiveValue()) + messageGenerator = { extensionValue: Type, context: Context -> + context.getString(R.string.max_decimal_validation_error_msg, extensionValue.primitiveValue()) }, ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxLengthValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxLengthValidator.kt index e04c0f549d..09c9b365e3 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxLengthValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxLengthValidator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,8 +18,11 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import com.google.android.fhir.datacapture.extensions.asStringValue +import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type /** * A validator to check if the answer exceeds the maximum number of permitted characters. @@ -32,6 +35,7 @@ internal object MaxLengthValidator : AnswerConstraintValidator { questionnaireItem: Questionnaire.QuestionnaireItemComponent, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, context: Context, + evaluateExtensionCqfCalculatedValue: (Extension, Expression) -> Type?, ): AnswerConstraintValidator.Result { if ( questionnaireItem.hasMaxLength() && diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt index 70f9f2c0d2..5f81a764f3 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,9 +20,8 @@ import android.content.Context import com.google.android.fhir.compareTo import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.getValueAsString -import com.google.android.fhir.datacapture.extensions.valueOrCalculateValue -import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type internal const val MAX_VALUE_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/maxValue" @@ -31,15 +30,15 @@ internal object MaxValueValidator : AnswerExtensionConstraintValidator( url = MAX_VALUE_EXTENSION_URL, predicate = { - extension: Extension, + extensionValue: Type, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, -> - answer.value > extension.value?.valueOrCalculateValue()!! + answer.value > extensionValue }, - messageGenerator = { extension: Extension, context: Context -> + messageGenerator = { extensionValue: Type, context: Context -> context.getString( R.string.max_value_validation_error_msg, - extension.value?.valueOrCalculateValue()?.getValueAsString(context), + extensionValue.getValueAsString(context), ) }, ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinLengthValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinLengthValidator.kt index 1630cda38c..cc9a9741b0 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinLengthValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinLengthValidator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,9 +18,9 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import com.google.android.fhir.datacapture.R -import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.PrimitiveType +import org.hl7.fhir.r4.model.Type /** * A validator to check if the answer fulfills the minimum number of permitted characters. @@ -37,13 +37,13 @@ import org.hl7.fhir.r4.model.PrimitiveType internal object MinLengthValidator : AnswerExtensionConstraintValidator( url = MIN_LENGTH_EXTENSION_URL, - predicate = { extension, answer -> + predicate = { extensionValue, answer -> answer.value.isPrimitive && (answer.value as PrimitiveType<*>).asStringValue().length < - (extension.value as IntegerType).value + (extensionValue as IntegerType).value }, - messageGenerator = { extension: Extension, context: Context -> - context.getString(R.string.min_length_validation_error_msg, extension.value.primitiveValue()) + messageGenerator = { extensionValue: Type, context: Context -> + context.getString(R.string.min_length_validation_error_msg, extensionValue.primitiveValue()) }, ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt index 021975e503..6fd92ed65e 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,9 +20,8 @@ import android.content.Context import com.google.android.fhir.compareTo import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.getValueAsString -import com.google.android.fhir.datacapture.extensions.valueOrCalculateValue -import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type internal const val MIN_VALUE_EXTENSION_URL = "http://hl7.org/fhir/StructureDefinition/minValue" @@ -31,15 +30,15 @@ internal object MinValueValidator : AnswerExtensionConstraintValidator( url = MIN_VALUE_EXTENSION_URL, predicate = { - extension: Extension, + extensionValue: Type, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, -> - answer.value < extension.value?.valueOrCalculateValue()!! + answer.value < extensionValue }, - messageGenerator = { extension: Extension, context: Context -> + messageGenerator = { extensionValue: Type, context: Context -> context.getString( R.string.min_value_validation_error_msg, - extension.value?.valueOrCalculateValue()?.getValueAsString(context), + extensionValue.getValueAsString(context), ) }, ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidator.kt index 94539af70b..9af989cada 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,8 +18,11 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import com.google.android.fhir.datacapture.extensions.isHidden +import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type internal object QuestionnaireResponseItemValidator { @@ -45,6 +48,7 @@ internal object QuestionnaireResponseItemValidator { questionnaireItem: Questionnaire.QuestionnaireItemComponent, answers: List, context: Context, + expressionValueEvaluator: (Extension, Expression) -> Type?, ): ValidationResult { if (questionnaireItem.isHidden) return NotValidated @@ -54,7 +58,11 @@ internal object QuestionnaireResponseItemValidator { } val questionnaireResponseItemAnswerConstraintValidationResult = answerConstraintValidators.flatMap { validator -> - answers.map { answer -> validator.validate(questionnaireItem, answer, context) } + answers.map { answer -> + validator.validate(questionnaireItem, answer, context) { extension, expression -> + expressionValueEvaluator.invoke(extension, expression) + } + } } return if ( diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt index 8f55f7cfe9..c7f1331b05 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -18,16 +18,10 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import com.google.android.fhir.datacapture.enablement.EnablementEvaluator -import com.google.android.fhir.datacapture.extensions.cqfCalculatedValueExpression -import com.google.android.fhir.datacapture.extensions.isCqfCalculatedValue -import com.google.android.fhir.datacapture.extensions.isFhirPath import com.google.android.fhir.datacapture.extensions.packRepeatedGroups import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator -import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Questionnaire -import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse -import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.Type @@ -76,13 +70,6 @@ object QuestionnaireResponseValidator { } val linkIdToValidationResultMap = mutableMapOf>() - val expressionEvaluator = - ExpressionEvaluator( - questionnaire, - questionnaireResponse, - questionnaireItemParentMap, - launchContextMap, - ) validateQuestionnaireResponseItems( questionnaire.item, @@ -94,27 +81,18 @@ object QuestionnaireResponseValidator { questionnaireItemParentMap, launchContextMap, ), - expressionEvaluator, + ExpressionEvaluator( + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + launchContextMap, + ), linkIdToValidationResultMap, ) return linkIdToValidationResultMap } - private fun ExpressionEvaluator.resolveCqfCalculatedValue( - questionnaireItem: QuestionnaireItemComponent, - questionnaireResponseItem: QuestionnaireResponseItemComponent, - expression: Expression, - ): Type? { - if (!expression.isFhirPath) { - throw UnsupportedOperationException("${expression.language} not supported yet") - } - - return evaluateExpression(questionnaireItem, questionnaireResponseItem, expression) - .singleOrNull() - ?.let { it as Type } - } - private fun validateQuestionnaireResponseItems( questionnaireItemList: List, questionnaireResponseItemList: List, @@ -143,23 +121,6 @@ object QuestionnaireResponseValidator { ) if (enabled) { - // Evaluate cqf-calculatedValues - questionnaireItem.extension - .filter { it.hasValue() && it.value.isCqfCalculatedValue } - .forEach { extension -> - extension.value.cqfCalculatedValueExpression?.let { expression -> - val previousValueExtensions = extension.value.extension - val evaluatedValue = - expressionEvaluator.resolveCqfCalculatedValue( - questionnaireItem, - questionnaireResponseItem, - expression, - ) - evaluatedValue?.setExtension(previousValueExtensions) - if (evaluatedValue != null) extension.setValue(evaluatedValue) - } - } - validateQuestionnaireResponseItem( questionnaireItem, questionnaireResponseItem, @@ -217,7 +178,13 @@ object QuestionnaireResponseValidator { questionnaireItem, questionnaireResponseItem.answer, context, - ), + ) { _, expression -> + expressionEvaluator.evaluateExpressionValue( + questionnaireItem, + questionnaireResponseItem, + expression, + ) + }, ) } } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RegexValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RegexValidator.kt index abd7840e4b..04a06cad08 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RegexValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RegexValidator.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,9 +21,9 @@ import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.asStringValue import java.util.regex.Pattern import java.util.regex.PatternSyntaxException -import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.PrimitiveType import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type import timber.log.Timber /** @@ -37,22 +37,22 @@ internal object RegexValidator : url = REGEX_EXTENSION_URL, predicate = predicate@{ - extension: Extension, + extensionValue: Type, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, -> - if (!extension.value.isPrimitive || !answer.value.isPrimitive) { + if (!extensionValue.isPrimitive || !answer.value.isPrimitive) { return@predicate false } try { - val pattern = Pattern.compile((extension.value as PrimitiveType<*>).asStringValue()) + val pattern = Pattern.compile((extensionValue as PrimitiveType<*>).asStringValue()) !pattern.matcher(answer.value.asStringValue()).matches() } catch (e: PatternSyntaxException) { - Timber.w("Can't parse regex: " + extension.value, e) + Timber.w("Can't parse regex: $extensionValue", e) false } }, - messageGenerator = { extension: Extension, context: Context -> - context.getString(R.string.regex_validation_error_msg, extension.value.primitiveValue()) + messageGenerator = { extensionValue: Type, context: Context -> + context.getString(R.string.regex_validation_error_msg, extensionValue.primitiveValue()) }, ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt index da5d3da8d3..bec698e978 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,12 +22,15 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.displayString import com.google.android.fhir.datacapture.extensions.localizedTextSpanned +import com.google.android.fhir.datacapture.extensions.maxValue +import com.google.android.fhir.datacapture.extensions.minValue import com.google.android.fhir.datacapture.extensions.toSpanned import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type /** * Data item for [QuestionnaireItemViewHolder] in [RecyclerView]. @@ -53,6 +56,8 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse * @param answersChangedCallback the callback to notify the view model that the answers have been * changed for the [QuestionnaireResponse.QuestionnaireResponseItemComponent] * @param enabledAnswerOptions the enabled answer options in [questionnaireItem] + * @param minAnswerValue the inclusive lower bound on the range of allowed answer values + * @param maxAnswerValue the inclusive upper bound on the range of allowed answer values. * @param draftAnswer the draft input that cannot be stored in the [QuestionnaireResponse]. * @param enabledDisplayItems the enabled display items in the given [questionnaireItem] * @param questionViewTextConfiguration configuration to show asterisk, required and optional text @@ -71,6 +76,8 @@ data class QuestionnaireViewItem( ) -> Unit, val enabledAnswerOptions: List = questionnaireItem.answerOption.ifEmpty { emptyList() }, + val minAnswerValue: Type? = questionnaireItem.minValue, + val maxAnswerValue: Type? = questionnaireItem.maxValue, val draftAnswer: Any? = null, val enabledDisplayItems: List = emptyList(), val questionViewTextConfiguration: QuestionTextConfiguration = QuestionTextConfiguration(), diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt index a2379c4f82..bc538d03f7 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/DatePickerViewHolderFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -29,8 +29,6 @@ import com.google.android.fhir.datacapture.extensions.format import com.google.android.fhir.datacapture.extensions.getDateSeparator import com.google.android.fhir.datacapture.extensions.getRequiredOrOptionalText import com.google.android.fhir.datacapture.extensions.getValidationErrorMessage -import com.google.android.fhir.datacapture.extensions.maxValue -import com.google.android.fhir.datacapture.extensions.minValue import com.google.android.fhir.datacapture.extensions.parseDate import com.google.android.fhir.datacapture.extensions.tryUnwrapContext import com.google.android.fhir.datacapture.validation.Invalid @@ -163,8 +161,8 @@ internal object DatePickerViewHolderFactory : } private fun getCalenderConstraint(): CalendarConstraints { - val min = (questionnaireViewItem.questionnaireItem.minValue as? DateType)?.value?.time - val max = (questionnaireViewItem.questionnaireItem.maxValue as? DateType)?.value?.time + val min = (questionnaireViewItem.minAnswerValue as? DateType)?.value?.time + val max = (questionnaireViewItem.maxAnswerValue as? DateType)?.value?.time if (min != null && max != null && min > max) { throw IllegalArgumentException("minValue cannot be greater than maxValue") diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt index 013c8d3a24..f18d284dcc 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/factories/SliderViewHolderFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,8 +19,6 @@ package com.google.android.fhir.datacapture.views.factories import android.view.View import android.widget.TextView import com.google.android.fhir.datacapture.R -import com.google.android.fhir.datacapture.extensions.maxValue -import com.google.android.fhir.datacapture.extensions.minValue import com.google.android.fhir.datacapture.extensions.sliderStepValue import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.NotValidated @@ -52,8 +50,8 @@ internal object SliderViewHolderFactory : QuestionnaireItemViewHolderFactory(R.l header.bind(questionnaireViewItem) header.showRequiredOrOptionalTextInHeaderView(questionnaireViewItem) val answer = questionnaireViewItem.answers.singleOrNull() - val minValue = getMinValue(questionnaireViewItem.questionnaireItem.minValue) - val maxValue = getMaxValue(questionnaireViewItem.questionnaireItem.maxValue) + val minValue = getMinValue(questionnaireViewItem.minAnswerValue) + val maxValue = getMaxValue(questionnaireViewItem.maxAnswerValue) if (minValue >= maxValue) { throw IllegalStateException("minValue $minValue must be smaller than maxValue $maxValue") } 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 f364e0d74a..88ababbe1d 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -53,10 +53,8 @@ import com.google.android.fhir.datacapture.extensions.EntryMode import com.google.android.fhir.datacapture.extensions.asStringValue import com.google.android.fhir.datacapture.extensions.entryMode import com.google.android.fhir.datacapture.extensions.getNestedQuestionnaireResponseItems -import com.google.android.fhir.datacapture.extensions.isCqfCalculatedValue import com.google.android.fhir.datacapture.extensions.logicalId import com.google.android.fhir.datacapture.extensions.maxValue -import com.google.android.fhir.datacapture.extensions.minValue import com.google.android.fhir.datacapture.testing.DataCaptureTestApplication import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.MAX_VALUE_EXTENSION_URL @@ -5993,105 +5991,95 @@ class QuestionnaireViewModelTest { // ==================================================================== // @Test - fun `should return calculated value for minValue extension with cqf-calculatedValue`() = runTest { - val questionnaire = - Questionnaire().apply { - addItem( - QuestionnaireItemComponent().apply { - linkId = "a" - type = Questionnaire.QuestionnaireItemType.DATE - text = "Select a date" - addExtension( - Extension( - MIN_VALUE_EXTENSION_URL, - DateType().apply { - addExtension( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - expression = "today()" - language = "text/fhirpath" - }, - ), - ) - }, - ), - ) - }, - ) - } - - val viewModel = createQuestionnaireViewModel(questionnaire) - viewModel.runViewModelBlocking { - viewModel - .getQuestionnaireItemViewItemList() - .map { it.asQuestion() } - .single { it.questionnaireItem.linkId == "a" } - .run { - assertThat((questionnaireItem.minValue as DateType).valueAsString) - .isEqualTo(LocalDate.now().toString()) - val questionnaireItem = viewModel.questionnaire.item.single() - assertThat( - questionnaireItem.extension.single().value.isCqfCalculatedValue, - ) - .isTrue() - assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) - .isEqualTo(LocalDate.now().toString()) + fun `should return correct value evaluated for minValue extension with cqf-calculatedValue`() = + runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat((this.minAnswerValue as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + } + } } - } @Test - fun `should replace value with evaluated cql-calculatedValue for minValue extension`() = runTest { - val lastLocalDate = LocalDate.now().minusMonths(1) - val questionnaire = - Questionnaire().apply { - addItem( - QuestionnaireItemComponent().apply { - linkId = "a" - type = Questionnaire.QuestionnaireItemType.DATE - text = "Select a date" - addExtension( - Extension( - MIN_VALUE_EXTENSION_URL, - DateType().apply { - value = - Date.from( - lastLocalDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant(), + fun `should return calculated value for minValue extension that has both value and cqf-calculatedValue expression`() = + runTest { + val lastLocalDate = LocalDate.now().minusMonths(1) + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + value = + Date.from( + lastLocalDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant(), + ) + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), ) - addExtension( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - expression = "today()" - language = "text/fhirpath" - }, - ), - ) - }, - ), - ) - }, - ) - } - - val viewModel = createQuestionnaireViewModel(questionnaire) - viewModel.runViewModelBlocking { - viewModel - .getQuestionnaireItemViewItemList() - .map { it.asQuestion() } - .single { it.questionnaireItem.linkId == "a" } - .run { - assertThat((questionnaireItem.minValue as DateType).valueAsString) - .isEqualTo(LocalDate.now().toString()) - val questionnaireItem = viewModel.questionnaire.item.single() - assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) - .isEqualTo(LocalDate.now().toString()) - assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) - .isNotEqualTo(lastLocalDate.toString()) + }, + ), + ) + }, + ) } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat((this.minAnswerValue as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + } + } } - } @Test fun `should correctly validate cqf-calculatedValue for minValue extension`() = runTest { @@ -6146,56 +6134,50 @@ class QuestionnaireViewModelTest { } @Test - fun `should return calculated value for maxValue extension with cqf-calculatedValue`() = runTest { - val questionnaire = - Questionnaire().apply { - addItem( - QuestionnaireItemComponent().apply { - linkId = "a" - type = Questionnaire.QuestionnaireItemType.DATE - text = "Select a date" - addExtension( - Extension( - MAX_VALUE_EXTENSION_URL, - DateType().apply { - addExtension( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - expression = "today()" - language = "text/fhirpath" - }, - ), - ) - }, - ), - ) - }, - ) - } - - val viewModel = createQuestionnaireViewModel(questionnaire) - viewModel.runViewModelBlocking { - viewModel - .getQuestionnaireItemViewItemList() - .map { it.asQuestion() } - .single { it.questionnaireItem.linkId == "a" } - .run { - assertThat((questionnaireItem.maxValue as DateType).valueAsString) - .isEqualTo(LocalDate.now().toString()) - val questionnaireItem = viewModel.questionnaire.item.single() - assertThat( - questionnaireItem.extension.single().value.isCqfCalculatedValue, - ) - .isTrue() - assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) - .isEqualTo(LocalDate.now().toString()) + fun `should return correct evaluated value for maxValue extension with cqf-calculatedValue extension`() = + runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MAX_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "today()" + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) } + + val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "a" } + .run { + assertThat((this.maxAnswerValue as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + } + } } - } @Test - fun `should update initial value on evaluation cql-calculatedValue for maxValue extension`() = + fun `should return calculated value for maxValue extension that has both value and cqf-calculatedValue expression`() = runTest { val lastLocalDate = LocalDate.now().minusMonths(1) val questionnaire = @@ -6241,13 +6223,8 @@ class QuestionnaireViewModelTest { .map { it.asQuestion() } .single { it.questionnaireItem.linkId == "a" } .run { - assertThat((questionnaireItem.maxValue as DateType).valueAsString) + assertThat((this.maxAnswerValue as DateType).valueAsString) .isEqualTo(LocalDate.now().toString()) - val questionnaireItem = viewModel.questionnaire.item.single() - assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) - .isEqualTo(LocalDate.now().toString()) - assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) - .isNotEqualTo(lastLocalDate.toString()) } } } @@ -6354,7 +6331,7 @@ class QuestionnaireViewModelTest { .getQuestionnaireItemViewItemList() .map { it.asQuestion() } .single { it.questionnaireItem.linkId == "b" } - .run { assertThat((questionnaireItem.minValue as? DateType)?.valueAsString).isNull() } + .run { assertThat((this.minAnswerValue as? DateType)?.valueAsString).isNull() } viewModel .getQuestionnaireItemViewItemList() @@ -6371,16 +6348,7 @@ class QuestionnaireViewModelTest { .map { it.asQuestion() } .single { it.questionnaireItem.linkId == "b" } .run { - assertThat((questionnaireItem.minValue as DateType).valueAsString) - .isEqualTo("2023-10-14") - val questionnaireItem = - viewModel.questionnaire.item.single { it.linkId == this.questionnaireItem.linkId } - assertThat( - questionnaireItem.extension.single().value.isCqfCalculatedValue, - ) - .isTrue() - assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) - .isEqualTo("2023-10-14") + assertThat((this.minAnswerValue as DateType).valueAsString).isEqualTo("2023-10-14") } } } @@ -6511,15 +6479,7 @@ class QuestionnaireViewModelTest { .map { it.asQuestion() } .single() .run { - assertThat((questionnaireItem.minValue as DateType).valueAsString) - .isEqualTo(LocalDate.now().toString()) - val questionnaireItem = - viewModel.questionnaire.item.single { it.linkId == this.questionnaireItem.linkId } - assertThat( - questionnaireItem.extension.single().value.isCqfCalculatedValue, - ) - .isTrue() - assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + assertThat((this.minAnswerValue as DateType).valueAsString) .isEqualTo(LocalDate.now().toString()) } } @@ -6577,15 +6537,7 @@ class QuestionnaireViewModelTest { .map { it.asQuestion() } .single() .run { - assertThat((questionnaireItem.minValue as DateType).valueAsString) - .isEqualTo(testDate.toString()) - val questionnaireItem = - viewModel.questionnaire.item.single { it.linkId == this.questionnaireItem.linkId } - assertThat( - questionnaireItem.extension.single().value.isCqfCalculatedValue, - ) - .isTrue() - assertThat((questionnaireItem.extension.single().value as DateType).valueAsString) + assertThat((this.minAnswerValue as DateType).valueAsString) .isEqualTo(testDate.toString()) } } @@ -6864,6 +6816,56 @@ class QuestionnaireViewModelTest { } } + @Test + fun `validateQuestionnaireAndUpdateUI should return valid and removes constraint for failing cqf-calculatedValue expressions`() = + runTest { + val questionnaire = + Questionnaire().apply { + addItem( + QuestionnaireItemComponent().apply { + linkId = "a" + type = Questionnaire.QuestionnaireItemType.DATE + text = "Select a date" + addExtension( + Extension( + MIN_VALUE_EXTENSION_URL, + DateType().apply { + addExtension( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + expression = "%nonExistentVariable" // invalid variable + language = "text/fhirpath" + }, + ), + ) + }, + ), + ) + }, + ) + } + + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("a")).apply { + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231010") + }, + ) + }, + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) + viewModel.runViewModelBlocking { + val results = viewModel.validateQuestionnaireAndUpdateUI() + assertThat(results["a"]?.single()).isEqualTo(Valid) + } + } + // ==================================================================== // // // // Display Category // 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 9def709e9b..7971eae2e2 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt index 9b4d080ffc..518800696a 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/extensions/MoreTypesTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,7 +22,6 @@ import androidx.test.core.app.ApplicationProvider import ca.uhn.fhir.model.api.TemporalPrecisionEnum import com.google.common.truth.Truth.assertThat import java.time.Instant -import java.time.LocalDate import java.time.ZoneId import java.util.Date import java.util.TimeZone @@ -32,10 +31,7 @@ import org.hl7.fhir.r4.model.CanonicalType import org.hl7.fhir.r4.model.CodeType import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.DateTimeType -import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.DecimalType -import org.hl7.fhir.r4.model.Expression -import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.IdType import org.hl7.fhir.r4.model.InstantType import org.hl7.fhir.r4.model.IntegerType @@ -272,66 +268,4 @@ class MoreTypesTest { val quantity = Quantity(20L) assertThat(quantity.getValueAsString(context)).isEqualTo("20") } - - @Test - fun `should return calculated value for cqf expression`() { - val today = LocalDate.now().toString() - val type = - DateType().apply { - extension = - listOf( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - language = "text/fhirpath" - expression = "today()" - }, - ), - ) - } - assertThat((type.valueOrCalculateValue() as DateType).valueAsString).isEqualTo(today) - } - - @Test - fun `should return calculated value for an invalid cqf expression`() { - val type = - DateType().apply { - extension = - listOf( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - language = "text/fhirpath" - expression = "%resource" // Invalid, since no context passed and no focus resource - }, - ), - ) - } - assertThat((type.valueOrCalculateValue() as DateType).valueAsString).isNull() - } - - @Test - fun `should return calculated value for a non-cqf extension`() { - LocalDate.now().toString() - val type = - DateType().apply { - extension = - listOf( - Extension( - "http://hl7.org/fhir/StructureDefinition/my-own-expression", - Expression().apply { - language = "text/fhirpath" - expression = "today()" - }, - ), - ) - } - assertThat((type.valueOrCalculateValue() as DateType).valueAsString).isEqualTo(null) - } - - @Test - fun `should return entered value when no cqf expression is defined`() { - val type = IntegerType().apply { value = 500 } - assertThat((type.valueOrCalculateValue() as IntegerType).value).isEqualTo(500) - } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt index 1902a2a876..b4abf3a639 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/CalculatedValueExpressionEvaluator.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/CalculatedValueExpressionEvaluator.kt new file mode 100644 index 0000000000..b96020879b --- /dev/null +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/CalculatedValueExpressionEvaluator.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.fhir.datacapture.validation + +import com.google.android.fhir.datacapture.fhirpath.evaluateToBase +import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.Type + +object CalculatedValueExpressionEvaluator { + /** + * Doesn't handle expressions containing FHIRPath supplements + * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements + */ + fun evaluate(type: Type, expression: Expression): Type? = + try { + evaluateToBase(type, expression.expression).singleOrNull() as? Type + } catch (_: Exception) { + null + } +} diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidatorTest.kt index 524aa9cbd5..aba8340b0f 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidatorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -49,7 +49,9 @@ class MaxDecimalPlacesValidatorTest { QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() .setValue(DecimalType("1.00")), context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -65,7 +67,9 @@ class MaxDecimalPlacesValidatorTest { QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() .setValue(DecimalType("1.00")), context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -81,7 +85,9 @@ class MaxDecimalPlacesValidatorTest { QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() .setValue(DecimalType("1.000")), context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt index d134264027..58d56ec773 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -130,7 +130,10 @@ class MaxLengthValidatorTest { this.value = Quantity(1234567.89) } - val validationResult = MaxLengthValidator.validate(requirement, answer, context) + val validationResult = + MaxLengthValidator.validate(requirement, answer, context) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -149,7 +152,9 @@ class MaxLengthValidatorTest { testComponent.requirement, testComponent.answer, context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage) @@ -167,7 +172,9 @@ class MaxLengthValidatorTest { testComponent.requirement, testComponent.answer, context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt index e5cce65740..8c9e4df18d 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import android.os.Build import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry import com.google.android.fhir.datacapture.extensions.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.common.truth.Truth.assertThat import java.time.LocalDate @@ -63,7 +64,10 @@ class MaxValueValidatorTest { value = IntegerType(200001) } - val validationResult = MaxValueValidator.validate(questionnaireItem, answer, context) + val validationResult = + MaxValueValidator.validate(questionnaireItem, answer, context) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage).isEqualTo("Maximum value allowed is:200000") @@ -85,7 +89,10 @@ class MaxValueValidatorTest { value = IntegerType(501) } - val validationResult = MaxValueValidator.validate(questionnaireItem, answer, context) + val validationResult = + MaxValueValidator.validate(questionnaireItem, answer, context) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -119,7 +126,10 @@ class MaxValueValidatorTest { value = DateType().apply { value = Date() } } - val validationResult = MaxValueValidator.validate(questionnaireItem, answer, context) + val validationResult = + MaxValueValidator.validate(questionnaireItem, answer, context) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage) @@ -158,9 +168,97 @@ class MaxValueValidatorTest { value = DateType().apply { value = Date() } } - val validationResult = MaxValueValidator.validate(questionnaireItem, answer, context) + val validationResult = + MaxValueValidator.validate(questionnaireItem, answer, context) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isFalse() - assertThat(validationResult.errorMessage).isEqualTo("Maximum value allowed is:$tenDaysAgo") + assertThat(validationResult.errorMessage) + .isEqualTo("Maximum value allowed is:${LocalDate.now().minusDays(7)}") + } + + @Test + fun `should return valid result and removes constraint for an answer value when maxValue cqf-calculatedValue evaluates to empty`() { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + url = MAX_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + extension = + listOf( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + language = "text/fhirpath" + expression = "yesterday()" // invalid FHIRPath expression + }, + ), + ) + }, + ) + }, + ) + } + + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType(Date()) + } + + val validationResult = + MaxValueValidator.validate( + questionnaireItem, + answer, + InstrumentationRegistry.getInstrumentation().context, + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } + + assertThat(validationResult.isValid).isTrue() + assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() + } + + @Test + fun `should return valid result and removes constraint for an answer with an empty value`() { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + url = MAX_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + extension = + listOf( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + language = "text/fhirpath" + expression = "today()" + }, + ), + ) + }, + ) + }, + ) + } + + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = DateType() } + + val validationResult = + MaxValueValidator.validate( + questionnaireItem, + answer, + InstrumentationRegistry.getInstrumentation().context, + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } + + assertThat(validationResult.isValid).isTrue() + assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt index 6f7e452450..359f667bb3 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -139,7 +139,10 @@ class MinLengthValidatorTest { this.value = Quantity(1234567.89) } - val validationResult = MaxLengthValidator.validate(requirement, answer, context) + val validationResult = + MaxLengthValidator.validate(requirement, answer, context) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -158,7 +161,9 @@ class MinLengthValidatorTest { testComponent.requirement, testComponent.answer, context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -173,7 +178,9 @@ class MinLengthValidatorTest { testComponent.requirement, testComponent.answer, context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt index c99d4fb0f9..497feb3439 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -67,7 +67,9 @@ class MinValueValidatorTest { questionnaireItem, answer, InstrumentationRegistry.getInstrumentation().context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage).isEqualTo("Minimum value allowed is:10") @@ -91,7 +93,9 @@ class MinValueValidatorTest { questionnaireItem, answer, InstrumentationRegistry.getInstrumentation().context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -129,7 +133,9 @@ class MinValueValidatorTest { questionnaireItem, answer, InstrumentationRegistry.getInstrumentation().context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -137,7 +143,7 @@ class MinValueValidatorTest { @Test fun `should return invalid result with correct min allowed value if contains both value and cqf-calculatedValue`() { - val threeDaysAgo = LocalDate.now().minusDays(3) + val sevenDaysAgo = LocalDate.now().minusDays(7) val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { @@ -147,12 +153,12 @@ class MinValueValidatorTest { this.setValue( DateType().apply { value = - Date.from(threeDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + Date.from(sevenDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) addExtension( Extension( EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { - expression = "today() - 7 'days'" + expression = "today() - 3 'days'" language = "text/fhirpath" }, ), @@ -171,9 +177,97 @@ class MinValueValidatorTest { } } - val validationResult = MinValueValidator.validate(questionnaireItem, answer, context) + val validationResult = + MinValueValidator.validate(questionnaireItem, answer, context) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isFalse() - assertThat(validationResult.errorMessage).isEqualTo("Minimum value allowed is:$threeDaysAgo") + assertThat(validationResult.errorMessage) + .isEqualTo("Minimum value allowed is:${LocalDate.now().minusDays(3)}") + } + + @Test + fun `should return valid result and removes constraint for an answer value when minValue cqf-calculatedValue evaluates to empty`() { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + url = MIN_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + extension = + listOf( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + language = "text/fhirpath" + expression = "yesterday()" // invalid FHIRPath expression + }, + ), + ) + }, + ) + }, + ) + } + + val twoDaysAgo = + Date.from( + LocalDate.now().minusDays(2).atStartOfDay().atZone(ZoneId.systemDefault()).toInstant(), + ) + val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType(twoDaysAgo) } + + val validationResult = + MinValueValidator.validate( + questionnaireItem, + answer, + InstrumentationRegistry.getInstrumentation().context, + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } + + assertThat(validationResult.isValid).isTrue() + assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() + } + + @Test + fun `should return valid result and removes constraint for an answer with an empty value`() { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + url = MIN_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + extension = + listOf( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + language = "text/fhirpath" + expression = "today()" + }, + ), + ) + }, + ) + }, + ) + } + + val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType() } + + val validationResult = + MinValueValidator.validate( + questionnaireItem, + answer, + InstrumentationRegistry.getInstrumentation().context, + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } + + assertThat(validationResult.isValid).isTrue() + assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt index c404430fc6..d9f98eefd5 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -77,7 +77,9 @@ class QuestionnaireResponseItemValidatorTest { questionnaireItem, answers, context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult).isEqualTo(Valid) } @@ -119,7 +121,9 @@ class QuestionnaireResponseItemValidatorTest { questionnaireItem, answers, context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult).isInstanceOf(Invalid::class.java) val invalidValidationResult = validationResult as Invalid @@ -141,7 +145,9 @@ class QuestionnaireResponseItemValidatorTest { questionnaireItem, answers, context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult).isInstanceOf(Invalid::class.java) val invalidValidationResult = validationResult as Invalid diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt index 9bfd1b9ad7..1613e9f0b3 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -160,7 +160,10 @@ class RegexValidatorTest { val response = QuestionnaireResponseItemAnswerComponent().apply { this.value = Quantity(1234567.89) } - val validationResult = RegexValidator.validate(requirement, response, context) + val validationResult = + RegexValidator.validate(requirement, response, context) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -179,7 +182,9 @@ class RegexValidatorTest { testComponent.requirement, testComponent.answer, context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -194,7 +199,9 @@ class RegexValidatorTest { testComponent.requirement, testComponent.answer, context, - ) + ) { extension, expression -> + CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + } assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage) From 3e173cb6ef818dd0ed9eacbb8c6c2de6539c1bc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Mon, 26 Feb 2024 15:02:51 +0300 Subject: [PATCH 4/6] Resolve comments for requested changes --- .../datacapture/QuestionnaireViewModel.kt | 2 +- .../extensions/MoreQuestionnaires.kt | 7 +- .../fhir/datacapture/extensions/MoreTypes.kt | 2 +- .../datacapture/QuestionnaireViewModelTest.kt | 363 +----------------- .../MoreQuestionnaireItemComponentsTest.kt | 2 +- .../fhirpath/ExpressionEvaluatorTest.kt | 2 +- .../datacapture/mapping/ResourceMapperTest.kt | 28 +- 7 files changed, 41 insertions(+), 365 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 25614f147b..f00a37e28d 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 @@ -724,7 +724,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat QuestionnaireResponseItemValidator.validate( questionnaireItem, questionnaireResponseItem.answer, - context = this@QuestionnaireViewModel.getApplication(), + this@QuestionnaireViewModel.getApplication(), ) { _, expression -> expressionEvaluator.evaluateExpressionValue( questionnaireItem, diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt index 82ecb6a2e7..540da3578f 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreQuestionnaires.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023 Google LLC + * Copyright 2023-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -94,7 +94,7 @@ private fun validateLaunchContextExtension(launchExtension: Extension) { false } - if (nameCoding.system != EXTENSION_LAUNCH_CONTEXT || !isValidResourceType) { + if (nameCoding.system != CODE_SYSTEM_LAUNCH_CONTEXT || !isValidResourceType) { error( "The extension:name and/or extension:type do not follow the format specified in $EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT", ) @@ -139,7 +139,8 @@ internal const val EXTENSION_ENTRY_MODE_URL: String = internal const val EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT = "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-launchContext" -internal const val EXTENSION_LAUNCH_CONTEXT = "http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext" +internal const val CODE_SYSTEM_LAUNCH_CONTEXT = + "http://hl7.org/fhir/uv/sdc/CodeSystem/launchContext" val Questionnaire.entryMode: EntryMode? get() { diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt index f5a5af943d..13af68f0e9 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/extensions/MoreTypes.kt @@ -95,7 +95,7 @@ private fun getDisplayString(type: Type, context: Context): String? = } /** - * Returns the string representation when type is of [PrimitiveType] or [Quantity], otherwise null + * Returns the string representation for [PrimitiveType] or [Quantity], otherwise defaults to null */ private fun getValueString(type: Type): String? = when (type) { 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 88ababbe1d..d3cc0f4264 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 @@ -31,6 +31,7 @@ import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_SHOW_CANCEL_BUTTON import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_SHOW_REVIEW_PAGE_FIRST import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_SHOW_SUBMIT_BUTTON +import com.google.android.fhir.datacapture.extensions.CODE_SYSTEM_LAUNCH_CONTEXT import com.google.android.fhir.datacapture.extensions.DisplayItemControlType import com.google.android.fhir.datacapture.extensions.EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION import com.google.android.fhir.datacapture.extensions.EXTENSION_ANSWER_OPTION_TOGGLE_EXPRESSION_OPTION @@ -46,7 +47,6 @@ import com.google.android.fhir.datacapture.extensions.EXTENSION_ENTRY_MODE_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_HIDDEN_URL import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_SYSTEM import com.google.android.fhir.datacapture.extensions.EXTENSION_ITEM_CONTROL_URL -import com.google.android.fhir.datacapture.extensions.EXTENSION_LAUNCH_CONTEXT import com.google.android.fhir.datacapture.extensions.EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT import com.google.android.fhir.datacapture.extensions.EXTENSION_VARIABLE_URL import com.google.android.fhir.datacapture.extensions.EntryMode @@ -60,7 +60,6 @@ import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.MAX_VALUE_EXTENSION_URL import com.google.android.fhir.datacapture.validation.MIN_VALUE_EXTENSION_URL import com.google.android.fhir.datacapture.validation.NotValidated -import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import com.google.common.truth.Truth.assertThat import java.time.LocalDate @@ -4489,7 +4488,7 @@ class QuestionnaireViewModelTest { addExtension( "name", Coding( - EXTENSION_LAUNCH_CONTEXT, + CODE_SYSTEM_LAUNCH_CONTEXT, "patient", "Patient", ), @@ -6327,12 +6326,14 @@ class QuestionnaireViewModelTest { val viewModel = createQuestionnaireViewModel(questionnaire) viewModel.runViewModelBlocking { + // Checks dependent answer is null at first viewModel .getQuestionnaireItemViewItemList() .map { it.asQuestion() } .single { it.questionnaireItem.linkId == "b" } .run { assertThat((this.minAnswerValue as? DateType)?.valueAsString).isNull() } + // Answers the first question viewModel .getQuestionnaireItemViewItemList() .map { it.asQuestion() } @@ -6343,6 +6344,7 @@ class QuestionnaireViewModelTest { }, ) + // Checks dependent answer has min value set correctly viewModel .getQuestionnaireItemViewItemList() .map { it.asQuestion() } @@ -6353,83 +6355,6 @@ class QuestionnaireViewModelTest { } } - @Test - fun `should correctly validate cqf-calculatedValue expression dependent on other question for maxValue extension`() = - runTest { - val questionnaire = - Questionnaire().apply { - addItem( - QuestionnaireItemComponent().apply { - linkId = "a" - type = Questionnaire.QuestionnaireItemType.DATE - text = "Select minimum date" - }, - ) - - addItem( - QuestionnaireItemComponent().apply { - linkId = "b" - type = Questionnaire.QuestionnaireItemType.DATE - text = "Select a date" - addExtension( - Extension( - MAX_VALUE_EXTENSION_URL, - DateType().apply { - addExtension( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - expression = - "%resource.repeat(item).where(linkId='a' and answer.empty().not()).select(answer.value)" - language = "text/fhirpath" - }, - ), - ) - }, - ), - ) - }, - ) - } - - val viewModel = createQuestionnaireViewModel(questionnaire) - viewModel.runViewModelBlocking { - viewModel - .getQuestionnaireItemViewItemList() - .map { it.asQuestion() } - .single { it.questionnaireItem.linkId == "a" } - .setAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = DateType.parseV3("20231014") - }, - ) - - viewModel - .getQuestionnaireItemViewItemList() - .map { it.asQuestion() } - .single { it.questionnaireItem.linkId == "b" } - .setAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = - DateType().apply { - val tomorrow = LocalDate.now().plusDays(1) - value = - Date.from(tomorrow.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) - } - }, - ) - - viewModel - .getQuestionnaireItemViewItemList() - .map { it.asQuestion() } - .single { it.questionnaireItem.linkId == "b" } - .run { - assertThat(validationResult) - .isEqualTo(Invalid(listOf("Maximum value allowed is:2023-10-14"))) - } - } - } - @Test fun `should evaluate cqf-calculatedValue with expression dependent on a variable expression`() = runTest { @@ -6486,19 +6411,14 @@ class QuestionnaireViewModelTest { } @Test - fun `should evaluate cqf-calculatedValue with expression dependent on x-fhir-query launchContext`() = + fun `should correctly evaluate cqf-calculatedValue with expression dependent on x-fhir-query launchContext`() = runTest { val testDate = LocalDate.now().minusYears(20) - val patient0 = - Patient().apply { - birthDate = Date.from(testDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) - } - val questionnaire = Questionnaire().apply { addExtension( Extension(EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT).apply { - addExtension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "patient", "Patient")) + addExtension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "patient", "Patient")) addExtension("type", CodeType("Patient")) }, ) @@ -6528,9 +6448,14 @@ class QuestionnaireViewModelTest { ) } + val patient0 = + Patient().apply { + birthDate = Date.from(testDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } state[EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP] = mapOf("patient" to printer.encodeResourceToString(patient0)) val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { viewModel .getQuestionnaireItemViewItemList() @@ -6544,19 +6469,15 @@ class QuestionnaireViewModelTest { } @Test - fun `should correctly validate cqf-calculatedValue with expression dependent on x-fhir-query launchContext for minValue extension`() = + fun `should correctly validate cqf-calculatedValue with expression dependent on x-fhir-query launchContext`() = runTest { val testDate = LocalDate.now().minusYears(20) - val patient0 = - Patient().apply { - birthDate = Date.from(testDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) - } val questionnaire = Questionnaire().apply { addExtension( Extension(EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT).apply { - addExtension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "patient", "Patient")) + addExtension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "patient", "Patient")) addExtension("type", CodeType("Patient")) }, ) @@ -6586,9 +6507,14 @@ class QuestionnaireViewModelTest { ) } + val patient0 = + Patient().apply { + birthDate = Date.from(testDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } state[EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP] = mapOf("patient" to printer.encodeResourceToString(patient0)) val viewModel = createQuestionnaireViewModel(questionnaire) + viewModel.runViewModelBlocking { viewModel .getQuestionnaireItemViewItemList() @@ -6615,257 +6541,6 @@ class QuestionnaireViewModelTest { } } - @Test - fun `validateQuestionnaireAndUpdateUI should return correct result for cqf-calculatedValue with expression dependent on other question`() = - runTest { - val questionnaire = - Questionnaire().apply { - addItem( - QuestionnaireItemComponent().apply { - linkId = "a" - type = Questionnaire.QuestionnaireItemType.DATE - text = "Select minimum date" - }, - ) - - addItem( - QuestionnaireItemComponent().apply { - linkId = "b" - type = Questionnaire.QuestionnaireItemType.DATE - text = "Select a date" - addExtension( - Extension( - MIN_VALUE_EXTENSION_URL, - DateType().apply { - addExtension( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - expression = - "%resource.repeat(item).where(linkId='a' and answer.empty().not()).select(answer.value)" - language = "text/fhirpath" - }, - ), - ) - }, - ), - ) - }, - ) - } - - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem( - QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("a")).apply { - addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = DateType.parseV3("20231108") - }, - ) - }, - ) - - addItem( - QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("b")).apply { - addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = DateType.parseV3("20231010") - }, - ) - }, - ) - } - - val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - viewModel.runViewModelBlocking { - val results = viewModel.validateQuestionnaireAndUpdateUI() - assertThat(results.values).isNotEmpty() - assertThat(results["a"]?.single()).isInstanceOf(Valid::class.java) - assertThat(results["b"]?.single()) - .isEqualTo(Invalid(listOf("Minimum value allowed is:2023-11-08"))) - } - } - - @Test - fun `validateQuestionnaireAndUpdateUI should return correct result for cqf-calculatedValue with variable expression`() = - runTest { - val questionnaire = - Questionnaire().apply { - addExtension( - Extension(EXTENSION_VARIABLE_URL).apply { - setValue( - Expression().apply { - name = "dateToday" - expression = "today()" - language = "text/fhirpath" - }, - ) - }, - ) - - addItem( - QuestionnaireItemComponent().apply { - linkId = "a" - type = Questionnaire.QuestionnaireItemType.DATE - text = "Select a date" - addExtension( - Extension( - MIN_VALUE_EXTENSION_URL, - DateType().apply { - addExtension( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - expression = "%dateToday" - language = "text/fhirpath" - }, - ), - ) - }, - ), - ) - }, - ) - } - - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem( - QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("a")).apply { - addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = DateType.parseV3("20231010") - }, - ) - }, - ) - } - - val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - viewModel.runViewModelBlocking { - val results = viewModel.validateQuestionnaireAndUpdateUI() - assertThat(results["a"]?.single()) - .isEqualTo(Invalid(listOf("Minimum value allowed is:${LocalDate.now()}"))) - } - } - - @Test - fun `validateQuestionnaireAndUpdateUI should return correct result for cqf-calculatedValue expression dependent on x-fhir-query launchContext`() = - runTest { - val testDate = LocalDate.now().minusYears(20) - val patient0 = - Patient().apply { - birthDate = Date.from(testDate.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) - } - - val questionnaire = - Questionnaire().apply { - addExtension( - Extension(EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT).apply { - addExtension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "patient", "Patient")) - addExtension("type", CodeType("Patient")) - }, - ) - - addItem( - QuestionnaireItemComponent().apply { - linkId = "a" - type = Questionnaire.QuestionnaireItemType.DATE - text = "Select a date" - addExtension( - Extension( - MIN_VALUE_EXTENSION_URL, - DateType().apply { - addExtension( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - expression = "%patient.birthDate" - language = "text/fhirpath" - }, - ), - ) - }, - ), - ) - }, - ) - } - - state[EXTRA_QUESTIONNAIRE_LAUNCH_CONTEXT_MAP] = - mapOf("patient" to printer.encodeResourceToString(patient0)) - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem( - QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("a")).apply { - addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = DateType.parseV3("20001010") - }, - ) - }, - ) - } - - val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - viewModel.runViewModelBlocking { - val results = viewModel.validateQuestionnaireAndUpdateUI() - assertThat(results["a"]?.single()) - .isEqualTo(Invalid(listOf("Minimum value allowed is:$testDate"))) - } - } - - @Test - fun `validateQuestionnaireAndUpdateUI should return valid and removes constraint for failing cqf-calculatedValue expressions`() = - runTest { - val questionnaire = - Questionnaire().apply { - addItem( - QuestionnaireItemComponent().apply { - linkId = "a" - type = Questionnaire.QuestionnaireItemType.DATE - text = "Select a date" - addExtension( - Extension( - MIN_VALUE_EXTENSION_URL, - DateType().apply { - addExtension( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - expression = "%nonExistentVariable" // invalid variable - language = "text/fhirpath" - }, - ), - ) - }, - ), - ) - }, - ) - } - - val questionnaireResponse = - QuestionnaireResponse().apply { - addItem( - QuestionnaireResponse.QuestionnaireResponseItemComponent(StringType("a")).apply { - addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = DateType.parseV3("20231010") - }, - ) - }, - ) - } - - val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - viewModel.runViewModelBlocking { - val results = viewModel.validateQuestionnaireAndUpdateUI() - assertThat(results["a"]?.single()).isEqualTo(Valid) - } - } - // ==================================================================== // // // // Display Category // 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 7971eae2e2..9def709e9b 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt index b4abf3a639..1902a2a876 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 Google LLC + * Copyright 2023 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt index df84802581..b8bb271973 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/mapping/ResourceMapperTest.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,7 +22,7 @@ import androidx.test.core.app.ApplicationProvider import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser -import com.google.android.fhir.datacapture.extensions.EXTENSION_LAUNCH_CONTEXT +import com.google.android.fhir.datacapture.extensions.CODE_SYSTEM_LAUNCH_CONTEXT import com.google.android.fhir.datacapture.extensions.EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT import com.google.android.fhir.datacapture.extensions.ITEM_INITIAL_EXPRESSION_URL import com.google.android.fhir.datacapture.views.factories.localDate @@ -1519,7 +1519,7 @@ class ResourceMapperTest { url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT extension = listOf( - Extension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "father", "Father")), + Extension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "father", "Father")), Extension("type", CodeType("Patient")), ) } @@ -1527,7 +1527,7 @@ class ResourceMapperTest { url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT extension = listOf( - Extension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "mother", "Mother")), + Extension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "mother", "Mother")), Extension("type", CodeType("Patient")), ) } @@ -1538,7 +1538,7 @@ class ResourceMapperTest { Extension( "name", Coding( - EXTENSION_LAUNCH_CONTEXT, + CODE_SYSTEM_LAUNCH_CONTEXT, "registration-encounter", "Registration Encounter", ), @@ -1643,7 +1643,7 @@ class ResourceMapperTest { url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT extension = listOf( - Extension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "father", "Father")), + Extension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "father", "Father")), Extension("type", CodeType("Patient")), ) } @@ -1718,7 +1718,7 @@ class ResourceMapperTest { url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT extension = listOf( - Extension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "father", "Father")), + Extension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "father", "Father")), Extension("type", CodeType("Patient")), ) } @@ -1758,7 +1758,7 @@ class ResourceMapperTest { url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT extension = listOf( - Extension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "father", "Father")), + Extension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "father", "Father")), Extension("type", CodeType("Patient")), ) } @@ -1798,7 +1798,7 @@ class ResourceMapperTest { url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT extension = listOf( - Extension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "father", "Father")), + Extension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "father", "Father")), Extension("type", CodeType("Patient")), ) } @@ -1845,7 +1845,7 @@ class ResourceMapperTest { url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT extension = listOf( - Extension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "patient", "Patient")), + Extension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "patient", "Patient")), Extension("type", CodeType("Patient")), ) } @@ -1882,7 +1882,7 @@ class ResourceMapperTest { url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT extension = listOf( - Extension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "father", "Father")), + Extension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "father", "Father")), Extension("type", CodeType("Patient")), ) } @@ -1922,7 +1922,7 @@ class ResourceMapperTest { url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT extension = listOf( - Extension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "mother", "Mother")), + Extension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "mother", "Mother")), Extension("type", CodeType("Patient")), ) } @@ -1976,7 +1976,7 @@ class ResourceMapperTest { url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT extension = listOf( - Extension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "mother", "Mother")), + Extension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "mother", "Mother")), Extension("type", CodeType("Patient")), ) } @@ -2928,7 +2928,7 @@ class ResourceMapperTest { url = EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT extension = listOf( - Extension("name", Coding(EXTENSION_LAUNCH_CONTEXT, "father", "Father")), + Extension("name", Coding(CODE_SYSTEM_LAUNCH_CONTEXT, "father", "Father")), Extension("type", CodeType("Patient")), ) } From 6eeca2c66b485f0b1c22e7623bbeff0f2925a5f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Wed, 28 Feb 2024 01:45:54 +0300 Subject: [PATCH 5/6] Suspend validate methods on answerConstraintValidators --- .../datacapture/QuestionnaireViewModel.kt | 6 +- .../fhirpath/ExpressionEvaluator.kt | 2 +- .../fhir/datacapture/fhirpath/FhirPathUtil.kt | 7 +- .../validation/AnswerConstraintValidator.kt | 5 +- .../AnswerExtensionConstraintValidator.kt | 24 +- .../validation/MaxDecimalPlacesValidator.kt | 8 +- .../validation/MaxLengthValidator.kt | 5 +- .../validation/MaxValueValidator.kt | 8 +- .../validation/MinLengthValidator.kt | 8 +- .../validation/MinValueValidator.kt | 8 +- .../QuestionnaireResponseItemValidator.kt | 9 +- .../QuestionnaireResponseValidator.kt | 4 +- .../datacapture/validation/RegexValidator.kt | 12 +- .../MaxDecimalPlacesValidatorTest.kt | 38 ++- .../validation/MaxLengthValidatorTest.kt | 47 +-- .../validation/MaxValueValidatorTest.kt | 301 +++++++++-------- .../validation/MinLengthValidatorTest.kt | 47 +-- .../validation/MinValueValidatorTest.kt | 314 +++++++++--------- .../QuestionnaireResponseItemValidatorTest.kt | 19 +- .../validation/RegexValidatorTest.kt | 49 +-- ...tor.kt => TestExpressionValueEvaluator.kt} | 7 +- 21 files changed, 469 insertions(+), 459 deletions(-) rename datacapture/src/test/java/com/google/android/fhir/datacapture/validation/{CalculatedValueExpressionEvaluator.kt => TestExpressionValueEvaluator.kt} (83%) 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 01ba3531be..f5573a33be 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 @@ -72,8 +72,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.withIndex import kotlinx.coroutines.launch -import org.hl7.fhir.r4.model.Base -import org.hl7.fhir.r4.model.Element import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -735,11 +733,11 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat questionnaireItem, questionnaireResponseItem.answer, this@QuestionnaireViewModel.getApplication(), - ) { _, expression -> + ) { expressionEvaluator.evaluateExpressionValue( questionnaireItem, questionnaireResponseItem, - expression, + it, ) } } else { diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt index 61be1b7f93..2a224a10b6 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt @@ -142,7 +142,7 @@ internal class ExpressionEvaluator( } /** Returns the evaluation result of an expression as a [Type] value */ - fun evaluateExpressionValue( + suspend fun evaluateExpressionValue( questionnaireItem: QuestionnaireItemComponent, questionnaireResponseItem: QuestionnaireResponseItemComponent?, expression: Expression, diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt index 4c7d626913..1e86e4b8fd 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/FhirPathUtil.kt @@ -1,5 +1,5 @@ /* - * Copyright 2022-2023 Google LLC + * Copyright 2022-2024 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,7 +24,6 @@ import org.hl7.fhir.r4.model.ExpressionNode import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent import org.hl7.fhir.r4.model.Resource -import org.hl7.fhir.r4.model.Type import org.hl7.fhir.r4.utils.FHIRPathEngine private val fhirPathEngine: FHIRPathEngine = @@ -101,9 +100,9 @@ internal fun evaluateToBase( } /** Evaluates the given expression and returns list of [Base] */ -internal fun evaluateToBase(type: Type, expression: String): List { +internal fun evaluateToBase(base: Base, expression: String): List { return fhirPathEngine.evaluate( - /* base = */ type, + /* base = */ base, /* path = */ expression, ) } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerConstraintValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerConstraintValidator.kt index c73094790d..20759607af 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerConstraintValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerConstraintValidator.kt @@ -18,7 +18,6 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import org.hl7.fhir.r4.model.Expression -import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Type @@ -38,11 +37,11 @@ internal interface AnswerConstraintValidator { * * [Learn more](https://www.hl7.org/fhir/questionnaireresponse.html#link). */ - fun validate( + suspend fun validate( questionnaireItem: Questionnaire.QuestionnaireItemComponent, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, context: Context, - evaluateExtensionCqfCalculatedValue: (Extension, Expression) -> Type?, + expressionEvaluator: suspend (Expression) -> Type?, ): Result /** diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt index 1d85275fc7..5e5ed9734d 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt @@ -20,7 +20,6 @@ import android.content.Context import com.google.android.fhir.datacapture.extensions.cqfCalculatedValueExpression import com.google.android.fhir.datacapture.extensions.hasValue import org.hl7.fhir.r4.model.Expression -import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Type @@ -40,38 +39,31 @@ internal open class AnswerExtensionConstraintValidator( val url: String, val predicate: ( - /*extensionValue*/ + /*constraintValue*/ Type, - /*answer*/ QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, ) -> Boolean, val messageGenerator: (Type, Context) -> String, ) : AnswerConstraintValidator { - override fun validate( + override suspend fun validate( questionnaireItem: Questionnaire.QuestionnaireItemComponent, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, context: Context, - evaluateExtensionCqfCalculatedValue: (Extension, Expression) -> Type?, + expressionEvaluator: suspend (Expression) -> Type?, ): AnswerConstraintValidator.Result { if (questionnaireItem.hasExtension(url)) { val extension = questionnaireItem.getExtensionByUrl(url) - val extensionValueType = - extension.value.let { - it.cqfCalculatedValueExpression?.let { expression -> - evaluateExtensionCqfCalculatedValue(extension, expression) - } - ?: it - } + val extensionCalculatedValue = + extension.value.cqfCalculatedValueExpression?.let { expressionEvaluator(it) } + val extensionValue = extensionCalculatedValue ?: extension.value // Only checks constraint if both extension and answer have a value if ( - extensionValueType.hasValue() && - answer.value.hasValue() && - predicate(extensionValueType, answer) + extensionValue.hasValue() && answer.value.hasValue() && predicate(extensionValue, answer) ) { return AnswerConstraintValidator.Result( false, - messageGenerator(extensionValueType, context), + messageGenerator(extensionValue, context), ) } } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt index 4b08c5b685..fdfc088be6 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidator.kt @@ -33,16 +33,16 @@ internal object MaxDecimalPlacesValidator : AnswerExtensionConstraintValidator( url = MAX_DECIMAL_URL, predicate = { - extensionValue: Type, + constraintValue: Type, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, -> - val maxDecimalPlaces = (extensionValue as? IntegerType)?.value + val maxDecimalPlaces = (constraintValue as? IntegerType)?.value answer.hasValueDecimalType() && maxDecimalPlaces != null && answer.valueDecimalType.valueAsString.substringAfter(".").length > maxDecimalPlaces }, - messageGenerator = { extensionValue: Type, context: Context -> - context.getString(R.string.max_decimal_validation_error_msg, extensionValue.primitiveValue()) + messageGenerator = { constraintValue: Type, context: Context -> + context.getString(R.string.max_decimal_validation_error_msg, constraintValue.primitiveValue()) }, ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxLengthValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxLengthValidator.kt index 09c9b365e3..0d3fb6aa55 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxLengthValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxLengthValidator.kt @@ -19,7 +19,6 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import com.google.android.fhir.datacapture.extensions.asStringValue import org.hl7.fhir.r4.model.Expression -import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Type @@ -31,11 +30,11 @@ import org.hl7.fhir.r4.model.Type * https://www.hl7.org/fhir/valueset-item-type.html#expansion */ internal object MaxLengthValidator : AnswerConstraintValidator { - override fun validate( + override suspend fun validate( questionnaireItem: Questionnaire.QuestionnaireItemComponent, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, context: Context, - evaluateExtensionCqfCalculatedValue: (Extension, Expression) -> Type?, + expressionEvaluator: suspend (Expression) -> Type?, ): AnswerConstraintValidator.Result { if ( questionnaireItem.hasMaxLength() && diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt index 5f81a764f3..3bd12d4788 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MaxValueValidator.kt @@ -30,15 +30,15 @@ internal object MaxValueValidator : AnswerExtensionConstraintValidator( url = MAX_VALUE_EXTENSION_URL, predicate = { - extensionValue: Type, + constraintValue: Type, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, -> - answer.value > extensionValue + answer.value > constraintValue }, - messageGenerator = { extensionValue: Type, context: Context -> + messageGenerator = { constraintValue: Type, context: Context -> context.getString( R.string.max_value_validation_error_msg, - extensionValue.getValueAsString(context), + constraintValue.getValueAsString(context), ) }, ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinLengthValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinLengthValidator.kt index cc9a9741b0..623554b2f9 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinLengthValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinLengthValidator.kt @@ -37,13 +37,13 @@ import org.hl7.fhir.r4.model.Type internal object MinLengthValidator : AnswerExtensionConstraintValidator( url = MIN_LENGTH_EXTENSION_URL, - predicate = { extensionValue, answer -> + predicate = { constraintValue, answer -> answer.value.isPrimitive && (answer.value as PrimitiveType<*>).asStringValue().length < - (extensionValue as IntegerType).value + (constraintValue as IntegerType).value }, - messageGenerator = { extensionValue: Type, context: Context -> - context.getString(R.string.min_length_validation_error_msg, extensionValue.primitiveValue()) + messageGenerator = { constraintValue: Type, context: Context -> + context.getString(R.string.min_length_validation_error_msg, constraintValue.primitiveValue()) }, ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt index 6fd92ed65e..5cc851e394 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinValueValidator.kt @@ -30,15 +30,15 @@ internal object MinValueValidator : AnswerExtensionConstraintValidator( url = MIN_VALUE_EXTENSION_URL, predicate = { - extensionValue: Type, + constraintValue: Type, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, -> - answer.value < extensionValue + answer.value < constraintValue }, - messageGenerator = { extensionValue: Type, context: Context -> + messageGenerator = { constraintValue: Type, context: Context -> context.getString( R.string.min_value_validation_error_msg, - extensionValue.getValueAsString(context), + constraintValue.getValueAsString(context), ) }, ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidator.kt index 9af989cada..7a386e48b2 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidator.kt @@ -19,7 +19,6 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import com.google.android.fhir.datacapture.extensions.isHidden import org.hl7.fhir.r4.model.Expression -import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Type @@ -44,11 +43,11 @@ internal object QuestionnaireResponseItemValidator { ) /** Validates [answers] contains valid answer(s) to [questionnaireItem]. */ - fun validate( + suspend fun validate( questionnaireItem: Questionnaire.QuestionnaireItemComponent, answers: List, context: Context, - expressionValueEvaluator: (Extension, Expression) -> Type?, + expressionEvaluator: suspend (Expression) -> Type?, ): ValidationResult { if (questionnaireItem.isHidden) return NotValidated @@ -59,9 +58,7 @@ internal object QuestionnaireResponseItemValidator { val questionnaireResponseItemAnswerConstraintValidationResult = answerConstraintValidators.flatMap { validator -> answers.map { answer -> - validator.validate(questionnaireItem, answer, context) { extension, expression -> - expressionValueEvaluator.invoke(extension, expression) - } + validator.validate(questionnaireItem, answer, context, expressionEvaluator) } } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt index 3fa95e3d91..ec449b36d9 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseValidator.kt @@ -181,11 +181,11 @@ object QuestionnaireResponseValidator { questionnaireItem, questionnaireResponseItem.answer, context, - ) { _, expression -> + ) { expressionEvaluator.evaluateExpressionValue( questionnaireItem, questionnaireResponseItem, - expression, + it, ) }, ) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RegexValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RegexValidator.kt index 04a06cad08..97d1a6f56d 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RegexValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/RegexValidator.kt @@ -37,22 +37,22 @@ internal object RegexValidator : url = REGEX_EXTENSION_URL, predicate = predicate@{ - extensionValue: Type, + constraintValue: Type, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, -> - if (!extensionValue.isPrimitive || !answer.value.isPrimitive) { + if (!constraintValue.isPrimitive || !answer.value.isPrimitive) { return@predicate false } try { - val pattern = Pattern.compile((extensionValue as PrimitiveType<*>).asStringValue()) + val pattern = Pattern.compile((constraintValue as PrimitiveType<*>).asStringValue()) !pattern.matcher(answer.value.asStringValue()).matches() } catch (e: PatternSyntaxException) { - Timber.w("Can't parse regex: $extensionValue", e) + Timber.w("Can't parse regex: $constraintValue", e) false } }, - messageGenerator = { extensionValue: Type, context: Context -> - context.getString(R.string.regex_validation_error_msg, extensionValue.primitiveValue()) + messageGenerator = { constraintValue: Type, context: Context -> + context.getString(R.string.regex_validation_error_msg, constraintValue.primitiveValue()) }, ) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidatorTest.kt index aba8340b0f..52b3f9f1ab 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxDecimalPlacesValidatorTest.kt @@ -20,6 +20,7 @@ import android.content.Context import android.os.Build import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.DecimalType import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.IntegerType @@ -42,15 +43,16 @@ class MaxDecimalPlacesValidatorTest { } @Test - fun validate_noExtension_shouldReturnValidResult() { + fun validate_noExtension_shouldReturnValidResult() = runTest { + val questionnaireItem = Questionnaire.QuestionnaireItemComponent() val validationResult = MaxDecimalPlacesValidator.validate( - Questionnaire.QuestionnaireItemComponent(), + questionnaireItem, QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() .setValue(DecimalType("1.00")), context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) } assertThat(validationResult.isValid).isTrue() @@ -58,17 +60,19 @@ class MaxDecimalPlacesValidatorTest { } @Test - fun validate_validAnswer_shouldReturnValidResult() { + fun validate_validAnswer_shouldReturnValidResult() = runTest { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + this.addExtension(Extension(MAX_DECIMAL_URL, IntegerType(2))) + } val validationResult = MaxDecimalPlacesValidator.validate( - Questionnaire.QuestionnaireItemComponent().apply { - this.addExtension(Extension(MAX_DECIMAL_URL, IntegerType(2))) - }, + questionnaireItem, QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() .setValue(DecimalType("1.00")), context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) } assertThat(validationResult.isValid).isTrue() @@ -76,17 +80,19 @@ class MaxDecimalPlacesValidatorTest { } @Test - fun validate_tooManyDecimalPlaces_shouldReturnInvalidResult() { + fun validate_tooManyDecimalPlaces_shouldReturnInvalidResult() = runTest { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + this.addExtension(Extension(MAX_DECIMAL_URL, IntegerType(2))) + } val validationResult = MaxDecimalPlacesValidator.validate( - Questionnaire.QuestionnaireItemComponent().apply { - this.addExtension(Extension(MAX_DECIMAL_URL, IntegerType(2))) - }, + questionnaireItem, QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() .setValue(DecimalType("1.000")), context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) } assertThat(validationResult.isValid).isFalse() diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt index 58d56ec773..cb9edda933 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxLengthValidatorTest.kt @@ -22,6 +22,7 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import java.net.URI import java.text.SimpleDateFormat +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.DecimalType @@ -51,79 +52,79 @@ class MaxLengthValidatorTest { } @Test - fun boolean_answerOverMaxLength_shouldReturnInvalidResult() { + fun boolean_answerOverMaxLength_shouldReturnInvalidResult() = runTest { checkAnswerOverMaxLength(maxLength = 4, value = BooleanType(false)) } @Test - fun boolean_answerUnderMaxLength_shouldReturnValidResult() { + fun boolean_answerUnderMaxLength_shouldReturnValidResult() = runTest { checkAnswerUnderMaxLength(maxLength = 6, value = BooleanType(false)) } @Test - fun decimal_answerOverMaxLength_shouldReturnInvalidResult() { + fun decimal_answerOverMaxLength_shouldReturnInvalidResult() = runTest { checkAnswerOverMaxLength(maxLength = 10, value = DecimalType(3.1415926535)) } @Test - fun decimal_answerUnderMaxLength_shouldReturnValidResult() { + fun decimal_answerUnderMaxLength_shouldReturnValidResult() = runTest { checkAnswerUnderMaxLength(maxLength = 16, value = DecimalType(3.1415926535)) } @Test - fun int_answerOverMaxLength_shouldReturnInvalidResult() { + fun int_answerOverMaxLength_shouldReturnInvalidResult() = runTest { checkAnswerOverMaxLength(maxLength = 5, value = IntegerType(1234567890)) } @Test - fun int_answerUnderMaxLength_shouldReturnValidResult() { + fun int_answerUnderMaxLength_shouldReturnValidResult() = runTest { checkAnswerUnderMaxLength(maxLength = 10, value = IntegerType(1234567890)) } @Test - fun dateType_answerOverMaxLength_shouldReturnInvalidResult() { + fun dateType_answerOverMaxLength_shouldReturnInvalidResult() = runTest { val dateFormat = SimpleDateFormat("yyyy-MM-dd") checkAnswerOverMaxLength(maxLength = 5, value = DateType(dateFormat.parse("2021-06-01"))) } @Test - fun date_answerUnderMaxLength_shouldReturnValidResult() { + fun date_answerUnderMaxLength_shouldReturnValidResult() = runTest { val dateFormat = SimpleDateFormat("yyyy-MM-dd") checkAnswerUnderMaxLength(maxLength = 11, value = DateType(dateFormat.parse("2021-06-01"))) } @Test - fun time_answerOverMaxLength_shouldReturnInvalidResult() { + fun time_answerOverMaxLength_shouldReturnInvalidResult() = runTest { checkAnswerOverMaxLength(maxLength = 5, value = TimeType("18:00:59")) } @Test - fun time_answerUnderMaxLength_shouldReturnValidResult() { + fun time_answerUnderMaxLength_shouldReturnValidResult() = runTest { checkAnswerUnderMaxLength(maxLength = 9, value = TimeType("18:00:59")) } @Test - fun string_answerOverMaxLength_shouldReturnInvalidResult() { + fun string_answerOverMaxLength_shouldReturnInvalidResult() = runTest { checkAnswerOverMaxLength(maxLength = 5, value = StringType("Hello World")) } @Test - fun string_answerUnderMaxLength_shouldReturnValidResult() { + fun string_answerUnderMaxLength_shouldReturnValidResult() = runTest { checkAnswerUnderMaxLength(maxLength = 11, value = StringType("Hello World")) } @Test - fun uri_answerOverMaxLength_shouldReturnInvalidResult() { + fun uri_answerOverMaxLength_shouldReturnInvalidResult() = runTest { checkAnswerOverMaxLength(maxLength = 5, value = UriType(URI.create("https://www.hl7.org/"))) } @Test - fun uri_answerUnderMaxLength_shouldReturnValidResult() { + fun uri_answerUnderMaxLength_shouldReturnValidResult() = runTest { checkAnswerUnderMaxLength(maxLength = 20, value = UriType(URI.create("https://www.hl7.org/"))) } @Test - fun nonPrimitiveOverMaxLength_shouldReturnValidResult() { + fun nonPrimitiveOverMaxLength_shouldReturnValidResult() = runTest { val requirement = Questionnaire.QuestionnaireItemComponent().apply { maxLength = 5 } val answer = QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { @@ -131,8 +132,8 @@ class MaxLengthValidatorTest { } val validationResult = - MaxLengthValidator.validate(requirement, answer, context) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + MaxLengthValidator.validate(requirement, answer, context) { + TestExpressionValueEvaluator.evaluate(requirement, it) } assertThat(validationResult.isValid).isTrue() @@ -144,7 +145,7 @@ class MaxLengthValidatorTest { var context: Context = ApplicationProvider.getApplicationContext() @JvmStatic - fun checkAnswerOverMaxLength(maxLength: Int, value: PrimitiveType<*>) { + suspend fun checkAnswerOverMaxLength(maxLength: Int, value: PrimitiveType<*>) { val testComponent = createMaxLengthQuestionnaireTestItem(maxLength, value) val validationResult = @@ -152,8 +153,8 @@ class MaxLengthValidatorTest { testComponent.requirement, testComponent.answer, context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(testComponent.requirement, it) } assertThat(validationResult.isValid).isFalse() @@ -164,7 +165,7 @@ class MaxLengthValidatorTest { } @JvmStatic - fun checkAnswerUnderMaxLength(maxLength: Int, value: PrimitiveType<*>) { + suspend fun checkAnswerUnderMaxLength(maxLength: Int, value: PrimitiveType<*>) { val testComponent = createMaxLengthQuestionnaireTestItem(maxLength, value) val validationResult = @@ -172,8 +173,8 @@ class MaxLengthValidatorTest { testComponent.requirement, testComponent.answer, context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(testComponent.requirement, it) } assertThat(validationResult.isValid).isTrue() diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt index 8c9e4df18d..2c3b1346bd 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueValidatorTest.kt @@ -25,6 +25,7 @@ import com.google.common.truth.Truth.assertThat import java.time.LocalDate import java.time.ZoneId import java.util.Date +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Extension @@ -49,7 +50,7 @@ class MaxValueValidatorTest { } @Test - fun `should return invalid result when entered value is greater than maxValue`() { + fun `should return invalid result when entered value is greater than maxValue`() = runTest { val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { addExtension( @@ -65,8 +66,8 @@ class MaxValueValidatorTest { } val validationResult = - MaxValueValidator.validate(questionnaireItem, answer, context) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + MaxValueValidator.validate(questionnaireItem, answer, context) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) } assertThat(validationResult.isValid).isFalse() @@ -74,7 +75,7 @@ class MaxValueValidatorTest { } @Test - fun `should return valid result when entered value is less than maxValue`() { + fun `should return valid result when entered value is less than maxValue`() = runTest { val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { addExtension( @@ -90,8 +91,8 @@ class MaxValueValidatorTest { } val validationResult = - MaxValueValidator.validate(questionnaireItem, answer, context) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + MaxValueValidator.validate(questionnaireItem, answer, context) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) } assertThat(validationResult.isValid).isTrue() @@ -99,166 +100,172 @@ class MaxValueValidatorTest { } @Test - fun `should return invalid result with correct max allowed value if contains only cqf-calculatedValue`() { - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - this.url = MAX_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - addExtension( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - expression = "today() - 7 'days'" - language = "text/fhirpath" - }, - ), - ) - }, - ) - }, - ) - } - val answer = - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = DateType().apply { value = Date() } - } - - val validationResult = - MaxValueValidator.validate(questionnaireItem, answer, context) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) - } - - assertThat(validationResult.isValid).isFalse() - assertThat(validationResult.errorMessage) - .isEqualTo("Maximum value allowed is:${LocalDate.now().minusDays(7)}") - } - - @Test - fun `should return invalid result with correct max allowed value if contains both value and cqf-calculatedValue`() { - val tenDaysAgo = LocalDate.now().minusDays(10) - - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - this.url = MAX_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - value = - Date.from(tenDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) - addExtension( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - expression = "today() - 7 'days'" - language = "text/fhirpath" - }, - ), - ) - }, - ) - }, - ) - } - val answer = - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = DateType().apply { value = Date() } - } - - val validationResult = - MaxValueValidator.validate(questionnaireItem, answer, context) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) - } - - assertThat(validationResult.isValid).isFalse() - assertThat(validationResult.errorMessage) - .isEqualTo("Maximum value allowed is:${LocalDate.now().minusDays(7)}") - } - - @Test - fun `should return valid result and removes constraint for an answer value when maxValue cqf-calculatedValue evaluates to empty`() { - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MAX_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - extension = - listOf( + fun `should return invalid result with correct max allowed value if contains only cqf-calculatedValue`() = + runTest { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + this.url = MAX_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + addExtension( Extension( EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { + expression = "today() - 7 'days'" language = "text/fhirpath" - expression = "yesterday()" // invalid FHIRPath expression }, ), ) - }, - ) - }, - ) - } - - val answer = - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = DateType(Date()) - } + }, + ) + }, + ) + } + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType().apply { value = Date() } + } - val validationResult = - MaxValueValidator.validate( - questionnaireItem, - answer, - InstrumentationRegistry.getInstrumentation().context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) - } + val validationResult = + MaxValueValidator.validate(questionnaireItem, answer, context) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } - assertThat(validationResult.isValid).isTrue() - assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() - } + assertThat(validationResult.isValid).isFalse() + assertThat(validationResult.errorMessage) + .isEqualTo("Maximum value allowed is:${LocalDate.now().minusDays(7)}") + } @Test - fun `should return valid result and removes constraint for an answer with an empty value`() { - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MAX_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - extension = - listOf( + fun `should return invalid result with correct max allowed value if contains both value and cqf-calculatedValue`() = + runTest { + val tenDaysAgo = LocalDate.now().minusDays(10) + + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + this.url = MAX_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + value = + Date.from(tenDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + addExtension( Extension( EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { + expression = "today() - 7 'days'" language = "text/fhirpath" - expression = "today()" }, ), ) - }, - ) - }, - ) - } + }, + ) + }, + ) + } + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType().apply { value = Date() } + } - val answer = - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = DateType() } + val validationResult = + MaxValueValidator.validate(questionnaireItem, answer, context) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } - val validationResult = - MaxValueValidator.validate( - questionnaireItem, - answer, - InstrumentationRegistry.getInstrumentation().context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) - } + assertThat(validationResult.isValid).isFalse() + assertThat(validationResult.errorMessage) + .isEqualTo("Maximum value allowed is:${LocalDate.now().minusDays(7)}") + } - assertThat(validationResult.isValid).isTrue() - assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() - } + @Test + fun `should return valid result and removes constraint for an answer value when maxValue cqf-calculatedValue evaluates to empty`() = + runTest { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + url = MAX_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + extension = + listOf( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + language = "text/fhirpath" + expression = "yesterday()" // invalid FHIRPath expression + }, + ), + ) + }, + ) + }, + ) + } + + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType(Date()) + } + + val validationResult = + MaxValueValidator.validate( + questionnaireItem, + answer, + InstrumentationRegistry.getInstrumentation().context, + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } + + assertThat(validationResult.isValid).isTrue() + assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() + } + + @Test + fun `should return valid result and removes constraint for an answer with an empty value`() = + runTest { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + url = MAX_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + extension = + listOf( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + language = "text/fhirpath" + expression = "today()" + }, + ), + ) + }, + ) + }, + ) + } + + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType() + } + + val validationResult = + MaxValueValidator.validate( + questionnaireItem, + answer, + InstrumentationRegistry.getInstrumentation().context, + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } + + assertThat(validationResult.isValid).isTrue() + assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() + } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt index 359f667bb3..c41ffbe49e 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinLengthValidatorTest.kt @@ -22,6 +22,7 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import java.net.URI import java.text.SimpleDateFormat +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.DecimalType @@ -52,79 +53,79 @@ class MinLengthValidatorTest { } @Test - fun boolean_answerUnderMinLength_shouldReturnInvalidResult() { + fun boolean_answerUnderMinLength_shouldReturnInvalidResult() = runTest { checkAnswerUnderMinLength(minLength = 10, value = BooleanType(false)) } @Test - fun boolean_answerOverMinLength_shouldReturnValidResult() { + fun boolean_answerOverMinLength_shouldReturnValidResult() = runTest { checkAnswerOverMinLength(minLength = 5, value = BooleanType(false)) } @Test - fun decimal_answerUnderMinLength_shouldReturnInvalidResult() { + fun decimal_answerUnderMinLength_shouldReturnInvalidResult() = runTest { checkAnswerUnderMinLength(minLength = 15, value = DecimalType(3.1415926535)) } @Test - fun decimal_answerOverMinLength_shouldReturnValidResult() { + fun decimal_answerOverMinLength_shouldReturnValidResult() = runTest { checkAnswerOverMinLength(minLength = 10, value = DecimalType(3.1415926535)) } @Test - fun int_answerUnderMinLength_shouldReturnInvalidResult() { + fun int_answerUnderMinLength_shouldReturnInvalidResult() = runTest { checkAnswerUnderMinLength(minLength = 5, value = IntegerType(123)) } @Test - fun int_answerOverMinLength_shouldReturnValidResult() { + fun int_answerOverMinLength_shouldReturnValidResult() = runTest { checkAnswerOverMinLength(minLength = 10, value = IntegerType(1234567890)) } @Test - fun dateType_answerUnderMinLength_shouldReturnInvalidResult() { + fun dateType_answerUnderMinLength_shouldReturnInvalidResult() = runTest { val dateFormat = SimpleDateFormat("yyyy-MM-dd") checkAnswerUnderMinLength(minLength = 11, value = DateType(dateFormat.parse("2021-06-01"))) } @Test - fun date_answerOverMinLength_shouldReturnValidResult() { + fun date_answerOverMinLength_shouldReturnValidResult() = runTest { val dateFormat = SimpleDateFormat("yyyy-MM-dd") checkAnswerOverMinLength(minLength = 10, value = DateType(dateFormat.parse("2021-06-01"))) } @Test - fun time_answerUnderMinLength_shouldReturnInvalidResult() { + fun time_answerUnderMinLength_shouldReturnInvalidResult() = runTest { checkAnswerUnderMinLength(minLength = 10, value = TimeType("18:00:59")) } @Test - fun time_answerOverMinLength_shouldReturnValidResult() { + fun time_answerOverMinLength_shouldReturnValidResult() = runTest { checkAnswerOverMinLength(minLength = 5, value = TimeType("18:00:59")) } @Test - fun string_answerUnderMinLength_shouldReturnInvalidResult() { + fun string_answerUnderMinLength_shouldReturnInvalidResult() = runTest { checkAnswerUnderMinLength(minLength = 12, value = StringType("Hello World")) } @Test - fun string_answerOverMinLength_shouldReturnValidResult() { + fun string_answerOverMinLength_shouldReturnValidResult() = runTest { checkAnswerOverMinLength(minLength = 5, value = StringType("Hello World")) } @Test - fun uri_answerUnderMinLength_shouldReturnInvalidResult() { + fun uri_answerUnderMinLength_shouldReturnInvalidResult() = runTest { checkAnswerUnderMinLength(minLength = 21, value = UriType(URI.create("https://www.hl7.org/"))) } @Test - fun uri_answerOverMinLength_shouldReturnValidResult() { + fun uri_answerOverMinLength_shouldReturnValidResult() = runTest { checkAnswerOverMinLength(minLength = 5, value = UriType(URI.create("https://www.hl7.org/"))) } @Test - fun nonPrimitiveUnderMinLength_shouldReturnValidResult() { + fun nonPrimitiveUnderMinLength_shouldReturnValidResult() = runTest { val requirement = Questionnaire.QuestionnaireItemComponent().apply { addExtension( @@ -140,8 +141,8 @@ class MinLengthValidatorTest { } val validationResult = - MaxLengthValidator.validate(requirement, answer, context) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + MaxLengthValidator.validate(requirement, answer, context) { + TestExpressionValueEvaluator.evaluate(requirement, it) } assertThat(validationResult.isValid).isTrue() @@ -153,7 +154,7 @@ class MinLengthValidatorTest { var context: Context = ApplicationProvider.getApplicationContext() @JvmStatic - fun checkAnswerOverMinLength(minLength: Int, value: PrimitiveType<*>) { + suspend fun checkAnswerOverMinLength(minLength: Int, value: PrimitiveType<*>) { val testComponent = createMaxLengthQuestionnaireTestItem(minLength, value) val validationResult = @@ -161,8 +162,8 @@ class MinLengthValidatorTest { testComponent.requirement, testComponent.answer, context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(testComponent.requirement, it) } assertThat(validationResult.isValid).isTrue() @@ -170,7 +171,7 @@ class MinLengthValidatorTest { } @JvmStatic - fun checkAnswerUnderMinLength(minLength: Int, value: PrimitiveType<*>) { + suspend fun checkAnswerUnderMinLength(minLength: Int, value: PrimitiveType<*>) { val testComponent = createMaxLengthQuestionnaireTestItem(minLength, value) val validationResult = @@ -178,8 +179,8 @@ class MinLengthValidatorTest { testComponent.requirement, testComponent.answer, context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(testComponent.requirement, it) } assertThat(validationResult.isValid).isFalse() diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt index 497feb3439..8622e7289e 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueValidatorTest.kt @@ -25,6 +25,7 @@ import com.google.common.truth.Truth.assertThat import java.time.LocalDate import java.time.ZoneId import java.util.Date +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Extension @@ -50,7 +51,7 @@ class MinValueValidatorTest { } @Test - fun `should return invalid result when entered value is less than minValue`() { + fun `should return invalid result when entered value is less than minValue`() = runTest { val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { addExtension( @@ -67,8 +68,8 @@ class MinValueValidatorTest { questionnaireItem, answer, InstrumentationRegistry.getInstrumentation().context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) } assertThat(validationResult.isValid).isFalse() @@ -76,7 +77,7 @@ class MinValueValidatorTest { } @Test - fun `should return valid result when entered value is greater than minValue`() { + fun `should return valid result when entered value is greater than minValue`() = runTest { val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { addExtension( @@ -93,8 +94,8 @@ class MinValueValidatorTest { questionnaireItem, answer, InstrumentationRegistry.getInstrumentation().context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) } assertThat(validationResult.isValid).isTrue() @@ -102,172 +103,179 @@ class MinValueValidatorTest { } @Test - fun `should return valid result when entered value is greater than minValue for cqf calculated expression`() { - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MIN_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - extension = - listOf( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - language = "text/fhirpath" - expression = "today() - 1 'days'" - }, - ), - ) - }, - ) - }, - ) - } + fun `should return valid result when entered value is greater than minValue for cqf calculated expression`() = + runTest { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + url = MIN_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + extension = + listOf( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + language = "text/fhirpath" + expression = "today() - 1 'days'" + }, + ), + ) + }, + ) + }, + ) + } - val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType(Date()) } + val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType(Date()) } - val validationResult = - MinValueValidator.validate( - questionnaireItem, - answer, - InstrumentationRegistry.getInstrumentation().context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) - } + val validationResult = + MinValueValidator.validate( + questionnaireItem, + answer, + InstrumentationRegistry.getInstrumentation().context, + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } - assertThat(validationResult.isValid).isTrue() - assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() - } + assertThat(validationResult.isValid).isTrue() + assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() + } @Test - fun `should return invalid result with correct min allowed value if contains both value and cqf-calculatedValue`() { - val sevenDaysAgo = LocalDate.now().minusDays(7) - - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - this.url = MIN_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - value = - Date.from(sevenDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) - addExtension( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - expression = "today() - 3 'days'" - language = "text/fhirpath" - }, - ), - ) - }, - ) - }, - ) - } - val answer = - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = - DateType().apply { - val fiveDaysAgo = LocalDate.now().minusDays(5) - value = Date.from(fiveDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) - } - } + fun `should return invalid result with correct min allowed value if contains both value and cqf-calculatedValue`() = + runTest { + val sevenDaysAgo = LocalDate.now().minusDays(7) - val validationResult = - MinValueValidator.validate(questionnaireItem, answer, context) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) - } - - assertThat(validationResult.isValid).isFalse() - assertThat(validationResult.errorMessage) - .isEqualTo("Minimum value allowed is:${LocalDate.now().minusDays(3)}") - } - - @Test - fun `should return valid result and removes constraint for an answer value when minValue cqf-calculatedValue evaluates to empty`() { - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MIN_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - extension = - listOf( + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + this.url = MIN_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + value = + Date.from( + sevenDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant(), + ) + addExtension( Extension( EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { + expression = "today() - 3 'days'" language = "text/fhirpath" - expression = "yesterday()" // invalid FHIRPath expression }, ), ) - }, - ) - }, - ) - } - - val twoDaysAgo = - Date.from( - LocalDate.now().minusDays(2).atStartOfDay().atZone(ZoneId.systemDefault()).toInstant(), - ) - val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType(twoDaysAgo) } + }, + ) + }, + ) + } + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = + DateType().apply { + val fiveDaysAgo = LocalDate.now().minusDays(5) + value = + Date.from(fiveDaysAgo.atStartOfDay().atZone(ZoneId.systemDefault()).toInstant()) + } + } - val validationResult = - MinValueValidator.validate( - questionnaireItem, - answer, - InstrumentationRegistry.getInstrumentation().context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) - } + val validationResult = + MinValueValidator.validate(questionnaireItem, answer, context) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } - assertThat(validationResult.isValid).isTrue() - assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() - } + assertThat(validationResult.isValid).isFalse() + assertThat(validationResult.errorMessage) + .isEqualTo("Minimum value allowed is:${LocalDate.now().minusDays(3)}") + } @Test - fun `should return valid result and removes constraint for an answer with an empty value`() { - val questionnaireItem = - Questionnaire.QuestionnaireItemComponent().apply { - addExtension( - Extension().apply { - url = MIN_VALUE_EXTENSION_URL - this.setValue( - DateType().apply { - extension = - listOf( - Extension( - EXTENSION_CQF_CALCULATED_VALUE_URL, - Expression().apply { - language = "text/fhirpath" - expression = "today()" - }, - ), - ) - }, - ) - }, + fun `should return valid result and removes constraint for an answer value when minValue cqf-calculatedValue evaluates to empty`() = + runTest { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + url = MIN_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + extension = + listOf( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + language = "text/fhirpath" + expression = "yesterday()" // invalid FHIRPath expression + }, + ), + ) + }, + ) + }, + ) + } + + val twoDaysAgo = + Date.from( + LocalDate.now().minusDays(2).atStartOfDay().atZone(ZoneId.systemDefault()).toInstant(), ) - } + val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType(twoDaysAgo) } - val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType() } + val validationResult = + MinValueValidator.validate( + questionnaireItem, + answer, + InstrumentationRegistry.getInstrumentation().context, + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } - val validationResult = - MinValueValidator.validate( - questionnaireItem, - answer, - InstrumentationRegistry.getInstrumentation().context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) - } + assertThat(validationResult.isValid).isTrue() + assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() + } - assertThat(validationResult.isValid).isTrue() - assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() - } + @Test + fun `should return valid result and removes constraint for an answer with an empty value`() = + runTest { + val questionnaireItem = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + Extension().apply { + url = MIN_VALUE_EXTENSION_URL + this.setValue( + DateType().apply { + extension = + listOf( + Extension( + EXTENSION_CQF_CALCULATED_VALUE_URL, + Expression().apply { + language = "text/fhirpath" + expression = "today()" + }, + ), + ) + }, + ) + }, + ) + } + + val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType() } + + val validationResult = + MinValueValidator.validate( + questionnaireItem, + answer, + InstrumentationRegistry.getInstrumentation().context, + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } + + assertThat(validationResult.isValid).isTrue() + assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() + } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt index d9f98eefd5..a0219d3f27 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidatorTest.kt @@ -20,6 +20,7 @@ import android.content.Context import android.os.Build import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Extension import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.Questionnaire @@ -43,7 +44,7 @@ class QuestionnaireResponseItemValidatorTest { } @Test - fun `should return valid result`() { + fun `should return valid result`() = runTest { val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { addExtension( @@ -77,15 +78,15 @@ class QuestionnaireResponseItemValidatorTest { questionnaireItem, answers, context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) } assertThat(validationResult).isEqualTo(Valid) } @Test - fun `should validate individual answers and combine results`() { + fun `should validate individual answers and combine results`() = runTest { val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { linkId = "a-question" @@ -121,8 +122,8 @@ class QuestionnaireResponseItemValidatorTest { questionnaireItem, answers, context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) } assertThat(validationResult).isInstanceOf(Invalid::class.java) @@ -132,7 +133,7 @@ class QuestionnaireResponseItemValidatorTest { } @Test - fun `should validate all answers`() { + fun `should validate all answers`() = runTest { val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { type = Questionnaire.QuestionnaireItemType.INTEGER @@ -145,8 +146,8 @@ class QuestionnaireResponseItemValidatorTest { questionnaireItem, answers, context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) } assertThat(validationResult).isInstanceOf(Invalid::class.java) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt index 1613e9f0b3..6baf6f5b98 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/RegexValidatorTest.kt @@ -22,6 +22,7 @@ import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import java.net.URI import java.text.SimpleDateFormat +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.DecimalType @@ -52,37 +53,37 @@ class RegexValidatorTest { } @Test - fun boolean_notMatchingRegex_shouldReturnInvalidResult() { + fun boolean_notMatchingRegex_shouldReturnInvalidResult() = runTest { checkAnswerNotMatchingRegex(regex = "true", value = BooleanType(false)) } @Test - fun boolean_matchingRegex_shouldReturnValidResult() { + fun boolean_matchingRegex_shouldReturnValidResult() = runTest { checkAnswerMatchingRegex(regex = "true", value = BooleanType(true)) } @Test - fun decimal_notMatchingRegex_shouldReturnInvalidResult() { + fun decimal_notMatchingRegex_shouldReturnInvalidResult() = runTest { checkAnswerNotMatchingRegex(regex = "[0-9]+\\.[0-9]+", value = DecimalType(31234)) } @Test - fun decimal_matchingRegex_shouldReturnValidResult() { + fun decimal_matchingRegex_shouldReturnValidResult() = runTest { checkAnswerMatchingRegex(regex = "[0-9]+\\.[0-9]+", value = DecimalType(3.1415926535)) } @Test - fun int_notMatchingRegex_shouldReturnInvalidResult() { + fun int_notMatchingRegex_shouldReturnInvalidResult() = runTest { checkAnswerNotMatchingRegex(regex = "[0-9]+", value = IntegerType(-1)) } @Test - fun int_matchingRegex_shouldReturnValidResult() { + fun int_matchingRegex_shouldReturnValidResult() = runTest { checkAnswerMatchingRegex(regex = "[0-9]+", value = IntegerType(1234567890)) } @Test - fun dateType_notMatchingRegex_shouldReturnInvalidResult() { + fun dateType_notMatchingRegex_shouldReturnInvalidResult() = runTest { val dateFormat = SimpleDateFormat("yyyy-MM-dd") checkAnswerNotMatchingRegex( regex = "[0-9]{2}-[0-9]{2}-[0-9]{2}", @@ -91,7 +92,7 @@ class RegexValidatorTest { } @Test - fun date_matchingRegex_shouldReturnValidResult() { + fun date_matchingRegex_shouldReturnValidResult() = runTest { val dateFormat = SimpleDateFormat("yyyy-MM-dd") checkAnswerMatchingRegex( regex = "[0-9]{4}-[0-9]{2}-[0-9]{2}", @@ -100,17 +101,17 @@ class RegexValidatorTest { } @Test - fun time_matchingRegex_shouldReturnInvalidResult() { + fun time_matchingRegex_shouldReturnInvalidResult() = runTest { checkAnswerNotMatchingRegex(regex = "[0-9]{2}:[0-9]{2}", value = TimeType("18:00:59")) } @Test - fun time_notMatchingRegex_shouldReturnValidResult() { + fun time_notMatchingRegex_shouldReturnValidResult() = runTest { checkAnswerMatchingRegex(regex = "[0-9]{2}:[0-9]{2}:[0-9]{2}", value = TimeType("18:00:59")) } @Test - fun string_notMatchingRegex_shouldReturnInvalidResult() { + fun string_notMatchingRegex_shouldReturnInvalidResult() = runTest { checkAnswerNotMatchingRegex( regex = "^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]", value = StringType("www.hl7.org"), @@ -118,7 +119,7 @@ class RegexValidatorTest { } @Test - fun string_matchingRegex_shouldReturnValidResult() { + fun string_matchingRegex_shouldReturnValidResult() = runTest { checkAnswerMatchingRegex( regex = "^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]", value = StringType("https://www.hl7.org/"), @@ -126,7 +127,7 @@ class RegexValidatorTest { } @Test - fun uri_notMatchingRegex_shouldReturnInvalidResult() { + fun uri_notMatchingRegex_shouldReturnInvalidResult() = runTest { checkAnswerNotMatchingRegex( regex = "[a-z]+", value = UriType(URI.create("https://www.hl7.org/")), @@ -134,7 +135,7 @@ class RegexValidatorTest { } @Test - fun uri_matchingRegex_shouldReturnValidResult() { + fun uri_matchingRegex_shouldReturnValidResult() = runTest { checkAnswerMatchingRegex( regex = "^(https?|ftp|file)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_|]", value = UriType(URI.create("https://www.hl7.org/")), @@ -142,12 +143,12 @@ class RegexValidatorTest { } @Test - fun invalidRegex_shouldReturnValidResult() { + fun invalidRegex_shouldReturnValidResult() = runTest { checkAnswerMatchingRegex("[.*", StringType("http://www.google.com")) } @Test - fun nonPrimitive_notMatchingRegex_shouldReturnValidResult() { + fun nonPrimitive_notMatchingRegex_shouldReturnValidResult() = runTest { val requirement = Questionnaire.QuestionnaireItemComponent().apply { addExtension( @@ -161,8 +162,8 @@ class RegexValidatorTest { QuestionnaireResponseItemAnswerComponent().apply { this.value = Quantity(1234567.89) } val validationResult = - RegexValidator.validate(requirement, response, context) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + RegexValidator.validate(requirement, response, context) { + TestExpressionValueEvaluator.evaluate(requirement, it) } assertThat(validationResult.isValid).isTrue() @@ -174,7 +175,7 @@ class RegexValidatorTest { var context: Context = ApplicationProvider.getApplicationContext() @JvmStatic - fun checkAnswerMatchingRegex(regex: String, value: PrimitiveType<*>) { + suspend fun checkAnswerMatchingRegex(regex: String, value: PrimitiveType<*>) { val testComponent = createRegexQuestionnaireTestItem(regex, value) val validationResult = @@ -182,8 +183,8 @@ class RegexValidatorTest { testComponent.requirement, testComponent.answer, context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(testComponent.requirement, it) } assertThat(validationResult.isValid).isTrue() @@ -191,7 +192,7 @@ class RegexValidatorTest { } @JvmStatic - fun checkAnswerNotMatchingRegex(regex: String, value: PrimitiveType<*>) { + suspend fun checkAnswerNotMatchingRegex(regex: String, value: PrimitiveType<*>) { val testComponent = createRegexQuestionnaireTestItem(regex, value) val validationResult = @@ -199,8 +200,8 @@ class RegexValidatorTest { testComponent.requirement, testComponent.answer, context, - ) { extension, expression -> - CalculatedValueExpressionEvaluator.evaluate(extension.value, expression) + ) { + TestExpressionValueEvaluator.evaluate(testComponent.requirement, it) } assertThat(validationResult.isValid).isFalse() diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/CalculatedValueExpressionEvaluator.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/TestExpressionValueEvaluator.kt similarity index 83% rename from datacapture/src/test/java/com/google/android/fhir/datacapture/validation/CalculatedValueExpressionEvaluator.kt rename to datacapture/src/test/java/com/google/android/fhir/datacapture/validation/TestExpressionValueEvaluator.kt index b96020879b..5cb104fb75 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/CalculatedValueExpressionEvaluator.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/TestExpressionValueEvaluator.kt @@ -17,17 +17,18 @@ package com.google.android.fhir.datacapture.validation import com.google.android.fhir.datacapture.fhirpath.evaluateToBase +import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Type -object CalculatedValueExpressionEvaluator { +object TestExpressionValueEvaluator { /** * Doesn't handle expressions containing FHIRPath supplements * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements */ - fun evaluate(type: Type, expression: Expression): Type? = + fun evaluate(base: Base, expression: Expression): Type? = try { - evaluateToBase(type, expression.expression).singleOrNull() as? Type + evaluateToBase(base, expression.expression).singleOrNull() as? Type } catch (_: Exception) { null } From c0fc3c6116ec5b456a2db60513d6fb3b7667c4ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=E2=89=A1ZRS?= <12814349+LZRS@users.noreply.github.com> Date: Wed, 6 Mar 2024 16:10:42 +0300 Subject: [PATCH 6/6] Update documentation --- .../fhir/datacapture/fhirpath/ExpressionEvaluator.kt | 5 ++++- .../validation/AnswerExtensionConstraintValidator.kt | 4 ++-- .../fhir/datacapture/views/QuestionnaireViewItem.kt | 9 +++++++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt index 2a224a10b6..b637a6bf08 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluator.kt @@ -141,7 +141,10 @@ internal class ExpressionEvaluator( ) } - /** Returns the evaluation result of an expression as a [Type] value */ + /** + * Returns single [Type] evaluation value result of an expression, including cqf-expression and + * cqf-calculatedValue expressions + */ suspend fun evaluateExpressionValue( questionnaireItem: QuestionnaireItemComponent, questionnaireResponseItem: QuestionnaireResponseItemComponent?, diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt index 5e5ed9734d..2d8397b912 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/AnswerExtensionConstraintValidator.kt @@ -53,9 +53,9 @@ internal open class AnswerExtensionConstraintValidator( ): AnswerConstraintValidator.Result { if (questionnaireItem.hasExtension(url)) { val extension = questionnaireItem.getExtensionByUrl(url) - val extensionCalculatedValue = + val extensionValue = extension.value.cqfCalculatedValueExpression?.let { expressionEvaluator(it) } - val extensionValue = extensionCalculatedValue ?: extension.value + ?: extension.value // Only checks constraint if both extension and answer have a value if ( diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt index b97e85b0a2..cd75191115 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireViewItem.kt @@ -29,6 +29,7 @@ import com.google.android.fhir.datacapture.extensions.toSpanned import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid import com.google.android.fhir.datacapture.validation.ValidationResult +import com.google.android.fhir.datacapture.views.factories.QuestionnaireItemViewHolder import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -59,8 +60,12 @@ import org.hl7.fhir.r4.model.Type * @param answersChangedCallback the callback to notify the view model that the answers have been * changed for the [QuestionnaireResponse.QuestionnaireResponseItemComponent] * @param enabledAnswerOptions the enabled answer options in [questionnaireItem] - * @param minAnswerValue the inclusive lower bound on the range of allowed answer values - * @param maxAnswerValue the inclusive upper bound on the range of allowed answer values. + * @param minAnswerValue the inclusive lower bound on the range of allowed answer values, that may + * be used for widgets that check for bounds and change behavior based on the min allowed answer + * value, e.g the Slider widget + * @param maxAnswerValue the inclusive upper bound on the range of allowed answer values, that may + * be used for widgets that check for bounds and change behavior based on the max allowed answer + * value, e.g the Slider widget * @param draftAnswer the draft input that cannot be stored in the [QuestionnaireResponse]. * @param enabledDisplayItems the enabled display items in the given [questionnaireItem] * @param questionViewTextConfiguration configuration to show asterisk, required and optional text