diff --git a/runtime/simulation/build.gradle.kts b/runtime/simulation/build.gradle.kts new file mode 100644 index 0000000..e8498a8 --- /dev/null +++ b/runtime/simulation/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + +kotlin { + jvmToolchain(17) +} + +dependencies { + api(project(":domain")) + implementation(libs.kotlinx.coroutines.core) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt new file mode 100644 index 0000000..3bb677e --- /dev/null +++ b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt @@ -0,0 +1,50 @@ +package com.flagship.abox.manager.simulation + +import com.flagship.abox.manager.domain.DeviceCatalog +import com.flagship.abox.manager.domain.DeviceDescriptor +import com.flagship.abox.manager.domain.DeviceId +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class SimulationDeviceCatalog : DeviceCatalog { + private val devices = ConcurrentHashMap< + ProviderProfileId, + MutableStateFlow>>, + >() + + override fun observeDevices( + profileId: ProviderProfileId, + ): Flow>> = deviceFlow(profileId) + + private fun deviceFlow( + profileId: ProviderProfileId, + ): MutableStateFlow>> = devices.getOrPut(profileId) { + MutableStateFlow(DomainResult.Success(defaultDevices(profileId))) + } + + private fun defaultDevices(profileId: ProviderProfileId): List = listOf( + DeviceDescriptor( + id = DeviceId("${profileId.value}:socket-1"), + profileId = profileId, + name = DeviceName("socket-1"), + type = DeviceType.SOCKET, + ), + DeviceDescriptor( + id = DeviceId("${profileId.value}:th-1"), + profileId = profileId, + name = DeviceName("th-1"), + type = DeviceType.TEMPERATURE_HUMIDITY, + ), + DeviceDescriptor( + id = DeviceId("${profileId.value}:ir-1"), + profileId = profileId, + name = DeviceName("ir-1"), + type = DeviceType.INFRARED, + ), + ) +} diff --git a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt new file mode 100644 index 0000000..c45d265 --- /dev/null +++ b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt @@ -0,0 +1,75 @@ +package com.flagship.abox.manager.simulation + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredCode +import com.flagship.abox.manager.domain.InfraredCodeDownloadResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.domain.SocketStatus +import com.flagship.abox.manager.domain.TemperatureHumidityGateway +import com.flagship.abox.manager.domain.TemperatureHumidityReading +import java.util.concurrent.ConcurrentHashMap + +class SimulationSocketGateway : SocketGateway { + private val states = ConcurrentHashMap() + + override suspend fun getStatus( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult { + val key = DeviceKey(profileId, deviceName) + return DomainResult.Success(SocketStatus(profileId, deviceName, states[key] ?: SocketPowerState.OFF)) + } + + override suspend fun setPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + val key = DeviceKey(profileId, deviceName) + states[key] = powerState + return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) + } +} + +class SimulationTemperatureHumidityGateway : TemperatureHumidityGateway { + override suspend fun getReading( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = DomainResult.Success( + TemperatureHumidityReading( + profileId = profileId, + deviceName = deviceName, + temperature = "23.5", + humidity = "45", + ), + ) +} + +class SimulationInfraredGateway : InfraredGateway { + override suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = DomainResult.Success(Unit) + + override suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = DomainResult.Success(Unit) + + override suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> = + DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) }) +} + +private data class DeviceKey( + val profileId: ProviderProfileId, + val deviceName: DeviceName, +) diff --git a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGateway.kt b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGateway.kt new file mode 100644 index 0000000..fb34f42 --- /dev/null +++ b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGateway.kt @@ -0,0 +1,38 @@ +package com.flagship.abox.manager.simulation + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LocalSessionGateway +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Session +import com.flagship.abox.manager.domain.SessionValidity +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class SimulationSessionGateway : LocalSessionGateway { + private val sessions = ConcurrentHashMap>() + + override fun observeSession(profileId: ProviderProfileId): Flow = flow(profileId) + + override suspend fun login( + profileId: ProviderProfileId, + credentials: LoginCredentials, + ): DomainResult = DomainResult.Success( + Session(profileId, credentials.username).also { flow(profileId).value = it }, + ) + + override suspend fun validateSession( + profileId: ProviderProfileId, + ): DomainResult = DomainResult.Success( + if (flow(profileId).value == null) SessionValidity.INVALID else SessionValidity.VALID, + ) + + override suspend fun logout(profileId: ProviderProfileId): DomainResult { + flow(profileId).value = null + return DomainResult.Success(Unit) + } + + private fun flow(profileId: ProviderProfileId): MutableStateFlow = + sessions.getOrPut(profileId) { MutableStateFlow(null) } +} diff --git a/runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGatewaysTest.kt b/runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGatewaysTest.kt new file mode 100644 index 0000000..0cafcc9 --- /dev/null +++ b/runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGatewaysTest.kt @@ -0,0 +1,54 @@ +package com.flagship.abox.manager.simulation + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.SocketPowerState +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SimulationDeviceGatewaysTest { + @Test + fun catalogProvidesSocketTemperatureHumidityAndInfraredDevicesForEachProfile() = runTest { + val profile = ProviderProfileId("local") + val devices = + (SimulationDeviceCatalog().observeDevices(profile).first() as DomainResult.Success).value + assertEquals( + setOf(DeviceType.SOCKET, DeviceType.TEMPERATURE_HUMIDITY, DeviceType.INFRARED), + devices.map { it.type }.toSet(), + ) + assertTrue(devices.all { it.profileId == profile }) + } + + @Test + fun socketStateIsMutableAndIsolatedByProfile() = runTest { + val gateway = SimulationSocketGateway() + val device = DeviceName("socket-1") + val first = ProviderProfileId("first") + val second = ProviderProfileId("second") + gateway.setPower(first, device, SocketPowerState.ON) + assertEquals( + SocketPowerState.ON, + (gateway.getStatus(first, device) as DomainResult.Success).value.powerState, + ) + assertEquals( + SocketPowerState.OFF, + (gateway.getStatus(second, device) as DomainResult.Success).value.powerState, + ) + } + + @Test + fun sensorAndInfraredOperationsSucceedWithoutNetworkConfiguration() = runTest { + val profile = ProviderProfileId("local") + val th = SimulationTemperatureHumidityGateway().getReading(profile, DeviceName("th-1")) + assertTrue(th is DomainResult.Success) + assertEquals( + DomainResult.Success(Unit), + SimulationInfraredGateway().sendKey(profile, DeviceName("ir-1"), "power"), + ) + } +} diff --git a/runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGatewayTest.kt b/runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGatewayTest.kt new file mode 100644 index 0000000..91c0289 --- /dev/null +++ b/runtime/simulation/src/test/kotlin/com/flagship/abox/manager/simulation/SimulationSessionGatewayTest.kt @@ -0,0 +1,31 @@ +package com.flagship.abox.manager.simulation + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.LoginCredentials +import com.flagship.abox.manager.domain.Password +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.SessionValidity +import com.flagship.abox.manager.domain.Username +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class SimulationSessionGatewayTest { + @Test + fun loginObserveValidateAndLogoutAreProfileScoped() = runTest { + val gateway = SimulationSessionGateway() + val first = ProviderProfileId("local-one") + val second = ProviderProfileId("local-two") + + val result = gateway.login(first, LoginCredentials(Username("local"), Password(""))) + + assertEquals(Username("local"), (result as DomainResult.Success).value.username) + assertEquals(first, gateway.observeSession(first).first()?.profileId) + assertNull(gateway.observeSession(second).first()) + assertEquals(DomainResult.Success(SessionValidity.VALID), gateway.validateSession(first)) + gateway.logout(first) + assertEquals(DomainResult.Success(SessionValidity.INVALID), gateway.validateSession(first)) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 398735c..4b9cad8 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -23,6 +23,7 @@ dependencyResolutionManagement { rootProject.name = "abox-manager" include(":domain") +include(":runtime:simulation") include(":absdk-bridge") include(":testing:fakes") include(":android:core:designsystem")