feat(scene): add scene editor, executor and fakes (phase 1) #5

Merged
KremeCN merged 10 commits from feat/scene-editor into main 2026-08-25 20:20:19 +08:00
9 changed files with 694 additions and 465 deletions
Showing only changes of commit e7da708e37 - Show all commits
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())
Outdated
Review

校验缺失

isValid 没有要求自动场景至少包含一个触发器,因此用户只填写名称和动作就能保存一个已启用但永远不会被触发的自动场景。应在类型为 AUTOMATIC 时要求 triggers 非空,或将其明确保存为禁用草稿。

**校验缺失** `isValid` 没有要求自动场景至少包含一个触发器,因此用户只填写名称和动作就能保存一个已启用但永远不会被触发的自动场景。应在类型为 `AUTOMATIC` 时要求 `triggers` 非空,或将其明确保存为禁用草稿。 <!-- pr-agent-dedup: 3de3aa0f9568 --> <!-- pr-agent-key-issue-location: 0ace8ec93357 -->
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 {
Outdated
Review

编辑竞态

编辑现有场景时,sceneId 只有在异步查询成功后才写入状态,且没有加载态阻止编辑或保存。仓库响应较慢或失败时,页面会按“新建”处理;用户若完成有效输入并保存,save() 将调用 createScene,产生重复场景而不是更新目标。应先绑定待编辑的 ID,并在加载完成前禁用保存。

**编辑竞态** 编辑现有场景时,`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>
+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,15 +37,48 @@ class DefaultSceneExecutor(
return try {
val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) {
coroutineScope {
scene.actions.map { action ->
async {
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
execResult.copy(executionDurationMillis = duration)
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
}
}
}.awaitAll()
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 {
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
execResult.copy(executionDurationMillis = duration)
}
}.awaitAll()
}
}
} else {
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
@@ -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,15 +228,48 @@ class FakeSceneExecutor(
return try {
Outdated
Review

策略失效

PARALLEL 分支一次性启动所有动作,完全未处理 scene.strategy。当用户组合选择并行与 ABORT_ON_ERROR 时,即使一个动作失败,其余插座或红外等副作用仍会执行,使“遇错中止”失效。应禁止该组合,或在失败后取消尚未完成的任务并明确已启动动作的语义。

**策略失效** `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) {
coroutineScope {
scene.actions.map { action ->
async {
val actionStart = System.currentTimeMillis()
Review

并发崩溃

并行且遇错终止时,子协程启动后会访问尚未完成赋值的 deferreds。在 Main.immediate 或 Unconfined 等可立即执行子协程的调度器上,首个动作同步失败会抛出 UninitializedPropertyAccessException,导致执行器直接异常退出而不是返回失败结果。应先以惰性启动方式构建完整列表,再统一启动任务。

**并发崩溃** 并行且遇错终止时,子协程启动后会访问尚未完成赋值的 `deferreds`。在 `Main.immediate` 或 `Unconfined` 等可立即执行子协程的调度器上,首个动作同步失败会抛出 `UninitializedPropertyAccessException`,导致执行器直接异常退出而不是返回失败结果。应先以惰性启动方式构建完整列表,再统一启动任务。 <!-- pr-agent-dedup: 2fe123f90bd7 --> <!-- pr-agent-key-issue-location: ed9914d39478 -->
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
execResult.copy(executionDurationMillis = duration)
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
}
}
}.awaitAll()
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 {
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
execResult.copy(executionDurationMillis = duration)
}
}.awaitAll()
}
}
} else {
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
@@ -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,