fix(scene): address bot review comments on concurrency, cancellation, validation, and race conditions

This commit is contained in:
2026-08-25 17:16:04 +08:00
parent db6a7978a9
commit 56451be0d8
11 changed files with 484 additions and 120 deletions
@@ -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()
}
}
@@ -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(
@@ -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(
@@ -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)
}
}
@@ -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)
}
}