feat: orchestrate infrared key learning
This commit is contained in:
@@ -0,0 +1,132 @@
|
|||||||
|
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.InfraredGateway
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
|
||||||
|
class LearnInfraredKey(
|
||||||
|
private val repository: InfraredKeyRepository,
|
||||||
|
private val gateway: InfraredGateway,
|
||||||
|
private val factory: InfraredKeyFactory,
|
||||||
|
private val clock: EpochMillisClock,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
displayName: String,
|
||||||
|
): DomainResult<InfraredKeyId> {
|
||||||
|
val key = when (val result = factory.create(profileId, deviceName, displayName)) {
|
||||||
|
is DomainResult.Success -> result.value
|
||||||
|
is DomainResult.Failure -> return result
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val result = repository.insert(key)) {
|
||||||
|
is DomainResult.Success -> Unit
|
||||||
|
is DomainResult.Failure -> return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (
|
||||||
|
val result = gateway.learnKey(
|
||||||
|
profileId = key.profileId,
|
||||||
|
deviceName = key.deviceName,
|
||||||
|
key = key.sdkKey,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is DomainResult.Success -> when (
|
||||||
|
val transition = repository.transitionStatus(
|
||||||
|
ref = key.ref(),
|
||||||
|
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
||||||
|
targetStatus = InfraredKeyStatus.READY,
|
||||||
|
lastErrorCode = null,
|
||||||
|
updatedAtEpochMillis = clock.now(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is DomainResult.Success -> DomainResult.Success(key.id)
|
||||||
|
is DomainResult.Failure -> transition
|
||||||
|
}
|
||||||
|
|
||||||
|
is DomainResult.Failure -> {
|
||||||
|
repository.transitionStatus(
|
||||||
|
ref = key.ref(),
|
||||||
|
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
||||||
|
targetStatus = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = result.error.externalCode,
|
||||||
|
updatedAtEpochMillis = clock.now(),
|
||||||
|
)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RetryInfraredKeyLearning(
|
||||||
|
private val repository: InfraredKeyRepository,
|
||||||
|
private val gateway: InfraredGateway,
|
||||||
|
private val clock: EpochMillisClock,
|
||||||
|
) {
|
||||||
|
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.InvalidRequest(detail = "Infrared key not found"),
|
||||||
|
)
|
||||||
|
|
||||||
|
is DomainResult.Failure -> return result
|
||||||
|
}
|
||||||
|
if (key.status == InfraredKeyStatus.LEARNING) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Infrared key is already learning"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (
|
||||||
|
val result = repository.transitionStatus(
|
||||||
|
ref = ref,
|
||||||
|
expectedStatuses = setOf(
|
||||||
|
InfraredKeyStatus.FAILED,
|
||||||
|
InfraredKeyStatus.READY,
|
||||||
|
),
|
||||||
|
targetStatus = InfraredKeyStatus.LEARNING,
|
||||||
|
lastErrorCode = null,
|
||||||
|
updatedAtEpochMillis = clock.now(),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is DomainResult.Success -> Unit
|
||||||
|
is DomainResult.Failure -> return result
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (
|
||||||
|
val result = gateway.learnKey(
|
||||||
|
profileId = key.profileId,
|
||||||
|
deviceName = key.deviceName,
|
||||||
|
key = key.sdkKey,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
is DomainResult.Success -> repository.transitionStatus(
|
||||||
|
ref = ref,
|
||||||
|
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
||||||
|
targetStatus = InfraredKeyStatus.READY,
|
||||||
|
lastErrorCode = null,
|
||||||
|
updatedAtEpochMillis = clock.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
is DomainResult.Failure -> {
|
||||||
|
repository.transitionStatus(
|
||||||
|
ref = ref,
|
||||||
|
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
||||||
|
targetStatus = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = result.error.externalCode,
|
||||||
|
updatedAtEpochMillis = clock.now(),
|
||||||
|
)
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SavedInfraredKey.ref() = InfraredKeyRef(
|
||||||
|
profileId = profileId,
|
||||||
|
keyId = id,
|
||||||
|
)
|
||||||
+189
@@ -0,0 +1,189 @@
|
|||||||
|
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.InfraredCode
|
||||||
|
import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
|
||||||
|
import com.flagship.abox.manager.domain.InfraredGateway
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
data class InfraredLearningCall(
|
||||||
|
val profileId: ProviderProfileId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val key: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class InfraredStatusTransitionCall(
|
||||||
|
val ref: InfraredKeyRef,
|
||||||
|
val expectedStatuses: Set<InfraredKeyStatus>,
|
||||||
|
val targetStatus: InfraredKeyStatus,
|
||||||
|
val lastErrorCode: String?,
|
||||||
|
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
|
||||||
|
val transitionErrors = mutableMapOf<InfraredKeyStatus, DomainError>()
|
||||||
|
|
||||||
|
var onInsert: ((SavedInfraredKey) -> Unit)? = null
|
||||||
|
var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null
|
||||||
|
|
||||||
|
val insertedKeys = mutableListOf<SavedInfraredKey>()
|
||||||
|
val transitionCalls = mutableListOf<InfraredStatusTransitionCall>()
|
||||||
|
|
||||||
|
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?> {
|
||||||
|
getError?.let { return DomainResult.Failure(it) }
|
||||||
|
return DomainResult.Success(keys[ref])
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> {
|
||||||
|
insertedKeys += key
|
||||||
|
onInsert?.invoke(key)
|
||||||
|
insertError?.let { return DomainResult.Failure(it) }
|
||||||
|
keys[key.ref()] = key
|
||||||
|
publish()
|
||||||
|
return DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun transitionStatus(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
expectedStatuses: Set<InfraredKeyStatus>,
|
||||||
|
targetStatus: InfraredKeyStatus,
|
||||||
|
lastErrorCode: String?,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Unit> {
|
||||||
|
val call = InfraredStatusTransitionCall(
|
||||||
|
ref = ref,
|
||||||
|
expectedStatuses = expectedStatuses,
|
||||||
|
targetStatus = targetStatus,
|
||||||
|
lastErrorCode = lastErrorCode,
|
||||||
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
|
)
|
||||||
|
transitionCalls += call
|
||||||
|
onTransition?.invoke(call)
|
||||||
|
transitionErrors[targetStatus]?.let { return DomainResult.Failure(it) }
|
||||||
|
|
||||||
|
val existing = keys[ref]
|
||||||
|
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
|
||||||
|
if (existing.status !in expectedStatuses) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Infrared key status changed"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
keys[ref] = existing.copy(
|
||||||
|
status = targetStatus,
|
||||||
|
lastErrorCode = lastErrorCode,
|
||||||
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
|
)
|
||||||
|
publish()
|
||||||
|
return DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun rename(
|
||||||
|
ref: InfraredKeyRef,
|
||||||
|
displayName: String,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Unit> {
|
||||||
|
val existing = keys[ref]
|
||||||
|
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
|
||||||
|
keys[ref] = existing.copy(
|
||||||
|
displayName = displayName,
|
||||||
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
|
)
|
||||||
|
publish()
|
||||||
|
return DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
|
||||||
|
val existing = keys[ref]
|
||||||
|
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
|
||||||
|
if (existing.status == InfraredKeyStatus.LEARNING) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Cannot delete a learning infrared key"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
keys.remove(ref)
|
||||||
|
publish()
|
||||||
|
return DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun failInterruptedLearning(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
updatedAtEpochMillis: Long,
|
||||||
|
): DomainResult<Int> {
|
||||||
|
var changed = 0
|
||||||
|
keys.replaceAll { _, key ->
|
||||||
|
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING) {
|
||||||
|
changed += 1
|
||||||
|
key.copy(
|
||||||
|
status = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = null,
|
||||||
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publish()
|
||||||
|
return DomainResult.Success(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seed(key: SavedInfraredKey) {
|
||||||
|
keys[key.ref()] = key
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onlyKey(): SavedInfraredKey = keys.values.single()
|
||||||
|
|
||||||
|
private fun publish() {
|
||||||
|
state.value = keys.values.toList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RecordingInfraredGateway : InfraredGateway {
|
||||||
|
var learnResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
||||||
|
var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult<Unit>)? = null
|
||||||
|
val learnedKeys = mutableListOf<InfraredLearningCall>()
|
||||||
|
|
||||||
|
override suspend fun learnKey(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
key: String,
|
||||||
|
): DomainResult<Unit> {
|
||||||
|
learnedKeys += InfraredLearningCall(profileId, deviceName, key)
|
||||||
|
return onLearn?.invoke(profileId, deviceName, key) ?: learnResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun sendKey(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
key: String,
|
||||||
|
): DomainResult<Unit> = DomainResult.Success(Unit)
|
||||||
|
|
||||||
|
override suspend fun downloadCodes(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
codes: List<InfraredCode>,
|
||||||
|
): DomainResult<List<InfraredCodeDownloadResult>> = DomainResult.Success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SavedInfraredKey.ref() = InfraredKeyRef(
|
||||||
|
profileId = profileId,
|
||||||
|
keyId = id,
|
||||||
|
)
|
||||||
+255
@@ -0,0 +1,255 @@
|
|||||||
|
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 LearnInfraredKeyTest {
|
||||||
|
private val profile = ProviderProfileId("abox")
|
||||||
|
private val device = DeviceName("ir-1")
|
||||||
|
private val keyId = InfraredKeyId("key1")
|
||||||
|
private val clock = SteppingClock(100L)
|
||||||
|
private val factory = InfraredKeyFactory(
|
||||||
|
idGenerator = InfraredKeyIdGenerator { keyId },
|
||||||
|
clock = clock,
|
||||||
|
)
|
||||||
|
private val repository = InMemoryInfraredKeyRepository()
|
||||||
|
private val gateway = RecordingInfraredGateway()
|
||||||
|
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
|
||||||
|
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest {
|
||||||
|
val events = mutableListOf<String>()
|
||||||
|
repository.onInsert = { events += "insert:${it.status}" }
|
||||||
|
gateway.onLearn = { _, _, _ ->
|
||||||
|
events += "hardware"
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
repository.onTransition = { events += "transition:${it.targetStatus}" }
|
||||||
|
|
||||||
|
val result = learn(profile, device, "Power")
|
||||||
|
|
||||||
|
assertEquals(DomainResult.Success(keyId), result)
|
||||||
|
assertEquals(listOf("insert:LEARNING", "hardware", "transition:READY"), events)
|
||||||
|
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
|
||||||
|
assertEquals(null, repository.onlyKey().lastErrorCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hardwareFailureMarksFailedAndReturnsOriginalError() = runTest {
|
||||||
|
val gatewayError = DomainError.DeviceOffline("20206")
|
||||||
|
gateway.learnResult = DomainResult.Failure(gatewayError)
|
||||||
|
|
||||||
|
val result = learn(profile, device, "Power")
|
||||||
|
|
||||||
|
assertEquals(gatewayError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
|
||||||
|
assertEquals("20206", repository.onlyKey().lastErrorCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun insertFailureNeverCallsHardware() = runTest {
|
||||||
|
val insertError = DomainError.Unknown(detail = "database unavailable")
|
||||||
|
repository.insertError = insertError
|
||||||
|
|
||||||
|
val result = learn(profile, device, "Power")
|
||||||
|
|
||||||
|
assertEquals(insertError, (result as DomainResult.Failure).error)
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
assertTrue(repository.transitionCalls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun factoryFailureNeverTouchesRepositoryOrHardware() = runTest {
|
||||||
|
val result = learn(profile, device, " ")
|
||||||
|
|
||||||
|
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||||
|
assertTrue(repository.insertedKeys.isEmpty())
|
||||||
|
assertTrue(repository.transitionCalls.isEmpty())
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest {
|
||||||
|
val transitionError = DomainError.Unknown(detail = "write conflict")
|
||||||
|
repository.transitionErrors[InfraredKeyStatus.READY] = transitionError
|
||||||
|
|
||||||
|
val result = learn(profile, device, "Power")
|
||||||
|
|
||||||
|
assertEquals(transitionError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
||||||
|
assertEquals(1, gateway.learnedKeys.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failedTransitionErrorDoesNotReplaceGatewayFailure() = runTest {
|
||||||
|
val gatewayError = DomainError.DeviceOffline("20206")
|
||||||
|
gateway.learnResult = DomainResult.Failure(gatewayError)
|
||||||
|
repository.transitionErrors[InfraredKeyStatus.FAILED] =
|
||||||
|
DomainError.Unknown(detail = "write conflict")
|
||||||
|
|
||||||
|
val result = learn(profile, device, "Power")
|
||||||
|
|
||||||
|
assertEquals(gatewayError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
||||||
|
assertEquals("20206", repository.transitionCalls.single().lastErrorCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun newLearningUsesExpectedLearningTransitionsAndFreshTimestamps() = runTest {
|
||||||
|
learn(profile, device, "Power")
|
||||||
|
|
||||||
|
val inserted = repository.insertedKeys.single()
|
||||||
|
val transition = repository.transitionCalls.single()
|
||||||
|
assertEquals(setOf(InfraredKeyStatus.LEARNING), transition.expectedStatuses)
|
||||||
|
assertEquals(inserted.ref(), transition.ref)
|
||||||
|
assertEquals(100L, inserted.createdAtEpochMillis)
|
||||||
|
assertEquals(100L, inserted.updatedAtEpochMillis)
|
||||||
|
assertEquals(101L, transition.updatedAtEpochMillis)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryReusesStableIdAndSdkKey() = runTest {
|
||||||
|
val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1")
|
||||||
|
repository.seed(original)
|
||||||
|
|
||||||
|
val result = retry(original.ref())
|
||||||
|
|
||||||
|
assertEquals(DomainResult.Success(Unit), result)
|
||||||
|
assertEquals("app_ir_key1", gateway.learnedKeys.single().key)
|
||||||
|
assertEquals(InfraredKeyId("key1"), repository.onlyKey().id)
|
||||||
|
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
|
||||||
|
assertEquals(0, repository.insertedKeys.size)
|
||||||
|
assertEquals(2, repository.transitionCalls.size)
|
||||||
|
assertEquals(
|
||||||
|
setOf(InfraredKeyStatus.FAILED, InfraredKeyStatus.READY),
|
||||||
|
repository.transitionCalls.first().expectedStatuses,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
setOf(InfraredKeyStatus.LEARNING),
|
||||||
|
repository.transitionCalls.last().expectedStatuses,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryRejectsAlreadyLearningKey() = runTest {
|
||||||
|
repository.seed(learningKey())
|
||||||
|
|
||||||
|
val result = retry(repository.onlyKey().ref())
|
||||||
|
|
||||||
|
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
assertTrue(repository.transitionCalls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryRejectsMissingKey() = runTest {
|
||||||
|
val result = retry(InfraredKeyRef(profile, InfraredKeyId("missing")))
|
||||||
|
|
||||||
|
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
assertTrue(repository.transitionCalls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryReturnsGetFailureWithoutCallingHardware() = runTest {
|
||||||
|
val getError = DomainError.Unknown(detail = "database unavailable")
|
||||||
|
repository.getError = getError
|
||||||
|
|
||||||
|
val result = retry(InfraredKeyRef(profile, keyId))
|
||||||
|
|
||||||
|
assertEquals(getError, (result as DomainResult.Failure).error)
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
assertTrue(repository.transitionCalls.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryDoesNotCallHardwareWhenLearningTransitionFails() = runTest {
|
||||||
|
val original = failedKey()
|
||||||
|
repository.seed(original)
|
||||||
|
val transitionError = DomainError.Unknown(detail = "write conflict")
|
||||||
|
repository.transitionErrors[InfraredKeyStatus.LEARNING] = transitionError
|
||||||
|
|
||||||
|
val result = retry(original.ref())
|
||||||
|
|
||||||
|
assertEquals(transitionError, (result as DomainResult.Failure).error)
|
||||||
|
assertTrue(gateway.learnedKeys.isEmpty())
|
||||||
|
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryGatewayFailureMarksFailedWithExternalCodeAndReturnsOriginalError() = runTest {
|
||||||
|
val original = failedKey()
|
||||||
|
repository.seed(original)
|
||||||
|
val gatewayError = DomainError.DeviceOffline("20206")
|
||||||
|
gateway.learnResult = DomainResult.Failure(gatewayError)
|
||||||
|
|
||||||
|
val result = retry(original.ref())
|
||||||
|
|
||||||
|
assertEquals(gatewayError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
|
||||||
|
assertEquals("20206", repository.onlyKey().lastErrorCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryFailedTransitionErrorDoesNotReplaceGatewayFailure() = runTest {
|
||||||
|
val original = failedKey()
|
||||||
|
repository.seed(original)
|
||||||
|
val gatewayError = DomainError.DeviceOffline("20206")
|
||||||
|
gateway.learnResult = DomainResult.Failure(gatewayError)
|
||||||
|
repository.transitionErrors[InfraredKeyStatus.FAILED] =
|
||||||
|
DomainError.Unknown(detail = "write conflict")
|
||||||
|
|
||||||
|
val result = retry(original.ref())
|
||||||
|
|
||||||
|
assertEquals(gatewayError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
||||||
|
assertEquals("20206", repository.transitionCalls.last().lastErrorCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun retryReadyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest {
|
||||||
|
val original = failedKey()
|
||||||
|
repository.seed(original)
|
||||||
|
val transitionError = DomainError.Unknown(detail = "write conflict")
|
||||||
|
repository.transitionErrors[InfraredKeyStatus.READY] = transitionError
|
||||||
|
|
||||||
|
val result = retry(original.ref())
|
||||||
|
|
||||||
|
assertEquals(transitionError, (result as DomainResult.Failure).error)
|
||||||
|
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
||||||
|
assertEquals(1, gateway.learnedKeys.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun failedKey(
|
||||||
|
id: InfraredKeyId = keyId,
|
||||||
|
sdkKey: String = "app_ir_key1",
|
||||||
|
) = SavedInfraredKey(
|
||||||
|
id = id,
|
||||||
|
profileId = profile,
|
||||||
|
deviceName = device,
|
||||||
|
displayName = "Power",
|
||||||
|
sdkKey = sdkKey,
|
||||||
|
status = InfraredKeyStatus.FAILED,
|
||||||
|
lastErrorCode = "old-error",
|
||||||
|
createdAtEpochMillis = 1L,
|
||||||
|
updatedAtEpochMillis = 2L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun learningKey() = failedKey().copy(
|
||||||
|
status = InfraredKeyStatus.LEARNING,
|
||||||
|
lastErrorCode = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class SteppingClock(start: Long) : EpochMillisClock {
|
||||||
|
private var next = start
|
||||||
|
|
||||||
|
override fun now(): Long = next++
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user