feat: add socket control and temperature humidity modules (#7)
## Summary - **Smart Socket Module**: - Implemented `SocketUiState` and `SocketViewModel` with support for querying power state, toggle control, duplicate click debouncing, and state confirmation timestamping. - Implemented Material 3 responsive `SocketScreen` with power indicator badge, toggle action, explicit on/off buttons, offline banner, and error handling. - **Temperature & Humidity Module**: - Implemented `TemperatureHumidityUiState` and `TemperatureHumidityViewModel` for read-only environmental telemetry. - Implemented `TemperatureHumidityScreen` featuring distinct temperature and humidity cards, read-only notice, no-data placeholder, and pull-to-refresh actions. - **Common & Infrastructure**: - Added `UiText` and `DomainErrorMapper` for localizing domain errors (`DomainError`). - Added navigation graph extensions (`deviceDetailGraph`) in `DeviceDetailNavigation.kt` and wired into `ABoxManagerApp.kt`. - Added `ReleaseModule` for Hilt dependency injection in release builds. - Added full English and Simplified Chinese resource translations in `values/strings.xml` and `values-zh-rCN/strings.xml`. - **Testing**: - Added `SocketViewModelTest` and `TemperatureHumidityViewModelTest` covering normal operations, offline states, error mapping, and refresh scenarios. ## Test Plan - [x] Run `./gradlew test` (all modules passed) - [x] Run `./gradlew :android:app:testDebugUnitTest` (passed) - [x] Run `./gradlew assembleDebug` (build successful) ?? Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: KremeCN <3265494849@qq.com> Reviewed-on: #7 Co-authored-by: Aurum <aurum_197@outlook.com>
This commit was merged in pull request #7.
This commit is contained in:
+197
@@ -0,0 +1,197 @@
|
||||
package com.flagship.abox.manager.ui.socket
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
|
||||
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SocketViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher())
|
||||
|
||||
private lateinit var fakeSocketGateway: FakeSocketGateway
|
||||
private val profileId = ProviderProfileId("test-profile")
|
||||
private val deviceName = DeviceName("test-socket")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fakeSocketGateway = FakeSocketGateway()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialization_fromSavedStateHandle_loadsStatusSuccessfully() = runTest {
|
||||
fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON)
|
||||
|
||||
val savedStateHandle = SavedStateHandle(
|
||||
mapOf(
|
||||
"profileId" to profileId.value,
|
||||
"deviceName" to deviceName.value,
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, savedStateHandle)
|
||||
val state = viewModel.state.value
|
||||
|
||||
assertEquals(profileId, state.profileId)
|
||||
assertEquals(deviceName, state.deviceName)
|
||||
assertEquals(SocketPowerState.ON, state.powerState)
|
||||
assertTrue(state.isPowerOn)
|
||||
assertFalse(state.isLoading)
|
||||
assertFalse(state.isOffline)
|
||||
assertNull(state.errorMessage)
|
||||
assertNotNull(state.lastConfirmedTimeMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialization_viaEvent_loadsStatusSuccessfully() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
assertNull(viewModel.state.value.profileId)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
val state = viewModel.state.value
|
||||
|
||||
assertEquals(profileId, state.profileId)
|
||||
assertEquals(deviceName, state.deviceName)
|
||||
assertEquals(SocketPowerState.OFF, state.powerState)
|
||||
assertFalse(state.isPowerOn)
|
||||
assertFalse(state.isLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadStatus_whenDeviceOffline_setsOfflineState() = runTest {
|
||||
fakeSocketGateway.setError(profileId, deviceName, DomainError.DeviceOffline())
|
||||
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertTrue(state.isOffline)
|
||||
assertFalse(state.isLoading)
|
||||
assertNotNull(state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadStatus_whenNetworkFails_setsErrorMessage() = runTest {
|
||||
fakeSocketGateway.setError(profileId, deviceName, DomainError.Network(detail = "Timeout"))
|
||||
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertFalse(state.isOffline)
|
||||
assertFalse(state.isLoading)
|
||||
assertNotNull(state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun togglePower_fromOffToOn_updatesState() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.TogglePower)
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertEquals(SocketPowerState.ON, state.powerState)
|
||||
assertTrue(state.isPowerOn)
|
||||
assertFalse(state.isOperating)
|
||||
assertNull(state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun togglePower_fromOnToOff_updatesState() = runTest {
|
||||
fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON)
|
||||
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.TogglePower)
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertEquals(SocketPowerState.OFF, state.powerState)
|
||||
assertFalse(state.isPowerOn)
|
||||
assertFalse(state.isOperating)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setPower_explicitState_updatesStateCorrectly() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
|
||||
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.OFF))
|
||||
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setPower_whenGatewayFails_setsErrorMessageAndPreservesState() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
|
||||
|
||||
fakeSocketGateway.setError(profileId, deviceName, DomainError.OperationFailed(detail = "Command failed"))
|
||||
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertEquals(SocketPowerState.OFF, state.powerState)
|
||||
assertFalse(state.isOperating)
|
||||
assertNotNull(state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refresh_queriesUpdatedGatewayStatus() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
|
||||
|
||||
// Modify gateway backend directly
|
||||
fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.Refresh)
|
||||
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changeDeviceName_updatesNameAndReloads() = runTest {
|
||||
val newDevice = DeviceName("socket-2")
|
||||
fakeSocketGateway.setPower(profileId, newDevice, SocketPowerState.ON)
|
||||
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.ChangeDeviceName(newDevice))
|
||||
assertEquals(newDevice, viewModel.state.value.deviceName)
|
||||
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearError_resetsErrorMessage() = runTest {
|
||||
fakeSocketGateway.setError(profileId, deviceName, DomainError.InvalidRequest())
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
assertNotNull(viewModel.state.value.errorMessage)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.ClearError)
|
||||
assertNull(viewModel.state.value.errorMessage)
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
package com.flagship.abox.manager.ui.temperaturehumidity
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.TemperatureHumidityReading
|
||||
import com.flagship.abox.manager.testing.fakes.FakeTemperatureHumidityGateway
|
||||
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TemperatureHumidityViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher())
|
||||
|
||||
private lateinit var fakeGateway: FakeTemperatureHumidityGateway
|
||||
private val profileId = ProviderProfileId("test-profile")
|
||||
private val deviceName = DeviceName("test-sensor")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fakeGateway = FakeTemperatureHumidityGateway()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialization_fromSavedStateHandle_loadsReadingSuccessfully() = runTest {
|
||||
fakeGateway.setReading(
|
||||
TemperatureHumidityReading(
|
||||
profileId = profileId,
|
||||
deviceName = deviceName,
|
||||
temperature = "26.5",
|
||||
humidity = "58",
|
||||
),
|
||||
)
|
||||
|
||||
val savedStateHandle = SavedStateHandle(
|
||||
mapOf(
|
||||
"profileId" to profileId.value,
|
||||
"deviceName" to deviceName.value,
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, savedStateHandle)
|
||||
val state = viewModel.state.value
|
||||
|
||||
assertEquals(profileId, state.profileId)
|
||||
assertEquals(deviceName, state.deviceName)
|
||||
assertEquals("26.5", state.temperature)
|
||||
assertEquals("58", state.humidity)
|
||||
assertEquals("26.5 °C", state.formattedTemperature)
|
||||
assertEquals("58 %", state.formattedHumidity)
|
||||
assertTrue(state.hasValidData)
|
||||
assertFalse(state.isLoading)
|
||||
assertFalse(state.isOffline)
|
||||
assertFalse(state.hasNoData)
|
||||
assertNull(state.errorMessage)
|
||||
assertNotNull(state.lastUpdatedTimeMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialization_viaEvent_loadsReadingSuccessfully() = runTest {
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
assertNull(viewModel.state.value.profileId)
|
||||
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
val state = viewModel.state.value
|
||||
|
||||
assertEquals(profileId, state.profileId)
|
||||
assertEquals(deviceName, state.deviceName)
|
||||
assertEquals("23.5", state.temperature)
|
||||
assertEquals("45", state.humidity)
|
||||
assertFalse(state.isLoading)
|
||||
assertTrue(state.hasValidData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadReading_whenDeviceOffline_setsOfflineState() = runTest {
|
||||
fakeGateway.setError(profileId, deviceName, DomainError.DeviceOffline())
|
||||
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertTrue(state.isOffline)
|
||||
assertFalse(state.isLoading)
|
||||
assertNotNull(state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadReading_whenNoDataError_setsNoDataState() = runTest {
|
||||
fakeGateway.setError(profileId, deviceName, DomainError.NoData())
|
||||
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertTrue(state.hasNoData)
|
||||
assertFalse(state.isLoading)
|
||||
assertNotNull(state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadReading_whenBlankValuesReturned_handlesAsNoData() = runTest {
|
||||
fakeGateway.setReading(
|
||||
TemperatureHumidityReading(
|
||||
profileId = profileId,
|
||||
deviceName = deviceName,
|
||||
temperature = "",
|
||||
humidity = "",
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertTrue(state.hasNoData)
|
||||
assertNull(state.temperature)
|
||||
assertNull(state.humidity)
|
||||
assertEquals("--", state.formattedTemperature)
|
||||
assertEquals("--", state.formattedHumidity)
|
||||
assertFalse(state.hasValidData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refresh_queriesUpdatedReading() = runTest {
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals("23.5", viewModel.state.value.temperature)
|
||||
|
||||
fakeGateway.setReading(
|
||||
TemperatureHumidityReading(
|
||||
profileId = profileId,
|
||||
deviceName = deviceName,
|
||||
temperature = "30.2",
|
||||
humidity = "65",
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Refresh)
|
||||
val state = viewModel.state.value
|
||||
assertEquals("30.2", state.temperature)
|
||||
assertEquals("65", state.humidity)
|
||||
assertEquals("30.2 °C", state.formattedTemperature)
|
||||
assertEquals("65 %", state.formattedHumidity)
|
||||
assertFalse(state.isRefreshing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changeDeviceName_updatesNameAndReloads() = runTest {
|
||||
val newDevice = DeviceName("th-2")
|
||||
fakeGateway.setReading(
|
||||
TemperatureHumidityReading(
|
||||
profileId = profileId,
|
||||
deviceName = newDevice,
|
||||
temperature = "26.8",
|
||||
humidity = "55",
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals("23.5", viewModel.state.value.temperature)
|
||||
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(newDevice))
|
||||
assertEquals(newDevice, viewModel.state.value.deviceName)
|
||||
assertEquals("26.8", viewModel.state.value.temperature)
|
||||
assertEquals("55", viewModel.state.value.humidity)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearError_resetsErrorMessage() = runTest {
|
||||
fakeGateway.setError(profileId, deviceName, DomainError.Network())
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
assertNotNull(viewModel.state.value.errorMessage)
|
||||
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.ClearError)
|
||||
assertNull(viewModel.state.value.errorMessage)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user