feat(scene): add scene editor, executor and fakes (phase 1) #5
+387
-328
File diff suppressed because it is too large
Load Diff
+15
-3
@@ -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 {
|
||||
|
pr-agent-bot
commented
编辑竞态 编辑现有场景时, **编辑竞态**
编辑现有场景时,`sceneId` 只有在异步查询成功后才写入状态,且没有加载态阻止编辑或保存。仓库响应较慢或失败时,页面会按“新建”处理;用户若完成有效输入并保存,`save()` 将调用 `createScene`,产生重复场景而不是更新目标。应先绑定待编辑的 ID,并在加载完成前禁用保存。
<!-- pr-agent-dedup: 41ec446dd682 -->
<!-- pr-agent-key-issue-location: 2c71b6e33002 -->
|
||||
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+34
@@ -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 {
|
||||
|
pr-agent-bot
commented
策略失效
**策略失效**
`PARALLEL` 分支一次性启动所有动作,完全未处理 `scene.strategy`。当用户组合选择并行与 `ABORT_ON_ERROR` 时,即使一个动作失败,其余插座或红外等副作用仍会执行,使“遇错中止”失效。应禁止该组合,或在失败后取消尚未完成的任务并明确已启动动作的语义。
<!-- pr-agent-dedup: 7ef796cc7fb8 -->
<!-- pr-agent-key-issue-location: d6ce8a1fade9 -->
|
||||
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
|
||||
|
||||
+40
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user
校验缺失
isValid没有要求自动场景至少包含一个触发器,因此用户只填写名称和动作就能保存一个已启用但永远不会被触发的自动场景。应在类型为AUTOMATIC时要求triggers非空,或将其明确保存为禁用草稿。