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.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -187,10 +185,10 @@ private fun ActionRow(
) )
} }
IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) { IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) {
Icon(Icons.Filled.ArrowUpward, contentDescription = null) Text("↑", style = MaterialTheme.typography.titleMedium)
} }
IconButton(onClick = { onMoveDown(index) }, enabled = index < total - 1) { IconButton(onClick = { onMoveDown(index) }, enabled = index < total - 1) {
Icon(Icons.Filled.ArrowDownward, contentDescription = null) Text("↓", style = MaterialTheme.typography.titleMedium)
} }
IconButton(onClick = onRemove) { IconButton(onClick = onRemove) {
Icon(Icons.Filled.Delete, contentDescription = null) Icon(Icons.Filled.Delete, contentDescription = null)
@@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId import com.flagship.abox.manager.domain.SceneActionId
@@ -39,7 +39,7 @@ data class SceneEditorState(
@HiltViewModel @HiltViewModel
class SceneEditorViewModel @Inject constructor( class SceneEditorViewModel @Inject constructor(
private val sceneRepository: SceneRepository, private val sceneRepository: SceneRepository,
@LocalProfileId private val localProfileId: ProviderProfileId, private val profileStore: ProviderProfileStore,
) : ViewModel() { ) : ViewModel() {
private val _state = MutableStateFlow(SceneEditorState()) private val _state = MutableStateFlow(SceneEditorState())
@@ -51,7 +51,12 @@ class SceneEditorViewModel @Inject constructor(
return return
} }
viewModelScope.launch { viewModelScope.launch {
val scene = (sceneRepository.getScene(localProfileId, sceneId) as? DomainResult.Success)?.value val profile = profileStore.activeProfile()
if (profile == null) {
_state.update { it.copy(errorMessage = "No active profile") }
return@launch
}
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
if (scene != null) { if (scene != null) {
_state.value = SceneEditorState( _state.value = SceneEditorState(
sceneId = scene.id, sceneId = scene.id,
@@ -74,24 +79,30 @@ class SceneEditorViewModel @Inject constructor(
fun addSocketAction(deviceName: String, powerState: SocketPowerState) { fun addSocketAction(deviceName: String, powerState: SocketPowerState) {
if (deviceName.isBlank()) return if (deviceName.isBlank()) return
val action = SceneAction.SocketPower( viewModelScope.launch {
id = SceneActionId(UUID.randomUUID().toString()), val profile = profileStore.activeProfile() ?: return@launch
profileId = localProfileId, val action = SceneAction.SocketPower(
deviceName = DeviceName(deviceName), id = SceneActionId(UUID.randomUUID().toString()),
powerState = powerState, profileId = profile.id,
) deviceName = DeviceName(deviceName),
_state.update { it.copy(actions = it.actions + action, errorMessage = null) } powerState = powerState,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
} }
fun addInfraredAction(deviceName: String, key: String) { fun addInfraredAction(deviceName: String, key: String) {
if (deviceName.isBlank() || key.isBlank()) return if (deviceName.isBlank() || key.isBlank()) return
val action = SceneAction.InfraredKey( viewModelScope.launch {
id = SceneActionId(UUID.randomUUID().toString()), val profile = profileStore.activeProfile() ?: return@launch
profileId = localProfileId, val action = SceneAction.InfraredKey(
deviceName = DeviceName(deviceName), id = SceneActionId(UUID.randomUUID().toString()),
key = key, profileId = profile.id,
) deviceName = DeviceName(deviceName),
_state.update { it.copy(actions = it.actions + action, errorMessage = null) } key = key,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
} }
fun removeAction(actionId: SceneActionId) = fun removeAction(actionId: SceneActionId) =
@@ -109,18 +120,23 @@ class SceneEditorViewModel @Inject constructor(
if (!state.isValid || state.isSaving) return if (!state.isValid || state.isSaving) return
_state.update { it.copy(isSaving = true, errorMessage = null) } _state.update { it.copy(isSaving = true, errorMessage = null) }
viewModelScope.launch { viewModelScope.launch {
val profile = profileStore.activeProfile()
if (profile == null) {
_state.update { it.copy(isSaving = false, errorMessage = "No active profile") }
return@launch
}
val scene = Scene( val scene = Scene(
id = state.sceneId ?: SceneId("${localProfileId.value}:scene-${UUID.randomUUID()}"), id = state.sceneId ?: SceneId("${profile.id.value}:scene-${UUID.randomUUID()}"),
profileId = localProfileId, profileId = profile.id,
name = state.name.trim(), name = state.name.trim(),
icon = state.icon, icon = state.icon,
color = SceneColor(state.colorHex), color = SceneColor(state.colorHex),
actions = state.actions, actions = state.actions,
) )
val result = if (state.isEditing) { val result = if (state.isEditing) {
sceneRepository.updateScene(localProfileId, scene) sceneRepository.updateScene(profile.id, scene)
} else { } else {
sceneRepository.createScene(localProfileId, scene) sceneRepository.createScene(profile.id, scene)
} }
_state.update { current -> _state.update { current ->
when (result) { when (result) {
@@ -134,3 +150,4 @@ class SceneEditorViewModel @Inject constructor(
} }
} }
} }
@@ -13,8 +13,8 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Error import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@@ -186,9 +186,9 @@ private fun ActionResultRow(entry: SceneActionExecutionResult) {
) { ) {
Icon( Icon(
imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) { imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) {
Icons.Filled.CheckCircle Icons.Filled.Check
} else { } else {
Icons.Filled.Error Icons.Filled.Close
}, },
contentDescription = null, contentDescription = null,
tint = if (entry.outcome == SceneActionOutcome.SUCCESS) { tint = if (entry.outcome == SceneActionOutcome.SUCCESS) {
@@ -3,7 +3,7 @@ package com.flagship.abox.manager.ui.scene
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneExecutionResult import com.flagship.abox.manager.domain.SceneExecutionResult
import com.flagship.abox.manager.domain.SceneExecutor import com.flagship.abox.manager.domain.SceneExecutor
@@ -28,7 +28,7 @@ sealed interface SceneExecutionUiState {
class SceneExecutionViewModel @Inject constructor( class SceneExecutionViewModel @Inject constructor(
private val sceneRepository: SceneRepository, private val sceneRepository: SceneRepository,
private val sceneExecutor: SceneExecutor, private val sceneExecutor: SceneExecutor,
@LocalProfileId private val localProfileId: ProviderProfileId, private val profileStore: ProviderProfileStore,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow<SceneExecutionUiState>(SceneExecutionUiState.Loading) private val _uiState = MutableStateFlow<SceneExecutionUiState>(SceneExecutionUiState.Loading)
@@ -37,7 +37,12 @@ class SceneExecutionViewModel @Inject constructor(
fun initialize(sceneId: SceneId) { fun initialize(sceneId: SceneId) {
_uiState.value = SceneExecutionUiState.Loading _uiState.value = SceneExecutionUiState.Loading
viewModelScope.launch { viewModelScope.launch {
val scene = (sceneRepository.getScene(localProfileId, sceneId) as? DomainResult.Success)?.value val profile = profileStore.activeProfile()
if (profile == null) {
_uiState.value = SceneExecutionUiState.Failed("No active profile")
return@launch
}
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
_uiState.value = if (scene != null) { _uiState.value = if (scene != null) {
SceneExecutionUiState.Ready(scene) SceneExecutionUiState.Ready(scene)
} else { } else {
@@ -62,3 +67,4 @@ class SceneExecutionViewModel @Inject constructor(
} }
} }
} }
@@ -19,9 +19,10 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -44,11 +45,13 @@ import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneId
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SceneListScreen( fun SceneListScreen(
onAddScene: () -> Unit, onAddScene: () -> Unit,
onEditScene: (SceneId) -> Unit, onEditScene: (SceneId) -> Unit,
onExecuteScene: (SceneId) -> Unit, onExecuteScene: (SceneId) -> Unit,
headerContent: @Composable (() -> Unit)? = null,
viewModel: SceneListViewModel = hiltViewModel(), viewModel: SceneListViewModel = hiltViewModel(),
) { ) {
val scenes by viewModel.scenes.collectAsStateWithLifecycle() val scenes by viewModel.scenes.collectAsStateWithLifecycle()
@@ -68,19 +71,26 @@ fun SceneListScreen(
) )
}, },
) { innerPadding -> ) { innerPadding ->
if (scenes.isEmpty()) { LazyColumn(
EmptySceneList(modifier = Modifier.padding(innerPadding)) modifier = Modifier.fillMaxSize(),
} else { contentPadding = PaddingValues(
LazyColumn( start = 16.dp,
modifier = Modifier.fillMaxSize(), end = 16.dp,
contentPadding = PaddingValues( top = innerPadding.calculateTopPadding(),
start = 16.dp, bottom = innerPadding.calculateBottomPadding() + 80.dp,
end = 16.dp, ),
top = innerPadding.calculateTopPadding(), verticalArrangement = Arrangement.spacedBy(12.dp),
bottom = innerPadding.calculateBottomPadding() + 80.dp, ) {
), if (headerContent != null) {
verticalArrangement = Arrangement.spacedBy(12.dp), item(key = "header") {
) { headerContent()
}
}
if (scenes.isEmpty()) {
item(key = "empty") {
EmptySceneList(modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp))
}
} else {
items(scenes, key = { it.id.value }) { scene -> items(scenes, key = { it.id.value }) { scene ->
SceneRow( SceneRow(
scene = scene, scene = scene,
@@ -151,7 +161,7 @@ private fun SceneRow(
} }
IconButton(onClick = onEdit) { IconButton(onClick = onEdit) {
Icon( Icon(
Icons.Filled.PlayArrow, Icons.Filled.Edit,
contentDescription = stringResource(R.string.scene_edit), contentDescription = stringResource(R.string.scene_edit),
) )
} }
@@ -190,6 +200,7 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
} }
internal fun com.flagship.abox.manager.domain.SceneColor.toColorOrNull(): Color? { internal fun com.flagship.abox.manager.domain.SceneColor.toColorOrNull(): Color? {
val hex = value.removePrefix("#") val cleanHex = hex.removePrefix("#")
return runCatching { Color(hex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull() return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull()
} }
@@ -3,35 +3,38 @@ package com.flagship.abox.manager.ui.scene
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository import com.flagship.abox.manager.domain.SceneRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** @OptIn(ExperimentalCoroutinesApi::class)
* Phase 1 scene list state. The repository is observed for the local-experience profile so the
* UI updates when scenes are added, edited or deleted from any screen.
*
* Phase 2 will replace the hard-coded [LocalProfileId] injection with the active session observed
* from SessionGateway (see project_plan.md §6.5 and §7).
*/
@HiltViewModel @HiltViewModel
class SceneListViewModel @Inject constructor( class SceneListViewModel @Inject constructor(
private val sceneRepository: SceneRepository, private val sceneRepository: SceneRepository,
@LocalProfileId private val localProfileId: ProviderProfileId, private val profileStore: ProviderProfileStore,
) : ViewModel() { ) : ViewModel() {
val scenes: StateFlow<List<Scene>> = sceneRepository val scenes: StateFlow<List<Scene>> = profileStore.observeActiveProfile()
.observeScenes(localProfileId) .flatMapLatest { profile ->
if (profile == null) {
flowOf(DomainResult.Success(emptyList()))
} else {
sceneRepository.observeScenes(profile.id)
}
}
.map { result -> (result as? DomainResult.Success)?.value.orEmpty() } .map { result -> (result as? DomainResult.Success)?.value.orEmpty() }
.stateIn( .stateIn(
scope = viewModelScope, scope = viewModelScope,
@@ -44,7 +47,8 @@ class SceneListViewModel @Inject constructor(
fun deleteScene(sceneId: SceneId) { fun deleteScene(sceneId: SceneId) {
viewModelScope.launch { viewModelScope.launch {
sceneRepository.deleteScene(localProfileId, sceneId) val profile = profileStore.activeProfile() ?: return@launch
sceneRepository.deleteScene(profile.id, sceneId)
_pendingDelete.value = null _pendingDelete.value = null
} }
} }
@@ -57,3 +61,4 @@ class SceneListViewModel @Inject constructor(
_pendingDelete.value = sceneId _pendingDelete.value = sceneId
} }
} }
@@ -1,5 +1,6 @@
package com.flagship.abox.manager.ui.scene package com.flagship.abox.manager.ui.scene
import androidx.compose.runtime.Composable
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType import androidx.navigation.NavType
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -24,12 +25,14 @@ fun NavGraphBuilder.sceneListDestination(
onAddScene: () -> Unit, onAddScene: () -> Unit,
onEditScene: (SceneId) -> Unit, onEditScene: (SceneId) -> Unit,
onExecuteScene: (SceneId) -> Unit, onExecuteScene: (SceneId) -> Unit,
headerContent: @Composable (() -> Unit)? = null,
) { ) {
composable(SceneRoutes.LIST) { composable(SceneRoutes.LIST) {
SceneListScreen( SceneListScreen(
onAddScene = onAddScene, onAddScene = onAddScene,
onEditScene = onEditScene, onEditScene = onEditScene,
onExecuteScene = onExecuteScene, onExecuteScene = onExecuteScene,
headerContent = headerContent,
) )
} }
} }
@@ -0,0 +1,127 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SceneEditorViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository()
private val profileId = ProviderProfileId("local-experience")
private val existingScene = Scene(
id = SceneId("local-experience:scene-1"),
profileId = profileId,
name = "Existing Scene",
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("local-experience:action-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
)
private suspend fun setupProfile() {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(existingScene))
}
@Test
fun initializeWithNullPreparesNewScene() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(null)
val state = viewModel.state.value
assertFalse(state.isEditing)
assertEquals("", state.name)
assertTrue(state.actions.isEmpty())
assertFalse(state.isValid)
}
@Test
fun initializeWithExistingSceneLoadsState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(existingScene.id)
advanceUntilIdle()
val state = viewModel.state.value
assertTrue(state.isEditing)
assertEquals("Existing Scene", state.name)
assertEquals(1, state.actions.size)
assertTrue(state.isValid)
}
@Test
fun addingAndReorderingActionsUpdatesState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(null)
viewModel.updateName("My New Scene")
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
viewModel.addInfraredAction("ir-1", "power")
advanceUntilIdle()
assertEquals(2, viewModel.state.value.actions.size)
assertTrue(viewModel.state.value.actions[0] is SceneAction.SocketPower)
assertTrue(viewModel.state.value.actions[1] is SceneAction.InfraredKey)
// Move second to first
viewModel.moveAction(1, 0)
assertTrue(viewModel.state.value.actions[0] is SceneAction.InfraredKey)
assertTrue(viewModel.state.value.actions[1] is SceneAction.SocketPower)
// Remove first action
val firstId = viewModel.state.value.actions[0].id
viewModel.removeAction(firstId)
assertEquals(1, viewModel.state.value.actions.size)
}
@Test
fun saveNewScenePersistsToRepository() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(null)
viewModel.updateName("Brand New Scene")
viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
advanceUntilIdle()
assertTrue(viewModel.state.value.isValid)
viewModel.save()
advanceUntilIdle()
assertTrue(viewModel.state.value.isSaved)
assertFalse(viewModel.state.value.isSaving)
}
}
@@ -0,0 +1,90 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SceneExecutionViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository()
private val fakeSocket = FakeSocketGateway()
private val fakeInfrared = FakeInfraredGateway()
private val sceneExecutor = FakeSceneExecutor(fakeSocket, fakeInfrared)
private val profileId = ProviderProfileId("local-experience")
private val testScene = Scene(
id = SceneId("local-experience:scene-1"),
profileId = profileId,
name = "Execution Test Scene",
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("local-experience:action-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
)
private suspend fun setupProfile() {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(testScene))
}
@Test
fun initializeLoadsSceneStateReady() = runTest {
setupProfile()
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.initialize(testScene.id)
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Ready)
val ready = viewModel.uiState.value as SceneExecutionUiState.Ready
assertEquals(testScene.id, ready.scene.id)
}
@Test
fun executeRunsSceneAndTransitionsToCompleted() = runTest {
setupProfile()
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.initialize(testScene.id)
advanceUntilIdle()
viewModel.execute()
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
val completed = viewModel.uiState.value as SceneExecutionUiState.Completed
assertEquals(testScene.id, completed.result.scene.id)
assertEquals(1, completed.result.results.size)
}
}
@@ -0,0 +1,101 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SceneListViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository()
private val profileId = ProviderProfileId("local-experience")
private val testScene = Scene(
id = SceneId("local-experience:scene-1"),
profileId = profileId,
name = "Test Scene",
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("local-experience:action-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
)
@Test
fun loadsScenesForActiveProfile() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(testScene))
val viewModel = SceneListViewModel(sceneRepository, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
assertEquals(1, viewModel.scenes.value.size)
assertEquals("Test Scene", viewModel.scenes.value.first().name)
}
@Test
fun deleteSceneRemovesFromRepositoryAndClearsPending() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(testScene))
val viewModel = SceneListViewModel(sceneRepository, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.markPendingDelete(testScene.id)
assertEquals(testScene.id, viewModel.pendingDelete.value)
viewModel.deleteScene(testScene.id)
advanceUntilIdle()
assertNull(viewModel.pendingDelete.value)
assertTrue(viewModel.scenes.value.isEmpty())
}
@Test
fun cancelDeleteClearsPendingState() = runTest {
val viewModel = SceneListViewModel(sceneRepository, profileStore)
viewModel.markPendingDelete(testScene.id)
assertEquals(testScene.id, viewModel.pendingDelete.value)
viewModel.cancelDelete()
assertNull(viewModel.pendingDelete.value)
}
}
@@ -0,0 +1,69 @@
package com.flagship.abox.manager.domain
import java.util.concurrent.ConcurrentHashMap
/**
* Production implementation of [SceneExecutor].
*
* Executes scene actions sequentially via [SocketGateway] and [InfraredGateway].
* Guarantees:
* 1. Actions are run sequentially in the order of [Scene.actions].
* 2. Individual action failures do not abort remaining actions; all actions run to completion.
* 3. Results are aggregated into a [SceneExecutionResult].
* 4. Re-entrant / concurrent execution of the same scene is rejected with [DomainError.OperationFailed].
*/
class DefaultSceneExecutor(
private val socketGateway: SocketGateway,
private val infraredGateway: InfraredGateway,
) : SceneExecutor {
private val runningScenes = ConcurrentHashMap<SceneId, Boolean>()
override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> {
val alreadyRunning = runningScenes.putIfAbsent(scene.id, true) != null
if (alreadyRunning) {
return DomainResult.Failure(
DomainError.OperationFailed(
detail = "Scene ${scene.id.value} is already running",
),
)
}
return try {
val results = scene.actions.map { action -> executeAction(scene.profileId, action) }
DomainResult.Success(SceneExecutionResult(scene = scene, results = results))
} finally {
runningScenes.remove(scene.id)
}
}
private suspend fun executeAction(
profileId: ProviderProfileId,
action: SceneAction,
): SceneActionExecutionResult {
val result = when (action) {
is SceneAction.SocketPower -> socketGateway.setPower(
profileId = profileId,
deviceName = action.deviceName,
powerState = action.powerState,
)
is SceneAction.InfraredKey -> infraredGateway.sendKey(
profileId = profileId,
deviceName = action.deviceName,
key = action.key,
)
}
return when (result) {
is DomainResult.Success -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.SUCCESS,
error = null,
)
is DomainResult.Failure -> SceneActionExecutionResult(
action = action,
outcome = SceneActionOutcome.FAILURE,
error = result.error,
)
}
}
}
@@ -0,0 +1,137 @@
package com.flagship.abox.manager.domain
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
/**
* Provider-isolated in-memory [SceneRepository] implementation that supports
* seed defaults and real-time observation.
*/
class InMemorySceneRepository : SceneRepository {
private val flows = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<DomainResult<List<Scene>>>>()
override fun observeScenes(profileId: ProviderProfileId): Flow<DomainResult<List<Scene>>> =
flowFor(profileId)
override suspend fun getScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Scene?> {
val scenes = currentScenes(profileId) ?: return notFound(sceneId)
return DomainResult.Success(scenes.firstOrNull { it.id == sceneId })
}
override suspend fun createScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene> {
require(scene.profileId == profileId) {
"Scene profileId must match the supplied profileId"
}
val scenes = currentScenes(profileId).orEmpty()
if (scenes.any { it.id == scene.id }) {
return DomainResult.Failure(
DomainError.InvalidRequest(
detail = "Scene id ${scene.id.value} already exists in profile ${profileId.value}",
),
)
}
val updated = scenes + scene
publish(profileId, updated)
return DomainResult.Success(scene)
}
override suspend fun updateScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene> {
require(scene.profileId == profileId) {
"Scene profileId must match the supplied profileId"
}
val scenes = currentScenes(profileId).orEmpty()
val index = scenes.indexOfFirst { it.id == scene.id }
if (index < 0) return notFound(scene.id)
val updated = scenes.toMutableList().apply { set(index, scene) }
publish(profileId, updated)
return DomainResult.Success(scene)
}
override suspend fun deleteScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Unit> {
val scenes = currentScenes(profileId).orEmpty()
val updated = scenes.filterNot { it.id == sceneId }
if (updated.size == scenes.size) return notFound(sceneId)
publish(profileId, updated)
return DomainResult.Success(Unit)
}
fun seedDefaults(profileId: ProviderProfileId, scenes: List<Scene>) {
require(scenes.all { it.profileId == profileId }) {
"All scenes must belong to the supplied profileId"
}
publish(profileId, scenes)
}
private fun flowFor(profileId: ProviderProfileId): MutableStateFlow<DomainResult<List<Scene>>> =
flows.getOrPut(profileId) {
val initial = if (profileId == LOCAL_EXPERIENCE_PROFILE) defaultLocalScenes(profileId) else emptyList()
MutableStateFlow(DomainResult.Success(initial))
}
private fun currentScenes(profileId: ProviderProfileId): List<Scene>? =
(flowFor(profileId).value as? DomainResult.Success)?.value
private fun publish(profileId: ProviderProfileId, scenes: List<Scene>) {
flowFor(profileId).value = DomainResult.Success(scenes)
}
private fun notFound(sceneId: SceneId): DomainResult<Nothing> = DomainResult.Failure(
DomainError.InvalidRequest(detail = "Scene ${sceneId.value} not found"),
)
private fun defaultLocalScenes(profileId: ProviderProfileId): List<Scene> = listOf(
Scene(
id = SceneId("${profileId.value}:scene-leaving-home"),
profileId = profileId,
name = "Leaving home",
icon = SceneIcon.AWAY,
color = SceneColor("#FF8A65"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
),
SceneAction.InfraredKey(
id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "power",
),
),
),
Scene(
id = SceneId("${profileId.value}:scene-movie-night"),
profileId = profileId,
name = "Movie night",
icon = SceneIcon.MOVIE,
color = SceneColor("#7986CB"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-movie-night:socket-1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
),
),
),
)
companion object {
val LOCAL_EXPERIENCE_PROFILE = ProviderProfileId("local-experience")
}
}
@@ -0,0 +1,152 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultSceneExecutorTest {
private val profileId = ProviderProfileId("local-experience")
private class TestSocketGateway(
var response: DomainResult<SocketStatus> = DomainResult.Success(
SocketStatus(
profileId = ProviderProfileId("local-experience"),
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
),
),
) : SocketGateway {
val calls = mutableListOf<Triple<ProviderProfileId, DeviceName, SocketPowerState>>()
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> = response
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> {
calls.add(Triple(profileId, deviceName, powerState))
return response
}
}
private class TestInfraredGateway(
var response: DomainResult<Unit> = DomainResult.Success(Unit),
) : InfraredGateway {
val calls = mutableListOf<Triple<ProviderProfileId, DeviceName, String>>()
override suspend fun sendKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> {
calls.add(Triple(profileId, deviceName, key))
return response
}
override suspend fun learnKey(
profileId: ProviderProfileId,
deviceName: DeviceName,
key: String,
): DomainResult<Unit> = DomainResult.Success(Unit)
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = DomainResult.Success(emptyList())
}
@Test
fun executesActionsSequentiallyAndAggregatesResults() = runTest {
val socketGateway = TestSocketGateway()
val infraredGateway = TestInfraredGateway()
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
)
val action2 = SceneAction.InfraredKey(
id = SceneActionId("a2"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "power",
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Test Scene",
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(action1, action2),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(scene, executionResult.scene)
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome)
assertEquals(1, socketGateway.calls.size)
assertEquals(Triple(profileId, DeviceName("socket-1"), SocketPowerState.ON), socketGateway.calls[0])
assertEquals(1, infraredGateway.calls.size)
assertEquals(Triple(profileId, DeviceName("ir-1"), "power"), infraredGateway.calls[0])
}
@Test
fun continuesExecutionWhenAnActionFails() = runTest {
val socketGateway = TestSocketGateway(
response = DomainResult.Failure(DomainError.DeviceOffline("socket-1 unreachable")),
)
val infraredGateway = TestInfraredGateway(
response = DomainResult.Success(Unit),
)
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
val action1 = SceneAction.SocketPower(
id = SceneActionId("a1"),
profileId = profileId,
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.OFF,
)
val action2 = SceneAction.InfraredKey(
id = SceneActionId("a2"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
key = "vol_up",
)
val scene = Scene(
id = SceneId("s1"),
profileId = profileId,
name = "Test Scene",
icon = SceneIcon.MOVIE,
color = SceneColor("#7986CB"),
actions = listOf(action1, action2),
)
val result = executor.execute(scene)
assertTrue(result is DomainResult.Success)
val executionResult = (result as DomainResult.Success).value
assertEquals(2, executionResult.results.size)
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
assertTrue(executionResult.results[0].error is DomainError.DeviceOffline)
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome)
assertEquals(1, socketGateway.calls.size)
assertEquals(1, infraredGateway.calls.size)
}
}
@@ -0,0 +1,86 @@
package com.flagship.abox.manager.domain
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InMemorySceneRepositoryTest {
private val localProfile = ProviderProfileId("local-experience")
private val aboxProfile = ProviderProfileId("abox:admin")
@Test
fun isolatesScenesBetweenProfiles() = runTest {
val repository = InMemorySceneRepository()
val localScenes = (repository.observeScenes(localProfile).first() as DomainResult.Success).value
assertTrue(localScenes.isNotEmpty())
val aboxScenes = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value
assertTrue(aboxScenes.isEmpty())
val newScene = Scene(
id = SceneId("abox:admin:scene-custom"),
profileId = aboxProfile,
name = "ABox Scene",
icon = SceneIcon.NIGHT,
color = SceneColor("#4CAF50"),
actions = emptyList(),
)
val createResult = repository.createScene(aboxProfile, newScene)
assertTrue(createResult is DomainResult.Success)
val updatedAbox = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value
assertEquals(1, updatedAbox.size)
assertEquals("ABox Scene", updatedAbox.first().name)
val updatedLocal = (repository.observeScenes(localProfile).first() as DomainResult.Success).value
assertEquals(localScenes.size, updatedLocal.size)
}
@Test
fun crudOperationsFunctionCorrectly() = runTest {
val repository = InMemorySceneRepository()
val sceneId = SceneId("abox:admin:scene-1")
val initialScene = Scene(
id = sceneId,
profileId = aboxProfile,
name = "Initial",
icon = SceneIcon.HOME,
color = SceneColor("#FFFFFF"),
actions = emptyList(),
)
// Create
val createResult = repository.createScene(aboxProfile, initialScene)
assertTrue(createResult is DomainResult.Success)
// Get
val getResult = repository.getScene(aboxProfile, sceneId)
assertTrue(getResult is DomainResult.Success)
assertEquals("Initial", (getResult as DomainResult.Success).value?.name)
// Update
val updatedScene = initialScene.copy(name = "Updated")
val updateResult = repository.updateScene(aboxProfile, updatedScene)
assertTrue(updateResult is DomainResult.Success)
val getAfterUpdate = repository.getScene(aboxProfile, sceneId)
assertEquals("Updated", (getAfterUpdate as DomainResult.Success).value?.name)
// Delete
val deleteResult = repository.deleteScene(aboxProfile, sceneId)
assertTrue(deleteResult is DomainResult.Success)
val getAfterDelete = repository.getScene(aboxProfile, sceneId)
assertNull((getAfterDelete as DomainResult.Success).value)
}
}
+4 -1
View File
@@ -1,5 +1,8 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8
org.gradle.configuration-cache=false org.gradle.configuration-cache=false
android.useAndroidX=true android.useAndroidX=true
android.nonTransitiveRClass=true android.nonTransitiveRClass=true
kotlin.code.style=official kotlin.code.style=official
android.overridePathCheck=true