From df46d6273d250cc5dcf01840ff4705b0426163d4 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Tue, 25 Aug 2026 11:22:06 +0800 Subject: [PATCH] feat(scene): integrate scene domain, viewmodels, screens and tests --- .../flagship/abox/manager/di/SceneModule.kt | 41 ----- .../abox/manager/di/SceneIntegrationModule.kt | 31 ++++ .../abox/manager/ui/scene/LocalProfileId.kt | 14 -- .../manager/ui/scene/SceneEditorScreen.kt | 6 +- .../manager/ui/scene/SceneEditorViewModel.kt | 59 ++++--- .../manager/ui/scene/SceneExecutionScreen.kt | 8 +- .../ui/scene/SceneExecutionViewModel.kt | 12 +- .../abox/manager/ui/scene/SceneListScreen.kt | 45 ++++-- .../manager/ui/scene/SceneListViewModel.kt | 29 ++-- .../abox/manager/ui/scene/SceneNavigation.kt | 3 + .../ui/scene/SceneEditorViewModelTest.kt | 127 +++++++++++++++ .../ui/scene/SceneExecutionViewModelTest.kt | 90 +++++++++++ .../ui/scene/SceneListViewModelTest.kt | 101 ++++++++++++ .../manager/domain/DefaultSceneExecutor.kt | 69 ++++++++ .../manager/domain/InMemorySceneRepository.kt | 137 ++++++++++++++++ .../domain/DefaultSceneExecutorTest.kt | 152 ++++++++++++++++++ .../domain/InMemorySceneRepositoryTest.kt | 86 ++++++++++ gradle.properties | 5 +- 18 files changed, 898 insertions(+), 117 deletions(-) delete mode 100644 android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt delete mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModelTest.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepositoryTest.kt diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt deleted file mode 100644 index fb2e90f..0000000 --- a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt +++ /dev/null @@ -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 -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt new file mode 100644 index 0000000..ed1aadb --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt @@ -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) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt deleted file mode 100644 index 44d1730..0000000 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt +++ /dev/null @@ -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 diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index ee4e407..c31cfc3 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -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) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt index 5bf3b6b..4a4f695 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt @@ -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( } } } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt index 9630525..654a0bf 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt @@ -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) { diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt index b577d6b..f2acfac 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt @@ -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.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( } } } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt index 5b17696..0b9725a 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -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() } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt index 1e47bc7..bd62a57 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt @@ -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> = sceneRepository - .observeScenes(localProfileId) + val scenes: StateFlow> = 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 } } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt index d335c4e..23cf62e 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt @@ -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, ) } } diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt new file mode 100644 index 0000000..6e90358 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt @@ -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) + } +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModelTest.kt new file mode 100644 index 0000000..02eb348 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModelTest.kt @@ -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) + } +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt new file mode 100644 index 0000000..9fcf7e4 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt @@ -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) + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt new file mode 100644 index 0000000..4105b90 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt @@ -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() + + override suspend fun execute(scene: Scene): DomainResult { + 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, + ) + } + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt new file mode 100644 index 0000000..2df7cca --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt @@ -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>>>() + + override fun observeScenes(profileId: ProviderProfileId): Flow>> = + flowFor(profileId) + + override suspend fun getScene( + profileId: ProviderProfileId, + sceneId: SceneId, + ): DomainResult { + val scenes = currentScenes(profileId) ?: return notFound(sceneId) + return DomainResult.Success(scenes.firstOrNull { it.id == sceneId }) + } + + override suspend fun createScene( + profileId: ProviderProfileId, + scene: Scene, + ): DomainResult { + 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 { + 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 { + 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) { + require(scenes.all { it.profileId == profileId }) { + "All scenes must belong to the supplied profileId" + } + publish(profileId, scenes) + } + + private fun flowFor(profileId: ProviderProfileId): MutableStateFlow>> = + flows.getOrPut(profileId) { + val initial = if (profileId == LOCAL_EXPERIENCE_PROFILE) defaultLocalScenes(profileId) else emptyList() + MutableStateFlow(DomainResult.Success(initial)) + } + + private fun currentScenes(profileId: ProviderProfileId): List? = + (flowFor(profileId).value as? DomainResult.Success)?.value + + private fun publish(profileId: ProviderProfileId, scenes: List) { + flowFor(profileId).value = DomainResult.Success(scenes) + } + + private fun notFound(sceneId: SceneId): DomainResult = DomainResult.Failure( + DomainError.InvalidRequest(detail = "Scene ${sceneId.value} not found"), + ) + + private fun defaultLocalScenes(profileId: ProviderProfileId): List = 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") + } +} diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt new file mode 100644 index 0000000..907060c --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt @@ -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 = DomainResult.Success( + SocketStatus( + profileId = ProviderProfileId("local-experience"), + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + ), + ), + ) : SocketGateway { + val calls = mutableListOf>() + + override suspend fun getStatus( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = response + + override suspend fun setPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + calls.add(Triple(profileId, deviceName, powerState)) + return response + } + } + + private class TestInfraredGateway( + var response: DomainResult = DomainResult.Success(Unit), + ) : InfraredGateway { + val calls = mutableListOf>() + + override suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult { + calls.add(Triple(profileId, deviceName, key)) + return response + } + + override suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = DomainResult.Success(Unit) + + override suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> = 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) + } +} diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepositoryTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepositoryTest.kt new file mode 100644 index 0000000..9ffda08 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepositoryTest.kt @@ -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) + } +} diff --git a/gradle.properties b/gradle.properties index badfccb..ce877bf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 + +