Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Launch and Submission Timestamps to QR #2672

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import com.google.android.fhir.datacapture.extensions.isHelpCode
import com.google.android.fhir.datacapture.extensions.isHidden
import com.google.android.fhir.datacapture.extensions.isPaginated
import com.google.android.fhir.datacapture.extensions.isRepeatedGroup
import com.google.android.fhir.datacapture.extensions.launchTimestamp
import com.google.android.fhir.datacapture.extensions.localizedTextSpanned
import com.google.android.fhir.datacapture.extensions.maxValue
import com.google.android.fhir.datacapture.extensions.maxValueCqfCalculatedValueExpression
Expand All @@ -64,6 +65,7 @@ import com.google.android.fhir.datacapture.validation.Valid
import com.google.android.fhir.datacapture.validation.ValidationResult
import com.google.android.fhir.datacapture.views.QuestionTextConfiguration
import com.google.android.fhir.datacapture.views.QuestionnaireViewItem
import java.util.Date
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
Expand All @@ -74,6 +76,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.withIndex
import kotlinx.coroutines.launch
import org.hl7.fhir.r4.model.DateTimeType
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent
import org.hl7.fhir.r4.model.QuestionnaireResponse
Expand Down Expand Up @@ -160,6 +163,8 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
.forEach { questionnaireResponse.addItem(it.createQuestionnaireResponseItem()) }
}
}
// Add extension for questionnaire launch time stamp
questionnaireResponse.launchTimestamp = DateTimeType(Date())
questionnaireResponse.packRepeatedGroups(questionnaire)
}

Expand Down Expand Up @@ -475,6 +480,8 @@ internal class QuestionnaireViewModel(application: Application, state: SavedStat
)
.map { it.copy() }
unpackRepeatedGroups([email protected])
// Use authored as a submission time stamp
authored = Date()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,14 @@

package com.google.android.fhir.datacapture.extensions

import org.hl7.fhir.r4.model.DateTimeType
import org.hl7.fhir.r4.model.Extension
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse

internal const val EXTENSION_LAUNCH_TIMESTAMP: String =
"http://github.com/google-android/questionnaire-launch-timestamp"

/** Pre-order list of all questionnaire response items in the questionnaire. */
val QuestionnaireResponse.allItems: List<QuestionnaireResponse.QuestionnaireResponseItemComponent>
get() = item.flatMap { it.descendant }
Expand Down Expand Up @@ -154,3 +159,21 @@ private fun unpackRepeatedGroups(
listOf(questionnaireResponseItem)
}
}

/** Adds a launch timestamp as an extension to the Questionnaire Response */
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you clarify in the doc that we only add the launch timestamp if there isn't one at the moment?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or should we make this extension repeated so that each time you edit the questionnaire response it is recorded.

useful for audit purposes?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OR if we really want to only keep one (to be a bit more consistent with the authored fied) we can rename this extension to lastLaunched?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion, the lastLaunched will make more sense. cc: @qiarie

internal var QuestionnaireResponse.launchTimestamp: DateTimeType?
get() {
val extension = this.extension.firstOrNull { it.url == EXTENSION_LAUNCH_TIMESTAMP }
return extension?.value as? DateTimeType
}
set(value) {
val noLaunchTimeStampExists = this.extension.none { it.url == EXTENSION_LAUNCH_TIMESTAMP }
if (noLaunchTimeStampExists) {
this.extension.add(
Extension(
EXTENSION_LAUNCH_TIMESTAMP,
value,
),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,8 +183,23 @@ class QuestionnaireViewModelParameterizedTest(
val printer: IParser = FhirContext.forR4().newJsonParser()

fun <T : IBaseResource> assertResourceEquals(actual: T, expected: T) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as @ndegwamartin pointed out, might as well split this assert function into two to make it clearer and use the right one in the relevant tests...

i think at the moment this code might hide errors and make debugging difficult in the future.

Copy link
Author

@hamza-vd hamza-vd Oct 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ndegwamartin can you please elaborate further on how can we split the assertion here?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create a new function to assert two questionnaire responses in which you fiddle with the timestamps.

this assertion is used by a lot of tests. seems at least inefficient to do this operation for all resource types.

Copy link
Author

@hamza-vd hamza-vd Oct 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jingtang10 @ndegwamartin cc: @qiarie

The updates I made to this method were not to test my own code, but to ensure the failing tests would pass. You can find the tests for my implementation here.

After adding timestamps to the QuestionnaireResponses, many tests that asserted response equality started failing due to mismatching timestamps. Although the difference was only in milliseconds, it still caused the checks to fail. This left me with two options:

  1. Update all the failing tests to use the same timestamp values for both launch and submission.
  2. Modify the assertion method to programmatically adjust the expected and actual response timestamps.

I chose the second approach as it was more time-efficient, and we already have separate tests for timestamp functionality. I hope this makes sense.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is that the function is now doing things that is quite specific to questionnaire responses and someone in the future is going to call the assertResouceEquals function and assume that it's just testing two resoures are equal but it's not... From a code health's perspective this is an anti pattern... we should make sure that the function does what the name suggests that it does.

Please create a function called assertQuestionnaireResponseEqualsIgnoringLaunchTimestamp which takes two QuestionnaireResponses. In the test cases that you are seeing that fail - they should use this new funciton instead of the old function assertResourceEquals.

I think this change shouldn't be too big - it should be easy to find and replace usage of this function with questionnaire responses and use the new funciton.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also make sure you don't mutate the resources - make a copy and then remove the timestamp.

assertThat(printer.encodeResourceToString(actual))
.isEqualTo(printer.encodeResourceToString(expected))
if (actual is QuestionnaireResponse && expected is QuestionnaireResponse) {
val actualResponse = (actual as QuestionnaireResponse)
val expectedResponse =
(expected as QuestionnaireResponse).apply {
extension.add(
actualResponse.extension.firstOrNull { extension ->
extension.url == "http://github.com/google-android/questionnaire-launch-timestamp"
},
)
authored = actualResponse.authored
}
assertThat(printer.encodeResourceToString(actualResponse))
.isEqualTo(printer.encodeResourceToString(expectedResponse))
} else {
assertThat(printer.encodeResourceToString(actual))
.isEqualTo(printer.encodeResourceToString(expected))
}
}

@JvmStatic
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@ class QuestionnaireViewModelTest {

val viewModel = createQuestionnaireViewModel(questionnaire, questionnaireResponse)

runTest { assertResourceEquals(questionnaireResponse, viewModel.getQuestionnaireResponse()) }
runTest { assertResourceEquals(viewModel.getQuestionnaireResponse(), questionnaireResponse) }
}

@Test
Expand Down Expand Up @@ -7514,8 +7514,23 @@ class QuestionnaireViewModelTest {
val printer: IParser = FhirContext.forR4().newJsonParser()

fun <T : IBaseResource> assertResourceEquals(actual: T, expected: T) {
assertThat(printer.encodeResourceToString(actual))
.isEqualTo(printer.encodeResourceToString(expected))
if (actual is QuestionnaireResponse && expected is QuestionnaireResponse) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here.

val actualResponse = (actual as QuestionnaireResponse)
val expectedResponse =
(expected as QuestionnaireResponse).apply {
extension.add(
actualResponse.extension.firstOrNull { extension ->
extension.url == "http://github.com/google-android/questionnaire-launch-timestamp"
},
)
authored = actualResponse.authored
}
assertThat(printer.encodeResourceToString(actualResponse))
.isEqualTo(printer.encodeResourceToString(expectedResponse))
} else {
assertThat(printer.encodeResourceToString(actual))
.isEqualTo(printer.encodeResourceToString(expected))
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.context.FhirVersionEnum
import com.google.common.truth.Truth.assertThat
import org.hl7.fhir.r4.model.BooleanType
import org.hl7.fhir.r4.model.DateTimeType
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.Questionnaire.QuestionnaireItemComponent
import org.hl7.fhir.r4.model.QuestionnaireResponse
Expand Down Expand Up @@ -695,4 +696,36 @@ class MoreQuestionnaireResponsesTest {
assertThat(iParser.encodeResourceToString(actual))
.isEqualTo(iParser.encodeResourceToString(expected))
}

@Test
fun `should add launchTimestamp`() {
val questionnaireResponse = QuestionnaireResponse()
val dateTimeType = DateTimeType("2024-07-05T00:00:00Z")
questionnaireResponse.launchTimestamp = dateTimeType

val launchTimestamp = questionnaireResponse.launchTimestamp
assertThat(launchTimestamp).isNotNull()
assertThat(dateTimeType).isEqualTo(launchTimestamp)
hamza-vd marked this conversation as resolved.
Show resolved Hide resolved
}

@Test
fun `launchTimestamp should be null when not added`() {
val questionnaireResponse = QuestionnaireResponse()

val launchTimestamp = questionnaireResponse.launchTimestamp
assertThat(launchTimestamp).isNull()
hamza-vd marked this conversation as resolved.
Show resolved Hide resolved
}

@Test
fun `launchTimestamp should not update if already exists`() {
val questionnaireResponse = QuestionnaireResponse()
val oldDateTimeType = DateTimeType("2024-07-01T00:00:00Z")
val newDateTimeType = DateTimeType("2024-07-05T00:00:00Z")
questionnaireResponse.launchTimestamp = oldDateTimeType
questionnaireResponse.launchTimestamp = newDateTimeType

val launchTimestamp = questionnaireResponse.launchTimestamp
assertThat(launchTimestamp).isNotNull()
assertThat(oldDateTimeType).isEqualTo(launchTimestamp)
hamza-vd marked this conversation as resolved.
Show resolved Hide resolved
}
}
Loading