diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt new file mode 100644 index 0000000..14e8c90 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt @@ -0,0 +1,27 @@ +package com.flagship.abox.manager.infrared.ui + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode + +sealed interface InfraredUiMessage { + data class Info(val text: String) : InfraredUiMessage + data class Error(val text: String) : InfraredUiMessage + data object SessionInvalid : InfraredUiMessage +} + +data class InfraredControlUiState( + val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, + val effectiveProfileId: ProviderProfileId? = null, + val deviceNameInput: String = "", + val savedDeviceName: DeviceName? = null, + val keys: List = emptyList(), + val loading: Boolean = true, + val sendingKeyIds: Set = emptySet(), + val message: InfraredUiMessage? = null, +) { + val hasActiveLearning: Boolean get() = keys.any { it.status == InfraredKeyStatus.LEARNING } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt new file mode 100644 index 0000000..7b5eeeb --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt @@ -0,0 +1,327 @@ +package com.flagship.abox.manager.infrared.ui + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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 com.flagship.abox.manager.domain.infrared.DeleteInfraredKey +import com.flagship.abox.manager.domain.infrared.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyRef +import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning +import com.flagship.abox.manager.domain.infrared.RenameInfraredKey +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class InfraredControlViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val keyRepository: InfraredKeyRepository, + private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, + private val runtimeModeStore: InfraredRuntimeModeStore, + private val profileResolver: InfraredRuntimeProfileResolver, + private val learningCoordinator: InfraredLearningCoordinator, + private val sendInfraredKey: SendInfraredKey, + private val renameInfraredKey: RenameInfraredKey, + private val deleteInfraredKey: DeleteInfraredKey, + private val recoverInterruptedInfraredLearning: RecoverInterruptedInfraredLearning, + private val clock: EpochMillisClock, +) : ViewModel() { + + private val rawProfileId: String? = savedStateHandle["profileId"] + private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } + + private val deviceNameInputFlow = MutableStateFlow("") + private val sendingKeyIdsFlow = MutableStateFlow>(emptySet()) + private val messageFlow = MutableStateFlow(null) + + private val effectiveProfileAndMode: Flow> = + runtimeModeStore.mode.mapModeToProfile() + + private fun Flow.mapModeToProfile(): Flow> { + return flatMapLatest { mode -> + if (authenticatedProfileId == null) { + flowOf(Pair(null, mode)) + } else { + val effectiveProfile = profileResolver.resolve(authenticatedProfileId, mode) + flowOf(Pair(effectiveProfile, mode)) + } + } + } + + private val deviceNameFlow: Flow> = + effectiveProfileAndMode.flatMapLatest { (profile, _) -> + if (profile == null) { + flowOf(DomainResult.Success(null)) + } else { + deviceConfigurationRepository.observeDeviceName(profile) + } + } + + private val keysFlow: Flow>> = + effectiveProfileAndMode.flatMapLatest { (profile, _) -> + if (profile == null) { + flowOf(DomainResult.Success(emptyList())) + } else { + deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> + when (deviceResult) { + is DomainResult.Success -> { + val device = deviceResult.value + if (device == null) { + flowOf(DomainResult.Success(emptyList())) + } else { + keyRepository.observeKeys(profile, device) + } + } + is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error)) + } + } + } + } + + private data class ServerState( + val profile: ProviderProfileId?, + val mode: InfraredRuntimeMode, + val deviceResult: DomainResult, + val keysResult: DomainResult>, + ) + + private val serverStateFlow: Flow = combine( + effectiveProfileAndMode, + deviceNameFlow, + keysFlow, + ) { profileAndMode, deviceResult, keysResult -> + ServerState( + profile = profileAndMode.first, + mode = profileAndMode.second, + deviceResult = deviceResult, + keysResult = keysResult, + ) + } + + val state: StateFlow = combine( + serverStateFlow, + deviceNameInputFlow, + sendingKeyIdsFlow, + messageFlow, + ) { serverState, input, sendingIds, msg -> + val savedDevice = (serverState.deviceResult as? DomainResult.Success)?.value + val keys = (serverState.keysResult as? DomainResult.Success>)?.value ?: emptyList() + val loading = serverState.profile == null || + serverState.deviceResult !is DomainResult.Success || + serverState.keysResult !is DomainResult.Success + + InfraredControlUiState( + runtimeMode = serverState.mode, + effectiveProfileId = serverState.profile, + deviceNameInput = input, + savedDeviceName = savedDevice, + keys = keys, + loading = loading, + sendingKeyIds = sendingIds, + message = if (authenticatedProfileId == null) { + InfraredUiMessage.Error("Invalid or missing profile ID") + } else { + msg + }, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = InfraredControlUiState( + runtimeMode = InfraredRuntimeMode.FAKE, + effectiveProfileId = null, + loading = true, + ), + ) + + init { + if (authenticatedProfileId != null) { + viewModelScope.launch { + val currentMode = runtimeModeStore.currentMode() + val effectiveProfile = profileResolver.resolve(authenticatedProfileId, currentMode) + recoverInterruptedInfraredLearning(effectiveProfile) + } + } + } + + fun onDeviceNameChanged(value: String) { + deviceNameInputFlow.value = value + } + + fun saveDeviceName() { + val currentProfile = state.value.effectiveProfileId ?: return + val trimmed = deviceNameInputFlow.value.trim() + if (trimmed.isBlank()) { + messageFlow.value = InfraredUiMessage.Error("Device name cannot be blank") + return + } + viewModelScope.launch { + when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info("Device name saved") + deviceNameInputFlow.value = "" + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun learnKey(displayName: String) { + val currentProfile = state.value.effectiveProfileId ?: return + val savedDevice = state.value.savedDeviceName + if (savedDevice == null) { + messageFlow.value = InfraredUiMessage.Error("Please set and save a device name first") + return + } + val trimmedName = displayName.trim() + if (trimmedName.isBlank()) { + messageFlow.value = InfraredUiMessage.Error("Key name cannot be blank") + return + } + learningCoordinator.learn(currentProfile, savedDevice, trimmedName) + } + + fun retryLearning(keyId: InfraredKeyId) { + val currentProfile = state.value.effectiveProfileId ?: return + learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId)) + } + + fun sendKey(keyId: InfraredKeyId) { + val currentProfile = state.value.effectiveProfileId ?: return + if (state.value.sendingKeyIds.contains(keyId)) return + + val key = state.value.keys.find { it.id == keyId } + if (key == null || key.status != InfraredKeyStatus.READY) { + messageFlow.value = InfraredUiMessage.Error("Key is not ready to send") + return + } + + sendingKeyIdsFlow.value = sendingKeyIdsFlow.value + keyId + viewModelScope.launch { + try { + when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, keyId))) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info("Signal sent successfully") + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } finally { + sendingKeyIdsFlow.value = sendingKeyIdsFlow.value - keyId + } + } + } + + fun renameKey(keyId: InfraredKeyId, displayName: String) { + val currentProfile = state.value.effectiveProfileId ?: return + val trimmed = displayName.trim() + if (trimmed.isBlank()) { + messageFlow.value = InfraredUiMessage.Error("Name cannot be blank") + return + } + viewModelScope.launch { + when (val result = renameInfraredKey(InfraredKeyRef(currentProfile, keyId), trimmed)) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info("Key renamed") + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun deleteKey(keyId: InfraredKeyId) { + val currentProfile = state.value.effectiveProfileId ?: return + viewModelScope.launch { + when (val result = deleteInfraredKey(InfraredKeyRef(currentProfile, keyId))) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info("Key removed locally") + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun setRuntimeMode(mode: InfraredRuntimeMode) { + if (state.value.hasActiveLearning) { + messageFlow.value = InfraredUiMessage.Error("Cannot change mode while learning is active") + return + } + viewModelScope.launch { + when (val result = runtimeModeStore.setMode(mode)) { + is DomainResult.Success -> { + if (authenticatedProfileId != null) { + val newEffectiveProfile = profileResolver.resolve(authenticatedProfileId, mode) + recoverInterruptedInfraredLearning(newEffectiveProfile) + } + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun clearMessage() { + messageFlow.value = null + } + + private fun handleDomainError(error: DomainError) { + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> { + messageFlow.value = InfraredUiMessage.SessionInvalid + } + is DomainError.DeviceOffline -> { + messageFlow.value = InfraredUiMessage.Error("Infrared controller is offline (code: ${error.externalCode ?: "20206"})") + } + is DomainError.DeviceNotFound -> { + messageFlow.value = InfraredUiMessage.Error("Device not found (code: ${error.externalCode ?: "20203"})") + } + is DomainError.InvalidRequest -> { + messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Invalid request") + } + is DomainError.OperationFailed -> { + messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Operation failed (code: ${error.externalCode ?: "unknown"})") + } + is DomainError.Network -> { + messageFlow.value = InfraredUiMessage.Error("Network error: ${error.detail ?: "connection failed"}") + } + is DomainError.NoData -> { + messageFlow.value = InfraredUiMessage.Error(error.detail ?: "No data found") + } + is DomainError.Unknown -> { + messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Unknown error") + } + } + } +} diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt new file mode 100644 index 0000000..014c39d --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt @@ -0,0 +1,294 @@ +package com.flagship.abox.manager.infrared.ui + +import androidx.lifecycle.SavedStateHandle +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 com.flagship.abox.manager.domain.infrared.DeleteInfraredKey +import com.flagship.abox.manager.domain.infrared.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyRef +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.LearnInfraredKey +import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning +import com.flagship.abox.manager.domain.infrared.RenameInfraredKey +import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver +import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway +import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class FakeRuntimeModeStore : InfraredRuntimeModeStore { + private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE) + override val mode: Flow = modeFlow + override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value + override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { + modeFlow.value = mode + return DomainResult.Success(Unit) + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +class InfraredControlViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val baseProfile = ProviderProfileId("abox") + private val fakeProfile = ProviderProfileId("abox:infrared-fake") + private val device = DeviceName("ir-living") + private val clock = EpochMillisClock { 10_000L } + + private val keyRepository = FakeInfraredKeyRepository() + private val deviceConfigRepository = FakeInfraredDeviceConfigurationRepository() + private val runtimeModeStore = FakeRuntimeModeStore() + private val profileResolver = InfraredRuntimeProfileResolver() + private val gateway = FakeInfraredGateway() + private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock) + + private val learnUseCase = LearnInfraredKey(keyRepository, gateway, factory, clock) + private val retryUseCase = RetryInfraredKeyLearning(keyRepository, gateway, clock) + private val sendUseCase = SendInfraredKey(keyRepository, gateway) + private val renameUseCase = RenameInfraredKey(keyRepository, clock) + private val deleteUseCase = DeleteInfraredKey(keyRepository) + private val recoverUseCase = RecoverInterruptedInfraredLearning(keyRepository, clock) + + private lateinit var coordinator: InfraredLearningCoordinator + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, testScope) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + private fun createViewModel(profileId: String? = "abox"): InfraredControlViewModel { + val handle = SavedStateHandle(if (profileId != null) mapOf("profileId" to profileId) else emptyMap()) + return InfraredControlViewModel( + savedStateHandle = handle, + keyRepository = keyRepository, + deviceConfigurationRepository = deviceConfigRepository, + runtimeModeStore = runtimeModeStore, + profileResolver = profileResolver, + learningCoordinator = coordinator, + sendInfraredKey = sendUseCase, + renameInfraredKey = renameUseCase, + deleteInfraredKey = deleteUseCase, + recoverInterruptedInfraredLearning = recoverUseCase, + clock = clock, + ) + } + + @Test + fun resolvesEffectiveProfileByRuntimeMode() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + assertEquals(fakeProfile, vm.state.value.effectiveProfileId) + assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode) + + vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK) + advanceUntilIdle() + + assertEquals(baseProfile, vm.state.value.effectiveProfileId) + assertEquals(InfraredRuntimeMode.REAL_ABSDK, vm.state.value.runtimeMode) + + job.cancel() + } + + @Test + fun blankDeviceNameReturnsValidationError() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onDeviceNameChanged(" ") + vm.saveDeviceName() + advanceUntilIdle() + + val msg = vm.state.value.message + assertTrue(msg is InfraredUiMessage.Error) + assertEquals("Device name cannot be blank", (msg as InfraredUiMessage.Error).text) + + job.cancel() + } + + @Test + fun saveDeviceNamePersistsAndTrims() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onDeviceNameChanged(" ir-main ") + vm.saveDeviceName() + advanceUntilIdle() + + assertEquals(DeviceName("ir-main"), vm.state.value.savedDeviceName) + assertEquals("", vm.state.value.deviceNameInput) + assertTrue(vm.state.value.message is InfraredUiMessage.Info) + + job.cancel() + } + + @Test + fun learnWithoutSavedDeviceFails() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.learnKey("Power") + advanceUntilIdle() + + val msg = vm.state.value.message + assertTrue(msg is InfraredUiMessage.Error) + assertEquals("Please set and save a device name first", (msg as InfraredUiMessage.Error).text) + + job.cancel() + } + + @Test + fun learnWithSavedDeviceSucceeds() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onDeviceNameChanged("ir-1") + vm.saveDeviceName() + advanceUntilIdle() + + vm.learnKey("Air Conditioner") + advanceUntilIdle() + + val keys = vm.state.value.keys + assertEquals(1, keys.size) + assertEquals("Air Conditioner", keys.first().displayName) + assertEquals(InfraredKeyStatus.READY, keys.first().status) + + job.cancel() + } + + @Test + fun sendReadyKeySendsSignalAndClearsSendingSet() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + deviceConfigRepository.setDeviceName(fakeProfile, device) + val key = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "Power", + sdkKey = "app_ir_k1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepository.insert(key) + advanceUntilIdle() + + vm.sendKey(InfraredKeyId("k1")) + advanceUntilIdle() + + assertTrue(vm.state.value.sendingKeyIds.isEmpty()) + assertTrue(vm.state.value.message is InfraredUiMessage.Info) + assertEquals(1, gateway.sentKeys.size) + + job.cancel() + } + + @Test + fun sendFailurePreservesReadyStatus() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + deviceConfigRepository.setDeviceName(fakeProfile, device) + val key = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "Power", + sdkKey = "app_ir_k1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepository.insert(key) + gateway.sendResult = DomainResult.Failure(DomainError.DeviceOffline("20206")) + advanceUntilIdle() + + vm.sendKey(InfraredKeyId("k1")) + advanceUntilIdle() + + val fetched = vm.state.value.keys.single() + assertEquals(InfraredKeyStatus.READY, fetched.status) + assertTrue(vm.state.value.message is InfraredUiMessage.Error) + + job.cancel() + } + + @Test + fun setModeBlockedWhileLearningIsActive() = testScope.runTest { + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + deviceConfigRepository.setDeviceName(fakeProfile, device) + val key = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = fakeProfile, + deviceName = device, + displayName = "Power", + sdkKey = "app_ir_k1", + status = InfraredKeyStatus.LEARNING, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + keyRepository.insert(key) + advanceUntilIdle() + + assertTrue(vm.state.value.hasActiveLearning) + vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK) + advanceUntilIdle() + + assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode) + assertTrue(vm.state.value.message is InfraredUiMessage.Error) + + job.cancel() + } +}