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> { - val startedTasks: List = protocol.triggers.flatMap { (triggerId, _) -> + val startedTasks: List> = protocol.triggers.flatMap { (triggerId, _) -> protocol.getTaskControls( triggerId ) .filter { it.control == TaskControl.Control.Start } .map { it.task } diff --git a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/deployment/UseCompositeTaskWarning.kt b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/deployment/UseCompositeTaskWarning.kt index e41c5f056..66b741321 100644 --- a/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/deployment/UseCompositeTaskWarning.kt +++ b/carp.protocols.core/src/commonMain/kotlin/dk/cachet/carp/protocols/domain/deployment/UseCompositeTaskWarning.kt @@ -20,7 +20,7 @@ class UseCompositeTaskWarning internal constructor() : DeploymentWarning data class OverlappingTasks( val trigger: Trigger<*>, val targetDevice: AnyDeviceDescriptor, - val tasks: List + val tasks: List> ) override val description: String = diff --git a/carp.protocols.core/src/commonTest/kotlin/dk/cachet/carp/protocols/application/StudyProtocolSnapshotTest.kt b/carp.protocols.core/src/commonTest/kotlin/dk/cachet/carp/protocols/application/StudyProtocolSnapshotTest.kt index 27683a4c9..6fe5f8fb2 100644 --- a/carp.protocols.core/src/commonTest/kotlin/dk/cachet/carp/protocols/application/StudyProtocolSnapshotTest.kt +++ b/carp.protocols.core/src/commonTest/kotlin/dk/cachet/carp/protocols/application/StudyProtocolSnapshotTest.kt @@ -85,7 +85,7 @@ class StudyProtocolSnapshotTest val connections = listOf( StudyProtocolSnapshot.DeviceConnection( "C1", "M1" ), StudyProtocolSnapshot.DeviceConnection( "C2", "M2" ) ) - val tasks = listOf( StubTaskDescriptor( "T1" ), StubTaskDescriptor( "T2" ) ) + val tasks = listOf>( StubTaskDescriptor( "T1" ), StubTaskDescriptor( "T2" ) ) val triggers = mapOf>( 0 to StubTrigger( masterDevices[ 0 ] ), 1 to StubTrigger( masterDevices[ 1 ] ) ) diff --git a/carp.protocols.core/src/commonTest/kotlin/dk/cachet/carp/protocols/domain/deployment/OnlyOptionalDevicesWarningTest.kt b/carp.protocols.core/src/commonTest/kotlin/dk/cachet/carp/protocols/domain/deployment/OnlyOptionalDevicesWarningTest.kt new file mode 100644 index 000000000..d23f70334 --- /dev/null +++ b/carp.protocols.core/src/commonTest/kotlin/dk/cachet/carp/protocols/domain/deployment/OnlyOptionalDevicesWarningTest.kt @@ -0,0 +1,36 @@ +package dk.cachet.carp.protocols.domain.deployment + +import dk.cachet.carp.common.infrastructure.test.StubMasterDeviceDescriptor +import dk.cachet.carp.protocols.infrastructure.test.createEmptyProtocol +import kotlin.test.* + + +/** + * Tests for [OnlyOptionalDevicesWarning]. + */ +class OnlyOptionalDevicesWarningTest +{ + @Test + fun isIssuePresent_true_with_only_optional_master_devices() + { + val protocol = createEmptyProtocol().apply { + addMasterDevice( StubMasterDeviceDescriptor( "Optional 1", isOptional = true ) ) + addMasterDevice( StubMasterDeviceDescriptor( "Optional 2", isOptional = true ) ) + } + + val warning = OnlyOptionalDevicesWarning() + assertTrue( warning.isIssuePresent( protocol ) ) + } + + @Test + fun isIssuePresent_false_with_at_least_one_required_master_devices() + { + val protocol = createEmptyProtocol().apply { + addMasterDevice( StubMasterDeviceDescriptor( "Required", isOptional = false ) ) + addMasterDevice( StubMasterDeviceDescriptor( "Optional", isOptional = true ) ) + } + + val warning = OnlyOptionalDevicesWarning() + assertFalse( warning.isIssuePresent( protocol ) ) + } +} diff --git a/carp.protocols.core/src/commonTest/kotlin/dk/cachet/carp/protocols/infrastructure/StudyProtocolSnapshotTest.kt b/carp.protocols.core/src/commonTest/kotlin/dk/cachet/carp/protocols/infrastructure/StudyProtocolSnapshotTest.kt index 408e69df5..8780e04bd 100644 --- a/carp.protocols.core/src/commonTest/kotlin/dk/cachet/carp/protocols/infrastructure/StudyProtocolSnapshotTest.kt +++ b/carp.protocols.core/src/commonTest/kotlin/dk/cachet/carp/protocols/infrastructure/StudyProtocolSnapshotTest.kt @@ -1,5 +1,6 @@ package dk.cachet.carp.protocols.infrastructure +import dk.cachet.carp.common.application.data.DataType import dk.cachet.carp.common.application.devices.MasterDeviceDescriptor import dk.cachet.carp.common.application.tasks.Measure import dk.cachet.carp.common.infrastructure.serialization.CustomDeviceDescriptor @@ -119,9 +120,9 @@ class StudyProtocolSnapshotTest // (1) Add unknown master with unknown sampling configuration and unknown connected device. val unknownSamplingConfiguration = StubSamplingConfiguration( "Unknown" ) val samplingConfiguration = mapOf( - STUB_DATA_TYPE to unknownSamplingConfiguration + DataType( "unknown", "type" ) to unknownSamplingConfiguration ) - val master = StubMasterDeviceDescriptor( "Unknown", samplingConfiguration ) + val master = StubMasterDeviceDescriptor( "Unknown", false, samplingConfiguration ) protocol.addMasterDevice( master ) val connected = StubDeviceDescriptor( "Unknown 2" ) protocol.addConnectedDevice( connected, master ) diff --git a/carp.protocols.core/src/jvmTest/kotlin/dk/cachet/carp/protocols/domain/StudyProtocolReflectionTest.kt b/carp.protocols.core/src/jvmTest/kotlin/dk/cachet/carp/protocols/domain/StudyProtocolReflectionTest.kt new file mode 100644 index 000000000..824b3e6ec --- /dev/null +++ b/carp.protocols.core/src/jvmTest/kotlin/dk/cachet/carp/protocols/domain/StudyProtocolReflectionTest.kt @@ -0,0 +1,37 @@ +package dk.cachet.carp.protocols.domain + +import dk.cachet.carp.protocols.domain.deployment.DeploymentIssue +import dk.cachet.carp.protocols.infrastructure.test.createEmptyProtocol +import dk.cachet.carp.test.findConcreteTypes +import kotlin.reflect.KClass +import kotlin.reflect.jvm.isAccessible +import kotlin.test.* + + +class StudyProtocolReflectionTest +{ + @Test + fun all_deployment_issues_are_registered() + { + val protocol = createEmptyProtocol() + + // Get all registered deployment issues. + val member = + StudyProtocol::class.members.first { it.name == "possibleDeploymentIssues" } + member.isAccessible = true + @Suppress( "UNCHECKED_CAST" ) + val registeredDeploymentIssues = member.call( protocol ) as? List + assertNotNull( registeredDeploymentIssues ) + val registeredDeploymentIssueTypes = registeredDeploymentIssues.map { it::class } + + val definedDeploymentIssues: List> = findConcreteTypes() + + // For each defined deployment issue, verify whether it is registered in `StudyProtocol`. + definedDeploymentIssues.forEach { + assertTrue( + it in registeredDeploymentIssueTypes, + "`$it` is not registered as a possible deployment issue in `StudyProtocol`." + ) + } + } +} diff --git a/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/RecruitmentService.kt b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/RecruitmentService.kt index faf7bcd02..7b6ad6cbb 100644 --- a/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/RecruitmentService.kt +++ b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/RecruitmentService.kt @@ -41,7 +41,9 @@ interface RecruitmentService : ApplicationService /** - * Deploy the study with the given [studyId] to a [group] of previously added participants. + * Create a new participant [group] of previously added participants and instantly send out invitations + * to participate in the study with the given [studyId]. + * * In case a group with the same participants has already been deployed and is still running (not stopped), * the latest status for this group is simply returned. * @@ -50,10 +52,10 @@ interface RecruitmentService : ApplicationService ): ParticipantGroupStatus + suspend fun inviteNewParticipantGroup( studyId: UUID, group: Set ): ParticipantGroupStatus /** * Get the status of all deployed participant groups in the study with the specified [studyId]. diff --git a/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceHost.kt b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceHost.kt index 6d06d7db1..406ce55e1 100644 --- a/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceHost.kt +++ b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceHost.kt @@ -41,7 +41,7 @@ class RecruitmentServiceHost( // Remove deployments in the deployments subsystem. val recruitment = participantRepository.getRecruitment( removed.studyId ) checkNotNull( recruitment ) - val idsToRemove = recruitment.participations.keys + val idsToRemove = recruitment.participantGroups.keys deploymentService.removeStudyDeployments( idsToRemove ) participantRepository.removeStudy( removed.studyId ) @@ -90,7 +90,9 @@ class RecruitmentServiceHost( getRecruitmentOrThrow( studyId ).participants.toList() /** - * Deploy the study with the given [studyId] to a [group] of previously added participants. + * Create a new participant [group] of previously added participants and instantly send out invitations + * to participate in the study with the given [studyId]. + * * In case a group with the same participants has already been deployed and is still running (not stopped), * the latest status for this group is simply returned. * @@ -99,10 +101,10 @@ class RecruitmentServiceHost( * - [group] is empty * - any of the participants specified in [group] does not exist * - any of the master device roles specified in [group] are not part of the configured study protocol - * - not all master devices part of the study have been assigned a participant + * - not all necessary master devices part of the study have been assigned a participant * @throws IllegalStateException when the study is not yet ready for deployment. */ - override suspend fun deployParticipantGroup( studyId: UUID, group: Set ): ParticipantGroupStatus + override suspend fun inviteNewParticipantGroup( studyId: UUID, group: Set ): ParticipantGroupStatus { val recruitment = getRecruitmentOrThrow( studyId ) val (protocol, invitations) = recruitment.createInvitations( group ) @@ -111,9 +113,9 @@ class RecruitmentServiceHost( // and that deployment is still running, return the existing group. // TODO: The same participants might be invited for different role names, which we currently cannot differentiate between. val toDeployParticipantIds = group.map { it.participantId }.toSet() - val deployedStatus = recruitment.participations.entries - .firstOrNull { (_, participations) -> - participations.map { it.id }.toSet() == toDeployParticipantIds + val deployedStatus = recruitment.participantGroups.entries + .firstOrNull { (_, group) -> + group.participantIds == toDeployParticipantIds } ?.let { deploymentService.getStudyDeploymentStatus( it.key ) } if ( deployedStatus != null && deployedStatus !is StudyDeploymentStatus.Stopped ) @@ -121,18 +123,11 @@ class RecruitmentServiceHost( return recruitment.getParticipantGroupStatus( deployedStatus ) } - // Create deployment for the participant group and send invitations. - // TODO: Assign deployment ID from `ParticipantGroup` ID? - val studyDeploymentId = UUID.randomUUID() - val deploymentStatus = deploymentService.createStudyDeployment( studyDeploymentId, protocol, invitations ) - - // Reflect that participants have been invited in the recruitment. - invitations.forEach { invitation -> - recruitment.addParticipation( - recruitment.participants.first { invitation.participantId == it.id }, - deploymentStatus.studyDeploymentId - ) - } + // Create participant group, deploy, and send invitations. + val participantGroup = recruitment.addParticipantGroup( toDeployParticipantIds ) + val deploymentStatus = deploymentService.createStudyDeployment( participantGroup.id, protocol, invitations ) + participantGroup.markAsInvited( deploymentStatus ) + participantRepository.updateRecruitment( recruitment ) return recruitment.getParticipantGroupStatus( deploymentStatus ) @@ -148,7 +143,7 @@ class RecruitmentServiceHost( val recruitment: Recruitment = getRecruitmentOrThrow( studyId ) // Get study deployment statuses. - val studyDeploymentIds = recruitment.participations.keys + val studyDeploymentIds = recruitment.participantGroups.keys val studyDeploymentStatuses: List = if ( studyDeploymentIds.isEmpty() ) emptyList() else deploymentService.getStudyDeploymentStatusList( studyDeploymentIds ) @@ -174,7 +169,7 @@ class RecruitmentServiceHost( private suspend fun getRecruitmentWithGroupOrThrow( studyId: UUID, groupId: UUID ): Recruitment { val recruitment: Recruitment = getRecruitmentOrThrow( studyId ) - val participations = recruitment.participations[ groupId ] + val participations = recruitment.participantGroups[ groupId ] requireNotNull( participations ) { "Study deployment with the specified groupId not found." } return recruitment diff --git a/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/users/ParticipantGroupStatus.kt b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/users/ParticipantGroupStatus.kt index a179d4fe5..984fcc887 100644 --- a/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/users/ParticipantGroupStatus.kt +++ b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/users/ParticipantGroupStatus.kt @@ -3,26 +3,133 @@ package dk.cachet.carp.studies.application.users import dk.cachet.carp.common.application.UUID import dk.cachet.carp.deployments.application.StudyDeploymentStatus import dk.cachet.carp.deployments.domain.StudyDeployment +import dk.cachet.carp.deployments.domain.users.ParticipantGroup +import kotlinx.datetime.Instant import kotlinx.serialization.Serializable /** - * A group of one or more [Participant]s participating in a [StudyDeployment]. + * A group of one or more [participants] which is first [Staged] to later be [Invited] to a [StudyDeployment]. + * Once [Invited], the participant group is [InDeployment] and will get the state [Running] once the deployment is running, + * until the deployment is [Stopped]. */ @Serializable -data class ParticipantGroupStatus( +sealed class ParticipantGroupStatus +{ /** - * The deployment status associated with this participant group. + * The ID of this participant group, which is equivalent to the ID of the associated study deployment once deployed. */ - val studyDeploymentStatus: StudyDeploymentStatus, + abstract val id: UUID + /** - * The participants that are part of this deployment. + * The participants that are part of this group. */ - val participants: Set -) -{ + abstract val participants: Set + + + /** + * The [participants] have not yet been invited. The list of participants can still be modified. + */ + @Serializable + data class Staged( + override val id: UUID, + override val participants: Set + ) : ParticipantGroupStatus() + + + /** + * A [ParticipantGroup] that has been invited to a [StudyDeployment]. + */ + @Serializable + sealed class InDeployment : ParticipantGroupStatus() + { + companion object + { + /** + * Initialize an [InDeployment] state for a group of [participants] based on [deploymentStatus]. + */ + fun fromDeploymentStatus( + participants: Set, + deploymentStatus: StudyDeploymentStatus + ): InDeployment + { + val id = deploymentStatus.studyDeploymentId + val createdOn: Instant = deploymentStatus.createdOn + val startedOn: Instant? = deploymentStatus.startedOn + + return when ( deploymentStatus ) + { + is StudyDeploymentStatus.Invited, + is StudyDeploymentStatus.DeployingDevices -> + // If deployment was ready at one point (`startedOn`), consider the study 'Running'. + if ( startedOn == null ) Invited( id, participants, createdOn, deploymentStatus ) + else Running( id, participants, createdOn, deploymentStatus, startedOn ) + is StudyDeploymentStatus.Running -> + Running( id, participants, createdOn, deploymentStatus, checkNotNull( startedOn ) ) + is StudyDeploymentStatus.Stopped -> + Stopped( id, participants, createdOn, deploymentStatus, startedOn, deploymentStatus.stoppedOn ) + } + } + } + + + /** + * The time at which the participant group was invited. + */ + abstract val invitedOn: Instant + /** + * The deployment status of the study deployment the participants were invited to. + */ + abstract val studyDeploymentStatus: StudyDeploymentStatus + } + + + /** + * The [participants] have been invited to a study deployment which isn't [Running] or hasn't been [Stopped] yet. + * More details are on the study deployment state are available in [studyDeploymentStatus]. + */ + @Serializable + data class Invited( + override val id: UUID, + override val participants: Set, + override val invitedOn: Instant, + override val studyDeploymentStatus: StudyDeploymentStatus + ) : InDeployment() + + /** + * The study deployment is [StudyDeploymentStatus.Running], + * of which more details are available in [studyDeploymentStatus]. + */ + @Serializable + data class Running( + override val id: UUID, + override val participants: Set, + override val invitedOn: Instant, + override val studyDeploymentStatus: StudyDeploymentStatus, + /** + * The time when the study deployment started running, i.e., when all devices were deployed for the first time. + */ + val startedOn: Instant + ) : InDeployment() + /** - * The ID of this participant group, which is equivalent to the ID of the associated study deployment. + * The study deployment has [StudyDeploymentStatus.Stopped], + * of which more details are available in [studyDeploymentStatus]. */ - val id: UUID get() = studyDeploymentStatus.studyDeploymentId + @Serializable + data class Stopped( + override val id: UUID, + override val participants: Set, + override val invitedOn: Instant, + override val studyDeploymentStatus: StudyDeploymentStatus, + /** + * The time when the study deployment was ready for the first time (all devices deployed), + * or null in case this was never the case. + */ + val startedOn: Instant?, + /** + * The time when the study deployment was stopped. + */ + val stoppedOn: Instant + ) : InDeployment() } diff --git a/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/domain/users/Recruitment.kt b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/domain/users/Recruitment.kt index 991e45d54..979bd45ac 100644 --- a/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/domain/users/Recruitment.kt +++ b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/domain/users/Recruitment.kt @@ -6,9 +6,9 @@ import dk.cachet.carp.common.application.users.EmailAccountIdentity import dk.cachet.carp.common.domain.AggregateRoot import dk.cachet.carp.common.domain.DomainEvent 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.StudyInvitation -import dk.cachet.carp.deployments.application.throwIfInvalid import dk.cachet.carp.protocols.application.StudyProtocolSnapshot import dk.cachet.carp.studies.application.users.AssignParticipantDevices import dk.cachet.carp.studies.application.users.Participant @@ -25,7 +25,7 @@ class Recruitment( val studyId: UUID ) : sealed class Event : DomainEvent() { data class ParticipantAdded( val participant: Participant ) : Event() - data class ParticipationAdded( val participant: Participant, val studyDeploymentId: UUID ) : Event() + data class ParticipantGroupAdded( val participantIds: Set ) : Event() } @@ -40,12 +40,7 @@ class Recruitment( val studyId: UUID ) : recruitment.lockInStudy( snapshot.studyProtocol, snapshot.invitation ) } snapshot.participants.forEach { recruitment._participants.add( it ) } - for ( (deploymentId, participantIds) in snapshot.participations ) - { - recruitment._participations[ deploymentId ] = participantIds - .map { id -> recruitment.participants.first { it.id == id } } - .toMutableSet() - } + snapshot.participantGroups.forEach { recruitment._participantGroups[ it.key ] = it.value } return recruitment } @@ -141,34 +136,38 @@ class Recruitment( val studyId: UUID ) : ) } val protocol = status.studyProtocol - protocol.throwIfInvalid( invitations ) + protocol.throwIfInvalidInvitations( invitations ) return Pair( protocol, invitations ) } /** - * Per study deployment ID, the set of participants that participate in it. + * Per study deployment ID, the group of participants that participates in it. */ - val participations: Map> - get() = _participations + val participantGroups: Map + get() = _participantGroups - private val _participations: MutableMap> = mutableMapOf() + private val _participantGroups: MutableMap = mutableMapOf() /** - * Specify that [participant] of this recruitment participates in the study deployment with [studyDeploymentId]. + * Create and add the participants identified by [participantIds] as a participant group. * - * @throws IllegalArgumentException when [participant] is not a participant in this recruitment. + * @throws IllegalArgumentException when one or more of the participants aren't in this recruitment. * @throws IllegalStateException when the study is not yet ready for deployment. */ - fun addParticipation( participant: Participant, studyDeploymentId: UUID ) + fun addParticipantGroup( participantIds: Set ): StagedParticipantGroup { - require( participant in participants ) { "The participant is not part of this recruitment." } + require( participantIds.all { id -> id in participants.map { it.id } } ) + { "One of the participants for which to create a participant group isn't part of this recruitment." } check( getStatus() is RecruitmentStatus.ReadyForDeployment ) { "The study is not yet ready for deployment." } - _participations - .getOrPut( studyDeploymentId ) { mutableSetOf() } - .add( participant ) - .eventIf( true ) { Event.ParticipationAdded( participant, studyDeploymentId ) } + val group = StagedParticipantGroup() + group.addParticipants( participantIds ) + + _participantGroups[ group.id ] = group + event( Event.ParticipantGroupAdded( participantIds ) ) + + return group } /** @@ -179,11 +178,11 @@ class Recruitment( val studyId: UUID ) : fun getParticipantGroupStatus( studyDeploymentStatus: StudyDeploymentStatus ): ParticipantGroupStatus { val deploymentId = studyDeploymentStatus.studyDeploymentId - val participants: Set = _participations.getOrElse( deploymentId ) { emptySet() } - require( participations.isNotEmpty() ) + val group: StagedParticipantGroup = requireNotNull( _participantGroups[ deploymentId ] ) { "A study deployment with ID \"$deploymentId\" is not part of this recruitment." } - return ParticipantGroupStatus( studyDeploymentStatus, participants ) + val participants = group.participantIds.map { id -> _participants.first { it.id == id } } + return ParticipantGroupStatus.InDeployment.fromDeploymentStatus( participants.toSet(), studyDeploymentStatus ) } /** diff --git a/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/domain/users/RecruitmentSnapshot.kt b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/domain/users/RecruitmentSnapshot.kt index 04e053ccb..0dd11d3f3 100644 --- a/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/domain/users/RecruitmentSnapshot.kt +++ b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/domain/users/RecruitmentSnapshot.kt @@ -16,10 +16,7 @@ data class RecruitmentSnapshot( val studyProtocol: StudyProtocolSnapshot?, val invitation: StudyInvitation?, val participants: Set = emptySet(), - /** - * Per study deployment ID, the IDs of the participants participating in it. - */ - val participations: Map> = emptyMap() + val participantGroups: Map = emptyMap() ) : Snapshot { companion object @@ -37,9 +34,7 @@ data class RecruitmentSnapshot( studyProtocol = status.studyProtocol invitation = status.invitation } - val participations = recruitment.participations.mapValues { - (_, participants) -> participants.map { it.id }.toSet() - } + val participations = recruitment.participantGroups return RecruitmentSnapshot( recruitment.studyId, diff --git a/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/domain/users/StagedParticipantGroup.kt b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/domain/users/StagedParticipantGroup.kt new file mode 100644 index 000000000..028a53b26 --- /dev/null +++ b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/domain/users/StagedParticipantGroup.kt @@ -0,0 +1,64 @@ +package dk.cachet.carp.studies.domain.users + +import dk.cachet.carp.common.application.UUID +import dk.cachet.carp.deployments.application.StudyDeploymentStatus +import kotlinx.datetime.Instant +import kotlinx.serialization.Serializable + + +/** + * A group of participants configured during recruitment, + * intended to be deployed as a whole once configuration is completed. + */ +@Serializable +data class StagedParticipantGroup( + /** + * The identifier for this participant group, used as deployment ID once the participant group is deployed. + */ + val id: UUID = UUID.randomUUID() +) +{ + private val _participantIds: MutableSet = mutableSetOf() + val participantIds: Set + get() = _participantIds + + /** + * The time at which the participant group was invited. + */ + var invitedOn: Instant? = null + private set + + /** + * Determines whether this participant group has been deployed. + */ + val isDeployed: Boolean + get() = invitedOn != null + + + + /** + * Add participants with [participantIds] to this group. + * This is only allowed when the group hasn't been deployed yet. + * + * @throws IllegalStateException when this participant group is already deployed. + */ + fun addParticipants( participantIds: Set ) + { + check( !isDeployed ) { "Can't add participant after a participant group has been deployed." } + + _participantIds.addAll( participantIds ) + } + + /** + * Specify that a deployment with [deploymentStatus] for this participant group has been created, + * and thus the participants have been invited. + * + * @throws IllegalStateException when no participants to invite are specified. + */ + fun markAsInvited( deploymentStatus: StudyDeploymentStatus ) + { + check( participantIds.isNotEmpty() ) { "No participants specified to deploy." } + + invitedOn = deploymentStatus.createdOn + } +} diff --git a/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/infrastructure/RecruitmentServiceRequest.kt b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/infrastructure/RecruitmentServiceRequest.kt index 873de3823..122a21425 100644 --- a/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/infrastructure/RecruitmentServiceRequest.kt +++ b/carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/infrastructure/RecruitmentServiceRequest.kt @@ -39,9 +39,9 @@ sealed class RecruitmentServiceRequest RecruitmentServiceInvoker> by createServiceInvoker( RecruitmentService::getParticipants, studyId ) @Serializable - data class DeployParticipantGroup( val studyId: UUID, val group: Set ) : + data class InviteNewParticipantGroup( val studyId: UUID, val group: Set ) : RecruitmentServiceRequest(), - RecruitmentServiceInvoker by createServiceInvoker( RecruitmentService::deployParticipantGroup, studyId, group ) + RecruitmentServiceInvoker by createServiceInvoker( RecruitmentService::inviteNewParticipantGroup, studyId, group ) @Serializable data class GetParticipantGroupStatusList( val studyId: UUID ) : diff --git a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/StudiesCodeSamples.kt b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/StudiesCodeSamples.kt index 94191107c..675e24e82 100644 --- a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/StudiesCodeSamples.kt +++ b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/StudiesCodeSamples.kt @@ -9,7 +9,6 @@ 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.application.StudyDeploymentStatus import dk.cachet.carp.deployments.infrastructure.InMemoryDeploymentRepository import dk.cachet.carp.protocols.application.StudyProtocolSnapshot import dk.cachet.carp.protocols.domain.ProtocolOwner @@ -65,8 +64,8 @@ class StudiesCodeSamples 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. } } diff --git a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/HostsIntegrationTest.kt b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/HostsIntegrationTest.kt index 707ab8501..9b7fcad7f 100644 --- a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/HostsIntegrationTest.kt +++ b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/HostsIntegrationTest.kt @@ -93,7 +93,7 @@ class HostsIntegrationTest // Call succeeding means recruitment is ready for deployment. val assignDevices = setOf( AssignParticipantDevices( participant.id, setOf( "Device" ) ) ) - recruitmentService.deployParticipantGroup( study.studyId, assignDevices ) + recruitmentService.inviteNewParticipantGroup( study.studyId, assignDevices ) assertEquals( study.studyId, studyGoneLive?.study?.studyId ) } @@ -105,8 +105,8 @@ class HostsIntegrationTest // Add participant and deploy participant group. val participant = recruitmentService.addParticipant( studyId, EmailAddress( "test@test.com" ) ) val assignDevices = AssignParticipantDevices( participant.id, setOf( deviceRole ) ) - val group = recruitmentService.deployParticipantGroup( studyId, setOf( assignDevices ) ) - val deploymentId = group.studyDeploymentStatus.studyDeploymentId + val group = recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignDevices ) ) + val deploymentId = group.id var studyRemovedEvent: StudyService.Event.StudyRemoved? = null eventBus.registerHandler( StudyService::class, StudyService.Event.StudyRemoved::class, this ) { studyRemovedEvent = it } diff --git a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceMock.kt b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceMock.kt index cf9361184..746e32361 100644 --- a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceMock.kt +++ b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceMock.kt @@ -10,6 +10,7 @@ import dk.cachet.carp.studies.application.users.AssignParticipantDevices import dk.cachet.carp.studies.application.users.Participant import dk.cachet.carp.studies.application.users.ParticipantGroupStatus import dk.cachet.carp.test.Mock +import kotlinx.datetime.Clock // TODO: Due to a bug, `Service` cannot be used here, although that would be preferred. // Change this once this is fixed: https://youtrack.jetbrains.com/issue/KT-24700 @@ -27,9 +28,11 @@ class RecruitmentServiceMock( { companion object { - private val groupStatus = ParticipantGroupStatus( - StudyDeploymentStatus.Invited( UUID.randomUUID(), emptyList(), null ), - emptySet() ) + private val now = Clock.System.now() + private val groupStatus = ParticipantGroupStatus.InDeployment.fromDeploymentStatus( + emptySet(), + StudyDeploymentStatus.Invited( now, UUID.randomUUID(), emptyList(), emptyList(), null ) + ) } @@ -45,9 +48,9 @@ class RecruitmentServiceMock( getParticipantsResult .also { trackSuspendCall( RecruitmentService::getParticipants, studyId ) } - override suspend fun deployParticipantGroup( studyId: UUID, group: Set ) = + override suspend fun inviteNewParticipantGroup( studyId: UUID, group: Set ) = deployParticipantResult - .also { trackSuspendCall( RecruitmentService::deployParticipantGroup, studyId, group ) } + .also { trackSuspendCall( RecruitmentService::inviteNewParticipantGroup, studyId, group ) } override suspend fun getParticipantGroupStatusList( studyId: UUID ) = getParticipantGroupStatusListResult diff --git a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceTest.kt b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceTest.kt index 708fdd86b..63aae45a0 100644 --- a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceTest.kt +++ b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/RecruitmentServiceTest.kt @@ -5,11 +5,11 @@ import dk.cachet.carp.common.application.UUID import dk.cachet.carp.common.application.data.input.CarpInputDataTypes import dk.cachet.carp.common.application.devices.Smartphone import dk.cachet.carp.common.application.users.ParticipantAttribute -import dk.cachet.carp.deployments.application.StudyDeploymentStatus import dk.cachet.carp.protocols.application.StudyProtocolSnapshot import dk.cachet.carp.protocols.domain.ProtocolOwner import dk.cachet.carp.protocols.domain.StudyProtocol import dk.cachet.carp.studies.application.users.AssignParticipantDevices +import dk.cachet.carp.studies.application.users.ParticipantGroupStatus import dk.cachet.carp.studies.application.users.StudyOwner import dk.cachet.carp.test.runSuspendTest import kotlin.test.* @@ -87,14 +87,14 @@ interface RecruitmentServiceTest } @Test - fun deployParticipantGroup_succeeds() = runSuspendTest { + fun inviteNewParticipantGroup_succeeds() = runSuspendTest { val (recruitmentService, studyService) = createService() val (studyId, protocolSnapshot) = createLiveStudy( studyService ) val participant = recruitmentService.addParticipant( studyId, EmailAddress( "test@test.com" ) ) val deviceRoles = protocolSnapshot.masterDevices.map { it.roleName }.toSet() val assignParticipant = AssignParticipantDevices( participant.id, deviceRoles ) - val groupStatus = recruitmentService.deployParticipantGroup( studyId, setOf( assignParticipant ) ) + val groupStatus = recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignParticipant ) ) assertEquals( participant, groupStatus.participants.single() ) val participantGroups = recruitmentService.getParticipantGroupStatusList( studyId ) val participantInGroup = participantGroups.single().participants.single() @@ -102,26 +102,26 @@ interface RecruitmentServiceTest } @Test - fun deployParticipantGroup_fails_for_unknown_studyId() = runSuspendTest { + fun inviteNewParticipantGroup_fails_for_unknown_studyId() = runSuspendTest { val (recruitmentService, _) = createService() val assignParticipant = AssignParticipantDevices( UUID.randomUUID(), setOf( "Test device" ) ) assertFailsWith { - recruitmentService.deployParticipantGroup( unknownId, setOf( assignParticipant ) ) + recruitmentService.inviteNewParticipantGroup( unknownId, setOf( assignParticipant ) ) } } @Test - fun deployParticipantGroup_fails_for_empty_group() = runSuspendTest { + fun inviteNewParticipantGroup_fails_for_empty_group() = runSuspendTest { val (recruitmentService, studyService) = createService() val (studyId, _) = createLiveStudy( studyService ) - assertFailsWith { recruitmentService.deployParticipantGroup( studyId, setOf() ) } + assertFailsWith { recruitmentService.inviteNewParticipantGroup( studyId, setOf() ) } } @Test - fun deployParticipantGroup_fails_for_unknown_participants() = runSuspendTest { + fun inviteNewParticipantGroup_fails_for_unknown_participants() = runSuspendTest { val (recruitmentService, studyService) = createService() val (studyId, protocolSnapshot) = createLiveStudy( studyService ) @@ -129,12 +129,12 @@ interface RecruitmentServiceTest val assignParticipant = AssignParticipantDevices( unknownId, deviceRoles ) assertFailsWith { - recruitmentService.deployParticipantGroup( studyId, setOf( assignParticipant ) ) + recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignParticipant ) ) } } @Test - fun deployParticipantGroup_fails_for_unknown_device_roles() = runSuspendTest { + fun inviteNewParticipantGroup_fails_for_unknown_device_roles() = runSuspendTest { val (recruitmentService, studyService) = createService() val (studyId, _) = createLiveStudy( studyService ) val participant = recruitmentService.addParticipant( studyId, EmailAddress( "test@test.com" ) ) @@ -142,12 +142,12 @@ interface RecruitmentServiceTest val assignParticipant = AssignParticipantDevices( participant.id, setOf( "Unknown device" ) ) assertFailsWith { - recruitmentService.deployParticipantGroup( studyId, setOf( assignParticipant ) ) + recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignParticipant ) ) } } @Test - fun deployParticipantGroup_fails_when_not_all_devices_assigned() = runSuspendTest { + fun inviteNewParticipantGroup_fails_when_not_all_devices_assigned() = runSuspendTest { val (recruitmentService, studyService) = createService() val (studyId, _) = createLiveStudy( studyService ) val participant = recruitmentService.addParticipant( studyId, EmailAddress( "test@test.com" ) ) @@ -155,36 +155,36 @@ interface RecruitmentServiceTest val assignParticipant = AssignParticipantDevices( participant.id, setOf() ) assertFailsWith { - recruitmentService.deployParticipantGroup( studyId, setOf( assignParticipant ) ) + recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignParticipant ) ) } } @Test - fun deployParticipantGroup_multiple_times_returns_same_group() = runSuspendTest { + fun inviteNewParticipantGroup_multiple_times_returns_same_group() = runSuspendTest { val (recruitmentService, studyService) = createService() val (studyId, protocolSnapshot) = createLiveStudy( studyService ) val participant = recruitmentService.addParticipant( studyId, EmailAddress( "test@test.com" ) ) val deviceRoles = protocolSnapshot.masterDevices.map { it.roleName }.toSet() val assignParticipant = AssignParticipantDevices( participant.id, deviceRoles ) - val groupStatus = recruitmentService.deployParticipantGroup( studyId, setOf( assignParticipant ) ) + val groupStatus = recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignParticipant ) ) // Deploy the same group a second time. - val groupStatus2 = recruitmentService.deployParticipantGroup( studyId, setOf( assignParticipant ) ) + val groupStatus2 = recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignParticipant ) ) assertEquals( groupStatus, groupStatus2 ) } @Test - fun deployParticipantGroup_for_previously_stopped_group_returns_new_group() = runSuspendTest { + fun inviteNewParticipantGroup_for_previously_stopped_group_returns_new_group() = runSuspendTest { val (recruitmentService, studyService) = createService() val (studyId, protocolSnapshot) = createLiveStudy( studyService ) val participant = recruitmentService.addParticipant( studyId, EmailAddress( "test@test.com" ) ) val deviceRoles = protocolSnapshot.masterDevices.map { it.roleName }.toSet() val assignParticipant = AssignParticipantDevices( participant.id, deviceRoles ) - val groupStatus = recruitmentService.deployParticipantGroup( studyId, setOf( assignParticipant ) ) + val groupStatus = recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignParticipant ) ) // Stop previous group. A new deployment with the same participants should be a new participant group. recruitmentService.stopParticipantGroup( studyId, groupStatus.id ) - val groupStatus2 = recruitmentService.deployParticipantGroup( studyId, setOf( assignParticipant ) ) + val groupStatus2 = recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignParticipant ) ) assertNotEquals( groupStatus, groupStatus2 ) } @@ -196,11 +196,11 @@ interface RecruitmentServiceTest val p1 = recruitmentService.addParticipant( studyId, EmailAddress( "test@test.com" ) ) val assignedP1 = AssignParticipantDevices( p1.id, deviceRoles ) - recruitmentService.deployParticipantGroup( studyId, setOf( assignedP1 ) ) + recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignedP1 ) ) val p2 = recruitmentService.addParticipant( studyId, EmailAddress( "test2@test.com" ) ) val assignedP2 = AssignParticipantDevices( p2.id, deviceRoles ) - recruitmentService.deployParticipantGroup( studyId, setOf( assignedP2 ) ) + recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignedP2 ) ) val participantGroups = recruitmentService.getParticipantGroupStatusList( studyId ) assertEquals( 2, participantGroups.size ) @@ -222,10 +222,10 @@ interface RecruitmentServiceTest val participant = recruitmentService.addParticipant( studyId, EmailAddress( "test@test.com" ) ) val deviceRoles = protocolSnapshot.masterDevices.map { it.roleName }.toSet() val assignParticipant = AssignParticipantDevices( participant.id, deviceRoles ) - val groupStatus = recruitmentService.deployParticipantGroup( studyId, setOf( assignParticipant ) ) + val groupStatus = recruitmentService.inviteNewParticipantGroup( studyId, setOf( assignParticipant ) ) val stoppedGroupStatus = recruitmentService.stopParticipantGroup( studyId, groupStatus.id ) - assertTrue( stoppedGroupStatus.studyDeploymentStatus is StudyDeploymentStatus.Stopped ) + assertTrue( stoppedGroupStatus is ParticipantGroupStatus.Stopped ) } @Test diff --git a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/users/ParticipantGroupStatusTest.kt b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/users/ParticipantGroupStatusTest.kt new file mode 100644 index 000000000..22f994478 --- /dev/null +++ b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/application/users/ParticipantGroupStatusTest.kt @@ -0,0 +1,41 @@ +package dk.cachet.carp.studies.application.users + +import dk.cachet.carp.common.application.UUID +import dk.cachet.carp.deployments.application.DeviceDeploymentStatus +import dk.cachet.carp.deployments.application.StudyDeploymentStatus +import dk.cachet.carp.deployments.application.users.ParticipantStatus +import kotlinx.datetime.Clock +import kotlin.test.* + + +/** + * Tests for [ParticipantGroupStatus]. + */ +class ParticipantGroupStatusTest +{ + private val now = Clock.System.now() + private val deploymentId = UUID.randomUUID() + private val devicesStatus = emptyList() + private val participants: Set = emptySet() + private val participantsStatus: List = emptyList() + + + @Test + fun fromDeploymentStatus_returns_Invited_while_deploying_devices() + { + val deployingDevices = StudyDeploymentStatus.DeployingDevices( now, deploymentId, devicesStatus, participantsStatus, null ) + + val status = ParticipantGroupStatus.InDeployment.fromDeploymentStatus( participants, deployingDevices ) + assertTrue( status is ParticipantGroupStatus.Invited ) + } + + @Test + fun fromDeploymentStatus_returns_Running_even_when_reregistering_devices() + { + val startedOn = Clock.System.now() + val redeployingDevices = StudyDeploymentStatus.DeployingDevices( now, deploymentId, devicesStatus, participantsStatus, startedOn ) + + val status = ParticipantGroupStatus.InDeployment.fromDeploymentStatus( participants, redeployingDevices ) + assertTrue( status is ParticipantGroupStatus.Running ) + } +} diff --git a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/domain/users/RecruitmentTest.kt b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/domain/users/RecruitmentTest.kt index 7414f92b9..90a63a7fd 100644 --- a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/domain/users/RecruitmentTest.kt +++ b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/domain/users/RecruitmentTest.kt @@ -7,6 +7,7 @@ import dk.cachet.carp.deployments.application.StudyDeploymentStatus import dk.cachet.carp.deployments.application.users.StudyInvitation import dk.cachet.carp.protocols.infrastructure.test.createEmptyProtocol import dk.cachet.carp.protocols.infrastructure.test.createSingleMasterDeviceProtocol +import kotlinx.datetime.Clock import kotlin.test.* @@ -16,7 +17,6 @@ import kotlin.test.* class RecruitmentTest { private val studyId = UUID.randomUUID() - private val studyDeploymentId = UUID.randomUUID() private val participantEmail = EmailAddress( "test@test.com" ) @@ -28,7 +28,7 @@ class RecruitmentTest val protocol = createEmptyProtocol() val invitation = StudyInvitation( "Test", "A study" ) recruitment.lockInStudy( protocol.getSnapshot(), invitation ) - recruitment.addParticipation( participant, studyDeploymentId ) + recruitment.addParticipantGroup( setOf( participant.id ) ) val snapshot = recruitment.getSnapshot() val fromSnapshot = Recruitment.fromSnapshot( snapshot ) @@ -36,7 +36,7 @@ class RecruitmentTest assertEquals( recruitment.studyId, fromSnapshot.studyId ) assertEquals( recruitment.getStatus(), fromSnapshot.getStatus() ) assertEquals( recruitment.participants, fromSnapshot.participants ) - assertEquals( recruitment.participations, fromSnapshot.participations ) + assertEquals( recruitment.participantGroups, fromSnapshot.participantGroups ) } @Test @@ -95,7 +95,7 @@ class RecruitmentTest } @Test - fun addParticipation_succeeds() + fun addParticipantGroup_succeeds() { val recruitment = Recruitment( studyId ) val participant = recruitment.addParticipant( participantEmail ) @@ -104,21 +104,26 @@ class RecruitmentTest assertTrue( recruitment.getStatus() is RecruitmentStatus.ReadyForDeployment ) - recruitment.addParticipation( participant, studyDeploymentId ) - assertEquals( Recruitment.Event.ParticipationAdded( participant, studyDeploymentId ), recruitment.consumeEvents().last() ) - assertEquals( participant, recruitment.participations[ studyDeploymentId ]?.singleOrNull() ) + val participantIds = setOf( participant.id ) + val group = recruitment.addParticipantGroup( participantIds ) + assertEquals( Recruitment.Event.ParticipantGroupAdded( participantIds ), recruitment.consumeEvents().last() ) + assertEquals( + participant.id, + recruitment.participantGroups[ group.id ]?.participantIds?.singleOrNull() + ) } @Test - fun addParticipation_fails_when_study_protocol_not_locked_in() + fun addParticipantGroup_fails_when_study_protocol_not_locked_in() { val recruitment = Recruitment( studyId ) val participant = recruitment.addParticipant( participantEmail ) assertFalse( recruitment.getStatus() is RecruitmentStatus.ReadyForDeployment ) - assertFailsWith { recruitment.addParticipation( participant, studyDeploymentId ) } - val participationEvents = recruitment.consumeEvents().filterIsInstance() + val participantIds = setOf( participant.id ) + assertFailsWith { recruitment.addParticipantGroup( participantIds ) } + val participationEvents = recruitment.consumeEvents().filterIsInstance() assertEquals( 0, participationEvents.count() ) } @@ -129,12 +134,13 @@ class RecruitmentTest val participant = recruitment.addParticipant( participantEmail ) val protocol = createEmptyProtocol() recruitment.lockInStudy( protocol.getSnapshot(), StudyInvitation( "Some study" ) ) - recruitment.addParticipation( participant, studyDeploymentId ) + val group = recruitment.addParticipantGroup( setOf( participant.id ) ) - val stubDeploymentStatus = StudyDeploymentStatus.DeployingDevices( studyDeploymentId, emptyList(), null ) + val stubDeploymentStatus = + StudyDeploymentStatus.DeployingDevices( Clock.System.now(), group.id, emptyList(), emptyList(), null ) val groupStatus = recruitment.getParticipantGroupStatus( stubDeploymentStatus ) - assertEquals( studyDeploymentId, groupStatus.id ) + assertEquals( group.id, groupStatus.id ) assertEquals( setOf( participant ), groupStatus.participants ) } @@ -144,7 +150,8 @@ class RecruitmentTest val recruitment = Recruitment( studyId ) val unknownId = UUID.randomUUID() - val stubDeploymentStatus = StudyDeploymentStatus.DeployingDevices( unknownId, emptyList(), null ) + val stubDeploymentStatus = + StudyDeploymentStatus.DeployingDevices( Clock.System.now(), unknownId, emptyList(), emptyList(), null ) assertFailsWith { recruitment.getParticipantGroupStatus( stubDeploymentStatus ) } diff --git a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/domain/users/StagedParticipantGroupTest.kt b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/domain/users/StagedParticipantGroupTest.kt new file mode 100644 index 000000000..fce63a255 --- /dev/null +++ b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/domain/users/StagedParticipantGroupTest.kt @@ -0,0 +1,64 @@ +package dk.cachet.carp.studies.domain.users + +import dk.cachet.carp.common.application.UUID +import dk.cachet.carp.deployments.application.StudyDeploymentStatus +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlin.test.* + + +/** + * Tests for [StagedParticipantGroup]. + */ +class StagedParticipantGroupTest +{ + @Test + fun addParticipants_succeeds() + { + val group = StagedParticipantGroup() + val participantId = UUID.randomUUID() + group.addParticipants( setOf( participantId ) ) + + assertEquals( participantId, group.participantIds.singleOrNull() ) + } + + @Test + fun addParticipants_fails_when_already_deployed() + { + val group = StagedParticipantGroup() + val participantId = UUID.randomUUID() + group.addParticipants( setOf( participantId ) ) + val stubInvitedStatus = + StudyDeploymentStatus.Invited( Clock.System.now(), UUID.randomUUID(), emptyList(), emptyList(), Clock.System.now() ) + group.markAsInvited( stubInvitedStatus ) + + val newParticipantId = UUID.randomUUID() + assertFailsWith { group.addParticipants( setOf( newParticipantId ) ) } + } + + @Test + fun markAsInvited_succeeds() + { + val group = StagedParticipantGroup() + val participantId = UUID.randomUUID() + group.addParticipants( setOf( participantId ) ) + assertFalse( group.isDeployed ) + + val expectedInvitedOn: Instant = Clock.System.now() + val mockInvitedStatus = + StudyDeploymentStatus.Invited( expectedInvitedOn, UUID.randomUUID(), emptyList(), emptyList(), Clock.System.now() ) + group.markAsInvited( mockInvitedStatus ) + + assertEquals( expectedInvitedOn, group.invitedOn ) + assertTrue( group.isDeployed ) + } + + @Test + fun markAsInvited_fails_when_no_participants_are_added() + { + val group = StagedParticipantGroup() + val stubInvitedStatus = + StudyDeploymentStatus.Invited( Clock.System.now(), UUID.randomUUID(), emptyList(), emptyList(), Clock.System.now() ) + assertFailsWith { group.markAsInvited( stubInvitedStatus ) } + } +} diff --git a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/infrastructure/ParticipantGroupStatusTest.kt b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/infrastructure/ParticipantGroupStatusTest.kt index 10bb5b79b..74646f5f7 100644 --- a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/infrastructure/ParticipantGroupStatusTest.kt +++ b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/infrastructure/ParticipantGroupStatusTest.kt @@ -6,6 +6,7 @@ import dk.cachet.carp.common.infrastructure.serialization.JSON import dk.cachet.carp.deployments.application.StudyDeploymentStatus import dk.cachet.carp.studies.application.users.Participant import dk.cachet.carp.studies.application.users.ParticipantGroupStatus +import kotlinx.datetime.Clock import kotlin.test.* @@ -18,9 +19,9 @@ class ParticipantGroupStatusTest fun can_serialize_and_deserialize_ParticipantGroupStatus_using_JSON() { val studyDeploymentId = UUID.randomUUID() - val deploymentStatus = StudyDeploymentStatus.Invited( studyDeploymentId, listOf(), null ) + val deploymentStatus = StudyDeploymentStatus.Invited( Clock.System.now(), studyDeploymentId, listOf(), emptyList(), null ) val participants = setOf( Participant( AccountIdentity.fromEmailAddress( "test@test.com" ) ) ) - val groupStatus = ParticipantGroupStatus( deploymentStatus, participants ) + val groupStatus = ParticipantGroupStatus.InDeployment.fromDeploymentStatus( participants, deploymentStatus ) val serialized: String = JSON.encodeToString( ParticipantGroupStatus.serializer(), groupStatus ) val parsed: ParticipantGroupStatus = JSON.decodeFromString( ParticipantGroupStatus.serializer(), serialized ) diff --git a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/infrastructure/RecruitmentServiceRequestsTest.kt b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/infrastructure/RecruitmentServiceRequestsTest.kt index afa64b4cc..9a17d8ebd 100644 --- a/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/infrastructure/RecruitmentServiceRequestsTest.kt +++ b/carp.studies.core/src/commonTest/kotlin/dk/cachet/carp/studies/infrastructure/RecruitmentServiceRequestsTest.kt @@ -25,7 +25,7 @@ class RecruitmentServiceRequestsTest : ApplicationServiceRequestsTest findConcreteTypes(): List> +{ + val klass = T::class + check( klass.isAbstract ) + val namespace = klass.java.`package`.name + + return Reflections( namespace ) + .getSubTypesOf( klass.java ) + .filter { type -> + // Only verify concrete types. + !Modifier.isAbstract( type.modifiers ) && !Modifier.isInterface( type.modifiers ) && + // Ignore private types since they are not part of the API. + Modifier.isPublic( type.modifiers ) + } + .map { it.kotlin } +} diff --git a/carp.test/src/jvmMain/kotlin/dk/cachet/carp/test/serialization/SerializerRegistrationTest.kt b/carp.test/src/jvmMain/kotlin/dk/cachet/carp/test/serialization/SerializerRegistrationTest.kt deleted file mode 100644 index 5548b1262..000000000 --- a/carp.test/src/jvmMain/kotlin/dk/cachet/carp/test/serialization/SerializerRegistrationTest.kt +++ /dev/null @@ -1,42 +0,0 @@ -package dk.cachet.carp.test.serialization - -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.modules.SerializersModule -import org.reflections.Reflections -import java.lang.reflect.Modifier -import kotlin.jvm.internal.Reflection -import kotlin.test.* - - -/** - * Verifies whether all extending types of the interface or abstract class [T] - * are registered for polymorphic serialization in [serializersModule]. - * - * It is assumed all extending classes are located in the same namespace. - */ -@ExperimentalSerializationApi -inline fun verifyTypesAreRegistered( serializersModule: SerializersModule ) -{ - val klass = T::class - check( klass.isAbstract ) - val namespace = klass.java.`package`.name - - val polymorphicSerializers = getPolymorphicSerializers( serializersModule ) - - val reflections = Reflections( namespace ) - reflections - .getSubTypesOf( klass.java ) - .filter { serializable -> - // Wrappers for unknown types are only used at runtime and don't need to be serializable. - serializable.interfaces.none { it.simpleName == "UnknownPolymorphicWrapper" } && - // Only verify concrete types. - !Modifier.isAbstract( serializable.modifiers ) && !Modifier.isInterface( serializable.modifiers ) && - // Ignore private types since they are not part of the API. - Modifier.isPublic( serializable.modifiers ) - } - .forEach { - val kotlinClass = Reflection.createKotlinClass( it ) - val serializer = polymorphicSerializers[ kotlinClass ] - assertNotNull( serializer, "No serializer registered for '$it'." ) - } -} diff --git a/docs/carp-clients.md b/docs/carp-clients.md index f3c7f4ae6..b2626d1b8 100644 --- a/docs/carp-clients.md +++ b/docs/carp-clients.md @@ -7,8 +7,8 @@ Integrations with sensors are loaded through a 'device data collector' plug-in s [`ClientManager`](../carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/ClientManager.kt) is the main entry point into this subsystem. Concrete devices extend on it, e.g., [`SmartphoneClient`](../carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/SmartphoneClient.kt) manages data collection on a smartphone. -## Study runtime state +## Study state -[`StudyRuntimeStatus`](../carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyRuntimeStatus.kt) represents the status of a single study which runs on `ClientManager`. +[`StudyStatus`](../carp.clients.core/src/commonMain/kotlin/dk/cachet/carp/clients/domain/StudyStatus.kt) represents the status of a single study which runs on `ClientManager`. -![Study deployment state machine](https://i.imgur.com/aBbsgqx.png) \ No newline at end of file +![Study state machine](https://i.imgur.com/fi348XB.png) \ No newline at end of file diff --git a/docs/carp-common.md b/docs/carp-common.md index 43b2d16dc..2cadc2118 100644 --- a/docs/carp-common.md +++ b/docs/carp-common.md @@ -15,14 +15,16 @@ All of the built-in data types belong to the namespace: **dk.cachet.carp**. | --- | --- | | [freeformtext](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/FreeFormText.kt) | Text of which the interpretation is left up to the specific application. | | [geolocation](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/Geolocation.kt) | Geographic location data, representing longitude and latitude. | +| [stepcount](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/StepCount.kt) | The number of steps a participant has taken in a specified time interval. | | [ecg](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/ECG.kt) | Electrocardiogram data of a single lead. | | [heartrate](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/HeartRate.kt) | Number of heart contractions (beats) per minute. | | [rrinterval](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/RRInterval.kt) | The time interval between two consecutive heartbeats (R-R interval). | | [sensorskincontact](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/SensorSkinContact.kt) | Whether a sensor requiring contact with skin is making proper contact at a specific point in time. | -| [stepcount](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/StepCount.kt) | The number of steps a participant has taken in a specified time interval. | -| [acceleration](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/Acceleration.kt) | Acceleration along perpendicular x, y, and z axes. | +| [nongravitationalacceleration](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/NonGravitationalAcceleration.kt) | Acceleration excluding gravity along perpendicular x, y, and z axes. | +| [angularvelocity](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/AngularVelocity.kt) | Rate of rotation around perpendicular x, y, and z axes. | | [signalstrength](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/SignalStrength.kt) | The received signal strength of a wireless device. | | [triggeredtask](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/TriggeredTask.kt) | A task which was started or stopped by a trigger, referring to identifiers in the study protocol. | +| [completedtask](../carp.common/src/commonMain/kotlin/dk/cachet/carp/common/application/data/CompletedTask.kt) | An interactive task which was completed over the course of a specified time interval. | ## Device descriptors diff --git a/docs/carp-deployments.md b/docs/carp-deployments.md index 7bc30da12..c21b1311d 100644 --- a/docs/carp-deployments.md +++ b/docs/carp-deployments.md @@ -10,7 +10,7 @@ The events between `DeploymentService` and `ParticipationService` are opaque to The application and domain services in `carp.studies` and `carp.client` abstract away this entire sequence. Matching code for these calls can be found in the main README as part of the [carp.deployments](../README.md#example-deployments) and [carp.client](../README.md#example-client) examples. -![Study deployment sequence diagram](https://i.imgur.com/T9VjzQm.png) +![Study deployment sequence diagram](https://i.imgur.com/EVRQ5wb.png) ## Study and device deployment state @@ -18,12 +18,12 @@ Most of the [the `DeploymentService` endpoints](#application-services) return th Depending on the current state of the deployment, different operations are available. This is represented by [`StudyDeploymentStatus`](../carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/StudyDeploymentStatus.kt), which reflects the underlying state machine: -![Study deployment state machine](https://i.imgur.com/HGpF8BI.png) +![Study deployment state machine](https://i.imgur.com/GTk5OHe.png) The overall deployment state depends on the aggregate of individual device deployment states. Each device within the study deployment has a corresponding [`DeviceDeploymentStatus`](../carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/DeviceDeploymentStatus.kt): -![Device deployment state machine](https://i.imgur.com/Mkb78W4.png) +![Device deployment state machine](https://i.imgur.com/acxD0Vw.png) ## Application services @@ -43,7 +43,7 @@ Allows deploying study protocols to participants and retrieving master device de | `registerDevice` | Register a device for a study deployment. | in deployment: `studyDeploymentId` | | | `unregisterDevice` | Unregister a device for a study deployment. | in deployment: `studyDeploymentId` | | | `getDeviceDeploymentFor` | Get the deployment configuration for a master device in a study deployment. | in deployment: `studyDeploymentId` | | -| `deploymentSuccessful` | Indicate to stakeholders in a study deployment that a master device was deployed successfully, i.e., that the study deployment was loaded on the device and that the necessary runtime is available to run it. | in deployment: `studyDeploymentId` | | +| `deviceDeployed` | Indicate to stakeholders in a study deployment that a master device was deployed successfully, i.e., that the study deployment was loaded on the device and that the necessary runtime is available to run it. | in deployment: `studyDeploymentId` | | | `stop` | Stop a study deployment. No further changes to this deployment will be allowed and no more data will be collected. | in deployment: `studyDeploymentId` | | ### [`ParticipationService`](../carp.deployments.core/src/commonMain/kotlin/dk/cachet/carp/deployments/application/ParticipationService.kt) diff --git a/docs/carp-studies.md b/docs/carp-studies.md index 4a7dfc208..f02c3d808 100644 --- a/docs/carp-studies.md +++ b/docs/carp-studies.md @@ -3,6 +3,26 @@ Supports management of research studies, including the recruitment of participants and assigning metadata (e.g., contact information). This subsystem maps pseudonymized data (managed by the 'deployments' subsystem) to actual participants. +## Study and participant group state + +Most of [the `StudyService` endpoints](#application-services) return the current status of the study after the requested operation has executed. +Depending on the current state of the study, different operations are available. This is represented by [`StudyStatus`](../carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/StudyStatus.kt). + +When a study is first created, you can configure it (`StudyStatus.Configuring`), e.g., set a description and study protocol. +Once the study goes live (`StudyService.goLive()`), it can be deployed to participant groups (`StudyStatus.Live`), +but, the study description and protocol can no longer be modified; they are "locked in". + +Participant groups are managed through `RecruitmentService`. The status of participant groups is represented by [`ParticipantGroupStatus`](../carp.studies.core/src/commonMain/kotlin/dk/cachet/carp/studies/application/users/ParticipantGroupStatus.kt), +which reflects the underlying state machine: + +![Participant group state machine](https://i.imgur.com/VIv3HKk.png) + +Note: `createParticipantGroup` and `inviteParticipantGroup` are envisioned new endpoints currently not yet available. + +Once a participant group is `InDeployment`, the state of the underlying `studyDeploymentStatus` determines the concrete `ParticipantGroupStatus`. +Calling `RecruitmentService.stopParticipantGroup()` will stop the underlying deployment, but the deployment can also be stopped by participants in the study. +More detailed information about the study deployment process, e.g., the remaining devices to register, can be retrieved through `studyDeploymentStatus`. + ## Application services The _'Require'_ and _'Grant'_ column lists claim-based authorization recommendations for implementing infrastructures. @@ -34,6 +54,6 @@ Allows setting recruitment goals, adding participants to studies, and creating d | `addParticipant` | Add a participant identified by a specified email address to a study. | manage study: `studyId` | | | `getParticipant` | Returns the participant with a specified ID for a study. | manage study: `studyId` | | | `getParticipants` | Get all participants for a study. | manage study: `studyId` | | -| `deployParticipantGroup` | Deploy a study to a group of previously added participants. | manage study: `studyId` | | +| `inviteNewParticipantGroup` | Create and instantly invite a group of previously added participants to a study. | manage study: `studyId` | | | `getParticipantGroupStatusList` | Get the status of all deployed participant groups in a study. | manage study: `studyId` | | | `stopParticipantGroup` | Stop the study deployment in a study of a participant group. No further changes to this deployment will be allowed and no more data will be collected. | manage study: `studyId` | | \ No newline at end of file diff --git a/typescript-declarations/@types/carp.core-kotlin-carp.common/index.d.ts b/typescript-declarations/@types/carp.core-kotlin-carp.common/index.d.ts index aae0edb53..b19c820cb 100644 --- a/typescript-declarations/@types/carp.core-kotlin-carp.common/index.d.ts +++ b/typescript-declarations/@types/carp.core-kotlin-carp.common/index.d.ts @@ -3,6 +3,7 @@ declare module 'carp.core-kotlin-carp.common' import { kotlin } from 'kotlin' import HashMap = kotlin.collections.HashMap import HashSet = kotlin.collections.HashSet + import Duration = kotlin.time.Duration import { kotlinx } from 'kotlinx-serialization-kotlinx-serialization-json-js-legacy' import Json = kotlinx.serialization.json.Json @@ -23,7 +24,6 @@ declare module 'carp.core-kotlin-carp.common' } interface EmailAddress$Companion { serializer(): any } - class NamespacedId { constructor( namespace: string, name: string ) @@ -35,6 +35,25 @@ declare module 'carp.core-kotlin-carp.common' } interface NamespacedId$Companion { serializer(): any } + class RecurrenceRule + { + static get Companion(): RecurrenceRule$Companion + + toString(): String + } + interface RecurrenceRule$Companion + { + serializer(): any + fromString_61zpoe$( rrule: String ): RecurrenceRule + } + + class TimeOfDay + { + constructor( hour: Number, minutes: Number, seconds: Number ) + + static get Companion(): TimeOfDay$Companion + } + interface TimeOfDay$Companion { serializer(): any } class Trilean { @@ -45,7 +64,6 @@ declare module 'carp.core-kotlin-carp.common' } function toTrilean_1v8dcc$( bool: boolean ): Trilean - class UUID { constructor( stringRepresentation: string ) @@ -64,6 +82,11 @@ declare module 'carp.core-kotlin-carp.common' namespace dk.cachet.carp.common.application.devices { + abstract class DeviceDescriptor + { + readonly roleName: String + } + abstract class DeviceRegistration { static get Companion(): DeviceRegistration$Companion @@ -78,13 +101,77 @@ declare module 'carp.core-kotlin-carp.common' constructor( deviceId: string ) } - class Smartphone + class Smartphone extends DeviceDescriptor { constructor( roleName: string, defaultSamplingConfiguration: HashMap ) } } + namespace dk.cachet.carp.common.application.tasks + { + abstract class TaskDescriptor + { + readonly name: String + readonly description?: String + } + + class WebTask extends TaskDescriptor + { + constructor( name: String, measures: any, description: String, url: String ) + + static get Companion(): WebTask$Companion + + readonly url: String + } + interface WebTask$Companion { serializer(): any } + } + + + namespace dk.cachet.carp.common.application.triggers + { + abstract class Trigger + { + readonly requiresMasterDevice: Boolean + readonly sourceDeviceRoleName: String + } + + class ElapsedTimeTrigger extends Trigger + { + constructor( sourceDeviceRoleName: String, elapsedTime: Duration ) + + readonly elapsedTime: Duration + + } + + class ManualTrigger extends Trigger + { + constructor( sourceDeviceRoleName: String, label: String, description?: String ) + + readonly label: String + readonly description?: String + } + + class ScheduledTrigger extends Trigger + { + constructor( sourceDeviceRoleName: String, time: TimeOfDay, recurrenceRule: RecurrenceRule ) + + readonly time: TimeOfDay + readonly recurrenceRule: RecurrenceRule + } + + class TaskControl + { + constructor( triggerId: Number, taskName: String, destinationDeviceRoleName: String, control: Number ) + + readonly triggerId: Number + readonly taskName: String + readonly destinationDeviceRoleName: String + readonly control: Number + } + } + + namespace dk.cachet.carp.common.application.users { import InputElement = dk.cachet.carp.common.application.data.input.elements.InputElement diff --git a/typescript-declarations/@types/carp.core-kotlin-carp.deployments.core/index.d.ts b/typescript-declarations/@types/carp.core-kotlin-carp.deployments.core/index.d.ts index 0f256dcd5..7e9ddf743 100644 --- a/typescript-declarations/@types/carp.core-kotlin-carp.deployments.core/index.d.ts +++ b/typescript-declarations/@types/carp.core-kotlin-carp.deployments.core/index.d.ts @@ -20,10 +20,13 @@ declare module 'carp.core-kotlin-carp.deployments.core' namespace dk.cachet.carp.deployments.application { + import ParticipantStatus = dk.cachet.carp.deployments.application.users.ParticipantStatus + + abstract class DeviceDeploymentStatus { readonly device: any - readonly requiresDeployment: Boolean + readonly canBeDeployed: Boolean readonly canObtainDeviceDeployment: Boolean static get Companion(): DeviceDeploymentStatus$Companion @@ -32,52 +35,39 @@ declare module 'carp.core-kotlin-carp.deployments.core' namespace DeviceDeploymentStatus { - interface NotDeployed + abstract class NotDeployed { - readonly requiresDeployment: Boolean readonly isReadyForDeployment: Boolean readonly remainingDevicesToRegisterToObtainDeployment: HashSet readonly remainingDevicesToRegisterBeforeDeployment: HashSet } - class Unregistered extends DeviceDeploymentStatus implements NotDeployed + class Unregistered extends NotDeployed { constructor( device: any, - requiresDeployment: Boolean, + canBeDeployed: Boolean, remainingDevicesToRegisterToObtainDeployment: HashSet, remainingDevicesToRegisterBeforeDeployment: HashSet ) - - readonly isReadyForDeployment: Boolean - readonly remainingDevicesToRegisterToObtainDeployment: HashSet - readonly remainingDevicesToRegisterBeforeDeployment: HashSet } - class Registered extends DeviceDeploymentStatus implements NotDeployed + class Registered extends NotDeployed { constructor( device: any, - requiresDeployment: Boolean, + canBeDeployed: Boolean, remainingDevicesToRegisterToObtainDeployment: HashSet, remainingDevicesToRegisterBeforeDeployment: HashSet ) - - readonly isReadyForDeployment: Boolean - readonly remainingDevicesToRegisterToObtainDeployment: HashSet - readonly remainingDevicesToRegisterBeforeDeployment: HashSet } class Deployed extends DeviceDeploymentStatus { constructor( device: any ) } - class NeedsRedeployment extends DeviceDeploymentStatus implements NotDeployed + class NeedsRedeployment extends NotDeployed { constructor( device: any, remainingDevicesToRegisterToObtainDeployment: HashSet, remainingDevicesToRegisterBeforeDeployment: HashSet ) - - readonly isReadyForDeployment: Boolean - readonly remainingDevicesToRegisterToObtainDeployment: HashSet - readonly remainingDevicesToRegisterBeforeDeployment: HashSet } } @@ -86,7 +76,7 @@ declare module 'carp.core-kotlin-carp.deployments.core' { constructor( deviceDescriptor: any, - configuration: DeviceRegistration, + registration: DeviceRegistration, connectedDevices?: HashSet, connectedDeviceConfigurations?: HashMap, tasks?: HashSet, @@ -97,9 +87,9 @@ declare module 'carp.core-kotlin-carp.deployments.core' static get Companion(): MasterDeviceDeployment$Companion readonly deviceDescriptor: any - readonly configuration: DeviceRegistration + readonly registration: DeviceRegistration readonly connectedDevices: HashSet - readonly connectedDeviceConfigurations: HashMap + readonly connectedDeviceRegistrations: HashMap readonly tasks: HashSet readonly triggers: HashMap readonly taskControls: HashSet @@ -110,8 +100,10 @@ declare module 'carp.core-kotlin-carp.deployments.core' abstract class StudyDeploymentStatus { + readonly createdOn: Instant readonly studyDeploymentId: UUID readonly devicesStatus: ArrayList + readonly participantsStatus: ArrayList readonly startedOn: Instant | null static get Companion(): StudyDeploymentStatus$Companion @@ -122,19 +114,21 @@ declare module 'carp.core-kotlin-carp.deployments.core' { class Invited extends StudyDeploymentStatus { - constructor( studyDeploymentId: UUID, devicesStatus: ArrayList, startedOn: Instant | null ) + constructor( createdOn: Instant, studyDeploymentId: UUID, devicesStatus: ArrayList, participantsStatus: ArrayList, startedOn: Instant | null ) } class DeployingDevices extends StudyDeploymentStatus { - constructor( studyDeploymentId: UUID, devicesStatus: ArrayList, startedOn: Instant | null ) + constructor( createdOn: Instant, studyDeploymentId: UUID, devicesStatus: ArrayList, participantsStatus: ArrayList, startedOn: Instant | null ) } - class DeploymentReady extends StudyDeploymentStatus + class Running extends StudyDeploymentStatus { - constructor( studyDeploymentId: UUID, devicesStatus: ArrayList, startedOn: Instant | null ) + constructor( createdOn: Instant, studyDeploymentId: UUID, devicesStatus: ArrayList, participantsStatus: ArrayList, startedOn: Instant ) } class Stopped extends StudyDeploymentStatus { - constructor( studyDeploymentId: UUID, devicesStatus: ArrayList, startedOn: Instant | null ) + constructor( createdOn: Instant, studyDeploymentId: UUID, devicesStatus: ArrayList, participantsStatus: ArrayList, startedOn: Instant | null, stoppedOn: Instant ) + + readonly stoppedOn: Instant } } } @@ -200,6 +194,17 @@ declare module 'carp.core-kotlin-carp.deployments.core' } interface ParticipantInvitation$Companion { serializer(): any } + class ParticipantStatus + { + constructor( participantId: UUID, assignedMasterDeviceRoleNames: HashSet ) + + readonly participantId: UUID + readonly assignedMasterDeviceRoleNames: HashSet + + static get Companion(): ParticipantStatus$Companion + } + interface ParticipantStatus$Companion { serializer(): any } + class StudyInvitation { constructor( name: string, description?: string | null, applicationData?: string | null ) @@ -254,7 +259,7 @@ declare module 'carp.core-kotlin-carp.deployments.core' { constructor( studyDeploymentId: UUID, masterDeviceRoleName: string ) } - class DeploymentSuccessful extends DeploymentServiceRequest + class DeviceDeployed extends DeploymentServiceRequest { constructor( studyDeploymentId: UUID, masterDeviceRoleName: string, deviceDeploymentLastUpdatedOn: Instant ) } diff --git a/typescript-declarations/@types/carp.core-kotlin-carp.protocols.core/index.d.ts b/typescript-declarations/@types/carp.core-kotlin-carp.protocols.core/index.d.ts index 73e55297e..7357ac1f0 100644 --- a/typescript-declarations/@types/carp.core-kotlin-carp.protocols.core/index.d.ts +++ b/typescript-declarations/@types/carp.core-kotlin-carp.protocols.core/index.d.ts @@ -2,13 +2,18 @@ declare module 'carp.core-kotlin-carp.protocols.core' { import { kotlin } from 'kotlin' import HashSet = kotlin.collections.HashSet + import HashMap = kotlin.collections.HashMap import { kotlinx as kxd } from 'Kotlin-DateTime-library-kotlinx-datetime-js-legacy' import Instant = kxd.datetime.Instant import { dk as cdk } from 'carp.core-kotlin-carp.common' import UUID = cdk.cachet.carp.common.application.UUID + import DeviceDescriptor = cdk.cachet.carp.common.application.devices.DeviceDescriptor import ParticipantAttribute = cdk.cachet.carp.common.application.users.ParticipantAttribute + import TaskDescriptor = cdk.cachet.carp.common.application.tasks.TaskDescriptor + import TaskControl = cdk.cachet.carp.common.application.triggers.TaskControl + import Trigger = cdk.cachet.carp.common.application.triggers.Trigger namespace dk.cachet.carp.protocols.application @@ -47,6 +52,10 @@ declare module 'carp.core-kotlin-carp.protocols.core' readonly id: StudyProtocolId readonly description: string readonly createdOn: Instant + readonly masterDevices: HashSet + readonly tasks: HashSet + readonly triggers: HashMap + readonly taskControls: Set readonly expectedParticipantData: HashSet } interface StudyProtocolSnapshot$Companion { serializer(): any } diff --git a/typescript-declarations/@types/carp.core-kotlin-carp.studies.core/index.d.ts b/typescript-declarations/@types/carp.core-kotlin-carp.studies.core/index.d.ts index 5548bce61..c522ce8ff 100644 --- a/typescript-declarations/@types/carp.core-kotlin-carp.studies.core/index.d.ts +++ b/typescript-declarations/@types/carp.core-kotlin-carp.studies.core/index.d.ts @@ -112,17 +112,60 @@ declare module 'carp.core-kotlin-carp.studies.core' interface Participant$Companion { serializer(): any } - class ParticipantGroupStatus + abstract class ParticipantGroupStatus { - constructor( studyDeploymentStatus: StudyDeploymentStatus, participants: HashSet ) - static get Companion(): ParticipantGroupStatus$Companion - readonly studyDeploymentStatus: StudyDeploymentStatus + readonly id: UUID readonly participants: HashSet } interface ParticipantGroupStatus$Companion { serializer(): any } + namespace ParticipantGroupStatus + { + class Staged extends ParticipantGroupStatus + { + constructor( id: UUID, participants: HashSet ) + } + abstract class InDeployment extends ParticipantGroupStatus + { + readonly invitedOn: Instant + readonly studyDeploymentStatus: StudyDeploymentStatus + } + class Invited extends InDeployment + { + constructor( + id: UUID, + participants: HashSet, + invitedOn: Instant, + studyDeploymentStatus: StudyDeploymentStatus ) + } + class Running extends InDeployment + { + constructor( + id: UUID, + participants: HashSet, + invitedOn: Instant, + studyDeploymentStatus: StudyDeploymentStatus, + startedOn: Instant ) + + readonly startedOn: Instant + } + class Stopped extends InDeployment + { + constructor( + id: UUID, + participants: HashSet, + invitedOn: Instant, + studyDeploymentStatus: StudyDeploymentStatus, + startedOn: Instant | null, + stoppedOn: Instant ) + + readonly startedOn: Instant | null + readonly stoppedOn: Instant + } + } + class StudyOwner { @@ -209,7 +252,7 @@ declare module 'carp.core-kotlin-carp.studies.core' { constructor( studyId: UUID ) } - class DeployParticipantGroup extends RecruitmentServiceRequest + class InviteNewParticipantGroup extends RecruitmentServiceRequest { constructor( studyId: UUID, group: HashSet ) } diff --git a/typescript-declarations/package-lock.json b/typescript-declarations/package-lock.json index 305d51ecb..6011dbc14 100644 --- a/typescript-declarations/package-lock.json +++ b/typescript-declarations/package-lock.json @@ -419,6 +419,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "big.js": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-6.1.1.tgz", + "integrity": "sha512-1vObw81a8ylZO5ePrtMay0n018TcftpTA5HFKDaSuiUDBo8biRBtjIobw60OpwuvrGk+FsxKamqN4cnmj/eXdg==" + }, "binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", diff --git a/typescript-declarations/package.json b/typescript-declarations/package.json index b5f8af928..c45d29ba3 100644 --- a/typescript-declarations/package.json +++ b/typescript-declarations/package.json @@ -3,7 +3,8 @@ "tsc": "tsc" }, "dependencies": { - "@js-joda/core": "3.2.0" + "@js-joda/core": "3.2.0", + "big.js": "6.1.1" }, "devDependencies": { "@types/chai": "4.2.21", diff --git a/typescript-declarations/tests/carp.common.ts b/typescript-declarations/tests/carp.common.ts index b71bae404..cc7ee147d 100644 --- a/typescript-declarations/tests/carp.common.ts +++ b/typescript-declarations/tests/carp.common.ts @@ -2,25 +2,34 @@ import { expect } from 'chai' import VerifyModule from './VerifyModule' import { kotlin } from 'kotlin' -import toSet = kotlin.collections.toSet_us0mfu$ +import toSet = kotlin.collections.toSet_us0mfu$; +import Duration = kotlin.time.Duration; import { dk } from 'carp.core-kotlin-carp.common' -import EmailAddress = dk.cachet.carp.common.application.EmailAddress -import NamespacedId = dk.cachet.carp.common.application.NamespacedId -import Trilean = dk.cachet.carp.common.application.Trilean -import UUID = dk.cachet.carp.common.application.UUID -import toTrilean = dk.cachet.carp.common.application.toTrilean_1v8dcc$ -import DefaultDeviceRegistration = dk.cachet.carp.common.application.devices.DefaultDeviceRegistration -import CarpInputDataTypes = dk.cachet.carp.common.application.data.input.CarpInputDataTypes -import SelectOne = dk.cachet.carp.common.application.data.input.elements.SelectOne -import Text = dk.cachet.carp.common.application.data.input.elements.Text -import DeviceRegistration = dk.cachet.carp.common.application.devices.DeviceRegistration -import AccountIdentity = dk.cachet.carp.common.application.users.AccountIdentity -import EmailAccountIdentity = dk.cachet.carp.common.application.users.EmailAccountIdentity -import ParticipantAttribute = dk.cachet.carp.common.application.users.ParticipantAttribute -import Username = dk.cachet.carp.common.application.users.Username -import UsernameAccountIdentity = dk.cachet.carp.common.application.users.UsernameAccountIdentity -import emailAccountIdentityFromString = dk.cachet.carp.common.application.users.EmailAccountIdentity_init_61zpoe$ +import EmailAddress = dk.cachet.carp.common.application.EmailAddress; +import NamespacedId = dk.cachet.carp.common.application.NamespacedId; +import RecurrenceRule = dk.cachet.carp.common.application.RecurrenceRule; +import TimeOfDay = dk.cachet.carp.common.application.TimeOfDay; +import Trilean = dk.cachet.carp.common.application.Trilean; +import UUID = dk.cachet.carp.common.application.UUID; +import toTrilean = dk.cachet.carp.common.application.toTrilean_1v8dcc$; +import DefaultDeviceRegistration = dk.cachet.carp.common.application.devices.DefaultDeviceRegistration; +import DeviceRegistration = dk.cachet.carp.common.application.devices.DeviceRegistration; +import Smartphone = dk.cachet.carp.common.application.devices.Smartphone; +import WebTask = dk.cachet.carp.common.application.tasks.WebTask; +import ElapsedTimeTrigger = dk.cachet.carp.common.application.triggers.ElapsedTimeTrigger; +import ManualTrigger = dk.cachet.carp.common.application.triggers.ManualTrigger; +import ScheduledTrigger = dk.cachet.carp.common.application.triggers.ScheduledTrigger; +import TaskControl = dk.cachet.carp.common.application.triggers.TaskControl; +import CarpInputDataTypes = dk.cachet.carp.common.application.data.input.CarpInputDataTypes; +import SelectOne = dk.cachet.carp.common.application.data.input.elements.SelectOne; +import Text = dk.cachet.carp.common.application.data.input.elements.Text; +import AccountIdentity = dk.cachet.carp.common.application.users.AccountIdentity; +import EmailAccountIdentity = dk.cachet.carp.common.application.users.EmailAccountIdentity; +import ParticipantAttribute = dk.cachet.carp.common.application.users.ParticipantAttribute; +import Username = dk.cachet.carp.common.application.users.Username; +import UsernameAccountIdentity = dk.cachet.carp.common.application.users.UsernameAccountIdentity; +import emailAccountIdentityFromString = dk.cachet.carp.common.application.users.EmailAccountIdentity_init_61zpoe$; describe( "carp.common", () => { @@ -32,6 +41,9 @@ describe( "carp.common", () => { EmailAddress.Companion, new NamespacedId( "namespace", "type" ), NamespacedId.Companion, + RecurrenceRule.Companion.fromString_61zpoe$( "RRULE:FREQ=WEEKLY;COUNT=10" ), + RecurrenceRule.Companion, + TimeOfDay.Companion, UUID.Companion.randomUUID(), UUID.Companion, [ "InputElement", new Text( "How are you feeling?" ) ], @@ -39,8 +51,21 @@ describe( "carp.common", () => { SelectOne.Companion, new Text( "How are you feeling?" ), Text.Companion, + [ "DeviceDescriptor", new Smartphone( "Role", toSet( [] ) ) ], [ "DeviceRegistration", new DefaultDeviceRegistration( "some device id" ) ], DeviceRegistration.Companion, + [ "TaskDescriptor", new WebTask( "name", undefined, "", "url.com" ) ], + new WebTask( "name", undefined, "", "url.com" ), + WebTask.Companion, + [ "Trigger", new ElapsedTimeTrigger( "device", Duration.Companion.INFINITE ) ], + new ElapsedTimeTrigger( "device", Duration.Companion.INFINITE ), + new ManualTrigger( "device", "manual", "" ), + new ScheduledTrigger( + "device", + new TimeOfDay( 10, 10, 10 ), + RecurrenceRule.Companion.fromString_61zpoe$( "RRULE:FREQ=WEEKLY;COUNT=10" ) + ), + new TaskControl( 1, "name", "destination", 1 ), AccountIdentity.Factory, new EmailAccountIdentity( new EmailAddress( "test@test.com" ) ), EmailAccountIdentity.Companion, @@ -49,7 +74,7 @@ describe( "carp.common", () => { new UsernameAccountIdentity( username ), UsernameAccountIdentity.Companion, [ "ParticipantAttribute", new ParticipantAttribute.DefaultParticipantAttribute( new NamespacedId( "namespace", "type" ) ) ], - ParticipantAttribute.Companion, + ParticipantAttribute.Companion ] const moduleVerifier = new VerifyModule( 'carp.core-kotlin-carp.common', instances ) diff --git a/typescript-declarations/tests/carp.deployments.core.ts b/typescript-declarations/tests/carp.deployments.core.ts index e19fb298e..faa4111c6 100644 --- a/typescript-declarations/tests/carp.deployments.core.ts +++ b/typescript-declarations/tests/carp.deployments.core.ts @@ -5,6 +5,9 @@ import ArrayList = kotlin.collections.ArrayList import toMap = kotlin.collections.toMap_v2dak7$ import toSet = kotlin.collections.toSet_us0mfu$ +import { kotlinx as kxd } from 'Kotlin-DateTime-library-kotlinx-datetime-js-legacy' +import Clock = kxd.datetime.Clock + import { dk as dkc } from 'carp.core-kotlin-carp.common' import UUID = dkc.cachet.carp.common.application.UUID import DefaultDeviceRegistration = dkc.cachet.carp.common.application.devices.DefaultDeviceRegistration @@ -21,6 +24,7 @@ import AssignedMasterDevice = dk.cachet.carp.deployments.application.users.Assig import ParticipantData = dk.cachet.carp.deployments.application.users.ParticipantData import Participation = dk.cachet.carp.deployments.application.users.Participation import ParticipantInvitation = dk.cachet.carp.deployments.application.users.ParticipantInvitation +import ParticipantStatus = dk.cachet.carp.deployments.application.users.ParticipantStatus import StudyInvitation = dk.cachet.carp.deployments.application.users.StudyInvitation import DeploymentServiceRequest = dk.cachet.carp.deployments.infrastructure.DeploymentServiceRequest import ParticipationServiceRequest = dk.cachet.carp.deployments.infrastructure.ParticipationServiceRequest @@ -28,8 +32,10 @@ import ParticipationServiceRequest = dk.cachet.carp.deployments.infrastructure.P describe( "carp.deployments.core", () => { it( "verify module declarations", async () => { + const now = Clock.System.now() const exampleDevice = new Smartphone( "test", toMap( [] ) ) const studyInvitation = new StudyInvitation( "Some study" ) + const instances = [ DeviceDeploymentStatus.Companion, [ "DeviceDeploymentStatus", new DeviceDeploymentStatus.Unregistered( null, true, toSet( [] ), toSet( [] ) ) ], @@ -37,17 +43,17 @@ describe( "carp.deployments.core", () => { new DeviceDeploymentStatus.Registered( null, true, toSet( [] ), toSet( [] ) ), new DeviceDeploymentStatus.Deployed( null ), new DeviceDeploymentStatus.NeedsRedeployment( null, toSet( [] ), toSet( [] ) ), - [ "NotDeployed", new DeviceDeploymentStatus.Unregistered( null, true, toSet( [] ), toSet( [] ) ) ], + [ "DeviceDeploymentStatus$NotDeployed", new DeviceDeploymentStatus.Unregistered( null, true, toSet( [] ), toSet( [] ) ) ], new MasterDeviceDeployment( exampleDevice, new DefaultDeviceRegistration( "some role" ), toSet( [] ), toMap( [] ), toSet( [] ), toMap( [] ), toSet( [] ), "" ), MasterDeviceDeployment.Companion, - [ "StudyDeploymentStatus", new StudyDeploymentStatus.Invited( UUID.Companion.randomUUID(), new ArrayList( [] ), null ) ], - new StudyDeploymentStatus.Invited( UUID.Companion.randomUUID(), new ArrayList( [] ), null ), - new StudyDeploymentStatus.DeployingDevices( UUID.Companion.randomUUID(), new ArrayList( [] ), null ), - new StudyDeploymentStatus.DeploymentReady( UUID.Companion.randomUUID(), new ArrayList( [] ), null ), - new StudyDeploymentStatus.Stopped( UUID.Companion.randomUUID(), new ArrayList( [] ), null ), + [ "StudyDeploymentStatus", new StudyDeploymentStatus.Invited( now, UUID.Companion.randomUUID(), new ArrayList( [] ), new ArrayList( [] ), null ) ], + new StudyDeploymentStatus.Invited( now, UUID.Companion.randomUUID(), new ArrayList( [] ), new ArrayList( [] ), null ), + new StudyDeploymentStatus.DeployingDevices( now, UUID.Companion.randomUUID(), new ArrayList( [] ), new ArrayList( [] ), null ), + new StudyDeploymentStatus.Running( now, UUID.Companion.randomUUID(), new ArrayList( [] ), new ArrayList( [] ), now ), + new StudyDeploymentStatus.Stopped( now, UUID.Companion.randomUUID(), new ArrayList( [] ), new ArrayList( [] ), null, now ), StudyDeploymentStatus.Companion, new ActiveParticipationInvitation( new Participation( UUID.Companion.randomUUID() ), studyInvitation, toSet( [] ) ), ActiveParticipationInvitation.Companion, @@ -57,6 +63,8 @@ describe( "carp.deployments.core", () => { ParticipantData.Companion, new ParticipantInvitation( UUID.Companion.randomUUID(), toSet( [] ), new UsernameAccountIdentity( new Username( "Test" ) ), studyInvitation ), ParticipantInvitation.Companion, + new ParticipantStatus( UUID.Companion.randomUUID(), toSet( [] ) ), + ParticipantStatus.Companion, new Participation( UUID.Companion.randomUUID() ), Participation.Companion, studyInvitation, diff --git a/typescript-declarations/tests/carp.studies.core.ts b/typescript-declarations/tests/carp.studies.core.ts index d7c3758a3..9076298d5 100644 --- a/typescript-declarations/tests/carp.studies.core.ts +++ b/typescript-declarations/tests/carp.studies.core.ts @@ -24,6 +24,7 @@ import createDefaultJSON = cdk.cachet.carp.common.infrastructure.serialization.c import { dk as ddk } from 'carp.core-kotlin-carp.deployments.core' import DeviceDeploymentStatus = ddk.cachet.carp.deployments.application.DeviceDeploymentStatus import StudyDeploymentStatus = ddk.cachet.carp.deployments.application.StudyDeploymentStatus +import ParticipantStatus = ddk.cachet.carp.deployments.application.users.ParticipantStatus import StudyInvitation = ddk.cachet.carp.deployments.application.users.StudyInvitation import { dk } from 'carp.core-kotlin-carp.studies.core' @@ -41,6 +42,10 @@ import StudyServiceRequest = dk.cachet.carp.studies.infrastructure.StudyServiceR describe( "carp.studies.core", () => { it( "verify module declarations", async () => { + const deploymentId = UUID.Companion.randomUUID() + const now = Clock.System.now() + const invitedDeploymentStatus = new StudyDeploymentStatus.Invited( now, deploymentId, new ArrayList( [] ), new ArrayList( [] ), null ) + const instances = [ new StudyDetails( UUID.Companion.randomUUID(), new StudyOwner(), "Name", Clock.System.now(), "Description", new StudyInvitation( "Some study" ), null ), StudyDetails.Companion, @@ -52,10 +57,12 @@ describe( "carp.studies.core", () => { AssignParticipantDevices.Companion, new Participant( new UsernameAccountIdentity( new Username( "Test" ) ) ), Participant.Companion, - new ParticipantGroupStatus( - new StudyDeploymentStatus.Invited( UUID.Companion.randomUUID(), new ArrayList( [] ), null ), - new HashSet() - ), + [ "ParticipantGroupStatus", new ParticipantGroupStatus.Staged( deploymentId, new HashSet() ) ], + new ParticipantGroupStatus.Staged( deploymentId, new HashSet() ), + [ "ParticipantGroupStatus$InDeployment", new ParticipantGroupStatus.Invited( deploymentId, new HashSet(), now, invitedDeploymentStatus ) ], + new ParticipantGroupStatus.Invited( deploymentId, new HashSet(), now, invitedDeploymentStatus ), + new ParticipantGroupStatus.Running( deploymentId, new HashSet(), now, invitedDeploymentStatus, now ), + new ParticipantGroupStatus.Stopped( deploymentId, new HashSet(), now, invitedDeploymentStatus, null, now ), ParticipantGroupStatus.Companion, new StudyOwner(), StudyOwner.Companion, @@ -141,7 +148,7 @@ describe( "carp.studies.core", () => { describe( "RecruitmentServiceRequest", () => { it( "can serialize DeployParticipantGroup", () => { - const deployGroup = new RecruitmentServiceRequest.DeployParticipantGroup( + const deployGroup = new RecruitmentServiceRequest.InviteNewParticipantGroup( UUID.Companion.randomUUID(), toSet( [ new AssignParticipantDevices( UUID.Companion.randomUUID(), toSet( [ "Smartphone" ] ) ) @@ -164,9 +171,11 @@ describe( "carp.studies.core", () => { } ) it( "can serialize ParticipantGroupStatus", () => { - const deploymentStatus = new StudyDeploymentStatus.DeploymentReady( UUID.Companion.randomUUID(), new ArrayList( [] ), Clock.System.now() ) + const deploymentId = UUID.Companion.randomUUID() + const now = Clock.System.now() + const deploymentStatus = new StudyDeploymentStatus.Running( now, deploymentId, new ArrayList( [] ), new ArrayList( [] ), now ) const participants = toSet( [ new Participant( new UsernameAccountIdentity( new Username( "Test" ) ) ) ] ) - const group = new ParticipantGroupStatus( deploymentStatus, participants ) + const group = new ParticipantGroupStatus.Invited( deploymentId, participants, now, deploymentStatus ) const json: Json = createDefaultJSON() const serializer = ParticipantGroupStatus.Companion.serializer()