feat(infrared): add infrared control, learning, and virtual remote module #9

Merged
KremeCN merged 41 commits from feat/moler-infrared-control into main 2026-08-25 14:51:34 +08:00
8 changed files with 245 additions and 12 deletions
Showing only changes of commit b6cae9b2c9 - Show all commits
@@ -5,15 +5,21 @@ import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyId import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.launch
class InfraredLearningCoordinator( class InfraredLearningCoordinator(
private val learnInfraredKey: LearnInfraredKey, private val learnInfraredKey: LearnInfraredKey,
private val retryInfraredKeyLearning: RetryInfraredKeyLearning, private val retryInfraredKeyLearning: RetryInfraredKeyLearning,
private val relearnInfraredKey: RelearnInfraredKey,
private val keyRepository: InfraredKeyRepository,
private val externalScope: CoroutineScope, private val externalScope: CoroutineScope,
) { ) {
fun learn( fun learn(
@@ -28,4 +34,14 @@ class InfraredLearningCoordinator(
fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async { fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
retryInfraredKeyLearning(ref) retryInfraredKeyLearning(ref)
} }
fun relearn(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
relearnInfraredKey(ref)
}
fun failInterruptedLearning(profileId: ProviderProfileId, updatedAtEpochMillis: Long = System.currentTimeMillis()) {
externalScope.launch(NonCancellable) {
keyRepository.failInterruptedLearning(profileId, updatedAtEpochMillis)
}
}
} }
@@ -170,15 +170,28 @@ object InfraredFeatureModule {
clock: EpochMillisClock, clock: EpochMillisClock,
): RecoverInterruptedInfraredLearning = RecoverInterruptedInfraredLearning(repository, clock) ): RecoverInterruptedInfraredLearning = RecoverInterruptedInfraredLearning(repository, clock)
@Provides
@Singleton
fun provideRelearnInfraredKey(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
clock: EpochMillisClock,
): com.flagship.abox.manager.domain.infrared.RelearnInfraredKey =
com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(repository, gateway, clock)
@Provides @Provides
@Singleton @Singleton
fun provideInfraredLearningCoordinator( fun provideInfraredLearningCoordinator(
learnInfraredKey: LearnInfraredKey, learnInfraredKey: LearnInfraredKey,
retryInfraredKeyLearning: RetryInfraredKeyLearning, retryInfraredKeyLearning: RetryInfraredKeyLearning,
relearnInfraredKey: com.flagship.abox.manager.domain.infrared.RelearnInfraredKey,
repository: InfraredKeyRepository,
@InfraredApplicationScope scope: CoroutineScope, @InfraredApplicationScope scope: CoroutineScope,
): InfraredLearningCoordinator = InfraredLearningCoordinator( ): InfraredLearningCoordinator = InfraredLearningCoordinator(
learnInfraredKey = learnInfraredKey, learnInfraredKey = learnInfraredKey,
retryInfraredKeyLearning = retryInfraredKeyLearning, retryInfraredKeyLearning = retryInfraredKeyLearning,
relearnInfraredKey = relearnInfraredKey,
keyRepository = repository,
externalScope = scope, externalScope = scope,
) )
} }
@@ -74,6 +74,12 @@ class InfraredRemoteViewModel @Inject constructor(
private var activeLearningJob: Job? = null private var activeLearningJob: Job? = null
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
init {
authenticatedProfileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile)
}
}
private data class InfraredRemoteConfigSnapshot( private data class InfraredRemoteConfigSnapshot(
val effectiveProfileId: ProviderProfileId? = null, val effectiveProfileId: ProviderProfileId? = null,
Outdated
Review

恢复错位

failInterruptedLearning 使用原始认证配置,而按键数据使用经运行模式解析后的 effectiveProfileId。在 FAKE 模式下,按键通常存储于派生配置(如 abox:infrared-fake);进程中断后,这些 LEARNING 记录不会被标记为失败,而 onButtonClick 又会直接忽略已有的 LEARNING 按键,导致该按键无法重试或重新学习。应针对当前运行模式解析出的配置执行恢复。

**恢复错位** `failInterruptedLearning` 使用原始认证配置,而按键数据使用经运行模式解析后的 `effectiveProfileId`。在 FAKE 模式下,按键通常存储于派生配置(如 `abox:infrared-fake`);进程中断后,这些 `LEARNING` 记录不会被标记为失败,而 `onButtonClick` 又会直接忽略已有的 `LEARNING` 按键,导致该按键无法重试或重新学习。应针对当前运行模式解析出的配置执行恢复。 <!-- pr-agent-dedup: fe03f4d5ccbe --> <!-- pr-agent-key-issue-location: 97afcff45f2a -->
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
@@ -151,9 +157,7 @@ class InfraredRemoteViewModel @Inject constructor(
activeLearningJob = null activeLearningJob = null
activeLearningSlotFlow.value = null activeLearningSlotFlow.value = null
state.value.effectiveProfileId?.let { profile -> state.value.effectiveProfileId?.let { profile ->
Outdated
Review

取消失效

关闭学习模式时取消的只是等待结果的 ViewModel Job,而 learn/retry 返回的任务由独立的应用级 scope 执行;取消 await 不会停止底层学习。用户关闭录制或离开页面后,硬件仍可能继续学习,并且清空 active slot 后重新开启模式可启动另一个并发学习。应提供对 coordinator 底层任务的取消能力,或在任务真正结束前保留忙碌状态并阻止新任务。

**取消失效** 关闭学习模式时取消的只是等待结果的 ViewModel Job,而 `learn`/`retry` 返回的任务由独立的应用级 scope 执行;取消 `await` 不会停止底层学习。用户关闭录制或离开页面后,硬件仍可能继续学习,并且清空 active slot 后重新开启模式可启动另一个并发学习。应提供对 coordinator 底层任务的取消能力,或在任务真正结束前保留忙碌状态并阻止新任务。 <!-- pr-agent-dedup: 42f163fda940 --> <!-- pr-agent-key-issue-location: bc00d351aea6 -->
viewModelScope.launch(kotlinx.coroutines.NonCancellable) { learningCoordinator.failInterruptedLearning(profile)
keyRepository.failInterruptedLearning(profile, System.currentTimeMillis())
Outdated
Review

取消残留

关闭学习模式会取消底层 Deferred 和等待任务,但此路径没有将已持久化的 LEARNING 记录转为 FAILED 或删除,onCleared 也采用相同方式。如果协调器没有在 CancellationException 下以不可取消方式回滚,用户中途关闭开关或离开页面后,该 slot 会保持 LEARNING,后续学习请求又会被直接忽略。请显式完成取消状态收尾,并补充取消学习的持久化测试。

**取消残留** 关闭学习模式会取消底层 `Deferred` 和等待任务,但此路径没有将已持久化的 `LEARNING` 记录转为 `FAILED` 或删除,`onCleared` 也采用相同方式。如果协调器没有在 `CancellationException` 下以不可取消方式回滚,用户中途关闭开关或离开页面后,该 slot 会保持 `LEARNING`,后续学习请求又会被直接忽略。请显式完成取消状态收尾,并补充取消学习的持久化测试。 <!-- pr-agent-dedup: bc52949c626d --> <!-- pr-agent-key-issue-location: 5e714cca24cf -->
}
} }
} else { } else {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
@@ -178,10 +182,10 @@ class InfraredRemoteViewModel @Inject constructor(
} }
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
Outdated
Review

重复发射

发射过程中没有检查已有的 sendingKeySlotFlow,而遥控器按键在显示加载状态时仍可点击。用户连续点击电源键会并发执行多次红外发射,可能导致设备连续切换两次并回到原状态;其中任一任务先结束还会提前清除另一个任务的加载指示。请在 ViewModel 中阻止重复请求,或按请求分别跟踪并禁用相应按键。

**重复发射** 发射过程中没有检查已有的 `sendingKeySlotFlow`,而遥控器按键在显示加载状态时仍可点击。用户连续点击电源键会并发执行多次红外发射,可能导致设备连续切换两次并回到原状态;其中任一任务先结束还会提前清除另一个任务的加载指示。请在 ViewModel 中阻止重复请求,或按请求分别跟踪并禁用相应按键。 <!-- pr-agent-dedup: fede3506d9e3 --> <!-- pr-agent-key-issue-location: d6cc1c91e5b2 -->
activeLearningSlotFlow.value = spec.defaultSlot activeLearningSlotFlow.value = spec.defaultSlot
val deferred = if (existingKey != null) { val deferred = when (existingKey?.status) {
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
} else { InfraredKeyStatus.READY -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
learningCoordinator.learn( else -> learningCoordinator.learn(
profileId = currentProfile, profileId = currentProfile,
deviceName = savedDevice, deviceName = savedDevice,
displayName = spec.displayName, displayName = spec.displayName,
Outdated
Review

错误丢失

学习任务的完成回调只清理加载状态,没有读取或传播学习结果,并且 handleDomainError 仅用于发送操作。设备离线或认证失效导致学习失败时,界面不会显示失败原因,认证失效也不会触发 onSessionInvalid;应由协调器返回或发布结果,并将错误映射为对应的 RemoteUiMessage。

**错误丢失** 学习任务的完成回调只清理加载状态,没有读取或传播学习结果,并且 `handleDomainError` 仅用于发送操作。设备离线或认证失效导致学习失败时,界面不会显示失败原因,认证失效也不会触发 `onSessionInvalid`;应由协调器返回或发布结果,并将错误映射为对应的 `RemoteUiMessage`。 <!-- pr-agent-dedup: 16b03ab5c738 --> <!-- pr-agent-key-issue-location: 392b62285515 -->
Outdated
Review

竞态条件

关闭学习模式时会异步触发按 profile 清理全部 LEARNING 记录,但界面立即允许重新开启学习。若用户快速重新开启并开始新任务,尚未执行的清理可能把新任务也标记为 FAILED,导致随后成功的学习无法转换为 READY;初始化和 onCleared 中也存在同类风险。应等待清理完成后再允许新任务,或使用时间戳/任务标识仅清理旧记录。

**竞态条件** 关闭学习模式时会异步触发按 profile 清理全部 `LEARNING` 记录,但界面立即允许重新开启学习。若用户快速重新开启并开始新任务,尚未执行的清理可能把新任务也标记为 `FAILED`,导致随后成功的学习无法转换为 `READY`;初始化和 `onCleared` 中也存在同类风险。应等待清理完成后再允许新任务,或使用时间戳/任务标识仅清理旧记录。 <!-- pr-agent-dedup: 943572f8ab5b --> <!-- pr-agent-key-issue-location: e2acde597e7e -->
@@ -254,6 +258,9 @@ class InfraredRemoteViewModel @Inject constructor(
underlyingLearningDeferred = null underlyingLearningDeferred = null
activeLearningJob?.cancel() activeLearningJob?.cancel()
activeLearningJob = null activeLearningJob = null
state.value.effectiveProfileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile)
}
} }
fun clearMessage() { fun clearMessage() {
@@ -9,6 +9,7 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
@@ -34,10 +35,11 @@ class InfraredLearningCoordinatorTest {
private val learn = LearnInfraredKey(repository, gateway, factory, clock) private val learn = LearnInfraredKey(repository, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(repository, gateway, clock) private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
private val relearn = RelearnInfraredKey(repository, gateway, clock)
@Test @Test
fun learnRunsAsynchronouslyInExternalScope() = runTest { fun learnRunsAsynchronouslyInExternalScope() = runTest {
val coordinator = InfraredLearningCoordinator(learn, retry, this) val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.learn(profile, device, "Power") coordinator.learn(profile, device, "Power")
advanceUntilIdle() advanceUntilIdle()
@@ -65,7 +67,7 @@ class InfraredLearningCoordinatorTest {
) )
repository.insert(failedKey) repository.insert(failedKey)
val coordinator = InfraredLearningCoordinator(learn, retry, this) val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.retry(InfraredKeyRef(profile, InfraredKeyId("key1"))) coordinator.retry(InfraredKeyRef(profile, InfraredKeyId("key1")))
advanceUntilIdle() advanceUntilIdle()
@@ -73,4 +75,54 @@ class InfraredLearningCoordinatorTest {
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!! val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status) assertEquals(InfraredKeyStatus.READY, updated.status)
} }
@Test
fun relearnRunsAsynchronouslyInExternalScope() = runTest {
val readyKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_key1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(readyKey)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1")))
advanceUntilIdle()
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
}
@Test
fun failInterruptedLearningFailsLearningKeys() = runTest {
val learningKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_key1",
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(learningKey)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.failInterruptedLearning(profile, 2_000L)
advanceUntilIdle()
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.FAILED, updated.status)
}
} }
@@ -64,13 +64,14 @@ class InfraredRemoteViewModelTest {
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock) private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock) private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
private val relearn = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepo, gateway, clock)
private val send = SendInfraredKey(keyRepo, gateway) private val send = SendInfraredKey(keyRepo, gateway)
private lateinit var coordinator: InfraredLearningCoordinator private lateinit var coordinator: InfraredLearningCoordinator
@Before @Before
fun setup() { fun setup() {
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learn, retry, testScope) coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope)
} }
@After @After
@@ -270,4 +271,38 @@ class InfraredRemoteViewModelTest {
job.cancel() job.cancel()
} }
@Test
fun relearningExistingReadyKeyOverwritesKey() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message)
job.cancel()
}
} }
@@ -71,6 +71,7 @@ class InfraredControlViewModelTest {
private val learnUseCase = LearnInfraredKey(keyRepository, gateway, factory, clock) private val learnUseCase = LearnInfraredKey(keyRepository, gateway, factory, clock)
private val retryUseCase = RetryInfraredKeyLearning(keyRepository, gateway, clock) private val retryUseCase = RetryInfraredKeyLearning(keyRepository, gateway, clock)
private val relearnUseCase = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepository, gateway, clock)
private val sendUseCase = SendInfraredKey(keyRepository, gateway) private val sendUseCase = SendInfraredKey(keyRepository, gateway)
private val renameUseCase = RenameInfraredKey(keyRepository, clock) private val renameUseCase = RenameInfraredKey(keyRepository, clock)
private val deleteUseCase = DeleteInfraredKey(keyRepository) private val deleteUseCase = DeleteInfraredKey(keyRepository)
@@ -81,7 +82,7 @@ class InfraredControlViewModelTest {
@Before @Before
fun setup() { fun setup() {
Dispatchers.setMain(testDispatcher) Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, testScope) coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, relearnUseCase, keyRepository, testScope)
} }
@After @After
@@ -106,7 +106,88 @@ class RetryInfraredKeyLearning(
ref = ref, ref = ref,
expectedStatuses = setOf( expectedStatuses = setOf(
InfraredKeyStatus.FAILED, InfraredKeyStatus.FAILED,
),
targetStatus = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
) {
is DomainResult.Success -> Unit
is DomainResult.Failure -> return result
}
val result = try {
gateway.learnKey(
profileId = key.profileId,
deviceName = key.deviceName,
key = key.sdkKey,
)
} catch (e: CancellationException) {
withContext(NonCancellable) {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = "Cancelled",
updatedAtEpochMillis = clock.now(),
)
}
throw e
}
return when (result) {
is DomainResult.Success -> repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
is DomainResult.Failure -> {
repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.FAILED,
lastErrorCode = result.error.externalCode,
lastErrorDetail = null,
updatedAtEpochMillis = clock.now(),
)
result
}
}
}
}
class RelearnInfraredKey(
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.READY, InfraredKeyStatus.READY,
InfraredKeyStatus.FAILED,
), ),
targetStatus = InfraredKeyStatus.LEARNING, targetStatus = InfraredKeyStatus.LEARNING,
lastErrorCode = null, lastErrorCode = null,
@@ -22,6 +22,7 @@ class LearnInfraredKeyTest {
private val gateway = RecordingInfraredGateway() private val gateway = RecordingInfraredGateway()
private val learn = LearnInfraredKey(repository, gateway, factory, clock) private val learn = LearnInfraredKey(repository, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(repository, gateway, clock) private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
private val relearn = RelearnInfraredKey(repository, gateway, clock)
@Test @Test
fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest { fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest {
@@ -131,7 +132,34 @@ class LearnInfraredKeyTest {
assertEquals(0, repository.insertedKeys.size) assertEquals(0, repository.insertedKeys.size)
assertEquals(2, repository.transitionCalls.size) assertEquals(2, repository.transitionCalls.size)
assertEquals( assertEquals(
setOf(InfraredKeyStatus.FAILED, InfraredKeyStatus.READY), setOf(InfraredKeyStatus.FAILED),
repository.transitionCalls.first().expectedStatuses,
)
assertEquals(
setOf(InfraredKeyStatus.LEARNING),
repository.transitionCalls.last().expectedStatuses,
)
}
@Test
fun relearnReusesStableIdAndSdkKeyForReadyKey() = runTest {
val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1").copy(
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
)
repository.seed(original)
val result = relearn(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.READY, InfraredKeyStatus.FAILED),
repository.transitionCalls.first().expectedStatuses, repository.transitionCalls.first().expectedStatuses,
) )
assertEquals( assertEquals(