docs: 修正 Task 10 计划(补 DeviceViewModel 测试与 Preview,修正步骤编号与文案清单)

This commit is contained in:
2026-09-10 11:23:05 +08:00
parent 5c82c38ef5
commit 7b111b33f3
@@ -3117,7 +3117,92 @@ git add -A && git commit -m "feat: 实现运动页与运动历史子页"
- `class DeviceViewModel(ringDeviceRepository)` 暴露 `uiState: StateFlow<DeviceUiState>`、`fun setFunctionEnabled(id: String, enabled: Boolean)`
- `@Composable fun DeviceScreen(onOpenSettings: () -> Unit, modifier, viewModel)`、`@Composable fun DeviceSettingsScreen(modifier, viewModel)`
- [ ] **Step 1: 写 `DeviceViewModel.kt`**
- [ ] **Step 1: 写失败的测试**
`app/src/test/java/com/placeholder/ringhealth/feature/device/DeviceViewModelTest.kt`
(与 Task 8 / Task 9 的两个 ViewModel 测试同形;ledger Ruling R27 —— 三个 ViewModel 的覆盖度应当一致):
```kotlin
package com.placeholder.ringhealth.feature.device
import com.placeholder.ringhealth.data.mock.MockRingDeviceRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/** 设备页 ViewModel 测试。 */
@OptIn(ExperimentalCoroutinesApi::class)
class DeviceViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `输出设备状态与五个功能开关`() = runTest(dispatcher) {
val viewModel = DeviceViewModel(MockRingDeviceRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
val state = viewModel.uiState.value
assertEquals(78, state.device.batteryPercent)
assertEquals("1.0.5", state.device.firmwareVersion)
assertEquals(5, state.settings.size)
}
@Test
fun `setFunctionEnabled 只改目标项且可来回切换`() = runTest(dispatcher) {
val viewModel = DeviceViewModel(MockRingDeviceRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
// Mock 的 5 项里只有 stress 与 blood_pressure 初始为 false,其余 3 项为 true
// (见 MockDataProvider.defaultFunctionSettings)。切换 stress 后应为 4 项开启。
val target = "stress"
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
viewModel.setFunctionEnabled(target, true)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.settings.first { it.id == target }.enabled)
// 关键:其它开关不受影响。只断言目标项变了,无法发现「整表被覆盖」这类回归。
assertEquals(4, viewModel.uiState.value.settings.count { it.enabled })
viewModel.setFunctionEnabled(target, false)
advanceUntilIdle()
assertFalse(viewModel.uiState.value.settings.first { it.id == target }.enabled)
}
}
```
- [ ] **Step 1b: 运行测试,确认失败**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.feature.device.DeviceViewModelTest"
```
Expected: `Unresolved reference: DeviceViewModel`,编译失败。
- [ ] **Step 2: 写 `DeviceViewModel.kt`**
```kotlin
package com.placeholder.ringhealth.feature.device
@@ -3168,7 +3253,7 @@ class DeviceViewModel(
}
```
- [ ] **Step 1b: 把 `DeviceViewModel` 注册到 `AppViewModelProvider`**
- [ ] **Step 3: 把 `DeviceViewModel` 注册到 `AppViewModelProvider`**
在 `AppViewModelProvider.kt` 的 `viewModelFactory { }` 内**追加**一个 `initializer`(ledger Ruling R2),
并在该文件顶部加 `import com.placeholder.ringhealth.feature.device.DeviceViewModel`:
@@ -3184,25 +3269,28 @@ class DeviceViewModel(
- [ ] **Step 4: 追加文案到 `strings.xml`**
```xml
<string name="device_title">我的设备</string>
<string name="device_connected">已连接</string>
<string name="device_disconnected">未连接</string>
<string name="device_battery">电量 %1$d%%</string>
<string name="device_firmware">固件版本 %1$s</string>
<string name="device_charging">充电中</string>
<string name="device_settings_title">功能设置</string>
<string name="device_find_ring">查找戒指</string>
<string name="device_find_ring_desc">让戒指震动以便寻找</string>
<string name="device_firmware_update">固件升级</string>
<string name="device_firmware_update_desc">检查并升级到最新固件</string>
<string name="device_wear_detection">佩戴检测</string>
<string name="device_wear_detection_desc">未佩戴时暂停测量</string>
<string name="device_more">更多设置</string>
<string name="device_switch_on">已开启</string>
<string name="device_switch_off">已关闭</string>
```
- [ ] **Step 3: 实现 `DeviceScreen.kt`**
> **本任务不得新增无消费者的字符串资源(ledger Ruling R27)。** 计划早期版本的这份清单有 16 条,
> 逐条 grep 调用点后砍到 8 条。被删的 8 条是
> `device_title`(`我的设备`)、`device_charging`(`充电中`)、
> `device_find_ring` / `device_find_ring_desc`、`device_firmware_update` / `device_firmware_update_desc`、
> `device_wear_detection` / `device_wear_detection_desc` —— 全计划**零调用点**。
> 它们看起来是照着小美运动健康的设备页草拟的(查找戒指 / 固件升级 / 佩戴检测三行入口),
> 但本任务实际实现的设备页只有「戒指信息卡 + 功能开关列表 + 更多设置入口」,
> 那三行从来没有被写进任何代码。**不要把它们加回来。**
> 若 M2 真要做「查找戒指」这类入口,那时再补文案 —— 提前写好只会留下 8 条永不渲染的字符串。
> (`device_settings_hint` 不在此列,它在 Step 5 单独追加,有调用点。)
- [ ] **Step 5: 实现 `DeviceScreen.kt`**
```kotlin
package com.placeholder.ringhealth.feature.device
@@ -3219,7 +3307,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Watch
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -3230,16 +3317,19 @@ import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.placeholder.ringhealth.AppViewModelProvider
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
import com.placeholder.ringhealth.domain.model.DeviceFunctionSetting
import com.placeholder.ringhealth.domain.model.RingDeviceState
/** 设备(戒指管理)页。 */
@Composable
@@ -3352,9 +3442,47 @@ internal fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Mod
}
}
}
@Preview(showBackground = true)
@Composable
private fun DeviceContentPreview() {
RingHealthTheme {
DeviceContent(
state = DeviceUiState(
device = RingDeviceState(true, "RingHealth 智能戒指", 78, "1.0.5", false),
settings = previewSettings(),
),
onOpenSettings = {},
)
}
}
/**
* Preview 用的假开关列表。
*
* `internal` 而非 `private`:`DeviceSettingsScreen.kt` 的 Preview 也要用。
* 取值与 `MockDataProvider.defaultFunctionSettings()` 保持一致,
* 免得 Preview 里看到的和跑起来看到的不是同一份数据。
*/
internal fun previewSettings(): List<DeviceFunctionSetting> = listOf(
DeviceFunctionSetting("heart_rate", "自动心率监测", "每 10 分钟测量一次", true),
DeviceFunctionSetting("blood_oxygen", "自动血氧监测", "夜间持续监测", true),
DeviceFunctionSetting("sleep", "睡眠监测", "自动识别睡眠时段", true),
DeviceFunctionSetting("stress", "压力监测", "全天候压力评估", false),
DeviceFunctionSetting("blood_pressure", "血压监测", "需在 App 内手动校准", false),
)
```
- [ ] **Step 4: 实现 `DeviceSettingsScreen.kt`(替换占位)**
> `previewSettings()` 是**唯一**的例外:它是 `internal` 而不是 `private`,因为两个页面的 Preview 共用。
> 它只在 Preview 与 debug 构建里被调用,不进任何生产路径。
> **Preview 是本项目唯一可用的目视检查手段(ledger Ruling R21 / R26)**:本机没有模拟器镜像也没有真机,
> `assembleDebug` 只能证明「能编译」。因此每个页面都必须拆成「公开的有状态外壳 + `private` 的无状态
> `XContent` + 挂在 `XContent` 上的 `@Preview`」——只有无状态的那层才能喂假数据渲染。
> 两个页面的 Preview 共用同一个 `internal fun previewSettings()`(定义在 `DeviceScreen.kt` 末尾,
> 见本步代码块之后):`DeviceSettingsScreen.kt` 也要用它,`private` 在文件之间传不过去。
- [ ] **Step 6: 实现 `DeviceSettingsScreen.kt`(替换占位)**
```kotlin
package com.placeholder.ringhealth.feature.device
@@ -3370,18 +3498,21 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.placeholder.ringhealth.AppViewModelProvider
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.core.designsystem.MetricGreen
import com.placeholder.ringhealth.core.designsystem.RingHealthTheme
import com.placeholder.ringhealth.core.designsystem.TextSecondary
import com.placeholder.ringhealth.core.designsystem.component.HealthCard
import com.placeholder.ringhealth.core.designsystem.component.SectionHeader
@@ -3396,13 +3527,32 @@ fun DeviceSettingsScreen(
viewModel: DeviceViewModel = viewModel(factory = AppViewModelProvider.Factory),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
DeviceSettingsContent(
settings = state.settings,
onToggle = viewModel::setFunctionEnabled,
modifier = modifier,
)
}
/**
* 无状态设备设置内容。
* 与 `DeviceContent` 同样拆出独立函数,Preview 才能喂假数据渲染(ledger Ruling R26)。
* 注意回调签名是 `(id, enabled)` 而不是 `(DeviceFunctionSetting, Boolean)`:
* 无状态层不该知道「怎么改」,只负责把「改哪一项、改成什么」报上去。
*/
@Composable
private fun DeviceSettingsContent(
settings: List<DeviceFunctionSetting>,
onToggle: (String, Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { SectionHeader(title = stringResource(R.string.device_settings_title)) }
items(state.settings) { setting ->
items(settings) { setting ->
HealthCard {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -3415,8 +3565,9 @@ fun DeviceSettingsScreen(
}
Switch(
checked = setting.enabled,
onCheckedChange = { checked -> viewModel.setFunctionEnabled(setting.id, checked) },
colors = androidx.compose.material3.SwitchDefaults.colors(checkedTrackColor = MetricGreen),
onCheckedChange = { checked -> onToggle(setting.id, checked) },
// SwitchDefaults 走正常 import,不写字面量全限定名(与其余文件的 import 风格一致)
colors = SwitchDefaults.colors(checkedTrackColor = MetricGreen),
)
}
}
@@ -3431,15 +3582,23 @@ fun DeviceSettingsScreen(
}
}
}
@Preview(showBackground = true)
@Composable
private fun DeviceSettingsContentPreview() {
RingHealthTheme {
DeviceSettingsContent(settings = previewSettings(), onToggle = { _, _ -> })
}
}
```
- [ ] **Step 5: 追加提示文案到 `strings.xml`**
- [ ] **Step 7: 追加提示文案到 `strings.xml`**
```xml
<string name="device_settings_hint">阶段一为演示开关,接入戒指后将同步到设备。</string>
```
- [ ] **Step 6: 在 `RingHealthApp.kt` 接入真实页面**
- [ ] **Step 8: 在 `RingHealthApp.kt` 接入真实页面**
```kotlin
composable(Routes.DEVICE) {
@@ -3452,15 +3611,22 @@ fun DeviceSettingsScreen(
删除 `DeviceSettingsPlaceholder` 的定义与 import。
- [ ] **Step 7: 构建 + 验证**
- [ ] **Step 9: 构建 + 单测**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew assembleDebug
```
Expected: 设备 Tab 显示戒指卡片(电量 78%、固件 1.0.5);“更多设置”进入后开关可切换且返回后状态保持。
Expected: 两条命令均 `BUILD SUCCESSFUL`;`DeviceViewModelTest` 由 Step 1b 的 RED 转为 GREEN,
全量测试 15 项通过(Task 9 之后是 13 项,本任务 +2)。
- [ ] **Step 8: Commit**
界面上「设备 Tab 显示戒指卡片(电量 78%、固件 1.0.5)」「更多设置进入后开关可切换且返回后状态保持」
这两条**不在本步验证**(ledger Ruling R21:本机无模拟器与真机),顺延到 Task 14 Step 3 的人工验收清单。
> **不要用 `FROM-CACHE` 的绿当作证据。** Task 9 的修复轮实测到:Gradle 构建缓存命中时
> 任务会直接标记为 `FROM-CACHE` 而不真正执行。若看到 `FROM-CACHE`,加 `--rerun` 重跑一次再下结论。
- [ ] **Step 10: Commit**
```bash
git add -A && git commit -m "feat: 实现设备页与设备设置子页(可交互开关)"