From c9fc1ba711e8f1493477d8c7227074bca4316614 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 01:49:54 +0800 Subject: [PATCH] feat: orchestrate infrared key learning --- .../domain/infrared/LearnInfraredKey.kt | 132 +++++++++ .../infrared/InMemoryInfraredTestDoubles.kt | 189 +++++++++++++ .../domain/infrared/LearnInfraredKeyTest.kt | 255 ++++++++++++++++++ 3 files changed, 576 insertions(+) create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt new file mode 100644 index 0000000..661c91e --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt @@ -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 { + 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 { + 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, +) diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt new file mode 100644 index 0000000..d9204f3 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt @@ -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, + val targetStatus: InfraredKeyStatus, + val lastErrorCode: String?, + val updatedAtEpochMillis: Long, +) + +class InMemoryInfraredKeyRepository : InfraredKeyRepository { + private val keys = linkedMapOf() + private val state = MutableStateFlow>(emptyList()) + + var getError: DomainError? = null + var insertError: DomainError? = null + val transitionErrors = mutableMapOf() + + var onInsert: ((SavedInfraredKey) -> Unit)? = null + var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null + + val insertedKeys = mutableListOf() + val transitionCalls = mutableListOf() + + override fun observeKeys( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> = state.map { values -> + DomainResult.Success( + values.filter { it.profileId == profileId && it.deviceName == deviceName }, + ) + } + + override suspend fun getKey(ref: InfraredKeyRef): DomainResult { + getError?.let { return DomainResult.Failure(it) } + return DomainResult.Success(keys[ref]) + } + + override suspend fun insert(key: SavedInfraredKey): DomainResult { + 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, + targetStatus: InfraredKeyStatus, + lastErrorCode: String?, + updatedAtEpochMillis: Long, + ): DomainResult { + 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 { + 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 { + 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 { + 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 = DomainResult.Success(Unit) + var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult)? = null + val learnedKeys = mutableListOf() + + override suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult { + learnedKeys += InfraredLearningCall(profileId, deviceName, key) + return onLearn?.invoke(profileId, deviceName, key) ?: learnResult + } + + override suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = DomainResult.Success(Unit) + + override suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> = DomainResult.Success(emptyList()) +} + +fun SavedInfraredKey.ref() = InfraredKeyRef( + profileId = profileId, + keyId = id, +) diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt new file mode 100644 index 0000000..fc3f148 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKeyTest.kt @@ -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() + 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++ +}