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 abb7e7d49d..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 @@ -37,11 +37,14 @@ import com.google.android.fhir.datacapture.extensions.filterByCodeInNameExtensio import com.google.android.fhir.datacapture.extensions.flattened import com.google.android.fhir.datacapture.extensions.hasDifferentAnswerSet import com.google.android.fhir.datacapture.extensions.isDisplayItem -import com.google.android.fhir.datacapture.extensions.isFhirPath import com.google.android.fhir.datacapture.extensions.isHelpCode 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 @@ -69,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 @@ -591,23 +592,6 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } } - private suspend 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 removeDisabledAnswers( questionnaireItem: QuestionnaireItemComponent, questionnaireResponseItem: QuestionnaireResponseItemComponent, @@ -749,17 +733,25 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat questionnaireItem, questionnaireResponseItem.answer, this@QuestionnaireViewModel.getApplication(), - ) + ) { + expressionEvaluator.evaluateExpressionValue( + questionnaireItem, + questionnaireResponseItem, + it, + ) + } } else { NotValidated } // Set question text dynamically from CQL expression - questionnaireResponseItem.apply { - resolveCqfExpression(questionnaireItem, this, questionnaireItem.textElement) - .firstOrNull() - ?.let { text = it.primitiveValue() } + questionnaireItem.textElement.cqfExpression?.let { expression -> + expressionEvaluator + .evaluateExpressionValue(questionnaireItem, questionnaireResponseItem, expression) + ?.primitiveValue() + ?.let { questionnaireResponseItem.text = it } } + val (enabledQuestionnaireAnswerOptions, disabledQuestionnaireResponseAnswers) = answerOptionsEvaluator.evaluate( questionnaireItem, @@ -786,6 +778,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 d4897c94e9..d1b373b1ee 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 @@ -82,9 +84,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" @@ -294,6 +293,18 @@ val Questionnaire.QuestionnaireItemComponent.sliderStepValue: Int? return null } +internal val Questionnaire.QuestionnaireItemComponent.minValue + get() = getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value + +internal val Questionnaire.QuestionnaireItemComponent.minValueCqfCalculatedValueExpression + get() = getExtensionByUrl(MIN_VALUE_EXTENSION_URL)?.value?.cqfCalculatedValueExpression + +internal val Questionnaire.QuestionnaireItemComponent.maxValue + get() = getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value + +internal val Questionnaire.QuestionnaireItemComponent.maxValueCqfCalculatedValueExpression + get() = getExtensionByUrl(MAX_VALUE_EXTENSION_URL)?.value?.cqfCalculatedValueExpression + // ********************************************************************************************** // // // // Additional display utilities: display item control, localized text spanned, // 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 7ac598930c..bf6a2b00bd 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 @@ -18,7 +18,6 @@ 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 @@ -95,13 +94,13 @@ private fun getDisplayString(type: Type, context: Context): String? = else -> (type as? PrimitiveType<*>)?.valueAsString } +/** + * Returns the string representation for [PrimitiveType] or [Quantity], otherwise defaults to null + */ private fun getValueString(type: Type): String? = when (type) { - is DateType, - is DateTimeType, - is StringType, -> type.asStringValue() - is Quantity -> type.value.toString() - else -> (type as? PrimitiveType<*>)?.valueAsString + is Quantity -> type.value?.toString() + else -> (type as? PrimitiveType<*>)?.asStringValue() } /** Converts StringType to toUriType. */ @@ -132,16 +131,10 @@ internal fun Quantity.toCoding(): Coding { return Coding(this.system, this.code, this.unit) } -fun Type.valueOrCalculateValue(): Type { - return if (this.hasExtension()) { - this.extension - .firstOrNull { it.url == EXTENSION_CQF_CALCULATED_VALUE_URL } - ?.let { extension -> - val expression = (extension.value as Expression).expression - evaluateToBase(this, expression).singleOrNull()?.let { it as Type } - } - ?: this - } else { - this - } -} +internal fun Type.hasValue(): Boolean = !getValueString(this).isNullOrBlank() + +internal val Type.cqfCalculatedValueExpression + 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 e6a1d9b1a8..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,6 +141,27 @@ internal class ExpressionEvaluator( ) } + /** + * Returns single [Type] evaluation value result of an expression, including cqf-expression and + * cqf-calculatedValue expressions + */ + suspend 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/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 ba884217ba..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 @@ -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,10 @@ package com.google.android.fhir.datacapture.validation import android.content.Context +import org.hl7.fhir.r4.model.Expression 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 @@ -35,10 +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, + 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 46928b4435..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 @@ -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,12 @@ package com.google.android.fhir.datacapture.validation import android.content.Context -import org.hl7.fhir.r4.model.Extension +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.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type /** * Validates [QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent] against a constraint @@ -35,18 +38,33 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse internal open class AnswerExtensionConstraintValidator( val url: String, val predicate: - (Extension, QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent) -> Boolean, - val messageGenerator: (Extension, Context) -> String, + ( + /*constraintValue*/ + Type, + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, + ) -> Boolean, + val messageGenerator: (Type, Context) -> String, ) : AnswerConstraintValidator { - override fun validate( + override suspend fun validate( questionnaireItem: Questionnaire.QuestionnaireItemComponent, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, context: Context, + expressionEvaluator: suspend (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 extensionValue = + extension.value.cqfCalculatedValueExpression?.let { expressionEvaluator(it) } + ?: extension.value + + // Only checks constraint if both extension and answer have a value + if ( + extensionValue.hasValue() && answer.value.hasValue() && predicate(extensionValue, answer) + ) { + return AnswerConstraintValidator.Result( + false, + messageGenerator(extensionValue, 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..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 @@ -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, + constraintValue: Type, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, -> - val maxDecimalPlaces = (extension.value as? IntegerType)?.value + val maxDecimalPlaces = (constraintValue 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 = { 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 e04c0f549d..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 @@ -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,10 @@ 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.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. @@ -28,10 +30,11 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse * 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, + 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 ba42e702bf..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 @@ -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,6 @@ 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.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Type @@ -33,22 +30,15 @@ internal object MaxValueValidator : AnswerExtensionConstraintValidator( url = MAX_VALUE_EXTENSION_URL, predicate = { - extension: Extension, + constraintValue: Type, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, -> - answer.value > extension.value?.valueOrCalculateValue()!! + answer.value > constraintValue }, - messageGenerator = { extension: Extension, context: Context -> + messageGenerator = { constraintValue: Type, context: Context -> context.getString( R.string.max_value_validation_error_msg, - extension.value?.valueOrCalculateValue()?.getValueAsString(context), + constraintValue.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/MinLengthValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/MinLengthValidator.kt index 1630cda38c..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 @@ -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 = { constraintValue, answer -> answer.value.isPrimitive && (answer.value as PrimitiveType<*>).asStringValue().length < - (extension.value as IntegerType).value + (constraintValue as IntegerType).value }, - messageGenerator = { extension: Extension, context: Context -> - context.getString(R.string.min_length_validation_error_msg, extension.value.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 ff44447a59..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 @@ -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,6 @@ 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.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Type @@ -33,24 +30,15 @@ internal object MinValueValidator : AnswerExtensionConstraintValidator( url = MIN_VALUE_EXTENSION_URL, predicate = { - extension: Extension, + constraintValue: Type, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, -> - answer.value < extension.value?.valueOrCalculateValue()!! + answer.value < constraintValue }, - messageGenerator = { extension: Extension, context: Context -> + messageGenerator = { constraintValue: Type, context: Context -> context.getString( R.string.min_value_validation_error_msg, - extension.value?.valueOrCalculateValue()?.getValueAsString(context), + constraintValue.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/QuestionnaireResponseItemValidator.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/QuestionnaireResponseItemValidator.kt index 94539af70b..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 @@ -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,10 @@ 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.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type internal object QuestionnaireResponseItemValidator { @@ -41,10 +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, + expressionEvaluator: suspend (Expression) -> Type?, ): ValidationResult { if (questionnaireItem.isHidden) return NotValidated @@ -54,7 +57,9 @@ internal object QuestionnaireResponseItemValidator { } val questionnaireResponseItemAnswerConstraintValidationResult = answerConstraintValidators.flatMap { validator -> - answers.map { answer -> validator.validate(questionnaireItem, answer, context) } + answers.map { answer -> + validator.validate(questionnaireItem, answer, context, expressionEvaluator) + } } 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 9427c38dfa..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 @@ -20,6 +20,7 @@ import android.content.Context import com.google.android.fhir.datacapture.XFhirQueryResolver import com.google.android.fhir.datacapture.enablement.EnablementEvaluator import com.google.android.fhir.datacapture.extensions.packRepeatedGroups +import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Resource @@ -83,6 +84,12 @@ object QuestionnaireResponseValidator { launchContextMap, xFhirQueryResolver, ), + ExpressionEvaluator( + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + launchContextMap, + ), linkIdToValidationResultMap, ) @@ -94,6 +101,7 @@ object QuestionnaireResponseValidator { questionnaireResponseItemList: List, context: Context, enablementEvaluator: EnablementEvaluator, + expressionEvaluator: ExpressionEvaluator, linkIdToValidationResultMap: MutableMap>, ): Map> { val questionnaireItemListIterator = questionnaireItemList.iterator() @@ -121,6 +129,7 @@ object QuestionnaireResponseValidator { questionnaireResponseItem, context, enablementEvaluator, + expressionEvaluator, linkIdToValidationResultMap, ) } @@ -133,6 +142,7 @@ object QuestionnaireResponseValidator { questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent, context: Context, enablementEvaluator: EnablementEvaluator, + expressionEvaluator: ExpressionEvaluator, linkIdToValidationResultMap: MutableMap>, ): Map> { when (checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" }) { @@ -146,6 +156,7 @@ object QuestionnaireResponseValidator { questionnaireResponseItem.item, context, enablementEvaluator, + expressionEvaluator, linkIdToValidationResultMap, ) else -> { @@ -159,6 +170,7 @@ object QuestionnaireResponseValidator { it.item, context, enablementEvaluator, + expressionEvaluator, linkIdToValidationResultMap, ) } @@ -169,7 +181,13 @@ object QuestionnaireResponseValidator { questionnaireItem, questionnaireResponseItem.answer, context, - ), + ) { + expressionEvaluator.evaluateExpressionValue( + questionnaireItem, + questionnaireResponseItem, + 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 abd7840e4b..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 @@ -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, + constraintValue: Type, answer: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent, -> - if (!extension.value.isPrimitive || !answer.value.isPrimitive) { + if (!constraintValue.isPrimitive || !answer.value.isPrimitive) { return@predicate false } try { - val pattern = Pattern.compile((extension.value 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: " + extension.value, e) + Timber.w("Can't parse regex: $constraintValue", e) false } }, - messageGenerator = { extension: Extension, context: Context -> - context.getString(R.string.regex_validation_error_msg, extension.value.primitiveValue()) + messageGenerator = { constraintValue: Type, context: Context -> + context.getString(R.string.regex_validation_error_msg, constraintValue.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 b2ac20c2fd..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 @@ -23,14 +23,18 @@ import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.displayString import com.google.android.fhir.datacapture.extensions.isHelpCode 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 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 import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComponent +import org.hl7.fhir.r4.model.Type /** * Data item for [QuestionnaireItemViewHolder] in [RecyclerView]. @@ -55,12 +59,15 @@ import org.hl7.fhir.r4.model.QuestionnaireResponse.QuestionnaireResponseItemComp * @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 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 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. */ @@ -77,6 +84,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 d715029ccb..8d65eddea2 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 @@ -34,8 +34,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 @@ -168,8 +166,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.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 8a50eead3d..eaec4454e4 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 @@ -24,8 +24,6 @@ import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.extensions.sliderStepValue import com.google.android.fhir.datacapture.extensions.tryUnwrapContext 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 @@ -34,8 +32,8 @@ import com.google.android.fhir.datacapture.views.QuestionnaireViewItem import com.google.android.material.slider.Slider import kotlinx.coroutines.launch 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 = @@ -58,8 +56,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.minAnswerValue) + val maxValue = getMaxValue(questionnaireViewItem.maxAnswerValue) if (minValue >= maxValue) { throw IllegalStateException("minValue $minValue must be smaller than maxValue $maxValue") } @@ -106,15 +104,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 73e0b496ed..6e1974e3a8 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,11 +31,13 @@ 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 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,17 +47,23 @@ 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_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 import com.google.android.fhir.datacapture.extensions.entryMode import com.google.android.fhir.datacapture.extensions.getNestedQuestionnaireResponseItems import com.google.android.fhir.datacapture.extensions.logicalId +import com.google.android.fhir.datacapture.extensions.maxValue 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.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 @@ -4503,13 +4511,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", + CODE_SYSTEM_LAUNCH_CONTEXT, "patient", "Patient", ), @@ -6004,6 +6012,564 @@ 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 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 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" + }, + ), + ) + }, + ), + ) + }, + ) + } + + 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 { + 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 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 return calculated value for maxValue 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( + 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) + assertThat( + (questionnaire.item.single { it.linkId == "a" }.maxValue as DateType).valueAsString, + ) + .isEqualTo(lastLocalDate.toString()) + + 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 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 { + // 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() } + .single { it.questionnaireItem.linkId == "a" } + .setAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType.parseV3("20231014") + }, + ) + + // Checks dependent answer has min value set correctly + viewModel + .getQuestionnaireItemViewItemList() + .map { it.asQuestion() } + .single { it.questionnaireItem.linkId == "b" } + .run { + assertThat((this.minAnswerValue as DateType).valueAsString).isEqualTo("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((this.minAnswerValue as DateType).valueAsString) + .isEqualTo(LocalDate.now().toString()) + } + } + } + + @Test + fun `should correctly evaluate cqf-calculatedValue with expression dependent on x-fhir-query launchContext`() = + runTest { + val testDate = LocalDate.now().minusYears(20) + val questionnaire = + Questionnaire().apply { + addExtension( + Extension(EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT).apply { + addExtension("name", Coding(CODE_SYSTEM_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" + }, + ), + ) + }, + ), + ) + }, + ) + } + + 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() + .map { it.asQuestion() } + .single() + .run { + assertThat((this.minAnswerValue as DateType).valueAsString) + .isEqualTo(testDate.toString()) + } + } + } + + @Test + fun `should correctly validate cqf-calculatedValue with expression dependent on x-fhir-query launchContext`() = + runTest { + val testDate = LocalDate.now().minusYears(20) + + val questionnaire = + Questionnaire().apply { + addExtension( + Extension(EXTENSION_SDC_QUESTIONNAIRE_LAUNCH_CONTEXT).apply { + addExtension("name", Coding(CODE_SYSTEM_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" + }, + ), + ) + }, + ), + ) + }, + ) + } + + 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() + .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"))) + } + } + } + // ==================================================================== // // // // 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 64d845aa5b..3f0e7b28cc 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 @@ -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 @@ -274,46 +270,14 @@ class MoreTypesTest { } @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 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) + fun `getValueAsString should return 'not answered' for an empty Quantity`() { + val quantity = Quantity() + assertThat(quantity.getValueAsString(context)).isEqualTo("Not Answered") } @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) + fun `getValueAsString should return correct value for a Quantity`() { + val quantity = Quantity(20L) + assertThat(quantity.getValueAsString(context)).isEqualTo("20") } } 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")), ) } 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..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 @@ -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. @@ -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,46 +43,57 @@ 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, - ) + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() } @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, - ) + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() } @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, - ) + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } 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 73c49047e6..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 @@ -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,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,86 +52,89 @@ 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 { this.value = Quantity(1234567.89) } - val validationResult = MaxLengthValidator.validate(requirement, answer, context) + val validationResult = + MaxLengthValidator.validate(requirement, answer, context) { + TestExpressionValueEvaluator.evaluate(requirement, it) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -141,11 +145,17 @@ 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 = - MaxLengthValidator.validate(testComponent.requirement, testComponent.answer, context) + MaxLengthValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) { + TestExpressionValueEvaluator.evaluate(testComponent.requirement, it) + } assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage) @@ -155,11 +165,17 @@ class MaxLengthValidatorTest { } @JvmStatic - fun checkAnswerUnderMaxLength(maxLength: Int, value: PrimitiveType<*>) { + suspend fun checkAnswerUnderMaxLength(maxLength: Int, value: PrimitiveType<*>) { val testComponent = createMaxLengthQuestionnaireTestItem(maxLength, value) val validationResult = - MaxLengthValidator.validate(testComponent.requirement, testComponent.answer, context) + MaxLengthValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) { + TestExpressionValueEvaluator.evaluate(testComponent.requirement, it) + } 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..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 @@ -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,10 +19,13 @@ 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.text.SimpleDateFormat 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 @@ -47,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( @@ -62,14 +65,17 @@ class MaxValueValidatorTest { value = IntegerType(200001) } - val validationResult = MaxValueValidator.validate(questionnaireItem, answer, context) + val validationResult = + MaxValueValidator.validate(questionnaireItem, answer, context) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage).isEqualTo("Maximum value allowed is:200000") } @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( @@ -84,36 +90,101 @@ class MaxValueValidatorTest { value = IntegerType(501) } - val validationResult = MaxValueValidator.validate(questionnaireItem, answer, context) + val validationResult = + MaxValueValidator.validate(questionnaireItem, answer, context) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() } @Test - fun `should return maxValue date`() { - val dateType = DateType(SimpleDateFormat("yyyy-MM-dd").parse("2023-06-01")) - val questionItem = - 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 { - url = MAX_VALUE_EXTENSION_URL - this.setValue(dateType) + 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() } + } - assertThat((MaxValueValidator.getMaxValue(questionItem.first()) as? DateType)?.value) - .isEqualTo(dateType.value) - } + val validationResult = + MaxValueValidator.validate(questionnaireItem, answer, context) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } + + assertThat(validationResult.isValid).isFalse() + assertThat(validationResult.errorMessage) + .isEqualTo("Maximum value allowed is:${LocalDate.now().minusDays(7)}") + } @Test - fun `should return today's date when expression evaluates to today`() { - val today = LocalDate.now().toString() - val questionItem = - 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" + }, + ), + ) + }, + ) + }, + ) + } + val answer = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = DateType().apply { value = Date() } + } + + val validationResult = + MaxValueValidator.validate(questionnaireItem, answer, context) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } + + 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`() = + runTest { + val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { addExtension( Extension().apply { @@ -126,7 +197,7 @@ class MaxValueValidatorTest { EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { language = "text/fhirpath" - expression = "today()" + expression = "yesterday()" // invalid FHIRPath expression }, ), ) @@ -134,18 +205,30 @@ class MaxValueValidatorTest { ) }, ) - }, - ) + } - assertThat((MaxValueValidator.getMaxValue(questionItem.first()) as? DateType)?.valueAsString) - .isEqualTo(today) - } + 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 future's date when expression evaluates`() { - val fiveDaysAhead = LocalDate.now().plusDays(5).toString() - val questionItem = - listOf( + fun `should return valid result and removes constraint for an answer with an empty value`() = + runTest { + val questionnaireItem = Questionnaire.QuestionnaireItemComponent().apply { addExtension( Extension().apply { @@ -158,7 +241,7 @@ class MaxValueValidatorTest { EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { language = "text/fhirpath" - expression = "today() + 5 'days' " + expression = "today()" }, ), ) @@ -166,10 +249,23 @@ class MaxValueValidatorTest { ) }, ) - }, - ) + } - assertThat((MaxValueValidator.getMaxValue(questionItem.first()) as? DateType)?.valueAsString) - .isEqualTo(fiveDaysAhead) - } + 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 3c14cee33b..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 @@ -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,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( @@ -139,7 +140,10 @@ class MinLengthValidatorTest { this.value = Quantity(1234567.89) } - val validationResult = MaxLengthValidator.validate(requirement, answer, context) + val validationResult = + MaxLengthValidator.validate(requirement, answer, context) { + TestExpressionValueEvaluator.evaluate(requirement, it) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -150,22 +154,34 @@ 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 = - MinLengthValidator.validate(testComponent.requirement, testComponent.answer, context) + MinLengthValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) { + TestExpressionValueEvaluator.evaluate(testComponent.requirement, it) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() } @JvmStatic - fun checkAnswerUnderMinLength(minLength: Int, value: PrimitiveType<*>) { + suspend fun checkAnswerUnderMinLength(minLength: Int, value: PrimitiveType<*>) { val testComponent = createMaxLengthQuestionnaireTestItem(minLength, value) val validationResult = - MinLengthValidator.validate(testComponent.requirement, testComponent.answer, context) + MinLengthValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) { + TestExpressionValueEvaluator.evaluate(testComponent.requirement, it) + } 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..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 @@ -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,16 +22,16 @@ 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 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 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 @@ -51,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( @@ -68,14 +68,16 @@ class MinValueValidatorTest { questionnaireItem, answer, InstrumentationRegistry.getInstrumentation().context, - ) + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage).isEqualTo("Minimum value allowed is:10") } @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( @@ -92,106 +94,109 @@ class MinValueValidatorTest { questionnaireItem, answer, InstrumentationRegistry.getInstrumentation().context, - ) + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() } @Test - fun `should return invalid result when entered value is less 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'" - }, - ), - ) - }, - ) - }, - ) - } - 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 } + 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 validationResult = - MinValueValidator.validate( - questionnaireItem, - 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") - } + val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType(Date()) } + + val validationResult = + MinValueValidator.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 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( + 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 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 = "today() - 1 'days'" }, ), ) - }, - ) - }, - ) - } - - val answer = QuestionnaireResponseItemAnswerComponent().apply { value = DateType(Date()) } + }, + ) + }, + ) + } + 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, - ) + 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 today's date when expression evaluates to today`() { - val today = LocalDate.now().toString() - val questionItem = - listOf( + 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 { @@ -204,7 +209,7 @@ class MinValueValidatorTest { EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { language = "text/fhirpath" - expression = "today()" + expression = "yesterday()" // invalid FHIRPath expression }, ), ) @@ -212,28 +217,65 @@ class MinValueValidatorTest { ) }, ) - }, - ) - assertThat((MinValueValidator.getMinValue(questionItem.first()) as? DateType)?.valueAsString) - .isEqualTo(today) - } + } + + 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, + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } + + assertThat(validationResult.isValid).isTrue() + assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() + } @Test - fun `should return minValue date`() { - val dateType = DateType(SimpleDateFormat("yyyy-MM-dd").parse("2021-06-01")) - val questionItem = - listOf( + 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) + 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)?.value) - .isEqualTo(dateType.value) - } + 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 dbfff75c12..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 @@ -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. @@ -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( @@ -73,13 +74,19 @@ class QuestionnaireResponseItemValidatorTest { ) val validationResult = - QuestionnaireResponseItemValidator.validate(questionnaireItem, answers, context) + QuestionnaireResponseItemValidator.validate( + questionnaireItem, + answers, + context, + ) { + 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" @@ -111,7 +118,13 @@ class QuestionnaireResponseItemValidatorTest { ) val validationResult = - QuestionnaireResponseItemValidator.validate(questionnaireItem, answers, context) + QuestionnaireResponseItemValidator.validate( + questionnaireItem, + answers, + context, + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } assertThat(validationResult).isInstanceOf(Invalid::class.java) val invalidValidationResult = validationResult as Invalid @@ -120,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 @@ -129,7 +142,13 @@ class QuestionnaireResponseItemValidatorTest { val answers = listOf() val validationResult = - QuestionnaireResponseItemValidator.validate(questionnaireItem, answers, context) + QuestionnaireResponseItemValidator.validate( + questionnaireItem, + answers, + context, + ) { + TestExpressionValueEvaluator.evaluate(questionnaireItem, it) + } 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..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 @@ -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,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( @@ -160,7 +161,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) { + TestExpressionValueEvaluator.evaluate(requirement, it) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() @@ -171,22 +175,34 @@ 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 = - RegexValidator.validate(testComponent.requirement, testComponent.answer, context) + RegexValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) { + TestExpressionValueEvaluator.evaluate(testComponent.requirement, it) + } assertThat(validationResult.isValid).isTrue() assertThat(validationResult.errorMessage.isNullOrBlank()).isTrue() } @JvmStatic - fun checkAnswerNotMatchingRegex(regex: String, value: PrimitiveType<*>) { + suspend fun checkAnswerNotMatchingRegex(regex: String, value: PrimitiveType<*>) { val testComponent = createRegexQuestionnaireTestItem(regex, value) val validationResult = - RegexValidator.validate(testComponent.requirement, testComponent.answer, context) + RegexValidator.validate( + testComponent.requirement, + testComponent.answer, + context, + ) { + TestExpressionValueEvaluator.evaluate(testComponent.requirement, it) + } assertThat(validationResult.isValid).isFalse() assertThat(validationResult.errorMessage) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/TestExpressionValueEvaluator.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/TestExpressionValueEvaluator.kt new file mode 100644 index 0000000000..5cb104fb75 --- /dev/null +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/TestExpressionValueEvaluator.kt @@ -0,0 +1,35 @@ +/* + * 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.Base +import org.hl7.fhir.r4.model.Expression +import org.hl7.fhir.r4.model.Type + +object TestExpressionValueEvaluator { + /** + * Doesn't handle expressions containing FHIRPath supplements + * https://build.fhir.org/ig/HL7/sdc/expressions.html#fhirpath-supplements + */ + fun evaluate(base: Base, expression: Expression): Type? = + try { + evaluateToBase(base, expression.expression).singleOrNull() as? Type + } catch (_: Exception) { + null + } +}