feat: manage saved infrared keys
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
package com.flagship.abox.manager.domain.infrared
|
||||
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.InfraredGateway
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
|
||||
class SendInfraredKey(
|
||||
private val repository: InfraredKeyRepository,
|
||||
private val gateway: InfraredGateway,
|
||||
) {
|
||||
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
|
||||
val key = when (val result = repository.getKey(ref)) {
|
||||
is DomainResult.Success -> result.value
|
||||
?: return DomainResult.Failure(
|
||||
DomainError.NoData(detail = "Infrared key not found"),
|
||||
)
|
||||
|
||||
is DomainResult.Failure -> return result
|
||||
}
|
||||
if (key.status != InfraredKeyStatus.READY) {
|
||||
return DomainResult.Failure(
|
||||
DomainError.InvalidRequest(detail = "Infrared key is not ready"),
|
||||
)
|
||||
}
|
||||
return gateway.sendKey(
|
||||
profileId = key.profileId,
|
||||
deviceName = key.deviceName,
|
||||
key = key.sdkKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class RenameInfraredKey(
|
||||
private val repository: InfraredKeyRepository,
|
||||
private val clock: EpochMillisClock,
|
||||
) {
|
||||
suspend operator fun invoke(
|
||||
ref: InfraredKeyRef,
|
||||
displayName: String,
|
||||
): DomainResult<Unit> {
|
||||
val trimmed = displayName.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return DomainResult.Failure(
|
||||
DomainError.InvalidRequest(detail = "Infrared key display name is required"),
|
||||
)
|
||||
}
|
||||
return repository.rename(ref, trimmed, clock.now())
|
||||
}
|
||||
}
|
||||
|
||||
class DeleteInfraredKey(
|
||||
private val repository: InfraredKeyRepository,
|
||||
) {
|
||||
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> =
|
||||
repository.deleteIfNotLearning(ref)
|
||||
}
|
||||
|
||||
class RecoverInterruptedInfraredLearning(
|
||||
private val repository: InfraredKeyRepository,
|
||||
private val clock: EpochMillisClock,
|
||||
) {
|
||||
suspend operator fun invoke(profileId: ProviderProfileId): DomainResult<Int> =
|
||||
repository.failInterruptedLearning(profileId, clock.now())
|
||||
}
|
||||
+39
-1
@@ -25,19 +25,43 @@ data class InfraredStatusTransitionCall(
|
||||
val updatedAtEpochMillis: Long,
|
||||
)
|
||||
|
||||
data class InfraredSendCall(
|
||||
val profileId: ProviderProfileId,
|
||||
val deviceName: DeviceName,
|
||||
val key: String,
|
||||
)
|
||||
|
||||
data class InfraredRenameCall(
|
||||
val ref: InfraredKeyRef,
|
||||
val displayName: String,
|
||||
val updatedAtEpochMillis: Long,
|
||||
)
|
||||
|
||||
data class InfraredRecoveryCall(
|
||||
val profileId: ProviderProfileId,
|
||||
val updatedAtEpochMillis: Long,
|
||||
)
|
||||
|
||||
class InMemoryInfraredKeyRepository : InfraredKeyRepository {
|
||||
private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>()
|
||||
private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList())
|
||||
|
||||
var getError: DomainError? = null
|
||||
var insertError: DomainError? = null
|
||||
var renameError: DomainError? = null
|
||||
var deleteError: DomainError? = null
|
||||
var recoveryError: DomainError? = null
|
||||
val transitionErrors = mutableMapOf<InfraredKeyStatus, DomainError>()
|
||||
|
||||
var onInsert: ((SavedInfraredKey) -> Unit)? = null
|
||||
var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null
|
||||
|
||||
val insertedKeys = mutableListOf<SavedInfraredKey>()
|
||||
val getCalls = mutableListOf<InfraredKeyRef>()
|
||||
val transitionCalls = mutableListOf<InfraredStatusTransitionCall>()
|
||||
val renameCalls = mutableListOf<InfraredRenameCall>()
|
||||
val deleteCalls = mutableListOf<InfraredKeyRef>()
|
||||
val recoveryCalls = mutableListOf<InfraredRecoveryCall>()
|
||||
|
||||
override fun observeKeys(
|
||||
profileId: ProviderProfileId,
|
||||
@@ -49,6 +73,7 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
|
||||
}
|
||||
|
||||
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
|
||||
getCalls += ref
|
||||
getError?.let { return DomainResult.Failure(it) }
|
||||
return DomainResult.Success(keys[ref])
|
||||
}
|
||||
@@ -101,6 +126,8 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
|
||||
displayName: String,
|
||||
updatedAtEpochMillis: Long,
|
||||
): DomainResult<Unit> {
|
||||
renameCalls += InfraredRenameCall(ref, displayName, updatedAtEpochMillis)
|
||||
renameError?.let { return DomainResult.Failure(it) }
|
||||
val existing = keys[ref]
|
||||
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
|
||||
keys[ref] = existing.copy(
|
||||
@@ -112,6 +139,8 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
|
||||
}
|
||||
|
||||
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
|
||||
deleteCalls += ref
|
||||
deleteError?.let { return DomainResult.Failure(it) }
|
||||
val existing = keys[ref]
|
||||
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
|
||||
if (existing.status == InfraredKeyStatus.LEARNING) {
|
||||
@@ -128,6 +157,8 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
|
||||
profileId: ProviderProfileId,
|
||||
updatedAtEpochMillis: Long,
|
||||
): DomainResult<Int> {
|
||||
recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis)
|
||||
recoveryError?.let { return DomainResult.Failure(it) }
|
||||
var changed = 0
|
||||
keys.replaceAll { _, key ->
|
||||
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) {
|
||||
@@ -152,6 +183,8 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
|
||||
|
||||
fun onlyKey(): SavedInfraredKey = keys.values.single()
|
||||
|
||||
fun allKeys(): List<SavedInfraredKey> = keys.values.toList()
|
||||
|
||||
private fun publish() {
|
||||
state.value = keys.values.toList()
|
||||
}
|
||||
@@ -159,8 +192,10 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
|
||||
|
||||
class RecordingInfraredGateway : InfraredGateway {
|
||||
var learnResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
||||
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
||||
var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult<Unit>)? = null
|
||||
val learnedKeys = mutableListOf<InfraredLearningCall>()
|
||||
val sentKeys = mutableListOf<InfraredSendCall>()
|
||||
|
||||
override suspend fun learnKey(
|
||||
profileId: ProviderProfileId,
|
||||
@@ -175,7 +210,10 @@ class RecordingInfraredGateway : InfraredGateway {
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
key: String,
|
||||
): DomainResult<Unit> = DomainResult.Success(Unit)
|
||||
): DomainResult<Unit> {
|
||||
sentKeys += InfraredSendCall(profileId, deviceName, key)
|
||||
return sendResult
|
||||
}
|
||||
|
||||
override suspend fun downloadCodes(
|
||||
profileId: ProviderProfileId,
|
||||
|
||||
+30
@@ -183,6 +183,36 @@ class LearnInfraredKeyTest {
|
||||
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retryExpectedStatusFailurePreventsHardwareAfterConcurrentStatusChange() = runTest {
|
||||
val original = failedKey()
|
||||
repository.seed(original)
|
||||
repository.onTransition = { call ->
|
||||
if (call.targetStatus == InfraredKeyStatus.LEARNING) {
|
||||
repository.seed(original.copy(status = InfraredKeyStatus.LEARNING))
|
||||
}
|
||||
}
|
||||
|
||||
val result = retry(original.ref())
|
||||
|
||||
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||
assertTrue(gateway.learnedKeys.isEmpty())
|
||||
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retryClearsPriorErrorWhileGatewayCallbackIsRunning() = runTest {
|
||||
val original = failedKey()
|
||||
repository.seed(original)
|
||||
gateway.onLearn = { _, _, _ ->
|
||||
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
||||
assertEquals(null, repository.onlyKey().lastErrorCode)
|
||||
DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
assertEquals(DomainResult.Success(Unit), retry(original.ref()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retryGatewayFailureMarksFailedWithExternalCodeAndReturnsOriginalError() = runTest {
|
||||
val original = failedKey()
|
||||
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
package com.flagship.abox.manager.domain.infrared
|
||||
|
||||
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 kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ManageInfraredKeyTest {
|
||||
private val profile = ProviderProfileId("abox")
|
||||
private val otherProfile = ProviderProfileId("other")
|
||||
private val repository = InMemoryInfraredKeyRepository()
|
||||
private val gateway = RecordingInfraredGateway()
|
||||
private val clock = SteppingManagementClock(500L)
|
||||
private val send = SendInfraredKey(repository, gateway)
|
||||
private val rename = RenameInfraredKey(repository, clock)
|
||||
private val delete = DeleteInfraredKey(repository)
|
||||
private val recover = RecoverInterruptedInfraredLearning(repository, clock)
|
||||
|
||||
@Test
|
||||
fun sendsReadyKeyUsingStoredDeviceAndSdkKeyWithoutRepositoryWrites() = runTest {
|
||||
val key = readyKey(deviceName = DeviceName("ir-living"), sdkKey = "app_ir_power")
|
||||
repository.seed(key)
|
||||
|
||||
assertEquals(DomainResult.Success(Unit), send(key.ref()))
|
||||
assertEquals(
|
||||
InfraredSendCall(profile, DeviceName("ir-living"), "app_ir_power"),
|
||||
gateway.sentKeys.single(),
|
||||
)
|
||||
assertEquals(listOf(key.ref()), repository.getCalls)
|
||||
assertTrue(repository.transitionCalls.isEmpty())
|
||||
assertTrue(repository.renameCalls.isEmpty())
|
||||
assertTrue(repository.deleteCalls.isEmpty())
|
||||
assertTrue(repository.recoveryCalls.isEmpty())
|
||||
assertEquals(key, repository.onlyKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendFailureDoesNotDemoteReadyKey() = runTest {
|
||||
val key = readyKey()
|
||||
repository.seed(key)
|
||||
val gatewayError = DomainError.OperationFailed("20205")
|
||||
gateway.sendResult = DomainResult.Failure(gatewayError)
|
||||
|
||||
assertEquals(gatewayError, (send(key.ref()) as DomainResult.Failure).error)
|
||||
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
|
||||
assertTrue(repository.transitionCalls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingSendReturnsNoDataWithoutHardwareCall() = runTest {
|
||||
val result = send(InfraredKeyRef(profile, InfraredKeyId("missing")))
|
||||
|
||||
assertTrue((result as DomainResult.Failure).error is DomainError.NoData)
|
||||
assertEquals(
|
||||
listOf(InfraredKeyRef(profile, InfraredKeyId("missing"))),
|
||||
repository.getCalls,
|
||||
)
|
||||
assertTrue(gateway.sentKeys.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nonReadySendReturnsInvalidRequestWithoutHardwareCall() = runTest {
|
||||
repository.seed(failedKey())
|
||||
|
||||
val result = send(repository.onlyKey().ref())
|
||||
|
||||
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||
assertTrue(gateway.sentKeys.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendReturnsRepositoryFailureWithoutHardwareCall() = runTest {
|
||||
val repositoryError = DomainError.Unknown(detail = "database unavailable")
|
||||
repository.getError = repositoryError
|
||||
|
||||
val result = send(InfraredKeyRef(profile, InfraredKeyId("key1")))
|
||||
|
||||
assertEquals(repositoryError, (result as DomainResult.Failure).error)
|
||||
assertTrue(gateway.sentKeys.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renameTrimsDisplayNameAndPreservesStableIdentity() = runTest {
|
||||
val before = readyKey(displayName = "Power", sdkKey = "app_ir_power")
|
||||
repository.seed(before)
|
||||
|
||||
assertEquals(DomainResult.Success(Unit), rename(before.ref(), " 空调开关 "))
|
||||
|
||||
val after = repository.onlyKey()
|
||||
assertEquals("空调开关", after.displayName)
|
||||
assertEquals(before.sdkKey, after.sdkKey)
|
||||
assertEquals(before.id, after.id)
|
||||
assertEquals(before.createdAtEpochMillis, after.createdAtEpochMillis)
|
||||
assertEquals(500L, after.updatedAtEpochMillis)
|
||||
assertEquals(
|
||||
InfraredRenameCall(before.ref(), "空调开关", 500L),
|
||||
repository.renameCalls.single(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renameRejectsBlankDisplayNameWithoutRepositoryCall() = runTest {
|
||||
val key = readyKey()
|
||||
repository.seed(key)
|
||||
|
||||
val result = rename(key.ref(), " \t ")
|
||||
|
||||
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||
assertTrue(repository.renameCalls.isEmpty())
|
||||
assertEquals(key, repository.onlyKey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renameReturnsRepositoryFailure() = runTest {
|
||||
val key = readyKey()
|
||||
repository.seed(key)
|
||||
val repositoryError = DomainError.Unknown(detail = "write failed")
|
||||
repository.renameError = repositoryError
|
||||
|
||||
val result = rename(key.ref(), "New name")
|
||||
|
||||
assertEquals(repositoryError, (result as DomainResult.Failure).error)
|
||||
assertEquals("Power", repository.onlyKey().displayName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteDelegatesAtomicLearningGuardToRepository() = runTest {
|
||||
val key = learningKey()
|
||||
repository.seed(key)
|
||||
|
||||
val result = delete(key.ref())
|
||||
|
||||
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||
assertEquals(listOf(key.ref()), repository.deleteCalls)
|
||||
assertTrue(repository.getCalls.isEmpty())
|
||||
assertTrue(repository.allKeys().isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteRemovesReadyKey() = runTest {
|
||||
val key = readyKey()
|
||||
repository.seed(key)
|
||||
|
||||
assertEquals(DomainResult.Success(Unit), delete(key.ref()))
|
||||
assertEquals(listOf(key.ref()), repository.deleteCalls)
|
||||
assertTrue(repository.getCalls.isEmpty())
|
||||
assertTrue(repository.allKeys().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveryDelegatesOnceAndMarksOnlyProfileLearningKeysFailed() = runTest {
|
||||
val learningOne = learningKey(id = InfraredKeyId("one"), lastErrorCode = "old")
|
||||
val learningTwo = learningKey(id = InfraredKeyId("two"))
|
||||
val ready = readyKey(id = InfraredKeyId("ready"))
|
||||
val other = learningKey(id = InfraredKeyId("other"), profileId = otherProfile)
|
||||
repository.seed(learningOne)
|
||||
repository.seed(learningTwo)
|
||||
repository.seed(ready)
|
||||
repository.seed(other)
|
||||
|
||||
assertEquals(DomainResult.Success(2), recover(profile))
|
||||
assertEquals(
|
||||
listOf(InfraredRecoveryCall(profile, 500L)),
|
||||
repository.recoveryCalls,
|
||||
)
|
||||
assertTrue(repository.getCalls.isEmpty())
|
||||
assertTrue(repository.transitionCalls.isEmpty())
|
||||
val recovered = repository.allKeys().filter { it.profileId == profile && it.id.value in setOf("one", "two") }
|
||||
assertTrue(recovered.all { it.status == InfraredKeyStatus.FAILED })
|
||||
assertTrue(recovered.all { it.lastErrorCode == null })
|
||||
assertTrue(recovered.all { it.updatedAtEpochMillis == 500L })
|
||||
assertEquals(InfraredKeyStatus.READY, repository.allKeys().single { it.id.value == "ready" }.status)
|
||||
assertEquals(InfraredKeyStatus.LEARNING, repository.allKeys().single { it.profileId == otherProfile }.status)
|
||||
assertTrue(gateway.learnedKeys.isEmpty())
|
||||
assertTrue(gateway.sentKeys.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recoveryReturnsRepositoryFailureWithoutHardwareCall() = runTest {
|
||||
val repositoryError = DomainError.Unknown(detail = "write failed")
|
||||
repository.recoveryError = repositoryError
|
||||
|
||||
val result = recover(profile)
|
||||
|
||||
assertEquals(repositoryError, (result as DomainResult.Failure).error)
|
||||
assertEquals(listOf(InfraredRecoveryCall(profile, 500L)), repository.recoveryCalls)
|
||||
assertTrue(gateway.learnedKeys.isEmpty())
|
||||
assertTrue(gateway.sentKeys.isEmpty())
|
||||
}
|
||||
|
||||
private fun readyKey(
|
||||
id: InfraredKeyId = InfraredKeyId("key1"),
|
||||
profileId: ProviderProfileId = profile,
|
||||
deviceName: DeviceName = DeviceName("ir-1"),
|
||||
displayName: String = "Power",
|
||||
sdkKey: String = "app_ir_key1",
|
||||
) = SavedInfraredKey(
|
||||
id = id,
|
||||
profileId = profileId,
|
||||
deviceName = deviceName,
|
||||
displayName = displayName,
|
||||
sdkKey = sdkKey,
|
||||
status = InfraredKeyStatus.READY,
|
||||
lastErrorCode = null,
|
||||
createdAtEpochMillis = 1L,
|
||||
updatedAtEpochMillis = 2L,
|
||||
)
|
||||
|
||||
private fun failedKey() = readyKey().copy(
|
||||
status = InfraredKeyStatus.FAILED,
|
||||
lastErrorCode = "old-error",
|
||||
)
|
||||
|
||||
private fun learningKey(
|
||||
id: InfraredKeyId = InfraredKeyId("key1"),
|
||||
profileId: ProviderProfileId = profile,
|
||||
lastErrorCode: String? = null,
|
||||
) = readyKey(id = id, profileId = profileId).copy(
|
||||
status = InfraredKeyStatus.LEARNING,
|
||||
lastErrorCode = lastErrorCode,
|
||||
)
|
||||
}
|
||||
|
||||
private class SteppingManagementClock(start: Long) : EpochMillisClock {
|
||||
private var next = start
|
||||
|
||||
override fun now(): Long = next++
|
||||
}
|
||||
Reference in New Issue
Block a user