fix(scene): address bot review comments on concurrency, cancellation, validation, and race conditions

This commit is contained in:
2026-08-25 17:16:04 +08:00
parent db6a7978a9
commit 56451be0d8
11 changed files with 484 additions and 120 deletions
@@ -58,7 +58,6 @@ import androidx.compose.runtime.mutableDoubleStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -895,19 +894,21 @@ private fun AddActionDialog(
var notifyTitle by remember { mutableStateOf("情景完成") } var notifyTitle by remember { mutableStateOf("情景完成") }
var notifyMsg by remember { mutableStateOf("自动化任务执行完成") } var notifyMsg by remember { mutableStateOf("自动化任务执行完成") }
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(availableRemotes, selectedRemoteIndex) { LaunchedEffect(availableRemotes, selectedRemoteIndex) {
if (availableRemotes.isNotEmpty() && selectedRemoteIndex in availableRemotes.indices) { if (availableRemotes.isNotEmpty() && selectedRemoteIndex in availableRemotes.indices) {
val remote = availableRemotes[selectedRemoteIndex] val remote = availableRemotes[selectedRemoteIndex]
coroutineScope.launch { keyBindings = emptyList()
val bindings = viewModel.getKeyBindings(remote.id) val bindings = viewModel.getKeyBindings(remote.id)
keyBindings = bindings keyBindings = bindings
if (bindings.isNotEmpty()) { if (bindings.isNotEmpty()) {
selectedButtonId = bindings[0].buttonId selectedButtonId = bindings[0].buttonId
selectedButtonName = bindings[0].displayName.ifBlank { 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 isSaved: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
) { ) {
val isValid: Boolean get() = name.isNotBlank() && actions.isNotEmpty() val isValid: Boolean get() = name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty())
val isEditing: Boolean get() = sceneId != null val isEditing: Boolean get() = sceneId != null
} }
@@ -68,6 +68,9 @@ class SceneEditorViewModel @Inject constructor(
private val _state = MutableStateFlow(SceneEditorState()) private val _state = MutableStateFlow(SceneEditorState())
val state: StateFlow<SceneEditorState> = _state.asStateFlow() val state: StateFlow<SceneEditorState> = _state.asStateFlow()
private var initializedSceneId: SceneId? = null
private var isInitialized: Boolean = false
init { init {
observeAvailableRemotes() observeAvailableRemotes()
} }
@@ -87,6 +90,10 @@ class SceneEditorViewModel @Inject constructor(
} }
fun initialize(sceneId: SceneId?, initialType: AutomationType? = null) { fun initialize(sceneId: SceneId?, initialType: AutomationType? = null) {
if (isInitialized && initializedSceneId == sceneId) return
isInitialized = true
initializedSceneId = sceneId
if (sceneId == null) { if (sceneId == null) {
_state.update { _state.update {
SceneEditorState( SceneEditorState(
@@ -34,7 +34,13 @@ class SceneExecutionViewModel @Inject constructor(
private val _uiState = MutableStateFlow<SceneExecutionUiState>(SceneExecutionUiState.Loading) private val _uiState = MutableStateFlow<SceneExecutionUiState>(SceneExecutionUiState.Loading)
val uiState: StateFlow<SceneExecutionUiState> = _uiState.asStateFlow() val uiState: StateFlow<SceneExecutionUiState> = _uiState.asStateFlow()
private var initializedSceneId: SceneId? = null
fun initialize(sceneId: SceneId) { fun initialize(sceneId: SceneId) {
if (initializedSceneId == sceneId && _uiState.value !is SceneExecutionUiState.Loading) {
return
}
initializedSceneId = sceneId
_uiState.value = SceneExecutionUiState.Loading _uiState.value = SceneExecutionUiState.Loading
viewModelScope.launch { viewModelScope.launch {
val profile = profileStore.activeProfile() val profile = profileStore.activeProfile()
@@ -53,11 +59,18 @@ class SceneExecutionViewModel @Inject constructor(
fun execute() { fun execute() {
val current = _uiState.value val current = _uiState.value
val scene = (current as? SceneExecutionUiState.Ready)?.scene val scene = when (current) {
?: (current as? SceneExecutionUiState.Running)?.scene is SceneExecutionUiState.Ready -> current.scene
?: return is SceneExecutionUiState.Completed -> current.result.scene
else -> return
}
_uiState.value = SceneExecutionUiState.Running(scene) _uiState.value = SceneExecutionUiState.Running(scene)
viewModelScope.launch { 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)) { when (val result = sceneExecutor.execute(scene)) {
is DomainResult.Success -> _uiState.value = SceneExecutionUiState.Completed(result.value) is DomainResult.Success -> _uiState.value = SceneExecutionUiState.Completed(result.value)
is DomainResult.Failure -> _uiState.value = SceneExecutionUiState.Failed( is DomainResult.Failure -> _uiState.value = SceneExecutionUiState.Failed(
@@ -158,5 +158,39 @@ class SceneEditorViewModelTest {
assertTrue(viewModel.state.value.isSaved) assertTrue(viewModel.state.value.isSaved)
assertFalse(viewModel.state.value.isSaving) 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(testScene.id, completed.result.scene.id)
assertEquals(1, completed.result.results.size) 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.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import kotlinx.coroutines.delay
import java.util.concurrent.ConcurrentHashMap 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]. * Production implementation of [SceneExecutor].
@@ -29,13 +33,25 @@ class DefaultSceneExecutor(
) )
} }
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
val results = mutableListOf<SceneActionExecutionResult>()
var aborted = false
return try { 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)
}
}.awaitAll()
}
} else {
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
var aborted = false
for (action in scene.actions) { for (action in scene.actions) {
if (aborted) { if (aborted) {
results.add( sequentialResults.add(
SceneActionExecutionResult( SceneActionExecutionResult(
action = action, action = action,
outcome = SceneActionOutcome.SKIPPED, outcome = SceneActionOutcome.SKIPPED,
@@ -49,12 +65,15 @@ class DefaultSceneExecutor(
val execResult = executeAction(scene.profileId, action) val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart val duration = System.currentTimeMillis() - actionStart
val enrichedResult = execResult.copy(executionDurationMillis = duration) val enrichedResult = execResult.copy(executionDurationMillis = duration)
results.add(enrichedResult) sequentialResults.add(enrichedResult)
if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) { if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
aborted = true aborted = true
} }
} }
sequentialResults
}
val totalDuration = System.currentTimeMillis() - startTime val totalDuration = System.currentTimeMillis() - startTime
DomainResult.Success( DomainResult.Success(
SceneExecutionResult( SceneExecutionResult(
@@ -63,6 +82,8 @@ class DefaultSceneExecutor(
totalDurationMillis = totalDuration, totalDurationMillis = totalDuration,
), ),
) )
} catch (e: CancellationException) {
throw e
} finally { } finally {
runningScenes.remove(scene.id) runningScenes.remove(scene.id)
} }
@@ -72,7 +93,8 @@ class DefaultSceneExecutor(
profileId: ProviderProfileId, profileId: ProviderProfileId,
action: SceneAction, action: SceneAction,
): SceneActionExecutionResult { ): SceneActionExecutionResult {
return when (action) { return try {
when (action) {
is SceneAction.SocketPower -> { is SceneAction.SocketPower -> {
val res = socketGateway.setPower( val res = socketGateway.setPower(
profileId = profileId, profileId = profileId,
@@ -102,27 +124,21 @@ class DefaultSceneExecutor(
} else { } else {
SceneActionExecutionResult( SceneActionExecutionResult(
action = action, action = action,
outcome = SceneActionOutcome.SUCCESS, outcome = SceneActionOutcome.FAILURE,
error = null, error = DomainError.OperationFailed(
detail = "Virtual remote action executor is not available",
),
) )
} }
} }
is SceneAction.Delay -> { is SceneAction.Delay -> {
try {
delayDurationProvider(action.durationSeconds * 1000L) delayDurationProvider(action.durationSeconds * 1000L)
SceneActionExecutionResult( SceneActionExecutionResult(
action = action, action = action,
outcome = SceneActionOutcome.SUCCESS, outcome = SceneActionOutcome.SUCCESS,
error = null, error = null,
) )
} catch (e: Exception) {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(detail = e.message),
)
}
} }
is SceneAction.Notification -> { 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 { private fun toExecutionResult(action: SceneAction, res: DomainResult<*>): SceneActionExecutionResult {
@@ -3,6 +3,8 @@ package com.flagship.abox.manager.domain
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** /**
* Provider-isolated in-memory [SceneRepository] implementation that supports * Provider-isolated in-memory [SceneRepository] implementation that supports
@@ -10,6 +12,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
*/ */
class InMemorySceneRepository : SceneRepository { class InMemorySceneRepository : SceneRepository {
private val flows = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<DomainResult<List<Scene>>>>() 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>>> = override fun observeScenes(profileId: ProviderProfileId): Flow<DomainResult<List<Scene>>> =
flowFor(profileId) flowFor(profileId)
@@ -25,10 +31,13 @@ class InMemorySceneRepository : SceneRepository {
override suspend fun createScene( override suspend fun createScene(
profileId: ProviderProfileId, profileId: ProviderProfileId,
scene: Scene, scene: Scene,
): DomainResult<Scene> { ): DomainResult<Scene> = mutexFor(profileId).withLock {
require(scene.profileId == profileId) { require(scene.profileId == profileId) {
"Scene profileId must match the supplied 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 scenes = currentScenes(profileId).orEmpty()
if (scenes.any { it.id == scene.id }) { if (scenes.any { it.id == scene.id }) {
return DomainResult.Failure( return DomainResult.Failure(
@@ -39,39 +48,45 @@ class InMemorySceneRepository : SceneRepository {
} }
val updated = scenes + scene val updated = scenes + scene
publish(profileId, updated) publish(profileId, updated)
return DomainResult.Success(scene) DomainResult.Success(scene)
} }
override suspend fun updateScene( override suspend fun updateScene(
profileId: ProviderProfileId, profileId: ProviderProfileId,
scene: Scene, scene: Scene,
): DomainResult<Scene> { ): DomainResult<Scene> = mutexFor(profileId).withLock {
require(scene.profileId == profileId) { require(scene.profileId == profileId) {
"Scene profileId must match the supplied 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 scenes = currentScenes(profileId).orEmpty()
val index = scenes.indexOfFirst { it.id == scene.id } val index = scenes.indexOfFirst { it.id == scene.id }
if (index < 0) return notFound(scene.id) if (index < 0) return notFound(scene.id)
val updated = scenes.toMutableList().apply { set(index, scene) } val updated = scenes.toMutableList().apply { set(index, scene) }
publish(profileId, updated) publish(profileId, updated)
return DomainResult.Success(scene) DomainResult.Success(scene)
} }
override suspend fun deleteScene( override suspend fun deleteScene(
profileId: ProviderProfileId, profileId: ProviderProfileId,
sceneId: SceneId, sceneId: SceneId,
): DomainResult<Unit> { ): DomainResult<Unit> = mutexFor(profileId).withLock {
val scenes = currentScenes(profileId).orEmpty() val scenes = currentScenes(profileId).orEmpty()
val updated = scenes.filterNot { it.id == sceneId } val updated = scenes.filterNot { it.id == sceneId }
if (updated.size == scenes.size) return notFound(sceneId) if (updated.size == scenes.size) return notFound(sceneId)
publish(profileId, updated) publish(profileId, updated)
return DomainResult.Success(Unit) DomainResult.Success(Unit)
} }
fun seedDefaults(profileId: ProviderProfileId, scenes: List<Scene>) { fun seedDefaults(profileId: ProviderProfileId, scenes: List<Scene>) {
require(scenes.all { it.profileId == profileId }) { require(scenes.all { it.profileId == profileId }) {
"All scenes must belong to the supplied 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) publish(profileId, scenes)
} }
@@ -221,5 +221,92 @@ class DefaultSceneExecutorTest {
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome) assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome)
assertEquals(5000L, delayCalled) 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 package com.flagship.abox.manager.domain
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -83,4 +86,43 @@ class InMemorySceneRepositoryTest {
val getAfterDelete = repository.getScene(aboxProfile, sceneId) val getAfterDelete = repository.getScene(aboxProfile, sceneId)
assertNull((getAfterDelete as DomainResult.Success).value) 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 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.InfraredGateway import com.flagship.abox.manager.domain.InfraredGateway
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
@@ -18,9 +20,15 @@ import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SocketGateway 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 kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow 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 * In-memory [SceneRepository] used by the scene UI and ViewModel tests. Scenes are isolated by
@@ -35,6 +43,10 @@ class FakeSceneRepository : SceneRepository {
ProviderProfileId, ProviderProfileId,
MutableStateFlow<DomainResult<List<Scene>>>, MutableStateFlow<DomainResult<List<Scene>>>,
>() >()
private val mutexes = ConcurrentHashMap<ProviderProfileId, Mutex>()
private fun mutexFor(profileId: ProviderProfileId): Mutex =
mutexes.getOrPut(profileId) { Mutex() }
override fun observeScenes( override fun observeScenes(
profileId: ProviderProfileId, profileId: ProviderProfileId,
@@ -51,10 +63,13 @@ class FakeSceneRepository : SceneRepository {
override suspend fun createScene( override suspend fun createScene(
profileId: ProviderProfileId, profileId: ProviderProfileId,
scene: Scene, scene: Scene,
): DomainResult<Scene> { ): DomainResult<Scene> = mutexFor(profileId).withLock {
require(scene.profileId == profileId) { require(scene.profileId == profileId) {
"Scene profileId must match the supplied 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 scenes = currentScenes(profileId).orEmpty()
if (scenes.any { it.id == scene.id }) { if (scenes.any { it.id == scene.id }) {
return DomainResult.Failure( return DomainResult.Failure(
@@ -65,33 +80,36 @@ class FakeSceneRepository : SceneRepository {
} }
val updated = scenes + scene val updated = scenes + scene
publish(profileId, updated) publish(profileId, updated)
return DomainResult.Success(scene) DomainResult.Success(scene)
} }
override suspend fun updateScene( override suspend fun updateScene(
profileId: ProviderProfileId, profileId: ProviderProfileId,
scene: Scene, scene: Scene,
): DomainResult<Scene> { ): DomainResult<Scene> = mutexFor(profileId).withLock {
require(scene.profileId == profileId) { require(scene.profileId == profileId) {
"Scene profileId must match the supplied 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 scenes = currentScenes(profileId).orEmpty()
val index = scenes.indexOfFirst { it.id == scene.id } val index = scenes.indexOfFirst { it.id == scene.id }
if (index < 0) return notFound(scene.id) if (index < 0) return notFound(scene.id)
val updated = scenes.toMutableList().apply { set(index, scene) } val updated = scenes.toMutableList().apply { set(index, scene) }
publish(profileId, updated) publish(profileId, updated)
return DomainResult.Success(scene) DomainResult.Success(scene)
} }
override suspend fun deleteScene( override suspend fun deleteScene(
profileId: ProviderProfileId, profileId: ProviderProfileId,
sceneId: SceneId, sceneId: SceneId,
): DomainResult<Unit> { ): DomainResult<Unit> = mutexFor(profileId).withLock {
val scenes = currentScenes(profileId).orEmpty() val scenes = currentScenes(profileId).orEmpty()
val updated = scenes.filterNot { it.id == sceneId } val updated = scenes.filterNot { it.id == sceneId }
if (updated.size == scenes.size) return notFound(sceneId) if (updated.size == scenes.size) return notFound(sceneId)
publish(profileId, updated) publish(profileId, updated)
return DomainResult.Success(Unit) DomainResult.Success(Unit)
} }
/** /**
@@ -102,6 +120,9 @@ class FakeSceneRepository : SceneRepository {
require(scenes.all { it.profileId == profileId }) { require(scenes.all { it.profileId == profileId }) {
"All scenes must belong to the supplied provider profile" "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) publish(profileId, scenes)
return this return this
} }
@@ -198,13 +219,43 @@ class FakeSceneExecutor(
) )
} }
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
val results = mutableListOf<SceneActionExecutionResult>()
return try { 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 actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action) val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart 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 val totalDuration = System.currentTimeMillis() - startTime
DomainResult.Success( DomainResult.Success(
@@ -214,6 +265,8 @@ class FakeSceneExecutor(
totalDurationMillis = totalDuration, totalDurationMillis = totalDuration,
), ),
) )
} catch (e: CancellationException) {
throw e
} finally { } finally {
running.remove(scene.id) running.remove(scene.id)
} }
@@ -223,6 +276,7 @@ class FakeSceneExecutor(
profileId: ProviderProfileId, profileId: ProviderProfileId,
action: SceneAction, action: SceneAction,
): SceneActionExecutionResult { ): SceneActionExecutionResult {
return try {
val result = when (action) { val result = when (action) {
is SceneAction.SocketPower -> socketGateway.setPower( is SceneAction.SocketPower -> socketGateway.setPower(
profileId = profileId, profileId = profileId,
@@ -240,7 +294,7 @@ class FakeSceneExecutor(
is SceneAction.Delay, is SceneAction.Delay,
is SceneAction.Notification -> DomainResult.Success(Unit) is SceneAction.Notification -> DomainResult.Success(Unit)
} }
return when (result) { when (result) {
is DomainResult.Success -> SceneActionExecutionResult( is DomainResult.Success -> SceneActionExecutionResult(
action = action, action = action,
outcome = SceneActionOutcome.SUCCESS, outcome = SceneActionOutcome.SUCCESS,
@@ -253,5 +307,14 @@ class FakeSceneExecutor(
error = result.error, 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.SceneIcon
import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SocketPowerState 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.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -122,6 +125,45 @@ class FakeSceneRepositoryTest {
assertNotNull((value as DomainResult.Failure).error) 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( private suspend fun scenesFor(
repo: FakeSceneRepository, repo: FakeSceneRepository,
profileId: ProviderProfileId, profileId: ProviderProfileId,