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] 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)