Compare commits

..
4 Commits
Author SHA1 Message Date
cuiluzheng 3d39fd3445 docs: 让 R17 的时钟注入真正可测 + 记录刷新语义(ledger R20)
- MockDataProviderTest 增加两条绝对时间戳断言;只比较 first==second 时,
  两次相邻调用几乎总在同一毫秒内,去掉时钟注入测试照样通过
- MockRingDeviceRepository 类级 KDoc 写明「刷新以 suspend 返回表示完成,
  不靠发射新状态」,避免调用方写成等待下一个不同发射而在 M1 永久挂起
2026-09-10 10:47:43 +08:00
cuiluzheng e43b76efab docs: 修正 Task 7 的 Produces 契约(ledger R19)
Interfaces 段原写 HomeScreen(onOpenDetail, onOpenWorkoutHistory),
但全计划无 onOpenWorkoutHistory 的任何调用点,Task 8 产出的是
HomeScreen(onOpenDetail, modifier, viewModel)。同时 WorkoutScreen/DeviceScreen
的 onOpenHistory/onOpenSettings 实为 Task 9/10 才加入,本任务只注册路由、无人跳转。
2026-09-10 10:45:06 +08:00
cuiluzheng c71c3c32f6 docs: Mock 数据源注入时钟,消除 flaky 断言(ledger R17)
- MockDataProvider 增加 now: () -> Long = System::currentTimeMillis
- metricHistory / workouts 改用 now(),使「确定性」承诺成立
- 测试注入 FIXED_NOW,并补一条时间戳严格递增的断言
2026-09-10 10:40:55 +08:00
cuiluzheng 42bc9fe12e docs: 修正格式化层与占位页的硬编码文案(ledger R13/R14)
- formatCalories 不再拼接「千卡」(与 R.string.home_calories_label 重复),
  workout_detail_calories 改为「消耗 %1$s 千卡」
- stressLevelLabel(String) 改为 @StringRes stressLevelLabelRes(Int),描述进 strings.xml
- Global Constraints 写明唯一豁免(formatSleepDuration/formatDuration 的复合时长值)及判据
- Task 7 的 7 个占位 Composable 改用 stringResource,补齐 import 与 3 条占位文案
2026-09-10 10:32:55 +08:00
@@ -18,6 +18,13 @@
- **工具链钉死**:Gradle `8.14`、AGP `8.13.1`、Kotlin `2.2.20`(三者均为本机缓存版本)。
- **依赖约束**:阶段一**不引入** Hilt、Room、Retrofit、第三方图表库。图表一律用 Compose `Canvas` 手绘。
- **不硬编码文案**:所有用户可见文字进 `res/values/strings.xml`(默认中文)。
**唯一豁免**(见 ledger Ruling R13):`core/common/Formatters.kt` 中
`formatSleepDuration`(`7小时32分`)与 `formatDuration`(`1小时05分`)这两条 ——
它们是数字与单位**黏合成单一复合值**、且设计系统没有独立单位槽位可放的时长排版,
拆开会把同一段 `when` 逻辑复制进每个调用点。除此之外,格式化函数**一律不得**拼接
用户可见文字:单位与描述词要么由调用点用资源提供(如 `formatCalories` 的「千卡」),
要么以 `@StringRes` 返回资源 id(如 `stressLevelLabelRes`)。判据是
「该文字是否已在 `strings.xml` 里有第二份」与「设计系统是否已提供独立槽位」。
- **禁止行内样式散落**:颜色/字号统一定义在 `core/designsystem/`,页面不得直接写 `Color(0xFF...)`。
- **命名规范**:类/Composable 用 PascalCase;函数/变量用 camelCase;资源文件用 kebab-case/snake_case。
`const val` 编译期常量用 UPPER_SNAKE_CASE。
@@ -1165,12 +1172,20 @@ git add -A && git commit -m "feat: 新增领域模型与仓库接口"
- `formatDuration(minutes: Int): String`
- `formatClock(timestamp: Long): String`
- `formatDate(timestamp: Long): String`
- `stressLevelLabel(stress: Int): String`
- `stressLevelLabelRes(stress: Int): Int`(带 `@StringRes` 标注)
> **范围说明(见 ledger Ruling R3)**:本任务**不提供** `metricDisplayName(type)`。
> 指标中文名属于用户可见文案,Global Constraints 要求一律进 `strings.xml`;
> 把它硬编码在 Kotlin 里会与 Task 8 加入 `R.string.metric_*` 形成两份事实来源。
> 详情页标题改用资源 id 映射,见 Task 12。
>
> **范围说明(见 ledger Ruling R13)**:本任务**不得**在 Kotlin 里拼接用户可见文字,两处按此调整:
> ①`formatCalories` 只返回千分位数字,「千卡」由调用点用资源拼接 —— 硬编码会与
> `R.string.home_calories_label` 形成两份事实来源,正是 R3 排除 `metricDisplayName` 的同一条理由;
> ②压力等级**改为返回字符串资源 id**(`stressLevelLabelRes`),中文描述进 `strings.xml`,
> 与 R3 对指标名的处理保持一致。**唯一豁免**见 Global Constraints 的说明
> (`formatSleepDuration` / `formatDuration` 的「7小时32分」这类数字+单位黏合复合值)。
> 本任务同时负责新增这几条字符串资源(见 Step 3)。
- [ ] **Step 1: 写失败的测试**
@@ -1179,6 +1194,7 @@ git add -A && git commit -m "feat: 新增领域模型与仓库接口"
```kotlin
package com.placeholder.ringhealth.core.common
import com.placeholder.ringhealth.R
import org.junit.Assert.assertEquals
import org.junit.Test
@@ -1212,16 +1228,17 @@ class FormattersTest {
}
@Test
fun `formatCalories 添加单位`() {
assertEquals("327 千卡", formatCalories(327))
fun `formatCalories 只返回千分位数字`() {
assertEquals("327", formatCalories(327))
assertEquals("1,234", formatCalories(1234))
}
@Test
fun `stressLevelLabel 按区间返回中文描述`() {
assertEquals("放松", stressLevelLabel(20))
assertEquals("正常", stressLevelLabel(45))
assertEquals("中等", stressLevelLabel(65))
assertEquals("偏高", stressLevelLabel(90))
fun `stressLevelLabelRes 按区间映射到对应字符串资源`() {
assertEquals(R.string.stress_relaxed, stressLevelLabelRes(20))
assertEquals(R.string.stress_normal, stressLevelLabelRes(45))
assertEquals(R.string.stress_medium, stressLevelLabelRes(65))
assertEquals(R.string.stress_high, stressLevelLabelRes(90))
}
}
```
@@ -1241,6 +1258,8 @@ Expected: 编译失败或测试失败(`formatSleepDuration` 等未定义 / `Un
```kotlin
package com.placeholder.ringhealth.core.common
import androidx.annotation.StringRes
import com.placeholder.ringhealth.R
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -1251,8 +1270,8 @@ fun formatSteps(steps: Int): String = String.format(Locale.CHINA, "%,d", steps)
/** 距离:保留两位小数(单位 km 由 UI 单独展示)。 */
fun formatDistance(km: Double): String = String.format(Locale.CHINA, "%.2f", km)
/** 卡路里:带中文单位。 */
fun formatCalories(kcal: Int): String = "$kcal 千卡"
/** 卡路里:只做千分位,单位「千卡」由 UI 用字符串资源拼接(ledger Ruling R13)。 */
fun formatCalories(kcal: Int): String = String.format(Locale.CHINA, "%,d", kcal)
/** 睡眠时长:7小时32分 / 45分 / 8小时。 */
fun formatSleepDuration(minutes: Int): String {
@@ -1284,15 +1303,25 @@ fun formatClock(timestamp: Long): String =
fun formatDate(timestamp: Long): String =
SimpleDateFormat("yyyy-MM-dd", Locale.CHINA).format(Date(timestamp))
/** 压力值 → 中文等级。 */
fun stressLevelLabel(stress: Int): String = when {
stress < 30 -> "放松"
stress < 60 -> "正常"
stress < 80 -> "中等"
else -> "偏高"
/** 压力值 → 字符串资源 id。中文描述的唯一来源是 `strings.xml`(ledger Ruling R13)。 */
@StringRes
fun stressLevelLabelRes(stress: Int): Int = when {
stress < 30 -> R.string.stress_relaxed
stress < 60 -> R.string.stress_normal
stress < 80 -> R.string.stress_medium
else -> R.string.stress_high
}
```
同时把本任务新增的文案追加进 `app/src/main/res/values/strings.xml` 的 `<resources>` 内:
```xml
<string name="stress_relaxed">放松</string>
<string name="stress_normal">正常</string>
<string name="stress_medium">中等</string>
<string name="stress_high">偏高</string>
```
- [ ] **Step 4: 运行测试,确认通过**
```bash
@@ -1321,10 +1350,17 @@ git add -A && git commit -m "feat: 新增数值格式化工具(含单元测试
- Consumes: `domain/model/*`、`domain/repository/*`
- Produces:
- `interface AppContainer { healthDataRepository; ringDeviceRepository }`、`class DefaultAppContainer : AppContainer`、`RingHealthApplication.container`
- `class MockDataProvider(seed: Long = DEFAULT_SEED)`,方法:`todaySummary(): HealthSummary`、`metricHistory(metric: MetricType, range: DateRange): List<MetricPoint>`、`workouts(): List<WorkoutRecord>`、`deviceState(): RingDeviceState`、`defaultFunctionSettings(): List<DeviceFunctionSetting>`
- `class MockDataProvider(seed: Long = DEFAULT_SEED, now: () -> Long = System::currentTimeMillis)`,方法:`todaySummary(): HealthSummary`、`metricHistory(metric: MetricType, range: DateRange): List<MetricPoint>`、`workouts(): List<WorkoutRecord>`、`deviceState(): RingDeviceState`、`defaultFunctionSettings(): List<DeviceFunctionSetting>`
- `class MockHealthDataRepository : HealthDataRepository`(每次发射前 `delay(200)`)
- `class MockRingDeviceRepository : RingDeviceRepository`(开关状态保存在内存 StateFlow)
> **范围说明(见 ledger Ruling R17)**:`MockDataProvider` 增加一个**可注入的时间源** `now: () -> Long`
> (默认 `System::currentTimeMillis`),`metricHistory` / `workouts` 内一律用 `now()` 而非
> `System.currentTimeMillis()`。原因:本类的 KDoc 承诺「所有方法都是确定性的(同参数返回同结果)」,
> 但直接读挂钟时 `timestamp` 会随毫秒跳动 —— brief 原先的测试 `assertEquals(first, second)`
> 因此是一条**间歇失败的 flaky 断言**(两次调用跨毫秒即挂)。注入时间源后该承诺才成立,
> 测试也才能传固定时钟、真正验证「可重复」。这是本任务唯一的接口增补。
- [ ] **Step 1: 写失败的测试**
`app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt`:
@@ -1340,7 +1376,16 @@ import org.junit.Test
class MockDataProviderTest {
private val provider = MockDataProvider()
/**
* 固定时钟。Mock 的「确定性」承诺依赖时间源固定,否则 `timestamp` 会随毫秒跳动
* (ledger Ruling R17)。
*/
private val provider = MockDataProvider(now = { FIXED_NOW })
private companion object {
/** 固定的“当前时间”,让历史数据的时间戳可复现。 */
const val FIXED_NOW = 1_760_000_000_000L
}
@Test
fun `今日概览返回固定演示数据`() {
@@ -1366,6 +1411,13 @@ class MockDataProviderTest {
val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK)
assertEquals(first, second)
assertTrue(first.all { it.value > 40.0 && it.value < 180.0 })
// 时间戳须严格递增(“每点间隔一天”倒推,最早的在最前)
assertTrue(first.zipWithNext().all { (a, b) -> b.timestamp > a.timestamp })
// 断言绝对时间戳,让注入的时钟真正成为被测对象:若退回 System.currentTimeMillis(),
// 这两条会立刻失败(ledger R17 / R20)。只比较 first == second 是不够的 ——
// 两次相邻调用几乎总落在同一毫秒,那时去掉时钟注入测试照样通过。
assertEquals(FIXED_NOW, first.last().timestamp)
assertEquals(FIXED_NOW - 6L * 24 * 60 * 60 * 1000, first.first().timestamp)
}
@Test
@@ -1408,10 +1460,17 @@ import com.placeholder.ringhealth.domain.model.WorkoutRecord
import kotlin.random.Random
/**
* 演示数据生成器。所有方法都是确定性的(同参数返回同结果),
* 演示数据生成器。所有方法都是确定性的(同参数、同时刻返回同结果),
* 保证页面展示与截图稳定。M2 接入真机后本类整体弃用。
*
* @param seed 随机种子,决定历史数据的取值序列。
* @param now 时间源,默认取系统时钟;测试注入固定值后可得到完全可复现的时间戳
* (ledger Ruling R17)。
*/
class MockDataProvider(private val seed: Long = DEFAULT_SEED) {
class MockDataProvider(
private val seed: Long = DEFAULT_SEED,
private val now: () -> Long = System::currentTimeMillis,
) {
/** 今日概览:固定演示值。血压为 null 以演示“未开启”占位态。 */
fun todaySummary(): HealthSummary = HealthSummary(
@@ -1435,13 +1494,13 @@ class MockDataProvider(private val seed: Long = DEFAULT_SEED) {
val count = if (range == DateRange.WEEK) 7 else 30
val random = Random(seed + metric.ordinal * 100L + range.ordinal)
val (min, max) = valueRange(metric)
val now = System.currentTimeMillis()
val current = now()
val dayMillis = 24L * 60 * 60 * 1000
return (0 until count).map { index ->
val progress = index.toFloat() / (count - 1).coerceAtLeast(1)
val value = min + (max - min) * progress + random.nextDouble(-1.0, 1.0) * (max - min) * 0.15
MetricPoint(
timestamp = now - (count - 1 - index) * dayMillis,
timestamp = current - (count - 1 - index) * dayMillis,
value = value.coerceIn(min, max),
)
}
@@ -1450,14 +1509,14 @@ class MockDataProvider(private val seed: Long = DEFAULT_SEED) {
/** 运动记录:固定 5 条演示数据,倒序(最近的在最前)。 */
fun workouts(): List<WorkoutRecord> {
val random = Random(seed + 7)
val now = System.currentTimeMillis()
val current = now()
val dayMillis = 24L * 60 * 60 * 1000
val types = listOf("户外跑步", "健走", "骑行", "自由训练", "户外跑步")
return types.mapIndexed { index, type ->
WorkoutRecord(
id = "workout-$index",
type = type,
startTime = now - (index + 1) * dayMillis,
startTime = current - (index + 1) * dayMillis,
durationMinutes = 25 + random.nextInt(0, 45),
distanceKm = 2.0 + random.nextDouble(0.0, 5.0),
calories = 120 + random.nextInt(0, 260),
@@ -1568,6 +1627,12 @@ import kotlinx.coroutines.flow.update
/**
* Mock 设备仓库。开关状态保存在内存中,进程重启后重置(阶段一不做持久化)。
*
* 注意 [refreshDeviceState] 的语义:它以 **suspend 函数正常返回**表示刷新完成,
* 而**不是**靠发射新状态。`MutableStateFlow` 会抑制相等值,M1 的设备状态是常量,
* 因此刷新不会产生任何可观察的发射。调用方要展示刷新反馈,应当
* `launch { repo.refreshDeviceState(); isRefreshing = false }`,
* **不要**写成「等待下一个不同的 deviceState 发射」(那样在 M1 会永远等不到)。
*/
class MockRingDeviceRepository(
private val provider: MockDataProvider = MockDataProvider(),
@@ -1677,7 +1742,15 @@ git add -A && git commit -m "feat: 新增 Mock 数据层与依赖容器(含单
- Produces:
- `object Routes { HOME, WORKOUT, DEVICE, PROFILE, DEVICE_SETTINGS, WORKOUT_HISTORY, DETAIL_PATTERN; fun detail(metric: MetricType): String }`
- `@Composable fun RingHealthApp()`——Scaffold + NavigationBar + NavHost
- 四个 Tab 的 Composable:`HomeScreen(onOpenDetail: (MetricType) -> Unit, onOpenWorkoutHistory: () -> Unit)`、`WorkoutScreen(onOpenHistory: () -> Unit)`、`DeviceScreen(onOpenSettings: () -> Unit)`、`ProfileScreen()`
- 四个 Tab 的**占位** Composable:`HomeScreen(onOpenDetail: (MetricType) -> Unit, modifier)`、
`WorkoutScreen(modifier)`、`DeviceScreen(modifier)`、`ProfileScreen(modifier)`
(`modifier: Modifier = Modifier`,实际签名以本任务 Step 2 的代码为准)。
**注意**:`Routes.DEVICE_SETTINGS` 与 `Routes.WORKOUT_HISTORY` 在本任务**只注册、无人跳转** ——
`WorkoutScreen` 的 `onOpenHistory` 与 `DeviceScreen` 的 `onOpenSettings` 分别在 Task 9 / Task 10
才加入签名,那两个 Task 会同步更新本任务 `RingHealthApp.kt` 里的调用点。
计划早期版本在本行写过一个 `HomeScreen` 的 `onOpenWorkoutHistory` 参数,
全计划没有任何调用点(Task 8 产出的是 `HomeScreen(onOpenDetail, modifier, viewModel)`),
属笔误,已删除 —— 不要把它加回来。
- [ ] **Step 1: 写 `Routes.kt`**
@@ -1720,16 +1793,18 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.domain.model.MetricType
/** 健康首页。 */
/** 健康首页(占位,待 Task 8 实现)。 */
@Composable
fun HomeScreen(
onOpenDetail: (MetricType) -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("健康首页")
Text(stringResource(R.string.tab_home))
}
}
```
@@ -1745,12 +1820,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 运动页。 */
/** 运动页(占位,待 Task 9 实现)。 */
@Composable
fun WorkoutScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("运动")
Text(stringResource(R.string.tab_workout))
}
}
```
@@ -1766,12 +1843,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 设备页。 */
/** 设备页(占位,待 Task 10 实现)。 */
@Composable
fun DeviceScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("设备")
Text(stringResource(R.string.tab_device))
}
}
```
@@ -1787,12 +1866,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 我的页。 */
/** 我的页(占位,待 Task 11 实现)。 */
@Composable
fun ProfileScreen(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("我的")
Text(stringResource(R.string.tab_profile))
}
}
```
@@ -1806,6 +1887,9 @@ fun ProfileScreen(modifier: Modifier = Modifier) {
<string name="tab_workout">运动</string>
<string name="tab_device">设备</string>
<string name="tab_profile">我的</string>
<string name="placeholder_device_settings">设备设置</string>
<string name="placeholder_workout_history">运动历史</string>
<string name="placeholder_metric_detail">指标详情</string>
```
- [ ] **Step 4: 写 `RingHealthApp.kt`**
@@ -1943,12 +2027,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 设备设置子页(待 Task 10 实现)。 */
/** 设备设置子页(占位,待 Task 10 实现)。 */
@Composable
fun DeviceSettingsPlaceholder(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("设备设置")
Text(stringResource(R.string.placeholder_device_settings))
}
}
```
@@ -1964,12 +2050,14 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
/** 运动历史子页(待 Task 9 实现)。 */
/** 运动历史子页(占位,待 Task 9 实现)。 */
@Composable
fun WorkoutHistoryPlaceholder(modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("运动历史")
Text(stringResource(R.string.placeholder_workout_history))
}
}
```
@@ -1985,13 +2073,15 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.placeholder.ringhealth.R
import com.placeholder.ringhealth.domain.model.MetricType
/** 指标详情子页(待 Task 12 实现)。 */
/** 指标详情子页(占位,待 Task 12 实现)。 */
@Composable
fun MetricDetailPlaceholder(metric: MetricType, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("详情:${metric.name}")
Text(stringResource(R.string.placeholder_metric_detail))
}
}
```
@@ -2394,7 +2484,7 @@ private fun StepsCard(summary: HealthSummary, onClick: () -> Unit, modifier: Mod
tint = MetricBlue,
)
SummaryMetric(
value = summary.calories.toString(),
value = formatCalories(summary.calories),
label = stringResource(R.string.home_calories_label),
tint = MetricOrange,
)
@@ -2602,7 +2692,7 @@ class WorkoutViewModel(
<string name="workout_empty">暂无运动记录</string>
<string name="workout_item_summary">%1$s · %2$s 公里</string>
<string name="workout_detail_duration">时长 %1$s</string>
<string name="workout_detail_calories">消耗 %1$s</string>
<string name="workout_detail_calories">消耗 %1$s 千卡</string>
<string name="action_view_all">查看全部</string>
```