Skip to content

Commit

Permalink
Merge branch 'master' into sp/988-landing-page
Browse files Browse the repository at this point in the history
  • Loading branch information
santosh-pingle committed Jan 3, 2022
2 parents c129e57 + 896b0ad commit b1525b5
Show file tree
Hide file tree
Showing 35 changed files with 1,442 additions and 126 deletions.
23 changes: 17 additions & 6 deletions common/src/main/java/com/google/android/fhir/UnitConverter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

package com.google.android.fhir

import java.lang.NullPointerException
import java.math.BigDecimal
import java.math.MathContext
import kotlin.jvm.Throws
Expand All @@ -24,14 +25,22 @@ import org.fhir.ucum.Pair
import org.fhir.ucum.UcumEssenceService
import org.fhir.ucum.UcumException

/** Canonicalizes unit values to UCUM base units. */
/**
* Canonicalizes unit values to UCUM base units.
*
* For details of UCUM, see https://unitsofmeasure.org/
*
* For using UCUM with FHIR, see https://www.hl7.org/fhir/ucum.html
*
* For the implementation of UCUM with FHIR, see https://github.com/FHIR/Ucum-java
*/
object UnitConverter {
private val ucumService by lazy {
UcumEssenceService(this::class.java.getResourceAsStream("/ucum-essence.xml"))
}

/**
* Returns the canonical form of a Ucum Value.
* Returns the canonical form of a UCUM Value.
*
* The canonical form is generated by normalizing [value] to UCUM base units, used to generate
* canonical matches on Quantity Search
Expand All @@ -47,13 +56,15 @@ object UnitConverter {
pair.code,
pair.value.asDecimal().toBigDecimal(MathContext(value.value.precision()))
)
} catch (exception: UcumException) {
exception.printStackTrace()
throw ConverterException(exception)
} catch (e: UcumException) {
throw ConverterException("UCUM conversion failed", e)
} catch (e: NullPointerException) {
// See https://github.com/google/android-fhir/issues/869 for why NPE needs to be caught
throw ConverterException("Missing numerical value in the canonical UCUM value", e)
}
}
}

class ConverterException(cause: Throwable) : Exception(cause)
class ConverterException(message: String, cause: Throwable) : Exception(message, cause)

data class UcumValue(val code: String, val value: BigDecimal)
51 changes: 51 additions & 0 deletions common/src/test/java/com/google/android/fhir/UnitConverterTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Copyright 2021 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
*
* https://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

import android.os.Build
import com.google.common.truth.Truth.assertThat
import java.math.BigDecimal
import org.junit.Assert.assertThrows
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 UnitConverterTest {

@Test
fun `should convert 1kg to 1000g`() {
val canonicalValue = UnitConverter.getCanonicalForm(UcumValue("kg", BigDecimal.valueOf(1.0)))

assertThat(canonicalValue.code).isEqualTo("g")
assertThat(canonicalValue.value.toInt()).isEqualTo(1000)
}

@Test
fun `should throw exception while converting Cel`() {
val exception =
assertThrows(ConverterException::class.java) {
UnitConverter.getCanonicalForm(UcumValue("Cel", BigDecimal.valueOf(37.0)))
}

assertThat(exception)
.hasMessageThat()
.isEqualTo("Missing numerical value in the canonical UCUM value")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import ca.uhn.fhir.context.FhirContext
import com.google.android.fhir.datacapture.enablement.EnablementEvaluator
import com.google.android.fhir.datacapture.validation.QuestionnaireResponseValidator.validateQuestionnaireResponseStructure
import com.google.android.fhir.datacapture.validation.QuestionnaireResponseValidator.checkQuestionnaireResponse
import com.google.android.fhir.datacapture.views.QuestionnaireItemViewItem
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
Expand Down Expand Up @@ -80,7 +80,7 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
questionnaireResponse =
FhirContext.forR4().newJsonParser().parseResource(questionnaireJsonResponseString) as
QuestionnaireResponse
validateQuestionnaireResponseStructure(questionnaire, questionnaireResponse)
checkQuestionnaireResponse(questionnaire, questionnaireResponse)
} else {
questionnaireResponse =
QuestionnaireResponse().apply {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import android.content.Context
import com.google.android.fhir.datacapture.hasNestedItemsWithinAnswers
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.Type

object QuestionnaireResponseValidator {

Expand Down Expand Up @@ -69,88 +70,168 @@ object QuestionnaireResponseValidator {
}

/**
* Traverse (DFS) through the [Questionnaire.item] list and the [QuestionnaireResponse.item] list
* to check if the linkId of the matching pairs of questionnaire item and questionnaire response
* item are equal.
* Checks that the [QuestionnaireResponse] is structurally consistent with the [Questionnaire].
* - Each item in the [QuestionnaireResponse] must have a corresponding item in the
* [Questionnaire] with the same `linkId` and `type`
* - The order of items in the [QuestionnaireResponse] must be the same as the order of the items
* in the [Questionnaire]
* -
* [Items nested under group](https://www.hl7.org/fhir/questionnaireresponse-definitions.html#QuestionnaireResponse.item.item)
* and
* [items nested under answer](https://www.hl7.org/fhir/questionnaireresponse-definitions.html#QuestionnaireResponse.item.answer.item)
* should follow the same rules recursively
*
* Note that although all the items in the [Questionnaire] SHOULD be included in the
* [QuestionnaireResponse], we do not throw an exception for missing items. This allows the
* [QuestionnaireResponse] to not include items that are not enabled due to `enableWhen`.
*
* @throws IllegalArgumentException if `questionnaireResponse` does not match `questionnaire`'s
* URL (if specified)
* @throws IllegalArgumentException if there is no questionnaire item with the same `linkId` as a
* questionnaire response item
* @throws IllegalArgumentException if the questionnaire response items are out of order
* @throws IllegalArgumentException if the type of a questionnaire response item does not match
* that of the questionnaire item
* @throws IllegalArgumentException if multiple answers are provided for a non-repeat
* questionnaire item
*
* See https://www.hl7.org/fhir/questionnaireresponse.html#link for more information.
*/
fun validateQuestionnaireResponseStructure(
fun checkQuestionnaireResponse(
questionnaire: Questionnaire,
questionnaireResponse: QuestionnaireResponse
) {
validateQuestionnaireResponseItemsStructurally(questionnaire.item, questionnaireResponse.item)
require(
questionnaireResponse.questionnaire == null ||
questionnaire.url == questionnaireResponse.questionnaire
) {
"Mismatching Questionnaire ${questionnaire.url} and QuestionnaireResponse (for Questionnaire ${questionnaireResponse.questionnaire})"
}
checkQuestionnaireResponseItems(questionnaire.item, questionnaireResponse.item)
}

private fun validateQuestionnaireResponseItemsStructurally(
private fun checkQuestionnaireResponseItems(
questionnaireItemList: List<Questionnaire.QuestionnaireItemComponent>,
questionnaireResponseInputItemList:
List<QuestionnaireResponse.QuestionnaireResponseItemComponent>,
questionnaireResponseItemList: List<QuestionnaireResponse.QuestionnaireResponseItemComponent>
) {
val questionnaireResponseInputItemListIterator = questionnaireResponseInputItemList.iterator()
val questionnaireItemListIterator = questionnaireItemList.iterator()
val questionnaireItemIterator = questionnaireItemList.iterator()
val questionnaireResponseInputItemIterator = questionnaireResponseItemList.iterator()

while (questionnaireResponseInputItemListIterator.hasNext()) {
// TODO: Validate type and item nesting within answers for repeated answers
// https://github.com/google/android-fhir/issues/286
val questionnaireResponseInputItem = questionnaireResponseInputItemListIterator.next()
if (questionnaireItemListIterator.hasNext()) {
val questionnaireItem = questionnaireItemListIterator.next()
require(questionnaireItem.linkId == questionnaireResponseInputItem.linkId) {
"Mismatching linkIds for questionnaire item ${questionnaireItem.linkId} and " +
"questionnaire response item ${questionnaireResponseInputItem.linkId}"
}
val type = checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" }
if (questionnaireResponseInputItem.hasAnswer() &&
type != Questionnaire.QuestionnaireItemType.GROUP
) {
if (!questionnaireItem.repeats && questionnaireResponseInputItem.answer.size > 1) {
throw IllegalArgumentException(
"Multiple answers in ${questionnaireResponseInputItem.linkId} and repeats false in " +
"questionnaire item ${questionnaireItem.linkId}"
)
}
questionnaireResponseInputItem.answer.forEachIndexed {
index,
questionnaireResponseItemAnswerComponent ->
if (questionnaireResponseItemAnswerComponent.hasValue()) {
when (type) {
Questionnaire.QuestionnaireItemType.BOOLEAN,
Questionnaire.QuestionnaireItemType.DECIMAL,
Questionnaire.QuestionnaireItemType.INTEGER,
Questionnaire.QuestionnaireItemType.DATE,
Questionnaire.QuestionnaireItemType.DATETIME,
Questionnaire.QuestionnaireItemType.TIME,
Questionnaire.QuestionnaireItemType.STRING,
Questionnaire.QuestionnaireItemType.URL ->
if (!questionnaireResponseItemAnswerComponent
.value
.fhirType()
.equals(type.toCode())
) {
throw IllegalArgumentException(
"Type mismatch for linkIds for questionnaire item ${questionnaireItem.linkId} and " +
"questionnaire response item ${questionnaireResponseInputItem.linkId}"
)
}
else -> Unit // Check type for primitives only
}
}
validateQuestionnaireResponseItemsStructurally(
questionnaireItem.item,
questionnaireResponseItemAnswerComponent.item
)
}
} else if (questionnaireResponseInputItem.hasItem()) {
validateQuestionnaireResponseItemsStructurally(
questionnaireItem.item,
questionnaireResponseInputItem.item
)
}
} else {
// Input response has more items
throw IllegalArgumentException(
"No matching questionnaire item for questionnaire response item ${questionnaireResponseInputItem.linkId}"
)
while (questionnaireResponseInputItemIterator.hasNext()) {
val questionnaireResponseItem = questionnaireResponseInputItemIterator.next()
var questionnaireItem: Questionnaire.QuestionnaireItemComponent?
do {
require(questionnaireItemIterator.hasNext()) {
"Missing questionnaire item for questionnaire response item ${questionnaireResponseItem.linkId}"
}
questionnaireItem = questionnaireItemIterator.next()
} while (questionnaireItem!!.linkId != questionnaireResponseItem.linkId)

checkQuestionnaireResponseItem(questionnaireItem, questionnaireResponseItem)
}
}

private fun checkQuestionnaireResponseItem(
questionnaireItem: Questionnaire.QuestionnaireItemComponent,
questionnaireResponseItem: QuestionnaireResponse.QuestionnaireResponseItemComponent,
) {
when (checkNotNull(questionnaireItem.type) { "Questionnaire item must have type" }) {
Questionnaire.QuestionnaireItemType.DISPLAY, Questionnaire.QuestionnaireItemType.NULL -> Unit
Questionnaire.QuestionnaireItemType.GROUP ->
// Nested items under group
// https://www.hl7.org/fhir/questionnaireresponse-definitions.html#QuestionnaireResponse.item.item
checkQuestionnaireResponseItems(questionnaireItem.item, questionnaireResponseItem.item)
else -> {
require(questionnaireItem.repeats || questionnaireResponseItem.answer.size <= 1) {
"Multiple answers for non-repeat questionnaire item ${questionnaireItem.linkId}"
}
questionnaireResponseItem.answer.forEach {
checkQuestionnaireResponseAnswerItem(questionnaireItem, it)
}
}
}
}

private fun checkQuestionnaireResponseAnswerItem(
questionnaireItem: Questionnaire.QuestionnaireItemComponent,
answerItem: QuestionnaireResponse.QuestionnaireResponseItemAnswerComponent
) {
if (answerItem.hasValue()) {
checkQuestionnaireResponseAnswerItemType(
questionnaireItem.linkId,
questionnaireItem.type,
answerItem.value
)
}
// Nested items under answer
// https://www.hl7.org/fhir/questionnaireresponse-definitions.html#QuestionnaireResponse.item.answer.item
checkQuestionnaireResponseItems(questionnaireItem.item, answerItem.item)
}

private fun checkQuestionnaireResponseAnswerItemType(
linkId: String,
questionnaireItemType: Questionnaire.QuestionnaireItemType,
value: Type
) {
val answerType = value.fhirType()
when (questionnaireItemType) {
Questionnaire.QuestionnaireItemType.BOOLEAN ->
require(answerType == "boolean") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
Questionnaire.QuestionnaireItemType.DECIMAL ->
require(answerType == "decimal") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
Questionnaire.QuestionnaireItemType.INTEGER ->
require(answerType == "integer") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
Questionnaire.QuestionnaireItemType.DATE ->
require(answerType == "date") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
Questionnaire.QuestionnaireItemType.DATETIME ->
require(answerType == "datetime") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
Questionnaire.QuestionnaireItemType.TIME ->
require(answerType == "time") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
Questionnaire.QuestionnaireItemType.STRING ->
require(answerType == "string") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
Questionnaire.QuestionnaireItemType.TEXT ->
require(answerType == "string") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
Questionnaire.QuestionnaireItemType.URL ->
require(answerType == "url") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
Questionnaire.QuestionnaireItemType.CHOICE ->
require(answerType == "Coding") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
Questionnaire.QuestionnaireItemType.OPENCHOICE ->
require(answerType == "Coding" || answerType == "string") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
Questionnaire.QuestionnaireItemType.ATTACHMENT ->
require(answerType == "attachment") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
Questionnaire.QuestionnaireItemType.REFERENCE ->
require(answerType == "reference") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
Questionnaire.QuestionnaireItemType.QUANTITY ->
require(answerType == "quantity") {
"Mismatching question type $questionnaireItemType and answer type $answerType for $linkId"
}
else -> Unit
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ internal object QuestionnaireItemAutoCompleteViewHolderFactory :
val adapter =
ArrayAdapter(
chipContainer.context,
android.R.layout.simple_dropdown_item_1line,
R.layout.questionnaire_item_drop_down_list,
answerOptionString
)
autoCompleteTextView.setAdapter(adapter)
Expand Down Expand Up @@ -216,7 +216,7 @@ internal object QuestionnaireItemAutoCompleteViewHolderFactory :
): Boolean {
if (chipIsAlreadyPresent(answer)) return false

val chip = Chip(chipContainer.context)
val chip = Chip(chipContainer.context, null, R.attr.chipStyleQuestionnaire)
chip.text = answer.valueCoding.display
chip.isCloseIconVisible = true
chip.isClickable = true
Expand Down
Loading

0 comments on commit b1525b5

Please sign in to comment.