Merge remote-tracking branch 'origin/main' into feat/scene-editor
This commit is contained in:
+40
-9
@@ -12,8 +12,8 @@ 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
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
|
||||
class FakeSocketGateway : SocketGateway {
|
||||
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
|
||||
@@ -78,10 +78,11 @@ class FakeTemperatureHumidityGateway : TemperatureHumidityGateway {
|
||||
}
|
||||
|
||||
class FakeInfraredGateway : InfraredGateway {
|
||||
val sentKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
|
||||
val learnedKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
|
||||
val downloadCalls: MutableList<InfraredDownloadCall> =
|
||||
Collections.synchronizedList(mutableListOf())
|
||||
val sentKeys = CopyOnWriteArrayList<InfraredCall>()
|
||||
val learnedKeys = CopyOnWriteArrayList<InfraredCall>()
|
||||
val downloadCalls = CopyOnWriteArrayList<InfraredDownloadCall>()
|
||||
private val sendResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
|
||||
private val learnResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
|
||||
|
||||
@Volatile
|
||||
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
||||
@@ -98,7 +99,7 @@ class FakeInfraredGateway : InfraredGateway {
|
||||
key: String,
|
||||
): DomainResult<Unit> {
|
||||
sentKeys += InfraredCall(profileId, deviceName, key)
|
||||
return sendResult
|
||||
return sendResults[InfraredOperationKey(profileId, deviceName, key)] ?: sendResult
|
||||
}
|
||||
|
||||
override suspend fun learnKey(
|
||||
@@ -107,16 +108,40 @@ class FakeInfraredGateway : InfraredGateway {
|
||||
key: String,
|
||||
): DomainResult<Unit> {
|
||||
learnedKeys += InfraredCall(profileId, deviceName, key)
|
||||
return learnResult
|
||||
return learnResults[InfraredOperationKey(profileId, deviceName, key)] ?: learnResult
|
||||
}
|
||||
|
||||
fun setSendResult(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
key: String,
|
||||
result: DomainResult<Unit>,
|
||||
) {
|
||||
sendResults[InfraredOperationKey(profileId, deviceName, key)] = result
|
||||
}
|
||||
|
||||
fun setLearnResult(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
key: String,
|
||||
result: DomainResult<Unit>,
|
||||
) {
|
||||
learnResults[InfraredOperationKey(profileId, deviceName, key)] = result
|
||||
}
|
||||
|
||||
val downloadedCodes: List<InfraredCode>
|
||||
get() = downloadCalls.flatMap { it.codes }
|
||||
|
||||
override suspend fun downloadCodes(
|
||||
profileId: ProviderProfileId,
|
||||
codes: List<InfraredCode>,
|
||||
): DomainResult<List<InfraredCodeDownloadResult>> {
|
||||
val codesSnapshot = codes.toList()
|
||||
downloadError?.let { return DomainResult.Failure(it) }
|
||||
downloadCalls += InfraredDownloadCall(profileId, codes)
|
||||
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
|
||||
downloadCalls += InfraredDownloadCall(profileId, codesSnapshot)
|
||||
return DomainResult.Success(
|
||||
codesSnapshot.map { InfraredCodeDownloadResult(it, successful = true) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +156,12 @@ data class InfraredDownloadCall(
|
||||
val codes: List<InfraredCode>,
|
||||
)
|
||||
|
||||
private data class InfraredOperationKey(
|
||||
val profileId: ProviderProfileId,
|
||||
val deviceName: DeviceName,
|
||||
val key: String,
|
||||
)
|
||||
|
||||
private data class DeviceKey(
|
||||
val profileId: ProviderProfileId,
|
||||
val deviceName: DeviceName,
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
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.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class FakeInfraredKeyRepository : InfraredKeyRepository {
|
||||
private val lock = Any()
|
||||
private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>()
|
||||
private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList())
|
||||
|
||||
override fun observeKeys(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): Flow<DomainResult<List<SavedInfraredKey>>> = state.map { values ->
|
||||
DomainResult.Success(
|
||||
values.filter { it.profileId == profileId && it.deviceName == deviceName },
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> =
|
||||
synchronized(lock) {
|
||||
DomainResult.Success(keys[ref])
|
||||
}
|
||||
|
||||
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> = synchronized(lock) {
|
||||
val ref = key.ref()
|
||||
if (keys.keys.any { it.keyId == key.id }) {
|
||||
return@synchronized invalidRequest("Infrared key id already exists")
|
||||
}
|
||||
if (keys.values.any {
|
||||
it.profileId == key.profileId &&
|
||||
it.deviceName == key.deviceName &&
|
||||
it.sdkKey == key.sdkKey
|
||||
}
|
||||
) {
|
||||
return@synchronized invalidRequest("Infrared key binding already exists")
|
||||
}
|
||||
keys[ref] = key
|
||||
publishLocked()
|
||||
DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun transitionStatus(
|
||||
ref: InfraredKeyRef,
|
||||
expectedStatuses: Set<InfraredKeyStatus>,
|
||||
targetStatus: InfraredKeyStatus,
|
||||
lastErrorCode: String?,
|
||||
lastErrorDetail: String?,
|
||||
updatedAtEpochMillis: Long,
|
||||
): DomainResult<Unit> = synchronized(lock) {
|
||||
val existing = keys[ref] ?: return@synchronized keyNotFound()
|
||||
if (existing.status !in expectedStatuses) {
|
||||
return@synchronized invalidRequest("Infrared key status changed")
|
||||
}
|
||||
keys[ref] = existing.copy(
|
||||
status = targetStatus,
|
||||
lastErrorCode = lastErrorCode,
|
||||
lastErrorDetail = lastErrorDetail,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
)
|
||||
publishLocked()
|
||||
DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun rename(
|
||||
ref: InfraredKeyRef,
|
||||
displayName: String,
|
||||
updatedAtEpochMillis: Long,
|
||||
): DomainResult<Unit> = synchronized(lock) {
|
||||
val existing = keys[ref] ?: return@synchronized keyNotFound()
|
||||
keys[ref] = existing.copy(
|
||||
displayName = displayName,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
)
|
||||
publishLocked()
|
||||
DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> =
|
||||
synchronized(lock) {
|
||||
val existing = keys[ref] ?: return@synchronized keyNotFound()
|
||||
if (existing.status == InfraredKeyStatus.LEARNING) {
|
||||
return@synchronized invalidRequest("Cannot delete a learning infrared key")
|
||||
}
|
||||
keys.remove(ref)
|
||||
publishLocked()
|
||||
DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun failInterruptedLearning(
|
||||
profileId: ProviderProfileId,
|
||||
cutoffEpochMillis: Long,
|
||||
updatedAtEpochMillis: Long,
|
||||
): DomainResult<Int> = synchronized(lock) {
|
||||
var changed = 0
|
||||
keys.replaceAll { _, key ->
|
||||
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) {
|
||||
changed += 1
|
||||
key.copy(
|
||||
status = InfraredKeyStatus.FAILED,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
)
|
||||
} else {
|
||||
key
|
||||
}
|
||||
}
|
||||
if (changed > 0) publishLocked()
|
||||
DomainResult.Success(changed)
|
||||
}
|
||||
|
||||
private fun publishLocked() {
|
||||
state.value = keys.values.toList()
|
||||
}
|
||||
|
||||
private fun keyNotFound(): DomainResult.Failure =
|
||||
invalidRequest("Infrared key not found")
|
||||
|
||||
private fun invalidRequest(detail: String): DomainResult.Failure =
|
||||
DomainResult.Failure(DomainError.InvalidRequest(detail = detail))
|
||||
}
|
||||
|
||||
class FakeInfraredDeviceConfigurationRepository : InfraredDeviceConfigurationRepository {
|
||||
private val lock = Any()
|
||||
private val deviceNames = MutableStateFlow<Map<ProviderProfileId, DeviceName>>(emptyMap())
|
||||
var observeError: DomainError? = null
|
||||
|
||||
override fun observeDeviceName(
|
||||
profileId: ProviderProfileId,
|
||||
): Flow<DomainResult<DeviceName?>> = deviceNames.map { values ->
|
||||
observeError?.let { return@map DomainResult.Failure(it) }
|
||||
DomainResult.Success(values[profileId])
|
||||
}
|
||||
|
||||
override suspend fun setDeviceName(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): DomainResult<Unit> = synchronized(lock) {
|
||||
val value = deviceName.value
|
||||
if (value.isBlank() || value != value.trim()) {
|
||||
return@synchronized DomainResult.Failure(
|
||||
DomainError.InvalidRequest(detail = "Infrared device name must be trimmed and non-blank"),
|
||||
)
|
||||
}
|
||||
deviceNames.value = deviceNames.value + (profileId to deviceName)
|
||||
DomainResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun SavedInfraredKey.ref() = InfraredKeyRef(
|
||||
profileId = profileId,
|
||||
keyId = id,
|
||||
)
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package com.flagship.abox.manager.testing.fakes
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class FakeVirtualRemoteRepository : VirtualRemoteRepository {
|
||||
|
||||
private val remotes = ConcurrentHashMap<VirtualRemoteId, VirtualRemote>()
|
||||
private val bindings = ConcurrentHashMap<VirtualRemoteId, MutableList<RemoteKeyBinding>>()
|
||||
private val changeTrigger = MutableStateFlow(0L)
|
||||
|
||||
override fun observeRemotes(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): Flow<DomainResult<List<VirtualRemote>>> = changeTrigger.map {
|
||||
DomainResult.Success(
|
||||
remotes.values.filter { it.profileId == profileId && it.deviceName == deviceName }
|
||||
.sortedByDescending { it.createdAtEpochMillis },
|
||||
)
|
||||
}
|
||||
|
||||
override fun observeRemote(
|
||||
profileId: ProviderProfileId,
|
||||
remoteId: VirtualRemoteId,
|
||||
): Flow<DomainResult<VirtualRemote?>> = changeTrigger.map {
|
||||
val remote = remotes[remoteId]?.takeIf { it.profileId == profileId }
|
||||
DomainResult.Success(remote)
|
||||
}
|
||||
|
||||
override fun observeKeyBindings(
|
||||
remoteId: VirtualRemoteId,
|
||||
): Flow<DomainResult<List<RemoteKeyBinding>>> = changeTrigger.map {
|
||||
DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
|
||||
}
|
||||
|
||||
override fun observeSlotQuota(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): Flow<DomainResult<SlotQuotaSummary>> = changeTrigger.map {
|
||||
val relevantBindings = remotes.values
|
||||
.filter { it.profileId == profileId && it.deviceName == deviceName }
|
||||
.flatMap { bindings[it.id].orEmpty() }
|
||||
val usedSlots = relevantBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
|
||||
DomainResult.Success(
|
||||
SlotQuotaSummary(
|
||||
totalCapacity = 100,
|
||||
dynamicScratchSlot = 100,
|
||||
assignedStaticSlots = usedSlots,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getRemote(
|
||||
profileId: ProviderProfileId,
|
||||
remoteId: VirtualRemoteId,
|
||||
): DomainResult<VirtualRemote?> {
|
||||
val remote = remotes[remoteId]?.takeIf { it.profileId == profileId }
|
||||
return DomainResult.Success(remote)
|
||||
}
|
||||
|
||||
override suspend fun getKeyBindings(
|
||||
remoteId: VirtualRemoteId,
|
||||
): DomainResult<List<RemoteKeyBinding>> =
|
||||
DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
|
||||
|
||||
override suspend fun createRemote(
|
||||
remote: VirtualRemote,
|
||||
initialBindings: List<RemoteKeyBinding>,
|
||||
): DomainResult<VirtualRemoteId> {
|
||||
remotes[remote.id] = remote
|
||||
bindings[remote.id] = initialBindings.toMutableList()
|
||||
changeTrigger.value = System.currentTimeMillis()
|
||||
return DomainResult.Success(remote.id)
|
||||
}
|
||||
|
||||
override suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit> {
|
||||
remotes[remote.id] = remote
|
||||
changeTrigger.value = System.currentTimeMillis()
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun deleteRemote(
|
||||
profileId: ProviderProfileId,
|
||||
remoteId: VirtualRemoteId,
|
||||
): DomainResult<Unit> {
|
||||
remotes.remove(remoteId)
|
||||
bindings.remove(remoteId)
|
||||
changeTrigger.value = System.currentTimeMillis()
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit> {
|
||||
val list = bindings[binding.remoteId] ?: mutableListOf()
|
||||
val index = list.indexOfFirst { it.id == binding.id || it.buttonId == binding.buttonId }
|
||||
if (index >= 0) {
|
||||
list[index] = binding
|
||||
} else {
|
||||
list.add(binding)
|
||||
}
|
||||
bindings[binding.remoteId] = list
|
||||
changeTrigger.value = System.currentTimeMillis()
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun deleteKeyBinding(
|
||||
remoteId: VirtualRemoteId,
|
||||
buttonId: String,
|
||||
): DomainResult<Unit> {
|
||||
val list = bindings[remoteId] ?: return DomainResult.Success(Unit)
|
||||
list.removeAll { it.buttonId == buttonId }
|
||||
bindings[remoteId] = list
|
||||
changeTrigger.value = System.currentTimeMillis()
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
override suspend fun updateAcState(
|
||||
profileId: ProviderProfileId,
|
||||
remoteId: VirtualRemoteId,
|
||||
state: AcStateSnapshot,
|
||||
): DomainResult<Unit> {
|
||||
val existing = remotes[remoteId] ?: return DomainResult.Success(Unit)
|
||||
remotes[remoteId] = existing.copy(acState = state)
|
||||
changeTrigger.value = System.currentTimeMillis()
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
}
|
||||
+121
@@ -4,8 +4,14 @@ 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.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -54,4 +60,119 @@ class FakeDeviceGatewaysTest {
|
||||
assertEquals(InfraredDownloadCall(profileOne, listOf(code)), gateway.downloadCalls.single())
|
||||
assertTrue(download is DomainResult.Success)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infraredDownloadRecordsAndReturnsAnInputSnapshot() = runTest {
|
||||
val gateway = FakeInfraredGateway()
|
||||
val code = InfraredCode(DeviceName("ir-1"), "power", "code-value")
|
||||
val mutableCodes = mutableListOf(code)
|
||||
|
||||
val result = gateway.downloadCodes(profileOne, mutableCodes)
|
||||
mutableCodes.clear()
|
||||
|
||||
assertEquals(
|
||||
InfraredDownloadCall(profileOne, listOf(code)),
|
||||
gateway.downloadCalls.single(),
|
||||
)
|
||||
assertEquals(
|
||||
DomainResult.Success(listOf(InfraredCodeDownloadResult(code, successful = true))),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infraredCallLogCanBeIteratedSafelyAroundConcurrentWrites() = runTest {
|
||||
val gateway = FakeInfraredGateway()
|
||||
val deviceName = DeviceName("ir-1")
|
||||
gateway.sendKey(profileOne, deviceName, "initial")
|
||||
val staleIterators = List(10) { gateway.sentKeys.iterator() }
|
||||
val start = CountDownLatch(1)
|
||||
val failures = ConcurrentLinkedQueue<Throwable>()
|
||||
val writerCount = 4
|
||||
val callsPerWriter = 100
|
||||
|
||||
val writers = List(writerCount) { writerIndex ->
|
||||
launch(Dispatchers.Default) {
|
||||
start.await()
|
||||
repeat(callsPerWriter) { callIndex ->
|
||||
gateway.sendKey(profileOne, deviceName, "$writerIndex-$callIndex")
|
||||
}
|
||||
}
|
||||
}
|
||||
val reader = launch(Dispatchers.Default) {
|
||||
start.await()
|
||||
repeat(100) {
|
||||
try {
|
||||
gateway.sentKeys.forEach { call -> call.key.length }
|
||||
} catch (failure: Throwable) {
|
||||
failures += failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start.countDown()
|
||||
(writers + reader).joinAll()
|
||||
staleIterators.forEach { iterator ->
|
||||
try {
|
||||
iterator.forEachRemaining { call -> call.key.length }
|
||||
} catch (failure: Throwable) {
|
||||
failures += failure
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(failures.toList().joinToString(), failures.isEmpty())
|
||||
assertEquals(1 + writerCount * callsPerWriter, gateway.sentKeys.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infraredLearnResultsCanDifferByProfileDeviceAndKey() = runTest {
|
||||
val gateway = FakeInfraredGateway()
|
||||
val infraredDevice = DeviceName("ir-1")
|
||||
gateway.setLearnResult(
|
||||
profileOne,
|
||||
infraredDevice,
|
||||
"power",
|
||||
DomainResult.Failure(DomainError.DeviceOffline("20206")),
|
||||
)
|
||||
|
||||
assertTrue(gateway.learnKey(profileOne, infraredDevice, "power") is DomainResult.Failure)
|
||||
assertEquals(
|
||||
DomainResult.Success(Unit),
|
||||
gateway.learnKey(profileTwo, infraredDevice, "power"),
|
||||
)
|
||||
assertEquals(
|
||||
DomainResult.Success(Unit),
|
||||
gateway.learnKey(profileOne, infraredDevice, "volume-up"),
|
||||
)
|
||||
assertEquals(
|
||||
DomainResult.Success(Unit),
|
||||
gateway.learnKey(profileOne, DeviceName("ir-2"), "power"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun infraredSendResultsCanDifferByProfileDeviceAndKey() = runTest {
|
||||
val gateway = FakeInfraredGateway()
|
||||
val infraredDevice = DeviceName("ir-1")
|
||||
gateway.setSendResult(
|
||||
profileOne,
|
||||
infraredDevice,
|
||||
"power",
|
||||
DomainResult.Failure(DomainError.DeviceOffline("20206")),
|
||||
)
|
||||
|
||||
assertTrue(gateway.sendKey(profileOne, infraredDevice, "power") is DomainResult.Failure)
|
||||
assertEquals(
|
||||
DomainResult.Success(Unit),
|
||||
gateway.sendKey(profileTwo, infraredDevice, "power"),
|
||||
)
|
||||
assertEquals(
|
||||
DomainResult.Success(Unit),
|
||||
gateway.sendKey(profileOne, infraredDevice, "volume-up"),
|
||||
)
|
||||
assertEquals(
|
||||
DomainResult.Success(Unit),
|
||||
gateway.sendKey(profileOne, DeviceName("ir-2"), "power"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
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.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.take
|
||||
import kotlinx.coroutines.flow.toList
|
||||
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 FakeInfraredKeyRepositoryTest {
|
||||
private val profileOne = ProviderProfileId("one")
|
||||
private val profileTwo = ProviderProfileId("two")
|
||||
private val deviceOne = DeviceName("ir-1")
|
||||
private val deviceTwo = DeviceName("ir-2")
|
||||
|
||||
@Test
|
||||
fun observeKeysEmitsChangesAndFiltersByProfileAndDevice() = runTest {
|
||||
val repository = FakeInfraredKeyRepository()
|
||||
val emissions = backgroundScope.async(start = CoroutineStart.UNDISPATCHED) {
|
||||
repository.observeKeys(profileOne, deviceOne).take(2).toList()
|
||||
}
|
||||
|
||||
repository.insert(savedKey(id = "matching"))
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
DomainResult.Success(emptyList<SavedInfraredKey>()),
|
||||
DomainResult.Success(listOf(savedKey(id = "matching"))),
|
||||
),
|
||||
emissions.await(),
|
||||
)
|
||||
|
||||
repository.insert(savedKey(id = "other-profile", profileId = profileTwo))
|
||||
repository.insert(savedKey(id = "other-device", deviceName = deviceTwo))
|
||||
|
||||
assertEquals(
|
||||
DomainResult.Success(listOf(savedKey(id = "matching"))),
|
||||
repository.observeKeys(profileOne, deviceOne).first(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getKeyReturnsOnlyTheMatchingProfileAndId() = runTest {
|
||||
val repository = FakeInfraredKeyRepository()
|
||||
repository.insert(savedKey(id = "power"))
|
||||
|
||||
assertEquals(
|
||||
DomainResult.Success(savedKey(id = "power")),
|
||||
repository.getKey(InfraredKeyRef(profileOne, InfraredKeyId("power"))),
|
||||
)
|
||||
assertEquals(
|
||||
DomainResult.Success(null),
|
||||
repository.getKey(InfraredKeyRef(profileTwo, InfraredKeyId("power"))),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun insertRejectsDuplicateIdAndDuplicateProviderDeviceSdkBinding() = runTest {
|
||||
val repository = FakeInfraredKeyRepository()
|
||||
assertEquals(DomainResult.Success(Unit), repository.insert(savedKey(id = "power")))
|
||||
|
||||
val duplicateId = repository.insert(
|
||||
savedKey(
|
||||
id = "power",
|
||||
profileId = profileTwo,
|
||||
deviceName = deviceTwo,
|
||||
sdkKey = "other-sdk-key",
|
||||
),
|
||||
)
|
||||
val duplicateBinding = repository.insert(
|
||||
savedKey(id = "volume", displayName = "Volume"),
|
||||
)
|
||||
|
||||
assertInvalidRequest(duplicateId)
|
||||
assertInvalidRequest(duplicateBinding)
|
||||
assertEquals(
|
||||
DomainResult.Success(listOf(savedKey(id = "power"))),
|
||||
repository.observeKeys(profileOne, deviceOne).first(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transitionStatusRequiresExpectedStatusAndUpdatesFailureContextAtomically() = runTest {
|
||||
val repository = FakeInfraredKeyRepository()
|
||||
val ref = InfraredKeyRef(profileOne, InfraredKeyId("power"))
|
||||
repository.insert(savedKey(id = "power", status = InfraredKeyStatus.LEARNING))
|
||||
|
||||
val rejected = repository.transitionStatus(
|
||||
ref = ref,
|
||||
expectedStatuses = setOf(InfraredKeyStatus.READY),
|
||||
targetStatus = InfraredKeyStatus.FAILED,
|
||||
lastErrorCode = "20206",
|
||||
lastErrorDetail = "offline",
|
||||
updatedAtEpochMillis = 20L,
|
||||
)
|
||||
assertInvalidRequest(rejected)
|
||||
assertEquals(InfraredKeyStatus.LEARNING, repository.successfulKey(ref)?.status)
|
||||
assertNull(repository.successfulKey(ref)?.lastErrorCode)
|
||||
assertNull(repository.successfulKey(ref)?.lastErrorDetail)
|
||||
|
||||
assertEquals(
|
||||
DomainResult.Success(Unit),
|
||||
repository.transitionStatus(
|
||||
ref = ref,
|
||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
||||
targetStatus = InfraredKeyStatus.FAILED,
|
||||
lastErrorCode = "20206",
|
||||
lastErrorDetail = "offline",
|
||||
updatedAtEpochMillis = 20L,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
savedKey(
|
||||
id = "power",
|
||||
status = InfraredKeyStatus.FAILED,
|
||||
lastErrorCode = "20206",
|
||||
lastErrorDetail = "offline",
|
||||
updatedAtEpochMillis = 20L,
|
||||
),
|
||||
repository.successfulKey(ref),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renameUpdatesDisplayNameAndTimestamp() = runTest {
|
||||
val repository = FakeInfraredKeyRepository()
|
||||
val ref = InfraredKeyRef(profileOne, InfraredKeyId("power"))
|
||||
repository.insert(savedKey(id = "power", status = InfraredKeyStatus.READY))
|
||||
|
||||
assertEquals(
|
||||
DomainResult.Success(Unit),
|
||||
repository.rename(ref, displayName = "Main Power", updatedAtEpochMillis = 30L),
|
||||
)
|
||||
assertEquals("Main Power", repository.successfulKey(ref)?.displayName)
|
||||
assertEquals(30L, repository.successfulKey(ref)?.updatedAtEpochMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteRejectsLearningKeysAndRemovesOtherStatuses() = runTest {
|
||||
val repository = FakeInfraredKeyRepository()
|
||||
val learningRef = InfraredKeyRef(profileOne, InfraredKeyId("learning"))
|
||||
val readyRef = InfraredKeyRef(profileOne, InfraredKeyId("ready"))
|
||||
repository.insert(savedKey(id = "learning", sdkKey = "learning", status = InfraredKeyStatus.LEARNING))
|
||||
repository.insert(savedKey(id = "ready", sdkKey = "ready", status = InfraredKeyStatus.READY))
|
||||
|
||||
assertInvalidRequest(repository.deleteIfNotLearning(learningRef))
|
||||
assertEquals(DomainResult.Success(Unit), repository.deleteIfNotLearning(readyRef))
|
||||
assertTrue(repository.successfulKey(learningRef) != null)
|
||||
assertNull(repository.successfulKey(readyRef))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failInterruptedLearningRecoversOnlyLearningKeysForOneProfileWithFixedDetail() = runTest {
|
||||
val repository = FakeInfraredKeyRepository()
|
||||
repository.insert(savedKey(id = "one-learning", sdkKey = "one-learning"))
|
||||
repository.insert(
|
||||
savedKey(
|
||||
id = "one-ready",
|
||||
sdkKey = "one-ready",
|
||||
status = InfraredKeyStatus.READY,
|
||||
),
|
||||
)
|
||||
repository.insert(
|
||||
savedKey(
|
||||
id = "two-learning",
|
||||
profileId = profileTwo,
|
||||
sdkKey = "two-learning",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
DomainResult.Success(1),
|
||||
repository.failInterruptedLearning(profileOne, updatedAtEpochMillis = 40L),
|
||||
)
|
||||
|
||||
val recovered = repository.successfulKey(
|
||||
InfraredKeyRef(profileOne, InfraredKeyId("one-learning")),
|
||||
)
|
||||
assertEquals(InfraredKeyStatus.FAILED, recovered?.status)
|
||||
assertNull(recovered?.lastErrorCode)
|
||||
assertEquals(INTERRUPTED_LEARNING_ERROR_DETAIL, recovered?.lastErrorDetail)
|
||||
assertEquals(40L, recovered?.updatedAtEpochMillis)
|
||||
assertEquals(
|
||||
InfraredKeyStatus.READY,
|
||||
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("one-ready")))?.status,
|
||||
)
|
||||
assertEquals(
|
||||
InfraredKeyStatus.LEARNING,
|
||||
repository.successfulKey(InfraredKeyRef(profileTwo, InfraredKeyId("two-learning")))?.status,
|
||||
)
|
||||
assertEquals(
|
||||
DomainResult.Success(0),
|
||||
repository.failInterruptedLearning(profileOne, updatedAtEpochMillis = 50L),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest {
|
||||
val repository = FakeInfraredKeyRepository()
|
||||
repository.insert(
|
||||
savedKey(
|
||||
id = "old-learning",
|
||||
sdkKey = "old-learning",
|
||||
status = InfraredKeyStatus.LEARNING,
|
||||
).copy(createdAtEpochMillis = 100L),
|
||||
)
|
||||
repository.insert(
|
||||
savedKey(
|
||||
id = "new-learning",
|
||||
sdkKey = "new-learning",
|
||||
status = InfraredKeyStatus.LEARNING,
|
||||
).copy(createdAtEpochMillis = 200L),
|
||||
)
|
||||
|
||||
val result = repository.failInterruptedLearning(
|
||||
profileId = profileOne,
|
||||
cutoffEpochMillis = 150L,
|
||||
updatedAtEpochMillis = 150L,
|
||||
)
|
||||
|
||||
assertEquals(DomainResult.Success(1), result)
|
||||
assertEquals(
|
||||
InfraredKeyStatus.FAILED,
|
||||
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("old-learning")))?.status,
|
||||
)
|
||||
assertEquals(
|
||||
InfraredKeyStatus.LEARNING,
|
||||
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("new-learning")))?.status,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceConfigurationIsIsolatedByProfile() = runTest {
|
||||
val repository = FakeInfraredDeviceConfigurationRepository()
|
||||
|
||||
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileOne).first())
|
||||
assertEquals(
|
||||
DomainResult.Success(Unit),
|
||||
repository.setDeviceName(profileOne, deviceOne),
|
||||
)
|
||||
assertEquals(DomainResult.Success(deviceOne), repository.observeDeviceName(profileOne).first())
|
||||
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileTwo).first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceConfigurationRejectsBlankOrSurroundingWhitespace() = runTest {
|
||||
val repository = FakeInfraredDeviceConfigurationRepository()
|
||||
|
||||
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName(" ")))
|
||||
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName(" ir-1")))
|
||||
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName("ir-1 ")))
|
||||
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileOne).first())
|
||||
}
|
||||
|
||||
private fun savedKey(
|
||||
id: String,
|
||||
profileId: ProviderProfileId = profileOne,
|
||||
deviceName: DeviceName = deviceOne,
|
||||
displayName: String = "Power",
|
||||
sdkKey: String = "power",
|
||||
status: InfraredKeyStatus = InfraredKeyStatus.LEARNING,
|
||||
lastErrorCode: String? = null,
|
||||
lastErrorDetail: String? = null,
|
||||
updatedAtEpochMillis: Long = 10L,
|
||||
) = SavedInfraredKey(
|
||||
id = InfraredKeyId(id),
|
||||
profileId = profileId,
|
||||
deviceName = deviceName,
|
||||
displayName = displayName,
|
||||
sdkKey = sdkKey,
|
||||
status = status,
|
||||
lastErrorCode = lastErrorCode,
|
||||
lastErrorDetail = lastErrorDetail,
|
||||
createdAtEpochMillis = 10L,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
)
|
||||
|
||||
private suspend fun FakeInfraredKeyRepository.successfulKey(
|
||||
ref: InfraredKeyRef,
|
||||
): SavedInfraredKey? = (getKey(ref) as DomainResult.Success<SavedInfraredKey?>).value
|
||||
|
||||
private fun assertInvalidRequest(result: DomainResult<*>) {
|
||||
assertTrue(result is DomainResult.Failure)
|
||||
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user