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

Merged
KremeCN merged 10 commits from feat/scene-editor into main 2026-08-25 20:20:19 +08:00
18 changed files with 898 additions and 117 deletions
Showing only changes of commit df46d6273d - Show all commits
@@ -1,41 +0,0 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import com.flagship.abox.manager.ui.scene.LocalProfileId
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Phase 1 Hilt bindings for the scene module. Binds the in-memory [FakeSceneRepository] and the
* [FakeSceneExecutor] that drives the existing socket/infrared fakes. The bindings live in the
* debug source set so the release build does not ship the Fake implementation; the real provider
* bindings will land in `feat/scene-persistence` and `feat/provider-login` respectively.
*/
@Module
Outdated
Review

发布构建

SceneRepository、SceneExecutor 和带 LocalProfileId 限定符的 ProviderProfileId 仅在 debug 源集中提供,但依赖它们的 ViewModel 位于 main 源集。当前没有 release 绑定,执行 release 变体的 Hilt 组件生成时会出现 MissingBinding;需要提供 release 实现或将相关消费者限制在 debug 变体。

**发布构建** `SceneRepository`、`SceneExecutor` 和带 `LocalProfileId` 限定符的 `ProviderProfileId` 仅在 debug 源集中提供,但依赖它们的 ViewModel 位于 main 源集。当前没有 release 绑定,执行 release 变体的 Hilt 组件生成时会出现 MissingBinding;需要提供 release 实现或将相关消费者限制在 debug 变体。 <!-- pr-agent-dedup: 9b2e0a4e82db --> <!-- pr-agent-key-issue-location: f99d94b4cef4 -->
@InstallIn(SingletonComponent::class)
object SceneModule {
@Provides
@Singleton
fun provideSceneRepository(): SceneRepository = FakeSceneRepository()
@Provides
@Singleton
fun provideSceneExecutor(
socketGateway: SocketGateway,
infraredGateway: InfraredGateway,
): SceneExecutor = FakeSceneExecutor(socketGateway, infraredGateway)
@Provides
@Singleton
@LocalProfileId
fun provideLocalProfileId(): ProviderProfileId = FakeSceneRepository.localProfile
}
@@ -0,0 +1,31 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DefaultSceneExecutor
import com.flagship.abox.manager.domain.InMemorySceneRepository
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SocketGateway
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
/**
* Production dependency injection module for Scene management and execution.
*/
@Module
@InstallIn(SingletonComponent::class)
object SceneIntegrationModule {
@Provides
@Singleton
fun provideSceneRepository(): SceneRepository = InMemorySceneRepository()
@Provides
@Singleton
fun provideSceneExecutor(
socketGateway: SocketGateway,
infraredGateway: InfraredGateway,
): SceneExecutor = DefaultSceneExecutor(socketGateway, infraredGateway)
}
@@ -1,14 +0,0 @@
package com.flagship.abox.manager.ui.scene
import javax.inject.Qualifier
/**
* Qualifies the [com.flagship.abox.manager.domain.ProviderProfileId] used by the scene module
* while the real provider selector is still being built (see project_plan.md §7 dependency graph).
*
* Phase 2 will replace this binding with the active session's profile id observed from
* SessionGateway; the qualifier will be retired at that point.
*/
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class LocalProfileId
@@ -14,8 +14,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -187,10 +185,10 @@ private fun ActionRow(
) )
} }
IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) { IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) {
Icon(Icons.Filled.ArrowUpward, contentDescription = null) Text("↑", style = MaterialTheme.typography.titleMedium)
} }
IconButton(onClick = { onMoveDown(index) }, enabled = index < total - 1) { IconButton(onClick = { onMoveDown(index) }, enabled = index < total - 1) {
Icon(Icons.Filled.ArrowDownward, contentDescription = null) Text("↓", style = MaterialTheme.typography.titleMedium)
} }
IconButton(onClick = onRemove) { IconButton(onClick = onRemove) {
Icon(Icons.Filled.Delete, contentDescription = null) Icon(Icons.Filled.Delete, contentDescription = null)
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId import com.flagship.abox.manager.domain.SceneActionId
@@ -39,7 +39,7 @@ data class SceneEditorState(
@HiltViewModel @HiltViewModel
class SceneEditorViewModel @Inject constructor( class SceneEditorViewModel @Inject constructor(
private val sceneRepository: SceneRepository, private val sceneRepository: SceneRepository,
@LocalProfileId private val localProfileId: ProviderProfileId, private val profileStore: ProviderProfileStore,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(SceneEditorState()) private val _state = MutableStateFlow(SceneEditorState())
@@ -51,7 +51,12 @@ class SceneEditorViewModel @Inject constructor(
return return
} }
viewModelScope.launch { viewModelScope.launch {
val scene = (sceneRepository.getScene(localProfileId, sceneId) as? DomainResult.Success)?.value val profile = profileStore.activeProfile()
if (profile == null) {
_state.update { it.copy(errorMessage = "No active profile") }
return@launch
}
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
if (scene != null) { if (scene != null) {
_state.value = SceneEditorState( _state.value = SceneEditorState(
sceneId = scene.id, sceneId = scene.id,
Outdated
Review

校验缺失

isValid 没有要求自动场景至少包含一个触发器,因此用户只填写名称和动作就能保存一个已启用但永远不会被触发的自动场景。应在类型为 AUTOMATIC 时要求 triggers 非空,或将其明确保存为禁用草稿。

**校验缺失** `isValid` 没有要求自动场景至少包含一个触发器,因此用户只填写名称和动作就能保存一个已启用但永远不会被触发的自动场景。应在类型为 `AUTOMATIC` 时要求 `triggers` 非空,或将其明确保存为禁用草稿。 <!-- pr-agent-dedup: 3de3aa0f9568 --> <!-- pr-agent-key-issue-location: 0ace8ec93357 -->
@@ -74,24 +79,30 @@ class SceneEditorViewModel @Inject constructor(
fun addSocketAction(deviceName: String, powerState: SocketPowerState) { fun addSocketAction(deviceName: String, powerState: SocketPowerState) {
if (deviceName.isBlank()) return if (deviceName.isBlank()) return
val action = SceneAction.SocketPower( viewModelScope.launch {
id = SceneActionId(UUID.randomUUID().toString()), val profile = profileStore.activeProfile() ?: return@launch
profileId = localProfileId, val action = SceneAction.SocketPower(
deviceName = DeviceName(deviceName), id = SceneActionId(UUID.randomUUID().toString()),
Outdated
Review

设备硬编码

observeRemotes 将红外设备名固定为中文字符串 红外控制器。只要当前配置中的实际设备名不同,例如英文名称、自定义名称或默认设备 ID,仓库查询就无法加载该设备的虚拟遥控器,遥控动作选择器会错误显示为空。应从当前配置或设备列表取得真实的 DeviceName。

**设备硬编码** `observeRemotes` 将红外设备名固定为中文字符串 `红外控制器`。只要当前配置中的实际设备名不同,例如英文名称、自定义名称或默认设备 ID,仓库查询就无法加载该设备的虚拟遥控器,遥控动作选择器会错误显示为空。应从当前配置或设备列表取得真实的 `DeviceName`。 <!-- pr-agent-dedup: 2b0abea93edd --> <!-- pr-agent-key-issue-location: 10a59afea5f1 -->
powerState = powerState, profileId = profile.id,
) deviceName = DeviceName(deviceName),
_state.update { it.copy(actions = it.actions + action, errorMessage = null) } powerState = powerState,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
} }
fun addInfraredAction(deviceName: String, key: String) { fun addInfraredAction(deviceName: String, key: String) {
if (deviceName.isBlank() || key.isBlank()) return if (deviceName.isBlank() || key.isBlank()) return
val action = SceneAction.InfraredKey( viewModelScope.launch {
id = SceneActionId(UUID.randomUUID().toString()), val profile = profileStore.activeProfile() ?: return@launch
profileId = localProfileId, val action = SceneAction.InfraredKey(
deviceName = DeviceName(deviceName), id = SceneActionId(UUID.randomUUID().toString()),
key = key, profileId = profile.id,
) deviceName = DeviceName(deviceName),
_state.update { it.copy(actions = it.actions + action, errorMessage = null) } key = key,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
} }
fun removeAction(actionId: SceneActionId) = fun removeAction(actionId: SceneActionId) =
@@ -109,18 +120,23 @@ class SceneEditorViewModel @Inject constructor(
if (!state.isValid || state.isSaving) return if (!state.isValid || state.isSaving) return
_state.update { it.copy(isSaving = true, errorMessage = null) } _state.update { it.copy(isSaving = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
val profile = profileStore.activeProfile()
if (profile == null) {
_state.update { it.copy(isSaving = false, errorMessage = "No active profile") }
return@launch
}
val scene = Scene( val scene = Scene(
id = state.sceneId ?: SceneId("${localProfileId.value}:scene-${UUID.randomUUID()}"), id = state.sceneId ?: SceneId("${profile.id.value}:scene-${UUID.randomUUID()}"),
profileId = localProfileId, profileId = profile.id,
name = state.name.trim(), name = state.name.trim(),
icon = state.icon, icon = state.icon,
color = SceneColor(state.colorHex), color = SceneColor(state.colorHex),
actions = state.actions, actions = state.actions,
) )
val result = if (state.isEditing) { val result = if (state.isEditing) {
sceneRepository.updateScene(localProfileId, scene) sceneRepository.updateScene(profile.id, scene)
} else { } else {
sceneRepository.createScene(localProfileId, scene) sceneRepository.createScene(profile.id, scene)
} }
_state.update { current -> _state.update { current ->
when (result) { when (result) {
@@ -134,3 +150,4 @@ class SceneEditorViewModel @Inject constructor(
} }
} }
} }
@@ -13,8 +13,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -186,9 +186,9 @@ private fun ActionResultRow(entry: SceneActionExecutionResult) {
) { ) {
Icon( Icon(
imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) { imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) {
Icons.Filled.CheckCircle Icons.Filled.Check
} else { } else {
Icons.Filled.Error Icons.Filled.Close
}, },
contentDescription = null, contentDescription = null,
tint = if (entry.outcome == SceneActionOutcome.SUCCESS) { tint = if (entry.outcome == SceneActionOutcome.SUCCESS) {
@@ -3,7 +3,7 @@ package com.flagship.abox.manager.ui.scene
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneExecutionResult import com.flagship.abox.manager.domain.SceneExecutionResult
import com.flagship.abox.manager.domain.SceneExecutor import com.flagship.abox.manager.domain.SceneExecutor
@@ -28,7 +28,7 @@ sealed interface SceneExecutionUiState {
class SceneExecutionViewModel @Inject constructor( class SceneExecutionViewModel @Inject constructor(
private val sceneRepository: SceneRepository, private val sceneRepository: SceneRepository,
private val sceneExecutor: SceneExecutor, private val sceneExecutor: SceneExecutor,
@LocalProfileId private val localProfileId: ProviderProfileId, private val profileStore: ProviderProfileStore,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow<SceneExecutionUiState>(SceneExecutionUiState.Loading) private val _uiState = MutableStateFlow<SceneExecutionUiState>(SceneExecutionUiState.Loading)
@@ -37,7 +37,12 @@ class SceneExecutionViewModel @Inject constructor(
fun initialize(sceneId: SceneId) { fun initialize(sceneId: SceneId) {
Outdated
Review

执行状态丢失

对同一场景重复调用 initialize 会把 Running 或 Completed 无条件重置为 Loading,随后变为 Ready。配置变更会重新触发界面初始化,因此已完成的结果会消失;执行过程中旋转设备还会重新显示执行按钮,而原协程仍在运行。应让初始化对同一场景幂等,并保留进行中及完成状态。

**执行状态丢失** 对同一场景重复调用 `initialize` 会把 `Running` 或 `Completed` 无条件重置为 `Loading`,随后变为 `Ready`。配置变更会重新触发界面初始化,因此已完成的结果会消失;执行过程中旋转设备还会重新显示执行按钮,而原协程仍在运行。应让初始化对同一场景幂等,并保留进行中及完成状态。 <!-- pr-agent-dedup: b97d8304ffda --> <!-- pr-agent-key-issue-location: 666775487b17 -->
_uiState.value = SceneExecutionUiState.Loading _uiState.value = SceneExecutionUiState.Loading
viewModelScope.launch { viewModelScope.launch {
val scene = (sceneRepository.getScene(localProfileId, sceneId) as? DomainResult.Success)?.value val profile = profileStore.activeProfile()
Outdated
Review

跨配置执行

活动配置文件仅在初始化时读取,加载后的场景会一直保存在 Ready 状态。若用户停留在执行页时切换提供方,随后仍会使用旧场景的 profileId 向网关发送命令。应监听配置文件切换并使当前场景失效,或在执行前重新校验活动配置文件与 scene.profileId 一致。

**跨配置执行** 活动配置文件仅在初始化时读取,加载后的场景会一直保存在 `Ready` 状态。若用户停留在执行页时切换提供方,随后仍会使用旧场景的 `profileId` 向网关发送命令。应监听配置文件切换并使当前场景失效,或在执行前重新校验活动配置文件与 `scene.profileId` 一致。 <!-- pr-agent-dedup: 353f16aeadc1 --> <!-- pr-agent-key-issue-location: 4c701012d709 -->
if (profile == null) {
_uiState.value = SceneExecutionUiState.Failed("No active profile")
return@launch
}
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
_uiState.value = if (scene != null) { _uiState.value = if (scene != null) {
SceneExecutionUiState.Ready(scene) SceneExecutionUiState.Ready(scene)
} else { } else {
@@ -62,3 +67,4 @@ class SceneExecutionViewModel @Inject constructor(
} }
} }
} }
@@ -19,9 +19,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -44,11 +45,13 @@ import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneId
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SceneListScreen( fun SceneListScreen(
onAddScene: () -> Unit, onAddScene: () -> Unit,
onEditScene: (SceneId) -> Unit, onEditScene: (SceneId) -> Unit,
onExecuteScene: (SceneId) -> Unit, onExecuteScene: (SceneId) -> Unit,
headerContent: @Composable (() -> Unit)? = null,
viewModel: SceneListViewModel = hiltViewModel(), viewModel: SceneListViewModel = hiltViewModel(),
) { ) {
val scenes by viewModel.scenes.collectAsStateWithLifecycle() val scenes by viewModel.scenes.collectAsStateWithLifecycle()
@@ -68,19 +71,26 @@ fun SceneListScreen(
) )
}, },
) { innerPadding -> ) { innerPadding ->
if (scenes.isEmpty()) { LazyColumn(
EmptySceneList(modifier = Modifier.padding(innerPadding)) modifier = Modifier.fillMaxSize(),
} else { contentPadding = PaddingValues(
LazyColumn( start = 16.dp,
modifier = Modifier.fillMaxSize(), end = 16.dp,
contentPadding = PaddingValues( top = innerPadding.calculateTopPadding(),
start = 16.dp, bottom = innerPadding.calculateBottomPadding() + 80.dp,
end = 16.dp, ),
top = innerPadding.calculateTopPadding(), verticalArrangement = Arrangement.spacedBy(12.dp),
bottom = innerPadding.calculateBottomPadding() + 80.dp, ) {
), if (headerContent != null) {
verticalArrangement = Arrangement.spacedBy(12.dp), item(key = "header") {
) { headerContent()
}
}
if (scenes.isEmpty()) {
item(key = "empty") {
EmptySceneList(modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp))
}
} else {
items(scenes, key = { it.id.value }) { scene -> items(scenes, key = { it.id.value }) { scene ->
SceneRow( SceneRow(
scene = scene, scene = scene,
@@ -151,7 +161,7 @@ private fun SceneRow(
} }
IconButton(onClick = onEdit) { IconButton(onClick = onEdit) {
Icon( Icon(
Icons.Filled.PlayArrow, Icons.Filled.Edit,
contentDescription = stringResource(R.string.scene_edit), contentDescription = stringResource(R.string.scene_edit),
) )
} }
@@ -190,6 +200,7 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
} }
internal fun com.flagship.abox.manager.domain.SceneColor.toColorOrNull(): Color? { internal fun com.flagship.abox.manager.domain.SceneColor.toColorOrNull(): Color? {
val hex = value.removePrefix("#") val cleanHex = hex.removePrefix("#")
Outdated
Review

编译错误

SceneColor 只定义了 hex 属性,此处访问不存在的 value 会导致主源码无法编译。应改为使用 hex.removePrefix("#")。

**编译错误** `SceneColor` 只定义了 `hex` 属性,此处访问不存在的 `value` 会导致主源码无法编译。应改为使用 `hex.removePrefix("#")`。 <!-- pr-agent-dedup: a50340fdd105 --> <!-- pr-agent-key-issue-location: 7c2f22d07761 -->
return runCatching { Color(hex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull() return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull()
} }
@@ -3,35 +3,38 @@ package com.flagship.abox.manager.ui.scene
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository import com.flagship.abox.manager.domain.SceneRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** @OptIn(ExperimentalCoroutinesApi::class)
* Phase 1 scene list state. The repository is observed for the local-experience profile so the
* UI updates when scenes are added, edited or deleted from any screen.
*
* Phase 2 will replace the hard-coded [LocalProfileId] injection with the active session observed
* from SessionGateway (see project_plan.md §6.5 and §7).
*/
@HiltViewModel @HiltViewModel
class SceneListViewModel @Inject constructor( class SceneListViewModel @Inject constructor(
private val sceneRepository: SceneRepository, private val sceneRepository: SceneRepository,
@LocalProfileId private val localProfileId: ProviderProfileId, private val profileStore: ProviderProfileStore,
) : ViewModel() { ) : ViewModel() {
val scenes: StateFlow<List<Scene>> = sceneRepository val scenes: StateFlow<List<Scene>> = profileStore.observeActiveProfile()
.observeScenes(localProfileId) .flatMapLatest { profile ->
if (profile == null) {
flowOf(DomainResult.Success(emptyList()))
} else {
sceneRepository.observeScenes(profile.id)
}
}
.map { result -> (result as? DomainResult.Success)?.value.orEmpty() } .map { result -> (result as? DomainResult.Success)?.value.orEmpty() }
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -44,7 +47,8 @@ class SceneListViewModel @Inject constructor(
fun deleteScene(sceneId: SceneId) { fun deleteScene(sceneId: SceneId) {
viewModelScope.launch { viewModelScope.launch {
sceneRepository.deleteScene(localProfileId, sceneId) val profile = profileStore.activeProfile() ?: return@launch
sceneRepository.deleteScene(profile.id, sceneId)
_pendingDelete.value = null _pendingDelete.value = null
} }
} }
@@ -57,3 +61,4 @@ class SceneListViewModel @Inject constructor(
_pendingDelete.value = sceneId _pendingDelete.value = sceneId
} }
} }
@@ -1,5 +1,6 @@
package com.flagship.abox.manager.ui.scene package com.flagship.abox.manager.ui.scene
import androidx.compose.runtime.Composable
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -24,12 +25,14 @@ fun NavGraphBuilder.sceneListDestination(
onAddScene: () -> Unit, onAddScene: () -> Unit,
onEditScene: (SceneId) -> Unit, onEditScene: (SceneId) -> Unit,
onExecuteScene: (SceneId) -> Unit, onExecuteScene: (SceneId) -> Unit,
headerContent: @Composable (() -> Unit)? = null,
) { ) {
composable(SceneRoutes.LIST) { composable(SceneRoutes.LIST) {
SceneListScreen( SceneListScreen(
onAddScene = onAddScene, onAddScene = onAddScene,
onEditScene = onEditScene, onEditScene = onEditScene,
onExecuteScene = onExecuteScene, onExecuteScene = onExecuteScene,
headerContent = headerContent,
) )
} }
} }
@@ -0,0 +1,127 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
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 com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SceneEditorViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository()
private val profileId = ProviderProfileId("local-experience")
private val existingScene = Scene(
id = SceneId("local-experience:scene-1"),
profileId = profileId,
name = "Existing Scene",
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("local-experience:action-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
)
private suspend fun setupProfile() {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(existingScene))
}
@Test
fun initializeWithNullPreparesNewScene() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(null)
val state = viewModel.state.value
assertFalse(state.isEditing)
assertEquals("", state.name)
assertTrue(state.actions.isEmpty())
assertFalse(state.isValid)
}
@Test
fun initializeWithExistingSceneLoadsState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(existingScene.id)
advanceUntilIdle()
val state = viewModel.state.value
assertTrue(state.isEditing)
assertEquals("Existing Scene", state.name)
assertEquals(1, state.actions.size)
assertTrue(state.isValid)
}
@Test
fun addingAndReorderingActionsUpdatesState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(null)
viewModel.updateName("My New Scene")
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
viewModel.addInfraredAction("ir-1", "power")
advanceUntilIdle()
assertEquals(2, viewModel.state.value.actions.size)
assertTrue(viewModel.state.value.actions[0] is SceneAction.SocketPower)
assertTrue(viewModel.state.value.actions[1] is SceneAction.InfraredKey)
// Move second to first
viewModel.moveAction(1, 0)
assertTrue(viewModel.state.value.actions[0] is SceneAction.InfraredKey)
assertTrue(viewModel.state.value.actions[1] is SceneAction.SocketPower)
// Remove first action
val firstId = viewModel.state.value.actions[0].id
viewModel.removeAction(firstId)
assertEquals(1, viewModel.state.value.actions.size)
}
@Test
fun saveNewScenePersistsToRepository() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(null)
viewModel.updateName("Brand New Scene")
viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
advanceUntilIdle()
assertTrue(viewModel.state.value.isValid)
viewModel.save()
advanceUntilIdle()
assertTrue(viewModel.state.value.isSaved)
assertFalse(viewModel.state.value.isSaving)
}
}
@@ -0,0 +1,90 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
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 com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SceneExecutionViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository()
private val fakeSocket = FakeSocketGateway()
private val fakeInfrared = FakeInfraredGateway()
private val sceneExecutor = FakeSceneExecutor(fakeSocket, fakeInfrared)
private val profileId = ProviderProfileId("local-experience")
private val testScene = Scene(
id = SceneId("local-experience:scene-1"),
profileId = profileId,
name = "Execution Test Scene",
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("local-experience:action-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
)
private suspend fun setupProfile() {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(testScene))
}
@Test
fun initializeLoadsSceneStateReady() = runTest {
setupProfile()
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.initialize(testScene.id)
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Ready)
val ready = viewModel.uiState.value as SceneExecutionUiState.Ready
assertEquals(testScene.id, ready.scene.id)
}
@Test
fun executeRunsSceneAndTransitionsToCompleted() = runTest {
setupProfile()
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.initialize(testScene.id)
advanceUntilIdle()
viewModel.execute()
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
val completed = viewModel.uiState.value as SceneExecutionUiState.Completed
assertEquals(testScene.id, completed.result.scene.id)
assertEquals(1, completed.result.results.size)
}
}
@@ -0,0 +1,101 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
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 com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SceneListViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository()
private val profileId = ProviderProfileId("local-experience")
private val testScene = Scene(
id = SceneId("local-experience:scene-1"),
profileId = profileId,
name = "Test Scene",
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("local-experience:action-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
)
@Test
fun loadsScenesForActiveProfile() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(testScene))
val viewModel = SceneListViewModel(sceneRepository, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
assertEquals(1, viewModel.scenes.value.size)
assertEquals("Test Scene", viewModel.scenes.value.first().name)
}
@Test
fun deleteSceneRemovesFromRepositoryAndClearsPending() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(testScene))
val viewModel = SceneListViewModel(sceneRepository, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.markPendingDelete(testScene.id)
assertEquals(testScene.id, viewModel.pendingDelete.value)
viewModel.deleteScene(testScene.id)
advanceUntilIdle()
assertNull(viewModel.pendingDelete.value)
assertTrue(viewModel.scenes.value.isEmpty())
}
@Test
fun cancelDeleteClearsPendingState() = runTest {
val viewModel = SceneListViewModel(sceneRepository, profileStore)
viewModel.markPendingDelete(testScene.id)
assertEquals(testScene.id, viewModel.pendingDelete.value)
viewModel.cancelDelete()
assertNull(viewModel.pendingDelete.value)
}
}
@@ -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)
}
}
@@ -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)
}
}
+4 -1
View File
@@ -1,5 +1,8 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8
org.gradle.configuration-cache=false org.gradle.configuration-cache=false
android.useAndroidX=true android.useAndroidX=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
kotlin.code.style=official kotlin.code.style=official
android.overridePathCheck=true