feat(scene): add scene editor, executor and fakes (phase 1) #5
@@ -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
|
||||
|
|
||||
@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.material.icons.Icons
|
||||
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.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
@@ -187,10 +185,10 @@ private fun ActionRow(
|
||||
)
|
||||
}
|
||||
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) {
|
||||
Icon(Icons.Filled.ArrowDownward, contentDescription = null)
|
||||
Text("↓", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
IconButton(onClick = onRemove) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = null)
|
||||
|
||||
+38
-21
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
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.SceneAction
|
||||
import com.flagship.abox.manager.domain.SceneActionId
|
||||
@@ -39,7 +39,7 @@ data class SceneEditorState(
|
||||
@HiltViewModel
|
||||
class SceneEditorViewModel @Inject constructor(
|
||||
private val sceneRepository: SceneRepository,
|
||||
@LocalProfileId private val localProfileId: ProviderProfileId,
|
||||
private val profileStore: ProviderProfileStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(SceneEditorState())
|
||||
@@ -51,7 +51,12 @@ class SceneEditorViewModel @Inject constructor(
|
||||
return
|
||||
}
|
||||
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) {
|
||||
_state.value = SceneEditorState(
|
||||
sceneId = scene.id,
|
||||
|
pr-agent-bot
commented
校验缺失
**校验缺失**
`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) {
|
||||
if (deviceName.isBlank()) return
|
||||
val action = SceneAction.SocketPower(
|
||||
id = SceneActionId(UUID.randomUUID().toString()),
|
||||
profileId = localProfileId,
|
||||
deviceName = DeviceName(deviceName),
|
||||
|
pr-agent-bot
commented
设备硬编码
**设备硬编码**
`observeRemotes` 将红外设备名固定为中文字符串 `红外控制器`。只要当前配置中的实际设备名不同,例如英文名称、自定义名称或默认设备 ID,仓库查询就无法加载该设备的虚拟遥控器,遥控动作选择器会错误显示为空。应从当前配置或设备列表取得真实的 `DeviceName`。
<!-- pr-agent-dedup: 2b0abea93edd -->
<!-- pr-agent-key-issue-location: 10a59afea5f1 -->
|
||||
powerState = powerState,
|
||||
)
|
||||
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
val profile = profileStore.activeProfile() ?: return@launch
|
||||
val action = SceneAction.SocketPower(
|
||||
id = SceneActionId(UUID.randomUUID().toString()),
|
||||
profileId = profile.id,
|
||||
deviceName = DeviceName(deviceName),
|
||||
powerState = powerState,
|
||||
)
|
||||
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
|
||||
}
|
||||
}
|
||||
|
||||
fun addInfraredAction(deviceName: String, key: String) {
|
||||
if (deviceName.isBlank() || key.isBlank()) return
|
||||
val action = SceneAction.InfraredKey(
|
||||
id = SceneActionId(UUID.randomUUID().toString()),
|
||||
profileId = localProfileId,
|
||||
deviceName = DeviceName(deviceName),
|
||||
key = key,
|
||||
)
|
||||
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
val profile = profileStore.activeProfile() ?: return@launch
|
||||
val action = SceneAction.InfraredKey(
|
||||
id = SceneActionId(UUID.randomUUID().toString()),
|
||||
profileId = profile.id,
|
||||
deviceName = DeviceName(deviceName),
|
||||
key = key,
|
||||
)
|
||||
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAction(actionId: SceneActionId) =
|
||||
@@ -109,18 +120,23 @@ class SceneEditorViewModel @Inject constructor(
|
||||
if (!state.isValid || state.isSaving) return
|
||||
_state.update { it.copy(isSaving = true, errorMessage = null) }
|
||||
viewModelScope.launch {
|
||||
val profile = profileStore.activeProfile()
|
||||
if (profile == null) {
|
||||
_state.update { it.copy(isSaving = false, errorMessage = "No active profile") }
|
||||
return@launch
|
||||
}
|
||||
val scene = Scene(
|
||||
id = state.sceneId ?: SceneId("${localProfileId.value}:scene-${UUID.randomUUID()}"),
|
||||
profileId = localProfileId,
|
||||
id = state.sceneId ?: SceneId("${profile.id.value}:scene-${UUID.randomUUID()}"),
|
||||
profileId = profile.id,
|
||||
name = state.name.trim(),
|
||||
icon = state.icon,
|
||||
color = SceneColor(state.colorHex),
|
||||
actions = state.actions,
|
||||
)
|
||||
val result = if (state.isEditing) {
|
||||
sceneRepository.updateScene(localProfileId, scene)
|
||||
sceneRepository.updateScene(profile.id, scene)
|
||||
} else {
|
||||
sceneRepository.createScene(localProfileId, scene)
|
||||
sceneRepository.createScene(profile.id, scene)
|
||||
}
|
||||
_state.update { current ->
|
||||
when (result) {
|
||||
@@ -134,3 +150,4 @@ class SceneEditorViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -13,8 +13,8 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -186,9 +186,9 @@ private fun ActionResultRow(entry: SceneActionExecutionResult) {
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) {
|
||||
Icons.Filled.CheckCircle
|
||||
Icons.Filled.Check
|
||||
} else {
|
||||
Icons.Filled.Error
|
||||
Icons.Filled.Close
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = if (entry.outcome == SceneActionOutcome.SUCCESS) {
|
||||
|
||||
+9
-3
@@ -3,7 +3,7 @@ package com.flagship.abox.manager.ui.scene
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.SceneExecutionResult
|
||||
import com.flagship.abox.manager.domain.SceneExecutor
|
||||
@@ -28,7 +28,7 @@ sealed interface SceneExecutionUiState {
|
||||
class SceneExecutionViewModel @Inject constructor(
|
||||
private val sceneRepository: SceneRepository,
|
||||
private val sceneExecutor: SceneExecutor,
|
||||
@LocalProfileId private val localProfileId: ProviderProfileId,
|
||||
private val profileStore: ProviderProfileStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow<SceneExecutionUiState>(SceneExecutionUiState.Loading)
|
||||
@@ -37,7 +37,12 @@ class SceneExecutionViewModel @Inject constructor(
|
||||
fun initialize(sceneId: SceneId) {
|
||||
|
pr-agent-bot
commented
执行状态丢失 对同一场景重复调用 **执行状态丢失**
对同一场景重复调用 `initialize` 会把 `Running` 或 `Completed` 无条件重置为 `Loading`,随后变为 `Ready`。配置变更会重新触发界面初始化,因此已完成的结果会消失;执行过程中旋转设备还会重新显示执行按钮,而原协程仍在运行。应让初始化对同一场景幂等,并保留进行中及完成状态。
<!-- pr-agent-dedup: b97d8304ffda -->
<!-- pr-agent-key-issue-location: 666775487b17 -->
|
||||
_uiState.value = SceneExecutionUiState.Loading
|
||||
viewModelScope.launch {
|
||||
val scene = (sceneRepository.getScene(localProfileId, sceneId) as? DomainResult.Success)?.value
|
||||
|
pr-agent-bot
commented
跨配置执行 活动配置文件仅在初始化时读取,加载后的场景会一直保存在 **跨配置执行**
活动配置文件仅在初始化时读取,加载后的场景会一直保存在 `Ready` 状态。若用户停留在执行页时切换提供方,随后仍会使用旧场景的 `profileId` 向网关发送命令。应监听配置文件切换并使当前场景失效,或在执行前重新校验活动配置文件与 `scene.profileId` 一致。
<!-- pr-agent-dedup: 353f16aeadc1 -->
<!-- pr-agent-key-issue-location: 4c701012d709 -->
|
||||
val profile = profileStore.activeProfile()
|
||||
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) {
|
||||
SceneExecutionUiState.Ready(scene)
|
||||
} 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.filled.Add
|
||||
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.Card
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
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.SceneId
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SceneListScreen(
|
||||
onAddScene: () -> Unit,
|
||||
onEditScene: (SceneId) -> Unit,
|
||||
onExecuteScene: (SceneId) -> Unit,
|
||||
headerContent: @Composable (() -> Unit)? = null,
|
||||
viewModel: SceneListViewModel = hiltViewModel(),
|
||||
) {
|
||||
val scenes by viewModel.scenes.collectAsStateWithLifecycle()
|
||||
@@ -68,19 +71,26 @@ fun SceneListScreen(
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
if (scenes.isEmpty()) {
|
||||
EmptySceneList(modifier = Modifier.padding(innerPadding))
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = innerPadding.calculateTopPadding(),
|
||||
bottom = innerPadding.calculateBottomPadding() + 80.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = innerPadding.calculateTopPadding(),
|
||||
bottom = innerPadding.calculateBottomPadding() + 80.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (headerContent != null) {
|
||||
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 ->
|
||||
SceneRow(
|
||||
scene = scene,
|
||||
@@ -151,7 +161,7 @@ private fun SceneRow(
|
||||
}
|
||||
IconButton(onClick = onEdit) {
|
||||
Icon(
|
||||
Icons.Filled.PlayArrow,
|
||||
Icons.Filled.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? {
|
||||
val hex = value.removePrefix("#")
|
||||
|
pr-agent-bot
commented
编译错误
**编译错误**
`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()
|
||||
val cleanHex = hex.removePrefix("#")
|
||||
return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull()
|
||||
}
|
||||
|
||||
|
||||
+17
-12
@@ -3,35 +3,38 @@ package com.flagship.abox.manager.ui.scene
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
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.SceneId
|
||||
import com.flagship.abox.manager.domain.SceneRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class SceneListViewModel @Inject constructor(
|
||||
private val sceneRepository: SceneRepository,
|
||||
@LocalProfileId private val localProfileId: ProviderProfileId,
|
||||
private val profileStore: ProviderProfileStore,
|
||||
) : ViewModel() {
|
||||
|
||||
val scenes: StateFlow<List<Scene>> = sceneRepository
|
||||
.observeScenes(localProfileId)
|
||||
val scenes: StateFlow<List<Scene>> = profileStore.observeActiveProfile()
|
||||
.flatMapLatest { profile ->
|
||||
if (profile == null) {
|
||||
flowOf(DomainResult.Success(emptyList()))
|
||||
} else {
|
||||
sceneRepository.observeScenes(profile.id)
|
||||
}
|
||||
}
|
||||
.map { result -> (result as? DomainResult.Success)?.value.orEmpty() }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
@@ -44,7 +47,8 @@ class SceneListViewModel @Inject constructor(
|
||||
|
||||
fun deleteScene(sceneId: SceneId) {
|
||||
viewModelScope.launch {
|
||||
sceneRepository.deleteScene(localProfileId, sceneId)
|
||||
val profile = profileStore.activeProfile() ?: return@launch
|
||||
sceneRepository.deleteScene(profile.id, sceneId)
|
||||
_pendingDelete.value = null
|
||||
}
|
||||
}
|
||||
@@ -57,3 +61,4 @@ class SceneListViewModel @Inject constructor(
|
||||
_pendingDelete.value = sceneId
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.flagship.abox.manager.ui.scene
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.composable
|
||||
@@ -24,12 +25,14 @@ fun NavGraphBuilder.sceneListDestination(
|
||||
onAddScene: () -> Unit,
|
||||
onEditScene: (SceneId) -> Unit,
|
||||
onExecuteScene: (SceneId) -> Unit,
|
||||
headerContent: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
composable(SceneRoutes.LIST) {
|
||||
SceneListScreen(
|
||||
onAddScene = onAddScene,
|
||||
onEditScene = onEditScene,
|
||||
onExecuteScene = onExecuteScene,
|
||||
headerContent = headerContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+127
@@ -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)
|
||||
}
|
||||
}
|
||||
+90
@@ -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)
|
||||
}
|
||||
}
|
||||
+101
@@ -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)
|
||||
}
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
android.overridePathCheck=true
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user
发布构建
SceneRepository、SceneExecutor和带LocalProfileId限定符的ProviderProfileId仅在 debug 源集中提供,但依赖它们的 ViewModel 位于 main 源集。当前没有 release 绑定,执行 release 变体的 Hilt 组件生成时会出现 MissingBinding;需要提供 release 实现或将相关消费者限制在 debug 变体。