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
11 changed files with 484 additions and 120 deletions
Showing only changes of commit 56451be0d8 - Show all commits
@@ -58,7 +58,6 @@ import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Outdated
Review

状态丢失

LaunchedEffect(sceneId) 会在 Activity 配置变更后重新执行,但 Hilt ViewModel 会被保留;此时 initialize 会重置新场景状态或从仓库覆盖编辑状态,导致用户旋转屏幕后丢失尚未保存的名称、颜色和动作。应确保同一 sceneId 只初始化一次,或通过 SavedStateHandle 保存编辑状态。

**状态丢失** `LaunchedEffect(sceneId)` 会在 Activity 配置变更后重新执行,但 Hilt ViewModel 会被保留;此时 `initialize` 会重置新场景状态或从仓库覆盖编辑状态,导致用户旋转屏幕后丢失尚未保存的名称、颜色和动作。应确保同一 `sceneId` 只初始化一次,或通过 `SavedStateHandle` 保存编辑状态。 <!-- pr-agent-dedup: 6e081e02d9e0 --> <!-- pr-agent-key-issue-location: 70ce30b89dc9 -->
Outdated
Review

草稿丢失

配置更改导致界面重建时,此副作用会在保留原 Hilt ViewModel 的情况下再次调用 initialize。该方法会重置新建场景或重新加载已有场景,因此用户尚未保存的名称、颜色和动作会被静默覆盖。应让初始化针对同一 sceneId 保持幂等,或通过 SavedStateHandle 只初始化一次。

**草稿丢失** 配置更改导致界面重建时,此副作用会在保留原 Hilt ViewModel 的情况下再次调用 `initialize`。该方法会重置新建场景或重新加载已有场景,因此用户尚未保存的名称、颜色和动作会被静默覆盖。应让初始化针对同一 `sceneId` 保持幂等,或通过 `SavedStateHandle` 只初始化一次。 <!-- pr-agent-dedup: 5221aa87cb7b --> <!-- pr-agent-key-issue-location: d864255316c6 -->
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) {
Outdated
Review

绑定竞态

LaunchedEffect 内又通过独立的 rememberCoroutineScope 启动查询,切换遥控器时旧查询不会随 Effect 取消,较慢的旧结果可能覆盖当前遥控器的按键列表。用户快速切换后确认时,可能保存“当前遥控器 + 另一遥控器按键”的错误组合;应直接在 LaunchedEffect 中执行挂起查询,并在切换时清空旧绑定。

**绑定竞态** `LaunchedEffect` 内又通过独立的 `rememberCoroutineScope` 启动查询,切换遥控器时旧查询不会随 Effect 取消,较慢的旧结果可能覆盖当前遥控器的按键列表。用户快速切换后确认时,可能保存“当前遥控器 + 另一遥控器按键”的错误组合;应直接在 `LaunchedEffect` 中执行挂起查询,并在切换时清空旧绑定。 <!-- pr-agent-dedup: 535be9484938 --> <!-- pr-agent-key-issue-location: 8ddcb697f4a2 -->
if (availableRemotes.isNotEmpty() && selectedRemoteIndex in availableRemotes.indices) {
val remote = availableRemotes[selectedRemoteIndex]
coroutineScope.launch {
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()
Outdated
Review

校验缺失

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

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

执行状态丢失

对同一场景重复调用 initialize 会把 Running 或 Completed 无条件重置为 Loading,随后变为 Ready。配置变更会重新触发界面初始化,因此已完成的结果会消失;执行过程中旋转设备还会重新显示执行按钮,而原协程仍在运行。应让初始化对同一场景幂等,并保留进行中及完成状态。

**执行状态丢失** 对同一场景重复调用 `initialize` 会把 `Running` 或 `Completed` 无条件重置为 `Loading`,随后变为 `Ready`。配置变更会重新触发界面初始化,因此已完成的结果会消失;执行过程中旋转设备还会重新显示执行按钮,而原协程仍在运行。应让初始化对同一场景幂等,并保留进行中及完成状态。 <!-- pr-agent-dedup: b97d8304ffda --> <!-- pr-agent-key-issue-location: 666775487b17 -->
if (initializedSceneId == sceneId && _uiState.value !is SceneExecutionUiState.Loading) {
return
}
initializedSceneId = sceneId
_uiState.value = SceneExecutionUiState.Loading
viewModelScope.launch {
val profile = profileStore.activeProfile()
Outdated
Review

跨配置执行

活动配置文件仅在初始化时读取,加载后的场景会一直保存在 Ready 状态。若用户停留在执行页时切换提供方,随后仍会使用旧场景的 profileId 向网关发送命令。应监听配置文件切换并使当前场景失效,或在执行前重新校验活动配置文件与 scene.profileId 一致。

**跨配置执行** 活动配置文件仅在初始化时读取,加载后的场景会一直保存在 `Ready` 状态。若用户停留在执行页时切换提供方,随后仍会使用旧场景的 `profileId` 向网关发送命令。应监听配置文件切换并使当前场景失效,或在执行前重新校验活动配置文件与 `scene.profileId` 一致。 <!-- pr-agent-dedup: 353f16aeadc1 --> <!-- pr-agent-key-issue-location: 4c701012d709 -->
@@ -53,11 +59,18 @@ class SceneExecutionViewModel @Inject constructor(
fun execute() {
Outdated
Review

重复执行

execute 明确接受 Running 状态,因此快速重复点击或重复事件会启动第二次执行。执行器会拒绝第二次调用,而该失败会把仍在正常运行的界面状态覆盖为 Failed。此处应仅允许从 Ready 状态开始执行。

**重复执行** `execute` 明确接受 `Running` 状态,因此快速重复点击或重复事件会启动第二次执行。执行器会拒绝第二次调用,而该失败会把仍在正常运行的界面状态覆盖为 `Failed`。此处应仅允许从 `Ready` 状态开始执行。 <!-- pr-agent-dedup: d0b9a0bd3b44 --> <!-- pr-agent-key-issue-location: 5bbdc5f5b2c9 -->
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)
}
}
@@ -2,8 +2,12 @@ package com.flagship.abox.manager.domain
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import kotlinx.coroutines.delay
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
/**
* Production implementation of [SceneExecutor].
@@ -29,13 +33,25 @@ class DefaultSceneExecutor(
)
}
val startTime = System.currentTimeMillis()
val results = mutableListOf<SceneActionExecutionResult>()
var aborted = false
return try {
Outdated
Review

并行失效

执行逻辑完全未读取 scene.concurrencyMode,所有动作始终通过循环串行执行。用户选择 PARALLEL 后,延时等动作仍会阻塞后续动作,使编辑器中的并行设置失效,并显著改变执行时长和预期行为。

**并行失效** 执行逻辑完全未读取 `scene.concurrencyMode`,所有动作始终通过循环串行执行。用户选择 `PARALLEL` 后,延时等动作仍会阻塞后续动作,使编辑器中的并行设置失效,并显著改变执行时长和预期行为。 <!-- pr-agent-dedup: 7e22b393cc56 --> <!-- pr-agent-key-issue-location: 2a617e7fe21b -->
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)
}
}.awaitAll()
}
} else {
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
var aborted = false
for (action in scene.actions) {
if (aborted) {
results.add(
sequentialResults.add(
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SKIPPED,
@@ -49,12 +65,15 @@ class DefaultSceneExecutor(
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
val enrichedResult = execResult.copy(executionDurationMillis = duration)
results.add(enrichedResult)
sequentialResults.add(enrichedResult)
if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
aborted = true
}
}
sequentialResults
}
val totalDuration = System.currentTimeMillis() - startTime
DomainResult.Success(
SceneExecutionResult(
@@ -63,6 +82,8 @@ class DefaultSceneExecutor(
totalDurationMillis = totalDuration,
),
)
} catch (e: CancellationException) {
throw e
} finally {
runningScenes.remove(scene.id)
}
@@ -72,7 +93,8 @@ class DefaultSceneExecutor(
profileId: ProviderProfileId,
action: SceneAction,
): SceneActionExecutionResult {
return when (action) {
return try {
when (action) {
is SceneAction.SocketPower -> {
val res = socketGateway.setPower(
profileId = profileId,
@@ -102,27 +124,21 @@ class DefaultSceneExecutor(
} else {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(
detail = "Virtual remote action executor is not available",
),
)
}
}
is SceneAction.Delay -> {
Outdated
Review

取消失效

catch (e: Exception) 会捕获协程的 CancellationException 并将其转换为普通动作失败。执行在延时期间被取消时,CONTINUE_ON_ERROR 场景可能继续触发后续设备动作;应显式重新抛出取消异常。

**取消失效** `catch (e: Exception)` 会捕获协程的 `CancellationException` 并将其转换为普通动作失败。执行在延时期间被取消时,`CONTINUE_ON_ERROR` 场景可能继续触发后续设备动作;应显式重新抛出取消异常。 <!-- pr-agent-dedup: 72ba712345d4 --> <!-- pr-agent-key-issue-location: 9b9cee04d1aa -->
try {
delayDurationProvider(action.durationSeconds * 1000L)
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
} catch (e: Exception) {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(detail = e.message),
)
}
}
is SceneAction.Notification -> {
@@ -133,6 +149,15 @@ class DefaultSceneExecutor(
)
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(detail = e.message),
)
}
}
private fun toExecutionResult(action: SceneAction, res: DomainResult<*>): SceneActionExecutionResult {
@@ -3,6 +3,8 @@ package com.flagship.abox.manager.domain
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Provider-isolated in-memory [SceneRepository] implementation that supports
@@ -10,6 +12,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
*/
class InMemorySceneRepository : SceneRepository {
private val flows = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<DomainResult<List<Scene>>>>()
private val mutexes = ConcurrentHashMap<ProviderProfileId, Mutex>()
private fun mutexFor(profileId: ProviderProfileId): Mutex =
mutexes.getOrPut(profileId) { Mutex() }
override fun observeScenes(profileId: ProviderProfileId): Flow<DomainResult<List<Scene>>> =
flowFor(profileId)
@@ -25,10 +31,13 @@ class InMemorySceneRepository : SceneRepository {
override suspend fun createScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene> {
): DomainResult<Scene> = mutexFor(profileId).withLock {
require(scene.profileId == profileId) {
Outdated
Review

隔离失效

创建和更新时只校验 Scene.profileId,没有校验每个 SceneAction.profileId。传入包含其他配置动作的场景会被保存并发布到当前配置,违反 SceneRepository 声明的动作隔离约束;配置切换期间保存编辑状态即可触发这种混合数据。请同时校验所有动作均属于传入的配置,并同步修复 fake 实现。

**隔离失效** 创建和更新时只校验 `Scene.profileId`,没有校验每个 `SceneAction.profileId`。传入包含其他配置动作的场景会被保存并发布到当前配置,违反 `SceneRepository` 声明的动作隔离约束;配置切换期间保存编辑状态即可触发这种混合数据。请同时校验所有动作均属于传入的配置,并同步修复 fake 实现。 <!-- pr-agent-dedup: 65dc5433fee6 --> <!-- pr-agent-key-issue-location: 322602ac4b24 -->
"Scene profileId must match the supplied profileId"
}
require(scene.actions.all { it.profileId == profileId }) {
"All scene actions must belong to the supplied profileId"
}
val scenes = currentScenes(profileId).orEmpty()
Outdated
Review

并发覆盖

创建操作采用非原子的“读取、检查、发布”流程。两个线程同时为同一配置创建不同场景时,可能读取相同旧列表,后一次发布会覆盖前一次结果并静默丢失场景;更新和删除也有相同问题。ConcurrentHashMap 只保护映射本身,应使用按配置的互斥锁或原子状态更新包住完整的读改写操作。

**并发覆盖** 创建操作采用非原子的“读取、检查、发布”流程。两个线程同时为同一配置创建不同场景时,可能读取相同旧列表,后一次发布会覆盖前一次结果并静默丢失场景;更新和删除也有相同问题。`ConcurrentHashMap` 只保护映射本身,应使用按配置的互斥锁或原子状态更新包住完整的读改写操作。 <!-- pr-agent-dedup: 1e12daa75cf2 --> <!-- pr-agent-key-issue-location: 82d394f22953 -->
Outdated
Review

更新丢失

场景修改采用非原子的“读取列表后再发布”流程。两个协程同时为同一配置创建场景时,可能都基于相同旧列表计算结果,最后一次发布会覆盖前一次,静默丢失一个场景;更新和删除也存在同类竞争。应使用按配置隔离的互斥锁或其他原子事务保护完整的校验与写入过程。

**更新丢失** 场景修改采用非原子的“读取列表后再发布”流程。两个协程同时为同一配置创建场景时,可能都基于相同旧列表计算结果,最后一次发布会覆盖前一次,静默丢失一个场景;更新和删除也存在同类竞争。应使用按配置隔离的互斥锁或其他原子事务保护完整的校验与写入过程。 <!-- pr-agent-dedup: e041f0bf6d5e --> <!-- pr-agent-key-issue-location: b2be8d2de054 -->
if (scenes.any { it.id == scene.id }) {
return DomainResult.Failure(
@@ -39,39 +48,45 @@ class InMemorySceneRepository : SceneRepository {
}
val updated = scenes + scene
publish(profileId, updated)
return DomainResult.Success(scene)
DomainResult.Success(scene)
}
override suspend fun updateScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene> {
): DomainResult<Scene> = mutexFor(profileId).withLock {
require(scene.profileId == profileId) {
"Scene profileId must match the supplied profileId"
}
require(scene.actions.all { it.profileId == profileId }) {
"All scene actions must belong to the supplied profileId"
}
val scenes = currentScenes(profileId).orEmpty()
val index = scenes.indexOfFirst { it.id == scene.id }
if (index < 0) return notFound(scene.id)
val updated = scenes.toMutableList().apply { set(index, scene) }
publish(profileId, updated)
return DomainResult.Success(scene)
DomainResult.Success(scene)
}
override suspend fun deleteScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Unit> {
): DomainResult<Unit> = mutexFor(profileId).withLock {
val scenes = currentScenes(profileId).orEmpty()
val updated = scenes.filterNot { it.id == sceneId }
if (updated.size == scenes.size) return notFound(sceneId)
publish(profileId, updated)
return DomainResult.Success(Unit)
DomainResult.Success(Unit)
}
fun seedDefaults(profileId: ProviderProfileId, scenes: List<Scene>) {
require(scenes.all { it.profileId == profileId }) {
"All scenes must belong to the supplied profileId"
}
require(scenes.all { s -> s.actions.all { it.profileId == profileId } }) {
"All scene actions must belong to the supplied profileId"
}
publish(profileId, scenes)
}
@@ -221,5 +221,92 @@ class DefaultSceneExecutorTest {
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome)
assertEquals(5000L, delayCalled)
}
@Test
fun executesActionsConcurrentlyWhenParallelMode() = runTest {
val socketGateway = TestSocketGateway()
val infraredGateway = TestInfraredGateway()
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
)
val action2 = SceneAction.InfraredKey(
id = SceneActionId("a2"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "power",
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Parallel Scene",
concurrencyMode = ConcurrencyMode.PARALLEL,
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.SUCCESS, executionResult.results[0].outcome)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome)
assertEquals(1, socketGateway.calls.size)
assertEquals(1, infraredGateway.calls.size)
}
@Test
fun failsVirtualRemoteButtonActionWhenSenderNotConfigured() = runTest {
val executor = DefaultSceneExecutor(
socketGateway = TestSocketGateway(),
infraredGateway = TestInfraredGateway(),
sendVirtualRemoteAction = null,
)
val action = SceneAction.VirtualRemoteButton(
id = SceneActionId("vr1"),
profileId = profileId,
remoteId = "tv-remote",
remoteDisplayName = "TV",
buttonId = "power",
buttonDisplayName = "Power",
)
val scene = Scene(
id = SceneId("s-vr"),
profileId = profileId,
name = "VR Scene",
actions = listOf(action),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(1, executionResult.results.size)
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
assertTrue(executionResult.results[0].error is DomainError.OperationFailed)
}
@Test(expected = kotlinx.coroutines.CancellationException::class)
fun rethrowsCancellationExceptionWhenDelayCancelled() = runTest {
val executor = DefaultSceneExecutor(
socketGateway = TestSocketGateway(),
infraredGateway = TestInfraredGateway(),
delayDurationProvider = { throw kotlinx.coroutines.CancellationException("Job cancelled") },
)
val action = SceneAction.Delay(
id = SceneActionId("d1"),
profileId = profileId,
durationSeconds = 10,
)
val scene = Scene(
id = SceneId("s-cancelled"),
profileId = profileId,
name = "Cancelled Scene",
actions = listOf(action),
)
executor.execute(scene)
}
}
@@ -1,6 +1,9 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
@@ -83,4 +86,43 @@ class InMemorySceneRepositoryTest {
val getAfterDelete = repository.getScene(aboxProfile, sceneId)
assertNull((getAfterDelete as DomainResult.Success).value)
}
@Test(expected = IllegalArgumentException::class)
fun rejectsSceneWithMismatchedActionProfileId() = runTest {
val repository = InMemorySceneRepository()
val crossAction = SceneAction.SocketPower(
id = SceneActionId("a-cross"),
profileId = ProviderProfileId("other-profile"),
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
)
val scene = Scene(
id = SceneId("abox:admin:scene-invalid"),
profileId = aboxProfile,
name = "Invalid Scene",
actions = listOf(crossAction),
)
repository.createScene(aboxProfile, scene)
}
@Test
fun concurrentCreationsAreThreadSafe() = runTest {
val repository = InMemorySceneRepository()
val count = 20
coroutineScope {
(1..count).map { idx ->
async {
val scene = Scene(
id = SceneId("abox:admin:scene-$idx"),
profileId = aboxProfile,
name = "Scene $idx",
actions = emptyList(),
)
repository.createScene(aboxProfile, scene)
}
}.awaitAll()
}
val scenes = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value
assertEquals(count, scenes.size)
}
}
@@ -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.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
@@ -18,9 +20,15 @@ import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository
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.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* In-memory [SceneRepository] used by the scene UI and ViewModel tests. Scenes are isolated by
@@ -35,6 +43,10 @@ class FakeSceneRepository : SceneRepository {
ProviderProfileId,
MutableStateFlow<DomainResult<List<Scene>>>,
>()
private val mutexes = ConcurrentHashMap<ProviderProfileId, Mutex>()
private fun mutexFor(profileId: ProviderProfileId): Mutex =
mutexes.getOrPut(profileId) { Mutex() }
override fun observeScenes(
profileId: ProviderProfileId,
@@ -51,10 +63,13 @@ class FakeSceneRepository : SceneRepository {
override suspend fun createScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene> {
): DomainResult<Scene> = mutexFor(profileId).withLock {
require(scene.profileId == profileId) {
"Scene profileId must match the supplied profileId"
}
require(scene.actions.all { it.profileId == profileId }) {
"All scene actions must belong to the supplied provider profile"
}
val scenes = currentScenes(profileId).orEmpty()
Outdated
Review

并发丢失

创建场景采用非原子的“读取列表后再发布”流程。同一配置下两个并发 createScene 都可能基于相同旧列表追加并返回成功,但后发布的结果会覆盖前一个,导致其中一个场景丢失。应对每个配置使用互斥锁,或将重复检查和列表更新合并为原子操作。

**并发丢失** 创建场景采用非原子的“读取列表后再发布”流程。同一配置下两个并发 `createScene` 都可能基于相同旧列表追加并返回成功,但后发布的结果会覆盖前一个,导致其中一个场景丢失。应对每个配置使用互斥锁,或将重复检查和列表更新合并为原子操作。 <!-- pr-agent-dedup: aea6a8725121 --> <!-- pr-agent-key-issue-location: 5ec14616b43d -->
if (scenes.any { it.id == scene.id }) {
return DomainResult.Failure(
@@ -65,33 +80,36 @@ class FakeSceneRepository : SceneRepository {
}
val updated = scenes + scene
publish(profileId, updated)
return DomainResult.Success(scene)
DomainResult.Success(scene)
}
override suspend fun updateScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene> {
): DomainResult<Scene> = mutexFor(profileId).withLock {
require(scene.profileId == profileId) {
"Scene profileId must match the supplied profileId"
}
require(scene.actions.all { it.profileId == profileId }) {
"All scene actions must belong to the supplied provider profile"
}
val scenes = currentScenes(profileId).orEmpty()
val index = scenes.indexOfFirst { it.id == scene.id }
if (index < 0) return notFound(scene.id)
val updated = scenes.toMutableList().apply { set(index, scene) }
publish(profileId, updated)
return DomainResult.Success(scene)
DomainResult.Success(scene)
}
override suspend fun deleteScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Unit> {
): DomainResult<Unit> = mutexFor(profileId).withLock {
val scenes = currentScenes(profileId).orEmpty()
val updated = scenes.filterNot { it.id == sceneId }
if (updated.size == scenes.size) return notFound(sceneId)
publish(profileId, updated)
return DomainResult.Success(Unit)
DomainResult.Success(Unit)
}
/**
@@ -102,6 +120,9 @@ class FakeSceneRepository : SceneRepository {
require(scenes.all { it.profileId == profileId }) {
"All scenes must belong to the supplied provider profile"
}
require(scenes.all { s -> s.actions.all { it.profileId == profileId } }) {
"All scene actions must belong to the supplied provider profile"
}
publish(profileId, scenes)
return this
}
@@ -198,13 +219,43 @@ class FakeSceneExecutor(
)
}
val startTime = System.currentTimeMillis()
val results = mutableListOf<SceneActionExecutionResult>()
return try {
for (action in scene.actions) {
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
results.add(execResult.copy(executionDurationMillis = duration))
execResult.copy(executionDurationMillis = duration)
}
}.awaitAll()
}
} else {
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
var aborted = false
for (action in scene.actions) {
if (aborted) {
sequentialResults.add(
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SKIPPED,
error = null,
),
)
continue
}
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
val enrichedResult = execResult.copy(executionDurationMillis = duration)
sequentialResults.add(enrichedResult)
if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
aborted = true
}
}
sequentialResults
}
val totalDuration = System.currentTimeMillis() - startTime
DomainResult.Success(
@@ -214,6 +265,8 @@ class FakeSceneExecutor(
totalDurationMillis = totalDuration,
),
)
} catch (e: CancellationException) {
throw e
} finally {
running.remove(scene.id)
}
@@ -223,6 +276,7 @@ class FakeSceneExecutor(
profileId: ProviderProfileId,
action: SceneAction,
): SceneActionExecutionResult {
return try {
val result = when (action) {
is SceneAction.SocketPower -> socketGateway.setPower(
profileId = profileId,
Outdated
Review

策略失效

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

**策略失效** `PARALLEL` 分支一次性启动所有动作,完全未处理 `scene.strategy`。当用户组合选择并行与 `ABORT_ON_ERROR` 时,即使一个动作失败,其余插座或红外等副作用仍会执行,使“遇错中止”失效。应禁止该组合,或在失败后取消尚未完成的任务并明确已启动动作的语义。 <!-- pr-agent-dedup: 7ef796cc7fb8 --> <!-- pr-agent-key-issue-location: d6ce8a1fade9 -->
@@ -240,7 +294,7 @@ class FakeSceneExecutor(
is SceneAction.Delay,
is SceneAction.Notification -> DomainResult.Success(Unit)
}
return when (result) {
when (result) {
is DomainResult.Success -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
@@ -253,5 +307,14 @@ class FakeSceneExecutor(
error = result.error,
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(detail = e.message),
)
}
}
}
@@ -11,6 +11,9 @@ import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SocketPowerState
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
@@ -122,6 +125,45 @@ class FakeSceneRepositoryTest {
assertNotNull((value as DomainResult.Failure).error)
}
@Test(expected = IllegalArgumentException::class)
fun createSceneRejectsMismatchedActionProfileId() = runTest {
val repo = FakeSceneRepository()
val crossAction = SceneAction.SocketPower(
id = SceneActionId("a-cross"),
profileId = ProviderProfileId("other"),
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
)
val scene = Scene(
id = SceneId("${localProfile.value}:scene-cross"),
profileId = localProfile,
name = "Cross Action",
actions = listOf(crossAction),
)
repo.createScene(localProfile, scene)
}
@Test
fun concurrentCreationsAreThreadSafe() = runTest {
val repo = FakeSceneRepository()
val count = 20
coroutineScope {
(1..count).map { idx ->
async {
val scene = Scene(
id = SceneId("${aboxProfile.value}:scene-$idx"),
profileId = aboxProfile,
name = "Scene $idx",
actions = emptyList(),
)
repo.createScene(aboxProfile, scene)
}
}.awaitAll()
}
val scenes = scenesFor(repo, aboxProfile)
assertEquals(count, scenes.size)
}
private suspend fun scenesFor(
repo: FakeSceneRepository,
profileId: ProviderProfileId,