diff --git a/README.md b/README.md
index 8413b7d69..e3d322659 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ Two key **design goals** differentiate this project from similar projects:
- [Study and device deployment state](docs/carp-deployments.md#study-and-device-deployment-state)
- [Application services](docs/carp-deployments.md#application-services)
- [Clients](docs/carp-clients.md)
- - [Study runtime state](docs/carp-clients.md#study-runtime-state)
+ - [Study state](docs/carp-clients.md#study-state)
- [Data](docs/carp-data.md)
- [Data streams](docs/carp-data.md#data-streams)
- [Application services](docs/carp-data.md#application-services)
@@ -222,8 +222,8 @@ if ( studyStatus.canDeployToParticipants )
val participation = AssignParticipantDevices( participant.id, setOf( patientPhone.roleName ) )
val participantGroup = setOf( participation )
- val groupStatus: ParticipantGroupStatus = recruitmentService.deployParticipantGroup( studyId, participantGroup )
- val isInvited = groupStatus.studyDeploymentStatus is StudyDeploymentStatus.Invited // True.
+ val groupStatus: ParticipantGroupStatus = recruitmentService.inviteNewParticipantGroup( studyId, participantGroup )
+ val isInvited = groupStatus is ParticipantGroupStatus.Invited // True.
}
```
@@ -264,9 +264,9 @@ if ( patientPhoneStatus.canObtainDeviceDeployment ) // True since there are no d
deploymentService.deploymentSuccessful( studyDeploymentId, patientPhone.roleName, deployedOn )
}
-// Now that all devices have been registered and deployed, the deployment is ready.
+// Now that all devices have been registered and deployed, the deployment is running.
status = deploymentService.getStudyDeploymentStatus( studyDeploymentId )
-val isReady = status is StudyDeploymentStatus.DeploymentReady // True.
+val isReady = status is StudyDeploymentStatus.Running // True.
```
@@ -283,7 +283,7 @@ val invitation: ActiveParticipationInvitation =
val studyDeploymentId: UUID = invitation.participation.studyDeploymentId
val deviceToUse: String = invitation.assignedDevices.first().device.roleName // This matches "Patient's phone".
-// Create a study runtime for the study.
+// Add the study to a client device manager.
val clientRepository = createRepository()
val client = SmartphoneClient( clientRepository, deploymentService, dataCollectorFactory )
client.configure {
@@ -292,18 +292,18 @@ client.configure {
// E.g., for a smartphone, a UUID deviceId is generated. To override this default:
deviceId = "xxxxxxxxx"
}
-var status: StudyRuntimeStatus = client.addStudy( studyDeploymentId, deviceToUse )
+var status: StudyStatus = client.addStudy( studyDeploymentId, deviceToUse )
// Register connected devices in case needed.
-if ( status is StudyRuntimeStatus.RegisteringDevices )
+if ( status is StudyStatus.RegisteringDevices )
{
val connectedDevice = status.remainingDevicesToRegister.first()
val connectedRegistration = connectedDevice.createRegistration()
deploymentService.registerDevice( studyDeploymentId, connectedDevice.roleName, connectedRegistration )
- // Re-try deployment now that devices have been registered.
+ // Try deployment now that devices have been registered.
status = client.tryDeployment( status.id )
- val isDeployed = status is StudyRuntimeStatus.Deployed // True.
+ val isDeployed = status is StudyStatus.Running // True.
}
```
diff --git a/build.gradle b/build.gradle
index ca026421d..83aae59e7 100644
--- a/build.gradle
+++ b/build.gradle
@@ -8,7 +8,7 @@ buildscript {
ext {
// Version used for all submodule artifacts.
// Snapshot publishing changes (or adds) the suffix after '-' with 'SNAPSHOT' prior to publishing.
- globalVersion = '1.0.0-alpha.37'
+ globalVersion = '1.0.0-alpha.38'
versions = [
// Kotlin multiplatform versions.
@@ -24,6 +24,7 @@ buildscript {
// JS versions.
nodePlugin:'3.1.1',
+ bigJs:'6.1.1',
// DevOps versions.
detektPlugin:'1.18.1',
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/ClientManager.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/application/ClientManager.kt
similarity index 51%
rename from carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/ClientManager.kt
rename to carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/application/ClientManager.kt
index 23cd9e02b..b69518497 100644
--- a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/ClientManager.kt
+++ b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/application/ClientManager.kt
@@ -1,9 +1,15 @@
-package dk.cachet.carp.clients.domain
+package dk.cachet.carp.clients.application
+import dk.cachet.carp.clients.domain.ClientRepository
+import dk.cachet.carp.clients.domain.DeviceRegistrationStatus
import dk.cachet.carp.clients.domain.data.ConnectedDeviceDataCollector
import dk.cachet.carp.clients.domain.data.DataListener
import dk.cachet.carp.clients.domain.data.DeviceDataCollector
import dk.cachet.carp.clients.domain.data.DeviceDataCollectorFactory
+import dk.cachet.carp.clients.domain.study.Study
+import dk.cachet.carp.clients.domain.study.StudyDeploymentProxy
+import dk.cachet.carp.clients.application.study.StudyId
+import dk.cachet.carp.clients.application.study.StudyStatus
import dk.cachet.carp.common.application.UUID
import dk.cachet.carp.common.application.devices.DeviceRegistration
import dk.cachet.carp.common.application.devices.DeviceRegistrationBuilder
@@ -12,7 +18,7 @@ import dk.cachet.carp.deployments.application.DeploymentService
/**
- * Allows managing [StudyRuntime]'s on a client device.
+ * Allows managing [Study]'s on a client device.
*/
abstract class ClientManager<
TMasterDevice : MasterDeviceDescriptor,
@@ -35,10 +41,11 @@ abstract class ClientManager<
)
{
private val dataListener: DataListener = DataListener( dataCollectorFactory )
+ private val studyDeployment: StudyDeploymentProxy = StudyDeploymentProxy( deploymentService, dataListener )
/**
- * Determines whether a [DeviceRegistration] has been configured for this client, which is necessary to start adding [StudyRuntime]s.
+ * Determines whether a [DeviceRegistration] has been configured for this client, which is necessary to start adding [Study]s.
*/
suspend fun isConfigured(): Boolean = repository.getDeviceRegistration() != null
@@ -60,89 +67,84 @@ abstract class ClientManager<
/**
* Get the status for the studies which run on this client device.
*/
- suspend fun getStudiesStatus(): List = repository.getStudyRuntimeList().map { it.getStatus() }
+ suspend fun getStudiesStatus(): List = repository.getStudyList().map { it.getStatus() }
/**
- * Add a study which needs to be executed on this client. This involves registering this device for the specified study deployment.
+ * Add a study which needs to be executed on this client. No deployment is attempted yet.
*
- * @param studyDeploymentId The ID of a study which has been deployed already and for which to collect data.
- * @param deviceRoleName The role which the client device this runtime is intended for plays as part of the deployment identified by [studyDeploymentId].
+ * @param studyDeploymentId The ID of the study deployment for which to collect data.
+ * @param deviceRoleName The role of the client device which takes part in the deployment identified by [studyDeploymentId].
*
- * @throws IllegalArgumentException when:
- * - the client has not yet been configured
- * - a deployment with [studyDeploymentId] does not exist
- * - [deviceRoleName] is not present in the deployment or is already registered by a different device
- * - a study with the same [studyDeploymentId] and [deviceRoleName] has already been added to this client
- * - the configured device registration of this client is invalid for the specified device
- * - the configured device registration of this client uses a device ID which has already been used as part of registration of a different device
- * @return The [StudyRuntime] through which data collection for the newly added study can be managed.
+ * @throws IllegalArgumentException if a study with the same [studyDeploymentId] and [deviceRoleName] has already been added to this client.
+ * @return The [StudyStatus] of the newly added study.
*/
- suspend fun addStudy( studyDeploymentId: UUID, deviceRoleName: String ): StudyRuntimeStatus
+ suspend fun addStudy( studyDeploymentId: UUID, deviceRoleName: String ): StudyStatus
{
- // TODO: Can/should it be reinforced here that only study runtimes for a matching master device type can be created?
- require( isConfigured() ) { "The client has not been configured yet." }
+ require( repository.getStudyBy( studyDeploymentId, deviceRoleName ) == null )
+ { "A study with the same study deployment ID and device role name has already been added." }
- val alreadyAdded = repository.getStudyRuntimeBy( studyDeploymentId, deviceRoleName ) != null
- require( !alreadyAdded ) { "A study with the same study deployment ID and device role name has already been added." }
+ val study = Study( studyDeploymentId, deviceRoleName )
+ repository.addStudy( study )
- // Create the study runtime.
- // IllegalArgumentException's will be thrown here when deployment or role name does not exist, or device is already registered.
- val deviceRegistration = repository.getDeviceRegistration()!!
- val runtime = StudyRuntime.initialize(
- deploymentService, dataListener,
- studyDeploymentId, deviceRoleName, deviceRegistration
- )
-
- repository.addStudyRuntime( runtime )
- return runtime.getStatus()
+ return study.getStatus()
}
/**
- * Verifies whether the device is ready for deployment of the study runtime identified by [studyRuntimeId],
+ * Verifies whether the device is ready for deployment of the study identified by [studyId],
* and in case it is, deploys. In case already deployed, nothing happens.
*
- * @throws IllegalArgumentException in case no [StudyRuntime] with the given [studyRuntimeId] exists.
- * @throws UnsupportedOperationException in case deployment failed since not all necessary plugins to execute the study are available.
+ * @throws IllegalArgumentException if:
+ * - the client has not yet been configured
+ * - a [Study] with the given [studyId] does not exist
+ * - deployment failed because of unexpected study deployment ID, device role name, or device registration
+ * @throws UnsupportedOperationException if deployment failed since the runtime does not support all requirements of the study.
*/
- suspend fun tryDeployment( studyRuntimeId: StudyRuntimeId ): StudyRuntimeStatus
+ suspend fun tryDeployment( studyId: StudyId ): StudyStatus
{
- val runtime = getStudyRuntime( studyRuntimeId )
+ require( isConfigured() ) { "The client has not been configured yet." }
- // Early out in case this runtime has already received and validated deployment information.
- val status = runtime.getStatus()
- if ( status is StudyRuntimeStatus.Deployed ) return status
+ val study = getStudy( studyId )
- val newStatus = runtime.tryDeployment( deploymentService, dataListener )
- if ( status != newStatus )
- {
- repository.updateStudyRuntime( runtime )
- }
+ // Early out in case this study has already received and validated deployment information.
+ val status = study.getStatus()
+ if ( status is StudyStatus.Running ) return status
+
+ // Try to deploy the study.
+ // IllegalArgumentException's will be thrown here when deployment or role name does not exist, or device is already registered.
+ // TODO: Can/should it be reinforced here that only matching master device type can be deployed?
+ val registration = repository.getDeviceRegistration()!!
+ studyDeployment.tryDeployment( study, registration )
+
+ val newStatus = study.getStatus()
+ if ( status != newStatus ) repository.updateStudy( study )
return newStatus
}
/**
- * Permanently stop collecting data for the study runtime identified by [studyRuntimeId].
+ * Permanently stop collecting data for the study identified by [studyId].
*
- * @throws IllegalArgumentException in case no [StudyRuntime] with the given [studyRuntimeId] exists.
+ * @throws IllegalArgumentException in case no [Study] with the given [studyId] exists.
*/
- suspend fun stopStudy( studyRuntimeId: StudyRuntimeId ): StudyRuntimeStatus
+ suspend fun stopStudy( studyId: StudyId ): StudyStatus
{
- val runtime = getStudyRuntime( studyRuntimeId )
- val status = runtime.getStatus()
+ val study = getStudy( studyId )
+ val status = study.getStatus()
+
+ studyDeployment.stop( study )
- val newStatus = runtime.stop( deploymentService )
+ val newStatus = study.getStatus()
if ( status != newStatus )
{
- repository.updateStudyRuntime( runtime )
+ repository.updateStudy( study )
}
return newStatus
}
- private suspend fun getStudyRuntime( studyRuntimeid: StudyRuntimeId ): StudyRuntime =
- repository.getStudyRuntimeList().firstOrNull { it.id == studyRuntimeid }
- ?: throw IllegalArgumentException( "The specified study runtime does not exist." )
+ private suspend fun getStudy( studyId: StudyId ): Study =
+ requireNotNull( repository.getStudyList().firstOrNull { it.id == studyId } )
+ { "The specified study does not exist." }
/**
* Once a connected device has been registered, this returns a manager which provides access to the status of the [registeredDevice].
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/ConnectedDeviceManager.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/application/ConnectedDeviceManager.kt
similarity index 95%
rename from carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/ConnectedDeviceManager.kt
rename to carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/application/ConnectedDeviceManager.kt
index 3ee86f262..ea96210ee 100644
--- a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/ConnectedDeviceManager.kt
+++ b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/application/ConnectedDeviceManager.kt
@@ -1,4 +1,4 @@
-package dk.cachet.carp.clients.domain
+package dk.cachet.carp.clients.application
import dk.cachet.carp.clients.domain.data.AnyConnectedDeviceDataCollector
import dk.cachet.carp.common.application.devices.DeviceRegistration
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyRuntimeId.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/application/study/StudyId.kt
similarity index 69%
rename from carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyRuntimeId.kt
rename to carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/application/study/StudyId.kt
index 2fef100cb..63023c018 100644
--- a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyRuntimeId.kt
+++ b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/application/study/StudyId.kt
@@ -1,12 +1,12 @@
-package dk.cachet.carp.clients.domain
+package dk.cachet.carp.clients.application.study
import dk.cachet.carp.common.application.UUID
/**
- * Uniquely identifies a [StudyRuntime] running on a [ClientManager].
+ * Uniquely identifies a [Study] added to a [ClientManager].
*/
-data class StudyRuntimeId(
+data class StudyId(
/**
* The ID of the deployed study for which to collect data.
*/
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/application/study/StudyStatus.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/application/study/StudyStatus.kt
new file mode 100644
index 000000000..02ff72792
--- /dev/null
+++ b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/application/study/StudyStatus.kt
@@ -0,0 +1,173 @@
+package dk.cachet.carp.clients.application.study
+
+import dk.cachet.carp.clients.domain.DeviceRegistrationStatus
+import dk.cachet.carp.common.application.ImplementAsDataClass
+import dk.cachet.carp.common.application.devices.AnyDeviceDescriptor
+import dk.cachet.carp.deployments.application.DeviceDeploymentStatus
+import dk.cachet.carp.deployments.application.MasterDeviceDeployment
+import dk.cachet.carp.deployments.application.StudyDeploymentStatus
+
+
+/**
+ * Describes the status of a study.
+ */
+@ImplementAsDataClass
+sealed class StudyStatus
+{
+ /**
+ * Unique ID of the study on the client manager.
+ */
+ abstract val id: StudyId
+
+
+ /**
+ * The study deployment process hasn't been started yet.
+ */
+ data class DeploymentNotStarted( override val id: StudyId ) : StudyStatus()
+
+ /**
+ * Once a deployment for this study has started, a [deploymentStatus] is available,
+ * regardless of whether the deployment is now [Running] or [Stopped].
+ */
+ interface DeploymentStatusAvailable
+ {
+ val deploymentStatus: StudyDeploymentStatus
+ }
+
+ /**
+ * The study deployment process is ongoing, but not yet completed.
+ * The state of the deployment can be tracked using [deploymentStatus].
+ */
+ sealed class Deploying : StudyStatus(), DeploymentStatusAvailable
+ {
+ companion object
+ {
+ /**
+ * Initialize a [Deploying] state for a study deployment that is currently [deployingDevices]
+ * for a client which may or may not yet have received its [deploymentInformation].
+ */
+ fun fromStudyDeploymentStatus(
+ id: StudyId,
+ deployingDevices: StudyDeploymentStatus.DeployingDevices,
+ deploymentInformation: MasterDeviceDeployment?
+ ): Deploying
+ {
+ return when ( val deviceStatus = deployingDevices.getDeviceStatus( id.deviceRoleName ) )
+ {
+ is DeviceDeploymentStatus.Unregistered -> error( "Client device should already be registered." )
+ is DeviceDeploymentStatus.NotDeployed ->
+ if ( deploymentInformation == null || deviceStatus is DeviceDeploymentStatus.NeedsRedeployment )
+ {
+ if ( deviceStatus.canObtainDeviceDeployment ) AwaitingDeviceDeployment( id, deployingDevices )
+ else AwaitingOtherDeviceRegistrations( id, deployingDevices )
+ }
+ else
+ {
+ RegisteringDevices( id, deployingDevices, deploymentInformation )
+ }
+ is DeviceDeploymentStatus.Deployed ->
+ AwaitingOtherDeviceDeployments( id, deployingDevices, checkNotNull( deploymentInformation ) )
+ }
+ }
+ }
+ }
+
+ /**
+ * Deployment information for this master device cannot be retrieved yet since
+ * other master devices in the study deployment need to be registered first.
+ */
+ data class AwaitingOtherDeviceRegistrations(
+ override val id: StudyId,
+ override val deploymentStatus: StudyDeploymentStatus
+ ) : Deploying()
+
+ /**
+ * The study deployment is ready to deliver the deployment information to this master device.
+ */
+ data class AwaitingDeviceDeployment(
+ override val id: StudyId,
+ override val deploymentStatus: StudyDeploymentStatus
+ ) : Deploying()
+
+ /**
+ * Deployment information has been received.
+ */
+ interface DeviceDeploymentReceived
+ {
+ // TODO: This should be consumed within this domain model and not be public.
+ // Currently, it is in order to work towards a first MVP which includes server/client communication through the domain model.
+ val deploymentInformation: MasterDeviceDeployment
+
+ /**
+ * The current [DeviceRegistrationStatus] for the client device and each of its connected devices.
+ */
+ val devicesRegistrationStatus: Map
+ }
+
+ /**
+ * The device deployment of this master device can complete
+ * once all [remainingDevicesToRegister] have been registered.
+ */
+ data class RegisteringDevices(
+ override val id: StudyId,
+ override val deploymentStatus: StudyDeploymentStatus,
+ override val deploymentInformation: MasterDeviceDeployment
+ ) : Deploying(), DeviceDeploymentReceived
+ {
+ override val devicesRegistrationStatus: Map =
+ getDevicesRegistrationStatus( deploymentInformation )
+
+ val remainingDevicesToRegister: Set = deploymentInformation
+ .getRuntimeDeviceInfo()
+ .filter { it.isConnectedDevice && it.registration == null }
+ .map { it.descriptor }
+ .toSet()
+ }
+
+ /**
+ * Device deployment for this master device has completed,
+ * but awaiting deployment of other devices in this study deployment.
+ */
+ data class AwaitingOtherDeviceDeployments(
+ override val id: StudyId,
+ override val deploymentStatus: StudyDeploymentStatus,
+ override val deploymentInformation: MasterDeviceDeployment,
+ ) : Deploying(), DeviceDeploymentReceived
+ {
+ override val devicesRegistrationStatus: Map =
+ getDevicesRegistrationStatus( deploymentInformation )
+ }
+
+ /**
+ * Study deployment has completed and the study is now running.
+ */
+ data class Running(
+ override val id: StudyId,
+ override val deploymentStatus: StudyDeploymentStatus,
+ override val deploymentInformation: MasterDeviceDeployment
+ ) : StudyStatus(), DeviceDeploymentReceived, DeploymentStatusAvailable
+ {
+ override val devicesRegistrationStatus: Map =
+ getDevicesRegistrationStatus( deploymentInformation )
+ }
+
+ /**
+ * Study status when deployment has been stopped, either by this client or researcher.
+ */
+ data class Stopped internal constructor(
+ override val id: StudyId,
+ override val deploymentStatus: StudyDeploymentStatus,
+ val deploymentInformation: MasterDeviceDeployment?
+ ) : StudyStatus(), DeploymentStatusAvailable
+}
+
+
+// TODO: By remote device unregister/register calls it can happen that registrations in `MasterDeviceDeployment`
+// differ from those in `StudyDeploymentStatus`. This needs to be taken into account.
+private fun getDevicesRegistrationStatus( deployment: MasterDeviceDeployment ) = deployment
+ .getRuntimeDeviceInfo()
+ .map {
+ val registration = it.registration
+ if ( registration == null ) DeviceRegistrationStatus.Unregistered( it.descriptor )
+ else DeviceRegistrationStatus.Registered( it.descriptor, registration )
+ }.associateBy { it.device }
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/ClientRepository.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/ClientRepository.kt
index 4d03847c5..22305088d 100644
--- a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/ClientRepository.kt
+++ b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/ClientRepository.kt
@@ -1,5 +1,6 @@
package dk.cachet.carp.clients.domain
+import dk.cachet.carp.clients.domain.study.Study
import dk.cachet.carp.common.application.UUID
import dk.cachet.carp.common.application.devices.DeviceRegistration
@@ -20,32 +21,32 @@ interface ClientRepository
suspend fun setDeviceRegistration( registration: DeviceRegistration )
/**
- * Adds the specified [studyRuntime] to the repository.
+ * Adds the specified [study] to the repository.
*
- * @throws IllegalArgumentException when a [StudyRuntime] which has the same study deployment ID and device role name already exists.
+ * @throws IllegalArgumentException when a [Study] which has the same study deployment ID and device role name already exists.
*/
- suspend fun addStudyRuntime( studyRuntime: StudyRuntime )
+ suspend fun addStudy( study: Study )
/**
- * Return the [StudyRuntime] with [studyDeploymentId] and [deviceRoleName], or null when no such [StudyRuntime] is found.
+ * Return the [Study] with [studyDeploymentId] and [deviceRoleName], or null when no such [Study] is found.
*/
- suspend fun getStudyRuntimeBy( studyDeploymentId: UUID, deviceRoleName: String ): StudyRuntime?
+ suspend fun getStudyBy( studyDeploymentId: UUID, deviceRoleName: String ): Study?
/**
- * Return all [StudyRuntime]s for the client.
+ * Return all [Study]s for the client.
*/
- suspend fun getStudyRuntimeList(): List
+ suspend fun getStudyList(): List
/**
- * Update a [StudyRuntime] which is already stored in the repository.
+ * Update a [study] which is already stored in the repository.
*
- * @throws IllegalArgumentException when no previous version of this study runtime is stored in the repository.
+ * @throws IllegalArgumentException when no previous version of this study is stored in the repository.
*/
- suspend fun updateStudyRuntime( runtime: StudyRuntime )
+ suspend fun updateStudy( study: Study )
/**
- * Remove a [StudyRuntime] which is already stored in the repository.
- * In case [runtime] is not stored in this repository, nothing happens.
+ * Remove a [study] which is already stored in the repository.
+ * In case [study] is not stored in this repository, nothing happens.
*/
- suspend fun removeStudyRuntime( runtime: StudyRuntime )
+ suspend fun removeStudy( study: Study )
}
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/SmartphoneClient.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/SmartphoneClient.kt
index a36be3dd9..b0b039ef1 100644
--- a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/SmartphoneClient.kt
+++ b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/SmartphoneClient.kt
@@ -1,5 +1,6 @@
package dk.cachet.carp.clients.domain
+import dk.cachet.carp.clients.application.ClientManager
import dk.cachet.carp.clients.domain.data.ConnectedDeviceDataCollector
import dk.cachet.carp.clients.domain.data.DeviceDataCollector
import dk.cachet.carp.clients.domain.data.DeviceDataCollectorFactory
@@ -10,7 +11,7 @@ import dk.cachet.carp.deployments.application.DeploymentService
/**
- * Allows managing [StudyRuntime]s on a smartphone.
+ * Allows managing [Study]s on a smartphone.
*/
class SmartphoneClient(
/**
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyRuntime.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyRuntime.kt
deleted file mode 100644
index 1a6b4cdb1..000000000
--- a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyRuntime.kt
+++ /dev/null
@@ -1,255 +0,0 @@
-package dk.cachet.carp.clients.domain
-
-import dk.cachet.carp.clients.domain.data.DataListener
-import dk.cachet.carp.common.application.UUID
-import dk.cachet.carp.common.application.data.Data
-import dk.cachet.carp.common.application.data.DataType
-import dk.cachet.carp.common.application.devices.AnyDeviceDescriptor
-import dk.cachet.carp.common.application.devices.AnyMasterDeviceDescriptor
-import dk.cachet.carp.common.application.devices.DeviceRegistration
-import dk.cachet.carp.common.application.tasks.Measure
-import dk.cachet.carp.common.domain.AggregateRoot
-import dk.cachet.carp.common.domain.DomainEvent
-import dk.cachet.carp.deployments.application.DeploymentService
-import dk.cachet.carp.deployments.application.DeviceDeploymentStatus
-import dk.cachet.carp.deployments.application.MasterDeviceDeployment
-import dk.cachet.carp.deployments.application.StudyDeploymentStatus
-
-
-/**
- * Manage data collection for a particular study on a client device.
- */
-class StudyRuntime private constructor(
- /**
- * The ID of the deployed study for which to collect data.
- */
- val studyDeploymentId: UUID,
- /**
- * The description of the device this runtime is intended for within the deployment identified by [studyDeploymentId].
- */
- val device: AnyMasterDeviceDescriptor
-) : AggregateRoot()
-{
- sealed class Event : DomainEvent()
- {
- data class DeploymentReceived(
- val deploymentInformation: MasterDeviceDeployment,
- val remainingDevicesToRegister: Set
- ) : Event()
-
- object DeploymentCompleted : Event()
-
- object DeploymentStopped : Event()
- }
-
-
- companion object Factory
- {
- /**
- * Instantiate a [StudyRuntime] by registering the client device in the [deploymentService].
- * In case the device is immediately ready for deployment, also deploy.
- *
- * @throws IllegalArgumentException when:
- * - a deployment with [studyDeploymentId] does not exist
- * - [deviceRoleName] is not present in the deployment or is already registered and a different [deviceRegistration] is specified than a previous request
- * - [deviceRegistration] is invalid for the specified device or uses a device ID which has already been used as part of registration of a different device
- * @throws UnsupportedOperationException in case deployment failed since not all necessary plugins to execute the study are available.
- * @throws IllegalStateException in case data requested in the deployment cannot be collected on this client.
- */
- internal suspend fun initialize(
- /**
- * The application service to use to retrieve and manage the study deployment with [studyDeploymentId].
- * This deployment service should have the deployment with [studyDeploymentId] available.
- */
- deploymentService: DeploymentService,
- /**
- * Allows subscribing to [Data] of requested [DataType]s for this master device and connected devices.
- */
- dataListener: DataListener,
- /**
- * The ID of the deployed study for which to collect data.
- */
- studyDeploymentId: UUID,
- /**
- * The role which the client device this runtime is intended for plays in the deployment identified by [studyDeploymentId].
- */
- deviceRoleName: String,
- /**
- * The device configuration for the device this study runtime runs on, identified by [deviceRoleName] in the study deployment with [studyDeploymentId].
- */
- deviceRegistration: DeviceRegistration
- ): StudyRuntime
- {
- // Register the client device this study runs on for the given study deployment.
- val deploymentStatus = deploymentService.registerDevice( studyDeploymentId, deviceRoleName, deviceRegistration )
-
- // Initialize runtime.
- val clientDeviceStatus = deploymentStatus.getDeviceStatus( deviceRoleName )
- val runtime = StudyRuntime( studyDeploymentId, clientDeviceStatus.device as AnyMasterDeviceDescriptor )
-
- // After registration, deployment information might immediately be available for this client device.
- runtime.tryDeployment( deploymentService, dataListener, deploymentStatus )
-
- return runtime
- }
-
- internal fun fromSnapshot( snapshot: StudyRuntimeSnapshot ): StudyRuntime =
- StudyRuntime( snapshot.studyDeploymentId, snapshot.device ).apply {
- createdOn = snapshot.createdOn
- isDeployed = snapshot.isDeployed
- deploymentInformation = snapshot.deploymentInformation
- remainingDevicesToRegister = snapshot.remainingDevicesToRegister.toSet()
- isStopped = snapshot.isStopped
- }
- }
-
-
- /**
- * Composite ID for this study runtime, comprised of the [studyDeploymentId] and [device] role name.
- */
- val id: StudyRuntimeId get() = StudyRuntimeId( studyDeploymentId, device.roleName )
-
- /**
- * Determines whether the device deployment has completed successfully.
- */
- var isDeployed: Boolean = false
- private set
-
- private var remainingDevicesToRegister: Set = emptySet()
- private var deploymentInformation: MasterDeviceDeployment? = null
-
- /**
- * Determines whether the study has stopped and no more further data is being collected.
- */
- var isStopped: Boolean = false
- private set
-
-
- /**
- * Get the status of this [StudyRuntime].
- */
- fun getStatus(): StudyRuntimeStatus =
- when {
- deploymentInformation == null -> StudyRuntimeStatus.NotReadyForDeployment( id )
- remainingDevicesToRegister.isNotEmpty() ->
- StudyRuntimeStatus.RegisteringDevices( id, deploymentInformation!!, remainingDevicesToRegister.toSet() )
- isStopped -> StudyRuntimeStatus.Stopped( id, deploymentInformation!! )
- isDeployed -> StudyRuntimeStatus.Deployed( id, deploymentInformation!! )
- else -> error( "Unexpected study runtime state." )
- }
-
- /**
- * Verifies whether the device is ready for deployment and in case it is, deploys.
- * In case already deployed, nothing happens.
- *
- * @throws UnsupportedOperationException in case deployment failed since not all necessary plugins to execute the study are available.
- */
- suspend fun tryDeployment( deploymentService: DeploymentService, dataListener: DataListener ): StudyRuntimeStatus
- {
- val deploymentStatus = deploymentService.getStudyDeploymentStatus( studyDeploymentId )
-
- tryDeployment( deploymentService, dataListener, deploymentStatus )
- return getStatus()
- }
-
- // TODO: Handle `NeedsRedeployment`, invalidating the retrieved deployment information.
- private suspend fun tryDeployment(
- deploymentService: DeploymentService,
- dataListener: DataListener,
- deploymentStatus: StudyDeploymentStatus
- )
- {
- // Early out in case state indicates the device is already deployed or deployment cannot yet be obtained.
- val deviceStatus = deploymentStatus.getDeviceStatus( device )
- if ( deviceStatus !is DeviceDeploymentStatus.NotDeployed ) return
- if ( !deviceStatus.canObtainDeviceDeployment ) return
-
- // Get deployment information.
- // TODO: Handle race condition in case other devices were unregistered in between.
- val deployment = deploymentService.getDeviceDeploymentFor( studyDeploymentId, device.roleName )
- check( deployment.deviceDescriptor == device )
- deploymentInformation = deployment
- remainingDevicesToRegister = deploymentStatus.devicesStatus
- .map { it.device }
- .filter { it.roleName in deviceStatus.remainingDevicesToRegisterBeforeDeployment }
- .toSet()
- event( Event.DeploymentReceived( deployment, remainingDevicesToRegister.toSet() ) )
-
- // Early out in case devices need to be registered before being able to complete deployment.
- if ( remainingDevicesToRegister.isNotEmpty() ) return
-
- // Verify whether data collection is supported on all connected devices and for all requested measures.
- for ( (device, tasks) in deployment.getTasksPerDevice() )
- {
- // It shouldn't be possible to be ready for deployment when connected device registration is not set.
- val registration = device.registration
- checkNotNull( registration )
-
- // Verify whether connected device is supported.
- val deviceType = device.descriptor::class
- if ( device.isConnectedDevice )
- {
- dataListener.tryGetConnectedDataCollector( deviceType, registration )
- ?: throw UnsupportedOperationException( "Connecting to device of type \"$deviceType\" is not supported on this client." )
- }
-
- val dataTypes = tasks
- .flatMap { it.measures.filterIsInstance() }
- .map { it.type }
- .distinct()
- for ( dataType in dataTypes )
- {
- val supportsData =
- if ( device.isConnectedDevice )
- {
- dataListener.supportsDataOnConnectedDevice( dataType, deviceType, registration )
- }
- else dataListener.supportsData( dataType )
-
- if ( !supportsData )
- {
- throw UnsupportedOperationException(
- "Subscribing to data of data type \"$dataType\" " +
- "on device with role \"${device.descriptor.roleName}\" is not supported on this client."
- )
- }
- }
- }
-
- // Notify deployment service of successful deployment.
- try
- {
- deploymentService.deploymentSuccessful( studyDeploymentId, device.roleName, deployment.lastUpdatedOn )
- isDeployed = true
- event( Event.DeploymentCompleted )
- }
- // Handle race conditions with competing clients modifying device registrations, invalidating this deployment.
- catch ( ignore: IllegalArgumentException ) { } // TODO: When deployment is out of date, maybe also use `IllegalStateException` for easier handling here.
- catch ( ignore: IllegalStateException ) { }
- }
-
- /**
- * Permanently stop collecting data for this [StudyRuntime].
- */
- suspend fun stop( deploymentService: DeploymentService ): StudyRuntimeStatus
- {
- // Early out in case study has already been stopped.
- val status = getStatus()
- if ( status is StudyRuntimeStatus.Stopped ) return status
-
- // Stop study deployment.
- // TODO: Right now this requires the client to be online in case `deploymentService` is an online service.
- // Once we have domain events in place this should be modeled as a request to stop deployment which is cached when offline.
- val deploymentStatus = deploymentService.stop( studyDeploymentId )
- check( deploymentStatus is StudyDeploymentStatus.Stopped )
- isStopped = true
- event( Event.DeploymentStopped )
-
- return getStatus()
- }
-
- /**
- * Get a serializable snapshot of the current state of this [StudyRuntime].
- */
- override fun getSnapshot(): StudyRuntimeSnapshot = StudyRuntimeSnapshot.fromStudyRuntime( this )
-}
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyRuntimeSnapshot.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyRuntimeSnapshot.kt
deleted file mode 100644
index 3ac08c50b..000000000
--- a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyRuntimeSnapshot.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package dk.cachet.carp.clients.domain
-
-import dk.cachet.carp.common.application.UUID
-import dk.cachet.carp.common.application.devices.AnyDeviceDescriptor
-import dk.cachet.carp.common.application.devices.AnyMasterDeviceDescriptor
-import dk.cachet.carp.common.domain.Snapshot
-import dk.cachet.carp.deployments.application.MasterDeviceDeployment
-import kotlinx.datetime.Instant
-import kotlinx.serialization.Serializable
-
-
-@Serializable
-data class StudyRuntimeSnapshot(
- val studyDeploymentId: UUID,
- override val createdOn: Instant,
- val device: AnyMasterDeviceDescriptor,
- val isDeployed: Boolean,
- val deploymentInformation: MasterDeviceDeployment?,
- val remainingDevicesToRegister: Set = emptySet(),
- val isStopped: Boolean
-) : Snapshot
-{
- companion object
- {
- fun fromStudyRuntime( studyRuntime: StudyRuntime ): StudyRuntimeSnapshot
- {
- val status = studyRuntime.getStatus()
-
- return StudyRuntimeSnapshot(
- studyRuntime.studyDeploymentId,
- studyRuntime.createdOn,
- studyRuntime.device,
- studyRuntime.isDeployed,
- (status as? StudyRuntimeStatus.DeploymentReceived)?.deploymentInformation,
- (status as? StudyRuntimeStatus.RegisteringDevices)?.remainingDevicesToRegister?.toSet()
- ?: emptySet(),
- studyRuntime.isStopped
- )
- }
- }
-
- override fun toObject(): StudyRuntime = StudyRuntime.fromSnapshot( this )
-}
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyRuntimeStatus.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyRuntimeStatus.kt
deleted file mode 100644
index 764d80785..000000000
--- a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyRuntimeStatus.kt
+++ /dev/null
@@ -1,91 +0,0 @@
-package dk.cachet.carp.clients.domain
-
-import dk.cachet.carp.common.application.ImplementAsDataClass
-import dk.cachet.carp.common.application.devices.AnyDeviceDescriptor
-import dk.cachet.carp.deployments.application.MasterDeviceDeployment
-
-
-/**
- * Describes the status of a [StudyRuntime].
- */
-@ImplementAsDataClass
-sealed class StudyRuntimeStatus
-{
- /**
- * Unique ID of the study runtime on the [ClientManager].
- */
- abstract val id: StudyRuntimeId
-
-
- /**
- * Deployment information has been received.
- */
- interface DeploymentReceived
- {
- val id: StudyRuntimeId
-
- /**
- * Contains all the information on the study to run.
- *
- * TODO: This should be consumed within this domain model and not be public.
- * Currently, it is in order to work towards a first MVP which includes server/client communication through the domain model.
- */
- val deploymentInformation: MasterDeviceDeployment
-
- /**
- * The [DeviceRegistrationStatus] for the master device and each of the devices this device needs to connect to.
- */
- val devicesRegistrationStatus: Map
- }
-
- /**
- * Deployment cannot succeed yet because other master devices have not been registered yet.
- */
- data class NotReadyForDeployment internal constructor( override val id: StudyRuntimeId ) : StudyRuntimeStatus()
-
- /**
- * Deployment can complete after [remainingDevicesToRegister] have been registered.
- */
- data class RegisteringDevices internal constructor(
- override val id: StudyRuntimeId,
- override val deploymentInformation: MasterDeviceDeployment,
- val remainingDevicesToRegister: Set
- ) : StudyRuntimeStatus(), DeploymentReceived
- {
- override val devicesRegistrationStatus = getDevicesRegistrationStatus( deploymentInformation )
- }
-
- /**
- * Study runtime status when deployment has been successfully completed:
- * the [MasterDeviceDeployment] has been retrieved and all necessary plugins to execute the study have been loaded.
- */
- data class Deployed internal constructor(
- override val id: StudyRuntimeId,
- override val deploymentInformation: MasterDeviceDeployment
- ) : StudyRuntimeStatus(), DeploymentReceived
- {
- override val devicesRegistrationStatus = getDevicesRegistrationStatus( deploymentInformation )
- }
-
- /**
- * Study runtime status when deployment has been stopped, either by this client or researcher.
- */
- data class Stopped internal constructor(
- override val id: StudyRuntimeId,
- override val deploymentInformation: MasterDeviceDeployment
- ) : StudyRuntimeStatus(), DeploymentReceived
- {
- override val devicesRegistrationStatus = getDevicesRegistrationStatus( deploymentInformation )
- }
-}
-
-
-private fun getDevicesRegistrationStatus( deployment: MasterDeviceDeployment ) = deployment
- .getAllDevicesAndRegistrations()
- .map {
- val registration = it.registration
- if ( registration == null ) DeviceRegistrationStatus.Unregistered( it.descriptor )
- else DeviceRegistrationStatus.Registered( it.descriptor, registration )
- }
- .map { it.device to it }
- .toMap()
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/data/DataListener.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/data/DataListener.kt
index 78d794de7..737f97531 100644
--- a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/data/DataListener.kt
+++ b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/data/DataListener.kt
@@ -45,11 +45,9 @@ class DataListener( private val dataCollectorFactory: DeviceDataCollectorFactory
return try
{
connectedDataCollectors
- .getOrPut( connectedDeviceType, { mutableMapOf() } )
- .getOrPut(
- registration,
+ .getOrPut( connectedDeviceType ) { mutableMapOf() }
+ .getOrPut( registration )
{ dataCollectorFactory.createConnectedDataCollector( connectedDeviceType, registration ) }
- )
}
catch ( _: UnsupportedOperationException ) { null }
}
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/study/Study.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/study/Study.kt
new file mode 100644
index 000000000..0c504a961
--- /dev/null
+++ b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/study/Study.kt
@@ -0,0 +1,162 @@
+package dk.cachet.carp.clients.domain.study
+
+import dk.cachet.carp.clients.application.study.StudyId
+import dk.cachet.carp.clients.application.study.StudyStatus
+import dk.cachet.carp.clients.domain.data.DataListener
+import dk.cachet.carp.common.application.UUID
+import dk.cachet.carp.common.application.tasks.Measure
+import dk.cachet.carp.common.domain.AggregateRoot
+import dk.cachet.carp.common.domain.DomainEvent
+import dk.cachet.carp.deployments.application.MasterDeviceDeployment
+import dk.cachet.carp.deployments.application.StudyDeploymentStatus
+
+
+/**
+ * A study deployment, identified by [studyDeploymentId],
+ * which a client device participates in with the role [deviceRoleName].
+ */
+class Study(
+ /**
+ * The ID of the deployed study for which to collect data.
+ */
+ val studyDeploymentId: UUID,
+ /**
+ * The role name of the device this runtime is intended for within the deployment identified by [studyDeploymentId].
+ */
+ val deviceRoleName: String
+) : AggregateRoot()
+{
+ sealed class Event : DomainEvent()
+ {
+ data class DeploymentStatusReceived( val deploymentStatus: StudyDeploymentStatus ) : Event()
+ data class DeviceDeploymentReceived( val deploymentInformation: MasterDeviceDeployment ) : Event()
+ }
+
+
+ companion object Factory
+ {
+ internal fun fromSnapshot( snapshot: StudySnapshot ): Study =
+ Study( snapshot.studyDeploymentId, snapshot.deviceRoleName ).apply {
+ createdOn = snapshot.createdOn
+ deploymentStatus = snapshot.deploymentStatus
+ deploymentInformation = snapshot.deploymentInformation
+ }
+ }
+
+
+ /**
+ * Composite ID for this study, comprised of the [studyDeploymentId] and [deviceRoleName].
+ */
+ val id: StudyId get() = StudyId( studyDeploymentId, deviceRoleName )
+
+ private var deploymentStatus: StudyDeploymentStatus? = null
+ private var deploymentInformation: MasterDeviceDeployment? = null
+
+
+ /**
+ * Get the status of this [Study].
+ */
+ fun getStatus(): StudyStatus
+ {
+ val status = deploymentStatus ?: return StudyStatus.DeploymentNotStarted( id )
+
+ return when ( status )
+ {
+ is StudyDeploymentStatus.Invited -> error( "Client device should already be registered." )
+ is StudyDeploymentStatus.DeployingDevices ->
+ StudyStatus.Deploying.fromStudyDeploymentStatus( id, status, deploymentInformation )
+ is StudyDeploymentStatus.Running ->
+ StudyStatus.Running( id, status, checkNotNull( deploymentInformation ) )
+ is StudyDeploymentStatus.Stopped ->
+ StudyStatus.Stopped( id, status, deploymentInformation )
+ }
+ }
+
+ /**
+ * An updated [deploymentStatus] has been received.
+ */
+ fun deploymentStatusReceived( deploymentStatus: StudyDeploymentStatus )
+ {
+ this.deploymentStatus = deploymentStatus
+ event( Event.DeploymentStatusReceived( deploymentStatus ) )
+ }
+
+ /**
+ * A new master device [deployment] determining what data to collect for this study has been received.
+ *
+ * @throws IllegalArgumentException when the role name [deployment] is intended for is different from the expected [deviceRoleName].
+ */
+ fun deviceDeploymentReceived( deployment: MasterDeviceDeployment )
+ {
+ checkNotNull( deploymentStatus )
+ { "Can't receive device deployment before having received deployment status." }
+ require( deployment.deviceDescriptor.roleName == deviceRoleName )
+ { "The deployment is intended for a device with a different role name." }
+
+ deploymentInformation = deployment
+ event( Event.DeviceDeploymentReceived( deployment ) )
+ }
+
+ /**
+ * Verify whether all prerequisites for the deployment to run on this device are met, or throw an exception otherwise.
+ *
+ * TODO: This shouldn't be a separate call that only works once all devices are registered.
+ * Partial validation should happen on `deviceDeploymentReceived`, subsequent `registerDevice` calls once added here,
+ * and remote device registrations through `deploymentStatusReceived`.
+ *
+ * @throws IllegalStateException when:
+ * - deployment hasn't been received yet
+ * - not all required devices have been registered
+ * @throws UnsupportedOperationException in case not all necessary plugins to execute the deployment are available.
+ */
+ fun validateDeviceDeployment( dataListener: DataListener )
+ {
+ val deployment = checkNotNull( deploymentInformation )
+ val remainingDevicesToRegister = deploymentStatus?.getRemainingDevicesToRegister() ?: emptySet()
+
+ // All devices need to be registered before deployment can be validated.
+ check( remainingDevicesToRegister.isEmpty() )
+
+ // Verify whether data collection is supported on all connected devices and for all requested measures.
+ for ( device in deployment.getRuntimeDeviceInfo() )
+ {
+ // It shouldn't be possible to be ready for deployment when connected device registration is not set.
+ val registration = checkNotNull( device.registration )
+
+ // Verify whether connected device is supported.
+ val deviceType = device.descriptor::class
+ if ( device.isConnectedDevice )
+ {
+ dataListener.tryGetConnectedDataCollector( deviceType, registration )
+ ?: throw UnsupportedOperationException( "Connecting to device of type \"$deviceType\" is not supported on this client." )
+ }
+
+ val dataTypes = device.tasks
+ .flatMap { it.measures.filterIsInstance() }
+ .map { it.type }
+ .distinct()
+ for ( dataType in dataTypes )
+ {
+ val supportsData =
+ if ( device.isConnectedDevice )
+ {
+ dataListener.supportsDataOnConnectedDevice( dataType, deviceType, registration )
+ }
+ else dataListener.supportsData( dataType )
+
+ if ( !supportsData )
+ {
+ throw UnsupportedOperationException(
+ "Subscribing to data of data type \"$dataType\" " +
+ "on device with role \"${device.descriptor.roleName}\" is not supported on this client."
+ )
+ }
+ }
+ }
+ }
+
+ /**
+ * Get a serializable snapshot of the current state of this [Study].
+ */
+ override fun getSnapshot(): StudySnapshot = StudySnapshot.fromStudy( this )
+}
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/study/StudyDeploymentProxy.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/study/StudyDeploymentProxy.kt
new file mode 100644
index 000000000..28321c439
--- /dev/null
+++ b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/study/StudyDeploymentProxy.kt
@@ -0,0 +1,94 @@
+package dk.cachet.carp.clients.domain.study
+
+import dk.cachet.carp.clients.application.study.StudyStatus
+import dk.cachet.carp.clients.domain.data.DataListener
+import dk.cachet.carp.common.application.UUID
+import dk.cachet.carp.common.application.devices.DeviceRegistration
+import dk.cachet.carp.deployments.application.DeploymentService
+import dk.cachet.carp.deployments.application.DeviceDeploymentStatus
+import dk.cachet.carp.deployments.application.StudyDeploymentStatus
+
+
+/**
+ * Perform deployment actions for a [Study] on a client device.
+ */
+class StudyDeploymentProxy(
+ private val deploymentService: DeploymentService,
+ private val dataListener: DataListener
+)
+{
+ /**
+ * Tries to deploy the [study] if it's ready to be deployed
+ * by registering the client device using [deviceRegistration] and verifying the study is supported on this device.
+ * In case already deployed, nothing happens.
+ *
+ * TODO: Handle `NeedsRedeployment`, invalidating the retrieved deployment information.
+ * @throws IllegalArgumentException if:
+ * - a deployment with study deployment ID matching this [study] does not exist
+ * - device role name of [study] is not present in the deployment
+ * or is already registered and a different [deviceRegistration] is specified
+ * - [deviceRegistration] of this client is invalid for the expected device role name
+ * or has a device ID which is already in use by the registration of a different device
+ * @throws UnsupportedOperationException when:
+ * - not all necessary plugins to execute the study are available
+ * - data requested in the deployment cannot be collected on this client device
+ */
+ suspend fun tryDeployment( study: Study, deviceRegistration: DeviceRegistration )
+ {
+ val (studyDeploymentId: UUID, deviceRoleName: String) = study.id
+
+ // Register the client device in the study deployment.
+ val studyStatus: StudyDeploymentStatus =
+ deploymentService.registerDevice( studyDeploymentId, deviceRoleName, deviceRegistration )
+ study.deploymentStatusReceived( studyStatus )
+ val deviceStatus = studyStatus.getDeviceStatus( deviceRoleName )
+
+ // Early out in case state indicates the device is already deployed or deployment cannot yet be obtained.
+ if ( deviceStatus !is DeviceDeploymentStatus.NotDeployed ) return
+ if ( !deviceStatus.canObtainDeviceDeployment ) return
+
+ // Get deployment information.
+ // TODO: Handle race condition in case other devices were unregistered in between.
+ val device = deviceStatus.device
+ val deployment = deploymentService.getDeviceDeploymentFor( studyDeploymentId, device.roleName )
+ check( deployment.deviceDescriptor == device )
+ val remainingDevicesToRegister = studyStatus.devicesStatus
+ .map { it.device }
+ .filter { it.roleName in deviceStatus.remainingDevicesToRegisterBeforeDeployment }
+ .toSet()
+ study.deviceDeploymentReceived( deployment )
+
+ // Early out in case devices need to be registered before being able to complete deployment.
+ if ( remainingDevicesToRegister.isNotEmpty() ) return
+
+ // Validate deployment and notify deployment service in case successful.
+ study.validateDeviceDeployment( dataListener )
+ try
+ {
+ val deployedStatus =
+ deploymentService.deviceDeployed( studyDeploymentId, device.roleName, deployment.lastUpdatedOn )
+ study.deploymentStatusReceived( deployedStatus )
+ }
+ // Handle race conditions with competing clients modifying device registrations, invalidating this deployment.
+ catch ( ignore: IllegalArgumentException ) { } // TODO: When deployment is out of date, maybe also use `IllegalStateException` for easier handling here.
+ catch ( ignore: IllegalStateException ) { }
+ }
+
+ /**
+ * Stop the study deployment which this [study] runtime is part of.
+ */
+ suspend fun stop( study: Study )
+ {
+ // Early out in case study has already been stopped.
+ val status = study.getStatus()
+ if ( status is StudyStatus.Stopped ) return
+
+ // Stop study deployment.
+ // TODO: Right now this requires the client to be online in case `deploymentService` is an online service.
+ // Once we have domain events in place this should be modeled as a request to stop deployment which is cached when offline.
+ val deploymentStatus = deploymentService.stop( study.studyDeploymentId )
+ check( deploymentStatus is StudyDeploymentStatus.Stopped )
+
+ study.deploymentStatusReceived( deploymentStatus )
+ }
+}
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/study/StudySnapshot.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/study/StudySnapshot.kt
new file mode 100644
index 000000000..6691c4e1f
--- /dev/null
+++ b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/study/StudySnapshot.kt
@@ -0,0 +1,45 @@
+package dk.cachet.carp.clients.domain.study
+
+import dk.cachet.carp.clients.application.study.StudyStatus
+import dk.cachet.carp.common.application.UUID
+import dk.cachet.carp.common.domain.Snapshot
+import dk.cachet.carp.deployments.application.MasterDeviceDeployment
+import dk.cachet.carp.deployments.application.StudyDeploymentStatus
+import kotlinx.datetime.Instant
+import kotlinx.serialization.Serializable
+
+
+@Serializable
+data class StudySnapshot(
+ val studyDeploymentId: UUID,
+ val deviceRoleName: String,
+ override val createdOn: Instant,
+ val deploymentStatus: StudyDeploymentStatus?,
+ val deploymentInformation: MasterDeviceDeployment?,
+) : Snapshot
+{
+ companion object
+ {
+ fun fromStudy( study: Study ): StudySnapshot
+ {
+ val status = study.getStatus()
+ val deploymentInformation: MasterDeviceDeployment? =
+ when ( status )
+ {
+ is StudyStatus.DeviceDeploymentReceived -> status.deploymentInformation
+ is StudyStatus.Stopped -> status.deploymentInformation
+ else -> null
+ }
+
+ return StudySnapshot(
+ study.studyDeploymentId,
+ study.deviceRoleName,
+ study.createdOn,
+ (status as? StudyStatus.DeploymentStatusAvailable)?.deploymentStatus,
+ deploymentInformation
+ )
+ }
+ }
+
+ override fun toObject(): Study = Study.fromSnapshot( this )
+}
diff --git a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/infrastructure/InMemoryClientRepository.kt b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/infrastructure/InMemoryClientRepository.kt
index b20372efe..dccd6fa6d 100644
--- a/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/infrastructure/InMemoryClientRepository.kt
+++ b/carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/infrastructure/InMemoryClientRepository.kt
@@ -1,14 +1,14 @@
package dk.cachet.carp.clients.infrastructure
import dk.cachet.carp.clients.domain.ClientRepository
-import dk.cachet.carp.clients.domain.StudyRuntime
-import dk.cachet.carp.clients.domain.StudyRuntimeSnapshot
+import dk.cachet.carp.clients.domain.study.Study
+import dk.cachet.carp.clients.domain.study.StudySnapshot
import dk.cachet.carp.common.application.UUID
import dk.cachet.carp.common.application.devices.DeviceRegistration
/**
- * A [ClientRepository] which holds [StudyRuntime]s in memory as long as the instance is held in memory.
+ * A [ClientRepository] which holds [Study]s in memory as long as the instance is held in memory.
*/
class InMemoryClientRepository : ClientRepository
{
@@ -27,64 +27,64 @@ class InMemoryClientRepository : ClientRepository
deviceRegistration = registration
}
- private val studyRuntimes: MutableList = mutableListOf()
+ private val studies: MutableList = mutableListOf()
/**
- * Adds the specified [studyRuntime] to the repository.
+ * Adds the specified [study] to the repository.
*
- * @throws IllegalArgumentException when a [StudyRuntime] which has the same study deployment ID and device role name already exists.
+ * @throws IllegalArgumentException when a [Study] which has the same study deployment ID and device role name already exists.
*/
- override suspend fun addStudyRuntime( studyRuntime: StudyRuntime )
+ override suspend fun addStudy( study: Study )
{
- val deploymentId = studyRuntime.studyDeploymentId
- val deviceRoleName = studyRuntime.id.deviceRoleName
- require( studyRuntimes.none { it.studyDeploymentId == deploymentId && it.device.roleName == deviceRoleName } )
+ val deploymentId = study.studyDeploymentId
+ val deviceRoleName = study.id.deviceRoleName
+ require( studies.none { it.studyDeploymentId == deploymentId && it.deviceRoleName == deviceRoleName } )
- studyRuntimes.add( studyRuntime.getSnapshot() )
+ studies.add( study.getSnapshot() )
}
/**
- * Return the [StudyRuntime] with [studyDeploymentId] and [deviceRoleName], or null when no such [StudyRuntime] is found.
+ * Return the [Study] with [studyDeploymentId] and [deviceRoleName], or null when no such [Study] is found.
*/
- override suspend fun getStudyRuntimeBy( studyDeploymentId: UUID, deviceRoleName: String ): StudyRuntime? =
- studyRuntimes
- .filter { it.studyDeploymentId == studyDeploymentId && it.device.roleName == deviceRoleName }
- .map { StudyRuntime.fromSnapshot( it ) }
+ override suspend fun getStudyBy( studyDeploymentId: UUID, deviceRoleName: String ): Study? =
+ studies
+ .filter { it.studyDeploymentId == studyDeploymentId && it.deviceRoleName == deviceRoleName }
+ .map { Study.fromSnapshot( it ) }
.firstOrNull()
/**
- * Return all [StudyRuntime]s for the client.
+ * Return all [Study]s for the client.
*/
- override suspend fun getStudyRuntimeList(): List =
- studyRuntimes.map { StudyRuntime.fromSnapshot( it ) }
+ override suspend fun getStudyList(): List =
+ studies.map { Study.fromSnapshot( it ) }
/**
- * Update a [StudyRuntime] which is already stored in the repository.
+ * Update a [study] which is already stored in the repository.
*
- * @throws IllegalArgumentException when no previous version of this study runtime is stored in the repository.
+ * @throws IllegalArgumentException when no previous version of this study is stored in the repository.
*/
- override suspend fun updateStudyRuntime( runtime: StudyRuntime )
+ override suspend fun updateStudy( study: Study )
{
- val storedRuntime = findRuntimeSnapshot( runtime )
- requireNotNull( storedRuntime ) { "The repository does not contain an existing study runtime matching the one to update." }
+ val storedStudy = findStudySnapshot( study )
+ requireNotNull( storedStudy ) { "The repository does not contain an existing study matching the one to update." }
- studyRuntimes.remove( storedRuntime )
- studyRuntimes.add( runtime.getSnapshot() )
+ studies.remove( storedStudy )
+ studies.add( study.getSnapshot() )
}
/**
- * Remove a [StudyRuntime] which is already stored in the repository.
- * In case [runtime] is not stored in this repository, nothing happens.
+ * Remove a [study] which is already stored in the repository.
+ * In case [study] is not stored in this repository, nothing happens.
*/
- override suspend fun removeStudyRuntime( runtime: StudyRuntime )
+ override suspend fun removeStudy( study: Study )
{
- val storedRuntime = findRuntimeSnapshot( runtime )
- studyRuntimes.remove( storedRuntime )
+ val storedStudy = findStudySnapshot( study )
+ studies.remove( storedStudy )
}
- private fun findRuntimeSnapshot( runtime: StudyRuntime ): StudyRuntimeSnapshot? =
- studyRuntimes.firstOrNull {
+ private fun findStudySnapshot( runtime: Study ): StudySnapshot? =
+ studies.firstOrNull {
it.studyDeploymentId == runtime.studyDeploymentId &&
- it.device.roleName == runtime.id.deviceRoleName
+ it.deviceRoleName == runtime.id.deviceRoleName
}
}
diff --git a/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/ClientCodeSamples.kt b/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/ClientCodeSamples.kt
index 74c16397f..38cce120c 100644
--- a/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/ClientCodeSamples.kt
+++ b/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/ClientCodeSamples.kt
@@ -1,7 +1,7 @@
package dk.cachet.carp.clients
import dk.cachet.carp.clients.domain.SmartphoneClient
-import dk.cachet.carp.clients.domain.StudyRuntimeStatus
+import dk.cachet.carp.clients.application.study.StudyStatus
import dk.cachet.carp.clients.domain.createDataCollectorFactory
import dk.cachet.carp.clients.domain.createParticipantInvitation
import dk.cachet.carp.clients.domain.data.DataListener
@@ -46,7 +46,7 @@ class ClientCodeSamples
val studyDeploymentId: UUID = invitation.participation.studyDeploymentId
val deviceToUse: String = invitation.assignedDevices.first().device.roleName // This matches "Patient's phone".
- // Create a study runtime for the study.
+ // Add the study to a client device manager.
val clientRepository = createRepository()
val client = SmartphoneClient( clientRepository, deploymentService, dataCollectorFactory )
client.configure {
@@ -55,18 +55,18 @@ class ClientCodeSamples
// E.g., for a smartphone, a UUID deviceId is generated. To override this default:
deviceId = "xxxxxxxxx"
}
- var status: StudyRuntimeStatus = client.addStudy( studyDeploymentId, deviceToUse )
+ var status: StudyStatus = client.addStudy( studyDeploymentId, deviceToUse )
// Register connected devices in case needed.
- if ( status is StudyRuntimeStatus.RegisteringDevices )
+ if ( status is StudyStatus.RegisteringDevices )
{
val connectedDevice = status.remainingDevicesToRegister.first()
val connectedRegistration = connectedDevice.createRegistration()
deploymentService.registerDevice( studyDeploymentId, connectedDevice.roleName, connectedRegistration )
- // Re-try deployment now that devices have been registered.
+ // Try deployment now that devices have been registered.
status = client.tryDeployment( status.id )
- val isDeployed = status is StudyRuntimeStatus.Deployed // True.
+ val isDeployed = status is StudyStatus.Running // True.
}
}
diff --git a/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/ClientManagerTest.kt b/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/ClientManagerTest.kt
index 3d4c83ea0..f6ed9f3d6 100644
--- a/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/ClientManagerTest.kt
+++ b/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/ClientManagerTest.kt
@@ -1,5 +1,8 @@
package dk.cachet.carp.clients.domain
+import dk.cachet.carp.clients.application.ClientManager
+import dk.cachet.carp.clients.application.study.StudyId
+import dk.cachet.carp.clients.application.study.StudyStatus
import dk.cachet.carp.clients.infrastructure.InMemoryClientRepository
import dk.cachet.carp.common.application.UUID
import dk.cachet.carp.deployments.application.DeploymentService
@@ -33,48 +36,14 @@ class ClientManagerTest
assertTrue( client.isConfigured() )
}
- @Test
- fun add_study_fails_when_not_yet_configured() = runSuspendTest {
- val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
- val client = SmartphoneClient( InMemoryClientRepository(), deploymentService, createDataCollectorFactory() )
-
- assertFailsWith
- {
- client.addStudy( deploymentStatus.studyDeploymentId, smartphone.roleName )
- }
- }
-
@Test
fun add_study_succeeds() = runSuspendTest {
// Create deployment service and client manager.
val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
val client = initializeSmartphoneClient( deploymentService )
- client.addStudy( deploymentStatus.studyDeploymentId, smartphone.roleName )
- }
-
- @Test
- fun add_study_fails_for_invalid_deployment() = runSuspendTest {
- // Create deployment service and client manager.
- val (deploymentService, _) = createStudyDeployment( createSmartphoneStudy() )
- val client = initializeSmartphoneClient( deploymentService )
-
- assertFailsWith
- {
- client.addStudy( unknownId, smartphone.roleName )
- }
- }
-
- @Test
- fun add_study_fails_for_nonexisting_device_role() = runSuspendTest {
- // Create deployment service and client manager.
- val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
- val client = initializeSmartphoneClient( deploymentService )
-
- assertFailsWith
- {
- client.addStudy( deploymentStatus.studyDeploymentId, "Invalid role" )
- }
+ val status = client.addStudy( deploymentStatus.studyDeploymentId, smartphone.roleName )
+ assertEquals( status, client.getStudiesStatus().singleOrNull() )
}
@Test
@@ -86,7 +55,8 @@ class ClientManagerTest
client.addStudy( deploymentStatus.studyDeploymentId, smartphone.roleName )
assertFailsWith
{
- client.addStudy( deploymentStatus.studyDeploymentId, smartphone.roleName )
+ val status = client.addStudy( deploymentStatus.studyDeploymentId, smartphone.roleName )
+ client.tryDeployment( status.id )
}
}
@@ -95,15 +65,16 @@ class ClientManagerTest
val (deploymentService, deploymentStatus) = createStudyDeployment( createDependentSmartphoneStudy() )
val client = initializeSmartphoneClient( deploymentService )
val deploymentId = deploymentStatus.studyDeploymentId
- var status: StudyRuntimeStatus = client.addStudy( deploymentId, smartphone.roleName )
+ var status: StudyStatus = client.addStudy( deploymentId, smartphone.roleName )
+ status = client.tryDeployment( status.id )
// Dependent device needs to be registered before the intended device can be deployed on this client.
- assertTrue( status is StudyRuntimeStatus.NotReadyForDeployment )
+ assertTrue( status is StudyStatus.AwaitingOtherDeviceRegistrations )
val dependentRegistration = deviceSmartphoneDependsOn.createRegistration()
deploymentService.registerDevice( deploymentId, deviceSmartphoneDependsOn.roleName, dependentRegistration )
status = client.tryDeployment( status.id )
- assertTrue( status is StudyRuntimeStatus.Deployed )
+ assertTrue( status is StudyStatus.AwaitingOtherDeviceDeployments )
}
@Test
@@ -112,50 +83,79 @@ class ClientManagerTest
createStudyDeployment( createSmartphoneWithConnectedDeviceStudy() )
val client = initializeSmartphoneClient( deploymentService )
val deploymentId = deploymentStatus.studyDeploymentId
- var status: StudyRuntimeStatus = client.addStudy( deploymentId, smartphone.roleName )
+ var status: StudyStatus = client.addStudy( deploymentId, smartphone.roleName )
+ status = client.tryDeployment( status.id )
// Connected device needs to be registered before deployment can complete.
// TODO: It should be possible to register this device through `ClientManager` rather than directly from `deploymentService`.
- assertTrue( status is StudyRuntimeStatus.RegisteringDevices )
+ assertTrue( status is StudyStatus.RegisteringDevices )
val connectedRegistration = connectedDevice.createRegistration()
deploymentService.registerDevice( deploymentId, connectedDevice.roleName, connectedRegistration )
status = client.tryDeployment( status.id )
- assertTrue( status is StudyRuntimeStatus.Deployed )
+ assertTrue( status is StudyStatus.Running )
}
@Test
- fun tryDeployment_returns_true_when_already_deployed() = runSuspendTest {
+ fun tryDeployment_succeeds_when_already_deployed() = runSuspendTest {
// Add a study which instantly deploys given that the protocol only contains one master device.
val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
val client = initializeSmartphoneClient( deploymentService )
val deploymentId = deploymentStatus.studyDeploymentId
- var status: StudyRuntimeStatus = client.addStudy( deploymentId, smartphone.roleName )
- assertTrue( status is StudyRuntimeStatus.Deployed )
+ var status: StudyStatus = client.addStudy( deploymentId, smartphone.roleName )
+ status = client.tryDeployment( status.id )
+ assertTrue( status is StudyStatus.Running )
status = client.tryDeployment( status.id )
- assertTrue( status is StudyRuntimeStatus.Deployed )
+ assertTrue( status is StudyStatus.Running )
+ }
+
+ @Test
+ fun tryDeployment_fails_when_not_yet_configured() = runSuspendTest {
+ val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
+ val client = SmartphoneClient( InMemoryClientRepository(), deploymentService, createDataCollectorFactory() )
+ val status = client.addStudy( deploymentStatus.studyDeploymentId, smartphone.roleName )
+
+ assertFailsWith { client.tryDeployment( status.id ) }
}
@Test
- fun tryDeployment_fails_for_unknown_id() = runSuspendTest {
+ fun tryDeployment_fails_for_unknown_study_id() = runSuspendTest {
val (deploymentService, _) = createStudyDeployment( createDependentSmartphoneStudy() )
val client = initializeSmartphoneClient( deploymentService )
- assertFailsWith
- {
- client.tryDeployment( StudyRuntimeId( unknownId, "Unknown device role" ) )
- }
+ val unknownStudyId = StudyId( unknownId, "Unknown device role" )
+ assertFailsWith { client.tryDeployment( unknownStudyId ) }
+ }
+
+ @Test
+ fun tryDeployment_fails_for_invalid_deployment() = runSuspendTest {
+ // Create deployment service and client manager.
+ val (deploymentService, _) = createStudyDeployment( createSmartphoneStudy() )
+ val client = initializeSmartphoneClient( deploymentService )
+ val status = client.addStudy( unknownId, smartphone.roleName )
+
+ assertFailsWith { client.tryDeployment( status.id ) }
+ }
+
+ @Test
+ fun tryDeployment_fails_for_nonexisting_device_role() = runSuspendTest {
+ // Create deployment service and client manager.
+ val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
+ val client = initializeSmartphoneClient( deploymentService )
+ val status = client.addStudy( deploymentStatus.studyDeploymentId, "Invalid role" )
+
+ assertFailsWith { client.tryDeployment( status.id ) }
}
@Test
fun stopStudy_succeeds() = runSuspendTest {
val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
val client = initializeSmartphoneClient( deploymentService )
- val status: StudyRuntimeStatus = client.addStudy( deploymentStatus.studyDeploymentId, smartphone.roleName )
+ val status: StudyStatus = client.addStudy( deploymentStatus.studyDeploymentId, smartphone.roleName )
val newStatus = client.stopStudy( status.id )
- assertTrue( newStatus is StudyRuntimeStatus.Stopped )
+ assertTrue( newStatus is StudyStatus.Stopped )
}
@Test
@@ -165,7 +165,7 @@ class ClientManagerTest
assertFailsWith
{
- client.stopStudy( StudyRuntimeId( unknownId, "Unknown device role" ) )
+ client.stopStudy( StudyId( unknownId, "Unknown device role" ) )
}
}
@@ -174,14 +174,13 @@ class ClientManagerTest
val (deploymentService, deploymentStatus) = createStudyDeployment( createDependentSmartphoneStudy() )
val client = initializeSmartphoneClient( deploymentService )
val deploymentId = deploymentStatus.studyDeploymentId
- var status: StudyRuntimeStatus = client.addStudy( deploymentId, smartphone.roleName )
+ var status: StudyStatus = client.addStudy( deploymentId, smartphone.roleName )
// Register dependent device and deploy client.
- check( status is StudyRuntimeStatus.NotReadyForDeployment )
val dependentRegistration = deviceSmartphoneDependsOn.createRegistration()
deploymentService.registerDevice( deploymentId, deviceSmartphoneDependsOn.roleName, dependentRegistration )
status = client.tryDeployment( status.id )
- check( status is StudyRuntimeStatus.Deployed )
+ check( status is StudyStatus.AwaitingOtherDeviceDeployments )
assertEquals( status, client.getStudiesStatus().first() )
// Stop client.
@@ -202,8 +201,9 @@ class ClientManagerTest
// Get device registration status.
val client = initializeSmartphoneClient( deploymentService )
- val studyStatus: StudyRuntimeStatus = client.addStudy( deploymentId, smartphone.roleName )
- assertTrue( studyStatus is StudyRuntimeStatus.DeploymentReceived )
+ var studyStatus: StudyStatus = client.addStudy( deploymentId, smartphone.roleName )
+ studyStatus = client.tryDeployment( studyStatus.id )
+ assertTrue( studyStatus is StudyStatus.DeviceDeploymentReceived )
val deviceStatus = studyStatus.devicesRegistrationStatus[ connectedDevice ]
assertTrue( deviceStatus is DeviceRegistrationStatus.Registered )
diff --git a/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/ClientRepositoryTest.kt b/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/ClientRepositoryTest.kt
index 02503cb61..fe34e60b8 100644
--- a/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/ClientRepositoryTest.kt
+++ b/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/ClientRepositoryTest.kt
@@ -1,15 +1,13 @@
package dk.cachet.carp.clients.domain
-import dk.cachet.carp.clients.domain.data.DataListener
+import dk.cachet.carp.clients.domain.study.Study
import dk.cachet.carp.common.application.UUID
-import dk.cachet.carp.common.application.devices.SmartphoneDeviceRegistration
-import dk.cachet.carp.common.application.services.createApplicationServiceAdapter
-import dk.cachet.carp.common.infrastructure.services.SingleThreadedEventBus
-import dk.cachet.carp.data.infrastructure.InMemoryDataStreamService
-import dk.cachet.carp.deployments.application.DeploymentService
-import dk.cachet.carp.deployments.application.DeploymentServiceHost
-import dk.cachet.carp.deployments.infrastructure.InMemoryDeploymentRepository
+import dk.cachet.carp.common.infrastructure.test.StubMasterDeviceDescriptor
+import dk.cachet.carp.deployments.application.DeviceDeploymentStatus
+import dk.cachet.carp.deployments.application.MasterDeviceDeployment
+import dk.cachet.carp.deployments.application.StudyDeploymentStatus
import dk.cachet.carp.test.runSuspendTest
+import kotlinx.datetime.Clock
import kotlin.test.*
@@ -20,129 +18,114 @@ interface ClientRepositoryTest
*/
fun createRepository(): ClientRepository
- private fun createDependencies(): Triple
- {
- val eventBus = SingleThreadedEventBus()
-
- val deploymentService = DeploymentServiceHost(
- InMemoryDeploymentRepository(),
- InMemoryDataStreamService(),
- eventBus.createApplicationServiceAdapter( DeploymentService::class ) )
- return Triple( createRepository(), deploymentService, createDataListener() )
- }
-
- private suspend fun addTestDeployment( deploymentService: DeploymentService ): UUID
- {
- val protocol = createSmartphoneStudy()
- val invitation = createParticipantInvitation( protocol )
- val status = deploymentService.createStudyDeployment( UUID.randomUUID(), protocol.getSnapshot(), listOf( invitation ) )
-
- return status.studyDeploymentId
- }
-
- private suspend fun createTestStudyRuntime(): Pair
- {
- val (repo, deploymentService, dataListener) = createDependencies()
- val deploymentId = addTestDeployment( deploymentService )
- val roleName = smartphone.roleName
- return repo to StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentId, roleName, smartphone.createRegistration() )
- }
-
@Test
fun deviceRegistration_is_initially_null() = runSuspendTest {
- val (repo, _) = createDependencies()
+ val repo = createRepository()
assertNull( repo.getDeviceRegistration() )
}
@Test
- fun addStudyRuntime_can_be_retrieved() = runSuspendTest {
- val (repo, studyRuntime) = createTestStudyRuntime()
- repo.addStudyRuntime( studyRuntime )
-
- // Runtime can be retrieved by ID.
- val deploymentId = studyRuntime.studyDeploymentId
- val roleName = studyRuntime.device.roleName
- val retrievedRuntime = repo.getStudyRuntimeBy( deploymentId, roleName )
- assertNotNull( retrievedRuntime )
-
- // Runtime is included in list.
- val allRuntimes = repo.getStudyRuntimeList()
- assertEquals( 1, allRuntimes.count() )
- assertNotNull( allRuntimes.single { it.studyDeploymentId == deploymentId && it.device.roleName == roleName } )
+ fun addStudy_can_be_retrieved() = runSuspendTest {
+ val repo = createRepository()
+
+ val study = Study( UUID.randomUUID(), "Device role" )
+ repo.addStudy( study )
+
+ // Study can be retrieved by ID.
+ val deploymentId = study.studyDeploymentId
+ val roleName = study.deviceRoleName
+ val retrievedStudy = repo.getStudyBy( deploymentId, roleName )
+ assertNotNull( retrievedStudy )
+
+ // Study is included in list.
+ val allStudies = repo.getStudyList()
+ assertEquals( 1, allStudies.count() )
+ assertNotNull( allStudies.single { it.studyDeploymentId == deploymentId && it.deviceRoleName == roleName } )
}
@Test
- fun addStudyRuntime_fails_for_existing_runtime() = runSuspendTest {
- val (repo, studyRuntime) = createTestStudyRuntime()
- repo.addStudyRuntime( studyRuntime )
+ fun addStudy_fails_for_existing_study() = runSuspendTest {
+ val repo = createRepository()
+ val study = Study( UUID.randomUUID(), "Device role" )
+ repo.addStudy( study )
- assertFailsWith { repo.addStudyRuntime( studyRuntime ) }
+ assertFailsWith { repo.addStudy( study ) }
}
@Test
- fun getStudyRuntimeBy_is_null_for_unknown_runtime() = runSuspendTest {
- val (repo, _) = createDependencies()
+ fun getStudyBy_is_null_for_unknown_study() = runSuspendTest {
+ val repo = createRepository()
val unknownId = UUID.randomUUID()
- assertNull( repo.getStudyRuntimeBy( unknownId, "Unknown" ) )
+ assertNull( repo.getStudyBy( unknownId, "Unknown" ) )
}
@Test
- fun getStudyRuntimeList_is_empty_initially() = runSuspendTest {
- val (repo, _) = createDependencies()
+ fun getStudyList_is_empty_initially() = runSuspendTest {
+ val repo = createRepository()
- assertEquals( 0, repo.getStudyRuntimeList().count() )
+ assertEquals( 0, repo.getStudyList().count() )
}
@Test
- fun updateStudyRuntime_succeeds() = runSuspendTest {
- val (repo, deploymentService, dataListener) = createDependencies()
- val protocol = createDependentSmartphoneStudy()
- val invitation = createParticipantInvitation( protocol )
+ fun updateStudy_succeeds() = runSuspendTest {
+ val repo = createRepository()
val deploymentId = UUID.randomUUID()
- deploymentService.createStudyDeployment( deploymentId, protocol.getSnapshot(), listOf( invitation ) )
- val studyRuntime = StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentId, smartphone.roleName, smartphone.createRegistration() )
- repo.addStudyRuntime( studyRuntime )
+ val deviceRoleName = "Device role"
+ val study = Study( deploymentId, deviceRoleName )
+ repo.addStudy( study )
// Make some changes and update.
- deploymentService.registerDevice( deploymentId, deviceSmartphoneDependsOn.roleName, SmartphoneDeviceRegistration( "dependent" ) )
- studyRuntime.tryDeployment( deploymentService, dataListener )
- repo.updateStudyRuntime( studyRuntime )
+ val masterDevice = StubMasterDeviceDescriptor( deviceRoleName )
+ val registration = masterDevice.createRegistration()
+ val masterDeviceDeployment = MasterDeviceDeployment( StubMasterDeviceDescriptor( deviceRoleName ), registration )
+ study.deploymentStatusReceived(
+ StudyDeploymentStatus.DeployingDevices(
+ Clock.System.now(),
+ deploymentId,
+ listOf(
+ DeviceDeploymentStatus.Registered( masterDevice, true, emptySet(), emptySet() )
+ ),
+ emptyList(),
+ null
+ )
+ )
+ study.deviceDeploymentReceived( masterDeviceDeployment )
+ repo.updateStudy( study )
// Verify whether changes were stored.
- val retrievedRuntime = repo.getStudyRuntimeBy( deploymentId, smartphone.roleName )
- assertNotNull( retrievedRuntime )
- assertEquals( studyRuntime.getSnapshot(), retrievedRuntime.getSnapshot() )
+ val retrievedStudy = repo.getStudyBy( deploymentId, deviceRoleName )
+ assertNotNull( retrievedStudy )
+ assertEquals( study.getSnapshot(), retrievedStudy.getSnapshot() )
}
@Test
- fun updateStudyRuntime_fails_for_unknown_runtime() = runSuspendTest {
- val (repo, studyRuntime) = createTestStudyRuntime()
+ fun updateStudy_fails_for_unknown_study() = runSuspendTest {
+ val repo = createRepository()
- assertFailsWith { repo.updateStudyRuntime( studyRuntime ) }
+ val study = Study( UUID.randomUUID(), "Device role" )
+ assertFailsWith { repo.updateStudy( study ) }
}
@Test
- fun removeStudyRuntime_succeeds() = runSuspendTest {
- val (repo, studyRuntime) = createTestStudyRuntime()
- repo.addStudyRuntime( studyRuntime )
+ fun removeStudy_succeeds() = runSuspendTest {
+ val repo = createRepository()
+ val study = Study( UUID.randomUUID(), "Device role" )
+ repo.addStudy( study )
- repo.removeStudyRuntime( studyRuntime )
+ repo.removeStudy( study )
- val deploymentId = studyRuntime.studyDeploymentId
- val roleName = studyRuntime.device.roleName
- assertNull( repo.getStudyRuntimeBy( deploymentId, roleName ) )
- assertEquals( 0, repo.getStudyRuntimeList().count() )
+ val deploymentId = study.studyDeploymentId
+ val roleName = study.deviceRoleName
+ assertNull( repo.getStudyBy( deploymentId, roleName ) )
+ assertEquals( 0, repo.getStudyList().count() )
}
@Test
- fun removeStudyRuntime_succeeds_when_runtime_not_present() = runSuspendTest {
- val (repo, studyRuntime) = createTestStudyRuntime()
- repo.removeStudyRuntime( studyRuntime )
+ fun removeStudy_succeeds_when_study_not_present() = runSuspendTest {
+ val repo = createRepository()
+ val study = Study( UUID.randomUUID(), "Device role" )
+ repo.removeStudy( study )
}
}
diff --git a/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/StudyRuntimeTest.kt b/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/study/StudyDeploymentProxyTest.kt
similarity index 50%
rename from carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/StudyRuntimeTest.kt
rename to carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/study/StudyDeploymentProxyTest.kt
index 989ba1c39..37ada1dbc 100644
--- a/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/StudyRuntimeTest.kt
+++ b/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/study/StudyDeploymentProxyTest.kt
@@ -1,5 +1,15 @@
-package dk.cachet.carp.clients.domain
-
+package dk.cachet.carp.clients.domain.study
+
+import dk.cachet.carp.clients.application.study.StudyStatus
+import dk.cachet.carp.clients.domain.connectedDevice
+import dk.cachet.carp.clients.domain.createDataListener
+import dk.cachet.carp.clients.domain.createDependentSmartphoneStudy
+import dk.cachet.carp.clients.domain.createSmartphoneStudy
+import dk.cachet.carp.clients.domain.createSmartphoneWithConnectedDeviceStudy
+import dk.cachet.carp.clients.domain.createStudyDeployment
+import dk.cachet.carp.clients.domain.deviceSmartphoneDependsOn
+import dk.cachet.carp.clients.domain.smartphone
+import dk.cachet.carp.clients.domain.DeviceRegistrationStatus
import dk.cachet.carp.clients.domain.data.AnyConnectedDeviceDataCollector
import dk.cachet.carp.clients.domain.data.DataListener
import dk.cachet.carp.clients.domain.data.DeviceDataCollectorFactory
@@ -22,54 +32,28 @@ import kotlin.test.*
/**
- * Tests for [StudyRuntime].
+ * Tests for [StudyDeploymentProxy].
*/
-class StudyRuntimeTest
+class StudyDeploymentProxyTest
{
@Test
- fun initialize_matches_requested_runtime() = runSuspendTest {
+ fun tryDeployment_deploys_when_possible() = runSuspendTest {
// Create a deployment service which contains a 'smartphone study'.
val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
+ val studyDeployment = StudyDeploymentProxy( deploymentService, createDataListener() )
- // Initialize study runtime.
+ val study = Study( deploymentStatus.studyDeploymentId, smartphone.roleName )
val deviceRegistration = smartphone.createRegistration()
- val dataListener = createDataListener()
- val runtime = StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, deviceRegistration )
-
- assertEquals( deploymentStatus.studyDeploymentId, runtime.studyDeploymentId )
- assertEquals( smartphone, runtime.device )
- }
-
- @Test
- fun initialize_deploys_when_possible() = runSuspendTest {
- // Create a deployment service which contains a 'smartphone study'.
- val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
+ studyDeployment.tryDeployment( study, deviceRegistration )
- // Initialize study runtime.
- val deviceRegistration = smartphone.createRegistration()
- val dataListener = createDataListener()
- val runtime = StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, deviceRegistration )
-
- // Study runtime status is deployed and contains registered master device.
- assertTrue( runtime.isDeployed )
- val runtimeStatus = runtime.getStatus()
- assertTrue( runtimeStatus is StudyRuntimeStatus.Deployed )
- val registrationStatus = runtimeStatus.devicesRegistrationStatus.values.singleOrNull()
+ // Study status is running and contains registered master device.
+ val studyStatus = study.getStatus()
+ assertTrue( studyStatus is StudyStatus.Running )
+ val registrationStatus = studyStatus.devicesRegistrationStatus.values.singleOrNull()
assertTrue( registrationStatus is DeviceRegistrationStatus.Registered )
assertEquals( smartphone, registrationStatus.device )
assertEquals( deviceRegistration, registrationStatus.registration )
- // Study runtime events reflects deployment has been received and completed.
- val events = runtime.consumeEvents()
- val receivedEvent = events.filterIsInstance()
- assertEquals( 1, receivedEvent.count() )
- assertEquals( runtimeStatus.deploymentInformation, receivedEvent.single().deploymentInformation )
- assertEquals( 1, events.filterIsInstance().count() )
-
// Master device status in deployment is also set to deployed.
val newDeploymentStatus = deploymentService.getStudyDeploymentStatus( deploymentStatus.studyDeploymentId )
val masterDeviceStatus = newDeploymentStatus.getDeviceStatus( smartphone )
@@ -77,25 +61,18 @@ class StudyRuntimeTest
}
@Test
- fun initialize_does_not_deploy_when_depending_on_other_devices() = runSuspendTest {
+ fun tryDeployment_does_not_deploy_when_depending_on_other_devices() = runSuspendTest {
// Create a deployment service which contains a study where 'smartphone' depends on another master device.
val (deploymentService, deploymentStatus) = createStudyDeployment( createDependentSmartphoneStudy() )
+ val studyDeployment = StudyDeploymentProxy( deploymentService, createDataListener() )
- // Initialize study runtime.
+ val study = Study( deploymentStatus.studyDeploymentId, smartphone.roleName )
val deviceRegistration = smartphone.createRegistration()
- val dataListener = createDataListener()
- val runtime = StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, deviceRegistration )
+ studyDeployment.tryDeployment( study, deviceRegistration )
- // Study runtime status is not ready for deployment.
- assertFalse( runtime.isDeployed )
- val runtimeStatus = runtime.getStatus()
- assertTrue( runtimeStatus is StudyRuntimeStatus.NotReadyForDeployment )
-
- // Study runtime events reflects deployment has not been received yet.
- val events = runtime.consumeEvents()
- assertEquals( 0, events.filterIsInstance().count() )
+ // Study status is not ready for deployment.
+ val studyStatus = study.getStatus()
+ assertTrue( studyStatus is StudyStatus.AwaitingOtherDeviceRegistrations )
// Master device status in deployment is registered, but not deployed.
val newDeploymentStatus = deploymentService.getStudyDeploymentStatus( deploymentStatus.studyDeploymentId )
@@ -104,34 +81,24 @@ class StudyRuntimeTest
}
@Test
- fun initialize_does_not_deploy_when_registering_devices() = runSuspendTest {
+ fun tryDeployment_does_not_deploy_when_registering_devices() = runSuspendTest {
// Create a deployment service which contains a study where 'smartphone' depends on a connected device.
val (deploymentService, deploymentStatus) =
createStudyDeployment( createSmartphoneWithConnectedDeviceStudy() )
+ val studyDeployment = StudyDeploymentProxy( deploymentService, createDataListener() )
- // Initialize study runtime.
+ val study = Study( deploymentStatus.studyDeploymentId, smartphone.roleName )
val deviceRegistration = smartphone.createRegistration()
- val dataListener = createDataListener()
- val runtime = StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, deviceRegistration )
-
- // Study runtime status indicates `connectedDevice` needs to be registered.
- assertFalse( runtime.isDeployed )
- val runtimeStatus = runtime.getStatus()
- assertTrue( runtimeStatus is StudyRuntimeStatus.RegisteringDevices )
- assertEquals( connectedDevice, runtimeStatus.remainingDevicesToRegister.single() )
- val connectedRegistrationStatus = runtimeStatus.devicesRegistrationStatus[ connectedDevice ]
+ studyDeployment.tryDeployment( study, deviceRegistration )
+
+ // Study status indicates `connectedDevice` needs to be registered.
+ val studyStatus = study.getStatus()
+ assertTrue( studyStatus is StudyStatus.RegisteringDevices )
+ assertEquals( connectedDevice, studyStatus.remainingDevicesToRegister.single() )
+ val connectedRegistrationStatus = studyStatus.devicesRegistrationStatus[ connectedDevice ]
assertTrue( connectedRegistrationStatus is DeviceRegistrationStatus.Unregistered )
assertEquals( connectedDevice, connectedRegistrationStatus.device )
- // Study runtime events reflects deployment has been received, but not completed.
- val events = runtime.consumeEvents()
- val receivedEvent = events.filterIsInstance()
- assertEquals( 1, receivedEvent.count() )
- assertEquals( runtimeStatus.deploymentInformation, receivedEvent.single().deploymentInformation )
- assertEquals( 0, events.filterIsInstance().count() )
-
// Master device status in deployment is registered, but not deployed.
val newDeploymentStatus = deploymentService.getStudyDeploymentStatus( deploymentStatus.studyDeploymentId )
val masterDeviceStatus = newDeploymentStatus.getDeviceStatus( smartphone )
@@ -139,69 +106,64 @@ class StudyRuntimeTest
}
@Test
- fun initialize_fails_for_unknown_studyDeploymentId() = runSuspendTest {
+ fun tryDeployment_fails_for_unknown_studyDeploymentId() = runSuspendTest {
// Create a deployment service which contains a 'smartphone study'.
val (deploymentService, _) = createStudyDeployment( createSmartphoneStudy() )
+ val studyDeployment = StudyDeploymentProxy( deploymentService, createDataListener() )
val unknownId = UUID.randomUUID()
+ val study = Study( unknownId, smartphone.roleName )
val deviceRegistration = smartphone.createRegistration()
- val dataListener = createDataListener()
assertFailsWith {
- StudyRuntime.initialize(
- deploymentService, dataListener,
- unknownId, smartphone.roleName, deviceRegistration )
+ studyDeployment.tryDeployment( study, deviceRegistration )
}
}
@Test
- fun initialize_fails_for_unknown_deviceRoleName() = runSuspendTest {
+ fun tryDeployment_fails_for_unknown_deviceRoleName() = runSuspendTest {
val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
+ val studyDeployment = StudyDeploymentProxy( deploymentService, createDataListener() )
+ val study = Study( deploymentStatus.studyDeploymentId, "Unknown role" )
val deviceRegistration = smartphone.createRegistration()
- val dataListener = createDataListener()
assertFailsWith {
- StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, "Unknown role", deviceRegistration )
+ studyDeployment.tryDeployment( study, deviceRegistration )
}
}
@Test
- fun initialize_fails_for_incorrect_deviceRegistration() = runSuspendTest {
+ fun tryDeployment_fails_for_incorrect_deviceRegistration() = runSuspendTest {
val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
+ val studyDeployment = StudyDeploymentProxy( deploymentService, createDataListener() )
+ val study = Study( deploymentStatus.studyDeploymentId, smartphone.roleName )
val incorrectRegistration = AltBeaconDeviceRegistration( 0, UUID.randomUUID(), 0, 0, 0 )
- val dataListener = createDataListener()
assertFailsWith {
- StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, incorrectRegistration )
+ studyDeployment.tryDeployment( study, incorrectRegistration )
}
}
@Test
fun tryDeployment_only_succeeds_after_ready_for_deployment() = runSuspendTest {
- // Create a study runtime for a study where 'smartphone' depends on another master device ('deviceSmartphoneDependsOn').
+ // Create a study where 'smartphone' depends on another master device ('deviceSmartphoneDependsOn').
val (deploymentService, deploymentStatus) = createStudyDeployment( createDependentSmartphoneStudy() )
+ val studyDeployment = StudyDeploymentProxy( deploymentService, createDataListener() )
+ val study = Study( deploymentStatus.studyDeploymentId, smartphone.roleName )
val deviceRegistration = smartphone.createRegistration()
- val dataListener = createDataListener()
- val runtime = StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, deviceRegistration )
+ studyDeployment.tryDeployment( study, deviceRegistration )
// Dependent devices are not yet registered.
- var status = runtime.tryDeployment( deploymentService, dataListener )
- assertEquals( 0, runtime.consumeEvents().filterIsInstance().count() )
- assertTrue( status is StudyRuntimeStatus.NotReadyForDeployment )
+ var status = study.getStatus()
+ assertTrue( status is StudyStatus.AwaitingOtherDeviceRegistrations )
// Once dependent devices are registered, deployment succeeds.
deploymentService.registerDevice(
deploymentStatus.studyDeploymentId,
deviceSmartphoneDependsOn.roleName,
deviceSmartphoneDependsOn.createRegistration() )
- status = runtime.tryDeployment( deploymentService, dataListener )
- assertEquals( 1, runtime.consumeEvents().filterIsInstance().count() )
- assertTrue( status is StudyRuntimeStatus.Deployed )
+ studyDeployment.tryDeployment( study, deviceRegistration )
+ status = study.getStatus()
+ assertTrue( status is StudyStatus.AwaitingOtherDeviceDeployments )
val registrationStatus = status.devicesRegistrationStatus.values.singleOrNull()
assertTrue( registrationStatus is DeviceRegistrationStatus.Registered )
assertEquals( smartphone, registrationStatus.device )
@@ -210,29 +172,27 @@ class StudyRuntimeTest
@Test
fun tryDeployment_only_succeeds_after_devices_are_registered() = runSuspendTest {
- // Create a study runtime for a study where 'smartphone' depends on a connected device.
+ // Create a study for a study where 'smartphone' depends on a connected device.
val (deploymentService, deploymentStatus) =
createStudyDeployment( createSmartphoneWithConnectedDeviceStudy() )
+ val studyDeployment = StudyDeploymentProxy( deploymentService, createDataListener() )
+ val study = Study( deploymentStatus.studyDeploymentId, smartphone.roleName )
val deviceRegistration = smartphone.createRegistration()
- val dataListener = createDataListener()
- val runtime = StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, deviceRegistration )
+ studyDeployment.tryDeployment( study, deviceRegistration )
// Connected device is not yet registered.
- var status = runtime.tryDeployment( deploymentService, dataListener )
- assertEquals( 0, runtime.consumeEvents().filterIsInstance().count() )
- assertTrue( status is StudyRuntimeStatus.RegisteringDevices )
+ var status = study.getStatus()
+ assertTrue( status is StudyStatus.RegisteringDevices )
// Once device is registered, deployment succeeds.
- // TODO: It should be possible to register this device through `StudyRuntime` rather than directly from `deploymentService`.
+ // TODO: It should be possible to register this device through `StudyManager` rather than directly from `deploymentService`.
deploymentService.registerDevice(
deploymentStatus.studyDeploymentId,
connectedDevice.roleName,
connectedDevice.createRegistration() )
- status = runtime.tryDeployment( deploymentService, dataListener )
- assertEquals( 1, runtime.consumeEvents().filterIsInstance().count() )
- assertTrue( status is StudyRuntimeStatus.Deployed )
+ studyDeployment.tryDeployment( study, deviceRegistration )
+ status = study.getStatus()
+ assertTrue( status is StudyStatus.Running )
val registrationStatuses = status.devicesRegistrationStatus
assertEquals( 2, registrationStatuses.size ) // Smartphone and connected device.
assertTrue( registrationStatuses[ smartphone ] is DeviceRegistrationStatus.Registered )
@@ -240,18 +200,18 @@ class StudyRuntimeTest
}
@Test
- fun tryDeployment_returns_true_when_already_deployed() = runSuspendTest {
- // Create a study runtime which instantly deploys because the protocol only contains one master device.
+ fun tryDeployment_changes_nothing_when_already_deployed() = runSuspendTest {
+ // Create a study which instantly deploys because the protocol only contains one master device.
val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
+ val studyDeployment = StudyDeploymentProxy( deploymentService, createDataListener() )
+ val study = Study( deploymentStatus.studyDeploymentId, smartphone.roleName )
val deviceRegistration = smartphone.createRegistration()
- val dataListener = createDataListener()
- val runtime = StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, deviceRegistration )
- assertTrue( runtime.isDeployed )
-
- val status = runtime.tryDeployment( deploymentService, dataListener )
- assertTrue( status is StudyRuntimeStatus.Deployed )
+ studyDeployment.tryDeployment( study, deviceRegistration )
+ assertTrue( study.getStatus() is StudyStatus.Running )
+
+ studyDeployment.tryDeployment( study, deviceRegistration )
+ val status = study.getStatus()
+ assertTrue( status is StudyStatus.Running )
}
@Test
@@ -270,19 +230,19 @@ class StudyRuntimeTest
mapOf( StubDeviceDescriptor::class to setOf( connectedDataType ) )
) )
- // Create study deployment with preregistered connected device (otherwise study runtime initialization won't complete).
+ // Create study deployment with preregistered connected device (otherwise study initialization won't complete).
val (deploymentService, deploymentStatus) = createStudyDeployment( protocol )
deploymentService.registerDevice(
deploymentStatus.studyDeploymentId,
connectedDevice.roleName,
connectedDevice.createRegistration() )
- // Initializing study runtime for the smartphone deployment should succeed since devices and data types are supported.
+ // Initializing study for the smartphone deployment should succeed since devices and data types are supported.
+ val studyDeployment = StudyDeploymentProxy( deploymentService, dataListener )
+ val study = Study( deploymentStatus.studyDeploymentId, smartphone.roleName )
val deviceRegistration = smartphone.createRegistration()
- val runtime = StudyRuntime.initialize( // This will 'tryDeployment'.
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, deviceRegistration )
- assertTrue( runtime.isDeployed )
+ studyDeployment.tryDeployment( study, deviceRegistration )
+ assertTrue( study.getStatus() is StudyStatus.Running )
}
@Test
@@ -292,15 +252,15 @@ class StudyRuntimeTest
val task = StubTaskDescriptor( "One measure", listOf( Measure.DataStream( STUB_DATA_TYPE ) ) )
protocol.addTaskControl( smartphone.atStartOfStudy().start( task, smartphone ) )
- // Initializing study runtime for the smartphone deployment should fail since StubMeasure can't be collected.
+ // Initializing study for the smartphone deployment should fail since StubMeasure can't be collected.
val (deploymentService, deploymentStatus) = createStudyDeployment( protocol )
- val deviceRegistration = smartphone.createRegistration()
val dataListener = createDataListener( supportedDataTypes = emptyArray() )
+ val studyDeployment = StudyDeploymentProxy( deploymentService, dataListener )
+ val study = Study( deploymentStatus.studyDeploymentId, smartphone.roleName )
+ val deviceRegistration = smartphone.createRegistration()
assertFailsWith
{
- StudyRuntime.initialize( // This will 'tryDeployment'.
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, deviceRegistration )
+ studyDeployment.tryDeployment( study, deviceRegistration )
}
}
@@ -313,7 +273,6 @@ class StudyRuntimeTest
deploymentStatus.studyDeploymentId,
connectedDevice.roleName,
connectedDevice.createRegistration() )
- val deviceRegistration = smartphone.createRegistration()
// Create a listener which does not support measuring on the connected device.
val localDataCollector = StubDeviceDataCollector( emptySet() )
@@ -328,60 +287,33 @@ class StudyRuntimeTest
val dataListener = DataListener( factory )
// Even though there are no measures for the connected device in the protocol, it should still verify support.
+ val studyDeployment = StudyDeploymentProxy( deploymentService, dataListener )
+ val study = Study( deploymentStatus.studyDeploymentId, smartphone.roleName )
+ val deviceRegistration = smartphone.createRegistration()
assertFailsWith
{
- StudyRuntime.initialize( // This will 'tryDeployment'.
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, deviceRegistration )
+ studyDeployment.tryDeployment( study, deviceRegistration )
}
}
@Test
fun stop_succeeds() = runSuspendTest {
- // Initialize a study runtime for a typical 'smartphone study'.
+ // Initialize a study for a typical 'smartphone study'.
val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
+ val study = Study( deploymentStatus.studyDeploymentId, smartphone.roleName )
+ val studyDeployment = StudyDeploymentProxy( deploymentService, createDataListener() )
val deviceRegistration = smartphone.createRegistration()
- val dataListener = createDataListener()
- val runtime = StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, deviceRegistration )
- check( runtime.isDeployed )
- runtime.consumeEvents() // Drop events so only new ones under test appear.
-
- val status = runtime.stop( deploymentService )
+ studyDeployment.tryDeployment( study, deviceRegistration )
+ check( study.getStatus() is StudyStatus.Running )
- // Study runtime status reflects the study has stopped.
- assertTrue( runtime.isStopped )
- assertTrue( status is StudyRuntimeStatus.Stopped )
- assertTrue( runtime.isDeployed ) // The device is still considered deployed.
+ studyDeployment.stop( study )
+ val status = study.getStatus()
- // Study runtime events reflects deployment has stopped
- assertEquals( 1, runtime.consumeEvents().filterIsInstance().count() )
+ // Study status reflects the study has stopped.
+ assertTrue( status is StudyStatus.Stopped )
// Deployment status also reflects deployment has stopped.
val newDeploymentStatus = deploymentService.getStudyDeploymentStatus( deploymentStatus.studyDeploymentId )
assertTrue( newDeploymentStatus is StudyDeploymentStatus.Stopped )
}
-
- @Test
- fun creating_runtime_fromSnapshot_obtained_by_getSnapshot_is_the_same() = runSuspendTest {
- // Create a study runtime snapshot for the 'smartphone' with an unregistered connected device.
- val protocol = createSmartphoneWithConnectedDeviceStudy()
- val (deploymentService, deploymentStatus) = createStudyDeployment( protocol )
- val deviceRegistration = smartphone.createRegistration()
- val dataListener = createDataListener()
- val runtime = StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, deviceRegistration )
- val snapshot = runtime.getSnapshot()
- val fromSnapshot = StudyRuntime.fromSnapshot( snapshot )
-
- assertEquals( runtime.studyDeploymentId, fromSnapshot.studyDeploymentId )
- assertEquals( runtime.createdOn, fromSnapshot.createdOn )
- assertEquals( runtime.device, fromSnapshot.device )
- assertEquals( runtime.isDeployed, fromSnapshot.isDeployed )
- assertEquals( runtime.isStopped, fromSnapshot.isStopped )
- assertEquals( runtime.getStatus(), fromSnapshot.getStatus() )
- assertEquals( 0, fromSnapshot.consumeEvents().size )
- }
}
diff --git a/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/study/StudyTest.kt b/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/study/StudyTest.kt
new file mode 100644
index 000000000..cdd95109e
--- /dev/null
+++ b/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/domain/study/StudyTest.kt
@@ -0,0 +1,334 @@
+package dk.cachet.carp.clients.domain.study
+
+import dk.cachet.carp.clients.application.study.StudyStatus
+import dk.cachet.carp.clients.domain.connectedDevice
+import dk.cachet.carp.clients.domain.smartphone
+import dk.cachet.carp.common.application.UUID
+import dk.cachet.carp.common.application.devices.AnyMasterDeviceDescriptor
+import dk.cachet.carp.common.application.users.UsernameAccountIdentity
+import dk.cachet.carp.common.infrastructure.test.StubMasterDeviceDescriptor
+import dk.cachet.carp.deployments.application.DeviceDeploymentStatus
+import dk.cachet.carp.deployments.application.MasterDeviceDeployment
+import dk.cachet.carp.deployments.application.StudyDeploymentStatus
+import dk.cachet.carp.deployments.application.users.ParticipantInvitation
+import dk.cachet.carp.deployments.application.users.StudyInvitation
+import dk.cachet.carp.deployments.domain.StudyDeployment
+import dk.cachet.carp.protocols.domain.ProtocolOwner
+import dk.cachet.carp.protocols.domain.StudyProtocol
+import dk.cachet.carp.test.runSuspendTest
+import kotlinx.datetime.Clock
+import kotlin.test.*
+
+
+/**
+ * Tests for [Study].
+ *
+ * TODO: This solely tests happy paths state transitions; we need to test/fail on unexpected calls.
+ */
+class StudyTest
+{
+ private val deploymentId: UUID = UUID.randomUUID()
+ private val device: AnyMasterDeviceDescriptor = StubMasterDeviceDescriptor( "Device role" )
+ private val dependentDevice: AnyMasterDeviceDescriptor = StubMasterDeviceDescriptor( "Other device" )
+
+ private fun deploymentNotStarted(
+ device: AnyMasterDeviceDescriptor,
+ dependentDevice: AnyMasterDeviceDescriptor? = null
+ ) = Pair(
+ Study( deploymentId, device.roleName ),
+ if (dependentDevice != null ) twoDeviceDeployment( device, dependentDevice )
+ else singleDeviceDeployment( device )
+ )
+
+ private fun awaitingOtherDeviceRegistrations(
+ device: AnyMasterDeviceDescriptor,
+ dependentDevice: AnyMasterDeviceDescriptor
+ ) =
+ deploymentNotStarted( device, dependentDevice ).also { (study, deployment) ->
+ deployment.registerDevice( device, device.createRegistration() )
+ study.deploymentStatusReceived( deployment.getStatus() )
+ study.consumeEvents()
+ }
+
+ private fun awaitingDeviceDeployment(
+ device: AnyMasterDeviceDescriptor,
+ dependentDevice: AnyMasterDeviceDescriptor? = null
+ ) =
+ deploymentNotStarted( device, dependentDevice ).also { (study, deployment) ->
+ deployment.registerDevice( device, device.createRegistration() )
+ if ( dependentDevice != null )
+ {
+ deployment.registerDevice( dependentDevice, dependentDevice.createRegistration() )
+ }
+ study.deploymentStatusReceived( deployment.getStatus() )
+ study.consumeEvents()
+ }
+
+ private fun registeringDevices(
+ device: AnyMasterDeviceDescriptor,
+ dependentDevice: AnyMasterDeviceDescriptor? = null
+ ) =
+ awaitingDeviceDeployment( device, dependentDevice ).also { (study, deployment) ->
+ val deviceDeployment = deployment.getDeviceDeploymentFor( device )
+ study.deviceDeploymentReceived( deviceDeployment )
+ study.consumeEvents()
+ }
+
+ private fun awaitingOtherDeviceDeployments(
+ device: AnyMasterDeviceDescriptor,
+ dependentDevice: AnyMasterDeviceDescriptor
+ ) =
+ registeringDevices( device, dependentDevice ).also { (study, deployment) ->
+ val deviceDeployment = deployment.getDeviceDeploymentFor( device )
+ deployment.deviceDeployed( device, deviceDeployment.lastUpdatedOn )
+ study.deploymentStatusReceived( deployment.getStatus() )
+ study.consumeEvents()
+ }
+
+ private fun running(
+ device: AnyMasterDeviceDescriptor,
+ dependentDevice: AnyMasterDeviceDescriptor? = null
+ ) =
+ registeringDevices( device, dependentDevice ).also { (study, deployment) ->
+ val deviceDeployment = deployment.getDeviceDeploymentFor( device )
+ deployment.deviceDeployed( device, deviceDeployment.lastUpdatedOn )
+ if ( dependentDevice != null )
+ {
+ val dependentDeviceDeployment = deployment.getDeviceDeploymentFor( dependentDevice )
+ deployment.deviceDeployed( dependentDevice, dependentDeviceDeployment.lastUpdatedOn )
+ }
+ study.deploymentStatusReceived( deployment.getStatus() )
+ study.consumeEvents()
+ }
+
+
+ private fun singleDeviceDeployment( device: AnyMasterDeviceDescriptor ) =
+ StudyDeployment.fromInvitations(
+ StudyProtocol( ProtocolOwner(), "Test" ).apply { addMasterDevice( device ) }.getSnapshot(),
+ listOf(
+ ParticipantInvitation(
+ UUID.randomUUID(),
+ setOf( device.roleName ),
+ UsernameAccountIdentity( "Test" ),
+ StudyInvitation( "Test" )
+ )
+ ),
+ deploymentId
+ )
+
+ private fun twoDeviceDeployment(
+ device: AnyMasterDeviceDescriptor,
+ dependentDevice: AnyMasterDeviceDescriptor
+ ) = StudyDeployment.fromInvitations(
+ StudyProtocol( ProtocolOwner(), "Test" ).apply {
+ addMasterDevice( device )
+ addMasterDevice( dependentDevice )
+ }.getSnapshot(),
+ listOf(
+ ParticipantInvitation(
+ UUID.randomUUID(),
+ setOf( device.roleName, dependentDevice.roleName ),
+ UsernameAccountIdentity( "Test" ),
+ StudyInvitation( "Test" )
+ )
+ ),
+ deploymentId
+ )
+
+
+ @Test
+ fun deploymentNotStarted_to_stopped()
+ {
+ val (study, deployment) = deploymentNotStarted( device )
+ assertEquals( StudyStatus.DeploymentNotStarted( study.id ), study.getStatus() )
+
+ val stopped: StudyDeploymentStatus = deployment.run {
+ stop()
+ getStatus()
+ }
+ study.deploymentStatusReceived( stopped )
+
+ assertEquals(
+ StudyStatus.Stopped( study.id, stopped, null ),
+ study.getStatus()
+ )
+ assertSingleEvent( study, Study.Event.DeploymentStatusReceived( stopped ) )
+ }
+
+ @Test
+ fun deploymentNotStarted_to_awaitingDeviceDeployment()
+ {
+ val (study, deployment) = deploymentNotStarted( device )
+
+ val registered: StudyDeploymentStatus = deployment.run {
+ registerDevice( device, device.createRegistration() )
+ getStatus()
+ }
+ study.deploymentStatusReceived( registered )
+
+ assertEquals(
+ StudyStatus.AwaitingDeviceDeployment( study.id, registered ),
+ study.getStatus()
+ )
+ assertSingleEvent( study, Study.Event.DeploymentStatusReceived( registered ) )
+ }
+
+ @Test
+ fun deploymentNotStarted_to_awaitingOtherDeviceRegistrations()
+ {
+ val (study, deployment) = deploymentNotStarted( device, dependentDevice )
+
+ val awaitingOtherRegistration: StudyDeploymentStatus = deployment.run {
+ registerDevice( device, device.createRegistration() )
+ getStatus()
+ }
+ study.deploymentStatusReceived( awaitingOtherRegistration )
+
+ assertEquals(
+ StudyStatus.AwaitingOtherDeviceRegistrations( study.id, awaitingOtherRegistration ),
+ study.getStatus()
+ )
+ assertSingleEvent( study, Study.Event.DeploymentStatusReceived( awaitingOtherRegistration ) )
+ }
+
+ @Test
+ fun awaitingOtherDeviceRegistrations_to_awaitingDeviceDeployment()
+ {
+ val (study, deployment) = awaitingOtherDeviceRegistrations( device, dependentDevice )
+
+ val awaitingDeviceDeployment: StudyDeploymentStatus = deployment.run {
+ registerDevice( dependentDevice, dependentDevice.createRegistration() )
+ getStatus()
+ }
+ study.deploymentStatusReceived( awaitingDeviceDeployment )
+
+ assertEquals(
+ StudyStatus.AwaitingDeviceDeployment( study.id, awaitingDeviceDeployment ),
+ study.getStatus()
+ )
+ assertSingleEvent( study, Study.Event.DeploymentStatusReceived( awaitingDeviceDeployment ) )
+ }
+
+ @Test
+ fun awaitingDeviceDeployment_to_registeringDevices()
+ {
+ val (study, deployment) = awaitingDeviceDeployment( device )
+
+ val deviceDeployment = deployment.getDeviceDeploymentFor( device )
+ study.deviceDeploymentReceived( deviceDeployment )
+
+ assertEquals(
+ StudyStatus.RegisteringDevices( study.id, deployment.getStatus(), deviceDeployment ),
+ study.getStatus()
+ )
+ assertSingleEvent( study, Study.Event.DeviceDeploymentReceived( deviceDeployment ) )
+ }
+
+ @Test
+ fun registeringDevices_to_running()
+ {
+ val (study, deployment) = registeringDevices( device )
+
+ val deviceDeployment = deployment.getDeviceDeploymentFor( device )
+ deployment.deviceDeployed( device, deviceDeployment.lastUpdatedOn )
+ val running = deployment.getStatus()
+ study.deploymentStatusReceived( running )
+
+ assertEquals(
+ StudyStatus.Running( study.id, running, deviceDeployment ),
+ study.getStatus()
+ )
+ assertSingleEvent( study, Study.Event.DeploymentStatusReceived( running ) )
+ }
+
+ @Test
+ fun registeringDevices_to_awaitingOtherDeviceDeployments()
+ {
+ val (study, deployment) = registeringDevices( device, dependentDevice )
+
+ val deviceDeployment = deployment.getDeviceDeploymentFor( device )
+ deployment.deviceDeployed( device, deviceDeployment.lastUpdatedOn )
+ val awaitingOther = deployment.getStatus()
+ study.deploymentStatusReceived( awaitingOther )
+
+ assertEquals(
+ StudyStatus.AwaitingOtherDeviceDeployments( study.id, awaitingOther, deviceDeployment ),
+ study.getStatus()
+ )
+ assertSingleEvent( study, Study.Event.DeploymentStatusReceived( awaitingOther ) )
+ }
+
+ @Test
+ fun awaitingOtherDeviceDeployments_to_running()
+ {
+ val (study, deployment) = awaitingOtherDeviceDeployments( device, dependentDevice )
+
+ val deviceDeployment = deployment.getDeviceDeploymentFor( dependentDevice )
+ deployment.deviceDeployed( dependentDevice, deviceDeployment.lastUpdatedOn )
+ val running = deployment.getStatus()
+ study.deploymentStatusReceived( running )
+
+ assertEquals(
+ StudyStatus.Running( study.id, running, deployment.getDeviceDeploymentFor( device ) ),
+ study.getStatus()
+ )
+ assertSingleEvent( study, Study.Event.DeploymentStatusReceived( running ) )
+ }
+
+ @Test
+ fun running_to_stopped()
+ {
+ val (study, deployment) = running( device )
+
+ deployment.stop()
+ val stopped = deployment.getStatus()
+ study.deploymentStatusReceived( stopped )
+
+ assertEquals(
+ StudyStatus.Stopped( study.id, stopped, deployment.getDeviceDeploymentFor( device ) ),
+ study.getStatus()
+ )
+ assertSingleEvent( study, Study.Event.DeploymentStatusReceived( stopped ) )
+ }
+
+ @Test
+ fun creating_study_fromSnapshot_obtained_by_getSnapshot_is_the_same() = runSuspendTest {
+ // Create a study snapshot for the 'smartphone' with an unregistered connected device.
+ val deploymentId = UUID.randomUUID()
+ val study = Study( deploymentId, smartphone.roleName )
+ val connectedDevices = setOf( connectedDevice )
+ val masterDeviceDeployment = MasterDeviceDeployment(
+ smartphone,
+ smartphone.createRegistration(),
+ connectedDevices
+ )
+ study.deploymentStatusReceived(
+ StudyDeploymentStatus.DeployingDevices(
+ Clock.System.now(),
+ deploymentId,
+ listOf(
+ DeviceDeploymentStatus.Registered(
+ smartphone,
+ true,
+ emptySet(),
+ connectedDevices.map { it.roleName }.toSet()
+ )
+ ),
+ emptyList(),
+ null
+ )
+ )
+ study.deviceDeploymentReceived( masterDeviceDeployment )
+ val snapshot = study.getSnapshot()
+ val fromSnapshot = Study.fromSnapshot( snapshot )
+
+ assertEquals( study.studyDeploymentId, fromSnapshot.studyDeploymentId )
+ assertEquals( study.createdOn, fromSnapshot.createdOn )
+ assertEquals( study.deviceRoleName, fromSnapshot.deviceRoleName )
+ assertEquals( study.getStatus(), fromSnapshot.getStatus() )
+ assertEquals( 0, fromSnapshot.consumeEvents().size )
+ }
+
+ private fun assertSingleEvent( study: Study, event: Study.Event ) =
+ assertEquals( event, study.consumeEvents().singleOrNull() )
+}
diff --git a/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/infrastructure/StudyRuntimeSnapshotTest.kt b/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/infrastructure/StudyRuntimeSnapshotTest.kt
deleted file mode 100644
index 3010f5dc4..000000000
--- a/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/infrastructure/StudyRuntimeSnapshotTest.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package dk.cachet.carp.clients.infrastructure
-
-import dk.cachet.carp.clients.domain.createSmartphoneStudy
-import dk.cachet.carp.clients.domain.createStudyDeployment
-import dk.cachet.carp.clients.domain.smartphone
-import dk.cachet.carp.clients.domain.StudyRuntime
-import dk.cachet.carp.clients.domain.StudyRuntimeSnapshot
-import dk.cachet.carp.clients.domain.createDataListener
-import dk.cachet.carp.common.application.devices.DefaultDeviceRegistrationBuilder
-import dk.cachet.carp.common.infrastructure.serialization.JSON
-import dk.cachet.carp.test.runSuspendTest
-import kotlinx.serialization.decodeFromString
-import kotlinx.serialization.encodeToString
-import kotlin.test.*
-
-
-/**
- * Tests for [StudyRuntimeSnapshot].
- */
-class StudyRuntimeSnapshotTest
-{
- @Test
- fun can_serialize_and_deserialize_snapshot_using_JSON() = runSuspendTest {
- // Create deployment service with one 'smartphone' study.
- val (deploymentService, deploymentStatus) = createStudyDeployment( createSmartphoneStudy() )
- val deviceRegistration = DefaultDeviceRegistrationBuilder().build()
- val dataListener = createDataListener()
- val runtime = StudyRuntime.initialize(
- deploymentService, dataListener,
- deploymentStatus.studyDeploymentId, smartphone.roleName, deviceRegistration )
- val snapshot = StudyRuntimeSnapshot.fromStudyRuntime( runtime )
-
- val serialized = JSON.encodeToString( snapshot )
- val parsed: StudyRuntimeSnapshot = JSON.decodeFromString( serialized )
- assertEquals( snapshot, parsed )
- }
-}
diff --git a/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/infrastructure/StudySnapshotTest.kt b/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/infrastructure/StudySnapshotTest.kt
new file mode 100644
index 000000000..a3a03aa86
--- /dev/null
+++ b/carp.clients.core/src/commonTest/kotlin/dk/cachet/carp/clients/infrastructure/StudySnapshotTest.kt
@@ -0,0 +1,27 @@
+package dk.cachet.carp.clients.infrastructure
+
+import dk.cachet.carp.clients.domain.study.Study
+import dk.cachet.carp.clients.domain.study.StudySnapshot
+import dk.cachet.carp.common.application.UUID
+import dk.cachet.carp.common.infrastructure.serialization.JSON
+import dk.cachet.carp.test.runSuspendTest
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlin.test.*
+
+
+/**
+ * Tests for [StudySnapshot].
+ */
+class StudySnapshotTest
+{
+ @Test
+ fun can_serialize_and_deserialize_snapshot_using_JSON() = runSuspendTest {
+ val study = Study( UUID.randomUUID(), "Some device" )
+ val snapshot = study.getSnapshot()
+
+ val serialized = JSON.encodeToString( snapshot )
+ val parsed: StudySnapshot = JSON.decodeFromString( serialized )
+ assertEquals( snapshot, parsed )
+ }
+}
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/InstantExtensions.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/InstantExtensions.kt
new file mode 100644
index 000000000..2def3506a
--- /dev/null
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/InstantExtensions.kt
@@ -0,0 +1,13 @@
+package dk.cachet.carp.common.application
+
+import kotlinx.datetime.Instant
+
+
+private const val MICROS_PER_SECOND = 1_000_000
+private const val NANOS_PER_MICRO = 1_000
+
+
+/**
+ * Get the elapsed microseconds since the start of the UTC day 1970-01-01 at this [Instant]'s moment in time.
+ */
+fun Instant.toEpochMicroseconds(): Long = epochSeconds * MICROS_PER_SECOND + nanosecondsOfSecond / NANOS_PER_MICRO
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/Acceleration.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/Acceleration.kt
deleted file mode 100644
index 3cb8beb04..000000000
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/Acceleration.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package dk.cachet.carp.common.application.data
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-
-/**
- * Holds acceleration data along perpendicular [x], [y], and [z] axes in g-force.
- */
-@Serializable
-@SerialName( CarpDataTypes.ACCELERATION_TYPE_NAME )
-data class Acceleration( val x: Double, val y: Double, val z: Double ) : Data
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/AngularVelocity.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/AngularVelocity.kt
new file mode 100644
index 000000000..0b5243769
--- /dev/null
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/AngularVelocity.kt
@@ -0,0 +1,14 @@
+package dk.cachet.carp.common.application.data
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+/**
+ * Holds rate of rotation around perpendicular [x], [y], and [z] axes in radians per second.
+ * Positive angular velocity indicates counter-clockwise rotation from the perspective of an observer
+ * at some positive location on the x, y, or z axis, while negative angular velocity indicates clockwise rotation.
+ */
+@Serializable
+@SerialName( CarpDataTypes.ANGULAR_VELOCITY_TYPE_NAME )
+data class AngularVelocity( val x: Double, val y: Double, val z: Double ) : Data
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/CarpDataTypes.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/CarpDataTypes.kt
index e0a3abdc5..3a708d411 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/CarpDataTypes.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/CarpDataTypes.kt
@@ -57,11 +57,17 @@ object CarpDataTypes : DataTypeMetaDataMap()
*/
val SENSOR_SKIN_CONTACT = add( SENSOR_SKIN_CONTACT_TYPE_NAME, "Sensor skin contact", DataTimeType.POINT )
- internal const val ACCELERATION_TYPE_NAME = "$CARP_NAMESPACE.acceleration"
+ internal const val NON_GRAVITATIONAL_ACCELERATION_TYPE_NAME = "$CARP_NAMESPACE.nongravitationalacceleration"
/**
- * Acceleration along perpendicular x, y, and z axes.
+ * Acceleration along perpendicular x, y, and z axes, excluding gravity.
*/
- val ACCELERATION = add( ACCELERATION_TYPE_NAME, "Accelerometry", DataTimeType.POINT )
+ val NON_GRAVITATIONAL_ACCELERATION = add( NON_GRAVITATIONAL_ACCELERATION_TYPE_NAME, "Acceleration without gravity", DataTimeType.POINT )
+
+ internal const val ANGULAR_VELOCITY_TYPE_NAME = "$CARP_NAMESPACE.angularvelocity"
+ /**
+ * Rate of rotation around perpendicular x, y, and z axes.
+ */
+ val ANGULAR_VELOCITY = add( ANGULAR_VELOCITY_TYPE_NAME, "Angular velocity", DataTimeType.POINT )
internal const val SIGNAL_STRENGTH_TYPE_NAME = "$CARP_NAMESPACE.signalstrength"
/**
@@ -74,4 +80,10 @@ object CarpDataTypes : DataTypeMetaDataMap()
* A task which was started or stopped by a trigger, referring to identifiers in the study protocol.
*/
val TRIGGERED_TASK = add( TRIGGERED_TASK_TYPE_NAME, "Triggered task", DataTimeType.POINT )
+
+ internal const val COMPLETED_TASK_TYPE_NAME = "$CARP_NAMESPACE.completedtask"
+ /**
+ * An interactive task which was completed over the course of a specified time interval.
+ */
+ val COMPLETED_TASK = add( COMPLETED_TASK_TYPE_NAME, "Completed task", DataTimeType.TIME_SPAN )
}
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/CompletedTask.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/CompletedTask.kt
new file mode 100644
index 000000000..7ba7ff942
--- /dev/null
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/CompletedTask.kt
@@ -0,0 +1,13 @@
+package dk.cachet.carp.common.application.data
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+/**
+ * Indicates the task with [taskName] was completed.
+ * [taskData] holds the result of a completed interactive task, or null if no result is expected.
+ */
+@Serializable
+@SerialName( CarpDataTypes.COMPLETED_TASK_TYPE_NAME )
+data class CompletedTask( val taskName: String, val taskData: Data? = null ) : Data
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/NonGravitationalAcceleration.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/NonGravitationalAcceleration.kt
new file mode 100644
index 000000000..244b239d2
--- /dev/null
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/NonGravitationalAcceleration.kt
@@ -0,0 +1,11 @@
+package dk.cachet.carp.common.application.data
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+/**
+ * Holds acceleration data excluding gravity along perpendicular [x], [y], and [z] axes in meters per second squared (m/s^2).
+ */
+@Serializable
+@SerialName( CarpDataTypes.NON_GRAVITATIONAL_ACCELERATION_TYPE_NAME )
+data class NonGravitationalAcceleration( val x: Double, val y: Double, val z: Double ) : Data
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/AltBeacon.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/AltBeacon.kt
index 16c221e19..561ce9225 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/AltBeacon.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/AltBeacon.kt
@@ -18,7 +18,10 @@ import kotlin.reflect.KClass
* A beacon meeting the open AltBeacon standard.
*/
@Serializable
-data class AltBeacon( override val roleName: String ) : DeviceDescriptor()
+data class AltBeacon(
+ override val roleName: String,
+ override val isOptional: Boolean = false,
+) : DeviceDescriptor()
{
object Sensors : DataTypeSamplingSchemeMap()
{
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/BLEHeartRateDevice.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/BLEHeartRateDevice.kt
index 295fbc76d..2446950a3 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/BLEHeartRateDevice.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/BLEHeartRateDevice.kt
@@ -16,7 +16,8 @@ import kotlin.reflect.KClass
*/
@Serializable
data class BLEHeartRateDevice(
- override val roleName: String
+ override val roleName: String,
+ override val isOptional: Boolean = false
) : DeviceDescriptor()
{
object Sensors : DataTypeSamplingSchemeMap()
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/CustomProtocolDevice.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/CustomProtocolDevice.kt
index b88642fb5..c5ab5cbd8 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/CustomProtocolDevice.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/CustomProtocolDevice.kt
@@ -14,7 +14,7 @@ import kotlin.reflect.KClass
* A master device which uses a single [CustomProtocolTask] to determine how to run a study on the device.
*/
@Serializable
-data class CustomProtocolDevice( override val roleName: String ) :
+data class CustomProtocolDevice( override val roleName: String, override val isOptional: Boolean = false ) :
MasterDeviceDescriptor()
{
object Sensors : DataTypeSamplingSchemeMap()
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/DeviceDescriptor.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/DeviceDescriptor.kt
index a30b37bb6..64aa85194 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/DeviceDescriptor.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/DeviceDescriptor.kt
@@ -34,6 +34,12 @@ abstract class DeviceDescriptor<
*/
abstract val roleName: String
+ /**
+ * Determines whether device registration for this device is optional prior to starting a study,
+ * i.e., whether the study can run without this device or not.
+ */
+ abstract val isOptional: Boolean
+
/**
* The set of [DataType]s defining which data stream data can be collected on this device.
*/
@@ -46,14 +52,21 @@ abstract class DeviceDescriptor<
*/
abstract val defaultSamplingConfiguration: Map
+
/**
- * Get the default sampling configuration to be used for measurements of [dataType].
- * The configuration to use may still be overridden by individual data stream measures.
+ * Do nothing in case [defaultSamplingConfiguration] is valid; throw [IllegalStateException] otherwise.
+ * Only known supported data types can be validated; unexpected data types are ignored.
*/
- fun getDefaultSamplingConfiguration( dataType: DataType ): SamplingConfiguration =
- defaultSamplingConfiguration[ dataType ]
- ?: getDataTypeSamplingSchemes()[ dataType ]?.default
- ?: throw IllegalArgumentException( "The specified `dataType` is not supported on this device." )
+ fun validateDefaultSamplingConfiguration()
+ {
+ val canBeValidated = getSupportedDataTypes()
+ for ( (dataType, samplingConfiguration) in defaultSamplingConfiguration.filter { it.key in canBeValidated } )
+ {
+ val samplingScheme = checkNotNull( getDataTypeSamplingSchemes()[ dataType ] )
+ check( samplingScheme.isValid( samplingConfiguration ) )
+ { "The sampling configuration for data type `$dataType` is invalid." }
+ }
+ }
/**
* Return sampling schemes for all the sensors available on this device.
@@ -61,7 +74,7 @@ abstract class DeviceDescriptor<
* Implementations of [DeviceDescriptor] should simply return the mandatory inner object
* `object Sensors : DataTypeSamplingSchemeMap()` here.
*/
- protected abstract fun getDataTypeSamplingSchemes(): DataTypeSamplingSchemeMap
+ abstract fun getDataTypeSamplingSchemes(): DataTypeSamplingSchemeMap
protected abstract fun createDeviceRegistrationBuilder(): TRegistrationBuilder
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/Smartphone.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/Smartphone.kt
index 123df8969..9fc7080d8 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/Smartphone.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/devices/Smartphone.kt
@@ -9,6 +9,7 @@ import dk.cachet.carp.common.application.sampling.*
import dk.cachet.carp.common.application.tasks.*
import kotlinx.serialization.Serializable
import kotlin.reflect.KClass
+import kotlin.time.Duration
typealias SmartphoneDeviceRegistration = DefaultDeviceRegistration
@@ -21,15 +22,17 @@ typealias SmartphoneDeviceRegistrationBuilder = DefaultDeviceRegistrationBuilder
@Serializable
data class Smartphone(
override val roleName: String,
+ override val isOptional: Boolean = false,
override val defaultSamplingConfiguration: Map = emptyMap()
) : MasterDeviceDescriptor()
{
- constructor( roleName: String, builder: SmartphoneBuilder.() -> Unit ) :
- this( roleName, SmartphoneBuilder().apply( builder ).buildSamplingConfiguration() )
+ constructor( roleName: String, isOptional: Boolean = false, builder: SmartphoneBuilder.() -> Unit ) :
+ this( roleName, isOptional, SmartphoneBuilder().apply( builder ).buildSamplingConfiguration() )
/**
* All the sensors commonly available on smartphones.
*/
+ @Suppress( "MagicNumber" )
object Sensors : DataTypeSamplingSchemeMap()
{
/**
@@ -50,6 +53,35 @@ data class Smartphone(
* Not certain this is available on iPhone.
*/
val STEP_COUNT = add( NoOptionsSamplingScheme( CarpDataTypes.STEP_COUNT ) ) // No configuration options available.
+
+ /**
+ * Acceleration along perpendicular x, y, and z axes,
+ * as measured by the phone's accelerometer and calibrated to exclude gravity and sensor bias.
+ * This uses the same coordinate system as the other sensors referring to x, y, and z axes.
+ *
+ * Android (https://developer.android.com/guide/topics/sensors/sensors_overview):
+ * - This corresponds to `TYPE_LINEAR_ACCELERATION`.
+ * - The sampling scheme's default measure interval corresponds to `SENSOR_DELAY_NORMAL` (200 ms).
+ * - The linear acceleration sensor always has an offset, which you need to remove: https://developer.android.com/guide/topics/sensors/sensors_motion
+ * - Only available starting from Android 3.0 (API Level 11): earlier versions don't support setting the interval as an absolute value.
+ */
+ val NON_GRAVITATIONAL_ACCELERATION = add(
+ IntervalSamplingScheme( CarpDataTypes.NON_GRAVITATIONAL_ACCELERATION, Duration.milliseconds( 200 ) )
+ )
+
+ /**
+ * Rate of rotation around perpendicular x, y and z axes,
+ * as measured by the phone's gyroscope and calibrated to remove drift and noise.
+ * This uses the same coordinate system as the other sensors referring to x, y, and z axes.
+ *
+ * Android (https://developer.android.com/guide/topics/sensors/sensors_overview):
+ * - This corresponds to `TYPE_GYROSCOPE`.
+ * - The sampling scheme's default measure interval corresponds to `SENSOR_DELAY_NORMAL` (200 ms).
+ * - Only available starting from Android 3.0 (API Level 11): earlier versions don't support setting the interval as an absolute value.
+ */
+ val ANGULAR_VELOCITY = add(
+ IntervalSamplingScheme( CarpDataTypes.ANGULAR_VELOCITY, Duration.milliseconds( 200 ) )
+ )
}
/**
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/BackgroundTask.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/BackgroundTask.kt
index b83bf54d5..3540e5d8b 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/BackgroundTask.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/BackgroundTask.kt
@@ -1,6 +1,6 @@
package dk.cachet.carp.common.application.tasks
-import dk.cachet.carp.common.application.data.DataType
+import dk.cachet.carp.common.application.data.NoData
import dk.cachet.carp.common.infrastructure.serialization.DurationSerializer
import kotlinx.serialization.Serializable
import kotlin.time.Duration
@@ -24,13 +24,7 @@ data class BackgroundTask(
*/
@Serializable( DurationSerializer::class )
val duration: Duration = Duration.INFINITE
-) : TaskDescriptor
-{
- /**
- * This list is empty, since a background task by definition does not contain any interactions.
- */
- override fun getInteractionDataTypes(): Set = emptySet()
-}
+) : TaskDescriptor // Not an interactive task, so uploads no data other than measures.
/**
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/CustomProtocolTask.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/CustomProtocolTask.kt
index b5cee16b6..4bb66cffe 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/CustomProtocolTask.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/CustomProtocolTask.kt
@@ -1,6 +1,6 @@
package dk.cachet.carp.common.application.tasks
-import dk.cachet.carp.common.application.data.DataType
+import dk.cachet.carp.common.application.data.NoData
import kotlinx.serialization.Serializable
@@ -14,7 +14,7 @@ data class CustomProtocolTask(
* A definition on how to run a study on a master device, serialized as a string.
*/
val studyProtocol: String
-) : TaskDescriptor
+) : TaskDescriptor
{
/**
* Description is empty, since it is likely defined in [studyProtocol] in a different format.
@@ -25,9 +25,4 @@ data class CustomProtocolTask(
* This list is empty, since measures are defined in [studyProtocol] in a different format.
*/
override val measures: List = emptyList()
-
- /**
- * This set is empty, since interaction data types are defined in [studyProtocol] in a different format.
- */
- override fun getInteractionDataTypes(): Set = emptySet()
}
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/TaskDescriptor.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/TaskDescriptor.kt
index e181f22a3..9e37d703c 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/TaskDescriptor.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/TaskDescriptor.kt
@@ -3,6 +3,7 @@ package dk.cachet.carp.common.application.tasks
import dk.cachet.carp.common.application.Immutable
import dk.cachet.carp.common.application.ImplementAsDataClass
import dk.cachet.carp.common.application.data.CarpDataTypes
+import dk.cachet.carp.common.application.data.Data
import dk.cachet.carp.common.application.data.DataType
import kotlinx.serialization.Polymorphic
@@ -14,7 +15,7 @@ import kotlinx.serialization.Polymorphic
@Polymorphic
@Immutable
@ImplementAsDataClass
-interface TaskDescriptor
+interface TaskDescriptor
{
/**
* A name which uniquely identifies the task.
@@ -30,12 +31,6 @@ interface TaskDescriptor
* A description of this task, emphasizing the reason why the data is collected.
*/
val description: String?
-
-
- /**
- * Get data types of all data which may be collected as the result of user interactions for this task.
- */
- fun getInteractionDataTypes(): Set
}
@@ -43,7 +38,7 @@ interface TaskDescriptor
* Get data types of all data which may be collected, either passively as part of task measures,
* or as the result of user interactions, for this task.
*/
-fun TaskDescriptor.getAllExpectedDataTypes(): Set =
+fun TaskDescriptor<*>.getAllExpectedDataTypes(): Set =
measures.map { measure ->
when ( measure )
{
@@ -51,7 +46,7 @@ fun TaskDescriptor.getAllExpectedDataTypes(): Set =
is Measure.DataStream -> measure.type
}
}
- .plus( getInteractionDataTypes() )
+ .plus( CarpDataTypes.COMPLETED_TASK.type )
.toSet()
@@ -59,7 +54,7 @@ fun TaskDescriptor.getAllExpectedDataTypes(): Set =
* A helper class to configure and construct immutable [TaskDescriptor] classes.
*/
@TaskDescriptorBuilderDsl
-abstract class TaskDescriptorBuilder
+abstract class TaskDescriptorBuilder>
{
/**
* The data which needs to be collected/measures as part of this task.
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/TaskDescriptorList.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/TaskDescriptorList.kt
index 21a5dc228..5517a01e6 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/TaskDescriptorList.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/TaskDescriptorList.kt
@@ -22,7 +22,7 @@ open class TaskDescriptorList private constructor( private val list: MutableList
val BACKGROUND = add { BackgroundTaskBuilder() }
- protected fun > add(
+ protected fun , TBuilder : TaskDescriptorBuilder> add(
builder: () -> TBuilder
): SupportedTaskDescriptor = SupportedTaskDescriptor( builder ).also { list.add( it ) }
}
@@ -31,7 +31,7 @@ open class TaskDescriptorList private constructor( private val list: MutableList
/**
* A [TaskDescriptor] which is listed as a supported task on a [DeviceDescriptor].
*/
-class SupportedTaskDescriptor>(
+class SupportedTaskDescriptor, TBuilder : TaskDescriptorBuilder>(
private val createBuilder: () -> TBuilder
)
{
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/WebTask.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/WebTask.kt
index dd8a429a5..99ecb20c6 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/WebTask.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/tasks/WebTask.kt
@@ -1,7 +1,7 @@
package dk.cachet.carp.common.application.tasks
import dk.cachet.carp.common.application.UUID
-import dk.cachet.carp.common.application.data.DataType
+import dk.cachet.carp.common.application.data.NoData
import dk.cachet.carp.common.application.tasks.WebTask.UrlVariable
import kotlinx.serialization.Serializable
@@ -20,7 +20,7 @@ data class WebTask(
* The URL may contain [UrlVariable] patterns which will be replaced with the corresponding values by the client runtime.
*/
val url: String
-) : TaskDescriptor
+) : TaskDescriptor // The execution of the task is delegated to a web page, so this task uploads no data.
{
companion object
{
@@ -48,11 +48,6 @@ data class WebTask(
}
- /**
- * This set is empty, since the execution of the task is delegated to a web page.
- */
- override fun getInteractionDataTypes(): Set = emptySet()
-
/**
* Replace the variables in [url] with the specified runtime values, if the variables are present.
*/
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/Serialization.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/Serialization.kt
index d4dc503a8..0ed002f00 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/Serialization.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/Serialization.kt
@@ -24,11 +24,13 @@ val COMMON_SERIAL_MODULE = SerializersModule {
polymorphic( Data::class )
{
// DataType classes.
- subclass( Acceleration::class )
+ subclass( AngularVelocity::class )
+ subclass( CompletedTask::class )
subclass( ECG::class )
subclass( FreeFormText::class )
subclass( Geolocation::class )
subclass( HeartRate::class )
+ subclass( NonGravitationalAcceleration::class )
// HACK: explicit serializer needs to be registered for object declarations due to limitation of the JS legacy backend.
// https://github.com/Kotlin/kotlinx.serialization/issues/1138#issuecomment-707989920
// This can likely be removed once we upgrade to the new IR backend.
@@ -45,6 +47,9 @@ val COMMON_SERIAL_MODULE = SerializersModule {
CustomInputSerializer( String::class, Int::class )
)
subclass( Sex::class, PolymorphicEnumSerializer( Sex.serializer() ) )
+
+ subclass( CustomData::class )
+ default { DataSerializer }
}
polymorphic( InputElement::class )
{
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/UnknownDataSerializers.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/UnknownDataSerializers.kt
new file mode 100644
index 000000000..faffdb4e2
--- /dev/null
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/UnknownDataSerializers.kt
@@ -0,0 +1,20 @@
+package dk.cachet.carp.common.infrastructure.serialization
+
+import dk.cachet.carp.common.application.data.Data
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+
+/**
+ * A wrapper used to load extending types from [Data] serialized as JSON which are unknown at runtime.
+ */
+@Serializable( DataSerializer::class )
+data class CustomData( override val className: String, override val jsonSource: String, val serializer: Json ) :
+ Data, UnknownPolymorphicWrapper
+
+/**
+ * Custom serializer for [Data] which enables deserializing types that are unknown at runtime, yet extend from [Data].
+ */
+object DataSerializer : KSerializer
+ by createUnknownPolymorphicSerializer( { className, json, serializer -> CustomData( className, json, serializer ) } )
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/UnknownDeviceSerializers.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/UnknownDeviceSerializers.kt
index bdb154c07..ceb4891a9 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/UnknownDeviceSerializers.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/UnknownDeviceSerializers.kt
@@ -28,6 +28,7 @@ data class CustomDeviceDescriptor(
) : DeviceDescriptor>(), UnknownPolymorphicWrapper
{
override val roleName: String
+ override val isOptional: Boolean
override val defaultSamplingConfiguration: Map
@@ -41,6 +42,7 @@ data class CustomDeviceDescriptor(
val json = Json( serializer ) { ignoreUnknownKeys = true }
val baseMembers = json.decodeFromString( BaseMembers.serializer(), jsonSource )
roleName = baseMembers.roleName
+ isOptional = baseMembers.isOptional
defaultSamplingConfiguration = baseMembers.defaultSamplingConfiguration
}
@@ -67,6 +69,7 @@ data class CustomMasterDeviceDescriptor(
) : MasterDeviceDescriptor>(), UnknownPolymorphicWrapper
{
override val roleName: String
+ override val isOptional: Boolean
override val defaultSamplingConfiguration: Map
@@ -80,6 +83,7 @@ data class CustomMasterDeviceDescriptor(
val json = Json( serializer ) { ignoreUnknownKeys = true }
val baseMembers = json.decodeFromString( BaseMembers.serializer(), jsonSource )
roleName = baseMembers.roleName
+ isOptional = baseMembers.isOptional
defaultSamplingConfiguration = baseMembers.defaultSamplingConfiguration
}
@@ -98,6 +102,7 @@ data class CustomMasterDeviceDescriptor(
@Serializable
private data class BaseMembers(
override val roleName: String,
+ override val isOptional: Boolean = false,
override val defaultSamplingConfiguration: Map = emptyMap()
) : DeviceDescriptor>()
{
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/UnknownTaskSerializers.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/UnknownTaskSerializers.kt
index fe4d2e041..41e151376 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/UnknownTaskSerializers.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/serialization/UnknownTaskSerializers.kt
@@ -1,6 +1,6 @@
package dk.cachet.carp.common.infrastructure.serialization
-import dk.cachet.carp.common.application.data.DataType
+import dk.cachet.carp.common.application.data.NoData
import dk.cachet.carp.common.application.tasks.TaskDescriptor
import dk.cachet.carp.common.application.tasks.Measure
import kotlinx.serialization.KSerializer
@@ -13,17 +13,14 @@ import kotlinx.serialization.json.Json
*/
@Serializable( TaskDescriptorSerializer::class )
data class CustomTaskDescriptor( override val className: String, override val jsonSource: String, val serializer: Json ) :
- TaskDescriptor, UnknownPolymorphicWrapper
+ TaskDescriptor, UnknownPolymorphicWrapper
{
@Serializable
private data class BaseMembers(
override val name: String,
override val measures: List = emptyList(),
override val description: String? = null
- ) : TaskDescriptor
- {
- override fun getInteractionDataTypes(): Set = emptySet()
- }
+ ) : TaskDescriptor
override val name: String
override val measures: List
@@ -36,13 +33,10 @@ data class CustomTaskDescriptor( override val className: String, override val js
name = baseMembers.name
measures = baseMembers.measures
}
-
- override fun getInteractionDataTypes(): Set =
- throw UnsupportedOperationException( "The task needs to be known at runtime to determine interaction data types." )
}
/**
* Custom serializer for [TaskDescriptor] which enables deserializing types that are unknown at runtime, yet extend from [TaskDescriptor].
*/
-object TaskDescriptorSerializer : KSerializer
+object TaskDescriptorSerializer : KSerializer>
by createUnknownPolymorphicSerializer( { className, json, serializer -> CustomTaskDescriptor( className, json, serializer ) } )
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/CreateTestObjects.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/CreateTestObjects.kt
index b060e8e0d..a78f2cab0 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/CreateTestObjects.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/CreateTestObjects.kt
@@ -66,6 +66,15 @@ val STUBS_SERIAL_MODULE = SerializersModule {
*/
fun createTestJSON(): Json = createDefaultJSON( STUBS_SERIAL_MODULE )
+/**
+ * Replace the type name of [data] in this JSON string with [unknownTypeName].
+ */
+fun String.makeUnknown(
+ data: StubData,
+ unknownTypeName: String = "com.unknown.UnknownData"
+): String =
+ this.makeUnknown( data, Data::class, "data", data.data, unknownTypeName )
+
/**
* Replace the type name of [deviceDescriptor] in this JSON string with [unknownTypeName].
*/
@@ -101,7 +110,7 @@ fun String.makeUnknown(
*/
@ExperimentalSerializationApi
fun String.makeUnknown(
- taskDescriptor: TaskDescriptor,
+ taskDescriptor: TaskDescriptor<*>,
unknownTypeName: String = "com.unknown.UnknownTaskDescriptor"
): String =
this.makeUnknown( taskDescriptor, TaskDescriptor::class, "name", taskDescriptor.name, unknownTypeName )
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubData.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubData.kt
index 42bd25bcb..6e55bc078 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubData.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubData.kt
@@ -1,18 +1,19 @@
package dk.cachet.carp.common.infrastructure.test
import dk.cachet.carp.common.application.data.Data
+import kotlinx.serialization.Required
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
@SerialName( StubDataTypes.STUB_DATA_TYPE_NAME )
-data class StubData( val data: String = "Stub" ) : Data
+data class StubData( @Required val data: String = "Stub" ) : Data
@Serializable
@SerialName( StubDataTypes.STUB_DATA_POINT_TYPE_NAME )
-data class StubDataPoint( val data: String = "Stub" ) : Data
+data class StubDataPoint( @Required val data: String = "Stub" ) : Data
@Serializable
@SerialName( StubDataTypes.STUB_DATA_TIME_SPAN_TYPE_NAME )
-data class StubDataTimeSpan( val data: String = "Stub" ) : Data
+data class StubDataTimeSpan( @Required val data: String = "Stub" ) : Data
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubDeviceDescriptor.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubDeviceDescriptor.kt
index eee1bf2c1..d9a440d92 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubDeviceDescriptor.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubDeviceDescriptor.kt
@@ -18,6 +18,7 @@ import kotlin.reflect.KClass
@Serializable
data class StubDeviceDescriptor(
@Required override val roleName: String = "Stub device",
+ override val isOptional: Boolean = false,
override val defaultSamplingConfiguration: Map = emptyMap()
) :
DeviceDescriptor()
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubMasterDeviceDescriptor.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubMasterDeviceDescriptor.kt
index d4a982ea2..4682317fc 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubMasterDeviceDescriptor.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubMasterDeviceDescriptor.kt
@@ -18,6 +18,7 @@ import kotlin.reflect.KClass
@Serializable
data class StubMasterDeviceDescriptor(
@Required override val roleName: String = "Stub master device",
+ override val isOptional: Boolean = false,
override val defaultSamplingConfiguration: Map = emptyMap()
) : MasterDeviceDescriptor()
{
diff --git a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubTaskDescriptor.kt b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubTaskDescriptor.kt
index f0f7ab69c..df4993a62 100644
--- a/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubTaskDescriptor.kt
+++ b/carp.common/src/commonMain/kotlin/dk/cachet/carp/common/infrastructure/test/StubTaskDescriptor.kt
@@ -1,6 +1,6 @@
package dk.cachet.carp.common.infrastructure.test
-import dk.cachet.carp.common.application.data.DataType
+import dk.cachet.carp.common.application.data.NoData
import dk.cachet.carp.common.application.tasks.TaskDescriptor
import dk.cachet.carp.common.application.tasks.Measure
import kotlinx.serialization.Required
@@ -11,9 +11,5 @@ import kotlinx.serialization.Serializable
data class StubTaskDescriptor(
@Required override val name: String = "Stub task",
override val measures: List = emptyList(),
- override val description: String? = null,
- private val _interactionDataTypes: Set = emptySet()
-) : TaskDescriptor
-{
- override fun getInteractionDataTypes(): Set = _interactionDataTypes
-}
+ override val description: String? = null
+) : TaskDescriptor
diff --git a/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/InstantExtensionsTest.kt b/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/InstantExtensionsTest.kt
new file mode 100644
index 000000000..f25bc9064
--- /dev/null
+++ b/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/InstantExtensionsTest.kt
@@ -0,0 +1,20 @@
+package dk.cachet.carp.common.application
+
+import kotlinx.datetime.Instant
+import kotlin.test.*
+import kotlin.time.Duration
+
+
+class InstantExtensionsTest
+{
+ @Test
+ fun toEpochMicroseconds_succeeds()
+ {
+ val oneMillisecond = Instant.fromEpochMilliseconds( 1 )
+ assertEquals( 1000, oneMillisecond.toEpochMicroseconds() )
+
+ val oneMicrosecond = Instant.fromEpochMilliseconds( 0 )
+ .plus( Duration.microseconds( 1 ) )
+ assertEquals( 1, oneMicrosecond.toEpochMicroseconds() )
+ }
+}
diff --git a/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/TestInstances.kt b/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/TestInstances.kt
new file mode 100644
index 000000000..ef3dbbef3
--- /dev/null
+++ b/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/TestInstances.kt
@@ -0,0 +1,87 @@
+@file:Suppress( "WildcardImport" )
+
+package dk.cachet.carp.common.application
+
+import dk.cachet.carp.common.application.data.*
+import dk.cachet.carp.common.application.data.input.*
+import dk.cachet.carp.common.application.data.input.elements.*
+import dk.cachet.carp.common.application.devices.*
+import dk.cachet.carp.common.application.sampling.*
+import dk.cachet.carp.common.application.tasks.*
+import dk.cachet.carp.common.application.triggers.*
+import dk.cachet.carp.common.application.users.*
+import kotlin.time.Duration
+
+
+/**
+ * An instance for each of the extending types from base classes in [dk.cachet.carp.common].
+ */
+val commonInstances = listOf(
+ // `data` namespace.
+ AngularVelocity( 42.0, 42.0, 42.0 ),
+ CompletedTask( "Task", null ),
+ ECG( 42.0 ),
+ FreeFormText( "Some text" ),
+ Geolocation( 42.0, 42.0 ),
+ HeartRate( 60 ),
+ NoData,
+ NonGravitationalAcceleration( 42.0, 42.0, 42.0 ),
+ RRInterval,
+ SensorSkinContact( true ),
+ SignalStrength( 0 ),
+ StepCount( 42 ),
+ TriggeredTask( 1, "Some task", "Destination device", TaskControl.Control.Start ),
+
+ // `data.input` namespace.
+ CustomInput( "42" ),
+ Sex.Male,
+
+ // `data.input.elements` namespace.
+ SelectOne( "Sex", setOf( "Male", "Female" ) ),
+ Text( "Name" ),
+
+ // Devices in `devices` namespace.
+ AltBeacon( "Kitchen" ),
+ AltBeaconDeviceRegistration( 0, UUID.randomUUID(), 0, 0, 0 ),
+ BLEHeartRateDevice( "Polar" ),
+ BLESerialNumberDeviceRegistration( "123456789" ),
+ CustomProtocolDevice( "User's phone" ),
+ Smartphone( "User's phone" ),
+
+ // Shared device registrations in `devices` namespace.
+ DefaultDeviceRegistration( "Some device" ),
+ MACAddressDeviceRegistration( MACAddress( "00-00-00-00-00-00" ) ),
+
+ // `sampling` namespace.
+ BatteryAwareSamplingConfiguration(
+ GranularitySamplingConfiguration( Granularity.Balanced ),
+ GranularitySamplingConfiguration( Granularity.Coarse ),
+ ),
+ GranularitySamplingConfiguration( Granularity.Balanced ),
+ IntervalSamplingConfiguration( Duration.milliseconds( 1000 ) ),
+ NoOptionsSamplingConfiguration,
+
+ // `tasks` namespace.
+ BackgroundTask( "Start measures", listOf() ),
+ CustomProtocolTask(
+ "Custom study runtime",
+ "{ \"\$type\": \"Study\", \"custom\": \"protocol\" }"
+ ),
+ WebTask( "Survey", emptyList(), "Some survey", "http://survey.com" ),
+
+ // `triggers` namespace.
+ ElapsedTimeTrigger( Smartphone( "User's phone" ), Duration.ZERO ),
+ ManualTrigger(
+ "User's phone",
+ "Mood",
+ "Describe how you are feeling at the moment."
+ ),
+ ScheduledTrigger(
+ Smartphone( "User's phone"),
+ TimeOfDay( 12 ), RecurrenceRule( RecurrenceRule.Frequency.DAILY )
+ ),
+
+ // `users` namespace.
+ EmailAccountIdentity( "test@test.com" ),
+ UsernameAccountIdentity( "Some user" ),
+)
diff --git a/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/devices/DeviceDescriptorTest.kt b/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/devices/DeviceDescriptorTest.kt
index 8de9e9056..dc46770c3 100644
--- a/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/devices/DeviceDescriptorTest.kt
+++ b/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/devices/DeviceDescriptorTest.kt
@@ -1,9 +1,6 @@
package dk.cachet.carp.common.application.devices
-import dk.cachet.carp.common.application.data.DataType
-import dk.cachet.carp.common.application.sampling.BatteryAwareSamplingConfiguration
-import dk.cachet.carp.common.application.sampling.Granularity
-import dk.cachet.carp.common.application.sampling.GranularitySamplingConfiguration
+import dk.cachet.carp.common.application.sampling.NoOptionsSamplingConfiguration
import kotlin.test.*
@@ -13,37 +10,24 @@ import kotlin.test.*
class DeviceDescriptorTest
{
@Test
- fun getDefaultSamplingConfiguration_succeeds()
+ fun validateModifiedDefaultSamplingConfigurations_with_correct_configuration()
{
- val typeMetaData = Smartphone.Sensors.GEOLOCATION
- val device = Smartphone( "Irrelevant" )
-
- val configuration = device.getDefaultSamplingConfiguration( typeMetaData.dataType.type )
- assertEquals( typeMetaData.default, configuration )
- }
-
- @Test
- fun getDefaultSamplingConfiguration_returns_overridden_defaultSamplingConfiguration()
- {
- val typeMetaData = Smartphone.Sensors.GEOLOCATION
- val dataType = typeMetaData.dataType.type
- val configurationOverride = BatteryAwareSamplingConfiguration(
- GranularitySamplingConfiguration( Granularity.Coarse ),
- GranularitySamplingConfiguration( Granularity.Coarse ),
- GranularitySamplingConfiguration( Granularity.Coarse )
+ val validConfigurations = mapOf(
+ Smartphone.Sensors.GEOLOCATION.dataType.type to Smartphone.Sensors.GEOLOCATION.default
)
- val device = Smartphone( "Irrelevant", mapOf( dataType to configurationOverride ) )
+ val device = Smartphone( "Irrelevant", false, validConfigurations )
- val configuration = device.getDefaultSamplingConfiguration( dataType )
- assertEquals( configurationOverride, configuration )
+ device.validateDefaultSamplingConfiguration()
}
@Test
- fun getDefaultSamplingConfiguration_fails_for_unsupported_type()
+ fun validateModifiedDefaultSamplingConfigurations_with_invalid_configuration()
{
- val device = Smartphone( "Irrelevant" )
+ val invalidConfigurations = mapOf(
+ Smartphone.Sensors.GEOLOCATION.dataType.type to NoOptionsSamplingConfiguration
+ )
+ val device = Smartphone( "Irrelevant", false, invalidConfigurations )
- val unsupportedType = DataType( "unsupported", "type" )
- assertFailsWith { device.getDefaultSamplingConfiguration( unsupportedType ) }
+ assertFailsWith { device.validateDefaultSamplingConfiguration() }
}
}
diff --git a/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/tasks/TaskDescriptorTest.kt b/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/tasks/TaskDescriptorTest.kt
index 1bae4feec..435c5a7be 100644
--- a/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/tasks/TaskDescriptorTest.kt
+++ b/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/application/tasks/TaskDescriptorTest.kt
@@ -1,7 +1,6 @@
package dk.cachet.carp.common.application.tasks
import dk.cachet.carp.common.application.data.CarpDataTypes
-import dk.cachet.carp.common.application.data.DataType
import dk.cachet.carp.common.infrastructure.test.STUB_DATA_TYPE
import dk.cachet.carp.common.infrastructure.test.StubTaskDescriptor
import kotlin.test.*
@@ -12,31 +11,21 @@ import kotlin.test.*
*/
class TaskDescriptorTest
{
- @Test
- fun getInteractionDataTypes_succeeds()
- {
- val interactionType = DataType( "some.namespace", "completedtask" )
- val task = StubTaskDescriptor( "Task", emptyList(), "Description", setOf( interactionType ) )
-
- val dataTypes = task.getInteractionDataTypes()
- assertEquals( setOf( interactionType ), dataTypes )
- }
-
@Test
fun getAllExpectedDataTypes_succeeds()
{
- val interactionType = DataType( "some.namespace", "completedtask" )
val task = StubTaskDescriptor(
"Task",
listOf( Measure.DataStream( STUB_DATA_TYPE ), Measure.TriggerData( 0 ) ),
- "Description",
- setOf( interactionType )
+ "Description"
)
val dataTypes = task.getAllExpectedDataTypes()
- assertEquals(
- setOf( STUB_DATA_TYPE, CarpDataTypes.TRIGGERED_TASK.type, interactionType ),
- dataTypes
+ val expectedDataTypes = setOf(
+ STUB_DATA_TYPE,
+ CarpDataTypes.TRIGGERED_TASK.type,
+ CarpDataTypes.COMPLETED_TASK.type
)
+ assertEquals( expectedDataTypes, dataTypes )
}
}
diff --git a/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/infrastructure/serialization/SerializationTest.kt b/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/infrastructure/serialization/SerializationTest.kt
index 952db9af4..48c0a1fea 100644
--- a/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/infrastructure/serialization/SerializationTest.kt
+++ b/carp.common/src/commonTest/kotlin/dk/cachet/carp/common/infrastructure/serialization/SerializationTest.kt
@@ -2,31 +2,44 @@
package dk.cachet.carp.common.infrastructure.serialization
-import dk.cachet.carp.common.application.*
-import dk.cachet.carp.common.application.data.*
-import dk.cachet.carp.common.application.data.input.*
-import dk.cachet.carp.common.application.data.input.elements.*
-import dk.cachet.carp.common.application.devices.*
-import dk.cachet.carp.common.application.sampling.*
-import dk.cachet.carp.common.application.tasks.*
-import dk.cachet.carp.common.application.triggers.*
-import dk.cachet.carp.common.application.users.*
+import dk.cachet.carp.common.application.data.Data
+import dk.cachet.carp.common.application.data.input.CustomInput
+import dk.cachet.carp.common.application.data.input.elements.InputElement
+import dk.cachet.carp.common.application.data.input.elements.Text
+import dk.cachet.carp.common.application.devices.DefaultDeviceRegistration
+import dk.cachet.carp.common.application.sampling.BatteryAwareSamplingConfiguration
+import dk.cachet.carp.common.application.sampling.Granularity
+import dk.cachet.carp.common.application.sampling.GranularitySamplingConfiguration
+import dk.cachet.carp.common.application.sampling.SamplingConfiguration
+import dk.cachet.carp.common.application.commonInstances
import dk.cachet.carp.common.infrastructure.test.*
import dk.cachet.carp.test.serialization.ConcreteTypesSerializationTest
+import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.PolymorphicSerializer
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlin.test.*
-import kotlin.time.Duration
val testJson = createDefaultJSON( STUBS_SERIAL_MODULE )
+val unknownInstances = listOf(
+ // `infrastructure` namespace.
+ unknown( StubData() ) { CustomData( it.first, it.second, it.third ) },
+ unknown( StubDeviceDescriptor() ) { CustomDeviceDescriptor( it.first, it.second, it.third ) },
+ unknown( StubMasterDeviceDescriptor() ) { CustomMasterDeviceDescriptor( it.first, it.second, it.third ) },
+ unknown( DefaultDeviceRegistration( "id" ) ) { CustomDeviceRegistration( it.first, it.second, it.third ) },
+ unknown( StubSamplingConfiguration( "" ) ) { CustomSamplingConfiguration( it.first, it.second, it.third ) },
+ unknown( StubTaskDescriptor() ) { CustomTaskDescriptor( it.first, it.second, it.third ) },
+ unknown( StubTrigger( "source" ) ) { CustomTrigger( it.first, it.second, it.third ) },
+)
+
/**
* Convert the specified [stub] to the corresponding [UnknownPolymorphicWrapper] as if it were unknown at runtime.
*/
+@OptIn( ExperimentalSerializationApi::class )
inline fun unknown( stub: Base, constructor: (Triple) -> Base ): Base
{
val originalObject = testJson.encodeToJsonElement( PolymorphicSerializer( Base::class ), stub ) as JsonObject
@@ -40,82 +53,6 @@ inline fun unknown( stub: Base, constructor: (Triple = listOf( Measure.DataStream( STUB_DATA_TYPE ) )
- val task = StubTaskDescriptor( "Unknown", measures )
- val serialized: String = JSON.encodeToString( StubTaskDescriptor.serializer(), task )
- val customTask = CustomTaskDescriptor( "Task", serialized, JSON )
-
- assertFailsWith { customTask.getInteractionDataTypes() }
- assertFailsWith { customTask.getAllExpectedDataTypes() }
- }
}
diff --git a/carp.common/src/jvmTest/kotlin/dk/cachet/carp/common/application/ConcreteTypes.kt b/carp.common/src/jvmTest/kotlin/dk/cachet/carp/common/application/ConcreteTypes.kt
new file mode 100644
index 000000000..3555cf07a
--- /dev/null
+++ b/carp.common/src/jvmTest/kotlin/dk/cachet/carp/common/application/ConcreteTypes.kt
@@ -0,0 +1,22 @@
+package dk.cachet.carp.common.application
+
+import dk.cachet.carp.common.application.data.Data
+import dk.cachet.carp.common.application.data.input.elements.InputElement
+import dk.cachet.carp.common.application.devices.AnyDeviceDescriptor
+import dk.cachet.carp.common.application.devices.DeviceRegistration
+import dk.cachet.carp.common.application.sampling.SamplingConfiguration
+import dk.cachet.carp.common.application.tasks.TaskDescriptor
+import dk.cachet.carp.common.application.triggers.Trigger
+import dk.cachet.carp.common.application.users.AccountIdentity
+import dk.cachet.carp.test.findConcreteTypes
+import kotlin.reflect.KClass
+
+
+val concreteDataTypes: List> = findConcreteTypes()
+val concreteInputElementTypes: List>> = findConcreteTypes()
+val concreteDeviceDescriptorTypes: List> = findConcreteTypes()
+val concreteDeviceRegistrationTypes: List> = findConcreteTypes()
+val concreteSamplingConfigurationTypes: List> = findConcreteTypes()
+val concreteTaskDescriptorTypes: List>> = findConcreteTypes()
+val concreteTriggerTypes: List>> = findConcreteTypes()
+val concreteAccountIdentityTypes: List> = findConcreteTypes()
diff --git a/carp.common/src/jvmTest/kotlin/dk/cachet/carp/common/application/devices/DeviceDescriptorsReflectionTest.kt b/carp.common/src/jvmTest/kotlin/dk/cachet/carp/common/application/devices/DeviceDescriptorsReflectionTest.kt
index 3b6e91d6f..8c8a749b9 100644
--- a/carp.common/src/jvmTest/kotlin/dk/cachet/carp/common/application/devices/DeviceDescriptorsReflectionTest.kt
+++ b/carp.common/src/jvmTest/kotlin/dk/cachet/carp/common/application/devices/DeviceDescriptorsReflectionTest.kt
@@ -1,30 +1,25 @@
package dk.cachet.carp.common.application.devices
+import dk.cachet.carp.common.application.commonInstances
+import dk.cachet.carp.common.application.concreteDeviceDescriptorTypes
import dk.cachet.carp.common.application.sampling.DataTypeSamplingSchemeMap
import dk.cachet.carp.common.application.tasks.TaskDescriptorList
-import org.reflections.Reflections
-import java.lang.reflect.Modifier
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertNotNull
+import kotlin.reflect.full.primaryConstructor
+import kotlin.test.*
class DeviceDescriptorsReflectionTest
{
+ private val instances = commonInstances
+ .filterIsInstance()
+ .associateBy { it::class }
+
+
@Test
- fun all_device_descriptors_define_sensors_and_tasks()
- {
- // Find all device descriptors in this subsystem.
- val deviceClass = DeviceDescriptor::class.java
- val namespace = deviceClass.`package`.name
- val reflections = Reflections( namespace )
- val concreteDeviceDescriptors = reflections
- .getSubTypesOf( deviceClass )
- .filter { !Modifier.isAbstract( it.modifiers ) }
-
- concreteDeviceDescriptors.forEach { concreteClass ->
- val name = concreteClass.simpleName
- val subclasses = concreteClass.classes.toList()
+ fun all_device_descriptors_define_sensors_and_tasks() =
+ concreteDeviceDescriptorTypes.forEach { descriptor ->
+ val name = descriptor.simpleName
+ val subclasses = descriptor.java.classes.toList()
// Does the DeviceDescriptor list available sensors?
val sensorsClass = subclasses.singleOrNull { it.name.endsWith( "\$Sensors" ) }
@@ -44,5 +39,43 @@ class DeviceDescriptorsReflectionTest
"`Tasks` subclass in \"$name\" does not extend from \"${superTasksClass.simpleName}\"."
)
}
- }
+
+ @Test
+ fun all_device_descriptors_have_an_instance_for_testing() =
+ concreteDeviceDescriptorTypes.forEach { descriptor ->
+ val instance = instances[ descriptor ]
+ assertNotNull( instance, "No test instance added for `$descriptor`." )
+ }
+
+ @Test
+ fun all_device_descriptors_have_correct_defaults() =
+ concreteDeviceDescriptorTypes.forEach { descriptor ->
+ val instance = instances.getValue( descriptor )
+
+ // Find constructor to instantiate a new instance with the default parameter values.
+ // TODO: Do we always expect the primary constructor to be the one we need? Okay for now.
+ val constructor = descriptor.primaryConstructor
+ assertNotNull( constructor, "`$descriptor` does not have a primary constructor." )
+
+ // For all non-optional parameters in constructor, get correct values from `instance`.
+ val parameters = constructor.parameters.filter { !it.isOptional }
+ val parameterValues = parameters.associateWith { parameter ->
+ val matchingMember = descriptor.members.firstOrNull { it.name == parameter.name }
+ assertNotNull( matchingMember, "No member with name `$parameter.name` found." )
+ matchingMember.call( instance )
+ }
+
+ // Construct instance which has default values and verify whether they are correct.
+ val hasDefaultValues = constructor.callBy( parameterValues )
+ assertEquals(
+ emptyMap(),
+ hasDefaultValues.defaultSamplingConfiguration,
+ "`${AnyDeviceDescriptor::defaultSamplingConfiguration.name}` of `$descriptor` doesn't have the correct default."
+ )
+ assertEquals(
+ false,
+ hasDefaultValues.isOptional,
+ "`${AnyDeviceDescriptor::isOptional.name}` of `$descriptor` doesn't have the correct default."
+ )
+ }
}
diff --git a/carp.common/src/jvmTest/kotlin/dk/cachet/carp/common/infrastructure/serialization/SerializationReflectionTest.kt b/carp.common/src/jvmTest/kotlin/dk/cachet/carp/common/infrastructure/serialization/SerializationReflectionTest.kt
index ccc99439c..2ca51ca94 100644
--- a/carp.common/src/jvmTest/kotlin/dk/cachet/carp/common/infrastructure/serialization/SerializationReflectionTest.kt
+++ b/carp.common/src/jvmTest/kotlin/dk/cachet/carp/common/infrastructure/serialization/SerializationReflectionTest.kt
@@ -1,20 +1,23 @@
package dk.cachet.carp.common.infrastructure.serialization
+import dk.cachet.carp.common.application.concreteAccountIdentityTypes
+import dk.cachet.carp.common.application.concreteDataTypes
+import dk.cachet.carp.common.application.concreteDeviceDescriptorTypes
+import dk.cachet.carp.common.application.concreteDeviceRegistrationTypes
+import dk.cachet.carp.common.application.concreteInputElementTypes
+import dk.cachet.carp.common.application.concreteSamplingConfigurationTypes
+import dk.cachet.carp.common.application.concreteTaskDescriptorTypes
+import dk.cachet.carp.common.application.concreteTriggerTypes
import dk.cachet.carp.common.application.data.Data
import dk.cachet.carp.common.application.data.input.CUSTOM_INPUT_TYPE_NAME
import dk.cachet.carp.common.application.data.input.CustomInputSerializer
import dk.cachet.carp.common.application.data.input.elements.InputElement
-import dk.cachet.carp.common.application.devices.AnyDeviceDescriptor
-import dk.cachet.carp.common.application.devices.DeviceRegistration
-import dk.cachet.carp.common.application.sampling.SamplingConfiguration
-import dk.cachet.carp.common.application.tasks.TaskDescriptor
-import dk.cachet.carp.common.application.triggers.Trigger
-import dk.cachet.carp.common.application.users.AccountIdentity
-import dk.cachet.carp.test.serialization.verifyTypesAreRegistered
+import dk.cachet.carp.test.serialization.getPolymorphicSerializers
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.InternalSerializationApi
import org.reflections.Reflections
import java.lang.reflect.ParameterizedType
+import kotlin.reflect.KClass
import kotlin.test.*
@@ -23,39 +26,50 @@ class SerializationReflectionTest
{
@Test
fun all_Data_types_registered_for_serialization() =
- verifyTypesAreRegistered()
+ verifyTypesAreRegistered( concreteDataTypes )
@Test
fun all_InputElement_types_registered_for_serialization() =
- verifyTypesAreRegistered>()
+ verifyTypesAreRegistered( concreteInputElementTypes )
@Test
fun all_DeviceDescriptor_types_registered_for_serialization() =
- verifyTypesAreRegistered()
+ verifyTypesAreRegistered( concreteDeviceDescriptorTypes )
@Test
fun all_DeviceRegistration_types_registered_for_serialization() =
- verifyTypesAreRegistered()
+ verifyTypesAreRegistered( concreteDeviceRegistrationTypes )
@Test
fun all_SamplingConfiguration_types_registered_for_serialization() =
- verifyTypesAreRegistered()
+ verifyTypesAreRegistered( concreteSamplingConfigurationTypes )
@Test
fun all_TaskDescriptor_types_registered_for_serialization() =
- verifyTypesAreRegistered()
+ verifyTypesAreRegistered( concreteTaskDescriptorTypes )
@Test
fun all_Trigger_types_registered_for_serialization() =
- verifyTypesAreRegistered>()
+ verifyTypesAreRegistered( concreteTriggerTypes )
@Test
fun all_AccountIdentity_types_registered_for_serialization() =
- verifyTypesAreRegistered()
+ verifyTypesAreRegistered( concreteAccountIdentityTypes )
- private inline fun verifyTypesAreRegistered() =
- verifyTypesAreRegistered( COMMON_SERIAL_MODULE )
+ private val polymorphicSerializers = getPolymorphicSerializers( COMMON_SERIAL_MODULE )
+ /**
+ * Verifies whether all [types] are registered for polymorphic serialization in [COMMON_SERIAL_MODULE].
+ */
+ private fun verifyTypesAreRegistered( types: List> ) = types
+ .filter { type ->
+ // Wrappers for unknown types are only used at runtime and don't need to be serializable.
+ type.java.interfaces.none { it == UnknownPolymorphicWrapper::class.java }
+ }
+ .forEach {
+ val serializer = polymorphicSerializers[ it ]
+ assertNotNull( serializer, "No serializer registered for '$it'." )
+ }
@InternalSerializationApi
@Test
diff --git a/carp.data.core/build.gradle b/carp.data.core/build.gradle
index 73a2f81df..f55c2eaf5 100644
--- a/carp.data.core/build.gradle
+++ b/carp.data.core/build.gradle
@@ -10,3 +10,13 @@ publishing {
}
}
}
+
+kotlin {
+ sourceSets {
+ jsMain {
+ dependencies {
+ implementation(npm("big.js", versions.bigJs))
+ }
+ }
+ }
+}
diff --git a/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/DataStreamBatch.kt b/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/DataStreamBatch.kt
index 2d3c96d13..676c52cd0 100644
--- a/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/DataStreamBatch.kt
+++ b/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/DataStreamBatch.kt
@@ -52,8 +52,9 @@ class MutableDataStreamBatch : DataStreamBatch
/**
* Append a sequence to a non-existing or previously appended data stream in this batch.
*
- * @throws IllegalArgumentException when the start of the [sequence] range precedes the end of
- * a previously appended sequence to the same data stream.
+ * @throws IllegalArgumentException when:
+ * - the start of the [sequence] range precedes the end of a previously appended sequence to the same data stream
+ * - the sync point of [sequence] is older than that of previous sequences in this batch
*/
fun appendSequence( sequence: DataStreamSequence )
{
@@ -69,6 +70,8 @@ class MutableDataStreamBatch : DataStreamBatch
val last = sequenceList.last()
require( last.range.last < sequence.range.first )
{ "Sequence range start lies before the end of a previously appended sequence to the same data stream." }
+ require( last.syncPoint.synchronizedOn <= sequence.syncPoint.synchronizedOn )
+ { "The sync point contained in this sequence can't have been obtained before a previous sync point." }
// Merge sequence with last sequence if possible; add new sequence otherwise.
if ( last.isImmediatelyFollowedBy( sequence ) )
diff --git a/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/DataStreamPoint.kt b/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/DataStreamPoint.kt
index e4d836fee..9590fb54f 100644
--- a/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/DataStreamPoint.kt
+++ b/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/DataStreamPoint.kt
@@ -27,7 +27,7 @@ data class DataStreamPoint(
/**
* The most recent synchronization information which was determined for this or a previous [DataStreamPoint].
*/
- val syncPoint: SyncPoint
+ val syncPoint: SyncPoint = SyncPoint.UnixEpoch
)
{
init
@@ -39,6 +39,20 @@ data class DataStreamPoint(
val dataStream: DataStreamId
get() = DataStreamId( studyDeploymentId, deviceRoleName, measurement.dataType )
+
+ /**
+ * Convert this [DataStreamPoint] to one for which the [measurement] is synchronized using [syncPoint].
+ */
+ fun synchronize(): DataStreamPoint
+ {
+ // Early out in case no synchronization is needed.
+ if ( syncPoint == SyncPoint.UnixEpoch ) return this
+
+ return copy(
+ measurement = measurement.synchronize( syncPoint ),
+ syncPoint = SyncPoint.UnixEpoch
+ )
+ }
}
diff --git a/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/DataStreamSequence.kt b/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/DataStreamSequence.kt
index 9f310a59e..58623a6c9 100644
--- a/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/DataStreamSequence.kt
+++ b/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/DataStreamSequence.kt
@@ -92,7 +92,7 @@ class MutableDataStreamSequence(
override val dataStream: DataStreamId,
override val firstSequenceId: Long,
triggerIds: List,
- override val syncPoint: SyncPoint
+ override val syncPoint: SyncPoint = SyncPoint.UnixEpoch
) : DataStreamSequence
{
override val triggerIds: List = triggerIds.toList()
@@ -154,7 +154,7 @@ object DataStreamSequenceSerializer : KSerializer
override val firstSequenceId: Long,
override val measurements: List>,
override val triggerIds: List,
- override val syncPoint: SyncPoint
+ override val syncPoint: SyncPoint = SyncPoint.UnixEpoch
) : DataStreamSequence
{
init { throwIfIllegalInitialization() }
diff --git a/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/Measurement.kt b/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/Measurement.kt
index 351079312..67acb6086 100644
--- a/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/Measurement.kt
+++ b/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/Measurement.kt
@@ -40,6 +40,17 @@ data class Measurement(
fun getDataTimeType(): DataTimeType =
if ( sensorEndTime == null ) DataTimeType.POINT
else DataTimeType.TIME_SPAN
+
+ /**
+ * Convert this [Measurement] to one synchronized using [syncPoint].
+ */
+ fun synchronize( syncPoint: SyncPoint ): Measurement =
+ copy(
+ sensorStartTime = syncPoint.applyToTimestamp( sensorStartTime ),
+ sensorEndTime =
+ if ( sensorEndTime == null ) sensorEndTime
+ else syncPoint.applyToTimestamp( sensorEndTime )
+ )
}
diff --git a/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/SyncPoint.kt b/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/SyncPoint.kt
index 5d8fc5158..de9ffda32 100644
--- a/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/SyncPoint.kt
+++ b/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/application/SyncPoint.kt
@@ -1,16 +1,17 @@
package dk.cachet.carp.data.application
+import dk.cachet.carp.common.application.toEpochMicroseconds
import kotlinx.datetime.Instant
import kotlinx.serialization.Required
import kotlinx.serialization.Serializable
/**
- * Information about a sensor clock at the timestamp [synchronizedOn] on a master device
- * which allows converting sensor timestamps to UTC time in microseconds.
+ * Information about a sensor clock at [synchronizedOn] on a master device
+ * which allows converting sensor time to number of microseconds since the Unix epoch.
*
- * The required units/sign to convert to UTC microseconds are determined by the formula:
- * (sensorTimeStamp * [relativeClockSpeed]) + [utcOffset]
+ * The required units/sign to carry out this conversion are determined by the formula:
+ * syncedTime = [relativeClockSpeed] * (sensorTime - [sensorTimestampAtSyncPoint]) + [synchronizedOn]
*/
@Serializable
data class SyncPoint(
@@ -19,13 +20,35 @@ data class SyncPoint(
*/
val synchronizedOn: Instant,
/**
- * The offset to be added to the sensor time stamps after having multiplied by [relativeClockSpeed].
+ * The sensor time at [synchronizedOn].
*/
@Required
- val utcOffset: Long = 0,
+ val sensorTimestampAtSyncPoint: Long = synchronizedOn.toEpochMicroseconds(),
/**
- * The value to multiply sensor time stamps by.
+ * The relative clock speed of UTC time compared to the sensor clock,
+ * calculated as the variation of UTC time divided by the variation of sensor time.
+ *
+ * E.g., if the sensor clock runs half as fast as UTC time, the relative clock speed is 2.
*/
@Required
val relativeClockSpeed: Double = 1.0
)
+{
+ companion object
+ {
+ /**
+ * The default [SyncPoint] for sensors which return timestamps as number of microseconds since the Unix epoch.
+ * Applying this [SyncPoint] to timestamps is a no-op.
+ */
+ val UnixEpoch: SyncPoint = SyncPoint( Instant.fromEpochSeconds( 0 ) )
+ }
+}
+
+
+/**
+ * Convert [timestamp] obtained by the sensor clock this [SyncPoint] relates to
+ * into number of microseconds since the Unix epoch.
+ *
+ * This requires a platform-specific implementation in order not to lose any precision; big decimal needs to be used.
+ */
+expect fun SyncPoint.applyToTimestamp( timestamp: Long ): Long
diff --git a/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/infrastructure/SerializerDerivedMethods.kt b/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/infrastructure/SerializerDerivedMethods.kt
index ba02b6081..32c81c815 100644
--- a/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/infrastructure/SerializerDerivedMethods.kt
+++ b/carp.data.core/src/commonMain/kotlin/dk/cachet/carp/data/infrastructure/SerializerDerivedMethods.kt
@@ -34,7 +34,7 @@ fun getDataType( dataKlass: KClass ): DataType =
}
/**
- * Initialize a [DataStreamId] with the specified [studyDeploymentId and [deviceRoleName].
+ * Initialize a [DataStreamId] with the specified [studyDeploymentId] and [deviceRoleName].
* The [DataType] is extracted from the serializer associated with the class of [TData].
*/
inline fun dataStreamId( studyDeploymentId: UUID, deviceRoleName: String ): DataStreamId =
diff --git a/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/CreateTestObjects.kt b/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/CreateTestObjects.kt
index 4c9a5ffad..a5388d2bf 100644
--- a/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/CreateTestObjects.kt
+++ b/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/CreateTestObjects.kt
@@ -2,6 +2,7 @@ package dk.cachet.carp.data.application
import dk.cachet.carp.common.application.UUID
import dk.cachet.carp.common.application.data.Data
+import dk.cachet.carp.common.application.toEpochMicroseconds
import dk.cachet.carp.data.infrastructure.dataStreamId
import dk.cachet.carp.data.infrastructure.measurement
import kotlinx.datetime.Clock
@@ -34,3 +35,18 @@ inline fun createStubSequence(
stubTriggerIds,
stubSyncPoint
).apply { appendMeasurements( measurements.toList() ) }
+
+/**
+ * Create a [SyncPoint] for the current time for a clock which runs twice as fast as UTC time.
+ */
+fun createDoubleSpeedSyncPoint(): SyncPoint
+{
+ val now = Clock.System.now()
+ val nowMicros = now.toEpochMicroseconds()
+
+ return SyncPoint(
+ now,
+ nowMicros * 2,
+ 0.5
+ )
+}
diff --git a/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/DataStreamPointTest.kt b/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/DataStreamPointTest.kt
index 0aba275dc..b068d89ca 100644
--- a/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/DataStreamPointTest.kt
+++ b/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/DataStreamPointTest.kt
@@ -5,6 +5,7 @@ import dk.cachet.carp.common.infrastructure.test.StubData
import dk.cachet.carp.data.infrastructure.measurement
import kotlinx.datetime.Clock
import kotlin.test.Test
+import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
@@ -42,4 +43,22 @@ class DataStreamPointTest
)
}
}
+
+ @Test
+ fun synchronize_succeeds()
+ {
+ val doubleSpeed = createDoubleSpeedSyncPoint()
+ val point = DataStreamPoint(
+ 0,
+ UUID.randomUUID(),
+ "Test device",
+ measurement( StubData(), 1000, null ),
+ listOf( 0 ),
+ doubleSpeed
+ )
+
+ val synchronized = point.synchronize()
+ assertEquals( 500, synchronized.measurement.sensorStartTime )
+ assertEquals( SyncPoint.UnixEpoch, synchronized.syncPoint )
+ }
}
diff --git a/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/MeasurementTest.kt b/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/MeasurementTest.kt
index cb02e060d..1cc36547b 100644
--- a/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/MeasurementTest.kt
+++ b/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/MeasurementTest.kt
@@ -34,4 +34,26 @@ class MeasurementTest
val timeSpan = Measurement( 0, 1, STUB_DATA_TIME_SPAN_TYPE, StubDataTimeSpan() )
assertEquals( DataTimeType.TIME_SPAN, timeSpan.getDataTimeType() )
}
+
+ @Test
+ fun synchronize_succeeds()
+ {
+ val point = Measurement( 1000, null, STUB_DATA_POINT_TYPE, StubDataPoint() )
+
+ val doubleSpeed = createDoubleSpeedSyncPoint()
+ val syncedPoint = point.synchronize( doubleSpeed )
+ assertEquals( 500, syncedPoint.sensorStartTime )
+ assertNull( syncedPoint.sensorEndTime )
+ }
+
+ @Test
+ fun synchronize_converts_both_start_and_end_time()
+ {
+ val point = Measurement( 1000, 2000, STUB_DATA_TIME_SPAN_TYPE, StubDataTimeSpan() )
+
+ val doubleSpeed = createDoubleSpeedSyncPoint()
+ val syncedPoint = point.synchronize( doubleSpeed )
+ assertEquals( 500, syncedPoint.sensorStartTime )
+ assertEquals( 1000, syncedPoint.sensorEndTime )
+ }
}
diff --git a/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/MutableDataStreamBatchTest.kt b/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/MutableDataStreamBatchTest.kt
index 59d3a9c44..90f2e3d6e 100644
--- a/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/MutableDataStreamBatchTest.kt
+++ b/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/MutableDataStreamBatchTest.kt
@@ -5,6 +5,7 @@ import dk.cachet.carp.common.infrastructure.test.StubData
import dk.cachet.carp.common.infrastructure.test.StubDataPoint
import dk.cachet.carp.data.infrastructure.dataStreamId
import dk.cachet.carp.data.infrastructure.measurement
+import kotlinx.datetime.Clock
import kotlin.test.*
@@ -69,6 +70,33 @@ class MutableDataStreamBatchTest
assertEquals( 2, batch.getDataStreamPoints( dataStream ).toList().count() )
}
+ @Test
+ fun appendSequence_succeeds_with_new_syncpoint()
+ {
+ val batch = MutableDataStreamBatch()
+ val dataStream = dataStreamId( UUID.randomUUID(), "Device" )
+ val firstSequence = MutableDataStreamSequence(
+ dataStream,
+ 0,
+ stubTriggerIds,
+ SyncPoint.UnixEpoch
+ )
+ firstSequence.appendMeasurements( measurement( StubData(), 0 ) )
+ batch.appendSequence( firstSequence )
+
+ val newSequence = MutableDataStreamSequence(
+ dataStream,
+ 1,
+ stubTriggerIds,
+ stubSyncPoint
+ )
+ newSequence.appendMeasurements( measurement( StubData(), 0 ) )
+ batch.appendSequence( newSequence )
+
+ assertEquals( 2, batch.sequences.count() ) // Due to the different sync point, the sequence is not merged.
+ assertEquals( 2, batch.getDataStreamPoints( dataStream ).toList().count() )
+ }
+
@Test
fun appendSequence_merges_sequence_when_there_is_no_sequence_gap()
{
@@ -107,6 +135,30 @@ class MutableDataStreamBatchTest
}
}
+ @Test
+ fun appendSequence_fails_for_older_sync_point()
+ {
+ val batch = MutableDataStreamBatch()
+ val dataStream = dataStreamId( UUID.randomUUID(), "Device" )
+ val firstSequence = MutableDataStreamSequence(
+ dataStream,
+ 0,
+ stubTriggerIds,
+ SyncPoint( Clock.System.now() )
+ )
+ firstSequence.appendMeasurements( measurement( StubData(), 0 ) )
+ batch.appendSequence( firstSequence )
+
+ val newSequence = MutableDataStreamSequence(
+ dataStream,
+ 1,
+ stubTriggerIds,
+ SyncPoint.UnixEpoch
+ )
+ newSequence.appendMeasurements( measurement( StubData(), 0 ) )
+ assertFailsWith { batch.appendSequence( newSequence ) }
+ }
+
@Test
fun appendBatch_succeeds()
{
diff --git a/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/SyncPointTest.kt b/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/SyncPointTest.kt
new file mode 100644
index 000000000..63aed026b
--- /dev/null
+++ b/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/application/SyncPointTest.kt
@@ -0,0 +1,35 @@
+package dk.cachet.carp.data.application
+
+import kotlin.test.*
+
+
+/**
+ * Tests for [SyncPoint].
+ */
+class SyncPointTest
+{
+ @Test
+ fun applyToTimestamp_for_double_speed_clock_succeeds()
+ {
+ val doubleSpeed = createDoubleSpeedSyncPoint()
+
+ val synchronized = doubleSpeed.applyToTimestamp( 1000 )
+ assertEquals( 500, synchronized )
+ }
+
+ @Test
+ fun applyToTimestamp_for_unix_epoch_syncpoint_succeeds()
+ {
+ val synchronized = SyncPoint.UnixEpoch.applyToTimestamp( 1000 )
+ assertEquals( 1000, synchronized )
+ }
+
+ @Test
+ fun applyToTimestamp_has_accurate_precision()
+ {
+ val bigNumber = 290017789727876000L
+
+ val noOp = SyncPoint.UnixEpoch.applyToTimestamp( bigNumber )
+ assertEquals( bigNumber, noOp )
+ }
+}
diff --git a/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/infrastructure/InMemoryDataStreamServiceTest.kt b/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/infrastructure/InMemoryDataStreamServiceTest.kt
index 29174453c..3ae2f3c32 100644
--- a/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/infrastructure/InMemoryDataStreamServiceTest.kt
+++ b/carp.data.core/src/commonTest/kotlin/dk/cachet/carp/data/infrastructure/InMemoryDataStreamServiceTest.kt
@@ -1,7 +1,24 @@
package dk.cachet.carp.data.infrastructure
+import dk.cachet.carp.common.application.UUID
+import dk.cachet.carp.common.application.data.Data
+import dk.cachet.carp.common.application.data.DataType
+import dk.cachet.carp.common.infrastructure.test.StubData
+import dk.cachet.carp.common.infrastructure.test.createTestJSON
+import dk.cachet.carp.common.infrastructure.test.makeUnknown
+import dk.cachet.carp.data.application.DataStreamId
+import dk.cachet.carp.data.application.DataStreamsConfiguration
import dk.cachet.carp.data.application.DataStreamService
import dk.cachet.carp.data.application.DataStreamServiceTest
+import dk.cachet.carp.data.application.Measurement
+import dk.cachet.carp.data.application.MutableDataStreamBatch
+import dk.cachet.carp.data.application.MutableDataStreamSequence
+import dk.cachet.carp.data.application.SyncPoint
+import dk.cachet.carp.test.runSuspendTest
+import kotlinx.datetime.Clock
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.encodeToString
+import kotlin.test.*
/**
@@ -10,4 +27,36 @@ import dk.cachet.carp.data.application.DataStreamServiceTest
class InMemoryDataStreamServiceTest : DataStreamServiceTest
{
override fun createService(): DataStreamService = InMemoryDataStreamService()
+
+
+ @Test
+ fun appendToDataStreams_for_unknown_datatype_succeeds() = runSuspendTest {
+ val service = createService()
+
+ val deploymentId = UUID.randomUUID()
+ val unknownType = DataType( "unknown", "type" )
+
+ // Open data stream for unknown data type.
+ val dataStreamId = DataStreamId( deploymentId, "Some device", unknownType )
+ val expectedStream = DataStreamsConfiguration.ExpectedDataStream.fromDataStreamId( dataStreamId )
+ val configuration = DataStreamsConfiguration( deploymentId, setOf( expectedStream ) )
+ service.openDataStreams( configuration )
+
+ // Create unknown data point.
+ val json = createTestJSON()
+ val dataPoint = StubData()
+ val dataPointJson = json.encodeToString( dataPoint )
+ val unknownDataPointJson = dataPointJson.makeUnknown( dataPoint )
+ val unknownDataPoint: Data = json.decodeFromString( unknownDataPointJson )
+
+ // Append data point.
+ val sequence = MutableDataStreamSequence( dataStreamId, 0, listOf( 0 ), SyncPoint( Clock.System.now() ) )
+ sequence.appendMeasurements( Measurement( 0, null, unknownType, unknownDataPoint ) )
+ val batch = MutableDataStreamBatch()
+ batch.appendSequence( sequence )
+ service.appendToDataStreams( deploymentId, batch )
+
+ val retrievedDataStream = service.getDataStream( dataStreamId, 0 )
+ assertEquals( unknownDataPoint, retrievedDataStream.single().measurement.data )
+ }
}
diff --git a/carp.data.core/src/jsMain/kotlin/dk/cachet/carp/data/application/SyncPoint.kt b/carp.data.core/src/jsMain/kotlin/dk/cachet/carp/data/application/SyncPoint.kt
new file mode 100644
index 000000000..8451fcb30
--- /dev/null
+++ b/carp.data.core/src/jsMain/kotlin/dk/cachet/carp/data/application/SyncPoint.kt
@@ -0,0 +1,21 @@
+
+
+package dk.cachet.carp.data.application
+
+import dk.cachet.carp.common.application.toEpochMicroseconds
+
+
+@JsModule( "big.js" )
+@JsNonModule
+@Suppress( "FunctionName" )
+external fun Big( number: Number ): dynamic
+
+
+actual fun SyncPoint.applyToTimestamp( timestamp: Long ): Long
+{
+ val excludingEpoch = Big( relativeClockSpeed ).times(
+ Big( timestamp - sensorTimestampAtSyncPoint )
+ ).toFixed() as String
+
+ return excludingEpoch.toLong() + synchronizedOn.toEpochMicroseconds()
+}
diff --git a/carp.data.core/src/jvmMain/kotlin/dk/cachet/carp/data/application/SyncPoint.kt b/carp.data.core/src/jvmMain/kotlin/dk/cachet/carp/data/application/SyncPoint.kt
new file mode 100644
index 000000000..94919239b
--- /dev/null
+++ b/carp.data.core/src/jvmMain/kotlin/dk/cachet/carp/data/application/SyncPoint.kt
@@ -0,0 +1,12 @@
+package dk.cachet.carp.data.application
+
+import dk.cachet.carp.common.application.toEpochMicroseconds
+
+
+actual fun SyncPoint.applyToTimestamp( timestamp: Long ): Long
+{
+ val excludingEpoch = relativeClockSpeed.toBigDecimal() *
+ (timestamp - sensorTimestampAtSyncPoint).toBigDecimal()
+
+ return excludingEpoch.toLong() + synchronizedOn.toEpochMicroseconds()
+}
diff --git a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/DeploymentService.kt b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/DeploymentService.kt
index e40e6f337..033364296 100644
--- a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/DeploymentService.kt
+++ b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/DeploymentService.kt
@@ -53,7 +53,7 @@ interface DeploymentService : ApplicationService
): StudyDeploymentStatus
{
- protocol.throwIfInvalid( invitations, connectedDevicePreregistrations )
-
- val newDeployment = StudyDeployment( protocol, id )
+ protocol.throwIfInvalidPreregistrations( connectedDevicePreregistrations )
+ val newDeployment = StudyDeployment.fromInvitations( protocol, invitations, id )
connectedDevicePreregistrations.forEach { (connected, registration) ->
val device = protocol.connectedDevices.first { it.roleName == connected }
newDeployment.registerDevice( device, registration )
@@ -200,7 +199,7 @@ class DeploymentServiceHost(
* - the [deviceDeploymentLastUpdatedOn] does not match the expected timestamp. The deployment might be outdated.
* @throws IllegalStateException when the deployment cannot be deployed yet, or the deployment has stopped.
*/
- override suspend fun deploymentSuccessful(
+ override suspend fun deviceDeployed(
studyDeploymentId: UUID,
masterDeviceRoleName: String,
deviceDeploymentLastUpdatedOn: Instant
@@ -214,8 +213,8 @@ class DeploymentServiceHost(
val deploymentStatus = deployment.getStatus()
- // Once the deployment is ready, open the required data streams.
- if ( deploymentStatus is StudyDeploymentStatus.DeploymentReady )
+ // Once the deployment is running, open the required data streams.
+ if ( deploymentStatus is StudyDeploymentStatus.Running )
{
dataStreamService.openDataStreams( deployment.requiredDataStreams )
}
@@ -236,7 +235,7 @@ class DeploymentServiceHost(
if ( !deployment.isStopped )
{
// Close all data streams used by the deployment.
- if ( deployment.getStatus() is StudyDeploymentStatus.DeploymentReady )
+ if ( deployment.getStatus() is StudyDeploymentStatus.Running )
{
dataStreamService.closeDataStreams( setOf( studyDeploymentId ) )
}
diff --git a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/DeviceDeploymentStatus.kt b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/DeviceDeploymentStatus.kt
index 1e6188e16..df435c7ed 100644
--- a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/DeviceDeploymentStatus.kt
+++ b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/DeviceDeploymentStatus.kt
@@ -16,10 +16,10 @@ sealed class DeviceDeploymentStatus
abstract val device: AnyDeviceDescriptor
/**
- * Determines whether the device requires a device deployment by retrieving [MasterDeviceDeployment].
+ * Determines whether the device can be deployed by retrieving [MasterDeviceDeployment].
* Not all master devices necessarily need deployment; chained master devices do not.
*/
- abstract val requiresDeployment: Boolean
+ abstract val canBeDeployed: Boolean
/**
* Determines whether the device requires a device deployment, and if so,
@@ -33,25 +33,23 @@ sealed class DeviceDeploymentStatus
/**
* A device deployment status which indicates the correct deployment has not been deployed yet.
*/
- interface NotDeployed
+ sealed class NotDeployed : DeviceDeploymentStatus()
{
- val requiresDeployment: Boolean
-
/**
* Determines whether the device and all dependent devices have been registered successfully and is ready for deployment.
*/
val isReadyForDeployment: Boolean
- get() = requiresDeployment && remainingDevicesToRegisterBeforeDeployment.isEmpty()
+ get() = canBeDeployed && remainingDevicesToRegisterBeforeDeployment.isEmpty()
/**
* The role names of devices which need to be registered before the deployment information for this device can be obtained.
*/
- val remainingDevicesToRegisterToObtainDeployment: Set
+ abstract val remainingDevicesToRegisterToObtainDeployment: Set
/**
* The role names of devices which need to be registered before this device can be declared as successfully deployed.
*/
- val remainingDevicesToRegisterBeforeDeployment: Set
+ abstract val remainingDevicesToRegisterBeforeDeployment: Set
}
@@ -61,10 +59,10 @@ sealed class DeviceDeploymentStatus
@Serializable
data class Unregistered(
override val device: AnyDeviceDescriptor,
- override val requiresDeployment: Boolean,
+ override val canBeDeployed: Boolean,
override val remainingDevicesToRegisterToObtainDeployment: Set,
override val remainingDevicesToRegisterBeforeDeployment: Set
- ) : DeviceDeploymentStatus(), NotDeployed
+ ) : NotDeployed()
/**
* Device deployment status for when a device has been registered.
@@ -72,10 +70,10 @@ sealed class DeviceDeploymentStatus
@Serializable
data class Registered(
override val device: AnyDeviceDescriptor,
- override val requiresDeployment: Boolean,
+ override val canBeDeployed: Boolean,
override val remainingDevicesToRegisterToObtainDeployment: Set,
override val remainingDevicesToRegisterBeforeDeployment: Set
- ) : DeviceDeploymentStatus(), NotDeployed
+ ) : NotDeployed()
/**
* Device deployment status when the device has retrieved its [MasterDeviceDeployment] and was able to load all the necessary plugins to execute the study.
@@ -85,8 +83,8 @@ sealed class DeviceDeploymentStatus
override val device: AnyDeviceDescriptor
) : DeviceDeploymentStatus()
{
- // All devices that can be deployed need to be deployed.
- override val requiresDeployment = true
+ // All devices that have been deployed necessarily can be deployed.
+ override val canBeDeployed = true
}
/**
@@ -97,9 +95,9 @@ sealed class DeviceDeploymentStatus
override val device: AnyDeviceDescriptor,
override val remainingDevicesToRegisterToObtainDeployment: Set,
override val remainingDevicesToRegisterBeforeDeployment: Set
- ) : DeviceDeploymentStatus(), NotDeployed
+ ) : NotDeployed()
{
- // Only devices that can be deployed ever need to be redeployed, and all those require deployment.
- override val requiresDeployment = true
+ // All devices that have been deployed necessarily can be deployed.
+ override val canBeDeployed = true
}
}
diff --git a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/MasterDeviceDeployment.kt b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/MasterDeviceDeployment.kt
index 14c4275c1..9b504344b 100644
--- a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/MasterDeviceDeployment.kt
+++ b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/MasterDeviceDeployment.kt
@@ -1,8 +1,11 @@
package dk.cachet.carp.deployments.application
+import dk.cachet.carp.common.application.data.DataType
import dk.cachet.carp.common.application.devices.AnyDeviceDescriptor
import dk.cachet.carp.common.application.devices.AnyMasterDeviceDescriptor
import dk.cachet.carp.common.application.devices.DeviceRegistration
+import dk.cachet.carp.common.application.sampling.DataTypeSamplingSchemeMap
+import dk.cachet.carp.common.application.sampling.SamplingConfiguration
import dk.cachet.carp.common.application.tasks.TaskDescriptor
import dk.cachet.carp.common.application.triggers.TaskControl
import dk.cachet.carp.common.application.triggers.Trigger
@@ -22,21 +25,21 @@ data class MasterDeviceDeployment(
*/
val deviceDescriptor: AnyMasterDeviceDescriptor,
/**
- * Configuration for this master device.
+ * Registrations for this master device.
*/
- val configuration: DeviceRegistration,
+ val registration: DeviceRegistration,
/**
* The devices this device needs to connect to.
*/
val connectedDevices: Set = emptySet(),
/**
- * Preregistration of connected devices, including configuration such as connection properties, stored per role name.
+ * Preregistration of connected devices, including information such as connection properties, stored per role name.
*/
- val connectedDeviceConfigurations: Map = emptyMap(),
+ val connectedDeviceRegistrations: Map = emptyMap(),
/**
* All tasks which should be able to be executed on this or connected devices.
*/
- val tasks: Set = emptySet(),
+ val tasks: Set> = emptySet(),
/**
* All triggers originating from this device and connected devices, stored per assigned id unique within the study protocol.
*/
@@ -56,20 +59,27 @@ data class MasterDeviceDeployment(
)
{
/**
- * A participating master or connected device in a deployment (determined by [isConnectedDevice])
- * with a matching [registration] in case the device has been registered.
+ * Runtime info of a master device or connected device (determined by [isConnectedDevice]) in a study deployment.
*/
- data class Device(
+ data class RuntimeDeviceInfo(
val descriptor: AnyDeviceDescriptor,
val isConnectedDevice: Boolean,
- val registration: DeviceRegistration?
+ /**
+ * The matching device [registration] for device [descriptor] in case it has been registered; null otherwise.
+ */
+ val registration: DeviceRegistration?,
+ /**
+ * The sampling configuration per data type to use for device when no custom sampling configuration is provided
+ * by an ongoing measure.
+ */
+ val defaultSamplingConfiguration: Map,
+ /**
+ * The set of tasks which may be sent to this device over the course of the deployment,
+ * or an empty set in case there are none.
+ */
+ val tasks: Set>
)
- /**
- * The set of [tasks] which may need to be executed on a master [device], or a connected [device], during a deployment.
- */
- data class DeviceTasks( val device: Device, val tasks: Set )
-
/**
* The time when this device deployment was last updated.
@@ -79,28 +89,49 @@ data class MasterDeviceDeployment(
// TODO: Remove this workaround once JS serialization bug is fixed:
// https://github.com/Kotlin/kotlinx.serialization/issues/716
@Suppress( "SENSELESS_COMPARISON" )
- if ( connectedDeviceConfigurations == null || configuration == null ) Clock.System.now()
- else connectedDeviceConfigurations.values.plus( configuration )
+ if ( connectedDeviceRegistrations == null || registration == null ) Clock.System.now()
+ else connectedDeviceRegistrations.values.plus( registration )
.maxOf { it.registrationCreatedOn }
/**
- * Get master device and each of the devices this device needs to connect to and their current [DeviceRegistration].
+ * Get info on the master device and each of the devices this device needs to connect to relevant at study runtime.
*/
- fun getAllDevicesAndRegistrations(): List =
- connectedDevices.map { Device( it, true, connectedDeviceConfigurations[ it.roleName ] ) }
- .plus( Device( deviceDescriptor, false, configuration ) ) // Add master device registration.
+ fun getRuntimeDeviceInfo(): List =
+ connectedDevices.map {
+ RuntimeDeviceInfo(
+ it,
+ isConnectedDevice = true,
+ connectedDeviceRegistrations[ it.roleName ],
+ getDefaultSamplingConfigurations( it ),
+ getDeviceTasks( it )
+ )
+ }
+ .plus(
+ // Master device runtime info.
+ RuntimeDeviceInfo(
+ deviceDescriptor,
+ isConnectedDevice = false,
+ registration,
+ getDefaultSamplingConfigurations( deviceDescriptor ),
+ getDeviceTasks( deviceDescriptor )
+ )
+ )
- /**
- * Retrieves for this master device and all connected devices
- * the set of tasks which may be sent to them over the course of the deployment, or an empty set in case there are none.
- * Tasks which target other master devices are not included in this collection.
- */
- fun getTasksPerDevice(): List = getAllDevicesAndRegistrations()
- .map { device ->
- val tasks = taskControls
- .filter { it.destinationDeviceRoleName == device.descriptor.roleName }
- .map { triggered -> tasks.first { it.name == triggered.taskName } }
- DeviceTasks( device, tasks.toSet() )
+ private fun getDefaultSamplingConfigurations( device: AnyDeviceDescriptor ): Map
+ {
+ val samplingSchemes: DataTypeSamplingSchemeMap = device.getDataTypeSamplingSchemes()
+
+ // Include configurations for unexpected data types in `defaultSamplingConfiguration` for which no scheme exists.
+ val dataTypes: Set = samplingSchemes.keys + device.defaultSamplingConfiguration.keys
+
+ return dataTypes.associateWith { dataType ->
+ device.defaultSamplingConfiguration[ dataType ] ?: samplingSchemes[ dataType ]!!.default
}
+ }
+
+ private fun getDeviceTasks( device: AnyDeviceDescriptor ): Set> = taskControls
+ .filter { it.destinationDeviceRoleName == device.roleName }
+ .map { triggered -> tasks.first { it.name == triggered.taskName } }
+ .toSet()
}
diff --git a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/StudyDeploymentStatus.kt b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/StudyDeploymentStatus.kt
index 61711d9c6..fc8281899 100644
--- a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/StudyDeploymentStatus.kt
+++ b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/StudyDeploymentStatus.kt
@@ -3,6 +3,7 @@ package dk.cachet.carp.deployments.application
import dk.cachet.carp.common.application.UUID
import dk.cachet.carp.common.application.devices.AnyDeviceDescriptor
import dk.cachet.carp.common.application.devices.AnyMasterDeviceDescriptor
+import dk.cachet.carp.deployments.application.users.ParticipantStatus
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@@ -13,12 +14,22 @@ import kotlinx.serialization.Serializable
@Serializable
sealed class StudyDeploymentStatus
{
+ /**
+ * The time when the deployment was created.
+ */
+ abstract val createdOn: Instant
+
abstract val studyDeploymentId: UUID
/**
* The list of all devices part of this study deployment and their status.
*/
abstract val devicesStatus: List
+ /**
+ * The list of all participants and their status in this study deployment.
+ */
+ abstract val participantsStatus: List
+
/**
* The time when the study deployment was ready for the first time (all devices deployed); null otherwise.
*/
@@ -30,8 +41,10 @@ sealed class StudyDeploymentStatus
*/
@Serializable
data class Invited(
+ override val createdOn: Instant,
override val studyDeploymentId: UUID,
override val devicesStatus: List,
+ override val participantsStatus: List,
override val startedOn: Instant?
) : StudyDeploymentStatus()
@@ -40,19 +53,24 @@ sealed class StudyDeploymentStatus
*/
@Serializable
data class DeployingDevices(
+ override val createdOn: Instant,
override val studyDeploymentId: UUID,
override val devicesStatus: List,
+ override val participantsStatus: List,
override val startedOn: Instant?
) : StudyDeploymentStatus()
/**
- * All master devices have been successfully deployed.
+ * All master devices have been successfully deployed and data collection has started
+ * on the time specified by [startedOn].
*/
@Serializable
- data class DeploymentReady(
+ data class Running(
+ override val createdOn: Instant,
override val studyDeploymentId: UUID,
override val devicesStatus: List,
- override val startedOn: Instant?
+ override val participantsStatus: List,
+ override val startedOn: Instant
) : StudyDeploymentStatus()
/**
@@ -60,9 +78,12 @@ sealed class StudyDeploymentStatus
*/
@Serializable
data class Stopped(
+ override val createdOn: Instant,
override val studyDeploymentId: UUID,
override val devicesStatus: List,
- override val startedOn: Instant?
+ override val participantsStatus: List,
+ override val startedOn: Instant?,
+ val stoppedOn: Instant
) : StudyDeploymentStatus()
diff --git a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/Validation.kt b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/Validation.kt
index 8b0ea538f..cdf586b0f 100644
--- a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/Validation.kt
+++ b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/Validation.kt
@@ -6,30 +6,38 @@ import dk.cachet.carp.protocols.application.StudyProtocolSnapshot
/**
- * Throw [IllegalArgumentException] when [invitations] or [connectedDevicePreregistrations]
- * do not match the requirements of the protocol.
+ * Throw [IllegalArgumentException] when [invitations] don't match the requirements of the protocol.
*
* @throws IllegalArgumentException when:
* - [invitations] is empty
* - any of the assigned device roles in [invitations] is not part of the study protocol
- * - not all master devices part of the study protocol have been assigned a participant
+ * - not all necessary master devices part of the study protocol have been assigned a participant
*/
-fun StudyProtocolSnapshot.throwIfInvalid(
- invitations: List,
- connectedDevicePreregistrations: Map = emptyMap()
-)
+fun StudyProtocolSnapshot.throwIfInvalidInvitations( invitations: List )
{
require( invitations.isNotEmpty() ) { "No participants invited." }
- val masterDevices = this.masterDevices.map { it.roleName }
- val assignedMasterDevices = invitations.flatMap { it.assignedMasterDeviceRoleNames }.toSet()
- assignedMasterDevices.forEach {
- require( it in masterDevices )
- { "The assigned master device with role name \"$it\" is not part of the study protocol." }
+ val assignedMasterDeviceRoleNames = invitations.flatMap { it.assignedMasterDeviceRoleNames }.toSet()
+ assignedMasterDeviceRoleNames.forEach { assigned ->
+ require( assigned in masterDevices.map { it.roleName } )
+ { "The assigned master device with role name \"$assigned\" is not part of the study protocol." }
}
- require( assignedMasterDevices.containsAll( masterDevices ) )
- { "Not all devices required for this study have been assigned to a participant." }
+ val requiredMasterDeviceRoleNames = masterDevices.filter { !it.isOptional }.map { it.roleName }
+ require( assignedMasterDeviceRoleNames.containsAll( requiredMasterDeviceRoleNames ) )
+ { "Not all necessary devices required for this study have been assigned to a participant." }
+}
+/**
+ * Throw [IllegalArgumentException] when [connectedDevicePreregistrations] don't match the requirements of the protocol.
+ *
+ * @throws IllegalArgumentException when:
+ * - one of the role names in [connectedDevicePreregistrations] isn't defined in the study protocol
+ * - an invalid registration for one of the devices is passed
+ */
+fun StudyProtocolSnapshot.throwIfInvalidPreregistrations(
+ connectedDevicePreregistrations: Map
+)
+{
connectedDevicePreregistrations.forEach { (roleName, registration) ->
val connectedDevice = connectedDevices.firstOrNull { it.roleName == roleName }
requireNotNull( connectedDevice )
diff --git a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/users/ParticipantStatus.kt b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/users/ParticipantStatus.kt
new file mode 100644
index 000000000..20a30ca67
--- /dev/null
+++ b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/users/ParticipantStatus.kt
@@ -0,0 +1,11 @@
+package dk.cachet.carp.deployments.application.users
+
+import dk.cachet.carp.common.application.UUID
+import kotlinx.serialization.Serializable
+
+
+/**
+ * Provides information on the status of a participant in a study deployment.
+ */
+@Serializable
+data class ParticipantStatus( val participantId: UUID, val assignedMasterDeviceRoleNames: Set )
diff --git a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/RegistrableDevice.kt b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/RegistrableDevice.kt
index fcf61c818..ed844029e 100644
--- a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/RegistrableDevice.kt
+++ b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/RegistrableDevice.kt
@@ -1,6 +1,7 @@
package dk.cachet.carp.deployments.domain
import dk.cachet.carp.common.application.devices.AnyDeviceDescriptor
+import dk.cachet.carp.deployments.application.MasterDeviceDeployment
import kotlinx.serialization.Serializable
@@ -14,7 +15,12 @@ data class RegistrableDevice(
*/
val device: AnyDeviceDescriptor,
/**
- * Determines whether this device requires deployment after it has been registered.
+ * Determines whether the device can be deployed by retrieving [MasterDeviceDeployment].
+ * Not all master devices necessarily need deployment; chained master devices do not.
+ */
+ val canBeDeployed: Boolean,
+ /**
+ * Determines whether this device can be deployed and requires deployment in order to start the study.
*/
val requiresDeployment: Boolean
)
diff --git a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/StudyDeployment.kt b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/StudyDeployment.kt
index afd228094..c2a574106 100644
--- a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/StudyDeployment.kt
+++ b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/StudyDeployment.kt
@@ -8,12 +8,14 @@ import dk.cachet.carp.common.application.tasks.getAllExpectedDataTypes
import dk.cachet.carp.common.application.triggers.TaskControl
import dk.cachet.carp.common.domain.AggregateRoot
import dk.cachet.carp.common.domain.DomainEvent
-import dk.cachet.carp.common.infrastructure.serialization.CustomTaskDescriptor
import dk.cachet.carp.common.infrastructure.serialization.UnknownPolymorphicWrapper
import dk.cachet.carp.data.application.DataStreamsConfiguration
import dk.cachet.carp.deployments.application.DeviceDeploymentStatus
import dk.cachet.carp.deployments.application.MasterDeviceDeployment
import dk.cachet.carp.deployments.application.StudyDeploymentStatus
+import dk.cachet.carp.deployments.application.throwIfInvalidInvitations
+import dk.cachet.carp.deployments.application.users.ParticipantInvitation
+import dk.cachet.carp.deployments.application.users.ParticipantStatus
import dk.cachet.carp.protocols.application.StudyProtocolSnapshot
import dk.cachet.carp.protocols.domain.StudyProtocol
import kotlinx.datetime.Clock
@@ -26,8 +28,11 @@ import kotlinx.datetime.Instant
* I.e., a [StudyDeployment] is responsible for registering the physical devices described in the [StudyProtocol],
* enabling a connection between them, tracking device connection issues, and assessing data quality.
*/
-class StudyDeployment( val protocolSnapshot: StudyProtocolSnapshot, val id: UUID = UUID.randomUUID() ) :
- AggregateRoot()
+class StudyDeployment private constructor(
+ val protocolSnapshot: StudyProtocolSnapshot,
+ val participants: List,
+ val id: UUID = UUID.randomUUID()
+) : AggregateRoot()
{
sealed class Event : DomainEvent()
{
@@ -36,15 +41,37 @@ class StudyDeployment( val protocolSnapshot: StudyProtocolSnapshot, val id: UUID
data class DeviceDeployed( val device: AnyMasterDeviceDescriptor ) : Event()
data class Started( val startedOn: Instant ) : Event()
data class DeploymentInvalidated( val device: AnyMasterDeviceDescriptor ) : Event()
- object Stopped : Event()
+ data class Stopped( val stoppedOn: Instant ) : Event()
}
companion object Factory
{
+ /**
+ * Initialize a deployment for a [protocolSnapshot] for the participants invited as defined by [invitations].
+ *
+ * @throws IllegalArgumentException if [invitations] don't match the requirements of the protocol.
+ */
+ fun fromInvitations(
+ protocolSnapshot: StudyProtocolSnapshot,
+ invitations: List,
+ id: UUID = UUID.randomUUID()
+ ): StudyDeployment
+ {
+ protocolSnapshot.throwIfInvalidInvitations( invitations )
+ val participants = invitations.map {
+ ParticipantStatus( it.participantId, it.assignedMasterDeviceRoleNames )
+ }
+
+ return StudyDeployment( protocolSnapshot, participants, id )
+ }
+
fun fromSnapshot( snapshot: StudyDeploymentSnapshot ): StudyDeployment
{
- val deployment = StudyDeployment( snapshot.studyProtocolSnapshot, snapshot.studyDeploymentId )
+ val deployment = StudyDeployment(
+ snapshot.studyProtocolSnapshot,
+ snapshot.participants.toList(),
+ snapshot.studyDeploymentId )
deployment.createdOn = snapshot.createdOn
deployment.startedOn = snapshot.startedOn
@@ -109,7 +136,6 @@ class StudyDeployment( val protocolSnapshot: StudyProtocolSnapshot, val id: UUID
id,
protocol.devices.flatMap { device ->
protocol.getTasksForDevice( device )
- .filter { it !is CustomTaskDescriptor } // Can't retrieve expected data types for unknown tasks.
.flatMap { it.getAllExpectedDataTypes() }
.map { DataStreamsConfiguration.ExpectedDataStream( device.roleName, it ) }
}.toSet()
@@ -156,25 +182,35 @@ class StudyDeployment( val protocolSnapshot: StudyProtocolSnapshot, val id: UUID
private val _invalidatedDeployedDevices: MutableSet = mutableSetOf()
/**
- * The time when the study deployment was ready for the first time (all devices deployed); null otherwise.
+ * The time when the study deployment was ready for the first time (all necessary devices deployed);
+ * null if the study deployment hasn't started yet.
*/
var startedOn: Instant? = null
private set
+ /**
+ * The time when the study deployment was stopped; null if [isStopped] is false.
+ */
+ var stoppedOn: Instant? = null
+
/**
* Determines whether the study deployment has been stopped and no further modifications are allowed.
*/
- var isStopped: Boolean = false
- private set
+ val isStopped: Boolean
+ get() = stoppedOn != null
init
{
require( protocol.isDeployable() ) { "The passed protocol snapshot contains deployment errors." }
- // Initialize information which devices can or should be registered for this deployment.
+ // Initialize information which devices can be registered, deployed, and should be deployed for this deployment.
_registrableDevices = protocol.devices
- // Top-level master devices require deployment.
- .map { RegistrableDevice( it, it in protocol.masterDevices ) }
+ // Top-level master devices that aren't optional require deployment.
+ .map {
+ val canBeDeployed = it in protocol.masterDevices
+ val requiresDeployment = canBeDeployed && !it.isOptional
+ RegistrableDevice( it, canBeDeployed, requiresDeployment )
+ }
.toMutableSet()
}
@@ -184,17 +220,22 @@ class StudyDeployment( val protocolSnapshot: StudyProtocolSnapshot, val id: UUID
*/
fun getStatus(): StudyDeploymentStatus
{
- val devicesStatus: List = _registrableDevices.map { getDeviceStatus( it.device ) }
- val allRequiredDevicesDeployed: Boolean = devicesStatus
- .filter { it.requiresDeployment }
- .all { it is DeviceDeploymentStatus.Deployed }
+ val devices: Map =
+ _registrableDevices.associateWith { getDeviceStatus( it.device ) }
+ val participantsStatus = participants.toList()
+ val allRequiredDevicesDeployed: Boolean = devices
+ .filter { it.key.requiresDeployment }
+ .all { it.value is DeviceDeploymentStatus.Deployed } &&
+ // At least one device needs to be deployed.
+ devices.any { it.value is DeviceDeploymentStatus.Deployed }
val anyRegistration: Boolean = deviceRegistrationHistory.any()
+ val devicesStatus = devices.values.toList()
return when {
- isStopped -> StudyDeploymentStatus.Stopped( id, devicesStatus, startedOn )
- allRequiredDevicesDeployed -> StudyDeploymentStatus.DeploymentReady( id, devicesStatus, startedOn )
- anyRegistration -> StudyDeploymentStatus.DeployingDevices( id, devicesStatus, startedOn )
- else -> StudyDeploymentStatus.Invited( id, devicesStatus, startedOn )
+ isStopped -> StudyDeploymentStatus.Stopped( createdOn, id, devicesStatus, participantsStatus, startedOn, stoppedOn!! )
+ allRequiredDevicesDeployed -> StudyDeploymentStatus.Running( createdOn, id, devicesStatus, participantsStatus, startedOn!! )
+ anyRegistration -> StudyDeploymentStatus.DeployingDevices( createdOn, id, devicesStatus, participantsStatus, startedOn )
+ else -> StudyDeploymentStatus.Invited( createdOn, id, devicesStatus, participantsStatus, startedOn )
}
}
@@ -206,28 +247,29 @@ class StudyDeployment( val protocolSnapshot: StudyProtocolSnapshot, val id: UUID
val needsRedeployment = device in invalidatedDeployedDevices
val isDeployed = device in deployedDevices
val isRegistered = device in _registeredDevices
- val requiresDeployment = registrableDevices.first{ it.device == device }.requiresDeployment
+ val canBeDeployed = registrableDevices.first{ it.device == device }.canBeDeployed
- val alreadyRegistered = registeredDevices.keys.map { r -> r.roleName }
- val dependentDevices = getDependentDevices( device ).map { d -> d.roleName }
- val toRegisterToObtainDeployment = dependentDevices
- .plus( device.roleName ) // Device itself needs to be registered.
+ val alreadyRegistered = registeredDevices.keys
+ val mandatoryDependentDevices = getDependentDevices( device ).filter { !it.isOptional }
+ val toRegisterToObtainDeployment = mandatoryDependentDevices
+ .plus( device ) // Device itself needs to be registered.
.minus( alreadyRegistered )
- .toSet()
+ val mandatoryConnectedDevices =
+ if ( device is AnyMasterDeviceDescriptor ) protocol.getConnectedDevices( device ).filter { !it.isOptional }
+ else emptyList()
val toRegisterBeforeDeployment = toRegisterToObtainDeployment
- // Master devices require all connected devices to be registered.
- .plus(
- if ( device is AnyMasterDeviceDescriptor ) protocol.getConnectedDevices( device ).map { c -> c.roleName }
- else emptyList() )
+ // Master devices require non-optional connected devices to be registered.
+ .plus( mandatoryConnectedDevices )
.minus( alreadyRegistered )
- .toSet()
+ val toObtainDeployment = toRegisterToObtainDeployment.map { it.roleName }.toSet()
+ val beforeDeployment = toRegisterBeforeDeployment.map { it.roleName }.toSet()
return when
{
- needsRedeployment -> DeviceDeploymentStatus.NeedsRedeployment( device, toRegisterToObtainDeployment, toRegisterBeforeDeployment )
+ needsRedeployment -> DeviceDeploymentStatus.NeedsRedeployment( device, toObtainDeployment, beforeDeployment )
isDeployed -> DeviceDeploymentStatus.Deployed( device )
- isRegistered -> DeviceDeploymentStatus.Registered( device, requiresDeployment, toRegisterToObtainDeployment, toRegisterBeforeDeployment )
- else -> DeviceDeploymentStatus.Unregistered( device, requiresDeployment, toRegisterToObtainDeployment, toRegisterBeforeDeployment )
+ isRegistered -> DeviceDeploymentStatus.Registered( device, canBeDeployed, toObtainDeployment, beforeDeployment )
+ else -> DeviceDeploymentStatus.Unregistered( device, canBeDeployed, toObtainDeployment, beforeDeployment )
}
}
@@ -284,6 +326,8 @@ class StudyDeployment( val protocolSnapshot: StudyProtocolSnapshot, val id: UUID
val registrationHistory = _deviceRegistrationHistory.getOrPut( device ) { mutableListOf() }
registrationHistory.add( registration )
event( Event.DeviceRegistered( device, registration ) )
+
+ invalidateDeploymentOfDependentDevices( device )
}
/**
@@ -306,7 +350,14 @@ class StudyDeployment( val protocolSnapshot: StudyProtocolSnapshot, val id: UUID
event( Event.DeviceUnregistered( device ) )
- // Invalidate deployed master devices which depend on this device that are deployed.
+ invalidateDeploymentOfDependentDevices( device )
+ }
+
+ /**
+ * Invalidate deployed master devices which depend on this [device].
+ */
+ private fun invalidateDeploymentOfDependentDevices( device: AnyDeviceDescriptor )
+ {
val dependentMasterDevices = getDependentDevices( device )
.filterIsInstance()
dependentMasterDevices.forEach {
@@ -401,8 +452,12 @@ class StudyDeployment( val protocolSnapshot: StudyProtocolSnapshot, val id: UUID
.add( device )
.eventIf( true ) { Event.DeviceDeployed( device ) }
- // Set start time first time deployment is ready (last device deployed).
- if ( startedOn == null && getStatus() is StudyDeploymentStatus.DeploymentReady )
+ // Set start time when deployment starts running (all necessary devices deployed).
+ val allRequiredDeviceDeployed = _registrableDevices
+ .filter { it.requiresDeployment }
+ .map { getDeviceStatus( it.device ) }
+ .all { it is DeviceDeploymentStatus.Deployed }
+ if ( startedOn == null && allRequiredDeviceDeployed )
{
val now = Clock.System.now()
startedOn = now
@@ -418,8 +473,9 @@ class StudyDeployment( val protocolSnapshot: StudyProtocolSnapshot, val id: UUID
{
if ( !isStopped )
{
- isStopped = true
- event( Event.Stopped )
+ val now = Clock.System.now()
+ stoppedOn = now
+ event( Event.Stopped( now ) )
}
}
diff --git a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/StudyDeploymentSnapshot.kt b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/StudyDeploymentSnapshot.kt
index a6fad21b5..24e9f926c 100644
--- a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/StudyDeploymentSnapshot.kt
+++ b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/StudyDeploymentSnapshot.kt
@@ -3,6 +3,7 @@ package dk.cachet.carp.deployments.domain
import dk.cachet.carp.common.application.UUID
import dk.cachet.carp.common.application.devices.DeviceRegistration
import dk.cachet.carp.common.domain.Snapshot
+import dk.cachet.carp.deployments.application.users.ParticipantStatus
import dk.cachet.carp.protocols.application.StudyProtocolSnapshot
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
@@ -16,6 +17,7 @@ data class StudyDeploymentSnapshot(
val studyDeploymentId: UUID,
override val createdOn: Instant,
val studyProtocolSnapshot: StudyProtocolSnapshot,
+ val participants: List,
val registeredDevices: Set = emptySet(),
val deviceRegistrationHistory: Map> = emptyMap(),
val deployedDevices: Set = emptySet(),
@@ -37,6 +39,7 @@ data class StudyDeploymentSnapshot(
studyDeployment.id,
studyDeployment.createdOn,
studyDeployment.protocolSnapshot,
+ studyDeployment.participants.toList(),
studyDeployment.registeredDevices.map { it.key.roleName }.toSet(),
studyDeployment.deviceRegistrationHistory.mapKeys { it.key.roleName },
studyDeployment.deployedDevices.map { it.roleName }.toSet(),
diff --git a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/users/ParticipantGroupService.kt b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/users/ParticipantGroupService.kt
index 0b7570b2f..9600516fb 100644
--- a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/users/ParticipantGroupService.kt
+++ b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/domain/users/ParticipantGroupService.kt
@@ -2,7 +2,8 @@ package dk.cachet.carp.deployments.domain.users
import dk.cachet.carp.deployments.application.DeploymentService
import dk.cachet.carp.deployments.application.users.Participation
-import dk.cachet.carp.deployments.application.throwIfInvalid
+import dk.cachet.carp.deployments.application.throwIfInvalidInvitations
+import dk.cachet.carp.deployments.application.throwIfInvalidPreregistrations
class ParticipantGroupService( val accountService: AccountService )
@@ -17,7 +18,8 @@ class ParticipantGroupService( val accountService: AccountService )
// Verify whether the participant group matches the requirements of the protocol.
val studyDeploymentId = createdDeployment.studyDeploymentId
val invitations = createdDeployment.invitations
- createdDeployment.protocol.throwIfInvalid( invitations, createdDeployment.connectedDevicePreregistrations )
+ createdDeployment.protocol.throwIfInvalidInvitations( invitations )
+ createdDeployment.protocol.throwIfInvalidPreregistrations( createdDeployment.connectedDevicePreregistrations )
// Create group.
val protocol = createdDeployment.protocol.toObject()
diff --git a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/infrastructure/DeploymentServiceRequest.kt b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/infrastructure/DeploymentServiceRequest.kt
index e5b7d8649..7971ca940 100644
--- a/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/infrastructure/DeploymentServiceRequest.kt
+++ b/carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/infrastructure/DeploymentServiceRequest.kt
@@ -65,12 +65,12 @@ sealed class DeploymentServiceRequest
Invoker by createServiceInvoker( Service::getDeviceDeploymentFor, studyDeploymentId, masterDeviceRoleName )
@Serializable
- data class DeploymentSuccessful(
+ data class DeviceDeployed(
val studyDeploymentId: UUID,
val masterDeviceRoleName: String,
val deviceDeploymentLastUpdatedOn: Instant
) : DeploymentServiceRequest(),
- Invoker by createServiceInvoker( Service::deploymentSuccessful, studyDeploymentId, masterDeviceRoleName, deviceDeploymentLastUpdatedOn )
+ Invoker by createServiceInvoker( Service::deviceDeployed, studyDeploymentId, masterDeviceRoleName, deviceDeploymentLastUpdatedOn )
@Serializable
data class Stop( val studyDeploymentId: UUID ) :
diff --git a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/DeploymentCodeSamples.kt b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/DeploymentCodeSamples.kt
index 3a938ad94..34dd8aedc 100644
--- a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/DeploymentCodeSamples.kt
+++ b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/DeploymentCodeSamples.kt
@@ -58,12 +58,12 @@ class DeploymentCodeSamples
val deploymentInformation: MasterDeviceDeployment =
deploymentService.getDeviceDeploymentFor( studyDeploymentId, patientPhone.roleName )
val deployedOn: Instant = deploymentInformation.lastUpdatedOn // To verify correct deployment.
- deploymentService.deploymentSuccessful( studyDeploymentId, patientPhone.roleName, deployedOn )
+ deploymentService.deviceDeployed( studyDeploymentId, patientPhone.roleName, deployedOn )
}
- // Now that all devices have been registered and deployed, the deployment is ready.
+ // Now that all devices have been registered and deployed, the deployment is running.
status = deploymentService.getStudyDeploymentStatus( studyDeploymentId )
- val isReady = status is StudyDeploymentStatus.DeploymentReady // True.
+ val isReady = status is StudyDeploymentStatus.Running // True.
}
diff --git a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/DeploymentServiceMock.kt b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/DeploymentServiceMock.kt
index bc380e165..a4ee5ddef 100644
--- a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/DeploymentServiceMock.kt
+++ b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/DeploymentServiceMock.kt
@@ -9,6 +9,7 @@ import dk.cachet.carp.common.infrastructure.test.StubMasterDeviceDescriptor
import dk.cachet.carp.deployments.application.users.ParticipantInvitation
import dk.cachet.carp.protocols.application.StudyProtocolSnapshot
import dk.cachet.carp.test.Mock
+import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
private typealias Service = DeploymentService
@@ -27,9 +28,8 @@ class DeploymentServiceMock(
{
companion object
{
- private val emptyStatus: StudyDeploymentStatus = StudyDeploymentStatus.DeployingDevices(
- UUID( "00000000-0000-0000-0000-000000000000"),
- listOf(), null )
+ private val emptyStatus: StudyDeploymentStatus =
+ StudyDeploymentStatus.DeployingDevices( Clock.System.now(), UUID.randomUUID(), emptyList(), emptyList(), null )
private val emptyMasterDeviceDeployment: MasterDeviceDeployment = MasterDeviceDeployment(
StubMasterDeviceDescriptor(),
DefaultDeviceRegistration( "Test" ) )
@@ -64,9 +64,9 @@ class DeploymentServiceMock(
getDeviceDeploymentForResult
.also { trackSuspendCall( Service::getDeviceDeploymentFor, studyDeploymentId, masterDeviceRoleName ) }
- override suspend fun deploymentSuccessful( studyDeploymentId: UUID, masterDeviceRoleName: String, deviceDeploymentLastUpdatedOn: Instant ) =
+ override suspend fun deviceDeployed( studyDeploymentId: UUID, masterDeviceRoleName: String, deviceDeploymentLastUpdatedOn: Instant ) =
deploymentSuccessfulResult
- .also { trackSuspendCall( Service::deploymentSuccessful, studyDeploymentId, masterDeviceRoleName, deviceDeploymentLastUpdatedOn ) }
+ .also { trackSuspendCall( Service::deviceDeployed, studyDeploymentId, masterDeviceRoleName, deviceDeploymentLastUpdatedOn ) }
override suspend fun stop( studyDeploymentId: UUID ) =
stopResult
diff --git a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/DeploymentServiceTest.kt b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/DeploymentServiceTest.kt
index 9d31dbd65..fb9f7f46e 100644
--- a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/DeploymentServiceTest.kt
+++ b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/DeploymentServiceTest.kt
@@ -43,7 +43,7 @@ abstract class DeploymentServiceTest
deploymentService.registerDevice( deploymentId, masterDevice.roleName, masterDevice.createRegistration() )
val deployment = deploymentService.getDeviceDeploymentFor( deploymentId, masterDevice.roleName )
- assertEquals( preregistration, deployment.connectedDeviceConfigurations[ connectedDevice.roleName ] )
+ assertEquals( preregistration, deployment.connectedDeviceRegistrations[ connectedDevice.roleName ] )
}
@Test
@@ -206,7 +206,7 @@ abstract class DeploymentServiceTest
{ deploymentService.unregisterDevice( studyDeploymentId, master.roleName ) }
val deviceDeployment = deploymentService.getDeviceDeploymentFor( studyDeploymentId, master.roleName )
assertFailsWith
- { deploymentService.deploymentSuccessful( studyDeploymentId, master.roleName, deviceDeployment.lastUpdatedOn ) }
+ { deploymentService.deviceDeployed( studyDeploymentId, master.roleName, deviceDeployment.lastUpdatedOn ) }
}
diff --git a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/HostsIntegrationTest.kt b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/HostsIntegrationTest.kt
index ac435d1a9..0d6ee7e71 100644
--- a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/HostsIntegrationTest.kt
+++ b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/HostsIntegrationTest.kt
@@ -229,7 +229,7 @@ class HostsIntegrationTest
// Data can now be appended to data streams once the deployment is "ready".
deploymentService.registerDevice( deploymentId, masterDevice.roleName, masterDevice.createRegistration() )
val deviceDeployment = deploymentService.getDeviceDeploymentFor( deploymentId, masterDevice.roleName )
- deploymentService.deploymentSuccessful( deploymentId, masterDevice.roleName, deviceDeployment.lastUpdatedOn )
+ deploymentService.deviceDeployed( deploymentId, masterDevice.roleName, deviceDeployment.lastUpdatedOn )
dataStreamService.appendToDataStreams( deploymentId, toAppend )
// Data can no longer be appended after a deployment is stopped.
diff --git a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/MasterDeviceDeploymentTest.kt b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/MasterDeviceDeploymentTest.kt
index 760ba5ca6..124b78fc3 100644
--- a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/MasterDeviceDeploymentTest.kt
+++ b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/MasterDeviceDeploymentTest.kt
@@ -1,5 +1,11 @@
package dk.cachet.carp.deployments.application
+import dk.cachet.carp.common.application.data.DataType
+import dk.cachet.carp.common.application.devices.Smartphone
+import dk.cachet.carp.common.application.sampling.BatteryAwareSamplingConfiguration
+import dk.cachet.carp.common.application.sampling.Granularity
+import dk.cachet.carp.common.application.sampling.GranularitySamplingConfiguration
+import dk.cachet.carp.common.application.sampling.NoOptionsSamplingConfiguration
import dk.cachet.carp.common.application.triggers.TaskControl
import dk.cachet.carp.common.infrastructure.test.StubDeviceDescriptor
import dk.cachet.carp.common.infrastructure.test.StubMasterDeviceDescriptor
@@ -14,7 +20,7 @@ import kotlin.test.*
class MasterDeviceDeploymentTest
{
@Test
- fun getAllDevicesAndRegistrations_succeeds()
+ fun getRuntimeDeviceInfo_contains_all_devices()
{
val master = StubMasterDeviceDescriptor( "Master" )
val registration = master.createRegistration()
@@ -23,20 +29,65 @@ class MasterDeviceDeploymentTest
// Deployment with registered master device and unregistered connected device.
val deployment = MasterDeviceDeployment( master, registration, setOf( connected ) )
- val devices = deployment.getAllDevicesAndRegistrations()
+ val devices = deployment.getRuntimeDeviceInfo()
assertEquals( 2, devices.size )
+ assertEquals( 1, devices.count { it.descriptor == master && !it.isConnectedDevice } )
+ assertEquals( 1, devices.count { it.descriptor == connected && it.isConnectedDevice } )
+ }
+
+ @Test
+ fun getRuntimeDeviceInfo_contains_default_sampling_configuration()
+ {
+ val master = StubMasterDeviceDescriptor( "Master" )
+ val registration = master.createRegistration()
+ val deployment = MasterDeviceDeployment( master, registration )
+
+ val deviceInfo = deployment.getRuntimeDeviceInfo()
+ .first { it.descriptor == master }
assertEquals(
- MasterDeviceDeployment.Device( master, false, registration ),
- devices.firstOrNull { it.descriptor == master }
+ StubMasterDeviceDescriptor.Sensors.map { it.key to it.value.default }.toMap(),
+ deviceInfo.defaultSamplingConfiguration
)
+ }
+
+ @Test
+ fun getRuntimeDeviceInfo_contains_overridden_sampling_configuration()
+ {
+ val typeMetaData = Smartphone.Sensors.GEOLOCATION
+ val dataType = typeMetaData.dataType.type
+ val configurationOverride = BatteryAwareSamplingConfiguration(
+ GranularitySamplingConfiguration( Granularity.Coarse ),
+ GranularitySamplingConfiguration( Granularity.Coarse ),
+ GranularitySamplingConfiguration( Granularity.Coarse )
+ )
+ val device = Smartphone( "Irrelevant", false, mapOf( dataType to configurationOverride ) )
+ val registration = device.createRegistration()
+ val deployment = MasterDeviceDeployment( device, registration )
+
+ val deviceInfo = deployment.getRuntimeDeviceInfo()
+ .first { it.descriptor == device }
assertEquals(
- MasterDeviceDeployment.Device( connected, true, null ),
- devices.firstOrNull { it.descriptor == connected }
+ configurationOverride,
+ deviceInfo.defaultSamplingConfiguration[ dataType ]
)
}
@Test
- fun getTasksPerDevice_succeeds()
+ fun getRuntimeDeviceInfo_contains_unexpected_data_type_sampling_configurations()
+ {
+ val unexpectedType = DataType( "something", "unexpected" )
+ val unexpectedTypeConfiguration = NoOptionsSamplingConfiguration
+ val master = StubMasterDeviceDescriptor( "Master", false, mapOf( unexpectedType to unexpectedTypeConfiguration ) )
+ val registration = master.createRegistration()
+ val deployment = MasterDeviceDeployment( master, registration )
+
+ val deviceInfo = deployment.getRuntimeDeviceInfo()
+ .first { it.descriptor == master }
+ assertEquals( unexpectedTypeConfiguration, deviceInfo.defaultSamplingConfiguration[ unexpectedType ] )
+ }
+
+ @Test
+ fun getRuntimeDeviceInfo_contains_all_tasks()
{
val device = StubMasterDeviceDescriptor( "Master" )
val registration = device.createRegistration()
@@ -48,9 +99,9 @@ class MasterDeviceDeploymentTest
val deployment = MasterDeviceDeployment(
deviceDescriptor = device,
- configuration = registration,
+ registration = registration,
connectedDevices = setOf( connected ),
- connectedDeviceConfigurations = mapOf( connected.roleName to connectedRegistration ),
+ connectedDeviceRegistrations = mapOf( connected.roleName to connectedRegistration ),
tasks = setOf( task ),
triggers = mapOf( 0 to masterTrigger, 1 to connectedTrigger ),
taskControls = setOf(
@@ -58,41 +109,29 @@ class MasterDeviceDeploymentTest
TaskControl( 1, task.name, connected.roleName, TaskControl.Control.Start )
)
)
- val deviceTasks: List = deployment.getTasksPerDevice()
+ val devices: List = deployment.getRuntimeDeviceInfo()
- assertEquals( 2, deviceTasks.size )
- val expectedMasterDeviceTasks = MasterDeviceDeployment.DeviceTasks(
- device = MasterDeviceDeployment.Device( device, false, registration ),
- tasks = setOf( task )
- )
- val expectedConnectedDeviceTasks = MasterDeviceDeployment.DeviceTasks(
- device = MasterDeviceDeployment.Device( connected, true, connectedRegistration ),
- tasks = setOf( task )
- )
- assertEquals( expectedMasterDeviceTasks, deviceTasks.first { it.device.descriptor == device } )
- assertEquals( expectedConnectedDeviceTasks, deviceTasks.first { it.device.descriptor == connected } )
+ assertEquals( 2, devices.size )
+ assertEquals( setOf( task ), devices.first { it.descriptor == device }.tasks )
+ assertEquals( setOf( task ), devices.first { it.descriptor == connected }.tasks )
}
@Test
- fun getTasksPerDevice_includes_devices_with_no_tasks()
+ fun getRuntimeDeviceInfo_includes_devices_with_no_tasks()
{
val device = StubMasterDeviceDescriptor( "Master" )
val registration = device.createRegistration()
- val deployment = MasterDeviceDeployment( deviceDescriptor = device, configuration = registration )
- val tasks: List = deployment.getTasksPerDevice()
+ val deployment = MasterDeviceDeployment( device, registration )
+ val devices: List = deployment.getRuntimeDeviceInfo()
- assertEquals( 1, tasks.size )
- val expectedDeviceTasks = MasterDeviceDeployment.DeviceTasks(
- device = MasterDeviceDeployment.Device( device, false, registration ),
- tasks = emptySet()
- )
- assertEquals( expectedDeviceTasks, tasks.single() )
+ assertEquals( 1, devices.size )
+ assertEquals( emptySet(), devices.single().tasks )
}
@Test
- fun getTaskPerDevice_does_not_include_tasks_for_other_master_devices()
+ fun getRuntimeDeviceInfo_does_not_include_tasks_for_other_master_devices()
{
val master1 = StubMasterDeviceDescriptor( "Master 1" )
val task = StubTaskDescriptor()
@@ -102,9 +141,9 @@ class MasterDeviceDeploymentTest
val deployment = MasterDeviceDeployment(
deviceDescriptor = master1,
- configuration = master1Registration,
+ registration = master1Registration,
connectedDevices = emptySet(),
- connectedDeviceConfigurations = emptyMap(),
+ connectedDeviceRegistrations = emptyMap(),
tasks = setOf( task ),
triggers = mapOf( 0 to master1Trigger ),
taskControls = setOf(
@@ -112,13 +151,10 @@ class MasterDeviceDeploymentTest
TaskControl( 0, "Task on Master 2", master2.roleName, TaskControl.Control.Start )
)
)
- val tasks: List = deployment.getTasksPerDevice()
+ val devices: List = deployment.getRuntimeDeviceInfo()
- assertEquals( 1, tasks.size ) // The other master device (master2) is not included.
- val expectedMasterDeviceTasks = MasterDeviceDeployment.DeviceTasks(
- device = MasterDeviceDeployment.Device( master1, false, master1Registration ),
- tasks = setOf( task )
- )
- assertEquals( expectedMasterDeviceTasks, tasks.single() )
+ assertEquals( 1, devices.size ) // The other master device (master2) is not included.
+ assertEquals( master1, devices.single().descriptor )
+ assertEquals( setOf( task ), devices.single().tasks )
}
}
diff --git a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/ValidationTest.kt b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/ValidationTest.kt
index 9f6662ec3..86cf8c209 100644
--- a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/ValidationTest.kt
+++ b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/application/ValidationTest.kt
@@ -27,34 +27,47 @@ class ValidationTest
@Test
- fun throwIfInvalid_for_valid_invitations()
+ fun throwIfInvalidInvitations_for_valid_invitations()
{
val deviceRoleName = "Test device"
val protocol = createSingleMasterDeviceProtocol( deviceRoleName ).getSnapshot()
val invitation = createInvitation( setOf( deviceRoleName ) )
- protocol.throwIfInvalid( listOf( invitation ) )
+ protocol.throwIfInvalidInvitations( listOf( invitation ) )
}
@Test
- fun throwIfInvalid_throws_for_empty_invitations()
+ fun throwIfInvalidInvitations_for_valid_invitations_with_unassigned_optional_master_device()
+ {
+ val toAssign = "Test device"
+ val protocol = createEmptyProtocol().apply {
+ addMasterDevice( StubMasterDeviceDescriptor( toAssign ) )
+ addMasterDevice( StubMasterDeviceDescriptor( "Unassigned optional device", true ) )
+ }.getSnapshot()
+ val invitation = createInvitation( setOf( toAssign ) )
+
+ protocol.throwIfInvalidInvitations( listOf( invitation ) )
+ }
+
+ @Test
+ fun throwIfInvalidInvitations_throws_for_empty_invitations()
{
val protocol = createSingleMasterDeviceProtocol().getSnapshot()
- assertFailsWith { protocol.throwIfInvalid( emptyList() ) }
+ assertFailsWith { protocol.throwIfInvalidInvitations( emptyList() ) }
}
@Test
- fun throwIfInvalid_throws_for_invalid_master_device()
+ fun throwIfInvalidInvitations_throws_for_invalid_master_device()
{
val protocol = createSingleMasterDeviceProtocol( "Master" ).getSnapshot()
val invitation = createInvitation( setOf( "Invalid" ) )
- assertFailsWith { protocol.throwIfInvalid( listOf( invitation ) ) }
+ assertFailsWith { protocol.throwIfInvalidInvitations( listOf( invitation ) ) }
}
@Test
- fun throwIfInvalid_throws_for_unassigned_master_device()
+ fun throwIfInvalidInvitations_throws_for_unassigned_master_device()
{
val toAssign = "Test device"
val protocol = createEmptyProtocol().apply {
@@ -63,68 +76,64 @@ class ValidationTest
}.getSnapshot()
val invitation = createInvitation( setOf( toAssign ) )
- assertFailsWith { protocol.throwIfInvalid( listOf( invitation ) ) }
+ assertFailsWith { protocol.throwIfInvalidInvitations( listOf( invitation ) ) }
}
@Test
- fun throwIfInvalid_for_valid_preregistrations()
+ fun throwIfInvalidPreregistrations_for_valid_preregistrations()
{
val masterRoleName = "Master"
val connectedRoleName = "Connected"
val protocol = createSingleMasterWithConnectedDeviceProtocol( masterRoleName, connectedRoleName ).getSnapshot()
- val invitation = createInvitation( setOf( masterRoleName ) )
val preregistrations = mapOf(
connectedRoleName to protocol.connectedDevices.first { it.roleName == connectedRoleName }.createRegistration()
)
- protocol.throwIfInvalid( listOf( invitation ), preregistrations )
+ protocol.throwIfInvalidPreregistrations( preregistrations )
}
@Test
- fun throwIfInvalid_throws_for_preregistration_for_nonconnected_devices()
+ fun throwIfInvalidPreregistrations_throws_for_preregistration_for_nonconnected_devices()
{
val masterRoleName = "Master"
val connectedRoleName = "Connected"
val protocol = createSingleMasterWithConnectedDeviceProtocol( masterRoleName, connectedRoleName ).getSnapshot()
- val invitation = createInvitation( setOf( masterRoleName ) )
val preregistrations = mapOf(
masterRoleName to protocol.masterDevices.first { it.roleName == masterRoleName }.createRegistration()
)
assertFailsWith {
- protocol.throwIfInvalid( listOf( invitation ), preregistrations )
+ protocol.throwIfInvalidPreregistrations( preregistrations )
}
}
@Test
- fun throwIfInvalid_throws_for_preregistration_for_unknown_devices()
+ fun throwIfInvalidPreregistrations_throws_for_preregistration_for_unknown_devices()
{
val deviceRoleName = "Master"
val protocol = createSingleMasterDeviceProtocol( deviceRoleName ).getSnapshot()
- val invitation = createInvitation( setOf( deviceRoleName ) )
val preregistrations = mapOf( "Unknown" to DefaultDeviceRegistration( "ID" ) )
assertFailsWith {
- protocol.throwIfInvalid( listOf( invitation ), preregistrations )
+ protocol.throwIfInvalidPreregistrations( preregistrations )
}
}
@Test
- fun throwIfInvalid_throws_for_invalid_preregistrations()
+ fun throwIfInvalidPreregistrations_throws_for_invalid_preregistrations()
{
val masterRoleName = "Master"
val connectedRoleName = "Connected"
val protocol = createSingleMasterWithConnectedDeviceProtocol( masterRoleName, connectedRoleName ).getSnapshot()
val invalidRegistration = object : DeviceRegistration() { override val deviceId: String = "Invalid" }
- val invitation = createInvitation( setOf( masterRoleName ) )
val preregistrations = mapOf( connectedRoleName to invalidRegistration )
assertFailsWith {
- protocol.throwIfInvalid( listOf( invitation ), preregistrations )
+ protocol.throwIfInvalidPreregistrations( preregistrations )
}
}
}
diff --git a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/CreateTestObjects.kt b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/CreateTestObjects.kt
index 5505a9826..ac96b6c99 100644
--- a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/CreateTestObjects.kt
+++ b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/CreateTestObjects.kt
@@ -8,6 +8,7 @@ import dk.cachet.carp.common.application.data.input.elements.Text
import dk.cachet.carp.common.application.devices.AnyMasterDeviceDescriptor
import dk.cachet.carp.common.application.users.AccountIdentity
import dk.cachet.carp.common.application.users.ParticipantAttribute
+import dk.cachet.carp.common.application.users.UsernameAccountIdentity
import dk.cachet.carp.common.domain.users.Account
import dk.cachet.carp.deployments.application.users.ParticipantInvitation
import dk.cachet.carp.deployments.application.users.Participation
@@ -18,10 +19,21 @@ import dk.cachet.carp.protocols.infrastructure.test.createSingleMasterDeviceProt
import dk.cachet.carp.protocols.infrastructure.test.createSingleMasterWithConnectedDeviceProtocol
+/**
+ * Create a study deployment with a test user assigned to each master device in the [protocol].
+ */
fun studyDeploymentFor( protocol: StudyProtocol ): StudyDeployment
{
val snapshot = protocol.getSnapshot()
- return StudyDeployment( snapshot )
+
+ // Create invitations.
+ val identity = UsernameAccountIdentity( "Test user" )
+ val invitation = StudyInvitation( "Test" )
+ val invitations = protocol.masterDevices.map {
+ ParticipantInvitation( UUID.randomUUID(), setOf( it.roleName ), identity, invitation )
+ }
+
+ return StudyDeployment.fromInvitations( snapshot, invitations )
}
/**
@@ -88,7 +100,7 @@ fun createComplexParticipantGroup(): ParticipantGroup
protocol.addExpectedParticipantData( defaultAttribute )
val customAttribute = ParticipantAttribute.CustomParticipantAttribute( Text( "Name" ) )
protocol.addExpectedParticipantData( customAttribute )
- val deployment = StudyDeployment( protocol.getSnapshot() )
+ val deployment = studyDeploymentFor( protocol )
return ParticipantGroup.fromNewDeployment( deployment ).apply {
addParticipation(
diff --git a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/DeploymentRepositoryTest.kt b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/DeploymentRepositoryTest.kt
index 1ebb833ef..1ea289638 100644
--- a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/DeploymentRepositoryTest.kt
+++ b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/DeploymentRepositoryTest.kt
@@ -69,9 +69,9 @@ interface DeploymentRepositoryTest
@Test
fun getStudyDeploymentsBy_succeeds() = runSuspendTest {
val repo = createRepository()
- val protocolSnapshot = createSingleMasterWithConnectedDeviceProtocol().getSnapshot()
- val deployment1 = StudyDeployment( protocolSnapshot )
- val deployment2 = StudyDeployment( protocolSnapshot )
+ val protocol = createSingleMasterWithConnectedDeviceProtocol()
+ val deployment1 = studyDeploymentFor( protocol )
+ val deployment2 = studyDeploymentFor( protocol )
repo.add( deployment1 )
repo.add( deployment2 )
diff --git a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/StudyDeploymentTest.kt b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/StudyDeploymentTest.kt
index b546d57f5..24f80de3a 100644
--- a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/StudyDeploymentTest.kt
+++ b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/StudyDeploymentTest.kt
@@ -1,3 +1,5 @@
+@file:Suppress( "LargeClass" )
+
package dk.cachet.carp.deployments.domain
import dk.cachet.carp.common.application.UUID
@@ -8,11 +10,10 @@ import dk.cachet.carp.common.application.devices.AnyDeviceDescriptor
import dk.cachet.carp.common.application.devices.AnyMasterDeviceDescriptor
import dk.cachet.carp.common.application.devices.DefaultDeviceRegistration
import dk.cachet.carp.common.application.tasks.Measure
-import dk.cachet.carp.common.application.tasks.TaskDescriptor
import dk.cachet.carp.common.application.triggers.TaskControl
+import dk.cachet.carp.common.application.users.UsernameAccountIdentity
import dk.cachet.carp.common.infrastructure.serialization.CustomDeviceDescriptor
import dk.cachet.carp.common.infrastructure.serialization.CustomMasterDeviceDescriptor
-import dk.cachet.carp.common.infrastructure.serialization.CustomTaskDescriptor
import dk.cachet.carp.common.infrastructure.serialization.createDefaultJSON
import dk.cachet.carp.common.infrastructure.test.STUB_DATA_TYPE
import dk.cachet.carp.common.infrastructure.test.StubDeviceDescriptor
@@ -23,12 +24,14 @@ import dk.cachet.carp.data.application.DataStreamsConfiguration
import dk.cachet.carp.deployments.application.DeviceDeploymentStatus
import dk.cachet.carp.deployments.application.MasterDeviceDeployment
import dk.cachet.carp.deployments.application.StudyDeploymentStatus
+import dk.cachet.carp.deployments.application.users.ParticipantInvitation
+import dk.cachet.carp.deployments.application.users.ParticipantStatus
+import dk.cachet.carp.deployments.application.users.StudyInvitation
import dk.cachet.carp.protocols.domain.start
import dk.cachet.carp.protocols.infrastructure.test.createEmptyProtocol
+import dk.cachet.carp.protocols.infrastructure.test.createSingleMasterDeviceProtocol
import dk.cachet.carp.protocols.infrastructure.test.createSingleMasterWithConnectedDeviceProtocol
import kotlinx.datetime.Clock
-import kotlinx.serialization.Required
-import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlin.test.*
@@ -36,6 +39,7 @@ import kotlin.test.*
/**
* Tests for [StudyDeployment].
*/
+@Suppress( "LargeClass" )
class StudyDeploymentTest
{
companion object
@@ -45,7 +49,7 @@ class StudyDeploymentTest
@Test
- fun cant_initialize_deployment_with_errors()
+ fun fromInvitations_with_invalid_protocol_fails()
{
val protocol = createEmptyProtocol()
val snapshot = protocol.getSnapshot()
@@ -53,7 +57,25 @@ class StudyDeploymentTest
// Protocol does not contain a master device, thus contains deployment error and can't be initialized.
assertFailsWith
{
- StudyDeployment( snapshot )
+ StudyDeployment.fromInvitations( snapshot, emptyList() )
+ }
+ }
+
+ @Test
+ fun fromInvitations_with_invalid_invitations_fails()
+ {
+ val protocol = createSingleMasterDeviceProtocol()
+ val snapshot = protocol.getSnapshot()
+
+ val incorrectInvitation = ParticipantInvitation(
+ UUID.randomUUID(),
+ setOf( "Invalid" ),
+ UsernameAccountIdentity( "Test" ),
+ StudyInvitation( "Test" )
+ )
+ assertFailsWith
+ {
+ StudyDeployment.fromInvitations( snapshot, listOf( incorrectInvitation ) )
}
}
@@ -74,11 +96,10 @@ class StudyDeploymentTest
}
@Test
- fun requiredDataStreams_is_complete_for_known_types()
+ fun requiredDataStreams_is_complete()
{
val masterDevice = StubMasterDeviceDescriptor()
val connectedDevice = StubDeviceDescriptor()
- val interactionResultType = DataType( "some.namespace", "interactionresult" )
val protocol = createEmptyProtocol().apply {
addMasterDevice( masterDevice )
addConnectedDevice( connectedDevice, masterDevice )
@@ -88,8 +109,7 @@ class StudyDeploymentTest
val task = StubTaskDescriptor(
"Task",
listOf( stubMeasure, trigger.measure() ),
- "Description",
- setOf( interactionResultType )
+ "Description"
)
addTaskControl( trigger.start( task, masterDevice ) )
@@ -101,42 +121,17 @@ class StudyDeploymentTest
val dataStreams = deployment.requiredDataStreams
assertEquals( deployment.id, dataStreams.studyDeploymentId )
val expectedMasterDeviceTypes =
- listOf( STUB_DATA_TYPE, CarpDataTypes.TRIGGERED_TASK.type, interactionResultType )
+ listOf( STUB_DATA_TYPE, CarpDataTypes.TRIGGERED_TASK.type, CarpDataTypes.COMPLETED_TASK.type )
.map { DataStreamsConfiguration.ExpectedDataStream( masterDevice.roleName, it ) }
val expectedConnectedDeviceType =
- DataStreamsConfiguration.ExpectedDataStream( connectedDevice.roleName, STUB_DATA_TYPE )
+ listOf( STUB_DATA_TYPE, CarpDataTypes.COMPLETED_TASK.type )
+ .map { DataStreamsConfiguration.ExpectedDataStream( connectedDevice.roleName, it ) }
assertEquals(
( expectedMasterDeviceTypes + expectedConnectedDeviceType ).toSet(),
dataStreams.expectedDataStreams
)
}
- @Serializable
- data class UnknownTaskDescriptor( @Required override val name: String = "Unknown task" ) : TaskDescriptor
- {
- override val measures: List = emptyList()
- override val description: String = ""
- override fun getInteractionDataTypes(): Set =
- setOf( DataType( "namespace", "unknownInteractionType" ) )
- }
-
- @Test
- fun requiredDataStreams_doesnt_contain_interaction_tasks_for_unknown_tasks()
- {
- val protocol = createEmptyProtocol().apply {
- val masterDevice = StubMasterDeviceDescriptor()
- addMasterDevice( masterDevice )
-
- val trigger = addTrigger( masterDevice.atStartOfStudy() )
- val serializedTask: String = JSON.encodeToString( UnknownTaskDescriptor.serializer(), UnknownTaskDescriptor() )
- val unknownTask = CustomTaskDescriptor( "some.unknown.TaskDescriptor", serializedTask, JSON )
- addTaskControl( trigger.start( unknownTask, masterDevice ) )
- }
- val deployment: StudyDeployment = studyDeploymentFor( protocol )
-
- assertEquals( emptySet(), deployment.requiredDataStreams.expectedDataStreams )
- }
-
@Test
fun registerDevice_succeeds()
{
@@ -156,6 +151,30 @@ class StudyDeploymentTest
assertEquals( StudyDeployment.Event.DeviceRegistered( device, registration ), deployment.consumeEvents().last() )
}
+ @Test
+ fun registerDevice_of_optional_master_device_triggers_redeployment()
+ {
+ // Deploy master device.
+ val master = StubMasterDeviceDescriptor( "Master 1" )
+ val optionalMaster = StubMasterDeviceDescriptor( "Master 2", true )
+ val protocol = createEmptyProtocol().apply {
+ addMasterDevice( master )
+ addMasterDevice( optionalMaster )
+ }
+ val deployment = studyDeploymentFor( protocol )
+ deployment.registerDevice( master, master.createRegistration() )
+ val deviceDeployment = deployment.getDeviceDeploymentFor( master )
+ deployment.deviceDeployed( master, deviceDeployment.lastUpdatedOn )
+ assertTrue( deployment.getStatus() is StudyDeploymentStatus.Running )
+
+ // Register dependent device.
+ deployment.registerDevice( optionalMaster, optionalMaster.createRegistration() )
+ val status = deployment.getStatus()
+ assertTrue( status is StudyDeploymentStatus.DeployingDevices )
+ val deviceStatus = status.getDeviceStatus( master )
+ assertTrue( deviceStatus is DeviceDeploymentStatus.NeedsRedeployment )
+ }
+
@Test
fun cant_registerDevice_not_part_of_deployment()
{
@@ -382,6 +401,26 @@ class StudyDeploymentTest
assertEquals( listOf( registration1, registration2 ), fromSnapshot.deviceRegistrationHistory[ master ] )
}
+ @Test
+ fun getStatus_contains_invited_participants()
+ {
+ val deviceRoleName = "Master"
+ val protocol = createSingleMasterDeviceProtocol( deviceRoleName )
+ val invitation = ParticipantInvitation(
+ UUID.randomUUID(),
+ setOf( deviceRoleName ),
+ UsernameAccountIdentity( "Test" ),
+ StudyInvitation( "Test " )
+ )
+ val deployment = StudyDeployment.fromInvitations( protocol.getSnapshot(), listOf( invitation ) )
+
+ val status = deployment.getStatus()
+ assertEquals(
+ listOf( ParticipantStatus( invitation.participantId, invitation.assignedMasterDeviceRoleNames ) ),
+ status.participantsStatus
+ )
+ }
+
@Test
fun getStatus_lifecycle_master_and_connected()
{
@@ -420,7 +459,7 @@ class StudyDeploymentTest
val deviceDeployment = deployment.getDeviceDeploymentFor( master )
deployment.deviceDeployed( master, deviceDeployment.lastUpdatedOn )
val afterDeployStatus = deployment.getStatus()
- assertTrue( afterDeployStatus is StudyDeploymentStatus.DeploymentReady )
+ assertTrue( afterDeployStatus is StudyDeploymentStatus.Running )
val deviceStatus = afterDeployStatus.getDeviceStatus( master )
assertTrue( deviceStatus is DeviceDeploymentStatus.Deployed )
assertEquals( 0, afterDeployStatus.getRemainingDevicesReadyToDeploy().count() )
@@ -445,10 +484,10 @@ class StudyDeploymentTest
deployment.deviceDeployed( master1, master1Deployment.lastUpdatedOn )
assertTrue( deployment.getStatus() is StudyDeploymentStatus.DeployingDevices )
- // After deployment of the second master device, deployment is ready.
+ // After deployment of the second master device, deployment is running.
val master2Deployment = deployment.getDeviceDeploymentFor( master2 )
deployment.deviceDeployed( master2, master2Deployment.lastUpdatedOn )
- assertTrue( deployment.getStatus() is StudyDeploymentStatus.DeploymentReady )
+ assertTrue( deployment.getStatus() is StudyDeploymentStatus.Running )
// Unregistering one device returns deployment to 'deploying'.
deployment.unregisterDevice( master1 )
@@ -456,7 +495,33 @@ class StudyDeploymentTest
}
@Test
- fun chained_master_devices_do_not_require_deployment()
+ fun getStatus_for_protocol_with_only_optional_devices()
+ {
+ val protocol = createEmptyProtocol()
+ val master = StubMasterDeviceDescriptor( "Master", isOptional = true )
+ val master2 = StubMasterDeviceDescriptor( "Master 2", isOptional = true )
+ protocol.addMasterDevice( master )
+ protocol.addMasterDevice( master2 )
+ val deployment = studyDeploymentFor( protocol )
+
+ // At least one device needs to be deployed before deployment can be considered "Running".
+ var status = deployment.getStatus()
+ assertTrue( status is StudyDeploymentStatus.Invited )
+
+ // Register device.
+ deployment.registerDevice( master, master.createRegistration() )
+ status = deployment.getStatus()
+ assertTrue( status is StudyDeploymentStatus.DeployingDevices )
+
+ // Deploy device. All other devices are optional, so deployment is "Running".
+ val deviceDeployment = deployment.getDeviceDeploymentFor( master )
+ deployment.deviceDeployed( master, deviceDeployment.lastUpdatedOn )
+ status = deployment.getStatus()
+ assertTrue( status is StudyDeploymentStatus.Running )
+ }
+
+ @Test
+ fun chained_master_devices_cant_be_deployed()
{
val protocol = createEmptyProtocol()
val master = StubMasterDeviceDescriptor( "Master" )
@@ -467,7 +532,7 @@ class StudyDeploymentTest
val status: StudyDeploymentStatus = deployment.getStatus()
val chainedStatus = status.getDeviceStatus( chained )
- assertFalse { chainedStatus.requiresDeployment }
+ assertFalse { chainedStatus.canBeDeployed }
}
@Test
@@ -486,15 +551,14 @@ class StudyDeploymentTest
deployment.registerDevice( master, registration )
deployment.registerDevice( connected, connected.createRegistration() )
- // Include an additional master device with a trigger which should not impact the `DeviceDeployment` tested here.
+ // Later changes made to the protocol don't impact the previously created deployment.
val ignoredMaster = StubMasterDeviceDescriptor( "Ignored" )
- protocol.addMasterDevice( ignoredMaster )
- protocol.addTaskControl( ignoredMaster.atStartOfStudy().start( masterTask, ignoredMaster ) )
+ protocol.addMasterDevice( ignoredMaster ) // Normally, this dependent device would block obtaining deployment.
val deviceDeployment: MasterDeviceDeployment = deployment.getDeviceDeploymentFor( master )
- assertEquals( "Registered master", deviceDeployment.configuration.deviceId )
+ assertEquals( "Registered master", deviceDeployment.registration.deviceId )
assertEquals( protocol.getConnectedDevices( master ).toSet(), deviceDeployment.connectedDevices )
- assertEquals( 1, deviceDeployment.connectedDeviceConfigurations.count() ) // One preregistered connected devices.
+ assertEquals( 1, deviceDeployment.connectedDeviceRegistrations.count() )
assertEquals( protocol.applicationData, deviceDeployment.applicationData )
// Device deployment lists both tasks, even if one is destined for the connected device.
@@ -521,8 +585,8 @@ class StudyDeploymentTest
deployment.registerDevice( connected, DefaultDeviceRegistration( "42" ) )
val deviceDeployment = deployment.getDeviceDeploymentFor( master )
- assertEquals( "Connected", deviceDeployment.connectedDeviceConfigurations.keys.single() )
- assertEquals( "42", deviceDeployment.connectedDeviceConfigurations.getValue( "Connected" ).deviceId )
+ assertEquals( "Connected", deviceDeployment.connectedDeviceRegistrations.keys.single() )
+ assertEquals( "42", deviceDeployment.connectedDeviceRegistrations.getValue( "Connected" ).deviceId )
}
@Test
@@ -535,7 +599,7 @@ class StudyDeploymentTest
val deviceDeployment = deployment.getDeviceDeploymentFor( master )
- assertTrue( deviceDeployment.connectedDeviceConfigurations.isEmpty() )
+ assertTrue( deviceDeployment.connectedDeviceRegistrations.isEmpty() )
}
@Test
@@ -573,6 +637,32 @@ class StudyDeploymentTest
assertEquals( 0, targetDeployment.connectedDevices.size )
}
+ @Test
+ fun getDeviceDeploymentFor_and_deviceDeployed_succeed_with_optional_unregistered_dependent_device()
+ {
+ val master = StubMasterDeviceDescriptor( "Master 1" )
+ val optionalMaster = StubMasterDeviceDescriptor( "Master 2", true )
+ val protocol = createEmptyProtocol().apply {
+ addMasterDevice( master )
+ addMasterDevice( optionalMaster )
+ }
+ val deployment = studyDeploymentFor( protocol )
+ deployment.registerDevice( master, master.createRegistration() )
+
+ // Can get deployment.
+ val deploymentStatus = deployment.getStatus()
+ val deviceStatus = deploymentStatus.getDeviceStatus( master )
+ assertTrue( deviceStatus is DeviceDeploymentStatus.Registered )
+ assertTrue( deviceStatus.canObtainDeviceDeployment )
+ val deviceDeployment = deployment.getDeviceDeploymentFor( master )
+ assertEquals( master, deviceDeployment.deviceDescriptor )
+
+ // Can complete deployment.
+ deployment.deviceDeployed( master, deviceDeployment.lastUpdatedOn )
+ val status = deployment.getStatus()
+ assertTrue( status is StudyDeploymentStatus.Running )
+ }
+
@Test
fun getDeviceDeploymentFor_fails_when_device_not_in_protocol()
{
@@ -722,10 +812,12 @@ class StudyDeploymentTest
val deviceDeployment = deployment.getDeviceDeploymentFor( device )
deployment.deviceDeployed( device, deviceDeployment.lastUpdatedOn )
- assertTrue( deployment.getStatus() is StudyDeploymentStatus.DeploymentReady )
+ assertTrue( deployment.getStatus() is StudyDeploymentStatus.Running )
+ assertNull( deployment.stoppedOn )
deployment.stop()
assertTrue( deployment.isStopped )
+ assertNotNull( deployment.stoppedOn )
assertTrue( deployment.getStatus() is StudyDeploymentStatus.Stopped )
assertEquals( 1, deployment.consumeEvents().filterIsInstance().count() )
}
@@ -740,9 +832,11 @@ class StudyDeploymentTest
deployment.registerDevice( device, device.createRegistration() )
assertTrue( deployment.getStatus() is StudyDeploymentStatus.DeployingDevices )
+ assertNull( deployment.stoppedOn )
deployment.stop()
assertTrue( deployment.isStopped )
+ assertNotNull( deployment.stoppedOn )
assertTrue( deployment.getStatus() is StudyDeploymentStatus.Stopped )
assertEquals( 1, deployment.consumeEvents().filterIsInstance().count() )
}
diff --git a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/users/ParticipantGroupTest.kt b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/users/ParticipantGroupTest.kt
index f9d54d97d..d95467173 100644
--- a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/users/ParticipantGroupTest.kt
+++ b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/users/ParticipantGroupTest.kt
@@ -13,8 +13,8 @@ import dk.cachet.carp.common.infrastructure.test.StubMasterDeviceDescriptor
import dk.cachet.carp.deployments.application.users.AssignedMasterDevice
import dk.cachet.carp.deployments.application.users.Participation
import dk.cachet.carp.deployments.application.users.StudyInvitation
-import dk.cachet.carp.deployments.domain.StudyDeployment
import dk.cachet.carp.deployments.domain.createComplexParticipantGroup
+import dk.cachet.carp.deployments.domain.studyDeploymentFor
import dk.cachet.carp.protocols.domain.StudyProtocol
import dk.cachet.carp.protocols.infrastructure.test.createSingleMasterDeviceProtocol
import kotlin.test.*
@@ -33,7 +33,7 @@ class ParticipantGroupTest
val protocol: StudyProtocol = createSingleMasterDeviceProtocol()
val expectedData = InputDataType( "some", "type" )
protocol.addExpectedParticipantData( ParticipantAttribute.DefaultParticipantAttribute( expectedData ) )
- val deployment = StudyDeployment( protocol.getSnapshot() )
+ val deployment = studyDeploymentFor( protocol )
val group = ParticipantGroup.fromNewDeployment( deployment )
@@ -340,7 +340,7 @@ class ParticipantGroupTest
private fun createParticipantGroup( protocol: StudyProtocol = createSingleMasterDeviceProtocol() ): ParticipantGroup
{
- val deployment = StudyDeployment( protocol.getSnapshot() )
+ val deployment = studyDeploymentFor( protocol )
return ParticipantGroup.fromNewDeployment( deployment )
}
}
diff --git a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/users/ParticipationRepositoryTest.kt b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/users/ParticipationRepositoryTest.kt
index 6bb02fe01..f2d02faa9 100644
--- a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/users/ParticipationRepositoryTest.kt
+++ b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/domain/users/ParticipationRepositoryTest.kt
@@ -4,8 +4,8 @@ import dk.cachet.carp.common.application.UUID
import dk.cachet.carp.common.domain.users.Account
import dk.cachet.carp.deployments.application.users.Participation
import dk.cachet.carp.deployments.application.users.StudyInvitation
-import dk.cachet.carp.deployments.domain.StudyDeployment
import dk.cachet.carp.deployments.domain.createComplexParticipantGroup
+import dk.cachet.carp.deployments.domain.studyDeploymentFor
import dk.cachet.carp.protocols.domain.StudyProtocol
import dk.cachet.carp.protocols.infrastructure.test.createSingleMasterDeviceProtocol
import dk.cachet.carp.test.runSuspendTest
@@ -30,7 +30,7 @@ interface ParticipationRepositoryTest
fun getParticipations_succeeds() = runSuspendTest {
val repo = createRepository()
val protocol: StudyProtocol = createSingleMasterDeviceProtocol()
- val deployment = StudyDeployment( protocol.getSnapshot() )
+ val deployment = studyDeploymentFor( protocol )
val group = ParticipantGroup.fromNewDeployment( deployment )
// Add participation.
@@ -85,11 +85,11 @@ interface ParticipationRepositoryTest
val repo = createRepository()
val protocol: StudyProtocol = createSingleMasterDeviceProtocol()
- val deployment1 = StudyDeployment( protocol.getSnapshot() )
+ val deployment1 = studyDeploymentFor( protocol )
val group1 = ParticipantGroup.fromNewDeployment( deployment1 )
repo.putParticipantGroup( group1 )
- val deployment2 = StudyDeployment( protocol.getSnapshot() )
+ val deployment2 = studyDeploymentFor( protocol )
val group2 = ParticipantGroup.fromNewDeployment( deployment2 )
repo.putParticipantGroup( group2 )
diff --git a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/infrastructure/DeploymentServiceRequestsTest.kt b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/infrastructure/DeploymentServiceRequestsTest.kt
index 4480555b9..3ffdbf26e 100644
--- a/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/infrastructure/DeploymentServiceRequestsTest.kt
+++ b/carp.deployments.core/src/commonTest/kotlin/dk/cachet/carp/deployments/infrastructure/DeploymentServiceRequestsTest.kt
@@ -29,7 +29,7 @@ class DeploymentServiceRequestsTest : ApplicationServiceRequestsTest
{
- StudyDeployment( invalidSnapshot )
+ StudyDeployment.fromInvitations( invalidSnapshot, invitations )
}
}
diff --git a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/application/StudyProtocolSnapshot.kt b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/application/StudyProtocolSnapshot.kt
index 5035e7ddc..09c65d329 100644
--- a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/application/StudyProtocolSnapshot.kt
+++ b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/application/StudyProtocolSnapshot.kt
@@ -24,7 +24,7 @@ data class StudyProtocolSnapshot(
val masterDevices: Set = emptySet(),
val connectedDevices: Set = emptySet(),
val connections: Set = emptySet(),
- val tasks: Set = emptySet(),
+ val tasks: Set> = emptySet(),
val triggers: Map> = emptyMap(),
val taskControls: Set = emptySet(),
val expectedParticipantData: Set = emptySet(),
diff --git a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/StudyProtocol.kt b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/StudyProtocol.kt
index 5c948732e..29e0332d1 100644
--- a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/StudyProtocol.kt
+++ b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/StudyProtocol.kt
@@ -1,3 +1,5 @@
+@file:Suppress( "WildcardImport" )
+
package dk.cachet.carp.protocols.domain
import dk.cachet.carp.common.application.UUID
@@ -14,13 +16,7 @@ import dk.cachet.carp.protocols.domain.configuration.EmptyDeviceConfiguration
import dk.cachet.carp.protocols.domain.configuration.EmptyParticipantDataConfiguration
import dk.cachet.carp.protocols.domain.configuration.EmptyTaskConfiguration
import dk.cachet.carp.protocols.domain.configuration.StudyProtocolComposition
-import dk.cachet.carp.protocols.domain.deployment.DeploymentError
-import dk.cachet.carp.protocols.domain.deployment.DeploymentIssue
-import dk.cachet.carp.protocols.domain.deployment.NoMasterDeviceError
-import dk.cachet.carp.protocols.domain.deployment.UnexpectedMeasuresWarning
-import dk.cachet.carp.protocols.domain.deployment.UnstartedTasksWarning
-import dk.cachet.carp.protocols.domain.deployment.UnusedDevicesWarning
-import dk.cachet.carp.protocols.domain.deployment.UseCompositeTaskWarning
+import dk.cachet.carp.protocols.domain.deployment.*
/**
@@ -52,8 +48,8 @@ class StudyProtocol private constructor( val ownerId: UUID, val name: String, va
data class MasterDeviceAdded( val device: AnyMasterDeviceDescriptor ) : Event()
data class ConnectedDeviceAdded( val connected: AnyDeviceDescriptor, val master: AnyMasterDeviceDescriptor ) : Event()
data class TriggerAdded( val trigger: Trigger<*> ) : Event()
- data class TaskAdded( val task: TaskDescriptor ) : Event()
- data class TaskRemoved( val task: TaskDescriptor ) : Event()
+ data class TaskAdded( val task: TaskDescriptor<*> ) : Event()
+ data class TaskRemoved( val task: TaskDescriptor<*> ) : Event()
data class TaskControlAdded( val control: TaskControl ) : Event()
data class TaskControlRemoved( val control: TaskControl ) : Event()
data class ExpectedParticipantDataAdded( val attribute: ParticipantAttribute ) : Event()
@@ -99,7 +95,7 @@ class StudyProtocol private constructor( val ownerId: UUID, val name: String, va
snapshot.taskControls.forEach { control ->
val triggerMatch = snapshot.triggers.entries.singleOrNull { it.key == control.triggerId }
?: throw IllegalArgumentException( "Can't find trigger with id '${control.triggerId}' in snapshot." )
- val task: TaskDescriptor = protocol.tasks.singleOrNull { it.name == control.taskName }
+ val task: TaskDescriptor<*> = protocol.tasks.singleOrNull { it.name == control.taskName }
?: throw IllegalArgumentException( "Can't find task with name '${control.taskName}' in snapshot." )
val device: AnyDeviceDescriptor = protocol.devices.singleOrNull { it.roleName == control.destinationDeviceRoleName }
?: throw IllegalArgumentException( "Can't find device with role name '${control.destinationDeviceRoleName}' in snapshot." )
@@ -127,7 +123,9 @@ class StudyProtocol private constructor( val ownerId: UUID, val name: String, va
* Add a [masterDevice] which is responsible for aggregating and synchronizing incoming data.
* Its role name should be unique in the protocol.
*
- * @throws IllegalArgumentException in case a device with the specified role name already exists.
+ * @throws IllegalArgumentException when:
+ * - a device with the specified role name already exists
+ * - [masterDevice] contains invalid default sampling configurations
* @return True if the [masterDevice] has been added; false if it is already set as a master device.
*/
override fun addMasterDevice( masterDevice: AnyMasterDeviceDescriptor ): Boolean =
@@ -141,6 +139,7 @@ class StudyProtocol private constructor( val ownerId: UUID, val name: String, va
* @throws IllegalArgumentException when:
* - a device with the specified role name already exists
* - [masterDevice] is not part of the device configuration
+ * - [device] contains invalid default sampling configurations
* @return True if the [device] has been added; false if it is already connected to the specified [masterDevice].
*/
override fun addConnectedDevice( device: AnyDeviceDescriptor, masterDevice: AnyMasterDeviceDescriptor ): Boolean =
@@ -204,7 +203,7 @@ class StudyProtocol private constructor( val ownerId: UUID, val name: String, va
*/
fun addTaskControl(
trigger: Trigger<*>,
- task: TaskDescriptor,
+ task: TaskDescriptor<*>,
destinationDevice: AnyDeviceDescriptor,
control: Control
): Boolean
@@ -266,7 +265,7 @@ class StudyProtocol private constructor( val ownerId: UUID, val name: String, va
/**
* Gets all the tasks triggered for the specified [device].
*/
- fun getTasksForDevice( device: AnyDeviceDescriptor ): Set
+ fun getTasksForDevice( device: AnyDeviceDescriptor ): Set>
{
return triggerControls
.flatMap { it.value }
@@ -281,7 +280,7 @@ class StudyProtocol private constructor( val ownerId: UUID, val name: String, va
* @throws IllegalArgumentException in case a task with the specified name already exists.
* @return True if the [task] has been added; false if it is already included in this configuration.
*/
- override fun addTask( task: TaskDescriptor ): Boolean =
+ override fun addTask( task: TaskDescriptor<*> ): Boolean =
super.addTask( task )
.eventIf( true ) { Event.TaskAdded( task ) }
@@ -291,7 +290,7 @@ class StudyProtocol private constructor( val ownerId: UUID, val name: String, va
*
* @return True if the [task] has been removed; false if it is not included in this configuration.
*/
- override fun removeTask( task: TaskDescriptor ): Boolean
+ override fun removeTask( task: TaskDescriptor<*> ): Boolean
{
// Remove all controls which control this task.
triggerControls.values.forEach { controls ->
@@ -364,7 +363,9 @@ class StudyProtocol private constructor( val ownerId: UUID, val name: String, va
*/
private val possibleDeploymentIssues: List = listOf(
NoMasterDeviceError(),
+ OnlyOptionalDevicesWarning(),
UnstartedTasksWarning(),
+ BackgroundTaskWithNoMeasuresWarning(),
UseCompositeTaskWarning(),
UnusedDevicesWarning(),
UnexpectedMeasuresWarning()
diff --git a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/TaskControl.kt b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/TaskControl.kt
index ffb788c4b..2dc21e2d1 100644
--- a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/TaskControl.kt
+++ b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/TaskControl.kt
@@ -11,7 +11,7 @@ import dk.cachet.carp.common.application.triggers.Trigger
*/
data class TaskControl(
val trigger: Trigger<*>,
- val task: TaskDescriptor,
+ val task: TaskDescriptor<*>,
val destinationDevice: AnyDeviceDescriptor,
val control: TaskControl.Control
)
@@ -20,11 +20,11 @@ data class TaskControl(
/**
* Specify that a [task] should start on the specified [destinationDevice] once this [Trigger] initiates.
*/
-fun Trigger<*>.start( task: TaskDescriptor, destinationDevice: AnyDeviceDescriptor ) =
+fun Trigger<*>.start( task: TaskDescriptor<*>, destinationDevice: AnyDeviceDescriptor ) =
TaskControl( this, task, destinationDevice, TaskControl.Control.Start )
/**
* Specify that a [task] should stop on the specified [destinationDevice] once this [Trigger] initiates.
*/
-fun Trigger<*>.stop( task: TaskDescriptor, destinationDevice: AnyDeviceDescriptor ) =
+fun Trigger<*>.stop( task: TaskDescriptor<*>, destinationDevice: AnyDeviceDescriptor ) =
TaskControl( this, task, destinationDevice, TaskControl.Control.Stop )
diff --git a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/TriggerWithId.kt b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/TriggerWithId.kt
index d920fd5a8..b1eb57f17 100644
--- a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/TriggerWithId.kt
+++ b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/TriggerWithId.kt
@@ -30,11 +30,11 @@ fun Trigger<*>.within( protocol: StudyProtocol ): TriggerWithId =
/**
* Specify that a [task] should start on the specified [destinationDevice] once this [Trigger] initiates.
*/
-fun TriggerWithId.start( task: TaskDescriptor, destinationDevice: AnyDeviceDescriptor ) =
+fun TriggerWithId.start( task: TaskDescriptor<*>, destinationDevice: AnyDeviceDescriptor ) =
this.trigger.start( task, destinationDevice )
/**
* Specify that a [task] should stop on the specified [destinationDevice] once this [Trigger] initiates.
*/
-fun TriggerWithId.stop( task: TaskDescriptor, destinationDevice: AnyDeviceDescriptor ) =
+fun TriggerWithId.stop( task: TaskDescriptor<*>, destinationDevice: AnyDeviceDescriptor ) =
this.trigger.stop( task, destinationDevice )
diff --git a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/configuration/DeviceConfiguration.kt b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/configuration/DeviceConfiguration.kt
index f8331dbe5..a6de9f7cb 100644
--- a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/configuration/DeviceConfiguration.kt
+++ b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/configuration/DeviceConfiguration.kt
@@ -24,7 +24,9 @@ interface DeviceConfiguration
/**
* Add a master device which is responsible for aggregating and synchronizing incoming data.
*
- * @throws IllegalArgumentException in case a device with the specified role name already exists.
+ * @throws IllegalArgumentException when:
+ * - a device with the specified role name already exists
+ * - [masterDevice] contains invalid default sampling configurations
* @param masterDevice A description of the master device to add. Its role name should be unique in the protocol.
* @return True if the [masterDevice] has been added; false if it is already set as a master device.
*/
@@ -36,6 +38,7 @@ interface DeviceConfiguration
* @throws IllegalArgumentException when:
* - a device with the specified role name already exists
* - [masterDevice] is not part of the device configuration
+ * - [device] contains invalid default sampling configurations
* @param device The device to be connected to a master device. Its role name should be unique in the protocol.
* @return True if the [device] has been added; false if it is already connected to the specified [masterDevice].
*/
diff --git a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/configuration/EmptyDeviceConfiguration.kt b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/configuration/EmptyDeviceConfiguration.kt
index 111b5bd28..da7e8d8f0 100644
--- a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/configuration/EmptyDeviceConfiguration.kt
+++ b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/configuration/EmptyDeviceConfiguration.kt
@@ -33,6 +33,8 @@ internal class EmptyDeviceConfiguration : AbstractMap(), TaskConfiguration
+class EmptyTaskConfiguration : AbstractMap>(), TaskConfiguration
{
- private val _tasks: ExtractUniqueKeyMap =
+ private val _tasks: ExtractUniqueKeyMap> =
ExtractUniqueKeyMap( { task -> task.name } )
{
key -> IllegalArgumentException( "Task name \"$key\" is not unique within task configuration." )
}
- override val entries: Set>
+ override val entries: Set>>
get() = _tasks.entries
- override val tasks: Set
+ override val tasks: Set>
get() = _tasks.values.toSet()
- override fun addTask( task: TaskDescriptor ): Boolean = _tasks.tryAddIfKeyIsNew( task )
+ override fun addTask( task: TaskDescriptor<*> ): Boolean = _tasks.tryAddIfKeyIsNew( task )
- override fun removeTask( task: TaskDescriptor ): Boolean = _tasks.remove( task )
+ override fun removeTask( task: TaskDescriptor<*> ): Boolean = _tasks.remove( task )
}
diff --git a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/configuration/TaskConfiguration.kt b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/configuration/TaskConfiguration.kt
index 5a9efeeb5..1f800da6b 100644
--- a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/configuration/TaskConfiguration.kt
+++ b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/configuration/TaskConfiguration.kt
@@ -13,7 +13,7 @@ interface TaskConfiguration
/**
* The tasks which measure data and/or present output on a device.
*/
- val tasks: Set
+ val tasks: Set>
/**
* Add a [task] to this configuration.
@@ -21,12 +21,12 @@ interface TaskConfiguration
* @throws IllegalArgumentException in case a task with the specified name already exists.
* @return True if the [task] has been added; false if it is already included in this configuration.
*/
- fun addTask( task: TaskDescriptor ): Boolean
+ fun addTask( task: TaskDescriptor<*> ): Boolean
/**
* Remove a [task] currently present in this configuration.
*
* @return True if the [task] has been removed; false if it is not included in this configuration.
*/
- fun removeTask( task: TaskDescriptor ): Boolean
+ fun removeTask( task: TaskDescriptor<*> ): Boolean
}
diff --git a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/deployment/OnlyOptionalDevicesWarning.kt b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/deployment/OnlyOptionalDevicesWarning.kt
new file mode 100644
index 000000000..a0d47e87f
--- /dev/null
+++ b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/deployment/OnlyOptionalDevicesWarning.kt
@@ -0,0 +1,20 @@
+package dk.cachet.carp.protocols.domain.deployment
+
+import dk.cachet.carp.protocols.domain.StudyProtocol
+
+
+/**
+ * Evaluates whether a [StudyProtocol] contains only optional master devices.
+ *
+ * If all master devices are optional, this means a deployment could "start" without any devices or participants.
+ */
+class OnlyOptionalDevicesWarning : DeploymentWarning
+{
+ override val description: String =
+ "The study protocol only contains optional master devices. " +
+ "This implies that a deployment could 'start' without any devices or participants, indicating a problem."
+
+
+ override fun isIssuePresent( protocol: StudyProtocol ): Boolean =
+ protocol.masterDevices.all { it.isOptional }
+}
diff --git a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/deployment/UnstartedTasksWarning.kt b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/deployment/UnstartedTasksWarning.kt
index 83bdc9316..9e7c79568 100644
--- a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/deployment/UnstartedTasksWarning.kt
+++ b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/deployment/UnstartedTasksWarning.kt
@@ -19,9 +19,9 @@ class UnstartedTasksWarning internal constructor() : DeploymentWarning
override fun isIssuePresent( protocol: StudyProtocol ): Boolean = getUnstartedTasks( protocol ).any()
- fun getUnstartedTasks( protocol: StudyProtocol ): Set
+ fun getUnstartedTasks( protocol: StudyProtocol ): Set