fix(infrared): propagate repository storage errors and recover learning keys across effective profiles
This commit is contained in:
+53
-22
@@ -75,8 +75,16 @@ class InfraredRemoteViewModel @Inject constructor(
|
||||
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
|
||||
|
||||
init {
|
||||
authenticatedProfileId?.let { profile ->
|
||||
learningCoordinator.failInterruptedLearning(profile)
|
||||
if (authenticatedProfileId != null) {
|
||||
viewModelScope.launch {
|
||||
runtimeModeStore.mode.collect { mode ->
|
||||
val effective = profileResolver.resolve(authenticatedProfileId, mode)
|
||||
learningCoordinator.failInterruptedLearning(effective)
|
||||
if (effective != authenticatedProfileId) {
|
||||
learningCoordinator.failInterruptedLearning(authenticatedProfileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +93,7 @@ class InfraredRemoteViewModel @Inject constructor(
|
||||
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
|
||||
val savedDeviceName: DeviceName? = null,
|
||||
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
||||
val configError: DomainError? = null,
|
||||
)
|
||||
|
||||
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
|
||||
@@ -94,25 +103,43 @@ class InfraredRemoteViewModel @Inject constructor(
|
||||
flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode))
|
||||
} else {
|
||||
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
|
||||
val deviceName = (deviceResult as? DomainResult.Success)?.value
|
||||
if (deviceName == null) {
|
||||
flowOf(
|
||||
when (deviceResult) {
|
||||
is DomainResult.Failure -> flowOf(
|
||||
InfraredRemoteConfigSnapshot(
|
||||
effectiveProfileId = profile,
|
||||
runtimeMode = mode,
|
||||
savedDeviceName = null,
|
||||
learnedSlotMap = emptyMap(),
|
||||
configError = deviceResult.error,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
keyRepository.observeKeys(profile, deviceName).map { keysResult ->
|
||||
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
|
||||
InfraredRemoteConfigSnapshot(
|
||||
effectiveProfileId = profile,
|
||||
runtimeMode = mode,
|
||||
savedDeviceName = deviceName,
|
||||
learnedSlotMap = keys.associateBy { it.sdkKey },
|
||||
)
|
||||
is DomainResult.Success -> {
|
||||
val deviceName = deviceResult.value
|
||||
if (deviceName == null) {
|
||||
flowOf(
|
||||
InfraredRemoteConfigSnapshot(
|
||||
effectiveProfileId = profile,
|
||||
runtimeMode = mode,
|
||||
savedDeviceName = null,
|
||||
learnedSlotMap = emptyMap(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
keyRepository.observeKeys(profile, deviceName).map { keysResult ->
|
||||
when (keysResult) {
|
||||
is DomainResult.Failure -> InfraredRemoteConfigSnapshot(
|
||||
effectiveProfileId = profile,
|
||||
runtimeMode = mode,
|
||||
savedDeviceName = deviceName,
|
||||
configError = keysResult.error,
|
||||
)
|
||||
is DomainResult.Success -> InfraredRemoteConfigSnapshot(
|
||||
effectiveProfileId = profile,
|
||||
runtimeMode = mode,
|
||||
savedDeviceName = deviceName,
|
||||
learnedSlotMap = keysResult.value.associateBy { it.sdkKey },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,6 +154,13 @@ class InfraredRemoteViewModel @Inject constructor(
|
||||
Triple(sending, learning, msg)
|
||||
},
|
||||
) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) ->
|
||||
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
|
||||
when (error) {
|
||||
is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid
|
||||
is DomainError.DeviceOffline -> RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})")
|
||||
else -> RemoteUiMessage.Error(error.detail ?: "数据加载失败")
|
||||
}
|
||||
}
|
||||
InfraredRemoteUiState(
|
||||
selectedScene = scene,
|
||||
isLearningMode = isLearning,
|
||||
@@ -136,7 +170,7 @@ class InfraredRemoteViewModel @Inject constructor(
|
||||
learnedSlotMap = snapshot.learnedSlotMap,
|
||||
sendingKeySlot = sendingSlot,
|
||||
activeLearningSlot = learningSlot,
|
||||
message = msg,
|
||||
message = resolvedMessage,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
@@ -177,15 +211,12 @@ class InfraredRemoteViewModel @Inject constructor(
|
||||
return
|
||||
}
|
||||
val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
|
||||
if (existingKey?.status == InfraredKeyStatus.LEARNING) {
|
||||
return
|
||||
}
|
||||
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
|
||||
activeLearningSlotFlow.value = spec.defaultSlot
|
||||
val deferred = when (existingKey?.status) {
|
||||
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
|
||||
InfraredKeyStatus.READY -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
|
||||
else -> learningCoordinator.learn(
|
||||
InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
|
||||
null -> learningCoordinator.learn(
|
||||
profileId = currentProfile,
|
||||
deviceName = savedDevice,
|
||||
displayName = spec.displayName,
|
||||
|
||||
+47
@@ -305,4 +305,51 @@ class InfraredRemoteViewModelTest {
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relearningKeyInLearningStatusSucceeds() = testScope.runTest {
|
||||
deviceRepo.setDeviceName(fakeProfile, device)
|
||||
val learningKey = SavedInfraredKey(
|
||||
id = InfraredKeyId("k1"),
|
||||
profileId = fakeProfile,
|
||||
deviceName = device,
|
||||
displayName = "空调-电源",
|
||||
sdkKey = "1",
|
||||
status = InfraredKeyStatus.LEARNING,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = null,
|
||||
createdAtEpochMillis = 100L,
|
||||
updatedAtEpochMillis = 100L,
|
||||
)
|
||||
keyRepo.insert(learningKey)
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun configSnapshotStorageErrorPropagatesToUiStateMessage() = testScope.runTest {
|
||||
deviceRepo.observeError = com.flagship.abox.manager.domain.DomainError.Unknown(detail = "Disk failure")
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(RemoteUiMessage.Error("Disk failure"), vm.state.value.message)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
+1
-5
@@ -176,11 +176,6 @@ class RelearnInfraredKey(
|
||||
|
||||
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(
|
||||
@@ -188,6 +183,7 @@ class RelearnInfraredKey(
|
||||
expectedStatuses = setOf(
|
||||
InfraredKeyStatus.READY,
|
||||
InfraredKeyStatus.FAILED,
|
||||
InfraredKeyStatus.LEARNING,
|
||||
),
|
||||
targetStatus = InfraredKeyStatus.LEARNING,
|
||||
lastErrorCode = null,
|
||||
|
||||
+1
-1
@@ -159,7 +159,7 @@ class LearnInfraredKeyTest {
|
||||
assertEquals(0, repository.insertedKeys.size)
|
||||
assertEquals(2, repository.transitionCalls.size)
|
||||
assertEquals(
|
||||
setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED),
|
||||
setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED, InfraredKeyStatus.LEARNING),
|
||||
repository.transitionCalls.first().expectedStatuses,
|
||||
)
|
||||
assertEquals(
|
||||
|
||||
+2
@@ -134,10 +134,12 @@ class FakeInfraredKeyRepository : InfraredKeyRepository {
|
||||
class FakeInfraredDeviceConfigurationRepository : InfraredDeviceConfigurationRepository {
|
||||
private val lock = Any()
|
||||
private val deviceNames = MutableStateFlow<Map<ProviderProfileId, DeviceName>>(emptyMap())
|
||||
var observeError: DomainError? = null
|
||||
|
||||
override fun observeDeviceName(
|
||||
profileId: ProviderProfileId,
|
||||
): Flow<DomainResult<DeviceName?>> = deviceNames.map { values ->
|
||||
observeError?.let { return@map DomainResult.Failure(it) }
|
||||
DomainResult.Success(values[profileId])
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user