feat: establish Android project foundation
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(21)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":domain"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.flagship.abox.manager.testing.fakes
|
||||
|
||||
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.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class FakeDeviceCatalog : DeviceCatalog {
|
||||
private val devices = ConcurrentHashMap<
|
||||
ProviderProfileId,
|
||||
MutableStateFlow<DomainResult<List<DeviceDescriptor>>>,
|
||||
>()
|
||||
|
||||
override fun observeDevices(
|
||||
profileId: ProviderProfileId,
|
||||
): Flow<DomainResult<List<DeviceDescriptor>>> = deviceFlow(profileId)
|
||||
|
||||
fun setDevices(profileId: ProviderProfileId, value: List<DeviceDescriptor>) {
|
||||
require(value.all { it.profileId == profileId }) {
|
||||
"All devices must belong to the supplied provider profile"
|
||||
}
|
||||
deviceFlow(profileId).value = DomainResult.Success(value)
|
||||
}
|
||||
|
||||
fun setError(profileId: ProviderProfileId, error: DomainError) {
|
||||
deviceFlow(profileId).value = DomainResult.Failure(error)
|
||||
}
|
||||
|
||||
private fun deviceFlow(
|
||||
profileId: ProviderProfileId,
|
||||
): MutableStateFlow<DomainResult<List<DeviceDescriptor>>> = devices.getOrPut(profileId) {
|
||||
MutableStateFlow(DomainResult.Success(defaultDevices(profileId)))
|
||||
}
|
||||
|
||||
private fun defaultDevices(profileId: ProviderProfileId): List<DeviceDescriptor> = 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
package com.flagship.abox.manager.testing.fakes
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
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.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class FakeSocketGateway : SocketGateway {
|
||||
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
|
||||
private val errors = ConcurrentHashMap<DeviceKey, DomainError>()
|
||||
|
||||
override suspend fun getStatus(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): DomainResult<SocketStatus> = resultFor(profileId, deviceName)
|
||||
|
||||
override suspend fun setPower(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
powerState: SocketPowerState,
|
||||
): DomainResult<SocketStatus> {
|
||||
val key = DeviceKey(profileId, deviceName)
|
||||
errors[key]?.let { return DomainResult.Failure(it) }
|
||||
states[key] = powerState
|
||||
return DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
|
||||
}
|
||||
|
||||
fun setError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) {
|
||||
val key = DeviceKey(profileId, deviceName)
|
||||
if (error == null) errors.remove(key) else errors[key] = error
|
||||
}
|
||||
|
||||
private fun resultFor(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): DomainResult<SocketStatus> {
|
||||
val key = DeviceKey(profileId, deviceName)
|
||||
errors[key]?.let { return DomainResult.Failure(it) }
|
||||
val state = states.getOrPut(key) { SocketPowerState.OFF }
|
||||
return DomainResult.Success(SocketStatus(profileId, deviceName, state))
|
||||
}
|
||||
}
|
||||
|
||||
class FakeTemperatureHumidityGateway : TemperatureHumidityGateway {
|
||||
private val readings = ConcurrentHashMap<DeviceKey, DomainResult<TemperatureHumidityReading>>()
|
||||
|
||||
override suspend fun getReading(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): DomainResult<TemperatureHumidityReading> = readings.getOrPut(DeviceKey(profileId, deviceName)) {
|
||||
DomainResult.Success(
|
||||
TemperatureHumidityReading(
|
||||
profileId = profileId,
|
||||
deviceName = deviceName,
|
||||
temperature = "23.5",
|
||||
humidity = "45",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun setReading(reading: TemperatureHumidityReading) {
|
||||
readings[DeviceKey(reading.profileId, reading.deviceName)] = DomainResult.Success(reading)
|
||||
}
|
||||
|
||||
fun setError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError) {
|
||||
readings[DeviceKey(profileId, deviceName)] = DomainResult.Failure(error)
|
||||
}
|
||||
}
|
||||
|
||||
class FakeInfraredGateway : InfraredGateway {
|
||||
val sentKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
|
||||
val learnedKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
|
||||
val downloadedCodes: MutableList<InfraredCode> = Collections.synchronizedList(mutableListOf())
|
||||
|
||||
@Volatile
|
||||
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
||||
|
||||
@Volatile
|
||||
var learnResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
||||
|
||||
@Volatile
|
||||
var downloadError: DomainError? = null
|
||||
|
||||
override suspend fun sendKey(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
key: String,
|
||||
): DomainResult<Unit> {
|
||||
sentKeys += InfraredCall(profileId, deviceName, key)
|
||||
return sendResult
|
||||
}
|
||||
|
||||
override suspend fun learnKey(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
key: String,
|
||||
): DomainResult<Unit> {
|
||||
learnedKeys += InfraredCall(profileId, deviceName, key)
|
||||
return learnResult
|
||||
}
|
||||
|
||||
override suspend fun downloadCodes(
|
||||
profileId: ProviderProfileId,
|
||||
codes: List<InfraredCode>,
|
||||
): DomainResult<List<InfraredCodeDownloadResult>> {
|
||||
require(codes.all { it.profileId == profileId }) {
|
||||
"All infrared codes must belong to the supplied provider profile"
|
||||
}
|
||||
downloadError?.let { return DomainResult.Failure(it) }
|
||||
downloadedCodes += codes
|
||||
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
|
||||
}
|
||||
}
|
||||
|
||||
data class InfraredCall(
|
||||
val profileId: ProviderProfileId,
|
||||
val deviceName: DeviceName,
|
||||
val key: String,
|
||||
)
|
||||
|
||||
private data class DeviceKey(
|
||||
val profileId: ProviderProfileId,
|
||||
val deviceName: DeviceName,
|
||||
)
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package com.flagship.abox.manager.testing.fakes
|
||||
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
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.SessionGateway
|
||||
import com.flagship.abox.manager.domain.SessionValidity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class FakeSessionGateway : SessionGateway {
|
||||
private val sessions = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<Session?>>()
|
||||
private val loginResults = ConcurrentHashMap<ProviderProfileId, DomainResult<Session>>()
|
||||
private val validationResults = ConcurrentHashMap<ProviderProfileId, DomainResult<SessionValidity>>()
|
||||
|
||||
override fun observeSession(profileId: ProviderProfileId): Flow<Session?> = sessionFlow(profileId)
|
||||
|
||||
override suspend fun login(
|
||||
profileId: ProviderProfileId,
|
||||
credentials: LoginCredentials,
|
||||
): DomainResult<Session> {
|
||||
val result = loginResults[profileId] ?: DomainResult.Success(
|
||||
Session(profileId = profileId, username = credentials.username),
|
||||
)
|
||||
if (result is DomainResult.Success) {
|
||||
sessionFlow(profileId).value = result.value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override suspend fun validateSession(
|
||||
profileId: ProviderProfileId,
|
||||
): DomainResult<SessionValidity> = validationResults[profileId]
|
||||
?: DomainResult.Success(
|
||||
if (sessionFlow(profileId).value == null) {
|
||||
SessionValidity.INVALID
|
||||
} else {
|
||||
SessionValidity.VALID
|
||||
},
|
||||
)
|
||||
|
||||
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> {
|
||||
sessionFlow(profileId).value = null
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
fun setLoginResult(profileId: ProviderProfileId, result: DomainResult<Session>) {
|
||||
loginResults[profileId] = result
|
||||
}
|
||||
|
||||
fun setValidationResult(
|
||||
profileId: ProviderProfileId,
|
||||
result: DomainResult<SessionValidity>,
|
||||
) {
|
||||
validationResults[profileId] = result
|
||||
}
|
||||
|
||||
fun failLogin(
|
||||
profileId: ProviderProfileId,
|
||||
error: DomainError = DomainError.Authentication(),
|
||||
) {
|
||||
setLoginResult(profileId, DomainResult.Failure(error))
|
||||
}
|
||||
|
||||
private fun sessionFlow(profileId: ProviderProfileId): MutableStateFlow<Session?> =
|
||||
sessions.getOrPut(profileId) { MutableStateFlow(null) }
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.flagship.abox.manager.testing.fakes
|
||||
|
||||
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 kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Test
|
||||
|
||||
class FakeDeviceCatalogTest {
|
||||
@Test
|
||||
fun defaultDevicesAreIsolatedByProviderProfile() = runTest {
|
||||
val gateway = FakeDeviceCatalog()
|
||||
val aboxProfile = ProviderProfileId("abox")
|
||||
val localProfile = ProviderProfileId("local")
|
||||
|
||||
val aboxDevices = (gateway.observeDevices(aboxProfile).first() as DomainResult.Success).value
|
||||
val localDevices = (gateway.observeDevices(localProfile).first() as DomainResult.Success).value
|
||||
|
||||
assertEquals(3, aboxDevices.size)
|
||||
assertEquals(3, localDevices.size)
|
||||
assertNotEquals(aboxDevices.first().id, localDevices.first().id)
|
||||
assertEquals(aboxProfile, aboxDevices.first().profileId)
|
||||
assertEquals(localProfile, localDevices.first().profileId)
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException::class)
|
||||
fun setDevicesRejectsAnotherProvidersData() {
|
||||
val gateway = FakeDeviceCatalog()
|
||||
gateway.setDevices(
|
||||
ProviderProfileId("one"),
|
||||
listOf(
|
||||
DeviceDescriptor(
|
||||
id = DeviceId("two:socket"),
|
||||
profileId = ProviderProfileId("two"),
|
||||
name = DeviceName("socket"),
|
||||
type = DeviceType.SOCKET,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.flagship.abox.manager.testing.fakes
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.InfraredCode
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FakeDeviceGatewaysTest {
|
||||
private val profileOne = ProviderProfileId("one")
|
||||
private val profileTwo = ProviderProfileId("two")
|
||||
private val socket = DeviceName("socket-1")
|
||||
|
||||
@Test
|
||||
fun socketStateChangesWithoutCrossingProviderBoundary() = runTest {
|
||||
val gateway = FakeSocketGateway()
|
||||
|
||||
gateway.setPower(profileOne, socket, SocketPowerState.ON)
|
||||
|
||||
val one = gateway.getStatus(profileOne, socket) as DomainResult.Success
|
||||
val two = gateway.getStatus(profileTwo, socket) as DomainResult.Success
|
||||
assertEquals(SocketPowerState.ON, one.value.powerState)
|
||||
assertEquals(SocketPowerState.OFF, two.value.powerState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun temperatureGatewaySupportsNoDataAndErrors() = runTest {
|
||||
val gateway = FakeTemperatureHumidityGateway()
|
||||
gateway.setError(profileOne, DeviceName("empty"), DomainError.NoData())
|
||||
gateway.setError(profileOne, DeviceName("offline"), DomainError.DeviceOffline())
|
||||
|
||||
assertTrue(gateway.getReading(profileOne, DeviceName("empty")) is DomainResult.Failure)
|
||||
assertTrue(gateway.getReading(profileOne, DeviceName("offline")) is DomainResult.Failure)
|
||||
assertTrue(gateway.getReading(profileOne, DeviceName("th-1")) is DomainResult.Success)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infraredGatewayRecordsCallsAndResults() = runTest {
|
||||
val gateway = FakeInfraredGateway()
|
||||
val deviceName = DeviceName("ir-1")
|
||||
val code = InfraredCode(profileOne, deviceName, "power", "code-value")
|
||||
|
||||
gateway.sendKey(profileOne, deviceName, "power")
|
||||
gateway.learnKey(profileOne, deviceName, "volume-up")
|
||||
val download = gateway.downloadCodes(profileOne, listOf(code))
|
||||
|
||||
assertEquals(InfraredCall(profileOne, deviceName, "power"), gateway.sentKeys.single())
|
||||
assertEquals("volume-up", gateway.learnedKeys.single().key)
|
||||
assertEquals(listOf(code), gateway.downloadedCodes)
|
||||
assertTrue(download is DomainResult.Success)
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.flagship.abox.manager.testing.fakes
|
||||
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
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.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class FakeSessionGatewayTest {
|
||||
private val profileId = ProviderProfileId("abox")
|
||||
private val credentials = LoginCredentials(Username("demo"), Password("secret"))
|
||||
|
||||
@Test
|
||||
fun loginPublishesSessionAndLogoutClearsIt() = runTest {
|
||||
val gateway = FakeSessionGateway()
|
||||
|
||||
val result = gateway.login(profileId, credentials)
|
||||
|
||||
assertTrue(result is DomainResult.Success)
|
||||
assertEquals(Username("demo"), gateway.observeSession(profileId).first()?.username)
|
||||
|
||||
gateway.logout(profileId)
|
||||
|
||||
assertNull(gateway.observeSession(profileId).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedLoginDoesNotCreateSession() = runTest {
|
||||
val gateway = FakeSessionGateway().apply {
|
||||
failLogin(profileId, DomainError.Authentication(externalCode = "20004"))
|
||||
}
|
||||
|
||||
val result = gateway.login(profileId, credentials)
|
||||
|
||||
assertTrue(result is DomainResult.Failure)
|
||||
assertNull(gateway.observeSession(profileId).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validationCanSimulateInvalidToken() = runTest {
|
||||
val gateway = FakeSessionGateway().apply {
|
||||
setValidationResult(profileId, DomainResult.Success(SessionValidity.INVALID))
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
DomainResult.Success(SessionValidity.INVALID),
|
||||
gateway.validateSession(profileId),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user