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

## Summary

Adds scene integration phase 1: domain model, fakes, UI, navigation, and tests.

## Changes

- **domain**: Add `Scene`, `SceneAction` (SocketPower/InfraredKey), `SceneRepository`, `SceneExecutor`, and execution result models.
- **testing/fakes**: Add `FakeSceneRepository` (isolated by `ProviderProfileId`, seeded defaults for the local profile) and `FakeSceneExecutor` (sequential execution, continue-on-failure).
- **android/app**: Add scene UI module - list screen, editor screen, execution progress screen - with Hilt-bound ViewModels.
- **navigation**: Wire scene routes into `ABoxManagerApp` NavHost.
- **strings**: Add `scene_*` entries for en and zh-rCN.
- **tests**: Cover `FakeSceneRepository` CRUD/isolation and `FakeSceneExecutor` ordering/continue-on-failure.

## Test plan

- [x] `./gradlew :testing:fakes:test` passes.
- [x] `FakeSceneRepository` isolates scenes by provider profile.
- [x] `FakeSceneExecutor` runs actions in order and continues on failure.

## Notes

Phase 1 ships fakes only; real repository/executor backed by the abox gateway will land in a follow-up.

---------

Co-authored-by: KremeCN <3265494849@qq.com>
Co-authored-by: mxhsnd <2049295946@qq.com>
Reviewed-on: #5
Co-authored-by: tzh <3091898014@qq.com>
This commit was merged in pull request #5.
This commit is contained in:
tzh
2026-08-25 20:20:18 +08:00
committed by KremeCN
co-authored by KremeCN Moler
parent 30d6d39597
commit 1570a95f30
28 changed files with 5471 additions and 1 deletions
@@ -0,0 +1,211 @@
package com.flagship.abox.manager.domain
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
import kotlinx.coroutines.delay
/**
* Production implementation of [SceneExecutor].
*
* Executes scene actions via [SocketGateway], [InfraredGateway], [SendVirtualRemoteAction],
* and built-in timers.
*/
class DefaultSceneExecutor(
private val socketGateway: SocketGateway,
private val infraredGateway: InfraredGateway,
private val sendVirtualRemoteAction: SendVirtualRemoteAction? = null,
private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) },
) : SceneExecutor {
private val runningScenes = ConcurrentHashMap<SceneId, Boolean>()
override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> {
val alreadyRunning = runningScenes.putIfAbsent(scene.id, true) != null
if (alreadyRunning) {
return DomainResult.Failure(
DomainError.OperationFailed(
detail = "Scene ${scene.id.value} is already running",
),
)
}
val startTime = System.currentTimeMillis()
return try {
val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) {
if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
coroutineScope {
lateinit var deferreds: List<Deferred<SceneActionExecutionResult>>
deferreds = scene.actions.mapIndexed { index, action ->
async {
val actionStart = System.currentTimeMillis()
val execResult = executeAction(scene.profileId, action)
val duration = System.currentTimeMillis() - actionStart
val enriched = execResult.copy(executionDurationMillis = duration)
if (enriched.outcome == SceneActionOutcome.FAILURE) {
deferreds.forEachIndexed { otherIndex, otherDeferred ->
if (otherIndex != index) {
otherDeferred.cancel()
}
}
}
enriched
}
}
deferreds.mapIndexed { index, deferred ->
try {
deferred.await()
} catch (e: CancellationException) {
SceneActionExecutionResult(
action = scene.actions[index],
outcome = SceneActionOutcome.SKIPPED,
error = null,
)
}
}
}
} else {
coroutineScope {
scene.actions.map { action ->
async {
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) {
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(
SceneExecutionResult(
scene = scene,
results = results,
totalDurationMillis = totalDuration,
),
)
} catch (e: CancellationException) {
throw e
} finally {
runningScenes.remove(scene.id)
}
}
private suspend fun executeAction(
profileId: ProviderProfileId,
action: SceneAction,
): SceneActionExecutionResult {
return try {
when (action) {
is SceneAction.SocketPower -> {
val res = socketGateway.setPower(
profileId = profileId,
deviceName = action.deviceName,
powerState = action.powerState,
)
toExecutionResult(action, res)
}
is SceneAction.InfraredKey -> {
val res = infraredGateway.sendKey(
profileId = profileId,
deviceName = action.deviceName,
key = action.key,
)
toExecutionResult(action, res)
}
is SceneAction.VirtualRemoteButton -> {
if (sendVirtualRemoteAction != null) {
val res = sendVirtualRemoteAction.sendButton(
profileId = profileId,
remoteId = VirtualRemoteId(action.remoteId),
buttonId = action.buttonId,
)
toExecutionResult(action, res)
} else {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = DomainError.OperationFailed(
detail = "Virtual remote action executor is not available",
),
)
}
}
is SceneAction.Delay -> {
delayDurationProvider(action.durationSeconds * 1000L)
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
is SceneAction.Notification -> {
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
}
} 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 {
return when (res) {
is DomainResult.Success -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
is DomainResult.Failure -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = res.error,
)
}
}
}
@@ -0,0 +1,179 @@
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
* seed defaults and real-time observation.
*/
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)
override suspend fun getScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Scene?> {
val scenes = currentScenes(profileId) ?: return notFound(sceneId)
return DomainResult.Success(scenes.firstOrNull { it.id == sceneId })
}
override suspend fun createScene(
profileId: ProviderProfileId,
scene: 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()
if (scenes.any { it.id == scene.id }) {
return DomainResult.Failure(
DomainError.InvalidRequest(
detail = "Scene id ${scene.id.value} already exists in profile ${profileId.value}",
),
)
}
val updated = scenes + scene
publish(profileId, updated)
DomainResult.Success(scene)
}
override suspend fun updateScene(
profileId: ProviderProfileId,
scene: 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)
DomainResult.Success(scene)
}
override suspend fun deleteScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): 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)
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)
}
private fun flowFor(profileId: ProviderProfileId): MutableStateFlow<DomainResult<List<Scene>>> =
flows.getOrPut(profileId) {
val initial = if (profileId == LOCAL_EXPERIENCE_PROFILE) defaultLocalScenes(profileId) else emptyList()
MutableStateFlow(DomainResult.Success(initial))
}
private fun currentScenes(profileId: ProviderProfileId): List<Scene>? =
(flowFor(profileId).value as? DomainResult.Success)?.value
private fun publish(profileId: ProviderProfileId, scenes: List<Scene>) {
flowFor(profileId).value = DomainResult.Success(scenes)
}
private fun notFound(sceneId: SceneId): DomainResult<Nothing> = DomainResult.Failure(
DomainError.InvalidRequest(detail = "Scene ${sceneId.value} not found"),
)
private fun defaultLocalScenes(profileId: ProviderProfileId): List<Scene> = listOf(
Scene(
id = SceneId("${profileId.value}:scene-leaving-home"),
profileId = profileId,
name = "离家模式",
description = "关闭插座并延时关闭电视",
type = AutomationType.MANUAL,
enabled = true,
icon = SceneIcon.AWAY,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"),
profileId = profileId,
deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.OFF,
),
SceneAction.Delay(
id = SceneActionId("${profileId.value}:scene-leaving-home:delay-1"),
profileId = profileId,
durationSeconds = 1,
),
SceneAction.InfraredKey(
id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"),
profileId = profileId,
deviceName = DeviceName("红外控制器"),
key = "1",
),
),
),
Scene(
id = SceneId("${profileId.value}:scene-cooling-auto"),
profileId = profileId,
name = "高温自动降温",
description = "当室温高于 28°C 时开启插座",
type = AutomationType.AUTOMATIC,
enabled = true,
icon = SceneIcon.HOME,
color = SceneColor("#26A69A"),
triggers = listOf(
SceneTrigger.Temperature(
id = SceneTriggerId("${profileId.value}:trigger-temp"),
deviceName = DeviceName("温湿度传感器"),
operator = ComparisonOperator.GREATER_THAN,
thresholdCelsius = 28.0,
label = "温度高于 28°C",
),
),
conditions = listOf(
SceneCondition.SocketPower(
id = SceneConditionId("${profileId.value}:cond-socket"),
deviceName = DeviceName("Zigbee插座控制器"),
expectedState = SocketPowerState.OFF,
),
),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-cooling:socket-1"),
profileId = profileId,
deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.ON,
),
),
),
)
companion object {
val LOCAL_EXPERIENCE_PROFILE = ProviderProfileId("local-experience")
}
}
@@ -0,0 +1,204 @@
package com.flagship.abox.manager.domain
@JvmInline
value class SceneId(val value: String)
@JvmInline
value class SceneActionId(val value: String)
@JvmInline
value class SceneTriggerId(val value: String)
@JvmInline
value class SceneConditionId(val value: String)
enum class AutomationType {
MANUAL, // 一键指令 (One-Tap / Instant Command)
AUTOMATIC, // 自动指令 (Trigger & Condition Based Automation)
}
enum class SceneActionType {
SOCKET_POWER,
VIRTUAL_REMOTE_BUTTON,
INFRARED_KEY,
DELAY,
NOTIFICATION,
}
enum class ComparisonOperator(val symbol: String) {
GREATER_THAN(">"),
LESS_THAN("<"),
EQUALS("="),
GREATER_OR_EQUAL(">="),
LESS_OR_EQUAL("<="),
}
sealed interface SceneTrigger {
val id: SceneTriggerId
data class Time(
override val id: SceneTriggerId,
val timeOfDay: String, // e.g. "08:30"
val daysOfWeek: Set<Int> = emptySet(), // 1..7 (Monday..Sunday), empty means everyday
val label: String = "",
) : SceneTrigger
data class Temperature(
override val id: SceneTriggerId,
val deviceName: DeviceName,
val operator: ComparisonOperator,
val thresholdCelsius: Double,
val label: String = "",
) : SceneTrigger
data class Humidity(
override val id: SceneTriggerId,
val deviceName: DeviceName,
val operator: ComparisonOperator,
val thresholdPercent: Double,
val label: String = "",
) : SceneTrigger
data class DeviceState(
override val id: SceneTriggerId,
val deviceName: DeviceName,
val expectedState: String, // e.g. "ON" / "OFF"
val label: String = "",
) : SceneTrigger
}
sealed interface SceneCondition {
val id: SceneConditionId
data class Temperature(
override val id: SceneConditionId,
val deviceName: DeviceName,
val operator: ComparisonOperator,
val thresholdCelsius: Double,
) : SceneCondition
data class Humidity(
override val id: SceneConditionId,
val deviceName: DeviceName,
val operator: ComparisonOperator,
val thresholdPercent: Double,
) : SceneCondition
data class SocketPower(
override val id: SceneConditionId,
val deviceName: DeviceName,
val expectedState: SocketPowerState,
) : SceneCondition
data class TimeRange(
override val id: SceneConditionId,
val startTime: String, // "22:00"
val endTime: String, // "07:00"
) : SceneCondition
}
/**
* A single executable step inside a [Scene].
*/
sealed interface SceneAction {
val id: SceneActionId
val profileId: ProviderProfileId
val type: SceneActionType
data class SocketPower(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val deviceName: DeviceName,
val powerState: SocketPowerState,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.SOCKET_POWER
}
data class VirtualRemoteButton(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val remoteId: String,
val remoteDisplayName: String,
val buttonId: String,
val buttonDisplayName: String,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.VIRTUAL_REMOTE_BUTTON
}
data class InfraredKey(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val deviceName: DeviceName,
val key: String,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.INFRARED_KEY
}
data class Delay(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val durationSeconds: Int,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.DELAY
}
data class Notification(
override val id: SceneActionId,
override val profileId: ProviderProfileId,
val title: String,
val message: String,
) : SceneAction {
override val type: SceneActionType get() = SceneActionType.NOTIFICATION
}
}
enum class ExecutionStrategy {
CONTINUE_ON_ERROR, // 遇错继续
ABORT_ON_ERROR, // 遇错中止
}
enum class ConcurrencyMode {
SEQUENTIAL, // 同步 / 串行
PARALLEL, // 异步 / 并行
}
/**
* Visual identity for a scene.
*/
enum class SceneIcon {
HOME,
OFFICE,
NIGHT,
MOVIE,
AWAY,
READING,
DINING,
GAMING,
THERMOSTAT,
LIGHT,
CLEAN,
CUSTOM,
}
/**
* Scene accent color stored as an uppercase hex string (e.g. `#3F51B5`).
*/
@JvmInline
value class SceneColor(val hex: String)
data class Scene(
val id: SceneId,
val profileId: ProviderProfileId,
val name: String,
val description: String = "",
val type: AutomationType = AutomationType.MANUAL,
val enabled: Boolean = true,
val icon: SceneIcon = SceneIcon.HOME,
val color: SceneColor = SceneColor("#3F51B5"),
val triggers: List<SceneTrigger> = emptyList(),
val conditions: List<SceneCondition> = emptyList(),
val actions: List<SceneAction> = emptyList(),
val strategy: ExecutionStrategy = ExecutionStrategy.CONTINUE_ON_ERROR,
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
val enableLogging: Boolean = true,
)
@@ -0,0 +1,36 @@
package com.flagship.abox.manager.domain
enum class SceneActionOutcome {
SUCCESS,
FAILURE,
SKIPPED,
}
/**
* Result of executing a single [SceneAction]. [error] is non-null when [outcome] is [FAILURE].
*/
data class SceneActionExecutionResult(
val action: SceneAction,
val outcome: SceneActionOutcome,
val error: DomainError?,
val executionDurationMillis: Long = 0L,
)
/**
* Aggregated result of executing a [Scene].
*/
data class SceneExecutionResult(
val scene: Scene,
val results: List<SceneActionExecutionResult>,
val totalDurationMillis: Long = 0L,
) {
val totalActions: Int get() = results.size
val succeededCount: Int
get() = results.count { it.outcome == SceneActionOutcome.SUCCESS }
val failedCount: Int
get() = results.count { it.outcome == SceneActionOutcome.FAILURE }
val skippedCount: Int
get() = results.count { it.outcome == SceneActionOutcome.SKIPPED }
val allSucceeded: Boolean
get() = results.isNotEmpty() && failedCount == 0 && skippedCount == 0
}
@@ -0,0 +1,12 @@
package com.flagship.abox.manager.domain
/**
* Executes a [Scene] sequentially. Per-action failures are recorded but do not abort subsequent
* actions; callers receive a [SceneExecutionResult] that summarizes every step.
*
* Implementations must be idempotent from the caller's perspective: triggering a scene that is
* already running returns [DomainError.OperationFailed] rather than starting a second pass.
*/
interface SceneExecutor {
suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult>
}
@@ -0,0 +1,34 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.flow.Flow
/**
* Owns the scene catalog for every provider profile. Implementations must isolate data by
* [ProviderProfileId]: scenes, actions and identifiers must not leak across profiles.
*
* This port is intentionally persistence-agnostic. The first PR provides an in-memory Fake
* implementation; Room persistence will land in a follow-up `feat/scene-persistence` branch.
*/
interface SceneRepository {
fun observeScenes(profileId: ProviderProfileId): Flow<DomainResult<List<Scene>>>
suspend fun getScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Scene?>
suspend fun createScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene>
suspend fun updateScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene>
suspend fun deleteScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Unit>
}
@@ -0,0 +1,352 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultSceneExecutorTest {
private val profileId = ProviderProfileId("local-experience")
private class TestSocketGateway(
var response: DomainResult<SocketStatus> = DomainResult.Success(
SocketStatus(
profileId = ProviderProfileId("local-experience"),
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
) : SocketGateway {
val calls = mutableListOf<Triple<ProviderProfileId, DeviceName, SocketPowerState>>()
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> = response
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> {
calls.add(Triple(profileId, deviceName, powerState))
return response
}
}
private class TestInfraredGateway(
var response: DomainResult<Unit> = DomainResult.Success(Unit),
) : InfraredGateway {
val calls = mutableListOf<Triple<ProviderProfileId, DeviceName, String>>()
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
calls.add(Triple(profileId, deviceName, key))
return response
}
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = DomainResult.Success(Unit)
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = DomainResult.Success(emptyList())
}
@Test
fun executesActionsSequentiallyAndAggregatesResults() = 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 = "Test Scene",
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(action1, action2),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(scene, executionResult.scene)
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(Triple(profileId, DeviceName("socket-1"), SocketPowerState.ON), socketGateway.calls[0])
assertEquals(1, infraredGateway.calls.size)
assertEquals(Triple(profileId, DeviceName("ir-1"), "power"), infraredGateway.calls[0])
}
@Test
fun continuesExecutionWhenAnActionFails() = runTest {
val socketGateway = TestSocketGateway(
response = DomainResult.Failure(DomainError.DeviceOffline("socket-1 unreachable")),
)
val infraredGateway = TestInfraredGateway(
response = DomainResult.Success(Unit),
)
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
)
val action2 = SceneAction.InfraredKey(
id = SceneActionId("a2"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "vol_up",
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Test Scene",
icon = SceneIcon.MOVIE,
color = SceneColor("#7986CB"),
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)
assertTrue(executionResult.results[0].error is DomainError.DeviceOffline)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome)
assertEquals(1, socketGateway.calls.size)
assertEquals(1, infraredGateway.calls.size)
}
@Test
fun abortsExecutionOnFailureWhenStrategyIsAbortOnError() = runTest {
val socketGateway = TestSocketGateway(
response = DomainResult.Failure(DomainError.DeviceOffline("socket-1 unreachable")),
)
val infraredGateway = TestInfraredGateway(
response = DomainResult.Success(Unit),
)
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
)
val action2 = SceneAction.InfraredKey(
id = SceneActionId("a2"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "vol_up",
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Test Scene",
icon = SceneIcon.MOVIE,
color = SceneColor("#7986CB"),
actions = listOf(action1, action2),
strategy = ExecutionStrategy.ABORT_ON_ERROR,
)
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)
assertEquals(0, infraredGateway.calls.size)
}
@Test
fun executesDelayActionSuccessfully() = runTest {
var delayCalled = 0L
val executor = DefaultSceneExecutor(
socketGateway = TestSocketGateway(),
infraredGateway = TestInfraredGateway(),
delayDurationProvider = { delayCalled = it },
)
val action = SceneAction.Delay(
id = SceneActionId("d1"),
profileId = profileId,
durationSeconds = 5,
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Delay 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.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)
}
@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)
}
}
@@ -0,0 +1,128 @@
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
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InMemorySceneRepositoryTest {
private val localProfile = ProviderProfileId("local-experience")
private val aboxProfile = ProviderProfileId("abox:admin")
@Test
fun isolatesScenesBetweenProfiles() = runTest {
val repository = InMemorySceneRepository()
val localScenes = (repository.observeScenes(localProfile).first() as DomainResult.Success).value
assertTrue(localScenes.isNotEmpty())
val aboxScenes = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value
assertTrue(aboxScenes.isEmpty())
val newScene = Scene(
id = SceneId("abox:admin:scene-custom"),
profileId = aboxProfile,
name = "ABox Scene",
icon = SceneIcon.NIGHT,
color = SceneColor("#4CAF50"),
actions = emptyList(),
)
val createResult = repository.createScene(aboxProfile, newScene)
assertTrue(createResult is DomainResult.Success)
val updatedAbox = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value
assertEquals(1, updatedAbox.size)
assertEquals("ABox Scene", updatedAbox.first().name)
val updatedLocal = (repository.observeScenes(localProfile).first() as DomainResult.Success).value
assertEquals(localScenes.size, updatedLocal.size)
}
@Test
fun crudOperationsFunctionCorrectly() = runTest {
val repository = InMemorySceneRepository()
val sceneId = SceneId("abox:admin:scene-1")
val initialScene = Scene(
id = sceneId,
profileId = aboxProfile,
name = "Initial",
icon = SceneIcon.HOME,
color = SceneColor("#FFFFFF"),
actions = emptyList(),
)
// Create
val createResult = repository.createScene(aboxProfile, initialScene)
assertTrue(createResult is DomainResult.Success)
// Get
val getResult = repository.getScene(aboxProfile, sceneId)
assertTrue(getResult is DomainResult.Success)
assertEquals("Initial", (getResult as DomainResult.Success).value?.name)
// Update
val updatedScene = initialScene.copy(name = "Updated")
val updateResult = repository.updateScene(aboxProfile, updatedScene)
assertTrue(updateResult is DomainResult.Success)
val getAfterUpdate = repository.getScene(aboxProfile, sceneId)
assertEquals("Updated", (getAfterUpdate as DomainResult.Success).value?.name)
// Delete
val deleteResult = repository.deleteScene(aboxProfile, sceneId)
assertTrue(deleteResult is DomainResult.Success)
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)
}
}