diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt index e7ef07c..0040df9 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt @@ -49,6 +49,7 @@ class InfraredKeyFactory( sdkKey = "app_ir_$canonicalId", status = InfraredKeyStatus.LEARNING, lastErrorCode = null, + lastErrorDetail = null, createdAtEpochMillis = now, updatedAtEpochMillis = now, ), diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt index 3092769..9c0a2ec 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt @@ -17,6 +17,13 @@ enum class InfraredKeyStatus { FAILED, } +const val INTERRUPTED_LEARNING_ERROR_DETAIL = + "Interrupted learning result is unknown; retry required" + +/** + * Persisted infrared key state. [lastErrorDetail] may contain only fixed, domain-owned, + * sanitized text; raw SDK maps, tokens, codes, and vendor payloads must never be stored there. + */ data class SavedInfraredKey( val id: InfraredKeyId, val profileId: ProviderProfileId, @@ -25,6 +32,7 @@ data class SavedInfraredKey( val sdkKey: String, val status: InfraredKeyStatus, val lastErrorCode: String?, + val lastErrorDetail: String?, val createdAtEpochMillis: Long, val updatedAtEpochMillis: Long, -) \ No newline at end of file +) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt index 7b0e55c..164f5ef 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyRepository.kt @@ -20,6 +20,7 @@ interface InfraredKeyRepository { expectedStatuses: Set, targetStatus: InfraredKeyStatus, lastErrorCode: String?, + lastErrorDetail: String?, updatedAtEpochMillis: Long, ): DomainResult @@ -31,6 +32,10 @@ interface InfraredKeyRepository { suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult + /** + * Atomically marks this profile's interrupted LEARNING keys as FAILED, clears the vendor + * error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp. + */ suspend fun failInterruptedLearning( profileId: ProviderProfileId, updatedAtEpochMillis: Long, 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 index 661c91e..9f6155b 100644 --- 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 @@ -40,6 +40,7 @@ class LearnInfraredKey( expectedStatuses = setOf(InfraredKeyStatus.LEARNING), targetStatus = InfraredKeyStatus.READY, lastErrorCode = null, + lastErrorDetail = null, updatedAtEpochMillis = clock.now(), ) ) { @@ -53,6 +54,7 @@ class LearnInfraredKey( expectedStatuses = setOf(InfraredKeyStatus.LEARNING), targetStatus = InfraredKeyStatus.FAILED, lastErrorCode = result.error.externalCode, + lastErrorDetail = null, updatedAtEpochMillis = clock.now(), ) result @@ -90,6 +92,7 @@ class RetryInfraredKeyLearning( ), targetStatus = InfraredKeyStatus.LEARNING, lastErrorCode = null, + lastErrorDetail = null, updatedAtEpochMillis = clock.now(), ) ) { @@ -109,6 +112,7 @@ class RetryInfraredKeyLearning( expectedStatuses = setOf(InfraredKeyStatus.LEARNING), targetStatus = InfraredKeyStatus.READY, lastErrorCode = null, + lastErrorDetail = null, updatedAtEpochMillis = clock.now(), ) @@ -118,6 +122,7 @@ class RetryInfraredKeyLearning( expectedStatuses = setOf(InfraredKeyStatus.LEARNING), targetStatus = InfraredKeyStatus.FAILED, lastErrorCode = result.error.externalCode, + lastErrorDetail = null, updatedAtEpochMillis = clock.now(), ) result 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 55f8d8a..db2a57f 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 @@ -22,6 +22,7 @@ data class InfraredStatusTransitionCall( val expectedStatuses: Set, val targetStatus: InfraredKeyStatus, val lastErrorCode: String?, + val lastErrorDetail: String?, val updatedAtEpochMillis: Long, ) @@ -92,6 +93,7 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { expectedStatuses: Set, targetStatus: InfraredKeyStatus, lastErrorCode: String?, + lastErrorDetail: String?, updatedAtEpochMillis: Long, ): DomainResult { val call = InfraredStatusTransitionCall( @@ -99,6 +101,7 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { expectedStatuses = expectedStatuses, targetStatus = targetStatus, lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, updatedAtEpochMillis = updatedAtEpochMillis, ) transitionCalls += call @@ -115,6 +118,7 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { keys[ref] = existing.copy( status = targetStatus, lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, updatedAtEpochMillis = updatedAtEpochMillis, ) publish() @@ -166,6 +170,7 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository { key.copy( status = InfraredKeyStatus.FAILED, lastErrorCode = null, + lastErrorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL, updatedAtEpochMillis = updatedAtEpochMillis, ) } else { diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt index 2adbe1e..dcc17e0 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt @@ -25,6 +25,8 @@ class InfraredKeyFactoryTest { assertEquals(id, result.value.id) assertEquals("app_ir_01j5molerabc123", result.value.sdkKey) assertEquals(InfraredKeyStatus.LEARNING, result.value.status) + assertEquals(null, result.value.lastErrorCode) + assertEquals(null, result.value.lastErrorDetail) assertEquals("空调开关", result.value.displayName) assertEquals(1_724_435_200_000L, result.value.createdAtEpochMillis) assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis) 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 aa6b419..0f4e3d4 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 @@ -39,6 +39,7 @@ class LearnInfraredKeyTest { assertEquals(listOf("insert:LEARNING", "hardware", "transition:READY"), events) assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) assertEquals(null, repository.onlyKey().lastErrorCode) + assertEquals(null, repository.onlyKey().lastErrorDetail) } @Test @@ -51,6 +52,7 @@ class LearnInfraredKeyTest { assertEquals(gatewayError, (result as DomainResult.Failure).error) assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status) assertEquals("20206", repository.onlyKey().lastErrorCode) + assertEquals(null, repository.onlyKey().lastErrorDetail) } @Test @@ -99,6 +101,7 @@ class LearnInfraredKeyTest { assertEquals(gatewayError, (result as DomainResult.Failure).error) assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status) assertEquals("20206", repository.transitionCalls.single().lastErrorCode) + assertEquals(null, repository.transitionCalls.single().lastErrorDetail) } @Test @@ -201,16 +204,23 @@ class LearnInfraredKeyTest { } @Test - fun retryClearsPriorErrorWhileGatewayCallbackIsRunning() = runTest { + fun retryUsesSeparateTimestampsAndClearsErrorsBeforeHardware() = runTest { val original = failedKey() repository.seed(original) gateway.onLearn = { _, _, _ -> assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status) assertEquals(null, repository.onlyKey().lastErrorCode) + assertEquals(null, repository.onlyKey().lastErrorDetail) + assertEquals(100L, repository.onlyKey().updatedAtEpochMillis) DomainResult.Success(Unit) } assertEquals(DomainResult.Success(Unit), retry(original.ref())) + assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) + assertEquals(null, repository.onlyKey().lastErrorCode) + assertEquals(null, repository.onlyKey().lastErrorDetail) + assertEquals(101L, repository.onlyKey().updatedAtEpochMillis) + assertEquals(listOf(100L, 101L), repository.transitionCalls.map { it.updatedAtEpochMillis }) } @Test @@ -225,6 +235,7 @@ class LearnInfraredKeyTest { assertEquals(gatewayError, (result as DomainResult.Failure).error) assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status) assertEquals("20206", repository.onlyKey().lastErrorCode) + assertEquals(null, repository.onlyKey().lastErrorDetail) } @Test @@ -241,6 +252,7 @@ class LearnInfraredKeyTest { assertEquals(gatewayError, (result as DomainResult.Failure).error) assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status) assertEquals("20206", repository.transitionCalls.last().lastErrorCode) + assertEquals(null, repository.transitionCalls.last().lastErrorDetail) } @Test @@ -268,6 +280,7 @@ class LearnInfraredKeyTest { sdkKey = sdkKey, status = InfraredKeyStatus.FAILED, lastErrorCode = "old-error", + lastErrorDetail = "old-detail", createdAtEpochMillis = 1L, updatedAtEpochMillis = 2L, ) @@ -275,6 +288,7 @@ class LearnInfraredKeyTest { private fun learningKey() = failedKey().copy( status = InfraredKeyStatus.LEARNING, lastErrorCode = null, + lastErrorDetail = null, ) } 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 index e42241c..9e2c655 100644 --- 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 @@ -46,6 +46,10 @@ class ManageInfraredKeyTest { gateway.sendResult = DomainResult.Failure(gatewayError) assertEquals(gatewayError, (send(key.ref()) as DomainResult.Failure).error) + assertEquals( + listOf(InfraredSendCall(profile, DeviceName("ir-1"), "app_ir_key1")), + gateway.sentKeys, + ) assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status) assertTrue(repository.transitionCalls.isEmpty()) } @@ -84,8 +88,12 @@ class ManageInfraredKeyTest { } @Test - fun renameTrimsDisplayNameAndPreservesStableIdentity() = runTest { - val before = readyKey(displayName = "Power", sdkKey = "app_ir_power") + fun renameTrimsDisplayNameAndPreservesStableIdentityAndErrors() = runTest { + val before = readyKey(displayName = "Power", sdkKey = "app_ir_power").copy( + status = InfraredKeyStatus.FAILED, + lastErrorCode = "20206", + lastErrorDetail = "fixed-detail", + ) repository.seed(before) assertEquals(DomainResult.Success(Unit), rename(before.ref(), " 空调开关 ")) @@ -94,6 +102,8 @@ class ManageInfraredKeyTest { assertEquals("空调开关", after.displayName) assertEquals(before.sdkKey, after.sdkKey) assertEquals(before.id, after.id) + assertEquals(before.lastErrorCode, after.lastErrorCode) + assertEquals(before.lastErrorDetail, after.lastErrorDetail) assertEquals(before.createdAtEpochMillis, after.createdAtEpochMillis) assertEquals(500L, after.updatedAtEpochMillis) assertEquals( @@ -172,6 +182,11 @@ class ManageInfraredKeyTest { 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.lastErrorDetail == "Interrupted learning result is unknown; retry required" + }, + ) 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) @@ -206,6 +221,7 @@ class ManageInfraredKeyTest { sdkKey = sdkKey, status = InfraredKeyStatus.READY, lastErrorCode = null, + lastErrorDetail = null, createdAtEpochMillis = 1L, updatedAtEpochMillis = 2L, ) @@ -213,15 +229,18 @@ class ManageInfraredKeyTest { private fun failedKey() = readyKey().copy( status = InfraredKeyStatus.FAILED, lastErrorCode = "old-error", + lastErrorDetail = "old-detail", ) private fun learningKey( id: InfraredKeyId = InfraredKeyId("key1"), profileId: ProviderProfileId = profile, lastErrorCode: String? = null, + lastErrorDetail: String? = null, ) = readyKey(id = id, profileId = profileId).copy( status = InfraredKeyStatus.LEARNING, lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, ) }