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,391 @@
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
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionExecutionResult
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneActionOutcome
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneExecutionResult
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneIcon
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.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
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
* [ProviderProfileId]; mutating scenes for one profile never touches another profile's flow.
*
* The repository is intentionally deterministic: [defaultScenes] seeds the same catalog every
* run so UI snapshots and tests stay stable. Mutations emit a new list through the profile's
* StateFlow.
*/
class FakeSceneRepository : 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 provider profile"
}
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 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)
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)
}
/**
* Replaces the catalog for [profileId] in tests. Returns the same repository so callers can
* chain `repo.setScenes(profile, listOf(...))`.
*/
fun setScenes(profileId: ProviderProfileId, scenes: List<Scene>): FakeSceneRepository {
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
}
fun setError(profileId: ProviderProfileId, error: DomainError) {
flowFor(profileId).value = DomainResult.Failure(error)
}
private fun flowFor(
profileId: ProviderProfileId,
): MutableStateFlow<DomainResult<List<Scene>>> =
flows.getOrPut(profileId) {
val initial = if (profileId == localProfile) defaultScenes(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 defaultScenes(profileId: ProviderProfileId): List<Scene> = listOf(
Scene(
id = SceneId("${profileId.value}:scene-leaving-home"),
profileId = profileId,
name = "Leaving home",
icon = SceneIcon.AWAY,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
),
SceneAction.InfraredKey(
id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "power",
),
),
),
Scene(
id = SceneId("${profileId.value}:scene-movie-night"),
profileId = profileId,
name = "Movie night",
icon = SceneIcon.MOVIE,
color = SceneColor("#7986CB"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-movie-night:socket-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
),
),
),
)
companion object {
/**
* The canonical profile id used by the in-app local experience provider. UI code that
* hard-codes a profile in phase 1 should use this constant so the Fake shows data.
*/
val localProfile: ProviderProfileId = ProviderProfileId("local-experience")
}
}
/**
* Fake [SceneExecutor] that drives the existing [SocketGateway] and [InfraredGateway] fakes,
* respecting delays and recording virtual remote / notification actions.
*/
class FakeSceneExecutor(
private val socketGateway: SocketGateway,
private val infraredGateway: InfraredGateway,
private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) },
) : SceneExecutor {
private val running = ConcurrentHashMap<SceneId, Boolean>()
val executedRemoteButtons = mutableListOf<SceneAction.VirtualRemoteButton>()
val sentNotifications = mutableListOf<SceneAction.Notification>()
override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> {
val alreadyRunning = running.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 {
running.remove(scene.id)
}
}
private suspend fun executeAction(
profileId: ProviderProfileId,
action: SceneAction,
): SceneActionExecutionResult {
return try {
when (action) {
is SceneAction.SocketPower -> {
val result = socketGateway.setPower(
profileId = profileId,
deviceName = action.deviceName,
powerState = action.powerState,
)
toExecutionResult(action, result)
}
is SceneAction.InfraredKey -> {
val result = infraredGateway.sendKey(
profileId = profileId,
deviceName = action.deviceName,
key = action.key,
)
toExecutionResult(action, result)
}
is SceneAction.VirtualRemoteButton -> {
executedRemoteButtons.add(action)
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
is SceneAction.Delay -> {
delayDurationProvider(action.durationSeconds * 1000L)
SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
}
is SceneAction.Notification -> {
sentNotifications.add(action)
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, result: DomainResult<*>): SceneActionExecutionResult {
return when (result) {
is DomainResult.Success -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
is DomainResult.Failure -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = result.error,
)
}
}
}
@@ -0,0 +1,247 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.ConcurrencyMode
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ExecutionStrategy
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneActionOutcome
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.SocketGateway
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.SocketStatus
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class FakeSceneExecutorTest {
private val profileId = ProviderProfileId("abox")
@Test
fun executesActionsInOrder() = runTest {
val socketGateway = FakeSocketGateway()
val infraredGateway = FakeInfraredGateway()
val executor = FakeSceneExecutor(socketGateway, infraredGateway)
val scene = scene(
actions = listOf(
socketAction("socket-1", SocketPowerState.ON),
infraredAction("ir-1", "power"),
socketAction("socket-2", SocketPowerState.OFF),
),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(3, executionResult.results.size)
executionResult.results.forEach {
assertEquals(SceneActionOutcome.SUCCESS, it.outcome)
}
assertEquals(1, infraredGateway.sentKeys.size)
}
@Test
fun continuesAfterActionFailure() = runTest {
val socketGateway = FakeSocketGateway()
socketGateway.setError(
profileId,
DeviceName("socket-1"),
DomainError.DeviceOffline(detail = "offline"),
)
val infraredGateway = FakeInfraredGateway()
val executor = FakeSceneExecutor(socketGateway, infraredGateway)
val scene = scene(
actions = listOf(
socketAction("socket-1", SocketPowerState.ON),
infraredAction("ir-1", "power"),
),
)
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.SUCCESS, executionResult.results[1].outcome)
assertEquals(1, executionResult.succeededCount)
assertEquals(1, executionResult.failedCount)
assertFalse(executionResult.allSucceeded)
}
@Test
fun runningSceneRejectsConcurrentExecution() = runTest {
val gate = CompletableDeferred<Unit>()
val socketGateway = BlockingSocketGateway(gate)
val executor = FakeSceneExecutor(socketGateway, FakeInfraredGateway())
val scene = scene(
actions = listOf(socketAction("socket-1", SocketPowerState.ON)),
)
val first = async { executor.execute(scene) }
// Let the first coroutine enter the executor and block on the socket gateway.
testScheduler.advanceUntilIdle()
val second = async { executor.execute(scene) }
val secondResult = second.await()
assertTrue(secondResult is DomainResult.Failure)
assertTrue((secondResult as DomainResult.Failure).error is DomainError.OperationFailed)
gate.complete(Unit)
val firstResult = first.await()
assertTrue(firstResult is DomainResult.Success)
}
@Test
fun emptySceneSucceedsWithNoResults() = runTest {
val executor = FakeSceneExecutor(FakeSocketGateway(), FakeInfraredGateway())
val scene = scene(actions = emptyList())
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(0, executionResult.results.size)
assertEquals(0, executionResult.succeededCount)
assertEquals(0, executionResult.failedCount)
assertFalse(executionResult.allSucceeded)
}
@Test
fun executesDelayVirtualRemoteAndNotification() = runTest {
val delays = mutableListOf<Long>()
val executor = FakeSceneExecutor(
socketGateway = FakeSocketGateway(),
infraredGateway = FakeInfraredGateway(),
delayDurationProvider = { delays.add(it) },
)
val remoteAction = SceneAction.VirtualRemoteButton(
id = SceneActionId("abox:btn-1"),
profileId = profileId,
remoteId = "remote-1",
remoteDisplayName = "TV Remote",
buttonId = "power",
buttonDisplayName = "Power",
)
val delayAction = SceneAction.Delay(
id = SceneActionId("abox:delay-1"),
profileId = profileId,
durationSeconds = 5,
)
val notificationAction = SceneAction.Notification(
id = SceneActionId("abox:notif-1"),
profileId = profileId,
title = "Test Done",
message = "Success",
)
val scene = scene(listOf(remoteAction, delayAction, notificationAction))
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val execResult = (result as DomainResult.Success).value
assertTrue(execResult.allSucceeded)
assertEquals(listOf(5000L), delays)
assertEquals(listOf(remoteAction), executor.executedRemoteButtons)
assertEquals(listOf(notificationAction), executor.sentNotifications)
}
@Test
fun parallelAbortOnErrorCancelsPendingActions() = runTest {
val socketGateway = FakeSocketGateway()
socketGateway.setError(
profileId,
DeviceName("socket-1"),
DomainError.DeviceOffline(detail = "offline"),
)
val executor = FakeSceneExecutor(
socketGateway = socketGateway,
infraredGateway = FakeInfraredGateway(),
delayDurationProvider = { kotlinx.coroutines.delay(1000) },
)
val action1 = socketAction("socket-1", SocketPowerState.ON)
val action2 = SceneAction.Delay(
id = SceneActionId("abox:delay-1"),
profileId = profileId,
durationSeconds = 10,
)
val scene = Scene(
id = SceneId("${profileId.value}:scene-par-abort"),
profileId = profileId,
name = "Parallel Abort Scene",
concurrencyMode = ConcurrencyMode.PARALLEL,
strategy = ExecutionStrategy.ABORT_ON_ERROR,
actions = listOf(action1, action2),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome)
}
private fun scene(actions: List<SceneAction>): Scene = Scene(
id = SceneId("${profileId.value}:scene-test"),
profileId = profileId,
name = "Test scene",
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = actions,
)
private fun socketAction(
deviceName: String,
powerState: SocketPowerState,
): SceneAction.SocketPower = SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:action-${deviceName}-${powerState.name}"),
profileId = profileId,
deviceName = DeviceName(deviceName),
powerState = powerState,
)
private fun infraredAction(
deviceName: String,
key: String,
): SceneAction.InfraredKey = SceneAction.InfraredKey(
id = SceneActionId("${profileId.value}:action-${deviceName}-$key"),
profileId = profileId,
deviceName = DeviceName(deviceName),
key = key,
)
private class BlockingSocketGateway(
private val gate: CompletableDeferred<Unit>,
) : SocketGateway {
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> = DomainResult.Success(
SocketStatus(profileId, deviceName, SocketPowerState.OFF),
)
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> {
gate.await()
return DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
}
}
}
@@ -0,0 +1,191 @@
package com.flagship.abox.manager.testing.fakes
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.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
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
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class FakeSceneRepositoryTest {
private val aboxProfile = ProviderProfileId("abox")
private val localProfile = FakeSceneRepository.localProfile
@Test
fun defaultScenesAreIsolatedByProviderProfile() = runTest {
val repo = FakeSceneRepository()
val aboxScenes = scenesFor(repo, aboxProfile)
val localScenes = scenesFor(repo, localProfile)
assertEquals(0, aboxScenes.size)
assertEquals(2, localScenes.size)
assertTrue(localScenes.all { it.profileId == localProfile })
}
@Test
fun createScenePublishesSceneToFlow() = runTest {
val repo = FakeSceneRepository()
val scene = sampleScene(localProfile, "scene-1")
val result = repo.createScene(localProfile, scene)
assertTrue(result is DomainResult.Success)
assertEquals(scene, (result as DomainResult.Success).value)
val list = scenesFor(repo, localProfile)
assertEquals(3, list.size)
assertEquals(scene, list.last())
}
@Test
fun createSceneWithDuplicateIdFails() = runTest {
val repo = FakeSceneRepository()
val scene = sampleScene(localProfile, "dup")
repo.createScene(localProfile, scene)
val second = repo.createScene(localProfile, scene)
assertTrue(second is DomainResult.Failure)
assertTrue((second as DomainResult.Failure).error is DomainError.InvalidRequest)
}
@Test
fun updateSceneReplacesExistingEntry() = runTest {
val repo = FakeSceneRepository()
val original = sampleScene(localProfile, "scene-2", "Original")
repo.createScene(localProfile, original)
val updated = original.copy(name = "Renamed")
val result = repo.updateScene(localProfile, updated)
assertTrue(result is DomainResult.Success)
val list = scenesFor(repo, localProfile)
assertEquals(3, list.size)
val stored = list.first { it.id == original.id }
assertEquals("Renamed", stored.name)
}
@Test
fun deleteSceneRemovesEntry() = runTest {
val repo = FakeSceneRepository()
val scene = sampleScene(localProfile, "scene-3")
repo.createScene(localProfile, scene)
val result = repo.deleteScene(localProfile, scene.id)
assertTrue(result is DomainResult.Success)
val list = scenesFor(repo, localProfile)
assertEquals(2, list.size)
assertNull(list.firstOrNull { it.id == scene.id })
}
@Test
fun deleteUnknownSceneFails() = runTest {
val repo = FakeSceneRepository()
val result = repo.deleteScene(localProfile, SceneId("missing"))
assertTrue(result is DomainResult.Failure)
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
}
@Test
fun getSceneReturnsNullForUnknownId() = runTest {
val repo = FakeSceneRepository()
val result = repo.getScene(localProfile, SceneId("missing"))
assertTrue(result is DomainResult.Success)
assertNull((result as DomainResult.Success).value)
}
@Test
fun setErrorStopsPublishingSuccess() = runTest {
val repo = FakeSceneRepository()
repo.setError(localProfile, DomainError.Unknown(detail = "boom"))
val value = repo.observeScenes(localProfile).first()
assertTrue(value is DomainResult.Failure)
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,
): List<Scene> = (repo.observeScenes(profileId).first() as DomainResult.Success).value
private fun sampleScene(
profileId: ProviderProfileId,
idSuffix: String,
name: String = "Test scene",
): Scene = Scene(
id = SceneId("${profileId.value}:scene-$idSuffix"),
profileId = profileId,
name = name,
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-$idSuffix:1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
)
}