Merge remote-tracking branch 'origin/main' into feat/scene-editor

This commit is contained in:
2026-08-25 15:48:02 +08:00
94 changed files with 15109 additions and 29 deletions
@@ -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,
@@ -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,
)
@@ -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)
}
}
@@ -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"),
)
}
}
@@ -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)
}
}