fix(scene): preserve fixed gateway device identifiers, fix edit loading race condition and parallel abort strategy

This commit is contained in:
2026-08-25 18:00:25 +08:00
parent b48079564f
commit e7da708e37
9 changed files with 694 additions and 465 deletions
File diff suppressed because it is too large Load Diff
@@ -50,11 +50,12 @@ data class SceneEditorState(
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
val enableLogging: Boolean = true,
val availableRemotes: List<VirtualRemote> = emptyList(),
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val isSaved: Boolean = false,
val errorMessage: String? = null,
) {
val isValid: Boolean get() = name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty())
val isValid: Boolean get() = !isLoading && name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty())
val isEditing: Boolean get() = sceneId != null
}
@@ -100,14 +101,24 @@ class SceneEditorViewModel @Inject constructor(
type = initialType ?: AutomationType.MANUAL,
colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5",
availableRemotes = it.availableRemotes,
isLoading = false,
)
}
return
}
_state.update {
it.copy(
sceneId = sceneId,
isLoading = true,
errorMessage = null,
)
}
viewModelScope.launch {
val profile = profileStore.activeProfile()
if (profile == null) {
_state.update { it.copy(errorMessage = "No active profile") }
_state.update { it.copy(isLoading = false, errorMessage = "No active profile") }
return@launch
}
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
@@ -127,10 +138,11 @@ class SceneEditorViewModel @Inject constructor(
strategy = scene.strategy,
concurrencyMode = scene.concurrencyMode,
enableLogging = scene.enableLogging,
isLoading = false,
)
}
} else {
_state.update { it.copy(errorMessage = "Scene not found") }
_state.update { it.copy(isLoading = false, errorMessage = "Scene not found") }
}
}
}
@@ -196,8 +196,9 @@
<string name="scene_condition_desc_humidity">湿度需 %1$s %2$s%%</string>
<string name="scene_condition_desc_socket">%1$s 处于 %2$s</string>
<string name="scene_condition_desc_time">时间在 %1$s ~ %2$s 之间</string>
<string name="scene_default_socket_name">Zigbee插座控制器</string>
<string name="scene_default_ir_name">红外控制器</string>
<string name="scene_device_name_label">设备名称(网关配对标识)</string>
<string name="scene_loading_scene">正在加载情景配置…</string>
<string name="scene_load_failed">无法加载情景配置。</string>
<string name="scene_default_button_power">电源</string>
<string name="scene_default_notify_title">情景完成</string>
<string name="scene_default_notify_msg">自动化任务执行完成</string>
+3 -2
View File
@@ -196,8 +196,9 @@
<string name="scene_condition_desc_humidity">Humidity must be %1$s %2$s%%</string>
<string name="scene_condition_desc_socket">%1$s is %2$s</string>
<string name="scene_condition_desc_time">Time is between %1$s ~ %2$s</string>
<string name="scene_default_socket_name">Zigbee Smart Socket</string>
<string name="scene_default_ir_name">Infrared Controller</string>
<string name="scene_device_name_label">Device Name (Gateway Identifier)</string>
<string name="scene_loading_scene">Loading scene...</string>
<string name="scene_load_failed">Failed to load scene.</string>
<string name="scene_default_button_power">Power</string>
<string name="scene_default_notify_title">Scene Completed</string>
<string name="scene_default_notify_msg">Automation task executed successfully</string>
@@ -81,10 +81,18 @@ class SceneEditorViewModelTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(existingScene.id)
// Immediately binds sceneId and enters loading state
assertTrue(viewModel.state.value.isEditing)
assertEquals(existingScene.id, viewModel.state.value.sceneId)
assertTrue(viewModel.state.value.isLoading)
assertFalse(viewModel.state.value.isValid)
advanceUntilIdle()
val state = viewModel.state.value
assertTrue(state.isEditing)
assertFalse(state.isLoading)
assertEquals("Existing Scene", state.name)
assertEquals(1, state.actions.size)
assertTrue(state.isValid)
@@ -4,6 +4,7 @@ import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@@ -36,6 +37,38 @@ class DefaultSceneExecutor(
return try {
val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) {
if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
coroutineScope {
lateinit var deferreds: List<Deferred<SceneActionExecutionResult>>
deferreds = scene.actions.mapIndexed { index, action ->
async {
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
val enriched = execResult.copy(executionDurationMillis = duration)
if (enriched.outcome == SceneActionOutcome.FAILURE) {
deferreds.forEachIndexed { otherIndex, otherDeferred ->
if (otherIndex != index) {
otherDeferred.cancel()
}
}
}
enriched
}
}
deferreds.mapIndexed { index, deferred ->
try {
deferred.await()
} catch (e: CancellationException) {
SceneActionExecutionResult(
action = scene.actions[index],
outcome = SceneActionOutcome.SKIPPED,
error = null,
)
}
}
}
} else {
coroutineScope {
scene.actions.map { action ->
async {
@@ -46,6 +79,7 @@ class DefaultSceneExecutor(
}
}.awaitAll()
}
}
} else {
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
var aborted = false
@@ -308,5 +308,45 @@ class DefaultSceneExecutorTest {
)
executor.execute(scene)
}
@Test
fun parallelAbortOnErrorCancelsPendingActions() = runTest {
val socketGateway = TestSocketGateway(
response = DomainResult.Failure(DomainError.OperationFailed(detail = "Socket failed")),
)
val executor = DefaultSceneExecutor(
socketGateway = socketGateway,
infraredGateway = TestInfraredGateway(),
delayDurationProvider = {
kotlinx.coroutines.delay(1000)
},
)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
)
val action2 = SceneAction.Delay(
id = SceneActionId("a2"),
profileId = profileId,
durationSeconds = 10,
)
val scene = Scene(
id = SceneId("s-par-abort"),
profileId = profileId,
name = "Parallel Abort Scene",
concurrencyMode = ConcurrencyMode.PARALLEL,
strategy = ExecutionStrategy.ABORT_ON_ERROR,
actions = listOf(action1, action2),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome)
}
}
@@ -22,6 +22,7 @@ import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.SocketPowerState
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@@ -227,6 +228,38 @@ class FakeSceneExecutor(
return try {
val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) {
if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
coroutineScope {
lateinit var deferreds: List<Deferred<SceneActionExecutionResult>>
deferreds = scene.actions.mapIndexed { index, action ->
async {
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
val enriched = execResult.copy(executionDurationMillis = duration)
if (enriched.outcome == SceneActionOutcome.FAILURE) {
deferreds.forEachIndexed { otherIndex, otherDeferred ->
if (otherIndex != index) {
otherDeferred.cancel()
}
}
}
enriched
}
}
deferreds.mapIndexed { index, deferred ->
try {
deferred.await()
} catch (e: CancellationException) {
SceneActionExecutionResult(
action = scene.actions[index],
outcome = SceneActionOutcome.SKIPPED,
error = null,
)
}
}
}
} else {
coroutineScope {
scene.actions.map { action ->
async {
@@ -237,6 +270,7 @@ class FakeSceneExecutor(
}
}.awaitAll()
}
}
} else {
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
var aborted = false
@@ -1,8 +1,10 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.ConcurrencyMode
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.ExecutionStrategy
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
@@ -156,6 +158,44 @@ class FakeSceneExecutorTest {
assertEquals(listOf(notificationAction), executor.sentNotifications)
}
@Test
fun parallelAbortOnErrorCancelsPendingActions() = runTest {
val socketGateway = FakeSocketGateway()
socketGateway.setError(
profileId,
DeviceName("socket-1"),
DomainError.DeviceOffline(detail = "offline"),
)
val executor = FakeSceneExecutor(
socketGateway = socketGateway,
infraredGateway = FakeInfraredGateway(),
delayDurationProvider = { kotlinx.coroutines.delay(1000) },
)
val action1 = socketAction("socket-1", SocketPowerState.ON)
val action2 = SceneAction.Delay(
id = SceneActionId("abox:delay-1"),
profileId = profileId,
durationSeconds = 10,
)
val scene = Scene(
id = SceneId("${profileId.value}:scene-par-abort"),
profileId = profileId,
name = "Parallel Abort Scene",
concurrencyMode = ConcurrencyMode.PARALLEL,
strategy = ExecutionStrategy.ABORT_ON_ERROR,
actions = listOf(action1, action2),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome)
}
private fun scene(actions: List<SceneAction>): Scene = Scene(
id = SceneId("${profileId.value}:scene-test"),
profileId = profileId,