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/ComponentListViewModel.kt b/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt index a440fa9a16..49ed891d32 100644 --- a/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt +++ b/catalog/src/main/java/com/google/android/fhir/catalog/ComponentListViewModel.kt @@ -109,5 +109,10 @@ class ComponentListViewModel(application: Application, private val state: SavedS "component_auto_complete.json", "component_auto_complete_with_validation.json" ), + CALCULATED_EXPRESSION( + R.drawable.ic_unitoptions, + R.string.component_name_calculated_expression, + "calculated_expression_questionnaire.json" + ), } } diff --git a/catalog/src/main/res/values/strings.xml b/catalog/src/main/res/values/strings.xml index b4f54482e3..3129ce1d30 100644 --- a/catalog/src/main/res/values/strings.xml +++ b/catalog/src/main/res/values/strings.xml @@ -30,6 +30,9 @@ Slider Dropdown Image + Calculated Expression Auto Complete Default Paginated diff --git a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/QuestionnaireItemBarCodeReaderViewHolderFactory.kt b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/QuestionnaireItemBarCodeReaderViewHolderFactory.kt index c708cc30d2..f368d9755e 100644 --- a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/QuestionnaireItemBarCodeReaderViewHolderFactory.kt +++ b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/QuestionnaireItemBarCodeReaderViewHolderFactory.kt @@ -22,13 +22,13 @@ import android.view.View import android.widget.TextView import androidx.fragment.app.FragmentResultListener import com.google.android.fhir.datacapture.contrib.views.barcode.mlkit.md.LiveBarcodeScanningFragment +import com.google.android.fhir.datacapture.contrib.views.barcode.mlkit.md.Utils.tryUnwrapContext import com.google.android.fhir.datacapture.localizedPrefixSpanned import com.google.android.fhir.datacapture.localizedTextSpanned import com.google.android.fhir.datacapture.validation.ValidationResult import com.google.android.fhir.datacapture.views.QuestionnaireItemViewHolderDelegate import com.google.android.fhir.datacapture.views.QuestionnaireItemViewHolderFactory import com.google.android.fhir.datacapture.views.QuestionnaireItemViewItem -import com.google.android.fhir.datacapture.views.tryUnwrapContext import org.hl7.fhir.r4.model.QuestionnaireResponse import org.hl7.fhir.r4.model.StringType diff --git a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/Utils.kt b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/Utils.kt index 0c54370319..e4a524c3cc 100644 --- a/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/Utils.kt +++ b/contrib/barcode/src/main/java/com/google/android/fhir/datacapture/contrib/views/barcode/mlkit/md/Utils.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Google LLC + * 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. @@ -32,6 +32,8 @@ import android.graphics.RectF import android.graphics.YuvImage import android.hardware.Camera import android.net.Uri +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ContextThemeWrapper import androidx.exifinterface.media.ExifInterface import com.google.android.fhir.datacapture.contrib.views.barcode.mlkit.md.camera.CameraSizePair import com.google.mlkit.vision.barcode.BarcodeScanning @@ -224,4 +226,25 @@ object Utils { } fun getBarcodeScanningClient() = BarcodeScanning.getClient() + + /** + * Returns the [AppCompatActivity] if there exists one wrapped inside [ContextThemeWrapper] s, or + * `null` otherwise. + * + * This function is inspired by the function with the same name in `AppCompateDelegateImpl`. See + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:appcompat/appcompat/src/main/java/androidx/appcompat/app/AppCompatDelegateImpl.java;l=1615 + * + * TODO: find a more robust way to do this as it is not guaranteed that the activity is an + * AppCompatActivity. + */ + internal fun Context.tryUnwrapContext(): AppCompatActivity? { + var context = this + while (true) { + when (context) { + is AppCompatActivity -> return context + is ContextThemeWrapper -> context = context.baseContext + else -> return null + } + } + } } diff --git a/datacapture/src/androidTest/AndroidManifest.xml b/datacapture/src/androidTest/AndroidManifest.xml index 4d2f89ab21..9d354ab04b 100644 --- a/datacapture/src/androidTest/AndroidManifest.xml +++ b/datacapture/src/androidTest/AndroidManifest.xml @@ -21,6 +21,7 @@ diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/DataCaptureTestApplication.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/DataCaptureTestApplication.kt new file mode 100644 index 0000000000..d45ec16ed3 --- /dev/null +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/DataCaptureTestApplication.kt @@ -0,0 +1,32 @@ +/* + * 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.app.Application + +/** Application class when you want to test the DataCaptureConfig.Provider */ +internal class DataCaptureTestApplication : Application(), DataCaptureConfig.Provider { + var dataCaptureConfiguration: DataCaptureConfig? = null + + override fun getDataCaptureConfig(): DataCaptureConfig { + if (dataCaptureConfiguration == null) { + dataCaptureConfiguration = DataCaptureConfig() + } + + return dataCaptureConfiguration!! + } +} diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsInstrumentedTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsInstrumentedTest.kt new file mode 100644 index 0000000000..b770e97863 --- /dev/null +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/MoreQuestionnaireItemComponentsInstrumentedTest.kt @@ -0,0 +1,126 @@ +/* + * 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.graphics.Bitmap +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import java.nio.charset.Charset +import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.Attachment +import org.hl7.fhir.r4.model.Binary +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MoreQuestionnaireItemComponentsInstrumentedTest { + + @Test + fun fetchBitmap_shouldReturnNull_whenAttachmentHasDataAndIncorrectContentType() { + val attachment = + Attachment().apply { + data = "some-byte".toByteArray(Charset.forName("UTF-8")) + contentType = "document/pdf" + } + val bitmap: Bitmap? + runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) } + assertThat(bitmap).isNull() + } + + @Test + fun fetchBitmap_shouldReturnBitmap_whenAttachmentHasDataAndCorrectContentType() { + val attachment = + Attachment().apply { + data = + Base64.decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", Base64.DEFAULT) + contentType = "image/png" + } + val bitmap: Bitmap? + runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) } + assertThat(bitmap).isNotNull() + } + + @Test + fun isImage_shouldTrue_whenAttachmentContentTypeIsImage() { + val attachment = Attachment().apply { contentType = "image/png" } + assertThat(attachment.isImage).isTrue() + } + + @Test + fun isImage_shouldFalseWhenAttachmentContentTypeIsNotImage() { + val attachment = Attachment().apply { contentType = "document/pdf" } + assertThat(attachment.isImage).isFalse() + } + + @Test + fun fetchBitmap_shouldReturnBitmapAndCallAttachmentResolverResolveBinaryResource() { + val attachment = Attachment().apply { url = "https://hapi.fhir.org/Binary/f006" } + ApplicationProvider.getApplicationContext() + .getDataCaptureConfig() + .attachmentResolver = TestAttachmentResolver() + + val bitmap: Bitmap? + runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) } + + assertThat(bitmap).isNotNull() + } + + @Test + fun fetchBitmap_shouldReturnBitmapAndCallAttachmentResolverResolveImageUrl() { + val attachment = Attachment().apply { url = "https://some-image-server.com/images/f0006.png" } + + val byteArray = + Base64.decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", Base64.DEFAULT) + val expectedBitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) + + ApplicationProvider.getApplicationContext() + .getDataCaptureConfig() + .attachmentResolver = TestAttachmentResolver(expectedBitmap) + + val resolvedBitmap: Bitmap? + runBlocking { + resolvedBitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) + } + + assertThat(resolvedBitmap).isEqualTo(expectedBitmap) + } + + class TestAttachmentResolver(var testBitmap: Bitmap? = null) : AttachmentResolver { + + override suspend fun resolveBinaryResource(uri: String): Binary? { + return if (uri == "https://hapi.fhir.org/Binary/f006") { + Binary().apply { + contentType = "image/png" + data = + Base64.decode( + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + Base64.DEFAULT + ) + } + } else null + } + + override suspend fun resolveImageUrl(uri: String): Bitmap? { + return if (uri == "https://some-image-server.com/images/f0006.png") { + testBitmap + } else null + } + } +} diff --git a/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryEspressoTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryEspressoTest.kt new file mode 100644 index 0000000000..6dfda5dc2e --- /dev/null +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextQuantityViewHolderFactoryEspressoTest.kt @@ -0,0 +1,123 @@ +/* + * 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.views + +import android.view.View +import android.widget.FrameLayout +import android.widget.TextView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.typeText +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.TestActivity +import com.google.common.truth.Truth.assertThat +import java.math.BigDecimal +import org.hl7.fhir.r4.model.Quantity +import org.hl7.fhir.r4.model.Questionnaire +import org.hl7.fhir.r4.model.QuestionnaireResponse +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class QuestionnaireItemEditTextQuantityViewHolderFactoryEspressoTest { + @Rule + @JvmField + var activityScenarioRule: ActivityScenarioRule = + 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_shouldReturnQuantityWithUnitAndSystem() { + val questionnaireItemViewItem = + QuestionnaireItemViewItem( + Questionnaire.QuestionnaireItemComponent().apply { + required = true + addInitial( + Questionnaire.QuestionnaireItemInitialComponent( + Quantity().apply { + code = "months" + system = "http://unitofmeasure.com" + } + ) + ) + }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = null, + 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_shouldReturnQuantityWithoutUnitAndSystem() { + val questionnaireItemViewItem = + QuestionnaireItemViewItem( + Questionnaire.QuestionnaireItemComponent().apply { required = true }, + QuestionnaireResponse.QuestionnaireResponseItemComponent(), + validationResult = null, + 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/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMediaViewInstrumentedTest.kt b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMediaViewInstrumentedTest.kt new file mode 100644 index 0000000000..52508725a5 --- /dev/null +++ b/datacapture/src/androidTest/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMediaViewInstrumentedTest.kt @@ -0,0 +1,124 @@ +/* + * 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.views + +import android.util.Base64 +import android.view.View +import android.widget.FrameLayout +import android.widget.ImageView +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.fhir.datacapture.EXTENSION_ITEM_MEDIA +import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.TestActivity +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.Attachment +import org.hl7.fhir.r4.model.Extension +import org.hl7.fhir.r4.model.Questionnaire +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class QuestionnaireItemMediaViewInstrumentedTest { + + @Rule + @JvmField + var activityScenarioRule: ActivityScenarioRule = + ActivityScenarioRule(TestActivity::class.java) + + private lateinit var parent: FrameLayout + private lateinit var view: QuestionnaireItemMediaView + + @Before + fun setUp() { + activityScenarioRule.scenario.onActivity { activity -> parent = FrameLayout(activity) } + view = QuestionnaireItemMediaView(parent.context, null) + setTestLayout(view) + } + + @Test + fun shouldShowImage_whenItemMediaExtensionIsSet_withImageContentType() = runBlocking { + val attachment = + Attachment().apply { + data = + Base64.decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", Base64.DEFAULT) + contentType = "image/png" + } + val questionnaireItemComponent = + Questionnaire.QuestionnaireItemComponent().apply { + text = "Kindly collect the reading as shown below in the figure" + extension = listOf(Extension(EXTENSION_ITEM_MEDIA, attachment)) + } + + runOnUI { view.bind(questionnaireItemComponent) } + + delay(1000) + + assertThat(view.findViewById(R.id.item_image).visibility).isEqualTo(View.VISIBLE) + } + + @Test + fun shouldHideImageView_whenItemMediaExtensionIsSet_withNonImageContentType() = runBlocking { + val attachment = + Attachment().apply { + data = + Base64.decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", Base64.DEFAULT) + contentType = "document/pdf" + } + val questionnaireItemComponent = + Questionnaire.QuestionnaireItemComponent().apply { + text = "Kindly collect the reading as shown below in the figure" + extension = listOf(Extension(EXTENSION_ITEM_MEDIA, attachment)) + } + + runOnUI { view.bind(questionnaireItemComponent) } + + delay(1000) + + assertThat(view.findViewById(R.id.item_image).visibility).isEqualTo(View.GONE) + } + + @Test + fun shouldHideImageView_whenItemMediaExtensionIsNotSet() = runBlocking { + val questionnaireItemComponent = + Questionnaire.QuestionnaireItemComponent().apply { + text = "Kindly collect the reading as shown below in the figure" + } + + runOnUI { view.bind(questionnaireItemComponent) } + + delay(1000) + + assertThat(view.findViewById(R.id.item_image).visibility).isEqualTo(View.GONE) + } + + /** 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/DataCaptureConfig.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/DataCaptureConfig.kt index 4b7bf17781..ef8222d564 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/DataCaptureConfig.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/DataCaptureConfig.kt @@ -17,8 +17,10 @@ package com.google.android.fhir.datacapture import android.app.Application +import android.graphics.Bitmap import com.google.android.fhir.datacapture.DataCaptureConfig.Provider import org.hl7.fhir.r4.context.SimpleWorkerContext +import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.StructureMap import org.hl7.fhir.utilities.npm.NpmPackage @@ -45,7 +47,13 @@ data class DataCaptureConfig( * should try to include the smallest [NpmPackage] possible that contains only the resources * needed by [StructureMap]s used by the client app. */ - var npmPackage: NpmPackage? = null + var npmPackage: NpmPackage? = null, + + /** + * Used to resolve/process [org.hl7.fhir.r4.model.Attachment] to it's binary equivalent. The + * attachment's can either have the data or a URL + */ + var attachmentResolver: AttachmentResolver? = null ) { internal val simpleWorkerContext: SimpleWorkerContext by lazy { @@ -75,3 +83,10 @@ data class DataCaptureConfig( interface ExternalAnswerValueSetResolver { suspend fun resolve(uri: String): List } + +interface AttachmentResolver { + + suspend fun resolveBinaryResource(uri: String): Binary? + + suspend fun resolveImageUrl(uri: String): Bitmap? +} 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 6fd11c4ad0..66aa493d7b 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 @@ -16,9 +16,15 @@ package com.google.android.fhir.datacapture +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.text.Spanned +import android.util.Base64 import androidx.core.text.HtmlCompat import com.google.android.fhir.getLocalizedText +import org.hl7.fhir.r4.model.Attachment +import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.CodeType import org.hl7.fhir.r4.model.CodeableConcept @@ -26,6 +32,7 @@ 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.StringType +import timber.log.Timber /** UI controls relevant to capturing question data. */ internal enum class ItemControlTypes( @@ -55,6 +62,11 @@ 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_ITEM_MEDIA = + "http://hl7.org/fhir/uv/sdc/StructureDefinition/sdc-questionnaire-itemMedia" + +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" @@ -69,6 +81,9 @@ internal const val EXTENSION_ENABLE_WHEN_EXPRESSION_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 val Questionnaire.QuestionnaireItemComponent.variableExpressions: List get() = this.extension.filter { it.url == EXTENSION_VARIABLE_URL }.map { it.castToExpression(it.value) } @@ -87,8 +102,29 @@ 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? @@ -104,7 +140,7 @@ internal val Questionnaire.QuestionnaireItemComponent.itemControl: ItemControlTy ?.coding ?.firstOrNull { it.system == EXTENSION_ITEM_CONTROL_SYSTEM || - it.system == EXTENSION_ITEM_CONTROL_SYSTEM_ANDROID_FHIR + it.system == EXTENSION_ITEM_CONTROL_SYSTEM_ANDROID_FHIR } ?.code return ItemControlTypes.values().firstOrNull { it.extensionCode == code } @@ -123,7 +159,7 @@ internal val Questionnaire.QuestionnaireItemComponent.choiceOrientation: ChoiceO get() { val code = (this.extension.firstOrNull { it.url == EXTENSION_CHOICE_ORIENTATION_URL }?.value - as CodeType?) + as CodeType?) ?.valueAsString return ChoiceOrientationTypes.values().firstOrNull { it.extensionCode == code } } @@ -199,7 +235,7 @@ internal val Questionnaire.QuestionnaireItemComponent.localizedInstructionsSpann return item .firstOrNull { questionnaireItem -> questionnaireItem.type == Questionnaire.QuestionnaireItemType.DISPLAY && - questionnaireItem.isInstructionsCode + questionnaireItem.isInstructionsCode } ?.localizedTextSpanned } @@ -213,7 +249,7 @@ internal val Questionnaire.QuestionnaireItemComponent.localizedFlyoverSpanned: S item .firstOrNull { questionnaireItem -> questionnaireItem.type == Questionnaire.QuestionnaireItemType.DISPLAY && - questionnaireItem.displayItemControl == DisplayItemControlType.FLYOVER + questionnaireItem.displayItemControl == DisplayItemControlType.FLYOVER } ?.localizedTextSpanned @@ -261,7 +297,7 @@ internal val Questionnaire.QuestionnaireItemComponent.isInstructionsCode: Boolea Questionnaire.QuestionnaireItemType.DISPLAY -> { val codeableConcept = this.extension.firstOrNull { it.url == EXTENSION_DISPLAY_CATEGORY_URL }?.value - as CodeableConcept? + as CodeableConcept? val code = codeableConcept ?.coding @@ -299,14 +335,14 @@ internal val Questionnaire.QuestionnaireItemComponent.isFlyoverCode: Boolean * https://www.hl7.org/fhir/questionnaireresponse.html#notes for more details. */ fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItem(): - QuestionnaireResponse.QuestionnaireResponseItemComponent { + QuestionnaireResponse.QuestionnaireResponseItemComponent { return QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = this@createQuestionnaireResponseItem.linkId answer = createQuestionnaireResponseItemAnswers() if (hasNestedItemsWithinAnswers && answer.isNotEmpty()) { this.addNestedItemsToAnswer(this@createQuestionnaireResponseItem) } else if (this@createQuestionnaireResponseItem.type == - Questionnaire.QuestionnaireItemType.GROUP + Questionnaire.QuestionnaireItemType.GROUP ) { this@createQuestionnaireResponseItem.item.forEach { this.addItem(it.createQuestionnaireResponseItem()) @@ -328,25 +364,30 @@ val Questionnaire.QuestionnaireItemComponent.enableWhenExpression: Expression? * value. */ private fun Questionnaire.QuestionnaireItemComponent.createQuestionnaireResponseItemAnswers(): - MutableList? { - if (initial.isEmpty()) { + MutableList? { + // 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 } - + if (type == Questionnaire.QuestionnaireItemType.GROUP || - type == Questionnaire.QuestionnaireItemType.DISPLAY + type == Questionnaire.QuestionnaireItemType.DISPLAY ) { throw IllegalArgumentException( "Questionnaire item $linkId has initial value(s) and is a group or display item. See rule que-8 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial." ) } - + if (initial.size > 1 && !repeats) { throw IllegalArgumentException( "Questionnaire item $linkId can only have multiple initial values for repeating items. See rule que-13 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial." ) } - + return mutableListOf( QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { value = initial[0].value @@ -368,6 +409,15 @@ fun QuestionnaireResponse.QuestionnaireResponseItemComponent.addNestedItemsToAns } } +/** + * 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]. @@ -377,3 +427,57 @@ fun QuestionnaireResponse.QuestionnaireResponseItemComponent.addNestedItemsToAns */ private inline fun Questionnaire.QuestionnaireItemComponent.getNestedQuestionnaireResponseItems() = item.map { it.createQuestionnaireResponseItem() } + +/** The Attachment defined in the [EXTENSION_ITEM_MEDIA] extension where applicable */ +internal val Questionnaire.QuestionnaireItemComponent.itemMedia: Attachment? + get() { + val extension = this.extension.singleOrNull { it.url == EXTENSION_ITEM_MEDIA } + return if (extension != null) extension.value as Attachment else null + } + +/** Whether the Attachment has a [Attachment.contentType] for an image */ +val Attachment.isImage: Boolean + get() = this.hasContentType() && contentType.startsWith("image") + +/** Whether the Binary has a [Binary.contentType] for an image */ +fun Binary.isImage(): Boolean = this.hasContentType() && contentType.startsWith("image") + +/** Decodes the Bitmap from the Base64 encoded string in [Bitmap.data] */ +fun Binary.getBitmap(): Bitmap? { + return if (isImage()) { + Base64.decode(this.dataElement.valueAsString, Base64.DEFAULT).let { byteArray -> + BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) + } + } else { + Timber.e("Binary does not have a contentType image") + null + } +} + +/** + * Returns the Bitmap defined in the attachment as inline Base64 encoded image, Binary resource + * defined in the url or externally hosted image. Inline Base64 encoded image requires to have + * contentType starting with image + */ +suspend fun Attachment.fetchBitmap(context: Context): Bitmap? { + // Attachment's with data inline need the contentType property + // Conversion to Bitmap should only be made if the contentType is image + if (data != null) { + if (isImage) { + return BitmapFactory.decodeByteArray(data, 0, data.size) + } + Timber.e("Attachment of contentType ${this.contentType} is not supported") + return null + } else if (url != null && (url.startsWith("https") || url.startsWith("http"))) { + // Points to a Binary resource on a FHIR compliant server + val attachmentResolver = DataCapture.getConfiguration(context).attachmentResolver + return if (url.contains("/Binary/")) { + attachmentResolver?.run { resolveBinaryResource(url)?.getBitmap() } + } else { + attachmentResolver?.resolveImageUrl(url) + } + } + + Timber.e("Could not determine the Bitmap in Attachment $id") + return null +} 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 041a114c74..c77ec91ffe 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 @@ -26,6 +26,8 @@ import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser import com.google.android.fhir.datacapture.enablement.EnablementEvaluator +import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.detectExpressionCyclicDependency +import com.google.android.fhir.datacapture.fhirpath.ExpressionEvaluator.evaluateCalculatedExpressions import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.QuestionnaireResponseItemValidator import com.google.android.fhir.datacapture.validation.QuestionnaireResponseValidator.checkQuestionnaireResponse @@ -46,13 +48,13 @@ import timber.log.Timber internal class QuestionnaireViewModel(application: Application, state: SavedStateHandle) : AndroidViewModel(application) { - + private val parser: IParser by lazy { FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() } - + /** The current questionnaire as questions are being answered. */ internal val questionnaire: Questionnaire private lateinit var currentPageItems: List - + init { questionnaire = when { @@ -60,7 +62,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat if (state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_JSON_STRING)) { Timber.w( "Both EXTRA_QUESTIONNAIRE_JSON_URI & EXTRA_QUESTIONNAIRE_JSON_STRING are provided. " + - "EXTRA_QUESTIONNAIRE_JSON_URI takes precedence." + "EXTRA_QUESTIONNAIRE_JSON_URI takes precedence." ) } val uri: Uri = state[QuestionnaireFragment.EXTRA_QUESTIONNAIRE_JSON_URI]!! @@ -77,13 +79,13 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat ) } } - + @VisibleForTesting val entryMode: EntryMode by lazy { questionnaire.entryMode ?: EntryMode.RANDOM } - + /** The current questionnaire response as questions are being answered. */ private val questionnaireResponse: QuestionnaireResponse - + /** * True if the user has tapped the next/previous pagination buttons on the current page. This is * needed to avoid spewing validation errors before any questions are answered. @@ -95,13 +97,13 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat if (state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING)) { Timber.w( "Both EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI & EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING are provided. " + - "EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI takes precedence." + "EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI takes precedence." ) } val uri: Uri = state[QuestionnaireFragment.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_URI]!! questionnaireResponse = parser.parseResource(application.contentResolver.openInputStream(uri)) - as QuestionnaireResponse + as QuestionnaireResponse checkQuestionnaireResponse(questionnaire, questionnaireResponse) } state.contains(QuestionnaireFragment.EXTRA_QUESTIONNAIRE_RESPONSE_JSON_STRING) -> { @@ -124,14 +126,14 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } } } - + /** * The pre-order traversal trace of the items in the [QuestionnaireResponse]. This essentially * represents the order in which all items are displayed in the UI. */ private val questionnaireResponseItemPreOrderList = mutableListOf() - + init { /** * Adds all items in the [QuestionnaireResponse] to the pre-order list. Note that each @@ -149,16 +151,16 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } } } - + for (item in questionnaireResponse.item) { buildPreOrderList(item) } } - + /** The map from each item in the [Questionnaire] to its parent. */ private var questionnaireItemParentMap: - Map - + Map + init { /** Adds each child-parent pair in the [Questionnaire] to the parent map. */ fun buildParentList( @@ -170,21 +172,21 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat buildParentList(child, questionnaireItemToParentMap) } } - + questionnaireItemParentMap = buildMap { for (item in questionnaire.item) { buildParentList(item, this) } } } - + /** The map from each item in the [QuestionnaireResponse] to its parent. */ private val questionnaireResponseItemParentMap = mutableMapOf< - QuestionnaireResponse.QuestionnaireResponseItemComponent, - QuestionnaireResponse.QuestionnaireResponseItemComponent - >() - + QuestionnaireResponse.QuestionnaireResponseItemComponent, + QuestionnaireResponse.QuestionnaireResponseItemComponent + >() + init { /** Adds each child-parent pair in the [QuestionnaireResponse] to the parent map. */ fun buildParentList(item: QuestionnaireResponse.QuestionnaireResponseItemComponent) { @@ -193,45 +195,45 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat buildParentList(child) } } - + for (item in questionnaireResponse.item) { buildParentList(item) } } - + /** The pages of the questionnaire, or null if the questionnaire is not paginated. */ @VisibleForTesting var pages: List? = questionnaire.getInitialPages() - + /** * The flow representing the index of the current page, or null if the questionnaire is not * paginated. */ @VisibleForTesting val currentPageIndexFlow = MutableStateFlow(getInitialPageIndex()) - + /** Flag to support fragment for review-feature */ private val enableReviewPage: Boolean - + init { enableReviewPage = state[QuestionnaireFragment.EXTRA_ENABLE_REVIEW_PAGE] ?: false } - + /** Flag to open fragment first in data-collection or review-mode */ private val showReviewPageFirst: Boolean - + init { showReviewPageFirst = enableReviewPage && state[QuestionnaireFragment.EXTRA_SHOW_REVIEW_PAGE_FIRST] ?: false } - + /** Tracks modifications in order to update the UI. */ private val modificationCount = MutableStateFlow(0) - + /** Toggles review mode. */ private val reviewFlow = MutableStateFlow(showReviewPageFirst) - + /** Flag to show/hide submit button. */ private var showSubmitButtonFlag = false - + /** * Contains [QuestionnaireResponse.QuestionnaireResponseItemComponent]s that have been modified by * the user. [QuestionnaireResponse.QuestionnaireResponseItemComponent]s that have not been @@ -240,7 +242,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat */ private val modifiedQuestionnaireResponseItemSet = mutableSetOf() - + /** * Callback function to update the view model after the answer(s) to a question have been changed. * This is passed to the [QuestionnaireItemViewItem] in its constructor so that it can invoke this @@ -258,25 +260,26 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat * new answers to the question. */ private val answersChangedCallback: - ( - Questionnaire.QuestionnaireItemComponent, - QuestionnaireResponse.QuestionnaireResponseItemComponent, - List - ) -> Unit = + ( + Questionnaire.QuestionnaireItemComponent, + QuestionnaireResponse.QuestionnaireResponseItemComponent, + List) -> Unit = { questionnaireItem, questionnaireResponseItem, answers -> // TODO(jingtang10): update the questionnaire response item pre-order list and the parent map questionnaireResponseItem.answer = answers.toList() if (questionnaireItem.hasNestedItemsWithinAnswers) { questionnaireResponseItem.addNestedItemsToAnswer(questionnaireItem) } - modifiedQuestionnaireResponseItemSet.add(questionnaireResponseItem) + + runCalculations(questionnaireItem) + modificationCount.update { it + 1 } } - + private val answerValueSetMap = mutableMapOf>() - + /** * Returns current [QuestionnaireResponse] captured by the UI which includes answers of enabled * questions. @@ -286,7 +289,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat item = getEnabledResponseItems(this@QuestionnaireViewModel.questionnaire.item, item) } } - + internal fun goToPreviousPage() { when (entryMode) { EntryMode.PRIOR_EDIT, @@ -303,7 +306,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } } } - + internal fun goToNextPage() { when (entryMode) { EntryMode.PRIOR_EDIT, @@ -314,7 +317,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat isPaginationButtonPressed = true modificationCount.update { it + 1 } } - + if (currentPageItems.all { it.validationResult is Valid }) { isPaginationButtonPressed = false val nextPageIndex = @@ -331,46 +334,74 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } } } - + internal fun setReviewMode(reviewModeFlag: Boolean) { reviewFlow.value = reviewModeFlag } - + internal fun setShowSubmitButtonFlag(showSubmitButton: Boolean) { showSubmitButtonFlag = showSubmitButton } - + /** [QuestionnaireState] to be displayed in the UI. */ internal val questionnaireStateFlow: Flow = combine(modificationCount, currentPageIndexFlow, reviewFlow) { _, pagination, reviewFlow -> - if (reviewFlow) { - getQuestionnaireState( - questionnaireItemList = questionnaire.item, - questionnaireResponseItemList = questionnaireResponse.item, - currentPageIndex = null, - reviewMode = reviewFlow - ) - } else { - getQuestionnaireState( - questionnaireItemList = questionnaire.item, - questionnaireResponseItemList = questionnaireResponse.item, - currentPageIndex = pagination, - reviewMode = reviewFlow - ) - } + if (reviewFlow) { + getQuestionnaireState( + questionnaireItemList = questionnaire.item, + questionnaireResponseItemList = questionnaireResponse.item, + currentPageIndex = null, + reviewMode = reviewFlow + ) + } else { + getQuestionnaireState( + questionnaireItemList = questionnaire.item, + questionnaireResponseItemList = questionnaireResponse.item, + currentPageIndex = pagination, + reviewMode = reviewFlow + ) } + } .stateIn( viewModelScope, SharingStarted.Lazily, initialValue = - getQuestionnaireState( - questionnaireItemList = questionnaire.item, - questionnaireResponseItemList = questionnaireResponse.item, - currentPageIndex = getInitialPageIndex(), - reviewMode = enableReviewPage - ) + getQuestionnaireState( + questionnaireItemList = questionnaire.item, + questionnaireResponseItemList = questionnaireResponse.item, + currentPageIndex = getInitialPageIndex(), + reviewMode = enableReviewPage + ) + .also { detectExpressionCyclicDependency(questionnaire.item) } + .also { questionnaire.item.flattened().forEach { runCalculations(it) } } ) - + + fun runCalculations(updatedQuestionnaireItem: Questionnaire.QuestionnaireItemComponent) { + evaluateCalculatedExpressions( + updatedQuestionnaireItem, + questionnaire, + questionnaireResponse, + modifiedQuestionnaireResponseItemSet, + questionnaireItemParentMap + ) + .forEach { (questionnaireItem, calculatedAnswers) -> + // update all response item with updated values + questionnaireResponseItemPreOrderList + .filter { it.linkId == questionnaireItem.linkId } + .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( uri: String @@ -379,14 +410,14 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat if (answerValueSetMap.contains(uri)) { return answerValueSetMap[uri]!! } - + val options = if (uri.startsWith("#")) { questionnaire.contained .firstOrNull { resource -> resource.id.equals(uri) && - resource.resourceType == ResourceType.ValueSet && - (resource as ValueSet).hasExpansion() + resource.resourceType == ResourceType.ValueSet && + (resource as ValueSet).hasExpansion() } ?.let { resource -> val valueSet = resource as ValueSet @@ -410,7 +441,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat answerValueSetMap[uri] = options return options } - + /** * Traverses through the list of questionnaire items, the list of questionnaire response items and * the list of items in the questionnaire response answer list and populates @@ -425,36 +456,36 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat currentPageIndex: Int?, reviewMode: Boolean ): QuestionnaireState { - + val showReviewButton = enableReviewPage && - !reviewFlow.value && - (currentPageIndex == null || - !QuestionnairePagination(pages = pages!!, currentPageIndex = currentPageIndex) - .hasNextPage) - + !reviewFlow.value && + (currentPageIndex == null || + !QuestionnairePagination(pages = pages!!, currentPageIndex = currentPageIndex) + .hasNextPage) + val showSubmitButton = showSubmitButtonFlag && - !showReviewButton && - (currentPageIndex == null || - !QuestionnairePagination(pages = pages!!, currentPageIndex = currentPageIndex) - .hasNextPage) - + !showReviewButton && + (currentPageIndex == null || + !QuestionnairePagination(pages = pages!!, currentPageIndex = currentPageIndex) + .hasNextPage) + if (currentPageIndex == null) { // Single-page questionnaire return QuestionnaireState( items = getQuestionnaireItemViewItems(questionnaireItemList, questionnaireResponseItemList), pagination = - QuestionnairePagination(false, emptyList(), -1, showSubmitButton, showReviewButton), + QuestionnairePagination(false, emptyList(), -1, showSubmitButton, showReviewButton), reviewMode = reviewMode ) } - + // Paginated questionnaire pages = questionnaireItemList.zip(questionnaireResponseItemList).mapIndexed { - index, - (questionnaireItem, questionnaireResponseItem) -> + index, + (questionnaireItem, questionnaireResponseItem) -> QuestionnairePage( index, EnablementEvaluator.evaluate( @@ -468,22 +499,22 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } return QuestionnaireState( items = - getQuestionnaireItemViewItems( - questionnaireItemList[currentPageIndex], - questionnaireResponseItemList[currentPageIndex] - ), + getQuestionnaireItemViewItems( + questionnaireItemList[currentPageIndex], + questionnaireResponseItemList[currentPageIndex] + ), pagination = - QuestionnairePagination( - true, - pages!!, - currentPageIndex, - showSubmitButton, - showReviewButton - ), + QuestionnairePagination( + true, + pages!!, + currentPageIndex, + showSubmitButton, + showReviewButton + ), reviewMode = reviewMode ) } - + /** * Returns the list of [QuestionnaireItemViewItem]s generated for the questionnaire items and * questionnaire response items. @@ -500,17 +531,17 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat // If there is an enabled questionnaire response available then we use that. Or else we // just use an empty questionnaireResponse Item if (responseIndex < questionnaireResponseItemList.size && - questionnaireItem.linkId == questionnaireResponseItemList[responseIndex].linkId + questionnaireItem.linkId == questionnaireResponseItemList[responseIndex].linkId ) { questionnaireResponseItem = questionnaireResponseItemList[responseIndex] responseIndex += 1 } - + getQuestionnaireItemViewItems(questionnaireItem, questionnaireResponseItem) } .toList() } - + /** * Returns the list of [QuestionnaireItemViewItem]s generated for the questionnaire item and * questionnaire response item. @@ -527,14 +558,14 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat ) { questionnaireResponseItem, linkId -> findEnableWhenQuestionnaireResponseItem(questionnaireResponseItem, linkId) } - + if (!enabled || questionnaireItem.isHidden) { return emptyList() } - + val validationResult = if (modifiedQuestionnaireResponseItemSet.contains(questionnaireResponseItem) || - isPaginationButtonPressed + isPaginationButtonPressed ) { QuestionnaireResponseItemValidator.validate( questionnaireItem, @@ -554,25 +585,25 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat resolveAnswerValueSet = { resolveAnswerValueSet(it) }, ) ) + - getQuestionnaireItemViewItems( - // If nested display item is identified as instructions or flyover, then do not create - // questionnaire state for it. - questionnaireItemList = - questionnaireItem.item.filterNot { - it.type == Questionnaire.QuestionnaireItemType.DISPLAY && - (it.isInstructionsCode || it.isFlyoverCode || it.isHelpCode) - }, - questionnaireResponseItemList = - if (questionnaireResponseItem.answer.isEmpty()) { - questionnaireResponseItem.item - } else { - questionnaireResponseItem.answer.first().item - }, - ) + getQuestionnaireItemViewItems( + // If nested display item is identified as instructions or flyover, then do not create + // questionnaire state for it. + questionnaireItemList = + questionnaireItem.item.filterNot { + it.type == Questionnaire.QuestionnaireItemType.DISPLAY && + (it.isInstructionsCode || it.isFlyoverCode || it.isHelpCode) + }, + questionnaireResponseItemList = + if (questionnaireResponseItem.answer.isEmpty()) { + questionnaireResponseItem.item + } else { + questionnaireResponseItem.answer.first().item + }, + ) currentPageItems = items return items } - + private fun getEnabledResponseItems( questionnaireItemList: List, questionnaireResponseItemList: List, @@ -603,7 +634,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } .toList() } - + /** * Checks if this questionnaire uses pagination via the "page" extension. * @@ -619,7 +650,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } else { null } - + private fun Questionnaire.getInitialPages() = if (questionnaire.isPaginated) { // Assume all pages are enabled to begin with @@ -627,7 +658,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } else { null } - + /** * Find a questionnaire response item in [QuestionnaireResponse] with the given `linkId` starting * from the `origin`. @@ -654,7 +685,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat } parent = questionnaireResponseItemParentMap[parent] } - + // Find the nearest item preceding the origin val itemIndex = questionnaireResponseItemPreOrderList.indexOf(origin) for (index in itemIndex - 1 downTo 0) { @@ -662,20 +693,20 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat return questionnaireResponseItemPreOrderList[index] } } - + // Find the nearest item succeeding the origin for (index in itemIndex + 1 until questionnaireResponseItemPreOrderList.size) { if (questionnaireResponseItemPreOrderList[index].linkId == linkId) { return questionnaireResponseItemPreOrderList[index] } } - + return null } } typealias ItemToParentMap = - MutableMap + MutableMap /** Questionnaire state for the Fragment to consume. */ internal data class QuestionnaireState( @@ -709,4 +740,4 @@ internal val QuestionnairePagination.hasPreviousPage: Boolean get() = pages.any { it.index < currentPageIndex && it.enabled } internal val QuestionnairePagination.hasNextPage: Boolean - get() = pages.any { it.index > currentPageIndex && it.enabled } + get() = pages.any { it.index > currentPageIndex && it.enabled } \ No newline at end of file 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 334d8e8461..aa641478c3 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 @@ -18,7 +18,6 @@ package com.google.android.fhir.datacapture.mapping 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.DataCapture import com.google.android.fhir.datacapture.createQuestionnaireResponseItem import com.google.android.fhir.datacapture.targetStructureMap @@ -77,7 +76,7 @@ object ResourceMapper { private val fhirPathEngine: FHIRPathEngine = with(FhirContext.forCached(FhirVersionEnum.R4)) { - FHIRPathEngine(HapiWorkerContext(this, DefaultProfileValidationSupport(this))) + FHIRPathEngine(HapiWorkerContext(this, this.validationSupport)) } /** diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/utilities/MoreContext.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/utilities/MoreContext.kt new file mode 100644 index 0000000000..98cc2e8479 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/utilities/MoreContext.kt @@ -0,0 +1,42 @@ +/* + * 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.utilities + +import android.content.Context +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ContextThemeWrapper + +/** + * Returns the [AppCompatActivity] if there exists one wrapped inside [ContextThemeWrapper] s, or + * `null` otherwise. + * + * This function is inspired by the function with the same name in `AppCompateDelegateImpl`. See + * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:appcompat/appcompat/src/main/java/androidx/appcompat/app/AppCompatDelegateImpl.java;l=1615 + * + * TODO: find a more robust way to do this as it is not guaranteed that the activity is an + * AppCompatActivity. + */ +internal fun Context.tryUnwrapContext(): AppCompatActivity? { + var context = this + while (true) { + when (context) { + is AppCompatActivity -> return context + is ContextThemeWrapper -> context = context.baseContext + else -> return null + } + } +} diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/utilities/MoreExpressions.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/utilities/MoreExpressions.kt new file mode 100644 index 0000000000..11d6e56005 --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/utilities/MoreExpressions.kt @@ -0,0 +1,28 @@ +/* + * 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.utilities + +import org.hl7.fhir.r4.model.Expression + +/** + * An expression which does not refer to a specific linkId or variable and derives value using a + * generic expression + */ +internal val Expression.hasDynamicExpression + get() = + this.expression.replace(" ", "").contains("linkId='").not() || + this.expression.matches(Regex(".*%\\w+.*")).not() 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..c5e3cacd2b 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 @@ -21,12 +21,11 @@ import android.content.Context import android.icu.text.DateFormat import android.text.TextWatcher import android.view.View -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.view.ContextThemeWrapper import androidx.core.widget.doAfterTextChanged import com.google.android.fhir.datacapture.R import com.google.android.fhir.datacapture.utilities.isAndroidIcuSupported import com.google.android.fhir.datacapture.utilities.localizedString +import com.google.android.fhir.datacapture.utilities.tryUnwrapContext import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.MaxValueConstraintValidator.getMaxValue import com.google.android.fhir.datacapture.validation.MinValueConstraintValidator.getMinValue @@ -73,7 +72,7 @@ internal object QuestionnaireItemDatePickerViewHolderFactory : IsoChronology.INSTANCE, Locale.getDefault() ) - + override fun init(itemView: View) { header = itemView.findViewById(R.id.header) textInputLayout = itemView.findViewById(R.id.text_input_layout) @@ -104,12 +103,13 @@ internal object QuestionnaireItemDatePickerViewHolderFactory : .show(context.supportFragmentManager, TAG) } } - + @SuppressLint("NewApi") // java.time APIs can be used due to desugaring override fun bind(questionnaireItemViewItem: QuestionnaireItemViewItem) { header.bind(questionnaireItemViewItem.questionnaireItem) textInputLayout.hint = localePattern textInputEditText.removeTextChangedListener(textWatcher) + if (isTextUpdateRequired( textInputEditText.context, questionnaireItemViewItem.answers.singleOrNull()?.valueDateType, @@ -117,7 +117,10 @@ internal object QuestionnaireItemDatePickerViewHolderFactory : ) ) { textInputEditText.setText( - questionnaireItemViewItem.answers.singleOrNull() + questionnaireItemViewItem + .answers + .singleOrNull() + ?.takeIf { it.hasValue() } ?.valueDateType ?.localDate ?.localizedString @@ -125,7 +128,7 @@ internal object QuestionnaireItemDatePickerViewHolderFactory : } textWatcher = textInputEditText.doAfterTextChanged { text -> updateAnswer(text.toString()) } } - + override fun displayValidationResult(validationResult: ValidationResult) { textInputLayout.error = when (validationResult) { @@ -133,12 +136,12 @@ internal object QuestionnaireItemDatePickerViewHolderFactory : is Invalid -> validationResult.getSingleStringValidationMessage() } } - + override fun setReadOnly(isReadOnly: Boolean) { textInputEditText.isEnabled = !isReadOnly textInputLayout.isEnabled = !isReadOnly } - + private fun createMaterialDatePicker(): MaterialDatePicker { val selectedDate = questionnaireItemViewItem @@ -156,25 +159,25 @@ internal object QuestionnaireItemDatePickerViewHolderFactory : .setCalendarConstraints(getCalenderConstraint()) .build() } - + private fun getCalenderConstraint(): CalendarConstraints { val min = (getMinValue(questionnaireItemViewItem.questionnaireItem) as? DateType)?.value?.time val max = (getMaxValue(questionnaireItemViewItem.questionnaireItem) as? DateType)?.value?.time - + if (min != null && max != null && min > max) { throw IllegalArgumentException("minValue cannot be greater than maxValue") } - + val listValidators = ArrayList() min?.let { listValidators.add(DateValidatorPointForward.from(it)) } max?.let { listValidators.add(DateValidatorPointBackward.before(it)) } val validators = CompositeDateValidator.allOf(listValidators) - + return CalendarConstraints.Builder().setValidator(validators).build() } - + private fun updateAnswer(text: CharSequence?) { try { val localDate = parseDate(text, textInputEditText.context.applicationContext) @@ -188,7 +191,7 @@ internal object QuestionnaireItemDatePickerViewHolderFactory : } } } - + private fun isTextUpdateRequired( context: Context, answer: DateType?, @@ -207,34 +210,15 @@ internal object QuestionnaireItemDatePickerViewHolderFactory : internal const val TAG = "date-picker" internal val ZONE_ID_UTC = ZoneId.of("UTC") -/** - * Returns the [AppCompatActivity] if there exists one wrapped inside [ContextThemeWrapper] s, or - * `null` otherwise. - * - * This function is inspired by the function with the same name in `AppCompateDelegateImpl`. See - * https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:appcompat/appcompat/src/main/java/androidx/appcompat/app/AppCompatDelegateImpl.java;l=1615 - * - * TODO: find a more robust way to do this as it is not guaranteed that the activity is an - * AppCompatActivity. - */ -fun Context.tryUnwrapContext(): AppCompatActivity? { - var context = this - while (true) { - when (context) { - is AppCompatActivity -> return context - is ContextThemeWrapper -> context = context.baseContext - else -> return null - } - } -} - 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) @@ -245,10 +229,10 @@ internal val Date.localDate internal fun parseDate(text: CharSequence?, context: Context): LocalDate { val localDate = if (isAndroidIcuSupported()) { - DateFormat.getDateInstance(DateFormat.SHORT).parse(text.toString()) - } else { - android.text.format.DateFormat.getDateFormat(context).parse(text.toString()) - } + DateFormat.getDateInstance(DateFormat.SHORT).parse(text.toString()) + } else { + android.text.format.DateFormat.getDateFormat(context).parse(text.toString()) + } .localDate // date/localDate with year more than 4 digit throws data format exception if deep copy // operation get performed on QuestionnaireResponse, @@ -266,4 +250,4 @@ internal fun Int.length() = when (this) { 0 -> 1 else -> log10(abs(toDouble())).toInt() + 1 - } + } \ No newline at end of file diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDateTimePickerViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDateTimePickerViewHolderFactory.kt index 75b1197589..7ee56e60b1 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDateTimePickerViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDateTimePickerViewHolderFactory.kt @@ -29,6 +29,7 @@ import com.google.android.fhir.datacapture.utilities.localizedDateString import com.google.android.fhir.datacapture.utilities.localizedString import com.google.android.fhir.datacapture.utilities.toLocalizedString import com.google.android.fhir.datacapture.utilities.toLocalizedTimeString +import com.google.android.fhir.datacapture.utilities.tryUnwrapContext import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDialogSelectViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDialogSelectViewHolderFactory.kt index 64bb479831..32db59590b 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDialogSelectViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDialogSelectViewHolderFactory.kt @@ -29,6 +29,7 @@ import com.google.android.fhir.datacapture.common.datatype.asStringValue import com.google.android.fhir.datacapture.displayString import com.google.android.fhir.datacapture.itemControl import com.google.android.fhir.datacapture.localizedTextSpanned +import com.google.android.fhir.datacapture.utilities.tryUnwrapContext import com.google.android.fhir.datacapture.validation.Invalid import com.google.android.fhir.datacapture.validation.NotValidated import com.google.android.fhir.datacapture.validation.Valid diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDisplayViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDisplayViewHolderFactory.kt index 113c56da46..7d27969f2b 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDisplayViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemDisplayViewHolderFactory.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Google LLC + * 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. @@ -25,14 +25,17 @@ internal object QuestionnaireItemDisplayViewHolderFactory : override fun getQuestionnaireItemViewHolderDelegate() = object : QuestionnaireItemViewHolderDelegate { private lateinit var header: QuestionnaireItemHeaderView + private lateinit var itemMedia: QuestionnaireItemMediaView override lateinit var questionnaireItemViewItem: QuestionnaireItemViewItem override fun init(itemView: View) { header = itemView.findViewById(R.id.header) + itemMedia = itemView.findViewById(R.id.item_media) } override fun bind(questionnaireItemViewItem: QuestionnaireItemViewItem) { header.bind(questionnaireItemViewItem.questionnaireItem) + itemMedia.bind(questionnaireItemViewItem.questionnaireItem) } override fun displayValidationResult(validationResult: ValidationResult) { 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..fdd8e36b17 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,8 +35,23 @@ 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) } } diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextViewHolderFactory.kt index 708663fdd4..71cac7e076 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemEditTextViewHolderFactory.kt @@ -45,6 +45,7 @@ internal abstract class QuestionnaireItemEditTextViewHolderFactory( abstract class QuestionnaireItemEditTextViewHolderDelegate(private val rawInputType: Int) : QuestionnaireItemViewHolderDelegate { private lateinit var header: QuestionnaireItemHeaderView + private lateinit var itemMedia: QuestionnaireItemMediaView private lateinit var textInputLayout: TextInputLayout private lateinit var textInputEditText: TextInputEditText override lateinit var questionnaireItemViewItem: QuestionnaireItemViewItem @@ -52,6 +53,7 @@ abstract class QuestionnaireItemEditTextViewHolderDelegate(private val rawInputT override fun init(itemView: View) { header = itemView.findViewById(R.id.header) + itemMedia = itemView.findViewById(R.id.item_media) textInputLayout = itemView.findViewById(R.id.text_input_layout) textInputEditText = itemView.findViewById(R.id.text_input_edit_text) textInputEditText.setRawInputType(rawInputType) @@ -80,6 +82,7 @@ abstract class QuestionnaireItemEditTextViewHolderDelegate(private val rawInputT override fun bind(questionnaireItemViewItem: QuestionnaireItemViewItem) { header.bind(questionnaireItemViewItem.questionnaireItem) + itemMedia.bind(questionnaireItemViewItem.questionnaireItem) textInputLayout.hint = questionnaireItemViewItem.questionnaireItem.localizedFlyoverSpanned textInputEditText.removeTextChangedListener(textWatcher) diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemGroupViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemGroupViewHolderFactory.kt index 70f17bd0cf..7b99d4d102 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemGroupViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemGroupViewHolderFactory.kt @@ -29,16 +29,19 @@ internal object QuestionnaireItemGroupViewHolderFactory : override fun getQuestionnaireItemViewHolderDelegate() = object : QuestionnaireItemViewHolderDelegate { private lateinit var header: QuestionnaireGroupTypeHeaderView + private lateinit var itemMedia: QuestionnaireItemMediaView private lateinit var error: TextView override lateinit var questionnaireItemViewItem: QuestionnaireItemViewItem override fun init(itemView: View) { header = itemView.findViewById(R.id.header) + itemMedia = itemView.findViewById(R.id.item_media) error = itemView.findViewById(R.id.error) } override fun bind(questionnaireItemViewItem: QuestionnaireItemViewItem) { header.bind(questionnaireItemViewItem.questionnaireItem) + itemMedia.bind(questionnaireItemViewItem.questionnaireItem) } override fun displayValidationResult(validationResult: ValidationResult) { diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMediaView.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMediaView.kt new file mode 100644 index 0000000000..ee20ce5f4b --- /dev/null +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemMediaView.kt @@ -0,0 +1,62 @@ +/* + * 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.views + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.lifecycle.lifecycleScope +import com.google.android.fhir.datacapture.R +import com.google.android.fhir.datacapture.fetchBitmap +import com.google.android.fhir.datacapture.isImage +import com.google.android.fhir.datacapture.itemMedia +import com.google.android.fhir.datacapture.utilities.tryUnwrapContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.Questionnaire + +class QuestionnaireItemMediaView(context: Context, attrs: AttributeSet?) : + LinearLayout(context, attrs) { + + init { + LayoutInflater.from(context).inflate(R.layout.questionnaire_item_media, this, true) + } + + private var itemImage: ImageView = findViewById(R.id.item_image) + + fun bind(questionnaireItem: Questionnaire.QuestionnaireItemComponent) { + itemImage.setImageBitmap(null) + + questionnaireItem.itemMedia?.let { + val activity = context.tryUnwrapContext()!! + + activity.lifecycleScope.launch(Dispatchers.IO) { + if (it.isImage) { + it.fetchBitmap(itemImage.context)?.run { + launch(Dispatchers.Main) { + itemImage.visibility = View.VISIBLE + itemImage.setImageBitmap(this@run) + } + } + } + } + } + } +} diff --git a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewHolderFactory.kt b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewHolderFactory.kt index 127e1f164b..6d04e041db 100644 --- a/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewHolderFactory.kt +++ b/datacapture/src/main/java/com/google/android/fhir/datacapture/views/QuestionnaireItemViewHolderFactory.kt @@ -22,6 +22,7 @@ import android.view.ViewGroup import androidx.annotation.LayoutRes import androidx.recyclerview.widget.RecyclerView import com.google.android.fhir.datacapture.validation.ValidationResult +import com.google.common.annotations.VisibleForTesting /** * Factory for [QuestionnaireItemViewHolder]. @@ -50,6 +51,7 @@ abstract class QuestionnaireItemViewHolderFactory(@LayoutRes open val resId: Int */ open class QuestionnaireItemViewHolder( itemView: View, + @org.jetbrains.annotations.VisibleForTesting private val delegate: QuestionnaireItemViewHolderDelegate ) : RecyclerView.ViewHolder(itemView) { init { diff --git a/datacapture/src/main/res/layout/questionnaire_item_display_view.xml b/datacapture/src/main/res/layout/questionnaire_item_display_view.xml index df36d75f7e..813120e1be 100644 --- a/datacapture/src/main/res/layout/questionnaire_item_display_view.xml +++ b/datacapture/src/main/res/layout/questionnaire_item_display_view.xml @@ -20,7 +20,7 @@ android:layout_height="wrap_content" android:layout_marginHorizontal="@dimen/item_margin_horizontal" android:layout_marginTop="@dimen/padding_default" - android:orientation="horizontal" + android:orientation="vertical" > + + diff --git a/datacapture/src/main/res/layout/questionnaire_item_edit_text_multi_line_view.xml b/datacapture/src/main/res/layout/questionnaire_item_edit_text_multi_line_view.xml index e647c7733e..235e9358f5 100644 --- a/datacapture/src/main/res/layout/questionnaire_item_edit_text_multi_line_view.xml +++ b/datacapture/src/main/res/layout/questionnaire_item_edit_text_multi_line_view.xml @@ -30,6 +30,12 @@ android:layout_marginBottom="@dimen/padding_default" /> + + + + + + + + + + + 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 94f30ae42e..cec3a04469 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 @@ -16,10 +16,20 @@ package com.google.android.fhir.datacapture +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Build +import android.util.Base64 +import androidx.test.core.app.ApplicationProvider import com.google.android.fhir.datacapture.mapping.ITEM_INITIAL_EXPRESSION_URL +import com.google.android.fhir.datacapture.testing.DataCaptureTestApplication import com.google.common.truth.Truth.assertThat +import java.math.BigDecimal +import java.nio.charset.Charset import java.util.Locale +import kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.Attachment +import org.hl7.fhir.r4.model.Binary import org.hl7.fhir.r4.model.BooleanType import org.hl7.fhir.r4.model.CodeType import org.hl7.fhir.r4.model.CodeableConcept @@ -27,18 +37,29 @@ import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Enumeration import org.hl7.fhir.r4.model.Expression import org.hl7.fhir.r4.model.Extension +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 +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers @RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.P]) +@Config(sdk = [Build.VERSION_CODES.P], application = DataCaptureTestApplication::class) class MoreQuestionnaireItemComponentsTest { + @Before + fun setUp() { + ReflectionHelpers.setStaticField(DataCapture::class.java, "configuration", null) + } + @Test fun itemControl_shouldReturnItemControlCodeDropDown() { val questionnaireItem = @@ -1059,6 +1080,37 @@ class MoreQuestionnaireItemComponentsTest { assertThat(questionItem.itemFirstRep.enableWhenExpression).isNull() } + @Test + fun calculatedExpression_shouldReturnExpression() { + val questionnaire = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + EXTENSION_CALCULATED_EXPRESSION_URL, + Expression().apply { + this.expression = "today()" + this.language = "text/fhirpath" + } + ) + } + assertThat(questionnaire.calculatedExpression).isNotNull() + assertThat(questionnaire.calculatedExpression!!.expression).isEqualTo("today()") + } + + @Test + fun calculatedExpression_shouldReturnNull() { + val questionnaire = + Questionnaire.QuestionnaireItemComponent().apply { + addExtension( + ITEM_INITIAL_EXPRESSION_URL, + Expression().apply { + this.expression = "today()" + this.language = "text/fhirpath" + } + ) + } + assertThat(questionnaire.calculatedExpression).isNull() + } + @Test fun localizedFlyoverSpanned_matchingLocale_shouldReturnFlyover() { val questionItemList = @@ -1253,6 +1305,62 @@ class MoreQuestionnaireItemComponentsTest { .isEqualTo(true) } + @Test + fun createQuestionResponseWithQuantityType_ShouldNotSetAnswer_WithValueEmpty() { + 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 createQuestionResponseWithQuantityType_ShouldSetAnswer() { + 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 = @@ -1262,6 +1370,166 @@ class MoreQuestionnaireItemComponentsTest { assertThat(questionnaireItem.entryFormat).isNull() } + @Test + fun `Attachment#fetchBitmap() should return null when Attachment has data and incorrect contentType`() { + val attachment = + Attachment().apply { + data = "some-byte".toByteArray(Charset.forName("UTF-8")) + contentType = "document/pdf" + } + val bitmap: Bitmap? + runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) } + assertThat(bitmap).isNull() + } + + @Test + fun `Attachment#fetchBitmap() should return null when Attachment has no data and no url`() { + val attachment = Attachment() + val bitmap: Bitmap? + + runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) } + + assertThat(bitmap).isNull() + } + + @Test + fun `Attachment#fetchBitmap() should return Bitmap when Attachment has data and correct contentType`() { + val attachment = + Attachment().apply { + data = + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7".toByteArray( + Charset.forName("UTF-8") + ) + contentType = "image/png" + } + val bitmap: Bitmap? + runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) } + assertThat(bitmap).isNotNull() + } + + @Test + fun `Attachment#isImage() should return true when Attachment contentType is image`() { + val attachment = Attachment().apply { contentType = "image/png" } + assertThat(attachment.isImage).isTrue() + } + + @Test + fun `Attachment#isImage() should return false when Attachment contentType is not image`() { + val attachment = Attachment().apply { contentType = "document/pdf" } + assertThat(attachment.isImage).isFalse() + } + + @Test + fun `Attachment#isImage() should return false when Attachment does not have contentType`() { + val attachment = Attachment() + assertThat(attachment.isImage).isFalse() + } + + @Test + fun `Binary#getBitmap() should return null when contentType is not for an image`() { + val binary = Binary().apply { contentType = "document/pdf" } + assertThat(binary.getBitmap()).isNull() + } + + @Test + fun `Binary#getBitmap() should return null when contentType is null`() { + val binary = Binary() + assertThat(binary.getBitmap()).isNull() + } + + @Test + fun `Binary#isImage() should return false when contentType is not for an image`() { + val binary = Binary().apply { contentType = "document/pdf" } + assertThat(binary.isImage()).isFalse() + } + + @Test + fun `Binary#isImage() should return false when contentType is null`() { + val binary = Binary() + assertThat(binary.isImage()).isFalse() + } + + @Test + fun `Attachment#fetchBitmap() should return Bitmap and call AttachmentResolver#resolveBinaryResource`() { + val attachment = Attachment().apply { url = "https://hapi.fhir.org/Binary/f006" } + val binary = + Binary().apply { + contentType = "image/png" + data = + "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7".toByteArray( + Charset.forName("UTF-8") + ) + } + val bitmap: Bitmap? + val attachmentResolver: AttachmentResolver = mock() + runBlocking { + Mockito.`when`(attachmentResolver.resolveBinaryResource("https://hapi.fhir.org/Binary/f006")) + .doReturn(binary) + } + + ApplicationProvider.getApplicationContext() + .getDataCaptureConfig() + .attachmentResolver = attachmentResolver + + runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) } + + assertThat(bitmap).isNotNull() + runBlocking { + Mockito.verify(attachmentResolver).resolveBinaryResource("https://hapi.fhir.org/Binary/f006") + } + } + + @Test + fun `Attachment#fetchBitmap() should return null when Attachment has Binary resource url but AttachmentResolver not configured`() { + val attachment = Attachment().apply { url = "https://hapi.fhir.org/Binary/f006" } + val bitmap: Bitmap? + + runBlocking { bitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) } + + assertThat(bitmap).isNull() + } + + @Test + fun `Attachment#fetchBitmap() should return Bitmap and call AttachmentResolver#resolveImageUrl`() { + val attachment = Attachment().apply { url = "https://some-image-server.com/images/f0006.png" } + val byteArray = + Base64.decode("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) + val attachmentResolver: AttachmentResolver = mock() + runBlocking { + Mockito.`when`( + attachmentResolver.resolveImageUrl("https://some-image-server.com/images/f0006.png") + ) + .doReturn(bitmap) + } + var context = ApplicationProvider.getApplicationContext() + context.getDataCaptureConfig().attachmentResolver = attachmentResolver + + val resolvedBitmap: Bitmap? + runBlocking { resolvedBitmap = attachment.fetchBitmap(context) } + + assertThat(resolvedBitmap).isEqualTo(bitmap) + runBlocking { + Mockito.verify(attachmentResolver) + .resolveImageUrl("https://some-image-server.com/images/f0006.png") + } + } + + @Test + fun `Attachment#fetchBitmap() should return null when Attachment has external url to image but AttachmentResolver is not configured`() { + val attachment = Attachment().apply { url = "https://some-image-server.com/images/f0006.png" } + + ApplicationProvider.getApplicationContext() + .getDataCaptureConfig() + .attachmentResolver = null + val resolvedBitmap: Bitmap? + runBlocking { + resolvedBitmap = attachment.fetchBitmap(ApplicationProvider.getApplicationContext()) + } + + assertThat(resolvedBitmap).isNull() + } + @Test fun entryFormat_shouldReturnDateFormat() { val questionnaireItem = 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..5e34825194 --- /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 testFragment_ShouldBeAbleToBuildQuestionnaireResponse() { + 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 e8cc64f17c..f85aae11fc 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 @@ -29,12 +29,15 @@ 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 import com.google.android.fhir.datacapture.views.QuestionnaireItemViewItem import com.google.common.truth.Truth.assertThat import java.io.File +import java.util.Calendar +import java.util.Date import kotlin.test.assertFailsWith import kotlin.test.assertTrue import kotlinx.coroutines.Dispatchers @@ -46,13 +49,17 @@ 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.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.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.Test @@ -72,29 +79,29 @@ class QuestionnaireViewModelTest( ) { private lateinit var state: SavedStateHandle private val context = ApplicationProvider.getApplicationContext() - + @Before fun setUp() { state = SavedStateHandle() check( ApplicationProvider.getApplicationContext() - is DataCaptureConfig.Provider + is DataCaptureConfig.Provider ) { "Few tests require a custom application class that implements DataCaptureConfig.Provider" } ReflectionHelpers.setStaticField(DataCapture::class.java, "configuration", null) } - + @Test fun stateHasNoQuestionnaire_shouldThrow() { val errorMessage = assertFailsWith { QuestionnaireViewModel(context, state) } .localizedMessage - + assertThat(errorMessage) .isEqualTo( "Neither EXTRA_QUESTIONNAIRE_JSON_URI nor EXTRA_QUESTIONNAIRE_JSON_STRING is supplied." ) } - + @Test fun stateHasNoQuestionnaireResponse_shouldCopyQuestionnaireUrl() { val questionnaire = @@ -102,7 +109,7 @@ class QuestionnaireViewModelTest( url = "http://www.sample-org/FHIR/Resources/Questionnaire/a-questionnaire" } val viewModel = createQuestionnaireViewModel(questionnaire) - + assertResourceEquals( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { @@ -110,7 +117,7 @@ class QuestionnaireViewModelTest( } ) } - + @Test fun stateHasNoQuestionnaireResponse_shouldCopyQuestion() { val questionnaire = @@ -125,7 +132,7 @@ class QuestionnaireViewModelTest( ) } val viewModel = createQuestionnaireViewModel(questionnaire) - + assertResourceEquals( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { @@ -138,7 +145,7 @@ class QuestionnaireViewModelTest( } ) } - + @Test fun stateHasNoQuestionnaireResponse_shouldCopyQuestionnaireStructure() { val questionnaire = @@ -160,7 +167,7 @@ class QuestionnaireViewModelTest( ) } val viewModel = createQuestionnaireViewModel(questionnaire) - + assertResourceEquals( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { @@ -179,7 +186,7 @@ class QuestionnaireViewModelTest( } ) } - + @Test fun stateHasQuestionnaireResponse_nestedItemsWithinGroupItems_shouldNotThrowException() { val questionnaire = @@ -239,10 +246,10 @@ class QuestionnaireViewModelTest( } ) } - + createQuestionnaireViewModel(questionnaire, questionnaireResponse) } - + @Test fun stateHasQuestionnaireResponse_nestedItemsWithinNonGroupItems_shouldNotThrowException() { val questionnaire = @@ -289,15 +296,15 @@ class QuestionnaireViewModelTest( } ) } - + createQuestionnaireViewModel(questionnaire, questionnaireResponse) } - + @Test fun stateHasQuestionnaireResponse_nonPrimitiveType_shouldNotThrowError() { val testOption1 = Coding("test", "option", "1") val testOption2 = Coding("test", "option", "2") - + val questionnaire = Questionnaire().apply { id = "a-questionnaire" @@ -328,10 +335,10 @@ class QuestionnaireViewModelTest( } ) } - + createQuestionnaireViewModel(questionnaire, questionnaireResponse) } - + @Test fun stateHasQuestionnaireResponse_questionnaireUrlMatches_shouldNotThrowError() { val questionnaire = @@ -344,10 +351,10 @@ class QuestionnaireViewModelTest( id = "a-questionnaire-response" this.questionnaire = "http://www.sample-org/FHIR/Resources/Questionnaire/a-questionnaire" } - + createQuestionnaireViewModel(questionnaire, questionnaireResponse) } - + @Test fun stateHasQuestionnaireResponse_questionnaireUrlDoesNotMatch_shouldThrowError() { val questionnaire = @@ -360,19 +367,19 @@ class QuestionnaireViewModelTest( id = "a-questionnaire-response" this.questionnaire = "Questionnaire/a-questionnaire" } - + val errorMessage = assertFailsWith { - createQuestionnaireViewModel(questionnaire, questionnaireResponse) - } + createQuestionnaireViewModel(questionnaire, questionnaireResponse) + } .localizedMessage - + assertThat(errorMessage) .isEqualTo( "Mismatching Questionnaire http://www.sample-org/FHIR/Resources/Questionnaire/questionnaire-1 and QuestionnaireResponse (for Questionnaire Questionnaire/a-questionnaire)" ) } - + @Test fun stateHasQuestionnaireResponse_wrongLinkId_shouldThrowError() { val questionnaire = @@ -400,17 +407,17 @@ class QuestionnaireViewModelTest( } ) } - + val errorMessage = assertFailsWith { - createQuestionnaireViewModel(questionnaire, questionnaireResponse) - } + createQuestionnaireViewModel(questionnaire, questionnaireResponse) + } .localizedMessage - + assertThat(errorMessage) .isEqualTo("Missing questionnaire item for questionnaire response item a-different-link-id") } - + @Test fun stateHasQuestionnaireResponse_lessItemsInQuestionnaireResponse_shouldAddTheMissingItem() = runBlocking { @@ -442,18 +449,18 @@ class QuestionnaireViewModelTest( } ) } - + val questionnaireViewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) val questionnaireItemViewItem = questionnaireViewModel.questionnaireStateFlow.first() assertThat(questionnaireItemViewItem.items.first().questionnaireItem.linkId) .isEqualTo(questionnaireResponseWithMissingItem.item.first().linkId) assertThat( - questionnaireItemViewItem.items.single().answers.single().valueBooleanType.booleanValue() - ) + questionnaireItemViewItem.items.single().answers.single().valueBooleanType.booleanValue() + ) .isTrue() } - + @Test fun stateHasQuestionnaireResponse_lessItemsInQuestionnaireResponse_shouldCopyAnswer() = runBlocking { @@ -500,31 +507,31 @@ class QuestionnaireViewModelTest( } ) } - + val questionnaireViewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) val questionnaireItemViewItemList = questionnaireViewModel.questionnaireStateFlow.first().items - + // Answer to first question should be created from questionnaire val questionnaireItemViewItem1 = questionnaireItemViewItemList[0] assertThat(questionnaireItemViewItem1.questionnaireItem.linkId).isEqualTo("q1") assertThat(questionnaireItemViewItem1.answers.single().valueBooleanType.booleanValue()) .isFalse() - + // Answer to second question should be copied from questionnaire response val questionnaireItemViewItem2 = questionnaireItemViewItemList[1] assertThat(questionnaireItemViewItem2.questionnaireItem.linkId).isEqualTo("q2") assertThat(questionnaireItemViewItem2.answers.single().valueBooleanType.booleanValue()) .isTrue() - + // Answer to third quesiton should be created from questionnaire val questionnaireItemViewItem3 = questionnaireItemViewItemList[2] assertThat(questionnaireItemViewItem3.questionnaireItem.linkId).isEqualTo("q3") assertThat(questionnaireItemViewItem3.answers.single().valueBooleanType.booleanValue()) .isFalse() } - + @Test fun stateHasQuestionnaireResponse_wrongType_shouldThrowError() { val questionnaire = @@ -552,17 +559,17 @@ class QuestionnaireViewModelTest( } ) } - + val errorMessage = assertFailsWith { - createQuestionnaireViewModel(questionnaire, questionnaireResponse) - } + createQuestionnaireViewModel(questionnaire, questionnaireResponse) + } .localizedMessage - + assertThat(errorMessage) .isEqualTo("Mismatching question type BOOLEAN and answer type string for a-link-id") } - + @Test fun stateHasQuestionnaireResponse_repeatsTrueWithMultipleAnswers_shouldNotThrowError() { val questionnaire = @@ -597,12 +604,12 @@ class QuestionnaireViewModelTest( } ) } - + val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse) - + assertResourceEquals(questionnaireResponse, viewModel.getQuestionnaireResponse()) } - + @Test fun stateHasQuestionnaireResponse_repeatsFalseWithMultipleAnswers_shouldThrowError() { val questionnaire = @@ -636,17 +643,17 @@ class QuestionnaireViewModelTest( } ) } - + val errorMessage = assertFailsWith { - createQuestionnaireViewModel(questionnaire, questionnaireResponse) - } + createQuestionnaireViewModel(questionnaire, questionnaireResponse) + } .localizedMessage - + assertThat(errorMessage) .isEqualTo("Multiple answers for non-repeat questionnaire item a-link-id") } - + @Test fun questionnaireHasInitialValue_shouldSetAnswerValueInQuestionnaireResponse() { val questionnaire = @@ -667,7 +674,7 @@ class QuestionnaireViewModelTest( ) } val viewModel = createQuestionnaireViewModel(questionnaire) - + assertResourceEquals( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { @@ -685,7 +692,7 @@ class QuestionnaireViewModelTest( } ) } - + @Test fun questionnaireHasMultipleInitialValuesForRepeatingCase_shouldSetFirstAnswerValueInQuestionnaireResponse() { val questionnaire = @@ -706,7 +713,7 @@ class QuestionnaireViewModelTest( ) } val viewModel = createQuestionnaireViewModel(questionnaire) - + assertResourceEquals( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { @@ -724,7 +731,7 @@ class QuestionnaireViewModelTest( } ) } - + @Test fun questionnaireHasInitialValueButQuestionnaireResponseAsEmpty_shouldSetEmptyAnswer() { val questionnaire = @@ -758,7 +765,7 @@ class QuestionnaireViewModelTest( } createQuestionnaireViewModel(questionnaire, questionnaireResponse) } - + @Test fun `getQuestionnaireResponse() should have text in questionnaire response item`() { val questionnaire = @@ -776,7 +783,7 @@ class QuestionnaireViewModelTest( ) } val viewModel = createQuestionnaireViewModel(questionnaire) - + assertResourceEquals( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { @@ -794,7 +801,7 @@ class QuestionnaireViewModelTest( } ) } - + @Test fun `getQuestionnaireResponse() should have translated text in questionnaire response item`() { val questionnaire = @@ -819,7 +826,7 @@ class QuestionnaireViewModelTest( ) } val viewModel = createQuestionnaireViewModel(questionnaire) - + assertResourceEquals( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { @@ -837,7 +844,7 @@ class QuestionnaireViewModelTest( } ) } - + @Test fun `getQuestionnaireResponse() should have text in questionnaire response item for nested items`() { val questionnaire = @@ -865,7 +872,7 @@ class QuestionnaireViewModelTest( ) } val viewModel = createQuestionnaireViewModel(questionnaire) - + assertResourceEquals( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { @@ -890,7 +897,7 @@ class QuestionnaireViewModelTest( } ) } - + @Test fun `createQuestionnaireViewModel() should throw IllegalArgumentException for multiple initial values with non repeating item`() { val questionnaire = @@ -910,17 +917,17 @@ class QuestionnaireViewModelTest( } ) } - + val errorMessage = assertFailsWith { createQuestionnaireViewModel(questionnaire) } .localizedMessage - + assertThat(errorMessage) .isEqualTo( "Questionnaire item a-link-id can only have multiple initial values for repeating items. See rule que-13 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial." ) } - + @Test fun questionnaireItemMissingType_shouldThrowError() { val questionnaire = @@ -933,7 +940,7 @@ class QuestionnaireViewModelTest( } ) } - + val questionnaireResponse = QuestionnaireResponse().apply { id = "a-questionnaire-response" @@ -941,16 +948,16 @@ class QuestionnaireViewModelTest( QuestionnaireResponse.QuestionnaireResponseItemComponent().apply { linkId = "a-link-id" } ) } - + val errorMessage = assertFailsWith { - createQuestionnaireViewModel(questionnaire, questionnaireResponse) - } + createQuestionnaireViewModel(questionnaire, questionnaireResponse) + } .localizedMessage - + assertThat(errorMessage).isEqualTo("Questionnaire item must have type") } - + @Test fun questionnaireHasInitialValueAndGroupType_shouldThrowError() { val questionnaire = @@ -968,17 +975,17 @@ class QuestionnaireViewModelTest( } ) } - + val errorMessage = assertFailsWith { createQuestionnaireViewModel(questionnaire) } .localizedMessage - + assertThat(errorMessage) .isEqualTo( "Questionnaire item a-link-id has initial value(s) and is a group or display item. See rule que-8 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial." ) } - + @Test fun questionnaireHasInitialValueAndDisplayType_shouldThrowError() { val questionnaire = @@ -996,17 +1003,17 @@ class QuestionnaireViewModelTest( } ) } - + val errorMessage = assertFailsWith { createQuestionnaireViewModel(questionnaire) } .localizedMessage - + assertThat(errorMessage) .isEqualTo( "Questionnaire item a-link-id has initial value(s) and is a group or display item. See rule que-8 at https://www.hl7.org/fhir/questionnaire-definitions.html#Questionnaire.item.initial." ) } - + @Test fun stateHasQuestionnaireResponse_moreItemsInQuestionnaireResponse_shouldThrowError() { val questionnaire = @@ -1044,17 +1051,17 @@ class QuestionnaireViewModelTest( } ) } - + val errorMessage = assertFailsWith { - createQuestionnaireViewModel(questionnaire, questionnaireResponse) - } + createQuestionnaireViewModel(questionnaire, questionnaireResponse) + } .localizedMessage - + assertThat(errorMessage) .isEqualTo("Missing questionnaire item for questionnaire response item a-different-link-id") } - + @Test fun `should emit questionnaire state flow`() = runBlocking { val questionnaire = @@ -1075,23 +1082,23 @@ class QuestionnaireViewModelTest( } ) } - + val viewModel = createQuestionnaireViewModel(questionnaire) - + val questionnaireItemViewItemList = viewModel.getQuestionnaireItemViewItemList() assertThat(questionnaireItemViewItemList).hasSize(2) - + val firstQuestionnaireItem = questionnaireItemViewItemList[0].questionnaireItem assertThat(firstQuestionnaireItem.linkId).isEqualTo("a-link-id") assertThat(firstQuestionnaireItem.text).isEqualTo("Basic questions") assertThat(firstQuestionnaireItem.type).isEqualTo(Questionnaire.QuestionnaireItemType.GROUP) - + val secondQuestionnaireItem = questionnaireItemViewItemList[1].questionnaireItem assertThat(secondQuestionnaireItem.linkId).isEqualTo("another-link-id") assertThat(secondQuestionnaireItem.text).isEqualTo("Name?") assertThat(secondQuestionnaireItem.type).isEqualTo(Questionnaire.QuestionnaireItemType.STRING) } - + @Test fun `should emit questionnaire state flow without initial validation`() = runBlocking { val questionnaire = @@ -1110,7 +1117,7 @@ class QuestionnaireViewModelTest( val questionnaireItemViewItemList = viewModel.getQuestionnaireItemViewItemList() assertThat(questionnaireItemViewItemList.single().validationResult).isEqualTo(NotValidated) } - + @Test fun `should emit questionnaire state flow with validation for modified items`() = runBlocking { val questionnaire = @@ -1125,10 +1132,10 @@ class QuestionnaireViewModelTest( } ) } - + val viewModel = createQuestionnaireViewModel(questionnaire) var questionnaireItemViewItem: QuestionnaireItemViewItem? = null - + val observer = launch(Dispatchers.Main) { viewModel.questionnaireStateFlow.collect { questionnaireItemViewItem = it.items.single() } @@ -1136,7 +1143,7 @@ class QuestionnaireViewModelTest( try { ShadowLooper.runUiThreadTasksIncludingDelayedTasks() questionnaireItemViewItem!!.clearAnswer() - + ShadowLooper.runUiThreadTasksIncludingDelayedTasks() assertThat(questionnaireItemViewItem!!.validationResult) .isEqualTo(Invalid(listOf("Missing answer for required field."))) @@ -1146,7 +1153,7 @@ class QuestionnaireViewModelTest( observer.cancelAndJoin() } } - + @Test fun `should emit questionnaire state flow without disabled questions`() = runBlocking { val questionnaire = @@ -1172,11 +1179,11 @@ class QuestionnaireViewModelTest( ) } val viewModel = createQuestionnaireViewModel(questionnaire) - + assertThat(viewModel.getQuestionnaireItemViewItemList().single().questionnaireItem.linkId) .isEqualTo("question-1") } - + @Test fun `should emit questionnaire state flow with enabled questions`() = runBlocking { val questionnaire = @@ -1202,14 +1209,14 @@ class QuestionnaireViewModelTest( ) } val viewModel = createQuestionnaireViewModel(questionnaire) - + val questionnaireItemViewItemList = viewModel.getQuestionnaireItemViewItemList() - + assertThat(questionnaireItemViewItemList).hasSize(2) assertThat(questionnaireItemViewItemList[0].questionnaireItem.linkId).isEqualTo("question-1") assertThat(questionnaireItemViewItemList[1].questionnaireItem.linkId).isEqualTo("question-2") } - + @Test fun questionnaireHasNestedItem_ofTypeGroup_shouldNestItemWithinItem() = runBlocking { val questionnaire = @@ -1251,17 +1258,17 @@ class QuestionnaireViewModelTest( ) } val viewModel = createQuestionnaireViewModel(questionnaire) - + viewModel .getQuestionnaireItemViewItemList()[1].setAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - this.value = valueBooleanType.setValue(false) - } - ) - + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + this.value = valueBooleanType.setValue(false) + } + ) + assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } - + @Test @Ignore("https://github.com/google/android-fhir/issues/487") fun questionnaireHasNestedItem_notOfTypeGroup_shouldNestItemWithinAnswerItem() = runBlocking { @@ -1283,7 +1290,7 @@ class QuestionnaireViewModelTest( } ) } - + val questionnaireResponse = QuestionnaireResponse().apply { addItem( @@ -1308,23 +1315,23 @@ class QuestionnaireViewModelTest( ) } val viewModel = createQuestionnaireViewModel(questionnaire) - + viewModel .getQuestionnaireItemViewItemList()[0].setAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - this.value = valueBooleanType.setValue(false) - } - ) + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + this.value = valueBooleanType.setValue(false) + } + ) viewModel .getQuestionnaireItemViewItemList()[1].setAnswer( - QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { - this.value = valueBooleanType.setValue(false) - } - ) - + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().apply { + this.value = valueBooleanType.setValue(false) + } + ) + assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) } - + @Test fun `should show questionnaire items in the active page in a paginated questionnaire`() = runBlocking { @@ -1381,7 +1388,7 @@ class QuestionnaireViewModelTest( assertThat(questionItem.text).isEqualTo("Question on page 1") } } - + @Test fun `should go to next page in a paginated questionnaire`() = runBlocking { val questionnaire = @@ -1418,7 +1425,7 @@ class QuestionnaireViewModelTest( } val viewModel = createQuestionnaireViewModel(questionnaire) var pagination: QuestionnairePagination? = null - + val observer = launch(Dispatchers.Main) { viewModel.questionnaireStateFlow.collect { pagination = it.pagination } @@ -1426,7 +1433,7 @@ class QuestionnaireViewModelTest( try { ShadowLooper.runUiThreadTasksIncludingDelayedTasks() viewModel.goToNextPage() - + ShadowLooper.runUiThreadTasksIncludingDelayedTasks() assertThat(pagination) .isEqualTo( @@ -1442,7 +1449,7 @@ class QuestionnaireViewModelTest( observer.cancelAndJoin() } } - + @Test fun `should go to previous page in a paginated questionnaire`() = runBlocking { val questionnaire = @@ -1479,7 +1486,7 @@ class QuestionnaireViewModelTest( } val viewModel = createQuestionnaireViewModel(questionnaire) var pagination: QuestionnairePagination? = null - + val observer = launch(Dispatchers.Main) { viewModel.questionnaireStateFlow.collect { pagination = it.pagination } @@ -1488,7 +1495,7 @@ class QuestionnaireViewModelTest( ShadowLooper.runUiThreadTasksIncludingDelayedTasks() viewModel.goToNextPage() viewModel.goToPreviousPage() - + ShadowLooper.runUiThreadTasksIncludingDelayedTasks() assertThat(pagination) .isEqualTo( @@ -1504,7 +1511,7 @@ class QuestionnaireViewModelTest( observer.cancelAndJoin() } } - + @Test fun `should skip disabled page in a paginated questionnaire`() = runBlocking { val questionnaire = @@ -1560,7 +1567,7 @@ class QuestionnaireViewModelTest( } val viewModel = createQuestionnaireViewModel(questionnaire) var pagination: QuestionnairePagination? = null - + val observer = launch(Dispatchers.Main) { viewModel.questionnaireStateFlow.collect { pagination = it.pagination } @@ -1568,18 +1575,18 @@ class QuestionnaireViewModelTest( try { ShadowLooper.runUiThreadTasksIncludingDelayedTasks() viewModel.goToNextPage() - + ShadowLooper.runUiThreadTasksIncludingDelayedTasks() assertThat(pagination) .isEqualTo( QuestionnairePagination( isPaginated = true, pages = - listOf( - QuestionnairePage(0, true), - QuestionnairePage(1, false), - QuestionnairePage(2, true) - ), + listOf( + QuestionnairePage(0, true), + QuestionnairePage(1, false), + QuestionnairePage(2, true) + ), currentPageIndex = 2 ) ) @@ -1589,7 +1596,7 @@ class QuestionnaireViewModelTest( observer.cancelAndJoin() } } - + @Test fun `should allow user to move forward using prior entry-mode`() = runBlocking { val entryModeExtension = @@ -1640,7 +1647,7 @@ class QuestionnaireViewModelTest( ShadowLooper.runUiThreadTasksIncludingDelayedTasks() viewModel.goToNextPage() ShadowLooper.runUiThreadTasksIncludingDelayedTasks() - + assertThat(questionnaire.entryMode).isEqualTo(EntryMode.PRIOR_EDIT) assertThat(pagination) .isEqualTo( @@ -1656,7 +1663,7 @@ class QuestionnaireViewModelTest( observer.cancelAndJoin() } } - + @Test fun `should allow user to move forward and back using prior entry-mode`() = runBlocking { val entryModeExtension = @@ -1708,7 +1715,7 @@ class QuestionnaireViewModelTest( viewModel.goToNextPage() viewModel.goToPreviousPage() ShadowLooper.runUiThreadTasksIncludingDelayedTasks() - + assertThat(questionnaire.entryMode).isEqualTo(EntryMode.PRIOR_EDIT) assertThat(pagination) .isEqualTo( @@ -1724,7 +1731,7 @@ class QuestionnaireViewModelTest( observer.cancelAndJoin() } } - + @Test fun `should not allow user to move forward using prior entry-mode`() = runBlocking { val entryModeExtension = @@ -1774,7 +1781,7 @@ class QuestionnaireViewModelTest( ShadowLooper.runUiThreadTasksIncludingDelayedTasks() viewModel.goToNextPage() ShadowLooper.runUiThreadTasksIncludingDelayedTasks() - + assertThat(pagination) .isEqualTo( QuestionnairePagination( @@ -1789,7 +1796,7 @@ class QuestionnaireViewModelTest( observer.cancelAndJoin() } } - + @Test fun `should allow user to move forward using random entry-mode`() = runBlocking { val entryModeExtension = @@ -1832,11 +1839,11 @@ class QuestionnaireViewModelTest( } val viewModel = createQuestionnaireViewModel(questionnaire) viewModel.goToNextPage() - + assertThat(questionnaire.entryMode).isEqualTo(EntryMode.RANDOM) assertTrue(viewModel.currentPageIndexFlow.value == viewModel.pages?.last()?.index) } - + @Test fun `should allow user to move forward and back using random entry-mode`() = runBlocking { val entryModeExtension = @@ -1880,11 +1887,11 @@ class QuestionnaireViewModelTest( val viewModel = createQuestionnaireViewModel(questionnaire) viewModel.goToNextPage() viewModel.goToPreviousPage() - + assertThat(questionnaire.entryMode).isEqualTo(EntryMode.RANDOM) assertTrue(viewModel.currentPageIndexFlow.value == viewModel.pages?.first()?.index) } - + @Test fun `should allow user to move forward when no entry-mode is defined`() = runBlocking { val questionnaire = @@ -1921,11 +1928,11 @@ class QuestionnaireViewModelTest( } val viewModel = createQuestionnaireViewModel(questionnaire) viewModel.goToNextPage() - + assertThat(viewModel.entryMode).isEqualTo(EntryMode.RANDOM) assertTrue(viewModel.currentPageIndexFlow.value == viewModel.pages?.last()?.index) } - + @Test fun `should allow user to move forward and back when no entry-mode is defined`() = runBlocking { val questionnaire = @@ -1963,11 +1970,11 @@ class QuestionnaireViewModelTest( val viewModel = createQuestionnaireViewModel(questionnaire) viewModel.goToNextPage() viewModel.goToPreviousPage() - + assertThat(viewModel.entryMode).isEqualTo(EntryMode.RANDOM) assertTrue(viewModel.currentPageIndexFlow.value == viewModel.pages?.first()?.index) } - + @Test fun `should allow user to move forward only using sequential entry-mode`() = runBlocking { val entryModeExtension = @@ -2018,7 +2025,7 @@ class QuestionnaireViewModelTest( ShadowLooper.runUiThreadTasksIncludingDelayedTasks() viewModel.goToNextPage() ShadowLooper.runUiThreadTasksIncludingDelayedTasks() - + assertThat(questionnaire.entryMode).isEqualTo(EntryMode.SEQUENTIAL) assertThat(pagination) .isEqualTo( @@ -2034,7 +2041,7 @@ class QuestionnaireViewModelTest( observer.cancelAndJoin() } } - + @Test fun `should not allow user to move forward using sequential entry-mode`() = runBlocking { val entryModeExtension = @@ -2084,7 +2091,7 @@ class QuestionnaireViewModelTest( ShadowLooper.runUiThreadTasksIncludingDelayedTasks() viewModel.goToNextPage() ShadowLooper.runUiThreadTasksIncludingDelayedTasks() - + assertThat(pagination) .isEqualTo( QuestionnairePagination( @@ -2099,7 +2106,7 @@ class QuestionnaireViewModelTest( observer.cancelAndJoin() } } - + @Test fun `should not user to move backward only using sequential entry-mode`() = runBlocking { val entryModeExtension = @@ -2151,7 +2158,7 @@ class QuestionnaireViewModelTest( viewModel.goToNextPage() viewModel.goToPreviousPage() ShadowLooper.runUiThreadTasksIncludingDelayedTasks() - + assertThat(pagination) .isEqualTo( QuestionnairePagination( @@ -2166,7 +2173,7 @@ class QuestionnaireViewModelTest( observer.cancelAndJoin() } } - + @Test fun questionnaire_resolveContainedAnswerValueSet() = runBlocking { val valueSetId = "yesnodontknow" @@ -2184,7 +2191,7 @@ class QuestionnaireViewModelTest( display = "Yes" } ) - + addContains( ValueSet.ValueSetExpansionContainsComponent().apply { system = CODE_SYSTEM_YES_NO @@ -2192,7 +2199,7 @@ class QuestionnaireViewModelTest( display = "No" } ) - + addContains( ValueSet.ValueSetExpansionContainsComponent().apply { system = CODE_SYSTEM_YES_NO @@ -2204,56 +2211,56 @@ class QuestionnaireViewModelTest( } ) } - + val viewModel = createQuestionnaireViewModel(questionnaire) val codeSet = viewModel.resolveAnswerValueSet("#$valueSetId") - + assertThat(codeSet.map { it.valueCoding.display }) .containsExactly("Yes", "No", "Don't Know") .inOrder() } - + @Test fun questionnaire_resolveAnswerValueSetExternalResolved() = runBlocking { val questionnaire = Questionnaire().apply { id = "a-questionnaire" } - + ApplicationProvider.getApplicationContext() .dataCaptureConfiguration = DataCaptureConfig( valueSetResolverExternal = - object : ExternalAnswerValueSetResolver { - override suspend fun resolve(uri: String): List { - - return if (uri == CODE_SYSTEM_YES_NO) - listOf( - Coding().apply { - system = CODE_SYSTEM_YES_NO - code = "Y" - display = "Yes" - }, - Coding().apply { - system = CODE_SYSTEM_YES_NO - code = "N" - display = "No" - }, - Coding().apply { - system = CODE_SYSTEM_YES_NO - code = "asked-unknown" - display = "Don't Know" - } - ) - else emptyList() - } + object : ExternalAnswerValueSetResolver { + override suspend fun resolve(uri: String): List { + + return if (uri == CODE_SYSTEM_YES_NO) + listOf( + Coding().apply { + system = CODE_SYSTEM_YES_NO + code = "Y" + display = "Yes" + }, + Coding().apply { + system = CODE_SYSTEM_YES_NO + code = "N" + display = "No" + }, + Coding().apply { + system = CODE_SYSTEM_YES_NO + code = "asked-unknown" + display = "Don't Know" + } + ) + else emptyList() } + } ) - + val viewModel = createQuestionnaireViewModel(questionnaire) val codeSet = viewModel.resolveAnswerValueSet(CODE_SYSTEM_YES_NO) assertThat(codeSet.map { it.valueCoding.display }) .containsExactly("Yes", "No", "Don't Know") .inOrder() } - + @Test fun questionnaireItem_hiddenExtensionTrue_doNotCreateQuestionnaireItemView() = runBlocking { val questionnaire = @@ -2271,15 +2278,15 @@ class QuestionnaireViewModelTest( } ) } - + val serializedQuestionnaire = printer.encodeResourceToString(questionnaire) state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, serializedQuestionnaire) - + val viewModel = QuestionnaireViewModel(context, state) - + assertThat(viewModel.getQuestionnaireItemViewItemList()).isEmpty() } - + @Test fun questionnaireItem_hiddenExtensionFalse_shouldCreateQuestionnaireItemView() = runBlocking { val questionnaire = @@ -2299,13 +2306,13 @@ class QuestionnaireViewModelTest( } val serializedQuestionnaire = printer.encodeResourceToString(questionnaire) state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, serializedQuestionnaire) - + val viewModel = QuestionnaireViewModel(context, state) - + assertThat(viewModel.getQuestionnaireItemViewItemList().single().questionnaireItem.linkId) .isEqualTo("a-boolean-item-1") } - + @Test fun questionnaireItem_hiddenExtensionValueIsNotBoolean_shouldCreateQuestionnaireItemView() = runBlocking { @@ -2326,13 +2333,13 @@ class QuestionnaireViewModelTest( } val serializedQuestionnaire = printer.encodeResourceToString(questionnaire) state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, serializedQuestionnaire) - + val viewModel = QuestionnaireViewModel(context, state) - + assertThat(viewModel.getQuestionnaireItemViewItemList().single().questionnaireItem.linkId) .isEqualTo("a-boolean-item-1") } - + @Test fun `should return questionnaire response without disabled questions`() = runBlocking { val questionnaire = @@ -2358,9 +2365,9 @@ class QuestionnaireViewModelTest( ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) - + val viewModel = QuestionnaireViewModel(context, state) - + assertResourceEquals( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { @@ -2377,7 +2384,7 @@ class QuestionnaireViewModelTest( } ) } - + @Test fun `should return questionnaire response with enabled questions`() = runBlocking { val questionnaire = @@ -2403,9 +2410,9 @@ class QuestionnaireViewModelTest( ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) - + val viewModel = QuestionnaireViewModel(context, state) - + assertResourceEquals( viewModel.getQuestionnaireResponse(), QuestionnaireResponse().apply { @@ -2425,7 +2432,7 @@ class QuestionnaireViewModelTest( } ) } - + @Test fun nestedDisplayItem_parentQuestionItemIsGroup_createQuestionnaireStateItem() = runBlocking { val questionnaire = @@ -2448,13 +2455,13 @@ class QuestionnaireViewModelTest( ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) - + val viewModel = QuestionnaireViewModel(context, state) - + assertThat(viewModel.getQuestionnaireItemViewItemList().last().questionnaireItem.linkId) .isEqualTo("nested-display-question") } - + @Test fun `nested display item with instructions code should not be created as questionnaire state item`() = runBlocking { @@ -2473,7 +2480,7 @@ class QuestionnaireViewModelTest( } ) } - + val questionnaire = Questionnaire().apply { id = "a-questionnaire" @@ -2495,13 +2502,13 @@ class QuestionnaireViewModelTest( ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) - + val viewModel = QuestionnaireViewModel(context, state) - + assertThat(viewModel.getQuestionnaireItemViewItemList().last().questionnaireItem.linkId) .isEqualTo("parent-question") } - + @Test fun `nested display item with flyover code should not be created as questionnaire state item`() = runBlocking { @@ -2520,7 +2527,7 @@ class QuestionnaireViewModelTest( } ) } - + val questionnaire = Questionnaire().apply { id = "a-questionnaire" @@ -2542,13 +2549,13 @@ class QuestionnaireViewModelTest( ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) - + val viewModel = QuestionnaireViewModel(context, state) - + assertThat(viewModel.getQuestionnaireItemViewItemList().last().questionnaireItem.linkId) .isEqualTo("parent-question") } - + @Test fun `nested display item with help code should not be created as questionnaire state item`() = runBlocking { @@ -2567,7 +2574,7 @@ class QuestionnaireViewModelTest( } ) } - + val questionnaire = Questionnaire().apply { id = "a-questionnaire" @@ -2589,13 +2596,13 @@ class QuestionnaireViewModelTest( ) } state.set(EXTRA_QUESTIONNAIRE_JSON_STRING, printer.encodeResourceToString(questionnaire)) - + val viewModel = QuestionnaireViewModel(context, state) - + assertThat(viewModel.getQuestionnaireItemViewItemList().last().questionnaireItem.linkId) .isEqualTo("parent-question") } - + @Test fun `setShowSubmitButtonFlag() to false should not show submit button`() { runBlocking { @@ -2614,7 +2621,7 @@ class QuestionnaireViewModelTest( assertThat(viewModel.questionnaireStateFlow.first().pagination.showSubmitButton).isFalse() } } - + @Test fun `setShowSubmitButtonFlag() to true should show submit button`() { runBlocking { @@ -2633,7 +2640,7 @@ class QuestionnaireViewModelTest( assertThat(viewModel.questionnaireStateFlow.first().pagination.showSubmitButton).isTrue() } } - + @Test fun `state has review feature and submit button to true should show submit button when moved to review page`() { runBlocking { @@ -2653,7 +2660,7 @@ class QuestionnaireViewModelTest( assertThat(viewModel.questionnaireStateFlow.first().pagination.showSubmitButton).isTrue() } } - + @Test fun `state has no review feature should not show review button`() { runBlocking { @@ -2671,7 +2678,7 @@ class QuestionnaireViewModelTest( assertThat(viewModel.questionnaireStateFlow.first().pagination.showReviewButton).isFalse() } } - + @Test fun `state has review feature should show review button`() { runBlocking { @@ -2689,7 +2696,7 @@ class QuestionnaireViewModelTest( assertThat(viewModel.questionnaireStateFlow.first().pagination.showReviewButton).isTrue() } } - + @Test fun `state has review feature and show review page first should not show review button`() { runBlocking { @@ -2712,7 +2719,7 @@ class QuestionnaireViewModelTest( assertThat(viewModel.questionnaireStateFlow.first().pagination.showReviewButton).isFalse() } } - + @Test fun `state has no review feature but show review page first should not show review button`() { runBlocking { @@ -2735,7 +2742,7 @@ class QuestionnaireViewModelTest( assertThat(viewModel.questionnaireStateFlow.first().pagination.showReviewButton).isFalse() } } - + @Test fun `paginated questionnaire with no review feature should not show review button when moved to next page`() = runBlocking { @@ -2781,7 +2788,7 @@ class QuestionnaireViewModelTest( ShadowLooper.runUiThreadTasksIncludingDelayedTasks() viewModel.goToNextPage() ShadowLooper.runUiThreadTasksIncludingDelayedTasks() - + assertThat(pagination?.showReviewButton).isFalse() } finally { observer.cancel() @@ -2789,7 +2796,7 @@ class QuestionnaireViewModelTest( observer.cancelAndJoin() } } - + @Test fun `paginated questionnaire with review feature should show review button when moved to next page`() = runBlocking { @@ -2835,7 +2842,7 @@ class QuestionnaireViewModelTest( ShadowLooper.runUiThreadTasksIncludingDelayedTasks() viewModel.goToNextPage() ShadowLooper.runUiThreadTasksIncludingDelayedTasks() - + assertThat(pagination?.showReviewButton).isTrue() } finally { observer.cancel() @@ -2843,7 +2850,7 @@ class QuestionnaireViewModelTest( observer.cancelAndJoin() } } - + @Test fun `toggle review mode to false should show review button`() { runBlocking { @@ -2862,7 +2869,7 @@ class QuestionnaireViewModelTest( assertThat(viewModel.questionnaireStateFlow.first().pagination.showReviewButton).isTrue() } } - + @Test fun `toggle review mode to true should not show review button`() { runBlocking { @@ -2881,7 +2888,245 @@ class QuestionnaireViewModelTest( assertThat(viewModel.questionnaireStateFlow.first().pagination.showReviewButton).isFalse() } } - + + @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 current = + viewModel.getQuestionnaireItemViewItemList().first { + it.questionnaireItem.linkId == "a-birthdate" + } + + assertThat(current.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") + } + ) + ) + } + + val updated = + viewModel.getQuestionnaireItemViewItemList().first { + it.questionnaireItem.linkId == "a-birthdate" + } + assertThat(updated.getQuestionnaireResponseItem().answer.first().valueDateType.valueAsString) + .isEqualTo(DateType(Date()).apply { add(Calendar.YEAR, -2) }.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, @@ -2900,7 +3145,7 @@ class QuestionnaireViewModelTest( shadowOf(context.contentResolver) .registerInputStream(questionnaireUri, questionnaireFile.inputStream()) } - + questionnaireResponse?.let { if (questionnaireResponseSource == QuestionnaireResponseSource.STRING) { state.set( @@ -2922,13 +3167,19 @@ class QuestionnaireViewModelTest( showReviewPageFirst.let { state.set(EXTRA_SHOW_REVIEW_PAGE_FIRST, it) } return QuestionnaireViewModel(context, state) } - + 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" - + private val paginationExtension = Extension().apply { url = EXTENSION_ITEM_CONTROL_URL @@ -2941,13 +3192,13 @@ class QuestionnaireViewModelTest( ) ) } - + val printer: IParser = FhirContext.forR4().newJsonParser() - + fun assertResourceEquals(r1: IBaseResource, r2: IBaseResource) { assertThat(printer.encodeResourceToString(r1)).isEqualTo(printer.encodeResourceToString(r2)) } - + @JvmStatic @Parameters fun parameters() = @@ -2970,4 +3221,4 @@ enum class QuestionnaireSource { enum class QuestionnaireResponseSource { STRING, URI -} +} \ No newline at end of file diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/testing/DataCaptureTestApplication.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/testing/DataCaptureTestApplication.kt index 1d56aabd95..6389d84335 100644 --- a/datacapture/src/test/java/com/google/android/fhir/datacapture/testing/DataCaptureTestApplication.kt +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/testing/DataCaptureTestApplication.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Google LLC + * 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. @@ -20,8 +20,14 @@ import android.app.Application import com.google.android.fhir.datacapture.DataCaptureConfig /** Application class when you want to test the DataCaptureConfig.Provider */ -internal class DataCaptureTestApplication : Application(), DataCaptureConfig.Provider { +class DataCaptureTestApplication : Application(), DataCaptureConfig.Provider { var dataCaptureConfiguration: DataCaptureConfig? = null - override fun getDataCaptureConfig() = dataCaptureConfiguration ?: DataCaptureConfig() + override fun getDataCaptureConfig(): DataCaptureConfig { + if (dataCaptureConfiguration == null) { + dataCaptureConfiguration = DataCaptureConfig() + } + + return dataCaptureConfiguration!! + } } diff --git a/datacapture/src/test/java/com/google/android/fhir/datacapture/utilities/MoreContextTest.kt b/datacapture/src/test/java/com/google/android/fhir/datacapture/utilities/MoreContextTest.kt new file mode 100644 index 0000000000..2d749b4dd1 --- /dev/null +++ b/datacapture/src/test/java/com/google/android/fhir/datacapture/utilities/MoreContextTest.kt @@ -0,0 +1,47 @@ +/* + * 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.utilities + +import android.app.Application +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ContextThemeWrapper +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class MoreContextTest { + + @Test + fun context_should_return_appCompatActivity() { + val context = AppCompatActivity().tryUnwrapContext() + assertThat(context).isInstanceOf(AppCompatActivity::class.java) + } + + @Test + fun context_should_return_baseContext_as_appCompatActivity() { + val context = ContextThemeWrapper(AppCompatActivity(), 0).tryUnwrapContext() + assertThat(context).isInstanceOf(AppCompatActivity::class.java) + } + + @Test + fun context_should_return_null() { + val context = Application().tryUnwrapContext() + assertThat(context).isNull() + } +} 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..9f32292901 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 shouldSetEmptyDateInput_WhenDateFieldInitialized_AndDateIsNull() { + viewHolder.bind( + QuestionnaireItemViewItem( + Questionnaire.QuestionnaireItemComponent().apply { text = "Question?" }, + QuestionnaireResponse.QuestionnaireResponseItemComponent() + .addAnswer( + QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent().setValue(DateType()) + ), + validationResult = null, + 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 diff --git a/demo/src/main/assets/screener-questionnaire.json b/demo/src/main/assets/screener-questionnaire.json index 1d5e6cc14c..47eac0facc 100644 --- a/demo/src/main/assets/screener-questionnaire.json +++ b/demo/src/main/assets/screener-questionnaire.json @@ -601,35 +601,35 @@ "valueCoding": { "code": "35489007", "display": "Depression", - "system" : "http://snomed.info/sct" + "system": "http://snomed.info/sct" } }, { "valueCoding": { "code": "161445009", "display": "Diabetes", - "system" : "http://snomed.info/sct" + "system": "http://snomed.info/sct" } }, { "valueCoding": { "code": "161501007", "display": "Hypertension", - "system" : "http://snomed.info/sct" + "system": "http://snomed.info/sct" } }, { "valueCoding": { "code": "56265001", "display": "Heart disease", - "system" : "http://snomed.info/sct" + "system": "http://snomed.info/sct" } }, { "valueCoding": { "code": "161450003", "display": "High blood lipids", - "system" : "http://snomed.info/sct" + "system": "http://snomed.info/sct" } } ] @@ -989,21 +989,21 @@ "valueCoding": { "code": "NV", "display": "Not vaccinated", - "system" : "custom" + "system": "custom" } }, { "valueCoding": { "code": "PV", "display": "Partially vaccinated", - "system" : "custom" + "system": "custom" } }, { "valueCoding": { "code": "FV", "display": "Fully vaccinated", - "system" : "custom" + "system": "custom" } } ] @@ -1017,7 +1017,7 @@ "question": "4.1.0", "operator": "=", "answerCoding": { - "system" : "custom", + "system": "custom", "code": "PV" } } @@ -1027,21 +1027,21 @@ "valueCoding": { "code": "AZ", "display": "AstraZeneca", - "system" : "custom" + "system": "custom" } }, { "valueCoding": { "code": "Pfizer", "display": "Pfizer BioNTech", - "system" : "custom" + "system": "custom" } }, { "valueCoding": { "code": "Moderna", "display": "Moderna", - "system" : "custom" + "system": "custom" } } ] @@ -1055,7 +1055,7 @@ "question": "4.1.0", "operator": "=", "answerCoding": { - "system" : "custom", + "system": "custom", "code": "PV" } } @@ -1070,7 +1070,7 @@ "question": "4.1.0", "operator": "=", "answerCoding": { - "system" : "custom", + "system": "custom", "code": "FV" } } @@ -1105,7 +1105,7 @@ "question": "4.1.0", "operator": "=", "answerCoding": { - "system" : "custom", + "system": "custom", "code": "FV" } } @@ -1144,7 +1144,16 @@ { "text": "Add instructions for capturing temperature", "type": "display", - "linkId": "5.1.0" + "linkId": "5.1.0", + "extension": [ + { + "url" : "http://hl7.org/fhir/uv/sdc/StructureDefinition/cpg-itemImage", + "valueAttachment" : { + "contentType": "image/png", + "url": "https://hapi.fhir.org/baseR4/Binary/android-fhir-thermometer-image" + } + } + ] }, { "linkId": "5.2.0", diff --git a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt index d12aa42427..9cdf58af8c 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt @@ -23,14 +23,16 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.FhirEngineConfiguration import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.ServerConfiguration +import com.google.android.fhir.datacapture.DataCaptureConfig import com.google.android.fhir.demo.data.FhirPeriodicSyncWorker import com.google.android.fhir.sync.Sync import com.google.android.fhir.sync.remote.HttpLogger import timber.log.Timber -class FhirApplication : Application() { +class FhirApplication : Application(), DataCaptureConfig.Provider { // Only initiate the FhirEngine when used for the first time, not when the app is created. private val fhirEngine: FhirEngine by lazy { constructFhirEngine() } + var dataCaptureConfiguration: DataCaptureConfig? = null override fun onCreate() { super.onCreate() @@ -53,6 +55,11 @@ class FhirApplication : Application() { ) ) Sync.oneTimeSync(this) + + dataCaptureConfiguration = + DataCaptureConfig().apply { + attachmentResolver = ReferenceAttachmentResolver(this@FhirApplication as Context) + } } private fun constructFhirEngine(): FhirEngine { @@ -62,4 +69,7 @@ class FhirApplication : Application() { companion object { fun fhirEngine(context: Context) = (context.applicationContext as FhirApplication).fhirEngine } + + override fun getDataCaptureConfig(): DataCaptureConfig = + dataCaptureConfiguration ?: DataCaptureConfig() } diff --git a/demo/src/main/java/com/google/android/fhir/demo/ReferenceAttachmentResolver.kt b/demo/src/main/java/com/google/android/fhir/demo/ReferenceAttachmentResolver.kt new file mode 100644 index 0000000000..5f33f586e4 --- /dev/null +++ b/demo/src/main/java/com/google/android/fhir/demo/ReferenceAttachmentResolver.kt @@ -0,0 +1,36 @@ +/* + * 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.demo + +import android.content.Context +import android.graphics.Bitmap +import com.google.android.fhir.datacapture.AttachmentResolver +import com.google.android.fhir.get +import org.hl7.fhir.r4.model.Binary + +class ReferenceAttachmentResolver(val context: Context) : AttachmentResolver { + + override suspend fun resolveBinaryResource(uri: String): Binary? { + return uri.substringAfter("Binary/").substringBefore("/").run { + FhirApplication.fhirEngine(context).get(this) + } + } + + override suspend fun resolveImageUrl(uri: String): Bitmap? { + return null + } +} diff --git a/demo/src/main/java/com/google/android/fhir/demo/data/DownloadWorkManagerImpl.kt b/demo/src/main/java/com/google/android/fhir/demo/data/DownloadWorkManagerImpl.kt index b235951743..c09ec86ff8 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/data/DownloadWorkManagerImpl.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/data/DownloadWorkManagerImpl.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Google LLC + * 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. @@ -29,17 +29,19 @@ import org.hl7.fhir.r4.model.ResourceType class DownloadWorkManagerImpl : DownloadWorkManager { private val resourceTypeList = ResourceType.values().map { it.name } - private val urls = LinkedList(listOf("Patient?address-city=NAIROBI")) + override var nextRequestUrl: String? = null + private val urls = + LinkedList(listOf("Patient?address-city=NAIROBI", "Binary?_id=android-fhir-thermometer-image")) override suspend fun getNextRequestUrl(context: SyncDownloadContext): String? { - var url = urls.poll() ?: return null + nextRequestUrl = urls.poll() ?: return null val resourceTypeToDownload = - ResourceType.fromCode(url.findAnyOf(resourceTypeList, ignoreCase = true)!!.second) + ResourceType.fromCode(nextRequestUrl?.findAnyOf(resourceTypeList, ignoreCase = true)!!.second) context.getLatestTimestampFor(resourceTypeToDownload)?.let { - url = affixLastUpdatedTimestamp(url!!, it) + nextRequestUrl = affixLastUpdatedTimestamp(nextRequestUrl!!, it) } - return url + return nextRequestUrl } override suspend fun processResponse(response: Resource): Collection { diff --git a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt index 3bb878c0b9..896cb41c2d 100644 --- a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt @@ -34,7 +34,9 @@ import com.google.android.fhir.sync.Resolved import com.google.android.fhir.toTimeZoneString import java.time.OffsetDateTime import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -87,12 +89,13 @@ internal class FhirEngineImpl(private val database: Database, private val contex conflictResolver: ConflictResolver, download: suspend (SyncDownloadContext) -> Flow> ) { + download( - object : SyncDownloadContext { - override suspend fun getLatestTimestampFor(type: ResourceType) = database.lastUpdate(type) - } - ) - .collect { resources -> + object : SyncDownloadContext { + override suspend fun getLatestTimestampFor(type: ResourceType) = database.lastUpdate(type) + } + ) + .onEach { resources -> database.withTransaction { val resolved = resolveConflictingResources( @@ -104,6 +107,10 @@ internal class FhirEngineImpl(private val database: Database, private val contex saveResolvedResourcesToDatabase(resolved) } } + .catch { throwable: Throwable -> + Timber.e(throwable, "Error saving remote resource to database") + } + .collect() } private suspend fun saveResolvedResourcesToDatabase(resolved: List?) { @@ -115,9 +122,11 @@ internal class FhirEngineImpl(private val database: Database, private val contex private suspend fun saveRemoteResourcesToDatabase(resources: List) { val timeStamps = - resources.groupBy { it.resourceType }.entries.map { - SyncedResourceEntity(it.key, it.value.maxOf { it.meta.lastUpdated }.toTimeZoneString()) - } + resources + .groupBy { it.resourceType } + .entries.map { + SyncedResourceEntity(it.key, it.value.maxOf { it.meta.lastUpdated }.toTimeZoneString()) + } database.insertSyncedResources(timeStamps, resources) } @@ -207,7 +216,8 @@ internal class FhirEngineImpl(private val database: Database, private val contex */ private val Bundle.BundleEntryResponseComponent.resourceIdAndType: Pair? get() = - location?.split("/")?.takeIf { it.size > 3 }?.let { - it[it.size - 3] to ResourceType.fromCode(it[it.size - 4]) - } + location + ?.split("/") + ?.takeIf { it.size > 3 } + ?.let { it[it.size - 3] to ResourceType.fromCode(it[it.size - 4]) } } diff --git a/engine/src/main/java/com/google/android/fhir/sync/DownloadWorkManager.kt b/engine/src/main/java/com/google/android/fhir/sync/DownloadWorkManager.kt index 994f99fc9d..2144e4c277 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/DownloadWorkManager.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/DownloadWorkManager.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Google LLC + * 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. @@ -26,6 +26,9 @@ import org.hl7.fhir.r4.model.Resource * manager be created or should there be an API to restart a new download job. */ interface DownloadWorkManager { + + var nextRequestUrl: String? + /** * Returns the URL for the next download request, or `null` if there is no more download request * to be issued. diff --git a/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt b/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt index d66b0dc73b..d244a9d20f 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/download/DownloaderImpl.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Google LLC + * 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. @@ -23,6 +23,7 @@ import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.Downloader import com.google.android.fhir.sync.ResourceSyncException import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flow import org.hl7.fhir.r4.model.ResourceType @@ -38,25 +39,29 @@ internal class DownloaderImpl( ) : Downloader { private val resourceTypeList = ResourceType.values().map { it.name } - override suspend fun download(context: SyncDownloadContext): Flow = flow { - var resourceTypeToDownload: ResourceType = ResourceType.Bundle - emit(DownloadState.Started(resourceTypeToDownload)) - var url = downloadWorkManager.getNextRequestUrl(context) - while (url != null) { - try { - resourceTypeToDownload = - ResourceType.fromCode(url.findAnyOf(resourceTypeList, ignoreCase = true)!!.second) - - emit( - DownloadState.Success( - downloadWorkManager.processResponse(dataSource.download(url!!)).toList() + override suspend fun download(context: SyncDownloadContext): Flow = + flow { + val resourceTypeToDownload: ResourceType = ResourceType.Bundle + emit(DownloadState.Started(resourceTypeToDownload)) + var url = downloadWorkManager.getNextRequestUrl(context) + while (url != null) { + emit( + DownloadState.Success( + downloadWorkManager.processResponse(dataSource.download(url)).toList() + ) + ) + url = downloadWorkManager.getNextRequestUrl(context) + } + } + .catch { throwable: Throwable -> + val resourceTypeToDownload = + ResourceType.fromCode( + downloadWorkManager.nextRequestUrl + ?.findAnyOf(resourceTypeList, ignoreCase = true) + ?.second ) + emit( + DownloadState.Failure(ResourceSyncException(resourceTypeToDownload, Exception(throwable))) ) - } catch (exception: Exception) { - emit(DownloadState.Failure(ResourceSyncException(resourceTypeToDownload, exception))) } - - url = downloadWorkManager.getNextRequestUrl(context) - } - } } diff --git a/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt b/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt index 0ae5b55bdc..39d7a9001e 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManager.kt @@ -38,6 +38,7 @@ typealias ResourceSearchParams = Map */ class ResourceParamsBasedDownloadWorkManager(syncParams: ResourceSearchParams) : DownloadWorkManager { + override var nextRequestUrl: String? = null private val resourcesToDownloadWithSearchParams = LinkedList(syncParams.entries) private val urlOfTheNextPagesToDownloadForAResource = LinkedList() @@ -57,7 +58,8 @@ class ResourceParamsBasedDownloadWorkManager(syncParams: ResourceSearchParams) : } } - "${resourceType.name}?${newParams.concatParams()}" + nextRequestUrl = "${resourceType.name}?${newParams.concatParams()}" + nextRequestUrl } } @@ -67,9 +69,9 @@ class ResourceParamsBasedDownloadWorkManager(syncParams: ResourceSearchParams) : } return if (response is Bundle && response.type == Bundle.BundleType.SEARCHSET) { - response.link.firstOrNull { component -> component.relation == "next" }?.url?.let { next -> - urlOfTheNextPagesToDownloadForAResource.add(next) - } + response.link + .firstOrNull { component -> component.relation == "next" } + ?.url?.let { next -> urlOfTheNextPagesToDownloadForAResource.add(next) } response.entry.map { it.resource } } else { diff --git a/engine/src/test-common/java/com/google/android/fhir/resource/TestingUtils.kt b/engine/src/test-common/java/com/google/android/fhir/resource/TestingUtils.kt index 87c2880c92..d42cb05689 100644 --- a/engine/src/test-common/java/com/google/android/fhir/resource/TestingUtils.kt +++ b/engine/src/test-common/java/com/google/android/fhir/resource/TestingUtils.kt @@ -31,7 +31,6 @@ import java.time.OffsetDateTime import java.util.Date import java.util.LinkedList import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Meta import org.hl7.fhir.r4.model.Patient @@ -104,7 +103,12 @@ class TestingUtils constructor(private val iParser: IParser) { ) : DownloadWorkManager { private val urls = LinkedList(queries) - override suspend fun getNextRequestUrl(context: SyncDownloadContext): String? = urls.poll() + override var nextRequestUrl: String? = null + + override suspend fun getNextRequestUrl(context: SyncDownloadContext): String? { + nextRequestUrl = urls.poll() + return nextRequestUrl + } override suspend fun processResponse(response: Resource): Collection { val patient = Patient().setMeta(Meta().setLastUpdated(Date())) @@ -142,12 +146,12 @@ class TestingUtils constructor(private val iParser: IParser) { download: suspend (SyncDownloadContext) -> Flow> ) { download( - object : SyncDownloadContext { - override suspend fun getLatestTimestampFor(type: ResourceType): String { - return "123456788" + object : SyncDownloadContext { + override suspend fun getLatestTimestampFor(type: ResourceType): String { + return "123456788" + } } - } - ) + ) .collect {} } override suspend fun count(search: Search): Long { diff --git a/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt b/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt index ff6acdc43d..f94651fbdd 100644 --- a/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt +++ b/engine/src/test/java/com/google/android/fhir/sync/download/ResourceParamsBasedDownloadWorkManagerTest.kt @@ -115,87 +115,91 @@ class ResourceParamsBasedDownloadWorkManagerTest { @Test fun getNextRequestUrl_withLastUpdatedTimeProvidedInContext_ShouldAppendGtPrefixToLastUpdatedSearchParam() = - runBlockingTest { - val downloadManager = - ResourceParamsBasedDownloadWorkManager(mapOf(ResourceType.Patient to emptyMap())) - val url = - downloadManager.getNextRequestUrl( - object : SyncDownloadContext { - override suspend fun getLatestTimestampFor(type: ResourceType) = "2022-06-28" - } - ) - assertThat(url).isEqualTo("Patient?_sort=_lastUpdated&_lastUpdated=gt2022-06-28") - } + runBlockingTest { + val downloadManager = + ResourceParamsBasedDownloadWorkManager(mapOf(ResourceType.Patient to emptyMap())) + val url = + downloadManager.getNextRequestUrl( + object : SyncDownloadContext { + override suspend fun getLatestTimestampFor(type: ResourceType) = "2022-06-28" + } + ) + assertThat(url).isEqualTo("Patient?_sort=_lastUpdated&_lastUpdated=gt2022-06-28") + } @Test fun getNextRequestUrl_withLastUpdatedSyncParamProvided_shouldReturnUrlWithExactProvidedLastUpdatedSyncParam() = - runBlockingTest { - val downloadManager = - ResourceParamsBasedDownloadWorkManager( - mapOf( - ResourceType.Patient to - mapOf( - SyncDataParams.LAST_UPDATED_KEY to "2022-06-28", - SyncDataParams.SORT_KEY to "status" - ) + runBlockingTest { + val downloadManager = + ResourceParamsBasedDownloadWorkManager( + mapOf( + ResourceType.Patient to + mapOf( + SyncDataParams.LAST_UPDATED_KEY to "2022-06-28", + SyncDataParams.SORT_KEY to "status" + ) + ) ) - ) - val url = - downloadManager.getNextRequestUrl( - object : SyncDownloadContext { - override suspend fun getLatestTimestampFor(type: ResourceType) = "2022-07-07" - } - ) - assertThat(url).isEqualTo("Patient?_lastUpdated=2022-06-28&_sort=status") - } + val url = + downloadManager.getNextRequestUrl( + object : SyncDownloadContext { + override suspend fun getLatestTimestampFor(type: ResourceType) = "2022-07-07" + } + ) + assertThat(url).isEqualTo("Patient?_lastUpdated=2022-06-28&_sort=status") + } @Test fun getNextRequestUrl_withLastUpdatedSyncParamHavingGtPrefix_shouldReturnUrlWithExactProvidedLastUpdatedSyncParam() = - runBlockingTest { - val downloadManager = - ResourceParamsBasedDownloadWorkManager( - mapOf(ResourceType.Patient to mapOf(SyncDataParams.LAST_UPDATED_KEY to "gt2022-06-28")) - ) - val url = - downloadManager.getNextRequestUrl( - object : SyncDownloadContext { - override suspend fun getLatestTimestampFor(type: ResourceType) = "2022-07-07" - } - ) - assertThat(url).isEqualTo("Patient?_lastUpdated=gt2022-06-28&_sort=_lastUpdated") - } + runBlockingTest { + val downloadManager = + ResourceParamsBasedDownloadWorkManager( + mapOf(ResourceType.Patient to mapOf(SyncDataParams.LAST_UPDATED_KEY to "gt2022-06-28")) + ) + val url = + downloadManager.getNextRequestUrl( + object : SyncDownloadContext { + override suspend fun getLatestTimestampFor(type: ResourceType) = "2022-07-07" + } + ) + assertThat(url).isEqualTo("Patient?_lastUpdated=gt2022-06-28&_sort=_lastUpdated") + } @Test fun getNextRequestUrl_withNullUpdatedTimeStamp_shouldReturnUrlWithoutLastUpdatedQueryParam() = - runBlockingTest { - val downloadManager = - ResourceParamsBasedDownloadWorkManager( - mapOf(ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI")) - ) - val actual = - downloadManager.getNextRequestUrl( - object : SyncDownloadContext { - override suspend fun getLatestTimestampFor(type: ResourceType) = null - } - ) - assertThat(actual).isEqualTo("Patient?address-city=NAIROBI&_sort=_lastUpdated") - } + runBlockingTest { + val downloadManager = + ResourceParamsBasedDownloadWorkManager( + mapOf(ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI")) + ) + val actual = + downloadManager.getNextRequestUrl( + object : SyncDownloadContext { + override suspend fun getLatestTimestampFor(type: ResourceType) = null + } + ) + val expectedUrl = "Patient?address-city=NAIROBI&_sort=_lastUpdated" + assertThat(downloadManager.nextRequestUrl).isEqualTo(expectedUrl) + assertThat(actual).isEqualTo(expectedUrl) + } @Test fun getNextRequestUrl_withEmptyUpdatedTimeStamp_shouldReturnUrlWithoutLastUpdatedQueryParam() = - runBlockingTest { - val downloadManager = - ResourceParamsBasedDownloadWorkManager( - mapOf(ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI")) - ) - val actual = - downloadManager.getNextRequestUrl( - object : SyncDownloadContext { - override suspend fun getLatestTimestampFor(type: ResourceType) = "" - } - ) - assertThat(actual).isEqualTo("Patient?address-city=NAIROBI&_sort=_lastUpdated") - } + runBlockingTest { + val downloadManager = + ResourceParamsBasedDownloadWorkManager( + mapOf(ResourceType.Patient to mapOf(Patient.ADDRESS_CITY.paramName to "NAIROBI")) + ) + val actual = + downloadManager.getNextRequestUrl( + object : SyncDownloadContext { + override suspend fun getLatestTimestampFor(type: ResourceType) = "" + } + ) + val expectedUrl = "Patient?address-city=NAIROBI&_sort=_lastUpdated" + assertThat(downloadManager.nextRequestUrl).isEqualTo(expectedUrl) + assertThat(actual).isEqualTo(expectedUrl) + } @Test fun processResponse_withBundleTypeSearchSet_shouldReturnPatient() = runBlockingTest {