From 01689b24fc7f234d2f796ffe9da70b4d7a5eec48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B4=94=E9=B2=81=E6=AD=A3?= <13780741225@163.com> Date: Thu, 10 Sep 2026 12:27:00 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E6=8C=87=E6=A0=87?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=AD=90=E9=A1=B5=EF=BC=88=E5=91=A8/?= =?UTF-8?q?=E6=9C=88=E5=88=87=E6=8D=A2=20+=20=E8=B6=8B=E5=8A=BF=E5=9B=BE?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 替换 MetricDetailPlaceholder 为真实详情页:范围切换、柱状趋势图、平均/最高/最低摘要, 睡眠指标额外渲染深睡/浅睡/清醒分期条与入睡/起床时间。 新增 MetricDetailViewModel(flatMapLatest 重新订阅数据源)与其 3 条 JVM 测试、 RoutesTest(锁住路由模板与 Routes.detail() 拼串)、Formatters.formatDecimal 与其测试。 接入 RingHealthApp 的 DETAIL_PATTERN,参数无法解析时直接抛而不是静默兜底。 Co-Authored-By: Claude Code --- .../ringhealth/core/common/Formatters.kt | 3 + .../core/navigation/RingHealthApp.kt | 15 +- .../feature/detail/MetricDetailScreen.kt | 311 +++++++++++++++++- .../feature/detail/MetricDetailViewModel.kt | 82 +++++ app/src/main/res/values/strings.xml | 14 + .../ringhealth/core/common/FormattersTest.kt | 7 + .../ringhealth/core/navigation/RoutesTest.kt | 25 ++ .../detail/MetricDetailViewModelTest.kt | 83 +++++ 8 files changed, 532 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModel.kt create mode 100644 app/src/test/java/com/placeholder/ringhealth/core/navigation/RoutesTest.kt create mode 100644 app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt diff --git a/app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt b/app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt index 776d2dc..d2ec998 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/common/Formatters.kt @@ -45,6 +45,9 @@ fun formatClock(timestamp: Long): String = fun formatDate(timestamp: Long): String = SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp)) +/** 数值 → 保留一位小数。详情页的统计摘要用(ledger Ruling R29)。 */ +fun formatDecimal(value: Double): String = String.format(Locale.CHINA, "%.1f", value) + /** 压力值 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。 */ @StringRes fun stressLevelLabelRes(stress: Int): Int = when { diff --git a/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt b/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt index 6965db3..6b7ae6f 100644 --- a/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt +++ b/app/src/main/java/com/placeholder/ringhealth/core/navigation/RingHealthApp.kt @@ -29,7 +29,7 @@ import com.placeholder.ringhealth.R import com.placeholder.ringhealth.domain.model.MetricType import com.placeholder.ringhealth.feature.device.DeviceScreen import com.placeholder.ringhealth.feature.device.DeviceSettingsScreen -import com.placeholder.ringhealth.feature.detail.MetricDetailPlaceholder +import com.placeholder.ringhealth.feature.detail.MetricDetailScreen import com.placeholder.ringhealth.feature.home.HomeScreen import com.placeholder.ringhealth.feature.profile.ProfileScreen import com.placeholder.ringhealth.feature.workout.WorkoutHistoryScreen @@ -114,9 +114,16 @@ fun RingHealthApp(navController: NavHostController = rememberNavController()) { route = Routes.DETAIL_PATTERN, arguments = listOf(navArgument(Routes.DETAIL_ARG_METRIC) { type = NavType.StringType }), ) { entry -> - val raw = entry.arguments?.getString(Routes.DETAIL_ARG_METRIC).orEmpty() - val metric = MetricType.entries.firstOrNull { it.name == raw } ?: MetricType.STEP - MetricDetailPlaceholder(metric = metric) + // 参数无法解析属于编程错误,直接抛(ledger Ruling R22):这里原先是 + // `?: MetricType.STEP`,一旦路由模板与 Routes.detail() 拼串不一致, + // 页面会静默显示步数的数据而不报错 —— 那是「错数据」而不是「响亮失败」。 + val raw = requireNotNull(entry.arguments?.getString(Routes.DETAIL_ARG_METRIC)) { + "详情路由缺少参数 ${Routes.DETAIL_ARG_METRIC}" + } + val metric = requireNotNull(MetricType.entries.firstOrNull { it.name == raw }) { + "无法识别的指标:$raw" + } + MetricDetailScreen(metric = metric, onBack = { navController.popBackStack() }) } } } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailScreen.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailScreen.kt index 62e2e6d..ba011d9 100644 --- a/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailScreen.kt +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailScreen.kt @@ -1,19 +1,322 @@ 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.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment 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 +import com.placeholder.ringhealth.core.designsystem.component.SectionHeader +import com.placeholder.ringhealth.core.designsystem.component.StatBar +import com.placeholder.ringhealth.core.designsystem.component.StatSegment +import com.placeholder.ringhealth.domain.model.DateRange +import com.placeholder.ringhealth.domain.model.MetricPoint import com.placeholder.ringhealth.domain.model.MetricType -/** 指标详情子页(占位,待 Task 12 实现)。 */ +/** + * 指标详情页(有状态外壳)。 + * + * 本页自带 `Scaffold` + `TopAppBar`(外层 `RingHealthApp` 也有一个 `Scaffold`, + * 它传入的 `innerPadding` 已用于 NavHost 的 `Modifier.padding`,底部栏因此保持可见)。 + * 页面内容全部在 `MetricDetailContent` 里,本函数只负责取状态与传回调(ledger Ruling R26)。 + */ @Composable -fun MetricDetailPlaceholder(metric: MetricType, modifier: Modifier = Modifier) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text(stringResource(R.string.placeholder_metric_detail)) +fun MetricDetailScreen( + metric: MetricType, + onBack: () -> Unit, + modifier: Modifier = Modifier, + 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(state.metric))) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) + } + }, + ) + }, + ) { innerPadding -> + if (state.loading) { + Box( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator(color = MetricGreen) + } + return@Scaffold + } + + LazyColumn( + modifier = Modifier.fillMaxSize().padding(innerPadding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { RangeSelector(current = state.range, onSelect = onSelectRange) } + item { + HealthCard { + SectionHeader(title = stringResource(R.string.detail_trend)) + MetricBarChart( + values = state.points.map { it.value.toFloat() }, + color = MetricGreen, + ) + } + } + item { + HealthCard { + val values = state.points.map { it.value } + StatLine(label = stringResource(R.string.detail_average), value = values.averageOrZero()) + StatLine(label = stringResource(R.string.detail_max), value = values.maxOrNull() ?: 0.0) + StatLine(label = stringResource(R.string.detail_min), value = values.minOrNull() ?: 0.0) + } + } + if (state.metric == MetricType.SLEEP) { + item { SleepStagesCard(points = state.points) } + } + } + } +} + +/** + * 睡眠分期卡片:深睡/浅睡/清醒分段条 + 入睡/起床时间(对应 spec §6 的 `detail/sleep`)。 + */ +@Composable +private fun SleepStagesCard(points: List, modifier: Modifier = Modifier) { + // 阶段一没有真实分期数据,按固定比例演示;M3 接入真实睡眠数据后改为按实际时段计算。 + val segments = listOf( + StatSegment(weight = 0.25f, color = MetricBlue), + StatSegment(weight = 0.65f, color = MetricBlue.copy(alpha = 0.45f)), + StatSegment(weight = 0.10f, color = MetricBlue.copy(alpha = 0.2f)), + ) + HealthCard(modifier = modifier) { + SectionHeader(title = stringResource(R.string.detail_sleep_stages)) + StatBar(segments = segments) + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + StageLegend(label = stringResource(R.string.detail_sleep_deep), tint = MetricBlue) + 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)) + } + // 入睡/起床演示值(ledger Ruling R29):Mock 的睡眠历史点是「每天一个」,首点是 7 天前、 + // 末点是现在。直接拿 first/last 当入睡/起床会显示成「睡了 7 天」,在验收时看着像 bug。 + // 这里改成以最后一个数据点为起床时刻、往前推 8 小时作为入睡时刻 —— 一张卡片讲「一晚」。 + // M3 接入真实睡眠数据后改为读取实际分期时段。 + val wakeUp = points.lastOrNull()?.timestamp + if (wakeUp != null) { + // 只有拿到起床时刻才渲染这一行;入睡时刻由它推出,所以判空只需判 wakeUp + // (brief 原文写的是 `fallAsleep != null && wakeUp != null`,Kotlin 会因其恒真而告警, + // 语义与此处完全等价)。 + val fallAsleep = wakeUp - DEMO_SLEEP_WINDOW_MILLIS + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "${stringResource(R.string.detail_sleep_fall_asleep)} ${formatClock(fallAsleep)}", + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + ) + Text( + text = "${stringResource(R.string.detail_sleep_wake_up)} ${formatClock(wakeUp)}", + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + ) + } + } + } +} + +/** 阶段一演示用的「一晚」时长:8 小时。M3 接入真实睡眠数据后删除。 */ +private const val DEMO_SLEEP_WINDOW_MILLIS = 8L * 60 * 60 * 1000 + +/** 分期图例:小色块 + 名称。 */ +@Composable +private fun StageLegend(label: String, tint: Color, modifier: Modifier = Modifier) { + Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .clip(RoundedCornerShape(5.dp)) + .background(tint), + ) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = TextSecondary, + modifier = Modifier.padding(start = 6.dp), + ) + } +} + +/** 指标类型 → 标题字符串资源。文案的唯一来源是 `strings.xml`(ledger Ruling R3)。 */ +@Composable +private fun metricTitleRes(type: MetricType): Int = when (type) { + MetricType.STEP -> R.string.metric_step + MetricType.HEART_RATE -> R.string.metric_heart_rate + MetricType.SLEEP -> R.string.metric_sleep + MetricType.BLOOD_OXYGEN -> R.string.metric_blood_oxygen + MetricType.STRESS -> R.string.metric_stress + MetricType.TEMPERATURE -> R.string.metric_temperature + MetricType.BLOOD_PRESSURE -> R.string.metric_blood_pressure +} + +/** 周/月切换。 */ +@Composable +private fun RangeSelector( + current: DateRange, + onSelect: (DateRange) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = current == DateRange.WEEK, + onClick = { onSelect(DateRange.WEEK) }, + label = { Text(stringResource(R.string.detail_range_week)) }, + ) + FilterChip( + selected = current == DateRange.MONTH, + onClick = { onSelect(DateRange.MONTH) }, + label = { Text(stringResource(R.string.detail_range_month)) }, + ) + } +} + +/** 一行统计摘要。 */ +@Composable +private fun StatLine(label: String, value: Double, modifier: Modifier = Modifier) { + Row( + modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = label, style = MaterialTheme.typography.bodyMedium, color = TextSecondary) + // 数值格式化一律走 core/common/Formatters.kt(Step 1c),页面里不写 String.format: + // 那是格式串副本,改口径时要满仓库找。 + Text(text = formatDecimal(value), style = MaterialTheme.typography.bodyMedium) + } +} + +/** 空列表时返回 0.0,避免 NaN。 */ +private fun List.averageOrZero(): Double = if (isEmpty()) 0.0 else average() + +/** + * Preview 用的样例数据:7 个点、每天一个、值从 `base` 起小幅递增。 + * 取值贴近区间下限,好让柱状图有高低差、统计摘要的三行数字不相等(都相等就看不出渲染问题)。 + */ +private fun previewPoints(base: Double): List { + 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) } +} + +/** + * 睡眠指标的详情页预览:走 `metric == SLEEP` 分支,带分期卡片。 + * 取 SLEEP 不是随便挑的 —— 睡眠是唯一有专属区块的指标,预览它才能看见 `SleepStagesCard`。 + */ +@Preview(showBackground = true) +@Composable +private fun MetricDetailContentSleepPreview() { + RingHealthTheme { + MetricDetailContent( + state = MetricDetailUiState( + loading = false, + metric = MetricType.SLEEP, + range = DateRange.WEEK, + points = previewPoints(base = 420.0), // 睡眠时长(分钟),与 Mock 的 330~510 同量级 + ), + onBack = {}, + onSelectRange = {}, + ) + } +} + +/** + * 非睡眠指标的详情页预览:走「只有趋势图 + 统计摘要」的分支。 + * 两个 Preview 各覆盖一个分支,是因为只看睡眠那个无法发现「分期卡片漏判 metric、所有指标都显示」这类回归。 + */ +@Preview(showBackground = true) +@Composable +private fun MetricDetailContentStepsPreview() { + RingHealthTheme { + MetricDetailContent( + state = MetricDetailUiState( + loading = false, + metric = MetricType.STEP, + range = DateRange.WEEK, + points = previewPoints(base = 7500.0), // 步数,与 Mock 的 3000~12000 同量级 + ), + onBack = {}, + onSelectRange = {}, + ) } } diff --git a/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModel.kt b/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModel.kt new file mode 100644 index 0000000..082bd8e --- /dev/null +++ b/app/src/main/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModel.kt @@ -0,0 +1,82 @@ +package com.placeholder.ringhealth.feature.detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.placeholder.ringhealth.domain.model.DateRange +import com.placeholder.ringhealth.domain.model.MetricPoint +import com.placeholder.ringhealth.domain.model.MetricType +import com.placeholder.ringhealth.domain.repository.HealthDataRepository +import com.placeholder.ringhealth.ringHealthApplication +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update + +/** 指标详情 UI 状态。 */ +data class MetricDetailUiState( + val loading: Boolean, + val metric: MetricType, + val range: DateRange, + val points: List, +) + +/** + * 指标详情 ViewModel。因需要指标参数,使用独立工厂创建。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MetricDetailViewModel( + private val metric: MetricType, + healthDataRepository: HealthDataRepository, +) : ViewModel() { + + private val range = MutableStateFlow(DateRange.WEEK) + + val uiState: StateFlow = range + .flatMapLatest { currentRange -> + healthDataRepository.observeMetricHistory(metric, currentRange) + .map { points -> + MetricDetailUiState( + loading = false, + metric = metric, + range = currentRange, + points = points, + ) + } + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), + initialValue = MetricDetailUiState( + loading = true, + metric = metric, + range = DateRange.WEEK, + points = emptyList(), + ), + ) + + /** 切换时间范围(周/月)。 */ + fun switchRange(newRange: DateRange) { + range.update { newRange } + } + + companion object { + private const val STOP_TIMEOUT_MILLIS = 5_000L + + /** 为指定指标构造 ViewModel 工厂。 */ + fun factory(metric: MetricType): ViewModelProvider.Factory = viewModelFactory { + initializer { + MetricDetailViewModel( + metric = metric, + healthDataRepository = ringHealthApplication().container.healthDataRepository, + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a71cb89..1992dda 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -67,4 +67,18 @@ 当前版本 0.1.0 帮助与反馈 常见问题与意见反馈 + + 近 7 天 + 近 30 天 + 平均 + 最高 + 最低 + 趋势 + 睡眠分期 + 深睡 + 浅睡 + 清醒 + 入睡时间 + 起床时间 + 返回 diff --git a/app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt b/app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt index 05a1b00..bf7b134 100644 --- a/app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt +++ b/app/src/test/java/com/placeholder/ringhealth/core/common/FormattersTest.kt @@ -39,6 +39,13 @@ class FormattersTest { assertEquals("1,234", formatCalories(1234)) } + @Test + fun `formatDecimal 保留一位小数`() { + assertEquals("72.0", formatDecimal(72.0)) + assertEquals("98.6", formatDecimal(98.56)) + assertEquals("0.0", formatDecimal(0.0)) + } + @Test fun `stressLevelLabelRes 按区间映射到对应字符串资源`() { assertEquals(R.string.stress_relaxed, stressLevelLabelRes(20)) diff --git a/app/src/test/java/com/placeholder/ringhealth/core/navigation/RoutesTest.kt b/app/src/test/java/com/placeholder/ringhealth/core/navigation/RoutesTest.kt new file mode 100644 index 0000000..2233c0d --- /dev/null +++ b/app/src/test/java/com/placeholder/ringhealth/core/navigation/RoutesTest.kt @@ -0,0 +1,25 @@ +package com.placeholder.ringhealth.core.navigation + +import com.placeholder.ringhealth.domain.model.MetricType +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 路由表的一致性测试:模板与实际拼出的路由必须始终吻合(ledger Ruling R22)。 */ +class RoutesTest { + + @Test + fun `detail 拼出的路由能被详情模板匹配`() { + val expectedPrefix = Routes.DETAIL_PATTERN.substringBefore("{") + MetricType.entries.forEach { metric -> + val route = Routes.detail(metric) + assertTrue("$route 不以 $expectedPrefix 开头", route.startsWith(expectedPrefix)) + assertEquals(metric.name, route.removePrefix(expectedPrefix)) + } + } + + @Test + fun `占位符名与参数常量一致`() { + assertTrue(Routes.DETAIL_PATTERN.contains("{${Routes.DETAIL_ARG_METRIC}}")) + } +} diff --git a/app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt b/app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt new file mode 100644 index 0000000..8b32593 --- /dev/null +++ b/app/src/test/java/com/placeholder/ringhealth/feature/detail/MetricDetailViewModelTest.kt @@ -0,0 +1,83 @@ +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 `构造时传入的 metric 被原样带进状态`() = runTest(dispatcher) { + // 名字就叫它断言的事,不要写成「标题映射不同」——`metricTitleRes` 是 @Composable, + // JVM 测试里根本调不了,那样命名会让人以为这里验了标题映射(其实没有, + // 映射本身由 `when` 的穷尽性在编译期保证)。本用例只锁「metric 被原样带进状态」。 + val viewModel = MetricDetailViewModel(MetricType.SLEEP, MockHealthDataRepository()) + backgroundScope.launch { viewModel.uiState.collect {} } + + advanceUntilIdle() + + assertEquals(MetricType.SLEEP, viewModel.uiState.value.metric) + } +}