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

## Summary

Adds scene integration phase 1: domain model, fakes, UI, navigation, and tests.

## Changes

- **domain**: Add `Scene`, `SceneAction` (SocketPower/InfraredKey), `SceneRepository`, `SceneExecutor`, and execution result models.
- **testing/fakes**: Add `FakeSceneRepository` (isolated by `ProviderProfileId`, seeded defaults for the local profile) and `FakeSceneExecutor` (sequential execution, continue-on-failure).
- **android/app**: Add scene UI module - list screen, editor screen, execution progress screen - with Hilt-bound ViewModels.
- **navigation**: Wire scene routes into `ABoxManagerApp` NavHost.
- **strings**: Add `scene_*` entries for en and zh-rCN.
- **tests**: Cover `FakeSceneRepository` CRUD/isolation and `FakeSceneExecutor` ordering/continue-on-failure.

## Test plan

- [x] `./gradlew :testing:fakes:test` passes.
- [x] `FakeSceneRepository` isolates scenes by provider profile.
- [x] `FakeSceneExecutor` runs actions in order and continues on failure.

## Notes

Phase 1 ships fakes only; real repository/executor backed by the abox gateway will land in a follow-up.

---------

Co-authored-by: KremeCN <3265494849@qq.com>
Co-authored-by: mxhsnd <2049295946@qq.com>
Reviewed-on: #5
Co-authored-by: tzh <3091898014@qq.com>
This commit was merged in pull request #5.
This commit is contained in:
tzh
2026-08-25 20:20:18 +08:00
committed by KremeCN
co-authored by KremeCN Moler
parent 30d6d39597
commit 1570a95f30
28 changed files with 5471 additions and 1 deletions
@@ -0,0 +1,204 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.ComparisonOperator
import com.flagship.abox.manager.domain.ConcurrencyMode
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ExecutionStrategy
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.SceneCondition
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneTrigger
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 com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
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 virtualRemoteRepository = FakeVirtualRemoteRepository()
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, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.MANUAL)
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, virtualRemoteRepository, profileStore)
viewModel.initialize(existingScene.id)
// Immediately binds sceneId and enters loading state
assertTrue(viewModel.state.value.isEditing)
assertEquals(existingScene.id, viewModel.state.value.sceneId)
assertTrue(viewModel.state.value.isLoading)
assertFalse(viewModel.state.value.isValid)
advanceUntilIdle()
val state = viewModel.state.value
assertTrue(state.isEditing)
assertFalse(state.isLoading)
assertEquals("Existing Scene", state.name)
assertEquals(1, state.actions.size)
assertTrue(state.isValid)
}
@Test
fun addingAndReorderingActionsUpdatesState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.MANUAL)
viewModel.updateName("My New Scene")
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
viewModel.addDelayAction(3)
viewModel.addNotificationAction("Done", "Completed")
advanceUntilIdle()
assertEquals(3, viewModel.state.value.actions.size)
assertTrue(viewModel.state.value.actions[0] is SceneAction.SocketPower)
assertTrue(viewModel.state.value.actions[1] is SceneAction.Delay)
assertTrue(viewModel.state.value.actions[2] is SceneAction.Notification)
// Move second to first
viewModel.moveAction(1, 0)
assertTrue(viewModel.state.value.actions[0] is SceneAction.Delay)
assertTrue(viewModel.state.value.actions[1] is SceneAction.SocketPower)
// Remove first action
val firstId = viewModel.state.value.actions[0].id
viewModel.removeAction(firstId)
assertEquals(2, viewModel.state.value.actions.size)
}
@Test
fun addingTriggersAndConditionsForAutomaticRule() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.AUTOMATIC)
viewModel.updateName("Auto Rule")
viewModel.addTemperatureTrigger("sensor-1", ComparisonOperator.GREATER_THAN, 28.0)
viewModel.addSocketPowerCondition("socket-1", SocketPowerState.OFF)
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
viewModel.updateConcurrencyMode(ConcurrencyMode.PARALLEL)
viewModel.updateStrategy(ExecutionStrategy.ABORT_ON_ERROR)
advanceUntilIdle()
val state = viewModel.state.value
assertEquals(1, state.triggers.size)
assertTrue(state.triggers[0] is SceneTrigger.Temperature)
assertEquals(1, state.conditions.size)
assertTrue(state.conditions[0] is SceneCondition.SocketPower)
assertEquals(ConcurrencyMode.PARALLEL, state.concurrencyMode)
assertEquals(ExecutionStrategy.ABORT_ON_ERROR, state.strategy)
assertTrue(state.isValid)
}
@Test
fun saveNewScenePersistsToRepository() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.MANUAL)
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)
}
@Test
fun automaticSceneWithoutTriggersIsInvalid() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(null, AutomationType.AUTOMATIC)
viewModel.updateName("Auto Without Trigger")
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
advanceUntilIdle()
// Missing triggers -> invalid
assertFalse(viewModel.state.value.isValid)
// Adding trigger makes it valid
viewModel.addTemperatureTrigger("sensor-1", ComparisonOperator.GREATER_THAN, 28.0)
advanceUntilIdle()
assertTrue(viewModel.state.value.isValid)
}
@Test
fun initializeIsIdempotentAndPreservesDraftState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
viewModel.initialize(existingScene.id)
advanceUntilIdle()
viewModel.updateName("Modified Unsaved Name")
assertEquals("Modified Unsaved Name", viewModel.state.value.name)
// Re-invoking initialize with same sceneId should NOT overwrite draft
viewModel.initialize(existingScene.id)
advanceUntilIdle()
assertEquals("Modified Unsaved Name", viewModel.state.value.name)
}
}
@@ -0,0 +1,125 @@
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)
}
@Test
fun initializeIsIdempotentAndPreservesCompletedState() = runTest {
setupProfile()
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.initialize(testScene.id)
advanceUntilIdle()
viewModel.execute()
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
// Re-initializing with same sceneId should preserve Completed state
viewModel.initialize(testScene.id)
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
}
@Test
fun executeFailsWhenActiveProfileMismatches() = runTest {
setupProfile()
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.initialize(testScene.id)
advanceUntilIdle()
// Switch to a different profile
profileStore.setActiveProfile(ProviderProfileId("different-profile"))
viewModel.execute()
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Failed)
val failed = viewModel.uiState.value as SceneExecutionUiState.Failed
assertEquals("Active profile mismatch", failed.message)
}
}
@@ -0,0 +1,170 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.AutomationType
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.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.assertFalse
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 socketGateway = FakeSocketGateway()
private val infraredGateway = FakeInfraredGateway()
private val sceneExecutor = FakeSceneExecutor(socketGateway, infraredGateway)
private val profileId = ProviderProfileId("local-experience")
private val manualScene = Scene(
id = SceneId("local-experience:scene-1"),
profileId = profileId,
name = "Manual Scene",
type = AutomationType.MANUAL,
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 val autoScene = Scene(
id = SceneId("local-experience:scene-2"),
profileId = profileId,
name = "Auto Rule",
type = AutomationType.AUTOMATIC,
enabled = true,
icon = SceneIcon.THERMOSTAT,
color = SceneColor("#26A69A"),
actions = listOf(
SceneAction.Delay(
id = SceneActionId("local-experience:action-2"),
profileId = profileId,
durationSeconds = 5,
),
),
)
@Test
fun loadsScenesForActiveProfile() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(manualScene, autoScene))
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
assertEquals(2, viewModel.scenes.value.size)
assertEquals("Manual Scene", viewModel.scenes.value[0].name)
assertEquals("Auto Rule", viewModel.scenes.value[1].name)
}
@Test
fun filterTabFiltersScenes() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(manualScene, autoScene))
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.selectTab(SceneFilterTab.MANUAL)
advanceUntilIdle()
assertEquals(1, viewModel.scenes.value.size)
assertEquals(AutomationType.MANUAL, viewModel.scenes.value[0].type)
viewModel.selectTab(SceneFilterTab.AUTOMATIC)
advanceUntilIdle()
assertEquals(1, viewModel.scenes.value.size)
assertEquals(AutomationType.AUTOMATIC, viewModel.scenes.value[0].type)
}
@Test
fun toggleEnabledUpdatesSceneInRepository() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(autoScene))
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.toggleEnabled(autoScene)
advanceUntilIdle()
val updated = viewModel.scenes.value.first()
assertFalse(updated.enabled)
}
@Test
fun deleteSceneRemovesFromRepositoryAndClearsPending() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(manualScene))
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.markPendingDelete(manualScene.id)
assertEquals(manualScene.id, viewModel.pendingDelete.value)
viewModel.deleteScene(manualScene.id)
advanceUntilIdle()
assertNull(viewModel.pendingDelete.value)
assertTrue(viewModel.scenes.value.isEmpty())
}
@Test
fun cancelDeleteClearsPendingState() = runTest {
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.markPendingDelete(manualScene.id)
assertEquals(manualScene.id, viewModel.pendingDelete.value)
viewModel.cancelDelete()
assertNull(viewModel.pendingDelete.value)
}
}