From ee60b75a32d6c2cd06cbcc46e6ea6e5a0aedc001 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 02:03:53 +0800 Subject: [PATCH] feat: manage saved infrared keys --- .../domain/infrared/ManageInfraredKey.kt | 65 +++++ .../infrared/InMemoryInfraredTestDoubles.kt | 40 ++- .../domain/infrared/LearnInfraredKeyTest.kt | 30 +++ .../domain/infrared/ManageInfraredKeyTest.kt | 232 ++++++++++++++++++ 4 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKey.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKey.kt new file mode 100644 index 0000000..37acd6f --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKey.kt @@ -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 { + 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 { + 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 = + repository.deleteIfNotLearning(ref) +} + +class RecoverInterruptedInfraredLearning( + private val repository: InfraredKeyRepository, + private val clock: EpochMillisClock, +) { + suspend operator fun invoke(profileId: ProviderProfileId): DomainResult = + repository.failInterruptedLearning(profileId, clock.now()) +} 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 index d9204f3..55f8d8a 100644 --- 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 @@ -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() private val state = MutableStateFlow>(emptyList()) var getError: DomainError? = null var insertError: DomainError? = null + var renameError: DomainError? = null + var deleteError: DomainError? = null + var recoveryError: DomainError? = null val transitionErrors = mutableMapOf() var onInsert: ((SavedInfraredKey) -> Unit)? = null var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null val insertedKeys = mutableListOf() + val getCalls = mutableListOf() val transitionCalls = mutableListOf() + val renameCalls = mutableListOf() + val deleteCalls = mutableListOf() + val recoveryCalls = mutableListOf() override fun observeKeys( profileId: ProviderProfileId, @@ -49,6 +73,7 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { } override suspend fun getKey(ref: InfraredKeyRef): DomainResult { + 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 { + 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 { + 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 { + 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 = keys.values.toList() + private fun publish() { state.value = keys.values.toList() } @@ -159,8 +192,10 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { class RecordingInfraredGateway : InfraredGateway { var learnResult: DomainResult = DomainResult.Success(Unit) + var sendResult: DomainResult = DomainResult.Success(Unit) var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult)? = null val learnedKeys = mutableListOf() + val sentKeys = mutableListOf() override suspend fun learnKey( profileId: ProviderProfileId, @@ -175,7 +210,10 @@ class RecordingInfraredGateway : InfraredGateway { profileId: ProviderProfileId, deviceName: DeviceName, key: String, - ): DomainResult = DomainResult.Success(Unit) + ): DomainResult { + sentKeys += InfraredSendCall(profileId, deviceName, key) + return sendResult + } override suspend fun downloadCodes( profileId: ProviderProfileId, 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 index fc3f148..aa6b419 100644 --- 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 @@ -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() diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt new file mode 100644 index 0000000..e42241c --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/ManageInfraredKeyTest.kt @@ -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++ +}