fix: preserve infrared failure context

This commit is contained in:
2026-08-24 02:18:33 +08:00
parent ea9f62632f
commit c9e9353a1b
8 changed files with 63 additions and 4 deletions
@@ -49,6 +49,7 @@ class InfraredKeyFactory(
sdkKey = "app_ir_$canonicalId",
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = now,
updatedAtEpochMillis = now,
),
@@ -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,
)
)
@@ -20,6 +20,7 @@ interface InfraredKeyRepository {
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit>
@@ -31,6 +32,10 @@ interface InfraredKeyRepository {
suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit>
/**
* 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,
@@ -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
@@ -22,6 +22,7 @@ data class InfraredStatusTransitionCall(
val expectedStatuses: Set<InfraredKeyStatus>,
val targetStatus: InfraredKeyStatus,
val lastErrorCode: String?,
val lastErrorDetail: String?,
val updatedAtEpochMillis: Long,
)
@@ -92,6 +93,7 @@ class InMemoryInfraredKeyRepository : InfraredKeyRepository {
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
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 {
@@ -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)
@@ -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,
)
}
@@ -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,
)
}