docs: Mock 数据源注入时钟,消除 flaky 断言(ledger R17)

- MockDataProvider 增加 now: () -> Long = System::currentTimeMillis
- metricHistory / workouts 改用 now(),使「确定性」承诺成立
- 测试注入 FIXED_NOW,并补一条时间戳严格递增的断言
This commit is contained in:
2026-09-10 10:40:55 +08:00
parent 42bc9fe12e
commit c71c3c32f6
@@ -1350,10 +1350,17 @@ git add -A && git commit -m "feat: 新增数值格式化工具(含单元测试
- Consumes: `domain/model/*`、`domain/repository/*` - Consumes: `domain/model/*`、`domain/repository/*`
- Produces: - Produces:
- `interface AppContainer { healthDataRepository; ringDeviceRepository }`、`class DefaultAppContainer : AppContainer`、`RingHealthApplication.container` - `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 MockHealthDataRepository : HealthDataRepository`(每次发射前 `delay(200)`)
- `class MockRingDeviceRepository : RingDeviceRepository`(开关状态保存在内存 StateFlow) - `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: 写失败的测试** - [ ] **Step 1: 写失败的测试**
`app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt`: `app/src/test/java/com/placeholder/ringhealth/data/mock/MockDataProviderTest.kt`:
@@ -1369,7 +1376,16 @@ import org.junit.Test
class MockDataProviderTest { 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 @Test
fun `今日概览返回固定演示数据`() { fun `今日概览返回固定演示数据`() {
@@ -1395,6 +1411,8 @@ class MockDataProviderTest {
val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK) val second = provider.metricHistory(MetricType.HEART_RATE, DateRange.WEEK)
assertEquals(first, second) assertEquals(first, second)
assertTrue(first.all { it.value > 40.0 && it.value < 180.0 }) assertTrue(first.all { it.value > 40.0 && it.value < 180.0 })
// 时间戳须严格递增(“每点间隔一天”倒推,最早的在最前)
assertTrue(first.zipWithNext().all { (a, b) -> b.timestamp > a.timestamp })
} }
@Test @Test
@@ -1437,10 +1455,17 @@ import com.placeholder.ringhealth.domain.model.WorkoutRecord
import kotlin.random.Random import kotlin.random.Random
/** /**
* 演示数据生成器。所有方法都是确定性的(同参数返回同结果), * 演示数据生成器。所有方法都是确定性的(同参数、同时刻返回同结果),
* 保证页面展示与截图稳定。M2 接入真机后本类整体弃用。 * 保证页面展示与截图稳定。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 以演示“未开启”占位态。 */ /** 今日概览:固定演示值。血压为 null 以演示“未开启”占位态。 */
fun todaySummary(): HealthSummary = HealthSummary( fun todaySummary(): HealthSummary = HealthSummary(
@@ -1464,13 +1489,13 @@ class MockDataProvider(private val seed: Long = DEFAULT_SEED) {
val count = if (range == DateRange.WEEK) 7 else 30 val count = if (range == DateRange.WEEK) 7 else 30
val random = Random(seed + metric.ordinal * 100L + range.ordinal) val random = Random(seed + metric.ordinal * 100L + range.ordinal)
val (min, max) = valueRange(metric) val (min, max) = valueRange(metric)
val now = System.currentTimeMillis() val current = now()
val dayMillis = 24L * 60 * 60 * 1000 val dayMillis = 24L * 60 * 60 * 1000
return (0 until count).map { index -> return (0 until count).map { index ->
val progress = index.toFloat() / (count - 1).coerceAtLeast(1) val progress = index.toFloat() / (count - 1).coerceAtLeast(1)
val value = min + (max - min) * progress + random.nextDouble(-1.0, 1.0) * (max - min) * 0.15 val value = min + (max - min) * progress + random.nextDouble(-1.0, 1.0) * (max - min) * 0.15
MetricPoint( MetricPoint(
timestamp = now - (count - 1 - index) * dayMillis, timestamp = current - (count - 1 - index) * dayMillis,
value = value.coerceIn(min, max), value = value.coerceIn(min, max),
) )
} }
@@ -1479,14 +1504,14 @@ class MockDataProvider(private val seed: Long = DEFAULT_SEED) {
/** 运动记录:固定 5 条演示数据,倒序(最近的在最前)。 */ /** 运动记录:固定 5 条演示数据,倒序(最近的在最前)。 */
fun workouts(): List<WorkoutRecord> { fun workouts(): List<WorkoutRecord> {
val random = Random(seed + 7) val random = Random(seed + 7)
val now = System.currentTimeMillis() val current = now()
val dayMillis = 24L * 60 * 60 * 1000 val dayMillis = 24L * 60 * 60 * 1000
val types = listOf("户外跑步", "健走", "骑行", "自由训练", "户外跑步") val types = listOf("户外跑步", "健走", "骑行", "自由训练", "户外跑步")
return types.mapIndexed { index, type -> return types.mapIndexed { index, type ->
WorkoutRecord( WorkoutRecord(
id = "workout-$index", id = "workout-$index",
type = type, type = type,
startTime = now - (index + 1) * dayMillis, startTime = current - (index + 1) * dayMillis,
durationMinutes = 25 + random.nextInt(0, 45), durationMinutes = 25 + random.nextInt(0, 45),
distanceKm = 2.0 + random.nextDouble(0.0, 5.0), distanceKm = 2.0 + random.nextDouble(0.0, 5.0),
calories = 120 + random.nextInt(0, 260), calories = 120 + random.nextInt(0, 260),