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:
+391
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+247
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user