feat(scene): integrate scene domain, viewmodels, screens and tests

This commit is contained in:
2026-08-25 11:26:07 +08:00
parent 533ce46de6
commit df46d6273d
18 changed files with 898 additions and 117 deletions
@@ -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)
@@ -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,
@@ -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),
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(
}
}
}
@@ -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) {
@@ -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) {
_uiState.value = SceneExecutionUiState.Loading
viewModelScope.launch {
val scene = (sceneRepository.getScene(localProfileId, sceneId) as? DomainResult.Success)?.value
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("#")
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()
}
@@ -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,
)
}
}
@@ -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)
}
}