feat: add infrared control state machine
This commit is contained in:
+27
@@ -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<SavedInfraredKey> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
val sendingKeyIds: Set<InfraredKeyId> = emptySet(),
|
||||
val message: InfraredUiMessage? = null,
|
||||
) {
|
||||
val hasActiveLearning: Boolean get() = keys.any { it.status == InfraredKeyStatus.LEARNING }
|
||||
}
|
||||
+327
@@ -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<Set<InfraredKeyId>>(emptySet())
|
||||
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
|
||||
|
||||
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
|
||||
runtimeModeStore.mode.mapModeToProfile()
|
||||
|
||||
private fun Flow<InfraredRuntimeMode>.mapModeToProfile(): Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> {
|
||||
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<DomainResult<DeviceName?>> =
|
||||
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
|
||||
if (profile == null) {
|
||||
flowOf(DomainResult.Success(null))
|
||||
} else {
|
||||
deviceConfigurationRepository.observeDeviceName(profile)
|
||||
}
|
||||
}
|
||||
|
||||
private val keysFlow: Flow<DomainResult<List<SavedInfraredKey>>> =
|
||||
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<DeviceName?>,
|
||||
val keysResult: DomainResult<List<SavedInfraredKey>>,
|
||||
)
|
||||
|
||||
private val serverStateFlow: Flow<ServerState> = combine(
|
||||
effectiveProfileAndMode,
|
||||
deviceNameFlow,
|
||||
keysFlow,
|
||||
) { profileAndMode, deviceResult, keysResult ->
|
||||
ServerState(
|
||||
profile = profileAndMode.first,
|
||||
mode = profileAndMode.second,
|
||||
deviceResult = deviceResult,
|
||||
keysResult = keysResult,
|
||||
)
|
||||
}
|
||||
|
||||
val state: StateFlow<InfraredControlUiState> = combine(
|
||||
serverStateFlow,
|
||||
deviceNameInputFlow,
|
||||
sendingKeyIdsFlow,
|
||||
messageFlow,
|
||||
) { serverState, input, sendingIds, msg ->
|
||||
val savedDevice = (serverState.deviceResult as? DomainResult.Success<DeviceName?>)?.value
|
||||
val keys = (serverState.keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+294
@@ -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<InfraredRuntimeMode> = modeFlow
|
||||
override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
|
||||
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user