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