feat(infrared): add infrared control, learning, and virtual remote module #9
+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