feat(scene): add scene editor, executor and fakes (phase 1) #5
+501
-442
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 concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
|
||||||
val enableLogging: Boolean = true,
|
val enableLogging: Boolean = true,
|
||||||
val availableRemotes: List<VirtualRemote> = emptyList(),
|
val availableRemotes: List<VirtualRemote> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
val isSaved: Boolean = false,
|
val isSaved: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
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
|
val isEditing: Boolean get() = sceneId != null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,14 +101,24 @@ class SceneEditorViewModel @Inject constructor(
|
|||||||
type = initialType ?: AutomationType.MANUAL,
|
type = initialType ?: AutomationType.MANUAL,
|
||||||
colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5",
|
colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5",
|
||||||
availableRemotes = it.availableRemotes,
|
availableRemotes = it.availableRemotes,
|
||||||
|
isLoading = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
sceneId = sceneId,
|
||||||
|
isLoading = true,
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
pr-agent-bot
commented
编辑竞态 编辑现有场景时, **编辑竞态**
编辑现有场景时,`sceneId` 只有在异步查询成功后才写入状态,且没有加载态阻止编辑或保存。仓库响应较慢或失败时,页面会按“新建”处理;用户若完成有效输入并保存,`save()` 将调用 `createScene`,产生重复场景而不是更新目标。应先绑定待编辑的 ID,并在加载完成前禁用保存。
<!-- pr-agent-dedup: 41ec446dd682 -->
<!-- pr-agent-key-issue-location: 2c71b6e33002 -->
|
|||||||
val profile = profileStore.activeProfile()
|
val profile = profileStore.activeProfile()
|
||||||
if (profile == null) {
|
if (profile == null) {
|
||||||
_state.update { it.copy(errorMessage = "No active profile") }
|
_state.update { it.copy(isLoading = false, errorMessage = "No active profile") }
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
|
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
|
||||||
@@ -127,10 +138,11 @@ class SceneEditorViewModel @Inject constructor(
|
|||||||
strategy = scene.strategy,
|
strategy = scene.strategy,
|
||||||
concurrencyMode = scene.concurrencyMode,
|
concurrencyMode = scene.concurrencyMode,
|
||||||
enableLogging = scene.enableLogging,
|
enableLogging = scene.enableLogging,
|
||||||
|
isLoading = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} 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_humidity">湿度需 %1$s %2$s%%</string>
|
||||||
<string name="scene_condition_desc_socket">%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_condition_desc_time">时间在 %1$s ~ %2$s 之间</string>
|
||||||
<string name="scene_default_socket_name">Zigbee插座控制器</string>
|
<string name="scene_device_name_label">设备名称(网关配对标识)</string>
|
||||||
<string name="scene_default_ir_name">红外控制器</string>
|
<string name="scene_loading_scene">正在加载情景配置…</string>
|
||||||
|
<string name="scene_load_failed">无法加载情景配置。</string>
|
||||||
<string name="scene_default_button_power">电源</string>
|
<string name="scene_default_button_power">电源</string>
|
||||||
<string name="scene_default_notify_title">情景完成</string>
|
<string name="scene_default_notify_title">情景完成</string>
|
||||||
<string name="scene_default_notify_msg">自动化任务执行完成</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_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_socket">%1$s is %2$s</string>
|
||||||
<string name="scene_condition_desc_time">Time is between %1$s ~ %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_device_name_label">Device Name (Gateway Identifier)</string>
|
||||||
<string name="scene_default_ir_name">Infrared Controller</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_button_power">Power</string>
|
||||||
<string name="scene_default_notify_title">Scene Completed</string>
|
<string name="scene_default_notify_title">Scene Completed</string>
|
||||||
<string name="scene_default_notify_msg">Automation task executed successfully</string>
|
<string name="scene_default_notify_msg">Automation task executed successfully</string>
|
||||||
|
|||||||
+8
@@ -81,10 +81,18 @@ class SceneEditorViewModelTest {
|
|||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||||
viewModel.initialize(existingScene.id)
|
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()
|
advanceUntilIdle()
|
||||||
|
|
||||||
val state = viewModel.state.value
|
val state = viewModel.state.value
|
||||||
assertTrue(state.isEditing)
|
assertTrue(state.isEditing)
|
||||||
|
assertFalse(state.isLoading)
|
||||||
assertEquals("Existing Scene", state.name)
|
assertEquals("Existing Scene", state.name)
|
||||||
assertEquals(1, state.actions.size)
|
assertEquals(1, state.actions.size)
|
||||||
assertTrue(state.isValid)
|
assertTrue(state.isValid)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
|||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
@@ -36,15 +37,48 @@ class DefaultSceneExecutor(
|
|||||||
|
|
||||||
return try {
|
return try {
|
||||||
val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) {
|
val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) {
|
||||||
coroutineScope {
|
if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
|
||||||
scene.actions.map { action ->
|
coroutineScope {
|
||||||
async {
|
lateinit var deferreds: List<Deferred<SceneActionExecutionResult>>
|
||||||
val actionStart = System.currentTimeMillis()
|
deferreds = scene.actions.mapIndexed { index, action ->
|
||||||
val execResult = executeAction(scene.profileId, action)
|
async {
|
||||||
val duration = System.currentTimeMillis() - actionStart
|
val actionStart = System.currentTimeMillis()
|
||||||
execResult.copy(executionDurationMillis = duration)
|
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 {
|
} else {
|
||||||
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
|
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
|
||||||
|
|||||||
@@ -308,5 +308,45 @@ class DefaultSceneExecutorTest {
|
|||||||
)
|
)
|
||||||
executor.execute(scene)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-8
@@ -22,6 +22,7 @@ import com.flagship.abox.manager.domain.SocketGateway
|
|||||||
import com.flagship.abox.manager.domain.SocketPowerState
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
@@ -227,15 +228,48 @@ class FakeSceneExecutor(
|
|||||||
|
|
||||||
return try {
|
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) {
|
val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) {
|
||||||
coroutineScope {
|
if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
|
||||||
scene.actions.map { action ->
|
coroutineScope {
|
||||||
async {
|
lateinit var deferreds: List<Deferred<SceneActionExecutionResult>>
|
||||||
val actionStart = System.currentTimeMillis()
|
deferreds = scene.actions.mapIndexed { index, action ->
|
||||||
|
pr-agent-bot
commented
并发崩溃 并行且遇错终止时,子协程启动后会访问尚未完成赋值的 **并发崩溃**
并行且遇错终止时,子协程启动后会访问尚未完成赋值的 `deferreds`。在 `Main.immediate` 或 `Unconfined` 等可立即执行子协程的调度器上,首个动作同步失败会抛出 `UninitializedPropertyAccessException`,导致执行器直接异常退出而不是返回失败结果。应先以惰性启动方式构建完整列表,再统一启动任务。
<!-- pr-agent-dedup: 2fe123f90bd7 -->
<!-- pr-agent-key-issue-location: ed9914d39478 -->
|
|||||||
val execResult = executeAction(scene.profileId, action)
|
async {
|
||||||
val duration = System.currentTimeMillis() - actionStart
|
val actionStart = System.currentTimeMillis()
|
||||||
execResult.copy(executionDurationMillis = duration)
|
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 {
|
} else {
|
||||||
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
|
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
|
||||||
|
|||||||
+40
@@ -1,8 +1,10 @@
|
|||||||
package com.flagship.abox.manager.testing.fakes
|
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.DeviceName
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
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.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.Scene
|
import com.flagship.abox.manager.domain.Scene
|
||||||
import com.flagship.abox.manager.domain.SceneAction
|
import com.flagship.abox.manager.domain.SceneAction
|
||||||
@@ -156,6 +158,44 @@ class FakeSceneExecutorTest {
|
|||||||
assertEquals(listOf(notificationAction), executor.sentNotifications)
|
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(
|
private fun scene(actions: List<SceneAction>): Scene = Scene(
|
||||||
id = SceneId("${profileId.value}:scene-test"),
|
id = SceneId("${profileId.value}:scene-test"),
|
||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
|
|||||||
Reference in New Issue
Block a user
校验缺失
isValid没有要求自动场景至少包含一个触发器,因此用户只填写名称和动作就能保存一个已启用但永远不会被触发的自动场景。应在类型为AUTOMATIC时要求triggers非空,或将其明确保存为禁用草稿。