feat(scene): integrate scene domain, viewmodels, screens and tests
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Production implementation of [SceneExecutor].
|
||||
*
|
||||
* Executes scene actions sequentially via [SocketGateway] and [InfraredGateway].
|
||||
* Guarantees:
|
||||
* 1. Actions are run sequentially in the order of [Scene.actions].
|
||||
* 2. Individual action failures do not abort remaining actions; all actions run to completion.
|
||||
* 3. Results are aggregated into a [SceneExecutionResult].
|
||||
* 4. Re-entrant / concurrent execution of the same scene is rejected with [DomainError.OperationFailed].
|
||||
*/
|
||||
class DefaultSceneExecutor(
|
||||
private val socketGateway: SocketGateway,
|
||||
private val infraredGateway: InfraredGateway,
|
||||
) : 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",
|
||||
),
|
||||
)
|
||||
}
|
||||
return try {
|
||||
val results = scene.actions.map { action -> executeAction(scene.profileId, action) }
|
||||
DomainResult.Success(SceneExecutionResult(scene = scene, results = results))
|
||||
} finally {
|
||||
runningScenes.remove(scene.id)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun executeAction(
|
||||
profileId: ProviderProfileId,
|
||||
action: SceneAction,
|
||||
): SceneActionExecutionResult {
|
||||
val result = when (action) {
|
||||
is SceneAction.SocketPower -> socketGateway.setPower(
|
||||
profileId = profileId,
|
||||
deviceName = action.deviceName,
|
||||
powerState = action.powerState,
|
||||
)
|
||||
|
||||
is SceneAction.InfraredKey -> infraredGateway.sendKey(
|
||||
profileId = profileId,
|
||||
deviceName = action.deviceName,
|
||||
key = action.key,
|
||||
)
|
||||
}
|
||||
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,137 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
/**
|
||||
* 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>>>>()
|
||||
|
||||
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> {
|
||||
require(scene.profileId == profileId) {
|
||||
"Scene profileId must match 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)
|
||||
return DomainResult.Success(scene)
|
||||
}
|
||||
|
||||
override suspend fun updateScene(
|
||||
profileId: ProviderProfileId,
|
||||
scene: Scene,
|
||||
): DomainResult<Scene> {
|
||||
require(scene.profileId == profileId) {
|
||||
"Scene profileId must match the supplied profileId"
|
||||
}
|
||||
val scenes = currentScenes(profileId).orEmpty()
|
||||
val index = scenes.indexOfFirst { it.id == scene.id }
|
||||
if (index < 0) return notFound(scene.id)
|
||||
val updated = scenes.toMutableList().apply { set(index, scene) }
|
||||
publish(profileId, updated)
|
||||
return DomainResult.Success(scene)
|
||||
}
|
||||
|
||||
override suspend fun deleteScene(
|
||||
profileId: ProviderProfileId,
|
||||
sceneId: SceneId,
|
||||
): DomainResult<Unit> {
|
||||
val scenes = currentScenes(profileId).orEmpty()
|
||||
val updated = scenes.filterNot { it.id == sceneId }
|
||||
if (updated.size == scenes.size) return notFound(sceneId)
|
||||
publish(profileId, updated)
|
||||
return DomainResult.Success(Unit)
|
||||
}
|
||||
|
||||
fun seedDefaults(profileId: ProviderProfileId, scenes: List<Scene>) {
|
||||
require(scenes.all { it.profileId == profileId }) {
|
||||
"All scenes 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 = "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 {
|
||||
val LOCAL_EXPERIENCE_PROFILE = ProviderProfileId("local-experience")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user