fix(scene): address bot review comments on concurrency, cancellation, validation, and race conditions
This commit is contained in:
+11
-10
@@ -58,7 +58,6 @@ import androidx.compose.runtime.mutableDoubleStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -895,19 +894,21 @@ private fun AddActionDialog(
|
||||
var notifyTitle by remember { mutableStateOf("情景完成") }
|
||||
var notifyMsg by remember { mutableStateOf("自动化任务执行完成") }
|
||||
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(availableRemotes, selectedRemoteIndex) {
|
||||
if (availableRemotes.isNotEmpty() && selectedRemoteIndex in availableRemotes.indices) {
|
||||
val remote = availableRemotes[selectedRemoteIndex]
|
||||
coroutineScope.launch {
|
||||
val bindings = viewModel.getKeyBindings(remote.id)
|
||||
keyBindings = bindings
|
||||
if (bindings.isNotEmpty()) {
|
||||
selectedButtonId = bindings[0].buttonId
|
||||
selectedButtonName = bindings[0].displayName.ifBlank { bindings[0].buttonId }
|
||||
}
|
||||
keyBindings = emptyList()
|
||||
val bindings = viewModel.getKeyBindings(remote.id)
|
||||
keyBindings = bindings
|
||||
if (bindings.isNotEmpty()) {
|
||||
selectedButtonId = bindings[0].buttonId
|
||||
selectedButtonName = bindings[0].displayName.ifBlank { bindings[0].buttonId }
|
||||
} else {
|
||||
selectedButtonId = "power"
|
||||
selectedButtonName = "电源"
|
||||
}
|
||||
} else {
|
||||
keyBindings = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
-1
@@ -54,7 +54,7 @@ data class SceneEditorState(
|
||||
val isSaved: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
) {
|
||||
val isValid: Boolean get() = name.isNotBlank() && actions.isNotEmpty()
|
||||
val isValid: Boolean get() = name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty())
|
||||
val isEditing: Boolean get() = sceneId != null
|
||||
}
|
||||
|
||||
@@ -68,6 +68,9 @@ class SceneEditorViewModel @Inject constructor(
|
||||
private val _state = MutableStateFlow(SceneEditorState())
|
||||
val state: StateFlow<SceneEditorState> = _state.asStateFlow()
|
||||
|
||||
private var initializedSceneId: SceneId? = null
|
||||
private var isInitialized: Boolean = false
|
||||
|
||||
init {
|
||||
observeAvailableRemotes()
|
||||
}
|
||||
@@ -87,6 +90,10 @@ class SceneEditorViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun initialize(sceneId: SceneId?, initialType: AutomationType? = null) {
|
||||
if (isInitialized && initializedSceneId == sceneId) return
|
||||
isInitialized = true
|
||||
initializedSceneId = sceneId
|
||||
|
||||
if (sceneId == null) {
|
||||
_state.update {
|
||||
SceneEditorState(
|
||||
|
||||
+16
-3
@@ -34,7 +34,13 @@ class SceneExecutionViewModel @Inject constructor(
|
||||
private val _uiState = MutableStateFlow<SceneExecutionUiState>(SceneExecutionUiState.Loading)
|
||||
val uiState: StateFlow<SceneExecutionUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var initializedSceneId: SceneId? = null
|
||||
|
||||
fun initialize(sceneId: SceneId) {
|
||||
if (initializedSceneId == sceneId && _uiState.value !is SceneExecutionUiState.Loading) {
|
||||
return
|
||||
}
|
||||
initializedSceneId = sceneId
|
||||
_uiState.value = SceneExecutionUiState.Loading
|
||||
viewModelScope.launch {
|
||||
val profile = profileStore.activeProfile()
|
||||
@@ -53,11 +59,18 @@ class SceneExecutionViewModel @Inject constructor(
|
||||
|
||||
fun execute() {
|
||||
val current = _uiState.value
|
||||
val scene = (current as? SceneExecutionUiState.Ready)?.scene
|
||||
?: (current as? SceneExecutionUiState.Running)?.scene
|
||||
?: return
|
||||
val scene = when (current) {
|
||||
is SceneExecutionUiState.Ready -> current.scene
|
||||
is SceneExecutionUiState.Completed -> current.result.scene
|
||||
else -> return
|
||||
}
|
||||
_uiState.value = SceneExecutionUiState.Running(scene)
|
||||
viewModelScope.launch {
|
||||
val profile = profileStore.activeProfile()
|
||||
if (profile == null || profile.id != scene.profileId) {
|
||||
_uiState.value = SceneExecutionUiState.Failed("Active profile mismatch")
|
||||
return@launch
|
||||
}
|
||||
when (val result = sceneExecutor.execute(scene)) {
|
||||
is DomainResult.Success -> _uiState.value = SceneExecutionUiState.Completed(result.value)
|
||||
is DomainResult.Failure -> _uiState.value = SceneExecutionUiState.Failed(
|
||||
|
||||
+34
@@ -158,5 +158,39 @@ class SceneEditorViewModelTest {
|
||||
assertTrue(viewModel.state.value.isSaved)
|
||||
assertFalse(viewModel.state.value.isSaving)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun automaticSceneWithoutTriggersIsInvalid() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
||||
viewModel.updateName("Auto Without Trigger")
|
||||
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Missing triggers -> invalid
|
||||
assertFalse(viewModel.state.value.isValid)
|
||||
|
||||
// Adding trigger makes it valid
|
||||
viewModel.addTemperatureTrigger("sensor-1", ComparisonOperator.GREATER_THAN, 28.0)
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.state.value.isValid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initializeIsIdempotentAndPreservesDraftState() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
viewModel.initialize(existingScene.id)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.updateName("Modified Unsaved Name")
|
||||
assertEquals("Modified Unsaved Name", viewModel.state.value.name)
|
||||
|
||||
// Re-invoking initialize with same sceneId should NOT overwrite draft
|
||||
viewModel.initialize(existingScene.id)
|
||||
advanceUntilIdle()
|
||||
assertEquals("Modified Unsaved Name", viewModel.state.value.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+35
@@ -87,4 +87,39 @@ class SceneExecutionViewModelTest {
|
||||
assertEquals(testScene.id, completed.result.scene.id)
|
||||
assertEquals(1, completed.result.results.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initializeIsIdempotentAndPreservesCompletedState() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
|
||||
viewModel.initialize(testScene.id)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.execute()
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
|
||||
|
||||
// Re-initializing with same sceneId should preserve Completed state
|
||||
viewModel.initialize(testScene.id)
|
||||
advanceUntilIdle()
|
||||
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun executeFailsWhenActiveProfileMismatches() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
|
||||
viewModel.initialize(testScene.id)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Switch to a different profile
|
||||
profileStore.setActiveProfile(ProviderProfileId("different-profile"))
|
||||
|
||||
viewModel.execute()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Failed)
|
||||
val failed = viewModel.uiState.value as SceneExecutionUiState.Failed
|
||||
assertEquals("Active profile mismatch", failed.message)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user