fix: 重写详情页 ViewModel 用例使其具备鉴别力并修正 Preview 夹具

审查指出第 3 条用例的唯一断言与 stateIn 的 initialValue 逐字相等(ledger R30 同类缺陷),
且三条用例都没有把构造函数的 metric 与 observeMetricHistory 的查询参数绑起来。
重写为「SLEEP 指标查到的是睡眠区间的数据」:点数断言杀掉「管线从不发射」,
取值区间断言(330~510,与步数 3000~12000 不相交)杀掉 metric 被写成常量的泄漏;
metric 回显断言保留但注释写明其无鉴别力。变异测试已证:把第 43 行的 metric 换成
MetricType.STEP 后该用例失败、另两条仍绿,回退后 3/3 通过。

顺带按 Minor 意见修正 previewPoints:KDoc 原文「贴近区间下限」不实,且步数夹具
7500~7572 在柱状图里渲染成齐平一排。改为带 step 形参,睡眠 330→480、步数 3200→11000,
两个 Preview 的 metric 分支覆盖不变。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-10 12:43:04 +08:00
co-authored by Claude Code
parent 01689b24fc
commit 0e17502334
2 changed files with 38 additions and 11 deletions
@@ -270,13 +270,17 @@ private fun StatLine(label: String, value: Double, modifier: Modifier = Modifier
private fun List<Double>.averageOrZero(): Double = if (isEmpty()) 0.0 else average()
/**
* Preview 用的样例数据:7 个点、每天一个、值从 `base` 起小幅递增。
* 取值贴近区间下限,好让柱状图有高低差、统计摘要的三行数字不相等(都相等就看不出渲染问题)。
* Preview 用的样例数据:7 个点、每天一个,取值从 `base` 起每点递增 `step`。
*
* `base` 与 `step` 由调用方按**所在指标的取值区间**给出:`base` 取区间低位,
* `step` 取区间跨度的约 1/7,使 7 个点铺满区间的大半。这样柱状图才有可见的高低差
* (`MetricBarChart` 按 `value / maxValue` 定高,点值若都挤在一起,7 根柱子会齐平,
* 预览就看不出渲染问题),统计摘要的「平均/最高/最低」三行也不会几乎相等。
*/
private fun previewPoints(base: Double): List<MetricPoint> {
private fun previewPoints(base: Double, step: Double): List<MetricPoint> {
val dayMillis = 24L * 60 * 60 * 1000
val start = 1_700_000_000_000L
return List(7) { index -> MetricPoint(timestamp = start + index * dayMillis, value = base + index * 12) }
return List(7) { index -> MetricPoint(timestamp = start + index * dayMillis, value = base + index * step) }
}
/**
@@ -292,7 +296,9 @@ private fun MetricDetailContentSleepPreview() {
loading = false,
metric = MetricType.SLEEP,
range = DateRange.WEEK,
points = previewPoints(base = 420.0), // 睡眠时长(分钟),与 Mock 的 330~510 同量级
// 睡眠时长(分钟):330→480,落在 Mock 的 330~510 区间内,跨度 150 分钟,
// 柱高从约 69% 递增到 100%,肉眼能看出梯度。
points = previewPoints(base = 330.0, step = 25.0),
),
onBack = {},
onSelectRange = {},
@@ -313,7 +319,10 @@ private fun MetricDetailContentStepsPreview() {
loading = false,
metric = MetricType.STEP,
range = DateRange.WEEK,
points = previewPoints(base = 7500.0), // 步数,与 Mock 的 3000~12000 同量级
// 步数:3200→11000,落在 Mock 的 3000~12000 区间内,跨度 7800 步,
// 柱高从约 27% 递增到 100%。原来的 7500 起点会让 7 根柱子全挤在 99%~100% 高度上,
// 看起来是一条平带 —— 恰是预览本该暴露的问题。
points = previewPoints(base = 3200.0, step = 1300.0),
),
onBack = {},
onSelectRange = {},
@@ -14,6 +14,7 @@ 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
@@ -69,15 +70,32 @@ class MetricDetailViewModelTest {
}
@Test
fun `构造时传入的 metric 被原样带进状态`() = runTest(dispatcher) {
// 名字就叫它断言的事,不要写成「标题映射不同」——`metricTitleRes` 是 @Composable,
// JVM 测试里根本调不了,那样命名会让人以为这里验了标题映射(其实没有,
// 映射本身由 `when` 的穷尽性在编译期保证)。本用例只锁「metric 被原样带进状态」。
fun `SLEEP 指标查到的是睡眠区间的数据`() = runTest(dispatcher) {
// 名字不叫「构造时传入的 metric 被原样带进状态」——本用例真正锁的是
// 「构造函数的 metric 被传给了仓库查询」,而不是状态字段回显(ledger R34)。
// 也不要写成「标题映射不同」:`metricTitleRes` 是 @Composable,JVM 测试里根本调不了,
// 那样命名会让人以为这里验了标题映射(其实没有,映射由 `when` 的穷尽性在编译期保证)。
val viewModel = MetricDetailViewModel(MetricType.SLEEP, MockHealthDataRepository())
backgroundScope.launch { viewModel.uiState.collect {} }
advanceUntilIdle()
assertEquals(MetricType.SLEEP, viewModel.uiState.value.metric)
val state = viewModel.uiState.value
// 承重断言一:`initialValue.points` 是 emptyList(),所以数据管线
// (observeMetricHistory / flatMapLatest / map)只要不发射,这一条必挂。
assertEquals(7, state.points.size)
// 承重断言二:上面那条只证明「有数据」,不证明「数据问对了指标」。
// `observeMetricHistory(metric, currentRange)` 里若把 metric 写成常量(如 MetricType.STEP),
// 点数仍是 7(点数只由 range 决定),只有取值区间会变 —— Mock 按指标区间生成并 coerceIn,
// 睡眠是 330~510、步数是 3000~12000,两者不相交(见 MockDataProvider.valueRange)。
// 注意 `all {}` 对空列表恒真,所以它必须与上面那条 size 断言成对出现,不能单用。
assertTrue(
"SLEEP 详情页拿到了区间外的值:${state.points.map { it.value }}",
state.points.all { it.value in 330.0..510.0 },
)
// 这一条单看**没有鉴别力**:`state.metric` 就是构造函数传进来的值,`stateIn` 的
// initialValue 里已经有它,数据管线从不发射时它照样成立(ledger R30)。保留它只是为了
// 钉住「状态里的 metric 与构造函数一致」,真正承重的是上面两条。
assertEquals(MetricType.SLEEP, state.metric)
}
}