diff --git a/catalog/src/main/assets/calculated_expression_questionnaire.json b/catalog/src/main/assets/calculated_expression_questionnaire.json new file mode 100644 index 0000000000..3097ed0701 --- /dev/null +++ b/catalog/src/main/assets/calculated_expression_questionnaire.json @@ -0,0 +1,36 @@ +{ + "resourceType": "Questionnaire", + "item": [ + { + "linkId": "a-birthdate", + "text": "Birth Date", + "type": "date", + "extension": [ + { + "url": "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression", + "valueExpression": { + "language": "text/fhirpath", + "expression": "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" + } + } + ] + }, + { + "linkId": "a-age-years", + "text": "Age years", + "type": "quantity", + "initial": [{ + "valueQuantity": { + "unit": "years", + "system": "http://unitsofmeasure.org", + "code": "years" + } + }] + }, + { + "linkId": "a-age-acknowledge", + "text": "Input age to automatically calculate birthdate until birthdate is updated manually", + "type": "display" + } + ] +} \ No newline at end of file diff --git a/catalog/src/main/java/com/google/android/fhir/catalog/BehaviorListViewModel.kt b/catalog/src/main/java/com/google/android/fhir/catalog/BehaviorListViewModel.kt index 57022cc977..71bdd0267f 100644 --- a/catalog/src/main/java/com/google/android/fhir/catalog/BehaviorListViewModel.kt +++ b/catalog/src/main/java/com/google/android/fhir/catalog/BehaviorListViewModel.kt @@ -33,7 +33,11 @@ class BehaviorListViewModel(application: Application) : AndroidViewModel(applica val questionnaireFileName: String, val workFlow: WorkflowType = WorkflowType.DEFAULT ) { - CALCULATIONS(R.drawable.ic_calculations_behavior, R.string.behavior_name_calculation, ""), + CALCULATED_EXPRESSION( + R.drawable.ic_calculations_behavior, + R.string.behavior_name_calculated_expression, + "calculated_expression_questionnaire.json" + ), SKIP_LOGIC(R.drawable.ic_skiplogic_behavior, R.string.behavior_name_skip_logic, "") } } diff --git a/catalog/src/main/res/values/strings.xml b/catalog/src/main/res/values/strings.xml index b4f54482e3..8409df7050 100644 --- a/catalog/src/main/res/values/strings.xml +++ b/catalog/src/main/res/values/strings.xml @@ -35,8 +35,10 @@ Paginated Review Read only - Calculation Skip logic + Calculated Expression Structured data capture \n Catalog = + ActivityScenarioRule(TestActivity::class.java) + + private lateinit var parent: FrameLayout + private lateinit var viewHolder: QuestionnaireItemViewHolder + + @Before + fun setup() { + activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } + viewHolder = QuestionnaireItemEditTextQuantityViewHolderFactory.create(parent) + setTestLayout(viewHolder.itemView) + } + + @Test + fun getValue_WithInitial_shouldReturn_Quantity_With_UnitAndSystem() { + val questionnaireItemViewItem = + QuestionnaireItemViewItem( + Questionnaire.QuestionnaireItemComponent().apply { + required = true + addInitial( + Questionnaire.QuestionnaireItemInitialComponent( + Quantity().apply { + code = "months" + system = "http://unitofmeasure.com" + } + ) + ) + }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _ -> }, + ) + runOnUI { viewHolder.bind(questionnaireItemViewItem) } + + onView(withId(R.id.text_input_edit_text)).perform(click()) + onView(withId(R.id.text_input_edit_text)).perform(typeText("22")) + assertThat( + viewHolder.itemView.findViewById(R.id.text_input_edit_text).text.toString() + ) + .isEqualTo("22") + + val responseValue = questionnaireItemViewItem.answers.first().valueQuantity + assertThat(responseValue.code).isEqualTo("months") + assertThat(responseValue.system).isEqualTo("http://unitofmeasure.com") + assertThat(responseValue.value).isEqualTo(BigDecimal(22)) + } + + @Test + fun getValue_WithoutInitial_shouldReturn_Quantity_Without_UnitAndSystem() { + val questionnaireItemViewItem = + QuestionnaireItemViewItem( + Questionnaire.QuestionnaireItemComponent().apply { required = true }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = NotValidated, + answersChangedCallback = { _, _, _ -> }, + ) + runOnUI { viewHolder.bind(questionnaireItemViewItem) } + + onView(withId(R.id.text_input_edit_text)).perform(click()) + onView(withId(R.id.text_input_edit_text)).perform(typeText("22")) + assertThat( + viewHolder.itemView.findViewById(R.id.text_input_edit_text).text.toString() + ) + .isEqualTo("22") + + val responseValue = questionnaireItemViewItem.answers.first().valueQuantity + assertThat(responseValue.code).isNull() + assertThat(responseValue.system).isNull() + assertThat(responseValue.value).isEqualTo(BigDecimal(22)) + } + + /** Method to run code snippet on UI/main thread */ + private fun runOnUI(action: () -> Unit) { + activityScenarioRule.scenario.onActivity { action() } + } + + /** Method to set content view for test activity */ + private fun setTestLayout(view: View) { + activityScenarioRule.scenario.onActivity { activity -> activity.setContentView(view) } + InstrumentationRegistry.getInstrumentation().waitForIdleSync() + } +} diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponents.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponents.kt index aae6da2370..6bf60c1af5 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponents.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponents.kt @@ -64,6 +64,9 @@ internal const val EXTENSION_ITEM_CONTROL_SYSTEM = "http://hl7.org/fhir/question internal const val EXTENSION_HIDDEN_URL = "http://hl7.org/fhir/StructureDefinition/questionnaire-hidden" +internal const val EXTENSION_CALCULATED_EXPRESSION_URL = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-calculatedExpression" + internal const val EXTENSION_ENTRY_FORMAT_URL = "http://hl7.org/fhir/StructureDefinition/entryFormat" @@ -83,6 +86,9 @@ internal const val EXTENSION_CHOICE_COLUMN_URL: String = internal const val EXTENSION_VARIABLE_URL = "http://hl7.org/fhir/StructureDefinition/variable" +internal const val EXTENSION_CQF_CALCULATED_VALUE_URL: String = + "http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue" + internal const val EXTENSION_SLIDER_STEP_VALUE_URL = "http://hl7.org/fhir/StructureDefinition/questionnaire-sliderStepValue" @@ -104,8 +110,32 @@ internal fun Questionnaire.QuestionnaireItemComponent.findVariableExpression( return variableExpressions.find { it.name == variableName } } -internal const val CQF_CALCULATED_EXPRESSION_URL: String = - "http://hl7.org/fhir/StructureDefinition/cqf-calculatedValue" +/** Returns Calculated expression, or null */ +internal val Questionnaire.QuestionnaireItemComponent.calculatedExpression: Expression? + get() = + this.getExtensionByUrl(EXTENSION_CALCULATED_EXPRESSION_URL)?.let { + it.castToExpression(it.value) + } + +/** Returns list of extensions whose value is of type [Expression] */ +internal val Questionnaire.QuestionnaireItemComponent.expressionBasedExtensions + get() = this.extension.filter { it.value is Expression } + +/** + * Whether [item] has any expression directly referencing the current questionnaire item by link ID + * (e.g. if [item] has an expression `%resource.item.where(linkId='this-question')` where + * `this-question` is the link ID of the current questionnaire item). + */ +internal fun Questionnaire.QuestionnaireItemComponent.isReferencedBy( + item: Questionnaire.QuestionnaireItemComponent +) = + item.expressionBasedExtensions.any { + it + .castToExpression(it.value) + .expression + .replace(" ", "") + .contains(Regex(".*linkId='${this.linkId}'.*")) + } // Item control code, or null internal val Questionnaire.QuestionnaireItemComponent.itemControl: ItemControlTypes? @@ -358,7 +388,12 @@ val Questionnaire.QuestionnaireItemComponent.enableWhenExpression: Expression? */ private fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItemAnswers(): MutableList? { - if (initial.isEmpty()) { + // https://build.fhir.org/ig/HL7/sdc/behavior.html#initial + // quantity given as initial without value is for unit reference purpose only. Answer conversion + // not needed + if (initial.isEmpty() || + (initialFirstRep.hasValueQuantity() && initialFirstRep.valueQuantity.value == null) + ) { return null } @@ -481,6 +516,15 @@ internal fun Questionnaire.QuestionnaireItemComponent.extractAnswerOptions( }.map { Questionnaire.QuestionnaireItemAnswerOptionComponent(it) } } +/** + * Flatten a nested list of [Questionnaire.QuestionnaireItemComponent] recursively and returns a + * flat list of all items into list embedded at any level + */ +fun List.flattened(): + List { + return this + this.flatMap { it.item.flattened() } +} + /** * Creates a list of [QuestionnaireResponse.QuestionnaireResponseItemComponent]s from the nested * items in the [Questionnaire.QuestionnaireItemComponent]. diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/MoreQuestionnaireResponseItemAnswerComponent.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/MoreQuestionnaireResponseItemAnswerComponent.kt index d97db0d0e7..2b015d90da 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/MoreQuestionnaireResponseItemAnswerComponent.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/MoreQuestionnaireResponseItemAnswerComponent.kt @@ -33,6 +33,7 @@ import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.model.TimeType +import org.hl7.fhir.r4.model.Type import org.hl7.fhir.r4.model.UriType /** @@ -73,3 +74,8 @@ internal fun QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent.disp else -> context.getString(R.string.not_answered) } } + +internal fun List + .hasDifferentAnswerSet(answers: List) = + this.size != answers.size || + this.map { it.value }.zip(answers).any { (v1, v2) -> v1.equalsDeep(v2).not() } 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 9f4a4de69b..8e41033d09 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/QuestionnaireViewModel.kt @@ -27,6 +27,8 @@ import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.datacapture.enablement.EnablementEvaluator +import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.detectExpressionCyclicDependency +import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.evaluateCalculatedExpressions import com.google.android.fhir.datacapture.utilities.fhirPathEngine import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.QuestionnaireResponseItemValidator @@ -274,8 +276,10 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat if (questionnaireItem.hasNestedItemsWithinAnswers) { questionnaireResponseItem.addNestedItemsToAnswer(questionnaireItem) } - modifiedQuestionnaireResponseItemSet.add(questionnaireResponseItem) + + updateDependentQuestionnaireResponseItems(questionnaireItem) + modificationCount.update { it + 1 } } @@ -377,12 +381,50 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat SharingStarted.Lazily, initialValue = getQuestionnaireState( - questionnaireItemList = questionnaire.item, - questionnaireResponseItemList = questionnaireResponse.item, - currentPageIndex = getInitialPageIndex(), - reviewMode = enableReviewPage - ) + questionnaireItemList = questionnaire.item, + questionnaireResponseItemList = questionnaireResponse.item, + currentPageIndex = getInitialPageIndex(), + reviewMode = enableReviewPage + ) + .also { detectExpressionCyclicDependency(questionnaire.item) } + .also { + questionnaire.item.flattened().forEach { + updateDependentQuestionnaireResponseItems(it) + } + } + ) + + fun updateDependentQuestionnaireResponseItems( + updatedQuestionnaireItem: Questionnaire.QuestionnaireItemComponent + ) { + evaluateCalculatedExpressions( + updatedQuestionnaireItem, + questionnaire, + questionnaireResponse, + questionnaireItemParentMap ) + .forEach { (questionnaireItem, calculatedAnswers) -> + // update all response item with updated values + questionnaireResponseItemPreOrderList + // Item answer should not be modified and touched by user; + // https://build.fhir.org/ig/HL7/sdc/StructureDefinition-sdc-questionnaire-calculatedExpression.html + .filter { + it.linkId == questionnaireItem.linkId && + !modifiedQuestionnaireResponseItemSet.contains(it) + } + .forEach { questionnaireResponseItem -> + // update and notify only if new answer has changed to prevent any event loop + if (questionnaireResponseItem.answer.hasDifferentAnswerSet(calculatedAnswers)) { + questionnaireResponseItem.answer = + calculatedAnswers.map { + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = it + } + } + } + } + } + } @PublishedApi internal suspend fun resolveAnswerValueSet( 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 95d21251d1..93bd9d45f6 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 @@ -18,7 +18,10 @@ package com.google.android.fhir.datacapture.fhirpath import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum +import com.google.android.fhir.datacapture.calculatedExpression import com.google.android.fhir.datacapture.findVariableExpression +import com.google.android.fhir.datacapture.flattened +import com.google.android.fhir.datacapture.isReferencedBy import com.google.android.fhir.datacapture.variableExpressions import org.hl7.fhir.exceptions.FHIRException import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext @@ -26,6 +29,7 @@ import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.hl7.fhir.r4.model.Type import org.hl7.fhir.r4.utils.FHIRPathEngine import timber.log.Timber @@ -61,6 +65,73 @@ object ExpressionEvaluator { } } + /** Detects if any item into list is referencing a dependent item in its calculated expression */ + internal fun detectExpressionCyclicDependency( + items: List + ) { + items + .flattened() + .filter { it.calculatedExpression != null } + .run { + forEach { current -> + // no calculable item depending on current item should be used as dependency into current + // item + this.forEach { dependent -> + check(!(current.isReferencedBy(dependent) && dependent.isReferencedBy(current))) { + "${current.linkId} and ${dependent.linkId} have cyclic dependency in expression based extension" + } + } + } + } + } + + /** + * 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 + */ + fun evaluateCalculatedExpressions( + updatedQuestionnaireItem: Questionnaire.QuestionnaireItemComponent, + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + questionnaireItemParentMap: + Map + ): List { + return questionnaire.item + .flattened() + .filter { item -> + // Condition 1. item is calculable + // Condition 2. item answer depends on the updated item answer OR has a variable dependency + item.calculatedExpression != null && + (updatedQuestionnaireItem.isReferencedBy(item) || + findDependentVariables(item.calculatedExpression!!).isNotEmpty()) + } + .map { questionnaireItem -> + val appContext = + mutableMapOf().apply { + extractDependentVariables( + questionnaireItem.calculatedExpression!!, + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + questionnaireItem, + this + ) + } + + val updatedAnswer = + fhirPathEngine + .evaluate( + appContext, + questionnaireResponse, + null, + null, + questionnaireItem.calculatedExpression!!.expression + ) + .map { it.castToType(it) } + questionnaireItem to updatedAnswer + } + } + /** * Evaluates variable expression defined at questionnaire item level and returns the evaluated * result. @@ -98,7 +169,40 @@ object ExpressionEvaluator { it.name == expression.name && it.expression == expression.expression } ) { "The expression should come from the same questionnaire item" } + extractDependentVariables( + expression, + questionnaire, + questionnaireResponse, + questionnaireItemParentMap, + questionnaireItem, + variablesMap + ) + + return evaluateVariable(expression, questionnaireResponse, variablesMap) + } + /** + * Parses the expression using regex [Regex] for variable and build a map of variables and its + * values respecting the scope and hierarchy level + * + * @param expression the [Expression] expression to find variables applicable + * @param questionnaire the [Questionnaire] respective questionnaire + * @param questionnaireResponse the [QuestionnaireResponse] respective questionnaire response + * @param questionnaireItemParentMap the [Map] of child to parent + * @param questionnaireItem the [Questionnaire.QuestionnaireItemComponent] where this expression + * @param variablesMap the [Map] of variables, the default value is empty map is + * defined + */ + internal fun extractDependentVariables( + expression: Expression, + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + questionnaireItemParentMap: + Map, + questionnaireItem: Questionnaire.QuestionnaireItemComponent, + variablesMap: MutableMap = mutableMapOf() + ) = findDependentVariables(expression).forEach { variableName -> if (variablesMap[variableName] == null) { findAndEvaluateVariable( @@ -112,9 +216,6 @@ object ExpressionEvaluator { } } - return evaluateVariable(expression, questionnaireResponse, variablesMap) - } - /** * Evaluates variable expression defined at questionnaire level and returns the evaluated result. * @@ -156,10 +257,11 @@ object ExpressionEvaluator { } private fun findDependentVariables(expression: Expression) = - variableRegex.findAll(expression.expression).map { it.groupValues[1] }.toList().filterNot { - variable -> - reservedVariables.contains(variable) - } + variableRegex + .findAll(expression.expression) + .map { it.groupValues[1] } + .toList() + .filterNot { variable -> reservedVariables.contains(variable) } /** * Finds the dependent variables at questionnaire item level first, then in ancestors and then at @@ -276,3 +378,6 @@ object ExpressionEvaluator { null } } + +/** Pair of a [Questionnaire.QuestionnaireItemComponent] with its evaluated answers */ +internal typealias ItemToAnswersPair = Pair> diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt index 554da373b9..5616f33bf2 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/mapping/ResourceMapper.kt @@ -16,6 +16,8 @@ package com.google.android.fhir.datacapture.mapping +import ca.uhn.fhir.context.FhirContext +import ca.uhn.fhir.context.FhirVersionEnum import com.google.android.fhir.datacapture.DataCapture import com.google.android.fhir.datacapture.createQuestionnaireResponseItem import com.google.android.fhir.datacapture.targetStructureMap @@ -29,6 +31,7 @@ import java.lang.reflect.Method import java.lang.reflect.ParameterizedType import java.util.Locale import org.hl7.fhir.r4.context.IWorkerContext +import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.CodeType @@ -48,6 +51,7 @@ import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.model.Type import org.hl7.fhir.r4.model.UriType +import org.hl7.fhir.r4.utils.FHIRPathEngine import org.hl7.fhir.r4.utils.StructureMapUtilities /** @@ -70,6 +74,12 @@ import org.hl7.fhir.r4.utils.StructureMapUtilities * for more information. */ object ResourceMapper { + + private val fhirPathEngine: FHIRPathEngine = + with(FhirContext.forCached(FhirVersionEnum.R4)) { + FHIRPathEngine(HapiWorkerContext(this, this.validationSupport)) + } + /** * Extract FHIR resources from a [questionnaire] and [questionnaireResponse]. * diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/ValidationUtil.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/ValidationUtil.kt index 0c25082ee6..4039028db3 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/ValidationUtil.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/validation/ValidationUtil.kt @@ -19,7 +19,7 @@ package com.google.android.fhir.datacapture.validation import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.context.support.DefaultProfileValidationSupport -import com.google.android.fhir.datacapture.CQF_CALCULATED_EXPRESSION_URL +import com.google.android.fhir.datacapture.EXTENSION_CQF_CALCULATED_VALUE_URL import org.hl7.fhir.r4.hapi.ctx.HapiWorkerContext import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Type @@ -27,10 +27,12 @@ import org.hl7.fhir.r4.utils.FHIRPathEngine fun Type.valueOrCalculateValue(): Type? { return if (this.hasExtension()) { - this.extension.firstOrNull { it.url == CQF_CALCULATED_EXPRESSION_URL }?.let { - val expression = (it.value as Expression).expression - fhirPathEngine.evaluate(this, expression).firstOrNull()?.let { it as Type } - } + this.extension + .firstOrNull { it.url == EXTENSION_CQF_CALCULATED_VALUE_URL } + ?.let { + val expression = (it.value as Expression).expression + fhirPathEngine.evaluate(this, expression).firstOrNull()?.let { it as Type } + } } else { this } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactory.kt index 1b2c697116..fccc63e6f5 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactory.kt @@ -110,6 +110,7 @@ internal object QuestionnaireItemDatePickerViewHolderFactory : header.bind(questionnaireItemViewItem.questionnaireItem) textInputLayout.hint = localePattern textInputEditText.removeTextChangedListener(textWatcher) + if (isTextUpdateRequired( textInputEditText.context, questionnaireItemViewItem.answers.singleOrNull()?.valueDateType, @@ -117,7 +118,9 @@ internal object QuestionnaireItemDatePickerViewHolderFactory : ) ) { textInputEditText.setText( - questionnaireItemViewItem.answers.singleOrNull() + questionnaireItemViewItem.answers + .singleOrNull() + ?.takeIf { it.hasValue() } ?.valueDateType ?.localDate ?.localizedString @@ -129,7 +132,8 @@ internal object QuestionnaireItemDatePickerViewHolderFactory : override fun displayValidationResult(validationResult: ValidationResult) { textInputLayout.error = when (validationResult) { - is NotValidated, Valid -> null + is NotValidated, + Valid -> null is Invalid -> validationResult.getSingleStringValidationMessage() } } @@ -141,8 +145,7 @@ internal object QuestionnaireItemDatePickerViewHolderFactory : private fun createMaterialDatePicker(): MaterialDatePicker { val selectedDate = - questionnaireItemViewItem - .answers + questionnaireItemViewItem.answers .singleOrNull() ?.valueDateType ?.localDate @@ -230,11 +233,13 @@ fun Context.tryUnwrapContext(): AppCompatActivity? { internal val DateType.localDate get() = - LocalDate.of( - year, - month + 1, - day, - ) + if (!this.hasValue()) null + else + LocalDate.of( + year, + month + 1, + day, + ) internal val LocalDate.dateType get() = DateType(year, monthValue - 1, dayOfMonth) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactory.kt index 534a37beb4..58a63ddf9d 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactory.kt @@ -18,6 +18,7 @@ package com.google.android.fhir.datacapture.views import android.text.InputType import com.google.android.fhir.datacapture.R +import java.math.BigDecimal import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.QuestionnaireResponse @@ -34,9 +35,26 @@ internal object QuestionnaireItemEditTextQuantityViewHolderFactory : override fun getValue( text: String ): QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent? { - return text.toDoubleOrNull()?.let { - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().setValue(Quantity(it)) - } + // https://build.fhir.org/ig/HL7/sdc/behavior.html#initial + // read default unit from initial, as ideally quantity must specify a unit + return text + .takeIf { it.isNotBlank() } + ?.let { + val value = BigDecimal(text) + val quantity = + with(questionnaireItemViewItem.questionnaireItem) { + if (this.hasInitial() && this.initialFirstRep.valueQuantity.hasCode()) + this.initialFirstRep.valueQuantity.let { initial -> + Quantity().apply { + this.value = value + this.code = initial.code + this.system = initial.system + } + } + else Quantity().apply { this.value = value } + } + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().setValue(quantity) + } } override fun getText( diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsTest.kt index b0618e810f..ca4ab3248d 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsTest.kt @@ -19,6 +19,7 @@ package com.google.android.fhir.datacapture import android.os.Build import com.google.android.fhir.datacapture.mapping.ITEM_INITIAL_EXPRESSION_URL import com.google.common.truth.Truth.assertThat +import java.math.BigDecimal import java.util.Locale import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.CodeType @@ -29,6 +30,7 @@ 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.Patient +import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.utils.ToolingExtensions @@ -1062,6 +1064,115 @@ class MoreQuestionnaireItemComponentsTest { assertThat(questionItem.itemFirstRep.enableWhenExpression).isNull() } + @Test + fun `calculatedExpression should return expression for valid extension url`() { + val item = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + EXTENSION_CALCULATED_EXPRESSION_URL, + Expression().apply { + this.expression = "today()" + this.language = "text/fhirpath" + } + ) + } + assertThat(item.calculatedExpression).isNotNull() + assertThat(item.calculatedExpression!!.expression).isEqualTo("today()") + } + + @Test + fun `calculatedExpression should return null for other extension url`() { + val item = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + this.expression = "today()" + this.language = "text/fhirpath" + } + ) + } + assertThat(item.calculatedExpression).isNull() + } + + @Test + fun `expressionBasedExtensions should return all extension of type expression`() { + val item = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension(EXTENSION_HIDDEN_URL, BooleanType(true)) + addExtension( + EXTENSION_CALCULATED_EXPRESSION_URL, + Expression().apply { + this.expression = "today()" + this.language = "text/fhirpath" + } + ) + addExtension( + EXTENSION_ENABLE_WHEN_EXPRESSION_URL, + Expression().apply { + this.expression = "%resource.status == 'draft'" + this.language = "text/fhirpath" + } + ) + } + + val result = item.expressionBasedExtensions + + assertThat(result.count()).isEqualTo(2) + assertThat(result.first().url).isEqualTo(EXTENSION_CALCULATED_EXPRESSION_URL) + assertThat(result.last().url).isEqualTo(EXTENSION_ENABLE_WHEN_EXPRESSION_URL) + } + + @Test + fun `isReferencedBy should return true`() { + val item1 = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "A" + addExtension( + EXTENSION_CALCULATED_EXPRESSION_URL, + Expression().apply { + this.expression = "%resource.item.where(linkId='B')" + this.language = "text/fhirpath" + } + ) + } + val item2 = Questionnaire.QuestionnaireItemComponent().apply { linkId = "B" } + assertThat(item2.isReferencedBy(item1)).isTrue() + } + + @Test + fun `isReferencedBy should return false`() { + val item1 = + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "A" + addExtension( + EXTENSION_CALCULATED_EXPRESSION_URL, + Expression().apply { + this.expression = "%resource.item.where(answer.value.empty())" + this.language = "text/fhirpath" + } + ) + } + val item2 = Questionnaire.QuestionnaireItemComponent().apply { linkId = "B" } + assertThat(item2.isReferencedBy(item1)).isFalse() + } + + @Test + fun `flattened should return linear list`() { + val items = + listOf( + Questionnaire.QuestionnaireItemComponent().apply { linkId = "A" }, + Questionnaire.QuestionnaireItemComponent() + .apply { linkId = "B" } + .addItem( + Questionnaire.QuestionnaireItemComponent() + .apply { linkId = "C" } + .addItem(Questionnaire.QuestionnaireItemComponent().apply { linkId = "D" }) + ) + ) + assertThat(items.flattened().map { it.linkId }).containsExactly("A", "B", "C", "D") + } + @Test fun localizedFlyoverSpanned_matchingLocale_shouldReturnFlyover() { val questionItemList = @@ -1256,6 +1367,62 @@ class MoreQuestionnaireItemComponentsTest { .isEqualTo(true) } + @Test + fun `createQuestionResponse should not set answer for quantity type with missing value`() { + val question = + Questionnaire.QuestionnaireItemComponent( + StringType("age"), + Enumeration( + Questionnaire.QuestionnaireItemTypeEnumFactory(), + Questionnaire.QuestionnaireItemType.QUANTITY + ) + ) + .apply { + initial = + listOf( + Questionnaire.QuestionnaireItemInitialComponent( + Quantity().apply { + code = "months" + system = "http://unitofmeausre.org" + } + ) + ) + } + + val questionResponse = question.createQuestionnaireResponseItem() + + assertThat(questionResponse.answer).isEmpty() + } + + @Test + fun `createQuestionResponse should set answer with quantity type`() { + val question = + Questionnaire.QuestionnaireItemComponent( + StringType("age"), + Enumeration( + Questionnaire.QuestionnaireItemTypeEnumFactory(), + Questionnaire.QuestionnaireItemType.QUANTITY + ) + ) + .apply { + initial = + listOf( + Questionnaire.QuestionnaireItemInitialComponent( + Quantity().apply { + code = "months" + system = "http://unitofmeausre.org" + value = BigDecimal("1") + } + ) + ) + } + + val questionResponse = question.createQuestionnaireResponseItem() + val answer = questionResponse.answerFirstRep.value as Quantity + assertThat(answer.value).isEqualTo(BigDecimal(1)) + assertThat(answer.code).isEqualTo("months") + } + @Test fun entryFormat_missingFormat_shouldReturnNull() { val questionnaireItem = diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/MoreQuestionnaireResponseItemAnswerComponentTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/MoreQuestionnaireResponseItemAnswerComponentTest.kt index 9e1a539ec9..0a98c2822e 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/MoreQuestionnaireResponseItemAnswerComponentTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/MoreQuestionnaireResponseItemAnswerComponentTest.kt @@ -221,4 +221,56 @@ class MoreQuestionnaireResponseItemAnswerComponentTest { assertThat(answer.displayString(context)).isEqualTo(context.getString(R.string.not_answered)) } + + @Test + fun `hasDifferentAnswerSet() should return false when both list values are exactly same`() { + val list1 = + listOf( + createCodingQuestionnaireResponseItemAnswerComponent("http://abc.org", "code1", "Code 1"), + createCodingQuestionnaireResponseItemAnswerComponent("http://abc.org", "code2", "Code 2") + ) + val list2 = + listOf( + Coding("http://abc.org", "code1", "Code 1"), + Coding("http://abc.org", "code2", "Code 2") + ) + assertThat(list1.hasDifferentAnswerSet(list2)).isFalse() + } + + @Test + fun `hasDifferentAnswerSet() should return true when both list sizes are different`() { + val list1 = + listOf( + createCodingQuestionnaireResponseItemAnswerComponent("http://abc.org", "code1", "Code 1"), + ) + val list2 = + listOf( + Coding("http://abc.org", "code1", "Code 1"), + Coding("http://abc.org", "code2", "Code 2") + ) + assertThat(list1.hasDifferentAnswerSet(list2)).isTrue() + } + + @Test + fun `hasDifferentAnswerSet() should return true when both list sizes are same with different items`() { + val list1 = + listOf( + createCodingQuestionnaireResponseItemAnswerComponent("http://abc.org", "code1", "Code 1"), + createCodingQuestionnaireResponseItemAnswerComponent("http://abc.org", "code2", "Code 2"), + ) + val list2 = + listOf( + Coding("http://abc.org", "code1", "Code 1"), + Coding("http://abc.org", "code4", "Code 4") + ) + assertThat(list1.hasDifferentAnswerSet(list2)).isTrue() + } + + private fun createCodingQuestionnaireResponseItemAnswerComponent( + url: String, + code: String, + display: String + ) = + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent() + .setValue(Coding(url, code, display)) } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireFragmentTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireFragmentTest.kt new file mode 100644 index 0000000000..b14ebf4d06 --- /dev/null +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireFragmentTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright 2022 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 + +import android.os.Build +import androidx.core.os.bundleOf +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.fragment.app.testing.withFragment +import androidx.lifecycle.Lifecycle +import ca.uhn.fhir.context.FhirContext +import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_JSON_STRING +import com.google.common.truth.Truth.assertThat +import org.hl7.fhir.r4.model.Questionnaire +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P]) +class QuestionnaireFragmentTest { + + @Test + fun `fragment should have valid questionnaire response`() { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-link-id" + type = Questionnaire.QuestionnaireItemType.BOOLEAN + } + ) + } + val questionnaireJson = + FhirContext.forR4().newJsonParser().encodeResourceToString(questionnaire) + val scenario = + launchFragmentInContainer( + bundleOf(EXTRA_QUESTIONNAIRE_JSON_STRING to questionnaireJson) + ) + scenario.moveToState(Lifecycle.State.RESUMED) + scenario.withFragment { + assertThat(this.getQuestionnaireResponse()).isNotNull() + assertThat(this.getQuestionnaireResponse().item.any { it.linkId == "a-link-id" }).isTrue() + } + } +} 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 091bf621e9..dcca930af5 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/QuestionnaireViewModelTest.kt @@ -31,6 +31,7 @@ import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI import com.google.android.fhir.datacapture.QuestionnaireFragment.Companion.EXTRA_SHOW_REVIEW_PAGE_FIRST +import com.google.android.fhir.datacapture.common.datatype.asStringValue import com.google.android.fhir.datacapture.testing.DataCaptureTestApplication import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.NotValidated @@ -39,6 +40,8 @@ import com.google.android.fhir.logicalId import com.google.android.fhir.testing.FhirEngineProviderTestRule import com.google.common.truth.Truth.assertThat import java.io.File +import java.util.Calendar +import java.util.Date import java.util.UUID import kotlin.test.assertFailsWith import kotlin.test.assertTrue @@ -51,16 +54,19 @@ import org.hl7.fhir.instance.model.api.IBaseResource import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.CodeableConcept import org.hl7.fhir.r4.model.Coding +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.HumanName import org.hl7.fhir.r4.model.IntegerType import org.hl7.fhir.r4.model.Practitioner +import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.StringType import org.hl7.fhir.r4.model.ValueSet import org.hl7.fhir.r4.utils.ToolingExtensions +import org.junit.Assert import org.junit.Before import org.junit.Ignore import org.junit.Rule @@ -2944,6 +2950,315 @@ class QuestionnaireViewModelTest( } } + @Test + fun `should calculate value on start for questionnaire item with calculated expression extension`() = + runBlocking { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-birthdate" + type = Questionnaire.QuestionnaireItemType.DATE + addExtension().apply { + url = EXTENSION_CALCULATED_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = + "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" + } + ) + } + } + ) + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-age-years" + type = Questionnaire.QuestionnaireItemType.QUANTITY + addInitial( + Questionnaire.QuestionnaireItemInitialComponent(Quantity.fromUcum("1", "year")) + ) + } + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + + assertThat( + viewModel + .getQuestionnaireResponse() + .item + .single { it.linkId == "a-birthdate" } + .answerFirstRep.value.asStringValue() + ) + .isEqualTo(DateType(Date()).apply { add(Calendar.YEAR, -1) }.asStringValue()) + + assertThat( + viewModel + .getQuestionnaireResponse() + .item + .single { it.linkId == "a-age-years" } + .answerFirstRep.valueQuantity.value.toString() + ) + .isEqualTo("1") + } + + @Test + fun `should calculate value on change for questionnaire item with calculated expression extension`() = + runBlocking { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-birthdate" + type = Questionnaire.QuestionnaireItemType.DATE + addExtension().apply { + url = EXTENSION_CALCULATED_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = + "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" + } + ) + } + } + ) + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-age-years" + type = Questionnaire.QuestionnaireItemType.INTEGER + } + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + + val birthdateItem = + viewModel.getQuestionnaireItemViewItemList().first { + it.questionnaireItem.linkId == "a-birthdate" + } + + assertThat(birthdateItem.getQuestionnaireResponseItem().answer).isEmpty() + + viewModel + .getQuestionnaireItemViewItemList() + .first { it.questionnaireItem.linkId == "a-age-years" } + .apply { + this.answersChangedCallback( + this.questionnaireItem, + this.getQuestionnaireResponseItem(), + listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + this.value = Quantity.fromUcum("2", "years") + } + ) + ) + } + + assertThat( + birthdateItem.getQuestionnaireResponseItem().answer.first().valueDateType.valueAsString + ) + .isEqualTo(DateType(Date()).apply { add(Calendar.YEAR, -2) }.valueAsString) + } + + @Test + fun `should not change value for modified questionnaire items with calculated expression extension`() = + runBlocking { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-birthdate" + type = Questionnaire.QuestionnaireItemType.DATE + addExtension().apply { + url = EXTENSION_CALCULATED_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = + "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" + } + ) + } + } + ) + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-age-years" + type = Questionnaire.QuestionnaireItemType.INTEGER + } + ) + } + + val viewModel = createQuestionnaireViewModel(questionnaire) + val birthdateItem = + viewModel.getQuestionnaireItemViewItemList().first { + it.questionnaireItem.linkId == "a-birthdate" + } + val birthdateValue = DateType(Date()) + birthdateItem.apply { + this.answersChangedCallback( + this.questionnaireItem, + this.getQuestionnaireResponseItem(), + listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + this.value = birthdateValue + } + ) + ) + } + + assertThat( + birthdateItem.getQuestionnaireResponseItem().answer.first().valueDateType.valueAsString + ) + .isEqualTo(birthdateValue.valueAsString) + + viewModel + .getQuestionnaireItemViewItemList() + .first { it.questionnaireItem.linkId == "a-age-years" } + .apply { + this.answersChangedCallback( + this.questionnaireItem, + this.getQuestionnaireResponseItem(), + listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + this.value = Quantity.fromUcum("2", "years") + } + ) + ) + } + + assertThat( + birthdateItem.getQuestionnaireResponseItem().answer.first().valueDateType.valueAsString + ) + .isEqualTo(birthdateValue.valueAsString) + } + + @Test + fun `should detect cyclic dependency for questionnaire item with calculated expression extension in flat list`() = + runBlocking { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-birthdate" + type = Questionnaire.QuestionnaireItemType.DATE + addInitial( + Questionnaire.QuestionnaireItemInitialComponent( + DateType(Date()).apply { add(Calendar.YEAR, -2) } + ) + ) + addExtension().apply { + url = EXTENSION_CALCULATED_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = + "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" + } + ) + } + } + ) + + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-age-years" + type = Questionnaire.QuestionnaireItemType.INTEGER + addExtension().apply { + url = EXTENSION_CALCULATED_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = + "today().toString().substring(0, 4).toInteger() - %resource.repeat(item).where(linkId='a-birthdate').answer.value.toString().substring(0, 4).toInteger()" + } + ) + } + } + ) + } + + val exception = + Assert.assertThrows(null, IllegalStateException::class.java) { + createQuestionnaireViewModel(questionnaire) + } + assertThat(exception.message) + .isEqualTo( + "a-birthdate and a-age-years have cyclic dependency in expression based extension" + ) + } + + @Test + fun `should detect cyclic dependency for questionnaire item with calculated expression extension in nested list`() = + runBlocking { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-birthdate" + type = Questionnaire.QuestionnaireItemType.DATE + addInitial( + Questionnaire.QuestionnaireItemInitialComponent( + DateType(Date()).apply { add(Calendar.YEAR, -2) } + ) + ) + addExtension().apply { + url = EXTENSION_CALCULATED_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = + "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" + } + ) + } + } + ) + .addItem() + .apply { + linkId = "a.1" + type = Questionnaire.QuestionnaireItemType.GROUP + } + .addItem() + .apply { + linkId = "a.1.1" + type = Questionnaire.QuestionnaireItemType.GROUP + } + .addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-age-years" + type = Questionnaire.QuestionnaireItemType.INTEGER + addExtension().apply { + url = EXTENSION_CALCULATED_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = + "today().toString().substring(0, 4).toInteger() - %resource.repeat(item).where(linkId='a-birthdate').answer.value.toString().substring(0, 4).toInteger()" + } + ) + } + } + ) + } + + val exception = + Assert.assertThrows(null, IllegalStateException::class.java) { + createQuestionnaireViewModel(questionnaire) + } + assertThat(exception.message) + .isEqualTo( + "a-birthdate and a-age-years have cyclic dependency in expression based extension" + ) + } + private fun createQuestionnaireViewModel( questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse? = null, @@ -2988,6 +3303,12 @@ class QuestionnaireViewModelTest( private suspend fun QuestionnaireViewModel.getQuestionnaireItemViewItemList() = questionnaireStateFlow.first().items + private fun QuestionnaireItemViewItem.getQuestionnaireResponseItem() = + ReflectionHelpers.getField( + this, + "questionnaireResponseItem" + ) + private companion object { const val CODE_SYSTEM_YES_NO = "http://terminology.hl7.org/CodeSystem/v2-0136" diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt index ee666bceb8..6a8114af23 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/fhirpath/ExpressionEvaluatorTest.kt @@ -16,13 +16,20 @@ package com.google.android.fhir.datacapture.fhirpath +import com.google.android.fhir.datacapture.EXTENSION_CALCULATED_EXPRESSION_URL import com.google.android.fhir.datacapture.EXTENSION_VARIABLE_URL import com.google.android.fhir.datacapture.common.datatype.asStringValue +import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.detectExpressionCyclicDependency +import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.evaluateCalculatedExpressions import com.google.android.fhir.datacapture.variableExpressions import com.google.common.truth.Truth.assertThat +import java.util.Calendar +import java.util.Date import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.DateType import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.IntegerType +import org.hl7.fhir.r4.model.Quantity import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.Type @@ -32,277 +39,277 @@ import org.junit.Test class ExpressionEvaluatorTest { @Test fun `should return not null value with simple variable expression for questionnaire root level`() = - runBlocking { - val questionnaire = - Questionnaire().apply { - addExtension().apply { - url = EXTENSION_VARIABLE_URL - setValue( - Expression().apply { - name = "A" - language = "text/fhirpath" - expression = "1" - } - ) + runBlocking { + val questionnaire = + Questionnaire().apply { + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "A" + language = "text/fhirpath" + expression = "1" + } + ) + } } - } - val result = - ExpressionEvaluator.evaluateQuestionnaireVariableExpression( - questionnaire.variableExpressions.first(), - questionnaire, - QuestionnaireResponse() - ) + val result = + ExpressionEvaluator.evaluateQuestionnaireVariableExpression( + questionnaire.variableExpressions.first(), + questionnaire, + QuestionnaireResponse() + ) - assertThat((result as Type).asStringValue()).isEqualTo("1") - } + assertThat((result as Type).asStringValue()).isEqualTo("1") + } @Test fun `should return not null value with variables dependent on other variables for questionnaire root level`() = - runBlocking { - val questionnaire = - Questionnaire().apply { - addExtension().apply { - url = EXTENSION_VARIABLE_URL - setValue( - Expression().apply { - name = "A" - language = "text/fhirpath" - expression = "1" - } - ) - } - addExtension().apply { - url = EXTENSION_VARIABLE_URL - setValue( - Expression().apply { - name = "B" - language = "text/fhirpath" - expression = "%A + 1" - } - ) + runBlocking { + val questionnaire = + Questionnaire().apply { + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "A" + language = "text/fhirpath" + expression = "1" + } + ) + } + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "B" + language = "text/fhirpath" + expression = "%A + 1" + } + ) + } } - } - val result = - ExpressionEvaluator.evaluateQuestionnaireVariableExpression( - questionnaire.variableExpressions.last(), - questionnaire, - QuestionnaireResponse() - ) + val result = + ExpressionEvaluator.evaluateQuestionnaireVariableExpression( + questionnaire.variableExpressions.last(), + questionnaire, + QuestionnaireResponse() + ) - assertThat((result as Type).asStringValue()).isEqualTo("2") - } + assertThat((result as Type).asStringValue()).isEqualTo("2") + } @Test fun `should return not null value with variables dependent on other variables in parent for questionnaire item level`() = - runBlocking { - val questionnaire = - Questionnaire().apply { - id = "a-questionnaire" - addItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "a-group-item" - text = "a question" - type = Questionnaire.QuestionnaireItemType.GROUP - addExtension().apply { - url = EXTENSION_VARIABLE_URL - setValue( - Expression().apply { - name = "A" - language = "text/fhirpath" - expression = "1" + runBlocking { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-group-item" + text = "a question" + type = Questionnaire.QuestionnaireItemType.GROUP + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "A" + language = "text/fhirpath" + expression = "1" + } + ) + } + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "an-item" + text = "a question" + type = Questionnaire.QuestionnaireItemType.TEXT + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "B" + language = "text/fhirpath" + expression = "%A + 1" + } + ) + } } ) } - addItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "an-item" - text = "a question" - type = Questionnaire.QuestionnaireItemType.TEXT - addExtension().apply { - url = EXTENSION_VARIABLE_URL - setValue( - Expression().apply { - name = "B" - language = "text/fhirpath" - expression = "%A + 1" - } - ) - } - } - ) - } - ) - } + ) + } - val result = - ExpressionEvaluator.evaluateQuestionnaireItemVariableExpression( - questionnaire.item[0].item[0].variableExpressions.last(), - questionnaire, - QuestionnaireResponse(), - mapOf(questionnaire.item[0].item[0] to questionnaire.item[0]), - questionnaire.item[0].item[0] - ) + val result = + ExpressionEvaluator.evaluateQuestionnaireItemVariableExpression( + questionnaire.item[0].item[0].variableExpressions.last(), + questionnaire, + QuestionnaireResponse(), + mapOf(questionnaire.item[0].item[0] to questionnaire.item[0]), + questionnaire.item[0].item[0] + ) - assertThat((result as Type).asStringValue()).isEqualTo("2") - } + assertThat((result as Type).asStringValue()).isEqualTo("2") + } @Test fun `should return not null value with variables dependent on multiple variables for questionnaire root level`() = - runBlocking { - val questionnaire = - Questionnaire().apply { - addExtension().apply { - url = EXTENSION_VARIABLE_URL - setValue( - Expression().apply { - name = "A" - language = "text/fhirpath" - expression = "1" - } - ) - } - addExtension().apply { - url = EXTENSION_VARIABLE_URL - setValue( - Expression().apply { - name = "B" - language = "text/fhirpath" - expression = "2" - } - ) - } - addExtension().apply { - url = EXTENSION_VARIABLE_URL - setValue( - Expression().apply { - name = "C" - language = "text/fhirpath" - expression = "%A + %B" - } - ) + runBlocking { + val questionnaire = + Questionnaire().apply { + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "A" + language = "text/fhirpath" + expression = "1" + } + ) + } + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "B" + language = "text/fhirpath" + expression = "2" + } + ) + } + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "C" + language = "text/fhirpath" + expression = "%A + %B" + } + ) + } } - } - val result = - ExpressionEvaluator.evaluateQuestionnaireVariableExpression( - questionnaire.variableExpressions.last(), - questionnaire, - QuestionnaireResponse() - ) + val result = + ExpressionEvaluator.evaluateQuestionnaireVariableExpression( + questionnaire.variableExpressions.last(), + questionnaire, + QuestionnaireResponse() + ) - assertThat((result as Type).asStringValue()).isEqualTo("3") - } + assertThat((result as Type).asStringValue()).isEqualTo("3") + } @Test fun `should return null with variables dependent on missing variables for questionnaire root level`() = - runBlocking { - val questionnaire = - Questionnaire().apply { - addExtension().apply { - url = EXTENSION_VARIABLE_URL - setValue( - Expression().apply { - name = "A" - language = "text/fhirpath" - expression = "%B + 1" - } - ) + runBlocking { + val questionnaire = + Questionnaire().apply { + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "A" + language = "text/fhirpath" + expression = "%B + 1" + } + ) + } } - } - val result = - ExpressionEvaluator.evaluateQuestionnaireVariableExpression( - questionnaire.variableExpressions.last(), - questionnaire, - QuestionnaireResponse() - ) + val result = + ExpressionEvaluator.evaluateQuestionnaireVariableExpression( + questionnaire.variableExpressions.last(), + questionnaire, + QuestionnaireResponse() + ) - assertThat(result).isEqualTo(null) - } + assertThat(result).isEqualTo(null) + } @Test fun `should return not null value with variables dependent on other variables at origin for questionnaire item level`() = - runBlocking { - val questionnaire = - Questionnaire().apply { - addItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "an-item" - text = "a question" - type = Questionnaire.QuestionnaireItemType.TEXT - addExtension().apply { - url = EXTENSION_VARIABLE_URL - setValue( - Expression().apply { - name = "B" - language = "text/fhirpath" - expression = "1" - } - ) - } - addExtension().apply { - url = EXTENSION_VARIABLE_URL - setValue( - Expression().apply { - name = "A" - language = "text/fhirpath" - expression = "%B + 1" - } - ) + runBlocking { + val questionnaire = + Questionnaire().apply { + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "an-item" + text = "a question" + type = Questionnaire.QuestionnaireItemType.TEXT + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "B" + language = "text/fhirpath" + expression = "1" + } + ) + } + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "A" + language = "text/fhirpath" + expression = "%B + 1" + } + ) + } } - } - ) - } + ) + } - val result = - ExpressionEvaluator.evaluateQuestionnaireItemVariableExpression( - questionnaire.item[0].variableExpressions.last(), - questionnaire, - QuestionnaireResponse(), - mapOf(), - questionnaire.item[0] - ) + val result = + ExpressionEvaluator.evaluateQuestionnaireItemVariableExpression( + questionnaire.item[0].variableExpressions.last(), + questionnaire, + QuestionnaireResponse(), + mapOf(), + questionnaire.item[0] + ) - assertThat((result as Type).asStringValue()).isEqualTo("2") - } + assertThat((result as Type).asStringValue()).isEqualTo("2") + } @Test fun `should return null with variables dependent on missing variables at origin for questionnaire item level`() = - runBlocking { - val questionnaire = - Questionnaire().apply { - addItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "an-item" - text = "a question" - type = Questionnaire.QuestionnaireItemType.TEXT - addExtension().apply { - url = EXTENSION_VARIABLE_URL - setValue( - Expression().apply { - name = "A" - language = "text/fhirpath" - expression = "%B + 1" - } - ) + runBlocking { + val questionnaire = + Questionnaire().apply { + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "an-item" + text = "a question" + type = Questionnaire.QuestionnaireItemType.TEXT + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "A" + language = "text/fhirpath" + expression = "%B + 1" + } + ) + } } - } - ) - } + ) + } - val result = - ExpressionEvaluator.evaluateQuestionnaireItemVariableExpression( - questionnaire.item[0].variableExpressions.last(), - questionnaire, - QuestionnaireResponse(), - mapOf(), - questionnaire.item[0] - ) + val result = + ExpressionEvaluator.evaluateQuestionnaireItemVariableExpression( + questionnaire.item[0].variableExpressions.last(), + questionnaire, + QuestionnaireResponse(), + mapOf(), + questionnaire.item[0] + ) - assertThat(result).isEqualTo(null) - } + assertThat(result).isEqualTo(null) + } @Test fun `should throw illegal argument exception with missing expression name for questionnaire variables`() { @@ -415,60 +422,247 @@ class ExpressionEvaluatorTest { @Test fun `should return not null value with expression dependent on answers of items for questionnaire item level`() = - runBlocking { + runBlocking { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-group-item" + text = "a question" + type = Questionnaire.QuestionnaireItemType.GROUP + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "M" + language = "text/fhirpath" + expression = + "%resource.repeat(item).where(linkId='an-item').answer.first().value" + } + ) + } + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "an-item" + text = "a question" + type = Questionnaire.QuestionnaireItemType.TEXT + } + ) + } + ) + } + + val questionnaireResponse = + QuestionnaireResponse().apply { + id = "a-questionnaire-response" + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "an-item" + addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + value = IntegerType(2) + } + ) + } + ) + } + + val result = + ExpressionEvaluator.evaluateQuestionnaireItemVariableExpression( + questionnaire.item[0].variableExpressions.last(), + questionnaire, + questionnaireResponse, + mapOf(), + questionnaire.item[0] + ) + + assertThat((result as Type).asStringValue()).isEqualTo("2") + } + + @Test + fun `evaluateCalculatedExpressions should return list of calculated values`() = runBlocking { val questionnaire = Questionnaire().apply { id = "a-questionnaire" addItem( Questionnaire.QuestionnaireItemComponent().apply { - linkId = "a-group-item" - text = "a question" - type = Questionnaire.QuestionnaireItemType.GROUP + linkId = "a-birthdate" + type = Questionnaire.QuestionnaireItemType.DATE addExtension().apply { - url = EXTENSION_VARIABLE_URL + url = EXTENSION_CALCULATED_EXPRESSION_URL setValue( Expression().apply { - name = "M" - language = "text/fhirpath" - expression = "%resource.repeat(item).where(linkId='an-item').answer.first().value" + this.language = "text/fhirpath" + this.expression = + "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" } ) } - addItem( - Questionnaire.QuestionnaireItemComponent().apply { - linkId = "an-item" - text = "a question" - type = Questionnaire.QuestionnaireItemType.TEXT - } - ) + } + ) + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-age-years" + type = Questionnaire.QuestionnaireItemType.QUANTITY } ) } val questionnaireResponse = QuestionnaireResponse().apply { - id = "a-questionnaire-response" addItem( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { - linkId = "an-item" - addAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - value = IntegerType(2) - } - ) + linkId = "a-birthdate" + } + ) + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "a-age-years" + answer = + listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + this.value = Quantity(1).apply { unit = "year" } + } + ) } ) } val result = - ExpressionEvaluator.evaluateQuestionnaireItemVariableExpression( - questionnaire.item[0].variableExpressions.last(), + evaluateCalculatedExpressions( + questionnaire.item.elementAt(1), questionnaire, questionnaireResponse, - mapOf(), - questionnaire.item[0] + emptyMap() ) - assertThat((result as Type).asStringValue()).isEqualTo("2") + assertThat(result.first().second.first().asStringValue()) + .isEqualTo(DateType(Date()).apply { add(Calendar.YEAR, -1) }.asStringValue()) + } + + @Test + fun `evaluateCalculatedExpressions should return list of calculated values with variables`() = + runBlocking { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addExtension().apply { + url = EXTENSION_VARIABLE_URL + setValue( + Expression().apply { + name = "AGE-YEARS" + language = "text/fhirpath" + expression = + "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" + } + ) + } + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-birthdate" + type = Questionnaire.QuestionnaireItemType.DATE + addExtension().apply { + url = EXTENSION_CALCULATED_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = "%AGE-YEARS" + } + ) + } + } + ) + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-age-years" + type = Questionnaire.QuestionnaireItemType.QUANTITY + } + ) + } + + val questionnaireResponse = + QuestionnaireResponse().apply { + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "a-birthdate" + } + ) + addItem( + QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { + linkId = "a-age-years" + answer = + listOf( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + this.value = Quantity(1).apply { unit = "year" } + } + ) + } + ) + } + + val result = + evaluateCalculatedExpressions( + questionnaire.item.elementAt(1), + questionnaire, + questionnaireResponse, + emptyMap() + ) + + assertThat(result.first().second.first().asStringValue()) + .isEqualTo(DateType(Date()).apply { add(Calendar.YEAR, -1) }.asStringValue()) + } + + @Test + fun `detectExpressionCyclicDependency() should throw illegal argument exception when item with calculated expression have cyclic dependency`() { + val questionnaire = + Questionnaire().apply { + id = "a-questionnaire" + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-birthdate" + type = Questionnaire.QuestionnaireItemType.DATE + addInitial( + Questionnaire.QuestionnaireItemInitialComponent( + DateType(Date()).apply { add(Calendar.YEAR, -2) } + ) + ) + addExtension().apply { + url = EXTENSION_CALCULATED_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = + "%resource.repeat(item).where(linkId='a-age-years' and answer.empty().not()).select(today() - answer.value)" + } + ) + } + } + ) + + addItem( + Questionnaire.QuestionnaireItemComponent().apply { + linkId = "a-age-years" + type = Questionnaire.QuestionnaireItemType.INTEGER + addExtension().apply { + url = EXTENSION_CALCULATED_EXPRESSION_URL + setValue( + Expression().apply { + this.language = "text/fhirpath" + this.expression = + "today().toString().substring(0, 4).toInteger() - %resource.repeat(item).where(linkId='a-birthdate').answer.value.toString().substring(0, 4).toInteger()" + } + ) + } + } + ) + } + + val exception = + assertThrows(null, IllegalStateException::class.java) { + detectExpressionCyclicDependency(questionnaire.item) + } + assertThat(exception.message) + .isEqualTo("a-birthdate and a-age-years have cyclic dependency in expression based extension") } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueConstraintValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueConstraintValidatorTest.kt index e9b9c9b807..5b6d1a16cd 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueConstraintValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MaxValueConstraintValidatorTest.kt @@ -19,7 +19,7 @@ package com.google.android.fhir.datacapture.validation import android.content.Context import android.os.Build import androidx.test.core.app.ApplicationProvider -import com.google.android.fhir.datacapture.CQF_CALCULATED_EXPRESSION_URL +import com.google.android.fhir.datacapture.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.common.truth.Truth.assertThat import java.text.SimpleDateFormat import java.time.LocalDate @@ -109,7 +109,7 @@ class MaxValueConstraintValidatorTest { extension = listOf( Extension( - CQF_CALCULATED_EXPRESSION_URL, + EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { language = "text/fhirpath" expression = "today()" @@ -184,7 +184,7 @@ class MaxValueConstraintValidatorTest { extension = listOf( Extension( - CQF_CALCULATED_EXPRESSION_URL, + EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { language = "text/fhirpath" expression = "today()" @@ -218,7 +218,7 @@ class MaxValueConstraintValidatorTest { extension = listOf( Extension( - CQF_CALCULATED_EXPRESSION_URL, + EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { language = "text/fhirpath" expression = "today() + 5 'days' " diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueConstraintValidatorTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueConstraintValidatorTest.kt index a54211faa8..9762f945d3 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueConstraintValidatorTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/validation/MinValueConstraintValidatorTest.kt @@ -20,7 +20,7 @@ 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.CQF_CALCULATED_EXPRESSION_URL +import com.google.android.fhir.datacapture.EXTENSION_CQF_CALCULATED_VALUE_URL import com.google.common.truth.Truth.assertThat import java.text.SimpleDateFormat import java.util.Calendar @@ -111,7 +111,7 @@ class MinValueConstraintValidatorTest { extension = listOf( Extension( - CQF_CALCULATED_EXPRESSION_URL, + EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { language = "text/fhirpath" expression = "today() - 1 'days'" @@ -161,7 +161,7 @@ class MinValueConstraintValidatorTest { extension = listOf( Extension( - CQF_CALCULATED_EXPRESSION_URL, + EXTENSION_CQF_CALCULATED_VALUE_URL, Expression().apply { language = "text/fhirpath" expression = "today() - 1 'days'" diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactoryTest.kt index 238675122e..1c900cdaa0 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDatePickerViewHolderFactoryTest.kt @@ -68,6 +68,26 @@ class QuestionnaireItemDatePickerViewHolderFactoryTest { assertThat(viewHolder.dateInputView.text.toString()).isEqualTo("") } + @Test + fun `should set text field empty when date field is initialized but answer date value is null`() { + viewHolder.bind( + QuestionnaireItemViewItem( + Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent() + .addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().setValue(DateType()) + ), + validationResult = NotValidated, + answersChangedCallback = { _, _, _ -> }, + ) + ) + + assertThat( + viewHolder.itemView.findViewById(R.id.text_input_edit_text).text.toString() + ) + .isEqualTo("") + } + @Test fun shouldSetDateInput_localeUs() { setLocale(Locale.US) diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryTest.kt index f3f4ae9bc5..14808e6219 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryTest.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryTest.kt @@ -134,7 +134,7 @@ class QuestionnaireItemEditTextQuantityViewHolderFactoryTest { val answer = questionnaireItemViewItem.answers assertThat(answer.size).isEqualTo(1) - assertThat(answer[0].valueQuantity!!.value!!.toString()).isEqualTo("10.0") + assertThat(answer[0].valueQuantity!!.value!!.toString()).isEqualTo("10") } @Test