docs: 补 Task 12 的 ViewModel 测试与 Preview、修正 Task 14 测试数与孤儿字符串清扫

This commit is contained in:
2026-09-10 11:30:58 +08:00
parent 430e669702
commit 85abf5daf2
@@ -3158,8 +3158,9 @@ class DeviceViewModelTest {
}
@Test
fun `输出设备状态与五个功能开关`() = runTest(dispatcher) {
val viewModel = DeviceViewModel(MockRingDeviceRepository())
fun `输出设备状态与五个功能开关,且仓库变化会回流到 UI 状态`() = runTest(dispatcher) {
val repository = MockRingDeviceRepository()
val viewModel = DeviceViewModel(repository)
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
@@ -3168,6 +3169,14 @@ class DeviceViewModelTest {
assertEquals(78, state.device.batteryPercent)
assertEquals("1.0.5", state.device.firmwareVersion)
assertEquals(5, state.settings.size)
// 上面三个值恰好与 stateIn 的 initialValue 相同(DeviceViewModel 的 initialValue
// 直接取仓库当前值),所以**单靠它们无法区分「combine 发射过」与「一直停在 initialValue」**
// (ledger Ruling R30,审查者指出)。这里绕开 ViewModel 直接改仓库,再断言状态跟着变 ——
// 只有 combine 真的在发射,这一条才可能成立。
repository.setFunctionEnabled("stress", true)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.settings.first { it.id == "stress" }.enabled)
}
@Test
@@ -3421,7 +3430,7 @@ private fun DeviceInfoCard(state: DeviceUiState, modifier: Modifier = Modifier)
/** 单行设置项(只读展示,开关在详情页操作)。 */
@Composable
internal fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Modifier) {
private fun SettingRow(setting: DeviceFunctionSetting, modifier: Modifier = Modifier) {
HealthCard(modifier = modifier) {
Row(
modifier = Modifier.fillMaxWidth(),
@@ -3473,8 +3482,10 @@ internal fun previewSettings(): List<DeviceFunctionSetting> = listOf(
)
```
> `previewSettings()` 是**唯一**的例外:它是 `internal` 而不是 `private`,因为两个页面的 Preview 共用。
> 它只在 Preview 与 debug 构建里被调用,不进任何生产路径。
> `previewSettings()` 是**唯一**被放宽到 `internal` 的函数(两个文件的 Preview 共用,`private` 传不过去),
> 文件里其余一切都保持 `private`。它只在 Preview 与 debug 构建里被调用,不进任何生产路径。
> —— 审查者指出 `SettingRow` 原本也写成了 `internal`,但它的消费者只有同文件的 `DeviceContent`,
> 属于无谓地放大暴露面,已改回 `private`(ledger Ruling R30)。
> **Preview 是本项目唯一可用的目视检查手段(ledger Ruling R21 / R26)**:本机没有模拟器镜像也没有真机,
> `assembleDebug` 只能证明「能编译」。因此每个页面都必须拆成「公开的有状态外壳 + `private` 的无状态
@@ -3516,6 +3527,7 @@ 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
/**
* 设备设置子页:可交互的功能开关列表。
@@ -3835,24 +3847,160 @@ git add -A && git commit -m "feat: 实现我的页"
**Files:**
- Create: `feature/detail/MetricDetailViewModel.kt`
- Create: `app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt`
- Create: `app/src/test/java/com/placeholder/ringhealth/core/navigation/RoutesTest.kt`
- Modify: `feature/detail/MetricDetailScreen.kt`(替换占位)
- Modify: `core/navigation/RingHealthApp.kt`(接入真实详情页 + 支持返回)
- Modify: `core/common/Formatters.kt`(+1 个 `formatDecimal`)
- Modify: `app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt`(+1 条用例)
- Modify: `res/values/strings.xml`
**Interfaces:**
- Consumes: `HealthDataRepository`、`MetricBarChart`、`StatBar`、Task 8 加入的 `R.string.metric_*`
- Consumes: `HealthDataRepository`、`MetricBarChart`、`StatBar`、`StatSegment`、Task 8 加入的 `R.string.metric_*`
- Produces:
- `data class MetricDetailUiState(val loading: Boolean, val metric: MetricType, val range: DateRange, val points: List<MetricPoint>)`
- `class MetricDetailViewModel(metric: MetricType, healthDataRepository)`,方法 `fun switchRange(range: DateRange)`
- `@Composable fun MetricDetailScreen(metric: MetricType, onBack: () -> Unit, modifier, viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)))`
- `MetricDetailViewModel.factory(metric: MetricType): ViewModelProvider.Factory`(伴生对象)
- `fun formatDecimal(value: Double): String`(`core/common/Formatters.kt`)
- `@Composable private fun metricTitleRes(type: MetricType): Int`(文件内私有)
> **两点范围说明**:
> **范围说明**:
> - 标题用 `metricTitleRes` 映射到 `R.string.metric_*`,**不再有** `metricDisplayName`(ledger Ruling R3)。
> - 增加睡眠专属区块(`StatBar` 分期条 + 入睡/起床时间),补齐 spec §6 的 `detail/sleep` 要求(ledger Ruling R4)。
> - 本任务同时补齐 Task 12 缺的 ViewModel 测试、两个 `@Preview`、以及 `formatDecimal` 的集中化(ledger Ruling R29)。
- [ ] **Step 1: 写 `MetricDetailViewModel.kt`**
- [ ] **Step 1: 写失败的测试**
`app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt`
(与 Task 8/9/10 的三个 ViewModel 测试同形;ledger Ruling R29 —— 四个 ViewModel 的覆盖度应当一致。
本任务这个 ViewModel 的逻辑最重:`range` 变化要经 `flatMapLatest` 换掉上游流,
是全 App 唯一一处会**重新订阅**数据源的地方,不测说不过去):
```kotlin
package com.placeholder.ringhealth.feature.detail
import com.placeholder.ringhealth.data.mock.MockHealthDataRepository
import com.placeholder.ringhealth.domain.model.DateRange
import com.placeholder.ringhealth.domain.model.MetricType
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.Before
import org.junit.Test
/** 指标详情 ViewModel 测试。 */
@OptIn(ExperimentalCoroutinesApi::class)
class MetricDetailViewModelTest {
private val dispatcher = StandardTestDispatcher()
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun `默认输出近 7 天的数据`() = runTest(dispatcher) {
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, MockHealthDataRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
val state = viewModel.uiState.value
assertFalse(state.loading)
assertEquals(MetricType.HEART_RATE, state.metric)
assertEquals(DateRange.WEEK, state.range)
assertEquals(7, state.points.size)
}
@Test
fun `switchRange 切到月度后变成 30 个点`() = runTest(dispatcher) {
val viewModel = MetricDetailViewModel(MetricType.HEART_RATE, MockHealthDataRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
viewModel.switchRange(DateRange.MONTH)
advanceUntilIdle()
val state = viewModel.uiState.value
// 三个断言缺一不可:只断言点数,无法发现「上游没重新订阅、只是把旧数据换了标签」;
// 只断言 range,无法发现 flatMapLatest 没生效。
assertEquals(DateRange.MONTH, state.range)
assertEquals(30, state.points.size)
assertEquals(MetricType.HEART_RATE, state.metric)
viewModel.switchRange(DateRange.WEEK)
advanceUntilIdle()
assertEquals(7, viewModel.uiState.value.points.size)
}
@Test
fun `不同指标的标题映射不同`() = runTest(dispatcher) {
// metricTitleRes 是 @Composable,JVM 测试里调不了;这里只锁「metric 被原样带进状态」,
// 保证详情页拿到的 metric 与导航参数一致(映射本身由编译器穷尽 when 保证)。
val viewModel = MetricDetailViewModel(MetricType.SLEEP, MockHealthDataRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
assertEquals(MetricType.SLEEP, viewModel.uiState.value.metric)
}
}
```
- [ ] **Step 1b: 运行测试,确认失败**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest --tests "com.placeholder.ringhealth.feature.detail.MetricDetailViewModelTest"
```
Expected: `Unresolved reference: MetricDetailViewModel`,编译失败。
- [ ] **Step 1c: 给 `Formatters.kt` 补一个 `formatDecimal`**
`StatLine` 要显示「平均 / 最高 / 最低」,需要保留一位小数。**不要在页面里直接写
`String.format(...)`**:全计划 grep 下来,那样写的话会是**整个 App 唯一**一处绕过
`core/common/Formatters.kt` 的数值格式化 —— 而该模块存在的全部理由就是「数值格式化集中在一处」
(Task 5)。补一个函数进去,并给它配一条测试:
在 `core/common/Formatters.kt` 末尾追加(文件顶部已有 `import java.util.Locale`):
```kotlin
/** 数值 → 保留一位小数。详情页的统计摘要用(ledger Ruling R29)。 */
fun formatDecimal(value: Double): String = String.format(Locale.CHINA, "%.1f", value)
```
在 `app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt` 追加一条用例
(沿用该文件已有的测试类与 import):
```kotlin
@Test
fun `formatDecimal 保留一位小数`() {
assertEquals("72.0", formatDecimal(72.0))
assertEquals("98.6", formatDecimal(98.56))
assertEquals("0.0", formatDecimal(0.0))
}
```
> 口径说明:**所有指标一律一位小数**,步数因此会显示成「平均 7512.3 步」——
> 对连续量(心率/血氧/体温/压力/睡眠时长)这是对的,对步数只是精度过剩,不算错。
> 按指标分别定精度属于 M3 的展示打磨,阶段一不做;已记入 ledger 的 parked observations。
- [ ] **Step 2: 写 `MetricDetailViewModel.kt`**
```kotlin
package com.placeholder.ringhealth.feature.detail
@@ -3940,7 +4088,7 @@ class MetricDetailViewModel(
> 说明:`ringHealthApplication()` 是 Task 4 中定义在 `com.placeholder.ringhealth` 包下的 `CreationExtras` 扩展函数,需在本文件顶部 `import com.placeholder.ringhealth.ringHealthApplication`。
- [ ] **Step 4: 追加文案到 `strings.xml`**
- [ ] **Step 3: 追加文案到 `strings.xml`**
```xml
<string name="detail_range_week">近 7 天</string>
@@ -3961,7 +4109,7 @@ class MetricDetailViewModel(
> `detail_average` / `detail_max` / `detail_min` 是**纯标签**,不带 `%1$s`:
> `StatLine` 把标签和数值分成左右两栏渲染,标签里再带占位符会原样打印出 `%1$s`。
- [ ] **Step 3: 实现 `MetricDetailScreen.kt`(替换占位)**
- [ ] **Step 4: 实现 `MetricDetailScreen.kt`(替换占位)**
```kotlin
package com.placeholder.ringhealth.feature.detail
@@ -3969,7 +4117,6 @@ package com.placeholder.ringhealth.feature.detail
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -3996,13 +4143,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
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.R
import com.placeholder.ringhealth.core.common.formatClock
import com.placeholder.ringhealth.core.common.formatDecimal
import com.placeholder.ringhealth.core.designsystem.MetricBlue
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.MetricBarChart
@@ -4014,9 +4164,12 @@ import com.placeholder.ringhealth.domain.model.MetricPoint
import com.placeholder.ringhealth.domain.model.MetricType
/**
* 指标详情页:范围切换 + 柱状趋势图 + 统计摘要。
* 指标详情页(有状态外壳)。
*
* 本页自带 `Scaffold` + `TopAppBar`(外层 `RingHealthApp` 也有一个 `Scaffold`,
* 它传入的 `innerPadding` 已用于 NavHost 的 `Modifier.padding`,底部栏因此保持可见)。
* 页面内容全部在 `MetricDetailContent` 里,本函数只负责取状态与传回调(ledger Ruling R26)。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MetricDetailScreen(
metric: MetricType,
@@ -4025,13 +4178,34 @@ fun MetricDetailScreen(
viewModel: MetricDetailViewModel = viewModel(factory = MetricDetailViewModel.factory(metric)),
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
MetricDetailContent(
state = state,
onBack = onBack,
onSelectRange = viewModel::switchRange,
modifier = modifier,
)
}
/**
* 无状态指标详情内容:范围切换 + 柱状趋势图 + 统计摘要(+ 睡眠分期)。
*
* 标题取自 `state.metric` 而不是外面传进来的 `metric` —— 无状态层只有 `state` 一个数据来源,
* 免得出现「标题写心率、数据画睡眠」这种两者不一致的可能。
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MetricDetailContent(
state: MetricDetailUiState,
onBack: () -> Unit,
onSelectRange: (DateRange) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.background,
topBar = {
TopAppBar(
title = { Text(text = stringResource(metricTitleRes(metric))) },
title = { Text(text = stringResource(metricTitleRes(state.metric))) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
@@ -4058,7 +4232,7 @@ fun MetricDetailScreen(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { RangeSelector(current = state.range, onSelect = viewModel::switchRange) }
item { RangeSelector(current = state.range, onSelect = onSelectRange) }
item {
HealthCard {
SectionHeader(title = stringResource(R.string.detail_trend))
@@ -4105,8 +4279,12 @@ private fun SleepStagesCard(points: List<MetricPoint>, modifier: Modifier = Modi
StageLegend(label = stringResource(R.string.detail_sleep_light), tint = MetricBlue.copy(alpha = 0.45f))
StageLegend(label = stringResource(R.string.detail_sleep_awake), tint = MetricBlue.copy(alpha = 0.2f))
}
val fallAsleep = points.firstOrNull()?.timestamp
// 入睡/起床演示值(ledger Ruling R29):Mock 的睡眠历史点是「每天一个」,首点是 7 天前、
// 末点是现在。直接拿 first/last 当入睡/起床会显示成「睡了 7 天」,在验收时看着像 bug。
// 这里改成以最后一个数据点为起床时刻、往前推 8 小时作为入睡时刻 —— 一张卡片讲「一晚」。
// M3 接入真实睡眠数据后改为读取实际分期时段。
val wakeUp = points.lastOrNull()?.timestamp
val fallAsleep = wakeUp?.minus(DEMO_SLEEP_WINDOW_MILLIS)
if (fallAsleep != null && wakeUp != null) {
Row(
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
@@ -4127,6 +4305,9 @@ private fun SleepStagesCard(points: List<MetricPoint>, modifier: Modifier = Modi
}
}
/** 阶段一演示用的「一晚」时长:8 小时。M3 接入真实睡眠数据后删除。 */
private const val DEMO_SLEEP_WINDOW_MILLIS = 8L * 60 * 60 * 1000
/** 分期图例:小色块 + 名称。 */
@Composable
private fun StageLegend(label: String, tint: Color, modifier: Modifier = Modifier) {
@@ -4190,15 +4371,66 @@ private fun StatLine(label: String, value: Double, modifier: Modifier = Modifier
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(text = label, style = MaterialTheme.typography.bodyMedium, color = TextSecondary)
Text(text = String.format(java.util.Locale.CHINA, "%.1f", value), style = MaterialTheme.typography.bodyMedium)
// 数值格式化一律走 core/common/Formatters.kt(Step 1c),页面里不写 String.format:
// 那是格式串副本,改口径时要满仓库找。
Text(text = formatDecimal(value), style = MaterialTheme.typography.bodyMedium)
}
}
/** 空列表时返回 0.0,避免 NaN。 */
private fun List<Double>.averageOrZero(): Double = if (isEmpty()) 0.0 else average()
/** 睡眠指标的样例数据,供下面两个 Preview 共用。 */
private fun previewSleepPoints(): List<MetricPoint> {
val dayMillis = 24L * 60 * 60 * 1000
val base = 1_700_000_000_000L
return List(7) { index -> MetricPoint(timestamp = base + index * dayMillis, value = 420.0 + index * 12) }
}
/**
* 睡眠指标的详情页预览:走 `metric == SLEEP` 分支,带分期卡片。
* 取 SLEEP 不是随便挑的 —— 睡眠是唯一有专属区块的指标,预览它才能看见 `SleepStagesCard`。
*/
@Preview(showBackground = true)
@Composable
private fun MetricDetailContentSleepPreview() {
RingHealthTheme {
MetricDetailContent(
state = MetricDetailUiState(
loading = false,
metric = MetricType.SLEEP,
range = DateRange.WEEK,
points = previewSleepPoints(),
),
onBack = {},
onSelectRange = {},
)
}
}
/**
* 非睡眠指标的详情页预览:走「只有趋势图 + 统计摘要」的分支。
* 两个 Preview 各覆盖一个分支,是因为只看睡眠那个无法发现「分期卡片漏判 metric、所有指标都显示」这类回归。
*/
@Preview(showBackground = true)
@Composable
private fun MetricDetailContentStepsPreview() {
RingHealthTheme {
MetricDetailContent(
state = MetricDetailUiState(
loading = false,
metric = MetricType.STEP,
range = DateRange.WEEK,
points = previewSleepPoints(),
),
onBack = {},
onSelectRange = {},
)
}
}
```
- [ ] **Step 4: 在 `RingHealthApp.kt` 接入真实详情页**
- [ ] **Step 5: 在 `RingHealthApp.kt` 接入真实详情页**
```kotlin
composable(
@@ -4259,15 +4491,23 @@ Expected: `BUILD SUCCESSFUL`,2 项通过。
> 注意:详情页自带 `Scaffold` + `TopAppBar`,而外层 `RingHealthApp` 的 `Scaffold` 会传入 `innerPadding`(已用于 NavHost 的 `Modifier.padding`)。为避免底部导航重复遮罩,详情页在 NavHost 内会同时受外层 padding 影响——这是预期行为(底部栏保持可见)。
- [ ] **Step 5: 构建 + 验证**
- [ ] **Step 6: 构建 + 单测**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew assembleDebug
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest && ./gradlew assembleDebug
```
Expected: 从首页点任一指标卡进入详情页;顶部标题为指标中文名;近 7 天/近 30 天可切换,柱状图数量随之变化(7 根 / 30 根);**睡眠详情额外显示分期卡片**(深睡/浅睡/清醒分段条与图例、入睡/起床时间),其他指标不显示;返回键回到首页。
Expected: 两条命令均 `BUILD SUCCESSFUL`;全量测试 **21 项**通过
(Task 10 之后是 15 项,本任务 +3 `MetricDetailViewModelTest` +2 `RoutesTest` +1 `FormattersTest.formatDecimal`)。
- [ ] **Step 6: Commit**
界面上「从首页点任一指标卡进入详情页、标题为指标中文名、近 7 天/近 30 天可切换(柱状图 7 根 / 30 根)、
睡眠详情额外显示分期卡片而其他指标不显示、返回键回到首页」这几条**不在本步验证**
(ledger Ruling R21:本机无模拟器与真机),顺延到 Task 14 Step 3 的人工验收清单。
本步能确认的是两个 `@Preview`(睡眠分支 / 步数分支)能在 Android Studio 中渲染 —— 这是本机唯一能看见界面的手段。
> **不要用 `FROM-CACHE` 的绿当作证据。** 缓存命中时任务会标 `FROM-CACHE` 而不真正执行,看到就加 `--rerun`。
- [ ] **Step 7: Commit**
```bash
git add -A && git commit -m "feat: 实现指标详情子页(周/月切换 + 趋势图)"
@@ -4420,15 +4660,38 @@ Expected: 推送成功。
- Consumes: 前 13 个任务的全部产出
- Produces: 可交付的 M1 静态 App + 验收记录。
- [ ] **Step 1: 全量单元测试**
- [ ] **Step 1: 清扫孤儿字符串资源(R25/R27/R30 的收尾)**
Task 7 建了三个占位 Composable,Task 9/10/12 各自删掉一个,于是 `strings.xml` 里留下三条
**没有任何调用点**的字符串。R25 当时就定了处置方式:**不在各自任务里删,留到最后一次性清扫**
(那时三个任务互相看不见对方的删除)。现在就是「最后」。先 grep 确认真的没人用,再删:
```bash
cd /d/cherry/repos/ring-health-app
for k in placeholder_workout_history placeholder_device_settings placeholder_metric_detail; do
echo "--- $k"; grep -rn "R.string.$k" app/src || echo " (无调用点)"
done
```
三条都应当打印 `(无调用点)`。**若任何一条仍有调用点,就不要删它**,并在验收报告里说明原因。
确认无调用点后,从 `res/values/strings.xml` 里删掉这三行。
> 只有这三条。`strings.xml` 里其余字符串**每一条都必须有调用点** —— 若 grep 出别的孤儿,
> 那是某个任务漏了接线,**先查清再决定**,不要顺手一起删(可能是某个页面本该显示却没显示的文案)。
- [ ] **Step 2: 全量单元测试**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew :app:testDebugUnitTest
```
Expected: 全部通过(Formatters 7 项 + MockDataProvider 5 项 + HomeViewModel 1 项)。
Expected: 全部通过,共 **21 项**:`FormattersTest` 7 + `MockDataProviderTest` 5 + `HomeViewModelTest` 1
+ `WorkoutViewModelTest` 1 + `DeviceViewModelTest` 2 + `MetricDetailViewModelTest` 3 + `RoutesTest` 2。
(若实际数字与此不符,先查清是多了还是少了 —— 数字对不上通常意味着某个任务的测试没进来。
本行原先写的是「Formatters 7 + MockDataProvider 5 + HomeViewModel 1 = 13」,两处都错:
当时 `FormattersTest` 实际只有 6 项,而 6+5+1=12 才是 Task 8 结束时的真实总数,ledger Ruling R29 已更正。)
- [ ] **Step 2: 干净构建**
- [ ] **Step 3: 干净构建**
```bash
cd /d/cherry/repos/ring-health-app && ./gradlew clean assembleDebug
@@ -4436,7 +4699,7 @@ cd /d/cherry/repos/ring-health-app && ./gradlew clean assembleDebug
Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk`。
- [ ] **Step 3: 真机/模拟器人工验收(逐项打勾)**
- [ ] **Step 4: 真机/模拟器人工验收(逐项打勾)**
> **本步在本机当前状态下无法执行(ledger Ruling R21)。** 本机没有任何模拟器镜像,也没有连接真机
> (Android SDK 只装了 `platform-tools` / `platforms;android-36` / `build-tools;36.1.0`)。
@@ -4461,6 +4724,9 @@ Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk
- [ ] 首页:点击任一指标卡进入详情页,标题为该指标中文名
- [ ] 详情页:近 7 天显示 7 根柱子,切到近 30 天显示 30 根柱子
- [ ] 睡眠详情额外显示分期卡片(深睡/浅睡/清醒分段条 + 入睡/起床时间),其他指标没有这张卡
- [ ] 睡眠卡片的入睡/起床时间**读起来像「一晚」**(比如 入睡 22:41 / 起床 06:41),
而不是跨了好几天的两个日期 —— 阶段一没有真实分期数据,这里按「最后一个数据点往前 8 小时」
演示(R29)。若显示成跨天,说明 `DEMO_SLEEP_WINDOW_MILLIS` 那段被改坏了
- [ ] 详情页:返回后回到首页
- [ ] 运动页:显示“开始运动”卡片 + 5 条运动记录
- [ ] 运动页:“查看全部”进入历史页,底部导航栏仍可见
@@ -4469,7 +4735,7 @@ Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk
- [ ] 我的页:头像卡与两个分组入口正常显示
- [ ] 旋转屏幕/深色系统设置下不崩溃(深色样式不保证,但不得崩溃)
- [ ] **Step 4: 写验收记录 `repos/ring-health-app/docs/verification-m1.md`**
- [ ] **Step 5: 写验收记录 `repos/ring-health-app/docs/verification-m1.md`**
```markdown
# M1 静态页面验收记录
@@ -4501,7 +4767,7 @@ Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk
- 仅浅色模式
```
- [ ] **Step 5: 更新 App `README.md` 页面清单**
- [ ] **Step 6: 更新 App `README.md` 页面清单**
在 README 的“当前阶段”下补充:
@@ -4519,7 +4785,7 @@ Expected: `BUILD SUCCESSFUL`,生成 `app/build/outputs/apk/debug/app-debug.apk
| 指标详情(步数/心率/睡眠/血氧/压力/体温/血压) | `detail/{metric}` |
```
- [ ] **Step 6: 提交并推送全部仓库**
- [ ] **Step 7: 提交并推送全部仓库**
```bash
cd /d/cherry/repos/ring-health-app && git add -A && git commit -m "docs: 补充 M1 验收记录与页面清单" && git push